847 lines
34 KiB
C#
847 lines
34 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.Groups;
|
||
using System.Collections.ObjectModel;
|
||
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 = "";
|
||
[ObservableProperty] private bool _showInactive;
|
||
[ObservableProperty] private StudentListItem? _selectedStudent;
|
||
[ObservableProperty] private bool _isImporting;
|
||
|
||
public ObservableCollection<StudentListItem> Students { get; } = [];
|
||
public string CountSummary => $"{Students.Count} Schüler gesamt";
|
||
public bool HasNoStudents => Students.Count == 0;
|
||
public bool HasStudents => !HasNoStudents;
|
||
public string EmptyListMessage => string.IsNullOrWhiteSpace(SearchText)
|
||
? ShowInactive ? "Noch keine Schüler vorhanden." : "Noch keine aktiven Schüler vorhanden."
|
||
: "Keine Schüler passen zur aktuellen Suche.";
|
||
|
||
public StudentListViewModel(IStudentRepository students, IGroupRepository groups,
|
||
IGroupMembershipRepository memberships)
|
||
{
|
||
_students = students;
|
||
_groups = groups;
|
||
_memberships = memberships;
|
||
LoadStudents();
|
||
}
|
||
|
||
partial void OnSearchTextChanged(string value) => LoadStudents();
|
||
partial void OnShowInactiveChanged(bool value) => LoadStudents();
|
||
partial void OnSelectedStudentChanged(StudentListItem? value)
|
||
{
|
||
if (value is not null) OnNavigateToDetail?.Invoke(value.Id);
|
||
}
|
||
|
||
public void LoadStudents()
|
||
{
|
||
Students.Clear();
|
||
var all = _students.GetAll(ShowInactive);
|
||
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));
|
||
OnPropertyChanged(nameof(HasNoStudents));
|
||
OnPropertyChanged(nameof(HasStudents));
|
||
OnPropertyChanged(nameof(EmptyListMessage));
|
||
}
|
||
|
||
public Func<Task>? OnAddStudent { get; set; }
|
||
|
||
[RelayCommand]
|
||
private async Task AddStudent()
|
||
{
|
||
if (OnAddStudent is null) return;
|
||
await OnAddStudent();
|
||
LoadStudents();
|
||
}
|
||
|
||
[RelayCommand] private void Refresh() => LoadStudents();
|
||
}
|
||
|
||
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));
|
||
}
|
||
}
|
||
|
||
public partial class StudentDetailViewModel : ObservableObject
|
||
{
|
||
private readonly IStudentRepository _students;
|
||
private readonly IGroupMembershipRepository _memberships;
|
||
private readonly IGroupRepository _groups;
|
||
private readonly ISubjectRepository _subjects;
|
||
private readonly IDocumentationRepository _docs;
|
||
private readonly IExamRepository _exams;
|
||
private readonly IExamResultRepository _examResults;
|
||
private readonly IGradeRepository _grades;
|
||
private readonly IParticipationRepository _participation;
|
||
private readonly IParticipationSessionRepository _participationSessions;
|
||
private readonly AttendanceBalanceService _attendanceBalance;
|
||
private readonly PersonalDataExportService _export;
|
||
private readonly SchoolYearService _schoolYear;
|
||
|
||
[ObservableProperty] private Student? _student;
|
||
[ObservableProperty] private string _studentTitle = "";
|
||
[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; } = [];
|
||
public ObservableCollection<ContactItem> Contacts { get; } = [];
|
||
public ObservableCollection<StudentGradeHistoryGroup> GradeHistory { get; } = [];
|
||
public bool HasNoContacts => Contacts.Count == 0;
|
||
public Func<Contact?, Task<Contact?>>? OnEditContact { get; set; }
|
||
public Action<ContactItem>? OnViewAddress { get; set; }
|
||
public Func<Guid, Documentation?, Task<Documentation?>>? OnEditDocumentation { get; set; }
|
||
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,
|
||
IDocumentationRepository docs, IExamRepository exams, IExamResultRepository examResults,
|
||
IGradeRepository grades, IParticipationRepository participation,
|
||
IParticipationSessionRepository participationSessions, AttendanceBalanceService attendanceBalance,
|
||
PersonalDataExportService export, SchoolYearService schoolYear)
|
||
{
|
||
_students = students; _memberships = memberships;
|
||
_groups = groups; _subjects = subjects; _docs = docs;
|
||
_exams = exams; _examResults = examResults; _grades = grades;
|
||
_participation = participation; _participationSessions = participationSessions;
|
||
_attendanceBalance = attendanceBalance; _export = export; _schoolYear = schoolYear;
|
||
}
|
||
|
||
public void LoadStudent(Guid id)
|
||
{
|
||
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();
|
||
foreach (var membership in _memberships.GetByStudent(Student.Id))
|
||
{
|
||
var g = _groups.GetById(membership.GroupId);
|
||
if (g is null) continue;
|
||
var subject = g.SubjectId is Guid subjectId ? _subjects.GetById(subjectId)?.Name ?? "" : "";
|
||
GroupMemberships.Add(new() { SchoolYear = g.SchoolYear, GroupName = g.Name, Subject = subject });
|
||
|
||
var historyGroup = BuildGradeHistory(g, subject);
|
||
if (historyGroup is not null) GradeHistory.Add(historyGroup);
|
||
}
|
||
|
||
LoadContacts();
|
||
LoadDocumentation();
|
||
LoadAttendanceBalance();
|
||
}
|
||
|
||
// ── Dokumentation (5.1) ───────────────────────────────────────────────────
|
||
|
||
private void LoadDocumentation()
|
||
{
|
||
if (Student is null) return;
|
||
Documentation.Clear();
|
||
foreach (var d in _docs.GetByStudent(Student.Id))
|
||
Documentation.Add(new DocumentationItem(d));
|
||
}
|
||
|
||
[RelayCommand]
|
||
private async Task AddDocumentation()
|
||
{
|
||
if (Student is null || OnEditDocumentation is null) return;
|
||
var result = await OnEditDocumentation(Student.Id, null);
|
||
if (result is null) return;
|
||
_docs.Save(result);
|
||
LoadDocumentation();
|
||
}
|
||
|
||
[RelayCommand]
|
||
private async Task EditDocumentation(DocumentationItem? item)
|
||
{
|
||
if (Student is null || item is null || OnEditDocumentation is null) return;
|
||
var result = await OnEditDocumentation(Student.Id, item.Model);
|
||
if (result is null) return;
|
||
_docs.Save(result);
|
||
LoadDocumentation();
|
||
}
|
||
|
||
[RelayCommand]
|
||
private async Task DeleteDocumentation(DocumentationItem? item)
|
||
{
|
||
if (item is null) return;
|
||
if (OnConfirmDeleteDocumentation is not null && !await OnConfirmDeleteDocumentation(item)) return;
|
||
_docs.Delete(item.Model.Id);
|
||
LoadDocumentation();
|
||
}
|
||
|
||
[RelayCommand]
|
||
private async Task ConductParentCall(DocumentationItem? item)
|
||
{
|
||
if (Student is null || item is null || OnConductParentCall is null) return;
|
||
var result = await OnConductParentCall(item.Model, Student.FullName);
|
||
if (result is null) return;
|
||
_docs.Save(result);
|
||
LoadDocumentation();
|
||
}
|
||
|
||
// ── Fehlzeitenbilanz (5.2.2/5.2.3) ────────────────────────────────────────
|
||
|
||
private void LoadAttendanceBalance()
|
||
{
|
||
if (Student is null) return;
|
||
var schoolYear = _schoolYear.CurrentSchoolYear();
|
||
var from = _schoolYear.SchoolYearStart(schoolYear);
|
||
var to = _schoolYear.SchoolYearEnd(schoolYear);
|
||
|
||
var entries = _participation.GetByStudent(Student.Id)
|
||
.Select(e => _participationSessions.GetById(e.SessionId) is { } session
|
||
? ((DateOnly?)session.Date, e.Attendance) : (null, e.Attendance))
|
||
.Where(t => t.Item1.HasValue)
|
||
.Select(t => (t.Item1!.Value, t.Attendance));
|
||
|
||
Attendance = _attendanceBalance.Calculate(entries, from, to);
|
||
}
|
||
|
||
// ── Datenauskunft (5.4.3) ─────────────────────────────────────────────────
|
||
|
||
[RelayCommand]
|
||
private async Task ExportPersonalData()
|
||
{
|
||
if (Student is null || OnSaveExportFile is null) return;
|
||
var json = _export.ExportAsJson(Student.Id);
|
||
await OnSaveExportFile(json);
|
||
ExportStatus = "Datenauskunft exportiert.";
|
||
}
|
||
|
||
// ── Notenentwicklung (2.5) ────────────────────────────────────────────────
|
||
|
||
private StudentGradeHistoryGroup? BuildGradeHistory(LearningGroup g, string subject)
|
||
{
|
||
var label = string.IsNullOrEmpty(subject) ? g.Name : $"{g.Name} · {subject}";
|
||
var entries = new List<(DateOnly Date, string PointLabel, string Value)>();
|
||
|
||
foreach (var exam in _exams.GetByGroup(g.Id))
|
||
{
|
||
var result = _examResults.GetByExamAndStudent(exam.Id, Student!.Id);
|
||
if (result is null || result.Absent || result.Grade is null) continue;
|
||
entries.Add((exam.Date, exam.Title, result.Grade));
|
||
}
|
||
foreach (var grade in _grades.GetByStudentAndGroup(Student!.Id, g.Id))
|
||
entries.Add((grade.Date, GradeCategoryDisplay.Label(grade.Category), grade.Value));
|
||
|
||
var ordered = entries.OrderBy(e => e.Date).ToList();
|
||
if (ordered.Count == 0) return null;
|
||
|
||
var historyGroup = new StudentGradeHistoryGroup(label);
|
||
int? previousNoteEquivalent = null;
|
||
foreach (var e in ordered)
|
||
{
|
||
int? noteEquivalent = int.TryParse(e.Value, out var raw)
|
||
? (g.GradingSystem == GradingSystem.Grades1To6 ? raw : int.Parse(PointsNoteMapping.PointsToNote(raw)))
|
||
: null;
|
||
|
||
var isFailing = noteEquivalent is >= 5;
|
||
var isDrop = noteEquivalent.HasValue && previousNoteEquivalent.HasValue
|
||
&& noteEquivalent.Value - previousNoteEquivalent.Value >= 1;
|
||
|
||
var warnings = new List<string>();
|
||
if (isDrop) warnings.Add("Abfall um ≥ 1 Note");
|
||
if (isFailing) warnings.Add("Versetzungsgefährdung");
|
||
|
||
historyGroup.Points.Add(new GradeHistoryPoint(e.Date, e.PointLabel, e.Value,
|
||
noteEquivalent, warnings.Count > 0, string.Join(" · ", warnings)));
|
||
|
||
if (noteEquivalent.HasValue) previousNoteEquivalent = noteEquivalent;
|
||
}
|
||
return historyGroup;
|
||
}
|
||
|
||
[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;
|
||
}
|
||
|
||
private void LoadContacts(Guid? selectedId = null)
|
||
{
|
||
Contacts.Clear();
|
||
if (Student is null) return;
|
||
foreach (var c in Student.Contacts
|
||
.OrderBy(c => c.InvalidSince.HasValue)
|
||
.ThenBy(c => c.Name))
|
||
Contacts.Add(new ContactItem(c));
|
||
OnPropertyChanged(nameof(HasNoContacts));
|
||
SelectedContact = selectedId.HasValue
|
||
? Contacts.FirstOrDefault(c => c.Id == selectedId.Value)
|
||
: null;
|
||
}
|
||
|
||
[RelayCommand]
|
||
private async Task AddContact()
|
||
{
|
||
if (Student is null || OnEditContact is null) return;
|
||
var contact = await OnEditContact(null);
|
||
if (contact is null) return;
|
||
Student.Contacts.Add(contact);
|
||
_students.Save(Student);
|
||
LoadContacts(contact.Id);
|
||
}
|
||
|
||
[RelayCommand(CanExecute = nameof(HasSelectedContact))]
|
||
private async Task EditContact()
|
||
{
|
||
if (Student is null || SelectedContact is null || OnEditContact is null) return;
|
||
var edited = await OnEditContact(SelectedContact.Model);
|
||
if (edited is null) return;
|
||
var index = Student.Contacts.FindIndex(c => c.Id == edited.Id);
|
||
if (index >= 0) Student.Contacts[index] = edited;
|
||
_students.Save(Student);
|
||
LoadContacts(edited.Id);
|
||
}
|
||
|
||
[RelayCommand(CanExecute = nameof(CanViewSelectedAddress))]
|
||
private void ViewSelectedAddress()
|
||
{
|
||
if (SelectedContact is not null) OnViewAddress?.Invoke(SelectedContact);
|
||
}
|
||
|
||
partial void OnSelectedContactChanged(ContactItem? value)
|
||
{
|
||
EditContactCommand.NotifyCanExecuteChanged();
|
||
ViewSelectedAddressCommand.NotifyCanExecuteChanged();
|
||
}
|
||
|
||
private bool HasSelectedContact() => SelectedContact is not null;
|
||
private bool CanViewSelectedAddress() => SelectedContact?.HasAddress == true;
|
||
}
|
||
|
||
public class GroupMembershipEntry { public string SchoolYear { get; set; } = ""; public string GroupName { get; set; } = ""; public string Subject { get; set; } = ""; }
|
||
|
||
// ── Notenentwicklung (2.5) ──────────────────────────────────────────────────
|
||
|
||
public class StudentGradeHistoryGroup(string label)
|
||
{
|
||
public string Label { get; } = label;
|
||
public ObservableCollection<GradeHistoryPoint> Points { get; } = [];
|
||
public bool HasWarnings => Points.Any(p => p.IsWarning);
|
||
}
|
||
|
||
public class GradeHistoryPoint
|
||
{
|
||
public string DateDisplay { get; }
|
||
public string Label { get; }
|
||
public string Value { get; }
|
||
public double BarHeight { get; }
|
||
public bool IsWarning { get; }
|
||
public string WarningText { get; }
|
||
public string TooltipText { get; }
|
||
|
||
public GradeHistoryPoint(DateOnly date, string label, string value, int? noteEquivalent,
|
||
bool isWarning, string warningText)
|
||
{
|
||
DateDisplay = date.ToString("dd.MM.", CultureInfo.InvariantCulture);
|
||
Label = label;
|
||
Value = value;
|
||
IsWarning = isWarning;
|
||
WarningText = warningText;
|
||
// Balkenhöhe nach Notenqualität (1 = beste Note) auf 6..60px, sonst neutrale Mindesthöhe.
|
||
BarHeight = noteEquivalent.HasValue
|
||
? 6 + Math.Clamp((6 - noteEquivalent.Value) / 5.0, 0, 1) * 54
|
||
: 6;
|
||
TooltipText = warningText.Length > 0
|
||
? $"{date:dd.MM.yyyy} · {label}: {value} ⚠ {warningText}"
|
||
: $"{date:dd.MM.yyyy} · {label}: {value}";
|
||
}
|
||
}
|
||
|
||
public class ContactItem
|
||
{
|
||
public Contact Model { get; }
|
||
public Guid Id => Model.Id;
|
||
public string Name { get; }
|
||
public string Relation { get; }
|
||
public string LetterSalutation { get; }
|
||
public string? Phone { get; }
|
||
public string? MobilePhone { get; }
|
||
public string? Email { get; }
|
||
public string Address { get; }
|
||
public bool HasPhone => !string.IsNullOrEmpty(Phone) || !string.IsNullOrEmpty(MobilePhone);
|
||
public bool HasEmail => !string.IsNullOrEmpty(Email);
|
||
public bool HasAddress => !string.IsNullOrEmpty(Address);
|
||
public bool IsInvalid => Model.InvalidSince.HasValue;
|
||
public bool IsValid => !IsInvalid;
|
||
public string PhoneDisplay => string.Join(" · ", new[] { MobilePhone, Phone }
|
||
.Where(value => !string.IsNullOrWhiteSpace(value)));
|
||
public string EmailDisplay => Email ?? "";
|
||
public string StatusText => IsInvalid
|
||
? $"Ungültig seit {Model.InvalidSince:dd.MM.yyyy} · {InvalidReasonText(Model.InvalidReason)}"
|
||
: "Aktuell";
|
||
|
||
public IRelayCommand CallCommand { get; }
|
||
public IRelayCommand MailCommand { get; }
|
||
|
||
public ContactItem(Contact c)
|
||
{
|
||
Model = c;
|
||
Name = c.Name;
|
||
Relation = c.Relation;
|
||
LetterSalutation = c.LetterSalutation ?? "";
|
||
Phone = c.Phone;
|
||
MobilePhone = c.MobilePhone;
|
||
Email = c.Email;
|
||
Address = FormatAddress(c);
|
||
CallCommand = new RelayCommand(() => OpenUri($"tel:{MobilePhone ?? Phone}"), () => HasPhone);
|
||
MailCommand = new RelayCommand(() => OpenUri($"mailto:{Email}"), () => HasEmail);
|
||
}
|
||
|
||
public static string InvalidReasonText(ContactInvalidReason? reason) => reason switch
|
||
{
|
||
ContactInvalidReason.Moved => "Umzug",
|
||
ContactInvalidReason.NewPhoneNumber => "Telefonnummer neu",
|
||
ContactInvalidReason.LostCustody => "Sorgerecht verloren",
|
||
ContactInvalidReason.NewEmailAddress => "E-Mail-Adresse neu",
|
||
ContactInvalidReason.NoLongerResponsible => "Nicht mehr zuständig",
|
||
ContactInvalidReason.Other => "Sonstiges",
|
||
_ => "Kein Grund angegeben",
|
||
};
|
||
|
||
private static string FormatAddress(Contact c)
|
||
{
|
||
var cityLine = string.Join(" ", new[] { c.PostalCode, c.City }
|
||
.Where(value => !string.IsNullOrWhiteSpace(value)));
|
||
return string.Join(Environment.NewLine, new[] { c.Street, cityLine }
|
||
.Where(value => !string.IsNullOrWhiteSpace(value)));
|
||
}
|
||
|
||
private static void OpenUri(string uri) =>
|
||
System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo(uri) { UseShellExecute = true });
|
||
}
|
||
|
||
// ── Dialog: Schüler anlegen ───────────────────────────────────────────────────
|
||
|
||
public partial class AddStudentDialogViewModel : ObservableObject
|
||
{
|
||
private readonly IStudentRepository _students;
|
||
|
||
[ObservableProperty] private string _firstName = "";
|
||
[ObservableProperty] private string _lastName = "";
|
||
[ObservableProperty] private string _dateOfBirthText = "";
|
||
[ObservableProperty] private string _selectedGender = "";
|
||
[ObservableProperty] private string _notes = "";
|
||
[ObservableProperty] private string _lastNameError = "";
|
||
[ObservableProperty] private string _firstNameError = "";
|
||
[ObservableProperty] private string _dateOfBirthError = "";
|
||
|
||
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; } = [];
|
||
public Student? Result { get; private set; }
|
||
|
||
public AddStudentDialogViewModel(IStudentRepository students) => _students = students;
|
||
|
||
[RelayCommand]
|
||
private void AddContact() =>
|
||
Contacts.Add(new ContactEntryViewModel(this) { Relation = "Elternteil" });
|
||
|
||
public void RemoveContact(ContactEntryViewModel c) => Contacts.Remove(c);
|
||
|
||
[RelayCommand]
|
||
private void Save()
|
||
{
|
||
LastNameError = ""; FirstNameError = ""; DateOfBirthError = "";
|
||
var valid = true;
|
||
|
||
if (string.IsNullOrWhiteSpace(LastName)) { LastNameError = "Nachname erforderlich."; valid = false; }
|
||
if (string.IsNullOrWhiteSpace(FirstName)) { FirstNameError = "Vorname erforderlich."; valid = false; }
|
||
|
||
DateOnly? dob = null;
|
||
if (!string.IsNullOrWhiteSpace(DateOfBirthText))
|
||
{
|
||
if (!DateOnly.TryParseExact(DateOfBirthText, "dd.MM.yyyy", null,
|
||
System.Globalization.DateTimeStyles.None, out var d))
|
||
{
|
||
DateOfBirthError = "Format TT.MM.JJJJ."; valid = false;
|
||
}
|
||
else dob = d;
|
||
}
|
||
|
||
if (!valid) return;
|
||
|
||
Result = new Student
|
||
{
|
||
FirstName = FirstName.Trim(),
|
||
LastName = LastName.Trim(),
|
||
DateOfBirth = dob,
|
||
Gender = GenderAvatarDisplay.FromOption(SelectedGender),
|
||
Notes = string.IsNullOrWhiteSpace(Notes) ? null : Notes.Trim(),
|
||
Contacts = Contacts.Select(c => c.ToModel()).Where(c => !string.IsNullOrWhiteSpace(c.Name)).ToList(),
|
||
};
|
||
_students.Save(Result);
|
||
}
|
||
}
|
||
|
||
public partial class ContactEntryViewModel : ObservableObject
|
||
{
|
||
private readonly AddStudentDialogViewModel _parent;
|
||
|
||
[ObservableProperty] private string _name = "";
|
||
[ObservableProperty] private string _relation = "";
|
||
[ObservableProperty] private string _letterSalutation = "";
|
||
[ObservableProperty] private string _phone = "";
|
||
[ObservableProperty] private string _mobilePhone = "";
|
||
[ObservableProperty] private string _email = "";
|
||
[ObservableProperty] private string _street = "";
|
||
[ObservableProperty] private string _postalCode = "";
|
||
[ObservableProperty] private string _city = "";
|
||
|
||
public ContactEntryViewModel(AddStudentDialogViewModel parent) => _parent = parent;
|
||
|
||
[RelayCommand] private void Remove() => _parent.RemoveContact(this);
|
||
|
||
public Contact ToModel() => new()
|
||
{
|
||
Name = Name.Trim(),
|
||
Relation = Relation.Trim(),
|
||
LetterSalutation = string.IsNullOrWhiteSpace(LetterSalutation) ? null : LetterSalutation.Trim(),
|
||
Phone = string.IsNullOrWhiteSpace(Phone) ? null : Phone.Trim(),
|
||
MobilePhone = string.IsNullOrWhiteSpace(MobilePhone) ? null : MobilePhone.Trim(),
|
||
Email = string.IsNullOrWhiteSpace(Email) ? null : Email.Trim(),
|
||
Street = string.IsNullOrWhiteSpace(Street) ? null : Street.Trim(),
|
||
PostalCode = string.IsNullOrWhiteSpace(PostalCode) ? null : PostalCode.Trim(),
|
||
City = string.IsNullOrWhiteSpace(City) ? null : City.Trim(),
|
||
};
|
||
}
|
||
|
||
public partial class ContactEditDialogViewModel : ObservableObject
|
||
{
|
||
private readonly Contact? _source;
|
||
|
||
[ObservableProperty] private string _name = "";
|
||
[ObservableProperty] private string _relation = "Elternteil";
|
||
[ObservableProperty] private string _letterSalutation = "";
|
||
[ObservableProperty] private string _phone = "";
|
||
[ObservableProperty] private string _mobilePhone = "";
|
||
[ObservableProperty] private string _email = "";
|
||
[ObservableProperty] private string _street = "";
|
||
[ObservableProperty] private string _postalCode = "";
|
||
[ObservableProperty] private string _city = "";
|
||
[ObservableProperty] private bool _isInvalid;
|
||
[ObservableProperty] private string _invalidSinceText = "";
|
||
[ObservableProperty] private string _selectedInvalidReason = "";
|
||
[ObservableProperty] private string _invalidReasonDetails = "";
|
||
[ObservableProperty] private string _nameError = "";
|
||
[ObservableProperty] private string _invalidSinceError = "";
|
||
|
||
public string DialogTitle => _source is null ? "Kontakt anlegen" : "Kontakt bearbeiten";
|
||
public List<string> RelationPresets { get; } =
|
||
["Schüler/in", "Mutter", "Vater", "Elternteil", "Erziehungsberechtigte/r", "Sonstige"];
|
||
public List<string> InvalidReasonOptions { get; } =
|
||
["Umzug", "Telefonnummer neu", "Sorgerecht verloren", "E-Mail-Adresse neu",
|
||
"Nicht mehr zuständig", "Sonstiges"];
|
||
public Contact? Result { get; private set; }
|
||
|
||
public ContactEditDialogViewModel(Contact? source)
|
||
{
|
||
_source = source;
|
||
if (source is null) return;
|
||
Name = source.Name;
|
||
Relation = source.Relation;
|
||
LetterSalutation = source.LetterSalutation ?? "";
|
||
Phone = source.Phone ?? "";
|
||
MobilePhone = source.MobilePhone ?? "";
|
||
Email = source.Email ?? "";
|
||
Street = source.Street ?? "";
|
||
PostalCode = source.PostalCode ?? "";
|
||
City = source.City ?? "";
|
||
IsInvalid = source.InvalidSince.HasValue;
|
||
InvalidSinceText = source.InvalidSince?.ToString("dd.MM.yyyy") ?? "";
|
||
SelectedInvalidReason = ContactItem.InvalidReasonText(source.InvalidReason);
|
||
InvalidReasonDetails = source.InvalidReasonDetails ?? "";
|
||
}
|
||
|
||
partial void OnIsInvalidChanged(bool value)
|
||
{
|
||
if (value && string.IsNullOrWhiteSpace(InvalidSinceText))
|
||
InvalidSinceText = DateTime.Today.ToString("dd.MM.yyyy");
|
||
if (value && string.IsNullOrWhiteSpace(SelectedInvalidReason))
|
||
SelectedInvalidReason = InvalidReasonOptions[0];
|
||
}
|
||
|
||
[RelayCommand]
|
||
private void Save()
|
||
{
|
||
NameError = ""; InvalidSinceError = "";
|
||
var valid = true;
|
||
|
||
if (string.IsNullOrWhiteSpace(Name))
|
||
{
|
||
NameError = "Name erforderlich.";
|
||
valid = false;
|
||
}
|
||
|
||
DateOnly? invalidSince = null;
|
||
if (IsInvalid)
|
||
{
|
||
if (!DateOnly.TryParseExact(InvalidSinceText, "dd.MM.yyyy", null,
|
||
System.Globalization.DateTimeStyles.None, out var parsedDate))
|
||
{
|
||
InvalidSinceError = "Format TT.MM.JJJJ.";
|
||
valid = false;
|
||
}
|
||
else invalidSince = parsedDate;
|
||
}
|
||
|
||
if (!valid) return;
|
||
|
||
Result = new Contact
|
||
{
|
||
Id = _source?.Id ?? Guid.NewGuid(),
|
||
Name = Name.Trim(),
|
||
Relation = Relation.Trim(),
|
||
LetterSalutation = NullIfEmpty(LetterSalutation),
|
||
Phone = NullIfEmpty(Phone),
|
||
MobilePhone = NullIfEmpty(MobilePhone),
|
||
Email = NullIfEmpty(Email),
|
||
Street = NullIfEmpty(Street),
|
||
PostalCode = NullIfEmpty(PostalCode),
|
||
City = NullIfEmpty(City),
|
||
InvalidSince = invalidSince,
|
||
InvalidReason = IsInvalid ? ParseInvalidReason(SelectedInvalidReason) : null,
|
||
InvalidReasonDetails = IsInvalid ? NullIfEmpty(InvalidReasonDetails) : null,
|
||
};
|
||
}
|
||
|
||
private static string? NullIfEmpty(string value) =>
|
||
string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
||
|
||
private static ContactInvalidReason ParseInvalidReason(string value) => value switch
|
||
{
|
||
"Umzug" => ContactInvalidReason.Moved,
|
||
"Telefonnummer neu" => ContactInvalidReason.NewPhoneNumber,
|
||
"Sorgerecht verloren" => ContactInvalidReason.LostCustody,
|
||
"E-Mail-Adresse neu" => ContactInvalidReason.NewEmailAddress,
|
||
"Nicht mehr zuständig" => ContactInvalidReason.NoLongerResponsible,
|
||
_ => ContactInvalidReason.Other,
|
||
};
|
||
}
|