Files
LehrerApp/LehrerApp.Desktop/ViewModels/Planning/UntisMappingReviewDialogViewModel.cs
T
adminandClaude Sonnet 5 4eb4d0a946 WIP (unstable): WebUntis-iCal-Abgleich für Vertretungen/Ausfälle
Erkennt Vertretungen, Ausfälle und Zusatzaufsichten aus dem persönlichen
WebUntis-iCal-Feed und schreibt sie automatisch als SubstitutionEntry.
Bekannter offener Bug: es tauchen weiterhin falsche Vertretungen für
Stunden auf, die real unverändert sind — wird in einem Folge-Commit
untersucht, deshalb vorerst auf diesem Branch statt main.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-23 13:30:15 +02:00

189 lines
9.0 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using LehrerApp.Core.Interfaces;
using LehrerApp.Core.Models;
using LehrerApp.Core.Services;
using LehrerApp.Desktop.Services;
using System.Collections.ObjectModel;
namespace LehrerApp.Desktop.ViewModels.Planning;
/// <summary>
/// Zeigt die erkannten regulären WebUntis-Wochenmuster und lässt den Nutzer die vorgeschlagene
/// Lerngruppe je Muster bestätigen oder ändern, bevor der laufende Abgleich (UntisSyncService)
/// automatisch Vertretungen dafür schreibt (Nutzer-Feedback: die erstmalige Zuordnung ist
/// fehleranfällig — falsche Gruppe würde falsche Vertretungen erzeugen — und braucht deshalb eine
/// Bestätigung). Termine ohne Klassenbezug (Aufsichten/Springstunden, Nutzer-Feedback: "Zwei
/// Termine sind meine Aufsichten, die nicht zugeordnet werden können") lassen sich stattdessen per
/// Checkbox als Aufsicht bestätigen. Zusätzlich informativ: Stundenplan-Einträge, zu denen kein
/// WebUntis-Muster mehr passt ("Stundenplan passt nicht mehr").
///
/// Beim erneuten Öffnen werden bereits bestätigte Zuordnungen aus dem Repository vorbefüllt
/// (Nutzer-Feedback: "Kann es sein, dass er meine Verbesserungen gar nicht einspeichert" — die
/// Auswahl wurde zwar gespeichert, beim nächsten Öffnen aber von der frischen Musterkennung
/// überschrieben, was wie ein Datenverlust wirkte) und beim Speichern per vorhandener Id
/// aktualisiert statt dupliziert (siehe UntisMappingRow.BuildMapping).
/// </summary>
public partial class UntisMappingReviewDialogViewModel : ObservableObject
{
private readonly UntisSyncService _untisSync;
private readonly IUntisSlotMappingRepository _mappings;
/// Gruppenname + Fach zur Auswahl (Nutzer-Feedback: "Meine Klasse habe ich 3-mal. Ohne das
/// Fach dabei, kann ich nicht sicher die richtige Lerngruppe hier auswählen") — dieselbe
/// Namenskonflikt-Begründung wie in TimetableSlotDialogViewModel, hier über eine kleine
/// Anzeige-Hülle statt eines Label-Strings, da dieser Dialog direkt an LearningGroup-Objekte
/// bindet.
public List<UntisGroupOption> Groups { get; }
public ObservableCollection<UntisMappingRow> Rows { get; } = [];
public ObservableCollection<string> UnmatchedSlotLabels { get; } = [];
public bool HasUnmatchedSlots => UnmatchedSlotLabels.Count > 0;
public bool HasNoRows => !IsLoading && Rows.Count == 0;
[ObservableProperty] private bool _isLoading = true;
[ObservableProperty] private string _errorMessage = "";
public bool Result { get; private set; }
public UntisMappingReviewDialogViewModel(UntisSyncService untisSync, IUntisSlotMappingRepository mappings,
List<LearningGroup> groups, ISubjectRepository subjects)
{
_untisSync = untisSync;
_mappings = mappings;
var subjectNames = subjects.GetAll().ToDictionary(s => s.Id, s => s.Name);
Groups = groups.OrderBy(g => g.Name)
.Select(g => new UntisGroupOption(g, g.SubjectId is { } sid ? subjectNames.GetValueOrDefault(sid) : null))
.ToList();
}
public async Task LoadAsync()
{
IsLoading = true;
ErrorMessage = "";
try
{
var preview = await _untisSync.FetchAndBuildMatchPreviewAsync();
Rows.Clear();
UnmatchedSlotLabels.Clear();
if (preview is null) { ErrorMessage = "Keine iCal-URL hinterlegt."; return; }
// Bei mehreren Mappings für denselben Slot (z.B. Altdaten vor dem Bugfix) gewinnt das
// zuletzt angelegte - gleiche Regel wie UntisDiffService.
var existingByKey = _mappings.GetAll()
.GroupBy(m => (m.Weekday, m.StartTime))
.ToDictionary(g => g.Key, g => g.OrderByDescending(m => m.CreatedAt).First());
foreach (var match in preview.Matches.Matches.OrderBy(m => m.Pattern.Weekday).ThenBy(m => m.Pattern.StartTime))
{
existingByKey.TryGetValue((match.Pattern.Weekday, match.Pattern.StartTime), out var existing);
Rows.Add(new UntisMappingRow(match, Groups, existing));
}
foreach (var slot in preview.Matches.UnmatchedTimetableSlots)
UnmatchedSlotLabels.Add($"{WeekdayLabel(slot.Weekday)}, {slot.PeriodNumber}. Stunde");
OnPropertyChanged(nameof(HasUnmatchedSlots));
}
catch (Exception ex) { ErrorMessage = $"Abruf fehlgeschlagen: {ex.Message}"; }
finally { IsLoading = false; OnPropertyChanged(nameof(HasNoRows)); }
}
[RelayCommand]
private void Save()
{
var confirmed = Rows.Select(r => r.BuildMapping()).Where(m => m is not null).Select(m => m!);
_untisSync.ConfirmMappings(confirmed);
Result = true;
}
private static string WeekdayLabel(DayOfWeek weekday) => weekday switch
{
DayOfWeek.Monday => "Montag", DayOfWeek.Tuesday => "Dienstag",
DayOfWeek.Wednesday => "Mittwoch", DayOfWeek.Thursday => "Donnerstag",
DayOfWeek.Friday => "Freitag", DayOfWeek.Saturday => "Samstag", DayOfWeek.Sunday => "Sonntag",
_ => weekday.ToString(),
};
}
public partial class UntisMappingRow : ObservableObject
{
private readonly Guid? _existingMappingId;
public UntisSlotMatch Match { get; }
public string WeekdayLabel { get; }
public string TimeLabel { get; }
public string PatternLabel { get; }
public bool IsConfident => Match.IsConfident;
public bool IsSupervisionCandidate => Match.IsSupervisionCandidate;
public bool CanResolve => IsSupervisionCandidate ? Match.AfterPeriod is not null : Match.PeriodNumber is not null;
[ObservableProperty] private UntisGroupOption? _selectedGroup;
[ObservableProperty] private bool _confirmAsSupervision;
public UntisMappingRow(UntisSlotMatch match, List<UntisGroupOption> groups, UntisSlotMapping? existing)
{
Match = match;
_existingMappingId = existing?.Id;
WeekdayLabel = match.Pattern.Weekday switch
{
DayOfWeek.Monday => "Mo", DayOfWeek.Tuesday => "Di", DayOfWeek.Wednesday => "Mi",
DayOfWeek.Thursday => "Do", DayOfWeek.Friday => "Fr",
DayOfWeek.Saturday => "Sa", _ => "So",
};
TimeLabel = IsSupervisionCandidate
? $"{match.Pattern.StartTime:HH:mm}{match.Pattern.EndTime:HH:mm} (Pause nach Stunde {match.AfterPeriod})"
: match.CoveredPeriods.Count > 1
? $"{match.CoveredPeriods[0]}.{match.CoveredPeriods[^1]}. Stunde ({match.Pattern.StartTime:HH:mm}, Doppelstunde)"
: match.PeriodNumber is { } period
? $"{period}. Stunde ({match.Pattern.StartTime:HH:mm})"
: $"{match.Pattern.StartTime:HH:mm} (keine passende Stunde im Stundenraster)";
PatternLabel = IsSupervisionCandidate
? "Aufsicht / Springstunde (kein Klassenbezug)"
: match.Pattern.ClassTokens.Count == 0
? (match.Pattern.Summary ?? "(ohne Fach)")
: $"{match.Pattern.Summary ?? "?"} · {string.Join(", ", match.Pattern.ClassTokens)}";
if (IsSupervisionCandidate)
{
_confirmAsSupervision = existing is { Confirmed: true, Kind: SubstitutionKind.Supervision };
}
else
{
_selectedGroup = existing is { Confirmed: true, Kind: SubstitutionKind.Lesson, GroupId: { } existingGroupId }
? groups.FirstOrDefault(g => g.Group.Id == existingGroupId)
: match.SuggestedGroupId is { } suggestedId ? groups.FirstOrDefault(g => g.Group.Id == suggestedId) : null;
}
}
/// null, wenn diese Zeile nicht (mehr) bestätigt ist — wird beim Speichern übersprungen.
internal UntisSlotMapping? BuildMapping()
{
if (IsSupervisionCandidate)
{
if (!ConfirmAsSupervision || Match.AfterPeriod is null) return null;
return new UntisSlotMapping
{
Id = _existingMappingId ?? Guid.NewGuid(),
Weekday = Match.Pattern.Weekday, StartTime = Match.Pattern.StartTime,
Summary = Match.Pattern.Summary, ClassToken = "",
Kind = SubstitutionKind.Supervision, AfterPeriod = Match.AfterPeriod,
Confirmed = true,
};
}
if (SelectedGroup is null || Match.PeriodNumber is null) return null;
return new UntisSlotMapping
{
Id = _existingMappingId ?? Guid.NewGuid(),
Weekday = Match.Pattern.Weekday, StartTime = Match.Pattern.StartTime,
Summary = Match.Pattern.Summary, ClassToken = string.Join(";", Match.Pattern.ClassTokens),
Kind = SubstitutionKind.Lesson, GroupId = SelectedGroup.Group.Id, PeriodNumber = Match.PeriodNumber,
CoveredPeriods = Match.CoveredPeriods, Confirmed = true,
};
}
}
public sealed class UntisGroupOption(LearningGroup group, string? subjectName)
{
public LearningGroup Group { get; } = group;
public string DisplayLabel { get; } = string.IsNullOrEmpty(subjectName) ? group.Name : $"{group.Name} ({subjectName})";
}