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>
189 lines
9.0 KiB
C#
189 lines
9.0 KiB
C#
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})";
|
||
}
|