Files
LehrerApp/LehrerApp.Desktop/ViewModels/Groups/GroupViewModels.cs
T
adminandClaude Sonnet 5 fd509fc921 feat: Komfort-Features für Unterrichtsplanung, Zeiterfassung und Dashboard
- Neue-Stunde-Dialog: Datum wird beim Anlegen anhand des Stundenplans
  und der letzten Stunde der Einheit vorbelegt statt auf "heute"
  (behebt eine falsch erkannte Doppelstunde, wenn "heute" nicht auf
  den passenden Wochentag fiel).
- Zeiterfassung: Button "Unterrichtszeit heute übernehmen" schlägt
  Start/Ende aus dem heutigen Stundenplan inkl. Puffer davor/danach vor.
- Dashboard: neue Kachel "Ungeplante Stunden" erinnert an Stunden ohne
  Thema für heute/morgen, mit Opt-out je Gruppe ("Benötigt
  Unterrichtsplanung"), Doppelstunden-Erkennung (keine doppelte Meldung
  für die zweite Periode) und Berücksichtigung von Stundenausfall.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-18 11:30:50 +02:00

902 lines
36 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using LehrerApp.Core.Interfaces;
using LehrerApp.Core.Models;
using LehrerApp.Core.Services;
using LehrerApp.Desktop.ViewModels.Students;
using System.Collections.ObjectModel;
namespace LehrerApp.Desktop.ViewModels.Groups;
// ── Gruppenliste ──────────────────────────────────────────────────────────────
public partial class GroupListViewModel : ObservableObject
{
private readonly IGroupRepository _groups;
private readonly ISubjectRepository _subjects;
public Action<Guid, int>? OnNavigateToDetail { get; set; }
public Func<Task>? OnAddGroup { get; set; }
public Func<Guid, Task>? OnEditGroup { get; set; }
public Func<Guid, Task<Guid?>>? OnRollOverGroup { get; set; }
public Func<GroupListItem, Task<bool>>? OnConfirmDelete { get; set; }
[ObservableProperty] private string _selectedSchoolYear = "";
[ObservableProperty] private string _searchText = "";
[ObservableProperty] private GroupListItem? _selectedGroup;
[ObservableProperty] private bool _showArchived;
public string SelectedGroupDisplayName => SelectedGroup?.DisplayName ?? "";
public string SelectedGroupSubtitle => SelectedGroup?.Subtitle ?? "";
public string ListSummary => ShowArchived
? $"{Groups.Count} archivierte Gruppen · {SelectedSchoolYear}"
: $"{Groups.Count} aktive Gruppen · {SelectedSchoolYear}";
public bool HasNoGroups => Groups.Count == 0;
public string EmptyListMessage => ShowArchived
? "Keine archivierten Lerngruppen in diesem Schuljahr."
: "Noch keine aktiven Lerngruppen in diesem Schuljahr.";
public ObservableCollection<string> SchoolYears { get; } = [];
public ObservableCollection<GroupListItem> Groups { get; } = [];
public GroupListViewModel(IGroupRepository groups, ISubjectRepository subjects, SchoolYearService sy)
{
_groups = groups; _subjects = subjects;
foreach (var y in sy.RecentSchoolYears()) SchoolYears.Add(y);
SelectedSchoolYear = sy.CurrentSchoolYear();
}
partial void OnSelectedSchoolYearChanged(string value) => LoadGroups();
partial void OnSearchTextChanged(string value) => LoadGroups();
partial void OnShowArchivedChanged(bool value) => LoadGroups();
partial void OnSelectedGroupChanged(GroupListItem? value)
{
OnPropertyChanged(nameof(SelectedGroupDisplayName));
OnPropertyChanged(nameof(SelectedGroupSubtitle));
NavigateToSectionCommand.NotifyCanExecuteChanged();
EditGroupCommand.NotifyCanExecuteChanged();
RollOverGroupCommand.NotifyCanExecuteChanged();
ToggleArchiveCommand.NotifyCanExecuteChanged();
DeleteGroupCommand.NotifyCanExecuteChanged();
}
public void LoadGroups()
{
var selectedId = SelectedGroup?.Id;
Groups.Clear();
var all = _groups.GetBySchoolYear(SelectedSchoolYear, includeInactive: ShowArchived)
.Where(g => g.IsActive != ShowArchived);
var subjectNames = _subjects.GetAll().ToDictionary(s => s.Id, s => s.Name);
var filtered = string.IsNullOrWhiteSpace(SearchText) ? all
: all.Where(g => g.Name.Contains(SearchText, StringComparison.OrdinalIgnoreCase)
|| (g.SubjectId is Guid id && subjectNames.GetValueOrDefault(id, "")
.Contains(SearchText, StringComparison.OrdinalIgnoreCase)));
foreach (var g in filtered.OrderBy(g => g.Name))
Groups.Add(new GroupListItem(g, g.SubjectId is Guid id ? subjectNames.GetValueOrDefault(id, "") : ""));
SelectedGroup = Groups.FirstOrDefault(g => g.Id == selectedId);
OnPropertyChanged(nameof(ListSummary));
OnPropertyChanged(nameof(HasNoGroups));
OnPropertyChanged(nameof(EmptyListMessage));
}
[RelayCommand]
private async Task AddGroup()
{
if (OnAddGroup is null) return;
await OnAddGroup();
LoadGroups();
}
[RelayCommand] private void Refresh() => LoadGroups();
[RelayCommand(CanExecute = nameof(CanEditSelectedGroup))]
private async Task EditGroup()
{
if (SelectedGroup is null || OnEditGroup is null) return;
var id = SelectedGroup.Id;
await OnEditGroup(id);
LoadGroups();
SelectedGroup = Groups.FirstOrDefault(g => g.Id == id);
}
[RelayCommand(CanExecute = nameof(HasSelectedGroup))]
private async Task RollOverGroup()
{
if (SelectedGroup is null || OnRollOverGroup is null) return;
var targetId = await OnRollOverGroup(SelectedGroup.Id);
if (targetId is null) return;
var target = _groups.GetById(targetId.Value);
if (target is null) return;
if (!SchoolYears.Contains(target.SchoolYear)) SchoolYears.Insert(0, target.SchoolYear);
SelectedSchoolYear = target.SchoolYear;
LoadGroups();
SelectedGroup = Groups.FirstOrDefault(g => g.Id == target.Id);
}
[RelayCommand(CanExecute = nameof(HasSelectedGroup))]
private void ToggleArchive()
{
if (SelectedGroup is null) return;
var group = _groups.GetById(SelectedGroup.Id);
if (group is null) return;
group.IsActive = !group.IsActive;
_groups.Save(group);
LoadGroups();
}
[RelayCommand(CanExecute = nameof(CanEditSelectedGroup))]
private async Task DeleteGroup()
{
if (SelectedGroup is null || OnConfirmDelete is null) return;
var selected = SelectedGroup;
if (!await OnConfirmDelete(selected)) return;
_groups.Delete(selected.Id);
LoadGroups();
}
[RelayCommand(CanExecute = nameof(HasSelectedGroup))]
private void NavigateToSection(string? tabIndex)
{
if (SelectedGroup is null || !int.TryParse(tabIndex, out var tab)) return;
OnNavigateToDetail?.Invoke(SelectedGroup.Id, tab);
}
private bool HasSelectedGroup() => SelectedGroup is not null;
private bool CanEditSelectedGroup() => SelectedGroup?.IsActive == true;
}
public class GroupListItem
{
public Guid Id { get; }
public string Name { get; }
public string Subject { get; }
public string DisplayName { get; }
public string TypeLabel { get; }
public string GradingLabel { get; }
public string Subtitle { get; }
public bool IsActive { get; }
public string ArchiveActionLabel => IsActive ? "Archivieren" : "Wieder aktivieren";
public GroupListItem(LearningGroup g, string subjectName)
{
Id = g.Id;
IsActive = g.IsActive;
Name = g.Name;
Subject = subjectName;
TypeLabel = g.Type == GroupType.Class ? "Klasse" : "Kurs";
GradingLabel = g.GradingSystem == GradingSystem.Grades1To6 ? "16" : "015";
DisplayName = string.IsNullOrEmpty(subjectName) ? g.Name : $"{g.Name} · {subjectName}";
Subtitle = $"{TypeLabel} · Stufe {g.GradeLevel} · Noten {GradingLabel} · {g.SchoolYear}";
}
}
// ── Gruppendetail ─────────────────────────────────────────────────────────────
public partial class GroupDetailViewModel : ObservableObject
{
private readonly IGroupRepository _groups;
private readonly IStudentRepository _students;
private readonly IGroupMembershipRepository _memberships;
private readonly ISubjectRepository _subjects;
private readonly IExamRepository _exams;
private readonly IGradeRepository _grades;
private readonly IWorkTaskRepository _tasks;
[ObservableProperty] private LearningGroup? _group;
[ObservableProperty] private string _groupTitle = "";
[ObservableProperty] private string _groupSubtitle = "";
[ObservableProperty] private int _studentCount;
[ObservableProperty] private int _activeTabIndex = 0;
[ObservableProperty] private StudentSummary? _selectedStudent;
[ObservableProperty] private ExamSummary? _selectedExam;
[ObservableProperty] private bool _showFormerStudents;
// Eigene Property statt "Group.IsDifferentiated" im Binding-Pfad: die View bindet bereits,
// bevor LoadGroup() läuft (siehe MainWindowViewModel.NavigateToGroupDetail), Group ist dann
// kurzzeitig null — ein verschachtelter Pfad würde dafür jedes Mal einen Binding-Fehler loggen.
public bool IsDifferentiated => Group?.IsDifferentiated ?? false;
public bool IsReadOnly => Group is { IsActive: false };
public bool IsEditable => Group is { IsActive: true };
public string SubjectName { get; private set; } = "";
partial void OnGroupChanged(LearningGroup? value)
{
OnPropertyChanged(nameof(IsDifferentiated));
OnPropertyChanged(nameof(IsReadOnly));
OnPropertyChanged(nameof(IsEditable));
NotifyWriteCommands();
}
partial void OnShowFormerStudentsChanged(bool value) => LoadStudents();
partial void OnActiveTabIndexChanged(int value)
{
if (value == 7) CompetencyOverviewTab.Refresh();
}
public ObservableCollection<StudentSummary> Students { get; } = [];
public ObservableCollection<ExamSummary> Exams { get; } = [];
public ParticipationTabViewModel ParticipationTab { get; }
public GradeOverviewTabViewModel GradeOverviewTab { get; }
public PlanningTabViewModel PlanningTab { get; }
public CompetencyOverviewTabViewModel CompetencyOverviewTab { get; }
public SeatingPlanTabViewModel SeatingPlanTab { get; }
public Func<Task<bool>>? OnAddStudent { get; set; }
public Func<StudentSummary, Task<bool>>? OnWithdrawStudent { get; set; }
public Func<Guid, Task<bool>>? OnAddExam { get; set; }
public Func<Exam, Task<bool>>? OnEditExam { get; set; }
public Func<Exam, Task<bool>>? OnDuplicateExam { get; set; }
public Func<ExamSummary, Task<bool>>? OnConfirmDeleteExam { get; set; }
public Func<Exam, Task>? OnGradeExam { get; set; }
public Func<Exam, Task>? OnEvaluateExam { get; set; }
public Func<Task<bool>>? OnConfirmReactivate { get; set; }
public GroupDetailViewModel(IGroupRepository groups, IStudentRepository students,
IGroupMembershipRepository memberships, ISubjectRepository subjects,
IExamRepository exams, IGradeRepository grades, IWorkTaskRepository tasks,
ParticipationTabViewModel participationTab, GradeOverviewTabViewModel gradeOverviewTab,
PlanningTabViewModel planningTab, CompetencyOverviewTabViewModel competencyOverviewTab,
SeatingPlanTabViewModel seatingPlanTab)
{
_groups = groups; _students = students; _memberships = memberships; _subjects = subjects;
_exams = exams; _grades = grades; _tasks = tasks;
ParticipationTab = participationTab;
GradeOverviewTab = gradeOverviewTab;
PlanningTab = planningTab;
CompetencyOverviewTab = competencyOverviewTab;
SeatingPlanTab = seatingPlanTab;
SeatingPlanTab.OnAssessmentChanged = () =>
{
ParticipationTab.LoadSessions();
ParticipationTab.RefreshCurrentGrid();
};
}
public void LoadGroup(Guid id)
{
Group = _groups.GetById(id);
if (Group is null) return;
SubjectName = Group.SubjectId is Guid subjectId
? _subjects.GetById(subjectId)?.Name ?? ""
: "";
GroupTitle = Group.Name;
GroupSubtitle = $"{Group.SchoolYear} · " +
$"{(Group.Type == GroupType.Class ? "Klasse" : "Kurs")} · " +
$"Stufe {Group.GradeLevel} · Noten {(Group.GradingSystem == GradingSystem.Grades1To6 ? "16" : "015")}";
LoadStudents();
ReloadExams();
ParticipationTab.Initialize(Group.Id, Group.SchoolYear, IsReadOnly);
GradeOverviewTab.Initialize(Group.Id, Group.GradingSystem, Group.Type, GroupTitle, Group.SchoolYear, IsReadOnly);
PlanningTab.Initialize(Group.Id, IsReadOnly);
CompetencyOverviewTab.Initialize(Group);
SeatingPlanTab.Initialize(Group.Id, IsReadOnly);
}
private void ReloadExams()
{
if (Group is null) return;
var selectedId = SelectedExam?.Id;
Exams.Clear();
foreach (var e in _exams.GetByGroup(Group.Id)) Exams.Add(new ExamSummary(e));
SelectedExam = Exams.FirstOrDefault(e => e.Id == selectedId);
CompetencyOverviewTab.Refresh();
}
public void LoadStudents()
{
if (Group is null) return;
Students.Clear();
var enrolled = _students.GetByGroup(Group.Id);
var memberships = _memberships.GetByGroup(Group.Id).ToDictionary(e => e.StudentId);
var today = DateOnly.FromDateTime(DateTime.Today);
StudentCount = memberships.Values.Count(m => GroupMembershipService.IsActiveOn(m, today));
foreach (var s in enrolled.OrderBy(s => s.LastName).ThenBy(s => s.FirstName))
{
memberships.TryGetValue(s.Id, out var membership);
if (membership is null) continue;
var summary = new StudentSummary(s, membership, today)
{
OnChanged = SaveStudentNiveau,
CanEdit = IsEditable,
};
if (summary.IsFormer && !ShowFormerStudents) continue;
Students.Add(summary);
}
}
private void SaveStudentNiveau(StudentSummary summary)
{
if (!IsEditable || Group is null) return;
var membership = _memberships.GetByStudentAndGroup(summary.Id, Group.Id);
if (membership is null) return;
membership.Niveau = summary.Niveau;
_memberships.Save(membership);
}
[RelayCommand(CanExecute = nameof(IsEditableGroup))]
private async Task AddStudent()
{
if (OnAddStudent is null) return;
var confirmed = await OnAddStudent();
if (confirmed)
{
LoadStudents();
ParticipationTab.RefreshCurrentGrid();
}
}
[RelayCommand(CanExecute = nameof(CanEditSelectedStudent))]
private async Task WithdrawStudent()
{
if (SelectedStudent is null || OnWithdrawStudent is null) return;
if (!await OnWithdrawStudent(SelectedStudent)) return;
LoadStudents();
SelectedStudent = null;
ParticipationTab.RefreshCurrentGrid();
}
[RelayCommand(CanExecute = nameof(CanEditAndReinstateSelectedStudent))]
private void ReinstateStudent()
{
if (Group is null || SelectedStudent is null) return;
var membership = _memberships.GetByStudentAndGroup(SelectedStudent.Id, Group.Id);
if (membership is null) return;
membership.LeftAt = null;
_memberships.Save(membership);
LoadStudents();
SelectedStudent = null;
ParticipationTab.RefreshCurrentGrid();
}
partial void OnSelectedStudentChanged(StudentSummary? value)
{
WithdrawStudentCommand.NotifyCanExecuteChanged();
ReinstateStudentCommand.NotifyCanExecuteChanged();
}
private bool CanEditSelectedStudent() => IsEditable && SelectedStudent is not null;
private bool CanEditAndReinstateSelectedStudent() => IsEditable && SelectedStudent?.HasExitDate == true;
[RelayCommand(CanExecute = nameof(IsEditableGroup))]
private async Task AddExam()
{
if (Group is null || OnAddExam is null) return;
var saved = await OnAddExam(Group.Id);
if (saved) ReloadExams();
}
[RelayCommand(CanExecute = nameof(CanEditSelectedExam))]
private async Task EditExam()
{
if (SelectedExam is null || OnEditExam is null) return;
var exam = _exams.GetById(SelectedExam.Id);
if (exam is null) return;
var id = exam.Id;
var saved = await OnEditExam(exam);
if (saved)
{
ReloadExams();
SelectedExam = Exams.FirstOrDefault(e => e.Id == id);
}
}
[RelayCommand(CanExecute = nameof(CanEditSelectedExam))]
private async Task GradeExam()
{
if (SelectedExam is null || OnGradeExam is null) return;
var exam = _exams.GetById(SelectedExam.Id);
if (exam is null) return;
await OnGradeExam(exam);
CompetencyOverviewTab.Refresh();
}
[RelayCommand(CanExecute = nameof(CanEditSelectedExam))]
private async Task EvaluateExam()
{
if (SelectedExam is null || OnEvaluateExam is null) return;
var exam = _exams.GetById(SelectedExam.Id);
if (exam is null) return;
await OnEvaluateExam(exam);
}
[RelayCommand(CanExecute = nameof(CanEditSelectedExam))]
private async Task DuplicateExam()
{
if (SelectedExam is null || OnDuplicateExam is null) return;
var exam = _exams.GetById(SelectedExam.Id);
if (exam is null) return;
var saved = await OnDuplicateExam(exam);
if (saved) ReloadExams();
}
[RelayCommand(CanExecute = nameof(CanEditSelectedExam))]
private async Task DeleteExam()
{
if (SelectedExam is null || OnConfirmDeleteExam is null) return;
var selected = SelectedExam;
if (!await OnConfirmDeleteExam(selected)) return;
_exams.Delete(selected.Id);
SelectedExam = null;
ReloadExams();
}
[RelayCommand(CanExecute = nameof(CanEditSelectedExam))]
private void AdvanceExamStatus()
{
if (SelectedExam is null) return;
SetExamStatus(SelectedExam.Status switch
{
ExamStatus.Planned => ExamStatus.Conducted,
ExamStatus.Conducted => ExamStatus.Graded,
ExamStatus.Graded => ExamStatus.Returned,
_ => SelectedExam.Status,
});
}
[RelayCommand(CanExecute = nameof(CanEditSelectedExam))]
private void SetExamStatus(ExamStatus status)
{
if (SelectedExam is null) return;
var exam = _exams.GetById(SelectedExam.Id);
if (exam is null) return;
var previousStatus = exam.Status;
exam.Status = status;
if (status == ExamStatus.Returned)
exam.ReturnedAt ??= DateOnly.FromDateTime(DateTime.Today);
else
exam.ReturnedAt = null;
_exams.Save(exam);
// 6.1.5: beim erstmaligen Erreichen von "Durchgeführt" automatisch eine Korrektur-Aufgabe anlegen.
if (status == ExamStatus.Conducted && previousStatus == ExamStatus.Planned)
_tasks.Save(new WorkTask
{
Title = $"Klausur korrigieren: {exam.Title}",
Category = TaskCategory.Correction,
GroupId = exam.GroupId,
DueDate = exam.Date.AddDays(14),
Status = WorkTaskStatus.Open,
});
var id = exam.Id;
ReloadExams();
SelectedExam = Exams.FirstOrDefault(e => e.Id == id);
}
partial void OnSelectedExamChanged(ExamSummary? value)
{
EditExamCommand.NotifyCanExecuteChanged();
GradeExamCommand.NotifyCanExecuteChanged();
EvaluateExamCommand.NotifyCanExecuteChanged();
DuplicateExamCommand.NotifyCanExecuteChanged();
DeleteExamCommand.NotifyCanExecuteChanged();
AdvanceExamStatusCommand.NotifyCanExecuteChanged();
SetExamStatusCommand.NotifyCanExecuteChanged();
}
private bool CanEditSelectedExam() => IsEditable && SelectedExam is not null;
private bool IsEditableGroup() => IsEditable;
[RelayCommand(CanExecute = nameof(IsReadOnlyGroup))]
private async Task ReactivateGroup()
{
if (Group is null || OnConfirmReactivate is null || !await OnConfirmReactivate()) return;
Group.IsActive = true;
_groups.Save(Group);
LoadGroup(Group.Id);
}
private bool IsReadOnlyGroup() => IsReadOnly;
private void NotifyWriteCommands()
{
AddStudentCommand.NotifyCanExecuteChanged();
WithdrawStudentCommand.NotifyCanExecuteChanged();
ReinstateStudentCommand.NotifyCanExecuteChanged();
AddExamCommand.NotifyCanExecuteChanged();
EditExamCommand.NotifyCanExecuteChanged();
GradeExamCommand.NotifyCanExecuteChanged();
EvaluateExamCommand.NotifyCanExecuteChanged();
DuplicateExamCommand.NotifyCanExecuteChanged();
DeleteExamCommand.NotifyCanExecuteChanged();
AdvanceExamStatusCommand.NotifyCanExecuteChanged();
SetExamStatusCommand.NotifyCanExecuteChanged();
ReactivateGroupCommand.NotifyCanExecuteChanged();
}
[RelayCommand] private void Refresh() { if (Group is not null) LoadGroup(Group.Id); }
}
// ── Niveau-Anzeige (Niveaudifferenzierung) ────────────────────────────────────
public static class NiveauDisplay
{
public static string[] Options { get; } = ["", "E-Niveau", "G-Niveau", "Förderniveau"];
public static string ToName(Niveau? n) => n switch
{
Core.Models.Niveau.E => "E-Niveau",
Core.Models.Niveau.G => "G-Niveau",
Core.Models.Niveau.Foerder => "Förderniveau",
_ => "",
};
public static Niveau? FromName(string? name) => name switch
{
"E-Niveau" => Core.Models.Niveau.E,
"G-Niveau" => Core.Models.Niveau.G,
"Förderniveau" => Core.Models.Niveau.Foerder,
_ => null,
};
}
public partial class StudentSummary : ObservableObject
{
public static string[] NiveauOptions => NiveauDisplay.Options;
public Guid Id { get; }
public string FullName { get; }
public string PeriodLabel { get; }
public bool IsFormer { get; }
public bool HasExitDate { get; }
public string MembershipStatus { get; }
public string WithdrawActionLabel => HasExitDate ? "Austrittsdatum ändern" : "Austragen";
public string AvatarLabel { get; }
public string AvatarColor { get; }
public string AvatarTooltip { get; }
[ObservableProperty] private Niveau? _niveau;
public string NiveauName
{
get => NiveauDisplay.ToName(Niveau);
set => Niveau = NiveauDisplay.FromName(value);
}
public Action<StudentSummary>? OnChanged { get; set; }
public bool CanEdit { get; init; } = true;
public StudentSummary(Core.Models.Student s, GroupMembership? membership, DateOnly? today = null)
{
Id = s.Id;
FullName = s.FullName;
AvatarLabel = GenderAvatarDisplay.Label(s.Gender);
AvatarColor = GenderAvatarDisplay.Color(s.Gender);
AvatarTooltip = GenderAvatarDisplay.Tooltip(s.Gender);
PeriodLabel = membership is null ? "" : BuildPeriodLabel(membership);
HasExitDate = membership?.LeftAt.HasValue == true;
IsFormer = membership?.LeftAt is { } leftAt
&& leftAt < (today ?? DateOnly.FromDateTime(DateTime.Today));
MembershipStatus = membership?.LeftAt is { } exitDate
? IsFormer ? $"Ausgetreten am {exitDate:dd.MM.yyyy}" : $"Austritt am {exitDate:dd.MM.yyyy}"
: "Aktiv";
_niveau = membership?.Niveau;
}
partial void OnNiveauChanged(Niveau? value)
{
OnPropertyChanged(nameof(NiveauName));
OnChanged?.Invoke(this);
}
private static string BuildPeriodLabel(GroupMembership membership)
{
var period = membership.Period switch
{
MembershipPeriod.H1Only => "H1",
MembershipPeriod.H2Only => "H2",
MembershipPeriod.Custom => "Datum",
_ => "Ganzes Jahr",
};
if (membership.JoinedAt.HasValue && membership.LeftAt.HasValue)
return $"{period} · {membership.JoinedAt:dd.MM.}{membership.LeftAt:dd.MM.}";
if (membership.JoinedAt.HasValue) return $"{period} · ab {membership.JoinedAt:dd.MM.}";
if (membership.LeftAt.HasValue) return $"{period} · bis {membership.LeftAt:dd.MM.}";
return period;
}
}
public class ExamSummary
{
public Guid Id { get; }
public string Title { get; }
public string Date { get; }
public ExamStatus Status { get; }
public string StatusLabel { get; }
public string StatusColorHex { get; }
public string ReturnedAtDisplay { get; }
public string NiveauLabel { get; }
public ExamSummary(Core.Models.Exam e)
{
Id = e.Id; Title = e.Title; Date = e.Date.ToString("dd.MM.yyyy"); Status = e.Status;
ReturnedAtDisplay = e.ReturnedAt?.ToString("dd.MM.yyyy") ?? "";
NiveauLabel = e.Niveau.HasValue ? NiveauDisplay.ToName(e.Niveau) : "alle";
StatusLabel = e.Status switch
{
ExamStatus.Planned => "Geplant",
ExamStatus.Conducted => "Durchgeführt",
ExamStatus.Graded => "Korrigiert",
ExamStatus.Returned => "Zurückgegeben",
_ => "",
};
StatusColorHex = e.Status switch
{
ExamStatus.Planned => "#9E9E9E",
ExamStatus.Conducted => "#FB8C00",
ExamStatus.Graded => "#43A047",
ExamStatus.Returned => "#1E88E5",
_ => "#9E9E9E",
};
}
}
// ── Dialog: Schüler zur Gruppe hinzufügen ─────────────────────────────────────
public partial class AddStudentToGroupDialogViewModel : ObservableObject
{
private readonly IStudentRepository _students;
private readonly IGroupMembershipRepository _memberships;
private readonly Guid _groupId;
[ObservableProperty] private string _searchText = "";
[ObservableProperty] private StudentPickerItem? _selectedStudent;
[ObservableProperty] private string _validationMessage = "";
[ObservableProperty] private MembershipPeriod _period = MembershipPeriod.FullYear;
[ObservableProperty] private string _joinedAtText = "";
[ObservableProperty] private string _leftAtText = "";
public bool IsFullYear { get => Period == MembershipPeriod.FullYear; set { if (value) Period = MembershipPeriod.FullYear; } }
public bool IsH1Only { get => Period == MembershipPeriod.H1Only; set { if (value) Period = MembershipPeriod.H1Only; } }
public bool IsH2Only { get => Period == MembershipPeriod.H2Only; set { if (value) Period = MembershipPeriod.H2Only; } }
public bool IsCustom { get => Period == MembershipPeriod.Custom; set { if (value) Period = MembershipPeriod.Custom; } }
public bool IsCustomPeriod => Period == MembershipPeriod.Custom;
public ObservableCollection<StudentPickerItem> AvailableStudents { get; } = [];
public GroupMembership? Result { get; private set; }
public AddStudentToGroupDialogViewModel(IStudentRepository students,
IGroupMembershipRepository memberships, Guid groupId)
{
_students = students; _memberships = memberships;
_groupId = groupId;
LoadAvailableStudents();
}
partial void OnPeriodChanged(MembershipPeriod value)
{
OnPropertyChanged(nameof(IsFullYear));
OnPropertyChanged(nameof(IsH1Only));
OnPropertyChanged(nameof(IsH2Only));
OnPropertyChanged(nameof(IsCustom));
OnPropertyChanged(nameof(IsCustomPeriod));
}
partial void OnSearchTextChanged(string value) => LoadAvailableStudents();
private void LoadAvailableStudents()
{
var alreadyAssigned = _memberships.GetByGroup(_groupId)
.Select(e => e.StudentId).ToHashSet();
var all = _students.GetAll();
var available = all
.Where(s => !alreadyAssigned.Contains(s.Id))
.Where(s => string.IsNullOrWhiteSpace(SearchText) ||
s.FullName.Contains(SearchText, StringComparison.OrdinalIgnoreCase));
AvailableStudents.Clear();
foreach (var s in available) AvailableStudents.Add(new StudentPickerItem(s));
}
[RelayCommand]
private void Save()
{
if (SelectedStudent is null) { ValidationMessage = "Bitte einen Schüler auswählen."; return; }
DateOnly? joinedAt = null, leftAt = null;
if (Period == MembershipPeriod.Custom)
{
if (!string.IsNullOrWhiteSpace(JoinedAtText) &&
DateOnly.TryParseExact(JoinedAtText, "dd.MM.yyyy",
null, System.Globalization.DateTimeStyles.None, out var j))
joinedAt = j;
if (!string.IsNullOrWhiteSpace(LeftAtText) &&
DateOnly.TryParseExact(LeftAtText, "dd.MM.yyyy",
null, System.Globalization.DateTimeStyles.None, out var l))
leftAt = l;
}
Result = new GroupMembership
{
StudentId = SelectedStudent.Id,
GroupId = _groupId,
Period = Period,
JoinedAt = joinedAt,
LeftAt = leftAt,
};
_memberships.Save(Result);
}
}
public class StudentPickerItem
{
public Guid Id { get; }
public string FullName { get; }
public StudentPickerItem(Student s) { Id = s.Id; FullName = s.FullName; }
}
// ── Dialog: Schüler aus einer Lerngruppe austragen ───────────────────────────
public partial class WithdrawStudentDialogViewModel : ObservableObject
{
private readonly IGroupMembershipRepository _memberships;
private readonly GroupMembership _membership;
[ObservableProperty] private DateTimeOffset? _selectedDate;
[ObservableProperty] private string _dateError = "";
public string StudentName { get; }
public string GroupName { get; }
public DateTimeOffset? EarliestDate { get; }
public bool WasSaved { get; private set; }
public WithdrawStudentDialogViewModel(IGroupMembershipRepository memberships,
GroupMembership membership, string studentName, string groupName, DateOnly? today = null)
{
_memberships = memberships;
_membership = membership;
StudentName = studentName;
GroupName = groupName;
var initialDate = membership.LeftAt ?? today ?? DateOnly.FromDateTime(DateTime.Today);
_selectedDate = ToDateTimeOffset(initialDate);
EarliestDate = membership.JoinedAt is { } joinedAt ? ToDateTimeOffset(joinedAt) : null;
}
[RelayCommand]
private void Save()
{
DateError = "";
if (SelectedDate is null)
{
DateError = "Bitte ein Austrittsdatum auswählen.";
return;
}
var exitDate = DateOnly.FromDateTime(SelectedDate.Value.LocalDateTime);
if (_membership.JoinedAt is { } joinedAt && exitDate < joinedAt)
{
DateError = $"Das Austrittsdatum darf nicht vor dem Eintritt am {joinedAt:dd.MM.yyyy} liegen.";
return;
}
_membership.LeftAt = exitDate;
_memberships.Save(_membership);
WasSaved = true;
}
private static DateTimeOffset ToDateTimeOffset(DateOnly date)
{
var localDateTime = date.ToDateTime(TimeOnly.MinValue);
return new DateTimeOffset(localDateTime, TimeZoneInfo.Local.GetUtcOffset(localDateTime));
}
}
// ── Dialog: Neue Lerngruppe anlegen ──────────────────────────────────────────
public partial class AddGroupDialogViewModel : ObservableObject
{
private readonly IGroupRepository _groups;
private readonly ISubjectRepository _subjects;
private List<Subject> _allSubjects = [];
private LearningGroup? _editingGroup;
public List<string> TypeOptions { get; } = ["Klasse", "Kurs"];
[ObservableProperty] private string _selectedTypeName = "Kurs";
public bool IsKurs => SelectedTypeName == "Kurs";
public string NameLabel => IsKurs ? "Kursname *" : "Klassenname *";
public string NameHint => IsKurs ? "z.B. Q1, LK, GK" : "z.B. 10E, 5a, 11b";
partial void OnSelectedTypeNameChanged(string value)
{
OnPropertyChanged(nameof(IsKurs));
OnPropertyChanged(nameof(NameLabel));
OnPropertyChanged(nameof(NameHint));
}
[ObservableProperty] private string _name = "";
[ObservableProperty] private string _subject = "";
[ObservableProperty] private int _gradeLevel = 10;
[ObservableProperty] private string _selectedGradingName = "Noten 16";
[ObservableProperty] private string _selectedSchoolYear = "";
[ObservableProperty] private int? _hoursPerWeek;
[ObservableProperty] private bool _isOwnClass;
[ObservableProperty] private bool _isDifferentiated;
[ObservableProperty] private bool _requiresLessonPlanning = true;
[ObservableProperty] private string _nameError = "";
[ObservableProperty] private string _gradeLevelError = "";
public List<string> SchoolYears { get; }
public List<string> GradingOptions { get; } = ["Noten 16", "Punkte 015"];
public List<string> KnownSubjectNames { get; private set; } = [];
public LearningGroup? Result { get; private set; }
public string DialogTitle => _editingGroup is null ? "Neue Lerngruppe anlegen" : "Lerngruppe bearbeiten";
public string SaveButtonText => _editingGroup is null ? "Anlegen" : "Speichern";
public AddGroupDialogViewModel(IGroupRepository groups, ISubjectRepository subjects,
SchoolYearService sy)
{
_groups = groups; _subjects = subjects;
_allSubjects = subjects.GetAll();
KnownSubjectNames = _allSubjects.Select(s => s.Name).ToList();
SchoolYears = sy.RecentSchoolYears(3);
SelectedSchoolYear = sy.CurrentSchoolYear();
PropertyChanged += (_, e) =>
{
if (e.PropertyName == nameof(GradeLevel))
SelectedGradingName = GradeLevel >= 11 ? "Punkte 015" : "Noten 16";
};
}
public void LoadForEdit(LearningGroup group)
{
_editingGroup = group;
SelectedTypeName = group.Type == GroupType.Class ? "Klasse" : "Kurs";
Name = group.Name;
Subject = group.SubjectId is Guid subjectId ? _subjects.GetById(subjectId)?.Name ?? "" : "";
GradeLevel = group.GradeLevel;
SelectedGradingName = group.GradingSystem == GradingSystem.Grades1To6
? "Noten 16" : "Punkte 015";
SelectedSchoolYear = group.SchoolYear;
HoursPerWeek = group.HoursPerWeek;
IsOwnClass = group.IsOwnClass;
IsDifferentiated = group.IsDifferentiated;
RequiresLessonPlanning = group.RequiresLessonPlanning;
OnPropertyChanged(nameof(DialogTitle));
OnPropertyChanged(nameof(SaveButtonText));
}
[RelayCommand]
private void Save()
{
NameError = ""; GradeLevelError = "";
var valid = true;
if (string.IsNullOrWhiteSpace(Name)) { NameError = "Bezeichnung erforderlich."; valid = false; }
if (GradeLevel is < 1 or > 13) { GradeLevelError = "Klassenstufe 113."; valid = false; }
if (!valid) return;
string? subjectName = !string.IsNullOrWhiteSpace(Subject) ? Subject.Trim() : null;
Guid? subjectId = null;
if (subjectName is not null)
{
var existing = _allSubjects.FirstOrDefault(
s => s.Name.Equals(subjectName, StringComparison.OrdinalIgnoreCase));
if (existing is not null)
{
subjectId = existing.Id;
}
else
{
var newSubject = new Subject { Name = subjectName };
_subjects.Save(newSubject);
subjectId = newSubject.Id;
}
}
Result = _editingGroup ?? new LearningGroup();
Result.Name = Name.Trim();
Result.SubjectId = subjectId;
Result.Type = IsKurs ? GroupType.Course : GroupType.Class;
Result.GradeLevel = GradeLevel;
Result.GradingSystem = SelectedGradingName == "Punkte 015"
? GradingSystem.Points0To15 : GradingSystem.Grades1To6;
Result.SchoolYear = SelectedSchoolYear;
Result.HoursPerWeek = HoursPerWeek;
Result.IsOwnClass = IsOwnClass;
Result.IsDifferentiated = IsDifferentiated;
Result.RequiresLessonPlanning = RequiresLessonPlanning;
_groups.Save(Result);
}
}