34 Commits
Author SHA1 Message Date
adminandClaude Sonnet 5 77fe9b1c79 fix: Dokumentationsdialog wächst bei Elternbrief über den Bildschirm
CI / build-and-test (push) Waiting to run
SizeToContent="Height" ohne Begrenzung ließ das Fenster beim Typ
"Elternbrief" (drei zusätzliche Felder) so hoch werden, dass der
Speichern-Button auf kleineren Bildschirmen (z.B. MacBook) nicht mehr
erreichbar war. ScrollViewer bekommt eine MaxHeight, ab der intern
gescrollt wird - analog zum bestehenden Muster in McpConfirmDialog.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-16 23:12:53 +02:00
adminandClaude Sonnet 5 92e497b54f fix: Fehlzeitenkalender Spaltenbreiten und unnötigen Seitenumbruch
CI / build-and-test (push) Waiting to run
Datum-Spalte in der Fehltage-Tabelle war für "dd.MM.yyyy" zu schmal
bemessen (an der kurzen Kopfzeile "Datum" orientiert) und wurde
abgeschnitten. RenderFlow erzwang außerdem zwischen den Ausschnitten
einer zu hohen Zeichenbox einen harten Seitenumbruch unabhängig vom
tatsächlich verbleibenden Platz auf der Seite - dadurch begann z.B.
der zweite Monat auf Seite 3, obwohl Seite 2 noch reichlich Platz
hatte.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-16 22:42:29 +02:00
admin 861f2c76ca fix: Umbruchlogik
CI / build-and-test (push) Canceled after 0s
2026-09-16 02:21:21 +02:00
admin ef3e9dbbb6 fix: Briefe Kontaktauswahl
CI / build-and-test (push) Canceled after 0s
2026-09-16 01:49:46 +02:00
admin 03a5e0dd9c fix: Fehlzeitenkalender in Templates, Datenbezug und Layout
CI / build-and-test (push) Canceled after 0s
2026-09-16 00:58:09 +02:00
admin 6dccf96039 fix: Fehlzeitenkalender in Templates
CI / build-and-test (push) Canceled after 0s
2026-09-16 00:50:10 +02:00
admin 6e57bf407d feat: fehlende Variablen ergänzen
CI / build-and-test (push) Canceled after 0s
2026-09-15 23:30:22 +02:00
admin 0b5cfc5522 feat: Feldeditor für Autofelder im TemplateDesigner
CI / build-and-test (push) Canceled after 0s
2026-09-15 11:07:37 +02:00
admin 09cbbf1b77 feat: Fehlzeiten Tabelle für Templates 2026-09-15 10:35:55 +02:00
admin 1671796844 feat: Fehlzeiten Calender 2026-09-15 10:02:33 +02:00
admin 70dc78904c Merge remote-tracking branch 'origin/main'
CI / build-and-test (push) Canceled after 0s
2026-09-14 00:54:43 +02:00
adminandClaude Sonnet 5 7b660c7152 feat: Planungsideen-Feld je Stunde + persistierter Materialerstellungs-Prompt
CI / build-and-test (push) Canceled after 0s
Lesson bekommt ein neues Freitextfeld PlanningIdeas fuer grobe Ideen vor der
Feinplanung, sichtbar im LessonDialog und als Kontext an die KI-Planung
(Backend + MCP) durchgereicht. LessonPhaseStep.MaterialPrompt speichert den
beim Uebernehmen einer KI-Stunde erzeugten Materialerstellungs-Prompt dauerhaft,
statt ihn nur einmalig im AiAssistDialog anzuzeigen - im Verlaufsplan-Editor
ueber einen neuen Kopieren-Button je Phase erneut nutzbar, auch ueber MCP
lesbar/schreibbar.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-14 00:41:13 +02:00
admin 3675b70004 feat: exclude group from untis hub
CI / build-and-test (push) Canceled after 0s
2026-09-13 23:58:38 +02:00
admin eb1b2340b7 feat: expand teaching mode with live timeline and seating quick checks
CI / build-and-test (push) Canceled after 0s
2026-09-13 23:16:36 +02:00
adminandClaude Sonnet 5 b8193be6ec fix: Untis-Hub-Status ueber Geraete hinweg synchronisieren
CI / build-and-test (push) Canceled after 0s
UntisHubJobState (Faelligkeits-Zeitstempel des Untis-Hubs) lief bisher
ausserhalb des Sync - jedes Geraet fuehrte seine eigene Buchhaltung, wodurch
auf allen Instanzen dieselben Punkte offen blieben und ein bereits erledigter
Abgleich anderswo erneut WebUntis-Traffic ausgeloest haette (Nutzer-Feedback).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-13 22:24:03 +02:00
admin 0962845ead fix: startup speed
CI / build-and-test (push) Canceled after 0s
2026-09-13 22:04:07 +02:00
adminandClaude Sonnet 5 42c650518c MCP: TMPDIR-Auseinanderdriften zwischen Desktop und Bridge auf Mac/Linux behoben
LehrerApp.Desktop und LehrerApp.McpBridge emulieren die Named Pipe unter
macOS/Linux über eine Socket-Datei unter TMPDIR. Von Finder/Dock
gestartet bekommt Desktop das reguläre Session-TMPDIR unter
/var/folders/.../T, während die Bridge als Kindprozess von Claude
Desktop oft eine reduzierte Umgebung mit TMPDIR=/tmp erbt - beide
suchten die Pipe-Datei dann an unterschiedlichen Orten und fanden sich
nie ("MCP nicht erreichbar", obwohl beide Prozesse liefen). TMPDIR wird
jetzt in beiden Prozessen vor der ersten Pipe-Instanz hart auf denselben,
von ApplicationData abgeleiteten Ordner gesetzt.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-13 01:46:04 +02:00
adminandClaude Sonnet 5 2c791258a1 fix: GroupId ueber MCP auflösbar (get_groups + get_untis_hub_status)
CI / build-and-test (push) Canceled after 0s
Nutzer-Feedback aus echtem Live-Test der neuen Untis-MCP-Tools: groupId ist ueberall
Pflichtparameter (get_grades, get_schedule, get_lesson_plans, get_untis_absence_rows, ...), war
aber nirgends ueber MCP auflösbar - ein KI-Client kannte bestenfalls den Klarnamen einer
Lerngruppe, nie ihre Id.

- Neues GroupTools.cs mit get_groups (Read): listet Lerngruppen mit Id/Name/Typ/Schuljahr/
  Klassenstufe/SubjectId/IsActive, optional nach Schuljahr gefiltert.
- UntisHubStatusRowDto liefert zusaetzlich GroupId mit (null bei den drei dashboard-weiten
  Zeilen), damit fuer eine dort gelistete Gruppe nicht zusaetzlich get_groups noetig ist.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-13 01:13:13 +02:00
admin c528bd825f fix: mcp timeout
CI / build-and-test (push) Canceled after 0s
2026-09-13 00:38:05 +02:00
adminandClaude Sonnet 5 56ab5067e6 feat: Formulare-Menü mit Elternbrief-Einstieg + Arbeitsblatt-Personalisierung (Nutzer-Feedback)
CI / build-and-test (push) Canceled after 0s
Neue Menü-Kategorie "Formulare" in der Menüleiste: "Elternbrief erzeugen..." öffnet
jetzt einen Schüler-Picker statt nur aus der Schülerdetailansicht erreichbar zu sein,
"Arbeitsblatt personalisieren..." ist aktiv, sobald eine einzelne Lerngruppe geöffnet
ist, und erzeugt aus einer in TemplateDesigner gebauten .lavorlage-Vorlage ein PDF je
aktivem Gruppenmitglied - über eine von den Elternbrief-Vorlagen getrennte
WorksheetTemplateStore-Bibliothek.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-13 00:21:08 +02:00
adminandClaude Sonnet 5 bfe214ecf2 feat: Untis-Hub-Kadenzkopplung, anpassbarer Statusvorschlag + KI-/MCP-Unterstuetzung beim Fehlzeitenabgleich (Nutzer-Feedback)
CI / build-and-test (push) Canceled after 0s
- UntisHubService.RecordRun: ein abgeschlossener Langzeit-Fehlzeitenabgleich schliesst die
  kurzfristige Kadenz derselben Gruppe automatisch mit ab (nicht umgekehrt).
- Fehlzeitenabgleich-Dialog: neue "Uebernahme als"-ComboBox statt starrem Zielstatus, vorbelegt
  mit dem berechneten Vorschlag, aber frei aenderbar.
- Neuer ai-backend-Endpunkt untis-status.php + AiPlanningService.RequestUntisStatusSuggestionsAsync:
  gebuendelter, anonymisierter KI-Statusvorschlag (nur Positions-Id + Rohsignale, nie Name/Klasse/
  Datum), mit hartem Id-Mengen-Abgleich gegen Verwechslung.
- Neue MCP-Tools (UntisComparisonTools): get_untis_hub_status, get_untis_absence_rows/
  apply_untis_absence_status (anonymer Weg ueber ENr-Zuordnung) sowie
  get_named_untis_absence_pattern als bewusste, eng begrenzte Ausnahme (Name+Fehlzeiten fuer
  explizit angegebene Schueler-IDs, mit Bestaetigung ohne Sitzungsfreigabe - dafuer
  IMcpConfirmationService.ConfirmAsync um allowSessionTrust erweitert).
- MapStatus/ENr-Zuordnung aus dem ViewModel in das neue, geteilte UntisLessonAbsenceHelper
  gezogen, damit Dialog und MCP-Tool nie unterschiedliche Statusvorschlaege berechnen.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-12 22:55:33 +02:00
adminandClaude Sonnet 5 d979a84e7b feat: LehrerApp.McpBridge zu resilientem MCP-Proxy umgebaut
Claude Desktop deaktivierte den ganzen MCP-Server, sobald der Initialize-Handshake beim
Bridge-Start fehlschlug - der Regelfall, wenn LehrerApp.Desktop (z.B. nach Windows-Autostart
von Claude Desktop) noch nicht läuft. Die Bridge ist jetzt selbst ein MCP-Server gegenüber
Claude Desktop und gleichzeitig ein MCP-Client gegenüber dem echten Server in LehrerApp.Desktop:
der Handshake gelingt dadurch immer.

Verbunden spiegelt sie die echte Werkzeugliste 1:1; ohne Verbindung bietet sie nur ein
lehrerapp_status-Werkzeug an, das den Grund erklärt und erneut verbindet. Ein Hintergrund-Loop
versucht unabhängig davon alle 5s zu reconnecten und schaltet per notifications/tools/list_changed
automatisch auf die echten Werkzeuge um, sobald LehrerApp erreichbar ist. Bricht die Verbindung
während eines laufenden Aufrufs ab, kommt ein normales Tool-Fehlerergebnis statt eines
Prozessabsturzes zurück.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-12 21:45:03 +02:00
adminandClaude Sonnet 5 3f8813df51 feat: MCP-Tools für Fach-/Kompetenzverwaltung + Kompetenzzuordnung zu Lessons (Nutzer-Nachtrag)
Neue CompetencyTools.cs: create/update/delete_subject, create/update/delete_competency_domain,
add/update/remove_competency_item, get_subjects, get_competency_catalog. delete_subject und
delete_competency_domain als weitere Destructive-Ausnahmen neben delete_lesson (kein Papierkorb).

Lesson bekommt ein neues Competencies-Feld (analog zu Unit.Competencies) sowie
add_lesson_competency/remove_lesson_competency in LessonPlanTools.cs.

ICompetencyDomainRepository um GetBySubject() erweitert, um den Katalog eines Fachs ohne
bekannte Klassenstufe abzufragen.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-12 20:01:23 +02:00
adminandClaude Sonnet 5 19aa302487 feat: Sitzungsfreigabe für MCP-Bestätigungen + LessonStatus.Cancelled
Sitzungsfreigabe: McpConfirmDialog bietet zwei Checkboxen ("diese Art
von Aktion" / "alle KI-Aktionen für den Rest der Sitzung nicht mehr
nachfragen"), nur beim Bestätigen wirksam. IMcpConfirmationService.
ConfirmAsync bekommt einen per [CallerMemberName] automatisch
befüllten operationKey - kein bestehender Aufruf musste geändert
werden. Freigaben leben als In-Memory-Bookkeeping auf der
Confirmation-Service-Instanz, gelten bis App-Ende, werden aber trotzdem
geloggt, damit stillschweigende Bestätigungen nicht spurlos bleiben.
Grund: 17 identische Bestätigungen in Folge für eine neu generierte
Unterrichtseinheit sind unzumutbar.

LessonStatus.Cancelled ("Ausgefallen") als Alternative zu
delete_lesson für Stunden, die nur ausgefallen sind (Exkursion,
Feiertag), aber als Ereignis dokumentiert bleiben sollen. Als
Cancelled=4 angehängt (bestehende LiteDB-Werte 0/1 sind fix). Konsistent
wie "bereits durchgeführt" behandelt an allen Stellen, die bisher nur
auf Conducted prüften: Terminverschiebung, Fortschrittsanzeige,
Hausaufgabe-kontrollieren-Erinnerung, "nächste Stunde"-Vorschlag.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-12 02:25:45 +02:00
adminandClaude Sonnet 5 a4733c156b feat: prominenterer Bestätigungsdialog + move_lesson/delete_lesson (Nutzer-Feedback)
Nach dem ersten Live-Test mit echtem Bridge-Prozess: der bisherige
ConfirmDialog fiel zu wenig auf, wenn LehrerApp im Hintergrund lief
(Normalfall, da der Anstoß vom KI-Client in einem anderen Fenster
kommt). Neuer, eigenständiger McpConfirmDialog statt Änderung am
geteilten ConfirmDialog (hätte alle anderen Aufrufer mitbetroffen):
breiter, auffälliger Kopfbereich, Topmost. AvaloniaMcpConfirmationService
holt das Hauptfenster zusätzlich aus einer möglichen Minimierung und
aktiviert es vor dem Anzeigen.

move_lesson kapselt die bereits vorhandene LessonSchedulingService.Move
(shiftFollowingLessons öffnet eine Lücke für eine neue Stunde, indem
spätere Stunden derselben Einheit mitverschoben werden) - keine neue
Terminlogik, nur Wiederverwendung.

delete_lesson ist eine bewusste, gezielte Ausnahme von "v1 ohne
Lösch-Tools" auf expliziten Nutzerwunsch: eigene
AllowedDestructiveWriteTools-Liste, Destructive=true-Annotation,
Bestätigungstext betont ausdrücklich die fehlende Papierkorb-Deckung
für Lessons.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-12 00:43:59 +02:00
adminandClaude Sonnet 5 e6c30b0bc7 feat: MCP-Tool add_lesson_attachment (Nutzer-Nachtrag)
CI / build-and-test (push) Canceled after 0s
download_lesson_attachment deckte bisher nur die Leserichtung ab, der
Nutzer legt Arbeitsblätter aber tatsächlich über die Anhang-Funktion an
Einzelstunden ab. Neues Write-Tool nimmt Dateiname + Base64-Inhalt
entgegen, validiert vor der Bestätigungsnachfrage (leer/ungültig/zu
groß) und lädt erst nach Bestätigung hoch.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-11 23:45:56 +02:00
adminandClaude Sonnet 5 55fba2cadb feat: lokaler MCP-Server, Phase 4 (Elternbrief-Vorlagen + Claude-Desktop-Registrierung)
Schließt die MCP-Server-Spec ab. "Worksheets" aus der Spec entsprechen
im tatsächlichen Datenmodell den .lavorlage-Elternbrief-Vorlagen
(LehrerApp.Templating) - es gibt kein separates Arbeitsblatt-Konzept mit
Fach/Klassenstufe-Metadaten. Neue Tools list_letter_templates (Read) und
render_letter (Read, liefert Base64-PDF, kein DB-Schreibzugriff).

upload_worksheet/update_worksheet bewusst nicht umgesetzt: das
Seitenlayout ist eine eigene positionsbasierte DSL mit eigenem
visuellen Editor (LehrerApp.TemplateDesigner) - ein LLM müsste sie
blind erzeugen, mit hohem Risiko für kaputte Layouts. Platzhalter-Logik
aus CreateLetterDialogViewModel nach LetterPlaceholderBuilder extrahiert,
damit Dialog und MCP-Tool nicht auseinanderdriften.

Neuer McpClientRegistrationService trägt den Bridge-Pfad in Claude
Desktops claude_desktop_config.json ein (Button in den Einstellungen,
nie automatisch), ohne bestehende Fremdeinträge zu verlieren und ohne
eine nicht lesbare Konfigurationsdatei zu überschreiben.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-11 22:53:46 +02:00
adminandClaude Sonnet 5 dd2e1e7c61 feat: lokaler MCP-Server, Phase 3 (kleinteilige Unit/Lesson-Tools)
Statt eines "update_lesson", das die ganze Stunde inkl. Verlaufsplan als
ein großes JSON-Objekt tauscht, gezielte kleine Tools je Teiloperation
(Nutzervorschlag): create/update_unit, create/update_lesson (Metadaten
ohne Phasen), add/update/remove_lesson_phase (je eine Phase),
download_lesson_attachment (Base64, auf 3 MB gedeckelt). Verkürzt das
Lesen-Schreiben-Zeitfenster je Operation und liefert lesbare Diffs für
den Bestätigungsdialog statt eines Objekt-Dumps.

ILessonRepository um GetById ergänzt (fehlte bisher, war aber
Voraussetzung für jedes der neuen Tools). get_lesson_plans liefert jetzt
zusätzlich Phase-IDs und Attachment-Metadaten, damit ein Client sie
gezielt referenzieren kann.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-11 21:52:48 +02:00
adminandClaude Sonnet 5 0c60a54c4d build: LehrerApp.McpBridge ins macOS-App-Bundle aufnehmen
build-macos-app.sh publiziert und platziert jetzt zusätzlich zur
Hauptapp die MCP-Bridge-Binary (Contents/MacOS/LehrerApp.McpBridge),
inklusive Universal-Binary-Handling und eigenständiger Ad-hoc-Signierung
vor dem Bundle-weiten codesign --deep-Schritt. Publish/Lipo/Kopier-Logik
dafür in eine wiederverwendbare place_binary()-Funktion extrahiert.

Nur auf Bash-Syntax geprüft (kein macOS in dieser Session verfügbar) -
noch nicht auf echtem macOS gebaut/verifiziert, siehe TODO.md 4.5.27.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-11 20:23:45 +02:00
adminandClaude Sonnet 5 9567d8d616 feat: lokaler MCP-Server, Phase 2 (Write-Tools + Bestätigungsdialog + Lesson-Plans)
Neue Write-Tools create_time_entry, create_grade_entry und
update_student_group_assignment schreiben nie direkt: jeder Aufruf zeigt
zuerst einen menschenlesbaren Bestätigungsdialog (bestehender
ConfirmDialog, über Dispatcher.UIThread aus dem Pipe-Session-Thread
angezeigt) und schreibt erst nach Bestätigung, mit 2-Minuten-Timeout
gegen eine hängende Session. Zusätzliches Read-Tool get_lesson_plans.

create_note bewusst nicht umgesetzt (kollidiert mit dem bestehenden
Dokumentations-Ausschluss aus Phase 1), create_lesson_plan/
update_lesson_plan wegen der Modellkomplexität von Lesson zurückgestellt
(siehe TODO.md 4.5.26).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-11 20:14:51 +02:00
adminandClaude Sonnet 5 98f5573999 feat: lokaler MCP-Server, Phase 1 (Infrastruktur + Read-Tools)
Erlaubt einem lokalen KI-Client (z.B. Claude Desktop) strukturierten
Lesezugriff auf Schüler, Klausuren, Noten, Stundenplan und Zeiterfassung.
Neuer LehrerApp.McpBridge-Prozess reicht stdio-JSON-RPC über eine Named
Pipe an einen In-Process-MCP-Server im Avalonia-Hauptprozess durch
(ModelContextProtocol.Core, StreamServerTransport direkt auf der Pipe).
Standardmäßig deaktiviert, Opt-in über neuen Einstellungen-Tab.
Dokumentationstypen (Gesprächsnotizen/Vorfälle/Förderpläne) sind auf
Code-Ebene nie erreichbar (McpToolScope, analog PlainEventStore.Allowed).

Write-Tools, Bestätigungsdialog-UI, Worksheets/Lesson-Plans-Tools und
macOS-Packaging folgen in späteren Phasen (siehe TODO.md 4.5.25).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-11 19:59:47 +02:00
admin 455c61c946 fix: Uhrzeit-Wraparound-Flakiness in MissingTeachingTime-Tests
CI / build-and-test (push) Canceled after 0s
TimeOnly.FromDateTime(DateTime.Now).AddHours(+-2) wickelt bei Mitternacht um (TimeOnly hat
keinen Datumsanteil) - je nach Ausfuehrungsuhrzeit (ca. 22:00-02:00) landete "+2 Stunden"
zeitlich vor statt nach "jetzt" oder umgekehrt, wodurch zwei Tests zufaellig rot wurden statt
das eigentliche Verhalten (Wartezeit nach Unterrichtsende) zu pruefen. Per Gegenprobe auf
unveraendertem main bestaetigt: dieselbe Flakiness besteht dort bereits.

DashboardViewModel erhaelt einen optionalen Func<DateTime>? now-Konstruktorparameter (Default
DateTime.Now, gleiches Muster wie die bereits bestehenden optionalen annualPlanEvents/
schoolWeather-Parameter) und nutzt ihn statt direkter DateTime.Now-Aufrufe in Load() und
LoadMissingTeachingTime. Die beiden betroffenen Tests injizieren jetzt einen festen
Referenzzeitpunkt (ein Feiertags-freier Dienstag im September) und sind damit unabhaengig
von der tatsaechlichen Ausfuehrungsuhrzeit deterministisch gruen.

Details siehe TODO.md, Abschnitt 9 (Dashboard).
2026-09-11 01:19:29 +02:00
admin 6af4bee1f0 refactor: Dashboard-Handlungsbedarf zu einer Karte zusammenfassen
Fasst die sieben Kacheln, die tatsaechlich "wo muss ich reagieren" beantworten
(Entschuldigungen, Fehlzeiten-Warnung, Foerderplan-Wiedervorlage, Korrekturen,
ungeplante Stunden, Auffaelligkeiten, Unterrichtszeit-Nacherfassung), zu einer
Karte "Handlungsbedarf" mit Filter-Chips zusammen statt sieben eigener
Sichtbarkeits-Schalter.

Neues gemeinsames Modell AttentionItem/AttentionGroup/AttentionAction traegt nur
Anzeigedaten - die Datenbeschaffung bleibt unveraendert in den jeweiligen
DashboardViewModel.LoadXxx-Methoden, die am Ende ein AttentionItem statt ihrer
eigenen Item-Klasse erzeugen. RebuildAttention() gruppiert nach Art (feste
Reihenfolge) und wendet Filter an, ohne die Repos erneut abzufragen.

SupportPlanDueItem, CorrectionProgressItem, UnplannedLessonItem und
DashboardAlertItem entfallen (nur dashboard-intern verwendet); OpenExcuseItem
und AttendanceWarningItem bleiben bestehen, da GroupOverviewViewModel sie
weiterhin nutzt. Fuenf Navigations-Commands wurden durch ein einziges
OpenAttentionItemCommand ersetzt.

Bewusste Verhaltensaenderung: LoadAlerts dupliziert Fehlzeiten-Ueberschreitungen
nicht mehr in die Auffaelligkeiten-Gruppe, da dieselbe Zahl sonst zweimal in
derselben Karte erschiene (vorher durch zwei getrennte Kacheln nicht sichtbar).

Details und Testanpassungen siehe TODO.md, Abschnitt 9 (Dashboard).
2026-09-11 00:40:58 +02:00
admin c3ce1a7204 fix: Dashboard-Kachelraster - Luecken und falsche Margins bei ausgeblendeten Kacheln
ApplyCardLayout() zaehlte bisher IsVisible statt EffectiveIsVisible, wodurch eine
eingeschaltete aber leere HideWhenEmpty-Kachel weiterhin einen Rasterplatz belegte.
Da im Alltag meist mehrere der sieben HideWhenEmpty-Kacheln leer sind, war das der
Normalfall, nicht die Ausnahme. UpdateDashboardSummary() setzte zudem alle IsEmpty-
Werte, ohne das Layout danach neu zu berechnen.

Zusaetzlich hatte jede Kachel ihren Grid-Margin fest im XAML verdrahtet (links/rechts),
obwohl Spalte und Zeile erst zur Laufzeit aus Sichtbarkeit und Reihenfolge berechnet
werden - beim Ausblenden einer Kachel wanderten die Nachbarn in die andere Spalte,
der Rinnstein sass dann auf der falschen Seite. DashboardCardOption.Margin leitet den
Wert jetzt aus Column ab; das XAML bindet darauf statt fixer Werte.

Drei Regressionstests decken beide Faelle ab.
2026-09-11 00:12:10 +02:00
143 changed files with 8830 additions and 673 deletions
+3
View File
@@ -25,6 +25,9 @@
<!-- API --> <!-- API -->
<PackageVersion Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.0" /> <PackageVersion Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.0" />
<!-- MCP-Server (lokal, siehe TODO.md) -->
<PackageVersion Include="ModelContextProtocol.Core" Version="2.2.0" />
<!-- Tests --> <!-- Tests -->
<PackageVersion Include="xunit" Version="2.9.3" /> <PackageVersion Include="xunit" Version="2.9.3" />
<PackageVersion Include="xunit.runner.visualstudio" Version="2.8.2" /> <PackageVersion Include="xunit.runner.visualstudio" Version="2.8.2" />
@@ -76,6 +76,9 @@ public class AiLesson
public List<AiPhaseStep> Phases { get; set; } = []; public List<AiPhaseStep> Phases { get; set; } = [];
public string? Homework { get; set; } public string? Homework { get; set; }
public string? Reflection { get; set; } public string? Reflection { get; set; }
/// Rohentwurf/erste Ideen der Lehrkraft vor der Feinplanung (siehe Lesson.PlanningIdeas) — reiner
/// Kontext für die KI wie Homework/Reflection, wird symmetrisch übernommen/zurückgegeben.
public string? PlanningIdeas { get; set; }
} }
public class AiPhaseStep public class AiPhaseStep
@@ -178,3 +181,54 @@ public class AiSubstanceResearchResponse
public string ActivityRestriction { get; set; } = ""; public string ActivityRestriction { get; set; } = "";
public string Source { get; set; } = ""; public string Source { get; set; } = "";
} }
/// <summary>
/// Wire-Vertrag für den Fehlzeiten-Statusvorschlag (ai-backend/untis-status.php, Nutzer-Feedback
/// zum Untis-Hub: der aus WebUntis abgeleitete Zielstatus war über den Anzeigetext oft nicht
/// nachvollziehbar). Bewusst OHNE jeden Personenbezug - <see cref="AiUntisStatusRow.Id"/> ist eine
/// rein technische, für die KI bedeutungslose Kennung, über die der Desktop-Client die Antwort
/// zurückordnet; kein Name, keine Klasse, kein Datum verlässt damit die App. Mehrere Zeilen eines
/// Abgleichslaufs werden in einer Anfrage gebündelt statt je Zeile einzeln (Kosten/Latenz).
/// </summary>
public class AiUntisStatusRequest
{
public List<AiUntisStatusRow> Rows { get; set; } = [];
}
public class AiUntisStatusRow
{
public string Id { get; set; } = "";
public string ReasonText { get; set; } = "";
public int AbsentMinutes { get; set; }
/// True, wenn WebUntis für den Eintrag bereits ein Bearbeitungsdatum führt (unabhängig vom
/// tatsächlichen Datum, das aus Datenschutzgründen nicht mitgeschickt wird).
public bool HandledOn { get; set; }
/// Schulinterne Konvention: Klammerung der Entschuldigungsnummer bedeutet unentschuldigt, ohne
/// Klammern entschuldigt; null, wenn keine Nummer hinterlegt ist (siehe UntisDiffService-Analog
/// in WebUntisLessonAbsenceComparisonViewModel.MapStatus).
public bool? ExternKeyInParentheses { get; set; }
/// Bereits regelbasiert ermittelter Status (siehe MapStatus) - der Systemprompt bittet die KI,
/// nur bei eindeutigem Widerspruch im Freitext davon abzuweichen, statt bei Unsicherheit zu raten.
public string CurrentGuess { get; set; } = "";
}
/// <summary>
/// Enthält absichtlich GENAU eine Zeile je gesendeter <see cref="AiUntisStatusRow.Id"/> - der
/// Client verwirft die gesamte Antwort, wenn die zurückgegebene Id-Menge nicht exakt der
/// gesendeten entspricht (siehe AiPlanningService.RequestUntisStatusSuggestionsAsync), statt sich
/// auf die Reihenfolge zu verlassen. So bleibt eine Verwechslung zwischen Vorschlag und Zeile
/// strukturell ausgeschlossen, nicht nur im Regelfall vermieden.
/// </summary>
public class AiUntisStatusResponse
{
public List<AiUntisStatusSuggestion> Suggestions { get; set; } = [];
}
public class AiUntisStatusSuggestion
{
public string Id { get; set; } = "";
/// Einer von "Present"/"Late"/"LeftDuringClass"/"ExcusePending"/"Excused"/"Unexcused" (siehe
/// Systemprompt in ai-backend/untis-status.php) - wird client-seitig gegen genau diese Menge
/// geprüft, bevor er als AttendanceStatus interpretiert wird.
public string Status { get; set; } = "";
}
@@ -95,6 +95,7 @@ public interface IUnitRepository
} }
public interface ILessonRepository public interface ILessonRepository
{ {
Lesson? GetById(Guid id);
List<Lesson> GetByUnit(Guid unitId); List<Lesson> GetByUnit(Guid unitId);
List<Lesson> GetByGroupAndDate(Guid groupId, DateOnly date); List<Lesson> GetByGroupAndDate(Guid groupId, DateOnly date);
List<Lesson> GetByGroupAndRange(Guid groupId, DateOnly from, DateOnly to); List<Lesson> GetByGroupAndRange(Guid groupId, DateOnly from, DateOnly to);
@@ -284,6 +285,10 @@ public interface ISubjectRepository
public interface ICompetencyDomainRepository public interface ICompetencyDomainRepository
{ {
List<CompetencyDomain> GetBySubjectAndGrade(Guid subjectId, int gradeLevel); List<CompetencyDomain> GetBySubjectAndGrade(Guid subjectId, int gradeLevel);
// Über alle Klassenstufen eines Fachs hinweg - für einen Überblick über den gesamten Katalog
// eines Fachs (z.B. MCP-Tool get_competency_catalog ohne gradeLevel-Filter), ohne dass der
// Aufrufer erst jede vorkommende Klassenstufe einzeln erraten/abfragen müsste.
List<CompetencyDomain> GetBySubject(Guid subjectId);
CompetencyDomain? GetById(Guid id); CompetencyDomain? GetById(Guid id);
void Save(CompetencyDomain domain); void Save(CompetencyDomain domain);
void Delete(Guid id); void Delete(Guid id);
+37
View File
@@ -0,0 +1,37 @@
namespace LehrerApp.Core.Mcp;
/// <summary>
/// Named-Pipe-Konvention zwischen dem Avalonia-Hauptprozess (Pipe-Server, siehe
/// LehrerApp.Desktop/Services/Mcp) und LehrerApp.McpBridge (Pipe-Client). Bewusst als einzelne
/// geteilte Konstante statt eigener Bibliothek — die Bridge reicht JSON-RPC-Nachrichten unverändert
/// durch und braucht sonst keine gemeinsamen Typen mit der App (siehe Planungsdokument).
/// </summary>
public static class McpPipeConstants
{
public const string PipeName = "LehrerApp.Mcp";
/// <summary>
/// Unter macOS/Linux emuliert .NET Named Pipes über eine Socket-Datei unter
/// <see cref="Path.GetTempPath"/> (intern "CoreFxPipe_" + Name), und die liest dafür die
/// TMPDIR-Umgebungsvariable des jeweiligen Prozesses. LehrerApp.Desktop (von Finder/Dock
/// gestartet) bekommt das reguläre per-Login-Session-TMPDIR unter /var/folders/.../T,
/// während LehrerApp.McpBridge als Kindprozess von Claude Desktop oft eine reduzierte
/// Umgebung mit TMPDIR=/tmp erbt - beide Prozesse suchen die Pipe-Datei dann an
/// unterschiedlichen Orten und finden sich nie ("MCP nicht erreichbar, obwohl LehrerApp
/// läuft", auch wenn beide Prozesse laufen und die Pipe grundsätzlich offen ist). Fix: TMPDIR
/// für beide Prozesse hart auf denselben Ordner setzen, bevor die erste
/// NamedPipeServerStream/-ClientStream-Instanz entsteht - dieselbe ApplicationData-Basis wie
/// AppBootstrapper (siehe dort) wird anders als TMPDIR zuverlässig an Kindprozesse
/// weitergereicht. Muss als eine der ersten Anweisungen in Main aufgerufen werden (Desktop wie
/// Bridge), auf Windows ein No-op (dort nutzen Named Pipes den Kernel-Namespace, keine
/// Socket-Datei).
/// </summary>
public static void EnsureStableUnixSocketDirectory()
{
if (OperatingSystem.IsWindows()) return;
var dir = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
"LehrerApp", "ipc");
Directory.CreateDirectory(dir);
Environment.SetEnvironmentVariable("TMPDIR", dir);
}
}
+8
View File
@@ -27,6 +27,14 @@ public class LearningGroup
/// </summary> /// </summary>
public int? WebUntisLessonId { get; set; } public int? WebUntisLessonId { get; set; }
/// <summary> /// <summary>
/// Blendet diese Lerngruppe im UntisHub (siehe <see cref="Services"/>-Layer im Desktop-Projekt:
/// UntisHubService) aus, auch wenn <see cref="WebUntisLessonId"/> gesetzt ist - z.B. für
/// Klassenrat/AGs, die zwar eine WebUntis-Unterrichtsnummer haben, aber nicht auf
/// Fehlzeiten/offene Periods hin überwacht werden sollen. Default false (alte Datensätze
/// verhalten sich unverändert wie bisher).
/// </summary>
public bool ExcludedFromUntisHub { get; set; }
/// <summary>
/// Vorgängergruppe aus dem Hochstufen (<see cref="Services.GroupRolloverService"/>) — /// Vorgängergruppe aus dem Hochstufen (<see cref="Services.GroupRolloverService"/>) —
/// ermöglicht einen Schuljahresvergleich (z.B. Klausurschnitt) über die Kette hinweg. /// ermöglicht einen Schuljahresvergleich (z.B. Klausurschnitt) über die Kette hinweg.
/// Wird ausschließlich beim Hochstufen automatisch gesetzt; vor Einführung dieses Felds /// Wird ausschließlich beim Hochstufen automatisch gesetzt; vor Einführung dieses Felds
+40 -1
View File
@@ -54,7 +54,15 @@ public class Lesson : IHasAttachments
/// Optionaler Stundenbeginn — solange kein Stundenplan (Kapitel 4.3) existiert, manuell /// Optionaler Stundenbeginn — solange kein Stundenplan (Kapitel 4.3) existiert, manuell
/// gepflegt. Dient nur der abgeleiteten Uhrzeit-Anzeige je Phase in <see cref="Phases"/>. /// gepflegt. Dient nur der abgeleiteten Uhrzeit-Anzeige je Phase in <see cref="Phases"/>.
public TimeOnly? StartTime { get; set; } public TimeOnly? StartTime { get; set; }
/// Grobe Ideen/erster Entwurf, festgehalten lange bevor der Verlaufsplan (<see cref="Phases"/>)
/// feingeplant wird (Nutzer-Feedback: die eigentliche Ideenfindung liegt zeitlich oft weit vor
/// der Feinplanung) — bewusst ein eigenes Feld statt Zweckentfremdung von <see cref="Reflection"/>
/// (die ist nach der Stunde) oder <see cref="Unit.Notes"/> (die ist auf Einheitenebene, nicht je
/// Stunde). Wird auch der KI-Planungsunterstützung (4.5.9) als Kontext mitgegeben, damit erste
/// eigene Ideen bei der KI-gestützten Weiterplanung nicht erneut abgetippt werden müssen.
public string? PlanningIdeas { get; set; }
public List<LessonPhaseStep> Phases { get; set; } = []; public List<LessonPhaseStep> Phases { get; set; } = [];
public TeachingTimelineState? TeachingTimeline { get; set; }
public string? Homework { get; set; } public string? Homework { get; set; }
/// Markiert, dass die hier eingetragene Hausaufgabe in einer Folgestunde besprochen/kontrolliert /// Markiert, dass die hier eingetragene Hausaufgabe in einer Folgestunde besprochen/kontrolliert
/// wurde — treibt das Stundenplan-Badge "Hausaufgabe kontrollieren" (4.5.4). /// wurde — treibt das Stundenplan-Badge "Hausaufgabe kontrollieren" (4.5.4).
@@ -63,6 +71,11 @@ public class Lesson : IHasAttachments
/// wenn die Hausaufgabe absichtlich nicht mehr kontrolliert wird). /// wenn die Hausaufgabe absichtlich nicht mehr kontrolliert wird).
public bool HomeworkCheckDismissed { get; set; } public bool HomeworkCheckDismissed { get; set; }
public string? Reflection { get; set; } public string? Reflection { get; set; }
/// Kompetenzcodes (<see cref="CompetencyItem.Code"/>) aus dem Katalog, die dieser Stunde als
/// Ganzes zugeordnet sind — analog zu <see cref="Unit.Competencies"/>, aber auf Stundenebene statt
/// Einheitenebene. Bewusst noch keine Verknüpfung je einzelner <see cref="LessonPhaseStep"/>
/// (siehe TODO.md 4.5.8, weiterhin offen); diese Liste deckt nur die gröbere Zuordnung ab.
public List<string> Competencies { get; set; } = [];
public LessonStatus Status { get; set; } = LessonStatus.Planned; public LessonStatus Status { get; set; } = LessonStatus.Planned;
/// Material/Arbeitsblätter sowie fachspezifische Anhänge (z.B. Experiment- und /// Material/Arbeitsblätter sowie fachspezifische Anhänge (z.B. Experiment- und
/// Gefährdungsbeurteilungs-Dokumente im Chemieunterricht) — dieselbe Anhang-Infrastruktur wie /// Gefährdungsbeurteilungs-Dokumente im Chemieunterricht) — dieselbe Anhang-Infrastruktur wie
@@ -89,6 +102,14 @@ public class LessonPhaseStep
public string Activity { get; set; } = ""; public string Activity { get; set; } = "";
public string Material { get; set; } = ""; public string Material { get; set; } = "";
public string Shorthand { get; set; } = ""; public string Shorthand { get; set; } = "";
/// Der über <see cref="AiPlanning.AiPlanningService.BuildMaterialPrompt"/> erzeugte, vollständige
/// Prompt für diese Phase (4.5.20), sofern die KI beim letzten "Übernehmen" einen Medienvorschlag
/// gemacht hatte — anders als der reine Vorschlagstext (<c>AiPhaseStep.MaterialSuggestion</c>,
/// nur transient während der Review) bewusst hier persistiert (Nutzer-Feedback: der Prompt soll
/// auch nach dem Übernehmen noch abrufbar/erneut kopierbar bleiben, statt nur einmalig im
/// Review-Dialog verfügbar zu sein). Rein informativ, kein Datenmodell-Bezug zu einem separaten
/// "Material"-Konzept, das es weiterhin nicht gibt — siehe TODO.md 4.5.26.
public string? MaterialPrompt { get; set; }
/// <summary> /// <summary>
/// null = Hauptweg. Sonst Verweis auf einen benannten <see cref="AlternativeLessonPath"/> aus /// null = Hauptweg. Sonst Verweis auf einen benannten <see cref="AlternativeLessonPath"/> aus
/// dem Katalog, über den Phasen alternativer Unterrichtsverläufe zusammengehören — /// dem Katalog, über den Phasen alternativer Unterrichtsverläufe zusammengehören —
@@ -103,7 +124,7 @@ public enum UnitStatus { Planned, Active, Completed }
// Planned=0 und Conducted=1 bleiben absichtlich an ihren bisherigen numerischen Positionen: // Planned=0 und Conducted=1 bleiben absichtlich an ihren bisherigen numerischen Positionen:
// LiteDB hat diese Werte bereits gespeichert. Die neuen Zustände werden nur angehängt, damit // LiteDB hat diese Werte bereits gespeichert. Die neuen Zustände werden nur angehängt, damit
// vorhandene Daten ohne Migration weiterhin korrekt gelesen werden. // vorhandene Daten ohne Migration weiterhin korrekt gelesen werden.
public enum LessonStatus { Planned = 0, Conducted = 1, Draft = 2, Ready = 3 } public enum LessonStatus { Planned = 0, Conducted = 1, Draft = 2, Ready = 3, Cancelled = 4 }
/// <summary> /// <summary>
/// Katalogeintrag für einen wiederverwendbaren "alternativen Ablauf" (z.B. "Kurzversion" bei /// Katalogeintrag für einen wiederverwendbaren "alternativen Ablauf" (z.B. "Kurzversion" bei
@@ -239,3 +260,21 @@ public class ReportGrade
public bool IsLocked { get; set; } public bool IsLocked { get; set; }
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow; public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
} }
/// <summary>Live timing is separate from the original lesson plan.</summary>
public class TeachingTimelineState
{
public DateTime StartUtc { get; set; }
public DateTime EndUtc { get; set; }
public DateTime? HeldSinceUtc { get; set; }
public Guid? HeldPhaseId { get; set; }
public List<TeachingPhaseTiming> Phases { get; set; } = [];
public List<Guid> TransferredPhaseIds { get; set; } = [];
}
public class TeachingPhaseTiming
{
public Guid PhaseId { get; set; }
public double Minutes { get; set; }
public bool ExplicitlyStarted { get; set; }
}
@@ -12,10 +12,13 @@ public sealed class DashboardCardSetting
/// <summary>Speichert Sichtbarkeit und Reihenfolge der Dashboard-Kacheln lokal.</summary> /// <summary>Speichert Sichtbarkeit und Reihenfolge der Dashboard-Kacheln lokal.</summary>
public sealed class DashboardSettingsService public sealed class DashboardSettingsService
{ {
// "attention" fasst die frueheren sieben Kacheln missingteachingtime/excuses/corrections/
// unplanned/alerts/attendance/support zusammen (siehe AttentionItem.cs im Desktop-Projekt).
// Alte gespeicherte dashboardsettings.json-Dateien mit den frueheren Keys sind unproblematisch:
// Load() unten verwirft unbekannte Keys ohnehin stillschweigend und ergaenzt neue als sichtbar.
public static readonly string[] DefaultCardOrder = public static readonly string[] DefaultCardOrder =
[ [
"today", "tasks", "missingteachingtime", "calendar", "excuses", "upcoming", "today", "tasks", "attention", "calendar", "upcoming", "groups", "examload",
"corrections", "unplanned", "alerts", "attendance", "support", "groups", "examload",
]; ];
private readonly string _configPath; private readonly string _configPath;
@@ -16,7 +16,7 @@ public sealed class LessonSchedulingService(ILessonRepository lessons)
{ {
foreach (var other in lessons.GetByUnit(lesson.UnitId)) foreach (var other in lessons.GetByUnit(lesson.UnitId))
{ {
if (other.Id == lesson.Id || other.Status == LessonStatus.Conducted || other.Date <= oldDate) if (other.Id == lesson.Id || other.Status is LessonStatus.Conducted or LessonStatus.Cancelled || other.Date <= oldDate)
continue; continue;
other.Date = other.Date.AddDays(delta); other.Date = other.Date.AddDays(delta);
lessons.Save(other); lessons.Save(other);
@@ -67,7 +67,7 @@ public sealed class LessonSchedulingService(ILessonRepository lessons)
HomeworkChecked = source.HomeworkChecked, HomeworkChecked = source.HomeworkChecked,
HomeworkCheckDismissed = source.HomeworkCheckDismissed, HomeworkCheckDismissed = source.HomeworkCheckDismissed,
Reflection = source.Reflection, Reflection = source.Reflection,
Status = source.Status == LessonStatus.Conducted ? LessonStatus.Draft : source.Status, Status = source.Status is LessonStatus.Conducted or LessonStatus.Cancelled ? LessonStatus.Draft : source.Status,
}; };
source.Homework = null; source.Homework = null;
source.HomeworkChecked = false; source.HomeworkChecked = false;
+20
View File
@@ -57,4 +57,24 @@ public sealed class ChangeHookTests
Assert.Null(exception); Assert.Null(exception);
} }
// Kein eigener Fall in ChangeHookMatrixTests: UntisHubJobStateRepository kennt kein Delete
// (siehe IUntisHubJobStateRepository), passt also nicht in deren Save+Delete-Tabellenform.
[Fact]
public void UntisHubJobStateRepository_Save_LoestOnChangeAus()
{
using var db = NewInMemoryContext();
var calls = new List<(string EntityType, string EntityId, string Operation, object? Payload)>();
db.OnChange = (type, id, op, payload) => calls.Add((type, id, op, payload));
var repo = new UntisHubJobStateRepository(db);
var state = new UntisHubJobState { Kind = UntisHubJobKind.OffenePeriods, LastRunAt = DateTime.UtcNow };
repo.Save(state);
var call = Assert.Single(calls);
Assert.Equal(nameof(UntisHubJobState), call.EntityType);
Assert.Equal(state.Id.ToString(), call.EntityId);
Assert.Equal("Save", call.Operation);
Assert.Same(state, call.Payload);
}
} }
+11 -1
View File
@@ -386,6 +386,7 @@ public class UnitRepository(LiteDbContext db) : IUnitRepository
public class LessonRepository(LiteDbContext db) : ILessonRepository public class LessonRepository(LiteDbContext db) : ILessonRepository
{ {
public Lesson? GetById(Guid id) => db.Lessons.FindById(id);
public List<Lesson> GetByUnit(Guid id) => public List<Lesson> GetByUnit(Guid id) =>
db.Lessons.Find(l => l.UnitId == id).OrderBy(l => l.Date).ThenBy(l => l.LessonNumber).ToList(); db.Lessons.Find(l => l.UnitId == id).OrderBy(l => l.Date).ThenBy(l => l.LessonNumber).ToList();
public List<Lesson> GetByGroupAndDate(Guid gid, DateOnly date) => public List<Lesson> GetByGroupAndDate(Guid gid, DateOnly date) =>
@@ -906,7 +907,11 @@ public class UntisHubJobStateRepository(LiteDbContext db) : IUntisHubJobStateRep
public List<UntisHubJobState> GetAll() => db.UntisHubJobStates.FindAll().ToList(); public List<UntisHubJobState> GetAll() => db.UntisHubJobStates.FindAll().ToList();
public void Save(UntisHubJobState state) => db.UntisHubJobStates.Upsert(state); public void Save(UntisHubJobState state)
{
db.UntisHubJobStates.Upsert(state);
db.OnChange?.Invoke(nameof(UntisHubJobState), state.Id.ToString(), "Save", state);
}
} }
public class CompetencyDomainRepository(LiteDbContext db) : ICompetencyDomainRepository public class CompetencyDomainRepository(LiteDbContext db) : ICompetencyDomainRepository
@@ -916,6 +921,11 @@ public class CompetencyDomainRepository(LiteDbContext db) : ICompetencyDomainRep
.Find(d => d.SubjectId == subjectId && d.GradeLevel == gradeLevel) .Find(d => d.SubjectId == subjectId && d.GradeLevel == gradeLevel)
.OrderBy(d => d.SortOrder) .OrderBy(d => d.SortOrder)
.ToList(); .ToList();
public List<CompetencyDomain> GetBySubject(Guid subjectId) =>
db.CompetencyDomains
.Find(d => d.SubjectId == subjectId)
.OrderBy(d => d.GradeLevel).ThenBy(d => d.SortOrder)
.ToList();
public CompetencyDomain? GetById(Guid id) => db.CompetencyDomains.FindById(id); public CompetencyDomain? GetById(Guid id) => db.CompetencyDomains.FindById(id);
public void Save(CompetencyDomain d) public void Save(CompetencyDomain d)
{ {
@@ -116,6 +116,72 @@ public sealed class AiPlanningServiceTests
Assert.Equal("Gerettete Stunde", Assert.Single(lessons.GetByUnit(unit.Id)).Topic); Assert.Equal("Gerettete Stunde", Assert.Single(lessons.GetByUnit(unit.Id)).Topic);
} }
[Fact]
public async Task RequestUntisStatusSuggestionsAsync_PassendeIds_LiefertZuordnung()
{
const string body = """
{"suggestions":[{"id":"0","status":"Excused"},{"id":"1","status":"Unexcused"}]}
""";
var service = BuildWithResponse(HttpStatusCode.OK, body);
var rows = new[]
{
new AiUntisStatusRow { Id = "0", CurrentGuess = "ExcusePending" },
new AiUntisStatusRow { Id = "1", CurrentGuess = "ExcusePending" },
};
var suggestions = await service.RequestUntisStatusSuggestionsAsync(rows, "token");
Assert.Equal("Excused", suggestions["0"]);
Assert.Equal("Unexcused", suggestions["1"]);
}
// Kernabsicherung gegen Verwechslung (Nutzer-Feedback zum Untis-Hub, siehe
// AiPlanningService.RequestUntisStatusSuggestionsAsync): weicht die zurückgegebene Id-Menge
// auch nur minimal von der gesendeten ab (hier: eine erfundene Id "2" statt "1"), wird die
// gesamte Antwort verworfen statt sich auf eine möglicherweise vermischte Zuordnung zu verlassen.
[Fact]
public async Task RequestUntisStatusSuggestionsAsync_AbweichendeIdMenge_VerwirftKomplett()
{
const string body = """
{"suggestions":[{"id":"0","status":"Excused"},{"id":"2","status":"Unexcused"}]}
""";
var service = BuildWithResponse(HttpStatusCode.OK, body);
var rows = new[]
{
new AiUntisStatusRow { Id = "0", CurrentGuess = "ExcusePending" },
new AiUntisStatusRow { Id = "1", CurrentGuess = "ExcusePending" },
};
var suggestions = await service.RequestUntisStatusSuggestionsAsync(rows, "token");
Assert.Empty(suggestions);
}
[Fact]
public async Task RequestUntisStatusSuggestionsAsync_DoppelteId_VerwirftKomplett()
{
const string body = """
{"suggestions":[{"id":"0","status":"Excused"},{"id":"0","status":"Unexcused"}]}
""";
var service = BuildWithResponse(HttpStatusCode.OK, body);
var rows = new[] { new AiUntisStatusRow { Id = "0", CurrentGuess = "ExcusePending" } };
var suggestions = await service.RequestUntisStatusSuggestionsAsync(rows, "token");
Assert.Empty(suggestions);
}
[Fact]
public async Task RequestUntisStatusSuggestionsAsync_KeineZeilen_KeinNetzwerkaufruf()
{
var service = Build(new FakeLessons(), new FakeGroups([]), new FakeSubjects([]),
new FakeCompetencyDomains(), new FakeAlternativeLessonPaths([]));
var suggestions = await service.RequestUntisStatusSuggestionsAsync([], "token");
Assert.Empty(suggestions);
}
private static AiPlanningService BuildWithResponse(HttpStatusCode status, string body) private static AiPlanningService BuildWithResponse(HttpStatusCode status, string body)
{ {
var http = new HttpClient(new StaticResponseHandler(status, body)) var http = new HttpClient(new StaticResponseHandler(status, body))
@@ -2,6 +2,7 @@ using LehrerApp.Core.Models;
using LehrerApp.Core.Services; using LehrerApp.Core.Services;
using LehrerApp.Desktop.Services; using LehrerApp.Desktop.Services;
using LehrerApp.Desktop.ViewModels.ClassTeacher; using LehrerApp.Desktop.ViewModels.ClassTeacher;
using LehrerApp.Templating;
using Xunit; using Xunit;
namespace LehrerApp.Desktop.Tests; namespace LehrerApp.Desktop.Tests;
@@ -71,6 +72,87 @@ public sealed class ClassTeacherViewModelsTests
Assert.False(days[4].HasSignal); // Hausaufgaben bleiben im Widget bewusst ausgeblendet Assert.False(days[4].HasSignal); // Hausaufgaben bleiben im Widget bewusst ausgeblendet
} }
[Fact]
public void Kompaktkalender_KannAufEinzelnenSchuelerEingeschraenktWerden()
{
var month = new DateOnly(2026, 9, 1);
var absences = new[]
{
new ClassAbsenceDaySummaryRow(new DateOnly(2026, 9, 3), "Müller Ada", 1, 2, 90,
["Deu"], [1, 2], ["nicht entsch."], ["Absent"], null, null, false),
new ClassAbsenceDaySummaryRow(new DateOnly(2026, 9, 4), "Schmidt Ben", 2, 2, 90,
["Mathe"], [1, 2], ["entsch."], ["Absent"], null, null, false),
};
var days = ClassTeacherOverviewViewModel.BuildCompactMonthDays(month, absences, [], month,
"Ada Müller");
Assert.Equal("U", days[2].SignalCode);
Assert.False(days[3].HasSignal);
}
[Fact]
public void ElternbriefKalender_LiefertPortablenDrawingPlatzhalterFuerSchueler()
{
var month = new DateOnly(2026, 9, 1);
var absences = new[]
{
new ClassAbsenceDaySummaryRow(new DateOnly(2026, 9, 3), "Müller Ada", 1, 2, 90,
["Deu"], [1, 2], ["nicht entsch."], ["Absent"], null, null, false),
new ClassAbsenceDaySummaryRow(new DateOnly(2026, 9, 4), "Schmidt Ben", 2, 2, 90,
["Mathe"], [1, 2], ["entsch."], ["Absent"], null, null, false),
};
var drawing = StudentAttendanceCalendarDrawingBuilder.Build("Ada Müller", month, absences, []);
Assert.True(drawing.ContentHeight > 0);
Assert.Contains(drawing.Commands.OfType<DrawStringEx>(), c => c.Text == "Ada Müller");
Assert.Contains(drawing.Commands.OfType<DrawStringEx>(), c => c.Text == "U");
Assert.DoesNotContain(drawing.Commands.OfType<DrawStringEx>(), c => c.Text == "E");
}
[Fact]
public void ElternbriefKalender_UnterstuetztEinBisDreiMonateUndGroessen()
{
var start = new DateOnly(2026, 9, 18);
var small = StudentAttendanceCalendarDrawingBuilder.Build("Ada Müller",
new AttendanceCalendarOptions(start, 3, AttendanceCalendarSize.Small), [], []);
var large = StudentAttendanceCalendarDrawingBuilder.Build("Ada Müller",
new AttendanceCalendarOptions(start, 3, AttendanceCalendarSize.Large), [], []);
var labels = large.Commands.OfType<DrawStringEx>().Select(c => c.Text).ToList();
Assert.Contains("September 2026", labels);
Assert.Contains("Oktober 2026", labels);
Assert.Contains("November 2026", labels);
Assert.True(large.ContentHeight > small.ContentHeight);
}
[Fact]
public void ElternbriefFehltage_ListetDatumUmfangUndEntschuldigungsstatus()
{
var options = new AttendanceCalendarOptions(new DateOnly(2026, 9, 1), 1);
var absences = new[]
{
new ClassAbsenceDaySummaryRow(new DateOnly(2026, 9, 3), "Müller Ada", 1, 6, 270,
["Deu"], [1, 2, 3, 4, 5, 6], ["entsch."], ["Krank"], null, null, true),
new ClassAbsenceDaySummaryRow(new DateOnly(2026, 9, 8), "Müller Ada", 1, 2, 90,
["Mathe"], [1, 2], ["nicht entsch."], ["Absent"], null, null, false),
new ClassAbsenceDaySummaryRow(new DateOnly(2026, 9, 9), "Schmidt Ben", 2, 4, 180,
["Eng"], [1, 2, 3, 4], ["entsch."], ["Krank"], null, null, true),
};
var drawing = StudentAbsenceDayListDrawingBuilder.Build("Ada Müller", options, absences);
var labels = drawing.Commands.OfType<DrawStringEx>().Select(c => c.Text).ToList();
Assert.Contains("03.09.2026", labels);
Assert.Contains("Ganzer Fehltag", labels);
Assert.Contains("08.09.2026", labels);
Assert.Contains("Fehlzeit · 2 Std.", labels);
Assert.Contains("Entschuldigt", labels);
Assert.Contains("Unentschuldigt", labels);
Assert.DoesNotContain("09.09.2026", labels);
}
[Fact] [Fact]
public void GroupByStudentAndDay_FasstFehlstundenProSchuelerUndTagZusammen() public void GroupByStudentAndDay_FasstFehlstundenProSchuelerUndTagZusammen()
{ {
@@ -1,4 +1,5 @@
using LehrerApp.Core.Models; using LehrerApp.Core.Models;
using LehrerApp.Desktop.Services;
using LehrerApp.Desktop.ViewModels.Students; using LehrerApp.Desktop.ViewModels.Students;
using LehrerApp.Templating; using LehrerApp.Templating;
using Xunit; using Xunit;
@@ -54,6 +55,185 @@ public sealed class CreateLetterDialogViewModelTests : IDisposable
Assert.Equal("%PDF", System.Text.Encoding.ASCII.GetString(File.ReadAllBytes(output), 0, 4)); Assert.Equal("%PDF", System.Text.Encoding.ASCII.GetString(File.ReadAllBytes(output), 0, 4));
} }
[Fact]
public async Task AdvancedContent_Anwesenheitskalender_WirdAlsDrawingGerendert()
{
var store = StoreWithTemplate(new PlaceholderDefinition(
StudentAttendanceCalendarDrawingBuilder.PlaceholderName, PlaceholderType.Drawing, true));
var drawing = StudentAttendanceCalendarDrawingBuilder.Build("Lena Beispiel", new DateOnly(2026, 9, 1), [], []);
var vm = new CreateLetterDialogViewModel(StudentWithContact("Sehr geehrte Frau Muster,"), store,
new QuestTemplateRenderer(), new FakeMemberships([]), new FakeGroups([]), _ => drawing);
var output = Path.Combine(_directory, "Kalender.pdf");
Assert.True(vm.UsesAttendanceCalendar);
await vm.SetAttendanceCalendarOptionsAsync(new AttendanceCalendarOptions(
new DateOnly(2026, 8, 19), 3, AttendanceCalendarSize.Large));
Assert.True(vm.AttendanceCalendarConfigured);
Assert.Contains("August 2026", vm.AttendanceCalendarSummary);
Assert.Contains("3 Monate", vm.AttendanceCalendarSummary);
Assert.Contains("Groß", vm.AttendanceCalendarSummary);
Assert.True(vm.Generate(output));
Assert.Equal("%PDF", System.Text.Encoding.ASCII.GetString(File.ReadAllBytes(output), 0, 4));
}
[Fact]
public async Task AttendanceKalenderKonfigurieren_LoestGezieltenDatenAbrufFuerDenZeitraumAus()
{
var store = StoreWithTemplate(new PlaceholderDefinition(
StudentAttendanceCalendarDrawingBuilder.PlaceholderName, PlaceholderType.Drawing, true));
AttendanceCalendarOptions? requested = null;
var vm = new CreateLetterDialogViewModel(StudentWithContact("Sehr geehrte Frau Muster,"), store,
new QuestTemplateRenderer(), new FakeMemberships([]), new FakeGroups([]),
_ => new DrawingValue([], 0), attendanceDataRefresher: (options, _) =>
{ requested = options; return Task.CompletedTask; });
await vm.SetAttendanceCalendarOptionsAsync(new AttendanceCalendarOptions(
new DateOnly(2026, 9, 1), 2, AttendanceCalendarSize.Medium));
Assert.NotNull(requested);
Assert.Equal(new DateOnly(2026, 9, 1), requested!.NormalizedStartMonth);
Assert.Equal(2, requested.NormalizedMonthCount);
Assert.False(vm.IsRefreshingAttendanceData);
Assert.Equal("", vm.AttendanceRefreshError);
}
[Fact]
public async Task AttendanceKalenderKonfigurieren_ZeigtFehlerBeiFehlgeschlagenemAbrufAnStattZuBlockieren()
{
var store = StoreWithTemplate(new PlaceholderDefinition(
StudentAttendanceCalendarDrawingBuilder.PlaceholderName, PlaceholderType.Drawing, true));
var vm = new CreateLetterDialogViewModel(StudentWithContact("Sehr geehrte Frau Muster,"), store,
new QuestTemplateRenderer(), new FakeMemberships([]), new FakeGroups([]),
_ => new DrawingValue([], 0),
attendanceDataRefresher: (_, _) => throw new WebUntisIntegrationException("Keine Verbindung."));
await vm.SetAttendanceCalendarOptionsAsync(new AttendanceCalendarOptions(
new DateOnly(2026, 9, 1), 1, AttendanceCalendarSize.Medium));
Assert.True(vm.AttendanceCalendarConfigured);
Assert.Contains("Keine Verbindung.", vm.AttendanceRefreshError);
Assert.False(vm.IsRefreshingAttendanceData);
}
[Fact]
public void AdvancedContent_Fehlzeitenliste_AktiviertKonfigurationsschritt()
{
var store = StoreWithTemplate(new PlaceholderDefinition(
StudentAbsenceDayListDrawingBuilder.PlaceholderName, PlaceholderType.Drawing, true));
var drawing = StudentAbsenceDayListDrawingBuilder.Build("Lena Beispiel",
new AttendanceCalendarOptions(new DateOnly(2026, 9, 1), 1), []);
var vm = new CreateLetterDialogViewModel(StudentWithContact("Sehr geehrte Frau Muster,"), store,
new QuestTemplateRenderer(), new FakeMemberships([]), new FakeGroups([]),
attendanceCalendarFactory: null, absenceDayListFactory: _ => drawing);
Assert.False(vm.UsesAttendanceCalendar);
Assert.True(vm.UsesAbsenceDayList);
Assert.True(vm.UsesAttendanceAdvancedContent);
Assert.True(vm.CanGenerate);
}
[Fact]
public void EigenerPlatzhalter_KannImDialogEingegebenWerden()
{
var store = StoreWithTemplate(new PlaceholderDefinition("Anrede", PlaceholderType.Text, true),
new PlaceholderDefinition("Datum", PlaceholderType.Date, true),
new PlaceholderDefinition("Brieftext", PlaceholderType.Multiline, true),
new PlaceholderDefinition("Betreff", PlaceholderType.Text, true));
var vm = Build(StudentWithContact("Sehr geehrte Frau Muster,"), store);
vm.LetterText = "Dies ist der Inhalt.";
var betreff = Assert.Single(vm.CustomPlaceholders);
Assert.Equal("Betreff", betreff.Name);
Assert.False(vm.CanGenerate);
Assert.Contains(vm.Issues, i => i.Message.Contains("Betreff", StringComparison.OrdinalIgnoreCase));
betreff.TextValue = "Wichtiger Termin";
var output = Path.Combine(_directory, "MitBetreff.pdf");
Assert.True(vm.CanGenerate);
Assert.True(vm.Generate(output));
Assert.Equal("%PDF", System.Text.Encoding.ASCII.GetString(File.ReadAllBytes(output), 0, 4));
}
[Fact]
public void StudentName_WirdAutomatischMitVollemNamenBefuellt()
{
var store = StoreWithTemplate(new PlaceholderDefinition("Anrede", PlaceholderType.Text, true),
new PlaceholderDefinition("Datum", PlaceholderType.Date, true),
new PlaceholderDefinition("Brieftext", PlaceholderType.Multiline, true),
new PlaceholderDefinition("Student.Name", PlaceholderType.Text, true));
var vm = Build(StudentWithContact("Sehr geehrte Frau Muster,"), store);
vm.LetterText = "Dies ist der Inhalt.";
Assert.Empty(vm.CustomPlaceholders);
Assert.True(vm.CanGenerate);
var output = Path.Combine(_directory, "StudentName.pdf");
Assert.True(vm.Generate(output));
}
[Fact]
public void Anrede_WirdAusKontaktVorbelegtUndBleibtEditierbar()
{
var store = StoreWithTemplate(new PlaceholderDefinition("Anrede", PlaceholderType.Text, true),
new PlaceholderDefinition("Datum", PlaceholderType.Date, true),
new PlaceholderDefinition("Brieftext", PlaceholderType.Multiline, true));
var vm = Build(StudentWithContact(null), store);
vm.LetterText = "Dies ist der Inhalt.";
Assert.Equal("", vm.Anrede);
Assert.False(vm.CanGenerate);
Assert.Contains(vm.Issues, i => i.Message.Contains("Anrede", StringComparison.OrdinalIgnoreCase));
vm.Anrede = "Sehr geehrte Familie Beispiel,";
var output = Path.Combine(_directory, "AnredeManuell.pdf");
Assert.True(vm.CanGenerate);
Assert.True(vm.Generate(output));
}
[Fact]
public void AddressPreview_FolgtDemGewaehltenKontaktUndAktualisiertSichBeimWechsel()
{
var student = new Student
{
FirstName = "Lena", LastName = "Beispiel",
Contacts =
[
new Contact { Name = "Frau Beispiel", Relation = "Mutter", Street = "Erste Str. 1", PostalCode = "11111", City = "Erststadt" },
new Contact { Name = "Herr Beispiel", Relation = "Vater", Street = "Zweite Str. 2", PostalCode = "22222", City = "Zweitstadt" },
],
};
var vm = Build(student, StoreWithTemplate(new PlaceholderDefinition("Anrede", PlaceholderType.Text, true)));
Assert.Equal(2, vm.Contacts.Count);
Assert.Contains("Frau Beispiel", vm.AddressPreview);
Assert.Contains("Erste Str. 1", vm.AddressPreview);
vm.SelectedContact = vm.Contacts.Single(c => c.Model.Name == "Herr Beispiel");
Assert.Contains("Herr Beispiel", vm.AddressPreview);
Assert.Contains("Zweite Str. 2", vm.AddressPreview);
Assert.DoesNotContain("Frau Beispiel", vm.AddressPreview);
}
[Fact]
public void SelectContactDialog_StartetBeimAktuellenKontaktUndAktualisiertVorschau()
{
var contacts = new List<LetterContactChoice>
{
new(new Contact { Name = "Frau Beispiel", Street = "Erste Str. 1", PostalCode = "11111", City = "Erststadt" }),
new(new Contact { Name = "Herr Beispiel", Street = "Zweite Str. 2", PostalCode = "22222", City = "Zweitstadt" }),
};
var dialogVm = new SelectContactDialogViewModel(contacts, contacts[1]);
Assert.Same(contacts[1], dialogVm.SelectedContact);
Assert.Contains("Zweite Str. 2", dialogVm.AddressPreview);
dialogVm.SelectedContact = contacts[0];
Assert.Contains("Erste Str. 1", dialogVm.AddressPreview);
}
private CreateLetterDialogViewModel Build(Student student, TemplateStore store) => private CreateLetterDialogViewModel Build(Student student, TemplateStore store) =>
new(student, store, new QuestTemplateRenderer(), new FakeMemberships([]), new FakeGroups([])); new(student, store, new QuestTemplateRenderer(), new FakeMemberships([]), new FakeGroups([]));
@@ -65,8 +245,18 @@ public sealed class CreateLetterDialogViewModelTests : IDisposable
var y = 20; var y = 20;
foreach (var definition in definitions) foreach (var definition in definitions)
{ {
var element = definition.Type == PlaceholderType.Multiline ? "TEXTBOX" : "TEXT"; var element = definition.Type switch
lines.Add(element == "TEXTBOX" ? $"TEXTBOX 20 {y} 170 80 ${definition.Name}" : $"TEXT 20 {y} ${definition.Name}"); {
PlaceholderType.Multiline => "TEXTBOX",
PlaceholderType.Drawing => "DRAWBOX",
_ => "TEXT",
};
lines.Add(element switch
{
"TEXTBOX" => $"TEXTBOX 20 {y} 170 80 ${definition.Name}",
"DRAWBOX" => $"DRAWBOX 20 {y} 170 80 ${definition.Name}",
_ => $"TEXT 20 {y} ${definition.Name}",
});
y += 20; y += 20;
} }
TemplatePackage.Create(source, manifest, string.Join('\n', lines), new Dictionary<string, byte[]>()); TemplatePackage.Create(source, manifest, string.Join('\n', lines), new Dictionary<string, byte[]>());
@@ -20,14 +20,75 @@ public sealed class DashboardViewModelTests
var vm = BuildVm(group, new Lesson { GroupId = group.Id, Date = today }); var vm = BuildVm(group, new Lesson { GroupId = group.Id, Date = today });
Assert.False(vm.ExcusesCard.EffectiveIsVisible); Assert.False(vm.AttentionCard.EffectiveIsVisible);
Assert.False(vm.CorrectionsCard.EffectiveIsVisible);
Assert.False(vm.AlertsCard.EffectiveIsVisible);
Assert.Equal("0 offene Punkte", vm.AttentionSummary); Assert.Equal("0 offene Punkte", vm.AttentionSummary);
Assert.True(vm.TodayCard.EffectiveIsVisible); Assert.True(vm.TodayCard.EffectiveIsVisible);
Assert.True(vm.CalendarCard.EffectiveIsVisible); Assert.True(vm.CalendarCard.EffectiveIsVisible);
} }
/// Regression: ApplyCardLayout zaehlte frueher IsVisible statt EffectiveIsVisible. Eine
/// eingeschaltete, aber leere HideWhenEmpty-Kachel belegte damit einen Rasterplatz, den das
/// Grid nie fuellt — im Alltag der Normalfall, weil meist mehrere Hinweiskacheln leer sind.
[Fact]
public void Kachelraster_LeereAusgeblendeteKacheln_HinterlassenKeineLuecke()
{
var group = new LearningGroup { Name = "9c" };
var today = DateOnly.FromDateTime(DateTime.Today);
var vm = BuildVm(group, new Lesson { GroupId = group.Id, Date = today });
// Ohne mindestens eine leer ausgeblendete Kachel wuerde der Test nichts pruefen.
Assert.False(vm.AttentionCard.EffectiveIsVisible);
var belegtePlaetze = vm.DashboardCards.Where(c => c.EffectiveIsVisible)
.Select(c => c.Row * 2 + c.Column).OrderBy(slot => slot).ToList();
Assert.Equal(Enumerable.Range(0, belegtePlaetze.Count), belegtePlaetze);
}
[Fact]
public void KachelMargin_FolgtDerBerechnetenSpalte()
{
var group = new LearningGroup { Name = "9c" };
var today = DateOnly.FromDateTime(DateTime.Today);
var vm = BuildVm(group, new Lesson { GroupId = group.Id, Date = today });
foreach (var card in vm.DashboardCards.Where(c => c.EffectiveIsVisible))
Assert.Equal(card.Column == 0
? new Avalonia.Thickness(0, 0, 8, 8)
: new Avalonia.Thickness(8, 0, 0, 8), card.Margin);
}
/// Regression: der Margin hing fest im XAML an der Kachel. Wandert sie durch Aus-/Einblenden
/// einer vorherigen Kachel in die andere Spalte, sass der Rinnstein auf der falschen Seite.
[Fact]
public void KachelAusblenden_DrehtDenMarginDerNachfolgendenKachel()
{
var group = new LearningGroup { Name = "9c" };
var today = DateOnly.FromDateTime(DateTime.Today);
var vm = BuildVm(group, new Lesson { GroupId = group.Id, Date = today });
Assert.Equal(1, vm.TasksCard.Column);
Assert.Equal(new Avalonia.Thickness(8, 0, 0, 8), vm.TasksCard.Margin);
vm.TodayCard.IsVisible = false;
Assert.Equal(0, vm.TasksCard.Column);
Assert.Equal(new Avalonia.Thickness(0, 0, 8, 8), vm.TasksCard.Margin);
}
/// Die frueheren sieben eigenen Listen (OpenExcuses, AttendanceWarnings, ...) sind zur
/// zusammengefassten Attention-Karte verschmolzen (siehe AttentionItem.cs) — Tests greifen
/// seither ueber Kind gefiltert zu statt ueber eine eigene Collection je Art.
private static IEnumerable<AttentionItem> Items(DashboardViewModel vm, AttentionKind kind) =>
vm.Attention.Where(g => g.Kind == kind).SelectMany(g => g.Items);
/// AttentionItem traegt fuer MissingTeachingTime kein eigenes Date-Feld (Title ist bereits der
/// formatierte Anzeigetext) — das genaue Datum steckt im mitgegebenen Action-Parameter
/// (derselbe MissingTeachingTimeItem, den auch der "Erfassen"-Dialog erhaelt).
private static DateOnly MissingTimeDate(AttentionItem item) =>
((MissingTeachingTimeItem)item.Actions.Single().Parameter!).Date;
private static PeriodScheduleService NewPeriodSchedule() private static PeriodScheduleService NewPeriodSchedule()
{ {
var tempPath = System.IO.Path.Combine( var tempPath = System.IO.Path.Combine(
@@ -61,7 +122,7 @@ public sealed class DashboardViewModelTests
FakeLessons? lessons = null, FakeSubstitutionEntries? substitutions = null, FakeLessons? lessons = null, FakeSubstitutionEntries? substitutions = null,
FakeSessions? sessions = null, FakeEntries? entries = null, FakeSessions? sessions = null, FakeEntries? entries = null,
FakeAnnualPlanEvents? annualPlanEvents = null, List<LearningGroup>? allGroups = null, FakeAnnualPlanEvents? annualPlanEvents = null, List<LearningGroup>? allGroups = null,
FakeTimeEntries? timeEntries = null) FakeTimeEntries? timeEntries = null, Func<DateTime>? now = null)
{ {
lessons ??= new FakeLessons(); lessons ??= new FakeLessons();
lessons.Add(lesson); lessons.Add(lesson);
@@ -75,7 +136,8 @@ public sealed class DashboardViewModelTests
new AttendanceBalanceService(), new SchoolYearService(), dashboardSettings ?? NewDashboardSettings(), new AttendanceBalanceService(), new SchoolYearService(), dashboardSettings ?? NewDashboardSettings(),
schoolHolidays ?? new FakeSchoolHolidays(), new PublicHolidayService(), NewCalendarSettings(), schoolHolidays ?? new FakeSchoolHolidays(), new PublicHolidayService(), NewCalendarSettings(),
substitutions ?? new FakeSubstitutionEntries(), timeEntries ?? new FakeTimeEntries(), substitutions ?? new FakeSubstitutionEntries(), timeEntries ?? new FakeTimeEntries(),
TestSupport.BuildUntisHubService(), TestSupport.BuildWebUntisIntegrationService(), annualPlanEvents); TestSupport.BuildUntisHubService(), TestSupport.BuildWebUntisIntegrationService(), annualPlanEvents,
annualPlanSync: null, schoolWeather: null, now: now);
} }
/// Montag einer Woche, die garantiert in der Zukunft liegt und innerhalb der /// Montag einer Woche, die garantiert in der Zukunft liegt und innerhalb der
@@ -153,9 +215,8 @@ public sealed class DashboardViewModelTests
var vm = BuildVm(group, new Lesson { GroupId = group.Id, Date = today }, slots: slots, periodSchedule: periodSchedule); var vm = BuildVm(group, new Lesson { GroupId = group.Id, Date = today }, slots: slots, periodSchedule: periodSchedule);
Assert.Contains(vm.MissingTeachingTimeEntries, i => i.Date == pastDay); Assert.Contains(Items(vm, AttentionKind.MissingTeachingTime), i => MissingTimeDate(i) == pastDay);
Assert.True(vm.MissingTeachingTimeCard.EffectiveIsVisible); Assert.True(vm.AttentionCard.EffectiveIsVisible);
Assert.Equal(2, vm.AttentionCount); // fehlende Unterrichtszeit + bereits bestehende ungeplante Stunde
} }
[Fact] [Fact]
@@ -175,7 +236,7 @@ public sealed class DashboardViewModelTests
var vm = BuildVm(group, new Lesson { GroupId = group.Id, Date = today }, var vm = BuildVm(group, new Lesson { GroupId = group.Id, Date = today },
slots: slots, periodSchedule: periodSchedule, timeEntries: timeEntries); slots: slots, periodSchedule: periodSchedule, timeEntries: timeEntries);
Assert.DoesNotContain(vm.MissingTeachingTimeEntries, i => i.Date == pastDay); Assert.DoesNotContain(Items(vm, AttentionKind.MissingTeachingTime), i => MissingTimeDate(i) == pastDay);
} }
[Fact] [Fact]
@@ -195,7 +256,7 @@ public sealed class DashboardViewModelTests
var vm = BuildVm(group, new Lesson { GroupId = group.Id, Date = today }, var vm = BuildVm(group, new Lesson { GroupId = group.Id, Date = today },
slots: slots, periodSchedule: periodSchedule, substitutions: substitutions); slots: slots, periodSchedule: periodSchedule, substitutions: substitutions);
Assert.DoesNotContain(vm.MissingTeachingTimeEntries, i => i.Date == pastDay); Assert.DoesNotContain(Items(vm, AttentionKind.MissingTeachingTime), i => MissingTimeDate(i) == pastDay);
} }
[Fact] [Fact]
@@ -215,43 +276,55 @@ public sealed class DashboardViewModelTests
var vm = BuildVm(group, new Lesson { GroupId = group.Id, Date = today }, var vm = BuildVm(group, new Lesson { GroupId = group.Id, Date = today },
slots: slots, periodSchedule: periodSchedule, schoolHolidays: schoolHolidays); slots: slots, periodSchedule: periodSchedule, schoolHolidays: schoolHolidays);
Assert.DoesNotContain(vm.MissingTeachingTimeEntries, i => i.Date == pastDay); Assert.DoesNotContain(Items(vm, AttentionKind.MissingTeachingTime), i => MissingTimeDate(i) == pastDay);
} }
/// Fixer Referenzzeitpunkt fuer die beiden "heute, kurz vor/nach Ablauf der Wartezeit"-Tests
/// unten. Regression: die Tests bauten "Unterrichtsende" bisher aus TimeOnly.FromDateTime(
/// DateTime.Now).AddHours(±2) — TimeOnly wickelt bei Mitternacht um, ausgefuehrt zwischen ca.
/// 22:00 und 02:00 Uhr wurde dadurch aus "+2h" ein Ende VOR "jetzt" (oder umgekehrt), je nach
/// Tageszeit zufaellig rot. September ist bewusst gewaehlt: keiner der bundesweiten oder
/// laenderspezifischen Feiertage (PublicHolidayService) faellt in diesen Monat, ein Dienstag
/// ist garantiert kein Wochenende — die injizierte Uhr (DashboardViewModel now:-Parameter)
/// macht "jetzt" fuer den Test unabhaengig von der tatsaechlichen Ausfuehrungsuhrzeit.
private static readonly DateTime FixedNow = new(2026, 9, 8, 10, 0, 0);
[Fact] [Fact]
public void MissingTeachingTime_HeuteVorAblaufDerWartezeit_WirdNichtGemeldet() public void MissingTeachingTime_HeuteVorAblaufDerWartezeit_WirdNichtGemeldet()
{ {
var group = new LearningGroup { Name = "9c" }; var group = new LearningGroup { Name = "9c" };
var today = DateOnly.FromDateTime(DateTime.Today); var today = DateOnly.FromDateTime(FixedNow);
var slots = new FakeTimetableSlots(); var slots = new FakeTimetableSlots();
slots.Add(new TimetableSlot { GroupId = group.Id, Weekday = today.DayOfWeek, PeriodNumber = 1 }); slots.Add(new TimetableSlot { GroupId = group.Id, Weekday = today.DayOfWeek, PeriodNumber = 1 });
var periodSchedule = NewPeriodSchedule(); var periodSchedule = NewPeriodSchedule();
// Unterrichtsende liegt garantiert noch keine 30 Minuten zurück. // Unterrichtsende liegt (relativ zur fixen Uhr FixedNow) noch keine 30 Minuten zurück.
var futureEnd = TimeOnly.FromDateTime(DateTime.Now).AddHours(2); var futureEnd = TimeOnly.FromDateTime(FixedNow).AddHours(2);
periodSchedule.SetPeriods([new PeriodTimeEntry periodSchedule.SetPeriods([new PeriodTimeEntry
{ PeriodNumber = 1, Start = futureEnd.AddHours(-1), End = futureEnd }]); { PeriodNumber = 1, Start = futureEnd.AddHours(-1), End = futureEnd }]);
var vm = BuildVm(group, new Lesson { GroupId = group.Id, Date = today }, slots: slots, periodSchedule: periodSchedule); var vm = BuildVm(group, new Lesson { GroupId = group.Id, Date = today }, slots: slots,
periodSchedule: periodSchedule, now: () => FixedNow);
Assert.DoesNotContain(vm.MissingTeachingTimeEntries, i => i.Date == today); Assert.DoesNotContain(Items(vm, AttentionKind.MissingTeachingTime), i => MissingTimeDate(i) == today);
} }
[Fact] [Fact]
public void MissingTeachingTime_HeuteNachAblaufDerWartezeit_WirdGemeldet() public void MissingTeachingTime_HeuteNachAblaufDerWartezeit_WirdGemeldet()
{ {
var group = new LearningGroup { Name = "9c" }; var group = new LearningGroup { Name = "9c" };
var today = DateOnly.FromDateTime(DateTime.Today); var today = DateOnly.FromDateTime(FixedNow);
var slots = new FakeTimetableSlots(); var slots = new FakeTimetableSlots();
slots.Add(new TimetableSlot { GroupId = group.Id, Weekday = today.DayOfWeek, PeriodNumber = 1 }); slots.Add(new TimetableSlot { GroupId = group.Id, Weekday = today.DayOfWeek, PeriodNumber = 1 });
var periodSchedule = NewPeriodSchedule(); var periodSchedule = NewPeriodSchedule();
// Unterrichtsende liegt garantiert mehr als 30 Minuten zurück. // Unterrichtsende liegt (relativ zur fixen Uhr FixedNow) mehr als 30 Minuten zurück.
var pastEnd = TimeOnly.FromDateTime(DateTime.Now).AddHours(-2); var pastEnd = TimeOnly.FromDateTime(FixedNow).AddHours(-2);
periodSchedule.SetPeriods([new PeriodTimeEntry periodSchedule.SetPeriods([new PeriodTimeEntry
{ PeriodNumber = 1, Start = pastEnd.AddHours(-1), End = pastEnd }]); { PeriodNumber = 1, Start = pastEnd.AddHours(-1), End = pastEnd }]);
var vm = BuildVm(group, new Lesson { GroupId = group.Id, Date = today }, slots: slots, periodSchedule: periodSchedule); var vm = BuildVm(group, new Lesson { GroupId = group.Id, Date = today }, slots: slots,
periodSchedule: periodSchedule, now: () => FixedNow);
Assert.Contains(vm.MissingTeachingTimeEntries, i => i.Date == today); Assert.Contains(Items(vm, AttentionKind.MissingTeachingTime), i => MissingTimeDate(i) == today);
} }
[Fact] [Fact]
@@ -378,10 +451,9 @@ public sealed class DashboardViewModelTests
exams: new FakeExams([exam]), results: results, memberships: memberships, exams: new FakeExams([exam]), results: results, memberships: memberships,
students: new FakeStudents([anna, ben])); students: new FakeStudents([anna, ben]));
var correction = Assert.Single(vm.OpenCorrections); var correction = Assert.Single(Items(vm, AttentionKind.Correction));
Assert.Equal(1, correction.Completed); Assert.Equal(50, correction.ProgressPercent);
Assert.Equal(2, correction.Total); Assert.Equal("1 von 2 Arbeiten bewertet", correction.ProgressLabel);
Assert.Equal(50, correction.Percent);
} }
[Fact] [Fact]
@@ -402,7 +474,7 @@ public sealed class DashboardViewModelTests
var vm = BuildVm(group, new Lesson { GroupId = group.Id, Date = today }, grades: grades, var vm = BuildVm(group, new Lesson { GroupId = group.Id, Date = today }, grades: grades,
memberships: memberships, students: new FakeStudents([student])); memberships: memberships, students: new FakeStudents([student]));
Assert.Contains(vm.Alerts, a => a.StudentId == student.Id && a.KindLabel == "Notenabfall"); Assert.Contains(Items(vm, AttentionKind.Alert), a => a.Title == student.FullName && a.TrailingText == "Notenabfall");
} }
[Fact] [Fact]
@@ -561,9 +633,8 @@ public sealed class DashboardViewModelTests
var vm = BuildVm(group, pastLesson, slots: slots); var vm = BuildVm(group, pastLesson, slots: slots);
var item = Assert.Single(vm.UnplannedLessons); var item = Assert.Single(Items(vm, AttentionKind.Unplanned));
Assert.Equal(group.Id, item.GroupId); Assert.Equal($"{group.Name} · 1. Stunde", item.Title);
Assert.Equal(1, item.PeriodNumber);
Assert.Equal("Heute", item.DateDisplay); Assert.Equal("Heute", item.DateDisplay);
} }
@@ -578,7 +649,7 @@ public sealed class DashboardViewModelTests
var vm = BuildVm(group, lesson, slots: slots); var vm = BuildVm(group, lesson, slots: slots);
Assert.Empty(vm.UnplannedLessons); Assert.Empty(Items(vm, AttentionKind.Unplanned));
} }
[Fact] [Fact]
@@ -592,7 +663,7 @@ public sealed class DashboardViewModelTests
var vm = BuildVm(group, pastLesson, slots: slots); var vm = BuildVm(group, pastLesson, slots: slots);
Assert.Empty(vm.UnplannedLessons); Assert.Empty(Items(vm, AttentionKind.Unplanned));
} }
[Fact] [Fact]
@@ -608,7 +679,7 @@ public sealed class DashboardViewModelTests
var vm = BuildVm(group, pastLesson, slots: slots, schoolHolidays: schoolHolidays); var vm = BuildVm(group, pastLesson, slots: slots, schoolHolidays: schoolHolidays);
Assert.Empty(vm.UnplannedLessons); Assert.Empty(Items(vm, AttentionKind.Unplanned));
} }
[Fact] [Fact]
@@ -623,7 +694,7 @@ public sealed class DashboardViewModelTests
var vm = BuildVm(group, lesson, slots: slots); var vm = BuildVm(group, lesson, slots: slots);
Assert.Empty(vm.UnplannedLessons); Assert.Empty(Items(vm, AttentionKind.Unplanned));
} }
[Fact] [Fact]
@@ -638,9 +709,10 @@ public sealed class DashboardViewModelTests
var vm = BuildVm(group, lesson, slots: slots); var vm = BuildVm(group, lesson, slots: slots);
Assert.Equal(2, vm.UnplannedLessons.Count); var unplanned = Items(vm, AttentionKind.Unplanned).ToList();
Assert.Contains(vm.UnplannedLessons, i => i.PeriodNumber == 3); Assert.Equal(2, unplanned.Count);
Assert.Contains(vm.UnplannedLessons, i => i.PeriodNumber == 4); Assert.Contains(unplanned, i => i.Title.Contains("3. Stunde"));
Assert.Contains(unplanned, i => i.Title.Contains("4. Stunde"));
} }
[Fact] [Fact]
@@ -658,7 +730,7 @@ public sealed class DashboardViewModelTests
var vm = BuildVm(group, pastLesson, slots: slots, substitutions: substitutions); var vm = BuildVm(group, pastLesson, slots: slots, substitutions: substitutions);
Assert.Empty(vm.UnplannedLessons); Assert.Empty(Items(vm, AttentionKind.Unplanned));
} }
[Fact] [Fact]
@@ -681,7 +753,7 @@ public sealed class DashboardViewModelTests
var vm = BuildVm(group, new Lesson { GroupId = group.Id, Date = today }, var vm = BuildVm(group, new Lesson { GroupId = group.Id, Date = today },
students: new FakeStudents([student]), sessions: sessions, entries: entries); students: new FakeStudents([student]), sessions: sessions, entries: entries);
Assert.Empty(vm.AttendanceWarnings); Assert.Empty(Items(vm, AttentionKind.Attendance));
} }
[Fact] [Fact]
@@ -705,9 +777,9 @@ public sealed class DashboardViewModelTests
var vm = BuildVm(group, new Lesson { GroupId = group.Id, Date = today }, var vm = BuildVm(group, new Lesson { GroupId = group.Id, Date = today },
students: new FakeStudents([student]), sessions: sessions, entries: entries); students: new FakeStudents([student]), sessions: sessions, entries: entries);
var item = Assert.Single(vm.AttendanceWarnings); var item = Assert.Single(Items(vm, AttentionKind.Attendance));
Assert.Equal(student.FullName, item.StudentName); Assert.Equal(student.FullName, item.Title);
Assert.Equal(30.0, item.AbsenceRatePercent); Assert.Equal("30 %", item.TrailingText);
} }
[Fact] [Fact]
+53 -3
View File
@@ -2,6 +2,7 @@ using LehrerApp.Core.Interfaces;
using LehrerApp.Core.Models; using LehrerApp.Core.Models;
using LehrerApp.Core.Services; using LehrerApp.Core.Services;
using LehrerApp.Desktop.Services; using LehrerApp.Desktop.Services;
using LehrerApp.Desktop.Services.Mcp;
using LehrerApp.Desktop.ViewModels.Settings; using LehrerApp.Desktop.ViewModels.Settings;
using LehrerApp.Sync; using LehrerApp.Sync;
using LehrerApp.Sync.Crypto; using LehrerApp.Sync.Crypto;
@@ -31,6 +32,17 @@ public static class TestSupport
new HttpClient(), new FakeLessons(), new FakeGroups([]), new FakeSubjects([]), new HttpClient(), new FakeLessons(), new FakeGroups([]), new FakeSubjects([]),
new FakeCompetencyDomains(), new FakeAlternativeLessonPaths([])); new FakeCompetencyDomains(), new FakeAlternativeLessonPaths([]));
/// Analog zu <see cref="BuildAiSettingsService"/>, eigenes Temp-Verzeichnis je Aufruf.
public static McpSettingsService BuildMcpSettingsService()
{
var tempPath = Path.Combine(Path.GetTempPath(), $"lehrerapp-mcpsettings-tests-{Guid.NewGuid():N}");
Directory.CreateDirectory(tempPath);
return new McpSettingsService(tempPath);
}
/// Kein Konstruktorparameter - Pfade werden intern über Environment.SpecialFolder aufgelöst.
public static McpClientRegistrationService BuildMcpClientRegistrationService() => new();
/// Analog zu <see cref="BuildAiSettingsService"/>, eigenes Temp-Verzeichnis je Aufruf. /// Analog zu <see cref="BuildAiSettingsService"/>, eigenes Temp-Verzeichnis je Aufruf.
public static WebUntisSettingsService BuildWebUntisSettingsService() public static WebUntisSettingsService BuildWebUntisSettingsService()
{ {
@@ -346,6 +358,7 @@ public class FakeLessons : ILessonRepository
{ {
private readonly List<Lesson> _all = []; private readonly List<Lesson> _all = [];
public void Add(Lesson l) => _all.Add(l); public void Add(Lesson l) => _all.Add(l);
public Lesson? GetById(Guid id) => _all.FirstOrDefault(l => l.Id == id);
public List<Lesson> GetByUnit(Guid unitId) => public List<Lesson> GetByUnit(Guid unitId) =>
_all.Where(l => l.UnitId == unitId).OrderBy(l => l.Date).ThenBy(l => l.LessonNumber).ToList(); _all.Where(l => l.UnitId == unitId).OrderBy(l => l.Date).ThenBy(l => l.LessonNumber).ToList();
public List<Lesson> GetByGroupAndDate(Guid groupId, DateOnly date) => public List<Lesson> GetByGroupAndDate(Guid groupId, DateOnly date) =>
@@ -360,9 +373,18 @@ public class FakeSubjects(List<Subject> all) : ISubjectRepository
{ {
public List<Subject> GetAll() => all; public List<Subject> GetAll() => all;
public Subject? GetById(Guid id) => all.FirstOrDefault(s => s.Id == id); public Subject? GetById(Guid id) => all.FirstOrDefault(s => s.Id == id);
public Subject? GetByName(string name) => all.FirstOrDefault(s => s.Name == name); public Subject? GetByName(string name) => all.FirstOrDefault(s =>
public void Save(Subject subject) { } string.Equals(s.Name.Trim(), name.Trim(), StringComparison.OrdinalIgnoreCase));
public void Delete(Guid id) { } public void Save(Subject subject)
{
if (string.IsNullOrWhiteSpace(subject.Name)) throw new ArgumentException("Der Fachname darf nicht leer sein.");
var duplicate = GetByName(subject.Name);
if (duplicate is not null && duplicate.Id != subject.Id)
throw new InvalidOperationException("Ein Fach mit diesem Namen existiert bereits.");
all.RemoveAll(s => s.Id == subject.Id);
all.Add(subject);
}
public void Delete(Guid id) => all.RemoveAll(s => s.Id == id);
} }
public class FakeCompetencyDomains : ICompetencyDomainRepository public class FakeCompetencyDomains : ICompetencyDomainRepository
@@ -372,6 +394,9 @@ public class FakeCompetencyDomains : ICompetencyDomainRepository
public List<CompetencyDomain> GetBySubjectAndGrade(Guid subjectId, int gradeLevel) => public List<CompetencyDomain> GetBySubjectAndGrade(Guid subjectId, int gradeLevel) =>
_all.Where(d => d.SubjectId == subjectId && d.GradeLevel == gradeLevel) _all.Where(d => d.SubjectId == subjectId && d.GradeLevel == gradeLevel)
.OrderBy(d => d.SortOrder).ToList(); .OrderBy(d => d.SortOrder).ToList();
public List<CompetencyDomain> GetBySubject(Guid subjectId) =>
_all.Where(d => d.SubjectId == subjectId)
.OrderBy(d => d.GradeLevel).ThenBy(d => d.SortOrder).ToList();
public CompetencyDomain? GetById(Guid id) => _all.FirstOrDefault(d => d.Id == id); public CompetencyDomain? GetById(Guid id) => _all.FirstOrDefault(d => d.Id == id);
public void Save(CompetencyDomain domain) { _all.RemoveAll(d => d.Id == domain.Id); _all.Add(domain); } public void Save(CompetencyDomain domain) { _all.RemoveAll(d => d.Id == domain.Id); _all.Add(domain); }
public void Delete(Guid id) => _all.RemoveAll(d => d.Id == id); public void Delete(Guid id) => _all.RemoveAll(d => d.Id == id);
@@ -586,3 +611,28 @@ public class FakeReportGrades : IReportGradeRepository
} }
public void Delete(Guid id) => _all.RemoveAll(r => r.Id == id); public void Delete(Guid id) => _all.RemoveAll(r => r.Id == id);
} }
/// <summary>Fake für MCP-Write-Tool-Tests (Phase 2): antwortet ohne echtes UI, konfigurierbar über
/// <see cref="Response"/>, merkt sich Titel/Nachricht des letzten Aufrufs zur Prüfung, dass der
/// Bestätigungstext tatsächlich menschenlesbar ist (kein rohes JSON/GUID-Dump).</summary>
public class FakeMcpConfirmation : IMcpConfirmationService
{
public bool Response { get; set; } = true;
public string? LastTitle { get; private set; }
public string? LastMessage { get; private set; }
public int CallCount { get; private set; }
public string? LastOperationKey { get; private set; }
public bool? LastAllowSessionTrust { get; private set; }
public Task<bool> ConfirmAsync(string title, string message, CancellationToken ct,
[System.Runtime.CompilerServices.CallerMemberName] string operationKey = "", bool allowSessionTrust = true)
{
CallCount++;
LastTitle = title;
LastMessage = message;
LastOperationKey = operationKey;
LastAllowSessionTrust = allowSessionTrust;
return Task.FromResult(Response);
}
}
@@ -10,6 +10,33 @@ namespace LehrerApp.Desktop.Tests;
/// zusammen, was Planung/Klausuren/Mitarbeit/Dokumentation ohnehin schon verwalten. /// zusammen, was Planung/Klausuren/Mitarbeit/Dokumentation ohnehin schon verwalten.
public sealed class GroupOverviewViewModelTests public sealed class GroupOverviewViewModelTests
{ {
[Fact]
public void UnterrichtHeute_BietetNurHeutigeNichtAusgefalleneStundenUndOeffnetAuswahl()
{
var group = new LearningGroup { IsActive = true };
var today = DateOnly.FromDateTime(DateTime.Today);
var lessons = new FakeLessons();
var first = new Lesson { GroupId = group.Id, Date = today, Topic = "Erste Stunde" };
var second = new Lesson { GroupId = group.Id, Date = today, Topic = "Zweite Stunde" };
lessons.Add(first);
lessons.Add(second);
lessons.Add(new Lesson { GroupId = group.Id, Date = today, Status = LessonStatus.Cancelled });
lessons.Add(new Lesson { GroupId = group.Id, Date = today.AddDays(1) });
lessons.Add(new Lesson { GroupId = Guid.NewGuid(), Date = today });
var vm = NewVm(lessons: lessons, groups: new FakeGroups([group]));
vm.Initialize(group.Id, group.Name);
Assert.True(vm.HasTodayLessons);
Assert.Equal(2, vm.TodayLessons.Count);
Lesson? opened = null;
vm.OnOpenTeachingMode = lesson => opened = lesson;
vm.SelectedTeachingLesson = second;
vm.StartTeachingModeCommand.Execute(null);
Assert.Same(second, opened);
group.IsActive = false;
vm.Refresh();
Assert.False(vm.HasTodayLessons);
}
private static GroupOverviewViewModel NewVm(FakeLessons? lessons = null, FakeExams? exams = null, private static GroupOverviewViewModel NewVm(FakeLessons? lessons = null, FakeExams? exams = null,
FakeSessions? sessions = null, FakeEntries? entries = null, FakeStudents? students = null, FakeSessions? sessions = null, FakeEntries? entries = null, FakeStudents? students = null,
FakeDocumentation? documentation = null, FakeWorkTasks? tasks = null, FakeDocumentation? documentation = null, FakeWorkTasks? tasks = null,
@@ -45,6 +45,39 @@ public sealed class LessonSchedulingServiceTests
Assert.Equal(new DateOnly(2026, 9, 4), conducted.Date); Assert.Equal(new DateOnly(2026, 9, 4), conducted.Date);
} }
[Fact]
public void Move_RuecktAuchAusgefalleneFolgestundenNichtNach()
{
var unitId = Guid.NewGuid();
var groupId = Guid.NewGuid();
var moved = new Lesson { UnitId = unitId, GroupId = groupId, Date = new(2026, 9, 1) };
var cancelledFollowing = new Lesson { UnitId = unitId, GroupId = groupId, Date = new(2026, 9, 4), Status = LessonStatus.Cancelled };
var repo = new FakeLessons();
repo.Add(moved); repo.Add(cancelledFollowing);
new LessonSchedulingService(repo).Move(moved, new(2026, 9, 8), null, shiftFollowing: true);
Assert.Equal(new DateOnly(2026, 9, 4), cancelledFollowing.Date);
}
[Fact]
public void SplitAndMoveSecondPart_AusgefalleneQuellstundeSetztFortsetzungAufEntwurfZurueck()
{
var source = new Lesson
{
UnitId = Guid.NewGuid(), GroupId = Guid.NewGuid(), Date = new(2026, 9, 1),
Status = LessonStatus.Cancelled,
Phases = [new LessonPhaseStep { Name = "Einstieg", DurationMinutes = 60 }],
};
var repo = new FakeLessons();
repo.Add(source);
var continuation = new LessonSchedulingService(repo).SplitAndMoveSecondPart(source, 30,
new(2026, 9, 3), 1, null);
Assert.Equal(LessonStatus.Draft, continuation.Status);
}
[Fact] [Fact]
public void SplitAndMoveSecondPart_TeiltAuchEineUeberDieGrenzeLaufendePhase() public void SplitAndMoveSecondPart_TeiltAuchEineUeberDieGrenzeLaufendePhase()
{ {
@@ -0,0 +1,38 @@
using LehrerApp.Core.Models;
using LehrerApp.Desktop.ViewModels.Groups;
using Xunit;
namespace LehrerApp.Desktop.Tests;
public sealed class LessonStatusDisplayTests
{
[Theory]
[InlineData(LessonStatus.Draft, "Entwurf")]
[InlineData(LessonStatus.Ready, "Bereit")]
[InlineData(LessonStatus.Conducted, "Durchgeführt")]
[InlineData(LessonStatus.Cancelled, "Ausgefallen")]
[InlineData(LessonStatus.Planned, "Geplant")]
public void ToName_FromName_RoundTrip(LessonStatus status, string expectedName)
{
Assert.Equal(expectedName, LessonStatusDisplay.ToName(status));
Assert.Equal(status, LessonStatusDisplay.FromName(expectedName));
}
[Fact]
public void UnitSummary_AusgefalleneStundenZaehlenWederAlsGehaltenNochAlsPensum()
{
var unit = new Unit { Title = "Optik" };
var lessons = new List<Lesson>
{
new() { Status = LessonStatus.Conducted },
new() { Status = LessonStatus.Cancelled },
new() { Status = LessonStatus.Ready },
};
var summary = new UnitSummary(unit, lessons);
Assert.Equal(2, summary.TotalCount);
Assert.Equal(1, summary.ConductedCount);
Assert.Equal(0.5, summary.ProgressFraction);
}
}
@@ -0,0 +1,132 @@
using LehrerApp.Core.Models;
using LehrerApp.Desktop.Services.Mcp.Tools;
using LehrerApp.Templating;
using Xunit;
namespace LehrerApp.Desktop.Tests;
public sealed class LetterTemplateToolsTests : IDisposable
{
private readonly string _directory = Path.Combine(Path.GetTempPath(), $"lehrerapp-lettertools-tests-{Guid.NewGuid():N}");
public LetterTemplateToolsTests() => Directory.CreateDirectory(_directory);
public void Dispose() { if (Directory.Exists(_directory)) Directory.Delete(_directory, true); }
private LetterTemplateTools BuildTool(TemplateStore store, FakeStudents? students = null, FakeGroups? groups = null) =>
new(store, new TemplateLoader(), new QuestTemplateRenderer(), students ?? new FakeStudents([]), groups ?? new FakeGroups([]));
private TemplateStore StoreWithTemplate(string name, params PlaceholderDefinition[] definitions)
{
var source = Path.Combine(_directory, $"{Guid.NewGuid():N}.lavorlage");
var manifest = new TemplateManifest { Id = $"brief-{Guid.NewGuid():N}", Name = name, Placeholders = [.. definitions] };
var lines = new List<string> { "PAGE 210 297 mm" };
var y = 20;
foreach (var definition in definitions)
{
var element = definition.Type == PlaceholderType.Multiline ? "TEXTBOX" : "TEXT";
lines.Add(element == "TEXTBOX" ? $"TEXTBOX 20 {y} 170 80 ${definition.Name}" : $"TEXT 20 {y} ${definition.Name}");
y += 20;
}
TemplatePackage.Create(source, manifest, string.Join('\n', lines), new Dictionary<string, byte[]>());
var store = new TemplateStore(Path.Combine(_directory, $"store-{Guid.NewGuid():N}"));
store.Import(source);
return store;
}
private static Student StudentWithContact(string? salutation = "Sehr geehrte Frau Muster,") => new()
{
FirstName = "Lena", LastName = "Beispiel",
Contacts = [new Contact
{
Name = "Frau Muster", Relation = "Mutter", LetterSalutation = salutation,
Street = "Hauptstraße 1", PostalCode = "12345", City = "Musterstadt",
}],
};
[Fact]
public void ListLetterTemplates_ListetVorlageMitPlatzhaltern()
{
var store = StoreWithTemplate("Elternbrief", new PlaceholderDefinition("Anrede", PlaceholderType.Text, true));
var tool = BuildTool(store);
var result = Assert.Single(tool.ListLetterTemplates());
Assert.Equal("Elternbrief", result.Name);
Assert.Contains(result.Placeholders, p => p.Name == "Anrede" && p.Required);
}
[Fact]
public void RenderLetter_UnbekannteVorlage_LiefertFehler()
{
var student = StudentWithContact();
var tool = BuildTool(new TemplateStore(_directory), new FakeStudents([student]));
var result = tool.RenderLetter("nicht-vorhanden", student.Id, "Text", "Frau Lehrer");
Assert.False(result.Success);
Assert.Null(result.Base64Pdf);
}
[Fact]
public void RenderLetter_UnbekannterSchueler_LiefertFehler()
{
var store = StoreWithTemplate("Elternbrief", new PlaceholderDefinition("Anrede", PlaceholderType.Text, true));
var installed = Assert.Single(store.GetTemplates());
var tool = BuildTool(store);
var result = tool.RenderLetter(installed.Id, Guid.NewGuid(), "Text", "Frau Lehrer");
Assert.False(result.Success);
}
[Fact]
public void RenderLetter_PflichtplatzhalterFehlt_LiefertFehlerOhnePdf()
{
var store = StoreWithTemplate("Elternbrief", new PlaceholderDefinition("Anrede", PlaceholderType.Text, true));
var installed = Assert.Single(store.GetTemplates());
var student = StudentWithContact(salutation: null); // keine Anrede hinterlegt
var tool = BuildTool(store, new FakeStudents([student]));
var result = tool.RenderLetter(installed.Id, student.Id, "Text", "Frau Lehrer");
Assert.False(result.Success);
Assert.Contains("Anrede", result.Message);
Assert.Null(result.Base64Pdf);
}
[Fact]
public void RenderLetter_VollstaendigeDaten_LiefertBase64Pdf()
{
var store = StoreWithTemplate("Elternbrief",
new PlaceholderDefinition("Anrede", PlaceholderType.Text, true),
new PlaceholderDefinition("Datum", PlaceholderType.Date, true),
new PlaceholderDefinition("Brieftext", PlaceholderType.Multiline, true));
var installed = Assert.Single(store.GetTemplates());
var student = StudentWithContact();
var tool = BuildTool(store, new FakeStudents([student]));
var result = tool.RenderLetter(installed.Id, student.Id, "Dies ist der Inhalt.", "Frau Lehrer");
Assert.True(result.Success);
Assert.NotNull(result.Base64Pdf);
var bytes = Convert.FromBase64String(result.Base64Pdf!);
Assert.Equal("%PDF", System.Text.Encoding.ASCII.GetString(bytes, 0, 4));
Assert.Equal("Elternbrief_Beispiel_Lena.pdf", result.SuggestedFileName);
}
[Fact]
public void RenderLetter_ExtraValues_FuelltZusaetzlichenPlatzhalter()
{
var store = StoreWithTemplate("Elternbrief",
new PlaceholderDefinition("Anrede", PlaceholderType.Text, true),
new PlaceholderDefinition("Betreff", PlaceholderType.Text, true));
var installed = Assert.Single(store.GetTemplates());
var student = StudentWithContact();
var tool = BuildTool(store, new FakeStudents([student]));
var result = tool.RenderLetter(installed.Id, student.Id, "Text", "Frau Lehrer",
extraValues: new Dictionary<string, string> { ["Betreff"] = "Elternabend" });
Assert.True(result.Success);
Assert.NotNull(result.Base64Pdf);
}
}
@@ -0,0 +1,96 @@
using System.Text.Json.Nodes;
using LehrerApp.Desktop.Services.Mcp;
using Xunit;
namespace LehrerApp.Desktop.Tests;
public sealed class McpClientRegistrationServiceTests : IDisposable
{
private readonly string _directory = Path.Combine(Path.GetTempPath(), $"lehrerapp-mcpreg-tests-{Guid.NewGuid():N}");
public McpClientRegistrationServiceTests() => Directory.CreateDirectory(_directory);
public void Dispose() { if (Directory.Exists(_directory)) Directory.Delete(_directory, true); }
private string ConfigPath => Path.Combine(_directory, "claude_desktop_config.json");
private string BuildFakeBridge()
{
var bridgePath = Path.Combine(_directory, "LehrerApp.McpBridge.exe");
File.WriteAllText(bridgePath, "fake");
return bridgePath;
}
[Fact]
public void Register_OhneBridgeDatei_LiefertFehlerUndSchreibtNichts()
{
var service = new McpClientRegistrationService(ConfigPath, Path.Combine(_directory, "fehlt.exe"));
var result = service.Register();
Assert.False(result.Success);
Assert.False(File.Exists(ConfigPath));
}
[Fact]
public void Register_OhneBestehendeKonfiguration_LegtDateiMitEintragAn()
{
var bridgePath = BuildFakeBridge();
var service = new McpClientRegistrationService(ConfigPath, bridgePath);
var result = service.Register();
Assert.True(result.Success);
Assert.True(service.IsRegistered());
var root = JsonNode.Parse(File.ReadAllText(ConfigPath))!;
Assert.Equal(bridgePath, root["mcpServers"]!["lehrerapp"]!["command"]!.GetValue<string>());
}
[Fact]
public void Register_BestehendeKonfigurationMitAnderenServern_BleibtErhalten()
{
File.WriteAllText(ConfigPath, """{"mcpServers":{"andererServer":{"command":"foo"}},"globalShortcut":"Ctrl+X"}""");
var bridgePath = BuildFakeBridge();
var service = new McpClientRegistrationService(ConfigPath, bridgePath);
var result = service.Register();
Assert.True(result.Success);
var root = JsonNode.Parse(File.ReadAllText(ConfigPath))!;
Assert.Equal("foo", root["mcpServers"]!["andererServer"]!["command"]!.GetValue<string>());
Assert.Equal("Ctrl+X", root["globalShortcut"]!.GetValue<string>());
Assert.Equal(bridgePath, root["mcpServers"]!["lehrerapp"]!["command"]!.GetValue<string>());
}
[Fact]
public void Register_KaputteBestehendeKonfiguration_WirdNichtUeberschrieben()
{
File.WriteAllText(ConfigPath, "{ das ist kein json");
var bridgePath = BuildFakeBridge();
var service = new McpClientRegistrationService(ConfigPath, bridgePath);
var result = service.Register();
Assert.False(result.Success);
Assert.Equal("{ das ist kein json", File.ReadAllText(ConfigPath));
}
[Fact]
public void Unregister_EntferntNurEigenenEintrag()
{
File.WriteAllText(ConfigPath, """{"mcpServers":{"andererServer":{"command":"foo"},"lehrerapp":{"command":"bar"}}}""");
var service = new McpClientRegistrationService(ConfigPath, BuildFakeBridge());
var result = service.Unregister();
Assert.True(result.Success);
Assert.False(service.IsRegistered());
Assert.Contains("andererServer", File.ReadAllText(ConfigPath));
}
[Fact]
public void IsRegistered_OhneDatei_IstFalse()
{
var service = new McpClientRegistrationService(ConfigPath, BuildFakeBridge());
Assert.False(service.IsRegistered());
}
}
+901
View File
@@ -0,0 +1,901 @@
using LehrerApp.Core.Models;
using LehrerApp.Desktop.Services.Mcp;
using LehrerApp.Desktop.Services.Mcp.Tools;
using Xunit;
namespace LehrerApp.Desktop.Tests;
public sealed class McpToolsTests
{
// ── McpToolScope ─────────────────────────────────────────────────────────────────────────
[Fact]
public void AllowedReadTools_EnthaeltGenauDieErwartetenReadTools()
{
Assert.Equal(
new[]
{
"download_lesson_attachment", "get_competency_catalog", "get_exams", "get_grades",
"get_groups", "get_lesson_plans", "get_named_untis_absence_pattern", "get_schedule",
"get_students", "get_subjects", "get_time_entries", "get_untis_absence_rows",
"get_untis_hub_status", "list_letter_templates", "render_letter",
},
McpToolScope.AllowedReadTools.OrderBy(n => n, StringComparer.Ordinal));
}
[Fact]
public void AllowedWriteTools_EnthaeltGenauDieErwartetenWriteTools()
{
Assert.Equal(
new[]
{
"add_competency_item", "add_lesson_attachment", "add_lesson_competency", "add_lesson_phase",
"apply_untis_absence_status", "create_competency_domain", "create_grade_entry", "create_lesson",
"create_subject", "create_time_entry", "create_unit", "move_lesson", "remove_competency_item",
"remove_lesson_competency", "remove_lesson_phase", "update_competency_domain",
"update_competency_item", "update_lesson", "update_lesson_phase", "update_student_group_assignment",
"update_subject", "update_unit",
},
McpToolScope.AllowedWriteTools.OrderBy(n => n, StringComparer.Ordinal));
}
[Fact]
public void AllowedDestructiveWriteTools_EnthaeltDeleteLessonSubjectUndCompetencyDomain()
{
Assert.Equal(
new[] { "delete_competency_domain", "delete_lesson", "delete_subject" },
McpToolScope.AllowedDestructiveWriteTools.OrderBy(n => n, StringComparer.Ordinal));
}
[Fact]
public void AllowedTools_EnthaeltKeineDokumentationstypen()
{
// Gesprächsnotizen/Vorfälle/Förderpläne dürfen technisch nie über MCP erreichbar sein
// (siehe Planungsdokument) - die Namenskonvention "documentation"/"vorgang" darf nie auftauchen.
var allNames = McpToolScope.AllowedReadTools.Concat(McpToolScope.AllowedWriteTools);
Assert.DoesNotContain(allNames, n =>
n.Contains("documentation", StringComparison.OrdinalIgnoreCase) ||
n.Contains("vorgang", StringComparison.OrdinalIgnoreCase) ||
n.Contains("note", StringComparison.OrdinalIgnoreCase));
}
// ── GroupTools ───────────────────────────────────────────────────────────────────────────
[Fact]
public void GetGroups_LiefertIdNameUndSchuljahrJeGruppe()
{
var group = new LearningGroup
{
Name = "9a", Type = GroupType.Class, SchoolYear = "2025/26", GradeLevel = 9, IsActive = true,
};
var tool = new GroupTools(new FakeGroups([group]));
var result = Assert.Single(tool.GetGroups());
Assert.Equal(group.Id, result.Id);
Assert.Equal("9a", result.Name);
Assert.Equal("2025/26", result.SchoolYear);
Assert.Equal(9, result.GradeLevel);
Assert.True(result.IsActive);
}
// ── StudentTools ─────────────────────────────────────────────────────────────────────────
[Fact]
public void GetStudents_OhneFilter_LiefertNurAktiveSchueler()
{
var active = new Student { FirstName = "Anna", LastName = "Aktiv", IsActive = true };
var inactive = new Student { FirstName = "Ida", LastName = "Inaktiv", IsActive = false };
var tool = new StudentTools(new FakeStudents([active, inactive]));
var result = tool.GetStudents();
Assert.Single(result);
Assert.Equal(active.Id, result[0].Id);
}
[Fact]
public void GetStudents_IncludeInactive_LiefertAuchInaktive()
{
var active = new Student { FirstName = "Anna", LastName = "Aktiv", IsActive = true };
var inactive = new Student { FirstName = "Ida", LastName = "Inaktiv", IsActive = false };
var tool = new StudentTools(new FakeStudents([active, inactive]));
var result = tool.GetStudents(includeInactive: true);
Assert.Equal(2, result.Count);
}
// ── ExamTools ────────────────────────────────────────────────────────────────────────────
[Fact]
public void GetExams_OhneIncludeResults_LiefertKeineErgebnisse()
{
var groupId = Guid.NewGuid();
var exam = new Exam { GroupId = groupId, Title = "Klausur 1" };
var results = new FakeResults();
results.Add(new ExamResult { ExamId = exam.Id, StudentId = Guid.NewGuid(), TotalPoints = 10 });
var tool = new ExamTools(new FakeExams([exam]), results);
var dto = Assert.Single(tool.GetExams(groupId));
Assert.Null(dto.Results);
}
[Fact]
public void GetExams_MitIncludeResults_LiefertErgebnisseJeSchueler()
{
var groupId = Guid.NewGuid();
var studentId = Guid.NewGuid();
var exam = new Exam { GroupId = groupId, Title = "Klausur 1" };
var results = new FakeResults();
results.Add(new ExamResult { ExamId = exam.Id, StudentId = studentId, TotalPoints = 12.5, Grade = "2+" });
var tool = new ExamTools(new FakeExams([exam]), results);
var dto = Assert.Single(tool.GetExams(groupId, includeResults: true));
var result = Assert.Single(dto.Results!);
Assert.Equal(studentId, result.StudentId);
Assert.Equal("2+", result.Grade);
}
// ── GradeTools ───────────────────────────────────────────────────────────────────────────
[Fact]
public void GetGrades_MitStudentId_FiltertAufEinenSchueler()
{
var groupId = Guid.NewGuid();
var studentA = Guid.NewGuid();
var studentB = Guid.NewGuid();
var grades = new FakeGrades();
grades.Add(new Grade { GroupId = groupId, StudentId = studentA, Value = "2" });
grades.Add(new Grade { GroupId = groupId, StudentId = studentB, Value = "3" });
var tool = new GradeTools(grades, new FakeStudents([]), new FakeMcpConfirmation());
var dto = Assert.Single(tool.GetGrades(groupId, studentA));
Assert.Equal(studentA, dto.StudentId);
}
[Fact]
public async Task CreateGradeEntry_NutzerBestaetigt_SpeichertNote()
{
var student = new Student { FirstName = "Anna", LastName = "Aktiv" };
var grades = new FakeGrades();
var confirmation = new FakeMcpConfirmation { Response = true };
var tool = new GradeTools(grades, new FakeStudents([student]), confirmation);
var groupId = Guid.NewGuid();
var result = await tool.CreateGradeEntry(
student.Id, groupId, GradeCategory.Oral, "2+", new DateOnly(2026, 1, 10));
Assert.True(result.Applied);
Assert.NotNull(result.Id);
Assert.Single(grades.GetByGroup(groupId));
// Bestätigungstext muss für einen Menschen lesbar sein (Name statt bloßer GUID).
Assert.Contains("Anna", confirmation.LastMessage);
Assert.DoesNotContain(student.Id.ToString(), confirmation.LastMessage);
}
[Fact]
public async Task CreateGradeEntry_NutzerLehntAb_SpeichertNichts()
{
var student = new Student { FirstName = "Anna", LastName = "Aktiv" };
var grades = new FakeGrades();
var confirmation = new FakeMcpConfirmation { Response = false };
var tool = new GradeTools(grades, new FakeStudents([student]), confirmation);
var groupId = Guid.NewGuid();
var result = await tool.CreateGradeEntry(
student.Id, groupId, GradeCategory.Oral, "2+", new DateOnly(2026, 1, 10));
Assert.False(result.Applied);
Assert.Empty(grades.GetByGroup(groupId));
Assert.Equal(1, confirmation.CallCount);
}
// ── ScheduleTools ────────────────────────────────────────────────────────────────────────
[Fact]
public void GetSchedule_MitGroupId_FiltertNachGruppe()
{
var groupId = Guid.NewGuid();
var slots = new FakeTimetableSlots();
slots.Add(new TimetableSlot { GroupId = groupId, Weekday = DayOfWeek.Monday, PeriodNumber = 1 });
slots.Add(new TimetableSlot { GroupId = Guid.NewGuid(), Weekday = DayOfWeek.Tuesday, PeriodNumber = 2 });
var tool = new ScheduleTools(slots);
var dto = Assert.Single(tool.GetSchedule(groupId));
Assert.Equal(groupId, dto.GroupId);
}
// ── TimeEntryTools ───────────────────────────────────────────────────────────────────────
[Fact]
public void GetTimeEntries_FiltertAufDenAngegebenenZeitraum()
{
var entries = new FakeTimeEntries();
entries.Add(new TimeEntry { Date = new DateOnly(2026, 1, 5), DurationMinutes = 30 });
entries.Add(new TimeEntry { Date = new DateOnly(2026, 2, 1), DurationMinutes = 45 });
var tool = new TimeEntryTools(entries, new FakeGroups([]), new FakeMcpConfirmation());
var dto = Assert.Single(tool.GetTimeEntries(new DateOnly(2026, 1, 1), new DateOnly(2026, 1, 31)));
Assert.Equal(30, dto.DurationMinutes);
}
[Fact]
public async Task CreateTimeEntry_NutzerBestaetigt_SpeichertEintrag()
{
var entries = new FakeTimeEntries();
var confirmation = new FakeMcpConfirmation { Response = true };
var tool = new TimeEntryTools(entries, new FakeGroups([]), confirmation);
var result = await tool.CreateTimeEntry("Korrektur", new DateOnly(2026, 1, 10), 30);
Assert.True(result.Applied);
Assert.Single(entries.GetByDateRange(new DateOnly(2026, 1, 1), new DateOnly(2026, 1, 31)));
}
[Fact]
public async Task CreateTimeEntry_NutzerLehntAb_SpeichertNichts()
{
var entries = new FakeTimeEntries();
var confirmation = new FakeMcpConfirmation { Response = false };
var tool = new TimeEntryTools(entries, new FakeGroups([]), confirmation);
var result = await tool.CreateTimeEntry("Korrektur", new DateOnly(2026, 1, 10), 30);
Assert.False(result.Applied);
Assert.Empty(entries.GetByDateRange(new DateOnly(2026, 1, 1), new DateOnly(2026, 1, 31)));
}
// ── LessonPlanTools ──────────────────────────────────────────────────────────────────────
private static LessonPlanTools BuildLessonPlanTools(
FakeUnits? units = null, FakeLessons? lessons = null, FakeGroups? groups = null,
FakeAttachmentStorage? attachments = null, FakeMcpConfirmation? confirmation = null) =>
new(units ?? new FakeUnits(), lessons ?? new FakeLessons(), groups ?? new FakeGroups([]),
attachments ?? new FakeAttachmentStorage(), confirmation ?? new FakeMcpConfirmation());
[Fact]
public void GetLessonPlans_LiefertEinheitenDerGruppeUndStundenImZeitraum()
{
var groupId = Guid.NewGuid();
var units = new FakeUnits();
units.Add(new Unit { GroupId = groupId, Title = "Optik" });
var lessons = new FakeLessons();
lessons.Add(new Lesson { GroupId = groupId, Date = new DateOnly(2026, 1, 5), Topic = "Brechung" });
lessons.Add(new Lesson { GroupId = groupId, Date = new DateOnly(2026, 3, 1), Topic = "Später" });
var tool = BuildLessonPlanTools(units, lessons);
var result = tool.GetLessonPlans(groupId, new DateOnly(2026, 1, 1), new DateOnly(2026, 1, 31));
Assert.Single(result.Units);
var lesson = Assert.Single(result.Lessons);
Assert.Equal("Brechung", lesson.Topic);
}
[Fact]
public async Task CreateUnit_NutzerBestaetigt_SpeichertEinheit()
{
var group = new LearningGroup { Name = "7a" };
var units = new FakeUnits();
var tool = BuildLessonPlanTools(units: units, groups: new FakeGroups([group]));
var result = await tool.CreateUnit(group.Id, "Optik");
Assert.True(result.Applied);
Assert.Single(units.GetByGroup(group.Id));
}
[Fact]
public async Task UpdateUnit_OhneAenderung_FragtNichtNach()
{
var unit = new Unit { Title = "Optik", Status = UnitStatus.Planned };
var units = new FakeUnits();
units.Add(unit);
var confirmation = new FakeMcpConfirmation();
var tool = BuildLessonPlanTools(units: units, confirmation: confirmation);
var result = await tool.UpdateUnit(unit.Id, title: "Optik", status: UnitStatus.Planned);
Assert.True(result.Applied);
Assert.Equal(0, confirmation.CallCount);
}
[Fact]
public async Task CreateLesson_UnbekannteEinheit_LiefertFehlerOhneNachfrage()
{
var confirmation = new FakeMcpConfirmation();
var tool = BuildLessonPlanTools(confirmation: confirmation);
var result = await tool.CreateLesson(Guid.NewGuid(), new DateOnly(2026, 1, 5), "Brechung");
Assert.False(result.Applied);
Assert.Equal(0, confirmation.CallCount);
}
[Fact]
public async Task CreateLesson_NutzerBestaetigt_UebernimmtGruppeVonDerEinheit()
{
var groupId = Guid.NewGuid();
var unit = new Unit { GroupId = groupId, Title = "Optik" };
var units = new FakeUnits();
units.Add(unit);
var lessons = new FakeLessons();
var tool = BuildLessonPlanTools(units, lessons);
var result = await tool.CreateLesson(unit.Id, new DateOnly(2026, 1, 5), "Brechung");
Assert.True(result.Applied);
var lesson = Assert.Single(lessons.GetByUnit(unit.Id));
Assert.Equal(groupId, lesson.GroupId);
}
[Fact]
public async Task UpdateLesson_AendertNurAngegebeneFelder()
{
var lesson = new Lesson { Topic = "Brechung", Homework = "S. 12" };
var lessons = new FakeLessons();
lessons.Add(lesson);
var tool = BuildLessonPlanTools(lessons: lessons);
var result = await tool.UpdateLesson(lesson.Id, topic: "Brechung II");
Assert.True(result.Applied);
var updated = lessons.GetById(lesson.Id)!;
Assert.Equal("Brechung II", updated.Topic);
Assert.Equal("S. 12", updated.Homework);
}
[Fact]
public async Task AddLessonPhase_NutzerBestaetigt_HaengtPhaseAn()
{
var lesson = new Lesson { Topic = "Brechung" };
var lessons = new FakeLessons();
lessons.Add(lesson);
var tool = BuildLessonPlanTools(lessons: lessons);
var result = await tool.AddLessonPhase(lesson.Id, "Einstieg", 10);
Assert.True(result.Applied);
var phase = Assert.Single(lessons.GetById(lesson.Id)!.Phases);
Assert.Equal("Einstieg", phase.Name);
Assert.Equal(result.Id, phase.Id);
}
[Fact]
public async Task AddLessonPhase_UebergibtEigenenMethodennamenAlsOperationKey()
{
// Belegt, dass IMcpConfirmationService.ConfirmAsync den [CallerMemberName]-Mechanismus
// tatsächlich nutzt (Grundlage für die Sitzungsfreigabe "diese Aktion nicht mehr
// nachfragen" in AvaloniaMcpConfirmationService) - ohne echtes UI testbar, weil der Name
// vom Compiler an der Aufrufstelle in AddLessonPhase eingesetzt wird, unabhängig von der
// konkreten IMcpConfirmationService-Implementierung.
var lesson = new Lesson { Topic = "Brechung" };
var lessons = new FakeLessons();
lessons.Add(lesson);
var confirmation = new FakeMcpConfirmation();
var tool = BuildLessonPlanTools(lessons: lessons, confirmation: confirmation);
await tool.AddLessonPhase(lesson.Id, "Einstieg", 10);
Assert.Equal(nameof(LessonPlanTools.AddLessonPhase), confirmation.LastOperationKey);
}
[Fact]
public async Task UpdateLessonPhase_AendertNurAngegebeneFelder()
{
var phase = new LessonPhaseStep { Name = "Einstieg", DurationMinutes = 10, Material = "Folie" };
var lesson = new Lesson { Topic = "Brechung" };
lesson.Phases.Add(phase);
var lessons = new FakeLessons();
lessons.Add(lesson);
var tool = BuildLessonPlanTools(lessons: lessons);
var result = await tool.UpdateLessonPhase(lesson.Id, phase.Id, durationMinutes: 15);
Assert.True(result.Applied);
var updated = lessons.GetById(lesson.Id)!.Phases.Single();
Assert.Equal(15, updated.DurationMinutes);
Assert.Equal("Folie", updated.Material);
}
[Fact]
public async Task RemoveLessonPhase_NutzerBestaetigt_EntferntPhase()
{
var phase = new LessonPhaseStep { Name = "Einstieg", DurationMinutes = 10 };
var lesson = new Lesson { Topic = "Brechung" };
lesson.Phases.Add(phase);
var lessons = new FakeLessons();
lessons.Add(lesson);
var tool = BuildLessonPlanTools(lessons: lessons);
var result = await tool.RemoveLessonPhase(lesson.Id, phase.Id);
Assert.True(result.Applied);
Assert.Empty(lessons.GetById(lesson.Id)!.Phases);
}
[Fact]
public void DownloadLessonAttachment_LiefertBase64Inhalt()
{
var storage = new FakeAttachmentStorage();
var storageId = storage.Upload("blatt.pdf", new MemoryStream("PDF-Inhalt"u8.ToArray()));
var lesson = new Lesson { Topic = "Brechung" };
lesson.Attachments.Add(new DocumentAttachment { StorageId = storageId, FileName = "blatt.pdf", SizeBytes = 10 });
var lessons = new FakeLessons();
lessons.Add(lesson);
var tool = BuildLessonPlanTools(lessons: lessons, attachments: storage);
var result = tool.DownloadLessonAttachment(lesson.Id, storageId);
Assert.Equal("blatt.pdf", result.FileName);
Assert.Equal("PDF-Inhalt", System.Text.Encoding.UTF8.GetString(Convert.FromBase64String(result.Base64Content)));
}
[Fact]
public void DownloadLessonAttachment_ZuGross_WirftFehler()
{
var lesson = new Lesson { Topic = "Brechung" };
lesson.Attachments.Add(new DocumentAttachment
{
StorageId = "big", FileName = "video.mp4", SizeBytes = LessonPlanTools.MaxInlineAttachmentBytes + 1,
});
var lessons = new FakeLessons();
lessons.Add(lesson);
var tool = BuildLessonPlanTools(lessons: lessons);
Assert.Throws<InvalidOperationException>(() => tool.DownloadLessonAttachment(lesson.Id, "big"));
}
[Fact]
public async Task AddLessonAttachment_NutzerBestaetigt_LaedtHochUndHaengtAn()
{
var lesson = new Lesson { Topic = "Brechung" };
var lessons = new FakeLessons();
lessons.Add(lesson);
var storage = new FakeAttachmentStorage();
var tool = BuildLessonPlanTools(lessons: lessons, attachments: storage);
var content = Convert.ToBase64String("Arbeitsblatt-Inhalt"u8.ToArray());
var result = await tool.AddLessonAttachment(lesson.Id, "arbeitsblatt.pdf", content);
Assert.True(result.Applied);
var attachment = Assert.Single(lessons.GetById(lesson.Id)!.Attachments);
Assert.Equal("arbeitsblatt.pdf", attachment.FileName);
using var stream = storage.OpenRead(attachment.StorageId)!;
using var reader = new StreamReader(stream);
Assert.Equal("Arbeitsblatt-Inhalt", reader.ReadToEnd());
}
[Fact]
public async Task AddLessonAttachment_NutzerLehntAb_SpeichertNichts()
{
var lesson = new Lesson { Topic = "Brechung" };
var lessons = new FakeLessons();
lessons.Add(lesson);
var confirmation = new FakeMcpConfirmation { Response = false };
var tool = BuildLessonPlanTools(lessons: lessons, confirmation: confirmation);
var content = Convert.ToBase64String("Inhalt"u8.ToArray());
var result = await tool.AddLessonAttachment(lesson.Id, "arbeitsblatt.pdf", content);
Assert.False(result.Applied);
Assert.Empty(lessons.GetById(lesson.Id)!.Attachments);
}
[Fact]
public async Task AddLessonAttachment_UngueltigesBase64_LiefertFehlerOhneNachfrage()
{
var lesson = new Lesson { Topic = "Brechung" };
var lessons = new FakeLessons();
lessons.Add(lesson);
var confirmation = new FakeMcpConfirmation();
var tool = BuildLessonPlanTools(lessons: lessons, confirmation: confirmation);
var result = await tool.AddLessonAttachment(lesson.Id, "arbeitsblatt.pdf", "nicht-base64!!!");
Assert.False(result.Applied);
Assert.Equal(0, confirmation.CallCount);
}
[Fact]
public async Task AddLessonAttachment_UnbekannteStunde_LiefertFehlerOhneNachfrage()
{
var confirmation = new FakeMcpConfirmation();
var tool = BuildLessonPlanTools(confirmation: confirmation);
var result = await tool.AddLessonAttachment(Guid.NewGuid(), "x.pdf", Convert.ToBase64String("x"u8.ToArray()));
Assert.False(result.Applied);
Assert.Equal(0, confirmation.CallCount);
}
[Fact]
public async Task MoveLesson_NutzerBestaetigt_VerschiebtDatum()
{
var lesson = new Lesson { Topic = "Brechung", Date = new DateOnly(2026, 1, 5) };
var lessons = new FakeLessons();
lessons.Add(lesson);
var tool = BuildLessonPlanTools(lessons: lessons);
var result = await tool.MoveLesson(lesson.Id, new DateOnly(2026, 1, 12));
Assert.True(result.Applied);
Assert.Equal(new DateOnly(2026, 1, 12), lessons.GetById(lesson.Id)!.Date);
}
[Fact]
public async Task MoveLesson_ShiftFollowing_VerschiebtSpaetereStundenDerselbenEinheitMit()
{
var unitId = Guid.NewGuid();
var moved = new Lesson { UnitId = unitId, Topic = "Brechung", Date = new DateOnly(2026, 1, 5) };
var later = new Lesson { UnitId = unitId, Topic = "Reflexion", Date = new DateOnly(2026, 1, 7) };
var earlier = new Lesson { UnitId = unitId, Topic = "Einstieg", Date = new DateOnly(2026, 1, 1) };
var lessons = new FakeLessons();
lessons.Add(moved); lessons.Add(later); lessons.Add(earlier);
var tool = BuildLessonPlanTools(lessons: lessons);
// moved: 5.1. -> 12.1. (Delta +7 Tage), later (7.1., danach) muss mitwandern, earlier (1.1., davor) nicht.
var result = await tool.MoveLesson(moved.Id, new DateOnly(2026, 1, 12), shiftFollowingLessons: true);
Assert.True(result.Applied);
Assert.Equal(new DateOnly(2026, 1, 14), lessons.GetById(later.Id)!.Date);
Assert.Equal(new DateOnly(2026, 1, 1), lessons.GetById(earlier.Id)!.Date);
}
[Fact]
public async Task MoveLesson_ZielStundeBelegt_LiefertFehlerNachBestaetigung()
{
var groupId = Guid.NewGuid();
var moved = new Lesson { GroupId = groupId, Topic = "Brechung", Date = new DateOnly(2026, 1, 5), LessonNumber = 1 };
var occupying = new Lesson { GroupId = groupId, Topic = "Anderer Kurs", Date = new DateOnly(2026, 1, 12), LessonNumber = 1 };
var lessons = new FakeLessons();
lessons.Add(moved); lessons.Add(occupying);
var confirmation = new FakeMcpConfirmation();
var tool = BuildLessonPlanTools(lessons: lessons, confirmation: confirmation);
var result = await tool.MoveLesson(moved.Id, new DateOnly(2026, 1, 12), newPeriod: 1);
Assert.False(result.Applied);
Assert.Equal(1, confirmation.CallCount); // Bestätigung lief bereits, Konflikt zeigt sich erst beim Speichern.
Assert.Equal(new DateOnly(2026, 1, 5), lessons.GetById(moved.Id)!.Date); // unverändert
}
[Fact]
public async Task MoveLesson_UnbekannteStunde_LiefertFehlerOhneNachfrage()
{
var confirmation = new FakeMcpConfirmation();
var tool = BuildLessonPlanTools(confirmation: confirmation);
var result = await tool.MoveLesson(Guid.NewGuid(), new DateOnly(2026, 1, 12));
Assert.False(result.Applied);
Assert.Equal(0, confirmation.CallCount);
}
[Fact]
public async Task DeleteLesson_NutzerBestaetigt_LoeschtEndgueltig()
{
var lesson = new Lesson { Topic = "Brechung" };
var lessons = new FakeLessons();
lessons.Add(lesson);
var tool = BuildLessonPlanTools(lessons: lessons);
var result = await tool.DeleteLesson(lesson.Id);
Assert.True(result.Applied);
Assert.Null(lessons.GetById(lesson.Id));
}
[Fact]
public async Task DeleteLesson_NutzerLehntAb_BleibtErhalten()
{
var lesson = new Lesson { Topic = "Brechung" };
var lessons = new FakeLessons();
lessons.Add(lesson);
var confirmation = new FakeMcpConfirmation { Response = false };
var tool = BuildLessonPlanTools(lessons: lessons, confirmation: confirmation);
var result = await tool.DeleteLesson(lesson.Id);
Assert.False(result.Applied);
Assert.NotNull(lessons.GetById(lesson.Id));
}
[Fact]
public async Task DeleteLesson_UnbekannteStunde_LiefertFehlerOhneNachfrage()
{
var confirmation = new FakeMcpConfirmation();
var tool = BuildLessonPlanTools(confirmation: confirmation);
var result = await tool.DeleteLesson(Guid.NewGuid());
Assert.False(result.Applied);
Assert.Equal(0, confirmation.CallCount);
}
// ── GroupMembershipTools ─────────────────────────────────────────────────────────────────
[Fact]
public async Task UpdateStudentGroupAssignment_KeineBestehendeMitgliedschaft_LegtNeueAn()
{
var student = new Student { FirstName = "Anna", LastName = "Aktiv" };
var group = new LearningGroup { Name = "7a" };
var memberships = new FakeMemberships([]);
var confirmation = new FakeMcpConfirmation { Response = true };
var tool = new GroupMembershipTools(memberships, new FakeStudents([student]), new FakeGroups([group]), confirmation);
var result = await tool.UpdateStudentGroupAssignment(student.Id, group.Id, niveau: Niveau.E);
Assert.True(result.Applied);
var membership = Assert.Single(memberships.GetByStudent(student.Id));
Assert.Equal(Niveau.E, membership.Niveau);
Assert.Contains("Anna", confirmation.LastMessage);
}
[Fact]
public async Task UpdateStudentGroupAssignment_BestehendeMitgliedschaftUnveraendert_FragtNichtNochmalNach()
{
var student = new Student { FirstName = "Anna", LastName = "Aktiv" };
var group = new LearningGroup { Name = "7a" };
var existing = new GroupMembership { StudentId = student.Id, GroupId = group.Id, Niveau = Niveau.G };
var memberships = new FakeMemberships([existing]);
var confirmation = new FakeMcpConfirmation { Response = true };
var tool = new GroupMembershipTools(memberships, new FakeStudents([student]), new FakeGroups([group]), confirmation);
var result = await tool.UpdateStudentGroupAssignment(student.Id, group.Id, niveau: Niveau.G);
Assert.True(result.Applied);
Assert.Equal(0, confirmation.CallCount);
}
[Fact]
public async Task UpdateStudentGroupAssignment_UnbekannterSchueler_LiefertFehlerOhneNachfrage()
{
var group = new LearningGroup { Name = "7a" };
var confirmation = new FakeMcpConfirmation { Response = true };
var tool = new GroupMembershipTools(new FakeMemberships([]), new FakeStudents([]), new FakeGroups([group]), confirmation);
var result = await tool.UpdateStudentGroupAssignment(Guid.NewGuid(), group.Id);
Assert.False(result.Applied);
Assert.Equal(0, confirmation.CallCount);
}
// ── LessonPlanTools — Kompetenzzuordnung ────────────────────────────────────────────────────
[Fact]
public async Task AddLessonCompetency_NutzerBestaetigt_OrdnetCodeZu()
{
var lesson = new Lesson { Topic = "Brechung" };
var lessons = new FakeLessons();
lessons.Add(lesson);
var tool = BuildLessonPlanTools(lessons: lessons);
var result = await tool.AddLessonCompetency(lesson.Id, "PH.9.1");
Assert.True(result.Applied);
Assert.Contains("PH.9.1", lessons.GetById(lesson.Id)!.Competencies);
}
[Fact]
public async Task AddLessonCompetency_BereitsZugeordnet_FragtNichtNochmalNach()
{
var lesson = new Lesson { Topic = "Brechung" };
lesson.Competencies.Add("PH.9.1");
var lessons = new FakeLessons();
lessons.Add(lesson);
var confirmation = new FakeMcpConfirmation();
var tool = BuildLessonPlanTools(lessons: lessons, confirmation: confirmation);
var result = await tool.AddLessonCompetency(lesson.Id, "PH.9.1");
Assert.True(result.Applied);
Assert.Equal(0, confirmation.CallCount);
Assert.Single(lessons.GetById(lesson.Id)!.Competencies);
}
[Fact]
public async Task RemoveLessonCompetency_NutzerBestaetigt_EntferntCode()
{
var lesson = new Lesson { Topic = "Brechung" };
lesson.Competencies.Add("PH.9.1");
var lessons = new FakeLessons();
lessons.Add(lesson);
var tool = BuildLessonPlanTools(lessons: lessons);
var result = await tool.RemoveLessonCompetency(lesson.Id, "PH.9.1");
Assert.True(result.Applied);
Assert.Empty(lessons.GetById(lesson.Id)!.Competencies);
}
[Fact]
public async Task RemoveLessonCompetency_NichtZugeordnet_LiefertFehlerOhneNachfrage()
{
var lesson = new Lesson { Topic = "Brechung" };
var lessons = new FakeLessons();
lessons.Add(lesson);
var confirmation = new FakeMcpConfirmation();
var tool = BuildLessonPlanTools(lessons: lessons, confirmation: confirmation);
var result = await tool.RemoveLessonCompetency(lesson.Id, "PH.9.1");
Assert.False(result.Applied);
Assert.Equal(0, confirmation.CallCount);
}
// ── CompetencyTools ──────────────────────────────────────────────────────────────────────
[Fact]
public void GetSubjects_LiefertAlleFaecher()
{
var subject = new Subject { Name = "Mathematik", ShortName = "Ma" };
var tool = new CompetencyTools(new FakeSubjects([subject]), new FakeCompetencyDomains(), new FakeMcpConfirmation());
var dto = Assert.Single(tool.GetSubjects());
Assert.Equal("Mathematik", dto.Name);
}
[Fact]
public async Task CreateSubject_NutzerBestaetigt_SpeichertFach()
{
var subjects = new FakeSubjects([]);
var tool = new CompetencyTools(subjects, new FakeCompetencyDomains(), new FakeMcpConfirmation());
var result = await tool.CreateSubject("Mathematik", "Ma");
Assert.True(result.Applied);
var subject = Assert.Single(subjects.GetAll());
Assert.Equal("Mathematik", subject.Name);
}
[Fact]
public async Task CreateSubject_NameBereitsVergeben_LiefertFehler()
{
var subjects = new FakeSubjects([new Subject { Name = "Mathematik" }]);
var tool = new CompetencyTools(subjects, new FakeCompetencyDomains(), new FakeMcpConfirmation());
var result = await tool.CreateSubject("Mathematik");
Assert.False(result.Applied);
Assert.Single(subjects.GetAll());
}
[Fact]
public async Task UpdateSubject_AendertNurAngegebeneFelder()
{
var subject = new Subject { Name = "Mathematik", ShortName = "Ma" };
var subjects = new FakeSubjects([subject]);
var tool = new CompetencyTools(subjects, new FakeCompetencyDomains(), new FakeMcpConfirmation());
var result = await tool.UpdateSubject(subject.Id, shortName: "M");
Assert.True(result.Applied);
var updated = subjects.GetById(subject.Id)!;
Assert.Equal("Mathematik", updated.Name);
Assert.Equal("M", updated.ShortName);
}
[Fact]
public async Task DeleteSubject_NutzerBestaetigt_LoeschtFach()
{
var subject = new Subject { Name = "Mathematik" };
var subjects = new FakeSubjects([subject]);
var tool = new CompetencyTools(subjects, new FakeCompetencyDomains(), new FakeMcpConfirmation());
var result = await tool.DeleteSubject(subject.Id);
Assert.True(result.Applied);
Assert.Empty(subjects.GetAll());
}
[Fact]
public async Task DeleteSubject_UnbekannteId_LiefertFehlerOhneNachfrage()
{
var confirmation = new FakeMcpConfirmation();
var tool = new CompetencyTools(new FakeSubjects([]), new FakeCompetencyDomains(), confirmation);
var result = await tool.DeleteSubject(Guid.NewGuid());
Assert.False(result.Applied);
Assert.Equal(0, confirmation.CallCount);
}
[Fact]
public void GetCompetencyCatalog_OhneKlassenstufe_LiefertAlleKlassenstufenDesFachs()
{
var subjectId = Guid.NewGuid();
var domains = new FakeCompetencyDomains();
domains.Add(new CompetencyDomain { SubjectId = subjectId, GradeLevel = 5, Name = "Zahlen" });
domains.Add(new CompetencyDomain { SubjectId = subjectId, GradeLevel = 9, Name = "Funktionen" });
var tool = new CompetencyTools(new FakeSubjects([]), domains, new FakeMcpConfirmation());
var result = tool.GetCompetencyCatalog(subjectId);
Assert.Equal(2, result.Count);
}
[Fact]
public async Task CreateCompetencyDomain_NutzerBestaetigt_SpeichertBereich()
{
var subject = new Subject { Name = "Mathematik" };
var domains = new FakeCompetencyDomains();
var tool = new CompetencyTools(new FakeSubjects([subject]), domains, new FakeMcpConfirmation());
var result = await tool.CreateCompetencyDomain(subject.Id, 9, "Funktionen");
Assert.True(result.Applied);
var domain = Assert.Single(domains.GetBySubjectAndGrade(subject.Id, 9));
Assert.Equal("Funktionen", domain.Name);
}
[Fact]
public async Task DeleteCompetencyDomain_NutzerBestaetigt_LoeschtBereichMitItems()
{
var domain = new CompetencyDomain { Name = "Funktionen" };
domain.Items.Add(new CompetencyItem { Code = "M.9.1", Description = "..." });
var domains = new FakeCompetencyDomains();
domains.Add(domain);
var tool = new CompetencyTools(new FakeSubjects([]), domains, new FakeMcpConfirmation());
var result = await tool.DeleteCompetencyDomain(domain.Id);
Assert.True(result.Applied);
Assert.Null(domains.GetById(domain.Id));
}
[Fact]
public async Task AddCompetencyItem_NutzerBestaetigt_HaengtItemAn()
{
var domain = new CompetencyDomain { Name = "Funktionen" };
var domains = new FakeCompetencyDomains();
domains.Add(domain);
var tool = new CompetencyTools(new FakeSubjects([]), domains, new FakeMcpConfirmation());
var result = await tool.AddCompetencyItem(domain.Id, "M.9.1", "lineare Funktionen erkennen");
Assert.True(result.Applied);
var item = Assert.Single(domains.GetById(domain.Id)!.Items);
Assert.Equal("M.9.1", item.Code);
}
[Fact]
public async Task UpdateCompetencyItem_AendertNurAngegebeneFelder()
{
var item = new CompetencyItem { Code = "M.9.1", Description = "alt" };
var domain = new CompetencyDomain { Name = "Funktionen" };
domain.Items.Add(item);
var domains = new FakeCompetencyDomains();
domains.Add(domain);
var tool = new CompetencyTools(new FakeSubjects([]), domains, new FakeMcpConfirmation());
var result = await tool.UpdateCompetencyItem(domain.Id, item.Id, description: "neu");
Assert.True(result.Applied);
var updated = domains.GetById(domain.Id)!.Items.Single();
Assert.Equal("M.9.1", updated.Code);
Assert.Equal("neu", updated.Description);
}
[Fact]
public async Task RemoveCompetencyItem_NutzerBestaetigt_EntferntItem()
{
var item = new CompetencyItem { Code = "M.9.1", Description = "..." };
var domain = new CompetencyDomain { Name = "Funktionen" };
domain.Items.Add(item);
var domains = new FakeCompetencyDomains();
domains.Add(domain);
var tool = new CompetencyTools(new FakeSubjects([]), domains, new FakeMcpConfirmation());
var result = await tool.RemoveCompetencyItem(domain.Id, item.Id);
Assert.True(result.Applied);
Assert.Empty(domains.GetById(domain.Id)!.Items);
}
}
@@ -0,0 +1,76 @@
using LehrerApp.Core.Models;
using LehrerApp.Desktop.ViewModels.Groups;
using LehrerApp.Templating;
using Xunit;
namespace LehrerApp.Desktop.Tests;
public sealed class PersonalizeWorksheetDialogViewModelTests : IDisposable
{
private readonly string _directory = Path.Combine(Path.GetTempPath(), $"lehrerapp-worksheet-vm-tests-{Guid.NewGuid():N}");
public PersonalizeWorksheetDialogViewModelTests() => Directory.CreateDirectory(_directory);
[Fact]
public void Generate_ErzeugtEinePdfJeAusgewaehltemSchueler()
{
var group = new LearningGroup { Name = "8a", SchoolYear = "2025/26" };
var anna = new Student { FirstName = "Anna", LastName = "Adler" };
var ben = new Student { FirstName = "Ben", LastName = "Bauer" };
var vm = Build(group, [anna, ben], StoreWithTemplate());
var output = Path.Combine(_directory, "output");
vm.Generate(output);
Assert.Equal(2, vm.Results.Count);
Assert.All(vm.Results, r => Assert.True(r.Success));
Assert.Equal(2, Directory.GetFiles(output, "*.pdf").Length);
}
[Fact]
public void Generate_AbgewaehlterSchueler_WirdUebersprungen()
{
var group = new LearningGroup { Name = "8a", SchoolYear = "2025/26" };
var anna = new Student { FirstName = "Anna", LastName = "Adler" };
var ben = new Student { FirstName = "Ben", LastName = "Bauer" };
var vm = Build(group, [anna, ben], StoreWithTemplate());
vm.Students.Single(s => s.Model.Id == ben.Id).IsIncluded = false;
var output = Path.Combine(_directory, "output");
vm.Generate(output);
Assert.Single(vm.Results);
Assert.Equal(anna.FullName, vm.Results[0].StudentName);
}
[Fact]
public void OhneVorlage_GenerateTutNichts()
{
var group = new LearningGroup { Name = "8a", SchoolYear = "2025/26" };
var anna = new Student { FirstName = "Anna", LastName = "Adler" };
var vm = Build(group, [anna], new WorksheetTemplateStore(new TemplateStore(Path.Combine(_directory, "empty-store"))));
var output = Path.Combine(_directory, "output");
vm.Generate(output);
Assert.Empty(vm.Results);
}
private static PersonalizeWorksheetDialogViewModel Build(LearningGroup group, List<Student> students, WorksheetTemplateStore store) =>
new(group, [.. students.Select(s => s.Id)], new FakeStudents(students), store, new QuestTemplateRenderer());
private WorksheetTemplateStore StoreWithTemplate()
{
var source = Path.Combine(_directory, $"{Guid.NewGuid():N}.lavorlage");
var manifest = new TemplateManifest
{
Id = $"arbeitsblatt-{Guid.NewGuid():N}", Name = "Arbeitsblatt",
Placeholders = [new PlaceholderDefinition("Student.FirstName", PlaceholderType.Text)],
};
TemplatePackage.Create(source, manifest, "PAGE 210 297 mm\nTEXT 20 20 $Student.FirstName", new Dictionary<string, byte[]>());
var store = new WorksheetTemplateStore(new TemplateStore(Path.Combine(_directory, $"store-{Guid.NewGuid():N}")));
store.Store.Import(source);
return store;
}
public void Dispose() { if (Directory.Exists(_directory)) Directory.Delete(_directory, true); }
}
@@ -0,0 +1,81 @@
using LehrerApp.Core.Models;
using LehrerApp.Desktop.ViewModels.Groups;
using Xunit;
namespace LehrerApp.Desktop.Tests;
public sealed class SeatingQuickCheckTests
{
private static (SeatingPlanTabViewModel Vm, FakeEntries Entries, ParticipationSession Session, Student Student) Build(bool readOnly = false)
{
var group = Guid.NewGuid();
var student = new Student { FirstName = "Anna", LastName = "Beispiel" };
var session = new ParticipationSession { GroupId = group, Date = DateOnly.FromDateTime(DateTime.Today) };
var entries = new FakeEntries();
var plan = new SeatingPlan { GroupId = group, Rows = 1, Columns = 2,
Assignments = [new SeatAssignment { Row = 0, Column = 0, StudentId = student.Id }] };
var vm = new SeatingPlanTabViewModel(new FakeSeatingPlans([plan]), new FakeStudents([student]),
new FakeMemberships([new GroupMembership { GroupId = group, StudentId = student.Id }]),
new FakeSessions([session]), entries, new FakeAspects());
vm.Initialize(group, readOnly);
return (vm, entries, session, student);
}
[Fact] public void Attendance_UsesSeatAndPreservesExistingHomeworkAndCounters()
{
var (vm, entries, session, student) = Build();
entries.Save(new ParticipationEntry { SessionId = session.Id, StudentId = student.Id,
Homework = HomeworkStatus.Completed, RaisedHandCount = 3, CalledOnCount = 2 });
vm.CheckAttendanceCommand.Execute(null);
var seat = vm.Seats[0];
Assert.True(seat.ShowQuickCheck);
Assert.False(vm.Seats[1].ShowQuickCheck);
seat.QuickNegativeCommand.Execute(null);
var entry = entries.GetBySessionAndStudent(session.Id, student.Id)!;
Assert.Equal(AttendanceStatus.ExcusePending, entry.Attendance);
Assert.Equal(HomeworkStatus.Completed, entry.Homework);
Assert.Equal(3, entry.RaisedHandCount);
Assert.Equal(2, entry.CalledOnCount);
Assert.Equal(1, seat.DisplayOpacity);
seat.QuickPositiveCommand.Execute(null);
Assert.Equal(AttendanceStatus.Present, entry.Attendance);
}
[Fact] public void Homework_UpdatesLegacyFlagAndPreservesAttendance()
{
var (vm, entries, session, student) = Build();
entries.Save(new ParticipationEntry { SessionId = session.Id, StudentId = student.Id, Attendance = AttendanceStatus.Late });
vm.CheckHomeworkCommand.Execute(null);
vm.Seats[0].QuickNegativeCommand.Execute(null);
var entry = entries.GetBySessionAndStudent(session.Id, student.Id)!;
Assert.Equal(HomeworkStatus.MissingOpen, entry.Homework);
Assert.True(entry.HomeworkMissing);
Assert.Equal(AttendanceStatus.Late, entry.Attendance);
vm.Seats[0].QuickPositiveCommand.Execute(null);
Assert.Equal(HomeworkStatus.Completed, entry.Homework);
Assert.False(entry.HomeworkMissing);
vm.EndQuickCheckCommand.Execute(null);
Assert.False(vm.Seats[0].ShowQuickCheck);
Assert.True(vm.Seats[0].ShowNormalActions);
}
[Fact] public async Task SpecialCases_OpenAssessmentForClickedStudentAndSession()
{
var (vm, _, _, _) = Build();
var calls = 0;
vm.OnAssessStudent = _ => { calls++; return Task.CompletedTask; };
vm.CheckAttendanceCommand.Execute(null);
await vm.Seats[0].QuickSpecialCommand.ExecuteAsync(null);
Assert.Equal(1, calls);
}
[Fact] public void ReadOnlyAndEmptySeats_CannotWriteQuickChecks()
{
var (vm, entries, session, _) = Build(readOnly: true);
vm.CheckHomeworkCommand.Execute(null);
vm.Seats[0].QuickNegativeCommand.Execute(null);
vm.Seats[1].QuickPositiveCommand.Execute(null);
Assert.False(vm.Seats[0].ShowQuickCheck);
Assert.Empty(entries.GetBySession(session.Id));
}
}
@@ -35,7 +35,9 @@ public sealed class SettingsViewModelTests
new FakeDocumentation(), new FakeStudents([]), new FakeShorthandCodes([]), new FakeDocumentation(), new FakeStudents([]), new FakeShorthandCodes([]),
holidays ?? new FakeSchoolHolidays(), new SchoolCalendarSettingsService(tempPath), holidays ?? new FakeSchoolHolidays(), new SchoolCalendarSettingsService(tempPath),
new PeriodScheduleService(tempPath), supervisionDuties ?? new FakeSupervisionDuties(), new PeriodScheduleService(tempPath), supervisionDuties ?? new FakeSupervisionDuties(),
new TemplateStore(tempPath), TestSupport.BuildAiSettingsService(), TestSupport.BuildAiPlanningService(), new TemplateStore(tempPath), new WorksheetTemplateStore(new TemplateStore(tempPath, subfolder: "worksheet-template-packages")), TestSupport.BuildAiSettingsService(), TestSupport.BuildAiPlanningService(),
TestSupport.BuildMcpSettingsService(),
TestSupport.BuildMcpClientRegistrationService(),
TestSupport.BuildWebUntisSettingsService(), TestSupport.BuildWebUntisSettingsService(),
TestSupport.BuildAnnualPlanSettingsService(), TestSupport.BuildAnnualPlanSettingsService(),
TestSupport.BuildSyncSettingsService(), TestSupport.BuildSyncAuthService(), TestSupport.BuildSyncSettingsService(), TestSupport.BuildSyncAuthService(),
@@ -340,7 +342,9 @@ public sealed class SettingsViewModelTests
new LiteDbContext(new MemoryStream()), new PrivacySettingsService(tempPath), new LiteDbContext(new MemoryStream()), new PrivacySettingsService(tempPath),
new FakeDocumentation(), new FakeStudents([]), new FakeShorthandCodes([]), new FakeDocumentation(), new FakeStudents([]), new FakeShorthandCodes([]),
new FakeSchoolHolidays(), calendarSettings, new PeriodScheduleService(tempPath), new FakeSchoolHolidays(), calendarSettings, new PeriodScheduleService(tempPath),
new FakeSupervisionDuties(), new TemplateStore(tempPath), TestSupport.BuildAiSettingsService(), TestSupport.BuildAiPlanningService(), new FakeSupervisionDuties(), new TemplateStore(tempPath), new WorksheetTemplateStore(new TemplateStore(tempPath, subfolder: "worksheet-template-packages")), TestSupport.BuildAiSettingsService(), TestSupport.BuildAiPlanningService(),
TestSupport.BuildMcpSettingsService(),
TestSupport.BuildMcpClientRegistrationService(),
TestSupport.BuildWebUntisSettingsService(), TestSupport.BuildWebUntisSettingsService(),
TestSupport.BuildAnnualPlanSettingsService(), TestSupport.BuildAnnualPlanSettingsService(),
TestSupport.BuildSyncSettingsService(), TestSupport.BuildSyncAuthService(), TestSupport.BuildEventQueue(), TestSupport.BuildSyncSettingsService(), TestSupport.BuildSyncAuthService(), TestSupport.BuildEventQueue(),
@@ -368,7 +372,9 @@ public sealed class SettingsViewModelTests
new LiteDbContext(new MemoryStream()), new PrivacySettingsService(tempPath), new LiteDbContext(new MemoryStream()), new PrivacySettingsService(tempPath),
new FakeDocumentation(), new FakeStudents([]), new FakeShorthandCodes([]), new FakeDocumentation(), new FakeStudents([]), new FakeShorthandCodes([]),
new FakeSchoolHolidays(), new SchoolCalendarSettingsService(tempPath), periodSchedule, new FakeSchoolHolidays(), new SchoolCalendarSettingsService(tempPath), periodSchedule,
new FakeSupervisionDuties(), new TemplateStore(tempPath), TestSupport.BuildAiSettingsService(), TestSupport.BuildAiPlanningService(), new FakeSupervisionDuties(), new TemplateStore(tempPath), new WorksheetTemplateStore(new TemplateStore(tempPath, subfolder: "worksheet-template-packages")), TestSupport.BuildAiSettingsService(), TestSupport.BuildAiPlanningService(),
TestSupport.BuildMcpSettingsService(),
TestSupport.BuildMcpClientRegistrationService(),
TestSupport.BuildWebUntisSettingsService(), TestSupport.BuildWebUntisSettingsService(),
TestSupport.BuildAnnualPlanSettingsService(), TestSupport.BuildAnnualPlanSettingsService(),
TestSupport.BuildSyncSettingsService(), TestSupport.BuildSyncAuthService(), TestSupport.BuildEventQueue(), TestSupport.BuildSyncSettingsService(), TestSupport.BuildSyncAuthService(), TestSupport.BuildEventQueue(),
@@ -400,7 +406,9 @@ public sealed class SettingsViewModelTests
new LiteDbContext(new MemoryStream()), new PrivacySettingsService(tempPath), new LiteDbContext(new MemoryStream()), new PrivacySettingsService(tempPath),
new FakeDocumentation(), new FakeStudents([]), new FakeShorthandCodes([]), new FakeDocumentation(), new FakeStudents([]), new FakeShorthandCodes([]),
new FakeSchoolHolidays(), new SchoolCalendarSettingsService(tempPath), periodSchedule, new FakeSchoolHolidays(), new SchoolCalendarSettingsService(tempPath), periodSchedule,
new FakeSupervisionDuties(), new TemplateStore(tempPath), TestSupport.BuildAiSettingsService(), TestSupport.BuildAiPlanningService(), new FakeSupervisionDuties(), new TemplateStore(tempPath), new WorksheetTemplateStore(new TemplateStore(tempPath, subfolder: "worksheet-template-packages")), TestSupport.BuildAiSettingsService(), TestSupport.BuildAiPlanningService(),
TestSupport.BuildMcpSettingsService(),
TestSupport.BuildMcpClientRegistrationService(),
TestSupport.BuildWebUntisSettingsService(), TestSupport.BuildWebUntisSettingsService(),
TestSupport.BuildAnnualPlanSettingsService(), TestSupport.BuildAnnualPlanSettingsService(),
TestSupport.BuildSyncSettingsService(), TestSupport.BuildSyncAuthService(), TestSupport.BuildEventQueue(), TestSupport.BuildSyncSettingsService(), TestSupport.BuildSyncAuthService(), TestSupport.BuildEventQueue(),
@@ -0,0 +1,49 @@
using LehrerApp.Core.Models;
using LehrerApp.Desktop.ViewModels.Students;
using Xunit;
namespace LehrerApp.Desktop.Tests;
public sealed class StudentPickerDialogViewModelTests
{
private static (StudentPickerDialogViewModel Vm, Student Anna, Student Ben) Build()
{
var anna = new Student { FirstName = "Anna", LastName = "Adler" };
var ben = new Student { FirstName = "Ben", LastName = "Bauer" };
var vm = new StudentPickerDialogViewModel(new FakeStudents([anna, ben]));
return (vm, anna, ben);
}
[Fact]
public void SearchText_FiltertNachTeilstring()
{
var (vm, anna, _) = Build();
vm.SearchText = "ann";
Assert.Single(vm.Students);
Assert.Equal(anna.FullName, vm.Students[0].FullName);
}
[Fact]
public void Select_OhneAuswahl_SetztValidationMessage()
{
var (vm, _, _) = Build();
vm.SelectCommand.Execute(null);
Assert.Null(vm.Result);
Assert.NotEqual("", vm.ValidationMessage);
}
[Fact]
public void Select_MitAuswahl_LiefertVollenSchueler()
{
var (vm, anna, _) = Build();
vm.SelectedStudent = vm.Students.Single(s => s.Id == anna.Id);
vm.SelectCommand.Execute(null);
Assert.Equal(anna.Id, vm.Result?.Id);
}
}
@@ -0,0 +1,183 @@
using LehrerApp.Core.Models;
using LehrerApp.Desktop.ViewModels.Groups;
using Xunit;
namespace LehrerApp.Desktop.Tests;
public sealed class TeachingTimelineViewModelTests
{
private readonly DateTime _start = new(2026, 9, 14, 8, 0, 0, DateTimeKind.Utc);
private DateTime _now;
private readonly FakeLessons _lessons = new();
private (Lesson Lesson, TeachingTimelineViewModel Vm) Build(bool scheduled = true)
{
_now = _start.AddMinutes(5);
var local = _start.ToLocalTime();
var lesson = new Lesson
{
Date = DateOnly.FromDateTime(local),
StartTime = scheduled ? TimeOnly.FromDateTime(local) : null,
Phases = [new() { Name = "Einstieg", DurationMinutes = 10 },
new() { Name = "Arbeit", DurationMinutes = 10 },
new() { Name = "Sicherung", DurationMinutes = 10 }]
};
_lessons.Add(lesson);
return (lesson, new TeachingTimelineViewModel(lesson, _lessons, utcNow: () => _now));
}
[Fact] public void Clock_HighlightsExactlyOnePhaseAtBoundary()
{
var (_, vm) = Build();
Assert.True(vm.Phases[0].IsActive);
Assert.Equal(50, vm.Phases[0].Progress);
_now = _start.AddMinutes(10);
vm.Refresh();
Assert.True(vm.Phases[0].IsCompleted);
Assert.True(vm.Phases[1].IsActive);
Assert.Single(vm.Phases, p => p.IsActive);
}
[Fact] public void NoStartTime_RequiresExplicitStart()
{
var (_, vm) = Build(scheduled: false);
Assert.True(vm.NeedsStart);
Assert.DoesNotContain(vm.Phases, p => p.IsActive);
vm.StartNowCommand.Execute(null);
Assert.False(vm.NeedsStart);
Assert.True(vm.Phases[0].IsActive);
Assert.Equal(_now, vm.Phases[0].StartUtc);
}
[Fact] public void Extend_ShiftsLaterPhasesAndPreservesOriginalPlan()
{
var (lesson, vm) = Build();
vm.Phases[0].ExtendTenCommand.Execute(null);
Assert.Equal(_start.AddMinutes(20), vm.Phases[1].StartUtc);
Assert.True(vm.Phases[2].IsOverflow);
Assert.Equal(10, lesson.Phases[0].DurationMinutes);
Assert.NotNull(_lessons.GetById(lesson.Id)!.TeachingTimeline);
}
[Fact] public void FinishEarly_StartsNextPhaseNow()
{
var (_, vm) = Build();
vm.Phases[0].FinishCommand.Execute(null);
Assert.True(vm.Phases[0].IsCompleted);
Assert.True(vm.Phases[1].IsActive);
Assert.Equal(_now, vm.Phases[1].StartUtc);
}
[Fact] public void Hold_SurvivesReopenAndContinuesOnlyOnNext()
{
var (lesson, vm) = Build();
vm.Phases[0].HoldCommand.Execute(null);
_now = _start.AddMinutes(65);
var reopened = new TeachingTimelineViewModel(lesson, _lessons, utcNow: () => _now);
Assert.True(reopened.Phases[0].IsHeld);
Assert.True(reopened.Phases[0].IsActive);
Assert.Single(reopened.Phases, p => p.IsActive);
reopened.Phases[0].FinishCommand.Execute(null);
Assert.True(reopened.Phases[1].IsActive);
Assert.False(reopened.Phases[0].IsHeld);
Assert.Equal(_now, reopened.Phases[1].StartUtc);
}
[Fact] public void BringForward_KeepsSkippedPendingPhasesBelowChosenPhase()
{
var (_, vm) = Build();
var chosen = vm.Phases[2];
chosen.BringForwardCommand.Execute(null);
Assert.Equal(new[] { "Einstieg", "Sicherung", "Arbeit" }, vm.Phases.Select(p => p.Source.Name));
Assert.True(chosen.IsActive);
Assert.Equal(_now, chosen.StartUtc);
Assert.False(vm.Phases[2].IsCompleted);
}
[Fact] public void Overflow_RemainsPendingTheNextDayAndCanBeStartedNow()
{
var (lesson, vm) = Build();
vm.Phases[0].ExtendTenCommand.Execute(null);
_now = _start.AddDays(1);
var reopened = new TeachingTimelineViewModel(lesson, _lessons, utcNow: () => _now);
var remainder = reopened.Phases[2];
Assert.True(remainder.IsOverflow);
Assert.False(remainder.IsCompleted);
Assert.False(remainder.IsActive);
Assert.True(reopened.HasRemainder);
remainder.BringForwardCommand.Execute(null);
Assert.True(remainder.IsActive);
Assert.Equal(_now, remainder.StartUtc);
}
[Fact] public void Transfer_CopiesIntoSelectedLessonOnlyOnceAndKeepsSource()
{
var (lesson, _) = Build();
var target = new Lesson { GroupId = lesson.GroupId, Date = lesson.Date.AddDays(1), Topic = "Folgestunde" };
_lessons.Add(target);
var vm = new TeachingTimelineViewModel(lesson, _lessons, utcNow: () => _now);
vm.Phases[0].ExtendTenCommand.Execute(null);
vm.TransferRemainderCommand.Execute(null);
vm.TransferRemainderCommand.Execute(null);
var copy = Assert.Single(target.Phases);
Assert.Equal("Sicherung", copy.Name);
Assert.NotEqual(lesson.Phases[2].Id, copy.Id);
Assert.Equal(3, lesson.Phases.Count);
Assert.True(vm.Phases[2].IsTransferred);
}
[Fact] public void PartiallyOverflowingPhase_PreservesOnlyUnfinishedMinutesAfterLessonEnds()
{
var (lesson, vm) = Build();
vm.Phases[0].ExtendFiveCommand.Execute(null);
_now = _start.AddMinutes(28);
vm.Refresh();
Assert.True(vm.Phases[2].IsActive);
_now = _start.AddDays(1);
var reopened = new TeachingTimelineViewModel(lesson, _lessons, utcNow: () => _now);
var remainder = reopened.Phases[2];
Assert.False(remainder.IsCompleted);
Assert.False(remainder.IsActive);
Assert.True(reopened.HasRemainder);
Assert.Equal(5, remainder.RemainingMinutes);
remainder.BringForwardCommand.Execute(null);
Assert.True(remainder.IsActive);
Assert.Equal(_now.AddMinutes(5), remainder.EndUtc);
}
[Fact] public void ReadOnly_DoesNotChangeTimingOrStartClock()
{
var (lesson, _) = Build(scheduled: false);
var vm = new TeachingTimelineViewModel(lesson, _lessons, readOnly: true, utcNow: () => _now);
vm.StartNowCommand.Execute(null);
Assert.Null(lesson.TeachingTimeline);
Assert.True(vm.NeedsStart);
}
[Fact] public void AlternativePhases_AreNotRunAlongsideMainPath()
{
var (lesson, _) = Build();
lesson.Phases.Add(new LessonPhaseStep { AlternativePathId = Guid.NewGuid(), DurationMinutes = 30 });
var vm = new TeachingTimelineViewModel(lesson, _lessons, utcNow: () => _now);
Assert.Equal(3, vm.Phases.Count);
Assert.Equal(_start.AddMinutes(30), vm.Phases[^1].EndUtc);
}
[Fact] public void HeldTiming_RoundTripsThroughLiteDbWithoutTimezoneShift()
{
using var stream = new MemoryStream();
using var db = new LehrerApp.Data.LiteDbContext(stream);
var repository = new LehrerApp.Data.Repositories.LessonRepository(db);
var (lesson, _) = Build();
repository.Save(lesson);
var vm = new TeachingTimelineViewModel(lesson, repository, utcNow: () => _now);
vm.Phases[0].HoldCommand.Execute(null);
_now = _start.AddMinutes(40);
var loaded = repository.GetById(lesson.Id)!;
var reopened = new TeachingTimelineViewModel(loaded, repository, utcNow: () => _now);
Assert.True(reopened.Phases[0].IsActive);
Assert.True(reopened.Phases[0].IsHeld);
Assert.Equal(_start, reopened.Phases[0].StartUtc);
Assert.Equal(_start.AddMinutes(45), reopened.Phases[0].EndUtc);
}
}
@@ -0,0 +1,72 @@
using LehrerApp.Core.Models;
using LehrerApp.Desktop.Services;
using LehrerApp.Desktop.Services.Mcp.Tools;
using Xunit;
namespace LehrerApp.Desktop.Tests;
/// Aus Konsistenz mit den drei bestehenden WebUntis-Abgleichs-ViewModels bewusst ohne Tests für
/// GetUntisAbsenceRows/GetNamedUntisAbsencePattern gelassen (siehe TODO.md) - beide rufen
/// WebUntisIntegrationService.GetLessonAbsencesAsync auf, was einen echten WebUntis-JSON-RPC-
/// Handshake voraussetzt. Getestet werden die Teile, die keinen WebUntis-Zugriff brauchen:
/// GetUntisHubStatus (reine Delegation an UntisHubService) und die Validierungspfade von
/// ApplyUntisAbsenceStatus, die vor jedem WebUntis-/Datenbankzugriff greifen.
public sealed class UntisComparisonToolsTests
{
private static UntisComparisonTools Build(
List<LearningGroup>? groups = null, List<Student>? students = null,
List<ParticipationSession>? sessions = null, FakeMcpConfirmation? confirmation = null) =>
new(new FakeGroups(groups ?? []), new FakeStudents(students ?? []), new FakeSessions(sessions ?? []),
new FakeEntries(), TestSupport.BuildWebUntisIntegrationService(),
TestSupport.BuildUntisHubService(groups ?? []), confirmation ?? new FakeMcpConfirmation());
[Fact]
public void GetUntisHubStatus_DelegiertAnUntisHubServiceUndMapptKindUndDueStateAlsText()
{
var group = new LearningGroup { Name = "9a", WebUntisLessonId = 42 };
var tool = Build([group]);
var rows = tool.GetUntisHubStatus();
Assert.Contains(rows, r => r.Kind == nameof(UntisHubJobKind.FehlzeitenKurz) && r.GroupName == "9a"
&& r.DueState == nameof(UntisHubDueState.Overdue));
Assert.Contains(rows, r => r.Kind == nameof(UntisHubJobKind.OffenePeriods) && r.GroupName == "Offene Stunden");
}
[Fact]
public async Task ApplyUntisAbsenceStatus_UnbekannterStatus_WirdOhneRowIdPruefungAbgelehnt()
{
var tool = Build();
var result = await tool.ApplyUntisAbsenceStatus("irgendeine-id", "Suspendiert");
Assert.False(result.Applied);
Assert.Contains("Status", result.Message);
}
[Fact]
public async Task ApplyUntisAbsenceStatus_NichtSelektierbarerAberGueltigerEnumWert_WirdAbgelehnt()
{
// "Truant"/"Suspended" etc. sind gültige AttendanceStatus-Werte, aber keine, die WebUntis
// hier je melden würde (siehe SelectableStatuses) - müssen trotzdem abgelehnt werden, damit
// ein KI-Client nicht versehentlich einen fachlich unpassenden Status setzen kann.
var tool = Build();
var result = await tool.ApplyUntisAbsenceStatus("irgendeine-id", nameof(AttendanceStatus.Truant));
Assert.False(result.Applied);
}
[Fact]
public async Task ApplyUntisAbsenceStatus_UnbekannteRowId_WirdAbgelehntOhneBestaetigung()
{
var confirmation = new FakeMcpConfirmation();
var tool = Build(confirmation: confirmation);
var result = await tool.ApplyUntisAbsenceStatus("nie-vergeben", nameof(AttendanceStatus.Excused));
Assert.False(result.Applied);
Assert.Contains("row-id", result.Message);
Assert.Equal(0, confirmation.CallCount); // erst gar nicht bis zur Bestätigung vorgedrungen
}
}
@@ -1,4 +1,5 @@
using LehrerApp.Core.Models; using LehrerApp.Core.Models;
using LehrerApp.Core.Services;
using LehrerApp.Desktop.Services; using LehrerApp.Desktop.Services;
using Xunit; using Xunit;
@@ -108,4 +109,34 @@ public sealed class UntisHubServiceTests
Assert.Equal(2, rows.Count(r => r.GroupId != null)); Assert.Equal(2, rows.Count(r => r.GroupId != null));
} }
[Fact]
public void RecordRun_FehlzeitenLang_SchliesstAuchFehlzeitenKurzDerselbenGruppeAb()
{
// Nutzer-Feedback: der Langzeit-Abgleich deckt das kurzfristige Zeitfenster als Teilmenge mit
// ab - ohne diese Kopplung bliebe die kurzfristige Kadenz trotz erledigtem Langzeit-Abgleich
// als fällig stehen.
var group = Group("9a", 42);
var jobStates = new FakeUntisHubJobStates();
var hub = new UntisHubService(new FakeGroups([group]), jobStates, new SchoolYearService());
hub.RecordRun(UntisHubJobKind.FehlzeitenLang, group.Id, "3 Fehlzeiten übernommen");
var kurzState = jobStates.Get(UntisHubJobKind.FehlzeitenKurz, group.Id);
var langState = jobStates.Get(UntisHubJobKind.FehlzeitenLang, group.Id);
Assert.NotNull(kurzState?.LastRunAt);
Assert.Equal(langState!.LastRunAt, kurzState!.LastRunAt);
}
[Fact]
public void RecordRun_FehlzeitenKurz_LaesstFehlzeitenLangUnangetastet()
{
var group = Group("9a", 42);
var jobStates = new FakeUntisHubJobStates();
var hub = new UntisHubService(new FakeGroups([group]), jobStates, new SchoolYearService());
hub.RecordRun(UntisHubJobKind.FehlzeitenKurz, group.Id, "keine Abweichungen");
Assert.Null(jobStates.Get(UntisHubJobKind.FehlzeitenLang, group.Id));
}
} }
@@ -18,4 +18,18 @@ public sealed class UntisNameMatchingTests
[InlineData("Ben Schmidt", "")] [InlineData("Ben Schmidt", "")]
public void NamesMatch_LehntUnterschiedlicheOderFehlendeNamenAb(string? a, string b) => public void NamesMatch_LehntUnterschiedlicheOderFehlendeNamenAb(string? a, string b) =>
Assert.False(UntisNameMatching.NamesMatch(a, b)); Assert.False(UntisNameMatching.NamesMatch(a, b));
// Regression: Student.FullName liefert "Nachname, Vorname" (mit Komma) für Anzeigezwecke.
// Wird dieser String direkt an NamesMatch übergeben, bleibt das Komma am Wort kleben
// ("gerste," != "gerste") und der Abgleich gegen WebUntis-Namen (immer ohne Komma) schlägt
// fehl - genau das ließ den Anwesenheitskalender/die Fehlzeitenliste im Elternbrief leer
// bleiben, obwohl echte Fehlzeiten vorlagen. Aufrufer müssen deshalb "Vorname Nachname" ohne
// Komma bilden (siehe StudentAttendanceCalendarDrawingBuilder.StudentAttendanceCalendarService
// und ClassTeacherOverviewViewModel.cs:1173), statt Student.FullName direkt zu verwenden.
[Fact]
public void NamesMatch_KommaGetrennterAnzeigename_PasstNichtOhneUmformung()
{
Assert.False(UntisNameMatching.NamesMatch("Gerste, Amelia", "Gerste Amelia"));
Assert.True(UntisNameMatching.NamesMatch("Amelia Gerste", "Gerste Amelia"));
}
} }
@@ -0,0 +1,40 @@
using LehrerApp.Templating;
using Xunit;
namespace LehrerApp.Desktop.Tests;
public sealed class _TempFlowCheck2
{
[Fact]
public void MarkerTest()
{
var outDir = @"C:\Users\SHedt\AppData\Local\Temp\claude\d--source-LehrerApp\fb3681df-caaf-4f2d-9996-e343e44f5554\scratchpad";
Directory.CreateDirectory(outDir);
var commands = new List<DrawingCommand>
{
new DrawStringEx(0, 0, 12, 170, "TOP-0", DrawingTextAlignment.AlignLeft, 10, Color: "#000000"),
new DrawStringEx(0, 90, 12, 170, "NEARBOTTOM-90", DrawingTextAlignment.AlignLeft, 10, Color: "#000000"),
new DrawStringEx(0, 105, 12, 170, "AFTERBOUNDARY-105", DrawingTextAlignment.AlignLeft, 10, Color: "#000000"),
new DrawStringEx(0, 190, 12, 170, "BOTTOM-190", DrawingTextAlignment.AlignLeft, 10, Color: "#000000"),
};
var drawing = new DrawingValue(commands, 200);
var manifest = new TemplateManifest
{
Id = "marker", Name = "Marker",
Placeholders = [new("Marker", PlaceholderType.Drawing, true)],
};
var layout = "PAGE 210 297 mm\nFLOWDRAWBOX 20 20 170 100 $Marker\n";
var loadedLayout = new LayoutParser().Parse(layout);
var loaded = new LoadedTemplate(manifest, loadedLayout, new Dictionary<string, byte[]>());
var provider = new FakeProvider(new Dictionary<string, PlaceholderValue> { ["Marker"] = drawing });
var pngs = new QuestTemplateRenderer().RenderPagesToPng(loaded, provider, dpi: 150);
for (var i = 0; i < pngs.Count; i++)
File.WriteAllBytes(Path.Combine(outDir, $"marker2-{i}.png"), pngs[i]);
}
private sealed class FakeProvider(IReadOnlyDictionary<string, PlaceholderValue> values) : ITemplateDataProvider
{ public IReadOnlyDictionary<string, PlaceholderValue> GetValues() => values; }
}
+50 -13
View File
@@ -26,6 +26,8 @@ public class App : Application
public static IServiceProvider Services { get; private set; } = null!; public static IServiceProvider Services { get; private set; } = null!;
private static ServiceProvider? _serviceProvider; private static ServiceProvider? _serviceProvider;
private static bool _exitHandlerAttached; private static bool _exitHandlerAttached;
private static Task _initialPolls = Task.CompletedTask;
private static readonly CancellationTokenSource StartupCancellation = new();
public override void Initialize() => AvaloniaXamlLoader.Load(this); public override void Initialize() => AvaloniaXamlLoader.Load(this);
@@ -65,7 +67,11 @@ public class App : Application
promptVm.OnUnlocked = async password => promptVm.OnUnlocked = async password =>
{ {
AppBootstrapper.DbPassword = password; AppBootstrapper.DbPassword = password;
await StartMainAppAsync(desktop, promptWindow); var unlockedSplash = new SplashWindow();
desktop.MainWindow = unlockedSplash;
unlockedSplash.Show();
promptWindow.Close();
await StartMainAppAsync(desktop, unlockedSplash);
}; };
desktop.MainWindow = promptWindow; desktop.MainWindow = promptWindow;
promptWindow.Show(); promptWindow.Show();
@@ -79,14 +85,21 @@ public class App : Application
private static async Task StartMainAppAsync( private static async Task StartMainAppAsync(
IClassicDesktopStyleApplicationLifetime desktop, Window? windowToClose = null) IClassicDesktopStyleApplicationLifetime desktop, Window? windowToClose = null)
{ {
_serviceProvider = AppBootstrapper.BuildServices(); var splash = windowToClose as SplashWindow;
var timer = System.Diagnostics.Stopwatch.StartNew();
var progress = new Progress<(int Value, string Text)>(step =>
splash?.SetProgress(step.Value, step.Text));
_serviceProvider = await Task.Run(() => AppBootstrapper.BuildServices(
(value, text) => ((IProgress<(int, string)>)progress).Report((value, text))));
Services = _serviceProvider; Services = _serviceProvider;
Services.GetRequiredService<AppLogger>().Info("Anwendung gestartet."); Services.GetRequiredService<AppLogger>().Info("Anwendung gestartet.");
GlobalExceptionHandler.AttachNotifications(Services.GetRequiredService<NotificationService>()); GlobalExceptionHandler.AttachNotifications(Services.GetRequiredService<NotificationService>());
// Papierkorb (14.3): Einträge älter als 30 Tage endgültig entfernen. Beim Start statt per // Papierkorb (14.3): Einträge älter als 30 Tage endgültig entfernen. Beim Start statt per
// Timer - reicht für ein Werkzeug, das ohnehin nur "Fehlklick eben rückgängig machen" sein // Timer - reicht für ein Werkzeug, das ohnehin nur "Fehlklick eben rückgängig machen" sein
// soll, kein dauerhaftes Archiv. // soll, kein dauerhaftes Archiv.
Services.GetRequiredService<ITrashRepository>().PurgeOlderThan(DateTime.UtcNow.AddDays(-30)); splash?.SetProgress(60, "Papierkorb aufräumen …");
await Task.Run(() => Services.GetRequiredService<ITrashRepository>()
.PurgeOlderThan(DateTime.UtcNow.AddDays(-30)));
if (!_exitHandlerAttached) if (!_exitHandlerAttached)
{ {
@@ -94,19 +107,18 @@ public class App : Application
_exitHandlerAttached = true; _exitHandlerAttached = true;
} }
// MCP-Server (lokal, Phase 1): Start ist ohne Wirkung, falls in den Einstellungen nicht
// aktiviert (siehe McpServerHostedService.Start). Kein Live-Reload beim Umschalten des
// Opt-in - ein Neustart der App richtet den Pipe-Listener neu ein.
Services.GetRequiredService<Services.Mcp.McpServerHostedService>().Start();
splash?.SetProgress(75, "Übersicht vorbereiten …");
await Dispatcher.UIThread.InvokeAsync(() => { }, DispatcherPriority.Background);
var mainVm = Services.GetRequiredService<MainWindowViewModel>(); var mainVm = Services.GetRequiredService<MainWindowViewModel>();
WireCallbacks(mainVm); WireCallbacks(mainVm);
// Beide optionalen Erstabgleiche noch unter dem Splashscreen abschließen. Ihre splash?.SetProgress(90, "Hauptfenster öffnen …");
// CPU-/Datenbankarbeit läuft innerhalb der Dienste im Threadpool; dadurch öffnet das await Dispatcher.UIThread.InvokeAsync(() => { }, DispatcherPriority.Background);
// Hauptfenster mit fertigem Datenstand und friert nicht kurz danach ein. Das Auflösen
// aktiviert zugleich die periodischen Timer.
var initialPolls = new List<Task>();
if (Services.GetService<UntisSyncService>() is { } untisSync)
initialPolls.Add(untisSync.PollAsync());
if (Services.GetService<AnnualPlanSyncService>() is { } annualPlanSync)
initialPolls.Add(annualPlanSync.PollAsync());
await Task.WhenAll(initialPolls);
var main = new MainWindow { DataContext = mainVm }; var main = new MainWindow { DataContext = mainVm };
main.EnableWindowSizePersistence(Services.GetRequiredService<WindowSettingsService>()); main.EnableWindowSizePersistence(Services.GetRequiredService<WindowSettingsService>());
@@ -114,7 +126,30 @@ public class App : Application
main.EnableFinalSync(syncEngine); main.EnableFinalSync(syncEngine);
desktop.MainWindow = main; desktop.MainWindow = main;
main.Show(); main.Show();
splash?.SetProgress(100, "Bereit");
windowToClose?.Close(); windowToClose?.Close();
AppBootstrapper.Logger.Info($"Start: Hauptfenster nach {timer.ElapsedMilliseconds} ms geöffnet.");
// Vorhandene lokale Daten sind sofort nutzbar; Netzwerkzugriffe blockieren den Start nicht.
var untis = Services.GetService<UntisSyncService>();
var annualPlan = Services.GetService<AnnualPlanSyncService>();
if (untis is not null)
untis.DataChanged += () => Dispatcher.UIThread.Post(() =>
{
Services.GetRequiredService<TimetableViewModel>().Load();
Services.GetRequiredService<DashboardViewModel>().RefreshCommand.Execute(null);
});
_initialPolls = Task.Run(async () =>
{
try
{
await Task.WhenAll(
untis?.PollAsync(StartupCancellation.Token) ?? Task.CompletedTask,
annualPlan?.PollAsync(StartupCancellation.Token) ?? Task.CompletedTask);
}
catch (OperationCanceledException) when (StartupCancellation.IsCancellationRequested) { }
catch (Exception ex) { AppBootstrapper.Logger.Error("Erstabgleich fehlgeschlagen.", ex); }
});
} }
/// <summary>Wechselt die Darstellung sofort, ohne Neustart (12.4) — Avalonia stylt den /// <summary>Wechselt die Darstellung sofort, ohne Neustart (12.4) — Avalonia stylt den
@@ -135,6 +170,8 @@ public class App : Application
try try
{ {
StartupCancellation.Cancel();
_initialPolls.GetAwaiter().GetResult();
serviceProvider.GetService<LiteDbContext>()?.Checkpoint(); serviceProvider.GetService<LiteDbContext>()?.Checkpoint();
} }
catch (Exception ex) catch (Exception ex)
+26 -1
View File
@@ -4,6 +4,8 @@ using LehrerApp.Core.Services;
using LehrerApp.Data; using LehrerApp.Data;
using LehrerApp.Data.Repositories; using LehrerApp.Data.Repositories;
using LehrerApp.Desktop.Services; using LehrerApp.Desktop.Services;
using LehrerApp.Desktop.Services.Mcp;
using LehrerApp.Desktop.Services.Mcp.Tools;
using LehrerApp.Desktop.ViewModels; using LehrerApp.Desktop.ViewModels;
using LehrerApp.Desktop.ViewModels.ClassTeacher; using LehrerApp.Desktop.ViewModels.ClassTeacher;
using LehrerApp.Desktop.ViewModels.Exams; using LehrerApp.Desktop.ViewModels.Exams;
@@ -99,7 +101,7 @@ public static class AppBootstrapper
Environment.Exit(0); Environment.Exit(0);
} }
public static ServiceProvider BuildServices() public static ServiceProvider BuildServices(Action<int, string>? reportProgress = null)
{ {
var services = new ServiceCollection(); var services = new ServiceCollection();
@@ -121,13 +123,16 @@ public static class AppBootstrapper
services.AddSingleton<ITemplateLoader, TemplateLoader>(); services.AddSingleton<ITemplateLoader, TemplateLoader>();
services.AddSingleton<ITemplateRenderer, QuestTemplateRenderer>(); services.AddSingleton<ITemplateRenderer, QuestTemplateRenderer>();
services.AddSingleton(_ => new TemplateStore(appData)); services.AddSingleton(_ => new TemplateStore(appData));
services.AddSingleton(_ => new WorksheetTemplateStore(new TemplateStore(appData, subfolder: "worksheet-template-packages")));
// ── Datensicherheit (13.3) ─────────────────────────────────────────── // ── Datensicherheit (13.3) ───────────────────────────────────────────
// Backup läuft vor dem Öffnen der Datenbank, rein dateibasiert (unabhängig von // Backup läuft vor dem Öffnen der Datenbank, rein dateibasiert (unabhängig von
// Verschlüsselung) — reine Datei-Kopie, kein offener LiteDB-Handle nötig. // Verschlüsselung) — reine Datei-Kopie, kein offener LiteDB-Handle nötig.
var backupSettings = new BackupSettingsService(appData); var backupSettings = new BackupSettingsService(appData);
var backup = new BackupService(appData) { SecondaryBackupDirectory = backupSettings.LoadSecondaryDirectory() }; var backup = new BackupService(appData) { SecondaryBackupDirectory = backupSettings.LoadSecondaryDirectory() };
reportProgress?.Invoke(10, "Sicherung erstellen …");
var backupPath = backup.CreateBackup(DbPath); var backupPath = backup.CreateBackup(DbPath);
reportProgress?.Invoke(25, "Sicherung prüfen …");
// Best-effort-Prüfung des automatischen Startbackups: nur geloggt, kein Blocker für den // Best-effort-Prüfung des automatischen Startbackups: nur geloggt, kein Blocker für den
// Programmstart — ein beschädigtes Backup soll auffallen, nicht den Nutzer aufhalten. // Programmstart — ein beschädigtes Backup soll auffallen, nicht den Nutzer aufhalten.
if (backupPath is not null && !new DatabaseEncryptionService().CanOpenAndRead(backupPath, DbPassword)) if (backupPath is not null && !new DatabaseEncryptionService().CanOpenAndRead(backupPath, DbPassword))
@@ -213,6 +218,24 @@ public static class AppBootstrapper
}); });
services.AddSingleton<AiPlanningService>(); services.AddSingleton<AiPlanningService>();
// ── MCP-Server (lokal, Phase 1 siehe Planungsdokument, optional per Opt-in) ─────────
services.AddSingleton(_ => new McpSettingsService(appData));
services.AddSingleton<IMcpConfirmationService, AvaloniaMcpConfirmationService>();
services.AddSingleton<StudentTools>();
services.AddSingleton<ExamTools>();
services.AddSingleton<GradeTools>();
services.AddSingleton<ScheduleTools>();
services.AddSingleton<TimeEntryTools>();
services.AddSingleton<LessonPlanTools>();
services.AddSingleton<GroupMembershipTools>();
services.AddSingleton<LetterTemplateTools>();
services.AddSingleton<StudentAttendanceCalendarService>();
services.AddSingleton<CompetencyTools>();
services.AddSingleton<GroupTools>();
services.AddSingleton<UntisComparisonTools>();
services.AddSingleton<McpServerHostedService>();
services.AddSingleton<McpClientRegistrationService>();
// ── WebUntis-iCal-Abgleich (optional nur wenn URL hinterlegt und aktiviert) ───────── // ── WebUntis-iCal-Abgleich (optional nur wenn URL hinterlegt und aktiviert) ─────────
var untisSettings = new WebUntisSettingsService(appData); var untisSettings = new WebUntisSettingsService(appData);
services.AddSingleton(untisSettings); services.AddSingleton(untisSettings);
@@ -343,7 +366,9 @@ public static class AppBootstrapper
services.AddTransient<TrashViewModel>(); services.AddTransient<TrashViewModel>();
services.AddTransient<SettingsViewModel>(); services.AddTransient<SettingsViewModel>();
reportProgress?.Invoke(40, "Datenbank öffnen und aktualisieren …");
var provider = services.BuildServiceProvider(); var provider = services.BuildServiceProvider();
provider.GetRequiredService<LiteDbContext>();
// Sync-agnostischer Hook auf LiteDbContext (siehe LiteDbContext.OnChange) wird erst hier, // Sync-agnostischer Hook auf LiteDbContext (siehe LiteDbContext.OnChange) wird erst hier,
// außerhalb der Repository-Registrierung, mit der tatsächlichen Sync-Logik verbunden. // außerhalb der Repository-Registrierung, mit der tatsächlichen Sync-Logik verbunden.
@@ -21,6 +21,7 @@
<PackageReference Include="CommunityToolkit.Mvvm" /> <PackageReference Include="CommunityToolkit.Mvvm" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" /> <PackageReference Include="Microsoft.Extensions.DependencyInjection" />
<PackageReference Include="QuestPDF" /> <PackageReference Include="QuestPDF" />
<PackageReference Include="ModelContextProtocol.Core" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<AvaloniaResource Include="Assets\**" /> <AvaloniaResource Include="Assets\**" />
+5
View File
@@ -1,4 +1,5 @@
using Avalonia; using Avalonia;
using LehrerApp.Core.Mcp;
using LehrerApp.Desktop.Services; using LehrerApp.Desktop.Services;
namespace LehrerApp.Desktop; namespace LehrerApp.Desktop;
@@ -8,6 +9,10 @@ class Program
[STAThread] [STAThread]
public static void Main(string[] args) public static void Main(string[] args)
{ {
// Muss vor der ersten NamedPipeServerStream-Instanz laufen (siehe
// McpServerHostedService), sonst löst TMPDIR-Auseinanderdriften zwischen diesem Prozess
// und LehrerApp.McpBridge auf macOS/Linux "MCP nicht erreichbar" aus - siehe Doku dort.
McpPipeConstants.EnsureStableUnixSocketDirectory();
var logger = AppBootstrapper.EnsureLogger(); var logger = AppBootstrapper.EnsureLogger();
GlobalExceptionHandler.Install(logger); GlobalExceptionHandler.Install(logger);
@@ -188,6 +188,7 @@ public class AiPlanningService(HttpClient http, ILessonRepository lessons,
StartTime = l.StartTime, StartTime = l.StartTime,
Homework = l.Homework, Homework = l.Homework,
Reflection = l.Reflection, Reflection = l.Reflection,
PlanningIdeas = l.PlanningIdeas,
Phases = ToAiPhases(l.Phases, pathNames), Phases = ToAiPhases(l.Phases, pathNames),
}) })
.ToList(); .ToList();
@@ -270,6 +271,9 @@ public class AiPlanningService(HttpClient http, ILessonRepository lessons,
if (!string.Equals(existing.Reflection, proposed.Reflection, StringComparison.Ordinal)) if (!string.Equals(existing.Reflection, proposed.Reflection, StringComparison.Ordinal))
diffs.Add("Reflexion geändert"); diffs.Add("Reflexion geändert");
if (!string.Equals(existing.PlanningIdeas, proposed.PlanningIdeas, StringComparison.Ordinal))
diffs.Add("Planungsideen geändert");
var pathNames = altPaths.GetAll().ToDictionary(p => p.Id, p => p.Name); var pathNames = altPaths.GetAll().ToDictionary(p => p.Id, p => p.Name);
var existingPhases = ToAiPhases(existing.Phases, pathNames); var existingPhases = ToAiPhases(existing.Phases, pathNames);
if (!PhasesEqual(existingPhases, proposed.Phases)) if (!PhasesEqual(existingPhases, proposed.Phases))
@@ -586,6 +590,7 @@ public class AiPlanningService(HttpClient http, ILessonRepository lessons,
StartTime = ai.StartTime, StartTime = ai.StartTime,
Homework = ai.Homework, Homework = ai.Homework,
Reflection = ai.Reflection, Reflection = ai.Reflection,
PlanningIdeas = ai.PlanningIdeas,
// Status bleibt bei einer Änderung erhalten — sonst würde eine bereits // Status bleibt bei einer Änderung erhalten — sonst würde eine bereits
// durchgeführte Stunde durch eine KI-Anpassung stillschweigend auf "Geplant" // durchgeführte Stunde durch eine KI-Anpassung stillschweigend auf "Geplant"
// zurückgesetzt (die KI kennt/liefert diesen Status gar nicht). // zurückgesetzt (die KI kennt/liefert diesen Status gar nicht).
@@ -597,6 +602,10 @@ public class AiPlanningService(HttpClient http, ILessonRepository lessons,
Activity = p.Activity, Activity = p.Activity,
Material = p.Material, Material = p.Material,
Shorthand = p.Shorthand, Shorthand = p.Shorthand,
// Persistiert den Prompt (4.5.20/4.5.36), damit er nach dem Übernehmen weiterhin
// im Stundeneditor kopierbar bleibt statt nur einmalig im Review-Dialog.
MaterialPrompt = string.IsNullOrWhiteSpace(p.MaterialSuggestion)
? null : BuildMaterialPrompt(unit, ai, p),
AlternativePathId = p.AlternativePathName is { } name && pathIdsByName.TryGetValue(name, out var pathId) AlternativePathId = p.AlternativePathName is { } name && pathIdsByName.TryGetValue(name, out var pathId)
? pathId : null, ? pathId : null,
}).ToList(), }).ToList(),
@@ -605,6 +614,59 @@ public class AiPlanningService(HttpClient http, ILessonRepository lessons,
return result; return result;
} }
/// <summary>
/// Fragt für eine Charge anonymisierter Fehlzeitenzeilen (ai-backend/untis-status.php,
/// Nutzer-Feedback zum Untis-Hub) einen Statusvorschlag ab - eine Anfrage für alle fraglichen
/// Zeilen eines Abgleichslaufs statt einer je Zeile (Kosten/Latenz-Überlegung aus der
/// Nutzerdiskussion). Liefert nur dann Vorschläge zurück, wenn die vom Backend gemeldete
/// Id-Menge exakt der gesendeten entspricht (keine fehlenden, zusätzlichen oder doppelten Ids) -
/// andernfalls eine leere Zuordnung, statt sich auf eine möglicherweise vermischte Reihenfolge
/// zu verlassen. Der Aufrufer behält für jede nicht zurückgelieferte Id den bisherigen
/// regelbasierten Status bei.
/// </summary>
public async Task<IReadOnlyDictionary<string, string>> RequestUntisStatusSuggestionsAsync(
IReadOnlyList<AiUntisStatusRow> rows, string token)
{
if (rows.Count == 0) return new Dictionary<string, string>();
using var req = new HttpRequestMessage(HttpMethod.Post, "untis-status.php")
{
Content = JsonContent.Create(new AiUntisStatusRequest { Rows = rows.ToList() }, options: JsonOptions),
};
req.Headers.Authorization = new("Bearer", token);
HttpResponseMessage resp;
try { resp = await http.SendAsync(req); }
catch (HttpRequestException)
{
throw new AiBackendException("Der KI-Dienst ist nicht erreichbar. Bitte Internetverbindung prüfen.");
}
if (resp.StatusCode == HttpStatusCode.Unauthorized)
throw new AiBackendException("Anmeldung abgelaufen. Bitte in den Einstellungen erneut anmelden.");
if (resp.StatusCode == (HttpStatusCode)402)
throw new AiBackendException("Nicht genügend KI-Guthaben. Bitte Guthaben aufladen.");
if (!resp.IsSuccessStatusCode)
throw await BuildRequestFailedExceptionAsync(resp);
AiUntisStatusResponse? result;
try { result = await resp.Content.ReadFromJsonAsync<AiUntisStatusResponse>(JsonOptions); }
catch (Exception ex) when (ex is not AiBackendException)
{
throw new AiBackendException("Die Antwort der KI konnte nicht verarbeitet werden. Bitte erneut versuchen.");
}
if (result is null)
throw new AiBackendException("Die Antwort der KI konnte nicht verarbeitet werden.");
var sentIds = rows.Select(r => r.Id).ToHashSet();
var receivedIds = result.Suggestions.Select(s => s.Id).ToList();
if (receivedIds.Count != sentIds.Count || receivedIds.Distinct().Count() != receivedIds.Count
|| !sentIds.SetEquals(receivedIds))
return new Dictionary<string, string>();
return result.Suggestions.ToDictionary(s => s.Id, s => s.Status);
}
private class LoginResult { public string Token { get; set; } = ""; } private class LoginResult { public string Token { get; set; } = ""; }
private class BalanceResult { public decimal BalanceUsd { get; set; } } private class BalanceResult { public decimal BalanceUsd { get; set; } }
private class BackendErrorResult private class BackendErrorResult
@@ -36,7 +36,7 @@ public sealed class AnnualPlanSyncService : IDisposable
_timer = new Timer(async _ => await PollAsync(), null, PollInterval, PollInterval); _timer = new Timer(async _ => await PollAsync(), null, PollInterval, PollInterval);
} }
public async Task PollAsync() public async Task PollAsync(CancellationToken cancellationToken = default)
{ {
if (!await _gate.WaitAsync(0).ConfigureAwait(false)) return; if (!await _gate.WaitAsync(0).ConfigureAwait(false)) return;
try try
@@ -47,7 +47,7 @@ public sealed class AnnualPlanSyncService : IDisposable
string icsText; string icsText;
try try
{ {
icsText = await _http.GetStringAsync(url).ConfigureAwait(false); icsText = await _http.GetStringAsync(url, cancellationToken).ConfigureAwait(false);
} }
catch (Exception ex) catch (Exception ex)
{ {
@@ -0,0 +1,48 @@
using Avalonia.Controls;
using LehrerApp.Core.Interfaces;
using LehrerApp.Core.Models;
using LehrerApp.Desktop.ViewModels.Students;
using LehrerApp.Desktop.Views.Students;
using LehrerApp.Templating;
using Microsoft.Extensions.DependencyInjection;
namespace LehrerApp.Desktop.Services;
/// <summary>Öffnet den bestehenden Elternbrief-Dialog für einen Schüler, egal ob der Aufruf aus
/// der Schülerdetailansicht (Student schon im DataContext) oder aus dem Formulare-Menü (Student
/// erst per Picker gewählt) kommt.</summary>
public static class LetterDialogs
{
public static async Task ShowCreateLetterDialogAsync(Window owner, Student student)
{
var vm = new CreateLetterDialogViewModel(student,
App.Services.GetRequiredService<TemplateStore>(),
App.Services.GetRequiredService<ITemplateRenderer>(),
App.Services.GetRequiredService<IGroupMembershipRepository>(),
App.Services.GetRequiredService<IGroupRepository>(),
options => App.Services.GetRequiredService<StudentAttendanceCalendarService>().Build(student, options),
options => App.Services.GetRequiredService<StudentAttendanceCalendarService>()
.BuildAbsenceDayList(student, options),
RefreshAttendanceDataAsync);
var dialog = new CreateLetterDialog { DataContext = vm };
var path = await dialog.ShowDialog<string?>(owner);
if (!string.IsNullOrEmpty(path) && File.Exists(path))
System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo(path) { UseShellExecute = true });
}
/// <summary>Holt genau den im Anwesenheitskalender-Dialog gewählten Zeitraum gezielt per
/// WebUntis nach (ausgelöst durch den expliziten "Konfigurieren…"-Klick, kein Hintergrundabruf
/// beim bloßen Öffnen des Briefdialogs). Damit sieht <see cref="StudentAttendanceCalendarService"/>
/// anschließend frische Daten im lokalen Cache, statt stillschweigend "keine Fehltage" zu
/// melden, nur weil die Klassenlehrer-Übersicht für diesen Zeitraum noch nie geöffnet wurde.</summary>
private static async Task RefreshAttendanceDataAsync(AttendanceCalendarOptions options, CancellationToken token)
{
var className = App.Services.GetRequiredService<WebUntisSettingsService>().HomeroomClassName;
if (string.IsNullOrWhiteSpace(className)) return;
var cache = App.Services.GetRequiredService<UntisReportCacheService>();
var start = options.NormalizedStartMonth;
var end = start.AddMonths(options.NormalizedMonthCount).AddDays(-1);
await cache.GetAbsencesAsync(className, start, end, token: token);
await cache.GetClassRegisterEventsAsync(className, start, end, token: token);
}
}
@@ -0,0 +1,53 @@
using LehrerApp.Core.Models;
using LehrerApp.Templating;
namespace LehrerApp.Desktop.Services;
/// <summary>
/// Baut die Standard-Platzhalterwerte für Elternbrief-Vorlagen (Datum, Anrede, Kontaktadresse,
/// Schülerdaten, Brieftext, ...). Ursprünglich Teil von
/// <see cref="ViewModels.Students.CreateLetterDialogViewModel"/>, hierher extrahiert, damit
/// <c>LetterTemplateTools</c> (MCP, siehe Planungsdokument) dieselbe Logik nutzt statt sie zu
/// duplizieren — beide Aufrufer müssen exakt dieselben Platzhalternamen befüllen, sonst driften
/// Dialog-generierte und KI-generierte Briefe unbemerkt auseinander.
/// </summary>
public static class LetterPlaceholderBuilder
{
public static Dictionary<string, PlaceholderValue> BuildStandardValues(
Student student, Contact? contact, LearningGroup? group, DateOnly date,
string letterText, string teacherName, DrawingValue? attendanceCalendar = null,
DrawingValue? absenceDays = null)
{
var address = FormatAddress(contact);
return new Dictionary<string, PlaceholderValue>(StringComparer.Ordinal)
{
["Datum"] = new DateValue(date), ["CurrentDate"] = new DateValue(date),
["Empfaenger"] = new TextValue(contact?.Name ?? ""), ["Anrede"] = new TextValue(contact?.LetterSalutation ?? ""),
["Brieftext"] = new MultilineValue(letterText), ["LehrerName"] = new TextValue(teacherName),
["Student.FirstName"] = new TextValue(student.FirstName), ["Student.LastName"] = new TextValue(student.LastName),
["Student.Name"] = new TextValue(student.FullName),
["Contact.Name"] = new TextValue(contact?.Name ?? ""), ["Contact.Address"] = new MultilineValue(address),
["Contact.Street"] = new TextValue(contact?.Street ?? ""), ["Contact.PostalCode"] = new TextValue(contact?.PostalCode ?? ""),
["Contact.City"] = new TextValue(contact?.City ?? ""), ["Letter.Salutation"] = new TextValue(contact?.LetterSalutation ?? ""),
["Group.Name"] = new TextValue(group?.Name ?? ""), ["SchoolYear"] = new TextValue(group?.SchoolYear ?? ""),
[StudentAttendanceCalendarDrawingBuilder.PlaceholderName] = attendanceCalendar ?? new DrawingValue([], 0),
[StudentAbsenceDayListDrawingBuilder.PlaceholderName] = absenceDays ?? new DrawingValue([], 0),
};
}
/// <summary>Mehrzeilige Anschrift (Name/Straße/PLZ Ort) für Adressvorschau im Dialog und
/// den <c>Contact.Address</c>-Platzhalter - eine Formatierung für beide Verwendungen.</summary>
public static string FormatAddress(Contact? contact)
{
var cityLine = string.Join(" ", new[] { contact?.PostalCode, contact?.City }.Where(x => !string.IsNullOrWhiteSpace(x)));
return string.Join(Environment.NewLine, new[] { contact?.Name, contact?.Street, cityLine }.Where(x => !string.IsNullOrWhiteSpace(x)));
}
public static bool IsEmpty(PlaceholderValue value) => value switch
{
TextValue x => string.IsNullOrWhiteSpace(x.Value),
MultilineValue x => string.IsNullOrWhiteSpace(x.Value),
DrawingValue x => x.ContentHeight <= 0 || x.Commands.Count == 0,
_ => false,
};
}
@@ -0,0 +1,98 @@
using System.Runtime.CompilerServices;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Controls.ApplicationLifetimes;
using Avalonia.Threading;
using LehrerApp.Core.Services;
using LehrerApp.Desktop.Views.Mcp;
namespace LehrerApp.Desktop.Services.Mcp;
/// <summary>
/// Produktive <see cref="IMcpConfirmationService"/>-Implementierung: zeigt <see cref="McpConfirmDialog"/>
/// über dem Hauptfenster an. Der aufrufende Tool-Handler läuft auf einem Hintergrund-Thread
/// (MCP-Pipe-Session in <see cref="McpServerHostedService"/>), deshalb Marshalling über
/// <see cref="Dispatcher.UIThread"/>.
///
/// Nutzer-Feedback: der Dialog fiel zu wenig auf, wenn LehrerApp im Hintergrund lief (naheliegend,
/// da der Anstoß von einem KI-Client in einem anderen Fenster kommt) — deshalb wird das Hauptfenster
/// vor dem Anzeigen aus einer möglichen Minimierung geholt und aktiviert, und der Dialog selbst
/// läuft `Topmost`.
///
/// Nutzer-Feedback (Nachtrag): bei vielen gleichartigen Vorschlägen in Folge (z.B. 17x
/// "add_lesson_phase" für eine neu generierte Unterrichtseinheit) einzeln nachfragen zu müssen, ist
/// unzumutbar. Der Dialog bietet deshalb zwei Sitzungsfreigaben an ("diese Aktion" / "alle
/// Aktionen"), die als reines In-Memory-Bookkeeping auf dieser Singleton-Instanz leben — sie gelten
/// bis zum Beenden der App (neuer Prozess = neue Instanz = wieder alles ungetraut) und werden nie
/// persistiert. <paramref name="operationKey"/> ist der Name der aufrufenden Tool-Methode (siehe
/// <see cref="IMcpConfirmationService.ConfirmAsync"/>), nicht der MCP-Wire-Name.
///
/// Ohne Reaktion des Nutzers würde die Pipe-Session (und damit der wartende KI-Client) unbegrenzt
/// hängen bleiben — nach <see cref="Timeout"/> wird der Dialog automatisch geschlossen und die
/// Änderung als abgelehnt gewertet.
/// </summary>
public sealed class AvaloniaMcpConfirmationService(AppLogger logger) : IMcpConfirmationService
{
private static readonly TimeSpan Timeout = TimeSpan.FromMinutes(2);
private readonly object _trustLock = new();
private readonly HashSet<string> _trustedOperations = [];
private bool _trustAll;
public async Task<bool> ConfirmAsync(string title, string message, CancellationToken ct,
[CallerMemberName] string operationKey = "", bool allowSessionTrust = true)
{
if (allowSessionTrust)
{
bool alreadyTrusted;
lock (_trustLock) alreadyTrusted = _trustAll || _trustedOperations.Contains(operationKey);
if (alreadyTrusted)
{
logger.Info($"MCP: „{title}“ automatisch bestätigt (Sitzungsfreigabe für " +
$"{(_trustAll ? "alle Aktionen" : operationKey)}).");
return true;
}
}
if (Application.Current?.ApplicationLifetime is not IClassicDesktopStyleApplicationLifetime { MainWindow: { } owner })
return false;
// Die gesamte Warte-/Timeout-Logik läuft als ein Stück innerhalb des UI-Thread-Callbacks:
// Avalonias Dispatcher-Synchronisationskontext sorgt dafür, dass die Fortsetzung nach
// "await Task.WhenAny(...)" wieder auf dem UI-Thread läuft, sodass dialog.Close() dort
// sicher aufgerufen werden kann.
var result = await Dispatcher.UIThread.InvokeAsync(async () =>
{
if (owner.WindowState == WindowState.Minimized) owner.WindowState = WindowState.Normal;
owner.Activate();
var dialog = new McpConfirmDialog
{
DataContext = new McpConfirmDialogInfo
{
Title = title, Message = message, ConfirmText = "Übernehmen",
AllowSessionTrust = allowSessionTrust,
},
};
var dialogTask = dialog.ShowDialog<McpConfirmDialogResult>(owner);
var timeoutTask = Task.Delay(Timeout, ct);
var completed = await Task.WhenAny(dialogTask, timeoutTask);
if (completed != dialogTask)
{
dialog.Close(McpConfirmDialogResult.Rejected);
return McpConfirmDialogResult.Rejected;
}
return await dialogTask;
});
if (result.Approved && allowSessionTrust)
{
lock (_trustLock)
{
if (result.TrustAll) _trustAll = true;
else if (result.TrustOperation) _trustedOperations.Add(operationKey);
}
}
return result.Approved;
}
}
@@ -0,0 +1,30 @@
using System.Runtime.CompilerServices;
namespace LehrerApp.Desktop.Services.Mcp;
/// <summary>
/// Zeigt dem Nutzer eine über MCP vorgeschlagene Änderung an, bevor sie tatsächlich geschrieben
/// wird — Sicherheitsmodell aus dem Planungsdokument: "Tool-Aufruf erzeugt einen Vorschlag/Diff,
/// der im Avalonia-Client als Bestätigungsdialog angezeigt wird, [...] kein 'silent write' durch
/// das Modell." Als Interface gehalten, damit Write-Tool-Tests ohne echtes UI laufen können (siehe
/// <see cref="AvaloniaMcpConfirmationService"/> für die produktive Implementierung).
/// </summary>
public interface IMcpConfirmationService
{
/// <param name="operationKey">Identifiziert die Art der Operation für eine mögliche
/// Sitzungsfreigabe ("diese Aktion für den Rest der Sitzung nicht mehr nachfragen") — bewusst
/// per <see cref="CallerMemberNameAttribute"/> automatisch befüllt (der Name der aufrufenden
/// Tool-Methode, z.B. "AddLessonPhase"), damit kein Aufrufer diesen Parameter selbst pflegen
/// muss. Nicht Teil des MCP-Wire-Protokolls, rein internes Bestätigungs-Bookkeeping.</param>
/// <param name="allowSessionTrust">False für die eine bewusste Ausnahme, bei der eine
/// Sitzungsfreigabe nicht angeboten werden soll (Nutzer-Entscheidung zu
/// <c>get_named_untis_absence_pattern</c>: eine namentliche Fehlzeitenauskunft muss JEDES MAL
/// einzeln bestätigt werden, nie pauschal für die restliche Sitzung) — weder die vorherige
/// Prüfung auf eine bereits bestehende Freigabe noch das Setzen einer neuen finden dann statt,
/// unabhängig davon, ob zuvor schon "alle Aktionen" freigegeben wurde.</param>
/// <returns>true, wenn der Nutzer bestätigt hat (direkt oder über eine bereits erteilte
/// Sitzungsfreigabe); false bei Ablehnung, Timeout oder falls kein Hauptfenster verfügbar ist
/// (z.B. während des DB-Passwort-Prompts beim Start).</returns>
Task<bool> ConfirmAsync(string title, string message, CancellationToken ct,
[CallerMemberName] string operationKey = "", bool allowSessionTrust = true);
}
@@ -0,0 +1,157 @@
using System.Text.Json;
using System.Text.Json.Nodes;
namespace LehrerApp.Desktop.Services.Mcp;
public record McpRegistrationResult(bool Success, string Message);
/// <summary>
/// Trägt LehrerApp.McpBridge in die MCP-Server-Konfiguration von Claude Desktop ein (siehe
/// Planungsdokument, "Registrierungs-Workflow"). Bewusst nur für Claude Desktop: das ist der einzige
/// KI-Client, den die Spec konkret benennt und der eine dokumentierte, stabile Config-Datei-Konvention
/// hat (<c>mcpServers</c>-Objekt in <c>claude_desktop_config.json</c>) — andere lokale Clients
/// (Ollama, LM Studio, ...) haben keine einheitliche Konvention, die sich hier ohne Rätselraten
/// unterstützen ließe.
///
/// Läuft nur auf explizite Nutzeraktion (Button in den Einstellungen), nie automatisch beim
/// App-Start — das Schreiben in die Konfigurationsdatei eines fremden Programms ist ein sichtbarer
/// externer Seiteneffekt und gehört nicht in den normalen Startpfad.
///
/// Setzt eine gepackte Installation voraus (Bridge liegt neben der Hauptapp-Executable, siehe
/// build-macos-app.sh) — bei einem lokalen "dotnet build/run" liegt die Bridge in ihrem eigenen
/// separaten bin-Ordner, <see cref="ResolveBridgePath"/> findet sie dann nicht.
/// </summary>
public class McpClientRegistrationService
{
private const string ServerName = "lehrerapp";
private static readonly JsonSerializerOptions WriteOptions = new() { WriteIndented = true };
private readonly string? _bridgePath;
public McpClientRegistrationService() : this(ResolveConfigPath(), ResolveBridgePath()) { }
/// <summary>Test-Seam: erlaubt, Konfigurations- und Bridge-Pfad ohne echte
/// Sonderordner/Installation vorzugeben (siehe <see cref="ResolveConfigPath"/>/
/// <see cref="ResolveBridgePath"/> für das produktive Verhalten).</summary>
public McpClientRegistrationService(string? configPath, string? bridgePath)
{
ConfigPath = configPath;
_bridgePath = bridgePath;
}
public string? ConfigPath { get; }
/// <summary>Ob überhaupt ein Claude-Desktop-Konfigurationsordner existiert — ein Hinweis, ob die
/// Anwendung installiert ist, unabhängig davon, ob LehrerApp dort schon eingetragen ist.</summary>
public bool IsClaudeDesktopDetected => ConfigPath is not null && File.Exists(ConfigPath);
public bool IsRegistered() =>
TryLoadRoot(out var root, out _) && root["mcpServers"]?[ServerName] is not null;
public McpRegistrationResult Register()
{
if (ConfigPath is null)
return new(false, "Claude Desktop wird auf diesem Betriebssystem nicht unterstützt.");
var bridgePath = _bridgePath;
if (bridgePath is null || !File.Exists(bridgePath))
return new(false,
$"Bridge-Programm nicht gefunden ({bridgePath ?? "unbekannter Pfad"}). " +
"Diese Funktion setzt eine gepackte Installation voraus, nicht einen lokalen Entwicklungs-Build.");
if (!TryLoadRoot(out var root, out var error))
return new(false, error!);
if (root["mcpServers"] is not JsonObject servers)
{
servers = new JsonObject();
root["mcpServers"] = servers;
}
servers[ServerName] = new JsonObject
{
["command"] = bridgePath,
["args"] = new JsonArray(),
};
var detectedBefore = IsClaudeDesktopDetected;
var result = WriteRoot(root, "In Claude Desktop eingetragen. Claude Desktop neu starten, damit die Änderung wirkt.");
if (result.Success && !detectedBefore)
return result with
{
Message = result.Message +
" Hinweis: Es wurde keine bestehende Claude-Desktop-Installation erkannt — die Konfiguration " +
"greift erst, sobald Claude Desktop installiert ist.",
};
return result;
}
public McpRegistrationResult Unregister()
{
if (ConfigPath is null || !File.Exists(ConfigPath))
return new(true, "Nichts einzutragen gefunden.");
if (!TryLoadRoot(out var root, out var error))
return new(false, error!);
if (root["mcpServers"] is not JsonObject servers || !servers.Remove(ServerName))
return new(true, "War nicht eingetragen.");
return WriteRoot(root, "Eintrag entfernt.");
}
private bool TryLoadRoot(out JsonObject root, out string? error)
{
error = null;
if (ConfigPath is null || !File.Exists(ConfigPath)) { root = new JsonObject(); return true; }
try
{
root = JsonNode.Parse(File.ReadAllText(ConfigPath)) as JsonObject ?? new JsonObject();
return true;
}
catch (JsonException ex)
{
// Nicht raten und überschreiben - eine kaputte fremde Konfigurationsdatei bleibt unangetastet,
// der Nutzer bekommt stattdessen eine klare Fehlermeldung mit Pfad.
root = new JsonObject();
error = $"Bestehende Claude-Desktop-Konfiguration ist kein gültiges JSON ({ex.Message}). " +
$"Bitte manuell prüfen: {ConfigPath}";
return false;
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
{
// Z.B. gesperrt, weil Claude Desktop selbst gerade schreibt - ebenfalls nicht überschreiben.
root = new JsonObject();
error = $"Bestehende Claude-Desktop-Konfiguration konnte nicht gelesen werden: {ex.Message}";
return false;
}
}
private McpRegistrationResult WriteRoot(JsonObject root, string successMessage)
{
try
{
Directory.CreateDirectory(Path.GetDirectoryName(ConfigPath!)!);
File.WriteAllText(ConfigPath!, root.ToJsonString(WriteOptions));
return new(true, successMessage);
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
{
return new(false, $"Konfiguration konnte nicht geschrieben werden: {ex.Message}");
}
}
private static string? ResolveBridgePath()
{
var bridgeFile = OperatingSystem.IsWindows() ? "LehrerApp.McpBridge.exe" : "LehrerApp.McpBridge";
return Path.Combine(AppContext.BaseDirectory, bridgeFile);
}
private static string? ResolveConfigPath()
{
if (OperatingSystem.IsWindows())
return Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
"Claude", "claude_desktop_config.json");
if (OperatingSystem.IsMacOS())
return Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
"Library", "Application Support", "Claude", "claude_desktop_config.json");
return null; // Linux: kein offizieller Claude-Desktop-Client bekannt.
}
}
@@ -0,0 +1,254 @@
using System.IO.Pipes;
using LehrerApp.Core.Mcp;
using LehrerApp.Core.Services;
using LehrerApp.Desktop.Services.Mcp.Tools;
using ModelContextProtocol.Protocol;
using ModelContextProtocol.Server;
namespace LehrerApp.Desktop.Services.Mcp;
/// <summary>
/// In-Process-MCP-Server (siehe Planungsdokument und TODO.md 4.5.25ff.). Lauscht auf der Named Pipe
/// <see cref="McpPipeConstants.PipeName"/> und bedient jede eingehende Verbindung (eine je
/// LehrerApp.McpBridge-Instanz) als eigene MCP-Session über <see cref="StreamServerTransport"/> —
/// ein <see cref="NamedPipeServerStream"/> ist ein normaler <see cref="Stream"/> und kann direkt
/// als Ein-/Ausgabe der Session übergeben werden, ohne eigenes JSON-RPC-Parsing.
///
/// Nur aktiv, wenn <see cref="McpSettingsService.Enabled"/> — sonst tut <see cref="Start"/> nichts.
/// Repositories sind im DI-Container Singletons (siehe AppBootstrapper), deshalb reicht es, die
/// Tool-Instanzen und die daraus gebaute <see cref="McpServerOptions"/> einmalig zu bauen und für
/// alle Sessions zu teilen.
/// </summary>
public sealed class McpServerHostedService : IAsyncDisposable
{
private readonly McpSettingsService _settings;
private readonly AppLogger _logger;
private readonly McpServerOptions _serverOptions;
private CancellationTokenSource? _cts;
private Task? _acceptLoop;
public McpServerHostedService(
McpSettingsService settings, AppLogger logger,
StudentTools studentTools, ExamTools examTools, GradeTools gradeTools,
ScheduleTools scheduleTools, TimeEntryTools timeEntryTools, LessonPlanTools lessonPlanTools,
GroupMembershipTools groupMembershipTools, LetterTemplateTools letterTemplateTools,
CompetencyTools competencyTools, UntisComparisonTools untisComparisonTools, GroupTools groupTools)
{
_settings = settings;
_logger = logger;
_serverOptions = BuildServerOptions(
studentTools, examTools, gradeTools, scheduleTools, timeEntryTools, lessonPlanTools,
groupMembershipTools, letterTemplateTools, competencyTools, untisComparisonTools, groupTools);
}
/// <summary>Setzt die Pipe-Server-Accept-Loop auf, falls aktiviert. Ohne Wirkung, falls
/// bereits gestartet oder in den Einstellungen deaktiviert (dann bleibt keine Pipe offen).</summary>
public void Start()
{
if (!_settings.Enabled || _cts is not null) return;
_cts = new CancellationTokenSource();
_acceptLoop = Task.Run(() => AcceptLoopAsync(_cts.Token));
_logger.Info("MCP-Server gestartet, lauscht auf Pipe '" + McpPipeConstants.PipeName + "'.");
}
private async Task AcceptLoopAsync(CancellationToken ct)
{
while (!ct.IsCancellationRequested)
{
var pipe = new NamedPipeServerStream(
McpPipeConstants.PipeName, PipeDirection.InOut,
NamedPipeServerStream.MaxAllowedServerInstances,
PipeTransmissionMode.Byte, PipeOptions.Asynchronous);
try
{
await pipe.WaitForConnectionAsync(ct);
}
catch (OperationCanceledException)
{
await pipe.DisposeAsync();
break;
}
catch (Exception ex)
{
_logger.Error("MCP: Fehler beim Warten auf eine Bridge-Verbindung.", ex);
await pipe.DisposeAsync();
continue;
}
// Nicht awaiten: die Accept-Loop muss sofort weiterlaufen, damit mehrere gleichzeitige
// Bridge-Instanzen (mehrere KI-Client-Sitzungen) unabhängig bedient werden.
_ = RunSessionAsync(pipe, ct);
}
}
private async Task RunSessionAsync(NamedPipeServerStream pipe, CancellationToken ct)
{
try
{
await using var transport = new StreamServerTransport(pipe, pipe, "LehrerApp");
await using var server = McpServer.Create(transport, _serverOptions, loggerFactory: null, serviceProvider: null);
await server.RunAsync(ct);
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
_logger.Warn($"MCP: Session beendet ({ex.Message}).");
}
finally
{
await pipe.DisposeAsync();
}
}
public async ValueTask DisposeAsync()
{
if (_cts is null) return;
await _cts.CancelAsync();
if (_acceptLoop is not null)
{
try { await _acceptLoop; }
catch { /* Beenden über Cancellation ist der Normalfall hier */ }
}
_cts.Dispose();
}
private static McpServerOptions BuildServerOptions(
StudentTools studentTools, ExamTools examTools, GradeTools gradeTools,
ScheduleTools scheduleTools, TimeEntryTools timeEntryTools, LessonPlanTools lessonPlanTools,
GroupMembershipTools groupMembershipTools, LetterTemplateTools letterTemplateTools,
CompetencyTools competencyTools, UntisComparisonTools untisComparisonTools, GroupTools groupTools)
{
var toolCollection = new McpServerPrimitiveCollection<McpServerTool>();
void AddReadTool(Delegate handler, string name, string description)
{
toolCollection.Add(McpServerTool.Create(handler, new McpServerToolCreateOptions
{
Name = name,
Description = description,
ReadOnly = true,
}));
}
// Write-Tools schreiben nie direkt - jede Handler-Methode ruft selbst erst
// IMcpConfirmationService auf (siehe die jeweilige Tool-Klasse). ReadOnly bewusst false,
// Destructive bewusst false (keine dieser Schreiboperationen löscht etwas) - für die
// Ausnahme siehe AddDestructiveWriteTool/McpToolScope.AllowedDestructiveWriteTools.
void AddWriteTool(Delegate handler, string name, string description)
{
toolCollection.Add(McpServerTool.Create(handler, new McpServerToolCreateOptions
{
Name = name,
Description = description,
ReadOnly = false,
Destructive = false,
}));
}
void AddDestructiveWriteTool(Delegate handler, string name, string description)
{
toolCollection.Add(McpServerTool.Create(handler, new McpServerToolCreateOptions
{
Name = name,
Description = description,
ReadOnly = false,
Destructive = true,
}));
}
AddReadTool(studentTools.GetStudents, "get_students",
"Listet Schüler, optional gefiltert nach Lerngruppe.");
AddReadTool(groupTools.GetGroups, "get_groups",
"Listet Lerngruppen (Klassen/Kurse) mit ihrer Id, optional gefiltert nach Schuljahr.");
AddReadTool(examTools.GetExams, "get_exams",
"Listet Klausuren, optional gefiltert nach Lerngruppe.");
AddReadTool(gradeTools.GetGrades, "get_grades",
"Listet Noten einer Lerngruppe, optional gefiltert auf einen Schüler.");
AddReadTool(scheduleTools.GetSchedule, "get_schedule",
"Listet Stundenplan-Einträge, optional gefiltert nach Lerngruppe.");
AddReadTool(timeEntryTools.GetTimeEntries, "get_time_entries",
"Listet eigene Zeiterfassungs-Einträge in einem Datumsbereich.");
AddReadTool(lessonPlanTools.GetLessonPlans, "get_lesson_plans",
"Listet Unterrichtseinheiten und -stunden einer Lerngruppe in einem Zeitraum.");
AddReadTool(lessonPlanTools.DownloadLessonAttachment, "download_lesson_attachment",
"Lädt den Inhalt eines an eine Einzelstunde angehängten Materials Base64-kodiert herunter.");
AddReadTool(letterTemplateTools.ListLetterTemplates, "list_letter_templates",
"Listet importierte Elternbrief-Vorlagen mit ihren Platzhaltern.");
AddReadTool(letterTemplateTools.RenderLetter, "render_letter",
"Erzeugt einen Elternbrief aus einer Vorlage für einen Schüler als Base64-PDF.");
AddReadTool(competencyTools.GetSubjects, "get_subjects",
"Listet alle Fächer.");
AddReadTool(competencyTools.GetCompetencyCatalog, "get_competency_catalog",
"Listet den Kompetenzkatalog eines Fachs, optional gefiltert auf eine Klassenstufe.");
AddReadTool(untisComparisonTools.GetUntisHubStatus, "get_untis_hub_status",
"Listet die Fälligkeit der Untis-Hub-Abgleiche, ohne selbst WebUntis anzufragen.");
AddReadTool(untisComparisonTools.GetUntisAbsenceRows, "get_untis_absence_rows",
"Listet anonymisierte Fehlzeiten-Diskrepanzen einer Lerngruppe gegenüber WebUntis (keine Schülernamen, nur eine row-id je Zeile).");
AddReadTool(untisComparisonTools.GetNamedUntisAbsencePattern, "get_named_untis_absence_pattern",
"Liefert Fehlzeiten MIT Schülername für explizit angegebene Schüler-IDs - Ausnahme von der sonstigen Anonymisierung, erfordert jedes Mal eine gesonderte Nutzerbestätigung.");
AddWriteTool(timeEntryTools.CreateTimeEntry, "create_time_entry",
"Schlägt einen neuen Zeiterfassungs-Eintrag vor (Bestätigung durch den Nutzer nötig).");
AddWriteTool(gradeTools.CreateGradeEntry, "create_grade_entry",
"Schlägt eine neue Note für einen Schüler vor (Bestätigung durch den Nutzer nötig).");
AddWriteTool(groupMembershipTools.UpdateStudentGroupAssignment, "update_student_group_assignment",
"Legt eine Gruppenmitgliedschaft an oder ändert Niveau/Zeitraum (Bestätigung durch den Nutzer nötig).");
AddWriteTool(lessonPlanTools.CreateUnit, "create_unit",
"Legt eine neue Unterrichtseinheit an (Bestätigung durch den Nutzer nötig).");
AddWriteTool(lessonPlanTools.UpdateUnit, "update_unit",
"Ändert Titel/Zeitraum/Status einer Unterrichtseinheit (Bestätigung durch den Nutzer nötig).");
AddWriteTool(lessonPlanTools.CreateLesson, "create_lesson",
"Legt eine neue Einzelstunde ohne Verlaufsplan an (Bestätigung durch den Nutzer nötig).");
AddWriteTool(lessonPlanTools.UpdateLesson, "update_lesson",
"Ändert Metadaten einer Einzelstunde, ohne den Verlaufsplan anzufassen (Bestätigung durch den Nutzer nötig).");
AddWriteTool(lessonPlanTools.AddLessonPhase, "add_lesson_phase",
"Fügt einer Einzelstunde eine Verlaufsplan-Phase hinzu (Bestätigung durch den Nutzer nötig).");
AddWriteTool(lessonPlanTools.UpdateLessonPhase, "update_lesson_phase",
"Ändert eine Verlaufsplan-Phase einer Einzelstunde (Bestätigung durch den Nutzer nötig).");
AddWriteTool(lessonPlanTools.RemoveLessonPhase, "remove_lesson_phase",
"Entfernt eine Verlaufsplan-Phase aus einer Einzelstunde (Bestätigung durch den Nutzer nötig).");
AddWriteTool(lessonPlanTools.AddLessonAttachment, "add_lesson_attachment",
"Fügt einer Einzelstunde ein neues Material als Base64-kodierten Anhang hinzu (Bestätigung durch den Nutzer nötig).");
AddWriteTool(lessonPlanTools.MoveLesson, "move_lesson",
"Verschiebt eine Einzelstunde auf ein neues Datum, optional mit Mitverschieben späterer Stunden derselben Einheit (Bestätigung durch den Nutzer nötig).");
AddWriteTool(lessonPlanTools.AddLessonCompetency, "add_lesson_competency",
"Ordnet einer Einzelstunde einen Kompetenzcode zu (Bestätigung durch den Nutzer nötig).");
AddWriteTool(lessonPlanTools.RemoveLessonCompetency, "remove_lesson_competency",
"Entfernt einen Kompetenzcode von einer Einzelstunde (Bestätigung durch den Nutzer nötig).");
AddWriteTool(competencyTools.CreateSubject, "create_subject",
"Legt ein neues Fach an (Bestätigung durch den Nutzer nötig).");
AddWriteTool(competencyTools.UpdateSubject, "update_subject",
"Ändert Name/Kurzform eines Fachs (Bestätigung durch den Nutzer nötig).");
AddWriteTool(competencyTools.CreateCompetencyDomain, "create_competency_domain",
"Legt einen neuen Kompetenzbereich für ein Fach/eine Klassenstufe an (Bestätigung durch den Nutzer nötig).");
AddWriteTool(competencyTools.UpdateCompetencyDomain, "update_competency_domain",
"Ändert Name/Kürzel eines Kompetenzbereichs (Bestätigung durch den Nutzer nötig).");
AddWriteTool(competencyTools.AddCompetencyItem, "add_competency_item",
"Fügt einem Kompetenzbereich eine Einzelkompetenz hinzu (Bestätigung durch den Nutzer nötig).");
AddWriteTool(competencyTools.UpdateCompetencyItem, "update_competency_item",
"Ändert Code/Beschreibung einer Einzelkompetenz (Bestätigung durch den Nutzer nötig).");
AddWriteTool(competencyTools.RemoveCompetencyItem, "remove_competency_item",
"Entfernt eine Einzelkompetenz aus einem Kompetenzbereich (Bestätigung durch den Nutzer nötig).");
AddWriteTool(untisComparisonTools.ApplyUntisAbsenceStatus, "apply_untis_absence_status",
"Übernimmt einen Statusvorschlag für eine über get_untis_absence_rows gelieferte row-id (Bestätigung durch den Nutzer nötig, nennt keinen Schülernamen).");
AddDestructiveWriteTool(lessonPlanTools.DeleteLesson, "delete_lesson",
"Löscht eine Einzelstunde endgültig, ohne Papierkorb (Bestätigung durch den Nutzer nötig).");
AddDestructiveWriteTool(competencyTools.DeleteSubject, "delete_subject",
"Löscht ein Fach endgültig, ohne Papierkorb (Bestätigung durch den Nutzer nötig).");
AddDestructiveWriteTool(competencyTools.DeleteCompetencyDomain, "delete_competency_domain",
"Löscht einen Kompetenzbereich endgültig samt Einzelkompetenzen, ohne Papierkorb (Bestätigung durch den Nutzer nötig).");
System.Diagnostics.Debug.Assert(
toolCollection.Select(t => t.ProtocolTool.Name).OrderBy(n => n)
.SequenceEqual(McpToolScope.AllowedReadTools.Concat(McpToolScope.AllowedWriteTools)
.Concat(McpToolScope.AllowedDestructiveWriteTools).OrderBy(n => n)),
"Registrierte MCP-Tools weichen von McpToolScope ab.");
return new McpServerOptions
{
ServerInfo = new Implementation { Name = "LehrerApp", Version = "1.0.0" },
Capabilities = new ServerCapabilities { Tools = new ToolsCapability() },
ToolCollection = toolCollection,
};
}
}
@@ -0,0 +1,81 @@
namespace LehrerApp.Desktop.Services.Mcp;
/// <summary>
/// Allowlist der über MCP exponierten Tool-Namen. Dieselbe Absicherung wie
/// LehrerApp.Api/PlainEventStore.cs (dort für den Klartext-Sync-Kanal): Gesprächsnotizen, Vorfälle
/// und Förderpläne (Documentation/Vorgang) sind hier bewusst nie aufgeführt und werden von keiner
/// Tool-Klasse referenziert — ein KI-Client kann diese Daten technisch nicht erreichen, unabhängig
/// davon, wie vertrauenswürdig der lokale Modell-Client erscheint oder wie die Tool-Liste künftig
/// wächst. <see cref="McpServerHostedService"/> registriert nur exakt diese Namen.
///
/// "get_named_untis_absence_pattern" ist die eine bewusste, eng begrenzte Ausnahme von der oben
/// beschriebenen Regel (Nutzer-Entscheidung, siehe TODO.md): es verknüpft Schülername mit
/// Fehlzeitendaten, aber nur für explizit angegebene Schüler-IDs und nur nach jedes Mal gesonderter,
/// prominenter Bestätigung ohne Sitzungsfreigabe (siehe UntisComparisonTools). Documentation/
/// Vorgang bleibt davon unberührt weiterhin vollständig ausgeschlossen.
/// </summary>
public static class McpToolScope
{
public static readonly IReadOnlyCollection<string> AllowedReadTools =
[
"get_students",
"get_groups",
"get_exams",
"get_grades",
"get_schedule",
"get_time_entries",
"get_lesson_plans",
"download_lesson_attachment",
"list_letter_templates",
"render_letter",
"get_subjects",
"get_competency_catalog",
"get_untis_hub_status",
"get_untis_absence_rows",
"get_named_untis_absence_pattern",
];
/// <summary>Write-Tools (Phase 2+3) — jeder Aufruf läuft über <see cref="IMcpConfirmationService"/>,
/// bevor irgendetwas geschrieben wird (siehe die jeweilige Tool-Klasse). Absichtlich kleinteilig
/// bei Unit/Lesson (create/update_unit, create/update_lesson, add/update/remove_lesson_phase)
/// statt eines einzelnen "update_lesson" für die ganze Stunde inkl. Verlaufsplan — siehe
/// Begründung in LessonPlanTools.</summary>
public static readonly IReadOnlyCollection<string> AllowedWriteTools =
[
"create_time_entry",
"create_grade_entry",
"update_student_group_assignment",
"create_unit",
"update_unit",
"create_lesson",
"update_lesson",
"add_lesson_phase",
"update_lesson_phase",
"remove_lesson_phase",
"add_lesson_attachment",
"move_lesson",
"add_lesson_competency",
"remove_lesson_competency",
"create_subject",
"update_subject",
"create_competency_domain",
"update_competency_domain",
"add_competency_item",
"update_competency_item",
"remove_competency_item",
"apply_untis_absence_status",
];
/// <summary>Löschende Write-Tools — ursprünglich eine bewusste, gezielte Ausnahme von der sonst
/// geltenden "v1 ohne Lösch-Tools"-Regel für "delete_lesson" (siehe Planungsdokument), inzwischen
/// (Nutzer-Nachtrag) um die endgültigen Löschungen für Fächer/Kompetenzbereiche erweitert — beide
/// ohne Papierkorb, wie delete_lesson. Getrennt von <see cref="AllowedWriteTools"/> aufgeführt,
/// damit diese Ausnahme beim Lesen sofort auffällt. <see cref="McpServerHostedService"/>
/// registriert diese Tools zusätzlich mit <c>Destructive = true</c>.</summary>
public static readonly IReadOnlyCollection<string> AllowedDestructiveWriteTools =
[
"delete_lesson",
"delete_subject",
"delete_competency_domain",
];
}
@@ -0,0 +1,252 @@
using System.ComponentModel;
using System.Text;
using LehrerApp.Core.Interfaces;
using LehrerApp.Core.Models;
namespace LehrerApp.Desktop.Services.Mcp.Tools;
/// <summary>MCP-Tools für die Fach- und Kompetenzkatalog-Verwaltung (Nutzer-Nachtrag zu 4.5.28ff.):
/// <see cref="Subject"/> und <see cref="CompetencyDomain"/>/<see cref="CompetencyItem"/> vollständig
/// anlegen/ändern/löschen. Die Zuordnung von Kompetenzcodes zu einzelnen Stunden lebt bewusst in
/// <see cref="LessonPlanTools"/> (AddLessonCompetency/RemoveLessonCompetency), nicht hier - sie
/// gehört fachlich zur Stundenverwaltung, nicht zum Katalog selbst.
///
/// "delete_subject" und "delete_competency_domain" sind wie "delete_lesson" (LessonPlanTools)
/// endgültige Löschungen ohne Papierkorb (die Repository-Implementierungen löschen direkt in der
/// LiteDB-Collection statt über MoveToTrash) - deshalb als Destructive registriert (siehe
/// McpServerHostedService) und mit entsprechend deutlicher Bestätigungsnachricht.</summary>
public class CompetencyTools(
ISubjectRepository subjects, ICompetencyDomainRepository domains, IMcpConfirmationService confirmation)
{
// ── Lesen ────────────────────────────────────────────────────────────────────────────────
[Description("Listet alle Fächer.")]
public List<SubjectDto> GetSubjects() =>
subjects.GetAll().Select(s => new SubjectDto(s.Id, s.Name, s.ShortName)).ToList();
[Description("Listet den Kompetenzkatalog (Bereiche mit Einzelkompetenzen) eines Fachs, optional gefiltert auf eine Klassenstufe.")]
public List<CompetencyDomainDto> GetCompetencyCatalog(
[Description("Fach-ID, aus get_subjects.")] Guid subjectId,
[Description("Optionale Klassenstufe. Weglassen: alle Klassenstufen dieses Fachs.")] int? gradeLevel = null)
{
var result = gradeLevel is not null
? domains.GetBySubjectAndGrade(subjectId, gradeLevel.Value)
: domains.GetBySubject(subjectId);
return result.Select(ToDto).ToList();
}
// ── Fächer (Subject) ─────────────────────────────────────────────────────────────────────
[Description("Legt ein neues Fach an. Muss der Nutzer erst bestätigen.")]
public async Task<WriteResultDto> CreateSubject(
[Description("Name des Fachs, z.B. \"Mathematik\".")] string name,
[Description("Kurzform, z.B. \"Ma\".")] string shortName = "",
CancellationToken ct = default)
{
if (string.IsNullOrWhiteSpace(name)) return new WriteResultDto(false, null, "Fachname darf nicht leer sein.");
var message = $"Neues Fach „{name.Trim()}“" +
(string.IsNullOrWhiteSpace(shortName) ? "" : $" ({shortName.Trim()})") + " anlegen?";
if (!await confirmation.ConfirmAsync("Fach anlegen?", message, ct))
return new WriteResultDto(false, null, "Vom Nutzer abgelehnt oder nicht bestätigt.");
var subject = new Subject { Name = name, ShortName = shortName };
try { subjects.Save(subject); }
catch (Exception ex) when (ex is ArgumentException or InvalidOperationException)
{
return new WriteResultDto(false, null, ex.Message);
}
return new WriteResultDto(true, subject.Id, "Fach gespeichert.");
}
[Description("Ändert Name/Kurzform eines bestehenden Fachs. Nur angegebene Felder werden geändert.")]
public async Task<WriteResultDto> UpdateSubject(
[Description("Fach-ID.")] Guid subjectId,
[Description("Neuer Name. Unverändert lassen: weglassen.")] string? name = null,
[Description("Neue Kurzform. Unverändert lassen: weglassen.")] string? shortName = null,
CancellationToken ct = default)
{
var subject = subjects.GetById(subjectId);
if (subject is null) return new WriteResultDto(false, null, "Unbekannte Fach-ID.");
var changes = new StringBuilder();
if (name is not null && name != subject.Name) { changes.AppendLine($"Name: „{subject.Name}“ → „{name}“"); subject.Name = name; }
if (shortName is not null && shortName != subject.ShortName) { changes.AppendLine($"Kurzform: „{subject.ShortName}“ → „{shortName}“"); subject.ShortName = shortName; }
if (changes.Length == 0)
return new WriteResultDto(true, subject.Id, "Keine Änderung nötig.");
if (!await confirmation.ConfirmAsync("Fach ändern?", $"„{subject.Name}“\n{changes}".TrimEnd(), ct))
return new WriteResultDto(false, null, "Vom Nutzer abgelehnt oder nicht bestätigt.");
try { subjects.Save(subject); }
catch (Exception ex) when (ex is ArgumentException or InvalidOperationException)
{
return new WriteResultDto(false, null, ex.Message);
}
return new WriteResultDto(true, subject.Id, "Fach gespeichert.");
}
[Description("Löscht ein Fach endgültig. Schlägt fehl, wenn noch eine Lerngruppe oder ein Kompetenzkatalog dieses Fach referenziert. Kein Papierkorb - nicht rückgängig zu machen. Muss der Nutzer erst bestätigen.")]
public async Task<WriteResultDto> DeleteSubject(
[Description("Fach-ID.")] Guid subjectId,
CancellationToken ct = default)
{
var subject = subjects.GetById(subjectId);
if (subject is null) return new WriteResultDto(false, null, "Unbekannte Fach-ID.");
var message = $"Das Fach „{subject.Name}“ wird endgültig gelöscht. Das kann NICHT rückgängig gemacht werden (kein Papierkorb für Fächer).";
if (!await confirmation.ConfirmAsync("Fach endgültig löschen?", message, ct))
return new WriteResultDto(false, null, "Vom Nutzer abgelehnt oder nicht bestätigt.");
try { subjects.Delete(subjectId); }
catch (InvalidOperationException ex)
{
return new WriteResultDto(false, null, ex.Message);
}
return new WriteResultDto(true, subjectId, "Fach gelöscht.");
}
// ── Kompetenzbereiche (CompetencyDomain) ────────────────────────────────────────────────
[Description("Legt einen neuen Kompetenzbereich (z.B. \"Zahlen und Operationen\") für ein Fach und eine Klassenstufe an, zunächst ohne Einzelkompetenzen. Muss der Nutzer erst bestätigen.")]
public async Task<WriteResultDto> CreateCompetencyDomain(
[Description("Fach-ID, aus get_subjects.")] Guid subjectId,
[Description("Klassenstufe.")] int gradeLevel,
[Description("Name des Bereichs.")] string name,
[Description("Optionales Kürzel, z.B. \"ZO\".")] string code = "",
CancellationToken ct = default)
{
var subject = subjects.GetById(subjectId);
if (subject is null) return new WriteResultDto(false, null, "Unbekannte Fach-ID.");
if (string.IsNullOrWhiteSpace(name)) return new WriteResultDto(false, null, "Bereichsname darf nicht leer sein.");
var sortOrder = domains.GetBySubjectAndGrade(subjectId, gradeLevel).Count;
var message = $"Neuen Kompetenzbereich „{name.Trim()}“ für {subject.Name}, Klasse {gradeLevel}, anlegen?";
if (!await confirmation.ConfirmAsync("Kompetenzbereich anlegen?", message, ct))
return new WriteResultDto(false, null, "Vom Nutzer abgelehnt oder nicht bestätigt.");
var domain = new CompetencyDomain
{
SubjectId = subjectId, GradeLevel = gradeLevel, Name = name, Code = code, SortOrder = sortOrder,
};
domains.Save(domain);
return new WriteResultDto(true, domain.Id, "Kompetenzbereich gespeichert.");
}
[Description("Ändert Name/Kürzel eines bestehenden Kompetenzbereichs. Nur angegebene Felder werden geändert.")]
public async Task<WriteResultDto> UpdateCompetencyDomain(
[Description("ID des Kompetenzbereichs, aus get_competency_catalog.")] Guid domainId,
[Description("Neuer Name. Unverändert lassen: weglassen.")] string? name = null,
[Description("Neues Kürzel. Unverändert lassen: weglassen.")] string? code = null,
CancellationToken ct = default)
{
var domain = domains.GetById(domainId);
if (domain is null) return new WriteResultDto(false, null, "Unbekannte Bereichs-ID.");
var changes = new StringBuilder();
if (name is not null && name != domain.Name) { changes.AppendLine($"Name: „{domain.Name}“ → „{name}“"); domain.Name = name; }
if (code is not null && code != domain.Code) { changes.AppendLine($"Kürzel: „{domain.Code}“ → „{code}“"); domain.Code = code; }
if (changes.Length == 0)
return new WriteResultDto(true, domain.Id, "Keine Änderung nötig.");
if (!await confirmation.ConfirmAsync("Kompetenzbereich ändern?", $"„{domain.Name}“\n{changes}".TrimEnd(), ct))
return new WriteResultDto(false, null, "Vom Nutzer abgelehnt oder nicht bestätigt.");
domains.Save(domain);
return new WriteResultDto(true, domain.Id, "Kompetenzbereich gespeichert.");
}
[Description("Löscht einen Kompetenzbereich endgültig, inklusive aller darin enthaltenen Einzelkompetenzen. Bereits vergebene Kompetenzcodes an Einheiten/Stunden/Klausuraufgaben bleiben als Freitext bestehen, gelten aber danach als \"nicht im Katalog\". Kein Papierkorb - nicht rückgängig zu machen. Muss der Nutzer erst bestätigen.")]
public async Task<WriteResultDto> DeleteCompetencyDomain(
[Description("ID des Kompetenzbereichs.")] Guid domainId,
CancellationToken ct = default)
{
var domain = domains.GetById(domainId);
if (domain is null) return new WriteResultDto(false, null, "Unbekannte Bereichs-ID.");
var message = $"Der Kompetenzbereich „{domain.Name}“ mit {domain.Items.Count} Einzelkompetenz(en) wird endgültig gelöscht. " +
"Das kann NICHT rückgängig gemacht werden (kein Papierkorb für Kompetenzbereiche).";
if (!await confirmation.ConfirmAsync("Kompetenzbereich endgültig löschen?", message, ct))
return new WriteResultDto(false, null, "Vom Nutzer abgelehnt oder nicht bestätigt.");
domains.Delete(domainId);
return new WriteResultDto(true, domainId, "Kompetenzbereich gelöscht.");
}
// ── Einzelkompetenzen (CompetencyItem) ──────────────────────────────────────────────────
[Description("Fügt einem Kompetenzbereich eine neue Einzelkompetenz hinzu (ans Ende). Muss der Nutzer erst bestätigen.")]
public async Task<WriteResultDto> AddCompetencyItem(
[Description("ID des Kompetenzbereichs, aus get_competency_catalog.")] Guid domainId,
[Description("Kompetenzcode, z.B. \"M.5.1\".")] string code,
[Description("Beschreibung der Kompetenz.")] string description,
CancellationToken ct = default)
{
var domain = domains.GetById(domainId);
if (domain is null) return new WriteResultDto(false, null, "Unbekannte Bereichs-ID.");
if (string.IsNullOrWhiteSpace(description)) return new WriteResultDto(false, null, "Beschreibung darf nicht leer sein.");
var message = $"Neue Kompetenz „{code}“ zu Bereich „{domain.Name}“ hinzufügen?\n{description}";
if (!await confirmation.ConfirmAsync("Kompetenz hinzufügen?", message, ct))
return new WriteResultDto(false, null, "Vom Nutzer abgelehnt oder nicht bestätigt.");
var item = new CompetencyItem { Code = code.Trim(), Description = description.Trim(), SortOrder = domain.Items.Count };
domain.Items.Add(item);
domains.Save(domain);
return new WriteResultDto(true, item.Id, "Kompetenz hinzugefügt.");
}
[Description("Ändert Code/Beschreibung einer bestehenden Einzelkompetenz. Nur angegebene Felder werden geändert.")]
public async Task<WriteResultDto> UpdateCompetencyItem(
[Description("ID des Kompetenzbereichs.")] Guid domainId,
[Description("ID der Einzelkompetenz, aus get_competency_catalog.")] Guid itemId,
[Description("Neuer Kompetenzcode. Unverändert lassen: weglassen.")] string? code = null,
[Description("Neue Beschreibung. Unverändert lassen: weglassen.")] string? description = null,
CancellationToken ct = default)
{
var domain = domains.GetById(domainId);
if (domain is null) return new WriteResultDto(false, null, "Unbekannte Bereichs-ID.");
var item = domain.Items.FirstOrDefault(i => i.Id == itemId);
if (item is null) return new WriteResultDto(false, null, "Unbekannte Kompetenz-ID in diesem Bereich.");
var changes = new StringBuilder();
if (code is not null && code != item.Code) { changes.AppendLine($"Code: „{item.Code}“ → „{code}“"); item.Code = code; }
if (description is not null && description != item.Description) { changes.AppendLine($"Beschreibung: „{item.Description}“ → „{description}“"); item.Description = description; }
if (changes.Length == 0)
return new WriteResultDto(true, item.Id, "Keine Änderung nötig.");
if (!await confirmation.ConfirmAsync("Kompetenz ändern?", $"Kompetenz „{item.Code}“ in Bereich „{domain.Name}“\n{changes}".TrimEnd(), ct))
return new WriteResultDto(false, null, "Vom Nutzer abgelehnt oder nicht bestätigt.");
domains.Save(domain);
return new WriteResultDto(true, item.Id, "Kompetenz gespeichert.");
}
[Description("Entfernt eine Einzelkompetenz aus einem Kompetenzbereich. Muss der Nutzer erst bestätigen.")]
public async Task<WriteResultDto> RemoveCompetencyItem(
[Description("ID des Kompetenzbereichs.")] Guid domainId,
[Description("ID der Einzelkompetenz, aus get_competency_catalog.")] Guid itemId,
CancellationToken ct = default)
{
var domain = domains.GetById(domainId);
if (domain is null) return new WriteResultDto(false, null, "Unbekannte Bereichs-ID.");
var item = domain.Items.FirstOrDefault(i => i.Id == itemId);
if (item is null) return new WriteResultDto(false, null, "Unbekannte Kompetenz-ID in diesem Bereich.");
var message = $"Kompetenz „{item.Code}“ ({item.Description}) aus Bereich „{domain.Name}“ entfernen?";
if (!await confirmation.ConfirmAsync("Kompetenz entfernen?", message, ct))
return new WriteResultDto(false, null, "Vom Nutzer abgelehnt oder nicht bestätigt.");
domain.Items.Remove(item);
domains.Save(domain);
return new WriteResultDto(true, item.Id, "Kompetenz entfernt.");
}
private static CompetencyDomainDto ToDto(CompetencyDomain d) => new(
d.Id, d.SubjectId, d.GradeLevel, d.Name, d.Code, d.SortOrder,
d.Items.OrderBy(i => i.SortOrder)
.Select(i => new CompetencyItemDto(i.Id, i.Code, i.Description, i.SortOrder)).ToList());
}
+114
View File
@@ -0,0 +1,114 @@
using LehrerApp.Core.Models;
namespace LehrerApp.Desktop.Services.Mcp.Tools;
// Schlanke, bewusst nicht 1:1 zu den LiteDB-Entities gehaltene Rückgabetypen: verhindert, dass ein
// später zum Modell hinzugefügtes Feld (z.B. ein neues personenbezogenes Attribut) unbeabsichtigt
// über ein MCP-Tool nach außen dringt, nur weil es Teil der Entity-Klasse ist.
public record StudentDto(Guid Id, string FirstName, string LastName, bool IsActive);
public record ExamResultDto(Guid StudentId, double TotalPoints, string? Grade, bool Absent);
public record ExamDto(
Guid Id, Guid GroupId, string Title, DateOnly Date, ExamStatus Status, Niveau? Niveau,
List<ExamResultDto>? Results);
public record GradeDto(
Guid Id, Guid StudentId, Guid GroupId, GradeCategory Category, string Value, DateOnly Date,
double Weight, string? Note);
public record TimetableSlotDto(Guid Id, Guid GroupId, DayOfWeek Weekday, int PeriodNumber, string? Room);
public record TimeEntryDto(
Guid Id, Guid? TaskId, string Category, Guid? GroupId, DateOnly Date,
TimeOnly? StartTime, TimeOnly? EndTime, int DurationMinutes, string? Description);
/// <summary><see cref="MaterialPrompt"/> ist der beim letzten KI-"Übernehmen" gespeicherte
/// Materialerstellungs-Prompt (4.5.20/4.5.36) — nur gesetzt, wenn die KI für diese Phase einen
/// Medienvorschlag gemacht hatte. Ein MCP-Client kann ihn direkt zur Materialerzeugung nutzen, ohne
/// erst den Desktop-Dialog öffnen zu müssen.</summary>
public record LessonPhaseDto(
Guid Id, string Name, int DurationMinutes, string Activity, string Material, string Shorthand,
string? MaterialPrompt);
public record LessonAttachmentDto(string StorageId, string FileName, long SizeBytes);
public record LessonDto(
Guid Id, Guid UnitId, Guid GroupId, DateOnly Date, int? LessonNumber, string Topic,
string? Homework, string? PlanningIdeas, LessonStatus Status, List<string> Competencies,
List<LessonPhaseDto> Phases, List<LessonAttachmentDto> Attachments);
/// <summary>Ergebnis von "download_lesson_attachment": Inhalt Base64-kodiert, weil MCP-Tool-Antworten
/// als JSON/Text übertragen werden. Bewusst kein Ressourcen-URI-Mechanismus (siehe Planungsdokument) -
/// dafür müsste der Server MCP-Resources anbieten, was über den Rahmen dieses Tools hinausgeht;
/// stattdessen deckelt <see cref="LessonPlanTools.MaxInlineAttachmentBytes"/> die Größe.</summary>
public record AttachmentContentDto(string FileName, long SizeBytes, string Base64Content);
public record UnitDto(
Guid Id, Guid GroupId, string Title, DateOnly? StartDate, DateOnly? EndDate,
UnitStatus Status, List<string> Competencies);
public record LessonPlanResultDto(List<UnitDto> Units, List<LessonDto> Lessons);
public record SubjectDto(Guid Id, string Name, string ShortName);
public record CompetencyItemDto(Guid Id, string Code, string Description, int SortOrder);
public record CompetencyDomainDto(
Guid Id, Guid SubjectId, int GradeLevel, string Name, string Code, int SortOrder,
List<CompetencyItemDto> Items);
public record GroupMembershipDto(
Guid Id, Guid StudentId, Guid GroupId, MembershipPeriod Period,
DateOnly? JoinedAt, DateOnly? LeftAt, Niveau? Niveau);
/// <summary>Ergebnis eines Write-Tools (Phase 2, siehe Planungsdokument): <see cref="Applied"/> ist
/// nur dann true, wenn der Nutzer die Änderung im Bestätigungsdialog angenommen hat.</summary>
public record WriteResultDto(bool Applied, Guid? Id, string Message);
public record PlaceholderInfoDto(string Name, string Type, bool Required, bool IsConstant);
/// <summary>"Worksheets" aus der ursprünglichen Spec entsprechen im tatsächlichen Datenmodell den
/// importierten Elternbrief-Vorlagen (<c>.lavorlage</c>, <c>LehrerApp.Templating</c>) — es gibt kein
/// separates "Arbeitsblatt"-Konzept mit Fach/Klassenstufe-Metadaten. Siehe LetterTemplateTools.</summary>
public record LetterTemplateDto(string Id, string Name, string Description, List<PlaceholderInfoDto> Placeholders);
public record LetterRenderResultDto(bool Success, string Message, string? Base64Pdf, string? SuggestedFileName);
/// <summary>Eine Zeile des Untis-Hub (siehe UntisHubService) - "Kind"/"DueState" als Text statt
/// Enum-Wert, damit ein KI-Client sie ohne Kenntnis des internen Enums lesen kann. GroupId ist bei
/// den drei dashboard-weiten Zeilen (offene Stunden, Klassenbuch-/Hausaufgabenabgleich) null - siehe
/// UntisHubJobRow. Enthalten, damit ein KI-Client die Id nicht erst über get_groups nachschlagen
/// muss, um get_untis_absence_rows/get_named_untis_absence_pattern für eine hier gelistete Gruppe
/// aufzurufen (Nutzer-Feedback: die GroupId war nirgends exponiert).</summary>
public record UntisHubStatusRowDto(
string Kind, Guid? GroupId, string GroupName, string DueState, string DueLabel,
DateTime? LastRunAt, string? LastResultSummary);
/// <summary>Lerngruppe (Klasse oder Kurs) - siehe GroupTools.GetGroups. Bewusst kein Verweis auf
/// SubjectId->Name aufgelöst (dafür get_subjects), um nicht bei jeder Gruppe implizit einen ganzen
/// Fach-Datensatz mitzuschleppen.</summary>
public record GroupDto(
Guid Id, string Name, GroupType Type, string SchoolYear, int GradeLevel,
Guid? SubjectId, bool IsActive);
/// <summary>Anonymisierte Fehlzeiten-Diskrepanz (siehe UntisComparisonTools.GetUntisAbsenceRows):
/// bewusst KEIN Schülername/keine Klasse - <see cref="RowId"/> ist die einzige Kennung, über die
/// UntisComparisonTools.ApplyUntisAbsenceStatus später zurückordnet.</summary>
public record UntisAbsenceRowDto(
string RowId, DateOnly Date, string ReasonText, int AbsentMinutes, bool HandledOn,
bool? ExternKeyInParentheses, string CurrentLocalStatus, string CurrentGuessStatus);
/// <summary>Eine Zeile aus dem namentlichen Ausnahmeweg (get_named_untis_absence_pattern) - im
/// Unterschied zu <see cref="UntisAbsenceRowDto"/> bewusst MIT Schülername, da genau diese
/// Zusammenführung von Name und Fehlzeitendaten der Zweck des Aufrufs ist (z.B. Fehlmuster-Vergleich
/// zwischen zwei Schülern) und der Nutzer sie je Anfrage einzeln freigegeben hat.</summary>
public record NamedUntisAbsenceRowDto(
Guid StudentId, string StudentFullName, DateOnly Date, string ReasonText,
string CurrentLocalStatus, string CurrentGuessStatus);
/// <summary><see cref="Granted"/> ist false bei Ablehnung/Timeout oder wenn keine der angegebenen
/// Schüler-IDs bekannt war - <see cref="Rows"/> ist dann null, nicht nur leer, damit ein KI-Client
/// "abgelehnt" nicht mit "keine Fehlzeiten gefunden" verwechselt.</summary>
public record NamedUntisAbsenceResultDto(bool Granted, string Message, List<NamedUntisAbsenceRowDto>? Rows);
@@ -0,0 +1,23 @@
using System.ComponentModel;
using LehrerApp.Core.Interfaces;
namespace LehrerApp.Desktop.Services.Mcp.Tools;
/// <summary>MCP-Read-Tool "get_exams" (Phase 1, siehe Planungsdokument).</summary>
public class ExamTools(IExamRepository exams, IExamResultRepository examResults)
{
[Description("Listet Klausuren, optional gefiltert nach Lerngruppe.")]
public List<ExamDto> GetExams(
[Description("Optionale Lerngruppen-ID zum Filtern.")] Guid? groupId = null,
[Description("Ergebnisse je Schüler mitliefern (Standard: nein, hält die Antwort klein).")] bool includeResults = false)
{
var list = groupId is { } id ? exams.GetByGroup(id) : exams.GetAll();
return list.Select(e => new ExamDto(
e.Id, e.GroupId, e.Title, e.Date, e.Status, e.Niveau,
includeResults
? examResults.GetByExam(e.Id)
.Select(r => new ExamResultDto(r.StudentId, r.TotalPoints, r.Grade, r.Absent))
.ToList()
: null)).ToList();
}
}
@@ -0,0 +1,70 @@
using System.ComponentModel;
using LehrerApp.Core.Interfaces;
using LehrerApp.Core.Models;
namespace LehrerApp.Desktop.Services.Mcp.Tools;
/// <summary>MCP-Tools "get_grades" (Phase 1) und "create_grade_entry" (Phase 2), siehe
/// Planungsdokument.</summary>
public class GradeTools(IGradeRepository grades, IStudentRepository students, IMcpConfirmationService confirmation)
{
[Description("Listet Noten einer Lerngruppe, optional gefiltert auf einen einzelnen Schüler.")]
public List<GradeDto> GetGrades(
[Description("Lerngruppen-ID.")] Guid groupId,
[Description("Optionale Schüler-ID zum Filtern auf einen einzelnen Schüler.")] Guid? studentId = null)
{
var list = studentId is { } sid ? grades.GetByStudentAndGroup(sid, groupId) : grades.GetByGroup(groupId);
return list.Select(g => new GradeDto(
g.Id, g.StudentId, g.GroupId, g.Category, g.Value, g.Date, g.Weight, g.Note)).ToList();
}
[Description("Schlägt eine neue Note für einen Schüler vor. Muss der Nutzer erst in einem Dialog in LehrerApp bestätigen, bevor sie gespeichert wird.")]
public async Task<WriteResultDto> CreateGradeEntry(
[Description("Schüler-ID.")] Guid studentId,
[Description("Lerngruppen-ID.")] Guid groupId,
[Description("Kategorie: Oral, Homework, Participation, Project oder Other.")] GradeCategory category,
[Description("Notenwert als Text, z.B. \"2+\" oder \"gut\".")] string value,
[Description("Datum, Format YYYY-MM-DD.")] DateOnly date,
[Description("Gewichtung, Standard 1.0.")] double weight = 1.0,
[Description("Optionale Notiz.")] string? note = null,
CancellationToken ct = default)
{
var student = students.GetById(studentId);
if (student is null)
return new WriteResultDto(false, null, "Unbekannte Schüler-ID.");
var message =
$"Neue Note für {student.FullName}: {GradeCategoryDisplayName(category)} = {value}" +
(weight != 1.0 ? $" (Gewichtung {weight:0.##})" : "") +
$", am {date:dd.MM.yyyy}" +
(string.IsNullOrWhiteSpace(note) ? "" : $"\n„{note}“");
if (!await confirmation.ConfirmAsync("Note anlegen?", message, ct))
return new WriteResultDto(false, null, "Vom Nutzer abgelehnt oder nicht bestätigt.");
var grade = new Grade
{
StudentId = studentId,
GroupId = groupId,
Category = category,
Value = value,
Date = date,
Weight = weight,
Note = note,
};
grades.Save(grade);
return new WriteResultDto(true, grade.Id, "Note gespeichert.");
}
// Eigene, schlanke Beschriftung statt Wiederverwendung von ViewModels.Groups.GradeCategoryDisplay:
// Tool-Klassen unter Services/Mcp sollen nicht von ViewModel-Klassen abhängen.
private static string GradeCategoryDisplayName(GradeCategory c) => c switch
{
GradeCategory.Oral => "Mündlich",
GradeCategory.Homework => "Hausaufgaben",
GradeCategory.Participation => "Mitarbeit",
GradeCategory.Project => "Projekt",
GradeCategory.Other => "Sonstiges",
_ => c.ToString(),
};
}
@@ -0,0 +1,69 @@
using System.ComponentModel;
using System.Text;
using LehrerApp.Core.Interfaces;
using LehrerApp.Core.Models;
namespace LehrerApp.Desktop.Services.Mcp.Tools;
/// <summary>MCP-Write-Tool "update_student_group_assignment" (Phase 2, siehe Planungsdokument).
/// Legt eine <see cref="GroupMembership"/> an, falls noch keine für Schüler+Gruppe existiert, sonst
/// werden nur die übergebenen (nicht-null) Felder überschrieben.</summary>
public class GroupMembershipTools(
IGroupMembershipRepository memberships, IStudentRepository students, IGroupRepository groups,
IMcpConfirmationService confirmation)
{
[Description("Legt eine Gruppenmitgliedschaft eines Schülers an oder ändert Niveau/Zeitraum einer bestehenden. Muss der Nutzer erst in einem Dialog in LehrerApp bestätigen.")]
public async Task<WriteResultDto> UpdateStudentGroupAssignment(
[Description("Schüler-ID.")] Guid studentId,
[Description("Lerngruppen-ID.")] Guid groupId,
[Description("Niveau: E, G oder Foerder. Unverändert lassen: weglassen.")] Niveau? niveau = null,
[Description("Zeitraum: FullYear, H1Only, H2Only oder Custom. Unverändert lassen: weglassen.")] MembershipPeriod? period = null,
[Description("Beitrittsdatum bei Custom-Zeitraum, Format YYYY-MM-DD.")] DateOnly? joinedAt = null,
[Description("Austrittsdatum bei Custom-Zeitraum, Format YYYY-MM-DD.")] DateOnly? leftAt = null,
CancellationToken ct = default)
{
var student = students.GetById(studentId);
var group = groups.GetById(groupId);
if (student is null || group is null)
return new WriteResultDto(false, null, "Unbekannte Schüler- oder Gruppen-ID.");
var existing = memberships.GetByStudentAndGroup(studentId, groupId);
var target = existing is null
? new GroupMembership { StudentId = studentId, GroupId = groupId }
: Clone(existing);
var changes = new StringBuilder();
if (niveau is not null && niveau != target.Niveau) { changes.AppendLine($"Niveau: {NiveauName(target.Niveau)} → {NiveauName(niveau)}"); target.Niveau = niveau; }
if (period is not null && period != target.Period) { changes.AppendLine($"Zeitraum: {target.Period} → {period}"); target.Period = period.Value; }
if (joinedAt is not null && joinedAt != target.JoinedAt) { changes.AppendLine($"Beitritt: {target.JoinedAt:dd.MM.yyyy} → {joinedAt:dd.MM.yyyy}"); target.JoinedAt = joinedAt; }
if (leftAt is not null && leftAt != target.LeftAt) { changes.AppendLine($"Austritt: {target.LeftAt:dd.MM.yyyy} → {leftAt:dd.MM.yyyy}"); target.LeftAt = leftAt; }
if (existing is not null && changes.Length == 0)
return new WriteResultDto(true, existing.Id, "Keine Änderung nötig, Mitgliedschaft besteht bereits unverändert.");
var title = existing is null ? "Gruppenmitgliedschaft anlegen?" : "Gruppenmitgliedschaft ändern?";
var message = $"{student.FullName} — {group.Name}" +
(existing is null ? "\nNeue Mitgliedschaft anlegen." : "") +
(changes.Length > 0 ? "\n" + changes.ToString().TrimEnd() : "");
if (!await confirmation.ConfirmAsync(title, message, ct))
return new WriteResultDto(false, null, "Vom Nutzer abgelehnt oder nicht bestätigt.");
memberships.Save(target);
return new WriteResultDto(true, target.Id, "Gruppenmitgliedschaft gespeichert.");
}
private static GroupMembership Clone(GroupMembership m) => new()
{
Id = m.Id, StudentId = m.StudentId, GroupId = m.GroupId, AddedOn = m.AddedOn,
Period = m.Period, JoinedAt = m.JoinedAt, LeftAt = m.LeftAt, Niveau = m.Niveau,
};
private static string NiveauName(Niveau? n) => n switch
{
Core.Models.Niveau.E => "E-Niveau",
Core.Models.Niveau.G => "G-Niveau",
Core.Models.Niveau.Foerder => "Förderniveau",
_ => "",
};
}
@@ -0,0 +1,23 @@
using System.ComponentModel;
using LehrerApp.Core.Interfaces;
namespace LehrerApp.Desktop.Services.Mcp.Tools;
/// <summary>MCP-Read-Tool "get_groups" (Nutzer-Nachtrag: die GroupId, die praktisch jedes andere
/// Tool als Pflichtparameter verlangt - get_grades, get_schedule, get_lesson_plans,
/// get_untis_absence_rows usw. - war nirgends über MCP auflösbar; ein KI-Client kannte bestenfalls
/// den Klarnamen einer Lerngruppe aus dem Gespräch, nie ihre Id). Reiner Lesezugriff auf das
/// bestehende Repository, keine eigene Datenzugriffslogik.</summary>
public class GroupTools(IGroupRepository groups)
{
[Description("Listet Lerngruppen (Klassen/Kurse) mit ihrer Id - Voraussetzung, um andere Tools (z.B. get_grades, get_schedule, get_untis_absence_rows) für eine bestimmte Gruppe aufzurufen, wenn nur ihr Name bekannt ist. Ohne schoolYear werden alle Schuljahre zurückgegeben.")]
public List<GroupDto> GetGroups(
[Description("Optionales Schuljahr zum Filtern, Format \"2025/26\". Ohne Angabe alle Schuljahre.")] string? schoolYear = null,
[Description("Auch inaktive/archivierte Gruppen einbeziehen.")] bool includeInactive = false)
{
var list = schoolYear is null
? groups.GetAll(includeInactive)
: groups.GetBySchoolYear(schoolYear, includeInactive);
return list.Select(g => new GroupDto(g.Id, g.Name, g.Type, g.SchoolYear, g.GradeLevel, g.SubjectId, g.IsActive)).ToList();
}
}
@@ -0,0 +1,420 @@
using System.ComponentModel;
using System.Text;
using LehrerApp.Core.Interfaces;
using LehrerApp.Core.Models;
using LehrerApp.Core.Services;
namespace LehrerApp.Desktop.Services.Mcp.Tools;
/// <summary>MCP-Tools rund um Unterrichtseinheiten (<see cref="Unit"/>) und Einzelstunden
/// (<see cref="Lesson"/>) — Phase 1 (get_lesson_plans) und Phase 3 (siehe Planungsdokument).
///
/// Bewusst kleinteilig statt eines einzelnen "update_lesson", das die komplette Stunde inkl.
/// Verlaufsplan als ein großes JSON-Objekt tauscht: <see cref="Lesson.Phases"/> ist zwar technisch
/// eine eingebettete Liste im selben LiteDB-Dokument (keine echte Sub-Collection, also kein
/// Zeilen-Locking auf DB-Ebene) — kleinteilige Tools verkürzen aber das Zeitfenster zwischen Lesen
/// und Schreiben je Operation drastisch (ein Tool-Aufruf ändert nur eine Phase, nicht die ganze
/// Stunde) und liefern einen für den Bestätigungsdialog tatsächlich lesbaren Diff statt eines
/// kompletten Objekt-Dumps. Ein Tool, das die ganze Stunde überschreibt, ist bewusst NICHT
/// vorgesehen; wo es fehlt, ist die Kombination aus update_lesson (Metadaten) +
/// add/update/remove_lesson_phase (je eine Phase) der vorgesehene Weg.
///
/// "delete_lesson" ist (Stand Nutzer-Nachtrag) das einzige Lösch-Tool im gesamten MCP-Katalog —
/// eine bewusste, gezielte Ausnahme von der sonst geltenden "v1 ohne Lösch-Tools"-Regel (siehe
/// Planungsdokument), nicht deren Aufhebung. Entsprechend als "Destructive" annotiert
/// (siehe McpServerHostedService) und mit besonders deutlicher Bestätigungsnachricht.</summary>
public class LessonPlanTools(
IUnitRepository units, ILessonRepository lessons, IGroupRepository groups,
IAttachmentStorage attachments, IMcpConfirmationService confirmation)
{
/// <summary>Deckelt die Antwortgröße von "download_lesson_attachment" (Base64 bläht ca. um
/// Faktor 1,33 auf). Kleiner als <see cref="IAttachmentStorage.MaxSizeBytes"/> (App-weites
/// Limit), damit ein einzelner MCP-Tool-Aufruf nicht unnötig groß wird — siehe Planungsdokument
/// zum offenen Punkt "Ressourcen statt Inline-Base64 für große Dateien".</summary>
public const long MaxInlineAttachmentBytes = 3 * 1024 * 1024;
// ── Lesen ────────────────────────────────────────────────────────────────────────────────
[Description("Listet Unterrichtseinheiten und -stunden einer Lerngruppe; die Einzelstunden werden auf den angegebenen Zeitraum gefiltert.")]
public LessonPlanResultDto GetLessonPlans(
[Description("Lerngruppen-ID.")] Guid groupId,
[Description("Startdatum (einschließlich) für die Einzelstunden, Format YYYY-MM-DD.")] DateOnly from,
[Description("Enddatum (einschließlich) für die Einzelstunden, Format YYYY-MM-DD.")] DateOnly to)
{
var unitDtos = units.GetByGroup(groupId)
.Select(u => new UnitDto(u.Id, u.GroupId, u.Title, u.StartDate, u.EndDate, u.Status, u.Competencies))
.ToList();
var lessonDtos = lessons.GetByGroupAndRange(groupId, from, to)
.Select(ToDto)
.ToList();
return new LessonPlanResultDto(unitDtos, lessonDtos);
}
[Description("Lädt den Inhalt eines an eine Einzelstunde angehängten Materials (z.B. Arbeitsblatt) Base64-kodiert herunter. Für die storageId siehe get_lesson_plans.")]
public AttachmentContentDto DownloadLessonAttachment(
[Description("ID der Einzelstunde.")] Guid lessonId,
[Description("Speicher-ID des Anhangs, aus get_lesson_plans.")] string storageId)
{
var lesson = lessons.GetById(lessonId) ?? throw new InvalidOperationException("Unbekannte Stunden-ID.");
var attachment = lesson.Attachments.FirstOrDefault(a => a.StorageId == storageId)
?? throw new InvalidOperationException("Kein Anhang mit dieser Speicher-ID an dieser Stunde.");
if (attachment.SizeBytes > MaxInlineAttachmentBytes)
throw new InvalidOperationException(
$"Anhang ist mit {attachment.SizeBytes / 1024 / 1024} MB zu groß für eine Inline-Antwort (Limit {MaxInlineAttachmentBytes / 1024 / 1024} MB).");
using var stream = attachments.OpenRead(storageId)
?? throw new InvalidOperationException("Anhang-Inhalt nicht auffindbar (Speicher inkonsistent).");
using var buffer = new MemoryStream();
stream.CopyTo(buffer);
return new AttachmentContentDto(attachment.FileName, attachment.SizeBytes, Convert.ToBase64String(buffer.ToArray()));
}
[Description("Fügt einer Einzelstunde ein neues Material (z.B. ein von der KI erzeugtes Arbeitsblatt) als Anhang hinzu, Base64-kodiert. Muss der Nutzer erst bestätigen.")]
public async Task<WriteResultDto> AddLessonAttachment(
[Description("ID der Einzelstunde.")] Guid lessonId,
[Description("Dateiname inkl. Endung, z.B. \"arbeitsblatt.pdf\".")] string fileName,
[Description("Dateiinhalt, Base64-kodiert.")] string base64Content,
CancellationToken ct = default)
{
var lesson = lessons.GetById(lessonId);
if (lesson is null) return new WriteResultDto(false, null, "Unbekannte Stunden-ID.");
byte[] bytes;
try { bytes = Convert.FromBase64String(base64Content); }
catch (FormatException) { return new WriteResultDto(false, null, "Ungültiger Base64-Inhalt."); }
if (bytes.Length == 0) return new WriteResultDto(false, null, "Leerer Dateiinhalt.");
if (bytes.Length > IAttachmentStorage.MaxSizeBytes)
return new WriteResultDto(false, null,
$"Datei ist mit {bytes.Length / 1024 / 1024} MB zu groß (Limit {IAttachmentStorage.MaxSizeBytes / 1024 / 1024} MB).");
var sizeDisplay = bytes.Length >= 1024 * 1024
? $"{bytes.Length / 1024 / 1024} MB" : $"{Math.Max(1, bytes.Length / 1024)} KB";
var message = $"„{fileName}“ ({sizeDisplay}) zu „{lesson.Topic}“ am {lesson.Date:dd.MM.yyyy} hinzufügen?";
if (!await confirmation.ConfirmAsync("Material anhängen?", message, ct))
return new WriteResultDto(false, null, "Vom Nutzer abgelehnt oder nicht bestätigt.");
string storageId;
using (var stream = new MemoryStream(bytes)) storageId = attachments.Upload(fileName, stream);
lesson.Attachments.Add(new DocumentAttachment { StorageId = storageId, FileName = fileName, SizeBytes = bytes.Length });
lessons.Save(lesson);
// DocumentAttachment hat keine eigene Guid-Id (nur StorageId, ein string) - deshalb Id hier
// null, storageId steht stattdessen in der Nachricht (für einen unmittelbaren Folgeaufruf,
// z.B. download_lesson_attachment zur Bestätigung, ohne erst get_lesson_plans erneut aufzurufen).
return new WriteResultDto(true, null, $"Anhang gespeichert (storageId={storageId}).");
}
// ── Unterrichtseinheiten (Unit) ──────────────────────────────────────────────────────────
[Description("Legt eine neue Unterrichtseinheit an. Muss der Nutzer erst in einem Dialog in LehrerApp bestätigen.")]
public async Task<WriteResultDto> CreateUnit(
[Description("Lerngruppen-ID.")] Guid groupId,
[Description("Titel der Einheit.")] string title,
[Description("Optionales Startdatum, Format YYYY-MM-DD.")] DateOnly? startDate = null,
[Description("Optionales Enddatum, Format YYYY-MM-DD.")] DateOnly? endDate = null,
CancellationToken ct = default)
{
var group = groups.GetById(groupId);
if (group is null) return new WriteResultDto(false, null, "Unbekannte Gruppen-ID.");
var message = $"Neue Unterrichtseinheit „{title}“ für {group.Name} anlegen?" +
(startDate is not null || endDate is not null
? $"\nZeitraum: {startDate:dd.MM.yyyy} {endDate:dd.MM.yyyy}" : "");
if (!await confirmation.ConfirmAsync("Unterrichtseinheit anlegen?", message, ct))
return new WriteResultDto(false, null, "Vom Nutzer abgelehnt oder nicht bestätigt.");
var unit = new Unit { GroupId = groupId, Title = title, StartDate = startDate, EndDate = endDate };
units.Save(unit);
return new WriteResultDto(true, unit.Id, "Unterrichtseinheit gespeichert.");
}
[Description("Ändert Titel/Zeitraum/Status einer bestehenden Unterrichtseinheit. Nur angegebene Felder werden geändert.")]
public async Task<WriteResultDto> UpdateUnit(
[Description("ID der Unterrichtseinheit.")] Guid unitId,
[Description("Neuer Titel. Unverändert lassen: weglassen.")] string? title = null,
[Description("Neues Startdatum, Format YYYY-MM-DD. Unverändert lassen: weglassen.")] DateOnly? startDate = null,
[Description("Neues Enddatum, Format YYYY-MM-DD. Unverändert lassen: weglassen.")] DateOnly? endDate = null,
[Description("Neuer Status: Planned, Active oder Completed. Unverändert lassen: weglassen.")] UnitStatus? status = null,
CancellationToken ct = default)
{
var unit = units.GetById(unitId);
if (unit is null) return new WriteResultDto(false, null, "Unbekannte Einheiten-ID.");
var changes = new StringBuilder();
if (title is not null && title != unit.Title) { changes.AppendLine($"Titel: „{unit.Title}“ → „{title}“"); unit.Title = title; }
if (startDate is not null && startDate != unit.StartDate) { changes.AppendLine($"Start: {unit.StartDate:dd.MM.yyyy} → {startDate:dd.MM.yyyy}"); unit.StartDate = startDate; }
if (endDate is not null && endDate != unit.EndDate) { changes.AppendLine($"Ende: {unit.EndDate:dd.MM.yyyy} → {endDate:dd.MM.yyyy}"); unit.EndDate = endDate; }
if (status is not null && status != unit.Status) { changes.AppendLine($"Status: {unit.Status} → {status}"); unit.Status = status.Value; }
if (changes.Length == 0)
return new WriteResultDto(true, unit.Id, "Keine Änderung nötig.");
if (!await confirmation.ConfirmAsync("Unterrichtseinheit ändern?", $"„{unit.Title}“\n{changes}".TrimEnd(), ct))
return new WriteResultDto(false, null, "Vom Nutzer abgelehnt oder nicht bestätigt.");
units.Save(unit);
return new WriteResultDto(true, unit.Id, "Unterrichtseinheit gespeichert.");
}
// ── Einzelstunden (Lesson) — Metadaten ───────────────────────────────────────────────────
[Description("Legt eine neue Einzelstunde ohne Verlaufsplan-Phasen an. Phasen danach einzeln über add_lesson_phase hinzufügen. Muss der Nutzer erst bestätigen.")]
public async Task<WriteResultDto> CreateLesson(
[Description("ID der übergeordneten Unterrichtseinheit.")] Guid unitId,
[Description("Datum, Format YYYY-MM-DD.")] DateOnly date,
[Description("Thema der Stunde.")] string topic,
[Description("Optionale Stundennummer im Tagesraster.")] int? lessonNumber = null,
[Description("Optionaler Stundenbeginn, Format HH:mm.")] TimeOnly? startTime = null,
CancellationToken ct = default)
{
var unit = units.GetById(unitId);
if (unit is null) return new WriteResultDto(false, null, "Unbekannte Einheiten-ID.");
var message = $"Neue Stunde „{topic}“ am {date:dd.MM.yyyy} in Einheit „{unit.Title}“ anlegen?";
if (!await confirmation.ConfirmAsync("Einzelstunde anlegen?", message, ct))
return new WriteResultDto(false, null, "Vom Nutzer abgelehnt oder nicht bestätigt.");
var lesson = new Lesson
{
UnitId = unitId,
GroupId = unit.GroupId,
Date = date,
Topic = topic,
LessonNumber = lessonNumber,
StartTime = startTime,
};
lessons.Save(lesson);
return new WriteResultDto(true, lesson.Id, "Einzelstunde gespeichert.");
}
[Description("Ändert Metadaten einer bestehenden Einzelstunde (Thema, Hausaufgabe, Planungsideen, Status, Beginn, Stundennummer) — der Verlaufsplan (Phasen) bleibt unverändert. Nur angegebene Felder werden geändert.")]
public async Task<WriteResultDto> UpdateLesson(
[Description("ID der Einzelstunde.")] Guid lessonId,
[Description("Neues Thema. Unverändert lassen: weglassen.")] string? topic = null,
[Description("Neue Hausaufgabe. Unverändert lassen: weglassen.")] string? homework = null,
[Description("Neue Planungsideen (grober Entwurf vor der Feinplanung). Unverändert lassen: weglassen.")] string? planningIdeas = null,
[Description("Neuer Status: Planned, Conducted, Draft, Ready oder Cancelled (ausgefallen, z.B. Exkursion/Feiertag - Alternative zu delete_lesson, wenn die Stunde als Ereignis dokumentiert bleiben soll). Unverändert lassen: weglassen.")] LessonStatus? status = null,
[Description("Neuer Stundenbeginn, Format HH:mm. Unverändert lassen: weglassen.")] TimeOnly? startTime = null,
[Description("Neue Stundennummer. Unverändert lassen: weglassen.")] int? lessonNumber = null,
CancellationToken ct = default)
{
var lesson = lessons.GetById(lessonId);
if (lesson is null) return new WriteResultDto(false, null, "Unbekannte Stunden-ID.");
var changes = new StringBuilder();
if (topic is not null && topic != lesson.Topic) { changes.AppendLine($"Thema: „{lesson.Topic}“ → „{topic}“"); lesson.Topic = topic; }
if (homework is not null && homework != lesson.Homework) { changes.AppendLine($"Hausaufgabe: „{lesson.Homework}“ → „{homework}“"); lesson.Homework = homework; }
if (planningIdeas is not null && planningIdeas != lesson.PlanningIdeas) { changes.AppendLine($"Planungsideen: „{lesson.PlanningIdeas}“ → „{planningIdeas}“"); lesson.PlanningIdeas = planningIdeas; }
if (status is not null && status != lesson.Status) { changes.AppendLine($"Status: {lesson.Status} → {status}"); lesson.Status = status.Value; }
if (startTime is not null && startTime != lesson.StartTime) { changes.AppendLine($"Beginn: {lesson.StartTime:HH\\:mm} → {startTime:HH\\:mm}"); lesson.StartTime = startTime; }
if (lessonNumber is not null && lessonNumber != lesson.LessonNumber) { changes.AppendLine($"Nr.: {lesson.LessonNumber} → {lessonNumber}"); lesson.LessonNumber = lessonNumber; }
if (changes.Length == 0)
return new WriteResultDto(true, lesson.Id, "Keine Änderung nötig.");
if (!await confirmation.ConfirmAsync("Einzelstunde ändern?", $"„{lesson.Topic}“ am {lesson.Date:dd.MM.yyyy}\n{changes}".TrimEnd(), ct))
return new WriteResultDto(false, null, "Vom Nutzer abgelehnt oder nicht bestätigt.");
lessons.Save(lesson);
return new WriteResultDto(true, lesson.Id, "Einzelstunde gespeichert.");
}
// ── Einzelstunden (Lesson) — Verlaufsplan-Phasen ─────────────────────────────────────────
[Description("Fügt einer Einzelstunde eine neue Verlaufsplan-Phase hinzu (ans Ende). Muss der Nutzer erst bestätigen.")]
public async Task<WriteResultDto> AddLessonPhase(
[Description("ID der Einzelstunde.")] Guid lessonId,
[Description("Name der Phase, z.B. \"Einstieg\", \"Erarbeitung\".")] string name,
[Description("Dauer in Minuten.")] int durationMinutes,
[Description("Tätigkeit/Sozialform.")] string activity = "",
[Description("Material.")] string material = "",
[Description("Kurzsymbol, z.B. \"AB001->S\".")] string shorthand = "",
[Description("Optionaler, vollständiger Prompt zur Materialerstellung für diese Phase (siehe get_lesson_plans, LessonPhaseDto.MaterialPrompt) - z.B. wenn eine externe KI-Sitzung ihn selbst formuliert hat und er zur Wiederverwendung gespeichert werden soll.")] string? materialPrompt = null,
CancellationToken ct = default)
{
var lesson = lessons.GetById(lessonId);
if (lesson is null) return new WriteResultDto(false, null, "Unbekannte Stunden-ID.");
var message = $"Neue Phase „{name}“ ({durationMinutes} Min.) zu „{lesson.Topic}“ am {lesson.Date:dd.MM.yyyy} hinzufügen?";
if (!await confirmation.ConfirmAsync("Phase hinzufügen?", message, ct))
return new WriteResultDto(false, null, "Vom Nutzer abgelehnt oder nicht bestätigt.");
var phase = new LessonPhaseStep
{
Name = name, DurationMinutes = durationMinutes, Activity = activity,
Material = material, Shorthand = shorthand, MaterialPrompt = materialPrompt,
};
lesson.Phases.Add(phase);
lessons.Save(lesson);
return new WriteResultDto(true, phase.Id, "Phase hinzugefügt.");
}
[Description("Ändert eine bestehende Verlaufsplan-Phase einer Einzelstunde. Nur angegebene Felder werden geändert.")]
public async Task<WriteResultDto> UpdateLessonPhase(
[Description("ID der Einzelstunde.")] Guid lessonId,
[Description("ID der Phase, aus get_lesson_plans.")] Guid phaseId,
[Description("Neuer Name. Unverändert lassen: weglassen.")] string? name = null,
[Description("Neue Dauer in Minuten. Unverändert lassen: weglassen.")] int? durationMinutes = null,
[Description("Neue Tätigkeit. Unverändert lassen: weglassen.")] string? activity = null,
[Description("Neues Material. Unverändert lassen: weglassen.")] string? material = null,
[Description("Neues Kurzsymbol. Unverändert lassen: weglassen.")] string? shorthand = null,
[Description("Neuer Prompt zur Materialerstellung (siehe get_lesson_plans, LessonPhaseDto.MaterialPrompt). Unverändert lassen: weglassen.")] string? materialPrompt = null,
CancellationToken ct = default)
{
var lesson = lessons.GetById(lessonId);
if (lesson is null) return new WriteResultDto(false, null, "Unbekannte Stunden-ID.");
var phase = lesson.Phases.FirstOrDefault(p => p.Id == phaseId);
if (phase is null) return new WriteResultDto(false, null, "Unbekannte Phasen-ID an dieser Stunde.");
var changes = new StringBuilder();
if (name is not null && name != phase.Name) { changes.AppendLine($"Name: „{phase.Name}“ → „{name}“"); phase.Name = name; }
if (durationMinutes is not null && durationMinutes != phase.DurationMinutes) { changes.AppendLine($"Dauer: {phase.DurationMinutes} → {durationMinutes} Min."); phase.DurationMinutes = durationMinutes.Value; }
if (activity is not null && activity != phase.Activity) { changes.AppendLine($"Tätigkeit: „{phase.Activity}“ → „{activity}“"); phase.Activity = activity; }
if (material is not null && material != phase.Material) { changes.AppendLine($"Material: „{phase.Material}“ → „{material}“"); phase.Material = material; }
if (shorthand is not null && shorthand != phase.Shorthand) { changes.AppendLine($"Kürzel: „{phase.Shorthand}“ → „{shorthand}“"); phase.Shorthand = shorthand; }
if (materialPrompt is not null && materialPrompt != phase.MaterialPrompt) { changes.AppendLine("Materialerstellungs-Prompt geändert."); phase.MaterialPrompt = materialPrompt; }
if (changes.Length == 0)
return new WriteResultDto(true, phase.Id, "Keine Änderung nötig.");
if (!await confirmation.ConfirmAsync("Phase ändern?", $"Phase „{phase.Name}“ in „{lesson.Topic}“ am {lesson.Date:dd.MM.yyyy}\n{changes}".TrimEnd(), ct))
return new WriteResultDto(false, null, "Vom Nutzer abgelehnt oder nicht bestätigt.");
lessons.Save(lesson);
return new WriteResultDto(true, phase.Id, "Phase gespeichert.");
}
[Description("Entfernt eine Verlaufsplan-Phase aus einer Einzelstunde. Muss der Nutzer erst bestätigen.")]
public async Task<WriteResultDto> RemoveLessonPhase(
[Description("ID der Einzelstunde.")] Guid lessonId,
[Description("ID der Phase, aus get_lesson_plans.")] Guid phaseId,
CancellationToken ct = default)
{
var lesson = lessons.GetById(lessonId);
if (lesson is null) return new WriteResultDto(false, null, "Unbekannte Stunden-ID.");
var phase = lesson.Phases.FirstOrDefault(p => p.Id == phaseId);
if (phase is null) return new WriteResultDto(false, null, "Unbekannte Phasen-ID an dieser Stunde.");
var message = $"Phase „{phase.Name}“ ({phase.DurationMinutes} Min.) aus „{lesson.Topic}“ am {lesson.Date:dd.MM.yyyy} entfernen?";
if (!await confirmation.ConfirmAsync("Phase entfernen?", message, ct))
return new WriteResultDto(false, null, "Vom Nutzer abgelehnt oder nicht bestätigt.");
lesson.Phases.Remove(phase);
lessons.Save(lesson);
return new WriteResultDto(true, phase.Id, "Phase entfernt.");
}
// ── Einzelstunden (Lesson) — Kompetenzzuordnung ──────────────────────────────────────────
// Auf Stundenebene (Lesson.Competencies), noch nicht je Phase - siehe TODO.md 4.5.8. Getrennte
// add/remove-Tools statt eines "set_lesson_competencies", das die ganze Liste ersetzt: dieselbe
// Race-Condition-Überlegung wie bei den Verlaufsplan-Phasen (add/update/remove_lesson_phase) -
// ein Tool-Aufruf ändert nur einen Code, kein "letzter Schreiber gewinnt" über die ganze Liste.
[Description("Ordnet einer Einzelstunde einen Kompetenzcode aus dem Katalog zu (siehe get_competency_catalog). Muss der Nutzer erst bestätigen.")]
public async Task<WriteResultDto> AddLessonCompetency(
[Description("ID der Einzelstunde.")] Guid lessonId,
[Description("Kompetenzcode, z.B. \"M.5.1\".")] string code,
CancellationToken ct = default)
{
var lesson = lessons.GetById(lessonId);
if (lesson is null) return new WriteResultDto(false, null, "Unbekannte Stunden-ID.");
code = code.Trim();
if (code.Length == 0) return new WriteResultDto(false, null, "Leerer Kompetenzcode.");
if (lesson.Competencies.Contains(code, StringComparer.OrdinalIgnoreCase))
return new WriteResultDto(true, lesson.Id, "Kompetenzcode ist bereits zugeordnet.");
var message = $"Kompetenz „{code}“ zu „{lesson.Topic}“ am {lesson.Date:dd.MM.yyyy} hinzufügen?";
if (!await confirmation.ConfirmAsync("Kompetenz zuordnen?", message, ct))
return new WriteResultDto(false, null, "Vom Nutzer abgelehnt oder nicht bestätigt.");
lesson.Competencies.Add(code);
lessons.Save(lesson);
return new WriteResultDto(true, lesson.Id, "Kompetenz zugeordnet.");
}
[Description("Entfernt einen Kompetenzcode von einer Einzelstunde. Muss der Nutzer erst bestätigen.")]
public async Task<WriteResultDto> RemoveLessonCompetency(
[Description("ID der Einzelstunde.")] Guid lessonId,
[Description("Kompetenzcode, wie in get_lesson_plans hinterlegt.")] string code,
CancellationToken ct = default)
{
var lesson = lessons.GetById(lessonId);
if (lesson is null) return new WriteResultDto(false, null, "Unbekannte Stunden-ID.");
var existing = lesson.Competencies.FirstOrDefault(c => string.Equals(c, code, StringComparison.OrdinalIgnoreCase));
if (existing is null) return new WriteResultDto(false, null, "Dieser Kompetenzcode ist dieser Stunde nicht zugeordnet.");
var message = $"Kompetenz „{existing}“ von „{lesson.Topic}“ am {lesson.Date:dd.MM.yyyy} entfernen?";
if (!await confirmation.ConfirmAsync("Kompetenz entfernen?", message, ct))
return new WriteResultDto(false, null, "Vom Nutzer abgelehnt oder nicht bestätigt.");
lesson.Competencies.Remove(existing);
lessons.Save(lesson);
return new WriteResultDto(true, lesson.Id, "Kompetenz entfernt.");
}
// ── Einzelstunden (Lesson) — Verschieben & Löschen ───────────────────────────────────────
[Description("Verschiebt eine Einzelstunde auf ein neues Datum (und optional eine neue Stundennummer). Mit shiftFollowingLessons=true verschieben sich alle noch nicht durchgeführten, späteren Stunden derselben Einheit um denselben Tages-Versatz mit — so lässt sich eine Lücke für eine neue Stunde öffnen: diese Stunde auf den Termin der übernächsten verschieben (mit shiftFollowingLessons), dann create_lesson auf das dadurch freigewordene ursprüngliche Datum. Muss der Nutzer erst bestätigen.")]
public async Task<WriteResultDto> MoveLesson(
[Description("ID der zu verschiebenden Einzelstunde.")] Guid lessonId,
[Description("Neues Datum, Format YYYY-MM-DD.")] DateOnly newDate,
[Description("Neue Stundennummer im Tagesraster. Unverändert lassen: weglassen.")] int? newPeriod = null,
[Description("Alle späteren, noch nicht durchgeführten Stunden derselben Einheit um denselben Tages-Versatz mitverschieben.")] bool shiftFollowingLessons = false,
CancellationToken ct = default)
{
var lesson = lessons.GetById(lessonId);
if (lesson is null) return new WriteResultDto(false, null, "Unbekannte Stunden-ID.");
var oldDate = lesson.Date;
// Muss exakt dieselbe Schutzbedingung wie LessonSchedulingService.Move verwenden, sonst
// weicht die Vorschau-Zahl im Bestätigungstext von dem ab, was tatsächlich verschoben wird.
var affectedCount = shiftFollowingLessons
? lessons.GetByUnit(lesson.UnitId).Count(l =>
l.Id != lesson.Id && l.Status is not (LessonStatus.Conducted or LessonStatus.Cancelled) && l.Date > oldDate)
: 0;
var message = $"„{lesson.Topic}“ von {oldDate:dd.MM.yyyy} auf {newDate:dd.MM.yyyy} verschieben?" +
(newPeriod is not null ? $"\nNeue Stundennummer: {newPeriod}." : "") +
(shiftFollowingLessons
? affectedCount > 0
? $"\n{affectedCount} spätere Stunde(n) derselben Einheit verschieben sich um denselben Versatz mit."
: "\nKeine späteren, noch offenen Stunden derselben Einheit betroffen."
: "");
if (!await confirmation.ConfirmAsync("Stunde verschieben?", message, ct))
return new WriteResultDto(false, null, "Vom Nutzer abgelehnt oder nicht bestätigt.");
try
{
new LessonSchedulingService(lessons).Move(lesson, newDate, newPeriod, shiftFollowingLessons);
}
catch (InvalidOperationException ex)
{
return new WriteResultDto(false, null, ex.Message);
}
return new WriteResultDto(true, lesson.Id, "Stunde verschoben.");
}
[Description("Löscht eine Einzelstunde endgültig, inklusive ihrer Anhänge. Anders als die meisten anderen Löschvorgänge in LehrerApp landet eine gelöschte Stunde NICHT im Papierkorb — nicht rückgängig zu machen. Für eine Stunde, die nur ausgefallen ist (Exkursion, Feiertag, Vertretung ohne Ersatztermin) aber als Ereignis dokumentiert bleiben soll, ist update_lesson mit status=Cancelled meist die bessere Wahl. Muss der Nutzer erst bestätigen.")]
public async Task<WriteResultDto> DeleteLesson(
[Description("ID der zu löschenden Einzelstunde.")] Guid lessonId,
CancellationToken ct = default)
{
var lesson = lessons.GetById(lessonId);
if (lesson is null) return new WriteResultDto(false, null, "Unbekannte Stunden-ID.");
var message = $"Die Stunde „{lesson.Topic}“ vom {lesson.Date:dd.MM.yyyy} wird endgültig gelöscht. " +
"Das kann NICHT rückgängig gemacht werden (kein Papierkorb für Stunden).";
if (!await confirmation.ConfirmAsync("Stunde endgültig löschen?", message, ct))
return new WriteResultDto(false, null, "Vom Nutzer abgelehnt oder nicht bestätigt.");
lessons.Delete(lessonId);
return new WriteResultDto(true, lessonId, "Stunde gelöscht.");
}
private static LessonDto ToDto(Lesson l) => new(
l.Id, l.UnitId, l.GroupId, l.Date, l.LessonNumber, l.Topic, l.Homework, l.PlanningIdeas, l.Status, l.Competencies,
l.Phases.Select(p => new LessonPhaseDto(p.Id, p.Name, p.DurationMinutes, p.Activity, p.Material, p.Shorthand, p.MaterialPrompt)).ToList(),
l.Attachments.Select(a => new LessonAttachmentDto(a.StorageId, a.FileName, a.SizeBytes)).ToList());
}
@@ -0,0 +1,138 @@
using System.ComponentModel;
using System.Globalization;
using LehrerApp.Core.Interfaces;
using LehrerApp.Desktop.Services;
using LehrerApp.Templating;
namespace LehrerApp.Desktop.Services.Mcp.Tools;
/// <summary>MCP-Tools "list_letter_templates"/"render_letter" (siehe Planungsdokument, Abschnitt
/// "Worksheets"). Die ursprüngliche Spec sah "Arbeitsblätter" mit Fach-/Klassenstufe-Metadaten vor —
/// das existiert im Datenmodell nicht. Was tatsächlich existiert: importierte
/// <c>.lavorlage</c>-Elternbrief-Vorlagen (<see cref="TemplateStore"/>), an Schüler+Kontakt
/// gebunden, exakt wie im bestehenden "Elternbrief"-Dialog
/// (<c>CreateLetterDialogViewModel</c>). Bewusst NICHT umgesetzt: "upload_worksheet"/
/// "update_worksheet" im Sinne von "eine neue Vorlage per KI entwerfen" — das Seitenlayout ist eine
/// eigene, positionsbasierte DSL (siehe LehrerApp.Templating/LayoutParser.cs), für die es absichtlich
/// einen eigenen visuellen Editor (LehrerApp.TemplateDesigner) gibt; ein LLM würde diese DSL blind
/// erzeugen müssen, mit hohem Risiko für unbrauchbare/kaputte Layouts. "render_letter" deckt den
/// tatsächlich nützlichen Fall ab: eine bestehende, vom Nutzer gestaltete Vorlage mit Werten füllen.</summary>
public class LetterTemplateTools(
TemplateStore templates, ITemplateLoader loader, ITemplateRenderer renderer,
IStudentRepository students, IGroupRepository groups,
StudentAttendanceCalendarService? attendanceCalendars = null)
{
[Description("Listet importierte Elternbrief-Vorlagen mit ihren deklarierten Platzhaltern (Name, Typ, Pflichtfeld, konstant).")]
public List<LetterTemplateDto> ListLetterTemplates()
{
var result = new List<LetterTemplateDto>();
foreach (var installed in templates.GetTemplates())
{
try
{
var loaded = templates.Load(installed);
result.Add(new LetterTemplateDto(installed.Id, installed.Name, installed.Description,
loaded.Manifest.Placeholders
.Select(p => new PlaceholderInfoDto(p.Name, p.Type.ToString(), p.Required, p.IsConstant))
.ToList()));
}
catch (Exception ex) when (ex is IOException or InvalidDataException or TemplateValidationException)
{
// Beschädigtes/ungültiges Paket - wie TemplateStore.GetTemplates() selbst überspringt
// auch diese Auflistung es kommentarlos, statt die ganze Liste scheitern zu lassen.
}
}
return result;
}
[Description("Erzeugt einen Elternbrief aus einer importierten Vorlage für einen Schüler und liefert ihn als Base64-PDF. Reiner Lesezugriff (kein Datenbank-Schreibzugriff), deshalb ohne Bestätigungsdialog.")]
public LetterRenderResultDto RenderLetter(
[Description("Vorlagen-ID, aus list_letter_templates.")] string templateId,
[Description("Schüler-ID.")] Guid studentId,
[Description("Brieftext (Fließtext).")] string letterText,
[Description("Name der unterschreibenden Lehrkraft.")] string teacherName,
[Description("Optionale Kontakt-ID des Schülers. Ohne Angabe wird der erste gültige Kontakt verwendet.")] Guid? contactId = null,
[Description("Optionale Lerngruppen-ID für Group.Name/SchoolYear-Platzhalter.")] Guid? groupId = null,
[Description("Briefdatum, Format YYYY-MM-DD. Standard: heute.")] DateOnly? letterDate = null,
[Description("Zusätzliche, von der Vorlage deklarierte Platzhalterwerte über die Standardfelder hinaus (Name -> Text/Zahl/Datum als Text).")]
Dictionary<string, string>? extraValues = null)
{
var student = students.GetById(studentId);
if (student is null) return new LetterRenderResultDto(false, "Unbekannte Schüler-ID.", null, null);
var installed = templates.GetTemplates().FirstOrDefault(t => t.Id == templateId);
if (installed is null) return new LetterRenderResultDto(false, "Unbekannte Vorlagen-ID.", null, null);
LoadedTemplate loaded;
try { loaded = templates.Load(installed); }
catch (Exception ex) when (ex is IOException or InvalidDataException or TemplateValidationException)
{ return new LetterRenderResultDto(false, $"Vorlage ist ungültig: {ex.Message}", null, null); }
var contact = contactId is { } cid
? student.Contacts.FirstOrDefault(c => c.Id == cid)
: student.Contacts.FirstOrDefault(c => !c.InvalidSince.HasValue);
var group = groupId is { } gid ? groups.GetById(gid) : null;
var date = letterDate ?? DateOnly.FromDateTime(DateTime.Today);
var values = LetterPlaceholderBuilder.BuildStandardValues(student, contact, group, date, letterText,
teacherName, attendanceCalendars?.Build(student, date),
attendanceCalendars?.BuildAbsenceDayList(student, new AttendanceCalendarOptions(date, 1)));
foreach (var (name, raw) in extraValues ?? [])
{
var definition = loaded.Manifest.Placeholders.FirstOrDefault(p => p.Name == name);
if (definition is null || definition.IsConstant) continue;
if (TryConvert(definition.Type, raw) is { } converted) values[name] = converted;
}
var resolved = TemplateDataResolver.Resolve(loaded.Manifest, values);
var validation = loader.Validate(loaded, resolved.ToDictionary(x => x.Key, x => x.Value.Type));
var missing = loaded.Manifest.Placeholders
.Where(p => !p.IsConstant && p.Required &&
(!resolved.TryGetValue(p.Name, out var v) || LetterPlaceholderBuilder.IsEmpty(v)))
.Select(p => p.Name).ToList();
if (!validation.IsValid || missing.Count > 0)
{
var issues = validation.Issues.Select(i => i.Message)
.Concat(missing.Select(m => $"Pflichtfeld „{m}“ fehlt."));
return new LetterRenderResultDto(false,
"Vorlage kann mit diesen Werten nicht gefüllt werden: " + string.Join(" ", issues), null, null);
}
byte[] pdf;
try { pdf = renderer.RenderToPdf(loaded, new DictionaryDataProvider(resolved)); }
catch (Exception ex) when (ex is InvalidDataException or TemplateValidationException)
{ return new LetterRenderResultDto(false, $"Brief konnte nicht erzeugt werden: {ex.Message}", null, null); }
var fileName = SanitizeFileName($"{installed.Name}_{student.LastName}_{student.FirstName}.pdf");
return new LetterRenderResultDto(true, "Brief erzeugt.", Convert.ToBase64String(pdf), fileName);
}
private static PlaceholderValue? TryConvert(PlaceholderType type, string raw) => type switch
{
PlaceholderType.Text => new TextValue(raw),
PlaceholderType.Multiline => new MultilineValue(raw),
PlaceholderType.Date => TryParseDate(raw) is { } date ? new DateValue(date) : null,
PlaceholderType.Number => TryParseNumber(raw) is { } number ? new NumberValue(number) : null,
// Image/Table/Chart/Drawing sind über MCP-Textparameter nicht sinnvoll setzbar.
_ => null,
};
private static DateOnly? TryParseDate(string raw) =>
DateOnly.TryParse(raw, CultureInfo.InvariantCulture, DateTimeStyles.None, out var d) ||
DateOnly.TryParse(raw, CultureInfo.GetCultureInfo("de-DE"), DateTimeStyles.None, out d) ? d : null;
private static decimal? TryParseNumber(string raw) =>
decimal.TryParse(raw, NumberStyles.Number, CultureInfo.InvariantCulture, out var n) ||
decimal.TryParse(raw, NumberStyles.Number, CultureInfo.GetCultureInfo("de-DE"), out n) ? n : null;
private static string SanitizeFileName(string value)
{
foreach (var character in Path.GetInvalidFileNameChars()) value = value.Replace(character, '_');
return value;
}
private sealed class DictionaryDataProvider(IReadOnlyDictionary<string, PlaceholderValue> values) : ITemplateDataProvider
{
public IReadOnlyDictionary<string, PlaceholderValue> GetValues() => values;
}
}
@@ -0,0 +1,16 @@
using System.ComponentModel;
using LehrerApp.Core.Interfaces;
namespace LehrerApp.Desktop.Services.Mcp.Tools;
/// <summary>MCP-Read-Tool "get_schedule" (Phase 1, siehe Planungsdokument).</summary>
public class ScheduleTools(ITimetableSlotRepository slots)
{
[Description("Listet Stundenplan-Einträge (Wochenraster), optional gefiltert nach Lerngruppe.")]
public List<TimetableSlotDto> GetSchedule(
[Description("Optionale Lerngruppen-ID zum Filtern.")] Guid? groupId = null)
{
var list = groupId is { } id ? slots.GetByGroup(id) : slots.GetAll();
return list.Select(s => new TimetableSlotDto(s.Id, s.GroupId, s.Weekday, s.PeriodNumber, s.Room)).ToList();
}
}
@@ -0,0 +1,20 @@
using System.ComponentModel;
using LehrerApp.Core.Interfaces;
namespace LehrerApp.Desktop.Services.Mcp.Tools;
/// <summary>MCP-Read-Tool "get_students" (Phase 1, siehe Planungsdokument). Reine Lesezugriffe auf
/// die bestehenden Repositories, keine eigene Datenzugriffslogik.</summary>
public class StudentTools(IStudentRepository students)
{
[Description("Listet Schüler, optional gefiltert nach Lerngruppe. Enthält standardmäßig nur aktive Schüler.")]
public List<StudentDto> GetStudents(
[Description("Optionale Lerngruppen-ID zum Filtern.")] Guid? groupId = null,
[Description("Auch inaktive/ausgeschiedene Schüler einbeziehen.")] bool includeInactive = false)
{
var list = groupId is { } id ? students.GetByGroup(id) : students.GetAll(includeInactive);
if (groupId is not null && !includeInactive)
list = list.Where(s => s.IsActive).ToList();
return list.Select(s => new StudentDto(s.Id, s.FirstName, s.LastName, s.IsActive)).ToList();
}
}
@@ -0,0 +1,49 @@
using System.ComponentModel;
using LehrerApp.Core.Interfaces;
using LehrerApp.Core.Models;
namespace LehrerApp.Desktop.Services.Mcp.Tools;
/// <summary>MCP-Tools "get_time_entries" (Phase 1) und "create_time_entry" (Phase 2), siehe
/// Planungsdokument. Der Zeitraum bei "get_time_entries" ist Pflicht (nicht optional), damit eine
/// unbedachte Anfrage nicht die gesamte Zeiterfassungshistorie zurückgibt.</summary>
public class TimeEntryTools(ITimeEntryRepository timeEntries, IGroupRepository groups, IMcpConfirmationService confirmation)
{
[Description("Listet eigene Zeiterfassungs-Einträge in einem Datumsbereich.")]
public List<TimeEntryDto> GetTimeEntries(
[Description("Startdatum (einschließlich), Format YYYY-MM-DD.")] DateOnly from,
[Description("Enddatum (einschließlich), Format YYYY-MM-DD.")] DateOnly to) =>
timeEntries.GetByDateRange(from, to).Select(t => new TimeEntryDto(
t.Id, t.TaskId, t.Category, t.GroupId, t.Date, t.StartTime, t.EndTime,
t.DurationMinutes, t.Description)).ToList();
[Description("Schlägt einen neuen Zeiterfassungs-Eintrag vor. Muss der Nutzer erst in einem Dialog in LehrerApp bestätigen, bevor er gespeichert wird.")]
public async Task<WriteResultDto> CreateTimeEntry(
[Description("Kategorie, z.B. \"Unterricht\", \"Korrektur\", \"Vorbereitung\".")] string category,
[Description("Datum, Format YYYY-MM-DD.")] DateOnly date,
[Description("Dauer in Minuten.")] int durationMinutes,
[Description("Optionale Lerngruppen-ID.")] Guid? groupId = null,
[Description("Optionale Beschreibung.")] string? description = null,
CancellationToken ct = default)
{
var groupName = groupId is { } gid ? groups.GetById(gid)?.Name : null;
var message =
$"Neuer Zeiteintrag: {category}, {durationMinutes} Min. am {date:dd.MM.yyyy}" +
(groupName is not null ? $", Gruppe {groupName}" : "") +
(string.IsNullOrWhiteSpace(description) ? "" : $"\n„{description}“");
if (!await confirmation.ConfirmAsync("Zeiteintrag anlegen?", message, ct))
return new WriteResultDto(false, null, "Vom Nutzer abgelehnt oder nicht bestätigt.");
var entry = new TimeEntry
{
Category = category,
Date = date,
DurationMinutes = durationMinutes,
GroupId = groupId,
Description = description,
};
timeEntries.Save(entry);
return new WriteResultDto(true, entry.Id, "Zeiteintrag gespeichert.");
}
}
@@ -0,0 +1,208 @@
using System.Collections.Concurrent;
using System.ComponentModel;
using LehrerApp.Core.Interfaces;
using LehrerApp.Core.Models;
namespace LehrerApp.Desktop.Services.Mcp.Tools;
/// <summary>
/// MCP-Tools für die WebUntis-Abgleiche (Nutzer-Nachtrag zum Untis-Hub, siehe TODO.md), zweigleisig
/// wie vom Nutzer entschieden:
///
/// - <see cref="GetUntisAbsenceRows"/>/<see cref="ApplyUntisAbsenceStatus"/> (bevorzugter Weg): eine
/// rein technische <c>row-id</c> ordnet zurück, nie ein Schülername - nur Zeilen, die WebUntis
/// selbst eindeutig über die externe Schülernummer (ENr) einem Kursmitglied zuordnet, werden
/// überhaupt gelistet (unklare, nur namensbasiert auflösbare Fälle fließen bewusst NICHT hier
/// hinein, siehe <see cref="GetUntisAbsenceRows"/>).
/// - <see cref="GetNamedUntisAbsencePattern"/> (bewusste, eng begrenzte Ausnahme von der sonst in
/// <see cref="McpToolScope"/> geltenden Regel, dass personenbezogene Verhaltens-/Anwesenheitsdaten
/// nie mit einem Namen verknüpft nach außen gehen): exponiert Name UND Fehlzeiten gemeinsam, aber
/// nur für explizit angegebene Schüler-IDs und nur nach JEDES MAL gesonderter, prominenter
/// Bestätigung ohne Sitzungsfreigabe (<c>allowSessionTrust: false</c>).
///
/// <see cref="GetUntisHubStatus"/> ergänzt beide Wege um einen Überblick, welche Abgleiche laut
/// <see cref="UntisHubService"/> überhaupt fällig sind, ohne selbst WebUntis anzufragen.
/// </summary>
public class UntisComparisonTools(
IGroupRepository groups, IStudentRepository students, IParticipationSessionRepository sessions,
IParticipationRepository participation, WebUntisIntegrationService untis, UntisHubService hub,
IMcpConfirmationService confirmation)
{
// Statuswerte, die ApplyUntisAbsenceStatus akzeptiert - dieselbe Einschränkung wie
// WebUntisLessonAbsenceRow.SelectableStatuses im interaktiven Dialog (nicht z.B. "Geschwänzt"
// oder "Suspendiert", die WebUntis hier nie meldet). Eigenständig gehalten statt der Row-Klasse
// referenziert, da Tool-Klassen unter Services/Mcp nicht von ViewModel-Klassen abhängen sollen
// (siehe GradeTools).
private static readonly AttendanceStatus[] SelectableStatuses =
[
AttendanceStatus.ExcusePending, AttendanceStatus.Excused, AttendanceStatus.Unexcused,
AttendanceStatus.Late, AttendanceStatus.LeftDuringClass, AttendanceStatus.Present,
];
// In-Memory, pro Prozesslaufzeit - eine row-id aus GetUntisAbsenceRows ist nur bis zum nächsten
// Neustart von LehrerApp gültig; danach muss der KI-Client die Liste erneut abrufen. Bewusst
// keine Ablauf-/Größenbegrenzung (siehe Nutzerdiskussion: geringe Nutzungsfrequenz, winzige
// Einträge) - ein v1-Kompromiss, kein Deployment-Risiko wie bei den ai-backend-Endpunkten.
private readonly ConcurrentDictionary<string, PendingAbsenceRow> _pendingRows = new();
private sealed record PendingAbsenceRow(Guid StudentId, Guid SessionId, Guid GroupId, DateOnly Date);
[Description("Listet die Fälligkeit der Untis-Hub-Abgleiche (Fehlzeiten je Lerngruppe, offene Stunden, Klassenbuch-/Hausaufgabenabgleich) - reine Lesefunktion aus der lokalen Fälligkeits-Historie, kein eigener WebUntis-Zugriff.")]
public List<UntisHubStatusRowDto> GetUntisHubStatus() =>
hub.GetRows().Select(r => new UntisHubStatusRowDto(
r.Kind.ToString(), r.GroupId, r.GroupName, r.DueState.ToString(), r.DueLabel,
r.LastRunAt, r.LastResultSummary)).ToList();
[Description("""
Listet Fehlzeiten-Diskrepanzen einer Lerngruppe gegenüber WebUntis in einem Zeitraum, ANONYMISIERT:
enthält keinen Schülernamen, nur eine technische row-id je Zeile (für apply_untis_absence_status).
Enthält nur Zeilen, die WebUntis über die externe Schülernummer eindeutig einem Kursmitglied zuordnen
konnte - Zeilen, die nur über den Namen auflösbar wären, fehlen hier bewusst; für die braucht es
get_named_untis_absence_pattern (Namen exponierend, gesondert bestätigungspflichtig).
""")]
public async Task<List<UntisAbsenceRowDto>> GetUntisAbsenceRows(
[Description("Lerngruppen-ID.")] Guid groupId,
[Description("Startdatum, Format YYYY-MM-DD.")] DateOnly startDate,
[Description("Enddatum, Format YYYY-MM-DD.")] DateOnly endDate,
CancellationToken ct = default)
{
var group = groups.GetById(groupId);
if (group?.WebUntisLessonId is not { } lessonId) return [];
var courseStudents = students.GetByGroup(groupId);
var byExternKey = courseStudents
.Select(s => (Student: s, Key: UntisLessonAbsenceHelper.StudentExternKey(s)))
.Where(x => x.Key is not null)
.ToDictionary(x => x.Key!.Value, x => x.Student);
var localSessions = sessions.GetByGroup(groupId)
.Where(s => s.Date >= startDate && s.Date <= endDate)
.GroupBy(s => s.Date).ToDictionary(g => g.Key, g => g.First());
var absences = await untis.GetLessonAbsencesAsync(lessonId, startDate, endDate, ct);
var rows = new List<UntisAbsenceRowDto>();
foreach (var absence in absences)
{
if (absence.ExternKey is not { } key || !byExternKey.TryGetValue(key, out var student)) continue;
if (!TryParseDate(absence.Date, out var date) || !localSessions.TryGetValue(date, out var session)) continue;
var rowId = Guid.NewGuid().ToString("N");
_pendingRows[rowId] = new PendingAbsenceRow(student.Id, session.Id, groupId, date);
var entry = participation.GetBySessionAndStudent(session.Id, student.Id);
var guess = UntisLessonAbsenceHelper.MapStatus(absence);
rows.Add(new UntisAbsenceRowDto(
rowId, date, absence.Reason ?? "", absence.AbsentMinutes,
!string.IsNullOrWhiteSpace(absence.HandledOn),
absence.ExternKey is null ? null : absence.ExternKeyInParentheses,
LocalStatusLabel(entry?.Attendance), guess.ToString()));
}
return rows;
}
[Description("Übernimmt einen Statusvorschlag für eine über get_untis_absence_rows gelieferte row-id in den lokalen Anwesenheitsstatus. Muss der Nutzer erst in einem Dialog in LehrerApp bestätigen; die Bestätigungsmeldung nennt bewusst KEINEN Schülernamen (nur Datum, Lerngruppe, Zielstatus).")]
public async Task<WriteResultDto> ApplyUntisAbsenceStatus(
[Description("row-id aus get_untis_absence_rows.")] string rowId,
[Description("Zielstatus: Present, Late, LeftDuringClass, ExcusePending, Excused oder Unexcused.")] string status,
CancellationToken ct = default)
{
if (!Enum.TryParse<AttendanceStatus>(status, out var target) || !SelectableStatuses.Contains(target))
return new WriteResultDto(false, null, "Unbekannter oder nicht zulässiger Status.");
if (!_pendingRows.TryGetValue(rowId, out var row))
return new WriteResultDto(false, null, "Unbekannte oder abgelaufene row-id - zuerst get_untis_absence_rows erneut aufrufen.");
var groupName = groups.GetById(row.GroupId)?.Name ?? "?";
var message = $"Fehlzeile vom {row.Date:dd.MM.yyyy} in Lerngruppe „{groupName}“: " +
$"Anwesenheitsstatus auf „{LocalStatusLabel(target)}“ setzen?";
if (!await confirmation.ConfirmAsync("Fehlzeiten-Status übernehmen?", message, ct))
return new WriteResultDto(false, null, "Vom Nutzer abgelehnt oder nicht bestätigt.");
var entry = participation.GetBySessionAndStudent(row.SessionId, row.StudentId)
?? new ParticipationEntry { SessionId = row.SessionId, StudentId = row.StudentId };
entry.Attendance = target;
entry.UpdatedAt = DateTime.UtcNow;
participation.Save(entry);
_pendingRows.TryRemove(rowId, out _);
return new WriteResultDto(true, entry.Id, "Status übernommen.");
}
[Description("""
Liefert Fehlzeiten für EXPLIZIT angegebene Schüler-IDs MIT Namen (z.B. für einen Bericht oder einen
Fehlmuster-Vergleich zwischen zwei Schülern) - bewusste, eng begrenzte Ausnahme von der sonst
geltenden Anonymisierung (siehe get_untis_absence_rows). So wenige studentIds wie für die Anfrage
nötig angeben, nicht den ganzen Kurs. Erfordert JEDES MAL eine gesonderte, prominente
Nutzerbestätigung ohne Sitzungsfreigabe - liefert bei Ablehnung granted:false und keine Zeilen.
""")]
public async Task<NamedUntisAbsenceResultDto> GetNamedUntisAbsencePattern(
[Description("Lerngruppen-ID.")] Guid groupId,
[Description("Ids der Schüler, für die Name UND Fehlzeiten gemeinsam offengelegt werden sollen.")] List<Guid> studentIds,
[Description("Startdatum, Format YYYY-MM-DD.")] DateOnly startDate,
[Description("Enddatum, Format YYYY-MM-DD.")] DateOnly endDate,
CancellationToken ct = default)
{
var group = groups.GetById(groupId);
if (group?.WebUntisLessonId is not { } lessonId)
return new NamedUntisAbsenceResultDto(false, "Für diese Lerngruppe ist keine WebUntis-Unterrichtsnummer hinterlegt.", null);
var resolvedStudents = studentIds.Distinct()
.Select(students.GetById).Where(s => s is not null).Cast<Student>().ToList();
if (resolvedStudents.Count == 0)
return new NamedUntisAbsenceResultDto(false, "Keine der angegebenen Schüler-IDs ist bekannt.", null);
var names = string.Join(", ", resolvedStudents.Select(s => s.FullName));
var message = $"Name UND Fehlzeiten gemeinsam an den KI-Assistenten weitergeben für:\n{names}\n\n" +
$"Zeitraum: {startDate:dd.MM.yyyy}{endDate:dd.MM.yyyy}, Lerngruppe „{group.Name}“.";
if (!await confirmation.ConfirmAsync("Namentliche Fehlzeitenauskunft freigeben?", message, ct, allowSessionTrust: false))
return new NamedUntisAbsenceResultDto(false, "Vom Nutzer abgelehnt oder nicht bestätigt.", null);
var byExternKey = resolvedStudents
.Select(s => (Student: s, Key: UntisLessonAbsenceHelper.StudentExternKey(s)))
.Where(x => x.Key is not null)
.ToDictionary(x => x.Key!.Value, x => x.Student);
var byName = resolvedStudents
.SelectMany(s => new[]
{
NameKey($"{s.LastName} {s.FirstName}"), NameKey($"{s.FirstName} {s.LastName}"),
}.Select(key => (Key: key, Student: s)))
.GroupBy(x => x.Key).Where(g => g.Select(x => x.Student).Distinct().Count() == 1)
.ToDictionary(g => g.Key, g => g.First().Student);
var localSessions = sessions.GetByGroup(groupId)
.Where(s => s.Date >= startDate && s.Date <= endDate)
.GroupBy(s => s.Date).ToDictionary(g => g.Key, g => g.First());
var absences = await untis.GetLessonAbsencesAsync(lessonId, startDate, endDate, ct);
var rows = new List<NamedUntisAbsenceRowDto>();
foreach (var absence in absences)
{
var match = absence.ExternKey is { } key && byExternKey.TryGetValue(key, out var byKeyStudent)
? byKeyStudent
: byName.GetValueOrDefault(NameKey(absence.StudentName));
if (match is null) continue; // nur die explizit freigegebenen Schüler, nie "geraten"
if (!TryParseDate(absence.Date, out var date)) continue;
var entry = localSessions.TryGetValue(date, out var session)
? participation.GetBySessionAndStudent(session.Id, match.Id) : null;
rows.Add(new NamedUntisAbsenceRowDto(
match.Id, match.FullName, date, absence.Reason ?? "",
LocalStatusLabel(entry?.Attendance), UntisLessonAbsenceHelper.MapStatus(absence).ToString()));
}
return new NamedUntisAbsenceResultDto(true, $"{rows.Count} Fehlzeile(n) für {resolvedStudents.Count} Schüler.", rows);
}
private static string NameKey(string value) => value.Trim().ToLowerInvariant();
private static bool TryParseDate(int value, out DateOnly date) =>
DateOnly.TryParseExact(value.ToString(), "yyyyMMdd", out date);
// Eigene, schlanke Beschriftung statt AttendanceDisplay (Views-/ViewModel-Bezug) - Tool-Klassen
// unter Services/Mcp sollen nicht von ViewModel-Klassen abhängen (siehe GradeTools).
private static string LocalStatusLabel(AttendanceStatus? s) => s switch
{
null => "kein Status erfasst",
AttendanceStatus.Present => "Anwesend",
AttendanceStatus.ExcusePending => "Krank (Entschuldigung offen)",
AttendanceStatus.Excused => "Krank, entschuldigt",
AttendanceStatus.Unexcused => "Krank, unentschuldigt",
AttendanceStatus.Late => "Verspätet",
AttendanceStatus.LeftDuringClass => "Während des Unterrichts abgängig",
_ => s.ToString()!,
};
}
@@ -0,0 +1,48 @@
using System.Text.Json;
namespace LehrerApp.Desktop.Services;
internal class McpSettingsConfig
{
public bool Enabled { get; set; }
}
/// <summary>
/// Opt-in-Schalter für den lokalen MCP-Server (siehe Planungsdokument, Phase 1). Anders als
/// <see cref="AiSettingsService"/> braucht Phase 1 kein Login/Token — die Named Pipe selbst ist die
/// Vertrauensgrenze (lokaler Prozess, gleiche Windows-Session bzw. Unix-Dateirechte), siehe
/// Begründung im Planungsdokument.
/// </summary>
public class McpSettingsService
{
private readonly string _configPath;
private McpSettingsConfig _config;
public bool Enabled => _config.Enabled;
public McpSettingsService(string appDataPath)
{
_configPath = Path.Combine(appDataPath, "mcp-settings.json");
_config = Load();
}
public void SetEnabled(bool enabled)
{
_config.Enabled = enabled;
Save();
}
private void Save() => File.WriteAllText(_configPath, JsonSerializer.Serialize(_config));
private McpSettingsConfig Load()
{
try
{
if (File.Exists(_configPath))
return JsonSerializer.Deserialize<McpSettingsConfig>(File.ReadAllText(_configPath))
?? new McpSettingsConfig();
}
catch { /* beschädigte Konfiguration -> Standardwert */ }
return new McpSettingsConfig();
}
}
@@ -0,0 +1,251 @@
using System.Globalization;
using LehrerApp.Core.Interfaces;
using LehrerApp.Core.Models;
using LehrerApp.Desktop.ViewModels.ClassTeacher;
using LehrerApp.Templating;
namespace LehrerApp.Desktop.Services;
public enum AttendanceCalendarSize { Small, Medium, Large }
public sealed record AttendanceCalendarOptions(DateOnly StartMonth, int MonthCount,
AttendanceCalendarSize Size = AttendanceCalendarSize.Medium)
{
public DateOnly NormalizedStartMonth => new(StartMonth.Year, StartMonth.Month, 1);
public int NormalizedMonthCount => Math.Clamp(MonthCount, 1, 3);
}
/// <summary>Erzeugt den portablen Advanced-Content-Platzhalter für Elternbriefe aus derselben
/// priorisierten Monatsansicht, die im Klassenlehrer-Sidebar-Widget verwendet wird.</summary>
public static class StudentAttendanceCalendarDrawingBuilder
{
public const string PlaceholderName = "Student.AttendanceCalendar";
public static DrawingValue Build(string studentName, DateOnly month,
IReadOnlyList<ClassAbsenceDaySummaryRow> absences,
IReadOnlyList<UntisForeignClassRegisterEventDto> registerEntries) =>
Build(studentName, new AttendanceCalendarOptions(month, 1), absences, registerEntries);
public static DrawingValue Build(string studentName, AttendanceCalendarOptions options,
IReadOnlyList<ClassAbsenceDaySummaryRow> absences,
IReadOnlyList<UntisForeignClassRegisterEventDto> registerEntries)
{
const float width = 170;
var scale = options.Size switch
{
AttendanceCalendarSize.Small => .7f,
AttendanceCalendarSize.Large => 1f,
_ => .85f,
};
// Die Breite richtet sich nach der DRAWBOX-Deklaration im Layout-Skript (fest, unabhängig
// von der Größenwahl) - nur Schrift/Zellenhöhe skalieren mit "Größe". Würde die Breite mit
// skalieren, würde "Groß" (scale=1) exakt die Skript-Box ausfüllen, "Klein"/"Normal" aber
// nur einen Teil davon - und eine größere Skalierung als 1 liefe über die Box hinaus und
// würde am rechten Rand abgeschnitten (SVG overflow="hidden").
var contentWidth = width;
var cellHeight = 10 * scale;
var monthGapX = 10f;
var first = options.NormalizedStartMonth;
var monthCount = options.NormalizedMonthCount;
var monthWidth = (contentWidth - monthGapX * (monthCount - 1)) / monthCount;
var cellWidth = monthWidth / 7;
var commands = new List<DrawingCommand>();
var y = 0f;
commands.Add(new DrawStringEx(0, y, 14 * scale, contentWidth, "Anwesenheit",
DrawingTextAlignment.AlignLeft, 11 * scale, Color: "#1F2937", Bold: true));
y += 14 * scale;
commands.Add(new DrawStringEx(0, y, 10 * scale, contentWidth, studentName,
DrawingTextAlignment.AlignLeft, 8 * scale, Color: "#6B7280"));
y += 10 * scale + 2 * scale;
var weekdays = new[] { "Mo", "Di", "Mi", "Do", "Fr", "Sa", "So" };
var gridStartY = y;
var maxWeeks = 0;
for (var monthIndex = 0; monthIndex < monthCount; monthIndex++)
{
var current = first.AddMonths(monthIndex);
var days = ClassTeacherOverviewViewModel.BuildCompactMonthDays(current, absences, registerEntries,
DateOnly.FromDateTime(DateTime.Today), studentName);
var offset = ((int)current.DayOfWeek + 6) % 7;
var weeks = (int)Math.Ceiling((offset + days.Count) / 7d);
maxWeeks = Math.Max(maxWeeks, weeks);
var xOffset = monthIndex * (monthWidth + monthGapX);
var monthY = gridStartY;
commands.Add(new DrawStringEx(xOffset, monthY, 11 * scale, monthWidth,
current.ToString("MMMM yyyy", CultureInfo.GetCultureInfo("de-DE")),
DrawingTextAlignment.AlignLeft, 9 * scale, Color: "#374151", Bold: true));
monthY += 11 * scale;
for (var column = 0; column < 7; column++)
commands.Add(new DrawStringEx(xOffset + column * cellWidth, monthY, 9 * scale, cellWidth,
weekdays[column], DrawingTextAlignment.AlignCenter, 7 * scale, Color: "#6B7280", Bold: true));
monthY += 9 * scale;
foreach (var day in days)
{
var index = offset + day.Date.Day - 1;
var column = index % 7;
var row = index / 7;
var x = xOffset + column * cellWidth;
var cellY = monthY + row * cellHeight;
commands.Add(new DrawRectangle(x + scale, cellY, cellWidth - 2 * scale, cellHeight - scale,
"#D1D5DB", .35f, day.HasSignal ? day.SignalColorHex : "#FFFFFF"));
commands.Add(new DrawStringEx(x, cellY + scale, cellHeight - 2 * scale, cellWidth,
day.HasSignal ? day.SignalCode : day.DayNumber, DrawingTextAlignment.AlignCenter, 7 * scale,
Color: day.HasSignal ? "#FFFFFF" : "#374151", Bold: day.HasSignal));
}
}
y = gridStartY + 11 * scale + 9 * scale + maxWeeks * cellHeight + monthGapX;
commands.Add(new DrawStringEx(0, y, 8 * scale, contentWidth,
"U unentschuldigt · A abwesend · V verspätet · E entschuldigt · ! Klassenbuch",
DrawingTextAlignment.AlignLeft, 6.5f * scale, Color: "#6B7280"));
return new DrawingValue(commands, y + 9 * scale);
}
}
/// <summary>Chronologische, portable Fehlzeitenliste für Elternbriefvorlagen.</summary>
public static class StudentAbsenceDayListDrawingBuilder
{
public const string PlaceholderName = "Student.AbsenceDays";
public static DrawingValue Build(string studentName, AttendanceCalendarOptions options,
IReadOnlyList<ClassAbsenceDaySummaryRow> absences)
{
const float width = 170;
var scale = options.Size switch
{
AttendanceCalendarSize.Small => .75f,
AttendanceCalendarSize.Large => 1f,
_ => .88f,
};
// Breite bleibt an die DRAWBOX-Deklaration im Layout-Skript gebunden (siehe
// StudentAttendanceCalendarDrawingBuilder) - nur Zeilenhöhe/Schrift skalieren mit "Größe".
var contentWidth = width;
var rowHeight = 12 * scale;
var start = options.NormalizedStartMonth;
var end = start.AddMonths(options.NormalizedMonthCount).AddDays(-1);
var rows = absences
.Where(a => a.Date >= start && a.Date <= end &&
UntisNameMatching.NamesMatch(a.StudentName, studentName))
.OrderBy(a => a.Date)
.ToList();
var commands = new List<DrawingCommand>();
var y = 0f;
commands.Add(new DrawStringEx(0, y, 14 * scale, contentWidth, "Fehltage",
DrawingTextAlignment.AlignLeft, 11 * scale, Color: "#1F2937", Bold: true));
y += 14 * scale;
commands.Add(new DrawStringEx(0, y, 10 * scale, contentWidth, studentName,
DrawingTextAlignment.AlignLeft, 8 * scale, Color: "#6B7280"));
y += 10 * scale + 2 * scale;
// Spaltenbreiten so gewählt, dass auch die Datenzeilen (nicht nur die kurzen Kopfzeilen-
// Labels) hineinpassen - "Datum" als Kopfzeile ist kürzer als "dd.MM.yyyy" und wurde bei
// 31 zu schmal bemessen, wodurch das Datum am rechten Rand abgeschnitten wurde.
const float dateColumnX = 2, dateColumnWidth = 38;
const float extentColumnX = 44, extentColumnWidth = 66;
const float statusColumnX = 114, statusColumnWidth = 54;
commands.Add(new DrawRectangle(0, y, contentWidth, rowHeight, "#CBD5E1", .4f, "#F3F4F6"));
commands.Add(new DrawStringEx(dateColumnX, y + scale, rowHeight - scale, dateColumnWidth, "Datum",
DrawingTextAlignment.AlignLeft, 7.5f * scale, Color: "#374151", Bold: true));
commands.Add(new DrawStringEx(extentColumnX, y + scale, rowHeight - scale, extentColumnWidth, "Umfang",
DrawingTextAlignment.AlignLeft, 7.5f * scale, Color: "#374151", Bold: true));
commands.Add(new DrawStringEx(statusColumnX, y + scale, rowHeight - scale, statusColumnWidth, "Status",
DrawingTextAlignment.AlignLeft, 7.5f * scale, Color: "#374151", Bold: true));
y += rowHeight;
if (rows.Count == 0)
{
commands.Add(new DrawStringEx(2, y + 2 * scale, rowHeight, contentWidth - 4,
"Keine Fehltage im gewählten Zeitraum", DrawingTextAlignment.AlignLeft, 8 * scale,
Color: "#6B7280", Italic: true));
y += rowHeight + 3 * scale;
}
else
{
for (var index = 0; index < rows.Count; index++)
{
var row = rows[index];
var fill = index % 2 == 0 ? "#FFFFFF" : "#F9FAFB";
var extent = row.CountsAsFullDay
? "Ganzer Fehltag"
: row.TotalAbsentPeriods > 0
? $"Fehlzeit · {row.TotalAbsentPeriods} Std."
: $"Fehlzeit · {row.TotalAbsentMinutes} Min.";
var status = row.IsUnexcused
? "Unentschuldigt"
: row.FriendlyStatusLabel.Contains("Entschuldigt", StringComparison.OrdinalIgnoreCase)
? "Entschuldigt"
: row.FriendlyStatusLabel;
var statusColor = row.IsUnexcused ? "#C62828" : "#2E7D32";
commands.Add(new DrawRectangle(0, y, contentWidth, rowHeight, "#E5E7EB", .3f, fill));
commands.Add(new DrawStringEx(dateColumnX, y + scale, rowHeight - scale, dateColumnWidth,
row.Date.ToString("dd.MM.yyyy"), DrawingTextAlignment.AlignLeft, 7.5f * scale,
Color: "#374151"));
commands.Add(new DrawStringEx(extentColumnX, y + scale, rowHeight - scale, extentColumnWidth,
extent, DrawingTextAlignment.AlignLeft, 7.5f * scale, Color: "#374151"));
commands.Add(new DrawStringEx(statusColumnX, y + scale, rowHeight - scale, statusColumnWidth,
status, DrawingTextAlignment.AlignLeft, 7.5f * scale, Color: statusColor,
Bold: row.IsUnexcused));
y += rowHeight;
}
}
return new DrawingValue(commands, y);
}
}
/// <summary>Liest ausschließlich den lokalen WebUntis-Cache. Das Öffnen oder Rendern eines
/// Elternbriefs löst dadurch keinen unerwarteten Netzwerkabruf aus.</summary>
public sealed class StudentAttendanceCalendarService(
WebUntisSettingsService settings,
IUntisAbsenceCacheRepository absenceCache,
IUntisClassRegisterCacheRepository registerCache,
IUntisStudentRosterCacheRepository rosterCache)
{
public DrawingValue Build(Student student, DateOnly month) =>
Build(student, new AttendanceCalendarOptions(month, 1));
public DrawingValue Build(Student student, AttendanceCalendarOptions options)
{
var className = settings.HomeroomClassName;
if (string.IsNullOrWhiteSpace(className)) return new DrawingValue([], 0);
var first = options.NormalizedStartMonth;
var last = first.AddMonths(options.NormalizedMonthCount).AddDays(-1);
var matchName = $"{student.FirstName} {student.LastName}";
var rosterName = rosterCache.GetByClass(className)
.FirstOrDefault(r => UntisNameMatching.NamesMatch(r.DisplayName, matchName))?.DisplayName
?? matchName;
var start = first.Year * 10000 + first.Month * 100 + first.Day;
var end = last.Year * 10000 + last.Month * 100 + last.Day;
var absences = absenceCache.GetByClassAndRange(className, start, end)
.Select(e => new UntisClassAbsenceEntryDto(e.StudentName, e.ExternKey, e.ClassName, e.Date,
e.AbsentPeriods, e.AbsentMinutes, e.TeacherUsernames, e.Subject, e.AbsenceReason, e.Note,
e.EntryId, e.HandledOn, e.Counts, e.ExcuseNote, e.PeriodNumber, e.Status, e.CountsAsFullDay))
.ToList();
var register = registerCache.GetByClassAndRange(className, start, end)
.Select(e => new UntisForeignClassRegisterEventDto(e.ClassName, e.Date, e.Subject, e.StudentName,
e.TeacherUsername, e.CategoryName, e.CategoryGroup, e.Text))
.ToList();
return StudentAttendanceCalendarDrawingBuilder.Build(rosterName, options,
ClassAbsenceDaySummaryRow.GroupByStudentAndDay(absences), register);
}
public DrawingValue BuildAbsenceDayList(Student student, AttendanceCalendarOptions options)
{
var className = settings.HomeroomClassName;
if (string.IsNullOrWhiteSpace(className)) return new DrawingValue([], 0);
var first = options.NormalizedStartMonth;
var last = first.AddMonths(options.NormalizedMonthCount).AddDays(-1);
var matchName = $"{student.FirstName} {student.LastName}";
var rosterName = rosterCache.GetByClass(className)
.FirstOrDefault(r => UntisNameMatching.NamesMatch(r.DisplayName, matchName))?.DisplayName
?? matchName;
var start = first.Year * 10000 + first.Month * 100 + first.Day;
var end = last.Year * 10000 + last.Month * 100 + last.Day;
var absences = absenceCache.GetByClassAndRange(className, start, end)
.Select(e => new UntisClassAbsenceEntryDto(e.StudentName, e.ExternKey, e.ClassName, e.Date,
e.AbsentPeriods, e.AbsentMinutes, e.TeacherUsernames, e.Subject, e.AbsenceReason, e.Note,
e.EntryId, e.HandledOn, e.Counts, e.ExcuseNote, e.PeriodNumber, e.Status, e.CountsAsFullDay));
return StudentAbsenceDayListDrawingBuilder.Build(rosterName, options,
ClassAbsenceDaySummaryRow.GroupByStudentAndDay(absences));
}
}
@@ -25,7 +25,9 @@ public static class UntisHubActions
App.Services.GetRequiredService<WebUntisIntegrationService>(), App.Services.GetRequiredService<WebUntisIntegrationService>(),
App.Services.GetRequiredService<IStudentRepository>(), App.Services.GetRequiredService<IStudentRepository>(),
App.Services.GetRequiredService<IParticipationSessionRepository>(), App.Services.GetRequiredService<IParticipationSessionRepository>(),
App.Services.GetRequiredService<IParticipationRepository>()) App.Services.GetRequiredService<IParticipationRepository>(),
App.Services.GetRequiredService<AiPlanningService>(),
App.Services.GetRequiredService<AiSettingsService>())
{ StartDate = start.ToDateTime(TimeOnly.MinValue), EndDate = end.ToDateTime(TimeOnly.MinValue) }; { StartDate = start.ToDateTime(TimeOnly.MinValue), EndDate = end.ToDateTime(TimeOnly.MinValue) };
var loaded = TrackLoad(vm, v => v.Busy); var loaded = TrackLoad(vm, v => v.Busy);
await new WebUntisLessonAbsenceComparisonDialog { DataContext = vm }.ShowDialog(owner); await new WebUntisLessonAbsenceComparisonDialog { DataContext = vm }.ShowDialog(owner);
+22 -4
View File
@@ -40,17 +40,29 @@ public sealed class UntisHubService(
public List<UntisHubJobRow> GetRows() public List<UntisHubJobRow> GetRows()
{ {
var eligibleGroups = groups.GetBySchoolYear(schoolYears.CurrentSchoolYear()) var eligibleGroups = groups.GetBySchoolYear(schoolYears.CurrentSchoolYear())
.Where(g => g.WebUntisLessonId is not null) .Where(g => g.WebUntisLessonId is not null && !g.ExcludedFromUntisHub)
.OrderBy(g => g.Name) .OrderBy(g => g.Name)
.ToList(); .ToList();
return BuildRows(eligibleGroups, jobStates.GetAll(), DateTime.UtcNow); return BuildRows(eligibleGroups, jobStates.GetAll(), DateTime.UtcNow);
} }
public void RecordRun(UntisHubJobKind kind, Guid? groupId, string? summary) => /// <summary>Der Langzeit-Fehlzeitenabgleich deckt seit Schuljahresbeginn ein Zeitfenster ab, das
/// das kurzfristige vollständig einschließt - ohne das hier mitzuziehen bliebe die kurzfristige
/// Kadenz trotz erledigtem Langzeit-Abgleich als fällig stehen (Nutzer-Feedback). Umgekehrt deckt
/// ein Kurz-Lauf das lange Fenster nicht ab, bleibt also einseitig.
public void RecordRun(UntisHubJobKind kind, Guid? groupId, string? summary)
{
var now = DateTime.UtcNow;
Save(kind, groupId, now, summary);
if (kind == UntisHubJobKind.FehlzeitenLang)
Save(UntisHubJobKind.FehlzeitenKurz, groupId, now, "durch Abgleich seit Schuljahresbeginn mit erledigt");
}
private void Save(UntisHubJobKind kind, Guid? groupId, DateTime at, string? summary) =>
jobStates.Save(new UntisHubJobState jobStates.Save(new UntisHubJobState
{ {
Id = jobStates.Get(kind, groupId)?.Id ?? Guid.NewGuid(), Id = jobStates.Get(kind, groupId)?.Id ?? Guid.NewGuid(),
Kind = kind, GroupId = groupId, LastRunAt = DateTime.UtcNow, LastResultSummary = summary, Kind = kind, GroupId = groupId, LastRunAt = at, LastResultSummary = summary,
}); });
/// Reine Entscheidungslogik ohne Repository-Zugriff (gleiches Muster wie /// Reine Entscheidungslogik ohne Repository-Zugriff (gleiches Muster wie
@@ -60,8 +72,14 @@ public sealed class UntisHubService(
public static List<UntisHubJobRow> BuildRows( public static List<UntisHubJobRow> BuildRows(
IReadOnlyList<LearningGroup> eligibleGroups, IReadOnlyList<UntisHubJobState> states, DateTime utcNow) IReadOnlyList<LearningGroup> eligibleGroups, IReadOnlyList<UntisHubJobState> states, DateTime utcNow)
{ {
// Nach Sync-Aktivierung von UntisHubJobState (siehe TODO.md) können zwei Geräte, die
// denselben, noch nie gelaufenen Job unabhängig voneinander zum ersten Mal ausführen,
// bevor sie sich gegenseitig gesehen haben, kurzzeitig zwei Datensätze für dasselbe
// (Kind, GroupId) anlegen - hier den zuletzt gelaufenen wählen statt einen beliebigen.
UntisHubJobState? State(UntisHubJobKind kind, Guid? groupId) => UntisHubJobState? State(UntisHubJobKind kind, Guid? groupId) =>
states.FirstOrDefault(s => s.Kind == kind && s.GroupId == groupId); states.Where(s => s.Kind == kind && s.GroupId == groupId)
.OrderByDescending(s => s.LastRunAt)
.FirstOrDefault();
var rows = new List<UntisHubJobRow>(); var rows = new List<UntisHubJobRow>();
foreach (var group in eligibleGroups) foreach (var group in eligibleGroups)
@@ -0,0 +1,44 @@
using LehrerApp.Core.Importing;
using LehrerApp.Core.Models;
namespace LehrerApp.Desktop.Services;
/// <summary>
/// Aus <c>WebUntisLessonAbsenceComparisonViewModel</c> herausgelöst (Nachtrag: die MCP-Tools in
/// <c>UntisComparisonTools</c> brauchen exakt dieselbe Regel für dieselbe Fehlzeile, siehe TODO.md) -
/// framework-frei (kein ObservableObject/Avalonia-Bezug), damit beide Aufrufer ohne Duplikat
/// garantiert denselben Statusvorschlag berechnen. Eine Abweichung zwischen Dialog und MCP-Tool für
/// dieselbe WebUntis-Zeile wäre verwirrender als die eine zusätzliche Indirektion hier.
/// </summary>
public static class UntisLessonAbsenceHelper
{
private const int FullLessonMinutes = 45;
// Der Bericht liefert keinen Entschuldigungstext, nur Minutenwerte, ein Bearbeitet-Datum und die
// (laut Schule) über Klammerung der ENr codierte Entscheidung des Klassenlehrers - ENr in
// Klammern bedeutet unentschuldigt, ohne Klammern abgeschlossen/entschuldigt. Reihenfolge ist
// wichtig: "nach Hause entlassen" zählt immer als vorzeitige Entlassung, unabhängig von der
// Dauer; darunter zählt jede Fehlzeit unter einer vollen Stunde (45 Min.) immer als Verspätung
// oder sonstiger Teilverlust, nie als komplette Abwesenheit - der Text "Verspätung" allein ist
// laut Schule nicht zuverlässig genug, deshalb primär über die Minutenschwelle erkannt.
public static AttendanceStatus MapStatus(UntisLessonAbsenceDto absence)
{
if (IsEarlyRelease(absence)) return AttendanceStatus.LeftDuringClass;
if (absence.AbsentMinutes < FullLessonMinutes) return AttendanceStatus.Late;
if (string.IsNullOrWhiteSpace(absence.HandledOn)) return AttendanceStatus.ExcusePending;
if (absence.ExternKey is null) return AttendanceStatus.ExcusePending;
return absence.ExternKeyInParentheses ? AttendanceStatus.Unexcused : AttendanceStatus.Excused;
}
private static bool IsEarlyRelease(UntisLessonAbsenceDto absence) =>
absence.Reason?.Contains("entlassen", StringComparison.OrdinalIgnoreCase) == true;
/// Erste Wahl zum Zuordnen einer WebUntis-Fehlzeile: die externe Schülernummer (ENr), sofern der
/// Schüler eine hat (z.B. nicht bei manuell statt per WebUntis-Import angelegten Schülern).
public static int? StudentExternKey(Student student)
{
student.ExternalIds ??= [];
return student.ExternalIds.TryGetValue(StudentImportFormats.MasterDataCsv.Value, out var value)
&& int.TryParse(value, out var key) ? key : null;
}
}
@@ -65,7 +65,7 @@ public class UntisSyncService : IDisposable
TimeSpan.FromMinutes(PollIntervalMinutes), TimeSpan.FromMinutes(PollIntervalMinutes)); TimeSpan.FromMinutes(PollIntervalMinutes), TimeSpan.FromMinutes(PollIntervalMinutes));
} }
public async Task PollAsync() public async Task PollAsync(CancellationToken cancellationToken = default)
{ {
if (!await _gate.WaitAsync(0).ConfigureAwait(false)) return; if (!await _gate.WaitAsync(0).ConfigureAwait(false)) return;
try try
@@ -76,7 +76,7 @@ public class UntisSyncService : IDisposable
string icsText; string icsText;
try try
{ {
icsText = await _http.GetStringAsync(url).ConfigureAwait(false); icsText = await _http.GetStringAsync(url, cancellationToken).ConfigureAwait(false);
} }
catch (Exception ex) catch (Exception ex)
{ {
@@ -0,0 +1,106 @@
using CommunityToolkit.Mvvm.ComponentModel;
using System.Windows.Input;
namespace LehrerApp.Desktop.ViewModels;
/// <summary>Die sieben Dashboard-Kacheln, die tatsächlich "wo muss ich reagieren" beantworten
/// (siehe <see cref="DashboardViewModel.AttentionCount"/>), zusammengefasst zu einer Karte mit
/// Filter-Chips statt sieben eigenen Sichtbarkeits-Schaltern. Reihenfolge hier ist die Reihenfolge
/// der Gruppen in der zusammengefassten Ansicht (siehe DashboardViewModel.RebuildAttention) — sie
/// übernimmt bewusst die frühere Sortierung aus DashboardSettingsService.DefaultCardOrder, damit
/// sich nichts, was Nutzer bereits kennen, unvorhersehbar umsortiert.
/// "Klausurwochen" (examload) bleibt bewusst außen vor: die Einträge dort sind Wochen-Aggregate,
/// keine Einzelvorgänge, die sich abhaken oder anklicken lassen — sie passen nicht ins Item-Modell.</summary>
public enum AttentionKind { MissingTeachingTime, Excuse, Correction, Unplanned, Alert, Attendance, SupportPlan }
/// <summary>Eine Inline-Aktion auf einem Handlungsbedarf-Eintrag (z.B. "Entschuldigt"/"Unentschuldigt",
/// "Erfassen"). Verwendet ICommand statt Action, damit die bereits vorhandenen generierten
/// RelayCommands (OpenExcuseItem.MarkExcusedCommand, DashboardViewModel.AddMissingTeachingTimeCommand)
/// direkt weiterverwendet werden können, statt sie in einen zweiten Delegate-Typ zu verpacken.</summary>
public sealed record AttentionAction(string Label, ICommand Command, object? Parameter = null);
/// <summary>Gemeinsame Projektion für die sieben Handlungsbedarf-Arten. Trägt nur Anzeigedaten —
/// die eigentliche Beschaffung bleibt unverändert in den jeweiligen DashboardViewModel.LoadXxx-
/// Methoden; sie erzeugen am Ende ein AttentionItem statt (wie vorher) ihre eigene Item-Klasse in
/// ihre eigene ObservableCollection einzutragen.</summary>
public sealed class AttentionItem
{
public AttentionKind Kind { get; }
public string Title { get; }
public string Subtitle { get; }
/// <summary>Kurztext rechts neben dem Titel — Fehlzeitenquote, Alert-Kind-Label. Meist leer.</summary>
public string TrailingText { get; }
public string DateDisplay { get; }
public bool IsOverdue { get; }
public AlertSeverity? Severity { get; }
/// <summary>Nur bei Korrekturen gesetzt (0100); steuert die ProgressBar. Eigenes HasProgress statt
/// Nullability, damit die ProgressBar-Bindung ein einfaches int (kompatibel mit Value:double) bleibt.</summary>
public int ProgressPercent { get; }
public bool HasProgress { get; }
/// <summary>Text unter der ProgressBar, z.B. "3 von 5 Arbeiten bewertet". Nur bei Korrekturen gesetzt.</summary>
public string ProgressLabel { get; }
/// <summary>Färbt TrailingText wie die frühere Fehlzeiten-Warnung (fest "Foreground=Red") ein —
/// nur bei Attendance gesetzt, weil ein AttentionItem dieser Art per Konstruktion nur entsteht,
/// wenn die Fehlzeitenquote den Schwellenwert bereits überschreitet.</summary>
public bool IsWarningTrailing { get; }
public IReadOnlyList<AttentionAction> Actions { get; }
public Action? Navigate { get; }
public AttentionItem(AttentionKind kind, string title, string subtitle = "", string trailingText = "",
string dateDisplay = "", bool isOverdue = false, AlertSeverity? severity = null,
int progressPercent = 0, string progressLabel = "", bool hasProgress = false,
bool isWarningTrailing = false, IReadOnlyList<AttentionAction>? actions = null, Action? navigate = null)
{
Kind = kind;
Title = title;
Subtitle = subtitle;
TrailingText = trailingText;
DateDisplay = dateDisplay;
IsOverdue = isOverdue;
Severity = severity;
ProgressPercent = progressPercent;
ProgressLabel = progressLabel;
HasProgress = hasProgress;
IsWarningTrailing = isWarningTrailing;
Actions = actions ?? [];
Navigate = navigate;
}
public bool HasSubtitle => !string.IsNullOrWhiteSpace(Subtitle);
public bool HasTrailingText => !string.IsNullOrWhiteSpace(TrailingText);
public bool HasDate => !string.IsNullOrWhiteSpace(DateDisplay);
public bool HasActions => Actions.Count > 0;
public bool HasSeverity => Severity is not null;
public bool IsHighSeverity => Severity == AlertSeverity.High;
public bool IsMediumSeverity => Severity == AlertSeverity.Medium;
}
/// <summary>Eine Kind-Gruppe innerhalb der zusammengefassten Handlungsbedarf-Liste. Items behalten
/// die Sortierung, die ihre jeweilige LoadXxx-Methode schon immer produziert hat (z.B. Fehlzeiten
/// nach Quote absteigend, Korrekturen nach Datum) — ein zweites, generisches "Importance"-Sortieren
/// quer über alle Arten würde diese bewusst gewählten Reihenfolgen wieder zerstören.</summary>
public sealed class AttentionGroup(AttentionKind kind, string header, IReadOnlyList<AttentionItem> items)
{
public AttentionKind Kind { get; } = kind;
public string Header { get; } = header;
public IReadOnlyList<AttentionItem> Items { get; } = items;
public int Count => Items.Count;
}
/// <summary>Ein Filter-Chip für eine Handlungsbedarf-Art in der zusammengefassten Karte — ersetzt
/// die frühere Sichtbarkeit je Einzelkachel (siehe DashboardViewModel.LoadAttentionFilters).</summary>
public partial class AttentionFilterOption : ObservableObject
{
[ObservableProperty] private bool _isActive = true;
public AttentionKind Kind { get; }
public string Label { get; }
public Action? OnChanged { get; set; }
public AttentionFilterOption(AttentionKind kind, string label)
{
Kind = kind;
Label = label;
}
partial void OnIsActiveChanged(bool value) => OnChanged?.Invoke();
}
@@ -18,6 +18,10 @@ public enum ClassTeacherStatusKind { Ok, Info, Warning, Danger }
/// Schlagwort gezielt dämpfen kann, siehe <see cref="ClassTeacherRosterRow.MatchDomains"/>.</summary> /// Schlagwort gezielt dämpfen kann, siehe <see cref="ClassTeacherRosterRow.MatchDomains"/>.</summary>
public enum VorgangScoreDomain { Attendance, Lateness, Classbook } public enum VorgangScoreDomain { Attendance, Lateness, Classbook }
/// <summary>Auswahlbereich des kompakten Monatskalenders. Ein leerer Schülername steht für die
/// bisherige Klassen-Gesamtansicht.</summary>
public sealed record ClassTeacherCalendarScope(string? StudentName, string DisplayName);
public sealed record ClassTeacherRosterRow(string StudentName, int? ExternKey, bool HasAbsenceToday, public sealed record ClassTeacherRosterRow(string StudentName, int? ExternKey, bool HasAbsenceToday,
string? AbsenceTooltip, bool HasRecentClassRegisterEntry) string? AbsenceTooltip, bool HasRecentClassRegisterEntry)
{ {
@@ -424,6 +428,7 @@ public partial class ClassTeacherOverviewViewModel : ObservableObject
public ObservableCollection<ClassTeacherPatternNotice> PatternNotices { get; } = []; public ObservableCollection<ClassTeacherPatternNotice> PatternNotices { get; } = [];
public ObservableCollection<ClassTeacherOpenExcuseRow> OpenExcuses { get; } = []; public ObservableCollection<ClassTeacherOpenExcuseRow> OpenExcuses { get; } = [];
public ObservableCollection<ClassTeacherCompactCalendarDay> CompactMonthDays { get; } = []; public ObservableCollection<ClassTeacherCompactCalendarDay> CompactMonthDays { get; } = [];
public ObservableCollection<ClassTeacherCalendarScope> CalendarScopes { get; } = [];
[ObservableProperty] private string? _homeroomClassName; [ObservableProperty] private string? _homeroomClassName;
[ObservableProperty] private int _activeTabIndex; [ObservableProperty] private int _activeTabIndex;
@@ -453,6 +458,7 @@ public partial class ClassTeacherOverviewViewModel : ObservableObject
/// den "Klassenbuch öffnen"-Button gehängt statt in einer eigenen Kennzahlkarte. /// den "Klassenbuch öffnen"-Button gehängt statt in einer eigenen Kennzahlkarte.
[ObservableProperty] private int _ownDocumentationFollowUpCount; [ObservableProperty] private int _ownDocumentationFollowUpCount;
[ObservableProperty] private int _ownDocumentationCriticalCount; [ObservableProperty] private int _ownDocumentationCriticalCount;
[ObservableProperty] private ClassTeacherCalendarScope? _selectedCalendarScope;
// Zuletzt per Load() geholte WebUntis-Rohdaten, für RefreshFromLocalDataOnly() - damit ein // Zuletzt per Load() geholte WebUntis-Rohdaten, für RefreshFromLocalDataOnly() - damit ein
// eingehendes Sync-Ereignis die Ansicht neu aufbauen kann, ohne selbst WebUntis anzufragen. // eingehendes Sync-Ereignis die Ansicht neu aufbauen kann, ohne selbst WebUntis anzufragen.
@@ -501,8 +507,17 @@ public partial class ClassTeacherOverviewViewModel : ObservableObject
public double UnexcusedAbsenceFraction => StudentCount == 0 ? 0 : (double)UnexcusedAbsenceCount / StudentCount; public double UnexcusedAbsenceFraction => StudentCount == 0 ? 0 : (double)UnexcusedAbsenceCount / StudentCount;
public string DayOverviewTooltip => $"{PresentCount} anwesend · {LateCount} verspätet · " + public string DayOverviewTooltip => $"{PresentCount} anwesend · {LateCount} verspätet · " +
$"{ExcusedAbsenceCount} entschuldigt · {UnexcusedAbsenceCount} unentschuldigt"; $"{ExcusedAbsenceCount} entschuldigt · {UnexcusedAbsenceCount} unentschuldigt";
public string CompactMonthLabel => DateOnly.FromDateTime(DateTime.Today).ToString("MMMM yyyy", public string CompactMonthLabel
System.Globalization.CultureInfo.GetCultureInfo("de-DE")); {
get
{
var month = DateOnly.FromDateTime(DateTime.Today).ToString("MMMM yyyy",
System.Globalization.CultureInfo.GetCultureInfo("de-DE"));
return SelectedCalendarScope?.StudentName is { Length: > 0 }
? $"{month} · {SelectedCalendarScope.DisplayName}"
: $"{month} · gesamte Klasse";
}
}
public Func<Task>? OnNavigateToSettings { get; set; } public Func<Task>? OnNavigateToSettings { get; set; }
public Func<Task>? OnNavigateToWorkload { get; set; } public Func<Task>? OnNavigateToWorkload { get; set; }
@@ -534,6 +549,11 @@ public partial class ClassTeacherOverviewViewModel : ObservableObject
partial void OnOpenExcuseOverflowCountChanged(int value) => OnPropertyChanged(nameof(HasOpenExcuseOverflow)); partial void OnOpenExcuseOverflowCountChanged(int value) => OnPropertyChanged(nameof(HasOpenExcuseOverflow));
partial void OnOwnDocumentationFollowUpCountChanged(int value) => OnPropertyChanged(nameof(HasOwnDocumentationAlerts)); partial void OnOwnDocumentationFollowUpCountChanged(int value) => OnPropertyChanged(nameof(HasOwnDocumentationAlerts));
partial void OnOwnDocumentationCriticalCountChanged(int value) => OnPropertyChanged(nameof(HasOwnDocumentationAlerts)); partial void OnOwnDocumentationCriticalCountChanged(int value) => OnPropertyChanged(nameof(HasOwnDocumentationAlerts));
partial void OnSelectedCalendarScopeChanged(ClassTeacherCalendarScope? value)
{
OnPropertyChanged(nameof(CompactMonthLabel));
if (CalendarScopes.Count > 0) RebuildCompactMonthFromCachedData();
}
partial void OnSearchTextChanged(string value) => ApplyRosterFilter(); partial void OnSearchTextChanged(string value) => ApplyRosterFilter();
partial void OnSelectedRosterFilterChanged(int value) partial void OnSelectedRosterFilterChanged(int value)
{ {
@@ -548,7 +568,8 @@ public partial class ClassTeacherOverviewViewModel : ObservableObject
{ {
ActiveTabIndex = 0; ActiveTabIndex = 0;
Roster.Clear(); PrimaryRoster.Clear(); SecondaryRoster.Clear(); TrendDays.Clear(); PatternNotices.Clear(); Roster.Clear(); PrimaryRoster.Clear(); SecondaryRoster.Clear(); TrendDays.Clear(); PatternNotices.Clear();
OpenExcuses.Clear(); CompactMonthDays.Clear(); OpenExcuseOverflowCount = 0; OpenExcuses.Clear(); CompactMonthDays.Clear(); CalendarScopes.Clear(); SelectedCalendarScope = null;
OpenExcuseOverflowCount = 0;
} }
// Wird beim Navigieren auf diese Seite aufgerufen statt LoadCommand: baut nur den lokalen // Wird beim Navigieren auf diese Seite aufgerufen statt LoadCommand: baut nur den lokalen
@@ -678,6 +699,12 @@ public partial class ClassTeacherOverviewViewModel : ObservableObject
holidayWeekdaysExcluded, termStart, classRegisterEvents, holidayWeekdaysExcluded, termStart, classRegisterEvents,
_patternScoreSettings.Load(), closedVorgaengeByNameKey)) Roster.Add(row); _patternScoreSettings.Load(), closedVorgaengeByNameKey)) Roster.Add(row);
CalendarScopes.Add(new ClassTeacherCalendarScope(null, "Gesamte Klasse"));
foreach (var student in students.OrderBy(s => s.DisplayName))
CalendarScopes.Add(new ClassTeacherCalendarScope(student.DisplayName,
ClassTeacherOverviewViewModel.DisplayStudentName(student.DisplayName)));
SelectedCalendarScope = CalendarScopes[0];
StudentCount = Roster.Count; StudentCount = Roster.Count;
TodayAlertCount = Roster.Count(r => r.HasAbsenceToday); TodayAlertCount = Roster.Count(r => r.HasAbsenceToday);
TodayUnexcusedCount = Roster.Count(r => r.IsUnexcused); TodayUnexcusedCount = Roster.Count(r => r.IsUnexcused);
@@ -724,7 +751,7 @@ public partial class ClassTeacherOverviewViewModel : ObservableObject
private void OpenMonthlyCalendar() private void OpenMonthlyCalendar()
{ {
var today = DateOnly.FromDateTime(DateTime.Today); var today = DateOnly.FromDateTime(DateTime.Today);
DetailsTab.StudentFilter = ""; DetailsTab.StudentFilter = SelectedCalendarScope?.StudentName ?? "";
DetailsTab.CalendarMonth = new DateOnly(today.Year, today.Month, 1); DetailsTab.CalendarMonth = new DateOnly(today.Year, today.Month, 1);
ActiveTabIndex = 2; ActiveTabIndex = 2;
DetailsTab.ShowCalendarCommand.Execute(null); DetailsTab.ShowCalendarCommand.Execute(null);
@@ -735,15 +762,29 @@ public partial class ClassTeacherOverviewViewModel : ObservableObject
{ {
CompactMonthDays.Clear(); CompactMonthDays.Clear();
foreach (var day in BuildCompactMonthDays(new DateOnly(today.Year, today.Month, 1), foreach (var day in BuildCompactMonthDays(new DateOnly(today.Year, today.Month, 1),
absences, registerEntries, today)) absences, registerEntries, today, SelectedCalendarScope?.StudentName))
CompactMonthDays.Add(day); CompactMonthDays.Add(day);
} }
private void RebuildCompactMonthFromCachedData()
{
if (_lastAbsences is null || _lastClassRegisterEvents is null) return;
var today = DateOnly.FromDateTime(DateTime.Today);
BuildCompactMonth(ClassAbsenceDaySummaryRow.GroupByStudentAndDay(_lastAbsences),
_lastClassRegisterEvents, today);
}
public static IReadOnlyList<ClassTeacherCompactCalendarDay> BuildCompactMonthDays(DateOnly month, public static IReadOnlyList<ClassTeacherCompactCalendarDay> BuildCompactMonthDays(DateOnly month,
IReadOnlyList<ClassAbsenceDaySummaryRow> absences, IReadOnlyList<ClassAbsenceDaySummaryRow> absences,
IReadOnlyList<UntisForeignClassRegisterEventDto> registerEntries, DateOnly today) IReadOnlyList<UntisForeignClassRegisterEventDto> registerEntries, DateOnly today,
string? studentName = null)
{ {
var first = new DateOnly(month.Year, month.Month, 1); var first = new DateOnly(month.Year, month.Month, 1);
if (!string.IsNullOrWhiteSpace(studentName))
{
absences = absences.Where(a => UntisNameMatching.NamesMatch(a.StudentName, studentName)).ToList();
registerEntries = registerEntries.Where(e => UntisNameMatching.NamesMatch(e.StudentName, studentName)).ToList();
}
var registerRows = registerEntries var registerRows = registerEntries
.Select(e => (Entry: e, Valid: TryDate(e.Date, out var date), Date: date)) .Select(e => (Entry: e, Valid: TryDate(e.Date, out var date), Date: date))
.Where(x => x.Valid && x.Date.Year == first.Year && x.Date.Month == first.Month) .Where(x => x.Valid && x.Date.Year == first.Year && x.Date.Month == first.Month)
@@ -768,6 +809,12 @@ public partial class ClassTeacherOverviewViewModel : ObservableObject
return result; return result;
} }
internal static string DisplayStudentName(string value)
{
var parts = value.Split(' ', StringSplitOptions.RemoveEmptyEntries);
return parts.Length < 2 ? value : string.Join(" ", parts.Skip(1).Append(parts[0]));
}
private static int SignalPriority(ClassTeacherCalendarEventKind kind) => kind switch private static int SignalPriority(ClassTeacherCalendarEventKind kind) => kind switch
{ {
ClassTeacherCalendarEventKind.Unexcused => 0, ClassTeacherCalendarEventKind.Unexcused => 0,
+180 -133
View File
@@ -40,6 +40,7 @@ public partial class DashboardViewModel : ObservableObject
private readonly SchoolWeatherService? _schoolWeather; private readonly SchoolWeatherService? _schoolWeather;
private readonly UntisHubService _untisHub; private readonly UntisHubService _untisHub;
private readonly WebUntisIntegrationService _webUntis; private readonly WebUntisIntegrationService _webUntis;
private readonly Func<DateTime> _now;
private const int OpenExcuseMaxAgeDays = 21; private const int OpenExcuseMaxAgeDays = 21;
private const int SupportPlanDueWithinDays = 14; private const int SupportPlanDueWithinDays = 14;
@@ -89,20 +90,43 @@ public partial class DashboardViewModel : ObservableObject
public ObservableCollection<TaskItem> OpenTasks { get; } = []; public ObservableCollection<TaskItem> OpenTasks { get; } = [];
public ObservableCollection<GroupChip> CurrentGroups { get; } = []; public ObservableCollection<GroupChip> CurrentGroups { get; } = [];
public ObservableCollection<CalendarDayCell> CalendarDays { get; } = []; public ObservableCollection<CalendarDayCell> CalendarDays { get; } = [];
public ObservableCollection<OpenExcuseItem> OpenExcuses { get; } = [];
public ObservableCollection<AttendanceWarningItem> AttendanceWarnings { get; } = [];
public ObservableCollection<ExamWeekLoadItem> ExamWeekLoads { get; } = []; public ObservableCollection<ExamWeekLoadItem> ExamWeekLoads { get; } = [];
public ObservableCollection<MissingTeachingTimeItem> MissingTeachingTimeEntries { get; } = [];
public ObservableCollection<SupportPlanDueItem> SupportPlanReviews { get; } = [];
public ObservableCollection<UpcomingDateItem> UpcomingDates { get; } = []; public ObservableCollection<UpcomingDateItem> UpcomingDates { get; } = [];
public ObservableCollection<CorrectionProgressItem> OpenCorrections { get; } = [];
public ObservableCollection<UnplannedLessonItem> UnplannedLessons { get; } = [];
public ObservableCollection<DashboardAlertItem> Alerts { get; } = [];
public ObservableCollection<CalendarEventItem> SelectedDayEvents { get; } = []; public ObservableCollection<CalendarEventItem> SelectedDayEvents { get; } = [];
public ObservableCollection<DashboardCardOption> DashboardCards { get; } = []; public ObservableCollection<DashboardCardOption> DashboardCards { get; } = [];
public ObservableCollection<DashboardWeatherWarningItem> WeatherWarnings { get; } = []; public ObservableCollection<DashboardWeatherWarningItem> WeatherWarnings { get; } = [];
// Zusammengefasste "Handlungsbedarf"-Karte (vormals sieben eigene Kacheln/Collections:
// Entschuldigungen, Fehlzeiten, Förderplan, Korrekturen, ungeplante Stunden, Auffälligkeiten,
// Unterrichtszeit-Nacherfassung — siehe AttentionItem.cs). Attention traegt nur, was nach
// Filterung (AttentionFilters) tatsaechlich angezeigt wird; die Rohdaten je Art liegen in den
// privaten _xyzItems-Feldern und werden von RebuildAttention() neu zusammengesetzt, sobald sich
// Daten oder Filter aendern — ohne die Repos erneut abzufragen.
public ObservableCollection<AttentionGroup> Attention { get; } = [];
public ObservableCollection<AttentionFilterOption> AttentionFilters { get; } = [];
public static string[] CalendarWeekdayHeaders { get; } = ["Mo", "Di", "Mi", "Do", "Fr", "Sa", "So"]; public static string[] CalendarWeekdayHeaders { get; } = ["Mo", "Di", "Mi", "Do", "Fr", "Sa", "So"];
private readonly List<OpenExcuseItem> _excuses = [];
private readonly List<AttentionItem> _attendanceItems = [];
private readonly List<AttentionItem> _supportItems = [];
private readonly List<AttentionItem> _correctionItems = [];
private readonly List<AttentionItem> _unplannedItems = [];
private readonly List<AttentionItem> _alertItems = [];
private readonly List<AttentionItem> _missingTimeItems = [];
/// Reihenfolge und Überschriften der Gruppen in der zusammengefassten Handlungsbedarf-Karte —
/// übernimmt die frühere Kachel-Reihenfolge aus DashboardSettingsService.DefaultCardOrder,
/// damit sich für Nutzer nichts unvorhersehbar umsortiert.
private static readonly (AttentionKind Kind, string Header)[] AttentionGroupOrder =
[
(AttentionKind.MissingTeachingTime, "Unterrichtszeit nacherfassen"),
(AttentionKind.Excuse, "Offene Entschuldigungen"),
(AttentionKind.Correction, "Offene Korrekturen"),
(AttentionKind.Unplanned, "Ungeplante Stunden"),
(AttentionKind.Alert, "Auffälligkeiten"),
(AttentionKind.Attendance, "Fehlzeiten-Warnung"),
(AttentionKind.SupportPlan, "Förderplan-Wiedervorlage"),
];
// Navigation-Callback wird von App.axaml.cs verdrahtet // Navigation-Callback wird von App.axaml.cs verdrahtet
public Action<Guid>? OnNavigateToGroup { get; set; } public Action<Guid>? OnNavigateToGroup { get; set; }
public Action<Guid>? OnNavigateToStudent { get; set; } public Action<Guid>? OnNavigateToStudent { get; set; }
@@ -125,21 +149,15 @@ public partial class DashboardViewModel : ObservableObject
public DashboardCardOption TodayCard => Card("today"); public DashboardCardOption TodayCard => Card("today");
public DashboardCardOption TasksCard => Card("tasks"); public DashboardCardOption TasksCard => Card("tasks");
public DashboardCardOption CalendarCard => Card("calendar"); public DashboardCardOption CalendarCard => Card("calendar");
public DashboardCardOption ExcusesCard => Card("excuses");
public DashboardCardOption UpcomingCard => Card("upcoming"); public DashboardCardOption UpcomingCard => Card("upcoming");
public DashboardCardOption CorrectionsCard => Card("corrections");
public DashboardCardOption UnplannedCard => Card("unplanned");
public DashboardCardOption AlertsCard => Card("alerts");
public DashboardCardOption AttendanceCard => Card("attendance");
public DashboardCardOption ExamLoadCard => Card("examload"); public DashboardCardOption ExamLoadCard => Card("examload");
public DashboardCardOption MissingTeachingTimeCard => Card("missingteachingtime");
public DashboardCardOption SupportCard => Card("support");
public DashboardCardOption GroupsCard => Card("groups"); public DashboardCardOption GroupsCard => Card("groups");
public DashboardCardOption AttentionCard => Card("attention");
public int TodayLessonCount => TodaysLessons.Count; public int TodayLessonCount => TodaysLessons.Count;
public int OpenTaskCount => OpenTasks.Count; public int OpenTaskCount => OpenTasks.Count;
public int UpcomingCount => UpcomingDates.Count; public int UpcomingCount => UpcomingDates.Count;
public int AttentionCount => OpenExcuses.Count + AttendanceWarnings.Count + SupportPlanReviews.Count public int AttentionCount => _excuses.Count + _attendanceItems.Count + _supportItems.Count
+ OpenCorrections.Count + UnplannedLessons.Count + Alerts.Count + MissingTeachingTimeEntries.Count; + _correctionItems.Count + _unplannedItems.Count + _alertItems.Count + _missingTimeItems.Count;
public string TodayLessonSummary => TodayLessonCount == 1 ? "1 Stunde" : $"{TodayLessonCount} Stunden"; public string TodayLessonSummary => TodayLessonCount == 1 ? "1 Stunde" : $"{TodayLessonCount} Stunden";
public string OpenTaskSummary => OpenTaskCount == 1 ? "1 Aufgabe" : $"{OpenTaskCount} Aufgaben"; public string OpenTaskSummary => OpenTaskCount == 1 ? "1 Aufgabe" : $"{OpenTaskCount} Aufgaben";
public string AttentionSummary => AttentionCount == 1 ? "1 offener Punkt" : $"{AttentionCount} offene Punkte"; public string AttentionSummary => AttentionCount == 1 ? "1 offener Punkt" : $"{AttentionCount} offene Punkte";
@@ -161,7 +179,8 @@ public partial class DashboardViewModel : ObservableObject
ISubstitutionEntryRepository substitutions, ITimeEntryRepository timeEntries, ISubstitutionEntryRepository substitutions, ITimeEntryRepository timeEntries,
UntisHubService untisHub, WebUntisIntegrationService webUntis, UntisHubService untisHub, WebUntisIntegrationService webUntis,
IAnnualPlanEventRepository? annualPlanEvents = null, IAnnualPlanEventRepository? annualPlanEvents = null,
AnnualPlanSyncService? annualPlanSync = null, SchoolWeatherService? schoolWeather = null) AnnualPlanSyncService? annualPlanSync = null, SchoolWeatherService? schoolWeather = null,
Func<DateTime>? now = null)
{ {
_groups = groups; _subjects = subjects; _lessons = lessons; _exams = exams; _tasks = tasks; _groups = groups; _subjects = subjects; _lessons = lessons; _exams = exams; _tasks = tasks;
_examResults = examResults; _grades = grades; _reportGrades = reportGrades; _memberships = memberships; _examResults = examResults; _grades = grades; _reportGrades = reportGrades; _memberships = memberships;
@@ -176,11 +195,17 @@ public partial class DashboardViewModel : ObservableObject
_schoolWeather = schoolWeather; _schoolWeather = schoolWeather;
_untisHub = untisHub; _untisHub = untisHub;
_webUntis = webUntis; _webUntis = webUntis;
// Testbare Uhr statt direkter DateTime.Now-Aufrufe (siehe Load()/LoadMissingTeachingTime):
// TimeOnly.AddHours()/AddMinutes() wickelt bei Mitternacht um, ohne injizierbares "jetzt"
// waeren Tests fuer "kurz vor/nach Ablauf einer Wartezeit" je nach Ausfuehrungsuhrzeit
// zufaellig rot oder gruen (siehe TODO.md, Abschnitt 9, Nachtrag Uhrzeit-Wraparound).
_now = now ?? (() => DateTime.Now);
if (annualPlanSync is not null) if (annualPlanSync is not null)
{ {
annualPlanSync.DataChanged += () => Avalonia.Threading.Dispatcher.UIThread.Post(LoadCalendar); annualPlanSync.DataChanged += () => Avalonia.Threading.Dispatcher.UIThread.Post(LoadCalendar);
} }
LoadDashboardCards(); LoadDashboardCards();
LoadAttentionFilters();
Load(); Load();
RefreshWebUntisHealth(); RefreshWebUntisHealth();
} }
@@ -204,7 +229,7 @@ public partial class DashboardViewModel : ObservableObject
private void Load() private void Load()
{ {
var now = DateTime.Now; var now = _now();
var today = DateOnly.FromDateTime(now); var today = DateOnly.FromDateTime(now);
CurrentDate = now.ToString("dddd, d. MMMM yyyy", De); CurrentDate = now.ToString("dddd, d. MMMM yyyy", De);
CurrentSchoolYear = _sy.CurrentSchoolYear(); CurrentSchoolYear = _sy.CurrentSchoolYear();
@@ -265,6 +290,7 @@ public partial class DashboardViewModel : ObservableObject
LoadOpenCorrections(groups, today); LoadOpenCorrections(groups, today);
LoadUnplannedLessons(groups, today); LoadUnplannedLessons(groups, today);
LoadAlerts(groups, today); LoadAlerts(groups, today);
RebuildAttention();
UpdateDashboardSummary(); UpdateDashboardSummary();
} }
@@ -273,16 +299,14 @@ public partial class DashboardViewModel : ObservableObject
TodayCard.IsEmpty = TodaysLessons.Count == 0; TodayCard.IsEmpty = TodaysLessons.Count == 0;
TasksCard.IsEmpty = OpenTasks.Count == 0; TasksCard.IsEmpty = OpenTasks.Count == 0;
CalendarCard.IsEmpty = false; CalendarCard.IsEmpty = false;
ExcusesCard.IsEmpty = OpenExcuses.Count == 0;
UpcomingCard.IsEmpty = UpcomingDates.Count == 0; UpcomingCard.IsEmpty = UpcomingDates.Count == 0;
CorrectionsCard.IsEmpty = OpenCorrections.Count == 0;
UnplannedCard.IsEmpty = UnplannedLessons.Count == 0;
AlertsCard.IsEmpty = Alerts.Count == 0;
AttendanceCard.IsEmpty = AttendanceWarnings.Count == 0;
ExamLoadCard.IsEmpty = ExamWeekLoads.Count == 0; ExamLoadCard.IsEmpty = ExamWeekLoads.Count == 0;
MissingTeachingTimeCard.IsEmpty = MissingTeachingTimeEntries.Count == 0;
SupportCard.IsEmpty = SupportPlanReviews.Count == 0;
GroupsCard.IsEmpty = CurrentGroups.Count == 0; GroupsCard.IsEmpty = CurrentGroups.Count == 0;
AttentionCard.IsEmpty = AttentionCount == 0;
// Erst nachdem alle IsEmpty-Werte stehen: welche Kachel tatsaechlich gerendert wird, haengt
// ueber EffectiveIsVisible daran, und davon wiederum die Zeilen-/Spaltenzuordnung.
ApplyCardLayout();
OnPropertyChanged(nameof(TodayLessonCount)); OnPropertyChanged(nameof(TodayLessonCount));
OnPropertyChanged(nameof(OpenTaskCount)); OnPropertyChanged(nameof(OpenTaskCount));
@@ -349,7 +373,7 @@ public partial class DashboardViewModel : ObservableObject
private void LoadAttendanceWarnings(DateOnly today) private void LoadAttendanceWarnings(DateOnly today)
{ {
AttendanceWarnings.Clear(); _attendanceItems.Clear();
var schoolYear = _sy.CurrentSchoolYear(); var schoolYear = _sy.CurrentSchoolYear();
var from = _sy.SchoolYearStart(schoolYear); var from = _sy.SchoolYearStart(schoolYear);
var to = _sy.SchoolYearEnd(schoolYear); var to = _sy.SchoolYearEnd(schoolYear);
@@ -372,9 +396,13 @@ public partial class DashboardViewModel : ObservableObject
items.Add(new AttendanceWarningItem(student.Id, student.FullName, balance.AbsenceRatePercent)); items.Add(new AttendanceWarningItem(student.Id, student.FullName, balance.AbsenceRatePercent));
} }
foreach (var item in items.OrderByDescending(i => i.AbsenceRatePercent)) foreach (var item in items.OrderByDescending(i => i.AbsenceRatePercent))
AttendanceWarnings.Add(item); _attendanceItems.Add(ToAttentionItem(item));
} }
private AttentionItem ToAttentionItem(AttendanceWarningItem w) => new(
AttentionKind.Attendance, w.StudentName, trailingText: $"{w.AbsenceRatePercent:0.#} %",
isWarningTrailing: true, navigate: () => OnNavigateToStudent?.Invoke(w.StudentId));
// ── Klausurwochen (Nutzer-Feedback) ─────────────────────────────────────── // ── Klausurwochen (Nutzer-Feedback) ───────────────────────────────────────
// //
// Persönliche Klausurlast über alle Kurse hinweg — anders als die klassenbezogene // Persönliche Klausurlast über alle Kurse hinweg — anders als die klassenbezogene
@@ -406,13 +434,13 @@ public partial class DashboardViewModel : ObservableObject
private void LoadMissingTeachingTime(DateOnly today) private void LoadMissingTeachingTime(DateOnly today)
{ {
MissingTeachingTimeEntries.Clear(); _missingTimeItems.Clear();
var firstDay = today.AddDays(-MissingTeachingTimeLookbackDays); var firstDay = today.AddDays(-MissingTeachingTimeLookbackDays);
var publicHolidayDates = Enumerable.Range(firstDay.Year, today.Year - firstDay.Year + 1) var publicHolidayDates = Enumerable.Range(firstDay.Year, today.Year - firstDay.Year + 1)
.SelectMany(y => _publicHolidays.GetHolidays(y, _calendarSettings.State)) .SelectMany(y => _publicHolidays.GetHolidays(y, _calendarSettings.State))
.Select(h => h.Date).ToHashSet(); .Select(h => h.Date).ToHashSet();
var schoolHolidays = _schoolHolidays.GetAll(); var schoolHolidays = _schoolHolidays.GetAll();
var nowTime = TimeOnly.FromDateTime(DateTime.Now); var nowTime = TimeOnly.FromDateTime(_now());
var items = new List<MissingTeachingTimeItem>(); var items = new List<MissingTeachingTimeItem>();
for (var date = firstDay; date <= today; date = date.AddDays(1)) for (var date = firstDay; date <= today; date = date.AddDays(1))
@@ -441,14 +469,15 @@ public partial class DashboardViewModel : ObservableObject
items.Add(new MissingTeachingTimeItem(date, windowStart, windowEnd)); items.Add(new MissingTeachingTimeItem(date, windowStart, windowEnd));
} }
foreach (var item in items.OrderBy(i => i.Date)) foreach (var item in items.OrderBy(i => i.Date))
MissingTeachingTimeEntries.Add(item); _missingTimeItems.Add(new AttentionItem(AttentionKind.MissingTeachingTime, item.DateDisplay,
actions: [new AttentionAction("Erfassen", AddMissingTeachingTimeCommand, item)]));
} }
// ── Förderplan-Wiedervorlage (5.3.2) ────────────────────────────────────── // ── Förderplan-Wiedervorlage (5.3.2) ──────────────────────────────────────
private void LoadSupportPlanReviews(DateOnly today) private void LoadSupportPlanReviews(DateOnly today)
{ {
SupportPlanReviews.Clear(); _supportItems.Clear();
var dueBy = today.AddDays(SupportPlanDueWithinDays); var dueBy = today.AddDays(SupportPlanDueWithinDays);
var due = _documentation.GetAll() var due = _documentation.GetAll()
@@ -461,8 +490,11 @@ public partial class DashboardViewModel : ObservableObject
{ {
var student = _students.GetById(d.StudentId); var student = _students.GetById(d.StudentId);
if (student is null) continue; if (student is null) continue;
SupportPlanReviews.Add(new SupportPlanDueItem( var reviewDate = d.SupportData!.ReviewDate!.Value;
d.StudentId, student.FullName, d.Title, d.SupportData!.ReviewDate!.Value, today)); var studentId = d.StudentId;
_supportItems.Add(new AttentionItem(AttentionKind.SupportPlan, student.FullName, subtitle: d.Title,
dateDisplay: reviewDate.ToString("dd.MM.yyyy"), isOverdue: reviewDate < today,
navigate: () => OnNavigateToStudent?.Invoke(studentId)));
} }
} }
@@ -507,7 +539,7 @@ public partial class DashboardViewModel : ObservableObject
private void LoadOpenCorrections(IReadOnlyDictionary<Guid, LearningGroup> groups, DateOnly today) private void LoadOpenCorrections(IReadOnlyDictionary<Guid, LearningGroup> groups, DateOnly today)
{ {
OpenCorrections.Clear(); _correctionItems.Clear();
foreach (var group in groups.Values) foreach (var group in groups.Values)
foreach (var exam in _exams.GetByGroup(group.Id) foreach (var exam in _exams.GetByGroup(group.Id)
.Where(e => e.Status is ExamStatus.Conducted or ExamStatus.Graded) .Where(e => e.Status is ExamStatus.Conducted or ExamStatus.Graded)
@@ -515,8 +547,13 @@ public partial class DashboardViewModel : ObservableObject
{ {
var (expected, evaluated) = ExamCorrectionCounter.Count(exam, var (expected, evaluated) = ExamCorrectionCounter.Count(exam,
_memberships.GetByGroup(group.Id), _examResults.GetByExam(exam.Id)); _memberships.GetByGroup(group.Id), _examResults.GetByExam(exam.Id));
OpenCorrections.Add(new CorrectionProgressItem(exam.Id, group.Id, exam.Title, var percent = expected == 0 ? 0 : (int)Math.Round(evaluated * 100.0 / expected);
group.Name, exam.Date, evaluated, expected, today)); var groupId = group.Id;
_correctionItems.Add(new AttentionItem(AttentionKind.Correction, exam.Title, subtitle: group.Name,
dateDisplay: exam.Date.ToString("dd.MM.yyyy"),
isOverdue: exam.Date < today.AddDays(-7) && evaluated < expected,
progressPercent: percent, progressLabel: $"{evaluated} von {expected} Arbeiten bewertet", hasProgress: true,
navigate: () => OnNavigateToExam?.Invoke(groupId)));
} }
} }
@@ -532,14 +569,14 @@ public partial class DashboardViewModel : ObservableObject
private void LoadUnplannedLessons(IReadOnlyDictionary<Guid, LearningGroup> groups, DateOnly today) private void LoadUnplannedLessons(IReadOnlyDictionary<Guid, LearningGroup> groups, DateOnly today)
{ {
UnplannedLessons.Clear(); _unplannedItems.Clear();
var lastDay = today.AddDays(UnplannedLessonsLookaheadDays); var lastDay = today.AddDays(UnplannedLessonsLookaheadDays);
var publicHolidayDates = Enumerable.Range(today.Year, lastDay.Year - today.Year + 1) var publicHolidayDates = Enumerable.Range(today.Year, lastDay.Year - today.Year + 1)
.SelectMany(y => _publicHolidays.GetHolidays(y, _calendarSettings.State)) .SelectMany(y => _publicHolidays.GetHolidays(y, _calendarSettings.State))
.Select(h => h.Date).ToHashSet(); .Select(h => h.Date).ToHashSet();
var schoolHolidays = _schoolHolidays.GetAll(); var schoolHolidays = _schoolHolidays.GetAll();
var items = new List<UnplannedLessonItem>(); var items = new List<(DateOnly Date, int PeriodNumber, LearningGroup Group)>();
foreach (var group in groups.Values.Where(g => g.RequiresLessonPlanning)) foreach (var group in groups.Values.Where(g => g.RequiresLessonPlanning))
{ {
var slots = _timetableSlots.GetByGroup(group.Id); var slots = _timetableSlots.GetByGroup(group.Id);
@@ -568,12 +605,19 @@ public partial class DashboardViewModel : ObservableObject
{ {
continue; continue;
} }
items.Add(new UnplannedLessonItem(group.Id, group.Name, date, slot.PeriodNumber, today)); items.Add((date, slot.PeriodNumber, group));
} }
} }
} }
foreach (var item in items.OrderBy(i => i.Date).ThenBy(i => i.PeriodNumber).ThenBy(i => i.GroupName)) foreach (var (date, periodNumber, group) in items.OrderBy(i => i.Date).ThenBy(i => i.PeriodNumber)
UnplannedLessons.Add(item); .ThenBy(i => i.Group.Name))
{
var dateDisplay = date == today ? "Heute" : date == today.AddDays(1) ? "Morgen" : date.ToString("dd.MM.");
var groupId = group.Id;
_unplannedItems.Add(new AttentionItem(AttentionKind.Unplanned,
$"{group.Name} · {periodNumber}. Stunde", dateDisplay: dateDisplay,
navigate: () => OnNavigateToUnplannedLesson?.Invoke(groupId)));
}
} }
/// <summary>Erkennt, ob eine Stunde ohne eigene Lesson bereits Teil einer Doppelstunde ist, die /// <summary>Erkennt, ob eine Stunde ohne eigene Lesson bereits Teil einer Doppelstunde ist, die
@@ -605,11 +649,11 @@ public partial class DashboardViewModel : ObservableObject
private void LoadAlerts(IReadOnlyDictionary<Guid, LearningGroup> groups, DateOnly today) private void LoadAlerts(IReadOnlyDictionary<Guid, LearningGroup> groups, DateOnly today)
{ {
Alerts.Clear(); _alertItems.Clear();
foreach (var warning in AttendanceWarnings) // Fehlzeiten-Auffälligkeiten erscheinen in der zusammengefassten Handlungsbedarf-Karte
Alerts.Add(new DashboardAlertItem(warning.StudentId, null, warning.StudentName, // bereits als eigene Gruppe "Fehlzeiten-Warnung" (LoadAttendanceWarnings) — eine weitere
"Fehlzeiten", $"Fehlzeitenquote {warning.AbsenceRatePercent:0.#} %", AlertSeverity.High)); // Kopie hier wäre jetzt eine sichtbare Dopplung derselben Schüler/Zahl, die vor dem Merge
// durch zwei getrennte Kacheln (Auffälligkeiten vs. Fehlzeiten-Warnung) nicht auffiel.
foreach (var group in groups.Values) foreach (var group in groups.Values)
{ {
var groupReportGrades = _reportGrades.GetByGroup(group.Id); var groupReportGrades = _reportGrades.GetByGroup(group.Id);
@@ -618,6 +662,7 @@ public partial class DashboardViewModel : ObservableObject
{ {
var student = _students.GetById(membership.StudentId); var student = _students.GetById(membership.StudentId);
if (student is null) continue; if (student is null) continue;
var studentId = student.Id;
var values = _grades.GetByStudentAndGroup(student.Id, group.Id) var values = _grades.GetByStudentAndGroup(student.Id, group.Id)
.OrderBy(g => g.Date) .OrderBy(g => g.Date)
.Select(g => int.TryParse(g.Value, out var value) ? (int?)value : null) .Select(g => int.TryParse(g.Value, out var value) ? (int?)value : null)
@@ -631,9 +676,10 @@ public partial class DashboardViewModel : ObservableObject
? recent - previous >= 1.0 ? recent - previous >= 1.0
: previous - recent >= 3.0; : previous - recent >= 3.0;
if (declined) if (declined)
Alerts.Add(new DashboardAlertItem(student.Id, group.Id, student.FullName, _alertItems.Add(new AttentionItem(AttentionKind.Alert, student.FullName,
"Notenabfall", $"{group.Name}: zuletzt {recent:0.0}, zuvor {previous:0.0}", subtitle: $"{group.Name}: zuletzt {recent:0.0}, zuvor {previous:0.0}",
AlertSeverity.Medium)); trailingText: "Notenabfall", severity: AlertSeverity.Medium,
navigate: () => OnNavigateToStudent?.Invoke(studentId)));
} }
var latestReport = groupReportGrades var latestReport = groupReportGrades
@@ -642,9 +688,10 @@ public partial class DashboardViewModel : ObservableObject
var effective = latestReport?.OverrideValue ?? latestReport?.CalculatedValue; var effective = latestReport?.OverrideValue ?? latestReport?.CalculatedValue;
if (int.TryParse(effective, out var reportValue) if (int.TryParse(effective, out var reportValue)
&& (group.GradingSystem == GradingSystem.Grades1To6 ? reportValue >= 5 : reportValue <= 4)) && (group.GradingSystem == GradingSystem.Grades1To6 ? reportValue >= 5 : reportValue <= 4))
Alerts.Add(new DashboardAlertItem(student.Id, group.Id, student.FullName, _alertItems.Add(new AttentionItem(AttentionKind.Alert, student.FullName,
"Versetzungsgefährdung", $"{group.Name}: aktueller Stand {reportValue}", subtitle: $"{group.Name}: aktueller Stand {reportValue}",
AlertSeverity.High)); trailingText: "Versetzungsgefährdung", severity: AlertSeverity.High,
navigate: () => OnNavigateToStudent?.Invoke(studentId)));
} }
} }
} }
@@ -666,19 +713,74 @@ public partial class DashboardViewModel : ObservableObject
private static string CardTitle(string key) => key switch private static string CardTitle(string key) => key switch
{ {
"today" => "Heute", "tasks" => "Offene Aufgaben", "calendar" => "Kalender", "today" => "Heute", "tasks" => "Offene Aufgaben", "calendar" => "Kalender",
"excuses" => "Offene Entschuldigungen", "upcoming" => "Anstehende Termine", "upcoming" => "Anstehende Termine", "groups" => "Meine Lerngruppen", "examload" => "Klausurwochen",
"corrections" => "Offene Korrekturen", "unplanned" => "Ungeplante Stunden", "alerts" => "Auffälligkeiten", "attention" => "Handlungsbedarf", _ => key,
"attendance" => "Fehlzeiten-Warnung", "support" => "Förderplan-Wiedervorlage",
"groups" => "Meine Lerngruppen", "examload" => "Klausurwochen",
"missingteachingtime" => "Unterrichtszeit nacherfassen", _ => key,
}; };
// ── Handlungsbedarf: Filter-Chips ─────────────────────────────────────────
//
// Ersetzt die frühere Sichtbarkeit je Einzelkachel (sieben Schalter im "Bereiche anpassen"-
// Panel) durch Filter-Chips innerhalb der zusammengefassten Karte — dichter, und der
// naheliegende Ort, weil alle sieben jetzt eine Karte sind. Bewusst nur für die Dauer der
// Sitzung (keine Persistenz über DashboardSettingsService): das JSON-Format dort ist eine
// flache Liste von Kachel-Einstellungen, eine zweite Objektform nur für diese sieben Filter
// hätte das Dateiformat aufgespalten, ohne dass "welche Handlungsbedarf-Art blende ich
// dauerhaft aus" bisher als Bedürfnis geäußert wurde.
private void LoadAttentionFilters()
{
AttentionFilters.Clear();
foreach (var (kind, header) in AttentionGroupOrder)
{
var option = new AttentionFilterOption(kind, header) { OnChanged = RebuildAttention };
AttentionFilters.Add(option);
}
}
private bool IsAttentionFilterActive(AttentionKind kind) =>
AttentionFilters.FirstOrDefault(f => f.Kind == kind)?.IsActive ?? true;
/// <summary>Setzt Attention aus den bereits geladenen _xyzItems-Feldern neu zusammen — reine
/// Umsortierung/Filterung im Speicher, kein Repo-Zugriff. Wird nach jedem Load() sowie nach
/// jeder punktuellen Änderung (Entschuldigung aufgelöst, Zeit nacherfasst, Filter-Chip
/// umgeschaltet) aufgerufen.</summary>
private void RebuildAttention()
{
var byKind = new Dictionary<AttentionKind, IReadOnlyList<AttentionItem>>
{
[AttentionKind.MissingTeachingTime] = _missingTimeItems,
[AttentionKind.Excuse] = _excuses.Select(ToAttentionItem).ToList(),
[AttentionKind.Correction] = _correctionItems,
[AttentionKind.Unplanned] = _unplannedItems,
[AttentionKind.Alert] = _alertItems,
[AttentionKind.Attendance] = _attendanceItems,
[AttentionKind.SupportPlan] = _supportItems,
};
Attention.Clear();
foreach (var (kind, header) in AttentionGroupOrder)
{
if (!IsAttentionFilterActive(kind)) continue;
var items = byKind[kind];
if (items.Count == 0) continue;
Attention.Add(new AttentionGroup(kind, header, items));
}
}
private static AttentionItem ToAttentionItem(OpenExcuseItem e) => new(
AttentionKind.Excuse, e.StudentName, subtitle: $"{e.GroupName} · {e.DateDisplay}",
actions: [new AttentionAction("Entschuldigt", e.MarkExcusedCommand),
new AttentionAction("Unentschuldigt", e.MarkUnexcusedCommand)]);
// Zaehlt bewusst EffectiveIsVisible, nicht IsVisible: eine eingeschaltete, aber gerade leere
// HideWhenEmpty-Kachel wird nicht gerendert und darf deshalb auch keinen Rasterplatz belegen,
// sonst bleibt an ihrer Stelle eine Luecke im zweispaltigen Grid.
private void ApplyCardLayout() private void ApplyCardLayout()
{ {
var visibleIndex = 0; var visibleIndex = 0;
foreach (var card in DashboardCards) foreach (var card in DashboardCards)
{ {
var index = card.IsVisible ? visibleIndex++ : 0; var index = card.EffectiveIsVisible ? visibleIndex++ : 0;
card.Row = index / 2; card.Row = index / 2;
card.Column = index % 2; card.Column = index % 2;
} }
@@ -714,11 +816,10 @@ public partial class DashboardViewModel : ObservableObject
SaveAndApplyCardLayout(); SaveAndApplyCardLayout();
} }
[RelayCommand] private void OpenStudentAttendance(AttendanceWarningItem? item) /// <summary>Ersetzt die früheren fünf eigenen Navigations-Commands (OpenStudentAttendance,
{ if (item is not null) OnNavigateToStudent?.Invoke(item.StudentId); } /// OpenStudentSupportPlan, OpenCorrection, OpenUnplannedLesson, OpenAlert) — jedes AttentionItem
/// trägt sein Sprungziel bereits als Closure in Navigate.</summary>
[RelayCommand] private void OpenStudentSupportPlan(SupportPlanDueItem? item) [RelayCommand] private void OpenAttentionItem(AttentionItem? item) => item?.Navigate?.Invoke();
{ if (item is not null) OnNavigateToStudent?.Invoke(item.StudentId); }
[RelayCommand] [RelayCommand]
private async Task AddMissingTeachingTime(MissingTeachingTimeItem? item) private async Task AddMissingTeachingTime(MissingTeachingTimeItem? item)
@@ -726,12 +827,13 @@ public partial class DashboardViewModel : ObservableObject
if (item is null || OnAddMissingTeachingTime is null) return; if (item is null || OnAddMissingTeachingTime is null) return;
await OnAddMissingTeachingTime(item); await OnAddMissingTeachingTime(item);
LoadMissingTeachingTime(DateOnly.FromDateTime(DateTime.Today)); LoadMissingTeachingTime(DateOnly.FromDateTime(DateTime.Today));
RebuildAttention();
UpdateDashboardSummary(); UpdateDashboardSummary();
} }
private void LoadOpenExcuses(List<LearningGroup> groups, DateOnly today) private void LoadOpenExcuses(List<LearningGroup> groups, DateOnly today)
{ {
OpenExcuses.Clear(); _excuses.Clear();
var cutoff = today.AddDays(-OpenExcuseMaxAgeDays); var cutoff = today.AddDays(-OpenExcuseMaxAgeDays);
var items = new List<OpenExcuseItem>(); var items = new List<OpenExcuseItem>();
@@ -750,8 +852,7 @@ public partial class DashboardViewModel : ObservableObject
} }
} }
} }
foreach (var item in items.OrderBy(i => i.Date)) _excuses.AddRange(items.OrderBy(i => i.Date));
OpenExcuses.Add(item);
} }
private void ResolveExcuse(OpenExcuseItem item, AttendanceStatus status) private void ResolveExcuse(OpenExcuseItem item, AttendanceStatus status)
@@ -760,7 +861,8 @@ public partial class DashboardViewModel : ObservableObject
if (entry is null) return; if (entry is null) return;
entry.Attendance = status; entry.Attendance = status;
_participationEntries.Save(entry); _participationEntries.Save(entry);
OpenExcuses.Remove(item); _excuses.Remove(item);
RebuildAttention();
UpdateDashboardSummary(); UpdateDashboardSummary();
} }
@@ -961,12 +1063,6 @@ public partial class DashboardViewModel : ObservableObject
else OnNavigateToGroup?.Invoke(groupId); else OnNavigateToGroup?.Invoke(groupId);
} }
} }
[RelayCommand] private void OpenCorrection(CorrectionProgressItem? item)
{ if (item is not null) OnNavigateToExam?.Invoke(item.GroupId); }
[RelayCommand] private void OpenUnplannedLesson(UnplannedLessonItem? item)
{ if (item is not null) OnNavigateToUnplannedLesson?.Invoke(item.GroupId); }
[RelayCommand] private void OpenAlert(DashboardAlertItem? item)
{ if (item is not null) OnNavigateToStudent?.Invoke(item.StudentId); }
[RelayCommand] private void Refresh() => Load(); [RelayCommand] private void Refresh() => Load();
[RelayCommand] private Task AddTask() => AddTaskInternal(startAsReminder: false); [RelayCommand] private Task AddTask() => AddTaskInternal(startAsReminder: false);
@@ -1115,26 +1211,6 @@ public class MissingTeachingTimeItem(DateOnly date, TimeOnly windowStart, TimeOn
public string DateDisplay { get; } = date.ToString("dddd, dd.MM.", De); public string DateDisplay { get; } = date.ToString("dddd, dd.MM.", De);
} }
// ── Förderplan-Wiedervorlage (5.3.2) ──────────────────────────────────────────
public class SupportPlanDueItem
{
public Guid StudentId { get; }
public string StudentName { get; }
public string Title { get; }
public string ReviewDateDisplay { get; }
public bool IsOverdue { get; }
public SupportPlanDueItem(Guid studentId, string studentName, string title, DateOnly reviewDate, DateOnly today)
{
StudentId = studentId;
StudentName = studentName;
Title = title;
ReviewDateDisplay = reviewDate.ToString("dd.MM.yyyy");
IsOverdue = reviewDate < today;
}
}
public partial class CalendarDayCell : ObservableObject public partial class CalendarDayCell : ObservableObject
{ {
[ObservableProperty] private bool _isSelected; [ObservableProperty] private bool _isSelected;
@@ -1210,46 +1286,8 @@ public sealed class UpcomingDateItem(UpcomingDateKind kind, DateOnly date, strin
}; };
} }
public sealed class CorrectionProgressItem(Guid examId, Guid groupId, string title, string groupName,
DateOnly date, int completed, int total, DateOnly today)
{
public Guid ExamId { get; } = examId;
public Guid GroupId { get; } = groupId;
public string Title { get; } = title;
public string GroupName { get; } = groupName;
public DateOnly Date { get; } = date;
public int Completed { get; } = completed;
public int Total { get; } = total;
public int Percent => Total == 0 ? 0 : (int)Math.Round(Completed * 100.0 / Total);
public string ProgressDisplay => $"{Completed} von {Total} Arbeiten bewertet";
public string DateDisplay => Date.ToString("dd.MM.yyyy");
public bool IsOverdue => Date < today.AddDays(-7) && Completed < Total;
}
public sealed class UnplannedLessonItem(Guid groupId, string groupName, DateOnly date, int periodNumber, DateOnly today)
{
public Guid GroupId { get; } = groupId;
public string GroupName { get; } = groupName;
public DateOnly Date { get; } = date;
public int PeriodNumber { get; } = periodNumber;
public string DateDisplay => Date == today ? "Heute" : Date == today.AddDays(1) ? "Morgen" : Date.ToString("dd.MM.");
public string Display => $"{GroupName} · {PeriodNumber}. Stunde";
}
public enum AlertSeverity { Medium, High } public enum AlertSeverity { Medium, High }
public sealed class DashboardAlertItem(Guid studentId, Guid? groupId, string studentName,
string kindLabel, string detail, AlertSeverity severity)
{
public Guid StudentId { get; } = studentId;
public Guid? GroupId { get; } = groupId;
public string StudentName { get; } = studentName;
public string KindLabel { get; } = kindLabel;
public string Detail { get; } = detail;
public AlertSeverity Severity { get; } = severity;
public string SeverityColor => Severity == AlertSeverity.High ? "#D32F2F" : "#F59E0B";
}
public partial class DashboardCardOption : ObservableObject public partial class DashboardCardOption : ObservableObject
{ {
[ObservableProperty] private bool _isVisible; [ObservableProperty] private bool _isVisible;
@@ -1262,12 +1300,19 @@ public partial class DashboardCardOption : ObservableObject
public bool EffectiveIsVisible => IsVisible && (!HideWhenEmpty || !IsEmpty); public bool EffectiveIsVisible => IsVisible && (!HideWhenEmpty || !IsEmpty);
public Action? OnVisibilityChanged { get; set; } public Action? OnVisibilityChanged { get; set; }
/// <summary>Rinnstein zur jeweils anderen Rasterspalte. Muss aus der berechneten
/// <see cref="Column"/> kommen und darf nicht im XAML fest an der Kachel haengen: welche Kachel
/// links und welche rechts landet, entscheidet sich erst zur Laufzeit aus Reihenfolge und
/// Sichtbarkeit, ein fester Margin sitzt dann bei jeder Umschaltung auf der falschen Seite.</summary>
public Avalonia.Thickness Margin => Column == 0
? new Avalonia.Thickness(0, 0, 8, 8)
: new Avalonia.Thickness(8, 0, 0, 8);
public DashboardCardOption(string key, string title, bool isVisible) public DashboardCardOption(string key, string title, bool isVisible)
{ {
Key = key; Key = key;
Title = title; Title = title;
HideWhenEmpty = key is "excuses" or "upcoming" or "corrections" or "unplanned" HideWhenEmpty = key is "upcoming" or "attention";
or "alerts" or "attendance" or "support";
_isVisible = isVisible; _isVisible = isVisible;
} }
@@ -1278,4 +1323,6 @@ public partial class DashboardCardOption : ObservableObject
} }
partial void OnIsEmptyChanged(bool value) => OnPropertyChanged(nameof(EffectiveIsVisible)); partial void OnIsEmptyChanged(bool value) => OnPropertyChanged(nameof(EffectiveIsVisible));
partial void OnColumnChanged(int value) => OnPropertyChanged(nameof(Margin));
} }
@@ -44,6 +44,15 @@ public partial class GroupOverviewViewModel : ObservableObject
private readonly GradingService _grading; private readonly GradingService _grading;
private readonly SchoolYearService _schoolYear; private readonly SchoolYearService _schoolYear;
public ObservableCollection<Lesson> TodayLessons { get; } = [];
[ObservableProperty] private Lesson? _selectedTeachingLesson;
public bool HasTodayLessons => TodayLessons.Count > 0;
public Action<Lesson>? OnOpenTeachingMode { get; set; }
[RelayCommand] private void StartTeachingMode()
{
if (SelectedTeachingLesson is { } lesson) OnOpenTeachingMode?.Invoke(lesson);
}
private Guid _groupId; private Guid _groupId;
private string _groupName = ""; private string _groupName = "";
@@ -130,6 +139,16 @@ public partial class GroupOverviewViewModel : ObservableObject
public void Refresh() public void Refresh()
{ {
var today = DateOnly.FromDateTime(DateTime.Today); var today = DateOnly.FromDateTime(DateTime.Today);
TodayLessons.Clear();
if (_groups.GetById(_groupId)?.IsActive == true)
foreach (var lesson in _lessons.GetByGroupAndRange(_groupId, today, today)
.Where(l => l.Status != LessonStatus.Cancelled)
.OrderBy(l => l.StartTime).ThenBy(l => l.LessonNumber))
TodayLessons.Add(lesson);
var now = TimeOnly.FromDateTime(DateTime.Now);
SelectedTeachingLesson = TodayLessons.LastOrDefault(l => l.StartTime <= now)
?? TodayLessons.FirstOrDefault();
OnPropertyChanged(nameof(HasTodayLessons));
LoadNextLesson(today); LoadNextLesson(today);
LoadNextExam(today); LoadNextExam(today);
LoadYearComparison(); LoadYearComparison();
@@ -145,7 +164,7 @@ public partial class GroupOverviewViewModel : ObservableObject
private void LoadNextLesson(DateOnly today) private void LoadNextLesson(DateOnly today)
{ {
var next = _lessons.GetByGroupAndRange(_groupId, today, today.AddDays(NextLessonLookaheadDays)) var next = _lessons.GetByGroupAndRange(_groupId, today, today.AddDays(NextLessonLookaheadDays))
.Where(l => l.Status != LessonStatus.Conducted) .Where(l => l.Status is not (LessonStatus.Conducted or LessonStatus.Cancelled))
.OrderBy(l => l.Date).ThenBy(l => l.LessonNumber ?? 0) .OrderBy(l => l.Date).ThenBy(l => l.LessonNumber ?? 0)
.FirstOrDefault(); .FirstOrDefault();
@@ -220,6 +239,7 @@ public partial class GroupOverviewViewModel : ObservableObject
private void LoadOpenHomeworkCheck(DateOnly today) private void LoadOpenHomeworkCheck(DateOnly today)
{ {
var previous = _lessons.GetByGroupAndRange(_groupId, today.AddDays(-HomeworkCheckLookbackDays), today.AddDays(-1)) var previous = _lessons.GetByGroupAndRange(_groupId, today.AddDays(-HomeworkCheckLookbackDays), today.AddDays(-1))
.Where(l => l.Status != LessonStatus.Cancelled)
.OrderByDescending(l => l.Date).ThenByDescending(l => l.LessonNumber ?? 0) .OrderByDescending(l => l.Date).ThenByDescending(l => l.LessonNumber ?? 0)
.FirstOrDefault(); .FirstOrDefault();
@@ -826,6 +826,7 @@ public partial class AddGroupDialogViewModel : ObservableObject
[ObservableProperty] private bool _isDifferentiated; [ObservableProperty] private bool _isDifferentiated;
[ObservableProperty] private bool _requiresLessonPlanning = true; [ObservableProperty] private bool _requiresLessonPlanning = true;
[ObservableProperty] private int? _webUntisLessonId; [ObservableProperty] private int? _webUntisLessonId;
[ObservableProperty] private bool _excludedFromUntisHub;
[ObservableProperty] private string _nameError = ""; [ObservableProperty] private string _nameError = "";
[ObservableProperty] private string _gradeLevelError = ""; [ObservableProperty] private string _gradeLevelError = "";
@@ -866,6 +867,7 @@ public partial class AddGroupDialogViewModel : ObservableObject
IsDifferentiated = group.IsDifferentiated; IsDifferentiated = group.IsDifferentiated;
RequiresLessonPlanning = group.RequiresLessonPlanning; RequiresLessonPlanning = group.RequiresLessonPlanning;
WebUntisLessonId = group.WebUntisLessonId; WebUntisLessonId = group.WebUntisLessonId;
ExcludedFromUntisHub = group.ExcludedFromUntisHub;
OnPropertyChanged(nameof(DialogTitle)); OnPropertyChanged(nameof(DialogTitle));
OnPropertyChanged(nameof(SaveButtonText)); OnPropertyChanged(nameof(SaveButtonText));
} }
@@ -913,6 +915,7 @@ public partial class AddGroupDialogViewModel : ObservableObject
Result.IsDifferentiated = IsDifferentiated; Result.IsDifferentiated = IsDifferentiated;
Result.RequiresLessonPlanning = RequiresLessonPlanning; Result.RequiresLessonPlanning = RequiresLessonPlanning;
Result.WebUntisLessonId = WebUntisLessonId; Result.WebUntisLessonId = WebUntisLessonId;
Result.ExcludedFromUntisHub = ExcludedFromUntisHub;
_groups.Save(Result); _groups.Save(Result);
} }
} }
@@ -0,0 +1,86 @@
using CommunityToolkit.Mvvm.ComponentModel;
using LehrerApp.Core.Interfaces;
using LehrerApp.Core.Models;
using LehrerApp.Desktop.Services;
using LehrerApp.Desktop.ViewModels.Students;
using LehrerApp.Templating;
using System.Collections.ObjectModel;
namespace LehrerApp.Desktop.ViewModels.Groups;
/// <summary>Erzeugt aus einer in TemplateDesigner gebauten Arbeitsblatt-.lavorlage-Vorlage ein
/// personalisiertes PDF je aktivem Mitglied der aktuell geöffneten Lerngruppe. Nutzt bewusst
/// dieselbe Platzhalter-/Rendering-Infrastruktur wie der Elternbrief-Dialog
/// (<see cref="LetterPlaceholderBuilder"/>, <see cref="ITemplateRenderer"/>), aber eine getrennte
/// <see cref="WorksheetTemplateStore"/>-Vorlagenbibliothek.</summary>
public partial class PersonalizeWorksheetDialogViewModel : ObservableObject
{
private readonly LearningGroup _group;
private readonly WorksheetTemplateStore _templates;
private readonly ITemplateRenderer _renderer;
[ObservableProperty] private LetterTemplateChoice? _selectedTemplate;
[ObservableProperty] private string _statusMessage = "";
public string GroupName => $"{_group.Name} · {_group.SchoolYear}";
public ObservableCollection<LetterTemplateChoice> Templates { get; } = [];
public ObservableCollection<WorksheetStudentChoice> Students { get; } = [];
public ObservableCollection<WorksheetGenerationResult> Results { get; } = [];
public bool HasNoTemplates => Templates.Count == 0;
public bool HasResults => Results.Count > 0;
public PersonalizeWorksheetDialogViewModel(LearningGroup group, IReadOnlyList<Guid> studentIds,
IStudentRepository students, WorksheetTemplateStore templates, ITemplateRenderer renderer)
{
_group = group; _templates = templates; _renderer = renderer;
foreach (var template in templates.Store.GetTemplates()) Templates.Add(new(template));
SelectedTemplate = Templates.FirstOrDefault();
foreach (var id in studentIds)
if (students.GetById(id) is { } student) Students.Add(new(student));
}
public void Generate(string outputFolder)
{
Results.Clear();
if (SelectedTemplate is null) return;
LoadedTemplate loaded;
try { loaded = _templates.Store.Load(SelectedTemplate.Model); }
catch (Exception ex) when (ex is IOException or InvalidDataException or TemplateValidationException)
{ StatusMessage = $"Vorlage ist ungültig: {ex.Message}"; return; }
Directory.CreateDirectory(outputFolder);
foreach (var choice in Students.Where(x => x.IsIncluded))
{
var student = choice.Model;
try
{
var values = LetterPlaceholderBuilder.BuildStandardValues(student, contact: null, _group,
DateOnly.FromDateTime(DateTime.Now), "", "");
var pdf = _renderer.RenderToPdf(loaded, new LetterDataProvider(values));
var fileName = SanitizeFileName($"{SelectedTemplate.Name}_{student.LastName}_{student.FirstName}.pdf");
File.WriteAllBytes(Path.Combine(outputFolder, fileName), pdf);
Results.Add(new(student.FullName, true, ""));
}
catch (Exception ex) when (ex is IOException or InvalidDataException or UnauthorizedAccessException or TemplateValidationException)
{ Results.Add(new(student.FullName, false, ex.Message)); }
}
OnPropertyChanged(nameof(HasResults));
StatusMessage = $"{Results.Count(x => x.Success)} von {Results.Count} Arbeitsblättern erzeugt.";
}
private static string SanitizeFileName(string value)
{ foreach (var character in Path.GetInvalidFileNameChars()) value = value.Replace(character, '_'); return value; }
}
public partial class WorksheetStudentChoice(Student model) : ObservableObject
{
public Student Model { get; } = model;
public string FullName => Model.FullName;
[ObservableProperty] private bool _isIncluded = true;
}
public sealed record WorksheetGenerationResult(string StudentName, bool Success, string ErrorMessage)
{
public string Icon => Success ? "✓" : "⚠";
public string Color => Success ? "SeaGreen" : "#D97706";
}
@@ -376,7 +376,7 @@ public partial class PlanningTabViewModel : ObservableObject
[RelayCommand(CanExecute = nameof(HasSelectedLesson))] [RelayCommand(CanExecute = nameof(HasSelectedLesson))]
private void AdvanceLessonStatus() private void AdvanceLessonStatus()
{ {
if (SelectedLesson is null || SelectedLesson.Model.Status == LessonStatus.Conducted) return; if (SelectedLesson is null || SelectedLesson.Model.Status is LessonStatus.Conducted or LessonStatus.Cancelled) return;
var lesson = SelectedLesson.Model; var lesson = SelectedLesson.Model;
lesson.Status = lesson.Status == LessonStatus.Ready lesson.Status = lesson.Status == LessonStatus.Ready
? LessonStatus.Conducted ? LessonStatus.Conducted
@@ -467,8 +467,11 @@ public class UnitSummary
}; };
CompetencyCountLabel = u.Competencies.Count == 0 ? "" : $"{u.Competencies.Count} Kompetenz(en)"; CompetencyCountLabel = u.Competencies.Count == 0 ? "" : $"{u.Competencies.Count} Kompetenz(en)";
TotalCount = lessons.Count; // Ausgefallene Stunden zählen weder als gehalten noch als noch zu haltendes Pensum -
ConductedCount = lessons.Count(l => l.Status == LessonStatus.Conducted); // sie fallen komplett aus dem Fortschritt heraus, statt den Nenner künstlich zu erhöhen.
var countableLessons = lessons.Where(l => l.Status != LessonStatus.Cancelled).ToList();
TotalCount = countableLessons.Count;
ConductedCount = countableLessons.Count(l => l.Status == LessonStatus.Conducted);
ProgressFraction = TotalCount == 0 ? 0 : (double)ConductedCount / TotalCount; ProgressFraction = TotalCount == 0 ? 0 : (double)ConductedCount / TotalCount;
ProgressText = TotalCount == 0 ? "Keine Stunden" : $"{ConductedCount} / {TotalCount} Stunden gehalten"; ProgressText = TotalCount == 0 ? "Keine Stunden" : $"{ConductedCount} / {TotalCount} Stunden gehalten";
} }
@@ -507,6 +510,7 @@ public class LessonSummary
LessonStatus.Draft => "#78909C", LessonStatus.Draft => "#78909C",
LessonStatus.Ready => "#1976D2", LessonStatus.Ready => "#1976D2",
LessonStatus.Conducted => "#43A047", LessonStatus.Conducted => "#43A047",
LessonStatus.Cancelled => "#C62828",
_ => "#9E9E9E", _ => "#9E9E9E",
}; };
PhaseCountLabel = l.Phases.Count == 0 ? "" : $"{l.Phases.Count} Phasen"; PhaseCountLabel = l.Phases.Count == 0 ? "" : $"{l.Phases.Count} Phasen";
@@ -543,13 +547,14 @@ public static class UnitStatusDisplay
public static class LessonStatusDisplay public static class LessonStatusDisplay
{ {
public static string[] Options { get; } = ["Entwurf", "Geplant", "Bereit", "Durchgeführt"]; public static string[] Options { get; } = ["Entwurf", "Geplant", "Bereit", "Durchgeführt", "Ausgefallen"];
public static string ToName(LessonStatus s) => s switch public static string ToName(LessonStatus s) => s switch
{ {
LessonStatus.Draft => "Entwurf", LessonStatus.Draft => "Entwurf",
LessonStatus.Ready => "Bereit", LessonStatus.Ready => "Bereit",
LessonStatus.Conducted => "Durchgeführt", LessonStatus.Conducted => "Durchgeführt",
LessonStatus.Cancelled => "Ausgefallen",
_ => "Geplant", _ => "Geplant",
}; };
@@ -558,6 +563,7 @@ public static class LessonStatusDisplay
"Entwurf" => LessonStatus.Draft, "Entwurf" => LessonStatus.Draft,
"Bereit" => LessonStatus.Ready, "Bereit" => LessonStatus.Ready,
"Durchgeführt" => LessonStatus.Conducted, "Durchgeführt" => LessonStatus.Conducted,
"Ausgefallen" => LessonStatus.Cancelled,
_ => LessonStatus.Planned, _ => LessonStatus.Planned,
}; };
} }
@@ -731,6 +737,7 @@ public partial class LessonDialogViewModel : ObservableObject
[ObservableProperty] private int? _lessonNumber; [ObservableProperty] private int? _lessonNumber;
[ObservableProperty] private string _topic = ""; [ObservableProperty] private string _topic = "";
[ObservableProperty] private string _startTimeText = ""; [ObservableProperty] private string _startTimeText = "";
[ObservableProperty] private string _planningIdeas = "";
[ObservableProperty] private string _homework = ""; [ObservableProperty] private string _homework = "";
[ObservableProperty] private bool _homeworkChecked; [ObservableProperty] private bool _homeworkChecked;
[ObservableProperty] private bool _homeworkCheckDismissed; [ObservableProperty] private bool _homeworkCheckDismissed;
@@ -817,6 +824,7 @@ public partial class LessonDialogViewModel : ObservableObject
LessonNumber = editingLesson.LessonNumber; LessonNumber = editingLesson.LessonNumber;
Topic = editingLesson.Topic; Topic = editingLesson.Topic;
StartTimeText = editingLesson.StartTime?.ToString("HH:mm") ?? ""; StartTimeText = editingLesson.StartTime?.ToString("HH:mm") ?? "";
PlanningIdeas = editingLesson.PlanningIdeas ?? "";
Homework = editingLesson.Homework ?? ""; Homework = editingLesson.Homework ?? "";
HomeworkChecked = editingLesson.HomeworkChecked; HomeworkChecked = editingLesson.HomeworkChecked;
HomeworkCheckDismissed = editingLesson.HomeworkCheckDismissed; HomeworkCheckDismissed = editingLesson.HomeworkCheckDismissed;
@@ -882,6 +890,7 @@ public partial class LessonDialogViewModel : ObservableObject
Activity = source?.Activity ?? "", Activity = source?.Activity ?? "",
Material = source?.Material ?? "", Material = source?.Material ?? "",
Shorthand = source?.Shorthand ?? "", Shorthand = source?.Shorthand ?? "",
MaterialPrompt = source?.MaterialPrompt,
}; };
item.OnChanged = RecomputeTimes; item.OnChanged = RecomputeTimes;
item.OnRemove = RemovePhase; item.OnRemove = RemovePhase;
@@ -1069,6 +1078,7 @@ public partial class LessonDialogViewModel : ObservableObject
Result.LessonNumber = LessonNumber; Result.LessonNumber = LessonNumber;
Result.Topic = Topic.Trim(); Result.Topic = Topic.Trim();
Result.StartTime = startTime; Result.StartTime = startTime;
Result.PlanningIdeas = string.IsNullOrWhiteSpace(PlanningIdeas) ? null : PlanningIdeas.Trim();
Result.Phases = Phases.Select(p => p.ToModel()).ToList(); Result.Phases = Phases.Select(p => p.ToModel()).ToList();
Result.Homework = string.IsNullOrWhiteSpace(Homework) ? null : Homework.Trim(); Result.Homework = string.IsNullOrWhiteSpace(Homework) ? null : Homework.Trim();
Result.HomeworkChecked = HomeworkChecked; Result.HomeworkChecked = HomeworkChecked;
@@ -1097,6 +1107,13 @@ public partial class PhaseStepEditItem : ObservableObject
[ObservableProperty] private string _material = ""; [ObservableProperty] private string _material = "";
[ObservableProperty] private string _shorthand = ""; [ObservableProperty] private string _shorthand = "";
[ObservableProperty] private string _computedTimeDisplay = ""; [ObservableProperty] private string _computedTimeDisplay = "";
/// Gespeicherter Materialerstellungs-Prompt (4.5.20/4.5.36) — nur gesetzt, wenn die KI beim
/// letzten "Übernehmen" einen Medienvorschlag für diese Phase gemacht hatte. Steuert die
/// Sichtbarkeit des Kopieren-Buttons im Verlaufsplan-Editor.
[ObservableProperty] private string? _materialPrompt;
public bool HasMaterialPrompt => !string.IsNullOrWhiteSpace(MaterialPrompt);
partial void OnMaterialPromptChanged(string? value) => OnPropertyChanged(nameof(HasMaterialPrompt));
/// Checkbox-Zustand im Editor: unchecked→checked öffnet den Zuweisen-Dialog /// Checkbox-Zustand im Editor: unchecked→checked öffnet den Zuweisen-Dialog
/// (<see cref="OnAssignAlternativePath"/>); checked→unchecked entfernt die Zuordnung. /// (<see cref="OnAssignAlternativePath"/>); checked→unchecked entfernt die Zuordnung.
@@ -1156,6 +1173,7 @@ public partial class PhaseStepEditItem : ObservableObject
Activity = Activity.Trim(), Activity = Activity.Trim(),
Material = Material.Trim(), Material = Material.Trim(),
Shorthand = Shorthand.Trim(), Shorthand = Shorthand.Trim(),
MaterialPrompt = MaterialPrompt,
AlternativePathId = AlternativePathId, AlternativePathId = AlternativePathId,
}; };
} }
@@ -16,6 +16,45 @@ public partial class SeatingPlanTabViewModel : ObservableObject
private readonly IParticipationRepository _participation; private readonly IParticipationRepository _participation;
private readonly IParticipationAspectRepository _aspects; private readonly IParticipationAspectRepository _aspects;
private readonly IDocumentationRepository? _documentation; private readonly IDocumentationRepository? _documentation;
[ObservableProperty] private bool _isTeachingMode;
[ObservableProperty] private string _quickMode = "";
public bool IsQuickMode => QuickMode.Length > 0;
public string QuickModeDisplay => QuickMode switch
{
"Attendance" => "Anwesenheit kontrollieren · Fehlend = Entschuldigung offen",
"Homework" => "Hausaufgaben kontrollieren",
_ => "Klicken: bewerten"
};
[RelayCommand] private void CheckAttendance() => QuickMode = "Attendance";
[RelayCommand] private void CheckHomework() => QuickMode = "Homework";
[RelayCommand] private void EndQuickCheck() => QuickMode = "";
partial void OnQuickModeChanged(string value)
{
if (value.Length > 0) IsEditMode = false;
foreach (var seat in Seats) seat.QuickMode = value;
OnPropertyChanged(nameof(IsQuickMode));
OnPropertyChanged(nameof(QuickModeDisplay));
}
private void SaveQuickStatus(SeatCellViewModel seat, bool positive)
{
if (!IsEditable || !IsQuickMode || seat.SelectedOption.StudentId is not Guid studentId) return;
var session = EnsureTodaySession();
if (session is null) return;
// Always merge with the latest entry, so quick checks preserve ratings and counters.
var entry = _participation.GetBySessionAndStudent(session.Id, studentId)
?? new ParticipationEntry { SessionId = session.Id, StudentId = studentId };
if (QuickMode == "Attendance") entry.Attendance = positive ? AttendanceStatus.Present : AttendanceStatus.ExcusePending;
else
{
entry.Homework = positive ? HomeworkStatus.Completed : HomeworkStatus.MissingOpen;
entry.HomeworkMissing = HomeworkDisplay.CountsAsMissing(entry.Homework);
}
_participation.Save(entry);
RefreshSeatLessonData();
OnAssessmentChanged?.Invoke();
}
private Guid _groupId; private Guid _groupId;
private SeatingPlan? _currentPlan; private SeatingPlan? _currentPlan;
private bool _isReadOnly; private bool _isReadOnly;
@@ -235,7 +274,12 @@ public partial class SeatingPlanTabViewModel : ObservableObject
?? StudentSeatOption.Empty; ?? StudentSeatOption.Empty;
var isHidden = hiddenSeats.Any(h => h.Row == row && h.Column == column); var isHidden = hiddenSeats.Any(h => h.Row == row && h.Column == column);
Seats.Add(new SeatCellViewModel(row, column, StudentOptions, option, OnSeatChanged, Seats.Add(new SeatCellViewModel(row, column, StudentOptions, option, OnSeatChanged,
CanEditLayout, ToggleSituationTag, IsEditable, isHidden, ToggleSeatHidden, TallyParticipation)); CanEditLayout, ToggleSituationTag, IsEditable, isHidden, ToggleSeatHidden, TallyParticipation)
{
QuickMode = QuickMode,
OnQuickStatus = SaveQuickStatus,
OnQuickSpecial = AssessStudent
});
} }
UpdateAssignmentSummary(); UpdateAssignmentSummary();
RefreshSeatLessonData(); RefreshSeatLessonData();
@@ -488,6 +532,7 @@ public partial class SeatingPlanTabViewModel : ObservableObject
partial void OnIsEditModeChanged(bool value) partial void OnIsEditModeChanged(bool value)
{ {
if (value) QuickMode = "";
OnPropertyChanged(nameof(CanEditLayout)); OnPropertyChanged(nameof(CanEditLayout));
foreach (var seat in Seats) foreach (var seat in Seats)
{ {
@@ -540,6 +585,32 @@ public sealed class ParticipationSessionOption(ParticipationSession session)
public partial class SeatCellViewModel : ObservableObject public partial class SeatCellViewModel : ObservableObject
{ {
[ObservableProperty] private string _quickMode = "";
public bool ShowQuickCheck => QuickMode.Length > 0 && IsOccupied && CanRecordLesson;
public bool ShowNormalActions => ShowLessonOverview && QuickMode.Length == 0;
public bool ShowSituationActions => ShowNormalActions && CanRecordLesson;
public string QuickPositiveLabel => QuickMode == "Attendance" ? "Anwesend" : "Gemacht";
public string QuickNegativeLabel => QuickMode == "Attendance" ? "Fehlend" : "Fehlt";
public Action<SeatCellViewModel, bool>? OnQuickStatus { get; init; }
public Func<SeatCellViewModel, Task>? OnQuickSpecial { get; init; }
[RelayCommand] private void QuickPositive() => OnQuickStatus?.Invoke(this, true);
[RelayCommand] private void QuickNegative() => OnQuickStatus?.Invoke(this, false);
[RelayCommand] private Task QuickSpecial() => OnQuickSpecial?.Invoke(this) ?? Task.CompletedTask;
partial void OnQuickModeChanged(string value)
{
OnPropertyChanged(nameof(ShowQuickCheck));
OnPropertyChanged(nameof(ShowNormalActions));
OnPropertyChanged(nameof(ShowSituationActions));
OnPropertyChanged(nameof(QuickPositiveLabel));
OnPropertyChanged(nameof(QuickNegativeLabel));
OnPropertyChanged(nameof(DisplayOpacity));
}
partial void OnCanRecordLessonChanged(bool value)
{
OnPropertyChanged(nameof(ShowQuickCheck));
OnPropertyChanged(nameof(ShowSituationActions));
}
private readonly Action<SeatCellViewModel> _onChanged; private readonly Action<SeatCellViewModel> _onChanged;
private bool _suppressChange; private bool _suppressChange;
private readonly Action<SeatCellViewModel, string> _toggleSituationTag; private readonly Action<SeatCellViewModel, string> _toggleSituationTag;
@@ -581,7 +652,7 @@ public partial class SeatCellViewModel : ObservableObject
/// Opacity ist im DataTemplate bereits lokal an LessonOpacity gebunden gewesen; ein lokal /// Opacity ist im DataTemplate bereits lokal an LessonOpacity gebunden gewesen; ein lokal
/// gebundener Wert überschreibt aber jeden Style-Setter für dieselbe Eigenschaft, daher muss /// gebundener Wert überschreibt aber jeden Style-Setter für dieselbe Eigenschaft, daher muss
/// die Abblendung für ausgeblendete Plätze hier statt per CSS-Klasse erfolgen.</summary> /// die Abblendung für ausgeblendete Plätze hier statt per CSS-Klasse erfolgen.</summary>
public double DisplayOpacity => IsHidden ? 0.4 : LessonOpacity; public double DisplayOpacity => IsHidden ? 0.4 : ShowQuickCheck ? 1 : LessonOpacity;
private readonly Action<SeatCellViewModel, bool> _tally; private readonly Action<SeatCellViewModel, bool> _tally;
@@ -615,6 +686,9 @@ public partial class SeatCellViewModel : ObservableObject
partial void OnSelectedOptionChanged(StudentSeatOption value) partial void OnSelectedOptionChanged(StudentSeatOption value)
{ {
OnPropertyChanged(nameof(ShowQuickCheck));
OnPropertyChanged(nameof(ShowNormalActions));
OnPropertyChanged(nameof(ShowSituationActions));
OnPropertyChanged(nameof(IsOccupied)); OnPropertyChanged(nameof(IsOccupied));
OnPropertyChanged(nameof(StudentName)); OnPropertyChanged(nameof(StudentName));
OnPropertyChanged(nameof(ShowLessonOverview)); OnPropertyChanged(nameof(ShowLessonOverview));
@@ -624,6 +698,8 @@ public partial class SeatCellViewModel : ObservableObject
partial void OnCanEditChanged(bool value) partial void OnCanEditChanged(bool value)
{ {
OnPropertyChanged(nameof(ShowNormalActions));
OnPropertyChanged(nameof(ShowSituationActions));
OnPropertyChanged(nameof(ShowLessonOverview)); OnPropertyChanged(nameof(ShowLessonOverview));
OnPropertyChanged(nameof(ShowSeat)); OnPropertyChanged(nameof(ShowSeat));
OnPropertyChanged(nameof(CanToggleHidden)); OnPropertyChanged(nameof(CanToggleHidden));
@@ -20,6 +20,7 @@ namespace LehrerApp.Desktop.ViewModels.Groups;
/// </summary> /// </summary>
public class TeachingModeViewModel public class TeachingModeViewModel
{ {
public TeachingTimelineViewModel Timeline { get; }
public string GroupName { get; } public string GroupName { get; }
public LessonViewerViewModel LessonInfo { get; } public LessonViewerViewModel LessonInfo { get; }
public SeatingPlanTabViewModel SeatingPlan { get; } public SeatingPlanTabViewModel SeatingPlan { get; }
@@ -38,9 +39,11 @@ public class TeachingModeViewModel
SeatingPlanTabViewModel seatingPlan, ParticipationTabViewModel participation) SeatingPlanTabViewModel seatingPlan, ParticipationTabViewModel participation)
{ {
GroupName = group.Name; GroupName = group.Name;
Timeline = new TeachingTimelineViewModel(lesson, lessons, !group.IsActive);
LessonInfo = new LessonViewerViewModel(lesson, alternativePaths); LessonInfo = new LessonViewerViewModel(lesson, alternativePaths);
SeatingPlan = seatingPlan; SeatingPlan = seatingPlan;
SeatingPlan.IsTeachingMode = true;
SeatingPlan.Initialize(group.Id, !group.IsActive); SeatingPlan.Initialize(group.Id, !group.IsActive);
SeatingPlan.SelectOrCreateSessionForLesson(lesson); SeatingPlan.SelectOrCreateSessionForLesson(lesson);
@@ -102,14 +105,19 @@ public partial class TeachingModeHomeworkViewModel : ObservableObject
if (_previousLesson is null) return; if (_previousLesson is null) return;
_previousLesson.HomeworkChecked = value; _previousLesson.HomeworkChecked = value;
if (value) _previousLesson.HomeworkCheckDismissed = false; if (value) _previousLesson.HomeworkCheckDismissed = false;
_lessons.Save(_previousLesson); var latest = _lessons.GetById(_previousLesson.Id) ?? _previousLesson;
latest.HomeworkChecked = value;
if (value) latest.HomeworkCheckDismissed = false;
_lessons.Save(latest);
} }
[RelayCommand] [RelayCommand]
private void SaveCurrentHomework() private void SaveCurrentHomework()
{ {
_lesson.Homework = CurrentHomework; _lesson.Homework = CurrentHomework;
_lessons.Save(_lesson); var latest = _lessons.GetById(_lesson.Id) ?? _lesson;
latest.Homework = CurrentHomework;
_lessons.Save(latest);
SaveStatus = "Gespeichert."; SaveStatus = "Gespeichert.";
} }
} }
@@ -0,0 +1,224 @@
using System.Collections.ObjectModel;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using LehrerApp.Core.Interfaces;
using LehrerApp.Core.Models;
namespace LehrerApp.Desktop.ViewModels.Groups;
public partial class TeachingTimelineViewModel : ObservableObject
{
private readonly Lesson _lesson;
private readonly ILessonRepository _lessons;
private readonly Func<DateTime> _utcNow;
private TeachingTimelineState? _state;
public bool IsEditable { get; }
public ObservableCollection<TeachingPhaseViewModel> Phases { get; } = [];
public ObservableCollection<Lesson> TransferTargets { get; } = [];
[ObservableProperty] private Lesson? _transferTarget;
[ObservableProperty] private string _status = "";
[ObservableProperty] private bool _needsStart;
public bool HasPhases => Phases.Count > 0;
public bool HasTransferTargets => TransferTargets.Count > 0;
public bool HasRemainder => Phases.Any(p => p.IsOverflow && !p.IsCompleted && !p.IsTransferred);
public TeachingTimelineViewModel(Lesson lesson, ILessonRepository lessons, bool readOnly = false,
Func<DateTime>? utcNow = null)
{
_lesson = lesson;
_lessons = lessons;
_utcNow = utcNow ?? (() => DateTime.UtcNow);
IsEditable = !readOnly;
_state = lesson.TeachingTimeline;
if (_state is not null)
{
// LiteDB returns local DateTimes by default; arithmetic below uses UTC.
_state.StartUtc = _state.StartUtc.ToUniversalTime();
_state.EndUtc = _state.EndUtc.ToUniversalTime();
_state.HeldSinceUtc = _state.HeldSinceUtc?.ToUniversalTime();
}
foreach (var phase in lesson.Phases.Where(p => p.AlternativePathId is null))
Phases.Add(new TeachingPhaseViewModel(phase, this));
if (_state is null && lesson.StartTime is { } start)
CreateState(lesson.Date.ToDateTime(start).ToUniversalTime());
foreach (var target in lessons.GetByGroupAndRange(lesson.GroupId, lesson.Date, lesson.Date.AddDays(120))
.Where(l => l.Id != lesson.Id && l.Status is not (LessonStatus.Cancelled or LessonStatus.Conducted)
&& (l.Date > lesson.Date || l.StartTime > lesson.StartTime || l.LessonNumber > lesson.LessonNumber))
.OrderBy(l => l.Date).ThenBy(l => l.StartTime).ThenBy(l => l.LessonNumber))
TransferTargets.Add(target);
TransferTarget = TransferTargets.FirstOrDefault();
Refresh();
}
private void CreateState(DateTime start)
{
_state = new TeachingTimelineState
{
StartUtc = start,
EndUtc = start.AddMinutes(Phases.Sum(p => Math.Max(0, p.Source.DurationMinutes))),
Phases = Phases.Select(p => new TeachingPhaseTiming
{ PhaseId = p.Source.Id, Minutes = Math.Max(0, p.Source.DurationMinutes) }).ToList()
};
}
[RelayCommand] private void StartNow()
{
if (!IsEditable || _state is not null) return;
CreateState(_utcNow());
Save();
Refresh();
}
public void Refresh()
{
NeedsStart = _state is null && HasPhases;
if (_state is null) return;
var now = _utcNow();
var cursor = _state.StartUtc;
_state.Phases.RemoveAll(t => !Phases.Any(p => p.Source.Id == t.PhaseId));
var ordered = _state.Phases.Select(t => Phases.FirstOrDefault(p => p.Source.Id == t.PhaseId))
.OfType<TeachingPhaseViewModel>().ToList();
// Keep surviving IDs in their live order when a plan was edited between openings.
foreach (var phase in Phases.Where(p => !ordered.Contains(p)).ToList())
{
_state.Phases.Add(new TeachingPhaseTiming { PhaseId = phase.Source.Id, Minutes = Math.Max(0, phase.Source.DurationMinutes) });
ordered.Add(phase);
}
for (var i = 0; i < ordered.Count; i++)
if (Phases.IndexOf(ordered[i]) != i) Phases.Move(Phases.IndexOf(ordered[i]), i);
foreach (var phase in Phases)
{
var timing = _state.Phases.First(t => t.PhaseId == phase.Source.Id);
var held = _state.HeldPhaseId == phase.Source.Id && _state.HeldSinceUtc.HasValue;
var extension = held ? Math.Max(0, (now - _state.HeldSinceUtc!.Value).TotalMinutes) : 0;
var end = cursor.AddMinutes(timing.Minutes + extension);
// Phases pushed entirely out of the lesson remain pending, even after closing
// the window overnight. They run only if explicitly brought forward.
var canRun = cursor < _state.EndUtc || timing.ExplicitlyStarted || held;
phase.StartUtc = cursor;
phase.EndUtc = end;
phase.IsActive = canRun && cursor <= now && (now < end || held)
&& (now < _state.EndUtc || timing.ExplicitlyStarted || held);
phase.IsCompleted = canRun && !held && end <= now
&& (end <= _state.EndUtc || timing.ExplicitlyStarted);
phase.IsOverflow = end > _state.EndUtc && !phase.IsCompleted;
phase.IsTransferred = _state.TransferredPhaseIds.Contains(phase.Source.Id);
phase.IsHeld = held;
var effectiveNow = timing.ExplicitlyStarted || held || now < _state.EndUtc ? now : _state.EndUtc;
var elapsed = Math.Clamp((effectiveNow - cursor).TotalMinutes, 0, timing.Minutes + extension);
phase.RemainingMinutes = phase.IsCompleted ? 0 : timing.Minutes + extension - elapsed;
phase.Progress = !canRun ? 0 : phase.IsCompleted ? 100 : elapsed / Math.Max(0.01, timing.Minutes + extension) * 100;
phase.TimeDisplay = $"{cursor.ToLocalTime():HH:mm}{end.ToLocalTime():HH:mm}";
phase.CanStartNow = IsEditable && !phase.IsCompleted && !phase.IsActive && !phase.IsTransferred;
cursor = end;
}
OnPropertyChanged(nameof(HasRemainder));
}
public void Extend(TeachingPhaseViewModel phase, int minutes)
{
Refresh();
if (!IsEditable || !phase.IsActive || _state is null) return;
var timing = _state.Phases.First(p => p.PhaseId == phase.Source.Id);
timing.Minutes += minutes;
timing.ExplicitlyStarted = true;
Save(); Refresh();
}
public void Hold(TeachingPhaseViewModel phase)
{
Refresh();
if (!IsEditable || !phase.IsActive || phase.IsHeld || _state is null) return;
_state.HeldPhaseId = phase.Source.Id;
_state.HeldSinceUtc = _utcNow();
_state.Phases.First(p => p.PhaseId == phase.Source.Id).ExplicitlyStarted = true;
Save(); Refresh();
}
public void Finish(TeachingPhaseViewModel phase, bool advanceNext = true)
{
Refresh();
if (!IsEditable || !phase.IsActive || _state is null) return;
_state.Phases.First(p => p.PhaseId == phase.Source.Id).Minutes = Math.Max(0, (_utcNow() - phase.StartUtc).TotalMinutes);
_state.HeldPhaseId = null;
_state.HeldSinceUtc = null;
if (advanceNext)
{
var nextIndex = _state.Phases.FindIndex(p => p.PhaseId == phase.Source.Id) + 1;
if (nextIndex < _state.Phases.Count) _state.Phases[nextIndex].ExplicitlyStarted = true;
}
Save(); Refresh();
}
public void BringForward(TeachingPhaseViewModel phase)
{
Refresh();
if (!phase.CanStartNow || _state is null) return;
var active = Phases.FirstOrDefault(p => p.IsActive);
if (active is not null) Finish(active, advanceNext: false);
var timing = _state.Phases.First(p => p.PhaseId == phase.Source.Id);
timing.Minutes = phase.RemainingMinutes;
_state.Phases.Remove(timing);
var completed = Phases.TakeWhile(p => p.IsCompleted).Count();
_state.Phases.Insert(Math.Min(completed, _state.Phases.Count), timing);
timing.ExplicitlyStarted = true;
var now = _utcNow();
// A pending phase may be selected long after the scheduled end. Anchor it to
// now instead of letting yesterday's timestamps immediately complete it.
var prefix = _state.Phases.Take(completed).Sum(p => p.Minutes);
var delay = (now - _state.StartUtc.AddMinutes(prefix)).TotalMinutes;
if (completed == 0) _state.StartUtc = now;
else if (delay > 0) _state.Phases[completed - 1].Minutes += delay;
Save(); Refresh();
}
[RelayCommand] private void TransferRemainder()
{
Refresh();
if (!IsEditable || _state is null || TransferTarget is null) return;
var target = _lessons.GetById(TransferTarget.Id);
if (target is null || target.Status is LessonStatus.Cancelled or LessonStatus.Conducted) return;
var remainder = Phases.Where(p => p.IsOverflow && !p.IsCompleted && !p.IsTransferred).ToList();
if (remainder.Count == 0) return;
foreach (var phase in remainder)
{
var source = phase.Source;
target.Phases.Add(new LessonPhaseStep { Name = source.Name, DurationMinutes = (int)Math.Ceiling(phase.RemainingMinutes),
Activity = source.Activity, Material = source.Material, Shorthand = source.Shorthand });
}
_lessons.Save(target);
_state.TransferredPhaseIds.AddRange(remainder.Select(p => p.Source.Id));
Save(); Refresh();
Status = $"{remainder.Count} Phase(n) nach {target.Date:dd.MM.yyyy} · {target.Topic} kopiert.";
}
private void Save()
{
var latest = _lessons.GetById(_lesson.Id) ?? _lesson;
latest.TeachingTimeline = _state;
_lesson.TeachingTimeline = _state;
_lessons.Save(latest);
}
}
public partial class TeachingPhaseViewModel(LessonPhaseStep source, TeachingTimelineViewModel owner) : ObservableObject
{
public LessonPhaseStep Source { get; } = source;
public DateTime StartUtc { get; set; }
public DateTime EndUtc { get; set; }
public double RemainingMinutes { get; set; }
[ObservableProperty] private bool _isActive;
[ObservableProperty] private bool _isCompleted;
[ObservableProperty] private bool _isOverflow;
[ObservableProperty] private bool _isHeld;
[ObservableProperty] private bool _isTransferred;
[ObservableProperty] private bool _canStartNow;
[ObservableProperty] private double _progress;
[ObservableProperty] private string _timeDisplay = "";
public bool IsEditable => owner.IsEditable;
[RelayCommand] private void ExtendFive() => owner.Extend(this, 5);
[RelayCommand] private void ExtendTen() => owner.Extend(this, 10);
[RelayCommand] private void Hold() => owner.Hold(this);
[RelayCommand] private void Finish() => owner.Finish(this);
[RelayCommand] private void BringForward() => owner.BringForward(this);
}
@@ -1,7 +1,7 @@
using System.Collections.ObjectModel; using System.Collections.ObjectModel;
using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input; using CommunityToolkit.Mvvm.Input;
using LehrerApp.Core.Importing; using LehrerApp.Core.AiPlanning;
using LehrerApp.Core.Interfaces; using LehrerApp.Core.Interfaces;
using LehrerApp.Core.Models; using LehrerApp.Core.Models;
using LehrerApp.Desktop.Services; using LehrerApp.Desktop.Services;
@@ -24,10 +24,38 @@ public partial class WebUntisLessonAbsenceRow : ObservableObject
public string DateLabel => Date.ToString("dd.MM.yyyy"); public string DateLabel => Date.ToString("dd.MM.yyyy");
public bool CanApply => SessionId is not null; public bool CanApply => SessionId is not null;
// Nur für den optionalen KI-Statusvorschlag mitgeführt (siehe
// WebUntisLessonAbsenceComparisonViewModel.SuggestStatusWithAi) - dieselben Rohsignale, aus
// denen MapStatus den TargetStatus berechnet, bewusst OHNE Name/Klasse/Datum, damit die Anfrage
// an das KI-Backend personenbezogen leer bleibt.
internal int AbsentMinutes { get; init; }
internal bool HandledOn { get; init; }
internal bool? ExternKeyInParentheses { get; init; }
// Statusübernahme ist per ComboBox anpassbar (Nutzer-Feedback: der aus WebUntis abgeleitete
// TargetStatus war über den reinen Anzeigetext oft nicht eindeutig nachvollziehbar; Ablehnen der
// ganzen Zeile und der Status manuell nachtragen war die einzige Korrekturmöglichkeit) - die
// ComboBox ist mit TargetStatus vorbelegt, aber vor "Übernehmen" frei änderbar. Bindet wie bei
// GradeCategoryDisplay über einen String-Wrapper statt direkt ans Enum (sonst ToString() auf
// Englisch). Nur die Stati, die MapStatus tatsächlich liefert bzw. die als Korrektur plausibel
// sind (nicht z.B. "Geschwänzt" oder "Suspendiert", die WebUntis hier nie meldet). Internal statt
// private, damit SuggestStatusWithAi eine von der KI zurückgegebene Statusangabe dagegen validieren
// kann, statt jeden von der KI genannten Enum-Namen blind zu übernehmen.
internal static readonly AttendanceStatus[] SelectableStatuses =
[
AttendanceStatus.ExcusePending, AttendanceStatus.Excused, AttendanceStatus.Unexcused,
AttendanceStatus.Late, AttendanceStatus.LeftDuringClass, AttendanceStatus.Present,
];
public static string[] StatusOptions { get; } = SelectableStatuses.Select(s => AttendanceDisplay.Label(s)).ToArray();
[ObservableProperty] private Student? _assignedStudent; [ObservableProperty] private Student? _assignedStudent;
[ObservableProperty] private string _localStatus = "ohne Zuordnung"; [ObservableProperty] private string _localStatus = "ohne Zuordnung";
[ObservableProperty] private Guid? _sessionId; [ObservableProperty] private Guid? _sessionId;
[ObservableProperty] private bool _selected; [ObservableProperty] private bool _selected;
[ObservableProperty] private string _selectedStatusName = "";
public AttendanceStatus SelectedStatus =>
SelectableStatuses.FirstOrDefault(s => AttendanceDisplay.Label(s) == SelectedStatusName, TargetStatus);
partial void OnAssignedStudentChanged(Student? value) => OnAssignmentChanged?.Invoke(this); partial void OnAssignedStudentChanged(Student? value) => OnAssignmentChanged?.Invoke(this);
} }
@@ -42,6 +70,8 @@ public partial class WebUntisLessonAbsenceComparisonViewModel : ObservableObject
private readonly IStudentRepository _students; private readonly IStudentRepository _students;
private readonly IParticipationSessionRepository _sessions; private readonly IParticipationSessionRepository _sessions;
private readonly IParticipationRepository _participation; private readonly IParticipationRepository _participation;
private readonly AiPlanningService _ai;
private readonly AiSettingsService _aiSettings;
private IReadOnlyList<Student> _loadedStudents = []; private IReadOnlyList<Student> _loadedStudents = [];
private IReadOnlyDictionary<DateOnly, ParticipationSession> _loadedSessions = private IReadOnlyDictionary<DateOnly, ParticipationSession> _loadedSessions =
@@ -52,14 +82,15 @@ public partial class WebUntisLessonAbsenceComparisonViewModel : ObservableObject
[ObservableProperty] private DateTimeOffset? _endDate = DateTimeOffset.Now; [ObservableProperty] private DateTimeOffset? _endDate = DateTimeOffset.Now;
[ObservableProperty] private string _status = "Zeitraum wählen und Fehlzeiten laden."; [ObservableProperty] private string _status = "Zeitraum wählen und Fehlzeiten laden.";
[ObservableProperty] private bool _busy; [ObservableProperty] private bool _busy;
[ObservableProperty] private bool _aiSuggestBusy;
[ObservableProperty] private bool _markUnknownAsPresent; [ObservableProperty] private bool _markUnknownAsPresent;
public WebUntisLessonAbsenceComparisonViewModel(LearningGroup group, WebUntisIntegrationService untis, public WebUntisLessonAbsenceComparisonViewModel(LearningGroup group, WebUntisIntegrationService untis,
IStudentRepository students, IParticipationSessionRepository sessions, IStudentRepository students, IParticipationSessionRepository sessions,
IParticipationRepository participation) IParticipationRepository participation, AiPlanningService ai, AiSettingsService aiSettings)
{ {
_group = group; _untis = untis; _students = students; _sessions = sessions; _group = group; _untis = untis; _students = students; _sessions = sessions;
_participation = participation; _participation = participation; _ai = ai; _aiSettings = aiSettings;
} }
[RelayCommand] [RelayCommand]
@@ -88,7 +119,7 @@ public partial class WebUntisLessonAbsenceComparisonViewModel : ObservableObject
// Erste Wahl: WebUntis-Kennung (ENr). Nicht jeder Schüler hat eine (z.B. manuell statt // Erste Wahl: WebUntis-Kennung (ENr). Nicht jeder Schüler hat eine (z.B. manuell statt
// per WebUntis-Import angelegt) - Fallback über den Namen, aber nur wenn er innerhalb // per WebUntis-Import angelegt) - Fallback über den Namen, aber nur wenn er innerhalb
// der Kursmitglieder eindeutig ist, sonst lieber unzugeordnet lassen als raten. // der Kursmitglieder eindeutig ist, sonst lieber unzugeordnet lassen als raten.
var byKey = courseStudents.Select(student => (Student: student, Key: StudentKey(student))) var byKey = courseStudents.Select(student => (Student: student, Key: UntisLessonAbsenceHelper.StudentExternKey(student)))
.Where(x => x.Key is not null).ToDictionary(x => x.Key!.Value, x => x.Student); .Where(x => x.Key is not null).ToDictionary(x => x.Key!.Value, x => x.Student);
var byName = courseStudents var byName = courseStudents
.SelectMany(student => new[] .SelectMany(student => new[]
@@ -125,13 +156,18 @@ public partial class WebUntisLessonAbsenceComparisonViewModel : ObservableObject
var match = absence.ExternKey is { } key && byKey.TryGetValue(key, out var byKeyStudent) var match = absence.ExternKey is { } key && byKey.TryGetValue(key, out var byKeyStudent)
? byKeyStudent ? byKeyStudent
: byName.GetValueOrDefault(NameKey(absence.StudentName)); : byName.GetValueOrDefault(NameKey(absence.StudentName));
var targetStatus = MapStatus(absence);
var row = new WebUntisLessonAbsenceRow var row = new WebUntisLessonAbsenceRow
{ {
UntisStudentName = absence.StudentName, Date = date!.Value, UntisStudentName = absence.StudentName, Date = date!.Value,
TimeLabel = TimeLabel(absence.StartTime, absence.EndTime), TimeLabel = TimeLabel(absence.StartTime, absence.EndTime),
UntisStatus = DisplayUntisStatus(absence), UntisStatus = DisplayUntisStatus(absence),
TargetStatus = MapStatus(absence), Reason = absence.Reason, TargetStatus = targetStatus, Reason = absence.Reason,
AbsentMinutes = absence.AbsentMinutes,
HandledOn = !string.IsNullOrWhiteSpace(absence.HandledOn),
ExternKeyInParentheses = absence.ExternKey is null ? null : absence.ExternKeyInParentheses,
Candidates = courseStudents, OnAssignmentChanged = ResolveLocalMatch, Candidates = courseStudents, OnAssignmentChanged = ResolveLocalMatch,
SelectedStatusName = AttendanceDisplay.Label(targetStatus),
}; };
Rows.Add(row); Rows.Add(row);
row.AssignedStudent = match; // löst OnAssignedStudentChanged aus und setzt SessionId/LocalStatus/Selected row.AssignedStudent = match; // löst OnAssignedStudentChanged aus und setzt SessionId/LocalStatus/Selected
@@ -146,6 +182,59 @@ public partial class WebUntisLessonAbsenceComparisonViewModel : ObservableObject
finally { Busy = false; } finally { Busy = false; }
} }
/// <summary>
/// Fragt für alle geladenen, einer lokalen Kursstunde zuordenbaren Zeilen in einer gebündelten
/// Anfrage (Kosten/Latenz, siehe AiPlanningService.RequestUntisStatusSuggestionsAsync) einen
/// KI-Statusvorschlag ab und setzt ihn nur in der "Übernahme als"-ComboBox vor - Namen, Klasse
/// und Datum verlassen dafür nie die App (siehe AiUntisStatusRow), nur die je Zeile rein
/// technische Positions-Id sowie die bereits lokal bekannten Rohsignale. Ersetzt nie
/// eigenständig einen bestehenden Übernahme-Status ohne Zutun der Lehrkraft - "Markierte
/// übernehmen" bleibt der einzige schreibende Schritt.
/// </summary>
[RelayCommand]
private async Task SuggestStatusWithAi()
{
var token = _aiSettings.GetToken();
if (token is null)
{
Status = "Nicht angemeldet. Bitte in den Einstellungen bei der KI-Unterstützung anmelden.";
return;
}
var candidates = Rows.Where(x => x.CanApply).ToList();
if (candidates.Count == 0)
{
Status = "Keine Zeilen mit lokaler Kursstunde geladen.";
return;
}
AiSuggestBusy = true;
try
{
var requestRows = candidates.Select((row, i) => new AiUntisStatusRow
{
Id = i.ToString(), ReasonText = row.Reason ?? "", AbsentMinutes = row.AbsentMinutes,
HandledOn = row.HandledOn, ExternKeyInParentheses = row.ExternKeyInParentheses,
CurrentGuess = row.TargetStatus.ToString(),
}).ToList();
var suggestions = await _ai.RequestUntisStatusSuggestionsAsync(requestRows, token);
var applied = 0;
for (var i = 0; i < candidates.Count; i++)
{
if (!suggestions.TryGetValue(i.ToString(), out var statusName)) continue;
if (!Enum.TryParse<AttendanceStatus>(statusName, out var status)) continue;
if (!WebUntisLessonAbsenceRow.SelectableStatuses.Contains(status)) continue;
candidates[i].SelectedStatusName = AttendanceDisplay.Label(status);
applied++;
}
Status = suggestions.Count == 0
? "Die KI hat keinen verwertbaren Vorschlag geliefert, die bisherige Vorbelegung bleibt unverändert."
: $"KI-Vorschlag für {applied} von {candidates.Count} Zeilen in \"Übernahme als\" vorbelegt - bitte prüfen.";
}
catch (AiBackendException ex) { Status = ex.Message; }
finally { AiSuggestBusy = false; }
}
[RelayCommand] [RelayCommand]
private void Apply() private void Apply()
{ {
@@ -155,7 +244,7 @@ public partial class WebUntisLessonAbsenceComparisonViewModel : ObservableObject
var studentId = row.AssignedStudent!.Id; var studentId = row.AssignedStudent!.Id;
var entry = _participation.GetBySessionAndStudent(row.SessionId!.Value, studentId) var entry = _participation.GetBySessionAndStudent(row.SessionId!.Value, studentId)
?? new ParticipationEntry { SessionId = row.SessionId.Value, StudentId = studentId }; ?? new ParticipationEntry { SessionId = row.SessionId.Value, StudentId = studentId };
entry.Attendance = row.TargetStatus; entry.Attendance = row.SelectedStatus;
entry.UpdatedAt = DateTime.UtcNow; entry.UpdatedAt = DateTime.UtcNow;
_participation.Save(entry); _participation.Save(entry);
} }
@@ -192,38 +281,16 @@ public partial class WebUntisLessonAbsenceComparisonViewModel : ObservableObject
return filled; return filled;
} }
private static int? StudentKey(Student student)
{
student.ExternalIds ??= [];
return student.ExternalIds.TryGetValue(StudentImportFormats.MasterDataCsv.Value, out var value)
&& int.TryParse(value, out var key) ? key : null;
}
// Groß-/Kleinschreibung, Leerraum und - da die tatsächliche WebUntis-Reihenfolge nicht // Groß-/Kleinschreibung, Leerraum und - da die tatsächliche WebUntis-Reihenfolge nicht
// dokumentiert und schulabhängig unterschiedlich beobachtet wurde - beide Namensreihenfolgen // dokumentiert und schulabhängig unterschiedlich beobachtet wurde - beide Namensreihenfolgen
// werden beim Aufbau von `byName` registriert; hier wird nur normalisiert. // werden beim Aufbau von `byName` registriert; hier wird nur normalisiert.
private static string NameKey(string value) => value.Trim().ToLowerInvariant(); private static string NameKey(string value) => value.Trim().ToLowerInvariant();
private const int FullLessonMinutes = 45; // MapStatus/StudentKey leben jetzt in UntisLessonAbsenceHelper (framework-frei), damit
// UntisComparisonTools (MCP) exakt dieselbe Regel verwendet statt eines eigenen Duplikats, das
// Der Bericht liefert keinen Entschuldigungstext, nur Minutenwerte, ein Bearbeitet-Datum und die // aus dem Tritt geraten könnte.
// (laut Schule) über Klammerung der ENr codierte Entscheidung des Klassenlehrers - ENr in private static AttendanceStatus MapStatus(UntisLessonAbsenceDto absence) =>
// Klammern bedeutet unentschuldigt, ohne Klammern abgeschlossen/entschuldigt. Reihenfolge ist UntisLessonAbsenceHelper.MapStatus(absence);
// wichtig: "nach Hause entlassen" zählt immer als vorzeitige Entlassung, unabhängig von der
// Dauer; darunter zählt jede Fehlzeit unter einer vollen Stunde (45 Min.) immer als Verspätung
// oder sonstiger Teilverlust, nie als komplette Abwesenheit - der Text "Verspätung" allein ist
// laut Schule nicht zuverlässig genug, deshalb primär über die Minutenschwelle erkannt.
private static AttendanceStatus MapStatus(UntisLessonAbsenceDto absence)
{
if (IsEarlyRelease(absence)) return AttendanceStatus.LeftDuringClass;
if (absence.AbsentMinutes < FullLessonMinutes) return AttendanceStatus.Late;
if (string.IsNullOrWhiteSpace(absence.HandledOn)) return AttendanceStatus.ExcusePending;
if (absence.ExternKey is null) return AttendanceStatus.ExcusePending;
return absence.ExternKeyInParentheses ? AttendanceStatus.Unexcused : AttendanceStatus.Excused;
}
private static bool IsEarlyRelease(UntisLessonAbsenceDto absence) =>
absence.Reason?.Contains("entlassen", StringComparison.OrdinalIgnoreCase) == true;
// Nutzt dieselben deutschen Bezeichnungen wie die reguläre Mitarbeitserfassung // Nutzt dieselben deutschen Bezeichnungen wie die reguläre Mitarbeitserfassung
// (AttendanceDisplay.Label), statt eigene Statustexte zu erfinden. // (AttendanceDisplay.Label), statt eigene Statustexte zu erfinden.
@@ -12,6 +12,7 @@ using LehrerApp.Desktop.ViewModels.Students;
using LehrerApp.Desktop.ViewModels.Workload; using LehrerApp.Desktop.ViewModels.Workload;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using System.Collections.ObjectModel; using System.Collections.ObjectModel;
using System.ComponentModel;
namespace LehrerApp.Desktop.ViewModels; namespace LehrerApp.Desktop.ViewModels;
@@ -38,6 +39,9 @@ public partial class MainWindowViewModel : ObservableObject
public bool IsClassTeacherActive => ActiveNavItem == NavItem.ClassTeacher; public bool IsClassTeacherActive => ActiveNavItem == NavItem.ClassTeacher;
public bool IsSettingsActive => ActiveNavItem == NavItem.Settings; public bool IsSettingsActive => ActiveNavItem == NavItem.Settings;
public GroupDetailViewModel? CurrentGroupDetail => CurrentPage as GroupDetailViewModel;
public bool CanPersonalizeWorksheet => CurrentGroupDetail?.Group is not null;
public MainWindowViewModel(IServiceProvider services, public MainWindowViewModel(IServiceProvider services,
DashboardViewModel dashboard, SchoolYearService sy, DashboardViewModel dashboard, SchoolYearService sy,
SyncStatusViewModel syncStatus, NotificationService notifications, AppLockViewModel appLock, SyncStatusViewModel syncStatus, NotificationService notifications, AppLockViewModel appLock,
@@ -103,6 +107,25 @@ public partial class MainWindowViewModel : ObservableObject
} }
} }
// GroupDetailViewModel.Group wird erst nach dem Wechsel von CurrentPage per LoadGroup gesetzt
// (siehe Kommentar in NavigateToGroupDetail) - ohne dieses Abonnement bliebe
// CanPersonalizeWorksheet bis zum nächsten Seitenwechsel auf dem alten Stand.
private GroupDetailViewModel? _observedGroupDetail;
partial void OnCurrentPageChanged(ObservableObject? value)
{
if (_observedGroupDetail is not null) _observedGroupDetail.PropertyChanged -= OnGroupDetailPropertyChanged;
_observedGroupDetail = value as GroupDetailViewModel;
if (_observedGroupDetail is not null) _observedGroupDetail.PropertyChanged += OnGroupDetailPropertyChanged;
OnPropertyChanged(nameof(CurrentGroupDetail));
OnPropertyChanged(nameof(CanPersonalizeWorksheet));
}
private void OnGroupDetailPropertyChanged(object? sender, PropertyChangedEventArgs e)
{
if (e.PropertyName == nameof(GroupDetailViewModel.Group)) OnPropertyChanged(nameof(CanPersonalizeWorksheet));
}
partial void OnActiveNavItemChanged(NavItem value) partial void OnActiveNavItemChanged(NavItem value)
{ {
OnPropertyChanged(nameof(IsDashboardActive)); OnPropertyChanged(nameof(IsDashboardActive));
@@ -612,6 +612,7 @@ public partial class TimetableViewModel : ObservableObject
private bool HasUnhandledHomework(Guid groupId, DateOnly date) private bool HasUnhandledHomework(Guid groupId, DateOnly date)
{ {
var previousLesson = _lessons.GetByGroupAndRange(groupId, date.AddDays(-120), date.AddDays(-1)) var previousLesson = _lessons.GetByGroupAndRange(groupId, date.AddDays(-120), date.AddDays(-1))
.Where(l => l.Status != LessonStatus.Cancelled)
.OrderByDescending(l => l.Date).ThenByDescending(l => l.LessonNumber ?? 0) .OrderByDescending(l => l.Date).ThenByDescending(l => l.LessonNumber ?? 0)
.FirstOrDefault(); .FirstOrDefault();
if (previousLesson is null || string.IsNullOrWhiteSpace(previousLesson.Homework)) return false; if (previousLesson is null || string.IsNullOrWhiteSpace(previousLesson.Homework)) return false;
@@ -0,0 +1,42 @@
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using LehrerApp.Desktop.Services;
using LehrerApp.Desktop.Services.Mcp;
namespace LehrerApp.Desktop.ViewModels.Settings;
public partial class SettingsViewModel
{
// ── MCP-Server (lokal, Phase 1) ──────────────────────────────────────────
[ObservableProperty] private bool _mcpEnabled;
private void LoadMcpSettings() => McpEnabled = _mcpSettings.Enabled;
// Kein Live-Reload (siehe McpServerHostedService/Planungsdokument) - der Pipe-Listener wird
// nur beim App-Start eingerichtet, deshalb wirkt eine Änderung hier erst nach Neustart.
partial void OnMcpEnabledChanged(bool value) => _mcpSettings.SetEnabled(value);
// ── MCP: Registrierung bei Claude Desktop ─────────────────────────────────
[ObservableProperty] private string _mcpRegistrationStatus = "";
[ObservableProperty] private bool _mcpIsRegisteredWithClaude;
private void LoadMcpRegistrationStatus() => McpIsRegisteredWithClaude = _mcpRegistration.IsRegistered();
[RelayCommand]
private void RegisterWithClaudeDesktop()
{
var result = _mcpRegistration.Register();
McpRegistrationStatus = result.Message;
McpIsRegisteredWithClaude = _mcpRegistration.IsRegistered();
}
[RelayCommand]
private void UnregisterFromClaudeDesktop()
{
var result = _mcpRegistration.Unregister();
McpRegistrationStatus = result.Message;
McpIsRegisteredWithClaude = _mcpRegistration.IsRegistered();
}
}
@@ -0,0 +1,60 @@
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using LehrerApp.Templating;
using System.Collections.ObjectModel;
namespace LehrerApp.Desktop.ViewModels.Settings;
public partial class SettingsViewModel
{
[ObservableProperty] private string _worksheetTemplateStatus = "";
public ObservableCollection<LetterTemplateListItem> WorksheetTemplateList { get; } = [];
private void LoadWorksheetTemplates()
{
WorksheetTemplateList.Clear();
foreach (var template in _worksheetTemplates.Store.GetTemplates()) WorksheetTemplateList.Add(CreateWorksheetItem(template));
}
public void ImportWorksheetTemplate(string path)
{
WorksheetTemplateStatus = "";
try
{
var template = _worksheetTemplates.Store.Import(path);
LoadWorksheetTemplates();
WorksheetTemplateStatus = $"„{template.Name}“ wurde geprüft und lokal importiert.";
}
catch (Exception ex) when (ex is IOException or InvalidDataException or UnauthorizedAccessException or TemplateValidationException)
{ WorksheetTemplateStatus = $"Import fehlgeschlagen: {ex.Message}"; }
}
[RelayCommand]
private void ValidateWorksheetTemplate(LetterTemplateListItem? item)
{
if (item is null) return;
try
{
var refreshed = CreateWorksheetItem(item.Model);
var index = WorksheetTemplateList.IndexOf(item);
if (index >= 0) WorksheetTemplateList[index] = refreshed;
WorksheetTemplateStatus = $"„{item.Name}“ ist gültig (Schema {refreshed.SchemaVersion}).";
}
catch (Exception ex) when (ex is IOException or InvalidDataException or TemplateValidationException)
{ WorksheetTemplateStatus = $"„{item.Name}“ ist ungültig: {ex.Message}"; }
}
[RelayCommand]
private void DeleteWorksheetTemplate(LetterTemplateListItem? item)
{
if (item is null) return;
_worksheetTemplates.Store.Delete(item.Id); WorksheetTemplateList.Remove(item); WorksheetTemplateStatus = "Vorlage gelöscht.";
}
private LetterTemplateListItem CreateWorksheetItem(InstalledTemplate template)
{
var loaded = _worksheetTemplates.Store.Load(template);
return new(template, loaded.Manifest.SchemaVersion, loaded.Manifest.Placeholders.Count(x => !x.IsConstant),
loaded.Manifest.Placeholders.Count(x => !x.IsConstant && x.Required));
}
}
@@ -40,6 +40,7 @@ public enum SettingsTab
WebUntis = 13, WebUntis = 13,
Appearance = 14, Appearance = 14,
Trash = 15, Trash = 15,
Mcp = 16,
} }
// ── Haupt-ViewModel ─────────────────────────────────────────────────────────── // ── Haupt-ViewModel ───────────────────────────────────────────────────────────
@@ -61,6 +62,7 @@ public partial class SettingsViewModel : ObservableObject
private readonly IStudentRepository _students; private readonly IStudentRepository _students;
private readonly IShorthandCodeRepository _shorthandCodes; private readonly IShorthandCodeRepository _shorthandCodes;
private readonly TemplateStore _letterTemplates; private readonly TemplateStore _letterTemplates;
private readonly WorksheetTemplateStore _worksheetTemplates;
[ObservableProperty] private int _activeTabIndex; [ObservableProperty] private int _activeTabIndex;
@@ -73,6 +75,8 @@ public partial class SettingsViewModel : ObservableObject
private readonly ISupervisionDutyRepository _supervisionDuties; private readonly ISupervisionDutyRepository _supervisionDuties;
private readonly AiSettingsService _aiSettings; private readonly AiSettingsService _aiSettings;
private readonly AiPlanningService _aiPlanning; private readonly AiPlanningService _aiPlanning;
private readonly McpSettingsService _mcpSettings;
private readonly Services.Mcp.McpClientRegistrationService _mcpRegistration;
private readonly WebUntisSettingsService _untisSettings; private readonly WebUntisSettingsService _untisSettings;
private readonly WebUntisIntegrationService? _untisIntegration; private readonly WebUntisIntegrationService? _untisIntegration;
private readonly UntisSyncService? _untisSync; private readonly UntisSyncService? _untisSync;
@@ -99,7 +103,9 @@ public partial class SettingsViewModel : ObservableObject
IShorthandCodeRepository shorthandCodes, ISchoolHolidayRepository schoolHolidays, IShorthandCodeRepository shorthandCodes, ISchoolHolidayRepository schoolHolidays,
SchoolCalendarSettingsService calendarSettings, PeriodScheduleService periodSchedule, SchoolCalendarSettingsService calendarSettings, PeriodScheduleService periodSchedule,
ISupervisionDutyRepository supervisionDuties, TemplateStore letterTemplates, ISupervisionDutyRepository supervisionDuties, TemplateStore letterTemplates,
AiSettingsService aiSettings, AiPlanningService aiPlanning, WorksheetTemplateStore worksheetTemplates,
AiSettingsService aiSettings, AiPlanningService aiPlanning, McpSettingsService mcpSettings,
Services.Mcp.McpClientRegistrationService mcpRegistration,
WebUntisSettingsService untisSettings, WebUntisSettingsService untisSettings,
AnnualPlanSettingsService annualPlanSettings, AnnualPlanSettingsService annualPlanSettings,
SyncSettingsService syncSettings, SyncAuthService syncAuth, EventQueue eventQueue, SyncSettingsService syncSettings, SyncAuthService syncAuth, EventQueue eventQueue,
@@ -135,8 +141,11 @@ public partial class SettingsViewModel : ObservableObject
_periodSchedule = periodSchedule; _periodSchedule = periodSchedule;
_supervisionDuties = supervisionDuties; _supervisionDuties = supervisionDuties;
_letterTemplates = letterTemplates; _letterTemplates = letterTemplates;
_worksheetTemplates = worksheetTemplates;
_aiSettings = aiSettings; _aiSettings = aiSettings;
_aiPlanning = aiPlanning; _aiPlanning = aiPlanning;
_mcpSettings = mcpSettings;
_mcpRegistration = mcpRegistration;
_untisSettings = untisSettings; _untisSettings = untisSettings;
_untisIntegration = untisIntegration; _untisIntegration = untisIntegration;
_untisSync = untisSync; _untisSync = untisSync;
@@ -163,7 +172,10 @@ public partial class SettingsViewModel : ObservableObject
LoadPeriodTimes(); LoadPeriodTimes();
LoadSupervisionDuties(); LoadSupervisionDuties();
LoadLetterTemplates(); LoadLetterTemplates();
LoadWorksheetTemplates();
LoadAiSettings(); LoadAiSettings();
LoadMcpSettings();
LoadMcpRegistrationStatus();
LoadUntisSettings(); LoadUntisSettings();
LoadAnnualPlanSettings(); LoadAnnualPlanSettings();
LoadSyncSettings(); LoadSyncSettings();
@@ -0,0 +1,35 @@
using CommunityToolkit.Mvvm.ComponentModel;
using LehrerApp.Desktop.Services;
namespace LehrerApp.Desktop.ViewModels.Students;
public sealed record AttendanceCalendarSizeChoice(AttendanceCalendarSize Value, string DisplayName);
public partial class AttendanceCalendarConfigurationViewModel : ObservableObject
{
[ObservableProperty] private DateTimeOffset? _startMonth;
[ObservableProperty] private int _monthCount;
[ObservableProperty] private AttendanceCalendarSizeChoice _selectedSize;
public IReadOnlyList<int> MonthCounts { get; } = [1, 2, 3];
public IReadOnlyList<AttendanceCalendarSizeChoice> Sizes { get; } =
[
new(AttendanceCalendarSize.Small, "Klein"),
new(AttendanceCalendarSize.Medium, "Standard"),
new(AttendanceCalendarSize.Large, "Groß"),
];
public AttendanceCalendarConfigurationViewModel(AttendanceCalendarOptions options)
{
StartMonth = new DateTimeOffset(options.NormalizedStartMonth.ToDateTime(TimeOnly.MinValue));
MonthCount = options.NormalizedMonthCount;
SelectedSize = Sizes.First(s => s.Value == options.Size);
}
public AttendanceCalendarOptions BuildResult()
{
var date = DateOnly.FromDateTime((StartMonth ?? DateTimeOffset.Now).LocalDateTime);
return new AttendanceCalendarOptions(new DateOnly(date.Year, date.Month, 1), MonthCount,
SelectedSize.Value);
}
}
@@ -1,6 +1,7 @@
using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.ComponentModel;
using LehrerApp.Core.Interfaces; using LehrerApp.Core.Interfaces;
using LehrerApp.Core.Models; using LehrerApp.Core.Models;
using LehrerApp.Desktop.Services;
using LehrerApp.Templating; using LehrerApp.Templating;
using System.Collections.ObjectModel; using System.Collections.ObjectModel;
@@ -11,31 +12,54 @@ public partial class CreateLetterDialogViewModel : ObservableObject
private readonly Student _student; private readonly Student _student;
private readonly TemplateStore _templates; private readonly TemplateStore _templates;
private readonly ITemplateRenderer _renderer; private readonly ITemplateRenderer _renderer;
private readonly Func<AttendanceCalendarOptions, DrawingValue>? _attendanceCalendarFactory;
private readonly Func<AttendanceCalendarOptions, DrawingValue>? _absenceDayListFactory;
private readonly Func<AttendanceCalendarOptions, CancellationToken, Task>? _attendanceDataRefresher;
private AttendanceCalendarOptions _attendanceCalendarOptions = new(
new DateOnly(DateTime.Today.Year, DateTime.Today.Month, 1), 1);
[ObservableProperty] private LetterTemplateChoice? _selectedTemplate; [ObservableProperty] private LetterTemplateChoice? _selectedTemplate;
[ObservableProperty] private LetterContactChoice? _selectedContact; [ObservableProperty] private LetterContactChoice? _selectedContact;
[ObservableProperty] private LetterGroupChoice? _selectedGroup; [ObservableProperty] private LetterGroupChoice? _selectedGroup;
[ObservableProperty] private DateTimeOffset? _letterDate = DateTimeOffset.Now; [ObservableProperty] private DateTimeOffset? _letterDate = DateTimeOffset.Now;
[ObservableProperty] private string _anrede = "";
[ObservableProperty] private string _letterText = ""; [ObservableProperty] private string _letterText = "";
[ObservableProperty] private string _teacherName = ""; [ObservableProperty] private string _teacherName = "";
[ObservableProperty] private string _generationError = ""; [ObservableProperty] private string _generationError = "";
[ObservableProperty] private bool _canGenerate; [ObservableProperty] private bool _canGenerate;
[ObservableProperty] private bool _usesAttendanceCalendar;
[ObservableProperty] private bool _usesAbsenceDayList;
[ObservableProperty] private bool _attendanceCalendarConfigured;
[ObservableProperty] private string _attendanceCalendarSummary = "1 Monat · Standardgröße";
[ObservableProperty] private bool _isRefreshingAttendanceData;
[ObservableProperty] private string _attendanceRefreshError = "";
public string StudentName => _student.FullName; public string StudentName => _student.FullName;
public ObservableCollection<LetterTemplateChoice> Templates { get; } = []; public ObservableCollection<LetterTemplateChoice> Templates { get; } = [];
public ObservableCollection<LetterContactChoice> Contacts { get; } = []; public ObservableCollection<LetterContactChoice> Contacts { get; } = [];
public ObservableCollection<LetterGroupChoice> Groups { get; } = []; public ObservableCollection<LetterGroupChoice> Groups { get; } = [];
public ObservableCollection<LetterGenerationIssue> Issues { get; } = []; public ObservableCollection<LetterGenerationIssue> Issues { get; } = [];
public ObservableCollection<LetterPlaceholderInput> CustomPlaceholders { get; } = [];
public bool HasIssues => Issues.Count > 0; public bool HasIssues => Issues.Count > 0;
public bool HasCustomPlaceholders => CustomPlaceholders.Count > 0;
public bool HasNoTemplates => Templates.Count == 0; public bool HasNoTemplates => Templates.Count == 0;
public bool HasNoContacts => Contacts.Count == 0; public bool HasNoContacts => Contacts.Count == 0;
public string AddressPreview => LetterPlaceholderBuilder.FormatAddress(SelectedContact?.Model);
public bool HasAddressPreview => !string.IsNullOrWhiteSpace(AddressPreview);
public bool UsesAttendanceAdvancedContent => UsesAttendanceCalendar || UsesAbsenceDayList;
public string SuggestedFileName => SanitizeFileName( public string SuggestedFileName => SanitizeFileName(
$"{SelectedTemplate?.Name ?? "Elternbrief"}_{_student.LastName}_{_student.FirstName}.pdf"); $"{SelectedTemplate?.Name ?? "Elternbrief"}_{_student.LastName}_{_student.FirstName}.pdf");
public CreateLetterDialogViewModel(Student student, TemplateStore templates, ITemplateRenderer renderer, public CreateLetterDialogViewModel(Student student, TemplateStore templates, ITemplateRenderer renderer,
IGroupMembershipRepository memberships, IGroupRepository groups) IGroupMembershipRepository memberships, IGroupRepository groups,
Func<AttendanceCalendarOptions, DrawingValue>? attendanceCalendarFactory = null,
Func<AttendanceCalendarOptions, DrawingValue>? absenceDayListFactory = null,
Func<AttendanceCalendarOptions, CancellationToken, Task>? attendanceDataRefresher = null)
{ {
_student = student; _templates = templates; _renderer = renderer; _student = student; _templates = templates; _renderer = renderer;
_attendanceCalendarFactory = attendanceCalendarFactory;
_absenceDayListFactory = absenceDayListFactory;
_attendanceDataRefresher = attendanceDataRefresher;
foreach (var template in templates.GetTemplates()) Templates.Add(new(template)); foreach (var template in templates.GetTemplates()) Templates.Add(new(template));
foreach (var contact in student.Contacts.Where(c => !c.InvalidSince.HasValue).OrderBy(c => c.Name)) Contacts.Add(new(contact)); foreach (var contact in student.Contacts.Where(c => !c.InvalidSince.HasValue).OrderBy(c => c.Name)) Contacts.Add(new(contact));
foreach (var membership in memberships.GetByStudent(student.Id)) foreach (var membership in memberships.GetByStudent(student.Id))
@@ -44,10 +68,33 @@ public partial class CreateLetterDialogViewModel : ObservableObject
RefreshValidation(); RefreshValidation();
} }
partial void OnSelectedTemplateChanged(LetterTemplateChoice? value) { OnPropertyChanged(nameof(SuggestedFileName)); RefreshValidation(); } partial void OnSelectedTemplateChanged(LetterTemplateChoice? value)
partial void OnSelectedContactChanged(LetterContactChoice? value) => RefreshValidation(); {
OnPropertyChanged(nameof(SuggestedFileName));
UsesAttendanceCalendar = TemplateUsesPlaceholder(value,
StudentAttendanceCalendarDrawingBuilder.PlaceholderName);
UsesAbsenceDayList = TemplateUsesPlaceholder(value,
StudentAbsenceDayListDrawingBuilder.PlaceholderName);
OnPropertyChanged(nameof(UsesAttendanceAdvancedContent));
AttendanceCalendarConfigured = false;
ResetAttendanceCalendarOptions();
RebuildCustomPlaceholders(value);
RefreshValidation();
}
partial void OnSelectedContactChanged(LetterContactChoice? value)
{
Anrede = value?.Model.LetterSalutation ?? "";
OnPropertyChanged(nameof(AddressPreview));
OnPropertyChanged(nameof(HasAddressPreview));
RefreshValidation();
}
partial void OnAnredeChanged(string value) => RefreshValidation();
partial void OnSelectedGroupChanged(LetterGroupChoice? value) => RefreshValidation(); partial void OnSelectedGroupChanged(LetterGroupChoice? value) => RefreshValidation();
partial void OnLetterDateChanged(DateTimeOffset? value) => RefreshValidation(); partial void OnLetterDateChanged(DateTimeOffset? value)
{
if (!AttendanceCalendarConfigured) ResetAttendanceCalendarOptions();
RefreshValidation();
}
partial void OnLetterTextChanged(string value) => RefreshValidation(); partial void OnLetterTextChanged(string value) => RefreshValidation();
partial void OnTeacherNameChanged(string value) => RefreshValidation(); partial void OnTeacherNameChanged(string value) => RefreshValidation();
@@ -90,25 +137,111 @@ public partial class CreateLetterDialogViewModel : ObservableObject
private IReadOnlyDictionary<string, PlaceholderValue> BuildValues() private IReadOnlyDictionary<string, PlaceholderValue> BuildValues()
{ {
var contact = SelectedContact?.Model; var group = SelectedGroup?.Model; var values = LetterPlaceholderBuilder.BuildStandardValues(
var cityLine = string.Join(" ", new[] { contact?.PostalCode, contact?.City }.Where(x => !string.IsNullOrWhiteSpace(x))); _student, SelectedContact?.Model, SelectedGroup?.Model,
var address = string.Join(Environment.NewLine, new[] { contact?.Name, contact?.Street, cityLine }.Where(x => !string.IsNullOrWhiteSpace(x))); DateOnly.FromDateTime((LetterDate ?? DateTimeOffset.Now).LocalDateTime), LetterText, TeacherName,
var date = DateOnly.FromDateTime((LetterDate ?? DateTimeOffset.Now).LocalDateTime); UsesAttendanceCalendar ? _attendanceCalendarFactory?.Invoke(_attendanceCalendarOptions) : null,
return new Dictionary<string, PlaceholderValue>(StringComparer.Ordinal) UsesAbsenceDayList ? _absenceDayListFactory?.Invoke(_attendanceCalendarOptions) : null);
{ values["Anrede"] = new TextValue(Anrede); values["Letter.Salutation"] = new TextValue(Anrede);
["Datum"] = new DateValue(date), ["CurrentDate"] = new DateValue(date), foreach (var custom in CustomPlaceholders) values[custom.Name] = custom.ToPlaceholderValue();
["Empfaenger"] = new TextValue(contact?.Name ?? ""), ["Anrede"] = new TextValue(contact?.LetterSalutation ?? ""), return values;
["Brieftext"] = new MultilineValue(LetterText), ["LehrerName"] = new TextValue(TeacherName),
["Student.FirstName"] = new TextValue(_student.FirstName), ["Student.LastName"] = new TextValue(_student.LastName),
["Contact.Name"] = new TextValue(contact?.Name ?? ""), ["Contact.Address"] = new MultilineValue(address),
["Contact.Street"] = new TextValue(contact?.Street ?? ""), ["Contact.PostalCode"] = new TextValue(contact?.PostalCode ?? ""),
["Contact.City"] = new TextValue(contact?.City ?? ""), ["Letter.Salutation"] = new TextValue(contact?.LetterSalutation ?? ""),
["Group.Name"] = new TextValue(group?.Name ?? ""), ["SchoolYear"] = new TextValue(group?.SchoolYear ?? ""),
};
} }
private static bool IsEmpty(PlaceholderValue value) => value switch private void RebuildCustomPlaceholders(LetterTemplateChoice? choice)
{ TextValue x => string.IsNullOrWhiteSpace(x.Value), MultilineValue x => string.IsNullOrWhiteSpace(x.Value), _ => false }; {
foreach (var existing in CustomPlaceholders) existing.PropertyChanged -= OnCustomPlaceholderChanged;
CustomPlaceholders.Clear();
if (choice is not null)
{
try
{
var loaded = _templates.Load(choice.Model);
foreach (var placeholder in loaded.Manifest.Placeholders.Where(p => !p.IsConstant
&& !StandardPlaceholderNames.Contains(p.Name) && p.Type is PlaceholderType.Text
or PlaceholderType.Multiline or PlaceholderType.Date or PlaceholderType.Number))
{
var input = new LetterPlaceholderInput(placeholder.Name, placeholder.Type);
input.PropertyChanged += OnCustomPlaceholderChanged;
CustomPlaceholders.Add(input);
}
}
catch (Exception ex) when (ex is IOException or InvalidDataException or TemplateValidationException) { }
}
OnPropertyChanged(nameof(HasCustomPlaceholders));
}
private void OnCustomPlaceholderChanged(object? sender, System.ComponentModel.PropertyChangedEventArgs e) => RefreshValidation();
private static readonly HashSet<string> StandardPlaceholderNames = new(StringComparer.Ordinal)
{
"Datum", "CurrentDate", "Empfaenger", "Anrede", "Brieftext", "LehrerName",
"Student.FirstName", "Student.LastName", "Student.Name", "Contact.Name", "Contact.Address", "Contact.Street",
"Contact.PostalCode", "Contact.City", "Letter.Salutation", "Group.Name", "SchoolYear",
StudentAttendanceCalendarDrawingBuilder.PlaceholderName, StudentAbsenceDayListDrawingBuilder.PlaceholderName,
};
public AttendanceCalendarOptions GetAttendanceCalendarOptions() => _attendanceCalendarOptions;
public async Task SetAttendanceCalendarOptionsAsync(AttendanceCalendarOptions options, CancellationToken token = default)
{
_attendanceCalendarOptions = options with { StartMonth = options.NormalizedStartMonth,
MonthCount = options.NormalizedMonthCount };
AttendanceCalendarConfigured = true;
AttendanceCalendarSummary = FormatAttendanceCalendarSummary(_attendanceCalendarOptions);
AttendanceRefreshError = "";
if (_attendanceDataRefresher is not null)
{
IsRefreshingAttendanceData = true;
try { await _attendanceDataRefresher(_attendanceCalendarOptions, token); }
catch (WebUntisIntegrationException ex)
{ AttendanceRefreshError = $"WebUntis-Daten konnten nicht aktualisiert werden: {ex.Message}"; }
finally { IsRefreshingAttendanceData = false; }
}
RefreshValidation();
}
private void ResetAttendanceCalendarOptions()
{
var date = DateOnly.FromDateTime((LetterDate ?? DateTimeOffset.Now).LocalDateTime);
_attendanceCalendarOptions = new AttendanceCalendarOptions(new(date.Year, date.Month, 1), 1);
AttendanceCalendarSummary = FormatAttendanceCalendarSummary(_attendanceCalendarOptions);
}
private bool TemplateUsesPlaceholder(LetterTemplateChoice? choice, string placeholderName)
{
if (choice is null) return false;
try
{
var loaded = _templates.Load(choice.Model);
return UsesPlaceholder(loaded.Layout, placeholderName) ||
(loaded.ContinuationLayout is not null && UsesPlaceholder(loaded.ContinuationLayout, placeholderName));
}
catch (Exception ex) when (ex is IOException or InvalidDataException or TemplateValidationException)
{
return false;
}
}
private static bool UsesPlaceholder(TemplateLayout layout, string placeholderName) =>
layout.Elements.Concat(layout.PageTemplates.SelectMany(p => p.Elements))
.Concat(layout.ContentFlows.SelectMany(f => f.Elements))
.Any(e => e is DrawBoxElement draw && draw.Placeholder == placeholderName ||
e is FlowDrawBoxElement flow && flow.Placeholder == placeholderName);
private static string FormatAttendanceCalendarSummary(AttendanceCalendarOptions options)
{
var month = options.NormalizedStartMonth.ToString("MMMM yyyy",
System.Globalization.CultureInfo.GetCultureInfo("de-DE"));
var size = options.Size switch
{
AttendanceCalendarSize.Small => "Klein",
AttendanceCalendarSize.Large => "Groß",
_ => "Standard",
};
return $"Ab {month} · {options.NormalizedMonthCount} Monat{(options.NormalizedMonthCount == 1 ? "" : "e")} · {size}";
}
private static bool IsEmpty(PlaceholderValue value) => LetterPlaceholderBuilder.IsEmpty(value);
private static string SanitizeFileName(string value) private static string SanitizeFileName(string value)
{ foreach (var character in Path.GetInvalidFileNameChars()) value = value.Replace(character, '_'); return value; } { foreach (var character in Path.GetInvalidFileNameChars()) value = value.Replace(character, '_'); return value; }
} }
@@ -120,3 +253,33 @@ public sealed class LetterContactChoice(Contact model) { public Contact Model {
public sealed class LetterGroupChoice(LearningGroup model) { public LearningGroup Model { get; } = model; public string Display => $"{Model.Name} · {Model.SchoolYear}"; } public sealed class LetterGroupChoice(LearningGroup model) { public LearningGroup Model { get; } = model; public string Display => $"{Model.Name} · {Model.SchoolYear}"; }
public sealed class LetterGenerationIssue(string message, bool isStrong) public sealed class LetterGenerationIssue(string message, bool isStrong)
{ public string Icon { get; } = isStrong ? "⚠" : "ⓘ"; public string Message { get; } = message; public string Color { get; } = isStrong ? "#D97706" : "#6B7280"; } { public string Icon { get; } = isStrong ? "⚠" : "ⓘ"; public string Message { get; } = message; public string Color { get; } = isStrong ? "#D97706" : "#6B7280"; }
public sealed partial class LetterPlaceholderInput : ObservableObject
{
public string Name { get; }
public PlaceholderType Type { get; }
public string Label => Type switch
{
PlaceholderType.Date => $"{Name} (Datum)",
PlaceholderType.Number => $"{Name} (Zahl)",
_ => Name,
};
public bool IsTextType => Type == PlaceholderType.Text;
public bool IsMultilineType => Type == PlaceholderType.Multiline;
public bool IsDateType => Type == PlaceholderType.Date;
public bool IsNumberType => Type == PlaceholderType.Number;
[ObservableProperty] private string _textValue = "";
[ObservableProperty] private DateTimeOffset? _dateValue;
[ObservableProperty] private decimal? _numberValue;
public LetterPlaceholderInput(string name, PlaceholderType type) { Name = name; Type = type; }
public PlaceholderValue ToPlaceholderValue() => Type switch
{
PlaceholderType.Multiline => new MultilineValue(TextValue),
PlaceholderType.Date => new DateValue(DateValue.HasValue ? DateOnly.FromDateTime(DateValue.Value.LocalDateTime) : default),
PlaceholderType.Number => new NumberValue(NumberValue ?? 0),
_ => new TextValue(TextValue),
};
}
@@ -0,0 +1,25 @@
using CommunityToolkit.Mvvm.ComponentModel;
using LehrerApp.Desktop.Services;
namespace LehrerApp.Desktop.ViewModels.Students;
public partial class SelectContactDialogViewModel : ObservableObject
{
[ObservableProperty] private LetterContactChoice? _selectedContact;
public IReadOnlyList<LetterContactChoice> Contacts { get; }
public string AddressPreview => LetterPlaceholderBuilder.FormatAddress(SelectedContact?.Model);
public bool HasAddressPreview => !string.IsNullOrWhiteSpace(AddressPreview);
public SelectContactDialogViewModel(IReadOnlyList<LetterContactChoice> contacts, LetterContactChoice? current)
{
Contacts = contacts;
SelectedContact = current ?? contacts.FirstOrDefault();
}
partial void OnSelectedContactChanged(LetterContactChoice? value)
{
OnPropertyChanged(nameof(AddressPreview));
OnPropertyChanged(nameof(HasAddressPreview));
}
}
@@ -0,0 +1,48 @@
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using LehrerApp.Core.Interfaces;
using LehrerApp.Core.Models;
using LehrerApp.Desktop.ViewModels.Groups;
using System.Collections.ObjectModel;
namespace LehrerApp.Desktop.ViewModels.Students;
/// <summary>Einfacher "einen Schüler wählen"-Dialog für Einstiegspunkte ohne bereits geöffneten
/// Schüler (z.B. das Formulare-Menü) - anders als <see cref="Groups.AddStudentToGroupDialogViewModel"/>
/// ohne Gruppenbezug/Mitgliedschaftszeitraum, einfach alle Schüler durchsuchbar.</summary>
public partial class StudentPickerDialogViewModel : ObservableObject
{
private readonly IStudentRepository _students;
[ObservableProperty] private string _searchText = "";
[ObservableProperty] private StudentPickerItem? _selectedStudent;
[ObservableProperty] private string _validationMessage = "";
public ObservableCollection<StudentPickerItem> Students { get; } = [];
public Student? Result { get; private set; }
public StudentPickerDialogViewModel(IStudentRepository students)
{
_students = students;
LoadStudents();
}
partial void OnSearchTextChanged(string value) => LoadStudents();
private void LoadStudents()
{
var matches = _students.GetAll()
.Where(s => string.IsNullOrWhiteSpace(SearchText) ||
s.FullName.Contains(SearchText, StringComparison.OrdinalIgnoreCase))
.OrderBy(s => s.FullName, StringComparer.CurrentCultureIgnoreCase);
Students.Clear();
foreach (var student in matches) Students.Add(new StudentPickerItem(student));
}
[RelayCommand]
private void Select()
{
if (SelectedStudent is null) { ValidationMessage = "Bitte einen Schüler auswählen."; return; }
Result = _students.GetById(SelectedStudent.Id);
}
}
@@ -436,6 +436,11 @@
Background="Transparent" BorderThickness="0" Padding="6,2" Background="Transparent" BorderThickness="0" Padding="6,2"
Foreground="{DynamicResource AppAccentTextBrush}"/> Foreground="{DynamicResource AppAccentTextBrush}"/>
</Grid> </Grid>
<ComboBox ItemsSource="{Binding CalendarScopes}"
SelectedItem="{Binding SelectedCalendarScope, Mode=TwoWay}"
DisplayMemberBinding="{Binding DisplayName}"
HorizontalAlignment="Stretch"
AutomationProperties.Name="Schüler für Monatskalender auswählen"/>
<ItemsControl ItemsSource="{Binding CompactMonthDays}" HorizontalAlignment="Center"> <ItemsControl ItemsSource="{Binding CompactMonthDays}" HorizontalAlignment="Center">
<ItemsControl.ItemsPanel> <ItemsControl.ItemsPanel>
<ItemsPanelTemplate><UniformGrid Columns="7"/></ItemsPanelTemplate> <ItemsPanelTemplate><UniformGrid Columns="7"/></ItemsPanelTemplate>
@@ -20,6 +20,28 @@
<Setter Property="BorderThickness" Value="2"/> <Setter Property="BorderThickness" Value="2"/>
<Setter Property="BorderBrush" Value="{DynamicResource SystemAccentColor}"/> <Setter Property="BorderBrush" Value="{DynamicResource SystemAccentColor}"/>
</Style> </Style>
<!-- Handlungsbedarf-Karte: Schweregrad-Streifen (nur Auffälligkeiten) und die rot
hervorgehobene Fehlzeitenquote — ersetzen die früheren fest kodierten Hex-Farben
(#D32F2F/#F59E0B, Foreground="Red") durch die validierte Status-Palette. -->
<Style Selector="Border.severitybar">
<Setter Property="Width" Value="4"/>
<Setter Property="Background" Value="Transparent"/>
</Style>
<Style Selector="Border.severitybar.high">
<Setter Property="Background" Value="{DynamicResource AppStatusDangerBrush}"/>
</Style>
<Style Selector="Border.severitybar.medium">
<Setter Property="Background" Value="{DynamicResource AppStatusWarningBrush}"/>
</Style>
<Style Selector="TextBlock.attentionTrailing">
<Setter Property="FontSize" Value="10"/>
<Setter Property="Opacity" Value="0.6"/>
</Style>
<Style Selector="TextBlock.attentionTrailing.warning">
<Setter Property="FontSize" Value="12"/>
<Setter Property="Opacity" Value="1"/>
<Setter Property="Foreground" Value="{DynamicResource AppStatusDangerBrush}"/>
</Style>
</UserControl.Styles> </UserControl.Styles>
<ScrollViewer Padding="24"> <ScrollViewer Padding="24">
@@ -142,11 +164,12 @@
<TextBlock Text="HEUTE UND HANDLUNGSBEDARF" FontSize="11" FontWeight="Bold" Opacity="0.5" <TextBlock Text="HEUTE UND HANDLUNGSBEDARF" FontSize="11" FontWeight="Bold" Opacity="0.5"
Margin="2,2,0,-8"/> Margin="2,2,0,-8"/>
<Grid ColumnDefinitions="3*,2*" RowDefinitions="Auto,Auto,Auto,Auto,Auto,Auto,Auto"> <!-- 7 Kacheln (today/tasks/calendar/examload/upcoming/groups/attention) auf 2 Spalten -> 4 Zeilen. -->
<Grid ColumnDefinitions="3*,2*" RowDefinitions="Auto,Auto,Auto,Auto">
<!-- Heutige Stunden --> <!-- Heutige Stunden -->
<Border Grid.Column="{Binding TodayCard.Column}" Grid.Row="{Binding TodayCard.Row}" <Border Grid.Column="{Binding TodayCard.Column}" Grid.Row="{Binding TodayCard.Row}"
IsVisible="{Binding TodayCard.EffectiveIsVisible}" Margin="0,0,8,8" IsVisible="{Binding TodayCard.EffectiveIsVisible}" Margin="{Binding TodayCard.Margin}"
Background="{DynamicResource SystemControlBackgroundAltHighBrush}" Background="{DynamicResource SystemControlBackgroundAltHighBrush}"
CornerRadius="8" Padding="16"> CornerRadius="8" Padding="16">
<StackPanel> <StackPanel>
@@ -187,7 +210,7 @@
<!-- Offene Aufgaben --> <!-- Offene Aufgaben -->
<Border Grid.Column="{Binding TasksCard.Column}" Grid.Row="{Binding TasksCard.Row}" <Border Grid.Column="{Binding TasksCard.Column}" Grid.Row="{Binding TasksCard.Row}"
IsVisible="{Binding TasksCard.EffectiveIsVisible}" Margin="8,0,0,8" IsVisible="{Binding TasksCard.EffectiveIsVisible}" Margin="{Binding TasksCard.Margin}"
Background="{DynamicResource SystemControlBackgroundAltHighBrush}" Background="{DynamicResource SystemControlBackgroundAltHighBrush}"
CornerRadius="8" Padding="16"> CornerRadius="8" Padding="16">
<StackPanel> <StackPanel>
@@ -224,7 +247,7 @@
<!-- Kalender: feste Position direkt unter Heute/Aufgaben, damit die wachsende <!-- Kalender: feste Position direkt unter Heute/Aufgaben, damit die wachsende
Lerngruppen-Liste darunter ihn nicht nach unten verdrängt. --> Lerngruppen-Liste darunter ihn nicht nach unten verdrängt. -->
<Border Grid.Column="{Binding CalendarCard.Column}" Grid.Row="{Binding CalendarCard.Row}" <Border Grid.Column="{Binding CalendarCard.Column}" Grid.Row="{Binding CalendarCard.Row}"
IsVisible="{Binding CalendarCard.EffectiveIsVisible}" Margin="0,0,8,8" IsVisible="{Binding CalendarCard.EffectiveIsVisible}" Margin="{Binding CalendarCard.Margin}"
Background="{DynamicResource SystemControlBackgroundAltHighBrush}" Background="{DynamicResource SystemControlBackgroundAltHighBrush}"
CornerRadius="8" Padding="16" HorizontalAlignment="Left" MaxWidth="320"> CornerRadius="8" Padding="16" HorizontalAlignment="Left" MaxWidth="320">
<StackPanel Spacing="8"> <StackPanel Spacing="8">
@@ -366,73 +389,9 @@
</StackPanel> </StackPanel>
</Border> </Border>
<!-- Offene Entschuldigungen: neben dem Kalender, ebenfalls feste Position -->
<Border Grid.Column="{Binding ExcusesCard.Column}" Grid.Row="{Binding ExcusesCard.Row}"
IsVisible="{Binding ExcusesCard.EffectiveIsVisible}" Margin="8,0,0,8" VerticalAlignment="Top"
Background="{DynamicResource SystemControlBackgroundAltHighBrush}"
CornerRadius="8" Padding="16">
<StackPanel>
<TextBlock Text="OFFENE ENTSCHULDIGUNGEN" FontSize="11" FontWeight="Bold"
Opacity="0.5" Margin="0,0,0,10"/>
<ItemsControl ItemsSource="{Binding OpenExcuses}">
<ItemsControl.ItemTemplate>
<DataTemplate DataType="vm:OpenExcuseItem">
<Grid ColumnDefinitions="*,Auto,Auto" Margin="0,4">
<StackPanel Grid.Column="0">
<TextBlock Text="{Binding StudentName}" FontSize="13" FontWeight="SemiBold"
TextTrimming="CharacterEllipsis"/>
<TextBlock FontSize="11" Opacity="0.6">
<Run Text="{Binding GroupName}"/>
<Run Text=" · "/>
<Run Text="{Binding DateDisplay}"/>
</TextBlock>
</StackPanel>
<Button Grid.Column="1" Content="Entschuldigt" FontSize="11" Padding="7,3"
Command="{Binding MarkExcusedCommand}" Margin="0,0,4,0"/>
<Button Grid.Column="2" Content="Unentschuldigt" FontSize="11" Padding="7,3"
Command="{Binding MarkUnexcusedCommand}"/>
</Grid>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<TextBlock Text="Keine offenen Entschuldigungen." Classes="emptyhint"
IsVisible="{Binding !OpenExcuses.Count}"/>
</StackPanel>
</Border>
<!-- Fehlzeiten-Warnung (5.2.3) -->
<Border Grid.Column="{Binding AttendanceCard.Column}" Grid.Row="{Binding AttendanceCard.Row}"
IsVisible="{Binding AttendanceCard.EffectiveIsVisible}" Margin="0,0,8,8" VerticalAlignment="Top"
Background="{DynamicResource SystemControlBackgroundAltHighBrush}"
CornerRadius="8" Padding="16">
<StackPanel>
<TextBlock Text="FEHLZEITEN-WARNUNG" FontSize="11" FontWeight="Bold"
Opacity="0.5" Margin="0,0,0,10"/>
<ItemsControl ItemsSource="{Binding AttendanceWarnings}">
<ItemsControl.ItemTemplate>
<DataTemplate DataType="vm:AttendanceWarningItem">
<Grid ColumnDefinitions="*,Auto" Margin="0,4">
<Button Grid.Column="0" Content="{Binding StudentName}" FontSize="13"
HorizontalAlignment="Left" HorizontalContentAlignment="Left"
Background="Transparent" Padding="0"
Command="{Binding $parent[ItemsControl].((vm:DashboardViewModel)DataContext).OpenStudentAttendanceCommand}"
CommandParameter="{Binding}"/>
<TextBlock Grid.Column="1" Foreground="Red" FontSize="12" VerticalAlignment="Center">
<Run Text="{Binding AbsenceRatePercent}"/>
<Run Text=" %"/>
</TextBlock>
</Grid>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<TextBlock Text="Keine Fehlzeiten über dem Schwellenwert." Classes="emptyhint"
IsVisible="{Binding !AttendanceWarnings.Count}"/>
</StackPanel>
</Border>
<!-- Klausurwochen (Nutzer-Feedback): eigene Klausurlast über alle Kurse hinweg --> <!-- Klausurwochen (Nutzer-Feedback): eigene Klausurlast über alle Kurse hinweg -->
<Border Grid.Column="{Binding ExamLoadCard.Column}" Grid.Row="{Binding ExamLoadCard.Row}" <Border Grid.Column="{Binding ExamLoadCard.Column}" Grid.Row="{Binding ExamLoadCard.Row}"
IsVisible="{Binding ExamLoadCard.EffectiveIsVisible}" Margin="0,0,8,8" VerticalAlignment="Top" IsVisible="{Binding ExamLoadCard.EffectiveIsVisible}" Margin="{Binding ExamLoadCard.Margin}" VerticalAlignment="Top"
Background="{DynamicResource SystemControlBackgroundAltHighBrush}" Background="{DynamicResource SystemControlBackgroundAltHighBrush}"
CornerRadius="8" Padding="16"> CornerRadius="8" Padding="16">
<StackPanel> <StackPanel>
@@ -454,66 +413,9 @@
</StackPanel> </StackPanel>
</Border> </Border>
<!-- Unterrichtszeit nacherfassen (Nutzer-Feedback) -->
<Border Grid.Column="{Binding MissingTeachingTimeCard.Column}" Grid.Row="{Binding MissingTeachingTimeCard.Row}"
IsVisible="{Binding MissingTeachingTimeCard.EffectiveIsVisible}" Margin="0,0,8,8" VerticalAlignment="Top"
Background="{DynamicResource SystemControlBackgroundAltHighBrush}"
CornerRadius="8" Padding="16">
<StackPanel>
<TextBlock Text="UNTERRICHTSZEIT NACHERFASSEN" FontSize="11" FontWeight="Bold"
Opacity="0.5" Margin="0,0,0,10"/>
<ItemsControl ItemsSource="{Binding MissingTeachingTimeEntries}">
<ItemsControl.ItemTemplate>
<DataTemplate DataType="vm:MissingTeachingTimeItem">
<Grid ColumnDefinitions="*,Auto" Margin="0,4">
<TextBlock Grid.Column="0" Text="{Binding DateDisplay}" FontSize="13"
VerticalAlignment="Center"/>
<Button Grid.Column="1" Content="Erfassen" FontSize="12" Padding="10,4"
Command="{Binding $parent[ItemsControl].((vm:DashboardViewModel)DataContext).AddMissingTeachingTimeCommand}"
CommandParameter="{Binding}"/>
</Grid>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<TextBlock Text="Keine fehlende Unterrichtszeit-Erfassung." Classes="emptyhint"
IsVisible="{Binding !MissingTeachingTimeEntries.Count}"/>
</StackPanel>
</Border>
<!-- Förderplan-Wiedervorlage (5.3.2) -->
<Border Grid.Column="{Binding SupportCard.Column}" Grid.Row="{Binding SupportCard.Row}"
IsVisible="{Binding SupportCard.EffectiveIsVisible}" Margin="8,0,0,8" VerticalAlignment="Top"
Background="{DynamicResource SystemControlBackgroundAltHighBrush}"
CornerRadius="8" Padding="16">
<StackPanel>
<TextBlock Text="FÖRDERPLAN-WIEDERVORLAGE" FontSize="11" FontWeight="Bold"
Opacity="0.5" Margin="0,0,0,10"/>
<ItemsControl ItemsSource="{Binding SupportPlanReviews}">
<ItemsControl.ItemTemplate>
<DataTemplate DataType="vm:SupportPlanDueItem">
<Grid ColumnDefinitions="*,Auto" Margin="0,4">
<StackPanel Grid.Column="0">
<Button Content="{Binding StudentName}" FontSize="13" FontWeight="SemiBold"
HorizontalAlignment="Left" HorizontalContentAlignment="Left"
Background="Transparent" Padding="0"
Command="{Binding $parent[ItemsControl].((vm:DashboardViewModel)DataContext).OpenStudentSupportPlanCommand}"
CommandParameter="{Binding}"/>
<TextBlock Text="{Binding Title}" FontSize="11" Opacity="0.6"/>
</StackPanel>
<TextBlock Grid.Column="1" Text="{Binding ReviewDateDisplay}" FontSize="12"
VerticalAlignment="Center" Classes.overdue="{Binding IsOverdue}"/>
</Grid>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<TextBlock Text="Keine fälligen Überprüfungen." Classes="emptyhint"
IsVisible="{Binding !SupportPlanReviews.Count}"/>
</StackPanel>
</Border>
<!-- Anstehende Termine (9.3) --> <!-- Anstehende Termine (9.3) -->
<Border Grid.Column="{Binding UpcomingCard.Column}" Grid.Row="{Binding UpcomingCard.Row}" <Border Grid.Column="{Binding UpcomingCard.Column}" Grid.Row="{Binding UpcomingCard.Row}"
IsVisible="{Binding UpcomingCard.EffectiveIsVisible}" Margin="0,0,8,8" VerticalAlignment="Top" IsVisible="{Binding UpcomingCard.EffectiveIsVisible}" Margin="{Binding UpcomingCard.Margin}" VerticalAlignment="Top"
Background="{DynamicResource SystemControlBackgroundAltHighBrush}" Background="{DynamicResource SystemControlBackgroundAltHighBrush}"
CornerRadius="8" Padding="16"> CornerRadius="8" Padding="16">
<StackPanel> <StackPanel>
@@ -548,106 +450,107 @@
</StackPanel> </StackPanel>
</Border> </Border>
<!-- Offene Korrekturen (9.4) --> <!-- Handlungsbedarf: zusammengefasste Karte für die frueheren sieben Kacheln
<Border Grid.Column="{Binding CorrectionsCard.Column}" Grid.Row="{Binding CorrectionsCard.Row}" (Entschuldigungen, Fehlzeiten, Foerderplan, Korrekturen, ungeplante Stunden,
IsVisible="{Binding CorrectionsCard.EffectiveIsVisible}" Margin="8,0,0,8" VerticalAlignment="Top" Auffaelligkeiten, Unterrichtszeit-Nacherfassung) — siehe AttentionItem.cs.
Filter-Chips ersetzen die vorherige Sichtbarkeit je Einzelkachel. x:Name traegt den
Weg zum DashboardViewModel durch zwei ItemsControl-Verschachtelungen (Gruppe -> Item)
hindurch, ohne $parent[ItemsControl] mit Ancestor-Level zaehlen zu muessen. -->
<Border Grid.Column="{Binding AttentionCard.Column}" Grid.Row="{Binding AttentionCard.Row}"
IsVisible="{Binding AttentionCard.EffectiveIsVisible}" Margin="{Binding AttentionCard.Margin}" VerticalAlignment="Top"
Background="{DynamicResource SystemControlBackgroundAltHighBrush}" Background="{DynamicResource SystemControlBackgroundAltHighBrush}"
CornerRadius="8" Padding="16"> CornerRadius="8" Padding="16">
<StackPanel> <StackPanel Spacing="10">
<TextBlock Text="OFFENE KORREKTUREN" FontSize="11" FontWeight="Bold" <TextBlock Text="HANDLUNGSBEDARF" FontSize="11" FontWeight="Bold" Opacity="0.5"/>
Opacity="0.5" Margin="0,0,0,10"/>
<ItemsControl ItemsSource="{Binding OpenCorrections}">
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:CorrectionProgressItem">
<Button Background="Transparent" BorderThickness="0" Padding="0" Margin="0,5"
HorizontalAlignment="Stretch" HorizontalContentAlignment="Stretch"
Command="{Binding $parent[ItemsControl].((vm:DashboardViewModel)DataContext).OpenCorrectionCommand}"
CommandParameter="{Binding}">
<StackPanel Spacing="3">
<Grid ColumnDefinitions="*,Auto">
<TextBlock Text="{Binding Title}" FontSize="13" FontWeight="SemiBold"/>
<TextBlock Grid.Column="1" Text="{Binding DateDisplay}" FontSize="11" Opacity="0.6"/>
</Grid>
<TextBlock Text="{Binding GroupName}" FontSize="11" Opacity="0.6"/>
<ProgressBar Minimum="0" Maximum="100" Value="{Binding Percent}" Height="6"/>
<TextBlock Text="{Binding ProgressDisplay}" FontSize="10" Opacity="0.65"/>
</StackPanel>
</Button>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<TextBlock Text="Keine offenen Korrekturen." Classes="emptyhint"
IsVisible="{Binding !OpenCorrections.Count}"/>
</StackPanel>
</Border>
<!-- Ungeplante Stunden --> <ItemsControl ItemsSource="{Binding AttentionFilters}">
<Border Grid.Column="{Binding UnplannedCard.Column}" Grid.Row="{Binding UnplannedCard.Row}" <ItemsControl.ItemsPanel>
IsVisible="{Binding UnplannedCard.EffectiveIsVisible}" Margin="0,0,8,8" VerticalAlignment="Top" <ItemsPanelTemplate><WrapPanel Orientation="Horizontal" ItemSpacing="6" LineSpacing="6"/></ItemsPanelTemplate>
Background="{DynamicResource SystemControlBackgroundAltHighBrush}" </ItemsControl.ItemsPanel>
CornerRadius="8" Padding="16">
<StackPanel>
<TextBlock Text="UNGEPLANTE STUNDEN" FontSize="11" FontWeight="Bold"
Opacity="0.5" Margin="0,0,0,10"/>
<ItemsControl ItemsSource="{Binding UnplannedLessons}">
<ItemsControl.ItemTemplate> <ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:UnplannedLessonItem"> <DataTemplate x:DataType="vm:AttentionFilterOption">
<Button Background="Transparent" BorderThickness="0" Padding="0" Margin="0,5" <ToggleButton Content="{Binding Label}" IsChecked="{Binding IsActive}"
HorizontalAlignment="Stretch" HorizontalContentAlignment="Stretch" FontSize="11" Padding="8,3"/>
Command="{Binding $parent[ItemsControl].((vm:DashboardViewModel)DataContext).OpenUnplannedLessonCommand}"
CommandParameter="{Binding}">
<Grid ColumnDefinitions="*,Auto">
<TextBlock Text="{Binding Display}" FontSize="13"
TextTrimming="CharacterEllipsis"/>
<TextBlock Grid.Column="1" Text="{Binding DateDisplay}" FontSize="12"
VerticalAlignment="Center" Opacity="0.6"/>
</Grid>
</Button>
</DataTemplate> </DataTemplate>
</ItemsControl.ItemTemplate> </ItemsControl.ItemTemplate>
</ItemsControl> </ItemsControl>
<TextBlock Text="Keine ungeplanten Stunden." Classes="emptyhint"
IsVisible="{Binding !UnplannedLessons.Count}"/>
</StackPanel>
</Border>
<!-- Auffälligkeiten (9.5) --> <ItemsControl x:Name="AttentionGroupsList" ItemsSource="{Binding Attention}">
<Border Grid.Column="{Binding AlertsCard.Column}" Grid.Row="{Binding AlertsCard.Row}"
IsVisible="{Binding AlertsCard.EffectiveIsVisible}" Margin="0,0,8,8" VerticalAlignment="Top"
Background="{DynamicResource SystemControlBackgroundAltHighBrush}"
CornerRadius="8" Padding="16">
<StackPanel>
<TextBlock Text="AUFFÄLLIGKEITEN" FontSize="11" FontWeight="Bold"
Opacity="0.5" Margin="0,0,0,10"/>
<ItemsControl ItemsSource="{Binding Alerts}">
<ItemsControl.ItemTemplate> <ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:DashboardAlertItem"> <DataTemplate x:DataType="vm:AttentionGroup">
<Button Background="Transparent" BorderThickness="0" Padding="0" Margin="0,4" <StackPanel Margin="0,6,0,0" Spacing="4">
HorizontalAlignment="Stretch" HorizontalContentAlignment="Stretch" <TextBlock FontSize="11" FontWeight="SemiBold" Opacity="0.7">
Command="{Binding $parent[ItemsControl].((vm:DashboardViewModel)DataContext).OpenAlertCommand}" <Run Text="{Binding Header}"/>
CommandParameter="{Binding}"> <Run Text=" ("/>
<Grid ColumnDefinitions="4,*"> <Run Text="{Binding Count}"/>
<Border Background="{Binding SeverityColor}" CornerRadius="2" Margin="0,0,9,0"/> <Run Text=")"/>
<StackPanel Grid.Column="1"> </TextBlock>
<Grid ColumnDefinitions="*,Auto"> <ItemsControl ItemsSource="{Binding Items}">
<TextBlock Text="{Binding StudentName}" FontSize="13" FontWeight="SemiBold"/> <ItemsControl.ItemTemplate>
<TextBlock Grid.Column="1" Text="{Binding KindLabel}" FontSize="10" Opacity="0.6"/> <DataTemplate x:DataType="vm:AttentionItem">
</Grid> <StackPanel Margin="0,4">
<TextBlock Text="{Binding Detail}" FontSize="11" Opacity="0.65"/> <Grid ColumnDefinitions="4,*">
</StackPanel> <Border Grid.Column="0" Classes="severitybar"
</Grid> Classes.high="{Binding IsHighSeverity}"
</Button> Classes.medium="{Binding IsMediumSeverity}" Margin="0,0,9,0"/>
<!-- Bewusst immer als Button (statt je nach IsClickable ein anderes Root-Element):
ein einheitliches Template für alle sieben Arten. Bei Excuse/MissingTeachingTime
ist Navigate null, der Klick auf die Zeile selbst ist dann folgenlos — die
eigentliche Aktion liegt dort in Actions (Entschuldigt/Erfassen) darunter. -->
<Button Grid.Column="1" Background="Transparent" BorderThickness="0" Padding="0"
HorizontalAlignment="Stretch" HorizontalContentAlignment="Stretch"
Command="{Binding #AttentionGroupsList.((vm:DashboardViewModel)DataContext).OpenAttentionItemCommand}"
CommandParameter="{Binding}">
<StackPanel Spacing="3">
<Grid ColumnDefinitions="*,Auto">
<TextBlock Text="{Binding Title}" FontSize="13" FontWeight="SemiBold"
TextTrimming="CharacterEllipsis"/>
<TextBlock Grid.Column="1" Text="{Binding TrailingText}"
Classes="attentionTrailing" Classes.warning="{Binding IsWarningTrailing}"
VerticalAlignment="Center" Margin="8,0,0,0"
IsVisible="{Binding HasTrailingText}"/>
<TextBlock Grid.Column="1" Text="{Binding DateDisplay}" FontSize="12"
VerticalAlignment="Center" Margin="8,0,0,0"
Classes.overdue="{Binding IsOverdue}"
IsVisible="{Binding HasDate}"/>
</Grid>
<TextBlock Text="{Binding Subtitle}" FontSize="11" Opacity="0.6"
IsVisible="{Binding HasSubtitle}"/>
<ProgressBar Minimum="0" Maximum="100" Value="{Binding ProgressPercent}"
Height="6" IsVisible="{Binding HasProgress}"/>
<TextBlock Text="{Binding ProgressLabel}" FontSize="10" Opacity="0.65"
IsVisible="{Binding HasProgress}"/>
</StackPanel>
</Button>
</Grid>
<ItemsControl ItemsSource="{Binding Actions}" Margin="13,4,0,0"
IsVisible="{Binding HasActions}">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate><StackPanel Orientation="Horizontal" Spacing="4"/></ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:AttentionAction">
<Button Content="{Binding Label}" FontSize="11" Padding="7,3"
Command="{Binding Command}" CommandParameter="{Binding Parameter}"/>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</StackPanel>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</StackPanel>
</DataTemplate> </DataTemplate>
</ItemsControl.ItemTemplate> </ItemsControl.ItemTemplate>
</ItemsControl> </ItemsControl>
<TextBlock Text="Keine Auffälligkeiten erkannt." Classes="emptyhint" <TextBlock Text="Keine offenen Punkte." Classes="emptyhint"
IsVisible="{Binding !Alerts.Count}"/> IsVisible="{Binding !Attention.Count}"/>
</StackPanel> </StackPanel>
</Border> </Border>
<!-- Meine Lerngruppen --> <!-- Meine Lerngruppen -->
<Border Grid.Column="{Binding GroupsCard.Column}" Grid.Row="{Binding GroupsCard.Row}" <Border Grid.Column="{Binding GroupsCard.Column}" Grid.Row="{Binding GroupsCard.Row}"
IsVisible="{Binding GroupsCard.EffectiveIsVisible}" IsVisible="{Binding GroupsCard.EffectiveIsVisible}" Margin="{Binding GroupsCard.Margin}"
Background="{DynamicResource SystemControlBackgroundAltHighBrush}" Background="{DynamicResource SystemControlBackgroundAltHighBrush}"
CornerRadius="8" Padding="16"> CornerRadius="8" Padding="16">
<StackPanel> <StackPanel>
@@ -84,6 +84,9 @@
ToolTip.Tip="lsid aus WebUntis (Unterricht -&gt; Mein Unterricht -&gt; Berichte-Symbol der Zeile). Wird von WebUntis pro Schuljahr neu vergeben und muss deshalb jedes Schuljahr aktualisiert werden."/> ToolTip.Tip="lsid aus WebUntis (Unterricht -&gt; Mein Unterricht -&gt; Berichte-Symbol der Zeile). Wird von WebUntis pro Schuljahr neu vergeben und muss deshalb jedes Schuljahr aktualisiert werden."/>
</StackPanel> </StackPanel>
<CheckBox Content="Nicht im Untis-Hub verfolgen" IsChecked="{Binding ExcludedFromUntisHub}"
ToolTip.Tip="Blendet diese Gruppe im Untis-Hub aus (keine Fälligkeits-Erinnerungen für Fehlzeiten/Abgleiche), auch wenn eine WebUntis-Unterrichtsnummer hinterlegt ist - z.B. für Klassenrat oder AGs."/>
</StackPanel> </StackPanel>
<Grid Grid.Row="1" ColumnDefinitions="*,8,*" Margin="0,20,0,0"> <Grid Grid.Row="1" ColumnDefinitions="*,8,*" Margin="0,20,0,0">
@@ -161,7 +161,9 @@ public partial class GroupDetailView : UserControl
App.Services.GetRequiredService<WebUntisIntegrationService>(), App.Services.GetRequiredService<WebUntisIntegrationService>(),
App.Services.GetRequiredService<IStudentRepository>(), App.Services.GetRequiredService<IStudentRepository>(),
App.Services.GetRequiredService<IParticipationSessionRepository>(), App.Services.GetRequiredService<IParticipationSessionRepository>(),
App.Services.GetRequiredService<IParticipationRepository>()); App.Services.GetRequiredService<IParticipationRepository>(),
App.Services.GetRequiredService<AiPlanningService>(),
App.Services.GetRequiredService<AiSettingsService>());
await new WebUntisLessonAbsenceComparisonDialog { DataContext = dialogVm }.ShowDialog(owner); await new WebUntisLessonAbsenceComparisonDialog { DataContext = dialogVm }.ShowDialog(owner);
vm.ParticipationTab.RefreshCurrentGrid(); vm.ParticipationTab.RefreshCurrentGrid();
} }
@@ -1,5 +1,6 @@
<UserControl xmlns="https://github.com/avaloniaui" <UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:models="clr-namespace:LehrerApp.Core.Models;assembly=LehrerApp.Core"
xmlns:vm="clr-namespace:LehrerApp.Desktop.ViewModels.Groups" xmlns:vm="clr-namespace:LehrerApp.Desktop.ViewModels.Groups"
xmlns:vmRoot="clr-namespace:LehrerApp.Desktop.ViewModels" xmlns:vmRoot="clr-namespace:LehrerApp.Desktop.ViewModels"
x:Class="LehrerApp.Desktop.Views.Groups.GroupOverviewTabView" x:Class="LehrerApp.Desktop.Views.Groups.GroupOverviewTabView"
@@ -42,6 +43,19 @@
<StackPanel Spacing="0"> <StackPanel Spacing="0">
<WrapPanel> <WrapPanel>
<Border Classes="card" IsVisible="{Binding HasTodayLessons}">
<StackPanel Spacing="8">
<TextBlock Text="UNTERRICHT HEUTE" Classes="cardTitle"/>
<ComboBox ItemsSource="{Binding TodayLessons}" SelectedItem="{Binding SelectedTeachingLesson}" HorizontalAlignment="Stretch">
<ComboBox.ItemTemplate>
<DataTemplate x:DataType="models:Lesson">
<TextBlock><Run Text="{Binding StartTime}"/><Run Text=" · "/><Run Text="{Binding Topic}"/></TextBlock>
</DataTemplate>
</ComboBox.ItemTemplate>
</ComboBox>
<Button Content="Unterrichtsansicht öffnen" Command="{Binding StartTeachingModeCommand}"/>
</StackPanel>
</Border>
<!-- Nächste Stunde --> <!-- Nächste Stunde -->
<Border Classes="card"> <Border Classes="card">
<StackPanel> <StackPanel>
@@ -1,8 +1,14 @@
using Avalonia.Controls; using Avalonia.Controls;
using LehrerApp.Desktop.ViewModels.Groups;
namespace LehrerApp.Desktop.Views.Groups; namespace LehrerApp.Desktop.Views.Groups;
public partial class GroupOverviewTabView : UserControl public partial class GroupOverviewTabView : UserControl
{ {
public GroupOverviewTabView() => InitializeComponent(); public GroupOverviewTabView() => InitializeComponent();
protected override void OnDataContextChanged(EventArgs e)
{
base.OnDataContextChanged(e);
if (DataContext is GroupOverviewViewModel vm) vm.OnOpenTeachingMode = TeachingModeWindow.Open;
}
} }
@@ -49,6 +49,13 @@
IsVisible="{Binding TopicError, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/> IsVisible="{Binding TopicError, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
</StackPanel> </StackPanel>
<StackPanel Spacing="4">
<TextBlock Text="Planungsideen (Rohentwurf)" FontSize="12" Opacity="0.7"
ToolTip.Tip="Erste, noch grobe Ideen — lange bevor der Verlaufsplan unten feingeplant wird. Fließt auch als Kontext in die KI-Unterstützung (Backend und MCP) ein."/>
<TextBox Text="{Binding PlanningIdeas}" AcceptsReturn="True" Height="64" TextWrapping="Wrap"
PlaceholderText="Grobe Ideen, mögliche Aufhänger, erste Materialgedanken ..."/>
</StackPanel>
<Separator Margin="0,4"/> <Separator Margin="0,4"/>
<Grid ColumnDefinitions="*,Auto"> <Grid ColumnDefinitions="*,Auto">
@@ -66,7 +73,7 @@
</Grid> </Grid>
<!-- Tabellenkopf --> <!-- Tabellenkopf -->
<Grid ColumnDefinitions="130,95,120,*,100,95,26,26,26" Margin="4,0,0,0"> <Grid ColumnDefinitions="130,95,120,*,100,95,26,26,26,26" Margin="4,0,0,0">
<TextBlock Grid.Column="0" Text="Phase" FontSize="11" FontWeight="SemiBold" Opacity="0.6"/> <TextBlock Grid.Column="0" Text="Phase" FontSize="11" FontWeight="SemiBold" Opacity="0.6"/>
<TextBlock Grid.Column="1" Text="Pfad" FontSize="11" FontWeight="SemiBold" Opacity="0.6" <TextBlock Grid.Column="1" Text="Pfad" FontSize="11" FontWeight="SemiBold" Opacity="0.6"
ToolTip.Tip="Ankreuzen, wenn diese Phase zu einem alternativen Ablauf gehört (z.B. Kurzversion bei Zeitnot) — öffnet die Zuweisung. Phasen mit demselben Ablauf werden im Verlaufsplan-Viewer gruppiert."/> ToolTip.Tip="Ankreuzen, wenn diese Phase zu einem alternativen Ablauf gehört (z.B. Kurzversion bei Zeitnot) — öffnet die Zuweisung. Phasen mit demselben Ablauf werden im Verlaufsplan-Viewer gruppiert."/>
@@ -83,7 +90,7 @@
<DataTemplate x:DataType="vm:PhaseStepEditItem"> <DataTemplate x:DataType="vm:PhaseStepEditItem">
<Border BorderBrush="{DynamicResource SystemControlForegroundBaseLowBrush}" <Border BorderBrush="{DynamicResource SystemControlForegroundBaseLowBrush}"
BorderThickness="0,0,0,1" Padding="0,8"> BorderThickness="0,0,0,1" Padding="0,8">
<Grid ColumnDefinitions="130,95,120,*,100,95,26,26,26"> <Grid ColumnDefinitions="130,95,120,*,100,95,26,26,26,26">
<TextBox Grid.Column="0" Text="{Binding Name}" PlaceholderText="z.B. Erarbeitung" <TextBox Grid.Column="0" Text="{Binding Name}" PlaceholderText="z.B. Erarbeitung"
VerticalAlignment="Top" Margin="0,0,6,0"/> VerticalAlignment="Top" Margin="0,0,6,0"/>
<CheckBox Grid.Column="1" IsChecked="{Binding HasAlternativePath}" <CheckBox Grid.Column="1" IsChecked="{Binding HasAlternativePath}"
@@ -110,11 +117,14 @@
FilterMode="Contains" MinimumPrefixLength="0" VerticalAlignment="Top" FilterMode="Contains" MinimumPrefixLength="0" VerticalAlignment="Top"
Margin="0,0,6,0" PlaceholderText="z.B. AB001-&gt;S, Plenum, LDE" Margin="0,0,6,0" PlaceholderText="z.B. AB001-&gt;S, Plenum, LDE"
ToolTip.Tip="Freitext für den schnellen Überblick — mal ein Materialfluss-Pfeil (AB001-&gt;S), mal nur eine Sozialform (Plenum, LDE)."/> ToolTip.Tip="Freitext für den schnellen Überblick — mal ein Materialfluss-Pfeil (AB001-&gt;S), mal nur eine Sozialform (Plenum, LDE)."/>
<Button Grid.Column="6" Content="" Command="{Binding MoveUpCommand}" Padding="4,2" <Button Grid.Column="6" Content="📋" Padding="4,2" VerticalAlignment="Top" Margin="0,0,2,0"
IsVisible="{Binding HasMaterialPrompt}" Click="OnCopyMaterialPrompt"
ToolTip.Tip="Gespeicherten Prompt zur Materialerstellung erneut in die Zwischenablage kopieren."/>
<Button Grid.Column="7" Content="↑" Command="{Binding MoveUpCommand}" Padding="4,2"
VerticalAlignment="Top" ToolTip.Tip="Nach oben"/> VerticalAlignment="Top" ToolTip.Tip="Nach oben"/>
<Button Grid.Column="7" Content="↓" Command="{Binding MoveDownCommand}" Padding="4,2" <Button Grid.Column="8" Content="↓" Command="{Binding MoveDownCommand}" Padding="4,2"
VerticalAlignment="Top" Margin="2,0,0,0" ToolTip.Tip="Nach unten"/> VerticalAlignment="Top" Margin="2,0,0,0" ToolTip.Tip="Nach unten"/>
<Button Grid.Column="8" Content="✕" Command="{Binding RemoveCommand}" Padding="4,2" <Button Grid.Column="9" Content="✕" Command="{Binding RemoveCommand}" Padding="4,2"
VerticalAlignment="Top" Margin="2,0,0,0" ToolTip.Tip="Entfernen"/> VerticalAlignment="Top" Margin="2,0,0,0" ToolTip.Tip="Entfernen"/>
</Grid> </Grid>
</Border> </Border>
@@ -1,4 +1,5 @@
using Avalonia.Controls; using Avalonia.Controls;
using Avalonia.Input.Platform;
using Avalonia.Interactivity; using Avalonia.Interactivity;
using Avalonia.Platform.Storage; using Avalonia.Platform.Storage;
using LehrerApp.Core.Interfaces; using LehrerApp.Core.Interfaces;
@@ -48,6 +49,20 @@ public partial class LessonDialog : Window
Close(false); Close(false);
} }
/// Kopiert den beim letzten KI-"Übernehmen" gespeicherten Materialerstellungs-Prompt (4.5.36,
/// Nachtrag zu 4.5.20) erneut in die Zwischenablage — derselbe Mechanismus wie im AiAssistDialog,
/// hier aber für einen bereits gespeicherten, nicht mehr nur transienten Vorschlag.
private async void OnCopyMaterialPrompt(object? sender, RoutedEventArgs e)
{
if (sender is not Button { DataContext: PhaseStepEditItem item } || string.IsNullOrWhiteSpace(item.MaterialPrompt))
return;
var clipboard = TopLevel.GetTopLevel(this)?.Clipboard;
if (clipboard is null) return;
await clipboard.SetTextAsync(item.MaterialPrompt);
App.Services.GetRequiredService<NotificationService>().ShowSuccess("Prompt in die Zwischenablage kopiert.");
}
private async void OnAddAttachment(object? sender, RoutedEventArgs e) private async void OnAddAttachment(object? sender, RoutedEventArgs e)
{ {
if (DataContext is not LessonDialogViewModel vm) return; if (DataContext is not LessonDialogViewModel vm) return;
@@ -0,0 +1,62 @@
<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="clr-namespace:LehrerApp.Desktop.ViewModels.Groups"
x:Class="LehrerApp.Desktop.Views.Groups.PersonalizeWorksheetDialog"
x:DataType="vm:PersonalizeWorksheetDialogViewModel"
Title="Arbeitsblatt personalisieren"
Width="460" Height="620"
CanResize="False" WindowStartupLocation="CenterOwner">
<Grid RowDefinitions="Auto,Auto,*,Auto,Auto" Margin="24">
<StackPanel Grid.Row="0" Spacing="4" Margin="0,0,0,14">
<TextBlock Text="Arbeitsblatt personalisieren" Classes="dialogtitle"/>
<TextBlock Text="{Binding GroupName}" FontSize="12" Opacity="0.65"/>
</StackPanel>
<StackPanel Grid.Row="1" Spacing="10" Margin="0,0,0,14">
<TextBlock Text="Vorlage" FontSize="12" FontWeight="SemiBold" Opacity="0.7"/>
<ComboBox ItemsSource="{Binding Templates}" SelectedItem="{Binding SelectedTemplate}"
HorizontalAlignment="Stretch" DisplayMemberBinding="{Binding Name}"/>
<TextBlock Text="Noch keine Arbeitsblatt-Vorlage importiert — in den Einstellungen unter „Briefvorlagen“ eine .lavorlage-Datei aus dem Vorlagen-Designer hinzufügen."
Classes="emptyhint" TextWrapping="Wrap"
IsVisible="{Binding HasNoTemplates}"/>
</StackPanel>
<ScrollViewer Grid.Row="2">
<StackPanel Spacing="4">
<TextBlock Text="Für diese Schüler erzeugen" FontSize="12" FontWeight="SemiBold" Opacity="0.7" Margin="0,0,0,4"/>
<ItemsControl ItemsSource="{Binding Students}">
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:WorksheetStudentChoice">
<CheckBox Content="{Binding FullName}" IsChecked="{Binding IsIncluded}" Margin="0,3"/>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<ItemsControl ItemsSource="{Binding Results}" Margin="0,10,0,0" IsVisible="{Binding HasResults}">
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:WorksheetGenerationResult">
<Grid ColumnDefinitions="20,*" Margin="0,2">
<TextBlock Grid.Column="0" Text="{Binding Icon}" Foreground="{Binding Color}"/>
<TextBlock Grid.Column="1" FontSize="12">
<Run Text="{Binding StudentName}"/><Run Text=" "/><Run Text="{Binding ErrorMessage}" Foreground="#D97706"/>
</TextBlock>
</Grid>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</StackPanel>
</ScrollViewer>
<TextBlock Grid.Row="3" Text="{Binding StatusMessage}" FontSize="12" Margin="0,10,0,0" TextWrapping="Wrap"
IsVisible="{Binding StatusMessage, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
<Grid Grid.Row="4" ColumnDefinitions="*,8,*" Margin="0,16,0,0">
<Button Grid.Column="0" Content="Schließen" HorizontalAlignment="Stretch" Click="OnClose"/>
<Button Grid.Column="2" Content="Erzeugen…" HorizontalAlignment="Stretch" Click="OnGenerate"
IsEnabled="{Binding !HasNoTemplates}"/>
</Grid>
</Grid>
</Window>
@@ -0,0 +1,25 @@
using Avalonia.Controls;
using Avalonia.Interactivity;
using Avalonia.Platform.Storage;
using LehrerApp.Desktop.ViewModels.Groups;
namespace LehrerApp.Desktop.Views.Groups;
public partial class PersonalizeWorksheetDialog : Window
{
public PersonalizeWorksheetDialog() => InitializeComponent();
private async void OnGenerate(object? sender, RoutedEventArgs e)
{
if (DataContext is not PersonalizeWorksheetDialogViewModel vm) return;
var folders = await StorageProvider.OpenFolderPickerAsync(new FolderPickerOpenOptions
{
Title = "Zielordner für die personalisierten Arbeitsblätter wählen",
AllowMultiple = false,
});
if (folders.Count == 0) return;
vm.Generate(folders[0].Path.LocalPath);
}
private void OnClose(object? sender, RoutedEventArgs e) => Close();
}
@@ -24,8 +24,8 @@
<Setter Property="Background" Value="Transparent"/> <Setter Property="Background" Value="Transparent"/>
</Style> </Style>
</UserControl.Styles> </UserControl.Styles>
<Grid ColumnDefinitions="260,*"> <Grid ColumnDefinitions="Auto,*">
<Border Grid.Column="0" BorderBrush="{DynamicResource SystemControlForegroundBaseLowBrush}" <Border Grid.Column="0" Width="260" IsVisible="{Binding !IsTeachingMode}" BorderBrush="{DynamicResource SystemControlForegroundBaseLowBrush}"
BorderThickness="0,0,1,0" Padding="16"> BorderThickness="0,0,1,0" Padding="16">
<Grid RowDefinitions="Auto,*,Auto"> <Grid RowDefinitions="Auto,*,Auto">
<StackPanel Grid.Row="0" Spacing="4" Margin="0,0,0,12"> <StackPanel Grid.Row="0" Spacing="4" Margin="0,0,0,12">
@@ -72,26 +72,35 @@
<Grid RowDefinitions="Auto,*" ColumnDefinitions="*,Auto" IsVisible="{Binding HasSelectedPlan}" Margin="24"> <Grid RowDefinitions="Auto,*" ColumnDefinitions="*,Auto" IsVisible="{Binding HasSelectedPlan}" Margin="24">
<Grid Grid.Row="0" Grid.ColumnSpan="2" ColumnDefinitions="*,Auto" Margin="0,0,0,18"> <Grid Grid.Row="0" Grid.ColumnSpan="2" ColumnDefinitions="*,Auto" Margin="0,0,0,18">
<StackPanel Spacing="3"> <StackPanel Spacing="3">
<TextBlock Text="{Binding PlanTitle}" FontSize="22" FontWeight="SemiBold"/> <ComboBox ItemsSource="{Binding Plans}" SelectedItem="{Binding SelectedPlan}" DisplayMemberBinding="{Binding Name}"
IsVisible="{Binding IsTeachingMode}" MinWidth="180"/>
<TextBlock Text="{Binding PlanTitle}" FontSize="22" FontWeight="SemiBold" IsVisible="{Binding !IsTeachingMode}"/>
<TextBlock Text="{Binding PlanSubtitle}" Opacity="0.65"/> <TextBlock Text="{Binding PlanSubtitle}" Opacity="0.65"/>
<TextBlock Text="{Binding QuickModeDisplay}" FontSize="12" TextWrapping="Wrap" IsVisible="{Binding IsTeachingMode}"/>
<Border IsVisible="{Binding !IsTeachingMode}">
<StackPanel Orientation="Horizontal" Spacing="8" Margin="0,6,0,0" <StackPanel Orientation="Horizontal" Spacing="8" Margin="0,6,0,0"
IsVisible="{Binding IsEditMode, Converter={x:Static BoolConverters.Not}}"> IsVisible="{Binding IsEditMode, Converter={x:Static BoolConverters.Not}}">
<TextBlock Text="Unterricht:" VerticalAlignment="Center" FontSize="12" Opacity="0.65"/> <TextBlock Text="Unterricht:" VerticalAlignment="Center" FontSize="12" Opacity="0.65"/>
<ComboBox ItemsSource="{Binding TodaySessions}" SelectedItem="{Binding SelectedSession}" <ComboBox ItemsSource="{Binding TodaySessions}" SelectedItem="{Binding SelectedSession}"
DisplayMemberBinding="{Binding DisplayName}" MinWidth="210"/> DisplayMemberBinding="{Binding DisplayName}" MinWidth="210" IsEnabled="{Binding !IsTeachingMode}"/>
</StackPanel> </StackPanel>
</Border>
</StackPanel> </StackPanel>
<StackPanel Grid.Column="1" VerticalAlignment="Bottom" Spacing="4"> <StackPanel Grid.Column="1" VerticalAlignment="Bottom" Spacing="4">
<StackPanel Orientation="Horizontal" Spacing="10" HorizontalAlignment="Right"> <StackPanel Orientation="Horizontal" Spacing="10" HorizontalAlignment="Right">
<Button Content="Als PDF" Click="OnExportPdfClick" VerticalAlignment="Center"/> <Button Content="Als PDF" Click="OnExportPdfClick" VerticalAlignment="Center"/>
<ToggleSwitch Content="Bearbeitungsmodus" IsChecked="{Binding IsEditMode}" <Border IsVisible="{Binding !IsTeachingMode}">
IsVisible="{Binding IsEditable}"/> <ToggleSwitch Content="Bearbeitungsmodus" IsChecked="{Binding IsEditMode}"
IsVisible="{Binding IsEditable}"/>
</Border>
</StackPanel> </StackPanel>
<TextBlock Text="{Binding AssignmentSummary}" HorizontalAlignment="Right" FontSize="12" Opacity="0.6"/> <TextBlock Text="{Binding AssignmentSummary}" HorizontalAlignment="Right" FontSize="12" Opacity="0.6"/>
<TextBlock Text="Ziehen: Platz ändern · Klicken: bewerten" HorizontalAlignment="Right" <TextBlock Text="Ziehen: Platz ändern · Klicken: bewerten" HorizontalAlignment="Right"
FontSize="11" Opacity="0.5" IsVisible="{Binding IsEditMode}"/> FontSize="11" Opacity="0.5" IsVisible="{Binding IsEditMode}"/>
<TextBlock Text="Klicken: bewerten" HorizontalAlignment="Right" <Border IsVisible="{Binding !IsTeachingMode}">
FontSize="11" Opacity="0.5" IsVisible="{Binding IsEditMode, Converter={x:Static BoolConverters.Not}}"/> <TextBlock Text="{Binding QuickModeDisplay}" HorizontalAlignment="Right"
FontSize="11" Opacity="0.5" IsVisible="{Binding IsEditMode, Converter={x:Static BoolConverters.Not}}"/>
</Border>
</StackPanel> </StackPanel>
</Grid> </Grid>
@@ -158,10 +167,20 @@
IsVisible="{Binding HasDayHighlightBadge}" IsVisible="{Binding HasDayHighlightBadge}"
ToolTip.Tip="Tagesflagge"/> ToolTip.Tip="Tagesflagge"/>
</StackPanel> </StackPanel>
<StackPanel Spacing="4" IsVisible="{Binding ShowQuickCheck}">
<Grid ColumnDefinitions="*,*">
<Button Content="{Binding QuickPositiveLabel}" Command="{Binding QuickPositiveCommand}"
FontSize="11" Padding="5,5" HorizontalAlignment="Stretch" Margin="0,0,3,0"/>
<Button Grid.Column="1" Content="{Binding QuickNegativeLabel}" Command="{Binding QuickNegativeCommand}"
FontSize="11" Padding="5,5" HorizontalAlignment="Stretch"/>
</Grid>
<Button Content="Sonderfälle …" Command="{Binding QuickSpecialCommand}"
FontSize="10" Padding="5,3" HorizontalAlignment="Stretch"/>
</StackPanel>
<!-- Strichliste Meldungen (Nutzer-Feedback): schnelles Mitzählen ohne den <!-- Strichliste Meldungen (Nutzer-Feedback): schnelles Mitzählen ohne den
vollen Bewertungsdialog zu öffnen --> vollen Bewertungsdialog zu öffnen -->
<StackPanel Orientation="Horizontal" HorizontalAlignment="Center" Spacing="5" <StackPanel Orientation="Horizontal" HorizontalAlignment="Center" Spacing="5"
IsVisible="{Binding ShowLessonOverview}"> IsVisible="{Binding ShowNormalActions}">
<Button Padding="6,2" FontSize="10" <Button Padding="6,2" FontSize="10"
Command="{Binding TallyRaisedHandCommand}" Command="{Binding TallyRaisedHandCommand}"
ToolTip.Tip="Meldung zählen"> ToolTip.Tip="Meldung zählen">
@@ -174,7 +193,7 @@
</Button> </Button>
</StackPanel> </StackPanel>
<Expander Header=" Situation" FontSize="10" <Expander Header=" Situation" FontSize="10"
IsVisible="{Binding CanRecordLesson}"> IsVisible="{Binding ShowSituationActions}">
<ItemsControl ItemsSource="{Binding SituationTags}" Margin="0,4,0,0"> <ItemsControl ItemsSource="{Binding SituationTags}" Margin="0,4,0,0">
<ItemsControl.ItemsPanel> <ItemsControl.ItemsPanel>
<ItemsPanelTemplate><WrapPanel ItemSpacing="3" LineSpacing="3"/></ItemsPanelTemplate> <ItemsPanelTemplate><WrapPanel ItemSpacing="3" LineSpacing="3"/></ItemsPanelTemplate>
@@ -1,5 +1,6 @@
<Window xmlns="https://github.com/avaloniaui" <Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:models="clr-namespace:LehrerApp.Core.Models;assembly=LehrerApp.Core"
xmlns:vm="clr-namespace:LehrerApp.Desktop.ViewModels.Groups" xmlns:vm="clr-namespace:LehrerApp.Desktop.ViewModels.Groups"
xmlns:views="clr-namespace:LehrerApp.Desktop.Views.Groups" xmlns:views="clr-namespace:LehrerApp.Desktop.Views.Groups"
x:Class="LehrerApp.Desktop.Views.Groups.TeachingModeWindow" x:Class="LehrerApp.Desktop.Views.Groups.TeachingModeWindow"
@@ -8,8 +9,23 @@
Width="1400" Height="850" MinWidth="1000" MinHeight="600" Width="1400" Height="850" MinWidth="1000" MinHeight="600"
WindowState="Maximized" CanResize="True" WindowStartupLocation="CenterScreen"> WindowState="Maximized" CanResize="True" WindowStartupLocation="CenterScreen">
<Window.Styles>
<Style Selector="Border.phase">
<Setter Property="Background" Value="{DynamicResource SystemControlBackgroundAltHighBrush}"/>
<Setter Property="BorderBrush" Value="Transparent"/>
<Setter Property="BorderThickness" Value="2"/>
</Style>
<Style Selector="Border.phase.overflow">
<Setter Property="Background" Value="#22E57373"/>
<Setter Property="BorderBrush" Value="#99E57373"/>
</Style>
<Style Selector="Border.phase.active">
<Setter Property="Background" Value="#223BA6C8"/>
<Setter Property="BorderBrush" Value="#3BA6C8"/>
</Style>
</Window.Styles>
<Grid RowDefinitions="Auto,*" Margin="20"> <Grid RowDefinitions="Auto,*" Margin="20">
<Grid Grid.Row="0" ColumnDefinitions="*,Auto" Margin="0,0,0,14"> <Grid Grid.Row="0" RowDefinitions="Auto,Auto" Margin="0,0,0,14">
<StackPanel Grid.Column="0" Spacing="3"> <StackPanel Grid.Column="0" Spacing="3">
<TextBlock FontSize="20" FontWeight="SemiBold"> <TextBlock FontSize="20" FontWeight="SemiBold">
<Run Text="{Binding GroupName}"/><Run Text=" · "/><Run Text="{Binding LessonInfo.Topic}"/> <Run Text="{Binding GroupName}"/><Run Text=" · "/><Run Text="{Binding LessonInfo.Topic}"/>
@@ -26,19 +42,22 @@
</TextBlock> </TextBlock>
</StackPanel> </StackPanel>
</StackPanel> </StackPanel>
<StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="8" VerticalAlignment="Center"> <WrapPanel Grid.Row="1" ItemSpacing="8" LineSpacing="6" Margin="0,10,0,0">
<!-- Nutzer-Feedback: die Schnellbewertungs-Dialoge gab es bisher nur über den <!-- Nutzer-Feedback: die Schnellbewertungs-Dialoge gab es bisher nur über den
Mitarbeit-Tab der Gruppe — hier direkt auf die Sitzung dieser Stunde vorselektiert Mitarbeit-Tab der Gruppe — hier direkt auf die Sitzung dieser Stunde vorselektiert
(siehe TeachingModeViewModel), kein Umweg mehr über "Zur Mitarbeit". --> (siehe TeachingModeViewModel), kein Umweg mehr über "Zur Mitarbeit". -->
<Button Content="⚡ Mitarbeit" Command="{Binding Participation.QuickInputCommand}" <Button Content="⚡ Mitarbeit" Command="{Binding Participation.QuickInputCommand}"
ToolTip.Tip="Mitarbeit dieser Stunde schnell bewerten."/> ToolTip.Tip="Mitarbeit dieser Stunde schnell bewerten."/>
<Button Content="Anwesenheit/Hausaufgabe" Command="{Binding Participation.StatusQuickInputCommand}" <Button Content="Anwesenheit kontrollieren" Command="{Binding SeatingPlan.CheckAttendanceCommand}" IsEnabled="{Binding SeatingPlan.IsEditable}"/>
ToolTip.Tip="Anwesenheit und Hausaufgabenstatus dieser Stunde schnell erfassen."/> <Button Content="Hausaufgaben kontrollieren" Command="{Binding SeatingPlan.CheckHomeworkCommand}" IsEnabled="{Binding SeatingPlan.IsEditable}"/>
<Button Content="Kontrolle beenden" Command="{Binding SeatingPlan.EndQuickCheckCommand}" IsVisible="{Binding SeatingPlan.IsQuickMode}"/>
<Button Content="Listenansicht …" Command="{Binding Participation.StatusQuickInputCommand}" ToolTip.Tip="Auch Schüler ohne Sitzplatz erfassen."/>
<Button Content="Vollbild ↔" Click="OnFullScreen" ToolTip.Tip="Vollbild umschalten (F11); mit Escape verlassen."/>
<Button Content="Zur Mitarbeit" Command="{Binding LessonInfo.NavigateToParticipationCommand}" <Button Content="Zur Mitarbeit" Command="{Binding LessonInfo.NavigateToParticipationCommand}"
ToolTip.Tip="Schließt den Unterrichtsmodus und springt zum Tab 'Mitarbeit' der Lerngruppe."/> ToolTip.Tip="Schließt den Unterrichtsmodus und springt zum Tab 'Mitarbeit' der Lerngruppe."/>
<Button Content="Zu den Noten" Command="{Binding LessonInfo.NavigateToGradesCommand}"/> <Button Content="Zu den Noten" Command="{Binding LessonInfo.NavigateToGradesCommand}"/>
<Button Content="Unterrichtsmodus beenden" Click="OnClose" Margin="16,0,0,0"/> <Button Content="Unterrichtsmodus beenden" Click="OnClose" Margin="16,0,0,0"/>
</StackPanel> </WrapPanel>
</Grid> </Grid>
<Grid Grid.Row="1" ColumnDefinitions="360,16,*"> <Grid Grid.Row="1" ColumnDefinitions="360,16,*">
@@ -48,44 +67,78 @@
<StackPanel Spacing="14"> <StackPanel Spacing="14">
<TextBlock Text="Verlaufsplan" FontSize="14" FontWeight="SemiBold"/> <TextBlock Text="Verlaufsplan" FontSize="14" FontWeight="SemiBold"/>
<ItemsControl ItemsSource="{Binding LessonInfo.PhaseGroups}"> <TextBlock Text="Hauptweg · Live-Verlauf" FontSize="12" Opacity="0.65"/>
<TextBlock Text="Noch keine Phasen geplant." IsVisible="{Binding !Timeline.HasPhases}"/>
<Button Content="Zeitmessung jetzt starten" Command="{Binding Timeline.StartNowCommand}" IsVisible="{Binding Timeline.NeedsStart}" IsEnabled="{Binding Timeline.IsEditable}"/>
<ItemsControl ItemsSource="{Binding Timeline.Phases}">
<ItemsControl.ItemTemplate> <ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:PhaseGroupViewItem"> <DataTemplate x:DataType="vm:TeachingPhaseViewModel">
<StackPanel Margin="0,0,0,10"> <Border Classes="phase" Classes.active="{Binding IsActive}" Classes.overflow="{Binding IsOverflow}" CornerRadius="6" Padding="10,8" Margin="0,0,0,8">
<StackPanel IsVisible="{Binding $parent[ItemsControl].((vm:LessonViewerViewModel)DataContext).HasAlternatives}"> <StackPanel Spacing="5">
<TextBlock Text="{Binding Label}" FontSize="12" FontWeight="SemiBold" Opacity="0.75" Margin="0,6,0,2"/> <TextBlock Text="{Binding Source.Name}" FontWeight="SemiBold" TextWrapping="Wrap"/>
<TextBlock Text="{Binding Description}" FontSize="11" Opacity="0.55" TextWrapping="Wrap" Margin="0,0,0,6" <TextBlock Text="{Binding TimeDisplay}" FontSize="12"/>
IsVisible="{Binding Description, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/> <TextBlock Text="{Binding Source.DurationMinutes, StringFormat='Geplant: {0} Min.'}" FontSize="11" Opacity="0.7"/>
<TextBlock Text="{Binding Source.Activity}" TextWrapping="Wrap" FontSize="12"
IsVisible="{Binding Source.Activity, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
<TextBlock Text="{Binding Source.Material}" TextWrapping="Wrap" FontSize="11" Opacity="0.7"
IsVisible="{Binding Source.Material, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
<TextBlock Text="{Binding Source.Shorthand}" FontSize="11" Opacity="0.7"
IsVisible="{Binding Source.Shorthand, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
<ProgressBar Minimum="0" Maximum="100" Value="{Binding Progress}" Height="3" IsVisible="{Binding IsActive}"/>
<TextBlock Text="Über dem geplanten Stundenende" FontSize="11" IsVisible="{Binding IsOverflow}"/>
<TextBlock Text="Erledigt" FontSize="11" Opacity="0.6" IsVisible="{Binding IsCompleted}"/>
<TextBlock Text="In Folgestunde kopiert" FontSize="11" IsVisible="{Binding IsTransferred}"/>
<StackPanel IsVisible="{Binding IsActive}" IsEnabled="{Binding IsEditable}" Spacing="4">
<WrapPanel ItemSpacing="4" LineSpacing="4">
<Button Content="Weiter" Command="{Binding FinishCommand}" FontSize="11" Padding="6,4"/>
<Button Content="+5 Min." Command="{Binding ExtendFiveCommand}" FontSize="11" Padding="6,4"/>
<Button Content="+10 Min." Command="{Binding ExtendTenCommand}" FontSize="11" Padding="6,4"/>
<Button Content="Halten bis Weiter" Command="{Binding HoldCommand}" FontSize="11" Padding="6,4" IsEnabled="{Binding !IsHeld}"/>
</WrapPanel>
<TextBlock Text="Gehalten mit Weiter nächste Phase starten" FontSize="11" TextWrapping="Wrap" IsVisible="{Binding IsHeld}"/>
</StackPanel>
<Button Content="Jetzt vorziehen" Command="{Binding BringForwardCommand}" IsVisible="{Binding CanStartNow}" FontSize="11"/>
</StackPanel> </StackPanel>
<ItemsControl ItemsSource="{Binding Phases}"> </Border>
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:PhaseViewItem">
<Border Background="{DynamicResource SystemControlBackgroundAltHighBrush}"
CornerRadius="6" Padding="10,8" Margin="0,0,0,6">
<StackPanel Spacing="2">
<Grid ColumnDefinitions="*,Auto">
<TextBlock Grid.Column="0" Text="{Binding Name}" FontSize="13" FontWeight="SemiBold"
TextWrapping="Wrap"/>
<TextBlock Grid.Column="1" FontSize="11" Opacity="0.6">
<Run Text="{Binding TimeDisplay}"/><Run Text=" · "/>
<Run Text="{Binding DurationMinutes, StringFormat='{}{0} Min.'}"/>
</TextBlock>
</Grid>
<TextBlock Text="{Binding Activity}" FontSize="12" TextWrapping="Wrap"
IsVisible="{Binding Activity, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
<TextBlock FontSize="11" Opacity="0.55" TextWrapping="Wrap"
IsVisible="{Binding Material, Converter={x:Static StringConverters.IsNotNullOrEmpty}}">
<Run Text="Material: "/><Run Text="{Binding Material}"/>
</TextBlock>
</StackPanel>
</Border>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</StackPanel>
</DataTemplate> </DataTemplate>
</ItemsControl.ItemTemplate> </ItemsControl.ItemTemplate>
</ItemsControl> </ItemsControl>
<StackPanel Spacing="6" IsVisible="{Binding Timeline.HasRemainder}" IsEnabled="{Binding Timeline.IsEditable}">
<TextBlock Text="Rest für eine Folgestunde" FontWeight="SemiBold"/>
<ComboBox ItemsSource="{Binding Timeline.TransferTargets}" SelectedItem="{Binding Timeline.TransferTarget}" HorizontalAlignment="Stretch">
<ComboBox.ItemTemplate>
<DataTemplate x:DataType="models:Lesson">
<TextBlock><Run Text="{Binding Date, StringFormat='{}{0:dd.MM.yyyy}'}"/><Run Text=" · "/><Run Text="{Binding Topic}"/></TextBlock>
</DataTemplate>
</ComboBox.ItemTemplate>
</ComboBox>
<Button Content="Rötlich markierte Phasen kopieren" Command="{Binding Timeline.TransferRemainderCommand}" IsEnabled="{Binding Timeline.HasTransferTargets}"/>
<TextBlock Text="Zuerst eine Folgestunde in der Planung anlegen." IsVisible="{Binding !Timeline.HasTransferTargets}" TextWrapping="Wrap"/>
</StackPanel>
<TextBlock Text="{Binding Timeline.Status}" TextWrapping="Wrap" FontSize="11"/>
<Expander Header="Alternative Abläufe" IsVisible="{Binding LessonInfo.HasAlternatives}">
<ItemsControl ItemsSource="{Binding LessonInfo.PhaseGroups}">
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:PhaseGroupViewItem">
<StackPanel IsVisible="{Binding !IsMainPath}" Spacing="4" Margin="0,4">
<TextBlock Text="{Binding Label}" FontWeight="SemiBold"/>
<TextBlock Text="{Binding Description}" TextWrapping="Wrap"/>
<ItemsControl ItemsSource="{Binding Phases}">
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:PhaseViewItem">
<StackPanel Margin="0,4">
<TextBlock><Run Text="{Binding Name}"/><Run Text="{Binding DurationMinutes, StringFormat=' · {0} Min.'}"/></TextBlock>
<TextBlock Text="{Binding Activity}" TextWrapping="Wrap" FontSize="12"/>
<TextBlock Text="{Binding Material}" TextWrapping="Wrap" FontSize="11"/>
</StackPanel>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</StackPanel>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</Expander>
<!-- Nutzer-Feedback: Hausaufgabe der letzten Stunde ansehen/als kontrolliert abhaken <!-- Nutzer-Feedback: Hausaufgabe der letzten Stunde ansehen/als kontrolliert abhaken
und die Hausaufgabe DIESER Stunde einsehen/ändern, ohne den vollen und die Hausaufgabe DIESER Stunde einsehen/ändern, ohne den vollen
@@ -1,5 +1,9 @@
using Avalonia.Controls; using Avalonia.Controls;
using Avalonia.Interactivity; using Avalonia.Interactivity;
using Avalonia.Input;
using Avalonia.Threading;
using LehrerApp.Core.Models;
using LehrerApp.Core.Interfaces;
using LehrerApp.Desktop.ViewModels; using LehrerApp.Desktop.ViewModels;
using LehrerApp.Desktop.ViewModels.Groups; using LehrerApp.Desktop.ViewModels.Groups;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
@@ -8,7 +12,53 @@ namespace LehrerApp.Desktop.Views.Groups;
public partial class TeachingModeWindow : Window public partial class TeachingModeWindow : Window
{ {
public TeachingModeWindow() => InitializeComponent(); private static readonly Dictionary<Guid, TeachingModeWindow> OpenWindows = [];
private readonly DispatcherTimer _clock = new() { Interval = TimeSpan.FromSeconds(1) };
private WindowState _previousState = WindowState.Maximized;
public static void Open(Lesson lesson)
{
if (OpenWindows.TryGetValue(lesson.Id, out var existing))
{
if (existing.WindowState == WindowState.Minimized) existing.WindowState = WindowState.Normal;
existing.Activate();
return;
}
var group = App.Services.GetRequiredService<IGroupRepository>().GetById(lesson.GroupId);
if (group is null) return;
var window = new TeachingModeWindow
{
DataContext = new TeachingModeViewModel(lesson, group,
App.Services.GetRequiredService<IAlternativeLessonPathRepository>(),
App.Services.GetRequiredService<ILessonRepository>(),
App.Services.GetRequiredService<SeatingPlanTabViewModel>(),
App.Services.GetRequiredService<ParticipationTabViewModel>())
};
OpenWindows.Add(lesson.Id, window);
window.Closed += (_, _) => OpenWindows.Remove(lesson.Id);
window.Show();
}
public TeachingModeWindow()
{
InitializeComponent();
_clock.Tick += (_, _) => (DataContext as TeachingModeViewModel)?.Timeline.Refresh();
Opened += (_, _) => _clock.Start();
Closed += (_, _) => _clock.Stop();
KeyDown += (_, e) =>
{
if (e.Key == Key.F11) { ToggleFullScreen(); e.Handled = true; }
else if (e.Key == Key.Escape && WindowState == WindowState.FullScreen)
{ WindowState = _previousState; e.Handled = true; }
};
}
private void ToggleFullScreen()
{
if (WindowState == WindowState.FullScreen) WindowState = _previousState;
else { _previousState = WindowState; WindowState = WindowState.FullScreen; }
}
private void OnFullScreen(object? sender, RoutedEventArgs e) => ToggleFullScreen();
protected override void OnDataContextChanged(EventArgs e) protected override void OnDataContextChanged(EventArgs e)
{ {
@@ -35,6 +85,7 @@ public partial class TeachingModeWindow : Window
private async Task ShowQuickInputDialog(ParticipationTabViewModel tabVm) private async Task ShowQuickInputDialog(ParticipationTabViewModel tabVm)
{ {
if (tabVm.StudentRows.Count == 0) return; if (tabVm.StudentRows.Count == 0) return;
tabVm.RefreshCurrentGrid();
var quickVm = new QuickInputViewModel(tabVm.StudentRows.ToList(), tabVm.Aspects.ToList()); var quickVm = new QuickInputViewModel(tabVm.StudentRows.ToList(), tabVm.Aspects.ToList());
var dialog = new ParticipationQuickInputDialog { DataContext = quickVm }; var dialog = new ParticipationQuickInputDialog { DataContext = quickVm };
await dialog.ShowDialog(this); await dialog.ShowDialog(this);
@@ -44,6 +95,7 @@ public partial class TeachingModeWindow : Window
private async Task ShowStatusQuickInputDialog(ParticipationTabViewModel tabVm) private async Task ShowStatusQuickInputDialog(ParticipationTabViewModel tabVm)
{ {
if (tabVm.StudentRows.Count == 0 || tabVm.SelectedSession is null) return; if (tabVm.StudentRows.Count == 0 || tabVm.SelectedSession is null) return;
tabVm.RefreshCurrentGrid();
var quickVm = new AttendanceHomeworkQuickInputViewModel(tabVm.StudentRows, tabVm.SelectedSessionDisplay); var quickVm = new AttendanceHomeworkQuickInputViewModel(tabVm.StudentRows, tabVm.SelectedSessionDisplay);
var dialog = new AttendanceHomeworkQuickInputDialog { DataContext = quickVm }; var dialog = new AttendanceHomeworkQuickInputDialog { DataContext = quickVm };
await dialog.ShowDialog(this); await dialog.ShowDialog(this);
@@ -4,12 +4,12 @@
xmlns:conv="clr-namespace:LehrerApp.Desktop.Converters" xmlns:conv="clr-namespace:LehrerApp.Desktop.Converters"
x:Class="LehrerApp.Desktop.Views.Groups.WebUntisLessonAbsenceComparisonDialog" x:Class="LehrerApp.Desktop.Views.Groups.WebUntisLessonAbsenceComparisonDialog"
x:DataType="vm:WebUntisLessonAbsenceComparisonViewModel" x:DataType="vm:WebUntisLessonAbsenceComparisonViewModel"
Title="Fehlzeiten je Unterricht mit WebUntis abgleichen" Width="1000" Height="640" Title="Fehlzeiten je Unterricht mit WebUntis abgleichen" Width="1150" Height="640"
MinWidth="800" MinHeight="450" WindowStartupLocation="CenterOwner"> MinWidth="900" MinHeight="450" WindowStartupLocation="CenterOwner">
<Grid RowDefinitions="Auto,Auto,Auto,*,Auto,Auto" Margin="24" RowSpacing="8"> <Grid RowDefinitions="Auto,Auto,Auto,*,Auto,Auto" Margin="24" RowSpacing="8">
<StackPanel Grid.Row="0" Spacing="4"> <StackPanel Grid.Row="0" Spacing="4">
<TextBlock Text="Fehlzeiten je Unterricht mit WebUntis abgleichen" Classes="dialogtitle"/> <TextBlock Text="Fehlzeiten je Unterricht mit WebUntis abgleichen" Classes="dialogtitle"/>
<TextBlock Text="Nur markierte Zeilen mit einer vorhandenen lokalen Kursstunde werden übernommen. Zeilen ohne automatische Zuordnung bitte manuell einem Kursmitglied zuweisen." <TextBlock Text="Nur markierte Zeilen mit einer vorhandenen lokalen Kursstunde werden übernommen. Zeilen ohne automatische Zuordnung bitte manuell einem Kursmitglied zuweisen. „Übernahme als“ ist mit dem aus WebUntis abgeleiteten Status vorbelegt, aber vor dem Übernehmen änderbar."
FontSize="12" Opacity="0.65" TextWrapping="Wrap"/> FontSize="12" Opacity="0.65" TextWrapping="Wrap"/>
</StackPanel> </StackPanel>
<StackPanel Grid.Row="1" Orientation="Horizontal" Spacing="8"> <StackPanel Grid.Row="1" Orientation="Horizontal" Spacing="8">
@@ -17,20 +17,24 @@
<TextBlock Text="bis" VerticalAlignment="Center"/> <TextBlock Text="bis" VerticalAlignment="Center"/>
<CalendarDatePicker SelectedDate="{Binding EndDate, Converter={x:Static conv:DateTimeOffsetToDateTimeConverter.Instance}}"/> <CalendarDatePicker SelectedDate="{Binding EndDate, Converter={x:Static conv:DateTimeOffsetToDateTimeConverter.Instance}}"/>
<Button Content="Fehlzeiten laden" Command="{Binding LoadCommand}" IsEnabled="{Binding !Busy}"/> <Button Content="Fehlzeiten laden" Command="{Binding LoadCommand}" IsEnabled="{Binding !Busy}"/>
<Button Content="KI-Vorschlag für Übernahme laden" Command="{Binding SuggestStatusWithAiCommand}"
IsEnabled="{Binding !AiSuggestBusy}"
ToolTip.Tip="Schickt für jede geladene Zeile nur eine technische Positions-Id sowie Grund/Minuten/Bearbeitet-Kennzeichen ans KI-Backend - nie Namen, Klasse oder Datum. Setzt nur die &quot;Übernahme als&quot;-Auswahl vor, übernimmt nichts von selbst."/>
</StackPanel> </StackPanel>
<Grid Grid.Row="2" ColumnDefinitions="Auto,1.2*,1.2*,80,80,1.1*,1.1*" ColumnSpacing="8" Margin="4,0"> <Grid Grid.Row="2" ColumnDefinitions="Auto,1.1*,1.1*,80,80,1.1*,1.1*,1*" ColumnSpacing="8" Margin="4,0">
<TextBlock Grid.Column="1" Text="Name (WebUntis)" FontSize="11" Opacity="0.6"/> <TextBlock Grid.Column="1" Text="Name (WebUntis)" FontSize="11" Opacity="0.6"/>
<TextBlock Grid.Column="2" Text="Zuordnung" FontSize="11" Opacity="0.6"/> <TextBlock Grid.Column="2" Text="Zuordnung" FontSize="11" Opacity="0.6"/>
<TextBlock Grid.Column="3" Text="Datum" FontSize="11" Opacity="0.6"/> <TextBlock Grid.Column="3" Text="Datum" FontSize="11" Opacity="0.6"/>
<TextBlock Grid.Column="4" Text="Zeit" FontSize="11" Opacity="0.6"/> <TextBlock Grid.Column="4" Text="Zeit" FontSize="11" Opacity="0.6"/>
<TextBlock Grid.Column="5" Text="WebUntis-Status" FontSize="11" Opacity="0.6"/> <TextBlock Grid.Column="5" Text="WebUntis-Status" FontSize="11" Opacity="0.6"/>
<TextBlock Grid.Column="6" Text="Lokaler Status" FontSize="11" Opacity="0.6"/> <TextBlock Grid.Column="6" Text="Übernahme als" FontSize="11" Opacity="0.6"/>
<TextBlock Grid.Column="7" Text="Lokaler Status" FontSize="11" Opacity="0.6"/>
</Grid> </Grid>
<ScrollViewer Grid.Row="3"> <ScrollViewer Grid.Row="3">
<ItemsControl ItemsSource="{Binding Rows}"> <ItemsControl ItemsSource="{Binding Rows}">
<ItemsControl.ItemTemplate> <ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:WebUntisLessonAbsenceRow"> <DataTemplate x:DataType="vm:WebUntisLessonAbsenceRow">
<Grid ColumnDefinitions="Auto,1.2*,1.2*,80,80,1.1*,1.1*" ColumnSpacing="8" Margin="0,3"> <Grid ColumnDefinitions="Auto,1.1*,1.1*,80,80,1.1*,1.1*,1*" ColumnSpacing="8" Margin="0,3">
<CheckBox Grid.Column="0" IsChecked="{Binding Selected}" IsEnabled="{Binding CanApply}"/> <CheckBox Grid.Column="0" IsChecked="{Binding Selected}" IsEnabled="{Binding CanApply}"/>
<TextBlock Grid.Column="1" Text="{Binding UntisStudentName}" VerticalAlignment="Center" TextWrapping="Wrap"/> <TextBlock Grid.Column="1" Text="{Binding UntisStudentName}" VerticalAlignment="Center" TextWrapping="Wrap"/>
<ComboBox Grid.Column="2" ItemsSource="{Binding Candidates}" SelectedItem="{Binding AssignedStudent}" <ComboBox Grid.Column="2" ItemsSource="{Binding Candidates}" SelectedItem="{Binding AssignedStudent}"
@@ -39,7 +43,9 @@
<TextBlock Grid.Column="3" Text="{Binding DateLabel}" VerticalAlignment="Center"/> <TextBlock Grid.Column="3" Text="{Binding DateLabel}" VerticalAlignment="Center"/>
<TextBlock Grid.Column="4" Text="{Binding TimeLabel}" VerticalAlignment="Center"/> <TextBlock Grid.Column="4" Text="{Binding TimeLabel}" VerticalAlignment="Center"/>
<TextBlock Grid.Column="5" Text="{Binding UntisStatus}" TextWrapping="Wrap" VerticalAlignment="Center"/> <TextBlock Grid.Column="5" Text="{Binding UntisStatus}" TextWrapping="Wrap" VerticalAlignment="Center"/>
<TextBlock Grid.Column="6" Text="{Binding LocalStatus}" TextWrapping="Wrap" VerticalAlignment="Center"/> <ComboBox Grid.Column="6" ItemsSource="{Binding StatusOptions}" SelectedItem="{Binding SelectedStatusName}"
HorizontalAlignment="Stretch"/>
<TextBlock Grid.Column="7" Text="{Binding LocalStatus}" TextWrapping="Wrap" VerticalAlignment="Center"/>
</Grid> </Grid>
</DataTemplate> </DataTemplate>
</ItemsControl.ItemTemplate> </ItemsControl.ItemTemplate>
+6
View File
@@ -40,6 +40,12 @@
<MenuItem Header="Klassenbuchabgleich…" Click="OnCompareUntisKlassenbuch"/> <MenuItem Header="Klassenbuchabgleich…" Click="OnCompareUntisKlassenbuch"/>
<MenuItem Header="Hausaufgabenabgleich…" Click="OnCompareUntisHausaufgaben"/> <MenuItem Header="Hausaufgabenabgleich…" Click="OnCompareUntisHausaufgaben"/>
</MenuItem> </MenuItem>
<MenuItem Header="Formulare">
<MenuItem Header="Elternbrief erzeugen…" Click="OnCreateParentLetter"/>
<MenuItem Header="Arbeitsblatt personalisieren…" Click="OnPersonalizeWorksheet"
IsEnabled="{Binding CanPersonalizeWorksheet}"
ToolTip.Tip="Nur verfügbar, während eine einzelne Lerngruppe geöffnet ist"/>
</MenuItem>
</Menu> </Menu>
<DrawerPage x:Name="RootDrawer" <DrawerPage x:Name="RootDrawer"
DrawerLength="220" DrawerLength="220"
@@ -1,10 +1,17 @@
using Avalonia.Controls; using Avalonia.Controls;
using Avalonia.Input; using Avalonia.Input;
using Avalonia.Threading; using Avalonia.Threading;
using LehrerApp.Core.Interfaces;
using LehrerApp.Core.Models;
using LehrerApp.Desktop.Services; using LehrerApp.Desktop.Services;
using LehrerApp.Desktop.ViewModels; using LehrerApp.Desktop.ViewModels;
using LehrerApp.Desktop.ViewModels.Groups;
using LehrerApp.Desktop.ViewModels.Students;
using LehrerApp.Desktop.Views.Groups;
using LehrerApp.Desktop.Views.Students;
using LehrerApp.Desktop.Views.UntisHub; using LehrerApp.Desktop.Views.UntisHub;
using LehrerApp.Sync; using LehrerApp.Sync;
using LehrerApp.Templating;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
namespace LehrerApp.Desktop.Views; namespace LehrerApp.Desktop.Views;
@@ -45,6 +52,25 @@ public partial class MainWindow : Window
await UntisHubActions.RunHausaufgabenAsync(this, App.Services.GetRequiredService<UntisHubService>()); await UntisHubActions.RunHausaufgabenAsync(this, App.Services.GetRequiredService<UntisHubService>());
} }
private async void OnCreateParentLetter(object? sender, Avalonia.Interactivity.RoutedEventArgs e)
{
var picker = new StudentPickerDialog
{ DataContext = new StudentPickerDialogViewModel(App.Services.GetRequiredService<IStudentRepository>()) };
if (await picker.ShowDialog<Student?>(this) is { } student)
await LetterDialogs.ShowCreateLetterDialogAsync(this, student);
}
private async void OnPersonalizeWorksheet(object? sender, Avalonia.Interactivity.RoutedEventArgs e)
{
if (DataContext is not MainWindowViewModel { CurrentGroupDetail.Group: { } group } vm) return;
var studentIds = vm.CurrentGroupDetail!.Students.Select(s => s.Id).ToList();
var dialogVm = new PersonalizeWorksheetDialogViewModel(group, studentIds,
App.Services.GetRequiredService<IStudentRepository>(),
App.Services.GetRequiredService<WorksheetTemplateStore>(),
App.Services.GetRequiredService<ITemplateRenderer>());
await new PersonalizeWorksheetDialog { DataContext = dialogVm }.ShowDialog(this);
}
private void OnWindowKeyDown(object? sender, KeyEventArgs e) private void OnWindowKeyDown(object? sender, KeyEventArgs e)
{ {
if (DataContext is not MainWindowViewModel vm) return; if (DataContext is not MainWindowViewModel vm) return;
@@ -0,0 +1,31 @@
<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
x:Class="LehrerApp.Desktop.Views.Mcp.McpConfirmDialog"
x:CompileBindings="False"
Title="{Binding Title}"
Width="520" MinHeight="240" SizeToContent="Height"
CanResize="True" WindowStartupLocation="CenterOwner"
Topmost="True" ShowInTaskbar="True">
<Grid RowDefinitions="Auto,*,Auto,Auto">
<Border Grid.Row="0" Background="#FFF4E5" BorderBrush="#D97706" BorderThickness="0,0,0,3" Padding="24,16">
<StackPanel Spacing="4">
<TextBlock Text="Vorschlag deines KI-Assistenten — bitte prüfen" FontSize="12" FontWeight="SemiBold" Foreground="#8A5A00"/>
<TextBlock Text="{Binding Title}" FontSize="20" FontWeight="Bold" TextWrapping="Wrap"/>
</StackPanel>
</Border>
<ScrollViewer Grid.Row="1" MaxHeight="380" Margin="24,18">
<TextBlock Text="{Binding Message}" TextWrapping="Wrap" FontSize="14" LineHeight="20"/>
</ScrollViewer>
<StackPanel Grid.Row="2" Margin="24,0,24,14" Spacing="6" IsVisible="{Binding AllowSessionTrust}">
<CheckBox x:Name="TrustOperationCheck" FontSize="12"
Content="Diese Art von Aktion für den Rest der Sitzung nicht mehr nachfragen"/>
<CheckBox x:Name="TrustAllCheck" FontSize="12"
Content="Alle KI-Aktionen für den Rest der Sitzung nicht mehr nachfragen"/>
</StackPanel>
<Grid Grid.Row="3" ColumnDefinitions="*,10,*" Margin="24,0,24,20">
<Button Grid.Column="0" Content="Ablehnen" HorizontalAlignment="Stretch" Padding="0,12" FontSize="14" Click="OnCancel"/>
<Button Grid.Column="2" Content="{Binding ConfirmText}" HorizontalAlignment="Stretch" Padding="0,12" FontSize="14"
FontWeight="SemiBold" Background="#D97706" Foreground="White" Click="OnConfirm"/>
</Grid>
</Grid>
</Window>
@@ -0,0 +1,15 @@
using Avalonia.Controls;
using Avalonia.Interactivity;
namespace LehrerApp.Desktop.Views.Mcp;
public partial class McpConfirmDialog : Window
{
public McpConfirmDialog() => InitializeComponent();
private void OnConfirm(object? sender, RoutedEventArgs e) =>
Close(new McpConfirmDialogResult(true, TrustOperationCheck.IsChecked == true, TrustAllCheck.IsChecked == true));
// Checkbox-Zustand wird beim Ablehnen bewusst ignoriert (siehe McpConfirmDialogResult).
private void OnCancel(object? sender, RoutedEventArgs e) => Close(McpConfirmDialogResult.Rejected);
}
@@ -0,0 +1,21 @@
namespace LehrerApp.Desktop.Views.Mcp;
/// <summary>DataContext für <see cref="McpConfirmDialog"/> — bewusst ein eigener, auffälligerer
/// Dialog statt Wiederverwendung von <see cref="Views.Shared.ConfirmDialog"/>: Letzterer wird an
/// vielen Stellen der App für routinemäßige Bestätigungen verwendet, eine Änderung an dessen
/// Größe/Optik hätte all diese Stellen mitbetroffen. Ein von der KI vorgeschlagener Schreibzugriff
/// soll sich dagegen bewusst absetzen (Nutzer-Feedback: der ursprüngliche gemeinsame Dialog fiel zu
/// wenig auf, wenn LehrerApp gerade nicht im Vordergrund war).</summary>
public class McpConfirmDialogInfo
{
public string Title { get; init; } = "";
public string Message { get; init; } = "";
public string ConfirmText { get; init; } = "Übernehmen";
/// <summary>False blendet beide "nicht mehr nachfragen"-Kontrollkästchen aus (Nutzer-Entscheidung
/// zu <c>get_named_untis_absence_pattern</c>: eine namentliche Fehlzeitenauskunft muss jedes Mal
/// einzeln bestätigt werden) - AvaloniaMcpConfirmationService ignoriert eine Sitzungsfreigabe für
/// diesen Aufruf ohnehin serverseitig, das Ausblenden verhindert zusätzlich die falsche
/// Erwartung, ein angekreuztes Kästchen hätte hier irgendeine Wirkung.</summary>
public bool AllowSessionTrust { get; init; } = true;
}

Some files were not shown because too many files have changed in this diff Show More