192 lines
9.9 KiB
C#
192 lines
9.9 KiB
C#
using System.Collections.ObjectModel;
|
||
using CommunityToolkit.Mvvm.ComponentModel;
|
||
using CommunityToolkit.Mvvm.Input;
|
||
using LehrerApp.Core.Interfaces;
|
||
using LehrerApp.Core.Models;
|
||
using LehrerApp.Core.Services;
|
||
using LehrerApp.Desktop.Services;
|
||
|
||
namespace LehrerApp.Desktop.ViewModels.Students;
|
||
|
||
/// <summary>Eine Zeile bleibt auch ohne automatische Zuordnung sichtbar - <see cref="AssignedStudent"/>
|
||
/// kann manuell per Auswahlliste gesetzt werden, wenn der Name nicht eindeutig auf ein/e Schüler*in
|
||
/// passt (siehe WebUntisLessonAbsenceComparisonViewModel für dasselbe Muster).</summary>
|
||
public partial class WebUntisDocumentationRow : ObservableObject
|
||
{
|
||
public required string ClassName { get; init; }
|
||
public required DateOnly Date { get; init; }
|
||
public required string UntisStudentName { get; init; }
|
||
public string? Subject { get; init; }
|
||
public string? CategoryName { get; init; }
|
||
public string? CategoryGroup { get; init; }
|
||
public string? Text { get; init; }
|
||
public required IReadOnlyList<Student> Candidates { get; init; }
|
||
public string DateLabel => Date.ToString("dd.MM.yyyy");
|
||
public bool CanApply => AssignedStudent is not null;
|
||
|
||
[ObservableProperty] private Student? _assignedStudent;
|
||
/// <summary>Titel/Text eines lokalen Eintrags, der am selben Tag für diese/n Schüler*in schon
|
||
/// existiert - nur nach Datum+Schüler*in erkannt, nicht nach Wortlaut (der unterscheidet sich oft
|
||
/// von der WebUntis-Kategorie). Deshalb Anzeige zum Vergleichen statt automatischem Ausblenden.</summary>
|
||
[ObservableProperty] private string? _existingLocalEntry;
|
||
[ObservableProperty] private bool _selected;
|
||
}
|
||
|
||
/// <summary>Lokaler Dokumentationseintrag im geladenen Zeitraum ohne passenden WebUntis-Eintrag - kann
|
||
/// nicht automatisch nach WebUntis geschrieben werden (bewusst keine schreibenden Aufrufe gegen die
|
||
/// undokumentierte API), deshalb nur als Kopiervorlage für die manuelle Nacherfassung dort.</summary>
|
||
public sealed record LocalOnlyDocumentationRow(
|
||
string StudentName, DateOnly Date, string? GroupName, string Title, string Content)
|
||
{
|
||
public string DateLabel => Date.ToString("dd.MM.yyyy");
|
||
public string ClipboardText =>
|
||
$"{DateLabel} – {StudentName}" + (GroupName is null ? "" : $" ({GroupName})") +
|
||
$"\n{Title}\n{Content}";
|
||
}
|
||
|
||
/// <summary>Abgleich der WebUntis-Klassenbucheinträge (eigene, siehe
|
||
/// <see cref="WebUntisIntegrationService.GetOwnClassRegisterEventsAsync"/>) gegen die lokale
|
||
/// <see cref="Documentation"/> - Dashboard-weit statt pro Lerngruppe, weil der WebUntis-"-alle-"-
|
||
/// Bericht ebenfalls klassenübergreifend ist (siehe TODO.md, Nachtrag zu 4.3).</summary>
|
||
public partial class WebUntisDocumentationComparisonViewModel : ObservableObject
|
||
{
|
||
private readonly WebUntisIntegrationService _untis;
|
||
private readonly IStudentRepository _students;
|
||
private readonly IGroupRepository _groups;
|
||
private readonly IDocumentationRepository _documentation;
|
||
private readonly SchoolYearService _schoolYears;
|
||
|
||
public ObservableCollection<WebUntisDocumentationRow> Rows { get; } = [];
|
||
public ObservableCollection<LocalOnlyDocumentationRow> LocalOnlyRows { get; } = [];
|
||
[ObservableProperty] private DateTimeOffset _startDate = DateTimeOffset.Now.AddDays(-7);
|
||
[ObservableProperty] private DateTimeOffset _endDate = DateTimeOffset.Now;
|
||
[ObservableProperty] private string _status = "Zeitraum wählen und Klassenbucheinträge laden.";
|
||
[ObservableProperty] private bool _busy;
|
||
|
||
public WebUntisDocumentationComparisonViewModel(WebUntisIntegrationService untis,
|
||
IStudentRepository students, IGroupRepository groups, IDocumentationRepository documentation,
|
||
SchoolYearService schoolYears)
|
||
{
|
||
_untis = untis; _students = students; _groups = groups; _documentation = documentation;
|
||
_schoolYears = schoolYears;
|
||
}
|
||
|
||
[RelayCommand]
|
||
private async Task Load()
|
||
{
|
||
var start = DateOnly.FromDateTime(StartDate.LocalDateTime);
|
||
var end = DateOnly.FromDateTime(EndDate.LocalDateTime);
|
||
if (end < start) { Status = "Das Enddatum darf nicht vor dem Startdatum liegen."; return; }
|
||
Busy = true; Rows.Clear(); LocalOnlyRows.Clear();
|
||
try
|
||
{
|
||
var ownStudents = _students.GetAll();
|
||
var globalIndex = BuildNameIndex(ownStudents);
|
||
// Zusätzlich pro Klasse (aktuelles Schuljahr) indiziert: löst den Fall "gleicher Name in
|
||
// verschiedenen Klassen" auf, den ein rein globaler Namensabgleich nicht unterscheiden könnte.
|
||
var currentSchoolYear = _schoolYears.CurrentSchoolYear();
|
||
var classIndexes = _groups.GetAll()
|
||
.Where(g => g.Type == GroupType.Class && g.SchoolYear == currentSchoolYear)
|
||
.GroupBy(g => g.Name, StringComparer.OrdinalIgnoreCase)
|
||
.ToDictionary(
|
||
g => g.Key,
|
||
g => BuildNameIndex(g.SelectMany(x => _students.GetByGroup(x.Id)).Distinct().ToList()),
|
||
StringComparer.OrdinalIgnoreCase);
|
||
|
||
var entries = await _untis.GetOwnClassRegisterEventsAsync(start, end);
|
||
var localDocs = _documentation.GetAll().Where(d => d.Date >= start && d.Date <= end).ToList();
|
||
|
||
var ordered = entries
|
||
.Select(e => (Entry: e, Date: TryDate(e.Date, out var d) ? d : (DateOnly?)null))
|
||
.Where(x => x.Date is not null)
|
||
.OrderBy(x => x.Date).ThenBy(x => x.Entry.StudentName);
|
||
|
||
// Nur nach Schüler*in+Datum erkannt, nicht nach Wortlaut: ein lokaler Eintrag von vor dieser
|
||
// Funktion (oder frei formuliert) hat selten denselben Titel wie die WebUntis-Kategorie.
|
||
List<Documentation> LocalDocsFor(Guid studentId, DateOnly date) =>
|
||
localDocs.Where(d => d.StudentId == studentId && d.Date == date).ToList();
|
||
|
||
foreach (var (entry, date) in ordered)
|
||
{
|
||
var nameKey = NameKey(entry.StudentName);
|
||
var match = (classIndexes.TryGetValue(entry.ClassName, out var classIndex)
|
||
? classIndex.GetValueOrDefault(nameKey)
|
||
: null)
|
||
?? globalIndex.GetValueOrDefault(nameKey);
|
||
var existing = match is null ? [] : LocalDocsFor(match.Id, date!.Value);
|
||
|
||
Rows.Add(new WebUntisDocumentationRow
|
||
{
|
||
ClassName = entry.ClassName, Date = date!.Value, UntisStudentName = entry.StudentName,
|
||
Subject = entry.Subject, CategoryName = entry.CategoryName, CategoryGroup = entry.CategoryGroup,
|
||
Text = entry.Text, Candidates = ownStudents, AssignedStudent = match,
|
||
ExistingLocalEntry = existing.Count == 0 ? null
|
||
: string.Join(" | ", existing.Select(d => $"{d.Title}: {d.Content}")),
|
||
Selected = match is not null && existing.Count == 0,
|
||
});
|
||
}
|
||
|
||
foreach (var doc in localDocs)
|
||
{
|
||
var coveredByReport = Rows.Any(r => r.AssignedStudent?.Id == doc.StudentId && r.Date == doc.Date);
|
||
if (coveredByReport) continue;
|
||
var student = ownStudents.FirstOrDefault(s => s.Id == doc.StudentId);
|
||
if (student is null) continue;
|
||
LocalOnlyRows.Add(new LocalOnlyDocumentationRow(student.FullName, doc.Date,
|
||
doc.GroupId is { } groupId ? _groups.GetById(groupId)?.Name : null, doc.Title, doc.Content));
|
||
}
|
||
|
||
var unresolved = Rows.Count(x => x.AssignedStudent is null);
|
||
var possibleDuplicates = Rows.Count(x => x.ExistingLocalEntry is not null);
|
||
Status = $"{Rows.Count} WebUntis-Einträge erhalten, {possibleDuplicates} mit lokalem Eintrag am " +
|
||
"selben Tag (bitte vergleichen)" +
|
||
(unresolved > 0 ? $", {unresolved} bitte manuell zuordnen" : "") +
|
||
$". {LocalOnlyRows.Count} lokale Einträge ohne WebUntis-Gegenstück.";
|
||
}
|
||
catch (WebUntisIntegrationException ex) { Status = ex.Message; }
|
||
finally { Busy = false; }
|
||
}
|
||
|
||
[RelayCommand]
|
||
private void Apply()
|
||
{
|
||
var selected = Rows.Where(x => x.Selected && x.CanApply).ToList();
|
||
foreach (var row in selected)
|
||
{
|
||
_documentation.Save(new Documentation
|
||
{
|
||
StudentId = row.AssignedStudent!.Id,
|
||
Date = row.Date,
|
||
Type = DocumentationType.Incident,
|
||
Title = row.CategoryName ?? "WebUntis-Klassenbucheintrag",
|
||
Content = row.Text ?? "",
|
||
Tags = row.CategoryGroup is { Length: > 0 } group ? [group] : [],
|
||
});
|
||
}
|
||
foreach (var row in selected)
|
||
{
|
||
row.ExistingLocalEntry = $"{row.CategoryName}: {row.Text}";
|
||
row.Selected = false;
|
||
}
|
||
Status = $"{selected.Count} Einträge aus WebUntis übernommen.";
|
||
}
|
||
|
||
private static string NameKey(string value) => value.Trim().ToLowerInvariant();
|
||
|
||
// Wie beim Fehlzeiten-Abgleich: WebUntis liefert Namen nicht einheitlich in einer Reihenfolge,
|
||
// deshalb werden beide Reihenfolgen registriert, aber nur falls innerhalb der Kandidaten eindeutig.
|
||
private static Dictionary<string, Student> BuildNameIndex(IReadOnlyList<Student> candidates) =>
|
||
candidates
|
||
.SelectMany(student => new[]
|
||
{
|
||
NameKey($"{student.LastName} {student.FirstName}"),
|
||
NameKey($"{student.FirstName} {student.LastName}"),
|
||
}.Select(key => (Key: key, Student: student)))
|
||
.GroupBy(x => x.Key)
|
||
.Where(group => group.Select(x => x.Student).Distinct().Count() == 1)
|
||
.ToDictionary(group => group.Key, group => group.First().Student);
|
||
|
||
private static bool TryDate(int value, out DateOnly date) =>
|
||
DateOnly.TryParseExact(value.ToString(), "yyyyMMdd", out date);
|
||
}
|