Dashboard und Schnellnavigation fix

This commit is contained in:
2026-08-29 19:35:05 +02:00
parent 7d3d021d09
commit b7a105af73
18 changed files with 368 additions and 31 deletions
+1 -1
View File
@@ -93,7 +93,7 @@ public interface IHasAttachments
List<DocumentAttachment> Attachments { get; } List<DocumentAttachment> Attachments { get; }
} }
// Hinten angefügt, damit die numerischen Werte bereits gespeicherter LiteDB-Einträge stabil bleiben. // Hinten angefügt, damit die numerischen Werte bereits gespeicherter LiteDB-Einträge stabil bleiben.
public enum DocumentationType { Conversation, Incident, SupportPlan, Absence, ParentCall, ParentLetter } public enum DocumentationType { Conversation, Incident, SupportPlan, Absence, ParentCall, ParentLetter, Planning }
public enum SupportStatus { Active, Completed, Paused } public enum SupportStatus { Active, Completed, Paused }
public class WorkTask public class WorkTask
@@ -59,12 +59,12 @@ public sealed class DashboardViewModelTests
DashboardSettingsService? dashboardSettings = null, FakeSchoolHolidays? schoolHolidays = null, DashboardSettingsService? dashboardSettings = null, FakeSchoolHolidays? schoolHolidays = null,
FakeLessons? lessons = null, FakeSubstitutionEntries? substitutions = null, FakeLessons? lessons = null, FakeSubstitutionEntries? substitutions = null,
FakeSessions? sessions = null, FakeEntries? entries = null, FakeSessions? sessions = null, FakeEntries? entries = null,
FakeAnnualPlanEvents? annualPlanEvents = null) FakeAnnualPlanEvents? annualPlanEvents = null, List<LearningGroup>? allGroups = null)
{ {
lessons ??= new FakeLessons(); lessons ??= new FakeLessons();
lessons.Add(lesson); lessons.Add(lesson);
return new DashboardViewModel( return new DashboardViewModel(
new FakeGroups([group]), new FakeSubjects([]), lessons, new FakeGroups(allGroups ?? [group]), new FakeSubjects([]), lessons,
exams ?? new FakeExams([]), results ?? new FakeResults(), grades ?? new FakeGrades(), exams ?? new FakeExams([]), results ?? new FakeResults(), grades ?? new FakeGrades(),
reportGrades ?? new FakeReportGrades(), memberships ?? new FakeMemberships([]), reportGrades ?? new FakeReportGrades(), memberships ?? new FakeMemberships([]),
tasks ?? new FakeWorkTasks(), sessions ?? new FakeSessions([]), entries ?? new FakeEntries(), tasks ?? new FakeWorkTasks(), sessions ?? new FakeSessions([]), entries ?? new FakeEntries(),
@@ -242,6 +242,26 @@ public sealed class DashboardViewModelTests
Assert.Contains(vm.SelectedDayEvents, e => e.Kind == CalendarEventKind.Exam && e.Title == "Test"); Assert.Contains(vm.SelectedDayEvents, e => e.Kind == CalendarEventKind.Exam && e.Title == "Test");
} }
[Fact]
public void Kalenderauswahl_SortiertKurseDesTagesVorDenAlphabetischenRest()
{
var today = DateOnly.FromDateTime(DateTime.Today);
var selectedDate = today.AddDays(1);
var alphabeticallyFirst = new LearningGroup { Name = "10a" };
var selectedDayGroup = new LearningGroup { Name = "WAT 10c" };
var lessons = new FakeLessons();
lessons.Add(new Lesson { GroupId = selectedDayGroup.Id, Date = selectedDate });
var vm = BuildVm(alphabeticallyFirst,
new Lesson { GroupId = alphabeticallyFirst.Id, Date = today }, lessons: lessons,
allGroups: [alphabeticallyFirst, selectedDayGroup]);
vm.SelectCalendarDayCommand.Execute(vm.CalendarDays.Single(d => d.Date == selectedDate));
Assert.Equal(selectedDayGroup.Id, vm.CurrentGroups[0].GroupId);
Assert.True(vm.CurrentGroups[0].IsOnSelectedDay);
Assert.Equal(alphabeticallyFirst.Id, vm.CurrentGroups[1].GroupId);
}
[Fact] [Fact]
public void Kalender_ZeigtMitarbeitssitzungenAlsEigenenTermintyp() public void Kalender_ZeigtMitarbeitssitzungenAlsEigenenTermintyp()
{ {
@@ -247,6 +247,25 @@ public sealed class DocumentationDialogViewModelTests
Assert.Equal(groupId, vm.Result.GroupId); Assert.Equal(groupId, vm.Result.GroupId);
} }
[Fact]
public void GesamteLerngruppe_SaveSpeichertOhneSchuelerbezug()
{
var groupId = Guid.NewGuid();
var wholeGroup = new StudentOption(Guid.Empty, "Gesamte Lerngruppe");
var vm = new DocumentationDialogViewModel(Guid.Empty, null, new FakeAttachmentStorage(),
[wholeGroup], groupId)
{
SelectedStudent = wholeGroup, Title = "Klausur planen", TypeName = "Planung / Erinnerung",
};
vm.SaveCommand.Execute(null);
Assert.NotNull(vm.Result);
Assert.Equal(Guid.Empty, vm.Result!.StudentId);
Assert.Equal(groupId, vm.Result.GroupId);
Assert.Equal(DocumentationType.Planning, vm.Result.Type);
}
[Fact] [Fact]
public void MitStudentOptions_Bearbeiten_SchuelerIstVorausgewaehlt() public void MitStudentOptions_Bearbeiten_SchuelerIstVorausgewaehlt()
{ {
@@ -14,7 +14,8 @@ public sealed class GlobalSearchViewModelTests
Assert.Collection(vm.Results, Assert.Collection(vm.Results,
item => Assert.Equal(GlobalSearchAction.NewTask, item.Action), item => Assert.Equal(GlobalSearchAction.NewTask, item.Action),
item => Assert.Equal(GlobalSearchAction.NewReminder, item.Action), item => Assert.Equal(GlobalSearchAction.NewReminder, item.Action),
item => Assert.Equal(GlobalSearchAction.NewStudent, item.Action)); item => Assert.Equal(GlobalSearchAction.NewStudent, item.Action),
item => Assert.Equal(GlobalSearchAction.NewGroupDocumentation, item.Action));
Assert.Same(vm.Results[0], vm.SelectedResult); Assert.Same(vm.Results[0], vm.SelectedResult);
} }
@@ -67,8 +68,36 @@ public sealed class GlobalSearchViewModelTests
Assert.True(reminder); Assert.True(reminder);
} }
[Fact]
public void Suche_FindetAktiveGruppeUeberFachUndZeigtFachImUntertitel()
{
var subject = new Subject { Name = "Wirtschaft-Arbeit-Technik", ShortName = "WAT" };
var group = new LearningGroup { Name = "10c", SubjectId = subject.Id, SchoolYear = "2026/27", GradeLevel = 10 };
var vm = BuildVm(groups: [group], subjects: [subject]);
vm.Query = "wat";
var result = Assert.Single(vm.Results, x => x.Kind == GlobalSearchResultKind.Group);
Assert.Contains("WAT", result.Subtitle);
}
[Fact]
public void Suche_UeberspringtArchivierteSchuelerUndGruppenSamtDerenKlausuren()
{
var archivedGroup = new LearningGroup { Name = "Testkurs Vorjahr", IsActive = false };
var archivedStudent = new Student { FirstName = "Test", LastName = "Archiv", IsActive = false };
var exam = new Exam { GroupId = archivedGroup.Id, Title = "Testklausur" };
var vm = BuildVm([archivedStudent], [archivedGroup], [exam]);
vm.Query = "Test";
Assert.DoesNotContain(vm.Results, x => x.Kind is GlobalSearchResultKind.Student
or GlobalSearchResultKind.Group or GlobalSearchResultKind.Exam);
}
private static GlobalSearchViewModel BuildVm(List<Student>? students = null, private static GlobalSearchViewModel BuildVm(List<Student>? students = null,
List<LearningGroup>? groups = null, List<Exam>? exams = null, FakeWorkTasks? tasks = null) => List<LearningGroup>? groups = null, List<Exam>? exams = null, FakeWorkTasks? tasks = null,
List<Subject>? subjects = null) =>
new(new FakeStudents(students ?? []), new FakeGroups(groups ?? []), new(new FakeStudents(students ?? []), new FakeGroups(groups ?? []),
new FakeExams(exams ?? []), tasks ?? new FakeWorkTasks()); new FakeSubjects(subjects ?? []), new FakeExams(exams ?? []), tasks ?? new FakeWorkTasks());
} }
@@ -0,0 +1,26 @@
using LehrerApp.Core.Models;
using LehrerApp.Desktop.ViewModels.Students;
using Xunit;
namespace LehrerApp.Desktop.Tests;
public sealed class GroupDocumentationQuickViewModelTests
{
[Fact]
public void Save_ErstelltPlanungFuerGesamteLerngruppe()
{
var group = new GroupDocumentationOption(Guid.NewGuid(), "10c · WAT");
var vm = new GroupDocumentationQuickViewModel([group])
{
SelectedGroup = group, Title = "Klausur ankündigen", Content = "Termin abstimmen",
};
vm.SaveCommand.Execute(null);
Assert.NotNull(vm.Result);
Assert.Equal(Guid.Empty, vm.Result!.StudentId);
Assert.Equal(group.Id, vm.Result.GroupId);
Assert.Equal(DocumentationType.Planning, vm.Result.Type);
Assert.True(vm.Result.ExcludeFromWebUntisSync);
}
}
@@ -36,6 +36,25 @@ public sealed class GroupDocumentationTabViewModelTests
Assert.Equal("Alt", vm.Entries[1].Model.Title); Assert.Equal("Alt", vm.Entries[1].Model.Title);
} }
[Fact]
public void Initialize_ZeigtGruppenweitenEintragAuchOhneSchueler()
{
var group = new LearningGroup { Name = "10c" };
var docs = new FakeDocumentation();
docs.Add(new Documentation
{
StudentId = Guid.Empty, GroupId = group.Id, Type = DocumentationType.Planning,
Title = "Klausur planen", Date = new DateOnly(2026, 9, 1),
});
var vm = BuildVm([], [group], docs);
vm.Initialize(group.Id);
var entry = Assert.Single(vm.Entries);
Assert.Equal("Gesamte Lerngruppe", entry.StudentName);
Assert.Equal("Planung / Erinnerung", entry.TypeLabel);
}
[Fact] [Fact]
public void Initialize_EintragAusAndererGruppe_WirdMitangezeigtAberAlsFremdMarkiert() public void Initialize_EintragAusAndererGruppe_WirdMitangezeigtAberAlsFremdMarkiert()
{ {
+23
View File
@@ -170,6 +170,7 @@ public class App : Application
}; };
search.OnQuickAddTask = startAsReminder => ShowQuickTaskDialog(startAsReminder, dash); search.OnQuickAddTask = startAsReminder => ShowQuickTaskDialog(startAsReminder, dash);
search.OnQuickAddStudent = ShowAddStudentDialog; search.OnQuickAddStudent = ShowAddStudentDialog;
search.OnQuickAddGroupDocumentation = () => ShowQuickGroupDocumentationDialog(dash);
// StudentList → StudentDetail + Anlegen // StudentList → StudentDetail + Anlegen
var sl = Services.GetRequiredService<StudentListViewModel>(); var sl = Services.GetRequiredService<StudentListViewModel>();
@@ -203,4 +204,26 @@ public class App : Application
dashboard.RefreshCommand.Execute(null); dashboard.RefreshCommand.Execute(null);
Services.GetRequiredService<WorkTaskListViewModel>().Load(); Services.GetRequiredService<WorkTaskListViewModel>().Load();
} }
private static async Task ShowQuickGroupDocumentationDialog(DashboardViewModel dashboard)
{
if (Application.Current?.ApplicationLifetime is not IClassicDesktopStyleApplicationLifetime
{ MainWindow: { } owner }) return;
var subjects = Services.GetRequiredService<ISubjectRepository>().GetAll()
.ToDictionary(s => s.Id);
var options = Services.GetRequiredService<IGroupRepository>().GetAll()
.Where(g => g.IsActive)
.OrderBy(g => g.Name)
.Select(g => new GroupDocumentationOption(g.Id,
g.SubjectId is { } subjectId && subjects.TryGetValue(subjectId, out var subject)
? $"{g.Name} · {(string.IsNullOrWhiteSpace(subject.ShortName) ? subject.Name : subject.ShortName)}"
: g.Name));
var vm = new GroupDocumentationQuickViewModel(options);
var dialog = new Views.Students.GroupDocumentationQuickDialog { DataContext = vm };
if (!await dialog.ShowDialog<bool>(owner) || vm.Result is null) return;
Services.GetRequiredService<IDocumentationRepository>().Save(vm.Result);
dashboard.RefreshCommand.Execute(null);
}
} }
@@ -56,6 +56,7 @@ public partial class DashboardViewModel : ObservableObject
[ObservableProperty] private string _currentSchoolYear = ""; [ObservableProperty] private string _currentSchoolYear = "";
[ObservableProperty] private DateOnly _calendarMonth = FirstOfMonth(DateTime.Today); [ObservableProperty] private DateOnly _calendarMonth = FirstOfMonth(DateTime.Today);
[ObservableProperty] private string _selectedDayLabel = ""; [ObservableProperty] private string _selectedDayLabel = "";
[ObservableProperty] private DateOnly _selectedCalendarDate = DateOnly.FromDateTime(DateTime.Today);
[ObservableProperty] private bool _isDashboardSettingsOpen; [ObservableProperty] private bool _isDashboardSettingsOpen;
[ObservableProperty] private bool _isWeatherPanelVisible; [ObservableProperty] private bool _isWeatherPanelVisible;
[ObservableProperty] private string _weatherSummary = ""; [ObservableProperty] private string _weatherSummary = "";
@@ -196,7 +197,7 @@ public partial class DashboardViewModel : ObservableObject
IsHighPriority = t.Priority == TaskPriority.High }); IsHighPriority = t.Priority == TaskPriority.High });
CurrentGroups.Clear(); CurrentGroups.Clear();
foreach (var g in groups.Values.OrderBy(g => g.Name)) foreach (var g in groups.Values)
CurrentGroups.Add(new() CurrentGroups.Add(new()
{ {
GroupId = g.Id, GroupId = g.Id,
@@ -749,9 +750,35 @@ public partial class DashboardViewModel : ObservableObject
{ {
if (day is null) return; if (day is null) return;
foreach (var cell in CalendarDays) cell.IsSelected = cell == day; foreach (var cell in CalendarDays) cell.IsSelected = cell == day;
SelectedCalendarDate = day.Date;
SelectedDayLabel = day.Date.ToString("dddd, d. MMMM", De); SelectedDayLabel = day.Date.ToString("dddd, d. MMMM", De);
SelectedDayEvents.Clear(); SelectedDayEvents.Clear();
foreach (var item in day.Events) SelectedDayEvents.Add(item); foreach (var item in day.Events) SelectedDayEvents.Add(item);
SortCurrentGroups(day.Date);
}
private void SortCurrentGroups(DateOnly date)
{
var schoolHolidays = _schoolHolidays.GetAll();
var publicHolidays = _publicHolidays.GetHolidays(date.Year, _calendarSettings.State)
.Select(h => h.Date).ToHashSet();
var isFreeDay = IsFreeDay(date, schoolHolidays, publicHolidays);
var cancelledPeriods = _substitutions.GetByDate(date)
.Where(s => s.Kind == SubstitutionKind.Cancelled)
.Select(s => s.PeriodNumber).ToHashSet();
foreach (var chip in CurrentGroups)
{
var hasLesson = _lessons.GetByGroupAndDate(chip.GroupId, date).Count > 0;
var hasActiveSlot = !isFreeDay && _timetableSlots.GetByGroup(chip.GroupId)
.Any(s => s.Weekday == date.DayOfWeek && !cancelledPeriods.Contains(s.PeriodNumber));
chip.IsOnSelectedDay = hasLesson || hasActiveSlot;
}
var sorted = CurrentGroups.OrderByDescending(g => g.IsOnSelectedDay)
.ThenBy(g => g.Name, StringComparer.CurrentCultureIgnoreCase).ToList();
CurrentGroups.Clear();
foreach (var chip in sorted) CurrentGroups.Add(chip);
} }
[RelayCommand] [RelayCommand]
@@ -891,7 +918,13 @@ public class LessonItem
public bool HasRoom => !string.IsNullOrWhiteSpace(Room); public bool HasRoom => !string.IsNullOrWhiteSpace(Room);
} }
public class TaskItem { public string Title { get; set; } = ""; public string DueDate { get; set; } = ""; public bool IsOverdue { get; set; } public bool IsReminder { get; set; } public bool IsHighPriority { get; set; } } public class TaskItem { public string Title { get; set; } = ""; public string DueDate { get; set; } = ""; public bool IsOverdue { get; set; } public bool IsReminder { get; set; } public bool IsHighPriority { get; set; } }
public class GroupChip { public Guid GroupId { get; set; } public string Name { get; set; } = ""; public string Subject { get; set; } = ""; } public class GroupChip
{
public Guid GroupId { get; set; }
public string Name { get; set; } = "";
public string Subject { get; set; } = "";
public bool IsOnSelectedDay { get; set; }
}
// ── Offene Entschuldigungen (aus Mitarbeit-Fehltagen) ──────────────────────── // ── Offene Entschuldigungen (aus Mitarbeit-Fehltagen) ────────────────────────
@@ -17,6 +17,7 @@ public partial class GlobalSearchViewModel : ObservableObject
private readonly IStudentRepository _students; private readonly IStudentRepository _students;
private readonly IGroupRepository _groups; private readonly IGroupRepository _groups;
private readonly ISubjectRepository _subjects;
private readonly IExamRepository _exams; private readonly IExamRepository _exams;
private readonly IWorkTaskRepository _tasks; private readonly IWorkTaskRepository _tasks;
@@ -30,13 +31,15 @@ public partial class GlobalSearchViewModel : ObservableObject
public Action<GlobalSearchResult>? OnNavigate { get; set; } public Action<GlobalSearchResult>? OnNavigate { get; set; }
public Func<bool, Task>? OnQuickAddTask { get; set; } public Func<bool, Task>? OnQuickAddTask { get; set; }
public Func<Task>? OnQuickAddStudent { get; set; } public Func<Task>? OnQuickAddStudent { get; set; }
public Func<Task>? OnQuickAddGroupDocumentation { get; set; }
public Action? OnClose { get; set; } public Action? OnClose { get; set; }
public GlobalSearchViewModel(IStudentRepository students, IGroupRepository groups, public GlobalSearchViewModel(IStudentRepository students, IGroupRepository groups,
IExamRepository exams, IWorkTaskRepository tasks) ISubjectRepository subjects, IExamRepository exams, IWorkTaskRepository tasks)
{ {
_students = students; _students = students;
_groups = groups; _groups = groups;
_subjects = subjects;
_exams = exams; _exams = exams;
_tasks = tasks; _tasks = tasks;
RefreshResults(); RefreshResults();
@@ -59,23 +62,34 @@ public partial class GlobalSearchViewModel : ObservableObject
if (query.Length > 0) if (query.Length > 0)
{ {
var groups = _groups.GetAll(includeInactive: true); var groups = _groups.GetAll().Where(g => g.IsActive).ToList();
var groupNames = groups.ToDictionary(g => g.Id, g => g.Name); var groupNames = groups.ToDictionary(g => g.Id, g => g.Name);
var subjects = _subjects.GetAll().ToDictionary(s => s.Id);
Subject? GroupSubject(LearningGroup group) => group.SubjectId is { } subjectId
? subjects.GetValueOrDefault(subjectId)
: null;
static string SubjectLabel(Subject? subject) => subject is null ? ""
: string.IsNullOrWhiteSpace(subject.ShortName)
|| subject.Name.Equals(subject.ShortName, StringComparison.CurrentCultureIgnoreCase)
? subject.Name
: $"{subject.ShortName} {subject.Name}";
var candidates = new List<GlobalSearchResult>(); var candidates = new List<GlobalSearchResult>();
candidates.AddRange(_students.GetAll(includeInactive: true) candidates.AddRange(_students.GetAll().Where(s => s.IsActive)
.Where(s => Matches(s.FullName, query)) .Where(s => Matches(s.FullName, query))
.Select(s => GlobalSearchResult.ForStudent(s))); .Select(s => GlobalSearchResult.ForStudent(s)));
candidates.AddRange(groups candidates.AddRange(groups
.Where(g => Matches($"{g.Name} {g.SchoolYear} {g.GradeLevel}", query)) .Where(g => Matches($"{g.Name} {GroupSubject(g)?.Name} {GroupSubject(g)?.ShortName} {g.SchoolYear} {g.GradeLevel}", query))
.Select(GlobalSearchResult.ForGroup)); .Select(g => GlobalSearchResult.ForGroup(g, SubjectLabel(GroupSubject(g)))));
candidates.AddRange(_exams.GetAll() candidates.AddRange(_exams.GetAll()
.Where(e => groupNames.ContainsKey(e.GroupId))
.Where(e => Matches($"{e.Title} {groupNames.GetValueOrDefault(e.GroupId)}", query)) .Where(e => Matches($"{e.Title} {groupNames.GetValueOrDefault(e.GroupId)}", query))
.Select(e => GlobalSearchResult.ForExam(e, groupNames.GetValueOrDefault(e.GroupId) ?? ""))); .Select(e => GlobalSearchResult.ForExam(e, groupNames.GetValueOrDefault(e.GroupId) ?? "")));
candidates.AddRange(_tasks.GetAll() candidates.AddRange(_tasks.GetAll()
.Where(t => t.GroupId is null || groupNames.ContainsKey(t.GroupId.Value))
.Where(t => Matches($"{t.Title} {t.Notes} {groupNames.GetValueOrDefault(t.GroupId ?? Guid.Empty)}", query)) .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) ?? ""))); .Select(t => GlobalSearchResult.ForTask(t, groupNames.GetValueOrDefault(t.GroupId ?? Guid.Empty) ?? "")));
@@ -99,6 +113,7 @@ public partial class GlobalSearchViewModel : ObservableObject
GlobalSearchResult.ForAction(GlobalSearchAction.NewTask, "Aufgabe anlegen", "Mit Fälligkeit, Gruppe und Priorität", ""), 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.NewReminder, "Erinnerung anlegen", "Kurze Notiz ohne Zeiterfassung", "◷"),
GlobalSearchResult.ForAction(GlobalSearchAction.NewStudent, "Schüler anlegen", "Neue Stammdaten erfassen", ""), GlobalSearchResult.ForAction(GlobalSearchAction.NewStudent, "Schüler anlegen", "Neue Stammdaten erfassen", ""),
GlobalSearchResult.ForAction(GlobalSearchAction.NewGroupDocumentation, "Lerngruppen-Eintrag", "Planung, Klausur oder Erinnerung dokumentieren", "N"),
}; };
foreach (var action in actions.Where(a => query.Length == 0 || Matches(a.Title, query))) foreach (var action in actions.Where(a => query.Length == 0 || Matches(a.Title, query)))
@@ -124,6 +139,9 @@ public partial class GlobalSearchViewModel : ObservableObject
case GlobalSearchAction.NewStudent: case GlobalSearchAction.NewStudent:
if (OnQuickAddStudent is not null) await OnQuickAddStudent(); if (OnQuickAddStudent is not null) await OnQuickAddStudent();
break; break;
case GlobalSearchAction.NewGroupDocumentation:
if (OnQuickAddGroupDocumentation is not null) await OnQuickAddGroupDocumentation();
break;
default: default:
OnNavigate?.Invoke(result); OnNavigate?.Invoke(result);
break; break;
@@ -134,7 +152,7 @@ public partial class GlobalSearchViewModel : ObservableObject
} }
public enum GlobalSearchResultKind { Action, Student, Group, Exam, Task } public enum GlobalSearchResultKind { Action, Student, Group, Exam, Task }
public enum GlobalSearchAction { None, NewTask, NewReminder, NewStudent } public enum GlobalSearchAction { None, NewTask, NewReminder, NewStudent, NewGroupDocumentation }
public sealed class GlobalSearchResult public sealed class GlobalSearchResult
{ {
@@ -171,10 +189,13 @@ public sealed class GlobalSearchResult
Title = student.FullName, Subtitle = student.IsActive ? "Aktiv" : "Inaktiv", Icon = "P", Title = student.FullName, Subtitle = student.IsActive ? "Aktiv" : "Inaktiv", Icon = "P",
}; };
public static GlobalSearchResult ForGroup(LearningGroup group) => new() public static GlobalSearchResult ForGroup(LearningGroup group, string subjectName) => new()
{ {
Kind = GlobalSearchResultKind.Group, EntityId = group.Id, GroupId = group.Id, Kind = GlobalSearchResultKind.Group, EntityId = group.Id, GroupId = group.Id,
Title = group.Name, Subtitle = $"{group.SchoolYear} · Stufe {group.GradeLevel}", Icon = "G", Title = group.Name,
Subtitle = string.Join(" · ", new[] { subjectName, group.SchoolYear, $"Stufe {group.GradeLevel}" }
.Where(x => !string.IsNullOrWhiteSpace(x))),
Icon = "G",
}; };
public static GlobalSearchResult ForExam(Exam exam, string groupName) => new() public static GlobalSearchResult ForExam(Exam exam, string groupName) => new()
@@ -25,6 +25,7 @@ public partial class GroupDocumentationTabViewModel : ObservableObject
private List<StudentOption> _groupStudents = []; private List<StudentOption> _groupStudents = [];
public static readonly StudentOption AllStudentsOption = new(Guid.Empty, "Alle Schüler"); public static readonly StudentOption AllStudentsOption = new(Guid.Empty, "Alle Schüler");
public static readonly StudentOption WholeGroupOption = new(Guid.Empty, "Gesamte Lerngruppe");
public ObservableCollection<DocumentationItem> Entries { get; } = []; public ObservableCollection<DocumentationItem> Entries { get; } = [];
public ObservableCollection<StudentOption> StudentFilterOptions { get; } = [AllStudentsOption]; public ObservableCollection<StudentOption> StudentFilterOptions { get; } = [AllStudentsOption];
@@ -62,8 +63,6 @@ public partial class GroupDocumentationTabViewModel : ObservableObject
private void Load() private void Load()
{ {
Entries.Clear(); Entries.Clear();
if (_groupStudents.Count == 0) return;
var studentNameById = _groupStudents.ToDictionary(s => s.Id, s => s.Name); var studentNameById = _groupStudents.ToDictionary(s => s.Id, s => s.Name);
var groupNameCache = new Dictionary<Guid, string>(); var groupNameCache = new Dictionary<Guid, string>();
string GroupLabel(Guid id) string GroupLabel(Guid id)
@@ -80,6 +79,10 @@ public partial class GroupDocumentationTabViewModel : ObservableObject
var all = relevantStudentIds var all = relevantStudentIds
.SelectMany(id => _docs.GetByStudent(id)) .SelectMany(id => _docs.GetByStudent(id))
.Concat(SelectedStudentFilter.Id == Guid.Empty
? _docs.GetAll().Where(d => d.StudentId == Guid.Empty && d.GroupId == _groupId)
: [])
.DistinctBy(d => d.Id)
.Where(d => !OnlyThisGroup || d.GroupId == _groupId) .Where(d => !OnlyThisGroup || d.GroupId == _groupId)
.OrderByDescending(d => d.IsDraft) .OrderByDescending(d => d.IsDraft)
.ThenByDescending(d => d.Date) .ThenByDescending(d => d.Date)
@@ -90,7 +93,10 @@ public partial class GroupDocumentationTabViewModel : ObservableObject
{ {
var isOwnGroup = d.GroupId is null || d.GroupId == _groupId; var isOwnGroup = d.GroupId is null || d.GroupId == _groupId;
var otherGroupLabel = isOwnGroup ? "" : GroupLabel(d.GroupId!.Value); var otherGroupLabel = isOwnGroup ? "" : GroupLabel(d.GroupId!.Value);
Entries.Add(new DocumentationItem(d, studentNameById.GetValueOrDefault(d.StudentId, ""), var studentName = d.StudentId == Guid.Empty
? WholeGroupOption.Name
: studentNameById.GetValueOrDefault(d.StudentId, "");
Entries.Add(new DocumentationItem(d, studentName,
isOwnGroup, otherGroupLabel)); isOwnGroup, otherGroupLabel));
} }
} }
@@ -100,7 +106,7 @@ public partial class GroupDocumentationTabViewModel : ObservableObject
[RelayCommand] [RelayCommand]
private async Task AddDocumentation() private async Task AddDocumentation()
{ {
if (OnEditDocumentation is null || _groupStudents.Count == 0) return; if (OnEditDocumentation is null) return;
var result = await OnEditDocumentation(_groupId, _groupStudents, null); var result = await OnEditDocumentation(_groupId, _groupStudents, null);
if (result is null) return; if (result is null) return;
_docs.Save(result); _docs.Save(result);
@@ -17,7 +17,7 @@ public record StudentOption(Guid Id, string Name);
public static class DocumentationTypeDisplay public static class DocumentationTypeDisplay
{ {
public static string[] Options { get; } = public static string[] Options { get; } =
["Gespräch", "Vorkommnis", "Förderplan", "Fehlzeit", "Elternanruf", "Elternbrief"]; ["Gespräch", "Vorkommnis", "Förderplan", "Fehlzeit", "Elternanruf", "Elternbrief", "Planung / Erinnerung"];
public static string Label(DocumentationType t) => t switch public static string Label(DocumentationType t) => t switch
{ {
@@ -27,6 +27,7 @@ public static class DocumentationTypeDisplay
DocumentationType.Absence => "Fehlzeit", DocumentationType.Absence => "Fehlzeit",
DocumentationType.ParentCall => "Elternanruf", DocumentationType.ParentCall => "Elternanruf",
DocumentationType.ParentLetter => "Elternbrief", DocumentationType.ParentLetter => "Elternbrief",
DocumentationType.Planning => "Planung / Erinnerung",
_ => "", _ => "",
}; };
@@ -37,6 +38,7 @@ public static class DocumentationTypeDisplay
"Fehlzeit" => DocumentationType.Absence, "Fehlzeit" => DocumentationType.Absence,
"Elternanruf" => DocumentationType.ParentCall, "Elternanruf" => DocumentationType.ParentCall,
"Elternbrief" => DocumentationType.ParentLetter, "Elternbrief" => DocumentationType.ParentLetter,
"Planung / Erinnerung" => DocumentationType.Planning,
_ => DocumentationType.Conversation, _ => DocumentationType.Conversation,
}; };
} }
@@ -301,7 +303,7 @@ public partial class DocumentationDialogViewModel : ObservableObject
LetterSentDateError = ""; LetterResponseDateError = ""; LetterSentDateError = ""; LetterResponseDateError = "";
var valid = true; var valid = true;
if (CanPickStudent && SelectedStudent is null) { StudentError = "Schüler auswählen."; valid = false; } if (CanPickStudent && SelectedStudent is null) { StudentError = "Bezug auswählen."; valid = false; }
if (string.IsNullOrWhiteSpace(Title)) { TitleError = "Titel erforderlich."; valid = false; } if (string.IsNullOrWhiteSpace(Title)) { TitleError = "Titel erforderlich."; valid = false; }
@@ -0,0 +1,46 @@
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using LehrerApp.Core.Models;
using System.Globalization;
namespace LehrerApp.Desktop.ViewModels.Students;
public sealed record GroupDocumentationOption(Guid Id, string DisplayName);
/// Kompakte Erfassung einer gruppenweiten Planungsnotiz aus der globalen Befehlspalette.
public partial class GroupDocumentationQuickViewModel(IEnumerable<GroupDocumentationOption> groups)
: ObservableObject
{
public List<GroupDocumentationOption> Groups { get; } = groups.ToList();
[ObservableProperty] private GroupDocumentationOption? _selectedGroup;
[ObservableProperty] private string _title = "";
[ObservableProperty] private string _content = "";
[ObservableProperty] private string _dateText = DateOnly.FromDateTime(DateTime.Today).ToString("dd.MM.yyyy");
[ObservableProperty] private string _groupError = "";
[ObservableProperty] private string _titleError = "";
[ObservableProperty] private string _dateError = "";
public Documentation? Result { get; private set; }
[RelayCommand]
private void Save()
{
GroupError = TitleError = DateError = "";
if (SelectedGroup is null) GroupError = "Lerngruppe auswählen.";
if (string.IsNullOrWhiteSpace(Title)) TitleError = "Titel erforderlich.";
if (!DateOnly.TryParseExact(DateText, "dd.MM.yyyy", null, DateTimeStyles.None, out var date))
DateError = "Format TT.MM.JJJJ.";
if (GroupError.Length > 0 || TitleError.Length > 0 || DateError.Length > 0) return;
Result = new Documentation
{
StudentId = Guid.Empty,
GroupId = SelectedGroup!.Id,
Type = DocumentationType.Planning,
Date = date,
Title = Title.Trim(),
Content = Content.Trim(),
ExcludeFromWebUntisSync = true,
};
}
}
@@ -586,10 +586,10 @@
<StackPanel> <StackPanel>
<TextBlock Text="MEINE LERNGRUPPEN" FontSize="11" FontWeight="Bold" <TextBlock Text="MEINE LERNGRUPPEN" FontSize="11" FontWeight="Bold"
Opacity="0.5" Margin="0,0,0,10"/> Opacity="0.5" Margin="0,0,0,10"/>
<ItemsControl ItemsSource="{Binding CurrentGroups}"> <ItemsControl ItemsSource="{Binding CurrentGroups}" HorizontalAlignment="Stretch">
<ItemsControl.ItemsPanel> <ItemsControl.ItemsPanel>
<ItemsPanelTemplate> <ItemsPanelTemplate>
<WrapPanel Orientation="Horizontal"/> <WrapPanel Orientation="Horizontal" ItemSpacing="8" LineSpacing="8"/>
</ItemsPanelTemplate> </ItemsPanelTemplate>
</ItemsControl.ItemsPanel> </ItemsControl.ItemsPanel>
<ItemsControl.ItemTemplate> <ItemsControl.ItemTemplate>
@@ -597,9 +597,15 @@
<Button Command="{Binding $parent[ItemsControl].((vm:DashboardViewModel)DataContext).OpenGroupCommand}" <Button Command="{Binding $parent[ItemsControl].((vm:DashboardViewModel)DataContext).OpenGroupCommand}"
CommandParameter="{Binding}" CommandParameter="{Binding}"
Background="{DynamicResource SystemAccentColorLight2}" Background="{DynamicResource SystemAccentColorLight2}"
CornerRadius="6" Padding="12,6" Margin="0,0,8,8"> CornerRadius="6" Padding="12,6" MinWidth="150"
ToolTip.Tip="Kurs öffnen">
<StackPanel> <StackPanel>
<TextBlock Text="{Binding Name}" FontWeight="SemiBold" FontSize="13"/> <StackPanel Orientation="Horizontal" Spacing="6">
<Border Width="6" Height="6" CornerRadius="3"
Background="{DynamicResource SystemAccentColor}"
IsVisible="{Binding IsOnSelectedDay}" VerticalAlignment="Center"/>
<TextBlock Text="{Binding Name}" FontWeight="SemiBold" FontSize="13"/>
</StackPanel>
<TextBlock Text="{Binding Subject}" FontSize="11" Opacity="0.7" <TextBlock Text="{Binding Subject}" FontSize="11" Opacity="0.7"
IsVisible="{Binding Subject, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/> IsVisible="{Binding Subject, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
</StackPanel> </StackPanel>
@@ -28,8 +28,15 @@ public partial class GroupDocumentationTabView : UserControl
var owner = TopLevel.GetTopLevel(this) as Window; var owner = TopLevel.GetTopLevel(this) as Window;
if (owner is null) return null; if (owner is null) return null;
var options = new List<StudentOption> { GroupDocumentationTabViewModel.WholeGroupOption };
options.AddRange(studentOptions);
var vm = new DocumentationDialogViewModel(editing?.StudentId ?? Guid.Empty, editing, var vm = new DocumentationDialogViewModel(editing?.StudentId ?? Guid.Empty, editing,
App.Services.GetRequiredService<IAttachmentStorage>(), studentOptions, groupId); App.Services.GetRequiredService<IAttachmentStorage>(), options, groupId);
if (editing is null)
{
vm.SelectedStudent = GroupDocumentationTabViewModel.WholeGroupOption;
vm.TypeName = DocumentationTypeDisplay.Label(DocumentationType.Planning);
}
var dialog = new DocumentationDialog { DataContext = vm }; var dialog = new DocumentationDialog { DataContext = vm };
var saved = await dialog.ShowDialog<bool>(owner); var saved = await dialog.ShowDialog<bool>(owner);
if (!saved) vm.DiscardUnsavedAttachments(); if (!saved) vm.DiscardUnsavedAttachments();
@@ -13,10 +13,10 @@
<TextBlock Text="{Binding DialogTitle}" Classes="dialogtitle"/> <TextBlock Text="{Binding DialogTitle}" Classes="dialogtitle"/>
<StackPanel Spacing="4" IsVisible="{Binding CanPickStudent}"> <StackPanel Spacing="4" IsVisible="{Binding CanPickStudent}">
<TextBlock Text="Schüler *" FontSize="12" Opacity="0.7"/> <TextBlock Text="Bezug *" FontSize="12" Opacity="0.7"/>
<ComboBox ItemsSource="{Binding StudentOptions}" SelectedItem="{Binding SelectedStudent}" <ComboBox ItemsSource="{Binding StudentOptions}" SelectedItem="{Binding SelectedStudent}"
DisplayMemberBinding="{Binding Name}" HorizontalAlignment="Stretch" DisplayMemberBinding="{Binding Name}" HorizontalAlignment="Stretch"
PlaceholderText="Schüler wählen"/> PlaceholderText="Schüler oder gesamte Lerngruppe wählen"/>
<TextBlock Text="{Binding StudentError}" Foreground="Red" FontSize="11" <TextBlock Text="{Binding StudentError}" Foreground="Red" FontSize="11"
IsVisible="{Binding StudentError, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/> IsVisible="{Binding StudentError, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
</StackPanel> </StackPanel>
@@ -0,0 +1,44 @@
<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="clr-namespace:LehrerApp.Desktop.ViewModels.Students"
x:Class="LehrerApp.Desktop.Views.Students.GroupDocumentationQuickDialog"
x:DataType="vm:GroupDocumentationQuickViewModel"
Title="Lerngruppen-Eintrag" Width="480" SizeToContent="Height"
CanResize="False" WindowStartupLocation="CenterOwner">
<Grid RowDefinitions="*,Auto" Margin="24">
<StackPanel Spacing="12">
<TextBlock Text="Lerngruppen-Eintrag" Classes="dialogtitle"/>
<TextBlock Text="Planung, Klausur oder Erinnerung für eine ganze Lerngruppe festhalten."
TextWrapping="Wrap" Opacity="0.7"/>
<StackPanel Spacing="4">
<TextBlock Text="Lerngruppe *" FontSize="12" Opacity="0.7"/>
<ComboBox ItemsSource="{Binding Groups}" SelectedItem="{Binding SelectedGroup}"
DisplayMemberBinding="{Binding DisplayName}" PlaceholderText="Lerngruppe wählen"/>
<TextBlock Text="{Binding GroupError}" Foreground="Red" FontSize="11"
IsVisible="{Binding GroupError, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
</StackPanel>
<Grid ColumnDefinitions="*,12,140">
<StackPanel Spacing="4">
<TextBlock Text="Titel *" FontSize="12" Opacity="0.7"/>
<TextBox Text="{Binding Title}" PlaceholderText="z. B. Klausur ankündigen"/>
<TextBlock Text="{Binding TitleError}" Foreground="Red" FontSize="11"
IsVisible="{Binding TitleError, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
</StackPanel>
<StackPanel Grid.Column="2" Spacing="4">
<TextBlock Text="Datum *" FontSize="12" Opacity="0.7"/>
<TextBox Text="{Binding DateText}"/>
<TextBlock Text="{Binding DateError}" Foreground="Red" FontSize="11"
IsVisible="{Binding DateError, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
</StackPanel>
</Grid>
<StackPanel Spacing="4">
<TextBlock Text="Notiz" FontSize="12" Opacity="0.7"/>
<TextBox Text="{Binding Content}" AcceptsReturn="True" Height="90" TextWrapping="Wrap"/>
</StackPanel>
</StackPanel>
<StackPanel Grid.Row="1" Orientation="Horizontal" HorizontalAlignment="Right" Spacing="8" Margin="0,20,0,0">
<Button Content="Abbrechen" Click="OnCancel"/>
<Button Content="Speichern" Classes="accent" Click="OnSave"/>
</StackPanel>
</Grid>
</Window>
@@ -0,0 +1,19 @@
using Avalonia.Controls;
using Avalonia.Interactivity;
using LehrerApp.Desktop.ViewModels.Students;
namespace LehrerApp.Desktop.Views.Students;
public partial class GroupDocumentationQuickDialog : Window
{
public GroupDocumentationQuickDialog() => InitializeComponent();
private void OnSave(object? sender, RoutedEventArgs e)
{
if (DataContext is not GroupDocumentationQuickViewModel vm) return;
vm.SaveCommand.Execute(null);
if (vm.Result is not null) Close(true);
}
private void OnCancel(object? sender, RoutedEventArgs e) => Close(false);
}
+19 -2
View File
@@ -2077,6 +2077,13 @@ dadurch faktisch schon (Quick-Input-Dialog bzw. "Offene Entschuldigungen" im Das
Auswahl gar nicht erst an. "Gespräch begleiten" (Elternanruf) und Anhänge funktionieren im Auswahl gar nicht erst an. "Gespräch begleiten" (Elternanruf) und Anhänge funktionieren im
Gruppen-Tab identisch zum Schüler-Tab, da beide dieselbe `DocumentationItem`/ Gruppen-Tab identisch zum Schüler-Tab, da beide dieselbe `DocumentationItem`/
`DocumentationDialog`-Infrastruktur verwenden. `DocumentationDialog`-Infrastruktur verwenden.
- [x] **5.1.8** Gruppenweite Planungs- und Erinnerungseinträge ohne Schülerbezug. Im
Dokumentationsdialog einer Lerngruppe steht jetzt „Gesamte Lerngruppe“ als eigener Bezug
zur Wahl; solche Einträge verwenden den angehängten Typ `DocumentationType.Planning`, die
bestehende `GroupId` und bewusst `Guid.Empty` als kompatiblen „kein Schüler“-Marker. Sie
erscheinen auch in Gruppen ohne Schüler und werden eindeutig als „Gesamte Lerngruppe“
beschriftet. Die globale Suche bietet zusätzlich die Schnellaktion „Lerngruppen-Eintrag“
mit einem kompakten Formular für Gruppe, Datum, Titel und Notiz.
### 5.2 Fehlzeiten (als Auswertung des bestehenden Anwesenheits-Trackings, siehe oben) ### 5.2 Fehlzeiten (als Auswertung des bestehenden Anwesenheits-Trackings, siehe oben)
- [x] **5.2.1** Schnelle Abwesenheitserfassung je Stunde — bereits vorhanden über - [x] **5.2.1** Schnelle Abwesenheitserfassung je Stunde — bereits vorhanden über
@@ -2644,6 +2651,13 @@ Bereich automatisch), die linke Inhaltsspalte erhält mehr Breite und leere rein
aktivierter Dashboard-Konfiguration automatisch ausgeblendet. Inhalte und Direktaktionen bleiben aktivierter Dashboard-Konfiguration automatisch ausgeblendet. Inhalte und Direktaktionen bleiben
unverändert erhalten; die Seite wird bei ruhiger Datenlage lediglich deutlich kürzer. unverändert erhalten; die Seite wird bei ruhiger Datenlage lediglich deutlich kürzer.
**Nachtrag Kursliste (August 2026):** Die Kachel „Meine Lerngruppen“ nutzt weiterhin ein echtes
mehrzeiliges `WrapPanel`, jetzt mit explizitem Zeilen-/Elementabstand und Mindestbreite statt einer
abschneidbaren Ein-Zeilen-Darstellung. Bei Auswahl eines Kalendertags werden Gruppen mit einer
tatsächlichen Lesson oder einem an diesem Tag aktiven Stundenplan-Slot zuerst angezeigt und mit
einem Akzentpunkt markiert; innerhalb dieser Gruppe sowie für den Rest gilt alphabetische Sortierung.
Ferien, Feiertage und vollständig ausgefallene Stunden werden dabei berücksichtigt.
--- ---
## 10. Sync & Server ## 10. Sync & Server
@@ -3505,10 +3519,13 @@ von 4.2.2) der Tab "Kürzel-Katalog" für die Von/Nach-Kürzel des Stundenverlau
- [x] **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 **Umsetzung:** `Strg+K` (Windows/Linux) bzw. `⌘K` (macOS) öffnet aus jeder Hauptansicht eine
modale Befehlspalette. `GlobalSearchViewModel` durchsucht lokal und ohne zusätzlichen Index modale Befehlspalette. `GlobalSearchViewModel` durchsucht lokal und ohne zusätzlichen Index
aktive wie inaktive Schüler/Lerngruppen sowie Klausuren und Aufgaben; Treffer springen direkt ausschließlich aktive Schüler/Lerngruppen sowie deren Klausuren und Aufgaben; Treffer springen direkt
ins Schülerdetail, Gruppendetail, den Klausuren-Tab oder die Aufgabenverwaltung. Pfeiltasten, 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 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 Gruppentreffer werden zusätzlich über den zugeordneten Fachnamen gefunden und zeigen diesen
im Untertitel, damit gleichnamige Klassen/Kurse eindeutig bleiben. Bei leerer Suche stehen die
Schnellaktionen „Aufgabe anlegen“, „Erinnerung anlegen“, „Schüler anlegen“ und
„Lerngruppen-Eintrag“ bereit und
verwenden die bereits vorhandenen Dialoge samt Validierung. Zusätzlich ist der Einstieg als verwenden die bereits vorhandenen Dialoge samt Validierung. Zusätzlich ist der Einstieg als
zugänglich benannte Schaltfläche im Navigationsbereich sichtbar. Tests in zugänglich benannte Schaltfläche im Navigationsbereich sichtbar. Tests in
`GlobalSearchViewModelTests` decken Ergebnisarten, Navigation und Schnellerfassung ab. `GlobalSearchViewModelTests` decken Ergebnisarten, Navigation und Schnellerfassung ab.