Kapitel 7 abgeschlossen.

This commit is contained in:
2026-08-16 00:29:01 +02:00
parent da8d2bb1da
commit f9e398b225
47 changed files with 2636 additions and 107 deletions
+2
View File
@@ -140,10 +140,12 @@ public static class AppBootstrapper
// ── Services ──────────────────────────────────────────────────────────
services.AddSingleton<GradingService>();
services.AddSingleton<SchoolYearService>();
services.AddSingleton<GroupRolloverService>();
services.AddSingleton<PublicHolidayService>();
services.AddSingleton(_ => new SchoolCalendarSettingsService(appData));
services.AddSingleton(_ => new PeriodScheduleService(appData));
services.AddSingleton(_ => new WorkloadSettingsService(appData));
services.AddSingleton(_ => new LetterTemplateService(appData));
// ── Sync (optional nur wenn Server konfiguriert) ────────────────────
services.AddSingleton(_ => new EventQueue(queuePath));
@@ -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 ? "16" : "015")}";
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 16", "Punkte 015"];
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),
@@ -17,9 +17,10 @@
<Button Content="Sortierung: Gesamt" Command="{Binding SortByTotalCommand}"/>
<Button Content="Noten verwalten" Command="{Binding ManageStudentGradesCommand}" Margin="12,0,0,0"
IsVisible="{Binding SelectedRow, Converter={x:Static ObjectConverters.IsNotNull}}"/>
<Button Content=" Sammelnote" Command="{Binding CollectiveGradeCommand}"/>
<Button Content="Zeugnisnoten" Command="{Binding ReportGradesCommand}"/>
IsVisible="{Binding SelectedRow, Converter={x:Static ObjectConverters.IsNotNull}}"
IsEnabled="{Binding !IsReadOnly}"/>
<Button Content=" Sammelnote" Command="{Binding CollectiveGradeCommand}" IsEnabled="{Binding !IsReadOnly}"/>
<Button Content="Zeugnisnoten" Command="{Binding ReportGradesCommand}" IsEnabled="{Binding !IsReadOnly}"/>
</StackPanel>
<DataGrid Grid.Row="1"
@@ -7,7 +7,7 @@
x:Class="LehrerApp.Desktop.Views.Groups.GroupDetailView"
x:DataType="vm:GroupDetailViewModel">
<Grid RowDefinitions="Auto,*">
<Grid RowDefinitions="Auto,Auto,*">
<!-- Header -->
<Border Grid.Row="0" Padding="20,16"
@@ -22,18 +22,32 @@
</TextBlock>
<CheckBox Content="Ausgetretene anzeigen" IsChecked="{Binding ShowFormerStudents}"
VerticalAlignment="Center"/>
<Button Content=" Schüler" Command="{Binding AddStudentCommand}"/>
<Button Content=" Schüler" Command="{Binding AddStudentCommand}" IsEnabled="{Binding IsEditable}"/>
<Button Content="{Binding SelectedStudent.WithdrawActionLabel}"
Command="{Binding WithdrawStudentCommand}"
IsVisible="{Binding SelectedStudent, Converter={x:Static ObjectConverters.IsNotNull}}"/>
<Button Content="Austragung zurücknehmen" Command="{Binding ReinstateStudentCommand}"
IsVisible="{Binding SelectedStudent.HasExitDate}"/>
<Button Content=" Klausur" Command="{Binding AddExamCommand}"/>
<Button Content=" Klausur" Command="{Binding AddExamCommand}" IsEnabled="{Binding IsEditable}"/>
</StackPanel>
</Grid>
</Border>
<TabbedPage Grid.Row="1" TabPlacement="Top" SelectedIndex="{Binding ActiveTabIndex}">
<Border Grid.Row="1" Background="#FFF3CD" BorderBrush="#D97706" BorderThickness="0,0,0,1"
Padding="20,10" IsVisible="{Binding IsReadOnly}">
<Grid ColumnDefinitions="*,Auto">
<StackPanel Grid.Column="0" Spacing="2">
<TextBlock Text="Archivierte Lerngruppe · schreibgeschützt" FontWeight="SemiBold"
Foreground="#92400E"/>
<TextBlock Text="Alle historischen Daten bleiben sichtbar. Für Korrekturen muss die Gruppe ausdrücklich wieder aktiviert werden."
FontSize="12" Foreground="#92400E" TextWrapping="Wrap"/>
</StackPanel>
<Button Grid.Column="1" Content="Gruppe wieder aktivieren"
Command="{Binding ReactivateGroupCommand}" Margin="16,0,0,0"/>
</Grid>
</Border>
<TabbedPage Grid.Row="2" TabPlacement="Top" SelectedIndex="{Binding ActiveTabIndex}">
<!-- Tab: Übersicht -->
<ContentPage Header="Übersicht">
@@ -57,6 +71,18 @@
CanUserResizeColumns="True"
Margin="0">
<DataGrid.Columns>
<DataGridTemplateColumn Header="" Width="48">
<DataGridTemplateColumn.CellTemplate>
<DataTemplate x:DataType="vm:StudentSummary">
<Border Width="30" Height="30" CornerRadius="15" Background="{Binding AvatarColor}"
HorizontalAlignment="Center" VerticalAlignment="Center"
ToolTip.Tip="{Binding AvatarTooltip}">
<TextBlock Text="{Binding AvatarLabel}" Foreground="White" FontWeight="Bold"
HorizontalAlignment="Center" VerticalAlignment="Center"/>
</Border>
</DataTemplate>
</DataGridTemplateColumn.CellTemplate>
</DataGridTemplateColumn>
<DataGridTextColumn Header="Name" Binding="{Binding FullName}" Width="*"/>
<DataGridTextColumn Header="Zeitraum" Binding="{Binding PeriodLabel}" Width="190"/>
<DataGridTextColumn Header="Status" Binding="{Binding MembershipStatus}" Width="170"/>
@@ -66,6 +92,7 @@
<DataTemplate x:DataType="vm:StudentSummary">
<ComboBox ItemsSource="{x:Static vm:StudentSummary.NiveauOptions}"
SelectedItem="{Binding NiveauName}"
IsEnabled="{Binding CanEdit}"
HorizontalAlignment="Stretch" Margin="2"/>
</DataTemplate>
</DataGridTemplateColumn.CellTemplate>
@@ -83,7 +110,8 @@
<ContentPage Header="Klausuren">
<Grid RowDefinitions="Auto,*">
<StackPanel Grid.Row="0" Orientation="Horizontal" Spacing="8" Margin="0,0,0,8"
IsVisible="{Binding SelectedExam, Converter={x:Static ObjectConverters.IsNotNull}}">
IsVisible="{Binding SelectedExam, Converter={x:Static ObjectConverters.IsNotNull}}"
IsEnabled="{Binding IsEditable}">
<Button Content="Punkte eingeben" Command="{Binding GradeExamCommand}"/>
<Button Content="Auswertung" Command="{Binding EvaluateExamCommand}"/>
<Button Content="Bearbeiten" Command="{Binding EditExamCommand}"/>
@@ -3,6 +3,7 @@ using LehrerApp.Core.Interfaces;
using LehrerApp.Core.Models;
using LehrerApp.Core.Services;
using LehrerApp.Desktop.ViewModels.Groups;
using LehrerApp.Desktop.Views.Shared;
using Microsoft.Extensions.DependencyInjection;
namespace LehrerApp.Desktop.Views.Groups;
@@ -24,9 +25,25 @@ public partial class GroupDetailView : UserControl
vm.OnConfirmDeleteExam = ShowDeleteExamDialog;
vm.OnGradeExam = ShowGradeExamDialog;
vm.OnEvaluateExam = ShowEvaluateExamDialog;
vm.OnConfirmReactivate = ShowReactivateConfirmDialog;
}
}
private async Task<bool> ShowReactivateConfirmDialog()
{
var dialog = new ConfirmDialog
{
DataContext = new ConfirmDialogInfo
{
Title = "Archivierte Gruppe wieder aktivieren?",
Message = "Nach der Wiederaktivierung können historische Daten dieser Gruppe wieder verändert werden.",
ConfirmText = "Wieder aktivieren",
},
};
var owner = TopLevel.GetTopLevel(this) as Window;
return owner is not null && await dialog.ShowDialog<bool>(owner);
}
private async Task<bool> ShowAddStudentDialog()
{
if (DataContext is not GroupDetailViewModel vm || vm.Group is null) return false;
@@ -91,6 +91,8 @@
<MenuFlyout>
<MenuItem Header="Details bearbeiten"
Command="{Binding EditGroupCommand}"/>
<MenuItem Header="Ins nächste Schuljahr übernehmen …"
Command="{Binding RollOverGroupCommand}"/>
<MenuItem Header="{Binding SelectedGroup.ArchiveActionLabel}"
Command="{Binding ToggleArchiveCommand}"/>
<Separator/>
@@ -15,6 +15,7 @@ public partial class GroupListView : UserControl
{
vm.OnAddGroup = ShowAddGroupDialog;
vm.OnEditGroup = ShowEditGroupDialog;
vm.OnRollOverGroup = ShowGroupRolloverDialog;
vm.OnConfirmDelete = ShowDeleteGroupDialog;
}
}
@@ -42,6 +43,23 @@ public partial class GroupListView : UserControl
await dialog.ShowDialog<bool>(owner);
}
private async Task<Guid?> ShowGroupRolloverDialog(Guid groupId)
{
var group = App.Services.GetRequiredService<LehrerApp.Core.Interfaces.IGroupRepository>()
.GetById(groupId);
var owner = TopLevel.GetTopLevel(this) as Window;
if (group is null || owner is null) return null;
var dialogVm = new GroupRolloverDialogViewModel(group,
App.Services.GetRequiredService<LehrerApp.Core.Interfaces.IStudentRepository>(),
App.Services.GetRequiredService<LehrerApp.Core.Interfaces.IGroupMembershipRepository>(),
App.Services.GetRequiredService<LehrerApp.Core.Interfaces.IGradingSchemeRepository>(),
App.Services.GetRequiredService<LehrerApp.Core.Services.SchoolYearService>(),
App.Services.GetRequiredService<LehrerApp.Core.Services.GroupRolloverService>());
var dialog = new GroupRolloverDialog { DataContext = dialogVm };
return await dialog.ShowDialog<Guid?>(owner);
}
private async Task<bool> ShowDeleteGroupDialog(GroupListItem group)
{
var dialog = new DeleteGroupDialog { DataContext = group.DisplayName };
@@ -0,0 +1,75 @@
<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="clr-namespace:LehrerApp.Desktop.ViewModels.Groups"
x:Class="LehrerApp.Desktop.Views.Groups.GroupRolloverDialog"
x:DataType="vm:GroupRolloverDialogViewModel"
Title="Ins nächste Schuljahr übernehmen"
Width="650" Height="720" MinWidth="560" MinHeight="600"
CanResize="True" WindowStartupLocation="CenterOwner">
<Grid RowDefinitions="Auto,*,Auto" Margin="24,20">
<StackPanel Grid.Row="0" Spacing="12">
<TextBlock Text="Ins nächste Schuljahr übernehmen" Classes="dialogtitle"/>
<TextBlock Text="{Binding SourceSummary}" FontSize="12" Opacity="0.65"/>
<TextBlock Text="Es werden nur Gruppeneinstellungen und ausgewählte Mitgliedschaften kopiert. Noten, Klausuren, Mitarbeit, Planung und Stundenplan bleiben im alten Schuljahr."
FontSize="12" Opacity="0.65" TextWrapping="Wrap"/>
<Grid ColumnDefinitions="*,12,130,12,100">
<StackPanel Grid.Column="0" Spacing="4">
<TextBlock Text="Neuer Gruppenname *" FontSize="12" Opacity="0.7"/>
<TextBox Text="{Binding Name}"/>
<TextBlock Text="{Binding NameError}" Foreground="Red" FontSize="11"
IsVisible="{Binding NameError, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
</StackPanel>
<StackPanel Grid.Column="2" Spacing="4">
<TextBlock Text="Zielschuljahr *" FontSize="12" Opacity="0.7"/>
<TextBox Text="{Binding TargetSchoolYear}" PlaceholderText="2027/28"/>
<TextBlock Text="{Binding SchoolYearError}" Foreground="Red" FontSize="11"
IsVisible="{Binding SchoolYearError, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
</StackPanel>
<StackPanel Grid.Column="4" Spacing="4">
<TextBlock Text="Klassenstufe" FontSize="12" Opacity="0.7"/>
<NumericUpDown Value="{Binding GradeLevel}" Minimum="1" Maximum="13" FormatString="0"/>
</StackPanel>
</Grid>
<Grid ColumnDefinitions="*,Auto">
<TextBlock Grid.Column="0" Text="Schülerauswahl" FontSize="14" FontWeight="SemiBold"/>
<TextBlock Grid.Column="1" Text="{Binding SelectedCountText}" FontSize="12" Opacity="0.6"/>
</Grid>
</StackPanel>
<Grid Grid.Row="1" RowDefinitions="*,Auto" Margin="0,10,0,0">
<ListBox Grid.Row="0" ItemsSource="{Binding Students}">
<ListBox.ItemTemplate>
<DataTemplate x:DataType="vm:GroupRolloverStudentItem">
<Grid ColumnDefinitions="Auto,*,70" Margin="4,5">
<CheckBox Grid.Column="0" IsChecked="{Binding IsSelected}" IsEnabled="{Binding CanSelect}"
VerticalAlignment="Center" Margin="0,0,10,0"/>
<StackPanel Grid.Column="1">
<TextBlock Text="{Binding FullName}" FontSize="13" FontWeight="SemiBold"/>
<TextBlock Text="{Binding MembershipStatus}" FontSize="11" Opacity="0.55"/>
</StackPanel>
<TextBlock Grid.Column="2" Text="{Binding NiveauText}" FontSize="12" Opacity="0.65"
HorizontalAlignment="Center" VerticalAlignment="Center"/>
</Grid>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
<TextBlock Grid.Row="1" Text="{Binding SelectionError}" Foreground="Red" FontSize="11" Margin="0,5,0,0"
IsVisible="{Binding SelectionError, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
</Grid>
<StackPanel Grid.Row="2" Spacing="10" Margin="0,14,0,0">
<CheckBox Content="Gruppenspezifisches Notenschema übernehmen"
IsChecked="{Binding CopyGradingScheme}" IsVisible="{Binding HasGradingScheme}"/>
<CheckBox Content="Alte Gruppe nach erfolgreicher Übernahme archivieren"
IsChecked="{Binding ArchiveSource}"/>
<TextBlock Text="{Binding GeneralError}" Foreground="Red" FontSize="12" TextWrapping="Wrap"
IsVisible="{Binding GeneralError, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
<Grid ColumnDefinitions="*,10,*">
<Button Grid.Column="0" Content="Abbrechen" HorizontalAlignment="Stretch" Click="OnCancel"/>
<Button Grid.Column="2" Content="Gruppe übernehmen" HorizontalAlignment="Stretch" Click="OnSave"/>
</Grid>
</StackPanel>
</Grid>
</Window>
@@ -0,0 +1,19 @@
using Avalonia.Controls;
using Avalonia.Interactivity;
using LehrerApp.Desktop.ViewModels.Groups;
namespace LehrerApp.Desktop.Views.Groups;
public partial class GroupRolloverDialog : Window
{
public GroupRolloverDialog() => InitializeComponent();
private void OnSave(object? sender, RoutedEventArgs e)
{
if (DataContext is not GroupRolloverDialogViewModel vm) return;
vm.SaveCommand.Execute(null);
if (vm.Result is not null) Close(vm.Result.Id);
}
private void OnCancel(object? sender, RoutedEventArgs e) => Close(null);
}
@@ -13,19 +13,19 @@
<Grid RowDefinitions="Auto,Auto,Auto,Auto,*">
<Button Grid.Row="0" Content=" Sitzung" Command="{Binding AddSessionCommand}"
HorizontalAlignment="Stretch" Margin="10,10,10,6"/>
HorizontalAlignment="Stretch" Margin="10,10,10,6" IsEnabled="{Binding !IsReadOnly}"/>
<StackPanel Grid.Row="1" Orientation="Horizontal" Spacing="6" Margin="10,0,10,6">
<Button Content="Schnell Mitarbeit" Command="{Binding QuickInputCommand}"
HorizontalAlignment="Stretch"/>
<Button Content="Anw./HA" Command="{Binding StatusQuickInputCommand}"/>
HorizontalAlignment="Stretch" IsEnabled="{Binding !IsReadOnly}"/>
<Button Content="Anw./HA" Command="{Binding StatusQuickInputCommand}" IsEnabled="{Binding !IsReadOnly}"/>
</StackPanel>
<Button Grid.Row="2" Content="Ø Mitarbeitsnote" Command="{Binding ComputeGradeCommand}"
HorizontalAlignment="Stretch" Margin="10,0,10,6"/>
HorizontalAlignment="Stretch" Margin="10,0,10,6" IsEnabled="{Binding !IsReadOnly}"/>
<Button Grid.Row="3" Content="Mitarbeits-Assistent" Command="{Binding OpenWizardCommand}"
HorizontalAlignment="Stretch" Margin="10,0,10,6"/>
HorizontalAlignment="Stretch" Margin="10,0,10,6" IsEnabled="{Binding !IsReadOnly}"/>
<ListBox Grid.Row="4"
ItemsSource="{Binding Sessions}"
@@ -68,7 +68,7 @@
BorderThickness="0,0,0,1"
Padding="12,8">
<ScrollViewer HorizontalScrollBarVisibility="Disabled" MaxHeight="200">
<ItemsControl ItemsSource="{Binding CompetencyTagGroups}">
<ItemsControl ItemsSource="{Binding CompetencyTagGroups}" IsEnabled="{Binding !IsReadOnly}">
<ItemsControl.ItemTemplate>
<DataTemplate DataType="vm:CompetencyTagGroup">
<StackPanel Spacing="4" Margin="0,0,0,10">
@@ -58,7 +58,7 @@ public partial class ParticipationTabView : UserControl
{
Header = $"{aspect.Label} [{AspectShortcut(i)}]",
Width = new DataGridLength(1, DataGridLengthUnitType.Star),
CellTemplate = BuildCellTemplate(idx, forCompetency: false),
CellTemplate = BuildCellTemplate(idx, forCompetency: false, _vm.IsReadOnly),
});
}
@@ -66,13 +66,13 @@ public partial class ParticipationTabView : UserControl
{
Header = "HA",
Width = new DataGridLength(50, DataGridLengthUnitType.Pixel),
CellTemplate = BuildHomeworkCellTemplate(),
CellTemplate = BuildHomeworkCellTemplate(_vm.IsReadOnly),
});
grid.Columns.Add(new DataGridTemplateColumn
{
Header = "Anwesenheit",
Width = new DataGridLength(80, DataGridLengthUnitType.Pixel),
CellTemplate = BuildAttendanceCellTemplate(),
CellTemplate = BuildAttendanceCellTemplate(_vm.IsReadOnly),
});
// Kompetenz-Spalten (opt-in)
@@ -85,13 +85,13 @@ public partial class ParticipationTabView : UserControl
{
Header = code,
Width = new DataGridLength(80, DataGridLengthUnitType.Pixel),
CellTemplate = BuildCellTemplate(idx, forCompetency: true),
CellTemplate = BuildCellTemplate(idx, forCompetency: true, _vm.IsReadOnly),
});
}
}
}
private static IDataTemplate BuildCellTemplate(int cellIndex, bool forCompetency)
private static IDataTemplate BuildCellTemplate(int cellIndex, bool forCompetency, bool isReadOnly)
{
return new FuncDataTemplate<ParticipationStudentRow>((row, _) =>
{
@@ -119,6 +119,7 @@ public partial class ParticipationTabView : UserControl
Padding = new Avalonia.Thickness(5, 1),
FontSize = 11,
Opacity = cell.Value == val ? 1.0 : 0.3,
IsEnabled = !isReadOnly,
};
var capturedVal = val;
btn.Click += (_, _) => cell.SetValue(capturedVal);
@@ -134,7 +135,7 @@ public partial class ParticipationTabView : UserControl
});
}
private static IDataTemplate BuildHomeworkCellTemplate()
private static IDataTemplate BuildHomeworkCellTemplate(bool isReadOnly)
{
return new FuncDataTemplate<ParticipationStudentRow>((row, _) =>
{
@@ -147,6 +148,7 @@ public partial class ParticipationTabView : UserControl
Height = 26,
Padding = new Avalonia.Thickness(3, 1),
Command = row.ToggleHomeworkCommand,
IsEnabled = !isReadOnly,
};
void RefreshHomework()
{
@@ -180,7 +182,7 @@ public partial class ParticipationTabView : UserControl
});
}
private static IDataTemplate BuildAttendanceCellTemplate()
private static IDataTemplate BuildAttendanceCellTemplate(bool isReadOnly)
{
return new FuncDataTemplate<ParticipationStudentRow>((row, _) =>
{
@@ -193,6 +195,7 @@ public partial class ParticipationTabView : UserControl
Height = 26,
Padding = new Avalonia.Thickness(3, 1),
Command = row.CycleAttendanceCommand,
IsEnabled = !isReadOnly,
};
void RefreshAttendance()
{
@@ -11,10 +11,10 @@
<TextBlock Grid.Column="0" Text="Unterrichtseinheiten" FontSize="14" FontWeight="SemiBold"
VerticalAlignment="Center"/>
<StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="8">
<Button Content=" Einheit" Command="{Binding AddUnitCommand}"/>
<Button Content="Bearbeiten" Command="{Binding EditUnitCommand}"/>
<Button Content=" Einheit" Command="{Binding AddUnitCommand}" IsEnabled="{Binding !IsReadOnly}"/>
<Button Content="Bearbeiten" Command="{Binding EditUnitCommand}" IsEnabled="{Binding !IsReadOnly}"/>
<Button Content="Als Vorlage kopieren" Command="{Binding CopyUnitCommand}"/>
<Button Content="Löschen" Command="{Binding DeleteUnitCommand}"/>
<Button Content="Löschen" Command="{Binding DeleteUnitCommand}" IsEnabled="{Binding !IsReadOnly}"/>
</StackPanel>
</Grid>
@@ -62,15 +62,16 @@
<TextBlock Text="{Binding SelectedUnitTitleSuffix}" FontSize="14" FontWeight="SemiBold"/>
</StackPanel>
<StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="8">
<Button Content=" Stunde" Command="{Binding AddLessonCommand}"/>
<Button Content=" Stunde" Command="{Binding AddLessonCommand}" IsEnabled="{Binding !IsReadOnly}"/>
<Button Content="Serie erzeugen" Command="{Binding GenerateLessonSeriesCommand}"
IsEnabled="{Binding !IsReadOnly}"
ToolTip.Tip="Stunden für alle Termine aus dem Stundenplan im gewählten Zeitraum anlegen."/>
<Button Content="Anzeigen" Command="{Binding ShowLessonCommand}"
ToolTip.Tip="Verlaufsplan schreibgeschützt und größer anzeigen — zum Mitnehmen in den Unterricht."/>
<Button Content="Bearbeiten" Command="{Binding EditLessonCommand}"/>
<Button Content="Verschieben" Command="{Binding MoveLessonCommand}"/>
<Button Content="Status → Durchgeführt" Command="{Binding AdvanceLessonStatusCommand}"/>
<Button Content="Löschen" Command="{Binding DeleteLessonCommand}"/>
<Button Content="Bearbeiten" Command="{Binding EditLessonCommand}" IsEnabled="{Binding !IsReadOnly}"/>
<Button Content="Verschieben" Command="{Binding MoveLessonCommand}" IsEnabled="{Binding !IsReadOnly}"/>
<Button Content="Status → Durchgeführt" Command="{Binding AdvanceLessonStatusCommand}" IsEnabled="{Binding !IsReadOnly}"/>
<Button Content="Löschen" Command="{Binding DeleteLessonCommand}" IsEnabled="{Binding !IsReadOnly}"/>
</StackPanel>
</Grid>
@@ -1,6 +1,7 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="clr-namespace:LehrerApp.Desktop.ViewModels.Settings"
xmlns:services="clr-namespace:LehrerApp.Core.Services;assembly=LehrerApp.Core"
xmlns:shared="clr-namespace:LehrerApp.Desktop.Views.Shared"
x:Class="LehrerApp.Desktop.Views.Settings.SettingsView"
x:DataType="vm:SettingsViewModel">
@@ -270,6 +271,86 @@
</ScrollViewer>
</ContentPage>
<!-- Tab: Word-Briefvorlagen (7.1.4 / 11.5) -->
<ContentPage Header="Briefvorlagen">
<ScrollViewer>
<StackPanel Margin="32,20,32,28" Spacing="16" MaxWidth="760">
<TextBlock Text="Word-Vorlagen bleiben vollständig in Word gestaltet. Die App befüllt Inhaltssteuerelemente anhand ihres Tags und prüft die Vorlage bereits beim Import."
FontSize="12" Opacity="0.65" TextWrapping="Wrap"/>
<Grid ColumnDefinitions="Auto,*" ColumnSpacing="12">
<Button Grid.Column="0" Content=" DOCX-Vorlage importieren" Click="OnImportLetterTemplateClick"/>
<TextBlock Grid.Column="1" Text="{Binding LetterTemplateStatus}" FontSize="12"
VerticalAlignment="Center" TextWrapping="Wrap"
IsVisible="{Binding LetterTemplateStatus, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
</Grid>
<TextBlock Text="Noch keine Briefvorlage importiert." Classes="emptyhint"
IsVisible="{Binding !LetterTemplateList.Count}"/>
<ItemsControl ItemsSource="{Binding LetterTemplateList}">
<ItemsControl.ItemTemplate>
<DataTemplate DataType="vm:LetterTemplateListItem">
<Border BorderBrush="{DynamicResource SystemControlForegroundBaseLowBrush}"
BorderThickness="1" CornerRadius="7" Padding="14,12" Margin="0,0,0,9">
<StackPanel Spacing="8">
<Grid ColumnDefinitions="*,Auto,Auto,Auto">
<StackPanel Grid.Column="0">
<TextBlock Text="{Binding Name}" FontWeight="SemiBold" FontSize="14"/>
<TextBlock FontSize="11" Opacity="0.55">
<Run Text="{Binding OriginalFileName}"/><Run Text=" · "/>
<Run Text="{Binding ValidationSummary}"/>
</TextBlock>
</StackPanel>
<Button Grid.Column="1" Content="Öffnen" FontSize="12" Padding="10,4"
Margin="8,0,0,0" Tag="{Binding}" Click="OnOpenLetterTemplateClick"/>
<Button Grid.Column="2" Content="Neu prüfen" FontSize="12" Padding="10,4"
Margin="8,0,0,0"
Command="{Binding $parent[ItemsControl].((vm:SettingsViewModel)DataContext).ValidateLetterTemplateCommand}"
CommandParameter="{Binding}"/>
<Button Grid.Column="3" Content="Löschen" FontSize="12" Padding="10,4"
Margin="8,0,0,0"
Command="{Binding $parent[ItemsControl].((vm:SettingsViewModel)DataContext).DeleteLetterTemplateCommand}"
CommandParameter="{Binding}"/>
</Grid>
<ItemsControl ItemsSource="{Binding Issues}" IsVisible="{Binding HasIssues}">
<ItemsControl.ItemTemplate>
<DataTemplate DataType="vm:LetterTemplateIssueItem">
<Grid ColumnDefinitions="24,*" Margin="0,2">
<TextBlock Grid.Column="0" Text="{Binding Icon}" Foreground="{Binding Color}"/>
<TextBlock Grid.Column="1" Text="{Binding Message}" Foreground="{Binding Color}"
FontSize="12" TextWrapping="Wrap"/>
</Grid>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<TextBlock Text="✓ Vorlage ohne Auffälligkeiten" Foreground="SeaGreen" FontSize="12"
IsVisible="{Binding HasNoIssues}"/>
</StackPanel>
</Border>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<Separator/>
<TextBlock Text="Unterstützte Tags" FontSize="15" FontWeight="SemiBold"/>
<TextBlock Text="In Word: Entwicklertools → Rich-Text- oder Nur-Text-Inhaltssteuerelement → Eigenschaften → Tag. Der Titel ist frei wählbar; ausgewertet wird der Tag."
FontSize="12" Opacity="0.65" TextWrapping="Wrap"/>
<ItemsControl ItemsSource="{Binding SupportedLetterPlaceholders}">
<ItemsControl.ItemTemplate>
<DataTemplate DataType="services:LetterPlaceholder">
<Grid ColumnDefinitions="190,*" Margin="0,3">
<TextBlock Grid.Column="0" Text="{Binding Tag}" FontFamily="Monospace" FontSize="12"/>
<TextBlock Grid.Column="1" Text="{Binding Description}" FontSize="12" Opacity="0.7"/>
</Grid>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</StackPanel>
</ScrollViewer>
</ContentPage>
<!-- Tab: Notenschema -->
<ContentPage Header="Notenschema">
<ContentPage.Resources>
@@ -91,4 +91,27 @@ public partial class SettingsView : UserControl
var owner = TopLevel.GetTopLevel(this) as Window;
if (owner is not null) await dialog.ShowDialog(owner);
}
private async void OnImportLetterTemplateClick(object? sender, RoutedEventArgs e)
{
var topLevel = TopLevel.GetTopLevel(this);
if (topLevel is null || DataContext is not SettingsViewModel vm) return;
var files = await topLevel.StorageProvider.OpenFilePickerAsync(new FilePickerOpenOptions
{
Title = "Word-Briefvorlage importieren",
AllowMultiple = false,
FileTypeFilter = [new FilePickerFileType("Word-Dokumente") { Patterns = ["*.docx"] }],
});
if (files.Count > 0) vm.ImportLetterTemplate(files[0].Path.LocalPath);
}
private void OnOpenLetterTemplateClick(object? sender, RoutedEventArgs e)
{
if (sender is not Button { Tag: LetterTemplateListItem item }) return;
var service = App.Services.GetRequiredService<LehrerApp.Core.Services.LetterTemplateService>();
var path = service.GetTemplatePath(item.Model);
if (!File.Exists(path)) return;
System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo(path) { UseShellExecute = true });
}
}
@@ -72,6 +72,9 @@
<Button Grid.Column="4" Content="×" Padding="6,2"
Command="{Binding RemoveCommand}" FontSize="14"/>
</Grid>
<TextBox Text="{Binding LetterSalutation}"
PlaceholderText="Briefanrede, z.B. Sehr geehrte Frau Mustermann,"
FontSize="12"/>
<!-- Zeile 2: Telefon + E-Mail -->
<Grid ColumnDefinitions="*,10,*">
<TextBox Grid.Column="0" Text="{Binding Phone}"
@@ -28,6 +28,14 @@
</StackPanel>
</Grid>
<StackPanel Spacing="4">
<TextBlock Text="Briefanrede" FontSize="12" Opacity="0.7"/>
<TextBox Text="{Binding LetterSalutation}"
PlaceholderText="z.B. Sehr geehrte Frau Mustermann,"/>
<TextBlock Text="Wird unverändert in Word-Vorlagen für Letter.Salutation eingesetzt."
FontSize="11" Opacity="0.55" TextWrapping="Wrap"/>
</StackPanel>
<Grid ColumnDefinitions="*,10,*">
<StackPanel Grid.Column="0" Spacing="4">
<TextBlock Text="Telefon" FontSize="12" Opacity="0.7"/>
@@ -0,0 +1,75 @@
<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="clr-namespace:LehrerApp.Desktop.ViewModels.Students"
x:Class="LehrerApp.Desktop.Views.Students.CreateLetterDialog"
x:DataType="vm:CreateLetterDialogViewModel"
Title="Word-Brief erstellen" Width="580" Height="650"
MinWidth="500" MinHeight="540" CanResize="True"
WindowStartupLocation="CenterOwner">
<Grid RowDefinitions="*,Auto" Margin="24,20">
<ScrollViewer Grid.Row="0" VerticalScrollBarVisibility="Auto">
<StackPanel Spacing="15">
<TextBlock Text="Word-Brief erstellen" Classes="dialogtitle"/>
<TextBlock FontSize="12" Opacity="0.65" TextWrapping="Wrap"
Text="Die Vorlage wird vor der Erzeugung erneut geprüft. Das Original bleibt unverändert; gespeichert wird eine frei bearbeitbare DOCX-Kopie."/>
<StackPanel Spacing="4">
<TextBlock Text="Schüler" FontSize="12" Opacity="0.7"/>
<TextBlock Text="{Binding StudentName}" FontWeight="SemiBold"/>
</StackPanel>
<StackPanel Spacing="4">
<TextBlock Text="Vorlage *" FontSize="12" Opacity="0.7"/>
<ComboBox ItemsSource="{Binding Templates}" SelectedItem="{Binding SelectedTemplate}"
DisplayMemberBinding="{Binding Name}" HorizontalAlignment="Stretch"/>
<TextBlock Text="Noch keine Vorlage. Bitte unter Einstellungen → Briefvorlagen importieren."
Classes="emptyhint" IsVisible="{Binding HasNoTemplates}"/>
</StackPanel>
<StackPanel Spacing="4">
<TextBlock Text="Kontakt *" FontSize="12" Opacity="0.7"/>
<ComboBox ItemsSource="{Binding Contacts}" SelectedItem="{Binding SelectedContact}"
DisplayMemberBinding="{Binding Display}" HorizontalAlignment="Stretch"/>
<TextBlock Text="Kein aktueller Kontakt vorhanden." Classes="emptyhint"
IsVisible="{Binding HasNoContacts}"/>
</StackPanel>
<Grid ColumnDefinitions="*,12,*">
<StackPanel Grid.Column="0" Spacing="4">
<TextBlock Text="Lerngruppe" FontSize="12" Opacity="0.7"/>
<ComboBox ItemsSource="{Binding Groups}" SelectedItem="{Binding SelectedGroup}"
DisplayMemberBinding="{Binding Display}" PlaceholderText="Optional"
HorizontalAlignment="Stretch"/>
</StackPanel>
<StackPanel Grid.Column="2" Spacing="4">
<TextBlock Text="Briefdatum *" FontSize="12" Opacity="0.7"/>
<CalendarDatePicker SelectedDate="{Binding LetterDate}" HorizontalAlignment="Stretch"/>
</StackPanel>
</Grid>
<Border BorderBrush="#D97706" BorderThickness="1" CornerRadius="6" Padding="10"
IsVisible="{Binding HasIssues}">
<StackPanel Spacing="5">
<TextBlock Text="Vor dem Erzeugen korrigieren" FontWeight="SemiBold" Foreground="#D97706"/>
<ItemsControl ItemsSource="{Binding Issues}">
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:LetterGenerationIssue">
<Grid ColumnDefinitions="24,*" Margin="0,2">
<TextBlock Grid.Column="0" Text="{Binding Icon}" Foreground="{Binding Color}"/>
<TextBlock Grid.Column="1" Text="{Binding Message}" Foreground="{Binding Color}"
FontSize="12" TextWrapping="Wrap"/>
</Grid>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</StackPanel>
</Border>
<TextBlock Text="{Binding GenerationError}" Foreground="Red" FontSize="12" TextWrapping="Wrap"
IsVisible="{Binding GenerationError, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
</StackPanel>
</ScrollViewer>
<Grid Grid.Row="1" ColumnDefinitions="*,10,*" Margin="0,18,0,0">
<Button Grid.Column="0" Content="Abbrechen" HorizontalAlignment="Stretch" Click="OnCancel"/>
<Button Grid.Column="2" Content="DOCX speichern …" HorizontalAlignment="Stretch"
IsEnabled="{Binding CanGenerate}" Click="OnGenerate"/>
</Grid>
</Grid>
</Window>
@@ -0,0 +1,27 @@
using Avalonia.Controls;
using Avalonia.Interactivity;
using Avalonia.Platform.Storage;
using LehrerApp.Desktop.ViewModels.Students;
namespace LehrerApp.Desktop.Views.Students;
public partial class CreateLetterDialog : Window
{
public CreateLetterDialog() => InitializeComponent();
private async void OnGenerate(object? sender, RoutedEventArgs e)
{
if (DataContext is not CreateLetterDialogViewModel vm) return;
var file = await StorageProvider.SaveFilePickerAsync(new FilePickerSaveOptions
{
Title = "Word-Brief speichern",
SuggestedFileName = vm.SuggestedFileName,
DefaultExtension = "docx",
FileTypeChoices = [new FilePickerFileType("Word-Dokumente") { Patterns = ["*.docx"] }],
});
if (file is null || !vm.Generate(file.Path.LocalPath)) return;
Close(file.Path.LocalPath);
}
private void OnCancel(object? sender, RoutedEventArgs e) => Close(null);
}
@@ -0,0 +1,59 @@
<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="clr-namespace:LehrerApp.Desktop.ViewModels.Students"
x:Class="LehrerApp.Desktop.Views.Students.ManageStudentDialog"
x:DataType="vm:ManageStudentDialogViewModel"
Title="Schüler verwalten"
Width="500" Height="500"
CanResize="False" WindowStartupLocation="CenterOwner">
<Grid RowDefinitions="*,Auto" Margin="24">
<StackPanel Grid.Row="0" Spacing="14">
<TextBlock Text="Schüler verwalten" Classes="dialogtitle"/>
<TextBlock Text="{Binding StudentName}" FontSize="15" FontWeight="SemiBold"/>
<Border Background="{DynamicResource SystemControlBackgroundAltHighBrush}"
CornerRadius="6" Padding="14">
<StackPanel Spacing="8">
<TextBlock Text="Deaktivieren blendet den Schüler aus den normalen Listen aus. Alle historischen Daten bleiben erhalten."
TextWrapping="Wrap" FontSize="12"/>
<TextBlock Text="Der Schüler ist bereits deaktiviert und kann wieder aktiviert werden."
TextWrapping="Wrap" FontSize="12" IsVisible="{Binding IsInactive}"/>
</StackPanel>
</Border>
<StackPanel Spacing="8" IsVisible="{Binding HasReferences}">
<TextBlock Text="Verknüpfte Daten" FontWeight="SemiBold"/>
<ItemsControl ItemsSource="{Binding ReferenceItems}">
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:StudentReferenceItem">
<Grid ColumnDefinitions="*,Auto" Margin="0,2">
<TextBlock Grid.Column="0" Text="{Binding Label}"/>
<TextBlock Grid.Column="1" Text="{Binding CountDisplay}" FontWeight="SemiBold"/>
</Grid>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<TextBlock Text="Eine endgültige Löschung ist deshalb gesperrt. Verwende stattdessen Deaktivieren."
Foreground="#D97706" TextWrapping="Wrap" FontSize="12"/>
</StackPanel>
<Border IsVisible="{Binding CanDelete}" BorderBrush="#C62828" BorderThickness="1"
CornerRadius="6" Padding="12">
<TextBlock Text="Es bestehen keine Verknüpfungen. Der Schüler kann endgültig gelöscht werden. Stammdaten und Kontakte gehen dabei unwiderruflich verloren."
Foreground="#C62828" TextWrapping="Wrap" FontSize="12"/>
</Border>
<TextBlock Text="{Binding ErrorMessage}" Foreground="Red" FontSize="12"
IsVisible="{Binding ErrorMessage, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
</StackPanel>
<Grid Grid.Row="1" ColumnDefinitions="Auto,Auto,*,Auto" ColumnSpacing="8" Margin="0,18,0,0">
<Button Grid.Column="0" Content="Deaktivieren" Click="OnDeactivate" IsVisible="{Binding IsActive}"/>
<Button Grid.Column="0" Content="Wieder aktivieren" Click="OnReactivate" IsVisible="{Binding IsInactive}"/>
<Button Grid.Column="1" Content="Endgültig löschen" Click="OnDeletePermanently"
IsEnabled="{Binding CanDelete}"/>
<Button Grid.Column="3" Content="Schließen" Click="OnCancel"/>
</Grid>
</Grid>
</Window>
@@ -0,0 +1,33 @@
using Avalonia.Controls;
using Avalonia.Interactivity;
using LehrerApp.Desktop.ViewModels.Students;
namespace LehrerApp.Desktop.Views.Students;
public partial class ManageStudentDialog : Window
{
public ManageStudentDialog() => InitializeComponent();
private void OnDeactivate(object? sender, RoutedEventArgs e)
{
if (DataContext is not ManageStudentDialogViewModel vm) return;
vm.DeactivateCommand.Execute(null);
Close(vm.Result);
}
private void OnReactivate(object? sender, RoutedEventArgs e)
{
if (DataContext is not ManageStudentDialogViewModel vm) return;
vm.ReactivateCommand.Execute(null);
Close(vm.Result);
}
private void OnDeletePermanently(object? sender, RoutedEventArgs e)
{
if (DataContext is not ManageStudentDialogViewModel vm) return;
vm.DeletePermanentlyCommand.Execute(null);
if (vm.Result == StudentManagementResult.Deleted) Close(vm.Result);
}
private void OnCancel(object? sender, RoutedEventArgs e) => Close(StudentManagementResult.Cancelled);
}
@@ -11,16 +11,29 @@
BorderBrush="{DynamicResource SystemControlForegroundBaseLowBrush}"
BorderThickness="0,0,0,1">
<Grid ColumnDefinitions="*,Auto">
<shared:PageHeader Grid.Column="0" IsVisible="{Binding !IsEditing}" Title="{Binding StudentTitle}"/>
<Border Grid.Column="0" Width="38" Height="38" CornerRadius="19"
Background="{Binding StudentAvatarColor}" HorizontalAlignment="Left"
ToolTip.Tip="{Binding StudentAvatarTooltip}" IsVisible="{Binding !IsEditing}">
<TextBlock Text="{Binding StudentAvatarLabel}" Foreground="White" FontWeight="Bold" FontSize="15"
HorizontalAlignment="Center" VerticalAlignment="Center"/>
</Border>
<shared:PageHeader Grid.Column="0" Margin="50,0,0,0" IsVisible="{Binding !IsEditing}" Title="{Binding StudentTitle}"/>
<StackPanel Grid.Column="0" Spacing="6" IsVisible="{Binding IsEditing}">
<Grid ColumnDefinitions="*,8,*">
<Grid ColumnDefinitions="*,8,*,8,170">
<TextBox Grid.Column="0" Text="{Binding EditFirstName}" PlaceholderText="Vorname"/>
<TextBox Grid.Column="2" Text="{Binding EditLastName}" PlaceholderText="Nachname"/>
<ComboBox Grid.Column="4" ItemsSource="{Binding GenderOptions}"
SelectedItem="{Binding EditGender}" PlaceholderText="Geschlecht"/>
</Grid>
</StackPanel>
<StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="8" VerticalAlignment="Top">
<TextBlock Text="{Binding StudentStatus}" VerticalAlignment="Center" Opacity="0.65"/>
<Button Content="Word-Brief" Command="{Binding CreateLetterCommand}"
IsVisible="{Binding !IsEditing}"/>
<Button Content="Bearbeiten" Command="{Binding StartEditCommand}"
IsVisible="{Binding !IsEditing}"/>
<Button Content="Schüler verwalten" Command="{Binding ManageStudentCommand}"
IsVisible="{Binding !IsEditing}"/>
<Button Content="Speichern" Command="{Binding SaveEditCommand}"
IsVisible="{Binding IsEditing}"/>
<Button Content="Abbrechen" Command="{Binding CancelEditCommand}"
@@ -88,6 +101,9 @@
CornerRadius="4" Padding="6,2" HorizontalAlignment="Left">
<TextBlock Text="{Binding Relation}" FontSize="11" Opacity="0.7"/>
</Border>
<TextBlock Text="{Binding LetterSalutation}" FontSize="11" Opacity="0.6"
TextWrapping="Wrap"
IsVisible="{Binding LetterSalutation, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
</StackPanel>
<StackPanel Grid.Column="1" Grid.Row="0" Orientation="Horizontal" Spacing="8">
@@ -23,6 +23,8 @@ public partial class StudentDetailView : UserControl
vm.OnConfirmDeleteDocumentation = ShowDeleteDocumentationDialog;
vm.OnSaveExportFile = SaveExportFile;
vm.OnConductParentCall = ShowParentCallSessionDialog;
vm.OnManageStudent = ShowManageStudentDialog;
vm.OnCreateLetter = ShowCreateLetterDialog;
}
private async Task<Contact?> ShowContactDialog(Contact? contact)
@@ -36,6 +38,32 @@ public partial class StudentDetailView : UserControl
return saved ? vm.Result : null;
}
private async Task<StudentManagementResult> ShowManageStudentDialog(Student student)
{
var owner = TopLevel.GetTopLevel(this) as Window;
if (owner is null) return StudentManagementResult.Cancelled;
var vm = new ManageStudentDialogViewModel(
App.Services.GetRequiredService<IStudentRepository>(), student);
var dialog = new ManageStudentDialog { DataContext = vm };
return await dialog.ShowDialog<StudentManagementResult>(owner);
}
private async Task ShowCreateLetterDialog()
{
var owner = TopLevel.GetTopLevel(this) as Window;
if (owner is null || DataContext is not StudentDetailViewModel { Student: { } student }) return;
var vm = new CreateLetterDialogViewModel(student,
App.Services.GetRequiredService<LehrerApp.Core.Services.LetterTemplateService>(),
App.Services.GetRequiredService<IGroupMembershipRepository>(),
App.Services.GetRequiredService<IGroupRepository>());
var dialog = new CreateLetterDialog { DataContext = vm };
var path = await dialog.ShowDialog<string?>(owner);
if (!string.IsNullOrEmpty(path) && File.Exists(path))
System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo(path) { UseShellExecute = true });
}
private void ShowAddressViewer(ContactItem contact)
{
if (!contact.HasAddress) return;
@@ -19,13 +19,25 @@
</Border>
<DockPanel Grid.Row="1">
<TextBox DockPanel.Dock="Top" Text="{Binding SearchText}"
PlaceholderText="Name suchen…" Margin="16,10,16,4"/>
PlaceholderText="Name oder Lerngruppe suchen…" Margin="16,10,16,4"/>
<DataGrid ItemsSource="{Binding Students}"
SelectedItem="{Binding SelectedStudent}"
AutoGenerateColumns="False" IsReadOnly="True"
GridLinesVisibility="Horizontal"
CanUserReorderColumns="False" Margin="16,4">
<DataGrid.Columns>
<DataGridTemplateColumn Header="" Width="48">
<DataGridTemplateColumn.CellTemplate>
<DataTemplate x:DataType="vm:StudentListItem">
<Border Width="30" Height="30" CornerRadius="15" Background="{Binding AvatarColor}"
HorizontalAlignment="Center" VerticalAlignment="Center"
ToolTip.Tip="{Binding AvatarTooltip}">
<TextBlock Text="{Binding AvatarLabel}" Foreground="White" FontWeight="Bold"
HorizontalAlignment="Center" VerticalAlignment="Center"/>
</Border>
</DataTemplate>
</DataGridTemplateColumn.CellTemplate>
</DataGridTemplateColumn>
<DataGridTextColumn Header="Name" Binding="{Binding FullName}" Width="*"/>
<DataGridTextColumn Header="Geburtsdatum" Binding="{Binding DateOfBirth}" Width="130"/>
</DataGrid.Columns>