feat: Abwesenheits-Hinweise, Fehlquote bei Zeugnisnoten, Gruppen-Dokumentation

- Schnellbewerten-Dialog: abwesende Schüler werden gedimmt und mit ihrem
  Anwesenheitsstatus statt der Aspektbeschriftung angezeigt, damit keine
  Mitarbeitsnote für nicht anwesende Schüler vergeben wird.
- Zeugnisnoten-Dialog: zeigt je Schüler die Fehlquote im gewählten
  Zeitraum, ab 50 % hervorgehoben (informativ, keine automatische
  Notenänderung).
- Gruppen-Tab "Dokumentation" (bisher Platzhalter) implementiert: listet
  alle Dokumentationseinträge der Gruppen-Schüler, mit Schüler-Filter und
  optionalem "Nur dieser Unterricht"-Schalter. Einträge aus anderen
  Lerngruppen werden standardmäßig mitangezeigt, aber gedimmt. Der
  Dokumentationsdialog bekommt dafür einen optionalen Schüler-Picker.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-18 19:45:01 +02:00
co-authored by Claude Sonnet 5
parent f2cb3c98c6
commit a0f44f1b25
19 changed files with 725 additions and 26 deletions
+1
View File
@@ -250,6 +250,7 @@ public static class AppBootstrapper
services.AddTransient<PlanningTabViewModel>();
services.AddTransient<CompetencyOverviewTabViewModel>();
services.AddTransient<SeatingPlanTabViewModel>();
services.AddTransient<GroupDocumentationTabViewModel>();
services.AddTransient<AddGroupDialogViewModel>();
services.AddTransient<SettingsViewModel>();
@@ -0,0 +1,132 @@
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using LehrerApp.Core.Interfaces;
using LehrerApp.Core.Models;
using LehrerApp.Desktop.ViewModels.Students;
using System.Collections.ObjectModel;
namespace LehrerApp.Desktop.ViewModels.Groups;
/// <summary>
/// Gruppen-Tab "Dokumentation" (bisher Platzhalter): zeigt die Dokumentationseinträge aller
/// aktuellen/ehemaligen Schüler dieser Gruppe an einem Ort, statt sie einzeln im Schüler-Tab
/// aufsuchen zu müssen. Nutzer-Feedback: die Liste soll standardmäßig auch Einträge aus anderen
/// Lerngruppen desselben Schülers mit anzeigen (optisch abgesetzt statt ausgeblendet), damit
/// Muster aus anderen Fächern/Kursen nicht verborgen bleiben — ein Schalter blendet sie bei
/// Bedarf ganz aus.
/// </summary>
public partial class GroupDocumentationTabViewModel : ObservableObject
{
private readonly IDocumentationRepository _docs;
private readonly IStudentRepository _students;
private readonly IGroupRepository _groups;
private Guid _groupId;
private List<StudentOption> _groupStudents = [];
public static readonly StudentOption AllStudentsOption = new(Guid.Empty, "Alle Schüler");
public ObservableCollection<DocumentationItem> Entries { get; } = [];
public ObservableCollection<StudentOption> StudentFilterOptions { get; } = [AllStudentsOption];
[ObservableProperty] private StudentOption _selectedStudentFilter = AllStudentsOption;
[ObservableProperty] private bool _onlyThisGroup;
public Func<Guid, List<StudentOption>, Documentation?, Task<Documentation?>>? OnEditDocumentation { get; set; }
public Func<DocumentationItem, Task<bool>>? OnConfirmDeleteDocumentation { get; set; }
public Func<Documentation, string, Task<Documentation?>>? OnConductParentCall { get; set; }
public GroupDocumentationTabViewModel(IDocumentationRepository docs, IStudentRepository students,
IGroupRepository groups)
{
_docs = docs; _students = students; _groups = groups;
}
partial void OnSelectedStudentFilterChanged(StudentOption value) => Load();
partial void OnOnlyThisGroupChanged(bool value) => Load();
public void Initialize(Guid groupId)
{
_groupId = groupId;
_groupStudents = _students.GetByGroup(groupId)
.Select(s => new StudentOption(s.Id, s.FullName)).ToList();
StudentFilterOptions.Clear();
StudentFilterOptions.Add(AllStudentsOption);
foreach (var s in _groupStudents) StudentFilterOptions.Add(s);
SelectedStudentFilter = AllStudentsOption;
Load();
}
private void Load()
{
Entries.Clear();
if (_groupStudents.Count == 0) return;
var studentNameById = _groupStudents.ToDictionary(s => s.Id, s => s.Name);
var groupNameCache = new Dictionary<Guid, string>();
string GroupLabel(Guid id)
{
if (groupNameCache.TryGetValue(id, out var cached)) return cached;
var label = _groups.GetById(id)?.Name ?? "";
groupNameCache[id] = label;
return label;
}
var relevantStudentIds = SelectedStudentFilter.Id == Guid.Empty
? _groupStudents.Select(s => s.Id)
: [SelectedStudentFilter.Id];
var all = relevantStudentIds
.SelectMany(id => _docs.GetByStudent(id))
.Where(d => !OnlyThisGroup || d.GroupId == _groupId)
.OrderByDescending(d => d.Date);
foreach (var d in all)
{
var isOwnGroup = d.GroupId is null || d.GroupId == _groupId;
var otherGroupLabel = isOwnGroup ? "" : GroupLabel(d.GroupId!.Value);
Entries.Add(new DocumentationItem(d, studentNameById.GetValueOrDefault(d.StudentId, ""),
isOwnGroup, otherGroupLabel));
}
}
[RelayCommand]
private async Task AddDocumentation()
{
if (OnEditDocumentation is null || _groupStudents.Count == 0) return;
var result = await OnEditDocumentation(_groupId, _groupStudents, null);
if (result is null) return;
_docs.Save(result);
Load();
}
[RelayCommand]
private async Task EditDocumentation(DocumentationItem? item)
{
if (item is null || OnEditDocumentation is null) return;
var result = await OnEditDocumentation(_groupId, _groupStudents, item.Model);
if (result is null) return;
_docs.Save(result);
Load();
}
[RelayCommand]
private async Task DeleteDocumentation(DocumentationItem? item)
{
if (item is null) return;
if (OnConfirmDeleteDocumentation is not null && !await OnConfirmDeleteDocumentation(item)) return;
_docs.Delete(item.Model.Id);
Load();
}
[RelayCommand]
private async Task ConductParentCall(DocumentationItem? item)
{
if (item is null || OnConductParentCall is null) return;
var result = await OnConductParentCall(item.Model, item.StudentName);
if (result is null) return;
_docs.Save(result);
Load();
}
}
@@ -219,6 +219,7 @@ public partial class GroupDetailViewModel : ObservableObject
public PlanningTabViewModel PlanningTab { get; }
public CompetencyOverviewTabViewModel CompetencyOverviewTab { get; }
public SeatingPlanTabViewModel SeatingPlanTab { get; }
public GroupDocumentationTabViewModel GroupDocumentationTab { get; }
public Func<Task<bool>>? OnAddStudent { get; set; }
public Func<StudentSummary, Task<bool>>? OnWithdrawStudent { get; set; }
public Func<Guid, Task<bool>>? OnAddExam { get; set; }
@@ -234,7 +235,7 @@ public partial class GroupDetailViewModel : ObservableObject
IExamRepository exams, IGradeRepository grades, IWorkTaskRepository tasks,
ParticipationTabViewModel participationTab, GradeOverviewTabViewModel gradeOverviewTab,
PlanningTabViewModel planningTab, CompetencyOverviewTabViewModel competencyOverviewTab,
SeatingPlanTabViewModel seatingPlanTab)
SeatingPlanTabViewModel seatingPlanTab, GroupDocumentationTabViewModel groupDocumentationTab)
{
_groups = groups; _students = students; _memberships = memberships; _subjects = subjects;
_exams = exams; _grades = grades; _tasks = tasks;
@@ -243,6 +244,7 @@ public partial class GroupDetailViewModel : ObservableObject
PlanningTab = planningTab;
CompetencyOverviewTab = competencyOverviewTab;
SeatingPlanTab = seatingPlanTab;
GroupDocumentationTab = groupDocumentationTab;
SeatingPlanTab.OnAssessmentChanged = () =>
{
ParticipationTab.LoadSessions();
@@ -268,6 +270,7 @@ public partial class GroupDetailViewModel : ObservableObject
PlanningTab.Initialize(Group.Id, IsReadOnly);
CompetencyOverviewTab.Initialize(Group);
SeatingPlanTab.Initialize(Group.Id, IsReadOnly);
GroupDocumentationTab.Initialize(Group.Id);
}
private void ReloadExams()
@@ -361,6 +361,9 @@ public partial class ParticipationStudentRow : ObservableObject
public string AttendanceLabel => AttendanceDisplay.ShortLabel(Attendance);
public string AttendanceTooltip => AttendanceDisplay.Label(Attendance);
/// Abwesend im Sinne der Mitarbeitsbewertung: eine Bewertung ergibt für diese Stunde keinen
/// Sinn, unabhängig davon, ob die Abwesenheit entschuldigt ist oder noch geklärt werden muss.
public bool IsAbsent => Attendance is not null and not AttendanceStatus.Present;
public string HomeworkSymbol => HomeworkDisplay.Symbol(Homework);
public string HomeworkTooltip => HomeworkDisplay.Label(Homework);
@@ -442,6 +445,7 @@ public partial class ParticipationStudentRow : ObservableObject
};
OnPropertyChanged(nameof(AttendanceLabel));
OnPropertyChanged(nameof(AttendanceTooltip));
OnPropertyChanged(nameof(IsAbsent));
AttendanceChangedCallback?.Invoke(StudentId, Attendance);
}
@@ -451,6 +455,7 @@ public partial class ParticipationStudentRow : ObservableObject
Attendance = value;
OnPropertyChanged(nameof(AttendanceLabel));
OnPropertyChanged(nameof(AttendanceTooltip));
OnPropertyChanged(nameof(IsAbsent));
AttendanceChangedCallback?.Invoke(StudentId, value);
}
}
@@ -727,6 +732,13 @@ public partial class QuickInputViewModel : ObservableObject
[ObservableProperty] private string _currentAspectLabel = "";
[ObservableProperty] private string _currentValueLabel = "";
[ObservableProperty] private string _progressText = "";
[ObservableProperty] private bool _currentStudentIsAbsent;
[ObservableProperty] private string _currentStudentAttendanceLabel = "";
/// Dimmt Name/Aspektliste, wenn der aktuelle Schüler abwesend ist — kein Blockieren der
/// Eingabe (manche Bewertungssysteme wollen trotzdem einen Eintrag, z.B. "0 Punkte"), nur ein
/// visueller Hinweis, dass eine Bewertung hier normalerweise keinen Sinn ergibt.
public double CurrentStudentContentOpacity => CurrentStudentIsAbsent ? 0.4 : 1.0;
public ObservableCollection<QuickAspectRow> AspectRows { get; } = [];
@@ -758,6 +770,7 @@ public partial class QuickInputViewModel : ObservableObject
}
partial void OnAspectIndexChanged(int value) => OnPropertyChanged(nameof(HotkeyLegend));
partial void OnCurrentStudentIsAbsentChanged(bool value) => OnPropertyChanged(nameof(CurrentStudentContentOpacity));
private AspectValueType CurrentAspectType() =>
_aspects.Count == 0 ? AspectValueType.Scale5 : _aspects[Math.Clamp(AspectIndex, 0, _aspects.Count - 1)].ValueType;
@@ -771,6 +784,8 @@ public partial class QuickInputViewModel : ObservableObject
var row = _rows[index];
StudentName = row.Name;
ProgressText = $"{index + 1} / {_rows.Count}";
CurrentStudentIsAbsent = row.IsAbsent;
CurrentStudentAttendanceLabel = row.AttendanceTooltip;
AspectRows.Clear();
foreach (var (a, i) in _aspects.Select((a, i) => (a, i)))
@@ -20,6 +20,9 @@ public partial class ReportGradeDialogViewModel : ObservableObject
private readonly IGroupMembershipRepository _memberships;
private readonly IGradingSchemeRepository _schemes;
private readonly IReportGradeRepository _reportGrades;
private readonly IParticipationSessionRepository _participationSessions;
private readonly IParticipationRepository _participation;
private readonly AttendanceBalanceService _attendanceBalance;
private readonly GradingService _grading;
private readonly Guid _groupId;
private readonly GroupType _groupType;
@@ -51,11 +54,15 @@ public partial class ReportGradeDialogViewModel : ObservableObject
public ReportGradeDialogViewModel(IGradeRepository grades, IExamRepository exams,
IExamResultRepository results, IStudentRepository students, IGroupMembershipRepository memberships,
IGradingSchemeRepository schemes, IReportGradeRepository reportGrades, GradingService grading,
IGradingSchemeRepository schemes, IReportGradeRepository reportGrades,
IParticipationSessionRepository participationSessions, IParticipationRepository participation,
AttendanceBalanceService attendanceBalance, GradingService grading,
Guid groupId, GroupType groupType, GradingSystem gradingSystem, string groupLabel, string schoolYear)
{
_grades = grades; _exams = exams; _results = results; _students = students;
_memberships = memberships; _schemes = schemes; _reportGrades = reportGrades; _grading = grading;
_participationSessions = participationSessions; _participation = participation;
_attendanceBalance = attendanceBalance;
_groupId = groupId; _groupType = groupType; _gradingSystem = gradingSystem; _groupLabel = groupLabel;
_schoolYear = schoolYear;
@@ -92,17 +99,36 @@ public partial class ReportGradeDialogViewModel : ObservableObject
var resultsByExam = exams.ToDictionary(e => e.Id, e => _results.GetByExam(e.Id).ToDictionary(r => r.StudentId));
var allGrades = _grades.GetByGroup(_groupId).Where(g => g.Date >= periodFrom && g.Date <= periodTo).ToList();
// Fehlquote (Nutzer-Feedback): je Schüler die Anwesenheits-Bilanz im gewählten Zeitraum,
// nur aus Sitzungen dieser Gruppe (anders als StudentDetailViewModel.LoadAttendanceBalance,
// das gruppenübergreifend über den ganzen Schüler rechnet) — hier zählt nur, was für diese
// Zeugnisnote relevant ist.
var sessionsInPeriod = _participationSessions.GetByGroup(_groupId)
.Where(s => s.Date >= periodFrom && s.Date <= periodTo).ToList();
var attendanceByStudent = new Dictionary<Guid, List<(DateOnly Date, AttendanceStatus? Status)>>();
foreach (var session in sessionsInPeriod)
foreach (var entry in _participation.GetBySession(session.Id))
{
if (!attendanceByStudent.TryGetValue(entry.StudentId, out var list))
attendanceByStudent[entry.StudentId] = list = [];
list.Add((session.Date, entry.Attendance));
}
Rows.Clear();
foreach (var student in students.OrderBy(s => s.LastName).ThenBy(s => s.FirstName))
{
membershipsByStudent.TryGetValue(student.Id, out var membership);
if (membership is not null && !GroupMembershipService.Overlaps(membership, periodFrom, periodTo)) continue;
var absenceRate = _attendanceBalance.Calculate(
attendanceByStudent.TryGetValue(student.Id, out var entries) ? entries : [],
periodFrom, periodTo).AbsenceRatePercent;
var existing = _reportGrades.GetByStudentGroupPeriod(student.Id, _groupId, periodTag);
if (existing is { IsLocked: true })
{
Rows.Add(ReportGradeRow.FromLocked(student.Id, student.FullName, existing, Save, ToggleLock));
Rows.Add(ReportGradeRow.FromLocked(student.Id, student.FullName, existing, absenceRate, Save, ToggleLock));
continue;
}
@@ -128,7 +154,7 @@ public partial class ReportGradeDialogViewModel : ObservableObject
var calculated = _grading.CalculateReportGrade(examGrades, participationGrades, otherGrades,
scheme, _gradingSystem, RoundingRule);
Rows.Add(ReportGradeRow.FromCalculated(student.Id, student.FullName, calculated, existing, Save, ToggleLock));
Rows.Add(ReportGradeRow.FromCalculated(student.Id, student.FullName, calculated, existing, absenceRate, Save, ToggleLock));
}
}
@@ -202,10 +228,16 @@ public static class RoundingRuleDisplay
public partial class ReportGradeRow : ObservableObject
{
/// Nutzer-Vorgabe: ab dieser Fehlquote darf unabhängig von der fachlichen Leistung eine 5
/// vergeben werden — reine Information/Hervorhebung, kein automatisches Übersteuern der
/// berechneten Note.
public const double HighAbsenceThresholdPercent = 50.0;
public Guid StudentId { get; }
public string Name { get; }
public string? CalculatedValue { get; private set; }
public bool IsLocked { get; private set; }
public double AbsenceRatePercent { get; }
[ObservableProperty] private string? _overrideValue;
[ObservableProperty] private string? _overrideReason;
@@ -214,17 +246,22 @@ public partial class ReportGradeRow : ObservableObject
public string CalculatedDisplay => CalculatedValue ?? "";
public string FinalDisplay => !string.IsNullOrWhiteSpace(OverrideValue) ? OverrideValue! : CalculatedDisplay;
public string LockLabel => IsLocked ? "Entsperren" : "Festschreiben";
public string AbsenceRateDisplay => $"{AbsenceRatePercent:0.#} % gefehlt";
public bool HasHighAbsenceRate => AbsenceRatePercent >= HighAbsenceThresholdPercent;
// Rot wie AttendanceDisplay.Color(Truant) — dieselbe Warnfarbe wie im Mitarbeit-Feature.
public string AbsenceRateColorHex => HasHighAbsenceRate ? "#D64545" : "#8A8A8A";
public IRelayCommand SaveCommand { get; }
public IRelayCommand ToggleLockCommand { get; }
private ReportGradeRow(Guid studentId, string name, string? calculated, ReportGrade? existing,
bool locked, Action<ReportGradeRow> onSave, Action<ReportGradeRow> onToggleLock)
double absenceRatePercent, bool locked, Action<ReportGradeRow> onSave, Action<ReportGradeRow> onToggleLock)
{
StudentId = studentId;
Name = name;
CalculatedValue = calculated;
IsLocked = locked;
AbsenceRatePercent = absenceRatePercent;
_overrideValue = existing?.OverrideValue;
_overrideReason = existing?.OverrideReason;
SaveCommand = new RelayCommand(() => onSave(this));
@@ -232,12 +269,13 @@ public partial class ReportGradeRow : ObservableObject
}
public static ReportGradeRow FromCalculated(Guid studentId, string name, string? calculated,
ReportGrade? existing, Action<ReportGradeRow> onSave, Action<ReportGradeRow> onToggleLock) =>
new(studentId, name, calculated, existing, existing?.IsLocked ?? false, onSave, onToggleLock);
ReportGrade? existing, double absenceRatePercent,
Action<ReportGradeRow> onSave, Action<ReportGradeRow> onToggleLock) =>
new(studentId, name, calculated, existing, absenceRatePercent, existing?.IsLocked ?? false, onSave, onToggleLock);
public static ReportGradeRow FromLocked(Guid studentId, string name, ReportGrade locked,
Action<ReportGradeRow> onSave, Action<ReportGradeRow> onToggleLock) =>
new(studentId, name, locked.CalculatedValue, locked, true, onSave, onToggleLock);
double absenceRatePercent, Action<ReportGradeRow> onSave, Action<ReportGradeRow> onToggleLock) =>
new(studentId, name, locked.CalculatedValue, locked, absenceRatePercent, true, onSave, onToggleLock);
public void MarkSaved()
{
@@ -4,9 +4,14 @@ using LehrerApp.Core.Interfaces;
using LehrerApp.Core.Models;
using System.Collections.ObjectModel;
using System.Globalization;
using System.Linq;
namespace LehrerApp.Desktop.ViewModels.Students;
/// Für die Schüler-Auswahl im Dokumentationsdialog, wenn er ohne festen Schüler geöffnet wird
/// (Gruppen-Tab, siehe GroupDocumentationTabViewModel).
public record StudentOption(Guid Id, string Name);
// ── Dokumentation: deutsche Anzeige für Typ/Status (5.1) ─────────────────────
public static class DocumentationTypeDisplay
@@ -86,8 +91,17 @@ public partial class DocumentationDialogViewModel : ObservableObject
private readonly IAttachmentStorage _attachmentStorage;
private readonly Documentation? _editing;
private readonly Guid _studentId;
private readonly Guid? _contextGroupId;
private readonly List<string> _newlyUploadedStorageIds = [];
// Nur gesetzt, wenn der Dialog aus einem Gruppen-Kontext (5.1, Dokumentation-Tab der
// Lerngruppe) ohne festen Schüler geöffnet wird — vom Schüler-Tab aus (fester _studentId)
// bleibt die Liste leer und die Auswahl unsichtbar.
public List<StudentOption> StudentOptions { get; }
public bool CanPickStudent => StudentOptions.Count > 0;
[ObservableProperty] private StudentOption? _selectedStudent;
[ObservableProperty] private string _studentError = "";
[ObservableProperty] private string _typeName = DocumentationTypeDisplay.Options[0];
[ObservableProperty] private string _dateText = DateOnly.FromDateTime(DateTime.Today).ToString("dd.MM.yyyy");
[ObservableProperty] private string _title = "";
@@ -145,11 +159,16 @@ public partial class DocumentationDialogViewModel : ObservableObject
public Documentation? Result { get; private set; }
public DocumentationDialogViewModel(Guid studentId, Documentation? editing, IAttachmentStorage attachmentStorage)
public DocumentationDialogViewModel(Guid studentId, Documentation? editing, IAttachmentStorage attachmentStorage,
List<StudentOption>? studentOptions = null, Guid? contextGroupId = null)
{
_studentId = studentId;
_editing = editing;
_attachmentStorage = attachmentStorage;
_contextGroupId = contextGroupId;
StudentOptions = studentOptions ?? [];
if (CanPickStudent)
SelectedStudent = StudentOptions.FirstOrDefault(s => s.Id == studentId);
if (editing is null) return;
TypeName = DocumentationTypeDisplay.Label(editing.Type);
@@ -276,10 +295,12 @@ public partial class DocumentationDialogViewModel : ObservableObject
[RelayCommand]
private void Save()
{
TitleError = ""; DateTextError = ""; ReviewDateTextError = "";
TitleError = ""; DateTextError = ""; ReviewDateTextError = ""; StudentError = "";
LetterSentDateError = ""; LetterResponseDateError = "";
var valid = true;
if (CanPickStudent && SelectedStudent is null) { StudentError = "Schüler auswählen."; valid = false; }
if (string.IsNullOrWhiteSpace(Title)) { TitleError = "Titel erforderlich."; valid = false; }
if (!DateOnly.TryParseExact(DateText, "dd.MM.yyyy", null, DateTimeStyles.None, out var date))
@@ -312,7 +333,8 @@ public partial class DocumentationDialogViewModel : ObservableObject
if (!valid) return;
var type = DocumentationTypeDisplay.FromLabel(TypeName);
Result = _editing ?? new Documentation { StudentId = _studentId };
var effectiveStudentId = CanPickStudent ? SelectedStudent!.Id : _studentId;
Result = _editing ?? new Documentation { StudentId = effectiveStudentId, GroupId = _contextGroupId };
Result.Type = type;
Result.Date = date;
Result.Title = Title.Trim();
@@ -401,10 +423,21 @@ public partial class DocumentationItem : ObservableObject
public bool HasAttachments { get; }
public string StatusLabel { get; }
public List<TagChip> TagChips { get; }
/// Nur im Gruppen-Tab (5.1, GroupDocumentationTabViewModel) gefüllt — die Schüler-Detailansicht
/// zeigt ohnehin nur Einträge eines einzelnen Schülers und braucht den Namen nicht.
public string StudentName { get; }
/// True, wenn der Eintrag keiner Gruppe zugeordnet ist oder der aktuell angezeigten Gruppe
/// entspricht — false für Einträge aus einem anderen Unterricht desselben Schülers (werden im
/// Gruppen-Tab optisch abgesetzt statt komplett ausgeblendet, siehe Nutzer-Feedback).
public bool IsOwnGroup { get; }
/// Name der Gruppe, aus der ein nicht-eigener Eintrag stammt (nur gesetzt, wenn !IsOwnGroup).
public string OtherGroupLabel { get; }
/// Dimmt Einträge aus anderen Lerngruppen im Gruppen-Tab, ohne sie zu verstecken.
public double ContentOpacity => IsOwnGroup ? 1.0 : 0.55;
[ObservableProperty] private bool _isRevealed;
public DocumentationItem(Documentation d)
public DocumentationItem(Documentation d, string studentName = "", bool isOwnGroup = true, string otherGroupLabel = "")
{
Model = d;
DateDisplay = d.Date.ToString("dd.MM.yyyy");
@@ -415,6 +448,9 @@ public partial class DocumentationItem : ObservableObject
HasAttachments = d.Attachments.Count > 0;
StatusLabel = BuildStatusLabel(d);
TagChips = d.Tags.Select(t => new TagChip(t)).ToList();
StudentName = studentName;
IsOwnGroup = isOwnGroup;
OtherGroupLabel = otherGroupLabel;
}
private static string BuildStatusLabel(Documentation d) => d.Type switch
@@ -66,6 +66,9 @@ public partial class GradeOverviewTabView : UserControl
App.Services.GetRequiredService<IGroupMembershipRepository>(),
App.Services.GetRequiredService<IGradingSchemeRepository>(),
App.Services.GetRequiredService<IReportGradeRepository>(),
App.Services.GetRequiredService<IParticipationSessionRepository>(),
App.Services.GetRequiredService<IParticipationRepository>(),
App.Services.GetRequiredService<AttendanceBalanceService>(),
App.Services.GetRequiredService<GradingService>(),
_vm!.GroupId, _vm.GroupType, _vm.GradingSystem, _vm.GroupLabel, _vm.SchoolYear);
@@ -205,12 +205,7 @@
<!-- Tab: Dokumentation -->
<ContentPage Header="Dokumentation">
<StackPanel HorizontalAlignment="Center" VerticalAlignment="Center">
<TextBlock Text="Schülerdokumentation" FontSize="16" Opacity="0.4"
HorizontalAlignment="Center"/>
<TextBlock Text="Wird implementiert." FontSize="12" Opacity="0.3"
HorizontalAlignment="Center"/>
</StackPanel>
<views:GroupDocumentationTabView DataContext="{Binding GroupDocumentationTab}"/>
</ContentPage>
</TabbedPage>
@@ -0,0 +1,94 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="clr-namespace:LehrerApp.Desktop.ViewModels.Groups"
xmlns:svm="clr-namespace:LehrerApp.Desktop.ViewModels.Students"
x:Class="LehrerApp.Desktop.Views.Groups.GroupDocumentationTabView"
x:DataType="vm:GroupDocumentationTabViewModel">
<Grid RowDefinitions="Auto,*" Margin="16">
<Grid Grid.Row="0" ColumnDefinitions="Auto,Auto,*,Auto" Margin="0,0,0,12">
<StackPanel Grid.Column="0" Orientation="Horizontal" Spacing="6" Margin="0,0,12,0">
<TextBlock Text="Schüler:" VerticalAlignment="Center"/>
<ComboBox ItemsSource="{Binding StudentFilterOptions}" SelectedItem="{Binding SelectedStudentFilter}"
DisplayMemberBinding="{Binding Name}" MinWidth="170"/>
</StackPanel>
<CheckBox Grid.Column="1" Content="Nur dieser Unterricht" IsChecked="{Binding OnlyThisGroup}"
VerticalAlignment="Center"
ToolTip.Tip="Standardmäßig werden auch Einträge aus anderen Lerngruppen desselben Schülers angezeigt (optisch abgesetzt) — damit Muster über mehrere Fächer/Kurse hinweg sichtbar bleiben."/>
<Button Grid.Column="3" Content=" Eintrag" Command="{Binding AddDocumentationCommand}"/>
</Grid>
<ScrollViewer Grid.Row="1">
<StackPanel>
<ItemsControl ItemsSource="{Binding Entries}">
<ItemsControl.ItemTemplate>
<DataTemplate DataType="svm:DocumentationItem">
<Border Background="{DynamicResource SystemControlBackgroundAltHighBrush}"
CornerRadius="6" Padding="12,10" Margin="0,0,0,8"
Opacity="{Binding ContentOpacity}">
<StackPanel Spacing="4">
<Grid ColumnDefinitions="80,*,Auto,Auto,Auto,Auto">
<TextBlock Grid.Column="0" Text="{Binding DateDisplay}" Opacity="0.5" FontSize="12"/>
<StackPanel Grid.Column="1" Margin="8,0">
<StackPanel Orientation="Horizontal" Spacing="6">
<TextBlock Text="{Binding StudentName}" FontWeight="SemiBold" FontSize="13"/>
<TextBlock Text="{Binding Model.Title}" FontSize="13" Opacity="0.8"
IsVisible="{Binding IsRevealed}"/>
<TextBlock Text="Vertraulich" FontSize="13" Opacity="0.6"
IsVisible="{Binding !IsRevealed}"/>
</StackPanel>
<StackPanel Orientation="Horizontal" Spacing="6">
<TextBlock Text="{Binding TypeLabel}" FontSize="11" Opacity="0.5"/>
<TextBlock Text="{Binding OtherGroupLabel, StringFormat='aus: {0}'}" FontSize="11"
Opacity="0.5" FontStyle="Italic" IsVisible="{Binding !IsOwnGroup}"/>
</StackPanel>
</StackPanel>
<TextBlock Grid.Column="2" Text="🔒" FontSize="14" VerticalAlignment="Center"
IsVisible="{Binding IsConfidential}" ToolTip.Tip="Vertraulich"/>
<Button Grid.Column="3" Content="Anzeigen" FontSize="11" Padding="8,3" Margin="6,0,0,0"
Command="{Binding RevealCommand}" IsVisible="{Binding !IsRevealed}"/>
<StackPanel Grid.Column="4" Orientation="Horizontal" Spacing="4" Margin="6,0,0,0"
IsVisible="{Binding IsRevealed}">
<Button Content="Gespräch begleiten" FontSize="11" Padding="8,3"
IsVisible="{Binding IsParentCall}"
Command="{Binding $parent[ItemsControl].((vm:GroupDocumentationTabViewModel)DataContext).ConductParentCallCommand}"
CommandParameter="{Binding}"/>
<Button Content="Bearbeiten" FontSize="11" Padding="8,3"
Command="{Binding $parent[ItemsControl].((vm:GroupDocumentationTabViewModel)DataContext).EditDocumentationCommand}"
CommandParameter="{Binding}"/>
<Button Content="Löschen" FontSize="11" Padding="8,3"
Command="{Binding $parent[ItemsControl].((vm:GroupDocumentationTabViewModel)DataContext).DeleteDocumentationCommand}"
CommandParameter="{Binding}"/>
</StackPanel>
</Grid>
<TextBlock Text="{Binding Model.Content}" FontSize="12" TextWrapping="Wrap" Opacity="0.8"
IsVisible="{Binding IsRevealed}"/>
<StackPanel Orientation="Horizontal" Spacing="8" IsVisible="{Binding IsRevealed}">
<TextBlock Text="{Binding StatusLabel}" FontSize="11" Opacity="0.55"
IsVisible="{Binding StatusLabel, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
<TextBlock Text="📎 Anhang" FontSize="11" Opacity="0.55" IsVisible="{Binding HasAttachments}"/>
</StackPanel>
<ItemsControl ItemsSource="{Binding TagChips}" IsVisible="{Binding IsRevealed}">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate><WrapPanel ItemSpacing="6" LineSpacing="4"/></ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
<ItemsControl.ItemTemplate>
<DataTemplate DataType="svm:TagChip">
<Border Background="{Binding ColorHex}" CornerRadius="10" Padding="8,2">
<TextBlock Text="{Binding Text}" FontSize="10" Foreground="White"/>
</Border>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</StackPanel>
</Border>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<TextBlock Text="Keine Dokumentation vorhanden." Classes="emptyhint"
IsVisible="{Binding !Entries.Count}"/>
</StackPanel>
</ScrollViewer>
</Grid>
</UserControl>
@@ -0,0 +1,62 @@
using Avalonia.Controls;
using LehrerApp.Core.Interfaces;
using LehrerApp.Core.Models;
using LehrerApp.Desktop.ViewModels.Groups;
using LehrerApp.Desktop.ViewModels.Students;
using LehrerApp.Desktop.Views.Shared;
using LehrerApp.Desktop.Views.Students;
using Microsoft.Extensions.DependencyInjection;
namespace LehrerApp.Desktop.Views.Groups;
public partial class GroupDocumentationTabView : UserControl
{
public GroupDocumentationTabView() => InitializeComponent();
protected override void OnDataContextChanged(EventArgs e)
{
base.OnDataContextChanged(e);
if (DataContext is not GroupDocumentationTabViewModel vm) return;
vm.OnEditDocumentation = ShowDocumentationDialog;
vm.OnConfirmDeleteDocumentation = ShowDeleteDocumentationDialog;
vm.OnConductParentCall = ShowParentCallSessionDialog;
}
private async Task<Documentation?> ShowDocumentationDialog(
Guid groupId, List<StudentOption> studentOptions, Documentation? editing)
{
var owner = TopLevel.GetTopLevel(this) as Window;
if (owner is null) return null;
var vm = new DocumentationDialogViewModel(editing?.StudentId ?? Guid.Empty, editing,
App.Services.GetRequiredService<IAttachmentStorage>(), studentOptions, groupId);
var dialog = new DocumentationDialog { DataContext = vm };
var saved = await dialog.ShowDialog<bool>(owner);
if (!saved) vm.DiscardUnsavedAttachments();
return saved ? vm.Result : null;
}
private async Task<Documentation?> ShowParentCallSessionDialog(Documentation documentation, string studentTitle)
{
var owner = TopLevel.GetTopLevel(this) as Window;
if (owner is null) return null;
var vm = new ParentCallSessionViewModel(documentation, studentTitle);
var dialog = new ParentCallSessionDialog { DataContext = vm };
var saved = await dialog.ShowDialog<bool>(owner);
return saved ? vm.Result : null;
}
private async Task<bool> ShowDeleteDocumentationDialog(DocumentationItem item)
{
var info = new ConfirmDialogInfo
{
Title = "Eintrag löschen?",
Message = $"\"{item.Model.Title}\" ({item.StudentName}) wird als gelöscht markiert und nicht mehr angezeigt.",
ConfirmText = "Löschen",
};
var dialog = new ConfirmDialog { DataContext = info };
var owner = TopLevel.GetTopLevel(this) as Window;
return owner is not null && await dialog.ShowDialog<bool>(owner);
}
}
@@ -12,15 +12,21 @@
<!-- Schülername + Fortschritt -->
<Grid Grid.Row="0" ColumnDefinitions="*,Auto" Margin="0,0,0,16">
<StackPanel Grid.Column="0">
<TextBlock Text="{Binding StudentName}" FontSize="22" FontWeight="SemiBold"/>
<TextBlock Text="{Binding CurrentAspectLabel}" FontSize="13" Opacity="0.5"/>
<TextBlock Text="{Binding StudentName}" FontSize="22" FontWeight="SemiBold"
Opacity="{Binding CurrentStudentContentOpacity}"/>
<TextBlock Text="{Binding CurrentAspectLabel}" FontSize="13" Opacity="0.5"
IsVisible="{Binding !CurrentStudentIsAbsent}"/>
<TextBlock Text="{Binding CurrentStudentAttendanceLabel, StringFormat='⚠ Abwesend — {0}'}"
FontSize="13" Foreground="#D96C00" FontWeight="SemiBold"
IsVisible="{Binding CurrentStudentIsAbsent}"/>
</StackPanel>
<TextBlock Grid.Column="1" Text="{Binding ProgressText}"
VerticalAlignment="Top" FontSize="13" Opacity="0.4"/>
</Grid>
<!-- Aspektliste -->
<ItemsControl Grid.Row="1" ItemsSource="{Binding AspectRows}">
<ItemsControl Grid.Row="1" ItemsSource="{Binding AspectRows}"
Opacity="{Binding CurrentStudentContentOpacity}">
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:QuickAspectRow">
<Grid ColumnDefinitions="4,8,*,44" Margin="0,3">
@@ -33,7 +33,12 @@
BorderThickness="0,0,0,1" Padding="0,8">
<StackPanel Spacing="6">
<Grid ColumnDefinitions="180,90,*,120,Auto,Auto">
<TextBlock Grid.Column="0" Text="{Binding Name}" VerticalAlignment="Center" FontSize="13"/>
<StackPanel Grid.Column="0" VerticalAlignment="Center">
<TextBlock Text="{Binding Name}" FontSize="13"/>
<TextBlock Text="{Binding AbsenceRateDisplay}" FontSize="11"
Foreground="{Binding AbsenceRateColorHex}"
ToolTip.Tip="Fehlquote im gewählten Zeitraum — ab 50 % darf laut Schulrecht unabhängig von der fachlichen Leistung eine 5 vergeben werden."/>
</StackPanel>
<TextBlock Grid.Column="1" Text="{Binding CalculatedDisplay}" VerticalAlignment="Center"
FontSize="13" Opacity="0.7" ToolTip.Tip="Berechnet"/>
<TextBox Grid.Column="2" Text="{Binding OverrideValue}" PlaceholderText="Übersteuern (optional)"
@@ -12,6 +12,15 @@
<StackPanel Spacing="12">
<TextBlock Text="{Binding DialogTitle}" Classes="dialogtitle"/>
<StackPanel Spacing="4" IsVisible="{Binding CanPickStudent}">
<TextBlock Text="Schüler *" FontSize="12" Opacity="0.7"/>
<ComboBox ItemsSource="{Binding StudentOptions}" SelectedItem="{Binding SelectedStudent}"
DisplayMemberBinding="{Binding Name}" HorizontalAlignment="Stretch"
PlaceholderText="Schüler wählen"/>
<TextBlock Text="{Binding StudentError}" Foreground="Red" FontSize="11"
IsVisible="{Binding StudentError, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
</StackPanel>
<Grid ColumnDefinitions="*,12,140">
<StackPanel Grid.Column="0" Spacing="4">
<TextBlock Text="Titel *" FontSize="12" Opacity="0.7"/>