diff --git a/LehrerApp.Core/Services/DashboardSettingsService.cs b/LehrerApp.Core/Services/DashboardSettingsService.cs new file mode 100644 index 0000000..14fda94 --- /dev/null +++ b/LehrerApp.Core/Services/DashboardSettingsService.cs @@ -0,0 +1,62 @@ +using System.Text.Json; + +namespace LehrerApp.Core.Services; + +public sealed class DashboardCardSetting +{ + public string Key { get; set; } = ""; + public bool IsVisible { get; set; } = true; + public int Order { get; set; } +} + +/// Speichert Sichtbarkeit und Reihenfolge der Dashboard-Kacheln lokal. +public sealed class DashboardSettingsService +{ + public static readonly string[] DefaultCardOrder = + [ + "today", "tasks", "calendar", "excuses", "upcoming", + "corrections", "alerts", "attendance", "support", "groups", + ]; + + private readonly string _configPath; + + public DashboardSettingsService(string appDataPath) => + _configPath = Path.Combine(appDataPath, "dashboardsettings.json"); + + public List Load() + { + try + { + if (File.Exists(_configPath)) + { + var saved = JsonSerializer.Deserialize>( + File.ReadAllText(_configPath)) ?? []; + var byKey = saved + .Where(s => DefaultCardOrder.Contains(s.Key)) + .GroupBy(s => s.Key).ToDictionary(g => g.Key, g => g.First()); + return DefaultCardOrder.Select((key, defaultOrder) => byKey.TryGetValue(key, out var item) + ? new DashboardCardSetting { Key = key, IsVisible = item.IsVisible, Order = item.Order } + : new DashboardCardSetting { Key = key, IsVisible = true, Order = defaultOrder }) + .OrderBy(s => s.Order).ThenBy(s => Array.IndexOf(DefaultCardOrder, s.Key)) + .Select((s, index) => new DashboardCardSetting + { Key = s.Key, IsVisible = s.IsVisible, Order = index }) + .ToList(); + } + } + catch { /* beschädigte Konfiguration -> Standardreihenfolge */ } + + return DefaultCardOrder.Select((key, index) => new DashboardCardSetting + { Key = key, IsVisible = true, Order = index }).ToList(); + } + + public void Save(IEnumerable settings) + { + var normalized = settings.Select((s, index) => new DashboardCardSetting + { + Key = s.Key, + IsVisible = s.IsVisible, + Order = index, + }).ToList(); + File.WriteAllText(_configPath, JsonSerializer.Serialize(normalized)); + } +} diff --git a/LehrerApp.Desktop.Tests/DashboardViewModelTests.cs b/LehrerApp.Desktop.Tests/DashboardViewModelTests.cs index 5205dca..e54e76a 100644 --- a/LehrerApp.Desktop.Tests/DashboardViewModelTests.cs +++ b/LehrerApp.Desktop.Tests/DashboardViewModelTests.cs @@ -18,17 +18,31 @@ public sealed class DashboardViewModelTests return new PeriodScheduleService(tempPath); } + private static DashboardSettingsService NewDashboardSettings() + { + var tempPath = System.IO.Path.Combine( + System.IO.Path.GetTempPath(), $"lehrerapp-dashboardsettings-tests-{Guid.NewGuid():N}"); + Directory.CreateDirectory(tempPath); + return new DashboardSettingsService(tempPath); + } + private static DashboardViewModel BuildVm(LearningGroup group, Lesson lesson, - FakeTimetableSlots? slots = null, PeriodScheduleService? periodSchedule = null) + FakeTimetableSlots? slots = null, PeriodScheduleService? periodSchedule = null, + FakeExams? exams = null, FakeResults? results = null, FakeGrades? grades = null, + FakeReportGrades? reportGrades = null, FakeMemberships? memberships = null, + FakeWorkTasks? tasks = null, FakeStudents? students = null, FakeDocumentation? documentation = null, + DashboardSettingsService? dashboardSettings = null) { var lessons = new FakeLessons(); lessons.Add(lesson); return new DashboardViewModel( new FakeGroups([group]), new FakeSubjects([]), lessons, - new FakeExams([]), new FakeWorkTasks(), new FakeSessions([]), new FakeEntries(), - new FakeStudents([]), new FakeDocumentation(), + exams ?? new FakeExams([]), results ?? new FakeResults(), grades ?? new FakeGrades(), + reportGrades ?? new FakeReportGrades(), memberships ?? new FakeMemberships([]), + tasks ?? new FakeWorkTasks(), new FakeSessions([]), new FakeEntries(), + students ?? new FakeStudents([]), documentation ?? new FakeDocumentation(), slots ?? new FakeTimetableSlots(), periodSchedule ?? NewPeriodSchedule(), - new AttendanceBalanceService(), new SchoolYearService()); + new AttendanceBalanceService(), new SchoolYearService(), dashboardSettings ?? NewDashboardSettings()); } [Fact] @@ -108,4 +122,109 @@ public sealed class DashboardViewModelTests Assert.Equal(group.Id, navigatedTo); } + + [Fact] + public void UpcomingDates_BuendeltKlausurenFristenUndFoerderplanPruefungen() + { + var today = DateOnly.FromDateTime(DateTime.Today); + var group = new LearningGroup { Name = "9c" }; + var exams = new FakeExams([ + new Exam { GroupId = group.Id, Title = "Chemie-Test", Date = today.AddDays(5) }, + ]); + var tasks = new FakeWorkTasks(); + tasks.Add(new WorkTask { Title = "Notenschluss", DueDate = today.AddDays(10) }); + var student = new Student { FirstName = "Anna", LastName = "Beispiel" }; + var documentation = new FakeDocumentation(); + documentation.Add(new Documentation + { + StudentId = student.Id, Type = DocumentationType.SupportPlan, Title = "Leseförderung", + SupportData = new SupportData { Status = SupportStatus.Active, ReviewDate = today.AddDays(3) }, + }); + + var vm = BuildVm(group, new Lesson { GroupId = group.Id, Date = today }, exams: exams, + tasks: tasks, students: new FakeStudents([student]), documentation: documentation); + + Assert.Contains(vm.UpcomingDates, i => i.Kind == UpcomingDateKind.Exam && i.Title == "Chemie-Test"); + Assert.Contains(vm.UpcomingDates, i => i.Kind == UpcomingDateKind.Deadline && i.Title == "Notenschluss"); + Assert.Contains(vm.UpcomingDates, i => i.Kind == UpcomingDateKind.SupportPlan && i.StudentId == student.Id); + } + + [Fact] + public void OpenCorrections_ZeigtBewertungsfortschrittJeKlausur() + { + var today = DateOnly.FromDateTime(DateTime.Today); + var group = new LearningGroup { Name = "9c" }; + var exam = new Exam + { GroupId = group.Id, Title = "Klausur 1", Date = today.AddDays(-2), Status = ExamStatus.Conducted }; + var anna = new Student { FirstName = "Anna", LastName = "A" }; + var ben = new Student { FirstName = "Ben", LastName = "B" }; + var memberships = new FakeMemberships([ + new GroupMembership { GroupId = group.Id, StudentId = anna.Id }, + new GroupMembership { GroupId = group.Id, StudentId = ben.Id }, + ]); + var results = new FakeResults(); + results.Add(new ExamResult { ExamId = exam.Id, StudentId = anna.Id, Grade = "2" }); + + var vm = BuildVm(group, new Lesson { GroupId = group.Id, Date = today }, + exams: new FakeExams([exam]), results: results, memberships: memberships, + students: new FakeStudents([anna, ben])); + + var correction = Assert.Single(vm.OpenCorrections); + Assert.Equal(1, correction.Completed); + Assert.Equal(2, correction.Total); + Assert.Equal(50, correction.Percent); + } + + [Fact] + public void Alerts_ErkenntDeutlichenNotenabfall() + { + var today = DateOnly.FromDateTime(DateTime.Today); + var group = new LearningGroup { Name = "9c", GradingSystem = GradingSystem.Grades1To6 }; + var student = new Student { FirstName = "Anna", LastName = "Beispiel" }; + var memberships = new FakeMemberships([ + new GroupMembership { GroupId = group.Id, StudentId = student.Id }, + ]); + var grades = new FakeGrades(); + grades.Add(new Grade { GroupId = group.Id, StudentId = student.Id, Date = today.AddDays(-20), Value = "2" }); + grades.Add(new Grade { GroupId = group.Id, StudentId = student.Id, Date = today.AddDays(-15), Value = "2" }); + grades.Add(new Grade { GroupId = group.Id, StudentId = student.Id, Date = today.AddDays(-10), Value = "4" }); + grades.Add(new Grade { GroupId = group.Id, StudentId = student.Id, Date = today.AddDays(-5), Value = "4" }); + + var vm = BuildVm(group, new Lesson { GroupId = group.Id, Date = today }, grades: grades, + memberships: memberships, students: new FakeStudents([student])); + + Assert.Contains(vm.Alerts, a => a.StudentId == student.Id && a.KindLabel == "Notenabfall"); + } + + [Fact] + public void Kalenderauswahl_ZeigtTermineDesGeklicktenTages() + { + var today = DateOnly.FromDateTime(DateTime.Today); + var group = new LearningGroup { Name = "9c" }; + var examDate = today.AddDays(2); + var vm = BuildVm(group, new Lesson { GroupId = group.Id, Date = today }, + exams: new FakeExams([new Exam { GroupId = group.Id, Title = "Test", Date = examDate }])); + var day = vm.CalendarDays.Single(d => d.Date == examDate); + + vm.SelectCalendarDayCommand.Execute(day); + + Assert.True(day.IsSelected); + Assert.Contains(vm.SelectedDayEvents, e => e.Kind == CalendarEventKind.Exam && e.Title == "Test"); + } + + [Fact] + public void DashboardKacheln_SichtbarkeitUndReihenfolgeWerdenGespeichert() + { + var settings = NewDashboardSettings(); + var today = DateOnly.FromDateTime(DateTime.Today); + var group = new LearningGroup { Name = "9c" }; + var vm = BuildVm(group, new Lesson { GroupId = group.Id, Date = today }, dashboardSettings: settings); + vm.TodayCard.IsVisible = false; + vm.MoveCardDownCommand.Execute(vm.TasksCard); + + var saved = settings.Load(); + + Assert.False(saved.Single(c => c.Key == "today").IsVisible); + Assert.True(saved.FindIndex(c => c.Key == "tasks") > 1); + } } diff --git a/LehrerApp.Desktop/App.axaml.cs b/LehrerApp.Desktop/App.axaml.cs index ae2149f..5c25180 100644 --- a/LehrerApp.Desktop/App.axaml.cs +++ b/LehrerApp.Desktop/App.axaml.cs @@ -99,7 +99,8 @@ public class App : Application var dash = Services.GetRequiredService(); dash.OnNavigateToGroup = id => main.NavigateToGroupDetail(id); dash.OnNavigateToStudent = id => main.NavigateToStudent(id); - dash.OnNavigateToLesson = id => main.NavigateToGroupDetail(id, 2); // Tab "Mitarbeit" + dash.OnNavigateToLesson = id => main.NavigateToGroupDetail(id, 3); // Tab "Mitarbeit" + dash.OnNavigateToExam = id => main.NavigateToGroupDetail(id, 4); // Tab "Klausuren" // StudentList → StudentDetail + Anlegen var sl = Services.GetRequiredService(); @@ -109,7 +110,7 @@ public class App : Application // Stundenplan "Heute" → GroupDetail (Tab "Planung") / Einstellungen (Zahnrad, Tab "Ferien & Feiertage") var timetable = Services.GetRequiredService(); timetable.OnNavigateToSettings = () => main.NavigateToSettings(7); - timetable.OnNavigateToGroup = id => main.NavigateToGroupDetail(id, 5); + timetable.OnNavigateToGroup = id => main.NavigateToGroupDetail(id, 6); } private static async Task ShowAddStudentDialog() diff --git a/LehrerApp.Desktop/AppBootstrapper.cs b/LehrerApp.Desktop/AppBootstrapper.cs index f7bcdff..24af746 100644 --- a/LehrerApp.Desktop/AppBootstrapper.cs +++ b/LehrerApp.Desktop/AppBootstrapper.cs @@ -154,6 +154,7 @@ public static class AppBootstrapper services.AddSingleton(_ => new SchoolCalendarSettingsService(appData)); services.AddSingleton(_ => new PeriodScheduleService(appData)); services.AddSingleton(_ => new WorkloadSettingsService(appData)); + services.AddSingleton(_ => new DashboardSettingsService(appData)); services.AddSingleton(_ => new LetterTemplateService(appData)); // ── KI-Unterstützung (4.5.9, optional – nur wenn in den Einstellungen aktiviert) ────── diff --git a/LehrerApp.Desktop/ViewModels/DashboardViewModel.cs b/LehrerApp.Desktop/ViewModels/DashboardViewModel.cs index f41d980..b7552cd 100644 --- a/LehrerApp.Desktop/ViewModels/DashboardViewModel.cs +++ b/LehrerApp.Desktop/ViewModels/DashboardViewModel.cs @@ -16,6 +16,10 @@ public partial class DashboardViewModel : ObservableObject private readonly ISubjectRepository _subjects; private readonly ILessonRepository _lessons; private readonly IExamRepository _exams; + private readonly IExamResultRepository _examResults; + private readonly IGradeRepository _grades; + private readonly IReportGradeRepository _reportGrades; + private readonly IGroupMembershipRepository _memberships; private readonly IWorkTaskRepository _tasks; private readonly IParticipationSessionRepository _participationSessions; private readonly IParticipationRepository _participationEntries; @@ -25,14 +29,18 @@ public partial class DashboardViewModel : ObservableObject private readonly PeriodScheduleService _periodSchedule; private readonly AttendanceBalanceService _attendanceBalance; private readonly SchoolYearService _sy; + private readonly DashboardSettingsService _dashboardSettings; private const int OpenExcuseMaxAgeDays = 21; private const int SupportPlanDueWithinDays = 14; + private const int UpcomingWithinDays = 30; [ObservableProperty] private string _greeting = ""; [ObservableProperty] private string _currentDate = ""; [ObservableProperty] private string _currentSchoolYear = ""; [ObservableProperty] private DateOnly _calendarMonth = FirstOfMonth(DateTime.Today); + [ObservableProperty] private string _selectedDayLabel = ""; + [ObservableProperty] private bool _isDashboardSettingsOpen; public string CalendarMonthLabel => CalendarMonth.ToString("MMMM yyyy", De); @@ -43,6 +51,11 @@ public partial class DashboardViewModel : ObservableObject public ObservableCollection OpenExcuses { get; } = []; public ObservableCollection AttendanceWarnings { get; } = []; public ObservableCollection SupportPlanReviews { get; } = []; + public ObservableCollection UpcomingDates { get; } = []; + public ObservableCollection OpenCorrections { get; } = []; + public ObservableCollection Alerts { get; } = []; + public ObservableCollection SelectedDayEvents { get; } = []; + public ObservableCollection DashboardCards { get; } = []; public static string[] CalendarWeekdayHeaders { get; } = ["Mo", "Di", "Mi", "Do", "Fr", "Sa", "So"]; // Navigation-Callback – wird von App.axaml.cs verdrahtet @@ -52,21 +65,40 @@ public partial class DashboardViewModel : ObservableObject // OnNavigateToGroup (Lerngruppen-Kacheln, Tab "Übersicht"), da der Sprung von einer konkreten // Stunde aus sinnvollerweise direkt in die Mitarbeitserfassung führt. public Action? OnNavigateToLesson { get; set; } + public Action? OnNavigateToExam { get; set; } + + public DashboardCardOption TodayCard => Card("today"); + public DashboardCardOption TasksCard => Card("tasks"); + public DashboardCardOption CalendarCard => Card("calendar"); + public DashboardCardOption ExcusesCard => Card("excuses"); + public DashboardCardOption UpcomingCard => Card("upcoming"); + public DashboardCardOption CorrectionsCard => Card("corrections"); + public DashboardCardOption AlertsCard => Card("alerts"); + public DashboardCardOption AttendanceCard => Card("attendance"); + public DashboardCardOption SupportCard => Card("support"); + public DashboardCardOption GroupsCard => Card("groups"); public DashboardViewModel(IGroupRepository groups, ISubjectRepository subjects, ILessonRepository lessons, - IExamRepository exams, IWorkTaskRepository tasks, IParticipationSessionRepository participationSessions, + IExamRepository exams, IExamResultRepository examResults, IGradeRepository grades, + IReportGradeRepository reportGrades, IGroupMembershipRepository memberships, + IWorkTaskRepository tasks, IParticipationSessionRepository participationSessions, IParticipationRepository participationEntries, IStudentRepository students, IDocumentationRepository documentation, ITimetableSlotRepository timetableSlots, - PeriodScheduleService periodSchedule, AttendanceBalanceService attendanceBalance, SchoolYearService sy) + PeriodScheduleService periodSchedule, AttendanceBalanceService attendanceBalance, SchoolYearService sy, + DashboardSettingsService dashboardSettings) { _groups = groups; _subjects = subjects; _lessons = lessons; _exams = exams; _tasks = tasks; + _examResults = examResults; _grades = grades; _reportGrades = reportGrades; _memberships = memberships; _participationSessions = participationSessions; _participationEntries = participationEntries; _students = students; _documentation = documentation; _timetableSlots = timetableSlots; _periodSchedule = periodSchedule; - _attendanceBalance = attendanceBalance; _sy = sy; + _attendanceBalance = attendanceBalance; _sy = sy; _dashboardSettings = dashboardSettings; + LoadDashboardCards(); Load(); } + private DashboardCardOption Card(string key) => DashboardCards.First(c => c.Key == key); + private static DateOnly FirstOfMonth(DateTime d) => new(d.Year, d.Month, 1); private void Load() @@ -123,6 +155,9 @@ public partial class DashboardViewModel : ObservableObject LoadOpenExcuses(groups.Values.ToList(), today); LoadAttendanceWarnings(today); LoadSupportPlanReviews(today); + LoadUpcomingDates(groups, today); + LoadOpenCorrections(groups, today); + LoadAlerts(groups, today); } // ── Fehlzeiten-Warnung (5.2.3) ──────────────────────────────────────────── @@ -173,6 +208,174 @@ public partial class DashboardViewModel : ObservableObject } } + // ── Anstehende Termine (9.3) ───────────────────────────────────────────── + + private void LoadUpcomingDates(IReadOnlyDictionary groups, DateOnly today) + { + UpcomingDates.Clear(); + var dueBy = today.AddDays(UpcomingWithinDays); + var items = new List(); + + foreach (var group in groups.Values) + foreach (var exam in _exams.GetByGroup(group.Id) + .Where(e => e.Date >= today && e.Date <= dueBy && e.Status == ExamStatus.Planned)) + items.Add(new UpcomingDateItem(UpcomingDateKind.Exam, exam.Date, exam.Title, + group.Name, group.Id, null, today)); + + foreach (var task in _tasks.GetByStatus(WorkTaskStatus.Open) + .Concat(_tasks.GetByStatus(WorkTaskStatus.InProgress)) + .Where(t => t.DueDate.HasValue && t.DueDate.Value <= dueBy)) + items.Add(new UpcomingDateItem(UpcomingDateKind.Deadline, task.DueDate!.Value, + task.Title, task.GroupId is Guid groupId && groups.TryGetValue(groupId, out var group) + ? group.Name : "Aufgabe", task.GroupId, null, today)); + + foreach (var doc in _documentation.GetAll() + .Where(d => d.Type == DocumentationType.SupportPlan + && d.SupportData is { Status: SupportStatus.Active, ReviewDate: not null } + && d.SupportData.ReviewDate.Value <= dueBy)) + { + var student = _students.GetById(doc.StudentId); + if (student is not null) + items.Add(new UpcomingDateItem(UpcomingDateKind.SupportPlan, + doc.SupportData!.ReviewDate!.Value, doc.Title, student.FullName, + doc.GroupId, doc.StudentId, today)); + } + + foreach (var item in items.OrderBy(i => i.Date).ThenBy(i => i.Title).Take(8)) + UpcomingDates.Add(item); + } + + // ── Offene Korrekturen (9.4) ───────────────────────────────────────────── + + private void LoadOpenCorrections(IReadOnlyDictionary groups, DateOnly today) + { + OpenCorrections.Clear(); + foreach (var group in groups.Values) + foreach (var exam in _exams.GetByGroup(group.Id) + .Where(e => e.Status is ExamStatus.Conducted or ExamStatus.Graded) + .OrderBy(e => e.Date)) + { + var expected = _memberships.GetByGroup(group.Id) + .Count(m => GroupMembershipService.IsActiveOn(m, exam.Date)); + var evaluated = _examResults.GetByExam(exam.Id) + .Count(r => r.Absent || !string.IsNullOrWhiteSpace(r.Grade) || r.Points.Count > 0); + OpenCorrections.Add(new CorrectionProgressItem(exam.Id, group.Id, exam.Title, + group.Name, exam.Date, Math.Min(evaluated, expected), expected, today)); + } + } + + // ── Auffälligkeiten (9.5) ──────────────────────────────────────────────── + + private void LoadAlerts(IReadOnlyDictionary groups, DateOnly today) + { + Alerts.Clear(); + foreach (var warning in AttendanceWarnings) + Alerts.Add(new DashboardAlertItem(warning.StudentId, null, warning.StudentName, + "Fehlzeiten", $"Fehlzeitenquote {warning.AbsenceRatePercent:0.#} %", AlertSeverity.High)); + + foreach (var group in groups.Values) + { + var groupReportGrades = _reportGrades.GetByGroup(group.Id); + foreach (var membership in _memberships.GetByGroup(group.Id) + .Where(m => GroupMembershipService.IsActiveOn(m, today))) + { + var student = _students.GetById(membership.StudentId); + if (student is null) continue; + var values = _grades.GetByStudentAndGroup(student.Id, group.Id) + .OrderBy(g => g.Date) + .Select(g => int.TryParse(g.Value, out var value) ? (int?)value : null) + .Where(v => v.HasValue).Select(v => v!.Value).ToList(); + + if (values.Count >= 4) + { + var previous = values.TakeLast(4).Take(2).Average(); + var recent = values.TakeLast(2).Average(); + var declined = group.GradingSystem == GradingSystem.Grades1To6 + ? recent - previous >= 1.0 + : previous - recent >= 3.0; + if (declined) + Alerts.Add(new DashboardAlertItem(student.Id, group.Id, student.FullName, + "Notenabfall", $"{group.Name}: zuletzt {recent:0.0}, zuvor {previous:0.0}", + AlertSeverity.Medium)); + } + + var latestReport = groupReportGrades + .Where(r => r.StudentId == student.Id) + .OrderByDescending(r => r.UpdatedAt).FirstOrDefault(); + var effective = latestReport?.OverrideValue ?? latestReport?.CalculatedValue; + if (int.TryParse(effective, out var reportValue) + && (group.GradingSystem == GradingSystem.Grades1To6 ? reportValue >= 5 : reportValue <= 4)) + Alerts.Add(new DashboardAlertItem(student.Id, group.Id, student.FullName, + "Versetzungsgefährdung", $"{group.Name}: aktueller Stand {reportValue}", + AlertSeverity.High)); + } + } + } + + // ── Konfigurierbare Kacheln (9.6) ──────────────────────────────────────── + + private void LoadDashboardCards() + { + DashboardCards.Clear(); + foreach (var setting in _dashboardSettings.Load()) + { + var option = new DashboardCardOption(setting.Key, CardTitle(setting.Key), setting.IsVisible); + option.OnVisibilityChanged = SaveAndApplyCardLayout; + DashboardCards.Add(option); + } + ApplyCardLayout(); + } + + private static string CardTitle(string key) => key switch + { + "today" => "Heute", "tasks" => "Offene Aufgaben", "calendar" => "Kalender", + "excuses" => "Offene Entschuldigungen", "upcoming" => "Anstehende Termine", + "corrections" => "Offene Korrekturen", "alerts" => "Auffälligkeiten", + "attendance" => "Fehlzeiten-Warnung", "support" => "Förderplan-Wiedervorlage", + "groups" => "Meine Lerngruppen", _ => key, + }; + + private void ApplyCardLayout() + { + var visibleIndex = 0; + foreach (var card in DashboardCards) + { + var index = card.IsVisible ? visibleIndex++ : 0; + card.Row = index / 2; + card.Column = index % 2; + } + } + + private void SaveAndApplyCardLayout() + { + ApplyCardLayout(); + _dashboardSettings.Save(DashboardCards.Select((c, i) => new DashboardCardSetting + { Key = c.Key, IsVisible = c.IsVisible, Order = i })); + } + + [RelayCommand] private void ToggleDashboardSettings() => + IsDashboardSettingsOpen = !IsDashboardSettingsOpen; + + [RelayCommand] + private void MoveCardUp(DashboardCardOption? card) + { + if (card is null) return; + var index = DashboardCards.IndexOf(card); + if (index <= 0) return; + DashboardCards.Move(index, index - 1); + SaveAndApplyCardLayout(); + } + + [RelayCommand] + private void MoveCardDown(DashboardCardOption? card) + { + if (card is null) return; + var index = DashboardCards.IndexOf(card); + if (index < 0 || index >= DashboardCards.Count - 1) return; + DashboardCards.Move(index, index + 1); + SaveAndApplyCardLayout(); + } + [RelayCommand] private void OpenStudentAttendance(AttendanceWarningItem? item) { if (item is not null) OnNavigateToStudent?.Invoke(item.StudentId); } @@ -240,8 +443,8 @@ public partial class DashboardViewModel : ObservableObject var agg = Agg(lesson.Date); agg.HasLesson = true; if (g.IsOwnClass) agg.IsOwnClassDay = true; - agg.Details.Add($"Unterricht: {g.Name}" + - (string.IsNullOrWhiteSpace(lesson.Topic) ? "" : $" – {lesson.Topic}")); + agg.Details.Add(new CalendarEventItem(CalendarEventKind.Lesson, lesson.Date, + g.Name, lesson.Topic, g.Id)); } foreach (var exam in _exams.GetByGroup(g.Id).Where(e => e.Date >= gridStart && e.Date <= gridEnd)) @@ -249,7 +452,8 @@ public partial class DashboardViewModel : ObservableObject var agg = Agg(exam.Date); agg.HasExam = true; if (g.IsOwnClass) agg.IsOwnClassDay = true; - agg.Details.Add($"Klausur: {exam.Title} ({g.Name})"); + agg.Details.Add(new CalendarEventItem(CalendarEventKind.Exam, exam.Date, + exam.Title, g.Name, g.Id)); } } @@ -261,6 +465,26 @@ public partial class DashboardViewModel : ObservableObject agg?.HasLesson ?? false, agg?.HasExam ?? false, agg?.IsOwnClassDay ?? false, agg?.Details ?? [])); } + SelectCalendarDay(CalendarDays.FirstOrDefault(d => d.Date == today && d.IsCurrentMonth) + ?? CalendarDays.First(d => d.IsCurrentMonth)); + } + + [RelayCommand] + private void SelectCalendarDay(CalendarDayCell? day) + { + if (day is null) return; + foreach (var cell in CalendarDays) cell.IsSelected = cell == day; + SelectedDayLabel = day.Date.ToString("dddd, d. MMMM", De); + SelectedDayEvents.Clear(); + foreach (var item in day.Events) SelectedDayEvents.Add(item); + } + + [RelayCommand] + private void OpenCalendarEvent(CalendarEventItem? item) + { + if (item is null) return; + if (item.Kind == CalendarEventKind.Exam) OnNavigateToExam?.Invoke(item.GroupId); + else OnNavigateToLesson?.Invoke(item.GroupId); } [RelayCommand] @@ -289,6 +513,19 @@ public partial class DashboardViewModel : ObservableObject [RelayCommand] private void OpenGroup(GroupChip? c) { if (c is not null) OnNavigateToGroup?.Invoke(c.GroupId); } [RelayCommand] private void OpenLesson(LessonItem? l) { if (l is not null) OnNavigateToLesson?.Invoke(l.GroupId); } + [RelayCommand] private void OpenUpcomingDate(UpcomingDateItem? item) + { + if (item?.StudentId is Guid studentId) OnNavigateToStudent?.Invoke(studentId); + else if (item?.GroupId is Guid groupId) + { + if (item.Kind == UpcomingDateKind.Exam) OnNavigateToExam?.Invoke(groupId); + else OnNavigateToGroup?.Invoke(groupId); + } + } + [RelayCommand] private void OpenCorrection(CorrectionProgressItem? item) + { if (item is not null) OnNavigateToExam?.Invoke(item.GroupId); } + [RelayCommand] private void OpenAlert(DashboardAlertItem? item) + { if (item is not null) OnNavigateToStudent?.Invoke(item.StudentId); } [RelayCommand] private void Refresh() => Load(); private class DayAgg @@ -296,7 +533,7 @@ public partial class DashboardViewModel : ObservableObject public bool HasLesson; public bool HasExam; public bool IsOwnClassDay; - public List Details { get; } = []; + public List Details { get; } = []; } } @@ -369,8 +606,10 @@ public class SupportPlanDueItem } } -public class CalendarDayCell +public partial class CalendarDayCell : ObservableObject { + [ObservableProperty] private bool _isSelected; + public DateOnly Date { get; } public int DayNumber { get; } public bool IsCurrentMonth { get; } public bool IsToday { get; } @@ -378,16 +617,103 @@ public class CalendarDayCell public bool HasExam { get; } public bool IsOwnClassDay { get; } public string Tooltip { get; } + public IReadOnlyList Events { get; } internal CalendarDayCell(DateOnly date, bool isCurrentMonth, bool isToday, - bool hasLesson, bool hasExam, bool isOwnClassDay, List details) + bool hasLesson, bool hasExam, bool isOwnClassDay, List details) { + Date = date; DayNumber = date.Day; IsCurrentMonth = isCurrentMonth; IsToday = isToday; HasLesson = hasLesson; HasExam = hasExam; IsOwnClassDay = isOwnClassDay; - Tooltip = details.Count == 0 ? date.ToString("dd.MM.yyyy") : string.Join("\n", details); + Events = details; + Tooltip = details.Count == 0 ? date.ToString("dd.MM.yyyy") + : string.Join("\n", details.Select(d => $"{d.KindLabel}: {d.Title}")); } } + +public enum CalendarEventKind { Lesson, Exam } + +public sealed class CalendarEventItem(CalendarEventKind kind, DateOnly date, string title, + string subtitle, Guid groupId) +{ + public CalendarEventKind Kind { get; } = kind; + public DateOnly Date { get; } = date; + public string Title { get; } = title; + public string Subtitle { get; } = subtitle; + public Guid GroupId { get; } = groupId; + public string KindLabel => Kind == CalendarEventKind.Exam ? "Klausur" : "Unterricht"; +} + +public enum UpcomingDateKind { Exam, SupportPlan, Deadline } + +public sealed class UpcomingDateItem(UpcomingDateKind kind, DateOnly date, string title, + string subtitle, Guid? groupId, Guid? studentId, DateOnly today) +{ + public UpcomingDateKind Kind { get; } = kind; + public DateOnly Date { get; } = date; + public string Title { get; } = title; + public string Subtitle { get; } = subtitle; + public Guid? GroupId { get; } = groupId; + public Guid? StudentId { get; } = studentId; + public bool IsOverdue { get; } = date < today; + public string DateDisplay => Date.ToString("dd.MM."); + public string KindLabel => Kind switch + { + UpcomingDateKind.Exam => "Klausur", + UpcomingDateKind.SupportPlan => "Förderplan", + _ => "Frist", + }; +} + +public sealed class CorrectionProgressItem(Guid examId, Guid groupId, string title, string groupName, + DateOnly date, int completed, int total, DateOnly today) +{ + public Guid ExamId { get; } = examId; + public Guid GroupId { get; } = groupId; + public string Title { get; } = title; + public string GroupName { get; } = groupName; + public DateOnly Date { get; } = date; + public int Completed { get; } = completed; + public int Total { get; } = total; + public int Percent => Total == 0 ? 0 : (int)Math.Round(Completed * 100.0 / Total); + public string ProgressDisplay => $"{Completed} von {Total} Arbeiten bewertet"; + public string DateDisplay => Date.ToString("dd.MM.yyyy"); + public bool IsOverdue => Date < today.AddDays(-7) && Completed < Total; +} + +public enum AlertSeverity { Medium, High } + +public sealed class DashboardAlertItem(Guid studentId, Guid? groupId, string studentName, + string kindLabel, string detail, AlertSeverity severity) +{ + public Guid StudentId { get; } = studentId; + public Guid? GroupId { get; } = groupId; + public string StudentName { get; } = studentName; + public string KindLabel { get; } = kindLabel; + public string Detail { get; } = detail; + public AlertSeverity Severity { get; } = severity; + public string SeverityColor => Severity == AlertSeverity.High ? "#D32F2F" : "#F59E0B"; +} + +public partial class DashboardCardOption : ObservableObject +{ + [ObservableProperty] private bool _isVisible; + [ObservableProperty] private int _row; + [ObservableProperty] private int _column; + public string Key { get; } + public string Title { get; } + public Action? OnVisibilityChanged { get; set; } + + public DashboardCardOption(string key, string title, bool isVisible) + { + Key = key; + Title = title; + _isVisible = isVisible; + } + + partial void OnIsVisibleChanged(bool value) => OnVisibilityChanged?.Invoke(); +} diff --git a/LehrerApp.Desktop/Views/Dashboard/DashboardView.axaml b/LehrerApp.Desktop/Views/Dashboard/DashboardView.axaml index affe294..1c70d9c 100644 --- a/LehrerApp.Desktop/Views/Dashboard/DashboardView.axaml +++ b/LehrerApp.Desktop/Views/Dashboard/DashboardView.axaml @@ -9,21 +9,56 @@ + - - - - + + + + + + @@ -183,11 +224,35 @@ + + + + + + + + + + + - @@ -220,7 +285,8 @@ - @@ -249,7 +315,8 @@ - @@ -278,8 +345,113 @@ - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/LehrerApp.Desktop/Views/Groups/GroupListView.axaml b/LehrerApp.Desktop/Views/Groups/GroupListView.axaml index 74c6853..fbd32a6 100644 --- a/LehrerApp.Desktop/Views/Groups/GroupListView.axaml +++ b/LehrerApp.Desktop/Views/Groups/GroupListView.axaml @@ -117,31 +117,36 @@ Padding="12,9" Command="{Binding NavigateToSectionCommand}" CommandParameter="1"/> +