UX-Update 2.0, Neues Dashboard

This commit is contained in:
2026-08-29 00:45:19 +02:00
parent 229ef79e75
commit 9b04d7eb98
14 changed files with 774 additions and 355 deletions
@@ -11,6 +11,22 @@ namespace LehrerApp.Desktop.Tests;
/// (offene Aufgaben, ...). /// (offene Aufgaben, ...).
public sealed class DashboardViewModelTests 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() private static PeriodScheduleService NewPeriodSchedule()
{ {
var tempPath = System.IO.Path.Combine( var tempPath = System.IO.Path.Combine(
@@ -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<Student>? students = null,
List<LearningGroup>? groups = null, List<Exam>? exams = null, FakeWorkTasks? tasks = null) =>
new(new FakeStudents(students ?? []), new FakeGroups(groups ?? []),
new FakeExams(exams ?? []), tasks ?? new FakeWorkTasks());
}
@@ -5,76 +5,69 @@ using Xunit;
namespace LehrerApp.Desktop.Tests; namespace LehrerApp.Desktop.Tests;
/// Tests für den Schnellüberblick im Auswahl-Panel der Gruppenliste (Nutzer-Feedback: /// <summary>Tests für die direkte, einlagige Lerngruppen-Navigation.</summary>
/// "oberhalb der Buttonliste ein paar Daten auswerfen. Nächste Stunde, nächste Arbeit,
/// wichtige Todos").
public sealed class GroupListViewModelTests public sealed class GroupListViewModelTests
{ {
private static GroupListViewModel BuildVm(LearningGroup group, FakeLessons? lessons = null, private static GroupListViewModel BuildVm(params LearningGroup[] groups) =>
FakeExams? exams = null, FakeWorkTasks? tasks = null) => new(new FakeGroups([.. groups]), new FakeSubjects([]), new SchoolYearService());
new(new FakeGroups([group]), new FakeSubjects([]), new SchoolYearService(),
lessons ?? new FakeLessons(), exams ?? new FakeExams([]), tasks ?? new FakeWorkTasks());
[Fact] [Fact]
public void SelectedGroup_OhneDatenZeigtKeinenSchnellueberblick() public void Gruppenkarte_OeffnetDirektDieUebersicht()
{ {
var group = new LearningGroup { Name = "9c" }; var group = CurrentGroup("9c");
var vm = BuildVm(group); 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] [Fact]
public void SelectedGroup_ZeigtNaechsteGeplanteStundeUndKlausur() public async Task Verwaltungsmenue_BearbeitetDieGewaehlteKarteOhneZwischenauswahl()
{ {
var group = new LearningGroup { Name = "9c" }; var first = CurrentGroup("9a");
var today = DateOnly.FromDateTime(DateTime.Today); var second = CurrentGroup("9b");
var lessons = new FakeLessons(); var vm = BuildVm(first, second);
lessons.Add(new Lesson { GroupId = group.Id, Date = today.AddDays(3), Topic = "Redox", Status = LessonStatus.Planned }); Guid? editedId = null;
var exams = new FakeExams([new Exam { GroupId = group.Id, Date = today.AddDays(10), Title = "Klausur 1" }]); vm.OnEditGroup = id => { editedId = id; return Task.CompletedTask; };
var vm = BuildVm(group, lessons: lessons, exams: exams); 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.Equal(second.Id, editedId);
Assert.Contains("Redox", vm.QuickNextLessonLabel);
Assert.True(vm.QuickHasNextExam);
Assert.Contains("Klausur 1", vm.QuickNextExamLabel);
Assert.True(vm.QuickHasAnything);
} }
[Fact] [Fact]
public void SelectedGroup_ZeigtOffeneAufgabenDieserGruppeSortiertNachFaelligkeit() public void Suche_FiltertWeiterhinNachGruppenname()
{ {
var group = new LearningGroup { Name = "9c" }; var vm = BuildVm(CurrentGroup("9 Chemie"), CurrentGroup("10 Mathematik"));
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);
vm.SelectedGroup = new GroupListItem(group, ""); vm.SearchText = "Chemie";
Assert.True(vm.QuickHasTasks); Assert.Equal("9 Chemie", Assert.Single(vm.Groups).Name);
Assert.Equal("Bald fällig", Assert.Single(vm.QuickTasks).Title);
} }
[Fact] [Fact]
public void SelectedGroup_Zuruecksetzen_LeertSchnellueberblick() public void Archivieren_WirktAufDieKarteUndEntferntSieAusDerAktivenListe()
{ {
var group = new LearningGroup { Name = "9c" }; var group = CurrentGroup("9c");
var today = DateOnly.FromDateTime(DateTime.Today); var vm = BuildVm(group);
var exams = new FakeExams([new Exam { GroupId = group.Id, Date = today.AddDays(10), Title = "Klausur 1" }]); var item = Assert.Single(vm.Groups);
var vm = BuildVm(group, exams: exams);
vm.SelectedGroup = new GroupListItem(group, "");
Assert.True(vm.QuickHasAnything);
vm.SelectedGroup = null; item.ToggleArchiveCommand.Execute(null);
Assert.False(vm.QuickHasAnything); Assert.False(group.IsActive);
Assert.Empty(vm.QuickTasks); Assert.Empty(vm.Groups);
} }
private static LearningGroup CurrentGroup(string name) => new()
{
Name = name,
SchoolYear = new SchoolYearService().CurrentSchoolYear(),
IsActive = true,
};
} }
+32
View File
@@ -10,7 +10,9 @@ using LehrerApp.Desktop.ViewModels;
using LehrerApp.Desktop.ViewModels.Groups; using LehrerApp.Desktop.ViewModels.Groups;
using LehrerApp.Desktop.ViewModels.Planning; using LehrerApp.Desktop.ViewModels.Planning;
using LehrerApp.Desktop.ViewModels.Students; using LehrerApp.Desktop.ViewModels.Students;
using LehrerApp.Desktop.ViewModels.Workload;
using LehrerApp.Desktop.Views; using LehrerApp.Desktop.Views;
using LehrerApp.Desktop.Views.Workload;
using LehrerApp.Sync; using LehrerApp.Sync;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
@@ -152,6 +154,23 @@ public class App : Application
dash.OnNavigateToExam = id => main.NavigateToGroupDetail(id, 4); // Tab "Klausuren" dash.OnNavigateToExam = id => main.NavigateToGroupDetail(id, 4); // Tab "Klausuren"
dash.OnNavigateToUnplannedLesson = id => main.NavigateToGroupDetail(id, 6); // Tab "Planung" 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<GlobalSearchViewModel>();
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 // StudentList → StudentDetail + Anlegen
var sl = Services.GetRequiredService<StudentListViewModel>(); var sl = Services.GetRequiredService<StudentListViewModel>();
sl.OnNavigateToDetail = id => main.NavigateToStudent(id); sl.OnNavigateToDetail = id => main.NavigateToStudent(id);
@@ -171,4 +190,17 @@ public class App : Application
if (Application.Current?.ApplicationLifetime is IClassicDesktopStyleApplicationLifetime { MainWindow: { } owner }) if (Application.Current?.ApplicationLifetime is IClassicDesktopStyleApplicationLifetime { MainWindow: { } owner })
await dialog.ShowDialog<bool>(owner); await dialog.ShowDialog<bool>(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<IWorkTaskRepository>().Save(result);
dashboard.RefreshCommand.Execute(null);
Services.GetRequiredService<WorkTaskListViewModel>().Load();
}
} }
+1
View File
@@ -291,6 +291,7 @@ public static class AppBootstrapper
// Singleton: einmal erstellt, überall dieselbe Instanz // Singleton: einmal erstellt, überall dieselbe Instanz
services.AddSingleton<AppLockViewModel>(); services.AddSingleton<AppLockViewModel>();
services.AddSingleton<MainWindowViewModel>(); services.AddSingleton<MainWindowViewModel>();
services.AddSingleton<GlobalSearchViewModel>();
services.AddSingleton<DashboardViewModel>(); services.AddSingleton<DashboardViewModel>();
services.AddSingleton(sp => services.AddSingleton(sp =>
new SyncStatusViewModel( new SyncStatusViewModel(
@@ -107,6 +107,15 @@ public partial class DashboardViewModel : ObservableObject
public DashboardCardOption AttendanceCard => Card("attendance"); public DashboardCardOption AttendanceCard => Card("attendance");
public DashboardCardOption SupportCard => Card("support"); public DashboardCardOption SupportCard => Card("support");
public DashboardCardOption GroupsCard => Card("groups"); 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, public DashboardViewModel(IGroupRepository groups, ISubjectRepository subjects, ILessonRepository lessons,
IExamRepository exams, IExamResultRepository examResults, IGradeRepository grades, IExamRepository exams, IExamResultRepository examResults, IGradeRepository grades,
@@ -204,6 +213,31 @@ public partial class DashboardViewModel : ObservableObject
LoadOpenCorrections(groups, today); LoadOpenCorrections(groups, today);
LoadUnplannedLessons(groups, today); LoadUnplannedLessons(groups, today);
LoadAlerts(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() private async Task LoadWeatherAsync()
@@ -592,6 +626,7 @@ public partial class DashboardViewModel : ObservableObject
entry.Attendance = status; entry.Attendance = status;
_participationEntries.Save(entry); _participationEntries.Save(entry);
OpenExcuses.Remove(item); OpenExcuses.Remove(item);
UpdateDashboardSummary();
} }
private void LoadCalendar() private void LoadCalendar()
@@ -1034,18 +1069,29 @@ public sealed class DashboardAlertItem(Guid studentId, Guid? groupId, string stu
public partial class DashboardCardOption : ObservableObject public partial class DashboardCardOption : ObservableObject
{ {
[ObservableProperty] private bool _isVisible; [ObservableProperty] private bool _isVisible;
[ObservableProperty] private bool _isEmpty;
[ObservableProperty] private int _row; [ObservableProperty] private int _row;
[ObservableProperty] private int _column; [ObservableProperty] private int _column;
public string Key { get; } public string Key { get; }
public string Title { get; } public string Title { get; }
public bool HideWhenEmpty { get; }
public bool EffectiveIsVisible => IsVisible && (!HideWhenEmpty || !IsEmpty);
public Action? OnVisibilityChanged { get; set; } public Action? OnVisibilityChanged { get; set; }
public DashboardCardOption(string key, string title, bool isVisible) public DashboardCardOption(string key, string title, bool isVisible)
{ {
Key = key; Key = key;
Title = title; Title = title;
HideWhenEmpty = key is "excuses" or "upcoming" or "corrections" or "unplanned"
or "alerts" or "attendance" or "support";
_isVisible = isVisible; _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));
} }
@@ -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;
/// <summary>
/// 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.
/// </summary>
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<GlobalSearchResult> Results { get; } = [];
public bool HasResults => Results.Count > 0;
public bool ShowNoResults => !string.IsNullOrWhiteSpace(Query) && Results.Count == 0;
public Action<GlobalSearchResult>? OnNavigate { get; set; }
public Func<bool, Task>? OnQuickAddTask { get; set; }
public Func<Task>? 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<GlobalSearchResult>();
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",
};
}
@@ -13,13 +13,8 @@ namespace LehrerApp.Desktop.ViewModels.Groups;
public partial class GroupListViewModel : ObservableObject public partial class GroupListViewModel : ObservableObject
{ {
private const int QuickTasksMaxCount = 3;
private readonly IGroupRepository _groups; private readonly IGroupRepository _groups;
private readonly ISubjectRepository _subjects; private readonly ISubjectRepository _subjects;
private readonly ILessonRepository _lessons;
private readonly IExamRepository _exams;
private readonly IWorkTaskRepository _tasks;
public Action<Guid, int>? OnNavigateToDetail { get; set; } public Action<Guid, int>? OnNavigateToDetail { get; set; }
public Func<Task>? OnAddGroup { get; set; } public Func<Task>? OnAddGroup { get; set; }
@@ -29,11 +24,8 @@ public partial class GroupListViewModel : ObservableObject
[ObservableProperty] private string _selectedSchoolYear = ""; [ObservableProperty] private string _selectedSchoolYear = "";
[ObservableProperty] private string _searchText = ""; [ObservableProperty] private string _searchText = "";
[ObservableProperty] private GroupListItem? _selectedGroup;
[ObservableProperty] private bool _showArchived; [ObservableProperty] private bool _showArchived;
public string SelectedGroupDisplayName => SelectedGroup?.DisplayName ?? "";
public string SelectedGroupSubtitle => SelectedGroup?.Subtitle ?? "";
public string ListSummary => ShowArchived public string ListSummary => ShowArchived
? $"{Groups.Count} archivierte Gruppen · {SelectedSchoolYear}" ? $"{Groups.Count} archivierte Gruppen · {SelectedSchoolYear}"
: $"{Groups.Count} aktive Gruppen · {SelectedSchoolYear}"; : $"{Groups.Count} aktive Gruppen · {SelectedSchoolYear}";
@@ -45,22 +37,10 @@ public partial class GroupListViewModel : ObservableObject
public ObservableCollection<string> SchoolYears { get; } = []; public ObservableCollection<string> SchoolYears { get; } = [];
public ObservableCollection<GroupListItem> Groups { get; } = []; public ObservableCollection<GroupListItem> Groups { get; } = [];
// ── Schnellüberblick im Auswahl-Panel (Nutzer-Feedback: "oberhalb der Buttonliste ein paar public GroupListViewModel(IGroupRepository groups, ISubjectRepository subjects, SchoolYearService sy)
// 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<GroupTaskItem> 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)
{ {
_groups = groups; _subjects = subjects; _lessons = lessons; _exams = exams; _tasks = tasks; _groups = groups;
_subjects = subjects;
foreach (var y in sy.RecentSchoolYears()) SchoolYears.Add(y); foreach (var y in sy.RecentSchoolYears()) SchoolYears.Add(y);
SelectedSchoolYear = sy.CurrentSchoolYear(); SelectedSchoolYear = sy.CurrentSchoolYear();
} }
@@ -68,58 +48,8 @@ public partial class GroupListViewModel : ObservableObject
partial void OnSelectedSchoolYearChanged(string value) => LoadGroups(); partial void OnSelectedSchoolYearChanged(string value) => LoadGroups();
partial void OnSearchTextChanged(string value) => LoadGroups(); partial void OnSearchTextChanged(string value) => LoadGroups();
partial void OnShowArchivedChanged(bool 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() public void LoadGroups()
{ {
var selectedId = SelectedGroup?.Id;
Groups.Clear(); Groups.Clear();
var all = _groups.GetBySchoolYear(SelectedSchoolYear, includeInactive: ShowArchived) var all = _groups.GetBySchoolYear(SelectedSchoolYear, includeInactive: ShowArchived)
.Where(g => g.IsActive != ShowArchived); .Where(g => g.IsActive != ShowArchived);
@@ -129,8 +59,18 @@ public partial class GroupListViewModel : ObservableObject
|| (g.SubjectId is Guid id && subjectNames.GetValueOrDefault(id, "") || (g.SubjectId is Guid id && subjectNames.GetValueOrDefault(id, "")
.Contains(SearchText, StringComparison.OrdinalIgnoreCase))); .Contains(SearchText, StringComparison.OrdinalIgnoreCase)));
foreach (var g in filtered.OrderBy(g => g.Name)) 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(ListSummary));
OnPropertyChanged(nameof(HasNoGroups)); OnPropertyChanged(nameof(HasNoGroups));
OnPropertyChanged(nameof(EmptyListMessage)); OnPropertyChanged(nameof(EmptyListMessage));
@@ -145,63 +85,57 @@ public partial class GroupListViewModel : ObservableObject
} }
[RelayCommand] private void Refresh() => LoadGroups(); [RelayCommand] private void Refresh() => LoadGroups();
[RelayCommand(CanExecute = nameof(CanEditSelectedGroup))] [RelayCommand]
private async Task EditGroup() private void OpenGroup(GroupListItem? group)
{ {
if (SelectedGroup is null || OnEditGroup is null) return; if (group is not null) OnNavigateToDetail?.Invoke(group.Id, 0);
var id = SelectedGroup.Id;
await OnEditGroup(id);
LoadGroups();
SelectedGroup = Groups.FirstOrDefault(g => g.Id == id);
} }
[RelayCommand(CanExecute = nameof(HasSelectedGroup))] [RelayCommand]
private async Task RollOverGroup() private async Task EditGroup(GroupListItem? group)
{ {
if (SelectedGroup is null || OnRollOverGroup is null) return; if (group?.IsActive != true || OnEditGroup is null) return;
var targetId = await OnRollOverGroup(SelectedGroup.Id); 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; if (targetId is null) return;
var target = _groups.GetById(targetId.Value); var target = _groups.GetById(targetId.Value);
if (target is null) return; if (target is null) return;
if (!SchoolYears.Contains(target.SchoolYear)) SchoolYears.Insert(0, target.SchoolYear); if (!SchoolYears.Contains(target.SchoolYear)) SchoolYears.Insert(0, target.SchoolYear);
SelectedSchoolYear = target.SchoolYear; SelectedSchoolYear = target.SchoolYear;
LoadGroups(); LoadGroups();
SelectedGroup = Groups.FirstOrDefault(g => g.Id == target.Id); OnNavigateToDetail?.Invoke(target.Id, 0);
} }
[RelayCommand(CanExecute = nameof(HasSelectedGroup))] [RelayCommand]
private void ToggleArchive() private void ToggleArchive(GroupListItem? selected)
{ {
if (SelectedGroup is null) return; if (selected is null) return;
var group = _groups.GetById(SelectedGroup.Id); var group = _groups.GetById(selected.Id);
if (group is null) return; if (group is null) return;
group.IsActive = !group.IsActive; group.IsActive = !group.IsActive;
_groups.Save(group); _groups.Save(group);
LoadGroups(); LoadGroups();
} }
[RelayCommand(CanExecute = nameof(CanEditSelectedGroup))] [RelayCommand]
private async Task DeleteGroup() private async Task DeleteGroup(GroupListItem? group)
{ {
if (SelectedGroup is null || OnConfirmDelete is null) return; if (group?.IsActive != true || OnConfirmDelete is null) return;
var selected = SelectedGroup; if (!await OnConfirmDelete(group)) return;
if (!await OnConfirmDelete(selected)) return; _groups.Delete(group.Id);
_groups.Delete(selected.Id);
LoadGroups(); 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 Guid Id { get; }
public string Name { get; } public string Name { get; }
@@ -212,6 +146,13 @@ public class GroupListItem
public string Subtitle { get; } public string Subtitle { get; }
public bool IsActive { get; } public bool IsActive { get; }
public string ArchiveActionLabel => IsActive ? "Archivieren" : "Wieder aktivieren"; public string ArchiveActionLabel => IsActive ? "Archivieren" : "Wieder aktivieren";
public string OpenAutomationName => $"Lerngruppe {DisplayName} öffnen";
public string ManageAutomationName => $"Lerngruppe {DisplayName} verwalten";
public Action<GroupListItem>? OnOpen { get; init; }
public Func<GroupListItem, Task>? OnEdit { get; init; }
public Func<GroupListItem, Task>? OnRollOver { get; init; }
public Action<GroupListItem>? OnToggleArchive { get; init; }
public Func<GroupListItem, Task>? OnDelete { get; init; }
public GroupListItem(LearningGroup g, string subjectName) public GroupListItem(LearningGroup g, string subjectName)
{ {
@@ -224,6 +165,12 @@ public class GroupListItem
DisplayName = string.IsNullOrEmpty(subjectName) ? g.Name : $"{g.Name} · {subjectName}"; DisplayName = string.IsNullOrEmpty(subjectName) ? g.Name : $"{g.Name} · {subjectName}";
Subtitle = $"{TypeLabel} · Stufe {g.GradeLevel} · Noten {GradingLabel} · {g.SchoolYear}"; 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 ───────────────────────────────────────────────────────────── // ── Gruppendetail ─────────────────────────────────────────────────────────────
@@ -21,10 +21,12 @@ public partial class MainWindowViewModel : ObservableObject
[ObservableProperty] private ObservableObject? _currentPage; [ObservableProperty] private ObservableObject? _currentPage;
[ObservableProperty] private NavItem _activeNavItem = NavItem.Dashboard; [ObservableProperty] private NavItem _activeNavItem = NavItem.Dashboard;
[ObservableProperty] private string _currentSchoolYear = ""; [ObservableProperty] private string _currentSchoolYear = "";
[ObservableProperty] private bool _isCommandPaletteOpen;
public SyncStatusViewModel SyncStatus { get; } public SyncStatusViewModel SyncStatus { get; }
public ObservableCollection<ToastItem> Toasts { get; } public ObservableCollection<ToastItem> Toasts { get; }
public AppLockViewModel AppLock { get; } public AppLockViewModel AppLock { get; }
public GlobalSearchViewModel CommandPalette { get; }
public bool IsDashboardActive => ActiveNavItem == NavItem.Dashboard; public bool IsDashboardActive => ActiveNavItem == NavItem.Dashboard;
public bool IsGroupsActive => ActiveNavItem == NavItem.Groups; public bool IsGroupsActive => ActiveNavItem == NavItem.Groups;
@@ -37,18 +39,32 @@ public partial class MainWindowViewModel : ObservableObject
public MainWindowViewModel(IServiceProvider services, public MainWindowViewModel(IServiceProvider services,
DashboardViewModel dashboard, SchoolYearService sy, DashboardViewModel dashboard, SchoolYearService sy,
SyncStatusViewModel syncStatus, NotificationService notifications, AppLockViewModel appLock) SyncStatusViewModel syncStatus, NotificationService notifications, AppLockViewModel appLock,
GlobalSearchViewModel commandPalette)
{ {
_services = services; _services = services;
SyncStatus = syncStatus; SyncStatus = syncStatus;
Toasts = notifications.Toasts; Toasts = notifications.Toasts;
AppLock = appLock; AppLock = appLock;
CommandPalette = commandPalette;
CommandPalette.OnClose = CloseCommandPalette;
CurrentSchoolYear = sy.CurrentSchoolYear(); CurrentSchoolYear = sy.CurrentSchoolYear();
CurrentPage = dashboard; CurrentPage = dashboard;
AppLock.ApplyConfig(); AppLock.ApplyConfig();
SyncStatus.DataChanged += OnSyncDataChanged; 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 // EventApplier schreibt bei eingehenden Sync-Ereignissen absichtlich direkt auf die rohe
// LiteDB-Collection, an jedem ViewModel vorbei (Ping-Pong-Vermeidung, siehe EventApplier- // LiteDB-Collection, an jedem ViewModel vorbei (Ping-Pong-Vermeidung, siehe EventApplier-
// Klassenkommentar) - ohne diesen Hook blieb die gerade sichtbare Seite bis zum nächsten // Klassenkommentar) - ohne diesen Hook blieb die gerade sichtbare Seite bis zum nächsten
@@ -28,11 +28,37 @@
<StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="8"> <StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="8">
<Button Content="Klassenbuch abgleichen…" Click="OnCompareWebUntisDocumentationClick" <Button Content="Klassenbuch abgleichen…" Click="OnCompareWebUntisDocumentationClick"
VerticalAlignment="Center"/> VerticalAlignment="Center"/>
<Button Content="Dashboard anpassen" Command="{Binding ToggleDashboardSettingsCommand}" <Button Content="Bereiche anpassen" Command="{Binding ToggleDashboardSettingsCommand}"
VerticalAlignment="Center"/> VerticalAlignment="Center"/>
</StackPanel> </StackPanel>
</Grid> </Grid>
<!-- Der Tagesfokus beantwortet zuerst die vier Fragen, die beim Öffnen der App zählen:
Was unterrichte ich, was ist zu tun, wo muss ich reagieren und was steht an? -->
<Border Background="{DynamicResource AppCardBackgroundBrush}"
BorderBrush="{DynamicResource AppCardBorderBrush}" BorderThickness="1"
CornerRadius="10" Padding="16,14">
<Grid ColumnDefinitions="*,*,*,*">
<StackPanel Grid.Column="0" Spacing="3">
<TextBlock Text="UNTERRICHT HEUTE" FontSize="10" FontWeight="Bold" Opacity="0.5"/>
<TextBlock Text="{Binding TodayLessonSummary}" FontSize="18" FontWeight="SemiBold"/>
</StackPanel>
<StackPanel Grid.Column="1" Spacing="3" Margin="18,0,0,0">
<TextBlock Text="AUFGABEN" FontSize="10" FontWeight="Bold" Opacity="0.5"/>
<TextBlock Text="{Binding OpenTaskSummary}" FontSize="18" FontWeight="SemiBold"/>
</StackPanel>
<StackPanel Grid.Column="2" Spacing="3" Margin="18,0,0,0">
<TextBlock Text="HANDLUNGSBEDARF" FontSize="10" FontWeight="Bold" Opacity="0.5"/>
<TextBlock Text="{Binding AttentionSummary}" FontSize="18" FontWeight="SemiBold"
Foreground="{DynamicResource AppStatusWarningBrush}"/>
</StackPanel>
<StackPanel Grid.Column="3" Spacing="3" Margin="18,0,0,0">
<TextBlock Text="NÄCHSTE 30 TAGE" FontSize="10" FontWeight="Bold" Opacity="0.5"/>
<TextBlock Text="{Binding UpcomingSummary}" FontSize="18" FontWeight="SemiBold"/>
</StackPanel>
</Grid>
</Border>
<Border Background="{DynamicResource SystemControlBackgroundAltHighBrush}" CornerRadius="8" <Border Background="{DynamicResource SystemControlBackgroundAltHighBrush}" CornerRadius="8"
Padding="12" IsVisible="{Binding IsDashboardSettingsOpen}"> Padding="12" IsVisible="{Binding IsDashboardSettingsOpen}">
<ItemsControl ItemsSource="{Binding DashboardCards}"> <ItemsControl ItemsSource="{Binding DashboardCards}">
@@ -59,14 +85,15 @@
</Border> </Border>
<!-- Serverseitig gecachte DWD-Daten für den in den Einstellungen hinterlegten Schulstandort. --> <!-- Serverseitig gecachte DWD-Daten für den in den Einstellungen hinterlegten Schulstandort. -->
<Border Background="{DynamicResource SystemControlBackgroundAltHighBrush}" CornerRadius="8" <Expander Header="{Binding WeatherSummary}" IsExpanded="{Binding HasWeatherWarnings}"
Padding="16" IsVisible="{Binding IsWeatherPanelVisible}"> IsVisible="{Binding IsWeatherPanelVisible}"
<StackPanel Spacing="10"> Background="{DynamicResource AppCardBackgroundBrush}"
BorderBrush="{DynamicResource AppCardBorderBrush}" BorderThickness="1"
CornerRadius="8" Padding="12,6">
<StackPanel Spacing="10" Margin="6,8,6,6">
<Grid ColumnDefinitions="*,Auto"> <Grid ColumnDefinitions="*,Auto">
<StackPanel> <StackPanel>
<TextBlock Text="WETTER AM SCHULSTANDORT" FontSize="11" FontWeight="Bold" Opacity="0.5"/> <TextBlock Text="WETTER AM SCHULSTANDORT" FontSize="11" FontWeight="Bold" Opacity="0.5"/>
<TextBlock Text="{Binding WeatherSummary}" FontSize="20" FontWeight="SemiBold" Margin="0,5,0,0"
IsVisible="{Binding WeatherSummary, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
<TextBlock Text="{Binding WeatherDetails}" FontSize="11" Opacity="0.65" TextWrapping="Wrap" <TextBlock Text="{Binding WeatherDetails}" FontSize="11" Opacity="0.65" TextWrapping="Wrap"
IsVisible="{Binding WeatherDetails, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/> IsVisible="{Binding WeatherDetails, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
</StackPanel> </StackPanel>
@@ -96,17 +123,20 @@
</ItemsControl> </ItemsControl>
<TextBlock Text="Wetterdaten © Deutscher Wetterdienst" FontSize="10" Opacity="0.5"/> <TextBlock Text="Wetterdaten © Deutscher Wetterdienst" FontSize="10" Opacity="0.5"/>
</StackPanel> </StackPanel>
</Border> </Expander>
<Grid ColumnDefinitions="*,*" RowDefinitions="Auto,Auto,Auto,Auto,Auto,Auto"> <TextBlock Text="HEUTE UND HANDLUNGSBEDARF" FontSize="11" FontWeight="Bold" Opacity="0.5"
Margin="2,2,0,-8"/>
<Grid ColumnDefinitions="3*,2*" RowDefinitions="Auto,Auto,Auto,Auto,Auto,Auto">
<!-- Heutige Stunden --> <!-- Heutige Stunden -->
<Border Grid.Column="{Binding TodayCard.Column}" Grid.Row="{Binding TodayCard.Row}" <Border Grid.Column="{Binding TodayCard.Column}" Grid.Row="{Binding TodayCard.Row}"
IsVisible="{Binding TodayCard.IsVisible}" Margin="0,0,8,8" IsVisible="{Binding TodayCard.EffectiveIsVisible}" Margin="0,0,8,8"
Background="{DynamicResource SystemControlBackgroundAltHighBrush}" Background="{DynamicResource SystemControlBackgroundAltHighBrush}"
CornerRadius="8" Padding="16"> CornerRadius="8" Padding="16">
<StackPanel> <StackPanel>
<TextBlock Text="HEUTE" FontSize="11" FontWeight="Bold" <TextBlock Text="Heute" FontSize="14" FontWeight="SemiBold"
Opacity="0.5" Margin="0,0,0,10"/> Opacity="0.5" Margin="0,0,0,10"/>
<ItemsControl ItemsSource="{Binding TodaysLessons}"> <ItemsControl ItemsSource="{Binding TodaysLessons}">
<ItemsControl.ItemTemplate> <ItemsControl.ItemTemplate>
@@ -143,12 +173,12 @@
<!-- Offene Aufgaben --> <!-- Offene Aufgaben -->
<Border Grid.Column="{Binding TasksCard.Column}" Grid.Row="{Binding TasksCard.Row}" <Border Grid.Column="{Binding TasksCard.Column}" Grid.Row="{Binding TasksCard.Row}"
IsVisible="{Binding TasksCard.IsVisible}" Margin="8,0,0,8" IsVisible="{Binding TasksCard.EffectiveIsVisible}" Margin="8,0,0,8"
Background="{DynamicResource SystemControlBackgroundAltHighBrush}" Background="{DynamicResource SystemControlBackgroundAltHighBrush}"
CornerRadius="8" Padding="16"> CornerRadius="8" Padding="16">
<StackPanel> <StackPanel>
<Grid ColumnDefinitions="*,Auto,Auto" Margin="0,0,0,10"> <Grid ColumnDefinitions="*,Auto,Auto" Margin="0,0,0,10">
<TextBlock Grid.Column="0" Text="OFFENE AUFGABEN" FontSize="11" FontWeight="Bold" <TextBlock Grid.Column="0" Text="Offene Aufgaben" FontSize="14" FontWeight="SemiBold"
Opacity="0.5" VerticalAlignment="Center"/> Opacity="0.5" VerticalAlignment="Center"/>
<Button Grid.Column="1" Content="🔔+" FontSize="12" Padding="7,2" Margin="0,0,4,0" <Button Grid.Column="1" Content="🔔+" FontSize="12" Padding="7,2" Margin="0,0,4,0"
ToolTip.Tip="Erinnerung anlegen" Command="{Binding AddReminderCommand}"/> ToolTip.Tip="Erinnerung anlegen" Command="{Binding AddReminderCommand}"/>
@@ -180,7 +210,7 @@
<!-- Kalender: feste Position direkt unter Heute/Aufgaben, damit die wachsende <!-- Kalender: feste Position direkt unter Heute/Aufgaben, damit die wachsende
Lerngruppen-Liste darunter ihn nicht nach unten verdrängt. --> Lerngruppen-Liste darunter ihn nicht nach unten verdrängt. -->
<Border Grid.Column="{Binding CalendarCard.Column}" Grid.Row="{Binding CalendarCard.Row}" <Border Grid.Column="{Binding CalendarCard.Column}" Grid.Row="{Binding CalendarCard.Row}"
IsVisible="{Binding CalendarCard.IsVisible}" Margin="0,0,8,8" IsVisible="{Binding CalendarCard.EffectiveIsVisible}" Margin="0,0,8,8"
Background="{DynamicResource SystemControlBackgroundAltHighBrush}" Background="{DynamicResource SystemControlBackgroundAltHighBrush}"
CornerRadius="8" Padding="16"> CornerRadius="8" Padding="16">
<StackPanel Spacing="8"> <StackPanel Spacing="8">
@@ -321,7 +351,7 @@
<!-- Offene Entschuldigungen: neben dem Kalender, ebenfalls feste Position --> <!-- Offene Entschuldigungen: neben dem Kalender, ebenfalls feste Position -->
<Border Grid.Column="{Binding ExcusesCard.Column}" Grid.Row="{Binding ExcusesCard.Row}" <Border Grid.Column="{Binding ExcusesCard.Column}" Grid.Row="{Binding ExcusesCard.Row}"
IsVisible="{Binding ExcusesCard.IsVisible}" Margin="8,0,0,8" VerticalAlignment="Top" IsVisible="{Binding ExcusesCard.EffectiveIsVisible}" Margin="8,0,0,8" VerticalAlignment="Top"
Background="{DynamicResource SystemControlBackgroundAltHighBrush}" Background="{DynamicResource SystemControlBackgroundAltHighBrush}"
CornerRadius="8" Padding="16"> CornerRadius="8" Padding="16">
<StackPanel> <StackPanel>
@@ -355,7 +385,7 @@
<!-- Fehlzeiten-Warnung (5.2.3) --> <!-- Fehlzeiten-Warnung (5.2.3) -->
<Border Grid.Column="{Binding AttendanceCard.Column}" Grid.Row="{Binding AttendanceCard.Row}" <Border Grid.Column="{Binding AttendanceCard.Column}" Grid.Row="{Binding AttendanceCard.Row}"
IsVisible="{Binding AttendanceCard.IsVisible}" Margin="0,0,8,8" VerticalAlignment="Top" IsVisible="{Binding AttendanceCard.EffectiveIsVisible}" Margin="0,0,8,8" VerticalAlignment="Top"
Background="{DynamicResource SystemControlBackgroundAltHighBrush}" Background="{DynamicResource SystemControlBackgroundAltHighBrush}"
CornerRadius="8" Padding="16"> CornerRadius="8" Padding="16">
<StackPanel> <StackPanel>
@@ -385,7 +415,7 @@
<!-- Förderplan-Wiedervorlage (5.3.2) --> <!-- Förderplan-Wiedervorlage (5.3.2) -->
<Border Grid.Column="{Binding SupportCard.Column}" Grid.Row="{Binding SupportCard.Row}" <Border Grid.Column="{Binding SupportCard.Column}" Grid.Row="{Binding SupportCard.Row}"
IsVisible="{Binding SupportCard.IsVisible}" Margin="8,0,0,8" VerticalAlignment="Top" IsVisible="{Binding SupportCard.EffectiveIsVisible}" Margin="8,0,0,8" VerticalAlignment="Top"
Background="{DynamicResource SystemControlBackgroundAltHighBrush}" Background="{DynamicResource SystemControlBackgroundAltHighBrush}"
CornerRadius="8" Padding="16"> CornerRadius="8" Padding="16">
<StackPanel> <StackPanel>
@@ -416,7 +446,7 @@
<!-- Anstehende Termine (9.3) --> <!-- Anstehende Termine (9.3) -->
<Border Grid.Column="{Binding UpcomingCard.Column}" Grid.Row="{Binding UpcomingCard.Row}" <Border Grid.Column="{Binding UpcomingCard.Column}" Grid.Row="{Binding UpcomingCard.Row}"
IsVisible="{Binding UpcomingCard.IsVisible}" Margin="0,0,8,8" VerticalAlignment="Top" IsVisible="{Binding UpcomingCard.EffectiveIsVisible}" Margin="0,0,8,8" VerticalAlignment="Top"
Background="{DynamicResource SystemControlBackgroundAltHighBrush}" Background="{DynamicResource SystemControlBackgroundAltHighBrush}"
CornerRadius="8" Padding="16"> CornerRadius="8" Padding="16">
<StackPanel> <StackPanel>
@@ -453,7 +483,7 @@
<!-- Offene Korrekturen (9.4) --> <!-- Offene Korrekturen (9.4) -->
<Border Grid.Column="{Binding CorrectionsCard.Column}" Grid.Row="{Binding CorrectionsCard.Row}" <Border Grid.Column="{Binding CorrectionsCard.Column}" Grid.Row="{Binding CorrectionsCard.Row}"
IsVisible="{Binding CorrectionsCard.IsVisible}" Margin="8,0,0,8" VerticalAlignment="Top" IsVisible="{Binding CorrectionsCard.EffectiveIsVisible}" Margin="8,0,0,8" VerticalAlignment="Top"
Background="{DynamicResource SystemControlBackgroundAltHighBrush}" Background="{DynamicResource SystemControlBackgroundAltHighBrush}"
CornerRadius="8" Padding="16"> CornerRadius="8" Padding="16">
<StackPanel> <StackPanel>
@@ -486,7 +516,7 @@
<!-- Ungeplante Stunden --> <!-- Ungeplante Stunden -->
<Border Grid.Column="{Binding UnplannedCard.Column}" Grid.Row="{Binding UnplannedCard.Row}" <Border Grid.Column="{Binding UnplannedCard.Column}" Grid.Row="{Binding UnplannedCard.Row}"
IsVisible="{Binding UnplannedCard.IsVisible}" Margin="0,0,8,8" VerticalAlignment="Top" IsVisible="{Binding UnplannedCard.EffectiveIsVisible}" Margin="0,0,8,8" VerticalAlignment="Top"
Background="{DynamicResource SystemControlBackgroundAltHighBrush}" Background="{DynamicResource SystemControlBackgroundAltHighBrush}"
CornerRadius="8" Padding="16"> CornerRadius="8" Padding="16">
<StackPanel> <StackPanel>
@@ -516,7 +546,7 @@
<!-- Auffälligkeiten (9.5) --> <!-- Auffälligkeiten (9.5) -->
<Border Grid.Column="{Binding AlertsCard.Column}" Grid.Row="{Binding AlertsCard.Row}" <Border Grid.Column="{Binding AlertsCard.Column}" Grid.Row="{Binding AlertsCard.Row}"
IsVisible="{Binding AlertsCard.IsVisible}" Margin="0,0,8,8" VerticalAlignment="Top" IsVisible="{Binding AlertsCard.EffectiveIsVisible}" Margin="0,0,8,8" VerticalAlignment="Top"
Background="{DynamicResource SystemControlBackgroundAltHighBrush}" Background="{DynamicResource SystemControlBackgroundAltHighBrush}"
CornerRadius="8" Padding="16"> CornerRadius="8" Padding="16">
<StackPanel> <StackPanel>
@@ -550,7 +580,7 @@
<!-- Meine Lerngruppen --> <!-- Meine Lerngruppen -->
<Border Grid.Column="{Binding GroupsCard.Column}" Grid.Row="{Binding GroupsCard.Row}" <Border Grid.Column="{Binding GroupsCard.Column}" Grid.Row="{Binding GroupsCard.Row}"
IsVisible="{Binding GroupsCard.IsVisible}" IsVisible="{Binding GroupsCard.EffectiveIsVisible}"
Background="{DynamicResource SystemControlBackgroundAltHighBrush}" Background="{DynamicResource SystemControlBackgroundAltHighBrush}"
CornerRadius="8" Padding="16"> CornerRadius="8" Padding="16">
<StackPanel> <StackPanel>
@@ -5,13 +5,6 @@
x:Class="LehrerApp.Desktop.Views.Groups.GroupListView" x:Class="LehrerApp.Desktop.Views.Groups.GroupListView"
x:DataType="vm:GroupListViewModel"> x:DataType="vm:GroupListViewModel">
<UserControl.Styles>
<Style Selector="TextBlock.overdue">
<Setter Property="Foreground" Value="Red"/>
<Setter Property="FontWeight" Value="SemiBold"/>
</Style>
</UserControl.Styles>
<Grid RowDefinitions="Auto,*"> <Grid RowDefinitions="Auto,*">
<!-- Kopfzeile mit Schuljahr-Wähler und Neu-Button --> <!-- Kopfzeile mit Schuljahr-Wähler und Neu-Button -->
@@ -28,180 +21,85 @@
</Grid> </Grid>
</Border> </Border>
<!-- Master-Detail: Liste links, Übersicht rechts --> <!-- Eine Navigationsebene: Karten öffnen direkt das Gruppendetail. Die Verwaltung sitzt
<Grid Grid.Row="1" ColumnDefinitions="260,*"> am jeweiligen Eintrag und benötigt keine vorgeschaltete Bereichsauswahl mehr. -->
<Grid Grid.Row="1" RowDefinitions="Auto,*">
<!-- Linke Spalte: Sucheingabe + Listenansicht --> <Grid Grid.Row="0" ColumnDefinitions="*,Auto" Margin="24,18,24,8">
<Border Grid.Column="0" <TextBox Grid.Column="0" Text="{Binding SearchText}"
BorderBrush="{DynamicResource SystemControlForegroundBaseLowBrush}" PlaceholderText="Name oder Fach suchen …" MaxWidth="560"
BorderThickness="0,0,1,0"> HorizontalAlignment="Stretch"/>
<DockPanel> <ToggleSwitch Grid.Column="1" Content="Archiv anzeigen" IsChecked="{Binding ShowArchived}"
<StackPanel DockPanel.Dock="Top" Margin="12,8" Spacing="8"> Margin="20,0,0,0" VerticalAlignment="Center"/>
<TextBox Text="{Binding SearchText}" PlaceholderText="Suchen…"/>
<ToggleSwitch Content="Archiv anzeigen" IsChecked="{Binding ShowArchived}"/>
</StackPanel>
<TextBlock Text="{Binding EmptyListMessage}" TextWrapping="Wrap"
Margin="16,12" FontSize="12" Opacity="0.45"
IsVisible="{Binding HasNoGroups}"/>
<ListBox ItemsSource="{Binding Groups}"
SelectedItem="{Binding SelectedGroup}">
<ListBox.ItemTemplate>
<DataTemplate DataType="vm:GroupListItem">
<Grid ColumnDefinitions="4,*" Margin="2,4">
<Border Grid.Column="0" Width="4" CornerRadius="2"
Background="{DynamicResource SystemAccentColor}"
Margin="0,0,10,0"/>
<StackPanel Grid.Column="1">
<Grid ColumnDefinitions="*,Auto">
<TextBlock Grid.Column="0" Text="{Binding Name}"
FontWeight="SemiBold" FontSize="13"/>
<TextBlock Grid.Column="1" Text="{Binding TypeLabel}"
FontSize="11" Opacity="0.5"/>
</Grid> </Grid>
<TextBlock Text="{Binding Subject}" FontSize="12" Opacity="0.65"
<StackPanel Grid.Row="1" HorizontalAlignment="Center" VerticalAlignment="Center" Spacing="10"
IsVisible="{Binding HasNoGroups}">
<TextBlock Text="{Binding EmptyListMessage}" FontSize="15" Opacity="0.55"
TextWrapping="Wrap" TextAlignment="Center"/>
<Button Content=" Erste Lerngruppe anlegen" Command="{Binding AddGroupCommand}"
IsVisible="{Binding !ShowArchived}" HorizontalAlignment="Center"/>
</StackPanel>
<ScrollViewer Grid.Row="1" IsVisible="{Binding !HasNoGroups}"
HorizontalScrollBarVisibility="Disabled">
<ItemsControl ItemsSource="{Binding Groups}" Margin="20,12,20,24">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate>
<WrapPanel Orientation="Horizontal"/>
</ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:GroupListItem">
<Border Width="330" MinHeight="116" Margin="6" CornerRadius="9"
Background="{DynamicResource AppCardBackgroundBrush}"
BorderBrush="{DynamicResource AppCardBorderBrush}" BorderThickness="1">
<Grid ColumnDefinitions="4,*,Auto">
<Border Grid.Column="0" Background="{DynamicResource SystemAccentColor}"
CornerRadius="9,0,0,9"/>
<Button Grid.Column="1" Background="Transparent" BorderThickness="0"
Padding="16,14" HorizontalAlignment="Stretch"
HorizontalContentAlignment="Stretch" VerticalContentAlignment="Stretch"
Command="{Binding OpenCommand}"
AutomationProperties.Name="{Binding OpenAutomationName}">
<StackPanel Spacing="6">
<TextBlock Text="{Binding Name}" FontSize="17" FontWeight="SemiBold"
TextTrimming="CharacterEllipsis"/>
<TextBlock Text="{Binding Subject}" FontSize="13" Opacity="0.7"
TextTrimming="CharacterEllipsis"
IsVisible="{Binding Subject, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/> IsVisible="{Binding Subject, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
<TextBlock Text="{Binding GradingLabel}" FontSize="11" Opacity="0.4"/> <StackPanel Orientation="Horizontal" Spacing="7">
</StackPanel> <Border Background="{DynamicResource AppChipBackgroundBrush}" CornerRadius="8" Padding="7,2">
</Grid> <TextBlock Text="{Binding TypeLabel}" FontSize="10" Opacity="0.75"/>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
</DockPanel>
</Border> </Border>
<TextBlock Text="{Binding GradingLabel}" FontSize="11" Opacity="0.5"
<!-- Rechte Spalte: Platzhalter wenn keine Auswahl --> VerticalAlignment="Center"/>
<StackPanel Grid.Column="1" HorizontalAlignment="Center"
VerticalAlignment="Center" Spacing="8"
IsVisible="{Binding SelectedGroup, Converter={x:Static ObjectConverters.IsNull}}">
<TextBlock Text="Gruppe auswählen" FontSize="16" Opacity="0.4"
HorizontalAlignment="Center"/>
<TextBlock Text="oder Neue Gruppe anlegen" FontSize="12" Opacity="0.3"
HorizontalAlignment="Center"/>
</StackPanel> </StackPanel>
<!-- Rechte Spalte: Gruppen-Übersicht wenn ausgewählt -->
<ScrollViewer Grid.Column="1"
IsVisible="{Binding SelectedGroup, Converter={x:Static ObjectConverters.IsNotNull}}">
<StackPanel Margin="28,24" Spacing="20">
<!-- Gruppenname und Kurzinfos -->
<Grid ColumnDefinitions="*,Auto">
<StackPanel Grid.Column="0" Spacing="4">
<TextBlock Text="{Binding SelectedGroupDisplayName}"
FontSize="24" FontWeight="SemiBold" TextWrapping="Wrap"/>
<TextBlock Text="{Binding SelectedGroupSubtitle}"
FontSize="12" Opacity="0.55"/>
</StackPanel> </StackPanel>
<Button Grid.Column="1" Content="⋯ Verwalten" VerticalAlignment="Top" </Button>
Margin="16,0,0,0"> <Button Grid.Column="2" Content="⋯" Width="36" Height="32" Margin="0,10,10,0"
Padding="0" VerticalAlignment="Top"
ToolTip.Tip="Lerngruppe verwalten"
AutomationProperties.Name="{Binding ManageAutomationName}">
<Button.Flyout> <Button.Flyout>
<MenuFlyout> <MenuFlyout>
<MenuItem Header="Details bearbeiten" <MenuItem Header="Details bearbeiten" IsEnabled="{Binding IsActive}"
Command="{Binding EditGroupCommand}"/> Command="{Binding EditCommand}"/>
<MenuItem Header="Ins nächste Schuljahr übernehmen …" <MenuItem Header="Ins nächste Schuljahr übernehmen …"
Command="{Binding RollOverGroupCommand}"/> Command="{Binding RollOverCommand}"/>
<MenuItem Header="{Binding SelectedGroup.ArchiveActionLabel}" <MenuItem Header="{Binding ArchiveActionLabel}"
Command="{Binding ToggleArchiveCommand}"/> Command="{Binding ToggleArchiveCommand}"/>
<Separator/> <Separator/>
<MenuItem Header="Teilnehmer importieren (bald)" IsEnabled="False" <MenuItem Header="Lerngruppe löschen" IsEnabled="{Binding IsActive}"
ToolTip.Tip="Import aus dem Teilnehmerexport der Lernplattform folgt."/> Command="{Binding DeleteCommand}"/>
<Separator/>
<MenuItem Header="Lerngruppe löschen"
Command="{Binding DeleteGroupCommand}"/>
</MenuFlyout> </MenuFlyout>
</Button.Flyout> </Button.Flyout>
</Button> </Button>
</Grid> </Grid>
</Border>
<Separator/>
<!-- Schnellüberblick (Nutzer-Feedback) -->
<StackPanel Spacing="8" IsVisible="{Binding QuickHasAnything}">
<TextBlock Text="SCHNELLÜBERBLICK" FontSize="10" FontWeight="Bold" Opacity="0.4"/>
<StackPanel Spacing="1" IsVisible="{Binding QuickHasNextLesson}">
<TextBlock Text="Nächste Stunde" FontSize="11" Opacity="0.55"/>
<TextBlock Text="{Binding QuickNextLessonLabel}" FontSize="13" TextWrapping="Wrap"/>
</StackPanel>
<StackPanel Spacing="1" IsVisible="{Binding QuickHasNextExam}">
<TextBlock Text="Nächste Klausur" FontSize="11" Opacity="0.55"/>
<TextBlock Text="{Binding QuickNextExamLabel}" FontSize="13" TextWrapping="Wrap"/>
</StackPanel>
<StackPanel Spacing="4" IsVisible="{Binding QuickHasTasks}">
<TextBlock Text="Wichtige Aufgaben" FontSize="11" Opacity="0.55"/>
<ItemsControl ItemsSource="{Binding QuickTasks}">
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:GroupTaskItem">
<Grid ColumnDefinitions="4,Auto,*,Auto" Margin="0,2">
<Border Grid.Column="0" Background="{Binding PriorityColorHex}" CornerRadius="2"
Margin="0,0,6,0" IsVisible="{Binding IsHighPriority}"/>
<TextBlock Grid.Column="1" Text="🔔" FontSize="11" Margin="0,0,4,0"
IsVisible="{Binding IsReminder}" VerticalAlignment="Center"/>
<TextBlock Grid.Column="2" Text="{Binding Title}" FontSize="12"
TextTrimming="CharacterEllipsis" VerticalAlignment="Center"/>
<TextBlock Grid.Column="3" Text="{Binding DueDateDisplay}" FontSize="11"
VerticalAlignment="Center" Classes.overdue="{Binding IsOverdue}"/>
</Grid>
</DataTemplate> </DataTemplate>
</ItemsControl.ItemTemplate> </ItemsControl.ItemTemplate>
</ItemsControl> </ItemsControl>
</StackPanel>
</StackPanel>
<Separator IsVisible="{Binding QuickHasAnything}"/>
<!-- Bereichs-Navigation -->
<TextBlock Text="BEREICHE" FontSize="10" FontWeight="Bold"
Opacity="0.4" Margin="0,0,0,2"/>
<StackPanel Spacing="6">
<Button Content="📊 Übersicht"
HorizontalAlignment="Stretch" HorizontalContentAlignment="Left"
Padding="12,9"
Command="{Binding NavigateToSectionCommand}"
CommandParameter="0"/>
<Button Content="👤 Schülerliste"
HorizontalAlignment="Stretch" HorizontalContentAlignment="Left"
Padding="12,9"
Command="{Binding NavigateToSectionCommand}"
CommandParameter="1"/>
<Button Content="🪑 Sitzpläne"
HorizontalAlignment="Stretch" HorizontalContentAlignment="Left"
Padding="12,9"
Command="{Binding NavigateToSectionCommand}"
CommandParameter="2"/>
<Button Content="✋ Mitarbeit"
HorizontalAlignment="Stretch" HorizontalContentAlignment="Left"
Padding="12,9"
Command="{Binding NavigateToSectionCommand}"
CommandParameter="3"/>
<Button Content="📝 Klausuren"
HorizontalAlignment="Stretch" HorizontalContentAlignment="Left"
Padding="12,9"
Command="{Binding NavigateToSectionCommand}"
CommandParameter="4"/>
<Button Content="🔢 Notenübersicht"
HorizontalAlignment="Stretch" HorizontalContentAlignment="Left"
Padding="12,9"
Command="{Binding NavigateToSectionCommand}"
CommandParameter="5"/>
<Button Content="📅 Unterrichtsplanung"
HorizontalAlignment="Stretch" HorizontalContentAlignment="Left"
Padding="12,9"
Command="{Binding NavigateToSectionCommand}"
CommandParameter="6"/>
<Button Content="🎯 Kompetenzübersicht"
HorizontalAlignment="Stretch" HorizontalContentAlignment="Left"
Padding="12,9"
Command="{Binding NavigateToSectionCommand}"
CommandParameter="7"/>
<Button Content="📋 Dokumentation"
HorizontalAlignment="Stretch" HorizontalContentAlignment="Left"
Padding="12,9"
Command="{Binding NavigateToSectionCommand}"
CommandParameter="8"/>
</StackPanel>
</StackPanel>
</ScrollViewer> </ScrollViewer>
</Grid> </Grid>
</Grid> </Grid>
</UserControl> </UserControl>
+85
View File
@@ -146,6 +146,20 @@
<ScrollViewer> <ScrollViewer>
<StackPanel Classes="navitems" Margin="8,12,8,0" Spacing="2"> <StackPanel Classes="navitems" Margin="8,12,8,0" Spacing="2">
<Button Classes="navitem" HorizontalAlignment="Stretch"
HorizontalContentAlignment="Left" CornerRadius="6"
Click="OnOpenCommandPaletteClick"
ToolTip.Tip="Suchen und schnell erfassen (Strg/⌘+K)"
AutomationProperties.Name="Suchen und schnell erfassen">
<Grid ColumnDefinitions="Auto,*,Auto">
<TextBlock Classes="navicon" Text="⌕" TextAlignment="Center"/>
<TextBlock Grid.Column="1" Classes="navlabel" Text="Suchen / Erfassen"/>
<TextBlock Grid.Column="2" Classes="navlabel" Text="⌘K" FontSize="10" Opacity="0.45"
VerticalAlignment="Center"/>
</Grid>
</Button>
<Separator Margin="4,6"/>
<Button Classes="navitem" Classes.active="{Binding IsDashboardActive}" HorizontalAlignment="Stretch" <Button Classes="navitem" Classes.active="{Binding IsDashboardActive}" HorizontalAlignment="Stretch"
HorizontalContentAlignment="Left" HorizontalContentAlignment="Left"
CornerRadius="6" CornerRadius="6"
@@ -241,6 +255,77 @@
</DrawerPage> </DrawerPage>
<!-- Globale Suche und Schnellerfassung (14.2). Bewusst als Overlay auf der aktuellen Seite:
Der Nutzer behält den Kontext und kann mit Escape ohne Navigation zurückkehren. -->
<Border Background="#A0000000" IsVisible="{Binding IsCommandPaletteOpen}"
AutomationProperties.Name="Globale Suche und Schnellerfassung">
<Grid>
<Button Background="Transparent" BorderThickness="0"
Command="{Binding CloseCommandPaletteCommand}"
AutomationProperties.Name="Suche schließen"/>
<Border Width="680" MaxHeight="570" Margin="24" Padding="0"
HorizontalAlignment="Center" VerticalAlignment="Top"
Background="{DynamicResource AppCardBackgroundBrush}"
BorderBrush="{DynamicResource AppCardBorderBrush}" BorderThickness="1"
CornerRadius="12">
<Grid RowDefinitions="Auto,Auto,*,Auto">
<Grid Grid.Row="0" ColumnDefinitions="Auto,*,Auto" Margin="18,16,18,10">
<TextBlock Text="⌕" FontSize="24" VerticalAlignment="Center" Margin="0,0,10,0"/>
<TextBox x:Name="CommandPaletteSearchBox" Grid.Column="1"
Text="{Binding CommandPalette.Query, UpdateSourceTrigger=PropertyChanged}"
PlaceholderText="Schüler, Lerngruppe, Klausur oder Aufgabe suchen …"
FontSize="16" BorderThickness="0" Background="Transparent"
AutomationProperties.Name="Suchbegriff"/>
<Button Grid.Column="2" Content="Esc" FontSize="10" Padding="8,3"
Command="{Binding CloseCommandPaletteCommand}"
AutomationProperties.Name="Suche schließen"/>
</Grid>
<Separator Grid.Row="1"/>
<ListBox Grid.Row="2" ItemsSource="{Binding CommandPalette.Results}"
SelectedItem="{Binding CommandPalette.SelectedResult}"
Background="Transparent" BorderThickness="0" Margin="8"
MaxHeight="420">
<ListBox.ItemTemplate>
<DataTemplate x:DataType="vm:GlobalSearchResult">
<Button Background="Transparent" BorderThickness="0" Padding="10,8"
HorizontalAlignment="Stretch" HorizontalContentAlignment="Stretch"
Command="{Binding $parent[Window].((vm:MainWindowViewModel)DataContext).CommandPalette.ExecuteCommand}"
CommandParameter="{Binding}">
<Grid ColumnDefinitions="38,*,Auto">
<Border Width="30" Height="30" CornerRadius="7"
Background="{DynamicResource AppAccentSoftBackgroundBrush}"
VerticalAlignment="Center">
<TextBlock Text="{Binding Icon}" FontWeight="SemiBold"
Foreground="{DynamicResource AppAccentOnSoftBrush}"
HorizontalAlignment="Center" VerticalAlignment="Center"/>
</Border>
<StackPanel Grid.Column="1" Margin="10,0">
<TextBlock Text="{Binding Title}" FontSize="14" FontWeight="SemiBold"/>
<TextBlock Text="{Binding Subtitle}" FontSize="11" Opacity="0.6"
TextTrimming="CharacterEllipsis"/>
</StackPanel>
<Border Grid.Column="2" Padding="7,3" CornerRadius="8"
Background="{DynamicResource AppChipBackgroundBrush}"
VerticalAlignment="Center">
<TextBlock Text="{Binding KindLabel}" FontSize="10" Opacity="0.7"/>
</Border>
</Grid>
</Button>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
<TextBlock Grid.Row="2" Text="Keine passenden Ergebnisse."
Classes="emptyhint" HorizontalAlignment="Center" Margin="20"
IsVisible="{Binding CommandPalette.ShowNoResults}"/>
<Grid Grid.Row="3" ColumnDefinitions="*,Auto" Margin="18,8,18,14">
<TextBlock Text="↑↓ auswählen · Enter öffnen · Esc schließen" FontSize="10" Opacity="0.5"/>
<TextBlock Grid.Column="1" Text="Strg/⌘ + K" FontSize="10" Opacity="0.5"/>
</Grid>
</Grid>
</Border>
</Grid>
</Border>
<!-- Dauerhafte Meldung bei einer echten Protokoll-Inkompatibilität. Als Overlay außerhalb <!-- Dauerhafte Meldung bei einer echten Protokoll-Inkompatibilität. Als Overlay außerhalb
von DrawerPage.Content bleibt der DataContext das MainWindowViewModel; innerhalb des von DrawerPage.Content bleibt der DataContext das MainWindowViewModel; innerhalb des
ContentPresenters würde eine fehlgeschlagene Bindung IsVisible auf true stehen lassen. --> ContentPresenters würde eine fehlgeschlagene Bindung IsVisible auf true stehen lassen. -->
@@ -1,5 +1,6 @@
using Avalonia.Controls; using Avalonia.Controls;
using Avalonia.Input; using Avalonia.Input;
using Avalonia.Threading;
using LehrerApp.Desktop.Services; using LehrerApp.Desktop.Services;
using LehrerApp.Desktop.ViewModels; using LehrerApp.Desktop.ViewModels;
using LehrerApp.Sync; using LehrerApp.Sync;
@@ -19,6 +20,62 @@ public partial class MainWindow : Window
PointerMoved += (_, _) => NotifyActivity(); PointerMoved += (_, _) => NotifyActivity();
PointerPressed += (_, _) => NotifyActivity(); PointerPressed += (_, _) => NotifyActivity();
KeyDown += (_, _) => 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<TextBox>("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) public void EnableFinalSync(SyncEngine syncEngine)
+32 -2
View File
@@ -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 obersten Karten des Kurs-Dashboards, hier nur ohne eigenen Tab-Wechsel. Sowie die beiden
fehlenden Buttons "📊 Übersicht" (Tab 0) und "✋ Mitarbeit" (Tab 3). 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 ### 6.2 Zeiterfassung
- [x] **6.2.1** Timer starten/stoppen mit Zuordnung zu Aufgabe oder Kategorie — - [x] **6.2.1** Timer starten/stoppen mit Zuordnung zu Aufgabe oder Kategorie —
`TimeTrackingViewModel`. Bewusst ohne live mitlaufende Sekundenanzeige (keine `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 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 oder gelöscht werden. Reine Ansichten und das Kopieren einer Unterrichtseinheit als Vorlage
in eine aktive Gruppe bleiben möglich. 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 ### 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 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. 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. - [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 die Konfiguration wird lokal in `dashboardsettings.json` gespeichert und das Raster ohne
Lücken neu angeordnet. Lücken neu angeordnet.
- [x] **9.7** Automatische Aktualisierung beim Zurücknavigieren. - [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 PeriodNumber`-Doku) wie in `TimetableViewModel.BuildToday` für die "Heute"-Ansicht des
Stundenplans. 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 ## 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 - [ ] **14.1** Tastaturbedienung durchgängig: alle Hauptfunktionen ohne Maus erreichbar
(Vorbild: Mitarbeit-Schnelleingabe). (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). - [~] **14.3** Rückgängig-Funktion für Löschvorgänge (mindestens Bestätigungsdialog überall).
**Umsetzung:** generischer, nicht-invasiver "Papierkorb light" statt einer **Umsetzung:** generischer, nicht-invasiver "Papierkorb light" statt einer