Add student-level Vorgänge tab and homework/lateness dashboard warnings
CI / build-and-test (push) Canceled after 0s

Dashboard warnings (attendance, and now homework backlog and lateness) apply
across all classes, but the existing Vorgänge case-file tab only works for
the teacher's own class since it resolves the WebUntis classbook roster.
Instead of loosening that coupling, students get their own narrower
Vorgänge tab, and all three cross-class warnings now jump straight there,
offering to open a matching case or create a new one.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Sonnet 5 committed 2026-09-25 23:55:54 +02:00
1 parent 4b73d2bea2
commit 3270b48850
16 files changed
+963 -21

No files matched your search

@@ -805,10 +805,152 @@ public sealed class DashboardViewModelTests
var item = Assert.Single(Items(vm, AttentionKind.Attendance));
Assert.Equal(student.FullName, item.Title);
Assert.Equal("30 %", item.TrailingText);
Assert.True(item.IsAttendanceWarning);
Assert.True(item.IsCrossClassWarning);
Assert.Equal(AttentionKind.Attendance, vm.Attention.First().Kind);
}
/// Fehlzeiten-Warnungen landen seit der Verknüpfung mit dem schülerbezogenen "Vorgänge"-Tab auf
/// OnNavigateToStudentCase (mit dem passenden Tag "Absentismus") statt auf OnNavigateToStudent.
[Fact]
public void AttendanceWarnings_NavigiertUeberOnNavigateToStudentCaseMitAbsentismusTag()
{
var today = DateOnly.FromDateTime(DateTime.Today);
var group = new LearningGroup { Name = "9c" };
var student = new Student { FirstName = "Anna", LastName = "Beispiel" };
var sessions = new FakeSessions([]);
var entries = new FakeEntries();
for (var i = 0; i < 10; i++)
{
var session = new ParticipationSession { GroupId = group.Id, Date = today.AddDays(-i - 1) };
sessions.Save(session);
var status = i < 3 ? AttendanceStatus.Unexcused : AttendanceStatus.Present;
entries.Add(new ParticipationEntry { SessionId = session.Id, StudentId = student.Id, Attendance = status });
}
var vm = BuildVm(group, new Lesson { GroupId = group.Id, Date = today },
students: new FakeStudents([student]), sessions: sessions, entries: entries);
Guid? navigatedStudentId = null; string? navigatedTag = null; string? navigatedTitle = null;
vm.OnNavigateToStudentCase = (id, tag, title) => { navigatedStudentId = id; navigatedTag = tag; navigatedTitle = title; };
vm.OnNavigateToStudent = _ => throw new InvalidOperationException("sollte nicht mehr aufgerufen werden");
Assert.Single(Items(vm, AttentionKind.Attendance)).Navigate!.Invoke();
Assert.Equal(student.Id, navigatedStudentId);
Assert.Equal("Absentismus", navigatedTag);
Assert.Equal("Fehlzeiten", navigatedTitle);
}
[Fact]
public void MissingHomeworkWarnings_UnterSchwellenwertKeineWarnung()
{
var today = DateOnly.FromDateTime(DateTime.Today);
var group = new LearningGroup { Name = "9c" };
var student = new Student { FirstName = "Anna", LastName = "Beispiel" };
var sessions = new FakeSessions([]);
var entries = new FakeEntries();
// Nur 2 offene Hausaufgaben — Schwelle liegt bei 3.
for (var i = 0; i < 2; i++)
{
var session = new ParticipationSession { GroupId = group.Id, Date = today.AddDays(-i - 1) };
sessions.Save(session);
entries.Add(new ParticipationEntry
{ SessionId = session.Id, StudentId = student.Id, Homework = HomeworkStatus.MissingOpen });
}
var vm = BuildVm(group, new Lesson { GroupId = group.Id, Date = today },
students: new FakeStudents([student]), sessions: sessions, entries: entries);
Assert.Empty(Items(vm, AttentionKind.MissingHomework));
}
[Fact]
public void MissingHomeworkWarnings_MeldetAbSchwellenwertUndZaehltPartiallyCompletedAlsOffen()
{
var today = DateOnly.FromDateTime(DateTime.Today);
var group = new LearningGroup { Name = "9c" };
var student = new Student { FirstName = "Anna", LastName = "Beispiel" };
var sessions = new FakeSessions([]);
var entries = new FakeEntries();
// 3 offene/überfällige Hausaufgaben (davon eine PartiallyCompleted, zählt laut
// HomeworkDisplay.CountsAsMissing ebenfalls als offen) — erreicht die Schwelle von 3.
var statuses = new[] { HomeworkStatus.MissingOpen, HomeworkStatus.MissingOverdue, HomeworkStatus.PartiallyCompleted };
for (var i = 0; i < statuses.Length; i++)
{
var session = new ParticipationSession { GroupId = group.Id, Date = today.AddDays(-i - 1) };
sessions.Save(session);
entries.Add(new ParticipationEntry { SessionId = session.Id, StudentId = student.Id, Homework = statuses[i] });
}
var vm = BuildVm(group, new Lesson { GroupId = group.Id, Date = today },
students: new FakeStudents([student]), sessions: sessions, entries: entries);
var item = Assert.Single(Items(vm, AttentionKind.MissingHomework));
Assert.Equal(student.FullName, item.Title);
Assert.Equal("3 offen", item.TrailingText);
Guid? navigatedStudentId = null; string? navigatedTag = null;
vm.OnNavigateToStudentCase = (id, tag, _) => { navigatedStudentId = id; navigatedTag = tag; };
item.Navigate!.Invoke();
Assert.Equal(student.Id, navigatedStudentId);
Assert.Equal("Hausaufgaben", navigatedTag);
}
[Fact]
public void LatenessWarnings_UnterSchwellenwertKeineWarnung()
{
var today = DateOnly.FromDateTime(DateTime.Today);
var group = new LearningGroup { Name = "9c" };
var student = new Student { FirstName = "Anna", LastName = "Beispiel" };
var sessions = new FakeSessions([]);
var entries = new FakeEntries();
// Nur 7 Verspätungen — Schwelle liegt bei 8.
for (var i = 0; i < 7; i++)
{
var session = new ParticipationSession { GroupId = group.Id, Date = today.AddDays(-i - 1) };
sessions.Save(session);
entries.Add(new ParticipationEntry { SessionId = session.Id, StudentId = student.Id, Attendance = AttendanceStatus.Late });
}
var vm = BuildVm(group, new Lesson { GroupId = group.Id, Date = today },
students: new FakeStudents([student]), sessions: sessions, entries: entries);
Assert.Empty(Items(vm, AttentionKind.Lateness));
}
/// Regressionsschutz: Late/SignificantlyLate zählen bewusst NICHT in die Fehlzeitenquote hinein
/// (AttendanceBalanceService behandelt sie als Present) — auch wenn ein Schüler die neue
/// Verspätungs-Schwelle reißt, darf die bestehende Fehlzeiten-Warnung dadurch nicht auftauchen.
[Fact]
public void LatenessWarnings_MeldetAbSchwellenwertUndZaehltNichtAlsFehlzeit()
{
var today = DateOnly.FromDateTime(DateTime.Today);
var group = new LearningGroup { Name = "9c" };
var student = new Student { FirstName = "Anna", LastName = "Beispiel" };
var sessions = new FakeSessions([]);
var entries = new FakeEntries();
// 5x Late, 3x SignificantlyLate — zusammen 8, erreicht die Schwelle.
for (var i = 0; i < 8; i++)
{
var session = new ParticipationSession { GroupId = group.Id, Date = today.AddDays(-i - 1) };
sessions.Save(session);
var status = i < 5 ? AttendanceStatus.Late : AttendanceStatus.SignificantlyLate;
entries.Add(new ParticipationEntry { SessionId = session.Id, StudentId = student.Id, Attendance = status });
}
var vm = BuildVm(group, new Lesson { GroupId = group.Id, Date = today },
students: new FakeStudents([student]), sessions: sessions, entries: entries);
var item = Assert.Single(Items(vm, AttentionKind.Lateness));
Assert.Equal(student.FullName, item.Title);
Assert.Equal("8× verspätet", item.TrailingText);
Assert.Empty(Items(vm, AttentionKind.Attendance));
}
[Fact]
public async Task AddTask_SpeichertErgebnisUndAktualisiertOffeneAufgaben()
{
+12
View File
@@ -329,6 +329,18 @@ public class FakeDocumentation : IDocumentationRepository
public void HardDelete(Guid id) => _all.RemoveAll(d => d.Id == id);
}
public class FakeVorgangRepository : IVorgangRepository
{
private readonly List<Vorgang> _all = [];
public void Add(Vorgang v) => _all.Add(v);
public List<Vorgang> GetAll() => _all.Where(v => !v.IsDeleted).ToList();
public List<Vorgang> GetByStudent(Guid studentId) =>
_all.Where(v => !v.IsDeleted && v.StudentIds.Contains(studentId)).ToList();
public Vorgang? GetById(Guid id) => _all.FirstOrDefault(v => v.Id == id && !v.IsDeleted);
public void Save(Vorgang vorgang) { _all.RemoveAll(v => v.Id == vorgang.Id); _all.Add(vorgang); }
public void Delete(Guid id) { var v = _all.FirstOrDefault(x => x.Id == id); if (v is not null) v.IsDeleted = true; }
}
public class FakeAttachmentStorage : IAttachmentStorage
{
private readonly Dictionary<string, byte[]> _blobs = [];
@@ -0,0 +1,141 @@
using LehrerApp.Core.Models;
using LehrerApp.Desktop.ViewModels.ClassTeacher;
using LehrerApp.Desktop.ViewModels.Students;
using Xunit;
namespace LehrerApp.Desktop.Tests;
/// Tests für den schülerbezogenen "Vorgänge"-Tab im Schülerdetail (StudentVorgaengeViewModel) —
/// der universelle, klassenübergreifende Einstiegspunkt für die Dashboard-Warnungen (Fehlzeiten,
/// Hausaufgaben-Rückstand, Verspätungen). Anders als ClassTeacherCasesViewModel keine
/// Roster-/WebUntis-Abhängigkeit, deshalb reine In-Memory-Fakes ohne Untis-Cache-Setup.
public sealed class StudentVorgaengeViewModelTests
{
private static StudentVorgaengeViewModel BuildVm(FakeVorgangRepository? vorgaenge = null,
FakeDocumentation? documentation = null, FakeStudents? students = null) =>
new(vorgaenge ?? new FakeVorgangRepository(), documentation ?? new FakeDocumentation(),
students ?? new FakeStudents([]));
[Fact]
public void Load_ZeigtNurVorgaengeDiesesSchuelers()
{
var student = new Student { FirstName = "Anna", LastName = "Beispiel" };
var other = new Student { FirstName = "Ben", LastName = "Fremd" };
var vorgaenge = new FakeVorgangRepository();
vorgaenge.Add(new Vorgang { Title = "Eigener Vorgang", StudentIds = [student.Id] });
vorgaenge.Add(new Vorgang { Title = "Fremder Vorgang", StudentIds = [other.Id] });
var vm = BuildVm(vorgaenge: vorgaenge, students: new FakeStudents([student, other]));
vm.Load(student.Id);
var item = Assert.Single(vm.Cases);
Assert.Equal("Eigener Vorgang", item.Model.Title);
}
[Fact]
public void Load_OnlyOpenFiltertGeschlosseneVorgaenge()
{
var student = new Student { FirstName = "Anna", LastName = "Beispiel" };
var vorgaenge = new FakeVorgangRepository();
vorgaenge.Add(new Vorgang { Title = "Offen", StudentIds = [student.Id], Status = VorgangStatus.Open });
vorgaenge.Add(new Vorgang { Title = "Geschlossen", StudentIds = [student.Id], Status = VorgangStatus.Closed });
var vm = BuildVm(vorgaenge: vorgaenge, students: new FakeStudents([student]));
vm.Load(student.Id);
Assert.Equal("Offen", Assert.Single(vm.Cases).Model.Title);
vm.OnlyOpen = false;
Assert.Equal(2, vm.Cases.Count);
}
[Fact]
public async Task HandleWarningNavigation_RevealtBestehendenOffenenVorgangMitPassendemTag_OhneRueckfrage()
{
var student = new Student { FirstName = "Anna", LastName = "Beispiel" };
var vorgaenge = new FakeVorgangRepository();
vorgaenge.Add(new Vorgang { Title = "Fehlzeiten", StudentIds = [student.Id], Tags = ["Absentismus"] });
var vm = BuildVm(vorgaenge: vorgaenge, students: new FakeStudents([student]));
vm.Load(student.Id);
var confirmCalled = false;
vm.OnConfirmCreateForWarning = _ => { confirmCalled = true; return Task.FromResult(true); };
await vm.HandleWarningNavigation("Absentismus", "Fehlzeiten");
Assert.False(confirmCalled);
Assert.True(Assert.Single(vm.Cases).IsRevealed);
}
[Fact]
public async Task HandleWarningNavigation_IgnoriertGeschlosseneVorgaengeMitPassendemTag()
{
var student = new Student { FirstName = "Anna", LastName = "Beispiel" };
var vorgaenge = new FakeVorgangRepository();
vorgaenge.Add(new Vorgang
{ Title = "Alt", StudentIds = [student.Id], Tags = ["Absentismus"], Status = VorgangStatus.Closed });
var vm = BuildVm(vorgaenge: vorgaenge, students: new FakeStudents([student]));
vm.Load(student.Id); // OnlyOpen=true (Default) -> der geschlossene Vorgang ist gar nicht in Cases
var confirmCalled = false;
vm.OnConfirmCreateForWarning = _ => { confirmCalled = true; return Task.FromResult(false); };
vm.OnEditVorgang = (_, _) => Task.FromResult<Vorgang?>(null);
await vm.HandleWarningNavigation("Absentismus", "Fehlzeiten");
Assert.True(confirmCalled);
}
[Fact]
public async Task HandleWarningNavigation_KeinPassenderVorgang_FragtNachUndLegtAnBeiZustimmung()
{
var student = new Student { FirstName = "Anna", LastName = "Beispiel" };
var vorgaenge = new FakeVorgangRepository();
var vm = BuildVm(vorgaenge: vorgaenge, students: new FakeStudents([student]));
vm.Load(student.Id);
string? confirmedTag = null;
vm.OnConfirmCreateForWarning = tag => { confirmedTag = tag; return Task.FromResult(true); };
List<StudentOption>? passedOptions = null;
Vorgang? passedDraft = null;
vm.OnEditVorgang = (options, editing) =>
{
passedOptions = options;
passedDraft = editing;
return Task.FromResult(editing);
};
await vm.HandleWarningNavigation("Hausaufgaben", "Hausaufgaben");
Assert.Equal("Hausaufgaben", confirmedTag);
Assert.Equal(student.Id, Assert.Single(passedOptions!).Id);
Assert.Equal("Hausaufgaben", passedDraft!.Title);
Assert.Contains("Hausaufgaben", passedDraft.Tags);
Assert.Equal([student.Id], passedDraft.StudentIds);
var created = Assert.Single(vorgaenge.GetAll());
Assert.Equal("Hausaufgaben", created.Title);
Assert.True(Assert.Single(vm.Cases).IsRevealed);
}
[Fact]
public async Task HandleWarningNavigation_KeinPassenderVorgang_LegtNichtsAnBeiAblehnung()
{
var student = new Student { FirstName = "Anna", LastName = "Beispiel" };
var vorgaenge = new FakeVorgangRepository();
var vm = BuildVm(vorgaenge: vorgaenge, students: new FakeStudents([student]));
vm.Load(student.Id);
vm.OnConfirmCreateForWarning = _ => Task.FromResult(false);
var editDialogCalled = false;
vm.OnEditVorgang = (_, _) => { editDialogCalled = true; return Task.FromResult<Vorgang?>(null); };
await vm.HandleWarningNavigation("Verspätungen", "Verspätungen");
Assert.False(editDialogCalled);
Assert.Empty(vorgaenge.GetAll());
Assert.Empty(vm.Cases);
}
}
+1
View File
@@ -234,6 +234,7 @@ public class App : Application
var dash = Services.GetRequiredService<DashboardViewModel>();
dash.OnNavigateToGroup = id => main.NavigateToGroupDetail(id);
dash.OnNavigateToStudent = id => main.NavigateToStudent(id);
dash.OnNavigateToStudentCase = (id, tag, title) => main.NavigateToStudentCase(id, tag, title);
dash.OnNavigateToLesson = id => main.NavigateToGroupDetail(id, 3); // Tab "Mitarbeit"
dash.OnNavigateToExam = id => main.NavigateToGroupDetail(id, 4); // Tab "Klausuren"
dash.OnNavigateToUnplannedLesson = id => main.NavigateToGroupDetail(id, 6); // Tab "Planung"
+1
View File
@@ -357,6 +357,7 @@ public static class AppBootstrapper
// Transient: neue Instanz pro Navigation (für Detailseiten)
services.AddTransient<GroupDetailViewModel>();
services.AddTransient<StudentDetailViewModel>();
services.AddTransient<StudentVorgaengeViewModel>();
services.AddTransient<GroupOverviewViewModel>();
services.AddTransient<ParticipationTabViewModel>();
services.AddTransient<GradeOverviewTabViewModel>();
+16 -7
View File
@@ -3,15 +3,21 @@ using System.Windows.Input;
namespace LehrerApp.Desktop.ViewModels;
/// <summary>Die sieben Dashboard-Kacheln, die tatsächlich "wo muss ich reagieren" beantworten
/// <summary>Die neun Dashboard-Kacheln, die tatsächlich "wo muss ich reagieren" beantworten
/// (siehe <see cref="DashboardViewModel.AttentionCount"/>), zusammengefasst zu einer Karte mit
/// Filter-Chips statt sieben eigenen Sichtbarkeits-Schaltern. Reihenfolge hier ist die Reihenfolge
/// Filter-Chips statt eigenen Sichtbarkeits-Schaltern je Art. Reihenfolge hier ist die Reihenfolge
/// der Gruppen in der zusammengefassten Ansicht (siehe DashboardViewModel.RebuildAttention) — sie
/// übernimmt bewusst die frühere Sortierung aus DashboardSettingsService.DefaultCardOrder, damit
/// sich nichts, was Nutzer bereits kennen, unvorhersehbar umsortiert.
/// "Klausurwochen" (examload) bleibt bewusst außen vor: die Einträge dort sind Wochen-Aggregate,
/// keine Einzelvorgänge, die sich abhaken oder anklicken lassen — sie passen nicht ins Item-Modell.</summary>
public enum AttentionKind { MissingTeachingTime, Excuse, Correction, Unplanned, Alert, Attendance, SupportPlan }
/// keine Einzelvorgänge, die sich abhaken oder anklicken lassen — sie passen nicht ins Item-Modell.
/// MissingHomework/Lateness (Hausaufgaben-/Verspätungs-Warnung) ergänzen Attendance um dieselbe
/// klassenübergreifende Logik — alle drei navigieren über OnNavigateToStudentCase direkt auf den
/// "Vorgänge"-Tab des Schülerdetails statt auf dessen Standard-Tab (siehe DashboardViewModel).</summary>
public enum AttentionKind
{
MissingTeachingTime, Excuse, Correction, Unplanned, Alert, Attendance, MissingHomework, Lateness, SupportPlan,
}
/// <summary>Eine Inline-Aktion auf einem Handlungsbedarf-Eintrag (z.B. "Entschuldigt"/"Unentschuldigt",
/// "Erfassen"). Verwendet ICommand statt Action, damit die bereits vorhandenen generierten
@@ -40,8 +46,8 @@ public sealed class AttentionItem
/// <summary>Text unter der ProgressBar, z.B. "3 von 5 Arbeiten bewertet". Nur bei Korrekturen gesetzt.</summary>
public string ProgressLabel { get; }
/// <summary>Färbt TrailingText wie die frühere Fehlzeiten-Warnung (fest "Foreground=Red") ein —
/// nur bei Attendance gesetzt, weil ein AttentionItem dieser Art per Konstruktion nur entsteht,
/// wenn die Fehlzeitenquote den Schwellenwert bereits überschreitet.</summary>
/// bei Attendance/MissingHomework/Lateness gesetzt, weil ein AttentionItem dieser Arten per
/// Konstruktion nur entsteht, wenn der jeweilige Schwellenwert bereits überschritten ist.</summary>
public bool IsWarningTrailing { get; }
public IReadOnlyList<AttentionAction> Actions { get; }
public Action? Navigate { get; }
@@ -73,7 +79,10 @@ public sealed class AttentionItem
public bool HasSeverity => Severity is not null;
public bool IsHighSeverity => Severity == AlertSeverity.High;
public bool IsMediumSeverity => Severity == AlertSeverity.Medium;
public bool IsAttendanceWarning => Kind == AttentionKind.Attendance;
/// Färbt den linken Severity-Balken wie die frühere Fehlzeiten-Warnung ein — für alle drei
/// klassenübergreifenden Warnarten (Fehlzeiten/Hausaufgaben/Verspätungen), die per Konstruktion
/// nur entstehen, wenn ihr jeweiliger Schwellenwert bereits überschritten ist.
public bool IsCrossClassWarning => Kind is AttentionKind.Attendance or AttentionKind.MissingHomework or AttentionKind.Lateness;
}
/// <summary>Eine Kind-Gruppe innerhalb der zusammengefassten Handlungsbedarf-Liste. Items behalten
@@ -144,8 +144,15 @@ public partial class VorgangDialogViewModel : ObservableObject
public VorgangDialogViewModel(List<StudentOption> rosterStudents, Vorgang? editing)
{
_editing = editing;
// Bei genau einem Kandidaten (z.B. der schülerbezogene Vorgänge-Tab im Schülerdetail) und
// einem neuen Vorgang ist ohnehin nur eine Auswahl möglich — Nutzer-Feedback: der sonst
// nötige Extra-Klick auf die einzige Checkbox ist unnötige Reibung.
StudentOptions = new ObservableCollection<VorgangStudentOption>(rosterStudents.Select(s =>
new VorgangStudentOption(s.Id, s.Name) { IsSelected = editing?.StudentIds.Contains(s.Id) == true }));
new VorgangStudentOption(s.Id, s.Name)
{
IsSelected = editing?.StudentIds.Contains(s.Id) == true
|| (editing is null && rosterStudents.Count == 1),
}));
if (editing is null) return;
Title = editing.Title;
@@ -4,6 +4,7 @@ using LehrerApp.Core.Interfaces;
using LehrerApp.Core.Models;
using LehrerApp.Core.Services;
using LehrerApp.Desktop.Services;
using LehrerApp.Desktop.ViewModels.Groups;
using System.Collections.ObjectModel;
using System.Globalization;
@@ -54,6 +55,13 @@ public partial class DashboardViewModel : ObservableObject
/// hat rein rechnerisch schon 100 %, das ist noch kein auffälliges Muster, nur eine zu kleine
/// Stichprobe. Dieselbe Konstante wie GroupOverviewViewModel.AttendanceMinSampleSize.
private const int AttendanceMinSampleSize = 8;
/// Nutzer-Entscheidung: ab 3 offenen/überfälligen Hausaufgaben im laufenden Schuljahr (alle
/// Gruppen) wird gewarnt. Keine Mindeststichprobe wie bei der Fehlzeitenquote nötig — das ist
/// hier ein absoluter Zähler, kein Anteil, der bei wenigen Terminen künstlich hoch ausfallen kann.
private const int MissingHomeworkWarningThreshold = 3;
/// Nutzer-Entscheidung: ab 8 Verspätungen (Late/SignificantlyLate) im laufenden Schuljahr
/// (alle Gruppen) wird gewarnt.
private const int LatenessWarningThreshold = 8;
/// Nutzer-Feedback: "ich vergesse es, und fasse die App auch außerhalb der Schule manchmal
/// nicht mehr an" — die Erinnerung an fehlende Unterrichtszeit-Erfassung schaut deshalb nicht
/// nur auf heute zurück, sondern auf ein paar Tage, damit ein vergessener Tag nicht verloren
@@ -107,6 +115,8 @@ public partial class DashboardViewModel : ObservableObject
private readonly List<OpenExcuseItem> _excuses = [];
private readonly List<AttentionItem> _attendanceItems = [];
private readonly List<AttentionItem> _missingHomeworkItems = [];
private readonly List<AttentionItem> _latenessItems = [];
private readonly List<AttentionItem> _supportItems = [];
private readonly List<AttentionItem> _correctionItems = [];
private readonly List<AttentionItem> _unplannedItems = [];
@@ -119,10 +129,13 @@ public partial class DashboardViewModel : ObservableObject
private static readonly (AttentionKind Kind, string Header)[] AttentionGroupOrder =
[
(AttentionKind.MissingTeachingTime, "Unterrichtszeit nacherfassen"),
// Die Fehlzeitenquote ist auch in fremden Klassen unmittelbar relevant, waehrend offene
// Entschuldigungen nur fuer die eigene Klasse bearbeitet werden koennen. Deshalb steht sie
// im Dashboard vor den Entschuldigungen statt weiter unten zwischen den sonstigen Hinweisen.
// Die Fehlzeitenquote, Hausaufgaben- und Verspätungs-Warnungen sind auch in fremden Klassen
// unmittelbar relevant, waehrend offene Entschuldigungen nur fuer die eigene Klasse bearbeitet
// werden koennen. Deshalb stehen alle drei im Dashboard vor den Entschuldigungen statt weiter
// unten zwischen den sonstigen Hinweisen.
(AttentionKind.Attendance, "Fehlzeiten-Warnung"),
(AttentionKind.Lateness, "Verspätungen-Warnung"),
(AttentionKind.MissingHomework, "Hausaufgaben-Warnung"),
(AttentionKind.Excuse, "Offene Entschuldigungen"),
(AttentionKind.Correction, "Offene Korrekturen"),
(AttentionKind.Unplanned, "Ungeplante Stunden"),
@@ -133,6 +146,11 @@ public partial class DashboardViewModel : ObservableObject
// Navigation-Callback – wird von App.axaml.cs verdrahtet
public Action<Guid>? OnNavigateToGroup { get; set; }
public Action<Guid>? OnNavigateToStudent { get; set; }
// Sprungziel für die drei klassenübergreifenden Warnungen (Fehlzeiten/Hausaufgaben/Verspätungen)
// direkt auf den "Vorgänge"-Tab des Schülerdetails (Guid Schüler, string vorgeschlagenes Tag,
// string vorgeschlagener Titel) — eigenes Delegate statt OnNavigateToStudent zu erweitern, damit
// dessen übrige Aufrufer (Förderplan, Auffälligkeiten, Termine) unverändert bleiben.
public Action<Guid, string, string>? OnNavigateToStudentCase { get; set; }
// Sprungziel eigens für eine Stunde in TodaysLessons (9.2) — bewusst getrennt von
// OnNavigateToGroup (Lerngruppen-Kacheln, Tab "Übersicht"), da der Sprung von einer konkreten
// Stunde aus sinnvollerweise direkt in die Mitarbeitserfassung führt.
@@ -160,7 +178,8 @@ public partial class DashboardViewModel : ObservableObject
public int OpenTaskCount => OpenTasks.Count;
public int UpcomingCount => UpcomingDates.Count;
public int AttentionCount => _excuses.Count + _attendanceItems.Count + _supportItems.Count
+ _correctionItems.Count + _unplannedItems.Count + _alertItems.Count + _missingTimeItems.Count;
+ _correctionItems.Count + _unplannedItems.Count + _alertItems.Count + _missingTimeItems.Count
+ _missingHomeworkItems.Count + _latenessItems.Count;
public string TodayLessonSummary => TodayLessonCount == 1 ? "1 Stunde" : $"{TodayLessonCount} Stunden";
public string OpenTaskSummary => OpenTaskCount == 1 ? "1 Aufgabe" : $"{OpenTaskCount} Aufgaben";
public string AttentionSummary => AttentionCount == 1 ? "1 offener Punkt" : $"{AttentionCount} offene Punkte";
@@ -286,6 +305,8 @@ public partial class DashboardViewModel : ObservableObject
LoadCalendar();
LoadOpenExcuses(groups.Values.ToList(), today);
LoadAttendanceWarnings(today);
LoadMissingHomeworkWarnings(today);
LoadLatenessWarnings(today);
LoadExamWeekLoads(groups, today);
LoadMissingTeachingTime(today);
LoadSupportPlanReviews(today);
@@ -374,16 +395,19 @@ public partial class DashboardViewModel : ObservableObject
// ── Fehlzeiten-Warnung (5.2.3) ────────────────────────────────────────────
/// Ein Bulk-Laden aller Sitzungen vermeidet, für jeden Mitarbeits-Eintrag jedes Schülers einzeln
/// GetById aufzurufen (N+1 bei vielen Schülern/Einträgen) — von allen drei klassenübergreifenden
/// Warnungen (Fehlzeiten/Hausaufgaben/Verspätungen) genutzt.
private Dictionary<Guid, DateOnly> BuildSessionDateLookup() =>
_participationSessions.GetAll().ToDictionary(s => s.Id, s => s.Date);
private void LoadAttendanceWarnings(DateOnly today)
{
_attendanceItems.Clear();
var schoolYear = _sy.CurrentSchoolYear();
var from = _sy.SchoolYearStart(schoolYear);
var to = _sy.SchoolYearEnd(schoolYear);
// Ein Bulk-Laden aller Sitzungen vermeidet, für jeden Mitarbeits-Eintrag jedes Schülers
// einzeln GetById aufzurufen (N+1 bei vielen Schülern/Einträgen).
var sessionDates = _participationSessions.GetAll().ToDictionary(s => s.Id, s => s.Date);
var sessionDates = BuildSessionDateLookup();
var items = new List<AttendanceWarningItem>();
foreach (var student in _students.GetAll())
@@ -404,7 +428,68 @@ public partial class DashboardViewModel : ObservableObject
private AttentionItem ToAttentionItem(AttendanceWarningItem w) => new(
AttentionKind.Attendance, w.StudentName, trailingText: $"{w.AbsenceRatePercent:0.#} %",
isWarningTrailing: true, navigate: () => OnNavigateToStudent?.Invoke(w.StudentId));
isWarningTrailing: true,
navigate: () => OnNavigateToStudentCase?.Invoke(w.StudentId, "Absentismus", "Fehlzeiten"));
// ── Hausaufgaben-Warnung (Nutzer-Feedback: analog zur Fehlzeiten-Warnung, klassenübergreifend) ──
private void LoadMissingHomeworkWarnings(DateOnly today)
{
_missingHomeworkItems.Clear();
var schoolYear = _sy.CurrentSchoolYear();
var from = _sy.SchoolYearStart(schoolYear);
var to = _sy.SchoolYearEnd(schoolYear);
var sessionDates = BuildSessionDateLookup();
var items = new List<MissingHomeworkWarningItem>();
foreach (var student in _students.GetAll())
{
var count = _participationEntries.GetByStudent(student.Id)
.Count(e => sessionDates.TryGetValue(e.SessionId, out var date) && date >= from && date <= to
&& HomeworkDisplay.CountsAsMissing(HomeworkDisplay.Effective(e)));
if (count >= MissingHomeworkWarningThreshold)
items.Add(new MissingHomeworkWarningItem(student.Id, student.FullName, count));
}
foreach (var item in items.OrderByDescending(i => i.MissingCount))
_missingHomeworkItems.Add(ToAttentionItem(item));
}
private AttentionItem ToAttentionItem(MissingHomeworkWarningItem w) => new(
AttentionKind.MissingHomework, w.StudentName, trailingText: $"{w.MissingCount} offen",
isWarningTrailing: true,
navigate: () => OnNavigateToStudentCase?.Invoke(w.StudentId, "Hausaufgaben", "Hausaufgaben"));
// ── Verspätungs-Warnung (Nutzer-Feedback: analog zur Fehlzeiten-Warnung, klassenübergreifend) ──
//
// Late/SignificantlyLate zählen bewusst NICHT in AttendanceBalanceService.Calculate hinein (dort
// gelten sie als Present für die Fehlzeitenquote, siehe Klassenkommentar dort) — diese Zählung
// läuft deshalb unabhängig davon direkt über die rohen ParticipationEntry-Einträge.
private void LoadLatenessWarnings(DateOnly today)
{
_latenessItems.Clear();
var schoolYear = _sy.CurrentSchoolYear();
var from = _sy.SchoolYearStart(schoolYear);
var to = _sy.SchoolYearEnd(schoolYear);
var sessionDates = BuildSessionDateLookup();
var items = new List<LatenessWarningItem>();
foreach (var student in _students.GetAll())
{
var count = _participationEntries.GetByStudent(student.Id)
.Count(e => sessionDates.TryGetValue(e.SessionId, out var date) && date >= from && date <= to
&& e.Attendance is AttendanceStatus.Late or AttendanceStatus.SignificantlyLate);
if (count >= LatenessWarningThreshold)
items.Add(new LatenessWarningItem(student.Id, student.FullName, count));
}
foreach (var item in items.OrderByDescending(i => i.LateCount))
_latenessItems.Add(ToAttentionItem(item));
}
private AttentionItem ToAttentionItem(LatenessWarningItem w) => new(
AttentionKind.Lateness, w.StudentName, trailingText: $"{w.LateCount}× verspätet",
isWarningTrailing: true,
navigate: () => OnNavigateToStudentCase?.Invoke(w.StudentId, "Verspätungen", "Verspätungen"));
// ── Klausurwochen (Nutzer-Feedback) ───────────────────────────────────────
//
@@ -757,6 +842,8 @@ public partial class DashboardViewModel : ObservableObject
[AttentionKind.Unplanned] = _unplannedItems,
[AttentionKind.Alert] = _alertItems,
[AttentionKind.Attendance] = _attendanceItems,
[AttentionKind.MissingHomework] = _missingHomeworkItems,
[AttentionKind.Lateness] = _latenessItems,
[AttentionKind.SupportPlan] = _supportItems,
};
@@ -1200,6 +1287,20 @@ public class AttendanceWarningItem(Guid studentId, string studentName, double ab
public double AbsenceRatePercent { get; } = absenceRatePercent;
}
public class MissingHomeworkWarningItem(Guid studentId, string studentName, int missingCount)
{
public Guid StudentId { get; } = studentId;
public string StudentName { get; } = studentName;
public int MissingCount { get; } = missingCount;
}
public class LatenessWarningItem(Guid studentId, string studentName, int lateCount)
{
public Guid StudentId { get; } = studentId;
public string StudentName { get; } = studentName;
public int LateCount { get; } = lateCount;
}
public class ExamWeekLoadItem(int isoWeek, DateOnly weekStart, DateOnly weekEnd, int examCount)
{
public int ExamCount { get; } = examCount;
@@ -241,6 +241,22 @@ public partial class MainWindowViewModel : ObservableObject
CurrentPage = vm;
}
/// Sprungziel aus einer klassenübergreifenden Dashboard-Warnung (Fehlzeiten, Hausaufgaben-
/// Rückstand, Verspätungen) direkt auf den "Vorgänge"-Tab des Schülerdetails. Anders als
/// NavigateToStudent bewusst CurrentPage VOR LoadStudent (wie NavigateToGroupDetail) — die View
/// muss ihre Dialog-Callbacks registriert haben, bevor HandleWarningNavigation ggf. sofort eine
/// Rückfrage/einen Anlege-Dialog auslöst.
public void NavigateToStudentCase(Guid studentId, string suggestedTag, string suggestedTitle)
{
ActiveNavItem = NavItem.Students;
var vm = _services.GetRequiredService<StudentDetailViewModel>();
vm.OnReturnToStudentList = NavigateToStudents;
vm.ActiveTabIndex = StudentDetailViewModel.VorgaengeTabIndex;
CurrentPage = vm; // View bindet zuerst → Callbacks werden registriert
vm.LoadStudent(studentId);
_ = vm.VorgaengeTab.HandleWarningNavigation(suggestedTag, suggestedTitle);
}
public void NavigateToStudents() => NavigateTo(NavItem.Students);
/// Sprungziel aus dem Kurs-Dashboard ("Anstehende Aufgaben für diese Klasse") in den
@@ -225,6 +225,11 @@ public partial class ManageStudentDialogViewModel : ObservableObject
public partial class StudentDetailViewModel : ObservableObject
{
/// Tab-Reihenfolge im Schülerdetail (Übersicht=0, Kontakte=1, Noten=2, Dokumentation=3) — für
/// MainWindowViewModel.NavigateToStudentCase, damit die Dashboard-Warnungen (Fehlzeiten,
/// Hausaufgaben, Verspätungen) gezielt hierher statt auf den Standard-Tab springen können.
public const int VorgaengeTabIndex = 4;
private readonly IStudentRepository _students;
private readonly IGroupMembershipRepository _memberships;
private readonly IGroupRepository _groups;
@@ -240,6 +245,7 @@ public partial class StudentDetailViewModel : ObservableObject
private readonly SchoolYearService _schoolYear;
[ObservableProperty] private Student? _student;
[ObservableProperty] private int _activeTabIndex;
[ObservableProperty] private string _studentTitle = "";
[ObservableProperty] private bool _isEditing;
[ObservableProperty] private string _editFirstName = "";
@@ -260,6 +266,7 @@ public partial class StudentDetailViewModel : ObservableObject
public ObservableCollection<DocumentationItem> Documentation { get; } = [];
public ObservableCollection<ContactItem> Contacts { get; } = [];
public ObservableCollection<StudentGradeHistoryGroup> GradeHistory { get; } = [];
public StudentVorgaengeViewModel VorgaengeTab { get; }
public bool HasNoContacts => Contacts.Count == 0;
public Func<Contact?, Task<Contact?>>? OnEditContact { get; set; }
public Action<ContactItem>? OnViewAddress { get; set; }
@@ -278,13 +285,14 @@ public partial class StudentDetailViewModel : ObservableObject
IDocumentationRepository docs, IExamRepository exams, IExamResultRepository examResults,
IGradeRepository grades, IParticipationRepository participation,
IParticipationSessionRepository participationSessions, AttendanceBalanceService attendanceBalance,
PersonalDataExportService export, SchoolYearService schoolYear)
PersonalDataExportService export, SchoolYearService schoolYear, StudentVorgaengeViewModel vorgaengeTab)
{
_students = students; _memberships = memberships;
_groups = groups; _subjects = subjects; _docs = docs;
_exams = exams; _examResults = examResults; _grades = grades;
_participation = participation; _participationSessions = participationSessions;
_attendanceBalance = attendanceBalance; _export = export; _schoolYear = schoolYear;
VorgaengeTab = vorgaengeTab;
}
public void LoadStudent(Guid id)
@@ -316,6 +324,7 @@ public partial class StudentDetailViewModel : ObservableObject
LoadContacts();
LoadDocumentation();
LoadAttendanceBalance();
VorgaengeTab.Load(Student.Id);
}
// ── Dokumentation (5.1) ───────────────────────────────────────────────────
@@ -0,0 +1,210 @@
using System.Collections.ObjectModel;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using LehrerApp.Core.Interfaces;
using LehrerApp.Core.Models;
using LehrerApp.Desktop.ViewModels.ClassTeacher;
namespace LehrerApp.Desktop.ViewModels.Students;
/// <summary>
/// Schülerbezogener "Vorgänge"-Tab im Schülerdetail — schmalere Variante von
/// <see cref="ClassTeacherCasesViewModel"/> ohne Klassen-Roster/WebUntis-Klassenbuch-Kopplung: zeigt
/// und verwaltet nur die Vorgänge dieser einen Person (<see cref="IVorgangRepository.GetByStudent"/>).
/// Universeller Einstiegspunkt für die klassenübergreifenden Dashboard-Warnungen (Fehlzeiten,
/// Hausaufgaben-Rückstand, Verspätungen) — der Klassenlehrer-"Vorgänge"-Tab bleibt bewusst
/// homeroom-only (siehe TODO.md). Beide teilen sich nur <see cref="Vorgang"/>/<see cref="VorgangItem"/>/
/// <see cref="VorgangDialogViewModel"/>, keine ViewModel-Logik.
/// </summary>
public partial class StudentVorgaengeViewModel : ObservableObject
{
private readonly IVorgangRepository _vorgaenge;
private readonly IDocumentationRepository _documentation;
private readonly IStudentRepository _students;
private Guid _studentId;
private string _studentName = "";
private List<StudentOption> _studentOptions = [];
public ObservableCollection<VorgangItem> Cases { get; } = [];
[ObservableProperty] private bool _onlyOpen = true;
public bool HasCases => Cases.Count > 0;
public Func<List<StudentOption>, Vorgang?, Task<Vorgang?>>? OnEditVorgang { get; set; }
public Func<VorgangItem, Task<bool>>? OnConfirmDeleteVorgang { get; set; }
public Func<Guid, Documentation?, Task<Documentation?>>? OnEditDocumentation { get; set; }
/// Rückfrage vor dem automatischen Anlegen eines Vorgangs aus einer Dashboard-Warnung heraus
/// (siehe <see cref="HandleWarningNavigation"/>) — Parameter ist das gesuchte Tag.
public Func<string, Task<bool>>? OnConfirmCreateForWarning { get; set; }
public StudentVorgaengeViewModel(IVorgangRepository vorgaenge, IDocumentationRepository documentation,
IStudentRepository students)
{
_vorgaenge = vorgaenge;
_documentation = documentation;
_students = students;
}
partial void OnOnlyOpenChanged(bool value) => Load(_studentId);
[RelayCommand] private void Refresh() => Load(_studentId);
/// Bewusst synchron (anders als ClassTeacherCasesViewModel.LoadInternal) — kein WebUntis-Roster
/// aufzulösen, deshalb kann HandleWarningNavigation direkt im Anschluss laufen, ohne auf einen
/// asynchronen Ladevorgang warten zu müssen.
public void Load(Guid studentId)
{
_studentId = studentId;
_studentName = _students.GetById(studentId)?.FullName ?? "";
_studentOptions = [new StudentOption(studentId, _studentName)];
// IsRevealed übersteht den Neuaufbau der Liste nicht automatisch (neue VorgangItem-Instanzen
// pro Load) - deshalb hier gemerkt und danach wiederhergestellt (gleiches Muster wie
// ClassTeacherCasesViewModel.LoadInternal).
var revealedIds = Cases.Where(c => c.IsRevealed).Select(c => c.Model.Id).ToHashSet();
Cases.Clear();
IEnumerable<Vorgang> vorgaenge = _vorgaenge.GetByStudent(studentId);
if (OnlyOpen) vorgaenge = vorgaenge.Where(v => v.Status == VorgangStatus.Open);
// Nur die Dokumentation dieses einen Schülers (nicht GetAll()) — dieses Panel ist die Sicht
// "von diesem Schüler aus", nicht Vorgangsverwaltung für mehrere Beteiligte.
var ownDocs = _documentation.GetByStudent(studentId);
foreach (var vorgang in vorgaenge.OrderByDescending(v => v.UpdatedAt))
{
var names = string.Join(", ", vorgang.StudentIds
.Select(id => id == studentId ? _studentName : _students.GetById(id)?.FullName)
.Where(n => !string.IsNullOrEmpty(n)));
var item = new VorgangItem(vorgang, names, LinkDocumentation, UnlinkDocumentation,
RemoveClassRegisterEntry, EditDocumentation)
{ IsRevealed = revealedIds.Contains(vorgang.Id) };
var linkedIds = vorgang.DocumentationIds.ToHashSet();
foreach (var doc in ownDocs.Where(d => linkedIds.Contains(d.Id)))
item.LinkedDocumentation.Add(new DocumentationItem(doc, _studentName));
foreach (var doc in ClassTeacherCasesViewModel.FilterAvailableDocumentation(ownDocs, vorgang))
item.AvailableDocumentation.Add(new DocumentationItem(doc, _studentName));
foreach (var entry in vorgang.ClassRegisterEntries.OrderByDescending(e => e.Date))
item.ClassRegisterRows.Add(new VorgangClassRegisterEntryRow(entry));
Cases.Add(item);
}
OnPropertyChanged(nameof(HasCases));
}
[RelayCommand]
private async Task AddVorgang()
{
if (OnEditVorgang is null) return;
var result = await OnEditVorgang(_studentOptions, null);
if (result is null) return;
_vorgaenge.Save(result);
Load(_studentId);
}
[RelayCommand]
private async Task EditVorgang(VorgangItem? item)
{
if (item is null || OnEditVorgang is null) return;
var result = await OnEditVorgang(_studentOptions, item.Model);
if (result is null) return;
_vorgaenge.Save(result);
Load(_studentId);
}
[RelayCommand]
private void ToggleStatus(VorgangItem? item)
{
if (item is null) return;
item.Model.Status = item.Model.Status == VorgangStatus.Open ? VorgangStatus.Closed : VorgangStatus.Open;
item.Model.ClosedAt = item.Model.Status == VorgangStatus.Closed ? DateTime.UtcNow : null;
_vorgaenge.Save(item.Model);
Load(_studentId);
}
[RelayCommand]
private async Task DeleteVorgang(VorgangItem? item)
{
if (item is null) return;
if (OnConfirmDeleteVorgang is not null && !await OnConfirmDeleteVorgang(item)) return;
_vorgaenge.Delete(item.Model.Id);
Load(_studentId);
}
[RelayCommand]
private async Task CreateAndLinkDocumentation(VorgangItem? item)
{
if (item is null || OnEditDocumentation is null) return;
var result = await OnEditDocumentation(_studentId, null);
if (result is null) return;
_documentation.Save(result);
item.Model.DocumentationIds.Add(result.Id);
_vorgaenge.Save(item.Model);
Load(_studentId);
}
private async Task EditDocumentation(DocumentationItem doc)
{
if (OnEditDocumentation is null) return;
var result = await OnEditDocumentation(doc.Model.StudentId, doc.Model);
if (result is null) return;
_documentation.Save(result);
Load(_studentId);
}
private Task LinkDocumentation(VorgangItem item, DocumentationItem doc)
{
item.Model.DocumentationIds.Add(doc.Model.Id);
_vorgaenge.Save(item.Model);
Load(_studentId);
return Task.CompletedTask;
}
private Task UnlinkDocumentation(VorgangItem item, DocumentationItem doc)
{
item.Model.DocumentationIds.Remove(doc.Model.Id);
_vorgaenge.Save(item.Model);
Load(_studentId);
return Task.CompletedTask;
}
private Task RemoveClassRegisterEntry(VorgangItem item, VorgangClassRegisterEntryRow row)
{
item.Model.ClassRegisterEntries.Remove(row.Model);
_vorgaenge.Save(item.Model);
Load(_studentId);
return Task.CompletedTask;
}
/// <summary>
/// Von <see cref="MainWindowViewModel.NavigateToStudentCase"/> direkt nach dem Laden aufgerufen:
/// sucht einen offenen Vorgang mit passendem Tag (z.B. "Absentismus" für die Fehlzeiten-Warnung)
/// und klappt ihn auf. Findet sich keiner (auch ein geschlossener Vorgang mit passendem Tag zählt
/// hier bewusst nicht als Treffer — er taucht bei OnlyOpen=true gar nicht erst in <see cref="Cases"/>
/// auf), wird per Rückfrage ein neuer mit vorbelegtem Titel/Tag/Schüler angeboten.
/// </summary>
public async Task HandleWarningNavigation(string suggestedTag, string suggestedTitle)
{
var match = Cases.FirstOrDefault(c =>
c.Model.Tags.Contains(suggestedTag, StringComparer.OrdinalIgnoreCase));
if (match is not null)
{
match.IsRevealed = true;
return;
}
if (OnConfirmCreateForWarning is null || OnEditVorgang is null) return;
if (!await OnConfirmCreateForWarning(suggestedTag)) return;
var draft = new Vorgang { Title = suggestedTitle, StudentIds = [_studentId], Tags = [suggestedTag] };
var result = await OnEditVorgang(_studentOptions, draft);
if (result is null) return;
_vorgaenge.Save(result);
Load(_studentId);
var created = Cases.FirstOrDefault(c => c.Model.Id == result.Id);
if (created is not null) created.IsRevealed = true;
}
}
@@ -498,7 +498,7 @@
<Border Grid.Column="0" Classes="severitybar"
Classes.high="{Binding IsHighSeverity}"
Classes.medium="{Binding IsMediumSeverity}"
Classes.attendance="{Binding IsAttendanceWarning}" Margin="0,0,9,0"/>
Classes.attendance="{Binding IsCrossClassWarning}" Margin="0,0,9,0"/>
<!-- Bewusst immer als Button (statt je nach IsClickable ein anderes Root-Element):
ein einheitliches Template für alle sieben Arten. Bei Excuse/MissingTeachingTime
ist Navigate null, der Klick auf die Zeile selbst ist dann folgenlos — die
@@ -2,6 +2,7 @@
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="clr-namespace:LehrerApp.Desktop.ViewModels.Students"
xmlns:shared="clr-namespace:LehrerApp.Desktop.Views.Shared"
xmlns:views="clr-namespace:LehrerApp.Desktop.Views.Students"
x:Class="LehrerApp.Desktop.Views.Students.StudentDetailView"
x:DataType="vm:StudentDetailViewModel">
<Grid RowDefinitions="Auto,*">
@@ -46,7 +47,7 @@
Avalonia 12: TabbedPage für Schüler-Tabs.
Kein manuelles Tab-Management nötig.
-->
<TabbedPage Grid.Row="1" TabPlacement="Top">
<TabbedPage Grid.Row="1" TabPlacement="Top" SelectedIndex="{Binding ActiveTabIndex}">
<ContentPage Header="Übersicht">
<ScrollViewer Padding="20">
@@ -322,6 +323,10 @@
</ScrollViewer>
</ContentPage>
<ContentPage Header="Vorgänge">
<views:StudentVorgaengeTabView DataContext="{Binding VorgaengeTab}"/>
</ContentPage>
</TabbedPage>
</Grid>
</UserControl>
@@ -0,0 +1,187 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="clr-namespace:LehrerApp.Desktop.ViewModels.ClassTeacher"
xmlns:svm="clr-namespace:LehrerApp.Desktop.ViewModels.Students"
x:Class="LehrerApp.Desktop.Views.Students.StudentVorgaengeTabView"
x:DataType="svm:StudentVorgaengeViewModel">
<UserControl.Styles>
<Style Selector="Border.statusPill">
<Setter Property="CornerRadius" Value="10"/>
<Setter Property="Padding" Value="8,2"/>
</Style>
<Style Selector="Border.statusPill.open">
<Setter Property="Background" Value="#FB8C00"/>
</Style>
<Style Selector="Border.statusPill.closed">
<Setter Property="Background" Value="#43A047"/>
</Style>
<Style Selector="Border.statusPill TextBlock">
<Setter Property="Foreground" Value="White"/>
<Setter Property="FontSize" Value="11"/>
<Setter Property="FontWeight" Value="SemiBold"/>
</Style>
</UserControl.Styles>
<Grid RowDefinitions="Auto,*" Margin="16" RowSpacing="10">
<Grid Grid.Row="0" ColumnDefinitions="*,Auto,Auto">
<StackPanel Grid.Column="0">
<TextBlock Text="Vorgänge" FontSize="22" FontWeight="SemiBold"/>
<TextBlock Text="Fallmappen für laufende Probleme mit diesem Schüler/dieser Schülerin"
FontSize="12" Opacity="0.55"/>
</StackPanel>
<CheckBox Grid.Column="1" Content="Nur offene" IsChecked="{Binding OnlyOpen}"
VerticalAlignment="Center" Margin="0,0,12,0"/>
<Button Grid.Column="2" Content="+ Vorgang" Command="{Binding AddVorgangCommand}"/>
</Grid>
<ScrollViewer Grid.Row="1">
<StackPanel>
<ItemsControl ItemsSource="{Binding Cases}">
<ItemsControl.ItemTemplate>
<DataTemplate DataType="vm:VorgangItem">
<Border Background="{DynamicResource AppCardBackgroundBrush}"
BorderBrush="{DynamicResource AppCardBorderBrush}" BorderThickness="1"
CornerRadius="8" Padding="12,10" Margin="0,0,0,8">
<StackPanel Spacing="8">
<Grid ColumnDefinitions="*,Auto,Auto,Auto,Auto,Auto">
<StackPanel Grid.Column="0">
<StackPanel Orientation="Horizontal" Spacing="8">
<Border Classes="statusPill" Classes.open="{Binding IsOpen}" Classes.closed="{Binding !IsOpen}">
<TextBlock Text="{Binding StatusLabel}"/>
</Border>
<TextBlock Text="{Binding Model.Title}" FontWeight="SemiBold" FontSize="14"/>
</StackPanel>
<ItemsControl ItemsSource="{Binding TagChips}">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate><WrapPanel ItemSpacing="6" LineSpacing="4"/></ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
<ItemsControl.ItemTemplate>
<DataTemplate DataType="svm:TagChip">
<Border Background="{Binding ColorHex}" CornerRadius="10" Padding="8,2" Margin="0,4,0,0">
<TextBlock Text="{Binding Text}" FontSize="10" Foreground="White"/>
</Border>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</StackPanel>
<StackPanel Grid.Column="1" Orientation="Horizontal">
<Button Content="Details anzeigen" FontSize="11" Padding="8,3"
Command="{Binding RevealCommand}" IsVisible="{Binding !IsRevealed}"/>
<Button Content="Details ausblenden" FontSize="11" Padding="8,3"
Command="{Binding RevealCommand}" IsVisible="{Binding IsRevealed}"/>
</StackPanel>
<Button Grid.Column="2" Content="Bearbeiten" FontSize="11" Padding="8,3"
Command="{Binding $parent[ItemsControl].((svm:StudentVorgaengeViewModel)DataContext).EditVorgangCommand}"
CommandParameter="{Binding}"/>
<StackPanel Grid.Column="3" Orientation="Horizontal">
<Button Content="Schließen" FontSize="11" Padding="8,3" IsVisible="{Binding IsOpen}"
Command="{Binding $parent[ItemsControl].((svm:StudentVorgaengeViewModel)DataContext).ToggleStatusCommand}"
CommandParameter="{Binding}"/>
<Button Content="Wieder öffnen" FontSize="11" Padding="8,3" IsVisible="{Binding !IsOpen}"
Command="{Binding $parent[ItemsControl].((svm:StudentVorgaengeViewModel)DataContext).ToggleStatusCommand}"
CommandParameter="{Binding}"/>
</StackPanel>
<Button Grid.Column="4" Content="Löschen" FontSize="11" Padding="8,3"
Command="{Binding $parent[ItemsControl].((svm:StudentVorgaengeViewModel)DataContext).DeleteVorgangCommand}"
CommandParameter="{Binding}"/>
</Grid>
<StackPanel Spacing="10" IsVisible="{Binding IsRevealed}" Margin="0,4,0,0">
<TextBlock Text="{Binding Model.Description}" FontSize="12" TextWrapping="Wrap" Opacity="0.85"
IsVisible="{Binding Model.Description, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
<StackPanel Spacing="4">
<TextBlock Text="Verknüpfte Dokumentation" FontSize="12" FontWeight="SemiBold" Opacity="0.7"/>
<ItemsControl ItemsSource="{Binding LinkedDocumentation}">
<ItemsControl.ItemTemplate>
<DataTemplate DataType="svm:DocumentationItem">
<Grid ColumnDefinitions="70,*,Auto,Auto,Auto" Margin="0,2">
<TextBlock Grid.Column="0" Text="{Binding DateDisplay}" FontSize="11" Opacity="0.5"/>
<StackPanel Grid.Column="1">
<StackPanel Orientation="Horizontal" Spacing="6">
<TextBlock Text="{Binding Model.Title}" FontSize="12" IsVisible="{Binding IsRevealed}"/>
<TextBlock Text="Vertraulich" FontSize="12" Opacity="0.6" IsVisible="{Binding !IsRevealed}"/>
</StackPanel>
<TextBlock Text="{Binding TypeLabel}" FontSize="10" Opacity="0.5"/>
</StackPanel>
<Button Grid.Column="2" Content="Anzeigen" FontSize="10" Padding="6,2" Margin="0,0,4,0"
Command="{Binding RevealCommand}" IsVisible="{Binding !IsRevealed}"/>
<!-- Nutzer-Feedback: verknüpfte Einträge sollen sich zum Nachlesen direkt
hier öffnen lassen, ohne den Vorgänge-Tab zu verlassen. -->
<Button Grid.Column="3" Content="Bearbeiten" FontSize="10" Padding="6,2" Margin="0,0,4,0"
IsVisible="{Binding IsRevealed}"
Command="{Binding $parent[ItemsControl].((vm:VorgangItem)DataContext).EditDocumentationCommand}"
CommandParameter="{Binding}"/>
<Button Grid.Column="4" Content="Entfernen" FontSize="10" Padding="6,2"
Command="{Binding $parent[ItemsControl].((vm:VorgangItem)DataContext).UnlinkCommand}"
CommandParameter="{Binding}"/>
</Grid>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<TextBlock Text="Noch keine Dokumentation verknüpft." FontSize="11" Opacity="0.5"
IsVisible="{Binding !HasLinkedDocumentation}"/>
<Button Content="+ Neue Dokumentation anlegen" FontSize="11" Padding="8,3"
HorizontalAlignment="Left"
Command="{Binding $parent[ItemsControl].((svm:StudentVorgaengeViewModel)DataContext).CreateAndLinkDocumentationCommand}"
CommandParameter="{Binding}"/>
</StackPanel>
<StackPanel Spacing="4" IsVisible="{Binding HasAvailableDocumentation}">
<TextBlock Text="Bestehende Dokumentation dieses Schülers verknüpfen" FontSize="12"
FontWeight="SemiBold" Opacity="0.7"/>
<ItemsControl ItemsSource="{Binding AvailableDocumentation}">
<ItemsControl.ItemTemplate>
<DataTemplate DataType="svm:DocumentationItem">
<Grid ColumnDefinitions="70,*,Auto,Auto,Auto" Margin="0,2">
<TextBlock Grid.Column="0" Text="{Binding DateDisplay}" FontSize="11" Opacity="0.5"/>
<StackPanel Grid.Column="1">
<StackPanel Orientation="Horizontal" Spacing="6">
<TextBlock Text="{Binding Model.Title}" FontSize="12" IsVisible="{Binding IsRevealed}"/>
<TextBlock Text="Vertraulich" FontSize="12" Opacity="0.6" IsVisible="{Binding !IsRevealed}"/>
</StackPanel>
<TextBlock Text="{Binding TypeLabel}" FontSize="10" Opacity="0.5"/>
</StackPanel>
<Button Grid.Column="2" Content="Anzeigen" FontSize="10" Padding="6,2" Margin="0,0,4,0"
Command="{Binding RevealCommand}" IsVisible="{Binding !IsRevealed}"/>
<Button Grid.Column="3" Content="Bearbeiten" FontSize="10" Padding="6,2" Margin="0,0,4,0"
IsVisible="{Binding IsRevealed}"
Command="{Binding $parent[ItemsControl].((vm:VorgangItem)DataContext).EditDocumentationCommand}"
CommandParameter="{Binding}"/>
<Button Grid.Column="4" Content="Verknüpfen" FontSize="10" Padding="6,2"
Command="{Binding $parent[ItemsControl].((vm:VorgangItem)DataContext).LinkCommand}"
CommandParameter="{Binding}"/>
</Grid>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</StackPanel>
<StackPanel Spacing="4" IsVisible="{Binding HasClassRegisterRows}">
<TextBlock Text="Angeheftete Klassenbucheinträge" FontSize="12" FontWeight="SemiBold" Opacity="0.7"/>
<ItemsControl ItemsSource="{Binding ClassRegisterRows}">
<ItemsControl.ItemTemplate>
<DataTemplate DataType="vm:VorgangClassRegisterEntryRow">
<Grid ColumnDefinitions="70,*,Auto" Margin="0,2">
<TextBlock Grid.Column="0" Text="{Binding DateLabel}" FontSize="11" Opacity="0.5"/>
<TextBlock Grid.Column="1" Text="{Binding SummaryLabel}" FontSize="11" Opacity="0.7" TextWrapping="Wrap"/>
<Button Grid.Column="2" Content="Entfernen" FontSize="10" Padding="6,2"
Command="{Binding $parent[ItemsControl].((vm:VorgangItem)DataContext).RemoveClassRegisterEntryCommand}"
CommandParameter="{Binding}"/>
</Grid>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</StackPanel>
</StackPanel>
</StackPanel>
</Border>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<TextBlock Text="Keine Vorgänge." Classes="emptyhint" IsVisible="{Binding !HasCases}"/>
</StackPanel>
</ScrollViewer>
</Grid>
</UserControl>
@@ -0,0 +1,77 @@
using Avalonia.Controls;
using LehrerApp.Core.Interfaces;
using LehrerApp.Core.Models;
using LehrerApp.Desktop.Services;
using LehrerApp.Desktop.ViewModels.ClassTeacher;
using LehrerApp.Desktop.ViewModels.Students;
using LehrerApp.Desktop.Views.ClassTeacher;
using LehrerApp.Desktop.Views.Shared;
using Microsoft.Extensions.DependencyInjection;
namespace LehrerApp.Desktop.Views.Students;
public partial class StudentVorgaengeTabView : UserControl
{
public StudentVorgaengeTabView() => InitializeComponent();
protected override void OnDataContextChanged(EventArgs e)
{
base.OnDataContextChanged(e);
if (DataContext is not StudentVorgaengeViewModel vm) return;
vm.OnEditVorgang = ShowVorgangDialog;
vm.OnConfirmDeleteVorgang = ShowDeleteVorgangDialog;
vm.OnEditDocumentation = ShowDocumentationDialog;
vm.OnConfirmCreateForWarning = ShowConfirmCreateDialog;
}
private async Task<Vorgang?> ShowVorgangDialog(List<StudentOption> students, Vorgang? editing)
{
var owner = TopLevel.GetTopLevel(this) as Window;
if (owner is null) return null;
var vm = new VorgangDialogViewModel(students, editing);
var dialog = new VorgangDialog { DataContext = vm };
var saved = await dialog.ShowDialog<bool>(owner);
return saved ? vm.Result : null;
}
private async Task<bool> ShowDeleteVorgangDialog(VorgangItem item)
{
var info = new ConfirmDialogInfo
{
Title = "Vorgang löschen?",
Message = $"\"{item.Model.Title}\" wird als gelöscht markiert und nicht mehr angezeigt. " +
"Verknüpfte Dokumentation bleibt erhalten.",
ConfirmText = "Löschen",
};
var dialog = new ConfirmDialog { DataContext = info };
var owner = TopLevel.GetTopLevel(this) as Window;
return owner is not null && await dialog.ShowDialog<bool>(owner);
}
private async Task<Documentation?> ShowDocumentationDialog(Guid studentId, Documentation? editing)
{
var owner = TopLevel.GetTopLevel(this) as Window;
if (owner is null) return null;
var vm = new DocumentationDialogViewModel(studentId, editing,
App.Services.GetRequiredService<IAttachmentStorage>());
var dialog = new DocumentationDialog { DataContext = vm };
var saved = await dialog.ShowDialog<bool>(owner);
if (!saved) vm.DiscardUnsavedAttachments();
return saved ? vm.Result : null;
}
private async Task<bool> ShowConfirmCreateDialog(string suggestedTag)
{
var info = new ConfirmDialogInfo
{
Title = "Neuen Vorgang anlegen?",
Message = $"Kein offener Vorgang zu \"{suggestedTag}\" gefunden. Neuen anlegen?",
ConfirmText = "Anlegen",
};
var dialog = new ConfirmDialog { DataContext = info };
var owner = TopLevel.GetTopLevel(this) as Window;
return owner is not null && await dialog.ShowDialog<bool>(owner);
}
}
+24
View File
@@ -1951,6 +1951,30 @@ eigenen Unterricht abfragt und deshalb mit den regulären Lehrkraft-Rechten funk
für bestehende Tests/Aufrufer). `ClassTeacherOverviewViewModel.Load` löst die geschlossenen
Vorgänge je Klassen-Roster-Namen über denselben Namensabgleich wie die übrigen `Build*`-Methoden
dieser Klasse auf (`IVorgangRepository.GetByStudent`).
- [x] **Dashboard-Warnungen → schülerbezogener "Vorgänge"-Tab (September 2026):** Nutzer-Feedback:
die Fehlzeiten-Warnung im Dashboard betrifft auch Schüler*innen fremder Klassen, für die der
bestehende "Vorgänge"-Tab der Klassenlehreransicht strukturell nicht nutzbar ist (er löst die
eigene Klasse über den WebUntis-Klassenbuch-Roster auf, siehe oben). Statt diese Kopplung
aufzuweichen, bekommt der Schülerdatensatz (`StudentDetailViewModel`/`-View`) einen eigenen,
fünften Tab "Vorgänge" (`StudentVorgaengeViewModel`/`StudentVorgaengeTabView`) — deutlich
schmaler als `ClassTeacherCasesViewModel`: kein Roster-Abgleich, kein Klassenbuch-Anheften, nur
`IVorgangRepository.GetByStudent`, dieselben Link/Unlink/Anlegen/Bearbeiten/Reveal/Status-
Aktionen über dasselbe, bereits über Callbacks von der Roster-Auflösung entkoppelte `VorgangItem`.
Zwei neue Dashboard-Warnungen ("Hausaufgaben-Warnung" ab 3 offenen/überfälligen Hausaufgaben,
"Verspätungen-Warnung" ab 8 Late/SignificantlyLate-Einträgen, jeweils laufendes Schuljahr, alle
Gruppen — beide Schwellen Nutzer-Entscheidung) ergänzen die bestehende Fehlzeiten-Warnung um zwei
weitere `AttentionKind`s mit identischer klassenübergreifender Logik (`DashboardViewModel`,
`BuildSessionDateLookup` als gemeinsamer Session-Datum-Lookup für alle drei). Alle drei navigieren
jetzt über `MainWindowViewModel.NavigateToStudentCase` (neues `DashboardViewModel.
OnNavigateToStudentCase`-Delegate, eigenständig neben `OnNavigateToStudent`, damit dessen übrige
Aufrufer unverändert bleiben) direkt auf den neuen Tab und suchen dort per Tag
(Absentismus/Hausaufgaben/Verspätungen, dieselben Strings wie `VorgangTagDisplay.Suggestions`)
nach einem passenden offenen Vorgang — keiner gefunden (ein geschlossener mit passendem Tag zählt
bewusst nicht als Treffer), wird per Rückfrage ein neuer mit vorbelegtem Titel/Tag/Schüler
angeboten. **Bewusst unverändert:** der Klassenlehrer-"Vorgänge"-Tab selbst bleibt homeroom-only;
beide Ansichten teilen sich nur `Vorgang`/`VorgangItem`/`VorgangDialogViewModel`, keine
ViewModel-Logik. `AttentionItem.IsAttendanceWarning` wurde zu `IsCrossClassWarning` verallgemeinert
(deckt jetzt alle drei klassenübergreifenden Warnarten mit derselben Balkenfarbe ab).
**Nachtrag zu 4.3, Bugfix Wochennavigation (September 2026):** Nutzer-Feedback: beim Planen der
nächsten Woche musste nach jedem kurzen Abstecher in einen anderen Bereich der Sidebar wieder