559 lines
29 KiB
C#
559 lines
29 KiB
C#
using System.Collections.ObjectModel;
|
||
using CommunityToolkit.Mvvm.ComponentModel;
|
||
using CommunityToolkit.Mvvm.Input;
|
||
using LehrerApp.Core.Interfaces;
|
||
using LehrerApp.Core.Models;
|
||
using LehrerApp.Desktop.Services;
|
||
using LehrerApp.Desktop.ViewModels.Students;
|
||
|
||
namespace LehrerApp.Desktop.ViewModels.ClassTeacher;
|
||
|
||
/// <summary>Eine Zeile aus dem "-alle-"-Klassenbuchbericht, die eine andere Lehrkraft angelegt hat.
|
||
/// Rein zur Ansicht - anders als beim Dokumentations-Abgleich (eigene Einträge) gibt es hier kein
|
||
/// "Übernehmen", da es keine eigenen Einträge zum lokalen Synchronisieren sind.</summary>
|
||
public sealed record ClassTeacherClassRegisterRow(DateOnly Date, string? Subject, string StudentName,
|
||
string? TeacherUsername, string? CategoryName, string? CategoryGroup, string? Text)
|
||
{
|
||
public string DateLabel => Date.ToString("dd.MM.yyyy");
|
||
public string StudentDisplayName => DisplayName(StudentName);
|
||
public ClassTeacherStatusKind CategoryKind =>
|
||
CategoryGroup?.Contains("Negativ", StringComparison.OrdinalIgnoreCase) == true
|
||
? ClassTeacherStatusKind.Danger : ClassTeacherStatusKind.Info;
|
||
public bool IsDangerStatus => CategoryKind == ClassTeacherStatusKind.Danger;
|
||
public bool IsInfoStatus => CategoryKind == ClassTeacherStatusKind.Info;
|
||
|
||
private static string DisplayName(string value)
|
||
{
|
||
var parts = value.Split(' ', StringSplitOptions.RemoveEmptyEntries);
|
||
return parts.Length < 2 ? value : string.Join(" ", parts.Skip(1).Append(parts[0]));
|
||
}
|
||
}
|
||
|
||
/// <summary>Zusammenfassung "wie oft welche Klassenbuch-Kategorie im Zeitraum, und bei wem am
|
||
/// häufigsten" — Nutzer-Feedback: die reine Gesamtzahl ("12 Klassenbucheinträge") sagt wenig,
|
||
/// eine Aufschlüsselung nach Kategorie ist die eigentlich interessante Information ("Hausaufgaben
|
||
/// fehlen: 12×, davon 5× Ben"). Rein clientseitig aus den bereits geladenen <see cref="Entries"/>
|
||
/// berechnet, kein zusätzlicher WebUntis-Abruf.</summary>
|
||
public sealed record ClassTeacherCategoryAggregateRow(string CategoryName, int Count, string TopStudentsLabel)
|
||
{
|
||
public string SummaryLabel => string.IsNullOrEmpty(TopStudentsLabel)
|
||
? $"{Count}×" : $"{Count}× — {TopStudentsLabel}";
|
||
|
||
public static IReadOnlyList<ClassTeacherCategoryAggregateRow> Build(
|
||
IReadOnlyList<ClassTeacherClassRegisterRow> entries, int topStudentsPerCategory = 2) =>
|
||
entries.Where(e => !string.IsNullOrWhiteSpace(e.CategoryName))
|
||
.GroupBy(e => e.CategoryName!)
|
||
.Select(g =>
|
||
{
|
||
var topStudents = g.GroupBy(e => e.StudentDisplayName)
|
||
.Select(sg => (Name: sg.Key, Count: sg.Count()))
|
||
.OrderByDescending(sg => sg.Count).ThenBy(sg => sg.Name)
|
||
.Take(topStudentsPerCategory)
|
||
.Select(sg => $"{sg.Count}× {sg.Name}");
|
||
return new ClassTeacherCategoryAggregateRow(g.Key, g.Count(), string.Join(", ", topStudents));
|
||
})
|
||
.OrderByDescending(r => r.Count).ThenBy(r => r.CategoryName)
|
||
.ToList();
|
||
}
|
||
|
||
/// <summary>Fehlzeiten eines/einer Schüler*in an einem Tag, über alle Fächer zusammengefasst -
|
||
/// der WebUntis-Bericht liefert eine Zeile pro Fehlstunde, was bei einer ganzen Klasse über mehrere
|
||
/// Wochen zu unübersichtlich wäre (Nutzer-Feedback: kompakte "auf einen Blick"-Übersicht statt
|
||
/// Rohdaten). Die Rohzeilen (<see cref="UntisClassAbsenceEntryDto"/>) bleiben trotzdem erreichbar,
|
||
/// falls später ein Detail-Drill-down pro Schüler*in dazukommt (siehe TODO.md).</summary>
|
||
public sealed record ClassAbsenceDaySummaryRow(DateOnly Date, string StudentName, int? ExternKey,
|
||
int TotalAbsentPeriods, int TotalAbsentMinutes, IReadOnlyList<string> Subjects,
|
||
IReadOnlyList<int> PeriodNumbers, IReadOnlyList<string> Statuses, IReadOnlyList<string> AbsenceReasons,
|
||
string? Note, string? ExcuseNote, bool CountsAsFullDay)
|
||
{
|
||
public string DateLabel => Date.ToString("dd.MM.yyyy");
|
||
public string StudentDisplayName
|
||
{
|
||
get
|
||
{
|
||
var parts = StudentName.Split(' ', StringSplitOptions.RemoveEmptyEntries);
|
||
return parts.Length < 2 ? StudentName : string.Join(" ", parts.Skip(1).Append(parts[0]));
|
||
}
|
||
}
|
||
public string SubjectsLabel => string.Join(", ", Subjects);
|
||
public string PeriodsLabel => string.Join(", ", PeriodNumbers.Order());
|
||
public string StatusLabel => string.Join(", ", Statuses);
|
||
public string ReasonLabel => string.Join("; ", AbsenceReasons);
|
||
public bool IsLate => TotalAbsentPeriods == 0 || AbsenceReasons.Any(r =>
|
||
r.Contains("verspät", StringComparison.OrdinalIgnoreCase));
|
||
public bool IsUnexcused => Statuses.Any(s =>
|
||
s.Contains("nicht entsch", StringComparison.OrdinalIgnoreCase));
|
||
public string KindLabel => IsLate ? $"{TotalAbsentMinutes} Min. verspätet" :
|
||
CountsAsFullDay ? "Ganzer Fehltag" : $"{TotalAbsentPeriods} Fehlstunde{(TotalAbsentPeriods == 1 ? "" : "n")}";
|
||
public string FriendlyStatusLabel => IsUnexcused ? "Unentschuldigt" :
|
||
Statuses.Any(s => s.Contains("entsch", StringComparison.OrdinalIgnoreCase)) ? "Entschuldigt" : StatusLabel;
|
||
public ClassTeacherStatusKind StatusKind => IsUnexcused ? ClassTeacherStatusKind.Danger :
|
||
IsLate ? ClassTeacherStatusKind.Warning : ClassTeacherStatusKind.Ok;
|
||
public bool IsDangerStatus => StatusKind == ClassTeacherStatusKind.Danger;
|
||
public bool IsWarningStatus => StatusKind == ClassTeacherStatusKind.Warning;
|
||
public string DetailLabel => string.Join(" · ", new[] { ReasonLabel, Note, ExcuseNote }
|
||
.Where(value => !string.IsNullOrWhiteSpace(value)));
|
||
|
||
public static IReadOnlyList<ClassAbsenceDaySummaryRow> GroupByStudentAndDay(
|
||
IEnumerable<UntisClassAbsenceEntryDto> entries) => entries
|
||
.Select(e => (Entry: e, Date: TryDate(e.Date, out var d) ? d : (DateOnly?)null))
|
||
.Where(x => x.Date is not null)
|
||
.GroupBy(x => (Date: x.Date!.Value, x.Entry.StudentName, x.Entry.ExternKey))
|
||
.Select(g => new ClassAbsenceDaySummaryRow(
|
||
g.Key.Date, g.Key.StudentName, g.Key.ExternKey,
|
||
g.Sum(x => x.Entry.AbsentPeriods), g.Sum(x => x.Entry.AbsentMinutes),
|
||
Distinct(g.Select(x => x.Entry.Subject)),
|
||
g.Select(x => x.Entry.PeriodNumber).Where(p => p is not null).Select(p => p!.Value).Distinct().ToList(),
|
||
Distinct(g.Select(x => x.Entry.Status)),
|
||
Distinct(g.Select(x => x.Entry.AbsenceReason)),
|
||
g.Select(x => x.Entry.Note).FirstOrDefault(n => !string.IsNullOrWhiteSpace(n)),
|
||
g.Select(x => x.Entry.ExcuseNote).FirstOrDefault(n => !string.IsNullOrWhiteSpace(n)),
|
||
g.Any(x => x.Entry.CountsAsFullDay)))
|
||
.OrderByDescending(r => r.Date).ThenBy(r => r.StudentName)
|
||
.ToList();
|
||
|
||
private static List<string> Distinct(IEnumerable<string?> values) =>
|
||
values.Where(v => !string.IsNullOrWhiteSpace(v)).Select(v => v!).Distinct().ToList();
|
||
|
||
private static bool TryDate(int value, out DateOnly date) =>
|
||
DateOnly.TryParseExact(value.ToString(), "yyyyMMdd", out date);
|
||
}
|
||
|
||
public enum ClassTeacherCalendarEventKind { Late, Absent, Unexcused, Excused, ClassRegister, Homework }
|
||
|
||
/// <summary>Kompakter, farblich codierter Marker in der Klassenlehrer-Monatsansicht.</summary>
|
||
public sealed record ClassTeacherCalendarEvent(string StudentName, string Code, string Label,
|
||
string ColorHex, ClassTeacherCalendarEventKind Kind)
|
||
{
|
||
public string Tooltip => $"{StudentName}: {Label}";
|
||
}
|
||
|
||
/// <summary>Ein Werktag im 5-Spalten-Monatsraster; Tage außerhalb des Monats sind Platzhalter.</summary>
|
||
public sealed record ClassTeacherCalendarDay(DateOnly? Date, IReadOnlyList<ClassTeacherCalendarEvent> Events)
|
||
{
|
||
public bool IsPlaceholder => Date is null;
|
||
public string DayLabel => Date?.ToString("dd.") ?? "";
|
||
public string WeekdayLabel => Date?.ToString("ddd", System.Globalization.CultureInfo.GetCultureInfo("de-DE")) ?? "";
|
||
public bool HasEvents => Events.Count > 0;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Details-Ansicht des Klassenlehrer-Bereichs: Klassenbucheinträge, die andere Lehrkräfte zu
|
||
/// Schülern der Klasse angelegt haben (WebUntis "-alle-"-Bericht, gefiltert auf fremde statt der
|
||
/// eigenen Benutzerkennung wie beim bestehenden Dokumentations-Abgleich), sowie die Fehlzeiten der
|
||
/// Klasse über alle Fächer — beide über <see cref="UntisReportCacheService"/> (lokal gecacht statt
|
||
/// bei jedem Öffnen neu abgerufen, siehe TODO.md). Beides rein zur Ansicht, kein Übernehmen in
|
||
/// lokale Daten. Bewusst unabhängig von jeder <see cref="Core.Models.LearningGroup"/> —
|
||
/// Klassenlehrer ist man für die ganze Klasse, nicht für einen einzelnen Unterricht; die Klasse
|
||
/// kommt aus <see cref="ClassTeacherOverviewViewModel"/> (letztlich aus den WebUntis-Einstellungen),
|
||
/// nicht aus einer Gruppen-ID.
|
||
/// </summary>
|
||
public partial class ClassTeacherDetailsViewModel : ObservableObject
|
||
{
|
||
private readonly UntisReportCacheService _cache;
|
||
private readonly IDocumentationRepository _documentation;
|
||
private readonly IStudentRepository _students;
|
||
private readonly ClassTeacherCasesViewModel _cases;
|
||
private string _className = "";
|
||
private DateOnly _loadedStart;
|
||
private DateOnly _loadedEnd;
|
||
/// Schüler*innen der Klasse, per Namensabgleich (<see cref="UntisNameMatching"/>) den lokalen
|
||
/// <see cref="Student"/>-Datensätzen zugeordnet — Grundlage für die "Eigene Dokumentation"-Ansicht,
|
||
/// die es (anders als der WebUntis-Klassenbuchbericht) nur lokal gibt. Nach jedem <see cref="LoadInternal"/>
|
||
/// neu aufgebaut.
|
||
private List<(Guid StudentId, string DisplayName)> _rosterMatches = [];
|
||
|
||
public ObservableCollection<ClassTeacherClassRegisterRow> Entries { get; } = [];
|
||
public ObservableCollection<ClassAbsenceDaySummaryRow> AbsenceEntries { get; } = [];
|
||
public ObservableCollection<ClassTeacherCategoryAggregateRow> CategoryAggregates { get; } = [];
|
||
public ObservableCollection<DocumentationItem> OwnDocumentationEntries { get; } = [];
|
||
public ObservableCollection<ClassTeacherCalendarDay> CalendarDays { get; } = [];
|
||
|
||
/// Für das Kontextmenü "→ An Vorgang anheften" (DataGrid.SelectedItem, zweigleisig gebunden).
|
||
[ObservableProperty] private ClassTeacherClassRegisterRow? _selectedEntry;
|
||
|
||
[ObservableProperty] private DateTimeOffset? _startDate = DateTimeOffset.Now.AddDays(-6);
|
||
[ObservableProperty] private DateTimeOffset? _endDate = DateTimeOffset.Now;
|
||
[ObservableProperty] private string _status = "Zeitraum wählen und laden.";
|
||
[ObservableProperty] private bool _busy;
|
||
/// Von der Übersicht gesetzt (Klick auf eine Roster-Zeile) - leer zeigt alle Schüler*innen.
|
||
[ObservableProperty] private string _studentFilter = "";
|
||
[ObservableProperty] private int _quickRangeIndex = 1;
|
||
[ObservableProperty] private bool _showMonthlyCalendar;
|
||
[ObservableProperty] private bool _includeHomeworkInCalendar;
|
||
[ObservableProperty] private DateOnly _calendarMonth = new(DateTime.Today.Year, DateTime.Today.Month, 1);
|
||
/// Umschalter Klassenbuch (WebUntis, andere Lehrkräfte) ↔ eigene Dokumentation.
|
||
[ObservableProperty] private bool _showOwnDocumentation;
|
||
[ObservableProperty] private int _ownDocumentationCount;
|
||
[ObservableProperty] private int _ownDocumentationFollowUpCount;
|
||
[ObservableProperty] private int _ownDocumentationCriticalCount;
|
||
|
||
public Func<List<StudentOption>, Documentation?, Task<Documentation?>>? OnEditOwnDocumentation { get; set; }
|
||
public Func<DocumentationItem, Task<bool>>? OnConfirmDeleteOwnDocumentation { get; set; }
|
||
/// Zeigt die "→ Vorgang"-Auswahl für eine Klassenbuchzeile: bestehenden offenen Vorgang wählen
|
||
/// oder einen neuen Titel eingeben. Liefert null bei Abbruch.
|
||
public Func<ClassTeacherClassRegisterRow, Task<PinToVorgangChoice?>>? OnPickVorgangForPin { get; set; }
|
||
|
||
public bool HasEntries => Entries.Count > 0;
|
||
public bool HasAbsenceEntries => AbsenceEntries.Count > 0;
|
||
public bool ShowAbsenceListEmpty => !ShowMonthlyCalendar && !HasAbsenceEntries;
|
||
public bool HasCategoryAggregates => CategoryAggregates.Count > 0;
|
||
public bool HasOwnDocumentationEntries => OwnDocumentationEntries.Count > 0;
|
||
/// Die Kategorien-Chipreihe fasst nur WebUntis-Kategorien zusammen (<see cref="CategoryAggregates"/>)
|
||
/// — im Modus "Eigene Dokumentation" ausgeblendet, dort gibt es kein Äquivalent zu CategoryGroup.
|
||
public bool ShowCategoryAggregates => HasCategoryAggregates && !ShowOwnDocumentation;
|
||
public int UntisCriticalCount => Entries.Count(e => e.IsDangerStatus);
|
||
public string ActiveFilterLabel => string.IsNullOrWhiteSpace(StudentFilter)
|
||
? "Alle Schüler*innen" : StudentFilter;
|
||
public string CalendarMonthLabel => CalendarMonth.ToString("MMMM yyyy",
|
||
System.Globalization.CultureInfo.GetCultureInfo("de-DE"));
|
||
|
||
public ClassTeacherDetailsViewModel(UntisReportCacheService cache, IDocumentationRepository documentation,
|
||
IStudentRepository students, ClassTeacherCasesViewModel cases)
|
||
{
|
||
_cache = cache;
|
||
_documentation = documentation;
|
||
_students = students;
|
||
_cases = cases;
|
||
}
|
||
|
||
public void Initialize(string className)
|
||
{
|
||
_className = className;
|
||
StudentFilter = "";
|
||
Entries.Clear();
|
||
AbsenceEntries.Clear();
|
||
CalendarDays.Clear();
|
||
CategoryAggregates.Clear();
|
||
OwnDocumentationEntries.Clear();
|
||
_rosterMatches = [];
|
||
Status = "Zeitraum wählen und laden.";
|
||
NotifyListState();
|
||
}
|
||
|
||
partial void OnStudentFilterChanged(string value) => OnPropertyChanged(nameof(ActiveFilterLabel));
|
||
|
||
partial void OnShowOwnDocumentationChanged(bool value) => OnPropertyChanged(nameof(ShowCategoryAggregates));
|
||
partial void OnShowMonthlyCalendarChanged(bool value) => OnPropertyChanged(nameof(ShowAbsenceListEmpty));
|
||
partial void OnIncludeHomeworkInCalendarChanged(bool value) => BuildCalendar();
|
||
partial void OnCalendarMonthChanged(DateOnly value) => OnPropertyChanged(nameof(CalendarMonthLabel));
|
||
|
||
partial void OnQuickRangeIndexChanged(int value)
|
||
{
|
||
var days = value switch { 0 => 0, 1 => 6, 2 => 29, _ => 6 };
|
||
EndDate = DateTimeOffset.Now;
|
||
StartDate = DateTimeOffset.Now.AddDays(-days);
|
||
}
|
||
|
||
[RelayCommand]
|
||
private Task Load() => LoadInternal(forceRefresh: false);
|
||
|
||
[RelayCommand] private void ShowUntisRegister() => ShowOwnDocumentation = false;
|
||
[RelayCommand] private void ShowOwnDocs() => ShowOwnDocumentation = true;
|
||
|
||
/// Umgeht bewusst die Stunden-Sperre von <see cref="UntisReportCacheService"/> — für den Fall,
|
||
/// dass man sicher weiß, dass sich seit dem letzten automatischen Abruf etwas geändert hat.
|
||
[RelayCommand]
|
||
private Task Refresh() => LoadInternal(forceRefresh: true);
|
||
|
||
[RelayCommand]
|
||
private Task ApplyFilter() => LoadInternal(forceRefresh: false);
|
||
|
||
[RelayCommand] private void ShowAbsenceList() => ShowMonthlyCalendar = false;
|
||
|
||
[RelayCommand]
|
||
private async Task ShowCalendar()
|
||
{
|
||
ShowMonthlyCalendar = true;
|
||
await LoadCalendarMonth(forceRefresh: false);
|
||
}
|
||
|
||
[RelayCommand]
|
||
private async Task PreviousCalendarMonth()
|
||
{
|
||
CalendarMonth = CalendarMonth.AddMonths(-1);
|
||
await LoadCalendarMonth(forceRefresh: false);
|
||
}
|
||
|
||
[RelayCommand]
|
||
private async Task NextCalendarMonth()
|
||
{
|
||
CalendarMonth = CalendarMonth.AddMonths(1);
|
||
await LoadCalendarMonth(forceRefresh: false);
|
||
}
|
||
|
||
private Task LoadCalendarMonth(bool forceRefresh)
|
||
{
|
||
StartDate = new DateTimeOffset(CalendarMonth.ToDateTime(TimeOnly.MinValue));
|
||
var end = CalendarMonth.AddMonths(1).AddDays(-1);
|
||
EndDate = new DateTimeOffset(end.ToDateTime(TimeOnly.MinValue));
|
||
return LoadInternal(forceRefresh);
|
||
}
|
||
|
||
[RelayCommand]
|
||
private async Task ClearStudentFilter()
|
||
{
|
||
StudentFilter = "";
|
||
await LoadInternal(forceRefresh: false);
|
||
}
|
||
|
||
private async Task LoadInternal(bool forceRefresh)
|
||
{
|
||
var start = DateOnly.FromDateTime((StartDate ?? DateTimeOffset.Now).LocalDateTime);
|
||
var end = DateOnly.FromDateTime((EndDate ?? DateTimeOffset.Now).LocalDateTime);
|
||
if (end < start) { Status = "Das Enddatum darf nicht vor dem Startdatum liegen."; return; }
|
||
if (string.IsNullOrWhiteSpace(_className)) { Status = "Keine Klasse ausgewählt."; return; }
|
||
|
||
Busy = true;
|
||
Entries.Clear(); AbsenceEntries.Clear(); CategoryAggregates.Clear(); OwnDocumentationEntries.Clear();
|
||
NotifyListState();
|
||
try
|
||
{
|
||
var classRegisterTask = _cache.GetClassRegisterEventsAsync(_className, start, end, forceRefresh);
|
||
var absencesTask = _cache.GetAbsencesAsync(_className, start, end, forceRefresh);
|
||
var rosterTask = _cache.GetStudentRosterAsync(_className);
|
||
await Task.WhenAll(classRegisterTask, absencesTask, rosterTask);
|
||
|
||
var ordered = classRegisterTask.Result
|
||
.Where(MatchesStudentFilter)
|
||
.Select(e => (Entry: e, Date: TryDate(e.Date, out var d) ? d : (DateOnly?)null))
|
||
.Where(x => x.Date is not null)
|
||
.OrderByDescending(x => x.Date).ThenBy(x => x.Entry.StudentName);
|
||
foreach (var (entry, date) in ordered)
|
||
{
|
||
Entries.Add(new ClassTeacherClassRegisterRow(date!.Value, entry.Subject, entry.StudentName,
|
||
entry.TeacherUsername, entry.CategoryName, entry.CategoryGroup, entry.Text));
|
||
}
|
||
foreach (var row in ClassTeacherCategoryAggregateRow.Build(Entries)) CategoryAggregates.Add(row);
|
||
|
||
var absences = absencesTask.Result.Where(MatchesStudentFilter);
|
||
foreach (var row in ClassAbsenceDaySummaryRow.GroupByStudentAndDay(absences))
|
||
AbsenceEntries.Add(row);
|
||
|
||
BuildCalendar();
|
||
|
||
var localStudents = _students.GetAll();
|
||
_rosterMatches = rosterTask.Result
|
||
.Select(r => (Roster: r, Student: ClassTeacherOverviewViewModel.MatchStudent(r.DisplayName, localStudents)))
|
||
.Where(x => x.Student is not null)
|
||
.Select(x => (StudentId: x.Student!.Id, DisplayName: x.Roster.DisplayName))
|
||
.DistinctBy(x => x.StudentId)
|
||
.ToList();
|
||
_loadedStart = start;
|
||
_loadedEnd = end;
|
||
LoadOwnDocumentationEntries();
|
||
|
||
Status = $"{Entries.Count} Klassenbucheinträge anderer Lehrkräfte, " +
|
||
$"{AbsenceEntries.Count} Fehlzeiten-Tage, {OwnDocumentationEntries.Count} eigene Dokumentation im Zeitraum.";
|
||
NotifyListState();
|
||
}
|
||
catch (WebUntisIntegrationException ex) { Status = ex.Message; }
|
||
finally { Busy = false; NotifyListState(); }
|
||
}
|
||
|
||
private void BuildCalendar()
|
||
{
|
||
CalendarDays.Clear();
|
||
foreach (var day in BuildCalendarDays(CalendarMonth, AbsenceEntries, Entries, IncludeHomeworkInCalendar))
|
||
CalendarDays.Add(day);
|
||
}
|
||
|
||
/// <summary>Baut ein Montag-bis-Freitag-Raster aus den ohnehin geladenen Berichten. Negative
|
||
/// Klassenbucheinträge erscheinen als !; Hausaufgaben sind eine bewusst optionale Sonderlage.</summary>
|
||
public static IReadOnlyList<ClassTeacherCalendarDay> BuildCalendarDays(DateOnly month,
|
||
IReadOnlyList<ClassAbsenceDaySummaryRow> absences,
|
||
IReadOnlyList<ClassTeacherClassRegisterRow> registerEntries, bool includeHomework)
|
||
{
|
||
var first = new DateOnly(month.Year, month.Month, 1);
|
||
var last = first.AddMonths(1).AddDays(-1);
|
||
var gridStart = first.AddDays(-(((int)first.DayOfWeek + 6) % 7));
|
||
var gridEnd = last.AddDays((7 - (int)last.DayOfWeek) % 7);
|
||
var result = new List<ClassTeacherCalendarDay>();
|
||
|
||
for (var date = gridStart; date <= gridEnd; date = date.AddDays(1))
|
||
{
|
||
if (date.DayOfWeek is DayOfWeek.Saturday or DayOfWeek.Sunday) continue;
|
||
if (date.Month != first.Month)
|
||
{
|
||
result.Add(new ClassTeacherCalendarDay(null, []));
|
||
continue;
|
||
}
|
||
|
||
result.Add(new ClassTeacherCalendarDay(date,
|
||
BuildDayEvents(date, absences, registerEntries, includeHomework)));
|
||
}
|
||
return result;
|
||
}
|
||
|
||
public static IReadOnlyList<ClassTeacherCalendarEvent> BuildDayEvents(DateOnly date,
|
||
IReadOnlyList<ClassAbsenceDaySummaryRow> absences,
|
||
IReadOnlyList<ClassTeacherClassRegisterRow> registerEntries, bool includeHomework)
|
||
{
|
||
var events = new List<ClassTeacherCalendarEvent>();
|
||
foreach (var absence in absences.Where(a => a.Date == date).OrderBy(a => a.StudentDisplayName))
|
||
{
|
||
var (code, label, color, kind) = absence.IsUnexcused
|
||
? ("U", "Unentschuldigt gefehlt", "#C62828", ClassTeacherCalendarEventKind.Unexcused)
|
||
: absence.IsLate
|
||
? ("V", "Verspätet", "#EF6C00", ClassTeacherCalendarEventKind.Late)
|
||
: absence.FriendlyStatusLabel.Contains("Entschuldigt", StringComparison.OrdinalIgnoreCase)
|
||
? ("E", "Entschuldigt gefehlt", "#2E7D32", ClassTeacherCalendarEventKind.Excused)
|
||
: ("A", "Abwesend", "#1565C0", ClassTeacherCalendarEventKind.Absent);
|
||
events.Add(new ClassTeacherCalendarEvent(absence.StudentDisplayName, code, label, color, kind));
|
||
}
|
||
|
||
foreach (var entry in registerEntries.Where(e => e.Date == date).OrderBy(e => e.StudentDisplayName))
|
||
{
|
||
var isHomework = ContainsHomework(entry);
|
||
if (isHomework && !includeHomework) continue;
|
||
if (!entry.IsDangerStatus && !isHomework) continue;
|
||
events.Add(new ClassTeacherCalendarEvent(entry.StudentDisplayName,
|
||
isHomework ? "H" : "!", isHomework ? "Hausaufgaben" : "Negativer Klassenbucheintrag",
|
||
isHomework ? "#6A1B9A" : "#8E24AA",
|
||
isHomework ? ClassTeacherCalendarEventKind.Homework : ClassTeacherCalendarEventKind.ClassRegister));
|
||
}
|
||
return events;
|
||
}
|
||
|
||
private static bool ContainsHomework(ClassTeacherClassRegisterRow entry) =>
|
||
entry.CategoryName?.Contains("Hausauf", StringComparison.OrdinalIgnoreCase) == true ||
|
||
entry.Text?.Contains("Hausauf", StringComparison.OrdinalIgnoreCase) == true;
|
||
|
||
/// Baut <see cref="OwnDocumentationEntries"/> aus <see cref="_rosterMatches"/> und dem zuletzt
|
||
/// geladenen Zeitraum neu auf — separat von <see cref="LoadInternal"/>, damit Anlegen/Bearbeiten/
|
||
/// Löschen eines eigenen Eintrags nicht auch die WebUntis-Berichte neu abruft. Die eigentliche
|
||
/// Filter-/Sortierlogik steckt in der reinen, ohne Repository-Zugriff testbaren
|
||
/// <see cref="FilterOwnDocumentation"/> — analog zu <see cref="ClassTeacherRosterRow.Build"/>.
|
||
private void LoadOwnDocumentationEntries()
|
||
{
|
||
OwnDocumentationEntries.Clear();
|
||
var entries = FilterOwnDocumentation(_documentation.GetAll(), _rosterMatches,
|
||
_loadedStart, _loadedEnd, StudentFilter);
|
||
foreach (var d in entries)
|
||
{
|
||
var name = _rosterMatches.First(m => m.StudentId == d.StudentId).DisplayName;
|
||
OwnDocumentationEntries.Add(new DocumentationItem(d, name));
|
||
}
|
||
OwnDocumentationCount = OwnDocumentationEntries.Count;
|
||
OwnDocumentationFollowUpCount = entries.Count(d => d.IsDraft);
|
||
OwnDocumentationCriticalCount = entries.Count(d =>
|
||
d.Tags.Any(t => string.Equals(t, "Kritisch", StringComparison.OrdinalIgnoreCase)));
|
||
OnPropertyChanged(nameof(HasOwnDocumentationEntries));
|
||
}
|
||
|
||
/// Eigene Dokumentation zu Schüler*innen der Klasse im gewählten Zeitraum: nur Einträge
|
||
/// (nicht gelöschter) Schüler*innen, die sich per Namensabgleich der WebUntis-Klasse zuordnen
|
||
/// ließen (<paramref name="rosterMatches"/>, siehe <see cref="ClassTeacherOverviewViewModel.MatchStudent"/>),
|
||
/// mit demselben Schülerfilter wie die WebUntis-Klassenbuchzeilen. Entwürfe ("Nacharbeiten")
|
||
/// zuerst, danach neueste zuerst.
|
||
public static List<Documentation> FilterOwnDocumentation(IReadOnlyList<Documentation> all,
|
||
IReadOnlyList<(Guid StudentId, string DisplayName)> rosterMatches,
|
||
DateOnly start, DateOnly end, string studentFilter)
|
||
{
|
||
var matchedIds = rosterMatches.Select(m => m.StudentId).ToHashSet();
|
||
return all
|
||
.Where(d => !d.IsDeleted && matchedIds.Contains(d.StudentId) && d.Date >= start && d.Date <= end)
|
||
.Where(d => MatchesOwnDocStudentFilter(d, rosterMatches, studentFilter))
|
||
.OrderByDescending(d => d.IsDraft).ThenByDescending(d => d.Date)
|
||
.ToList();
|
||
}
|
||
|
||
private static bool MatchesOwnDocStudentFilter(Documentation d,
|
||
IReadOnlyList<(Guid StudentId, string DisplayName)> rosterMatches, string studentFilter)
|
||
{
|
||
if (string.IsNullOrWhiteSpace(studentFilter)) return true;
|
||
var name = rosterMatches.FirstOrDefault(m => m.StudentId == d.StudentId).DisplayName;
|
||
return name is not null && UntisNameMatching.NamesMatch(name, studentFilter);
|
||
}
|
||
|
||
[RelayCommand]
|
||
private async Task AddOwnDocumentation()
|
||
{
|
||
if (OnEditOwnDocumentation is null) return;
|
||
var options = _rosterMatches.Select(m => new StudentOption(m.StudentId, m.DisplayName)).ToList();
|
||
var result = await OnEditOwnDocumentation(options, null);
|
||
if (result is null) return;
|
||
_documentation.Save(result);
|
||
LoadOwnDocumentationEntries();
|
||
}
|
||
|
||
[RelayCommand]
|
||
private async Task EditOwnDocumentation(DocumentationItem? item)
|
||
{
|
||
if (item is null || OnEditOwnDocumentation is null) return;
|
||
var options = _rosterMatches.Select(m => new StudentOption(m.StudentId, m.DisplayName)).ToList();
|
||
var result = await OnEditOwnDocumentation(options, item.Model);
|
||
if (result is null) return;
|
||
_documentation.Save(result);
|
||
LoadOwnDocumentationEntries();
|
||
}
|
||
|
||
[RelayCommand]
|
||
private async Task DeleteOwnDocumentation(DocumentationItem? item)
|
||
{
|
||
if (item is null) return;
|
||
if (OnConfirmDeleteOwnDocumentation is not null && !await OnConfirmDeleteOwnDocumentation(item)) return;
|
||
_documentation.Delete(item.Model.Id);
|
||
LoadOwnDocumentationEntries();
|
||
}
|
||
|
||
/// Für den "→ Vorgang"-Auswahldialog im Code-behind: offene Vorgänge, an die sich diese
|
||
/// Klassenbuchzeile anheften lässt (Durchreiche zur Geschwister-ViewModel-Instanz, siehe _cases).
|
||
public List<VorgangItem> OpenCasesFor(string studentName) => _cases.OpenCasesForStudent(studentName);
|
||
|
||
/// Heftet eine WebUntis-Klassenbuchzeile als eingefrorene Kopie an einen (ggf. neuen) Vorgang
|
||
/// im "Vorgänge"-Tab (<see cref="ClassTeacherCasesViewModel"/>) an — die Zeile selbst hat keine
|
||
/// stabile ID, deshalb Werte-Kopie statt Referenz (siehe VorgangClassRegisterEntry).
|
||
[RelayCommand]
|
||
private async Task PinToVorgang(ClassTeacherClassRegisterRow? row)
|
||
{
|
||
if (row is null || OnPickVorgangForPin is null) return;
|
||
var choice = await OnPickVorgangForPin(row);
|
||
if (choice is null) return;
|
||
|
||
var entry = BuildClassRegisterSnapshot(row);
|
||
|
||
if (choice.ExistingVorgangId is { } vorgangId)
|
||
await _cases.PinClassRegisterEntryAsync(vorgangId, entry);
|
||
else if (!string.IsNullOrWhiteSpace(choice.NewVorgangTitle))
|
||
{
|
||
var student = _cases.RosterStudentOptions
|
||
.FirstOrDefault(s => UntisNameMatching.NamesMatch(s.Name, row.StudentName));
|
||
if (student is null) return;
|
||
await _cases.CreateAndPinAsync(choice.NewVorgangTitle, student.Id, entry);
|
||
}
|
||
}
|
||
|
||
/// Baut die eingefrorene Klassenbuch-Kopie aus einer Zeile — reine, ohne Repository-Zugriff
|
||
/// testbare Umwandlung.
|
||
public static VorgangClassRegisterEntry BuildClassRegisterSnapshot(ClassTeacherClassRegisterRow row) => new()
|
||
{
|
||
Date = row.Date, StudentName = row.StudentName, Subject = row.Subject,
|
||
TeacherUsername = row.TeacherUsername, CategoryName = row.CategoryName,
|
||
CategoryGroup = row.CategoryGroup, Text = row.Text,
|
||
};
|
||
|
||
// WebUntis liefert Namen je nach Bericht in anderer Reihenfolge als der Schülerreport, aus dem
|
||
// StudentFilter beim Klick in der Übersicht gesetzt wird (siehe UntisNameMatching) - ein
|
||
// exakter String-Vergleich hier ließ die gefilterten Listen fälschlich leer erscheinen.
|
||
private bool MatchesStudentFilter(UntisForeignClassRegisterEventDto entry) =>
|
||
string.IsNullOrWhiteSpace(StudentFilter) || UntisNameMatching.NamesMatch(entry.StudentName, StudentFilter);
|
||
|
||
private bool MatchesStudentFilter(UntisClassAbsenceEntryDto entry) =>
|
||
string.IsNullOrWhiteSpace(StudentFilter) || UntisNameMatching.NamesMatch(entry.StudentName, StudentFilter);
|
||
|
||
private static bool TryDate(int value, out DateOnly date) =>
|
||
DateOnly.TryParseExact(value.ToString(), "yyyyMMdd", out date);
|
||
|
||
private void NotifyListState()
|
||
{
|
||
OnPropertyChanged(nameof(HasEntries));
|
||
OnPropertyChanged(nameof(HasAbsenceEntries));
|
||
OnPropertyChanged(nameof(ShowAbsenceListEmpty));
|
||
OnPropertyChanged(nameof(HasCategoryAggregates));
|
||
OnPropertyChanged(nameof(ShowCategoryAggregates));
|
||
OnPropertyChanged(nameof(HasOwnDocumentationEntries));
|
||
OnPropertyChanged(nameof(UntisCriticalCount));
|
||
}
|
||
}
|