From 9b04d7eb98fc24f3c397f9d80e0af15f31b420ac Mon Sep 17 00:00:00 2001 From: Sebastian Hedtrich Date: Sat, 29 Aug 2026 00:45:19 +0200 Subject: [PATCH] UX-Update 2.0, Neues Dashboard --- .../DashboardViewModelTests.cs | 16 ++ .../GlobalSearchViewModelTests.cs | 74 +++++ .../GroupListViewModelTests.cs | 83 +++--- LehrerApp.Desktop/App.axaml.cs | 32 +++ LehrerApp.Desktop/AppBootstrapper.cs | 1 + .../ViewModels/DashboardViewModel.cs | 48 +++- .../ViewModels/GlobalSearchViewModel.cs | 194 ++++++++++++++ .../ViewModels/Groups/GroupViewModels.cs | 163 ++++------- .../ViewModels/MainWindowViewModel.cs | 18 +- .../Views/Dashboard/DashboardView.axaml | 72 +++-- .../Views/Groups/GroupListView.axaml | 252 ++++++------------ LehrerApp.Desktop/Views/MainWindow.axaml | 85 ++++++ LehrerApp.Desktop/Views/MainWindow.axaml.cs | 57 ++++ TODO.md | 34 ++- 14 files changed, 774 insertions(+), 355 deletions(-) create mode 100644 LehrerApp.Desktop.Tests/GlobalSearchViewModelTests.cs create mode 100644 LehrerApp.Desktop/ViewModels/GlobalSearchViewModel.cs diff --git a/LehrerApp.Desktop.Tests/DashboardViewModelTests.cs b/LehrerApp.Desktop.Tests/DashboardViewModelTests.cs index c9d089c..dbedb49 100644 --- a/LehrerApp.Desktop.Tests/DashboardViewModelTests.cs +++ b/LehrerApp.Desktop.Tests/DashboardViewModelTests.cs @@ -11,6 +11,22 @@ namespace LehrerApp.Desktop.Tests; /// (offene Aufgaben, ...). public sealed class DashboardViewModelTests { + [Fact] + public void LeereHinweisbereiche_WerdenAusgeblendetUndZusammenfassungBleibtKompakt() + { + var group = new LearningGroup { Name = "9c" }; + var today = DateOnly.FromDateTime(DateTime.Today); + + var vm = BuildVm(group, new Lesson { GroupId = group.Id, Date = today }); + + Assert.False(vm.ExcusesCard.EffectiveIsVisible); + Assert.False(vm.CorrectionsCard.EffectiveIsVisible); + Assert.False(vm.AlertsCard.EffectiveIsVisible); + Assert.Equal("0 offene Punkte", vm.AttentionSummary); + Assert.True(vm.TodayCard.EffectiveIsVisible); + Assert.True(vm.CalendarCard.EffectiveIsVisible); + } + private static PeriodScheduleService NewPeriodSchedule() { var tempPath = System.IO.Path.Combine( diff --git a/LehrerApp.Desktop.Tests/GlobalSearchViewModelTests.cs b/LehrerApp.Desktop.Tests/GlobalSearchViewModelTests.cs new file mode 100644 index 0000000..d215a94 --- /dev/null +++ b/LehrerApp.Desktop.Tests/GlobalSearchViewModelTests.cs @@ -0,0 +1,74 @@ +using LehrerApp.Core.Models; +using LehrerApp.Desktop.ViewModels; +using Xunit; + +namespace LehrerApp.Desktop.Tests; + +public sealed class GlobalSearchViewModelTests +{ + [Fact] + public void LeereSuche_ZeigtSchnellaktionen() + { + var vm = BuildVm(); + + Assert.Collection(vm.Results, + item => Assert.Equal(GlobalSearchAction.NewTask, item.Action), + item => Assert.Equal(GlobalSearchAction.NewReminder, item.Action), + item => Assert.Equal(GlobalSearchAction.NewStudent, item.Action)); + Assert.Same(vm.Results[0], vm.SelectedResult); + } + + [Fact] + public void Suche_FindetSchuelerGruppeKlausurUndAufgabe() + { + var group = new LearningGroup { Name = "9 Chemie", SchoolYear = "2026/27", GradeLevel = 9 }; + var student = new Student { FirstName = "Mia", LastName = "Chemie" }; + var exam = new Exam { GroupId = group.Id, Title = "Chemie-Test", Date = new DateOnly(2026, 9, 1) }; + var tasks = new FakeWorkTasks(); + tasks.Add(new WorkTask { GroupId = group.Id, Title = "Chemie-Klausur korrigieren" }); + var vm = BuildVm([student], [group], [exam], tasks); + + vm.Query = "Chemie"; + + Assert.Contains(vm.Results, x => x.Kind == GlobalSearchResultKind.Student && x.EntityId == student.Id); + Assert.Contains(vm.Results, x => x.Kind == GlobalSearchResultKind.Group && x.EntityId == group.Id); + Assert.Contains(vm.Results, x => x.Kind == GlobalSearchResultKind.Exam && x.EntityId == exam.Id); + Assert.Contains(vm.Results, x => x.Kind == GlobalSearchResultKind.Task && x.Title.Contains("korrigieren")); + } + + [Fact] + public async Task TrefferAusfuehren_NavigiertUndSchliesstPalette() + { + var student = new Student { FirstName = "Mia", LastName = "Muster" }; + var vm = BuildVm([student]); + vm.Query = "Muster"; + var result = Assert.Single(vm.Results, x => x.Kind == GlobalSearchResultKind.Student); + GlobalSearchResult? navigated = null; + var closed = false; + vm.OnNavigate = x => navigated = x; + vm.OnClose = () => closed = true; + + await vm.ExecuteCommand.ExecuteAsync(result); + + Assert.Same(result, navigated); + Assert.True(closed); + } + + [Fact] + public async Task ErinnerungSchnellErfassen_StartetDialogAlsErinnerung() + { + var vm = BuildVm(); + bool? reminder = null; + vm.OnQuickAddTask = value => { reminder = value; return Task.CompletedTask; }; + var action = vm.Results.Single(x => x.Action == GlobalSearchAction.NewReminder); + + await vm.ExecuteCommand.ExecuteAsync(action); + + Assert.True(reminder); + } + + private static GlobalSearchViewModel BuildVm(List? students = null, + List? groups = null, List? exams = null, FakeWorkTasks? tasks = null) => + new(new FakeStudents(students ?? []), new FakeGroups(groups ?? []), + new FakeExams(exams ?? []), tasks ?? new FakeWorkTasks()); +} diff --git a/LehrerApp.Desktop.Tests/GroupListViewModelTests.cs b/LehrerApp.Desktop.Tests/GroupListViewModelTests.cs index fb50301..d92ec09 100644 --- a/LehrerApp.Desktop.Tests/GroupListViewModelTests.cs +++ b/LehrerApp.Desktop.Tests/GroupListViewModelTests.cs @@ -5,76 +5,69 @@ using Xunit; namespace LehrerApp.Desktop.Tests; -/// Tests für den Schnellüberblick im Auswahl-Panel der Gruppenliste (Nutzer-Feedback: -/// "oberhalb der Buttonliste ein paar Daten auswerfen. Nächste Stunde, nächste Arbeit, -/// wichtige Todos"). +/// Tests für die direkte, einlagige Lerngruppen-Navigation. public sealed class GroupListViewModelTests { - private static GroupListViewModel BuildVm(LearningGroup group, FakeLessons? lessons = null, - FakeExams? exams = null, FakeWorkTasks? tasks = null) => - new(new FakeGroups([group]), new FakeSubjects([]), new SchoolYearService(), - lessons ?? new FakeLessons(), exams ?? new FakeExams([]), tasks ?? new FakeWorkTasks()); + private static GroupListViewModel BuildVm(params LearningGroup[] groups) => + new(new FakeGroups([.. groups]), new FakeSubjects([]), new SchoolYearService()); [Fact] - public void SelectedGroup_OhneDatenZeigtKeinenSchnellueberblick() + public void Gruppenkarte_OeffnetDirektDieUebersicht() { - var group = new LearningGroup { Name = "9c" }; + var group = CurrentGroup("9c"); var vm = BuildVm(group); + Guid? openedId = null; + int? openedTab = null; + vm.OnNavigateToDetail = (id, tab) => { openedId = id; openedTab = tab; }; - vm.SelectedGroup = vm.Groups.Count > 0 ? vm.Groups[0] : new GroupListItem(group, ""); + Assert.Single(vm.Groups).OpenCommand.Execute(null); - Assert.False(vm.QuickHasAnything); + Assert.Equal(group.Id, openedId); + Assert.Equal(0, openedTab); } [Fact] - public void SelectedGroup_ZeigtNaechsteGeplanteStundeUndKlausur() + public async Task Verwaltungsmenue_BearbeitetDieGewaehlteKarteOhneZwischenauswahl() { - var group = new LearningGroup { Name = "9c" }; - var today = DateOnly.FromDateTime(DateTime.Today); - var lessons = new FakeLessons(); - lessons.Add(new Lesson { GroupId = group.Id, Date = today.AddDays(3), Topic = "Redox", Status = LessonStatus.Planned }); - var exams = new FakeExams([new Exam { GroupId = group.Id, Date = today.AddDays(10), Title = "Klausur 1" }]); - var vm = BuildVm(group, lessons: lessons, exams: exams); + var first = CurrentGroup("9a"); + var second = CurrentGroup("9b"); + var vm = BuildVm(first, second); + Guid? editedId = null; + vm.OnEditGroup = id => { editedId = id; return Task.CompletedTask; }; + var secondItem = vm.Groups.Single(x => x.Id == second.Id); - vm.SelectedGroup = new GroupListItem(group, ""); + await secondItem.EditCommand.ExecuteAsync(null); - Assert.True(vm.QuickHasNextLesson); - Assert.Contains("Redox", vm.QuickNextLessonLabel); - Assert.True(vm.QuickHasNextExam); - Assert.Contains("Klausur 1", vm.QuickNextExamLabel); - Assert.True(vm.QuickHasAnything); + Assert.Equal(second.Id, editedId); } [Fact] - public void SelectedGroup_ZeigtOffeneAufgabenDieserGruppeSortiertNachFaelligkeit() + public void Suche_FiltertWeiterhinNachGruppenname() { - var group = new LearningGroup { Name = "9c" }; - var today = DateOnly.FromDateTime(DateTime.Today); - var tasks = new FakeWorkTasks(); - tasks.Add(new WorkTask { GroupId = group.Id, Title = "Bald fällig", DueDate = today.AddDays(1), Status = WorkTaskStatus.Open }); - tasks.Add(new WorkTask { GroupId = group.Id, Title = "Erledigt", DueDate = today, Status = WorkTaskStatus.Done }); - tasks.Add(new WorkTask { GroupId = Guid.NewGuid(), Title = "Andere Gruppe", DueDate = today, Status = WorkTaskStatus.Open }); - var vm = BuildVm(group, tasks: tasks); + var vm = BuildVm(CurrentGroup("9 Chemie"), CurrentGroup("10 Mathematik")); - vm.SelectedGroup = new GroupListItem(group, ""); + vm.SearchText = "Chemie"; - Assert.True(vm.QuickHasTasks); - Assert.Equal("Bald fällig", Assert.Single(vm.QuickTasks).Title); + Assert.Equal("9 Chemie", Assert.Single(vm.Groups).Name); } [Fact] - public void SelectedGroup_Zuruecksetzen_LeertSchnellueberblick() + public void Archivieren_WirktAufDieKarteUndEntferntSieAusDerAktivenListe() { - var group = new LearningGroup { Name = "9c" }; - var today = DateOnly.FromDateTime(DateTime.Today); - var exams = new FakeExams([new Exam { GroupId = group.Id, Date = today.AddDays(10), Title = "Klausur 1" }]); - var vm = BuildVm(group, exams: exams); - vm.SelectedGroup = new GroupListItem(group, ""); - Assert.True(vm.QuickHasAnything); + var group = CurrentGroup("9c"); + var vm = BuildVm(group); + var item = Assert.Single(vm.Groups); - vm.SelectedGroup = null; + item.ToggleArchiveCommand.Execute(null); - Assert.False(vm.QuickHasAnything); - Assert.Empty(vm.QuickTasks); + Assert.False(group.IsActive); + Assert.Empty(vm.Groups); } + + private static LearningGroup CurrentGroup(string name) => new() + { + Name = name, + SchoolYear = new SchoolYearService().CurrentSchoolYear(), + IsActive = true, + }; } diff --git a/LehrerApp.Desktop/App.axaml.cs b/LehrerApp.Desktop/App.axaml.cs index 696bfe6..1c34c86 100644 --- a/LehrerApp.Desktop/App.axaml.cs +++ b/LehrerApp.Desktop/App.axaml.cs @@ -10,7 +10,9 @@ using LehrerApp.Desktop.ViewModels; using LehrerApp.Desktop.ViewModels.Groups; using LehrerApp.Desktop.ViewModels.Planning; using LehrerApp.Desktop.ViewModels.Students; +using LehrerApp.Desktop.ViewModels.Workload; using LehrerApp.Desktop.Views; +using LehrerApp.Desktop.Views.Workload; using LehrerApp.Sync; using Microsoft.Extensions.DependencyInjection; @@ -152,6 +154,23 @@ public class App : Application dash.OnNavigateToExam = id => main.NavigateToGroupDetail(id, 4); // Tab "Klausuren" dash.OnNavigateToUnplannedLesson = id => main.NavigateToGroupDetail(id, 6); // Tab "Planung" + // Globale Suche/Schnellerfassung (14.2): Navigation bleibt im MainWindow-VM, die + // vorhandenen Dialog-Helfer übernehmen Eingabe und Validierung. + var search = Services.GetRequiredService(); + search.OnNavigate = result => + { + if (result.Kind == GlobalSearchResultKind.Student && result.EntityId is { } studentId) + main.NavigateToStudent(studentId); + else if (result.Kind == GlobalSearchResultKind.Group && result.GroupId is { } groupId) + main.NavigateToGroupDetail(groupId); + else if (result.Kind == GlobalSearchResultKind.Exam && result.GroupId is { } examGroupId) + main.NavigateToGroupDetail(examGroupId, 4); + else if (result.Kind == GlobalSearchResultKind.Task) + main.NavigateToWorkload(); + }; + search.OnQuickAddTask = startAsReminder => ShowQuickTaskDialog(startAsReminder, dash); + search.OnQuickAddStudent = ShowAddStudentDialog; + // StudentList → StudentDetail + Anlegen var sl = Services.GetRequiredService(); sl.OnNavigateToDetail = id => main.NavigateToStudent(id); @@ -171,4 +190,17 @@ public class App : Application if (Application.Current?.ApplicationLifetime is IClassicDesktopStyleApplicationLifetime { MainWindow: { } owner }) await dialog.ShowDialog(owner); } + + private static async Task ShowQuickTaskDialog(bool startAsReminder, DashboardViewModel dashboard) + { + if (Application.Current?.ApplicationLifetime is not IClassicDesktopStyleApplicationLifetime + { MainWindow: { } owner }) return; + + var result = await WorkTaskDialogHelper.ShowDialog(owner, startAsReminder: startAsReminder); + if (result is null) return; + + Services.GetRequiredService().Save(result); + dashboard.RefreshCommand.Execute(null); + Services.GetRequiredService().Load(); + } } diff --git a/LehrerApp.Desktop/AppBootstrapper.cs b/LehrerApp.Desktop/AppBootstrapper.cs index add621c..f4f046a 100644 --- a/LehrerApp.Desktop/AppBootstrapper.cs +++ b/LehrerApp.Desktop/AppBootstrapper.cs @@ -291,6 +291,7 @@ public static class AppBootstrapper // Singleton: einmal erstellt, überall dieselbe Instanz services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(sp => new SyncStatusViewModel( diff --git a/LehrerApp.Desktop/ViewModels/DashboardViewModel.cs b/LehrerApp.Desktop/ViewModels/DashboardViewModel.cs index 5ac6cae..22eb15d 100644 --- a/LehrerApp.Desktop/ViewModels/DashboardViewModel.cs +++ b/LehrerApp.Desktop/ViewModels/DashboardViewModel.cs @@ -107,6 +107,15 @@ public partial class DashboardViewModel : ObservableObject public DashboardCardOption AttendanceCard => Card("attendance"); public DashboardCardOption SupportCard => Card("support"); public DashboardCardOption GroupsCard => Card("groups"); + public int TodayLessonCount => TodaysLessons.Count; + public int OpenTaskCount => OpenTasks.Count; + public int UpcomingCount => UpcomingDates.Count; + public int AttentionCount => OpenExcuses.Count + AttendanceWarnings.Count + SupportPlanReviews.Count + + OpenCorrections.Count + UnplannedLessons.Count + Alerts.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"; + public string UpcomingSummary => UpcomingCount == 1 ? "1 Termin" : $"{UpcomingCount} Termine"; public DashboardViewModel(IGroupRepository groups, ISubjectRepository subjects, ILessonRepository lessons, IExamRepository exams, IExamResultRepository examResults, IGradeRepository grades, @@ -204,6 +213,31 @@ public partial class DashboardViewModel : ObservableObject LoadOpenCorrections(groups, today); LoadUnplannedLessons(groups, today); LoadAlerts(groups, today); + UpdateDashboardSummary(); + } + + private void UpdateDashboardSummary() + { + TodayCard.IsEmpty = TodaysLessons.Count == 0; + TasksCard.IsEmpty = OpenTasks.Count == 0; + CalendarCard.IsEmpty = false; + ExcusesCard.IsEmpty = OpenExcuses.Count == 0; + UpcomingCard.IsEmpty = UpcomingDates.Count == 0; + CorrectionsCard.IsEmpty = OpenCorrections.Count == 0; + UnplannedCard.IsEmpty = UnplannedLessons.Count == 0; + AlertsCard.IsEmpty = Alerts.Count == 0; + AttendanceCard.IsEmpty = AttendanceWarnings.Count == 0; + SupportCard.IsEmpty = SupportPlanReviews.Count == 0; + GroupsCard.IsEmpty = CurrentGroups.Count == 0; + + OnPropertyChanged(nameof(TodayLessonCount)); + OnPropertyChanged(nameof(OpenTaskCount)); + OnPropertyChanged(nameof(UpcomingCount)); + OnPropertyChanged(nameof(AttentionCount)); + OnPropertyChanged(nameof(TodayLessonSummary)); + OnPropertyChanged(nameof(OpenTaskSummary)); + OnPropertyChanged(nameof(AttentionSummary)); + OnPropertyChanged(nameof(UpcomingSummary)); } private async Task LoadWeatherAsync() @@ -592,6 +626,7 @@ public partial class DashboardViewModel : ObservableObject entry.Attendance = status; _participationEntries.Save(entry); OpenExcuses.Remove(item); + UpdateDashboardSummary(); } private void LoadCalendar() @@ -1034,18 +1069,29 @@ public sealed class DashboardAlertItem(Guid studentId, Guid? groupId, string stu public partial class DashboardCardOption : ObservableObject { [ObservableProperty] private bool _isVisible; + [ObservableProperty] private bool _isEmpty; [ObservableProperty] private int _row; [ObservableProperty] private int _column; public string Key { get; } public string Title { get; } + public bool HideWhenEmpty { get; } + public bool EffectiveIsVisible => IsVisible && (!HideWhenEmpty || !IsEmpty); public Action? OnVisibilityChanged { get; set; } public DashboardCardOption(string key, string title, bool isVisible) { Key = key; Title = title; + HideWhenEmpty = key is "excuses" or "upcoming" or "corrections" or "unplanned" + or "alerts" or "attendance" or "support"; _isVisible = isVisible; } - partial void OnIsVisibleChanged(bool value) => OnVisibilityChanged?.Invoke(); + partial void OnIsVisibleChanged(bool value) + { + OnPropertyChanged(nameof(EffectiveIsVisible)); + OnVisibilityChanged?.Invoke(); + } + + partial void OnIsEmptyChanged(bool value) => OnPropertyChanged(nameof(EffectiveIsVisible)); } diff --git a/LehrerApp.Desktop/ViewModels/GlobalSearchViewModel.cs b/LehrerApp.Desktop/ViewModels/GlobalSearchViewModel.cs new file mode 100644 index 0000000..21a5350 --- /dev/null +++ b/LehrerApp.Desktop/ViewModels/GlobalSearchViewModel.cs @@ -0,0 +1,194 @@ +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using LehrerApp.Core.Interfaces; +using LehrerApp.Core.Models; +using System.Collections.ObjectModel; + +namespace LehrerApp.Desktop.ViewModels; + +/// +/// Globale Suche und Schnellerfassung (14.2). Die Projektion bleibt bewusst klein und lokal: +/// durchsucht werden die wichtigsten täglichen Ziele, ohne dafür einen zusätzlichen Suchindex +/// oder eine Netzwerkabhängigkeit einzuführen. +/// +public partial class GlobalSearchViewModel : ObservableObject +{ + private const int MaxDataResults = 12; + + private readonly IStudentRepository _students; + private readonly IGroupRepository _groups; + private readonly IExamRepository _exams; + private readonly IWorkTaskRepository _tasks; + + [ObservableProperty] private string _query = ""; + [ObservableProperty] private GlobalSearchResult? _selectedResult; + + public ObservableCollection Results { get; } = []; + public bool HasResults => Results.Count > 0; + public bool ShowNoResults => !string.IsNullOrWhiteSpace(Query) && Results.Count == 0; + + public Action? OnNavigate { get; set; } + public Func? OnQuickAddTask { get; set; } + public Func? OnQuickAddStudent { get; set; } + public Action? OnClose { get; set; } + + public GlobalSearchViewModel(IStudentRepository students, IGroupRepository groups, + IExamRepository exams, IWorkTaskRepository tasks) + { + _students = students; + _groups = groups; + _exams = exams; + _tasks = tasks; + RefreshResults(); + } + + partial void OnQueryChanged(string value) => RefreshResults(); + + public void Reset() + { + Query = ""; + RefreshResults(); + } + + private void RefreshResults() + { + Results.Clear(); + var query = Query.Trim(); + + AddQuickActions(query); + + if (query.Length > 0) + { + var groups = _groups.GetAll(includeInactive: true); + var groupNames = groups.ToDictionary(g => g.Id, g => g.Name); + var candidates = new List(); + + candidates.AddRange(_students.GetAll(includeInactive: true) + .Where(s => Matches(s.FullName, query)) + .Select(s => GlobalSearchResult.ForStudent(s))); + + candidates.AddRange(groups + .Where(g => Matches($"{g.Name} {g.SchoolYear} {g.GradeLevel}", query)) + .Select(GlobalSearchResult.ForGroup)); + + candidates.AddRange(_exams.GetAll() + .Where(e => Matches($"{e.Title} {groupNames.GetValueOrDefault(e.GroupId)}", query)) + .Select(e => GlobalSearchResult.ForExam(e, groupNames.GetValueOrDefault(e.GroupId) ?? ""))); + + candidates.AddRange(_tasks.GetAll() + .Where(t => Matches($"{t.Title} {t.Notes} {groupNames.GetValueOrDefault(t.GroupId ?? Guid.Empty)}", query)) + .Select(t => GlobalSearchResult.ForTask(t, groupNames.GetValueOrDefault(t.GroupId ?? Guid.Empty) ?? ""))); + + foreach (var item in candidates + .OrderByDescending(x => x.Title.StartsWith(query, StringComparison.CurrentCultureIgnoreCase)) + .ThenBy(x => x.KindSortOrder) + .ThenBy(x => x.Title) + .Take(MaxDataResults)) + Results.Add(item); + } + + SelectedResult = Results.FirstOrDefault(); + OnPropertyChanged(nameof(HasResults)); + OnPropertyChanged(nameof(ShowNoResults)); + } + + private void AddQuickActions(string query) + { + var actions = new[] + { + GlobalSearchResult.ForAction(GlobalSearchAction.NewTask, "Aufgabe anlegen", "Mit Fälligkeit, Gruppe und Priorität", "+"), + GlobalSearchResult.ForAction(GlobalSearchAction.NewReminder, "Erinnerung anlegen", "Kurze Notiz ohne Zeiterfassung", "◷"), + GlobalSearchResult.ForAction(GlobalSearchAction.NewStudent, "Schüler anlegen", "Neue Stammdaten erfassen", "+"), + }; + + foreach (var action in actions.Where(a => query.Length == 0 || Matches(a.Title, query))) + Results.Add(action); + } + + private static bool Matches(string? value, string query) => + value?.Contains(query, StringComparison.CurrentCultureIgnoreCase) == true; + + [RelayCommand] + private async Task Execute(GlobalSearchResult? result) + { + if (result is null) return; + + switch (result.Action) + { + case GlobalSearchAction.NewTask: + if (OnQuickAddTask is not null) await OnQuickAddTask(false); + break; + case GlobalSearchAction.NewReminder: + if (OnQuickAddTask is not null) await OnQuickAddTask(true); + break; + case GlobalSearchAction.NewStudent: + if (OnQuickAddStudent is not null) await OnQuickAddStudent(); + break; + default: + OnNavigate?.Invoke(result); + break; + } + + OnClose?.Invoke(); + } +} + +public enum GlobalSearchResultKind { Action, Student, Group, Exam, Task } +public enum GlobalSearchAction { None, NewTask, NewReminder, NewStudent } + +public sealed class GlobalSearchResult +{ + public GlobalSearchResultKind Kind { get; private init; } + public GlobalSearchAction Action { get; private init; } + public Guid? EntityId { get; private init; } + public Guid? GroupId { get; private init; } + public string Title { get; private init; } = ""; + public string Subtitle { get; private init; } = ""; + public string Icon { get; private init; } = ""; + public int KindSortOrder => Kind switch + { + GlobalSearchResultKind.Student => 0, + GlobalSearchResultKind.Group => 1, + GlobalSearchResultKind.Exam => 2, + GlobalSearchResultKind.Task => 3, + _ => -1, + }; + public string KindLabel => Kind switch + { + GlobalSearchResultKind.Student => "Schüler", + GlobalSearchResultKind.Group => "Lerngruppe", + GlobalSearchResultKind.Exam => "Klausur", + GlobalSearchResultKind.Task => "Aufgabe", + _ => "Schnellaktion", + }; + + public static GlobalSearchResult ForAction(GlobalSearchAction action, string title, string subtitle, string icon) => + new() { Kind = GlobalSearchResultKind.Action, Action = action, Title = title, Subtitle = subtitle, Icon = icon }; + + public static GlobalSearchResult ForStudent(Student student) => new() + { + Kind = GlobalSearchResultKind.Student, EntityId = student.Id, + Title = student.FullName, Subtitle = student.IsActive ? "Aktiv" : "Inaktiv", Icon = "P", + }; + + public static GlobalSearchResult ForGroup(LearningGroup group) => new() + { + Kind = GlobalSearchResultKind.Group, EntityId = group.Id, GroupId = group.Id, + Title = group.Name, Subtitle = $"{group.SchoolYear} · Stufe {group.GradeLevel}", Icon = "G", + }; + + public static GlobalSearchResult ForExam(Exam exam, string groupName) => new() + { + Kind = GlobalSearchResultKind.Exam, EntityId = exam.Id, GroupId = exam.GroupId, + Title = exam.Title, Subtitle = $"{groupName} · {exam.Date:dd.MM.yyyy}", Icon = "K", + }; + + public static GlobalSearchResult ForTask(WorkTask task, string groupName) => new() + { + Kind = GlobalSearchResultKind.Task, EntityId = task.Id, GroupId = task.GroupId, + Title = task.Title, + Subtitle = string.Join(" · ", new[] { groupName, task.DueDate?.ToString("dd.MM.yyyy") ?? "" } + .Where(x => !string.IsNullOrWhiteSpace(x))), + Icon = task.Kind == TaskKind.Reminder ? "E" : "A", + }; +} diff --git a/LehrerApp.Desktop/ViewModels/Groups/GroupViewModels.cs b/LehrerApp.Desktop/ViewModels/Groups/GroupViewModels.cs index 265dad6..54a6b7b 100644 --- a/LehrerApp.Desktop/ViewModels/Groups/GroupViewModels.cs +++ b/LehrerApp.Desktop/ViewModels/Groups/GroupViewModels.cs @@ -13,13 +13,8 @@ namespace LehrerApp.Desktop.ViewModels.Groups; public partial class GroupListViewModel : ObservableObject { - private const int QuickTasksMaxCount = 3; - private readonly IGroupRepository _groups; private readonly ISubjectRepository _subjects; - private readonly ILessonRepository _lessons; - private readonly IExamRepository _exams; - private readonly IWorkTaskRepository _tasks; public Action? OnNavigateToDetail { get; set; } public Func? OnAddGroup { get; set; } @@ -29,11 +24,8 @@ public partial class GroupListViewModel : ObservableObject [ObservableProperty] private string _selectedSchoolYear = ""; [ObservableProperty] private string _searchText = ""; - [ObservableProperty] private GroupListItem? _selectedGroup; [ObservableProperty] private bool _showArchived; - public string SelectedGroupDisplayName => SelectedGroup?.DisplayName ?? ""; - public string SelectedGroupSubtitle => SelectedGroup?.Subtitle ?? ""; public string ListSummary => ShowArchived ? $"{Groups.Count} archivierte Gruppen · {SelectedSchoolYear}" : $"{Groups.Count} aktive Gruppen · {SelectedSchoolYear}"; @@ -45,22 +37,10 @@ public partial class GroupListViewModel : ObservableObject public ObservableCollection SchoolYears { get; } = []; public ObservableCollection Groups { get; } = []; - // ── Schnellüberblick im Auswahl-Panel (Nutzer-Feedback: "oberhalb der Buttonliste ein paar - // Daten auswerfen. Nächste Stunde, nächste Arbeit, wichtige Todos") — bewusst dieselben - // kompakten Kennzahlen wie die obersten Karten des Kurs-Dashboards (GroupOverviewViewModel), - // hier nur ohne eigenen Tab-Wechsel, da man ohnehin schon auf der Gruppenliste steht. - [ObservableProperty] private bool _quickHasNextLesson; - [ObservableProperty] private string _quickNextLessonLabel = ""; - [ObservableProperty] private bool _quickHasNextExam; - [ObservableProperty] private string _quickNextExamLabel = ""; - public ObservableCollection QuickTasks { get; } = []; - public bool QuickHasTasks => QuickTasks.Count > 0; - public bool QuickHasAnything => QuickHasNextLesson || QuickHasNextExam || QuickHasTasks; - - public GroupListViewModel(IGroupRepository groups, ISubjectRepository subjects, SchoolYearService sy, - ILessonRepository lessons, IExamRepository exams, IWorkTaskRepository tasks) + public GroupListViewModel(IGroupRepository groups, ISubjectRepository subjects, SchoolYearService sy) { - _groups = groups; _subjects = subjects; _lessons = lessons; _exams = exams; _tasks = tasks; + _groups = groups; + _subjects = subjects; foreach (var y in sy.RecentSchoolYears()) SchoolYears.Add(y); SelectedSchoolYear = sy.CurrentSchoolYear(); } @@ -68,58 +48,8 @@ public partial class GroupListViewModel : ObservableObject partial void OnSelectedSchoolYearChanged(string value) => LoadGroups(); partial void OnSearchTextChanged(string value) => LoadGroups(); partial void OnShowArchivedChanged(bool value) => LoadGroups(); - partial void OnSelectedGroupChanged(GroupListItem? value) - { - OnPropertyChanged(nameof(SelectedGroupDisplayName)); - OnPropertyChanged(nameof(SelectedGroupSubtitle)); - NavigateToSectionCommand.NotifyCanExecuteChanged(); - EditGroupCommand.NotifyCanExecuteChanged(); - RollOverGroupCommand.NotifyCanExecuteChanged(); - ToggleArchiveCommand.NotifyCanExecuteChanged(); - DeleteGroupCommand.NotifyCanExecuteChanged(); - LoadQuickInfo(); - } - - private void LoadQuickInfo() - { - QuickTasks.Clear(); - if (SelectedGroup is null) - { - QuickHasNextLesson = false; QuickNextLessonLabel = ""; - QuickHasNextExam = false; QuickNextExamLabel = ""; - OnPropertyChanged(nameof(QuickHasTasks)); - OnPropertyChanged(nameof(QuickHasAnything)); - return; - } - - var groupId = SelectedGroup.Id; - var today = DateOnly.FromDateTime(DateTime.Today); - - var nextLesson = _lessons.GetByGroupAndRange(groupId, today, today.AddDays(90)) - .Where(l => l.Status == LessonStatus.Planned) - .OrderBy(l => l.Date).ThenBy(l => l.LessonNumber ?? 0).FirstOrDefault(); - QuickHasNextLesson = nextLesson is not null; - QuickNextLessonLabel = nextLesson is null ? "" - : string.IsNullOrWhiteSpace(nextLesson.Topic) - ? nextLesson.Date.ToString("dd.MM.yyyy") - : $"{nextLesson.Date:dd.MM.yyyy} — {nextLesson.Topic}"; - - var nextExam = _exams.GetByGroup(groupId).Where(e => e.Date >= today).MinBy(e => e.Date); - QuickHasNextExam = nextExam is not null; - QuickNextExamLabel = nextExam is null ? "" : $"{nextExam.Date:dd.MM.yyyy} — {nextExam.Title}"; - - foreach (var t in _tasks.GetByGroup(groupId).Where(t => t.Status != WorkTaskStatus.Done) - .OrderBy(t => t.DueDate ?? DateOnly.MaxValue).Take(QuickTasksMaxCount)) - QuickTasks.Add(new GroupTaskItem(t.Title, t.Kind == TaskKind.Reminder, - t.DueDate?.ToString("dd.MM.yyyy") ?? "", t.DueDate is { } d && d < today, - TaskPriorityDisplay.ColorHex(t.Priority), t.Priority == TaskPriority.High)); - OnPropertyChanged(nameof(QuickHasTasks)); - OnPropertyChanged(nameof(QuickHasAnything)); - } - public void LoadGroups() { - var selectedId = SelectedGroup?.Id; Groups.Clear(); var all = _groups.GetBySchoolYear(SelectedSchoolYear, includeInactive: ShowArchived) .Where(g => g.IsActive != ShowArchived); @@ -129,8 +59,18 @@ public partial class GroupListViewModel : ObservableObject || (g.SubjectId is Guid id && subjectNames.GetValueOrDefault(id, "") .Contains(SearchText, StringComparison.OrdinalIgnoreCase))); foreach (var g in filtered.OrderBy(g => g.Name)) - Groups.Add(new GroupListItem(g, g.SubjectId is Guid id ? subjectNames.GetValueOrDefault(id, "") : "")); - SelectedGroup = Groups.FirstOrDefault(g => g.Id == selectedId); + { + var item = new GroupListItem(g, + g.SubjectId is Guid id ? subjectNames.GetValueOrDefault(id, "") : "") + { + OnOpen = OpenGroup, + OnEdit = EditGroup, + OnRollOver = RollOverGroup, + OnToggleArchive = ToggleArchive, + OnDelete = DeleteGroup, + }; + Groups.Add(item); + } OnPropertyChanged(nameof(ListSummary)); OnPropertyChanged(nameof(HasNoGroups)); OnPropertyChanged(nameof(EmptyListMessage)); @@ -145,63 +85,57 @@ public partial class GroupListViewModel : ObservableObject } [RelayCommand] private void Refresh() => LoadGroups(); - [RelayCommand(CanExecute = nameof(CanEditSelectedGroup))] - private async Task EditGroup() + [RelayCommand] + private void OpenGroup(GroupListItem? group) { - if (SelectedGroup is null || OnEditGroup is null) return; - var id = SelectedGroup.Id; - await OnEditGroup(id); - LoadGroups(); - SelectedGroup = Groups.FirstOrDefault(g => g.Id == id); + if (group is not null) OnNavigateToDetail?.Invoke(group.Id, 0); } - [RelayCommand(CanExecute = nameof(HasSelectedGroup))] - private async Task RollOverGroup() + [RelayCommand] + private async Task EditGroup(GroupListItem? group) { - if (SelectedGroup is null || OnRollOverGroup is null) return; - var targetId = await OnRollOverGroup(SelectedGroup.Id); + if (group?.IsActive != true || OnEditGroup is null) return; + var id = group.Id; + await OnEditGroup(id); + LoadGroups(); + } + + [RelayCommand] + private async Task RollOverGroup(GroupListItem? group) + { + if (group is null || OnRollOverGroup is null) return; + var targetId = await OnRollOverGroup(group.Id); if (targetId is null) return; var target = _groups.GetById(targetId.Value); if (target is null) return; if (!SchoolYears.Contains(target.SchoolYear)) SchoolYears.Insert(0, target.SchoolYear); SelectedSchoolYear = target.SchoolYear; LoadGroups(); - SelectedGroup = Groups.FirstOrDefault(g => g.Id == target.Id); + OnNavigateToDetail?.Invoke(target.Id, 0); } - [RelayCommand(CanExecute = nameof(HasSelectedGroup))] - private void ToggleArchive() + [RelayCommand] + private void ToggleArchive(GroupListItem? selected) { - if (SelectedGroup is null) return; - var group = _groups.GetById(SelectedGroup.Id); + if (selected is null) return; + var group = _groups.GetById(selected.Id); if (group is null) return; group.IsActive = !group.IsActive; _groups.Save(group); LoadGroups(); } - [RelayCommand(CanExecute = nameof(CanEditSelectedGroup))] - private async Task DeleteGroup() + [RelayCommand] + private async Task DeleteGroup(GroupListItem? group) { - if (SelectedGroup is null || OnConfirmDelete is null) return; - var selected = SelectedGroup; - if (!await OnConfirmDelete(selected)) return; - _groups.Delete(selected.Id); + if (group?.IsActive != true || OnConfirmDelete is null) return; + if (!await OnConfirmDelete(group)) return; + _groups.Delete(group.Id); LoadGroups(); } - - [RelayCommand(CanExecute = nameof(HasSelectedGroup))] - private void NavigateToSection(string? tabIndex) - { - if (SelectedGroup is null || !int.TryParse(tabIndex, out var tab)) return; - OnNavigateToDetail?.Invoke(SelectedGroup.Id, tab); - } - - private bool HasSelectedGroup() => SelectedGroup is not null; - private bool CanEditSelectedGroup() => SelectedGroup?.IsActive == true; } -public class GroupListItem +public partial class GroupListItem : ObservableObject { public Guid Id { get; } public string Name { get; } @@ -212,6 +146,13 @@ public class GroupListItem public string Subtitle { get; } public bool IsActive { get; } public string ArchiveActionLabel => IsActive ? "Archivieren" : "Wieder aktivieren"; + public string OpenAutomationName => $"Lerngruppe {DisplayName} öffnen"; + public string ManageAutomationName => $"Lerngruppe {DisplayName} verwalten"; + public Action? OnOpen { get; init; } + public Func? OnEdit { get; init; } + public Func? OnRollOver { get; init; } + public Action? OnToggleArchive { get; init; } + public Func? OnDelete { get; init; } public GroupListItem(LearningGroup g, string subjectName) { @@ -224,6 +165,12 @@ public class GroupListItem DisplayName = string.IsNullOrEmpty(subjectName) ? g.Name : $"{g.Name} · {subjectName}"; Subtitle = $"{TypeLabel} · Stufe {g.GradeLevel} · Noten {GradingLabel} · {g.SchoolYear}"; } + + [RelayCommand] private void Open() => OnOpen?.Invoke(this); + [RelayCommand] private Task Edit() => OnEdit?.Invoke(this) ?? Task.CompletedTask; + [RelayCommand] private Task RollOver() => OnRollOver?.Invoke(this) ?? Task.CompletedTask; + [RelayCommand] private void ToggleArchive() => OnToggleArchive?.Invoke(this); + [RelayCommand] private Task Delete() => OnDelete?.Invoke(this) ?? Task.CompletedTask; } // ── Gruppendetail ───────────────────────────────────────────────────────────── diff --git a/LehrerApp.Desktop/ViewModels/MainWindowViewModel.cs b/LehrerApp.Desktop/ViewModels/MainWindowViewModel.cs index f0c1952..0703b1d 100644 --- a/LehrerApp.Desktop/ViewModels/MainWindowViewModel.cs +++ b/LehrerApp.Desktop/ViewModels/MainWindowViewModel.cs @@ -21,10 +21,12 @@ public partial class MainWindowViewModel : ObservableObject [ObservableProperty] private ObservableObject? _currentPage; [ObservableProperty] private NavItem _activeNavItem = NavItem.Dashboard; [ObservableProperty] private string _currentSchoolYear = ""; + [ObservableProperty] private bool _isCommandPaletteOpen; public SyncStatusViewModel SyncStatus { get; } public ObservableCollection Toasts { get; } public AppLockViewModel AppLock { get; } + public GlobalSearchViewModel CommandPalette { get; } public bool IsDashboardActive => ActiveNavItem == NavItem.Dashboard; public bool IsGroupsActive => ActiveNavItem == NavItem.Groups; @@ -37,18 +39,32 @@ public partial class MainWindowViewModel : ObservableObject public MainWindowViewModel(IServiceProvider services, DashboardViewModel dashboard, SchoolYearService sy, - SyncStatusViewModel syncStatus, NotificationService notifications, AppLockViewModel appLock) + SyncStatusViewModel syncStatus, NotificationService notifications, AppLockViewModel appLock, + GlobalSearchViewModel commandPalette) { _services = services; SyncStatus = syncStatus; Toasts = notifications.Toasts; AppLock = appLock; + CommandPalette = commandPalette; + CommandPalette.OnClose = CloseCommandPalette; CurrentSchoolYear = sy.CurrentSchoolYear(); CurrentPage = dashboard; AppLock.ApplyConfig(); SyncStatus.DataChanged += OnSyncDataChanged; } + [RelayCommand] + private void OpenCommandPalette() + { + if (AppLock.IsLocked) return; + CommandPalette.Reset(); + IsCommandPaletteOpen = true; + } + + [RelayCommand] + private void CloseCommandPalette() => IsCommandPaletteOpen = false; + // EventApplier schreibt bei eingehenden Sync-Ereignissen absichtlich direkt auf die rohe // LiteDB-Collection, an jedem ViewModel vorbei (Ping-Pong-Vermeidung, siehe EventApplier- // Klassenkommentar) - ohne diesen Hook blieb die gerade sichtbare Seite bis zum nächsten diff --git a/LehrerApp.Desktop/Views/Dashboard/DashboardView.axaml b/LehrerApp.Desktop/Views/Dashboard/DashboardView.axaml index 476e631..3e52dd2 100644 --- a/LehrerApp.Desktop/Views/Dashboard/DashboardView.axaml +++ b/LehrerApp.Desktop/Views/Dashboard/DashboardView.axaml @@ -28,11 +28,37 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + - diff --git a/LehrerApp.Desktop/Views/MainWindow.axaml b/LehrerApp.Desktop/Views/MainWindow.axaml index e1fc74d..3ffefa8 100644 --- a/LehrerApp.Desktop/Views/MainWindow.axaml +++ b/LehrerApp.Desktop/Views/MainWindow.axaml @@ -146,6 +146,20 @@ + + + + + + + + + + + + + + + + diff --git a/LehrerApp.Desktop/Views/MainWindow.axaml.cs b/LehrerApp.Desktop/Views/MainWindow.axaml.cs index ebc28f5..1458991 100644 --- a/LehrerApp.Desktop/Views/MainWindow.axaml.cs +++ b/LehrerApp.Desktop/Views/MainWindow.axaml.cs @@ -1,5 +1,6 @@ using Avalonia.Controls; using Avalonia.Input; +using Avalonia.Threading; using LehrerApp.Desktop.Services; using LehrerApp.Desktop.ViewModels; using LehrerApp.Sync; @@ -19,6 +20,62 @@ public partial class MainWindow : Window PointerMoved += (_, _) => NotifyActivity(); PointerPressed += (_, _) => NotifyActivity(); KeyDown += (_, _) => NotifyActivity(); + KeyDown += OnWindowKeyDown; + } + + private void OnWindowKeyDown(object? sender, KeyEventArgs e) + { + if (DataContext is not MainWindowViewModel vm) return; + + var commandModifier = (e.KeyModifiers & (KeyModifiers.Control | KeyModifiers.Meta)) != 0; + if (commandModifier && e.Key == Key.K) + { + vm.OpenCommandPaletteCommand.Execute(null); + FocusCommandPalette(); + e.Handled = true; + return; + } + + if (!vm.IsCommandPaletteOpen) return; + if (e.Key == Key.Escape) + { + vm.CloseCommandPaletteCommand.Execute(null); + e.Handled = true; + } + else if (e.Key == Key.Enter) + { + vm.CommandPalette.ExecuteCommand.Execute(vm.CommandPalette.SelectedResult); + e.Handled = true; + } + else if (e.Key is Key.Down or Key.Up) + { + MoveCommandPaletteSelection(vm, e.Key == Key.Down ? 1 : -1); + e.Handled = true; + } + } + + private void OnOpenCommandPaletteClick(object? sender, Avalonia.Interactivity.RoutedEventArgs e) + { + if (DataContext is MainWindowViewModel vm) + vm.OpenCommandPaletteCommand.Execute(null); + FocusCommandPalette(); + } + + private void FocusCommandPalette() => Dispatcher.UIThread.Post(() => + { + if (this.FindControl("CommandPaletteSearchBox") is { } search) + { + search.Focus(); + search.SelectAll(); + } + }); + + private static void MoveCommandPaletteSelection(MainWindowViewModel vm, int delta) + { + var results = vm.CommandPalette.Results; + if (results.Count == 0) return; + var current = vm.CommandPalette.SelectedResult is { } selected ? results.IndexOf(selected) : -1; + vm.CommandPalette.SelectedResult = results[Math.Clamp(current + delta, 0, results.Count - 1)]; } public void EnableFinalSync(SyncEngine syncEngine) diff --git a/TODO.md b/TODO.md index 44fbf08..3732489 100644 --- a/TODO.md +++ b/TODO.md @@ -2241,6 +2241,13 @@ erreichbar. Ergänzt: ein "SCHNELLÜBERBLICK"-Block oberhalb der Buttonliste obersten Karten des Kurs-Dashboards, hier nur ohne eigenen Tab-Wechsel. Sowie die beiden fehlenden Buttons "📊 Übersicht" (Tab 0) und "✋ Mitarbeit" (Tab 3). +**Spätere UX-Vereinfachung (August 2026):** Dieses Auswahl-Panel wurde wieder entfernt, nachdem +die Gruppenansicht selbst bereits denselben Schnellüberblick als Übersicht-Tab anbot und dadurch +zwei nahezu identische Bereichsnavigationen nacheinander entstanden. Die Gruppenliste zeigt nun +responsive Karten; ein Klick öffnet unmittelbar den Übersicht-Tab der Gruppe. Bearbeiten, +Schuljahresübernahme, Archivieren/Reaktivieren und Löschen bleiben über ein Drei-Punkte-Menü an +jeder Karte erreichbar. Suche, Schuljahresfilter und Archivansicht bleiben erhalten. + ### 6.2 Zeiterfassung - [x] **6.2.1** Timer starten/stoppen mit Zuordnung zu Aufgabe oder Kategorie — `TimeTrackingViewModel`. Bewusst ohne live mitlaufende Sekundenanzeige (keine @@ -2417,6 +2424,12 @@ Hinweis in Kapitel 1 — betrifft auch Kurse, nicht nur Klassen. Gruppeneigenschaften können erst in einem getrennten Schritt nach der Reaktivierung geändert oder gelöscht werden. Reine Ansichten und das Kopieren einer Unterrichtseinheit als Vorlage in eine aktive Gruppe bleiben möglich. +- [x] **7.2.6** Einlagige Lerngruppen-Navigation: Gruppen werden als responsive Karten dargestellt + und öffnen mit einem Klick direkt den Übersicht-Tab. Die zuvor vorgeschaltete Detailspalte + mit einer zweiten Liste aller Bereiche wurde entfernt; die Tabs im Gruppendetail sind damit + die einzige Bereichsnavigation. Das Verwaltungsmenü sitzt direkt an jeder Karte und ist per + Automation-Namen zugänglich. `GroupListViewModelTests` prüfen Direktnavigation, kontextuelle + Bearbeitung, Suche und Archivierung. ### 7.3 Import @@ -2528,7 +2541,7 @@ Hervorhebung "eigene Klasse" über `LearningGroup.IsOwnClass`, feste Kartenbreit Notenstufe bzw. drei Punkte); Versetzungsgefährdung basiert auf dem jüngsten gespeicherten Zeugnisnotenstand (Note 5/6 bzw. höchstens 4 Punkte). Klick öffnet den betroffenen Schüler. - [x] **9.6** Dashboard-Kacheln ein-/ausblendbar und in der Reihenfolge konfigurierbar. - „Dashboard anpassen“ bietet für jede Kachel Sichtbarkeit sowie Hoch-/Runter-Sortierung; + „Bereiche anpassen“ bietet für jede Kachel Sichtbarkeit sowie Hoch-/Runter-Sortierung; die Konfiguration wird lokal in `dashboardsettings.json` gespeichert und das Raster ohne Lücken neu angeordnet. - [x] **9.7** Automatische Aktualisierung beim Zurücknavigieren. @@ -2581,6 +2594,14 @@ gleiche Abfrage (Datum + Stundennummer, bewusst ohne Gruppenbezug, siehe `Substi PeriodNumber`-Doku) wie in `TimetableViewModel.BuildToday` für die "Heute"-Ansicht des Stundenplans. +**Nachtrag Dashboard-Fokus (August 2026):** Oberhalb der konfigurierbaren Karten fasst ein neuer +Tagesfokus Unterricht, offene Aufgaben, Handlungsbedarf und Termine der nächsten 30 Tage in vier +Kennzahlen zusammen. Wetterdetails sind standardmäßig eingeklappt (amtliche Warnungen öffnen den +Bereich automatisch), die linke Inhaltsspalte erhält mehr Breite und leere reine Hinweis-Karten +(u.a. Fehlzeiten-Warnung, Förderplan-Wiedervorlage, Korrekturen und Auffälligkeiten) werden trotz +aktivierter Dashboard-Konfiguration automatisch ausgeblendet. Inhalte und Direktaktionen bleiben +unverändert erhalten; die Seite wird bei ruhiger Datenlage lediglich deutlich kürzer. + --- ## 10. Sync & Server @@ -3439,7 +3460,16 @@ von 4.2.2) der Tab "Kürzel-Katalog" für die Von/Nach-Kürzel des Stundenverlau - [ ] **14.1** Tastaturbedienung durchgängig: alle Hauptfunktionen ohne Maus erreichbar (Vorbild: Mitarbeit-Schnelleingabe). -- [ ] **14.2** Globale Suche (Schüler, Gruppe, Klausur) über Tastenkürzel. +- [x] **14.2** Globale Suche (Schüler, Gruppe, Klausur) über Tastenkürzel. + **Umsetzung:** `Strg+K` (Windows/Linux) bzw. `⌘K` (macOS) öffnet aus jeder Hauptansicht eine + modale Befehlspalette. `GlobalSearchViewModel` durchsucht lokal und ohne zusätzlichen Index + aktive wie inaktive Schüler/Lerngruppen sowie Klausuren und Aufgaben; Treffer springen direkt + ins Schülerdetail, Gruppendetail, den Klausuren-Tab oder die Aufgabenverwaltung. Pfeiltasten, + Enter und Escape bedienen die Palette vollständig ohne Maus. Bei leerer Suche stehen die + Schnellaktionen „Aufgabe anlegen“, „Erinnerung anlegen“ und „Schüler anlegen“ bereit und + verwenden die bereits vorhandenen Dialoge samt Validierung. Zusätzlich ist der Einstieg als + zugänglich benannte Schaltfläche im Navigationsbereich sichtbar. Tests in + `GlobalSearchViewModelTests` decken Ergebnisarten, Navigation und Schnellerfassung ab. - [~] **14.3** Rückgängig-Funktion für Löschvorgänge (mindestens Bestätigungsdialog überall). **Umsetzung:** generischer, nicht-invasiver "Papierkorb light" statt einer