using System.Collections.ObjectModel;
using System.Text.RegularExpressions;
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.Groups;
/// Eine Zeile bleibt auch ohne automatische Zuordnung sichtbar -
/// kann manuell per Auswahlliste gesetzt werden, gleiches Muster wie
/// und
/// .
public partial class WebUntisHomeworkRow : ObservableObject
{
public required string ClassName { get; init; }
public required DateOnly Date { get; init; }
public required string UntisStudentName { get; init; }
public string? SubjectLabel { get; init; }
public string? Text { get; init; }
public required IReadOnlyList Candidates { get; init; }
internal Guid? MatchedSubjectId { get; init; }
internal Action? OnAssignmentChanged { get; init; }
public string DateLabel => Date.ToString("dd.MM.yyyy");
public bool CanApply => SessionId is not null;
[ObservableProperty] private Student? _assignedStudent;
[ObservableProperty] private string _localStatus = "ohne Zuordnung";
[ObservableProperty] private Guid? _sessionId;
[ObservableProperty] private bool _selected;
partial void OnAssignedStudentChanged(Student? value) => OnAssignmentChanged?.Invoke(this);
}
/// Abgleich negativer Klassenbucheinträge zum Stichwort "Hausaufgabe" (eigene, siehe
/// ) gegen
/// - Dashboard-weit statt pro Lerngruppe, aus demselben
/// Grund wie beim Dokumentations-Abgleich (der WebUntis-"-alle-"-Bericht ist klassenübergreifend,
/// siehe TODO.md). Nutzer-Feedback: WebUntis liefert für Klassenbucheinträge weder eine externe
/// Schülerkennung noch eine feste Fach-/Lerngruppenzuordnung, aber der Lehrkraft-eigene Text enthält
/// praktisch immer das Untis-Fachkürzel - darüber wird die passende Lerngruppe (SubjectId + aktive
/// Mitgliedschaft am Eintragsdatum) aufgelöst, mehrdeutige Treffer bleiben unaufgelöst statt zu raten.
/// Ein Eintrag setzt lokal ausschließlich vor, und auch nur,
/// wenn dort noch gar kein Status hinterlegt ist - bereits vorhandene, feinere Erfassungen ("Teilweise
/// angefertigt", "nachgereicht" usw.) werden nie automatisch überschrieben, sondern nur zum Vergleich
/// danebengestellt (Nutzerwunsch).
public partial class WebUntisHomeworkComparisonViewModel : ObservableObject
{
private readonly WebUntisIntegrationService _untis;
private readonly IStudentRepository _students;
private readonly IGroupRepository _groups;
private readonly ISubjectRepository _subjects;
private readonly IGroupMembershipRepository _memberships;
private readonly IParticipationSessionRepository _sessions;
private readonly IParticipationRepository _participation;
private readonly SchoolYearService _schoolYears;
public ObservableCollection Rows { 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 WebUntisHomeworkComparisonViewModel(WebUntisIntegrationService untis, IStudentRepository students,
IGroupRepository groups, ISubjectRepository subjects, IGroupMembershipRepository memberships,
IParticipationSessionRepository sessions, IParticipationRepository participation,
SchoolYearService schoolYears)
{
_untis = untis; _students = students; _groups = groups; _subjects = subjects;
_memberships = memberships; _sessions = sessions; _participation = participation;
_schoolYears = schoolYears;
}
[RelayCommand]
private async Task Load()
{
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; }
Busy = true; Rows.Clear();
try
{
var ownStudents = _students.GetAll();
var globalIndex = BuildNameIndex(ownStudents);
// Wie beim Dokumentations-Abgleich zusätzlich pro Klasse indiziert, um gleiche Namen in
// verschiedenen Klassen unterscheiden zu können.
var classIndexes = _groups.GetAll()
.Where(g => g.Type == GroupType.Class)
.GroupBy(g => g.Name, StringComparer.OrdinalIgnoreCase)
.ToDictionary(
g => g.Key,
g => BuildNameIndex(g.SelectMany(x => _students.GetByGroup(x.Id)).Distinct().ToList()),
StringComparer.OrdinalIgnoreCase);
var shortNameIndex = _subjects.GetAll()
.Where(s => !string.IsNullOrWhiteSpace(s.ShortName))
.GroupBy(s => s.ShortName.Trim().ToUpperInvariant())
.Where(g => g.Count() == 1) // mehrdeutiges Kürzel lieber nicht zuordnen als raten
.ToDictionary(g => g.Key, g => g.First());
var entries = await _untis.GetOwnClassRegisterEventsAsync(start, end);
var ordered = entries
.Where(IsMissingHomeworkEntry)
.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);
void ResolveLocalMatch(WebUntisHomeworkRow row)
{
if (row.AssignedStudent is not { } student)
{
row.SessionId = null; row.LocalStatus = "ohne Zuordnung"; row.Selected = false;
return;
}
var group = ResolveGroup(student, row.MatchedSubjectId, row.Date);
var session = group is null ? null
: _sessions.GetByGroup(group.Id).FirstOrDefault(s => s.Date == row.Date);
var entry = session is null ? null : _participation.GetBySessionAndStudent(session.Id, student.Id);
var current = entry is null ? null : HomeworkDisplay.Effective(entry);
row.SessionId = session?.Id;
row.LocalStatus = session is null
? row.MatchedSubjectId is null ? "Fachkürzel nicht erkannt"
: group is null ? "kein passender Kurs gefunden" : "keine lokale Stunde"
: HomeworkDisplay.Label(current);
// Nur vorbelegen, wenn lokal noch überhaupt nichts erfasst ist - jeder vorhandene
// Status (auch ein bereits gesetztes "fehlt") bleibt unangetastet, siehe Klassenkommentar.
row.Selected = session is not null && current is null;
}
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 subject = MatchSubject(entry.Text, shortNameIndex) ?? MatchSubject(entry.CategoryName, shortNameIndex);
var row = new WebUntisHomeworkRow
{
ClassName = entry.ClassName, Date = date!.Value, UntisStudentName = entry.StudentName,
SubjectLabel = subject?.ShortName, Text = entry.Text, Candidates = ownStudents,
MatchedSubjectId = subject?.Id, OnAssignmentChanged = ResolveLocalMatch,
};
Rows.Add(row);
row.AssignedStudent = match; // löst OnAssignedStudentChanged aus und setzt SessionId/LocalStatus/Selected
}
var unresolvedStudent = Rows.Count(x => x.AssignedStudent is null);
var unresolvedSubject = Rows.Count(x => x.MatchedSubjectId is null);
Status = $"{Rows.Count} Einträge \"fehlende Hausaufgabe\" erhalten" +
(unresolvedStudent > 0 ? $", {unresolvedStudent} bitte manuell zuordnen" : "") +
(unresolvedSubject > 0 ? $", bei {unresolvedSubject} kein Fachkürzel im Text erkannt" : "") +
$". {Rows.Count(x => x.CanApply)} einer lokalen Stunde zuordenbar.";
}
catch (WebUntisIntegrationException ex) { Status = ex.Message; }
finally { Busy = false; }
}
[RelayCommand]
private void Apply()
{
var selected = Rows.Where(x => x.Selected && x.SessionId is not null && x.AssignedStudent is not null).ToList();
foreach (var row in selected)
{
var studentId = row.AssignedStudent!.Id;
var entry = _participation.GetBySessionAndStudent(row.SessionId!.Value, studentId)
?? new ParticipationEntry { SessionId = row.SessionId.Value, StudentId = studentId };
entry.Homework = HomeworkStatus.MissingOpen;
entry.HomeworkMissing = true;
entry.UpdatedAt = DateTime.UtcNow;
_participation.Save(entry);
row.LocalStatus = HomeworkDisplay.Label(HomeworkStatus.MissingOpen);
row.Selected = false;
}
Status = $"{selected.Count} Hausaufgaben-Status übernommen.";
}
/// Nur Lerngruppen (nicht die Klasse selbst), bei denen der/die Schüler*in am Eintragsdatum aktiv
/// Mitglied ist und deren Fach zum erkannten Kürzel passt - mehr als ein Treffer bleibt bewusst
/// unaufgelöst statt irgendeinen davon zu wählen.
private LearningGroup? ResolveGroup(Student student, Guid? subjectId, DateOnly date)
{
if (subjectId is null) return null;
var schoolYear = _schoolYears.CurrentSchoolYear(date);
var candidates = _memberships.GetByStudent(student.Id)
.Where(m => GroupMembershipService.IsActiveOn(m, date))
.Select(m => _groups.GetById(m.GroupId))
.Where(g => g is not null && g.SubjectId == subjectId && g.SchoolYear == schoolYear)
.Cast()
.ToList();
return candidates.Count == 1 ? candidates[0] : null;
}
// Gleiche Heuristik wie ClassTeacherDetailsViewModel.ContainsHomework, zusätzlich auf negative
// Einträge eingeschränkt (eine positive "Hausaufgabe"-Kategorie wäre kein Fehlen-Signal).
private static bool IsMissingHomeworkEntry(UntisClassRegisterEventDto entry) =>
string.Equals(entry.CategoryGroup, "Negativ", StringComparison.OrdinalIgnoreCase) &&
(entry.CategoryName?.Contains("Hausauf", StringComparison.OrdinalIgnoreCase) == true ||
entry.Text?.Contains("Hausauf", StringComparison.OrdinalIgnoreCase) == true);
private static Subject? MatchSubject(string? text, IReadOnlyDictionary shortNameIndex)
{
if (string.IsNullOrWhiteSpace(text)) return null;
foreach (var token in Regex.Split(text, @"[^\p{L}\p{Nd}]+"))
if (token.Length > 0 && shortNameIndex.TryGetValue(token.ToUpperInvariant(), out var subject))
return subject;
return null;
}
private static string NameKey(string value) => value.Trim().ToLowerInvariant();
// Wie bei den übrigen WebUntis-Abgleichen: beide Namensreihenfolgen registriert, aber nur falls
// innerhalb der Kandidaten eindeutig.
private static Dictionary BuildNameIndex(IReadOnlyList 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);
}