Kapitel 7 abgeschlossen.
This commit is contained in:
@@ -37,6 +37,7 @@ public partial class GradeOverviewTabViewModel : ObservableObject
|
||||
[ObservableProperty] private bool _showAsPoints = true;
|
||||
[ObservableProperty] private GradeOverviewRow? _selectedRow;
|
||||
[ObservableProperty] private int _rebuildColumnsSignal;
|
||||
[ObservableProperty] private bool _isReadOnly;
|
||||
|
||||
public bool CanTogglePointsView => _gradingSystem == GradingSystem.Points0To15;
|
||||
|
||||
@@ -64,13 +65,14 @@ public partial class GradeOverviewTabViewModel : ObservableObject
|
||||
}
|
||||
|
||||
public void Initialize(Guid groupId, GradingSystem gradingSystem, GroupType groupType,
|
||||
string groupLabel, string schoolYear)
|
||||
string groupLabel, string schoolYear, bool isReadOnly = false)
|
||||
{
|
||||
_groupId = groupId;
|
||||
_gradingSystem = gradingSystem;
|
||||
_groupType = groupType;
|
||||
_groupLabel = groupLabel;
|
||||
_schoolYear = schoolYear;
|
||||
IsReadOnly = isReadOnly;
|
||||
ShowAsPoints = gradingSystem == GradingSystem.Points0To15;
|
||||
OnPropertyChanged(nameof(CanTogglePointsView));
|
||||
Recompute();
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using LehrerApp.Core.Interfaces;
|
||||
using LehrerApp.Core.Models;
|
||||
using LehrerApp.Core.Services;
|
||||
using System.Collections.ObjectModel;
|
||||
|
||||
namespace LehrerApp.Desktop.ViewModels.Groups;
|
||||
|
||||
public partial class GroupRolloverDialogViewModel : ObservableObject
|
||||
{
|
||||
private readonly LearningGroup _source;
|
||||
private readonly GroupRolloverService _rollover;
|
||||
private readonly SchoolYearService _schoolYears;
|
||||
|
||||
[ObservableProperty] private string _name;
|
||||
[ObservableProperty] private string _targetSchoolYear;
|
||||
[ObservableProperty] private int _gradeLevel;
|
||||
[ObservableProperty] private bool _copyGradingScheme;
|
||||
[ObservableProperty] private bool _archiveSource = true;
|
||||
[ObservableProperty] private string _nameError = "";
|
||||
[ObservableProperty] private string _schoolYearError = "";
|
||||
[ObservableProperty] private string _selectionError = "";
|
||||
[ObservableProperty] private string _generalError = "";
|
||||
|
||||
public string SourceSummary => $"{_source.Name} · {_source.SchoolYear} · Stufe {_source.GradeLevel}";
|
||||
public bool HasGradingScheme { get; }
|
||||
public int SelectedCount => Students.Count(s => s.IsSelected);
|
||||
public string SelectedCountText => $"{SelectedCount} von {Students.Count} Schülern ausgewählt";
|
||||
public ObservableCollection<GroupRolloverStudentItem> Students { get; } = [];
|
||||
public LearningGroup? Result { get; private set; }
|
||||
|
||||
public GroupRolloverDialogViewModel(LearningGroup source,
|
||||
IStudentRepository students, IGroupMembershipRepository memberships,
|
||||
IGradingSchemeRepository gradingSchemes, SchoolYearService schoolYears,
|
||||
GroupRolloverService rollover)
|
||||
{
|
||||
_source = source;
|
||||
_rollover = rollover;
|
||||
_schoolYears = schoolYears;
|
||||
_name = source.Name;
|
||||
_targetSchoolYear = NextSchoolYear(source.SchoolYear, schoolYears);
|
||||
_gradeLevel = Math.Min(13, source.GradeLevel + 1);
|
||||
HasGradingScheme = gradingSchemes.GetByGroup(source.Id) is not null;
|
||||
_copyGradingScheme = HasGradingScheme;
|
||||
|
||||
var sourceEnd = schoolYears.SchoolYearEnd(source.SchoolYear);
|
||||
foreach (var membership in memberships.GetByGroup(source.Id)
|
||||
.Select(m => (Membership: m, Student: students.GetById(m.StudentId)))
|
||||
.Where(x => x.Student is not null)
|
||||
.OrderBy(x => x.Student!.LastName).ThenBy(x => x.Student!.FirstName))
|
||||
{
|
||||
var canSelect = membership.Student!.IsActive;
|
||||
var isPreselected = canSelect
|
||||
&& GroupMembershipService.IsActiveOn(membership.Membership, sourceEnd);
|
||||
var item = new GroupRolloverStudentItem(
|
||||
membership.Student, membership.Membership, isPreselected, canSelect);
|
||||
item.PropertyChanged += (_, e) =>
|
||||
{
|
||||
if (e.PropertyName != nameof(GroupRolloverStudentItem.IsSelected)) return;
|
||||
SelectionError = "";
|
||||
OnPropertyChanged(nameof(SelectedCount));
|
||||
OnPropertyChanged(nameof(SelectedCountText));
|
||||
};
|
||||
Students.Add(item);
|
||||
}
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void Save()
|
||||
{
|
||||
NameError = "";
|
||||
SchoolYearError = "";
|
||||
SelectionError = "";
|
||||
GeneralError = "";
|
||||
var valid = true;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(Name))
|
||||
{
|
||||
NameError = "Gruppenname erforderlich.";
|
||||
valid = false;
|
||||
}
|
||||
try
|
||||
{
|
||||
var targetText = TargetSchoolYear.Trim();
|
||||
var targetStart = _schoolYears.SchoolYearStart(targetText);
|
||||
if (targetText != _schoolYears.FormatSchoolYear(targetStart.Year))
|
||||
{
|
||||
SchoolYearError = "Format JJJJ/JJ, z.B. 2027/28.";
|
||||
valid = false;
|
||||
}
|
||||
else if (targetStart <= _schoolYears.SchoolYearStart(_source.SchoolYear))
|
||||
{
|
||||
SchoolYearError = "Das Zielschuljahr muss nach dem bisherigen liegen.";
|
||||
valid = false;
|
||||
}
|
||||
}
|
||||
catch (Exception ex) when (ex is FormatException or ArgumentOutOfRangeException or IndexOutOfRangeException)
|
||||
{
|
||||
SchoolYearError = "Format JJJJ/JJ, z.B. 2027/28.";
|
||||
valid = false;
|
||||
}
|
||||
if (SelectedCount == 0)
|
||||
{
|
||||
SelectionError = "Bitte mindestens einen Schüler auswählen.";
|
||||
valid = false;
|
||||
}
|
||||
if (!valid) return;
|
||||
|
||||
try
|
||||
{
|
||||
Result = _rollover.RollOver(_source, new GroupRolloverRequest(
|
||||
Name, TargetSchoolYear.Trim(), GradeLevel,
|
||||
Students.Where(s => s.IsSelected).Select(s => s.MembershipId).ToList(),
|
||||
CopyGradingScheme, ArchiveSource));
|
||||
}
|
||||
catch (Exception ex) when (ex is InvalidOperationException or FormatException or ArgumentOutOfRangeException)
|
||||
{
|
||||
GeneralError = ex.Message;
|
||||
}
|
||||
}
|
||||
|
||||
private static string NextSchoolYear(string source, SchoolYearService schoolYears)
|
||||
{
|
||||
var start = schoolYears.SchoolYearStart(source);
|
||||
return schoolYears.FormatSchoolYear(start.Year + 1);
|
||||
}
|
||||
}
|
||||
|
||||
public partial class GroupRolloverStudentItem : ObservableObject
|
||||
{
|
||||
[ObservableProperty] private bool _isSelected;
|
||||
|
||||
public Guid MembershipId { get; }
|
||||
public string FullName { get; }
|
||||
public string NiveauText { get; }
|
||||
public string MembershipStatus { get; }
|
||||
public bool CanSelect { get; }
|
||||
|
||||
public GroupRolloverStudentItem(Student student, GroupMembership membership,
|
||||
bool isSelected, bool canSelect)
|
||||
{
|
||||
MembershipId = membership.Id;
|
||||
FullName = student.FullName;
|
||||
NiveauText = membership.Niveau?.ToString() ?? "–";
|
||||
CanSelect = canSelect;
|
||||
_isSelected = isSelected;
|
||||
MembershipStatus = BuildStatus(student, membership, isSelected);
|
||||
}
|
||||
|
||||
partial void OnIsSelectedChanged(bool value)
|
||||
{
|
||||
if (!CanSelect && value) IsSelected = false;
|
||||
}
|
||||
|
||||
private static string BuildStatus(Student student, GroupMembership membership, bool isPreselected)
|
||||
{
|
||||
if (!student.IsActive) return "Schüler inaktiv · nicht übernehmbar";
|
||||
if (isPreselected) return "Bis Schuljahresende aktiv · vorausgewählt";
|
||||
if (membership.LeftAt is { } leftAt) return $"Ausgetreten am {leftAt:dd.MM.yyyy} · nicht vorausgewählt";
|
||||
if (membership.Period == MembershipPeriod.H1Only) return "Nur 1. Halbjahr · nicht vorausgewählt";
|
||||
return "Am Schuljahresende nicht aktiv · nicht vorausgewählt";
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ 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;
|
||||
@@ -17,6 +18,7 @@ public partial class GroupListViewModel : ObservableObject
|
||||
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 = "";
|
||||
@@ -53,6 +55,7 @@ public partial class GroupListViewModel : ObservableObject
|
||||
OnPropertyChanged(nameof(SelectedGroupSubtitle));
|
||||
NavigateToSectionCommand.NotifyCanExecuteChanged();
|
||||
EditGroupCommand.NotifyCanExecuteChanged();
|
||||
RollOverGroupCommand.NotifyCanExecuteChanged();
|
||||
ToggleArchiveCommand.NotifyCanExecuteChanged();
|
||||
DeleteGroupCommand.NotifyCanExecuteChanged();
|
||||
}
|
||||
@@ -85,7 +88,7 @@ public partial class GroupListViewModel : ObservableObject
|
||||
}
|
||||
[RelayCommand] private void Refresh() => LoadGroups();
|
||||
|
||||
[RelayCommand(CanExecute = nameof(HasSelectedGroup))]
|
||||
[RelayCommand(CanExecute = nameof(CanEditSelectedGroup))]
|
||||
private async Task EditGroup()
|
||||
{
|
||||
if (SelectedGroup is null || OnEditGroup is null) return;
|
||||
@@ -95,6 +98,20 @@ public partial class GroupListViewModel : ObservableObject
|
||||
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()
|
||||
{
|
||||
@@ -106,7 +123,7 @@ public partial class GroupListViewModel : ObservableObject
|
||||
LoadGroups();
|
||||
}
|
||||
|
||||
[RelayCommand(CanExecute = nameof(HasSelectedGroup))]
|
||||
[RelayCommand(CanExecute = nameof(CanEditSelectedGroup))]
|
||||
private async Task DeleteGroup()
|
||||
{
|
||||
if (SelectedGroup is null || OnConfirmDelete is null) return;
|
||||
@@ -124,6 +141,7 @@ public partial class GroupListViewModel : ObservableObject
|
||||
}
|
||||
|
||||
private bool HasSelectedGroup() => SelectedGroup is not null;
|
||||
private bool CanEditSelectedGroup() => SelectedGroup?.IsActive == true;
|
||||
}
|
||||
|
||||
public class GroupListItem
|
||||
@@ -175,9 +193,17 @@ public partial class GroupDetailViewModel : ObservableObject
|
||||
// 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));
|
||||
partial void OnGroupChanged(LearningGroup? value)
|
||||
{
|
||||
OnPropertyChanged(nameof(IsDifferentiated));
|
||||
OnPropertyChanged(nameof(IsReadOnly));
|
||||
OnPropertyChanged(nameof(IsEditable));
|
||||
NotifyWriteCommands();
|
||||
}
|
||||
partial void OnShowFormerStudentsChanged(bool value) => LoadStudents();
|
||||
|
||||
public ObservableCollection<StudentSummary> Students { get; } = [];
|
||||
@@ -194,6 +220,7 @@ public partial class GroupDetailViewModel : ObservableObject
|
||||
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,
|
||||
@@ -221,9 +248,9 @@ public partial class GroupDetailViewModel : ObservableObject
|
||||
$"Stufe {Group.GradeLevel} · Noten {(Group.GradingSystem == GradingSystem.Grades1To6 ? "1–6" : "0–15")}";
|
||||
LoadStudents();
|
||||
ReloadExams();
|
||||
ParticipationTab.Initialize(Group.Id, Group.SchoolYear);
|
||||
GradeOverviewTab.Initialize(Group.Id, Group.GradingSystem, Group.Type, GroupTitle, Group.SchoolYear);
|
||||
PlanningTab.Initialize(Group.Id);
|
||||
ParticipationTab.Initialize(Group.Id, Group.SchoolYear, IsReadOnly);
|
||||
GradeOverviewTab.Initialize(Group.Id, Group.GradingSystem, Group.Type, GroupTitle, Group.SchoolYear, IsReadOnly);
|
||||
PlanningTab.Initialize(Group.Id, IsReadOnly);
|
||||
}
|
||||
|
||||
private void ReloadExams()
|
||||
@@ -247,7 +274,11 @@ public partial class GroupDetailViewModel : ObservableObject
|
||||
{
|
||||
memberships.TryGetValue(s.Id, out var membership);
|
||||
if (membership is null) continue;
|
||||
var summary = new StudentSummary(s, membership, today) { OnChanged = SaveStudentNiveau };
|
||||
var summary = new StudentSummary(s, membership, today)
|
||||
{
|
||||
OnChanged = SaveStudentNiveau,
|
||||
CanEdit = IsEditable,
|
||||
};
|
||||
if (summary.IsFormer && !ShowFormerStudents) continue;
|
||||
Students.Add(summary);
|
||||
}
|
||||
@@ -255,14 +286,14 @@ public partial class GroupDetailViewModel : ObservableObject
|
||||
|
||||
private void SaveStudentNiveau(StudentSummary summary)
|
||||
{
|
||||
if (Group is null) return;
|
||||
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]
|
||||
[RelayCommand(CanExecute = nameof(IsEditableGroup))]
|
||||
private async Task AddStudent()
|
||||
{
|
||||
if (OnAddStudent is null) return;
|
||||
@@ -274,7 +305,7 @@ public partial class GroupDetailViewModel : ObservableObject
|
||||
}
|
||||
}
|
||||
|
||||
[RelayCommand(CanExecute = nameof(HasSelectedStudent))]
|
||||
[RelayCommand(CanExecute = nameof(CanEditSelectedStudent))]
|
||||
private async Task WithdrawStudent()
|
||||
{
|
||||
if (SelectedStudent is null || OnWithdrawStudent is null) return;
|
||||
@@ -284,7 +315,7 @@ public partial class GroupDetailViewModel : ObservableObject
|
||||
ParticipationTab.RefreshCurrentGrid();
|
||||
}
|
||||
|
||||
[RelayCommand(CanExecute = nameof(CanReinstateSelectedStudent))]
|
||||
[RelayCommand(CanExecute = nameof(CanEditAndReinstateSelectedStudent))]
|
||||
private void ReinstateStudent()
|
||||
{
|
||||
if (Group is null || SelectedStudent is null) return;
|
||||
@@ -303,10 +334,10 @@ public partial class GroupDetailViewModel : ObservableObject
|
||||
ReinstateStudentCommand.NotifyCanExecuteChanged();
|
||||
}
|
||||
|
||||
private bool HasSelectedStudent() => SelectedStudent is not null;
|
||||
private bool CanReinstateSelectedStudent() => SelectedStudent?.HasExitDate == true;
|
||||
private bool CanEditSelectedStudent() => IsEditable && SelectedStudent is not null;
|
||||
private bool CanEditAndReinstateSelectedStudent() => IsEditable && SelectedStudent?.HasExitDate == true;
|
||||
|
||||
[RelayCommand]
|
||||
[RelayCommand(CanExecute = nameof(IsEditableGroup))]
|
||||
private async Task AddExam()
|
||||
{
|
||||
if (Group is null || OnAddExam is null) return;
|
||||
@@ -314,7 +345,7 @@ public partial class GroupDetailViewModel : ObservableObject
|
||||
if (saved) ReloadExams();
|
||||
}
|
||||
|
||||
[RelayCommand(CanExecute = nameof(HasSelectedExam))]
|
||||
[RelayCommand(CanExecute = nameof(CanEditSelectedExam))]
|
||||
private async Task EditExam()
|
||||
{
|
||||
if (SelectedExam is null || OnEditExam is null) return;
|
||||
@@ -329,7 +360,7 @@ public partial class GroupDetailViewModel : ObservableObject
|
||||
}
|
||||
}
|
||||
|
||||
[RelayCommand(CanExecute = nameof(HasSelectedExam))]
|
||||
[RelayCommand(CanExecute = nameof(CanEditSelectedExam))]
|
||||
private async Task GradeExam()
|
||||
{
|
||||
if (SelectedExam is null || OnGradeExam is null) return;
|
||||
@@ -338,7 +369,7 @@ public partial class GroupDetailViewModel : ObservableObject
|
||||
await OnGradeExam(exam);
|
||||
}
|
||||
|
||||
[RelayCommand(CanExecute = nameof(HasSelectedExam))]
|
||||
[RelayCommand(CanExecute = nameof(CanEditSelectedExam))]
|
||||
private async Task EvaluateExam()
|
||||
{
|
||||
if (SelectedExam is null || OnEvaluateExam is null) return;
|
||||
@@ -347,7 +378,7 @@ public partial class GroupDetailViewModel : ObservableObject
|
||||
await OnEvaluateExam(exam);
|
||||
}
|
||||
|
||||
[RelayCommand(CanExecute = nameof(HasSelectedExam))]
|
||||
[RelayCommand(CanExecute = nameof(CanEditSelectedExam))]
|
||||
private async Task DuplicateExam()
|
||||
{
|
||||
if (SelectedExam is null || OnDuplicateExam is null) return;
|
||||
@@ -357,7 +388,7 @@ public partial class GroupDetailViewModel : ObservableObject
|
||||
if (saved) ReloadExams();
|
||||
}
|
||||
|
||||
[RelayCommand(CanExecute = nameof(HasSelectedExam))]
|
||||
[RelayCommand(CanExecute = nameof(CanEditSelectedExam))]
|
||||
private async Task DeleteExam()
|
||||
{
|
||||
if (SelectedExam is null || OnConfirmDeleteExam is null) return;
|
||||
@@ -368,7 +399,7 @@ public partial class GroupDetailViewModel : ObservableObject
|
||||
ReloadExams();
|
||||
}
|
||||
|
||||
[RelayCommand(CanExecute = nameof(HasSelectedExam))]
|
||||
[RelayCommand(CanExecute = nameof(CanEditSelectedExam))]
|
||||
private void AdvanceExamStatus()
|
||||
{
|
||||
if (SelectedExam is null) return;
|
||||
@@ -381,7 +412,7 @@ public partial class GroupDetailViewModel : ObservableObject
|
||||
});
|
||||
}
|
||||
|
||||
[RelayCommand(CanExecute = nameof(HasSelectedExam))]
|
||||
[RelayCommand(CanExecute = nameof(CanEditSelectedExam))]
|
||||
private void SetExamStatus(ExamStatus status)
|
||||
{
|
||||
if (SelectedExam is null) return;
|
||||
@@ -409,7 +440,35 @@ public partial class GroupDetailViewModel : ObservableObject
|
||||
SetExamStatusCommand.NotifyCanExecuteChanged();
|
||||
}
|
||||
|
||||
private bool HasSelectedExam() => SelectedExam is not null;
|
||||
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); }
|
||||
}
|
||||
@@ -448,6 +507,9 @@ public partial class StudentSummary : ObservableObject
|
||||
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;
|
||||
|
||||
@@ -458,11 +520,15 @@ public partial class StudentSummary : ObservableObject
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
@@ -37,6 +37,7 @@ public partial class ParticipationTabViewModel : ObservableObject
|
||||
[ObservableProperty] private bool _hasCompetencyCatalog;
|
||||
[ObservableProperty] private bool _studentCompetencyRatingsVisible;
|
||||
[ObservableProperty] private int _rebuildColumnsSignal;
|
||||
[ObservableProperty] private bool _isReadOnly;
|
||||
|
||||
public string SelectedSessionDisplay => SelectedSession?.Display ?? "";
|
||||
public List<string> ActiveCompetencyCodes { get; private set; } = [];
|
||||
@@ -67,10 +68,11 @@ public partial class ParticipationTabViewModel : ObservableObject
|
||||
_competencyDomains = competencyDomains;
|
||||
}
|
||||
|
||||
public void Initialize(Guid groupId, string schoolYear)
|
||||
public void Initialize(Guid groupId, string schoolYear, bool isReadOnly = false)
|
||||
{
|
||||
_groupId = groupId;
|
||||
_schoolYear = schoolYear;
|
||||
IsReadOnly = isReadOnly;
|
||||
|
||||
var group = _groups.GetById(groupId);
|
||||
_subjectId = group?.SubjectId;
|
||||
@@ -160,6 +162,7 @@ public partial class ParticipationTabViewModel : ObservableObject
|
||||
|
||||
private void SaveRating(Guid sessionId, Guid studentId, string key, int? value)
|
||||
{
|
||||
if (IsReadOnly) return;
|
||||
var entry = _entries.GetBySessionAndStudent(sessionId, studentId)
|
||||
?? new ParticipationEntry
|
||||
{
|
||||
@@ -187,6 +190,7 @@ public partial class ParticipationTabViewModel : ObservableObject
|
||||
|
||||
private void SaveHomework(Guid sessionId, Guid studentId, HomeworkStatus? value)
|
||||
{
|
||||
if (IsReadOnly) return;
|
||||
var entry = _entries.GetBySessionAndStudent(sessionId, studentId)
|
||||
?? new ParticipationEntry { SessionId = sessionId, StudentId = studentId };
|
||||
entry.Homework = value;
|
||||
@@ -196,6 +200,7 @@ public partial class ParticipationTabViewModel : ObservableObject
|
||||
|
||||
private void SaveAttendance(Guid sessionId, Guid studentId, AttendanceStatus? value)
|
||||
{
|
||||
if (IsReadOnly) return;
|
||||
var entry = _entries.GetBySessionAndStudent(sessionId, studentId)
|
||||
?? new ParticipationEntry { SessionId = sessionId, StudentId = studentId };
|
||||
entry.Attendance = value;
|
||||
|
||||
@@ -39,6 +39,7 @@ public partial class PlanningTabViewModel : ObservableObject
|
||||
|
||||
[ObservableProperty] private UnitSummary? _selectedUnit;
|
||||
[ObservableProperty] private LessonSummary? _selectedLesson;
|
||||
[ObservableProperty] private bool _isReadOnly;
|
||||
|
||||
// Eigene Property statt "SelectedUnit.Title" im Binding-Pfad: SelectedUnit ist zwischen
|
||||
// Gruppenwechsel/Laden kurzzeitig null — ein verschachtelter Pfad würde dafür jedes Mal
|
||||
@@ -74,9 +75,10 @@ public partial class PlanningTabViewModel : ObservableObject
|
||||
_subjects = subjects; _competencyDomains = competencyDomains;
|
||||
}
|
||||
|
||||
public void Initialize(Guid groupId)
|
||||
public void Initialize(Guid groupId, bool isReadOnly = false)
|
||||
{
|
||||
_groupId = groupId;
|
||||
IsReadOnly = isReadOnly;
|
||||
var group = _groups.GetById(groupId);
|
||||
SubjectId = group?.SubjectId;
|
||||
GradeLevel = group?.GradeLevel ?? 0;
|
||||
|
||||
@@ -64,7 +64,7 @@ public partial class MainWindowViewModel : ObservableObject
|
||||
{
|
||||
NavItem.Dashboard => GetDashboard(),
|
||||
NavItem.Groups => _services.GetRequiredService<GroupListViewModel>(),
|
||||
NavItem.Students => _services.GetRequiredService<StudentListViewModel>(),
|
||||
NavItem.Students => GetStudents(),
|
||||
NavItem.Exams => new PlaceholderViewModel { Title = "Klausuren", Icon = "📝" },
|
||||
NavItem.Planner => GetTimetable(),
|
||||
NavItem.Workload => GetWorkload(),
|
||||
@@ -80,6 +80,14 @@ public partial class MainWindowViewModel : ObservableObject
|
||||
return dashboard;
|
||||
}
|
||||
|
||||
private StudentListViewModel GetStudents()
|
||||
{
|
||||
var students = _services.GetRequiredService<StudentListViewModel>();
|
||||
students.SelectedStudent = null;
|
||||
students.LoadStudents();
|
||||
return students;
|
||||
}
|
||||
|
||||
private TimetableViewModel GetTimetable()
|
||||
{
|
||||
var timetable = _services.GetRequiredService<TimetableViewModel>();
|
||||
@@ -120,9 +128,12 @@ public partial class MainWindowViewModel : ObservableObject
|
||||
{
|
||||
ActiveNavItem = NavItem.Students;
|
||||
var vm = _services.GetRequiredService<StudentDetailViewModel>();
|
||||
vm.OnReturnToStudentList = NavigateToStudents;
|
||||
vm.LoadStudent(studentId);
|
||||
CurrentPage = vm;
|
||||
}
|
||||
|
||||
public void NavigateToStudents() => NavigateTo(NavItem.Students);
|
||||
}
|
||||
|
||||
public enum NavItem { Dashboard, Groups, Students, Exams, Planner, Workload, Settings }
|
||||
|
||||
@@ -29,6 +29,7 @@ public partial class SettingsViewModel : ObservableObject
|
||||
private readonly IDocumentationRepository _documentation;
|
||||
private readonly IStudentRepository _students;
|
||||
private readonly IShorthandCodeRepository _shorthandCodes;
|
||||
private readonly LetterTemplateService _letterTemplates;
|
||||
|
||||
[ObservableProperty] private int _activeTabIndex;
|
||||
|
||||
@@ -67,6 +68,13 @@ public partial class SettingsViewModel : ObservableObject
|
||||
public List<string> GradingSystemOptions { get; } = ["Noten 1–6", "Punkte 0–15"];
|
||||
public ObservableCollection<GradingKeyTemplateEditItem> GradingKeyTemplateList { get; } = [];
|
||||
|
||||
// ── Word-Briefvorlagen (7.1.4 / 11.5) ───────────────────────────────────
|
||||
|
||||
[ObservableProperty] private string _letterTemplateStatus = "";
|
||||
public ObservableCollection<LetterTemplateListItem> LetterTemplateList { get; } = [];
|
||||
public IReadOnlyList<LetterPlaceholder> SupportedLetterPlaceholders =>
|
||||
LetterTemplateService.SupportedPlaceholders;
|
||||
|
||||
// ── Gewichtungsschema-Voreinstellungen (2.3.3) ───────────────────────────
|
||||
|
||||
[ObservableProperty] private GradingSchemeEditItem _classScheme = null!;
|
||||
@@ -160,7 +168,7 @@ public partial class SettingsViewModel : ObservableObject
|
||||
IDocumentationRepository documentation, IStudentRepository students,
|
||||
IShorthandCodeRepository shorthandCodes, ISchoolHolidayRepository schoolHolidays,
|
||||
SchoolCalendarSettingsService calendarSettings, PeriodScheduleService periodSchedule,
|
||||
ISupervisionDutyRepository supervisionDuties)
|
||||
ISupervisionDutyRepository supervisionDuties, LetterTemplateService letterTemplates)
|
||||
{
|
||||
_subjects = subjects;
|
||||
_domainRepo = domainRepo;
|
||||
@@ -179,6 +187,7 @@ public partial class SettingsViewModel : ObservableObject
|
||||
_calendarSettings = calendarSettings;
|
||||
_periodSchedule = periodSchedule;
|
||||
_supervisionDuties = supervisionDuties;
|
||||
_letterTemplates = letterTemplates;
|
||||
LoadSubjects();
|
||||
LoadShorthandCodes();
|
||||
LoadGradingKeyTemplates();
|
||||
@@ -193,6 +202,55 @@ public partial class SettingsViewModel : ObservableObject
|
||||
LoadSchoolHolidays();
|
||||
LoadPeriodTimes();
|
||||
LoadSupervisionDuties();
|
||||
LoadLetterTemplates();
|
||||
}
|
||||
|
||||
// ── Word-Briefvorlagen: Import und Validierung ──────────────────────────
|
||||
|
||||
private void LoadLetterTemplates()
|
||||
{
|
||||
LetterTemplateList.Clear();
|
||||
foreach (var template in _letterTemplates.GetTemplates())
|
||||
LetterTemplateList.Add(new LetterTemplateListItem(template, _letterTemplates.Validate(template)));
|
||||
}
|
||||
|
||||
public void ImportLetterTemplate(string path)
|
||||
{
|
||||
LetterTemplateStatus = "";
|
||||
try
|
||||
{
|
||||
var template = _letterTemplates.Import(path);
|
||||
var validation = _letterTemplates.Validate(template);
|
||||
LoadLetterTemplates();
|
||||
LetterTemplateStatus = validation.Issues.Count == 0
|
||||
? "Vorlage importiert und ohne Auffälligkeiten geprüft."
|
||||
: $"Vorlage importiert. Die Prüfung meldet {validation.Issues.Count} Hinweis(e).";
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or InvalidDataException or UnauthorizedAccessException)
|
||||
{
|
||||
LetterTemplateStatus = $"Import fehlgeschlagen: {ex.Message}";
|
||||
}
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void ValidateLetterTemplate(LetterTemplateListItem? item)
|
||||
{
|
||||
if (item is null) return;
|
||||
var index = LetterTemplateList.IndexOf(item);
|
||||
var refreshed = new LetterTemplateListItem(item.Model, _letterTemplates.Validate(item.Model));
|
||||
if (index >= 0) LetterTemplateList[index] = refreshed;
|
||||
LetterTemplateStatus = refreshed.Validation.Issues.Count == 0
|
||||
? $"„{item.Name}“ ist ohne Auffälligkeiten."
|
||||
: $"„{item.Name}“: {refreshed.Validation.Issues.Count} Hinweis(e).";
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void DeleteLetterTemplate(LetterTemplateListItem? item)
|
||||
{
|
||||
if (item is null) return;
|
||||
_letterTemplates.Delete(item.Id);
|
||||
LetterTemplateList.Remove(item);
|
||||
LetterTemplateStatus = "Vorlage gelöscht.";
|
||||
}
|
||||
|
||||
// ── Aufsichten: Laden / Hinzufügen / Löschen ─────────────────────────────
|
||||
@@ -929,6 +987,45 @@ public class BackupListItem(BackupInfo info)
|
||||
public string Display { get; } = $"{info.CreatedAt:dd.MM.yyyy HH:mm} · {info.SizeBytes / 1024.0:0} KB";
|
||||
}
|
||||
|
||||
public sealed class LetterTemplateListItem
|
||||
{
|
||||
public LetterTemplateInfo Model { get; }
|
||||
public TemplateValidationResult Validation { get; }
|
||||
public Guid Id => Model.Id;
|
||||
public string Name => Model.Name;
|
||||
public string OriginalFileName => Model.OriginalFileName;
|
||||
public bool HasIssues => Validation.Issues.Count > 0;
|
||||
public bool HasNoIssues => !HasIssues;
|
||||
public string ValidationSummary => HasNoIssues
|
||||
? $"{Validation.Tags.Count} Feld(er) · keine Auffälligkeiten"
|
||||
: $"{Validation.Tags.Count} Feld(er) · {Validation.Issues.Count} Hinweis(e)";
|
||||
public ObservableCollection<LetterTemplateIssueItem> Issues { get; }
|
||||
|
||||
public LetterTemplateListItem(LetterTemplateInfo model, TemplateValidationResult validation)
|
||||
{
|
||||
Model = model;
|
||||
Validation = validation;
|
||||
Issues = new(validation.Issues.Select(i => new LetterTemplateIssueItem(i)));
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class LetterTemplateIssueItem(TemplateValidationIssue issue)
|
||||
{
|
||||
public string Icon => issue.Severity switch
|
||||
{
|
||||
TemplateIssueSeverity.Error => "⛔",
|
||||
TemplateIssueSeverity.StrongWarning => "⚠",
|
||||
_ => "ⓘ",
|
||||
};
|
||||
public string Message => issue.Message;
|
||||
public string Color => issue.Severity switch
|
||||
{
|
||||
TemplateIssueSeverity.Error => "#DC2626",
|
||||
TemplateIssueSeverity.StrongWarning => "#D97706",
|
||||
_ => "#6B7280",
|
||||
};
|
||||
}
|
||||
|
||||
public class ExpiredDocumentItem(Documentation d, string studentName)
|
||||
{
|
||||
public Guid Id { get; } = d.Id;
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using LehrerApp.Core.Interfaces;
|
||||
using LehrerApp.Core.Models;
|
||||
using LehrerApp.Core.Services;
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Globalization;
|
||||
|
||||
namespace LehrerApp.Desktop.ViewModels.Students;
|
||||
|
||||
public partial class CreateLetterDialogViewModel : ObservableObject
|
||||
{
|
||||
private readonly Student _student;
|
||||
private readonly LetterTemplateService _templates;
|
||||
|
||||
[ObservableProperty] private LetterTemplateChoice? _selectedTemplate;
|
||||
[ObservableProperty] private LetterContactChoice? _selectedContact;
|
||||
[ObservableProperty] private LetterGroupChoice? _selectedGroup;
|
||||
[ObservableProperty] private DateTimeOffset? _letterDate = DateTimeOffset.Now;
|
||||
[ObservableProperty] private string _generationError = "";
|
||||
[ObservableProperty] private bool _canGenerate;
|
||||
|
||||
public string StudentName => _student.FullName;
|
||||
public ObservableCollection<LetterTemplateChoice> Templates { get; } = [];
|
||||
public ObservableCollection<LetterContactChoice> Contacts { get; } = [];
|
||||
public ObservableCollection<LetterGroupChoice> Groups { get; } = [];
|
||||
public ObservableCollection<LetterGenerationIssue> Issues { get; } = [];
|
||||
public bool HasIssues => Issues.Count > 0;
|
||||
public bool HasNoTemplates => Templates.Count == 0;
|
||||
public bool HasNoContacts => Contacts.Count == 0;
|
||||
public string SuggestedFileName => SanitizeFileName(
|
||||
$"{SelectedTemplate?.Name ?? "Elternbrief"}_{_student.LastName}_{_student.FirstName}.docx");
|
||||
|
||||
public CreateLetterDialogViewModel(Student student, LetterTemplateService templates,
|
||||
IGroupMembershipRepository memberships, IGroupRepository groups)
|
||||
{
|
||||
_student = student;
|
||||
_templates = templates;
|
||||
|
||||
foreach (var template in templates.GetTemplates())
|
||||
Templates.Add(new LetterTemplateChoice(template));
|
||||
foreach (var contact in student.Contacts.Where(c => !c.InvalidSince.HasValue).OrderBy(c => c.Name))
|
||||
Contacts.Add(new LetterContactChoice(contact));
|
||||
foreach (var membership in memberships.GetByStudent(student.Id))
|
||||
{
|
||||
var group = groups.GetById(membership.GroupId);
|
||||
if (group is not null) Groups.Add(new LetterGroupChoice(group));
|
||||
}
|
||||
|
||||
SelectedTemplate = Templates.FirstOrDefault();
|
||||
SelectedContact = Contacts.FirstOrDefault();
|
||||
SelectedGroup = Groups.FirstOrDefault();
|
||||
RefreshValidation();
|
||||
}
|
||||
|
||||
partial void OnSelectedTemplateChanged(LetterTemplateChoice? value)
|
||||
{
|
||||
OnPropertyChanged(nameof(SuggestedFileName));
|
||||
RefreshValidation();
|
||||
}
|
||||
partial void OnSelectedContactChanged(LetterContactChoice? value) => RefreshValidation();
|
||||
partial void OnSelectedGroupChanged(LetterGroupChoice? value) => RefreshValidation();
|
||||
partial void OnLetterDateChanged(DateTimeOffset? value) => RefreshValidation();
|
||||
|
||||
public bool Generate(string outputPath)
|
||||
{
|
||||
RefreshValidation();
|
||||
if (!CanGenerate || SelectedTemplate is null) return false;
|
||||
GenerationError = "";
|
||||
try
|
||||
{
|
||||
_templates.Generate(_templates.GetTemplatePath(SelectedTemplate.Model), outputPath, BuildValues());
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or InvalidDataException or UnauthorizedAccessException)
|
||||
{
|
||||
GenerationError = $"Der Brief konnte nicht erzeugt werden: {ex.Message}";
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private void RefreshValidation()
|
||||
{
|
||||
Issues.Clear();
|
||||
GenerationError = "";
|
||||
|
||||
if (SelectedTemplate is null)
|
||||
Issues.Add(new("Bitte zuerst in den Einstellungen eine DOCX-Briefvorlage importieren.", true));
|
||||
if (SelectedContact is null)
|
||||
Issues.Add(new("Für den Schüler ist kein aktueller Kontakt ausgewählt.", true));
|
||||
if (LetterDate is null)
|
||||
Issues.Add(new("Bitte ein Briefdatum auswählen.", true));
|
||||
|
||||
if (SelectedTemplate is not null)
|
||||
{
|
||||
var validation = _templates.Validate(SelectedTemplate.Model);
|
||||
foreach (var issue in validation.Issues)
|
||||
Issues.Add(new(issue.Message, issue.Severity is TemplateIssueSeverity.StrongWarning or TemplateIssueSeverity.Error));
|
||||
|
||||
var tags = validation.Tags.ToHashSet(StringComparer.Ordinal);
|
||||
var values = BuildValues();
|
||||
foreach (var tag in tags.Where(t => values.TryGetValue(t, out var value) && string.IsNullOrWhiteSpace(value)))
|
||||
{
|
||||
var message = tag switch
|
||||
{
|
||||
"Letter.Salutation" => "Beim ausgewählten Kontakt fehlt die Briefanrede.",
|
||||
"Contact.Address" or "Contact.Street" or "Contact.PostalCode" or "Contact.City" =>
|
||||
$"Beim ausgewählten Kontakt fehlt der Wert für „{tag}“.",
|
||||
"Group.Name" or "SchoolYear" => "Die Vorlage verwendet Gruppendaten; bitte eine Lerngruppe auswählen.",
|
||||
_ => $"Für das Vorlagenfeld „{tag}“ ist kein Wert vorhanden.",
|
||||
};
|
||||
Issues.Add(new(message, true));
|
||||
}
|
||||
}
|
||||
|
||||
CanGenerate = SelectedTemplate is not null && SelectedContact is not null && LetterDate is not null
|
||||
&& Issues.Count == 0;
|
||||
OnPropertyChanged(nameof(HasIssues));
|
||||
}
|
||||
|
||||
private IReadOnlyDictionary<string, string?> BuildValues()
|
||||
{
|
||||
var contact = SelectedContact?.Model;
|
||||
var group = SelectedGroup?.Model;
|
||||
var cityLine = string.Join(" ", new[] { contact?.PostalCode, contact?.City }
|
||||
.Where(v => !string.IsNullOrWhiteSpace(v)));
|
||||
var address = string.Join(Environment.NewLine, new[] { contact?.Street, cityLine }
|
||||
.Where(v => !string.IsNullOrWhiteSpace(v)));
|
||||
var date = LetterDate is null ? null : DateOnly.FromDateTime(LetterDate.Value.LocalDateTime)
|
||||
.ToString("dd.MM.yyyy", CultureInfo.GetCultureInfo("de-DE"));
|
||||
|
||||
return new Dictionary<string, string?>
|
||||
{
|
||||
["Student.FirstName"] = _student.FirstName,
|
||||
["Student.LastName"] = _student.LastName,
|
||||
["Contact.Name"] = contact?.Name,
|
||||
["Contact.Address"] = address,
|
||||
["Contact.Street"] = contact?.Street,
|
||||
["Contact.PostalCode"] = contact?.PostalCode,
|
||||
["Contact.City"] = contact?.City,
|
||||
["Letter.Salutation"] = contact?.LetterSalutation,
|
||||
["Group.Name"] = group?.Name,
|
||||
["SchoolYear"] = group?.SchoolYear,
|
||||
["CurrentDate"] = date,
|
||||
};
|
||||
}
|
||||
|
||||
private static string SanitizeFileName(string value)
|
||||
{
|
||||
foreach (var character in Path.GetInvalidFileNameChars()) value = value.Replace(character, '_');
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class LetterTemplateChoice
|
||||
{
|
||||
public LetterTemplateInfo Model { get; }
|
||||
public string Name => Model.Name;
|
||||
public LetterTemplateChoice(LetterTemplateInfo model) => Model = model;
|
||||
}
|
||||
|
||||
public sealed class LetterContactChoice
|
||||
{
|
||||
public Contact Model { get; }
|
||||
public string Display => string.IsNullOrWhiteSpace(Model.Relation)
|
||||
? Model.Name
|
||||
: $"{Model.Name} · {Model.Relation}";
|
||||
public LetterContactChoice(Contact model) => Model = model;
|
||||
}
|
||||
|
||||
public sealed class LetterGroupChoice
|
||||
{
|
||||
public LearningGroup Model { get; }
|
||||
public string Display => $"{Model.Name} · {Model.SchoolYear}";
|
||||
public LetterGroupChoice(LearningGroup model) => Model = model;
|
||||
}
|
||||
|
||||
public sealed class LetterGenerationIssue(string message, bool isStrong)
|
||||
{
|
||||
public string Icon { get; } = isStrong ? "⚠" : "ⓘ";
|
||||
public string Message { get; } = message;
|
||||
public string Color { get; } = isStrong ? "#D97706" : "#6B7280";
|
||||
}
|
||||
@@ -9,9 +9,56 @@ using System.Globalization;
|
||||
|
||||
namespace LehrerApp.Desktop.ViewModels.Students;
|
||||
|
||||
public static class GenderAvatarDisplay
|
||||
{
|
||||
public static List<string> Options { get; } = ["", "M – männlich", "W – weiblich", "D – divers"];
|
||||
|
||||
public static string Label(Gender? gender) => gender switch
|
||||
{
|
||||
Gender.M => "M",
|
||||
Gender.W => "W",
|
||||
Gender.D => "D",
|
||||
_ => "?",
|
||||
};
|
||||
|
||||
public static string Color(Gender? gender) => gender switch
|
||||
{
|
||||
Gender.M => "#3B82F6",
|
||||
Gender.W => "#A855F7",
|
||||
Gender.D => "#0D9488",
|
||||
_ => "#6B7280",
|
||||
};
|
||||
|
||||
public static string Tooltip(Gender? gender) => gender switch
|
||||
{
|
||||
Gender.M => "Geschlecht: männlich",
|
||||
Gender.W => "Geschlecht: weiblich",
|
||||
Gender.D => "Geschlecht: divers",
|
||||
_ => "Geschlecht nicht angegeben",
|
||||
};
|
||||
|
||||
public static string ToOption(Gender? gender) => gender switch
|
||||
{
|
||||
Gender.M => "M – männlich",
|
||||
Gender.W => "W – weiblich",
|
||||
Gender.D => "D – divers",
|
||||
_ => "",
|
||||
};
|
||||
|
||||
public static Gender? FromOption(string? option) => option switch
|
||||
{
|
||||
"M – männlich" => Gender.M,
|
||||
"W – weiblich" => Gender.W,
|
||||
"D – divers" => Gender.D,
|
||||
_ => null,
|
||||
};
|
||||
}
|
||||
|
||||
public partial class StudentListViewModel : ObservableObject
|
||||
{
|
||||
private readonly IStudentRepository _students;
|
||||
private readonly IGroupRepository _groups;
|
||||
private readonly IGroupMembershipRepository _memberships;
|
||||
public Action<Guid>? OnNavigateToDetail { get; set; }
|
||||
|
||||
[ObservableProperty] private string _searchText = "";
|
||||
@@ -21,9 +68,12 @@ public partial class StudentListViewModel : ObservableObject
|
||||
public ObservableCollection<StudentListItem> Students { get; } = [];
|
||||
public string CountSummary => $"{Students.Count} Schüler gesamt";
|
||||
|
||||
public StudentListViewModel(IStudentRepository students)
|
||||
public StudentListViewModel(IStudentRepository students, IGroupRepository groups,
|
||||
IGroupMembershipRepository memberships)
|
||||
{
|
||||
_students = students;
|
||||
_groups = groups;
|
||||
_memberships = memberships;
|
||||
LoadStudents();
|
||||
}
|
||||
|
||||
@@ -38,9 +88,21 @@ public partial class StudentListViewModel : ObservableObject
|
||||
{
|
||||
Students.Clear();
|
||||
var all = _students.GetAll(ShowInactive);
|
||||
var f = string.IsNullOrWhiteSpace(SearchText) ? all
|
||||
: all.Where(s => s.LastName.Contains(SearchText, StringComparison.OrdinalIgnoreCase)
|
||||
|| s.FirstName.Contains(SearchText, StringComparison.OrdinalIgnoreCase));
|
||||
var query = SearchText.Trim();
|
||||
IEnumerable<Student> f = all;
|
||||
if (query.Length > 0)
|
||||
{
|
||||
var matchingStudentIds = _groups.GetAll(includeInactive: true)
|
||||
.Where(g => g.Name.Contains(query, StringComparison.OrdinalIgnoreCase))
|
||||
.SelectMany(g => _memberships.GetByGroup(g.Id))
|
||||
.Select(m => m.StudentId)
|
||||
.ToHashSet();
|
||||
|
||||
f = all.Where(s => s.LastName.Contains(query, StringComparison.OrdinalIgnoreCase)
|
||||
|| s.FirstName.Contains(query, StringComparison.OrdinalIgnoreCase)
|
||||
|| s.FullName.Contains(query, StringComparison.OrdinalIgnoreCase)
|
||||
|| matchingStudentIds.Contains(s.Id));
|
||||
}
|
||||
foreach (var s in f) Students.Add(new StudentListItem(s));
|
||||
OnPropertyChanged(nameof(CountSummary));
|
||||
}
|
||||
@@ -63,10 +125,92 @@ public class StudentListItem
|
||||
public Guid Id { get; }
|
||||
public string FullName { get; }
|
||||
public string DateOfBirth { get; }
|
||||
public string AvatarLabel { get; }
|
||||
public string AvatarColor { get; }
|
||||
public string AvatarTooltip { get; }
|
||||
public StudentListItem(Student s)
|
||||
{
|
||||
Id = s.Id; FullName = s.FullName;
|
||||
DateOfBirth = s.DateOfBirth?.ToString("dd.MM.yyyy") ?? "";
|
||||
AvatarLabel = GenderAvatarDisplay.Label(s.Gender);
|
||||
AvatarColor = GenderAvatarDisplay.Color(s.Gender);
|
||||
AvatarTooltip = GenderAvatarDisplay.Tooltip(s.Gender);
|
||||
}
|
||||
}
|
||||
|
||||
public enum StudentManagementResult { Cancelled, Deactivated, Reactivated, Deleted }
|
||||
|
||||
public class StudentReferenceItem(string label, int count)
|
||||
{
|
||||
public string Label { get; } = label;
|
||||
public int Count { get; } = count;
|
||||
public string CountDisplay => Count.ToString(CultureInfo.InvariantCulture);
|
||||
}
|
||||
|
||||
public partial class ManageStudentDialogViewModel : ObservableObject
|
||||
{
|
||||
private readonly IStudentRepository _students;
|
||||
private readonly Student _student;
|
||||
|
||||
[ObservableProperty] private string _errorMessage = "";
|
||||
|
||||
public string StudentName => _student.FullName;
|
||||
public bool IsActive => _student.IsActive;
|
||||
public bool IsInactive => !IsActive;
|
||||
public StudentReferenceSummary References { get; }
|
||||
public bool HasReferences => References.HasReferences;
|
||||
public bool CanDelete => !HasReferences;
|
||||
public ObservableCollection<StudentReferenceItem> ReferenceItems { get; } = [];
|
||||
public StudentManagementResult Result { get; private set; }
|
||||
|
||||
public ManageStudentDialogViewModel(IStudentRepository students, Student student)
|
||||
{
|
||||
_students = students;
|
||||
_student = student;
|
||||
References = students.GetReferenceSummary(student.Id);
|
||||
|
||||
AddReference("Gruppenzuordnungen", References.Memberships);
|
||||
AddReference("Klausurergebnisse", References.ExamResults);
|
||||
AddReference("Einzelnoten", References.Grades);
|
||||
AddReference("Zeugnisnoten", References.ReportGrades);
|
||||
AddReference("Mitarbeitseinträge", References.ParticipationEntries);
|
||||
AddReference("Dokumentationseinträge", References.DocumentationEntries);
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void Deactivate()
|
||||
{
|
||||
_student.IsActive = false;
|
||||
_students.Save(_student);
|
||||
Result = StudentManagementResult.Deactivated;
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void Reactivate()
|
||||
{
|
||||
_student.IsActive = true;
|
||||
_students.Save(_student);
|
||||
Result = StudentManagementResult.Reactivated;
|
||||
}
|
||||
|
||||
[RelayCommand(CanExecute = nameof(CanDelete))]
|
||||
private void DeletePermanently()
|
||||
{
|
||||
ErrorMessage = "";
|
||||
try
|
||||
{
|
||||
_students.Delete(_student.Id);
|
||||
Result = StudentManagementResult.Deleted;
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
ErrorMessage = ex.Message;
|
||||
}
|
||||
}
|
||||
|
||||
private void AddReference(string label, int count)
|
||||
{
|
||||
if (count > 0) ReferenceItems.Add(new StudentReferenceItem(label, count));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -91,9 +235,17 @@ public partial class StudentDetailViewModel : ObservableObject
|
||||
[ObservableProperty] private bool _isEditing;
|
||||
[ObservableProperty] private string _editFirstName = "";
|
||||
[ObservableProperty] private string _editLastName = "";
|
||||
[ObservableProperty] private string _editGender = "";
|
||||
[ObservableProperty] private ContactItem? _selectedContact;
|
||||
[ObservableProperty] private AttendanceBalance? _attendance;
|
||||
[ObservableProperty] private string _exportStatus = "";
|
||||
[ObservableProperty] private bool _isStudentActive = true;
|
||||
|
||||
public string StudentStatus => IsStudentActive ? "Aktiv" : "Inaktiv";
|
||||
public List<string> GenderOptions => GenderAvatarDisplay.Options;
|
||||
public string StudentAvatarLabel => GenderAvatarDisplay.Label(Student?.Gender);
|
||||
public string StudentAvatarColor => GenderAvatarDisplay.Color(Student?.Gender);
|
||||
public string StudentAvatarTooltip => GenderAvatarDisplay.Tooltip(Student?.Gender);
|
||||
|
||||
public ObservableCollection<GroupMembershipEntry> GroupMemberships { get; } = [];
|
||||
public ObservableCollection<DocumentationItem> Documentation { get; } = [];
|
||||
@@ -106,6 +258,11 @@ public partial class StudentDetailViewModel : ObservableObject
|
||||
public Func<DocumentationItem, Task<bool>>? OnConfirmDeleteDocumentation { get; set; }
|
||||
public Func<string, Task>? OnSaveExportFile { get; set; }
|
||||
public Func<Documentation, string, Task<Documentation?>>? OnConductParentCall { get; set; }
|
||||
public Func<Student, Task<StudentManagementResult>>? OnManageStudent { get; set; }
|
||||
public Func<Task>? OnCreateLetter { get; set; }
|
||||
public Action? OnReturnToStudentList { get; set; }
|
||||
|
||||
partial void OnIsStudentActiveChanged(bool value) => OnPropertyChanged(nameof(StudentStatus));
|
||||
|
||||
public StudentDetailViewModel(IStudentRepository students,
|
||||
IGroupMembershipRepository memberships, IGroupRepository groups, ISubjectRepository subjects,
|
||||
@@ -126,8 +283,13 @@ public partial class StudentDetailViewModel : ObservableObject
|
||||
Student = _students.GetById(id);
|
||||
if (Student is null) return;
|
||||
StudentTitle = Student.FullName;
|
||||
IsStudentActive = Student.IsActive;
|
||||
EditFirstName = Student.FirstName;
|
||||
EditLastName = Student.LastName;
|
||||
EditGender = GenderAvatarDisplay.ToOption(Student.Gender);
|
||||
OnPropertyChanged(nameof(StudentAvatarLabel));
|
||||
OnPropertyChanged(nameof(StudentAvatarColor));
|
||||
OnPropertyChanged(nameof(StudentAvatarTooltip));
|
||||
|
||||
GroupMemberships.Clear();
|
||||
GradeHistory.Clear();
|
||||
@@ -269,18 +431,44 @@ public partial class StudentDetailViewModel : ObservableObject
|
||||
}
|
||||
|
||||
[RelayCommand] private void StartEdit() => IsEditing = true;
|
||||
|
||||
[RelayCommand]
|
||||
private async Task CreateLetter()
|
||||
{
|
||||
if (Student is not null && OnCreateLetter is not null) await OnCreateLetter();
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private async Task ManageStudent()
|
||||
{
|
||||
if (Student is null || OnManageStudent is null) return;
|
||||
var result = await OnManageStudent(Student);
|
||||
if (result == StudentManagementResult.Deleted)
|
||||
{
|
||||
OnReturnToStudentList?.Invoke();
|
||||
return;
|
||||
}
|
||||
if (result is StudentManagementResult.Deactivated or StudentManagementResult.Reactivated)
|
||||
LoadStudent(Student.Id);
|
||||
}
|
||||
|
||||
[RelayCommand] private void CancelEdit()
|
||||
{
|
||||
if (Student is null) return;
|
||||
EditFirstName = Student.FirstName; EditLastName = Student.LastName;
|
||||
EditGender = GenderAvatarDisplay.ToOption(Student.Gender);
|
||||
IsEditing = false;
|
||||
}
|
||||
[RelayCommand] private void SaveEdit()
|
||||
{
|
||||
if (Student is null) return;
|
||||
Student.FirstName = EditFirstName; Student.LastName = EditLastName;
|
||||
Student.Gender = GenderAvatarDisplay.FromOption(EditGender);
|
||||
_students.Save(Student);
|
||||
StudentTitle = Student.FullName;
|
||||
OnPropertyChanged(nameof(StudentAvatarLabel));
|
||||
OnPropertyChanged(nameof(StudentAvatarColor));
|
||||
OnPropertyChanged(nameof(StudentAvatarTooltip));
|
||||
IsEditing = false;
|
||||
}
|
||||
|
||||
@@ -382,6 +570,7 @@ public class ContactItem
|
||||
public Guid Id => Model.Id;
|
||||
public string Name { get; }
|
||||
public string Relation { get; }
|
||||
public string LetterSalutation { get; }
|
||||
public string? Phone { get; }
|
||||
public string? Email { get; }
|
||||
public string Address { get; }
|
||||
@@ -404,6 +593,7 @@ public class ContactItem
|
||||
Model = c;
|
||||
Name = c.Name;
|
||||
Relation = c.Relation;
|
||||
LetterSalutation = c.LetterSalutation ?? "";
|
||||
Phone = c.Phone;
|
||||
Email = c.Email;
|
||||
Address = FormatAddress(c);
|
||||
@@ -449,7 +639,7 @@ public partial class AddStudentDialogViewModel : ObservableObject
|
||||
[ObservableProperty] private string _firstNameError = "";
|
||||
[ObservableProperty] private string _dateOfBirthError = "";
|
||||
|
||||
public List<string> GenderOptions { get; } = ["", "M – männlich", "W – weiblich", "D – divers"];
|
||||
public List<string> GenderOptions => GenderAvatarDisplay.Options;
|
||||
public List<string> RelationPresets { get; } = ["Schüler/in", "Mutter", "Vater", "Elternteil", "Erziehungsberechtigte/r", "Sonstige"];
|
||||
|
||||
public ObservableCollection<ContactEntryViewModel> Contacts { get; } = [];
|
||||
@@ -490,13 +680,7 @@ public partial class AddStudentDialogViewModel : ObservableObject
|
||||
FirstName = FirstName.Trim(),
|
||||
LastName = LastName.Trim(),
|
||||
DateOfBirth = dob,
|
||||
Gender = SelectedGender switch
|
||||
{
|
||||
"M – männlich" => Gender.M,
|
||||
"W – weiblich" => Gender.W,
|
||||
"D – divers" => Gender.D,
|
||||
_ => (Gender?)null,
|
||||
},
|
||||
Gender = GenderAvatarDisplay.FromOption(SelectedGender),
|
||||
Notes = string.IsNullOrWhiteSpace(Notes) ? null : Notes.Trim(),
|
||||
Contacts = Contacts.Select(c => c.ToModel()).Where(c => !string.IsNullOrWhiteSpace(c.Name)).ToList(),
|
||||
};
|
||||
@@ -510,6 +694,7 @@ public partial class ContactEntryViewModel : ObservableObject
|
||||
|
||||
[ObservableProperty] private string _name = "";
|
||||
[ObservableProperty] private string _relation = "";
|
||||
[ObservableProperty] private string _letterSalutation = "";
|
||||
[ObservableProperty] private string _phone = "";
|
||||
[ObservableProperty] private string _email = "";
|
||||
[ObservableProperty] private string _street = "";
|
||||
@@ -524,6 +709,7 @@ public partial class ContactEntryViewModel : ObservableObject
|
||||
{
|
||||
Name = Name.Trim(),
|
||||
Relation = Relation.Trim(),
|
||||
LetterSalutation = string.IsNullOrWhiteSpace(LetterSalutation) ? null : LetterSalutation.Trim(),
|
||||
Phone = string.IsNullOrWhiteSpace(Phone) ? null : Phone.Trim(),
|
||||
Email = string.IsNullOrWhiteSpace(Email) ? null : Email.Trim(),
|
||||
Street = string.IsNullOrWhiteSpace(Street) ? null : Street.Trim(),
|
||||
@@ -538,6 +724,7 @@ public partial class ContactEditDialogViewModel : ObservableObject
|
||||
|
||||
[ObservableProperty] private string _name = "";
|
||||
[ObservableProperty] private string _relation = "Elternteil";
|
||||
[ObservableProperty] private string _letterSalutation = "";
|
||||
[ObservableProperty] private string _phone = "";
|
||||
[ObservableProperty] private string _email = "";
|
||||
[ObservableProperty] private string _street = "";
|
||||
@@ -564,6 +751,7 @@ public partial class ContactEditDialogViewModel : ObservableObject
|
||||
if (source is null) return;
|
||||
Name = source.Name;
|
||||
Relation = source.Relation;
|
||||
LetterSalutation = source.LetterSalutation ?? "";
|
||||
Phone = source.Phone ?? "";
|
||||
Email = source.Email ?? "";
|
||||
Street = source.Street ?? "";
|
||||
@@ -614,6 +802,7 @@ public partial class ContactEditDialogViewModel : ObservableObject
|
||||
Id = _source?.Id ?? Guid.NewGuid(),
|
||||
Name = Name.Trim(),
|
||||
Relation = Relation.Trim(),
|
||||
LetterSalutation = NullIfEmpty(LetterSalutation),
|
||||
Phone = NullIfEmpty(Phone),
|
||||
Email = NullIfEmpty(Email),
|
||||
Street = NullIfEmpty(Street),
|
||||
|
||||
Reference in New Issue
Block a user