Mitarbeit bewerten begonnen, Schülerdaten, Gruppen

This commit is contained in:
2026-06-23 10:12:52 +02:00
parent fdbaa9ef4d
commit b2211270b4
32 changed files with 1968 additions and 49 deletions
@@ -21,6 +21,9 @@ public partial class GroupListViewModel : ObservableObject
[ObservableProperty] private string _searchText = "";
[ObservableProperty] private GroupListItem? _selectedGroup;
public string SelectedGroupDisplayName => SelectedGroup?.DisplayName ?? "";
public string SelectedGroupSubtitle => SelectedGroup?.Subtitle ?? "";
public ObservableCollection<string> SchoolYears { get; } = [];
public ObservableCollection<GroupListItem> Groups { get; } = [];
@@ -33,8 +36,12 @@ public partial class GroupListViewModel : ObservableObject
partial void OnSelectedSchoolYearChanged(string value) => LoadGroups();
partial void OnSearchTextChanged(string value) => LoadGroups();
partial void OnSelectedGroupChanged(GroupListItem? value) =>
partial void OnSelectedGroupChanged(GroupListItem? value)
{
OnPropertyChanged(nameof(SelectedGroupDisplayName));
OnPropertyChanged(nameof(SelectedGroupSubtitle));
NavigateToSectionCommand.NotifyCanExecuteChanged();
}
public void LoadGroups()
{
@@ -88,6 +95,7 @@ public partial class GroupDetailViewModel : ObservableObject
{
private readonly IGroupRepository _groups;
private readonly IStudentRepository _students;
private readonly IEnrollmentRepository _enrollments;
private readonly IExamRepository _exams;
private readonly IGradeRepository _grades;
@@ -96,14 +104,21 @@ public partial class GroupDetailViewModel : ObservableObject
[ObservableProperty] private string _groupSubtitle = "";
[ObservableProperty] private int _studentCount;
[ObservableProperty] private int _activeTabIndex = 0;
[ObservableProperty] private StudentSummary? _selectedStudent;
public ObservableCollection<StudentSummary> Students { get; } = [];
public ObservableCollection<ExamSummary> Exams { get; } = [];
public ParticipationTabViewModel ParticipationTab { get; }
public Func<Task<bool>>? OnAddStudent { get; set; }
public GroupDetailViewModel(IGroupRepository groups, IStudentRepository students,
IExamRepository exams, IGradeRepository grades)
IEnrollmentRepository enrollments, IExamRepository exams, IGradeRepository grades,
ParticipationTabViewModel participationTab)
{
_groups = groups; _students = students; _exams = exams; _grades = grades;
_groups = groups; _students = students; _enrollments = enrollments;
_exams = exams; _grades = grades;
ParticipationTab = participationTab;
}
public void LoadGroup(Guid id)
@@ -114,19 +129,48 @@ public partial class GroupDetailViewModel : ObservableObject
GroupSubtitle = $"{Group.SchoolYear} · " +
$"{(Group.Type == GroupType.Class ? "Klasse" : "Kurs")} · " +
$"Stufe {Group.GradeLevel} · Noten {(Group.GradingSystem == GradingSystem.Grades1To6 ? "16" : "015")}";
LoadStudents();
Exams.Clear();
foreach (var e in _exams.GetByGroup(Group.Id)) Exams.Add(new ExamSummary(e));
ParticipationTab.Initialize(Group.Id, Group.SchoolYear);
}
public void LoadStudents()
{
if (Group is null) return;
Students.Clear();
var enrolled = _students.GetByGroup(Group.Id, Group.SchoolYear);
StudentCount = enrolled.Count;
foreach (var s in enrolled) Students.Add(new StudentSummary(s));
Exams.Clear();
foreach (var e in _exams.GetByGroup(Group.Id)) Exams.Add(new ExamSummary(e));
}
[RelayCommand] private void AddStudent() { /* TODO */ }
[RelayCommand] private void AddExam() { /* TODO */ }
[RelayCommand] private void Refresh() { if (Group is not null) LoadGroup(Group.Id); }
[RelayCommand]
private async Task AddStudent()
{
if (OnAddStudent is null) return;
var confirmed = await OnAddStudent();
if (confirmed) LoadStudents();
}
[RelayCommand(CanExecute = nameof(HasSelectedStudent))]
private void RemoveStudent()
{
if (Group is null || SelectedStudent is null) return;
var enrollment = _enrollments.GetByGroupAndYear(Group.Id, Group.SchoolYear)
.FirstOrDefault(e => e.StudentId == SelectedStudent.Id);
if (enrollment is null) return;
_enrollments.Delete(enrollment.Id);
LoadStudents();
SelectedStudent = null;
}
partial void OnSelectedStudentChanged(StudentSummary? value) =>
RemoveStudentCommand.NotifyCanExecuteChanged();
private bool HasSelectedStudent() => SelectedStudent is not null;
[RelayCommand] private void AddExam() { /* TODO */ }
[RelayCommand] private void Refresh() { if (Group is not null) LoadGroup(Group.Id); }
}
public class StudentSummary
@@ -156,6 +200,66 @@ public class ExamSummary
}
}
// ── Dialog: Schüler zur Gruppe hinzufügen ─────────────────────────────────────
public partial class AddStudentToGroupDialogViewModel : ObservableObject
{
private readonly IStudentRepository _students;
private readonly IEnrollmentRepository _enrollments;
private readonly Guid _groupId;
private readonly string _schoolYear;
[ObservableProperty] private string _searchText = "";
[ObservableProperty] private StudentPickerItem? _selectedStudent;
[ObservableProperty] private string _validationMessage = "";
public ObservableCollection<StudentPickerItem> AvailableStudents { get; } = [];
public Enrollment? Result { get; private set; }
public AddStudentToGroupDialogViewModel(IStudentRepository students,
IEnrollmentRepository enrollments, Guid groupId, string schoolYear)
{
_students = students; _enrollments = enrollments;
_groupId = groupId; _schoolYear = schoolYear;
LoadAvailableStudents();
}
partial void OnSearchTextChanged(string value) => LoadAvailableStudents();
private void LoadAvailableStudents()
{
var alreadyEnrolled = _enrollments.GetByGroupAndYear(_groupId, _schoolYear)
.Select(e => e.StudentId).ToHashSet();
var all = _students.GetAll();
var available = all
.Where(s => !alreadyEnrolled.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; }
Result = new Enrollment
{
StudentId = SelectedStudent.Id,
GroupId = _groupId,
SchoolYear = _schoolYear,
};
_enrollments.Save(Result);
}
}
public class StudentPickerItem
{
public Guid Id { get; }
public string FullName { get; }
public StudentPickerItem(Student s) { Id = s.Id; FullName = s.FullName; }
}
// ── Dialog: Neue Lerngruppe anlegen ──────────────────────────────────────────
public partial class AddGroupDialogViewModel : ObservableObject
@@ -0,0 +1,463 @@
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using LehrerApp.Core.Interfaces;
using LehrerApp.Core.Models;
using System.Collections.ObjectModel;
namespace LehrerApp.Desktop.ViewModels.Groups;
// ── Tab-ViewModel ─────────────────────────────────────────────────────────────
public partial class ParticipationTabViewModel : ObservableObject
{
private readonly IParticipationSessionRepository _sessions;
private readonly IParticipationRepository _entries;
private readonly IParticipationAspectRepository _aspects;
private readonly IStudentRepository _students;
private Guid _groupId;
private string _schoolYear = "";
[ObservableProperty] private ParticipationSessionItem? _selectedSession;
[ObservableProperty] private string _noDataText = "Keine Bewertungssitzung ausgewählt.";
public string SelectedSessionDisplay => SelectedSession?.Display ?? "";
public ObservableCollection<ParticipationSessionItem> Sessions { get; } = [];
public ObservableCollection<ParticipationStudentRow> StudentRows { get; } = [];
public ObservableCollection<AspectColumnDef> Aspects { get; } = [];
public Func<Task<ParticipationSession?>>? OnAddSession { get; set; }
public Func<ParticipationTabViewModel, Task>? OnQuickInput { get; set; }
public ParticipationTabViewModel(
IParticipationSessionRepository sessions,
IParticipationRepository entries,
IParticipationAspectRepository aspects,
IStudentRepository students)
{
_sessions = sessions; _entries = entries;
_aspects = aspects; _students = students;
}
public void Initialize(Guid groupId, string schoolYear)
{
_groupId = groupId;
_schoolYear = schoolYear;
LoadAspects();
LoadSessions();
}
private void LoadAspects()
{
Aspects.Clear();
var defaults = _aspects.GetDefaults();
var specific = _aspects.GetByGroup(_groupId);
var all = defaults.Concat(specific).ToList();
if (!all.Any())
{
foreach (var a in DefaultParticipationAspects.All)
Aspects.Add(new AspectColumnDef(a));
}
else
{
foreach (var a in all) Aspects.Add(new AspectColumnDef(a));
}
}
public void LoadSessions()
{
Sessions.Clear();
foreach (var s in _sessions.GetByGroup(_groupId))
Sessions.Add(new ParticipationSessionItem(s));
if (SelectedSession is null && Sessions.Any())
SelectedSession = Sessions[0];
}
partial void OnSelectedSessionChanged(ParticipationSessionItem? value)
{
OnPropertyChanged(nameof(SelectedSessionDisplay));
if (value is null) { StudentRows.Clear(); QuickInputCommand.NotifyCanExecuteChanged(); return; }
LoadGrid(value.Id);
}
private void LoadGrid(Guid sessionId)
{
StudentRows.Clear();
var students = _students.GetByGroup(_groupId, _schoolYear);
var entries = _entries.GetBySession(sessionId);
foreach (var s in students)
{
var entry = entries.FirstOrDefault(e => e.StudentId == s.Id)
?? new ParticipationEntry { SessionId = sessionId, GroupId = _groupId, StudentId = s.Id };
var row = new ParticipationStudentRow(s.Id, s.FullName, entry, Aspects.ToList());
row.OnRatingChanged = (studentId, key, val) => SaveRating(sessionId, studentId, key, val);
StudentRows.Add(row);
}
QuickInputCommand.NotifyCanExecuteChanged();
}
private void SaveRating(Guid sessionId, Guid studentId, string key, int? value)
{
var session = _sessions.GetById(sessionId);
var entry = _entries.GetBySessionAndStudent(sessionId, studentId)
?? new ParticipationEntry
{
SessionId = sessionId,
GroupId = _groupId,
StudentId = studentId,
Date = session?.Date ?? DateOnly.FromDateTime(DateTime.Today),
};
var existing = entry.Ratings.FirstOrDefault(r => r.Key == key);
if (value is null)
{
if (existing is not null) entry.Ratings.Remove(existing);
}
else
{
if (existing is null) entry.Ratings.Add(new AspectRating { Key = key, Value = value.Value });
else existing.Value = value.Value;
}
_entries.Save(entry);
}
[RelayCommand]
private async Task AddSession()
{
if (OnAddSession is null) return;
var session = await OnAddSession();
if (session is null) return;
session.GroupId = _groupId;
_sessions.Save(session);
LoadSessions();
SelectedSession = Sessions.FirstOrDefault(s => s.Id == session.Id);
}
[RelayCommand(CanExecute = nameof(CanQuickInput))]
private async Task QuickInput()
{
if (OnQuickInput is null) return;
await OnQuickInput(this);
if (SelectedSession is not null) LoadGrid(SelectedSession.Id);
}
private bool CanQuickInput() => SelectedSession is not null && StudentRows.Count > 0;
[RelayCommand]
private void DeleteSession()
{
if (SelectedSession is null) return;
_sessions.Delete(SelectedSession.Id);
LoadSessions();
}
public void SaveNote(Guid sessionId, Guid studentId, string note)
{
var entry = _entries.GetBySessionAndStudent(sessionId, studentId);
if (entry is null) return;
entry.Note = note;
_entries.Save(entry);
}
}
// ── Zeilendaten für das Bewertungsraster ─────────────────────────────────────
public partial class ParticipationStudentRow : ObservableObject
{
public Guid StudentId { get; }
public string Name { get; }
private readonly ParticipationEntry _entry;
private readonly IReadOnlyList<AspectColumnDef> _aspectDefs;
public ObservableCollection<RatingCell> Cells { get; } = [];
public Action<Guid, string, int?>? OnRatingChanged { get; set; }
public ParticipationStudentRow(Guid id, string name, ParticipationEntry entry, List<AspectColumnDef> aspects)
{
StudentId = id;
Name = name;
_entry = entry;
_aspectDefs = aspects;
foreach (var a in aspects)
{
var existing = entry.Ratings.FirstOrDefault(r => r.Key == a.Key);
var cell = new RatingCell(id, a.Key, existing?.Value);
cell.OnChanged = (sid, key, val) => OnRatingChanged?.Invoke(sid, key, val);
Cells.Add(cell);
}
}
public int? GetRating(string key) =>
_entry.Ratings.FirstOrDefault(r => r.Key == key)?.Value;
public void SetRating(string key, int? value)
{
var cell = Cells.FirstOrDefault(c => c.AspectKey == key);
cell?.SetValue(value);
OnRatingChanged?.Invoke(StudentId, key, value);
}
}
// ── Eine Bewertungszelle ──────────────────────────────────────────────────────
public partial class RatingCell : ObservableObject
{
public Guid StudentId { get; }
public string AspectKey { get; }
[ObservableProperty] private int? _value;
[ObservableProperty] private string _displayLabel = "";
public Action<Guid, string, int?>? OnChanged { get; set; }
public RatingCell(Guid studentId, string key, int? value)
{
StudentId = studentId;
AspectKey = key;
_value = value;
UpdateLabel();
}
public void SetValue(int? value)
{
Value = value;
UpdateLabel();
OnChanged?.Invoke(StudentId, AspectKey, value);
}
[RelayCommand]
private void CycleUp()
{
var next = Value is null ? -2 : Math.Min(2, Value.Value + 1);
SetValue(next);
}
[RelayCommand]
private void CycleDown()
{
var next = Value is null ? 2 : Math.Max(-2, Value.Value - 1);
SetValue(next);
}
[RelayCommand]
private void Clear() => SetValue(null);
private void UpdateLabel() => DisplayLabel = Value switch
{
2 => "++",
1 => "+",
0 => "~",
-1 => "",
-2 => "−−",
_ => "",
};
}
// ── Hilfsklassen ──────────────────────────────────────────────────────────────
public class AspectColumnDef
{
public string Key { get; }
public string Label { get; }
public AspectColumnDef(ParticipationAspect a) { Key = a.Key; Label = a.Label; }
}
public class ParticipationSessionItem
{
public Guid Id { get; }
public string Display { get; }
public string Comment { get; }
public DateOnly Date { get; }
public ParticipationSessionItem(ParticipationSession s)
{
Id = s.Id;
Date = s.Date;
Comment = s.Comment ?? "";
Display = s.Comment is { Length: > 0 }
? $"{s.Date:dd.MM.yyyy} {s.Comment}"
: s.Date.ToString("dd.MM.yyyy");
}
}
// ── Dialog: Sitzung anlegen ───────────────────────────────────────────────────
public partial class AddSessionDialogViewModel : ObservableObject
{
[ObservableProperty] private DateOnly _date = DateOnly.FromDateTime(DateTime.Today);
[ObservableProperty] private string _comment = "";
[ObservableProperty] private string _dateText = DateOnly.FromDateTime(DateTime.Today).ToString("dd.MM.yyyy");
[ObservableProperty] private string _validationMessage = "";
public ParticipationSession? Result { get; private set; }
[RelayCommand]
private void Save()
{
if (!DateOnly.TryParseExact(DateText, "dd.MM.yyyy", null,
System.Globalization.DateTimeStyles.None, out var date))
{
ValidationMessage = "Datum im Format TT.MM.JJJJ eingeben.";
return;
}
Result = new ParticipationSession { Date = date, Comment = Comment.Trim() };
}
}
// ── Dialog: Schnelleingabe ────────────────────────────────────────────────────
public partial class QuickInputViewModel : ObservableObject
{
private readonly List<ParticipationStudentRow> _rows;
private readonly List<AspectColumnDef> _aspects;
[ObservableProperty] private int _studentIndex;
[ObservableProperty] private int _aspectIndex;
[ObservableProperty] private string _studentName = "";
[ObservableProperty] private string _currentAspectLabel = "";
[ObservableProperty] private string _currentValueLabel = "";
[ObservableProperty] private string _progressText = "";
public ObservableCollection<QuickAspectRow> AspectRows { get; } = [];
public QuickInputViewModel(List<ParticipationStudentRow> rows, List<AspectColumnDef> aspects)
{
_rows = rows;
_aspects = aspects;
if (rows.Any()) ShowStudent(0);
}
private void ShowStudent(int index)
{
if (index < 0 || index >= _rows.Count) return;
StudentIndex = index;
var row = _rows[index];
StudentName = row.Name;
ProgressText = $"{index + 1} / {_rows.Count}";
AspectRows.Clear();
foreach (var (a, i) in _aspects.Select((a, i) => (a, i)))
{
var val = row.GetRating(a.Key);
AspectRows.Add(new QuickAspectRow(i, a.Label, val, i == AspectIndex));
}
UpdateCurrentAspect();
}
private void UpdateCurrentAspect()
{
if (!_aspects.Any()) return;
var safeIdx = Math.Clamp(AspectIndex, 0, _aspects.Count - 1);
CurrentAspectLabel = _aspects[safeIdx].Label;
var row = AspectRows.ElementAtOrDefault(safeIdx);
CurrentValueLabel = row?.DisplayLabel ?? "";
foreach (var r in AspectRows) r.IsActive = r.Index == safeIdx;
}
public void SetRatingByNumber(int num)
{
// 1=−−, 2=, 3=~, 4=+, 5=++
var val = num switch { 1 => -2, 2 => -1, 3 => 0, 4 => 1, 5 => 2, _ => (int?)null };
if (val is null) return;
ApplyRating(val.Value);
}
public void IncrementRating()
{
var cell = GetCurrentCell();
if (cell is null) return;
var next = cell.Value is null ? -2 : Math.Min(2, cell.Value.Value + 1);
ApplyRating(next);
}
public void DecrementRating()
{
var cell = GetCurrentCell();
if (cell is null) return;
var next = cell.Value is null ? 2 : Math.Max(-2, cell.Value.Value - 1);
ApplyRating(next);
}
private void ApplyRating(int val)
{
if (_rows.Count == 0 || !_aspects.Any()) return;
var key = _aspects[Math.Clamp(AspectIndex, 0, _aspects.Count - 1)].Key;
_rows[StudentIndex].SetRating(key, val);
var row = AspectRows.ElementAtOrDefault(AspectIndex);
if (row is not null)
{
row.Value = val;
row.UpdateLabel();
}
CurrentValueLabel = RatingLabel(val);
}
public void SelectAspect(int index)
{
if (index < 0 || index >= _aspects.Count) return;
AspectIndex = index;
UpdateCurrentAspect();
}
public void NextAspect()
{
AspectIndex = (AspectIndex + 1) % _aspects.Count;
UpdateCurrentAspect();
}
public void NextStudent()
{
if (StudentIndex >= _rows.Count - 1) return;
AspectIndex = 0;
ShowStudent(StudentIndex + 1);
}
public void PreviousStudent()
{
if (StudentIndex <= 0) return;
AspectIndex = 0;
ShowStudent(StudentIndex - 1);
}
public bool IsLastStudent => StudentIndex >= _rows.Count - 1;
private RatingCell? GetCurrentCell()
{
if (_rows.Count == 0 || !_aspects.Any()) return null;
var key = _aspects[Math.Clamp(AspectIndex, 0, _aspects.Count - 1)].Key;
return _rows[StudentIndex].Cells.FirstOrDefault(c => c.AspectKey == key);
}
private static string RatingLabel(int? v) => v switch
{
2 => "++", 1 => "+", 0 => "~", -1 => "", -2 => "−−", _ => "",
};
}
public partial class QuickAspectRow : ObservableObject
{
public int Index { get; }
public string Label { get; }
[ObservableProperty] private bool _isActive;
[ObservableProperty] private string _displayLabel = "";
public int? Value { get; set; }
public QuickAspectRow(int index, string label, int? value, bool isActive)
{
Index = index;
Label = label;
Value = value;
IsActive = isActive;
UpdateLabel();
}
public void UpdateLabel() => DisplayLabel = Value switch
{
2 => "++", 1 => "+", 0 => "~", -1 => "", -2 => "−−", _ => "·",
};
}
@@ -15,10 +15,14 @@ public partial class MainWindowViewModel : ObservableObject
[ObservableProperty] private NavItem _activeNavItem = NavItem.Dashboard;
[ObservableProperty] private string _currentSchoolYear = "";
public SyncStatusViewModel SyncStatus { get; }
public MainWindowViewModel(IServiceProvider services,
DashboardViewModel dashboard, SchoolYearService sy)
DashboardViewModel dashboard, SchoolYearService sy,
SyncStatusViewModel syncStatus)
{
_services = services;
SyncStatus = syncStatus;
CurrentSchoolYear = sy.CurrentSchoolYear();
CurrentPage = dashboard;
}
@@ -42,11 +46,11 @@ public partial class MainWindowViewModel : ObservableObject
public void NavigateToGroupDetail(Guid groupId, int initialTab = 0)
{
ActiveNavItem = NavItem.Groups;
var vm = _services.GetRequiredService<GroupDetailViewModel>();
vm.LoadGroup(groupId);
ActiveNavItem = NavItem.Groups;
var vm = _services.GetRequiredService<GroupDetailViewModel>();
vm.ActiveTabIndex = initialTab;
CurrentPage = vm;
CurrentPage = vm; // View bindet zuerst → Callbacks werden registriert
vm.LoadGroup(groupId); // dann Daten laden
}
public void NavigateToStudent(Guid studentId)
@@ -40,8 +40,17 @@ public partial class StudentListViewModel : ObservableObject
foreach (var s in f) Students.Add(new StudentListItem(s));
}
[RelayCommand] private void AddStudent() { /* TODO */ }
[RelayCommand] private void Refresh() => LoadStudents();
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
@@ -68,9 +77,14 @@ public partial class StudentDetailViewModel : ObservableObject
[ObservableProperty] private bool _isEditing;
[ObservableProperty] private string _editFirstName = "";
[ObservableProperty] private string _editLastName = "";
[ObservableProperty] private ContactItem? _selectedContact;
public ObservableCollection<EnrollmentEntry> Enrollments { get; } = [];
public ObservableCollection<DocEntry> Documentation { get; } = [];
public ObservableCollection<EnrollmentEntry> Enrollments { get; } = [];
public ObservableCollection<DocEntry> Documentation { get; } = [];
public ObservableCollection<ContactItem> Contacts { get; } = [];
public bool HasNoContacts => Contacts.Count == 0;
public Func<Contact?, Task<Contact?>>? OnEditContact { get; set; }
public Action<ContactItem>? OnViewAddress { get; set; }
public StudentDetailViewModel(IStudentRepository students,
IEnrollmentRepository enrollments, IGroupRepository groups,
@@ -96,6 +110,8 @@ public partial class StudentDetailViewModel : ObservableObject
Enrollments.Add(new() { SchoolYear = e.SchoolYear, GroupName = g.Name, Subject = g.Subject ?? "" });
}
LoadContacts();
Documentation.Clear();
foreach (var d in _docs.GetByStudent(Student.Id))
Documentation.Add(new() { Date = d.Date.ToString("dd.MM.yyyy"), Title = d.Title,
@@ -125,7 +141,309 @@ public partial class StudentDetailViewModel : ObservableObject
StudentTitle = Student.FullName;
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 EnrollmentEntry { public string SchoolYear { get; set; } = ""; public string GroupName { get; set; } = ""; public string Subject { get; set; } = ""; }
public class DocEntry { public string Date { get; set; } = ""; public string Title { get; set; } = ""; public string TypeLabel { get; set; } = ""; public bool IsConfidential { get; set; } }
public class ContactItem
{
public Contact Model { get; }
public Guid Id => Model.Id;
public string Name { get; }
public string Relation { get; }
public string? Phone { get; }
public string? Email { get; }
public string Address { get; }
public bool HasPhone => !string.IsNullOrEmpty(Phone);
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 => Phone ?? "";
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;
Phone = c.Phone;
Email = c.Email;
Address = FormatAddress(c);
CallCommand = new RelayCommand(() => OpenUri($"tel:{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 _validationMessage = "";
public List<string> GenderOptions { get; } = ["", "M männlich", "W weiblich", "D divers"];
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()
{
if (string.IsNullOrWhiteSpace(LastName)) { ValidationMessage = "Nachname erforderlich."; return; }
if (string.IsNullOrWhiteSpace(FirstName)) { ValidationMessage = "Vorname erforderlich."; return; }
DateOnly? dob = null;
if (!string.IsNullOrWhiteSpace(DateOfBirthText))
{
if (!DateOnly.TryParseExact(DateOfBirthText, "dd.MM.yyyy", null,
System.Globalization.DateTimeStyles.None, out var d))
{
ValidationMessage = "Geburtsdatum im Format TT.MM.JJJJ."; return;
}
dob = d;
}
Result = new Student
{
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,
},
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 _phone = "";
[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(),
Phone = string.IsNullOrWhiteSpace(Phone) ? null : Phone.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 _phone = "";
[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 _validationMessage = "";
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;
Phone = source.Phone ?? "";
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()
{
if (string.IsNullOrWhiteSpace(Name))
{
ValidationMessage = "Name erforderlich.";
return;
}
DateOnly? invalidSince = null;
if (IsInvalid)
{
if (!DateOnly.TryParseExact(InvalidSinceText, "dd.MM.yyyy", null,
System.Globalization.DateTimeStyles.None, out var parsedDate))
{
ValidationMessage = "Ungültig seit im Format TT.MM.JJJJ angeben.";
return;
}
invalidSince = parsedDate;
}
Result = new Contact
{
Id = _source?.Id ?? Guid.NewGuid(),
Name = Name.Trim(),
Relation = Relation.Trim(),
Phone = NullIfEmpty(Phone),
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,
};
}