- 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>
902 lines
36 KiB
C#
902 lines
36 KiB
C#
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 ? "1–6" : "0–15";
|
||
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 ? "1–6" : "0–15")}";
|
||
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 1–6";
|
||
[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 1–6", "Punkte 0–15"];
|
||
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 0–15" : "Noten 1–6";
|
||
};
|
||
}
|
||
|
||
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 1–6" : "Punkte 0–15";
|
||
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 1–13."; 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 0–15"
|
||
? GradingSystem.Points0To15 : GradingSystem.Grades1To6;
|
||
Result.SchoolYear = SelectedSchoolYear;
|
||
Result.HoursPerWeek = HoursPerWeek;
|
||
Result.IsOwnClass = IsOwnClass;
|
||
Result.IsDifferentiated = IsDifferentiated;
|
||
Result.RequiresLessonPlanning = RequiresLessonPlanning;
|
||
_groups.Save(Result);
|
||
}
|
||
}
|