Author SHA1 Message Date
adminandClaude Sonnet 5 7d3d021d09 Stundenplan: Wochenend-Flakiness in Unterrichtsmodus-Test behoben
OpenWeekCell_LessonHeuteWaehrendUnterrichtszeit_OeffnetUnterrichtsmodus schlug
an Sa/So fehl, weil today.DayOfWeek dort keiner Kachel im Mo-Fr-Wochenraster
entspricht. TimetableViewModel bekommt dafür eine austauschbare Clock-Property
(Standard: DateTime.Now), sodass der Test "heute" unabhängig vom tatsächlichen
Wochentag simulieren kann.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-29 01:35:16 +02:00
adminandClaude Sonnet 5 7fbf035cbb Klausuren-Hauptseite: gruppenübergreifende Liste mit Prioritäts-Score statt Datum
Sidebar-Klausuren war bisher ein Placeholder. Neue Seite zeigt alle Klausuren des
Schuljahres sortiert nach abgeleiteter Dringlichkeit (Korrekturfortschritt statt
manuellem Status), mit Detailbereich, Parallelkurs-Umschalter und Notenspiegel.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-29 01:27:18 +02:00
admin 9b04d7eb98 UX-Update 2.0, Neues Dashboard 2026-08-29 00:45:19 +02:00
adminandClaude Sonnet 5 229ef79e75 Stundenplan: Popup-Menü im Wochenraster statt Dropdown in der Tagesliste
Nutzer-Feedback: die Tagesliste-Buttons waren schon in Ordnung, das Problem
lag im Wochenraster darüber — ein Klick auf eine Stunden-Kachel führt dort
entweder in den Planungsviewer oder zur Einheitenplanung, ohne dass von
außen erkennbar wäre welches Ziel man bekommt. Tagesliste auf den
ursprünglichen Stand zurückgesetzt.

Neu: ein kleiner "⋮"-Button pro Kachel öffnet ein Popup-Menü mit vier
ausdrücklich benannten Zielen (Unterrichtsansicht/Planungsviewer/Sitzplan/
Planung). MenuFlyout statt ComboBox — dabei verstanden, dass ein
$parent[ItemsControl]-Vorfahrenpfad im Flyout nicht funktioniert, eine
normale {Binding} über die DataContext-Vererbung aber sehr wohl. Der
Direktklick springt jetzt außerdem "einheitlicher": bei einer Lesson
während der eigentlichen Unterrichtszeit direkt in den Unterrichtsmodus
statt in den Viewer.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-27 23:21:49 +02:00
adminandClaude Sonnet 5 cd08bbbbc4 Stundenplan: Dropdown für Unterrichtsansicht/Sitzplan/Planung/Planungsviewer
Die zwei Buttons in der "Heute"-Tagesliste mit teils vom Lesson-Status
abhängiger Doppelbedeutung ("Verlaufsplan ansehen"/"Zur Lerngruppe") waren
unklar. Ersetzt durch ein Dropdown mit vier ausdrücklich benannten Zielen;
drei nutzen bestehende Commands, "Sitzplan" (Sitzpläne-Tab) ist neu und
bisher nur indirekt über den Unterrichtsmodus erreichbar gewesen.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-27 17:03:11 +02:00
adminandClaude Sonnet 5 442e3d5e3d Unterrichtsmodus: Schnellbewertung und Hausaufgabe-Kontrolle direkt im Fenster
Zwei neue Buttons ("Mitarbeit", "Anwesenheit/Hausaufgabe") öffnen die
bestehenden Schnellbewertungs-Dialoge direkt aus dem Unterrichtsmodus, ohne
zum Mitarbeit-Tab wechseln zu müssen — TeachingModeViewModel bekommt dafür
eine eigene ParticipationTabViewModel-Instanz, vorselektiert auf die zu
dieser Stunde gehörende Sitzung. Die linke Spalte zeigt jetzt zusätzlich die
Hausaufgabe der letzten Stunde mit Kontrolliert-Checkbox sowie die
Hausaufgabe dieser Stunde als editierbares Feld, statt nur lesbar.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-27 16:20:40 +02:00
adminandClaude Sonnet 5 3f484ce3c8 Klassenlehrer-Tab: vier Feature-Ideen umgesetzt (ohne Elterngesprächs-PDF)
Offene Entschuldigungen mit Frist (neue Seitenspalten-Karte, sortiert nach
Tagen offen), aggregierte Klassenbuch-Kategorien (Chip-Reihe im
Klassenbuch-Tab), Wochentags-Häufung als dritte Mustererkennungsregel, und
eine Wiedervorlagen-Verknüpfung: ein "+"-Button legt direkt aus der Zeile
eine WorkTask "Eltern kontaktieren" an, und eine dritte Musterregel
verknüpft hohe Jahresfehlquote mit fallendem Mitarbeit-Trend.

ContextMenu auf den Zeilen-Buttons scheiterte an Avalonias
$parent-Vorfahren-Bindings (Popup hängt nicht im normalen visuellen Baum) —
stattdessen zwei Geschwister-Buttons pro Zeile. Elterngesprächs-Blatt als
PDF bewusst zurückgestellt, Konzept noch nicht geschärft.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-27 13:57:09 +02:00
adminandClaude Sonnet 5 3f8dab4abd Klassenlehrer-Tab: Warnung/Gefahr-Farben per validate_palette.js ersetzt
Amber und Korallrot waren laut Nutzer-Feedback auf dem Bildschirm kaum
auseinanderzuhalten. Die dataviz-Skill-Validierung bestätigte das
quantitativ (ΔE 2.5 unter Deuteranopie, 11.4 selbst bei normaler
Farbwahrnehmung — beides unter der Zielschwelle). Neue Werte aus der
validierten Kategorial-/Statuspalette der Skill statt erneut freihändig
gewählter Farben; Tagesüberblick-Balken tauscht zusätzlich die
Segmentreihenfolge, weil Grün und das neue Dunkel-Gelb sonst direkt
aneinanderstießen. Auch ein Kopiereinfügen-Duplikat in TODO.md behoben.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-27 13:33:54 +02:00
adminandClaude Sonnet 5 b3b86f0cf4 Klassenlehrer-Tab: restliche Quick-Wins (responsive Balken, Tagesüberblick-Chart, Tooltips, Status-Symbol)
Balkenbreiten hingen bisher an einer im ViewModel hart codierten Pixelkonstante;
Grid.ColumnDefinitions ließ sich dafür nicht binden (AVLN3000, kein Setter bei
kompilierten Bindings), deshalb neuer FractionWidthConverter (MultiBinding
gegen die gerenderte Breite einer Track-Border statt Grid-Sternspalten).
Tagesüberblick zeigt jetzt denselben gestapelten Balken statt vier
Einzelzeilen. AbsenceTooltip und TodayUnexcusedPercent waren berechnet aber
ungebunden, jetzt sichtbar. Status-Zeilen bekommen zusätzlich zur Farbe ein
vorangestelltes Symbol (Barrierefreiheit).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-27 13:25:32 +02:00
adminandClaude Sonnet 5 9d375caf14 Klassenlehrer-Tab: Theme-Ressourcen statt Hex-Farben, gestapelter Trend, Fehlquote seit Schuljahresbeginn
Semantische Brushes (Light/Dark) lösen die fest codierten Dark-Hex-Werte ab, die
im hellen Theme unbenutzbar waren. Trend-Chart zeigt jetzt einen echten
gestapelten Balken über die letzten 7 Schultage statt drei unabhängig
skalierten über Kalendertage; neue Fehlquote je Schüler*in seit
Schuljahresbeginn ergänzt den Heute-Snapshot.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-27 12:47:13 +02:00
admin cf8de08501 Redesign Klassenlehrer Tab. 2026-08-26 22:50:40 +02:00
admin 265ffe9a74 Untis: Stundenplansync ab App Start 2026-08-26 22:24:10 +02:00
adminandClaude Sonnet 5 0c8bb58e1b fix: Klassenlehrer-Übersicht rief Schülerreport bislang live statt gecacht ab
Die Übersicht wirkte beim Öffnen spürbar zeitverzögert, obwohl Fehlzeiten
und Klassenbuch schon lokal gecacht waren (Nutzer-Feedback: "fühlt sich an
wie ein Live-Pull mit CSV-Parsing"). Grund: genau das passierte - der
Schülerreport (Namen fürs Roster) lief komplett am Cache vorbei bei jedem
Öffnen live gegen WebUntis.

Neues UntisStudentRosterCacheEntry/UntisStudentRosterCacheRepository
(gleiches "kein db.OnChange"-Prinzip wie die bestehenden Caches) plus
UntisReportCacheService.GetStudentRosterAsync - ohne heißes/kaltes Fenster,
da eine Klassenliste keine Historie hat, nur dieselbe Stundenschwelle
"gilt der letzte Abruf noch als frisch".

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-26 22:18:53 +02:00
adminandClaude Sonnet 5 69cc16bbd2 fix: Namensabgleich und Tab-Wechsel im Klassenlehrer-Bereich, Fehltag-Hervorhebung
Zwei Bugs behoben:
- Gefilterte Listen blieben nach Klick auf eine Roster-Zeile leer: WebUntis
  liefert Schülernamen je nach Bericht in unterschiedlicher Reihenfolge
  (Schülerreport "Vorname Nachname", Fehlzeiten-/Klassenbuchberichte
  "Nachname Vorname"). Neuer reihenfolge-unabhängiger Namensvergleich
  (UntisNameMatching), betraf auch den Roster-Namensabgleich der Übersicht
  selbst (Ampel-Symbole/Badges waren ohne ExternKey vermutlich fast immer
  falsch).
- Nach dem ersten Tab-Wechsel (Klick auf Roster-Zeile) blieb ein zweiter
  Klick wirkungslos, sobald man zwischendurch manuell zur Übersicht
  zurückgeklickt hatte: TabbedPage.SelectedIndex war ohne Mode=TwoWay
  gebunden. Explizit ergänzt.

Zusätzlich: Fehlzeiten-Zeilen für einen kompletten Fehltag werden jetzt
dezent hervorgehoben (Nutzer-Feedback, bisher nicht erkennbar).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-25 23:27:14 +02:00
adminandClaude Sonnet 5 2b29dea824 WIP (unstable): lokaler Cache für Klassenlehrer-Fehlzeiten & Klassenbucheinträge
Nutzer-Feedback: die WebUntis-Berichtszeilen sind starr genug für ein eigenes
Datenmodell, Warnungen sollen sofort da sein statt bei jedem Öffnen neu
abgerufen zu werden - vor allem darf derselbe Bericht nicht mehrfach pro
Stunde abgerufen werden, nur weil die Ansicht mehrfach geöffnet wird (Sorge,
bei WebUntis aufzufallen).

Neue Modelle UntisAbsenceCacheEntry/UntisClassRegisterCacheEntry (1:1 zu den
bestehenden DTOs) + UntisCacheFetchState, bewusst nicht synchronisiert
(gleiches "kein db.OnChange"-Muster wie UntisSnapshotEntry/AnnualPlanEvent) -
jedes Gerät ruft WebUntis selbst ab, die Zeilenzahl wächst übers Schuljahr
gewollt an.

UntisReportCacheService: festes heißes Fenster der letzten 14 Tage, höchstens
stündlich automatisch aufgefrischt; alles Ältere gilt als endgültig und wird
dauerhaft aus dem Cache bedient. Die Entscheidungslogik (Plan) ist als reine,
ohne Repositories/HTTP testbare Funktion ausgelagert. Klassenlehrer-Ansichten
nutzen den Cache-Service statt WebUntisIntegrationService direkt; ein
zusätzlicher Button umgeht die Stundensperre bewusst für manuelle Abrufe.

Noch nicht mit echtem WebUntis-Zugang gegengeprüft (siehe TODO.md).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-25 23:10:01 +02:00
adminandClaude Sonnet 5 056e864edd fix: WebUntis-Schülerreport verkraftet fehlenden externKey
Klassenlehrer-Übersicht scheiterte komplett mit "Ungültige Zahl in Spalte
externKey", sobald ein Schüler der Klasse keinen gepflegten externen
Schlüssel hat (in echten Schuldaten vorkommend). externKey ist jetzt
optional statt Pflichtfeld (UntisStudent/UntisStudentDto.ExternKey -> int?);
betraf denselben Bericht wie der bestehende WebUntis-Klassenimport, der das
aber schon immer beim Weiterimport toleriert hatte.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-25 22:47:04 +02:00
adminandClaude Sonnet 5 33c15ffe3e WIP (unstable): Klassenlehrer-Feature auf Einstellungen/eigenen Bereich umgestellt
Nutzer-Feedback: Klassenlehrer ist eine Rolle für die ganze Klasse, unabhängig vom
eigenen Unterricht - die Anbindung an ein LearningGroup.IsClassTeacher-Flag mit Tab
in der Gruppendetailansicht war deshalb der falsche Ort. Ersetzt durch:

- Klassenauswahl in den WebUntis-Einstellungen (WebUntisSettingsService.HomeroomClassName),
  über den bestehenden WebUntis-Klassenpicker.
- Eigener Sidebar-Bereich "Klassenlehrer" statt Gruppen-Tab.
- Zwei Ebenen: kompakte Roster-Übersicht mit Ampel-Symbolen (Fehlzeiten heute,
  Klassenbuch-Badge) und Details (die bisherigen Rohdaten-Listen, jetzt gefiltert
  auf einzelne Schüler*innen anspringbar).

Noch nicht mit echtem WebUntis-Zugang gegengeprüft (siehe TODO.md).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-25 22:30:34 +02:00
admin 5e16ebf1bf Update Sync 2026-08-25 21:30:00 +02:00
adminandClaude Sonnet 5 7245196689 WIP (unstable): Klassenlehrer-Tab (fremde Klassenbucheinträge + Fehlzeiten der Klasse)
Neuer Tab in der Gruppendetailansicht (LearningGroup.IsClassTeacher-Flag), zeigt
Klassenbucheinträge anderer Lehrkräfte sowie Fehlzeiten der ganzen Klasse über alle
Fächer, pro Schüler*in und Tag zusammengefasst. Beides rein lesend, kein Übernehmen
in lokale Daten. Fehlzeiten-Bericht (AbsencePerStudent) adressiert die Klasse über
"KL"+getKlassen-ID, automatisch aufgelöst - noch nicht mit echtem WebUntis-Zugang
gegengeprüft (siehe TODO.md).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-25 21:19:39 +02:00
adminandClaude Sonnet 5 f9f5f75aa8 Dokumentation: Eintrag von WebUntis-Klassenbuch-Abgleich ausschließen
Neues Feld Documentation.ExcludeFromWebUntisSync (Checkbox im Dialog) blendet
den Eintrag aus dem Zwischenablage-Vorschlag im Klassenbuch-Abgleich aus, für
Notizen, die nur für die eigene pädagogische Arbeit gedacht sind.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-25 11:08:50 +02:00
admin 7fa8a93c82 Klassenbucheinträge abgleich und poll 2026-08-25 09:31:41 +02:00
admin 87a7badb44 Untis-API: Fehlzeiten abgleich 2026-08-25 00:21:53 +02:00
admin 17475a781f Untis-API Optimierung. 2026-08-24 22:33:30 +02:00
admin 1db16f8b69 Untis-API: Download Bug behoben 2026-08-24 22:06:42 +02:00
admin 34a9fdf73b Untis-API von Server zu Client 2026-08-24 22:00:26 +02:00
admin 0f10f754d0 Untis API Integration 2026-08-24 21:52:00 +02:00
admin 5841d96c5b Vorarbeit: WebUntis-API 2026-08-24 12:14:36 +02:00
adminandClaude Sonnet 5 a30aab0fa5 Fix: doppelte Mitarbeits-Sitzung durch Unterrichtsmodus-Instanz
EnsureTodaySession() prüfte nur den lokalen Instanz-Zustand, nicht das
Repository. Da der Unterrichtsmodus eine eigene, per DI frisch aufgelöste
SeatingPlanTabViewModel-Instanz nutzt (getrennt vom Sitzplan-Tab der
Kursübersicht), legte eine spätere Bewertung im normalen Tab eine zweite,
unverknüpfte Sitzung für denselben Tag an, statt die bereits über den
Unterrichtsmodus verknüpfte zu erkennen.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-24 11:43:19 +02:00
adminandClaude Sonnet 5 07788dcfcb TODO: TickTick-Sync für Erinnerungen ergänzt
Recherche zur TickTick Open API (Auth per persönlichem Token, Push/Pull-
Endpunkte, Architektur analog zum bestehenden WebUntis-Abgleich) als
neuer Punkt 6.1.7 festgehalten.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-24 01:12:47 +02:00
109 changed files with 9588 additions and 449 deletions
+4
View File
@@ -60,12 +60,16 @@ The API can also run via `docker/docker-compose.yml` (reads `JWT_SECRET` from th
orchestration, timer-driven), `SnapshotService`, `Crypto/SyncCrypto` (AES-256-GCM payload
encryption — desktop events are encrypted at rest and in transit; Companion/WebApp events are
plaintext, see `PlainSyncEvent` vs `SyncEvent`).
- **LehrerApp.WebUntis** — direkter, serverunabhängiger WebUntis-Client für den Desktop. Hält die
persönliche JSON-RPC-Sitzung lokal und parst den Schülerreport lokal; WebUntis-Zugangsdaten und
personenbezogene Antworten dürfen nicht über `LehrerApp.Api` geleitet werden.
- **LehrerApp.Api** — minimal ASP.NET Core server: JWT auth, an append-only `EventStore` plus
`SnapshotStore`/`ReadableSnapshotStore` per device, mapped in `Endpoints/Endpoints.cs`. Sync is
optional — Desktop only registers `SyncEngine`/`SnapshotService` in DI when a server URL is
configured (`AppBootstrapper.LoadServerUrl`).
- Each library has a matching `*.Tests` project (`LehrerApp.Tests` → Core, `LehrerApp.Data.Tests`
Data, `LehrerApp.Desktop.Tests` → Desktop, `LehrerApp.Sync.Tests` → Sync), all xUnit.
`LehrerApp.WebUntis.Tests` covers the direct WebUntis client and report parser.
### MVVM conventions (Desktop)
+1
View File
@@ -1,6 +1,7 @@
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using System.Text;
using System.Globalization;
using LehrerApp.Core.Models;
using LehrerApp.Sync.Models;
using Microsoft.AspNetCore.Mvc;
+2 -1
View File
@@ -1,4 +1,5 @@
using LiteDB;
using LehrerApp.Sync;
using LehrerApp.Sync.Models;
namespace LehrerApp.Api;
@@ -67,7 +68,7 @@ public class EventStore(string dataPath) : IDisposable
{
var col = GetCol(userId);
var events = col.Find(e => e.ServerSeq > since && e.DeviceId != requestingDeviceId)
.OrderBy(e => e.ServerSeq).Take(500)
.OrderBy(e => e.ServerSeq).Take(SyncProtocol.PullBatchSize)
.Select(e => new SyncEvent { EventId = e.EventId, DeviceId = e.DeviceId,
DeviceType = e.DeviceType, Timestamp = e.Timestamp,
SequenceNr = e.ServerSeq, EntityType = e.EntityType,
@@ -136,6 +136,40 @@ public interface IUntisSnapshotRepository
void Save(UntisSnapshotEntry entry);
void Delete(Guid id);
}
/// Lokaler Cache der Fehlzeiten-Berichtszeilen fürs Klassenlehrer-Feature (siehe TODO.md) —
/// bewusst nicht synchronisiert (siehe Implementierung), jedes Gerät ruft WebUntis selbst ab.
public interface IUntisAbsenceCacheRepository
{
List<UntisAbsenceCacheEntry> GetByClassAndRange(string className, int startDate, int endDate);
/// Löscht vorhandene Zeilen im Bereich und fügt die übergebenen neu ein — fürs "heiße" Fenster,
/// dessen Inhalt sich seit dem letzten Abruf geändert haben kann.
void ReplaceRange(string className, int startDate, int endDate, IEnumerable<UntisAbsenceCacheEntry> entries);
/// Fügt nur ein, ohne zu löschen — fürs Erweitern der als endgültig angenommenen "kalten" Historie.
void InsertRange(IEnumerable<UntisAbsenceCacheEntry> entries);
}
/// Lokaler Cache der Klassenbuch-Berichtszeilen anderer Lehrkräfte fürs Klassenlehrer-Feature —
/// gleiches Muster wie <see cref="IUntisAbsenceCacheRepository"/>.
public interface IUntisClassRegisterCacheRepository
{
List<UntisClassRegisterCacheEntry> GetByClassAndRange(string className, int startDate, int endDate);
void ReplaceRange(string className, int startDate, int endDate, IEnumerable<UntisClassRegisterCacheEntry> entries);
void InsertRange(IEnumerable<UntisClassRegisterCacheEntry> entries);
}
/// Lokaler Cache der aktuellen Klassenliste (Roster) fürs Klassenlehrer-Feature — anders als
/// Fehlzeiten/Klassenbuch keine Historie, deshalb kein ReplaceRange/InsertRange, sondern immer der
/// komplette Ersatz des zuletzt bekannten Standes einer Klasse.
public interface IUntisStudentRosterCacheRepository
{
List<UntisStudentRosterCacheEntry> GetByClass(string className);
void ReplaceAll(string className, IEnumerable<UntisStudentRosterCacheEntry> entries);
}
/// Merkt sich je Klasse+Berichtsart, wann das heiße Fenster zuletzt aufgefrischt wurde und wie weit
/// die kalte Historie bereits abgedeckt ist (siehe UntisReportCacheService).
public interface IUntisCacheFetchStateRepository
{
UntisCacheFetchState? Get(string className, UntisCacheKind kind);
void Save(UntisCacheFetchState state);
}
/// Vom Nutzer bestätigte Zuordnungen WebUntis-Wochenmuster → LearningGroup.
public interface IUntisSlotMappingRepository
{
+6
View File
@@ -13,6 +13,12 @@ public class Exam
public DateOnly? ReturnedAt { get; set; }
public Niveau? Niveau { get; set; }
public string? Notes { get; set; }
public DateOnly? ApprovalGrantedAt { get; set; }
public DateOnly? AnnouncedAt { get; set; }
/// Verknüpft Parallelklausuren (gleiche Arbeit, mehrere Kurse) für den Kurs-Umschalter
/// auf der Klausuren-Hauptseite — Id der "Ursprungs"-Klausur, gesetzt beim Duplizieren
/// (siehe ExamDialogViewModel.Save).
public Guid? SharedExamGroupId { get; set; }
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
}
+6
View File
@@ -20,6 +20,12 @@ public class LearningGroup
/// deaktivierbar - blendet die Erinnerung "Ungeplante Stunden" im Dashboard für diese Gruppe aus.
/// </summary>
public bool RequiresLessonPlanning { get; set; } = true;
/// <summary>
/// WebUntis-interne Unterrichtsnummer (lsid) dieser Lerngruppe, Grundlage für den Abruf des
/// "Fehlzeiten pro Unterricht"-Berichts. Wird von WebUntis pro Schuljahr neu vergeben und muss
/// deshalb händisch je Lerngruppe/Schuljahr gepflegt werden - keine JSON-RPC-Methode liefert sie.
/// </summary>
public int? WebUntisLessonId { get; set; }
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
}
+79
View File
@@ -0,0 +1,79 @@
namespace LehrerApp.Core.Models;
/// <summary>
/// Lokal zwischengespeicherte Zeile aus dem WebUntis-Bericht "Fehlzeiten pro Schüler*in"
/// (Klassenlehrer-Feature, siehe TODO.md) — 1:1-Abbildung von `UntisClassAbsenceEntryDto`
/// (LehrerApp.Desktop), damit Fehlzeiten nicht bei jedem Öffnen der Ansicht neu abgerufen werden
/// müssen. <c>Date</c> bewusst als <c>int</c> (yyyyMMdd) wie die DTOs statt <c>DateOnly</c>/
/// <c>DateTime</c> — vermeidet die LiteDB-<c>DateTime</c>-Kind-Tücke (siehe CLAUDE.md) und ist
/// direkt für Bereichs-Queries nutzbar.
/// </summary>
public class UntisAbsenceCacheEntry
{
public Guid Id { get; set; } = Guid.NewGuid();
public string ClassName { get; set; } = "";
public string StudentName { get; set; } = "";
public int? ExternKey { get; set; }
public int Date { get; set; }
public int AbsentPeriods { get; set; }
public int AbsentMinutes { get; set; }
public string? TeacherUsernames { get; set; }
public string? Subject { get; set; }
public string? AbsenceReason { get; set; }
public string? Note { get; set; }
public int? EntryId { get; set; }
public string? HandledOn { get; set; }
public bool Counts { get; set; }
public string? ExcuseNote { get; set; }
public int? PeriodNumber { get; set; }
public string? Status { get; set; }
public bool CountsAsFullDay { get; set; }
}
/// <summary>Lokal zwischengespeicherte Zeile aus dem "-alle-"-Klassenbuchbericht, gefiltert auf
/// Einträge anderer Lehrkräfte (Klassenlehrer-Feature) — 1:1-Abbildung von
/// `UntisForeignClassRegisterEventDto` (LehrerApp.Desktop).</summary>
public class UntisClassRegisterCacheEntry
{
public Guid Id { get; set; } = Guid.NewGuid();
public string ClassName { get; set; } = "";
public int Date { get; set; }
public string? Subject { get; set; }
public string StudentName { get; set; } = "";
public string? TeacherUsername { get; set; }
public string? CategoryName { get; set; }
public string? CategoryGroup { get; set; }
public string? Text { get; set; }
}
/// <summary>Lokal zwischengespeicherte Zeile aus dem WebUntis-Schülerreport, eingeschränkt auf die
/// für die Klassenlehrer-Übersicht nötigen Felder (Roster-Namen) — anders als Fehlzeiten/
/// Klassenbuch keine Historie, sondern immer nur der zuletzt abgerufene Stand einer Klasse
/// (siehe <see cref="UntisClassRegisterCacheEntry"/> für den Grund, warum es trotzdem ein eigenes
/// Modell statt Wiederverwendung des vollen Schülerreport-DTOs ist: nur diese zwei Felder werden
/// für den Roster-Abgleich tatsächlich gebraucht).</summary>
public class UntisStudentRosterCacheEntry
{
public Guid Id { get; set; } = Guid.NewGuid();
public string ClassName { get; set; } = "";
public int? ExternKey { get; set; }
public string DisplayName { get; set; } = "";
}
public enum UntisCacheKind { Absences, ClassRegister, Roster }
/// <summary>
/// Ein Datensatz pro (<see cref="ClassName"/>, <see cref="Kind"/>): wann das "heiße" Fenster
/// (die letzten Tage, deren Status sich noch ändern kann) zuletzt aufgefrischt wurde, und wie weit
/// die "kalte", als endgültig angenommene Historie bereits lückenlos zurückreicht. Getrennt von den
/// Cache-Zeilen selbst, weil ein Fenster ohne Treffer (z.B. eine ereignislose Woche) sonst keine
/// Zeile hinterließe, aus der sich "zuletzt abgerufen am…" ableiten ließe.
/// </summary>
public class UntisCacheFetchState
{
public Guid Id { get; set; } = Guid.NewGuid();
public string ClassName { get; set; } = "";
public UntisCacheKind Kind { get; set; }
public DateTime? HotWindowFetchedAt { get; set; }
public int? ColdCoverageStartDate { get; set; }
}
+4
View File
@@ -24,6 +24,10 @@ public class Documentation : IHasAttachments
/// <summary>Im Unterricht schnell erfasst und noch inhaltlich nachzuarbeiten.</summary>
public bool IsDraft { get; set; }
public bool IsConfidential { get; set; }
/// <summary>Für die pädagogische Arbeit/Leistungsbewertung gedacht, aber nicht für den
/// WebUntis-Klassenbuch-Abgleich vorgesehen - blendet den Eintrag im Abgleichsdialog
/// (WebUntisDocumentationComparisonViewModel) aus dem Zwischenablage-Vorschlag aus.</summary>
public bool ExcludeFromWebUntisSync { get; set; }
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
// Löschen entfernt den Eintrag nicht hart, sondern markiert ihn nur (Nachvollziehbarkeit, 5.1.4).
@@ -0,0 +1,67 @@
using LehrerApp.Core.Models;
namespace LehrerApp.Core.Services;
/// Zählt den Korrekturfortschritt einer Klausur aus den vorhandenen Ergebnissen, statt einen
/// eigenen "Korrektur läuft"-Status pflegen zu müssen — ein `ExamResult` gilt als erledigt, sobald
/// entweder Punkte/Note eingetragen oder der Schüler als abwesend markiert wurde. Von
/// DashboardViewModel (Karte "Offene Korrekturen") und ExamsOverviewViewModel (Klausuren-
/// Hauptseite) gemeinsam genutzt, damit beide Stellen exakt dieselbe Zahl zeigen.
public static class ExamCorrectionCounter
{
public static (int Expected, int Evaluated) Count(Exam exam, List<GroupMembership> groupMemberships,
List<ExamResult> examResults)
{
var expected = groupMemberships.Count(m => GroupMembershipService.IsActiveOn(m, exam.Date));
var evaluated = examResults.Count(r => r.Absent || !string.IsNullOrWhiteSpace(r.Grade) || r.Points.Count > 0);
return (expected, Math.Min(evaluated, expected));
}
}
public enum ExamListStatus { Planned, AwaitingCorrection, CorrectionInProgress, CorrectionStuck, AwaitingReturn, Returned }
public readonly record struct ExamPriorityInfo(ExamListStatus Status, double Score);
/// Ordnet Klausuren für die Klausuren-Hauptseite nach einem unsichtbaren Prioritäts-Score statt
/// nach Datum: unbearbeitete/überfällige Korrekturen oben, erledigte unten. "Korrektur läuft"
/// wird bewusst nicht als eigener, manuell zu pflegender Status abgelegt (siehe
/// `ExamCorrectionCounter`) — bleibt eine Klausur trotzdem lange in Bearbeitung hängen (typischer
/// Grund: ein einzelner nie nachschreibender Schüler), fällt sie nach `StuckAfterDays` aus der
/// dringenden Zone in eine ruhige "hängt fest"-Einstufung, statt dauerhaft oben zu kleben.
public static class ExamPriorityService
{
public const int StuckAfterDays = 21;
private const int CorrectionGraceDays = 3;
public static ExamPriorityInfo Evaluate(Exam exam, int expected, int evaluated, DateOnly today)
{
if (exam.Status == ExamStatus.Returned)
return new ExamPriorityInfo(ExamListStatus.Returned, -100);
if (exam.Status == ExamStatus.Graded)
return new ExamPriorityInfo(ExamListStatus.AwaitingReturn, 300);
if (exam.Status == ExamStatus.Planned)
{
var daysUntil = exam.Date.DayNumber - today.DayNumber;
return new ExamPriorityInfo(ExamListStatus.Planned, Math.Clamp(150 - daysUntil, 20, 150));
}
// Status == Conducted: Korrekturfortschritt entscheidet über die genaue Einstufung.
var daysSince = today.DayNumber - exam.Date.DayNumber;
if (evaluated <= 0)
{
var score = daysSince <= CorrectionGraceDays ? 400 + daysSince * 10 : 600 + daysSince;
return new ExamPriorityInfo(ExamListStatus.AwaitingCorrection, score);
}
if (expected > 0 && evaluated >= expected)
return new ExamPriorityInfo(ExamListStatus.AwaitingReturn, 300);
if (daysSince > StuckAfterDays)
return new ExamPriorityInfo(ExamListStatus.CorrectionStuck, 90);
return new ExamPriorityInfo(ExamListStatus.CorrectionInProgress, 400 + daysSince * 3);
}
}
@@ -60,6 +60,8 @@ public sealed class GroupRolloverService(
IsActive = true,
IsOwnClass = source.IsOwnClass,
IsDifferentiated = source.IsDifferentiated,
// WebUntisLessonId bewusst nicht übernommen: WebUntis vergibt sie pro Schuljahr neu,
// eine übernommene alte lsid würde in der Folgegruppe stumm falsche Fehlzeiten liefern.
};
var sourceWasActive = source.IsActive;
+95
View File
@@ -1053,6 +1053,101 @@ public sealed class RepositoryTests
Assert.Equal("fahrt", repo.GetByExternalId("fahrt")!.ExternalId);
}
// ── UntisAbsenceCacheRepository / UntisClassRegisterCacheRepository / UntisCacheFetchStateRepository ──
[Fact]
public void UntisAbsenceCacheRepository_GetByClassAndRange_FiltertKlasseUndDatumsbereich()
{
using var db = NewInMemoryContext();
var repo = new UntisAbsenceCacheRepository(db);
repo.InsertRange(
[
new UntisAbsenceCacheEntry { ClassName = "10c", StudentName = "Max", Date = 20260824 },
new UntisAbsenceCacheEntry { ClassName = "10c", StudentName = "Max", Date = 20260901 },
new UntisAbsenceCacheEntry { ClassName = "10d", StudentName = "Ben", Date = 20260824 },
]);
var result = repo.GetByClassAndRange("10c", 20260801, 20260831);
Assert.Single(result);
Assert.Equal(20260824, result[0].Date);
}
[Fact]
public void UntisAbsenceCacheRepository_ReplaceRange_ErsetztNurDenAngegebenenBereich()
{
using var db = NewInMemoryContext();
var repo = new UntisAbsenceCacheRepository(db);
repo.InsertRange(
[
new UntisAbsenceCacheEntry { ClassName = "10c", StudentName = "Alt", Date = 20260810 },
new UntisAbsenceCacheEntry { ClassName = "10c", StudentName = "Alt", Date = 20260901 },
]);
repo.ReplaceRange("10c", 20260801, 20260831,
[new UntisAbsenceCacheEntry { ClassName = "10c", StudentName = "Neu", Date = 20260815 }]);
var inRange = repo.GetByClassAndRange("10c", 20260801, 20260831);
var untouched = repo.GetByClassAndRange("10c", 20260901, 20260901);
Assert.Equal("Neu", Assert.Single(inRange).StudentName);
Assert.Single(untouched);
}
[Fact]
public void UntisClassRegisterCacheRepository_InsertUndGetByClassAndRange_RoundTrip()
{
using var db = NewInMemoryContext();
var repo = new UntisClassRegisterCacheRepository(db);
repo.InsertRange([new UntisClassRegisterCacheEntry
{
ClassName = "10c", Date = 20260824, StudentName = "Max", Subject = "Deu",
TeacherUsername = "mueller", CategoryName = "Positiv",
}]);
var result = Assert.Single(repo.GetByClassAndRange("10c", 20260801, 20260831));
Assert.Equal("mueller", result.TeacherUsername);
}
[Fact]
public void UntisStudentRosterCacheRepository_ReplaceAll_ErsetztNurDieAngegebeneKlasse()
{
using var db = NewInMemoryContext();
var repo = new UntisStudentRosterCacheRepository(db);
repo.ReplaceAll("10c", [new UntisStudentRosterCacheEntry { ClassName = "10c", DisplayName = "Alt" }]);
repo.ReplaceAll("10d", [new UntisStudentRosterCacheEntry { ClassName = "10d", DisplayName = "Andere Klasse" }]);
repo.ReplaceAll("10c", [new UntisStudentRosterCacheEntry { ClassName = "10c", DisplayName = "Neu", ExternKey = 42 }]);
var tenC = repo.GetByClass("10c");
var tenD = repo.GetByClass("10d");
Assert.Equal("Neu", Assert.Single(tenC).DisplayName);
Assert.Equal(42, tenC[0].ExternKey);
Assert.Single(tenD); // andere Klasse bleibt unberührt
}
[Fact]
public void UntisCacheFetchStateRepository_SaveUndGet_RoundTripJeKlasseUndArt()
{
using var db = NewInMemoryContext();
var repo = new UntisCacheFetchStateRepository(db);
var fetchedAt = new DateTime(2026, 8, 25, 10, 0, 0, DateTimeKind.Utc);
repo.Save(new UntisCacheFetchState
{
ClassName = "10c", Kind = UntisCacheKind.Absences,
HotWindowFetchedAt = fetchedAt, ColdCoverageStartDate = 20260101,
});
var result = repo.Get("10c", UntisCacheKind.Absences);
var missing = repo.Get("10c", UntisCacheKind.ClassRegister);
Assert.NotNull(result);
// LiteDB rundet Kind Utc->Local, verschiebt dabei die Ticks um denselben Zeitpunkt zu
// erhalten (siehe CLAUDE.md) - deshalb erst beide Seiten auf UTC normalisieren.
Assert.Equal(fetchedAt.ToUniversalTime(), result!.HotWindowFetchedAt!.Value.ToUniversalTime());
Assert.Equal(20260101, result.ColdCoverageStartDate);
Assert.Null(missing);
}
// ── WorkTaskRepository ────────────────────────────────────────────────────
[Fact]
+11
View File
@@ -68,6 +68,10 @@ public class LiteDbContext : IDisposable
public ILiteCollection<SubstitutionEntry> SubstitutionEntries => _db.GetCollection<SubstitutionEntry>("substitution_entries");
public ILiteCollection<UntisSnapshotEntry> UntisSnapshotEntries => _db.GetCollection<UntisSnapshotEntry>("untis_snapshot_entries");
public ILiteCollection<UntisSlotMapping> UntisSlotMappings => _db.GetCollection<UntisSlotMapping>("untis_slot_mappings");
public ILiteCollection<UntisAbsenceCacheEntry> UntisAbsenceCache => _db.GetCollection<UntisAbsenceCacheEntry>("untis_absence_cache");
public ILiteCollection<UntisClassRegisterCacheEntry> UntisClassRegisterCache => _db.GetCollection<UntisClassRegisterCacheEntry>("untis_classregister_cache");
public ILiteCollection<UntisCacheFetchState> UntisCacheFetchStates => _db.GetCollection<UntisCacheFetchState>("untis_cache_fetch_state");
public ILiteCollection<UntisStudentRosterCacheEntry> UntisStudentRosterCache => _db.GetCollection<UntisStudentRosterCacheEntry>("untis_student_roster_cache");
public ILiteCollection<AnnualPlanEvent> AnnualPlanEvents => _db.GetCollection<AnnualPlanEvent>("annual_plan_events");
public ILiteCollection<TrashedItem> TrashedItems => _db.GetCollection<TrashedItem>("trash");
@@ -535,6 +539,13 @@ public class LiteDbContext : IDisposable
AnnualPlanEvents.EnsureIndex(x => x.ExternalId, unique: true);
AnnualPlanEvents.EnsureIndex(x => x.StartDate);
AnnualPlanEvents.EnsureIndex(x => x.EndDate);
UntisAbsenceCache.EnsureIndex(x => x.ClassName);
UntisAbsenceCache.EnsureIndex(x => x.Date);
UntisClassRegisterCache.EnsureIndex(x => x.ClassName);
UntisClassRegisterCache.EnsureIndex(x => x.Date);
UntisStudentRosterCache.EnsureIndex(x => x.ClassName);
UntisCacheFetchStates.EnsureIndex("ux_class_kind",
BsonExpression.Create("STRING($.ClassName) + ':' + STRING($.Kind)"), unique: true);
}
public void Dispose() => _db.Dispose();
@@ -794,6 +794,65 @@ public class AnnualPlanEventRepository(LiteDbContext db) : IAnnualPlanEventRepos
public void Delete(Guid id) => db.AnnualPlanEvents.Delete(id);
}
// Wie UntisSnapshotEntry/AnnualPlanEvent rein lokal, bewusst kein db.OnChange: Fehlzeiten- und
// Klassenbuch-Cache fürs Klassenlehrer-Feature (siehe TODO.md) wachsen über ein Schuljahr auf viele
// hundert Zeilen an - über Sync würde das nur Rauschen erzeugen, und jedes Gerät ruft WebUntis
// ohnehin selbst ab (siehe UntisReportCacheService).
public class UntisAbsenceCacheRepository(LiteDbContext db) : IUntisAbsenceCacheRepository
{
public List<UntisAbsenceCacheEntry> GetByClassAndRange(string className, int startDate, int endDate) =>
db.UntisAbsenceCache
.Find(e => e.ClassName == className && e.Date >= startDate && e.Date <= endDate)
.ToList();
public void ReplaceRange(string className, int startDate, int endDate, IEnumerable<UntisAbsenceCacheEntry> entries)
{
db.UntisAbsenceCache.DeleteMany(e => e.ClassName == className && e.Date >= startDate && e.Date <= endDate);
db.UntisAbsenceCache.InsertBulk(entries);
}
public void InsertRange(IEnumerable<UntisAbsenceCacheEntry> entries) => db.UntisAbsenceCache.InsertBulk(entries);
}
public class UntisClassRegisterCacheRepository(LiteDbContext db) : IUntisClassRegisterCacheRepository
{
public List<UntisClassRegisterCacheEntry> GetByClassAndRange(string className, int startDate, int endDate) =>
db.UntisClassRegisterCache
.Find(e => e.ClassName == className && e.Date >= startDate && e.Date <= endDate)
.ToList();
public void ReplaceRange(string className, int startDate, int endDate, IEnumerable<UntisClassRegisterCacheEntry> entries)
{
db.UntisClassRegisterCache.DeleteMany(e => e.ClassName == className && e.Date >= startDate && e.Date <= endDate);
db.UntisClassRegisterCache.InsertBulk(entries);
}
public void InsertRange(IEnumerable<UntisClassRegisterCacheEntry> entries) => db.UntisClassRegisterCache.InsertBulk(entries);
}
// Gleiches "bewusst kein db.OnChange"-Prinzip wie UntisAbsenceCacheRepository oben, auch wenn der
// Roster selbst klein bleibt (aktuelle Klassenliste, keine Historie) - jedes Gerät ruft ihn ohnehin
// selbst ab.
public class UntisStudentRosterCacheRepository(LiteDbContext db) : IUntisStudentRosterCacheRepository
{
public List<UntisStudentRosterCacheEntry> GetByClass(string className) =>
db.UntisStudentRosterCache.Find(e => e.ClassName == className).ToList();
public void ReplaceAll(string className, IEnumerable<UntisStudentRosterCacheEntry> entries)
{
db.UntisStudentRosterCache.DeleteMany(e => e.ClassName == className);
db.UntisStudentRosterCache.InsertBulk(entries);
}
}
public class UntisCacheFetchStateRepository(LiteDbContext db) : IUntisCacheFetchStateRepository
{
public UntisCacheFetchState? Get(string className, UntisCacheKind kind) =>
db.UntisCacheFetchStates.FindOne(s => s.ClassName == className && s.Kind == kind);
public void Save(UntisCacheFetchState state) => db.UntisCacheFetchStates.Upsert(state);
}
public class CompetencyDomainRepository(LiteDbContext db) : ICompetencyDomainRepository
{
public List<CompetencyDomain> GetBySubjectAndGrade(Guid subjectId, int gradeLevel) =>
@@ -0,0 +1,412 @@
using LehrerApp.Core.Models;
using LehrerApp.Desktop.Services;
using LehrerApp.Desktop.ViewModels.ClassTeacher;
using Xunit;
namespace LehrerApp.Desktop.Tests;
public sealed class ClassTeacherViewModelsTests
{
[Fact]
public void GroupByStudentAndDay_FasstFehlstundenProSchuelerUndTagZusammen()
{
var entries = new[]
{
new UntisClassAbsenceEntryDto("Muster Max", 1, "6a", 20260824, 1, 45, "HED", "Che", "Absent",
"Krank", 1, "24.08.26", true, null, 3, "nicht entsch.", false),
new UntisClassAbsenceEntryDto("Muster Max", 1, "6a", 20260824, 1, 45, "PAN", "Kunst", null,
"Krank", 2, "24.08.26", true, null, 5, "nicht entsch.", true),
new UntisClassAbsenceEntryDto("Andere Schuelerin", 2, "6a", 20260824, 1, 20, "BEN", "Deu",
"Verspätung", null, 3, "24.08.26", false, "Zug verpasst", 1, "entsch.", false),
};
var rows = ClassAbsenceDaySummaryRow.GroupByStudentAndDay(entries);
Assert.Equal(2, rows.Count);
var max = rows[1];
Assert.Equal("Muster Max", max.StudentName);
Assert.Equal(2, max.TotalAbsentPeriods);
Assert.Equal(90, max.TotalAbsentMinutes);
Assert.Equal(["Che", "Kunst"], max.Subjects);
Assert.Equal([3, 5], max.PeriodNumbers.Order());
Assert.Equal(["nicht entsch."], max.Statuses);
Assert.Equal(["Absent"], max.AbsenceReasons);
Assert.Equal("Krank", max.Note);
Assert.Null(max.ExcuseNote);
Assert.True(max.CountsAsFullDay);
var andere = rows[0];
Assert.Equal("Andere Schuelerin", andere.StudentName);
Assert.Equal(1, andere.TotalAbsentPeriods);
Assert.Equal(20, andere.TotalAbsentMinutes);
Assert.Equal(["Deu"], andere.Subjects);
Assert.Null(andere.Note);
Assert.Equal("Zug verpasst", andere.ExcuseNote);
Assert.False(andere.CountsAsFullDay);
}
[Fact]
public void GroupByStudentAndDay_TrenntGleichenNamenAnUnterschiedlichenTagen()
{
var entries = new[]
{
new UntisClassAbsenceEntryDto("Muster Max", 1, "6a", 20260824, 1, 45, "HED", "Che", null,
null, 1, "24.08.26", true, null, 3, "nicht entsch.", false),
new UntisClassAbsenceEntryDto("Muster Max", 1, "6a", 20260825, 1, 45, "HED", "Che", null,
null, 2, "25.08.26", true, null, 3, "nicht entsch.", false),
};
var rows = ClassAbsenceDaySummaryRow.GroupByStudentAndDay(entries);
Assert.Equal(2, rows.Count);
Assert.Equal(new DateOnly(2026, 8, 25), rows[0].Date);
Assert.Equal(new DateOnly(2026, 8, 24), rows[1].Date);
}
[Fact]
public void RosterBuild_OrdnetFehlzeitPerExternKeyUndKlassenbuchPerNameZu()
{
var students = new[]
{
Student(1001, "Ada Müller"),
Student(1002, "Ben Schmidt"),
Student(1003, "Cem Yilmaz"),
};
var todayAbsences = new[]
{
new ClassAbsenceDaySummaryRow(new DateOnly(2026, 8, 25), "Ada Müller", 1001, 2, 90,
["Che"], [3, 4], ["nicht entsch."], ["Absent"], null, null, false),
};
var recentClassRegister = new[]
{
new UntisForeignClassRegisterEventDto("6a", 20260824, "Deu", "Ben Schmidt", "mueller",
"Vergessen", "Organisation", "Buch vergessen"),
};
var roster = ClassTeacherRosterRow.Build(students, todayAbsences, recentClassRegister);
Assert.Equal(3, roster.Count);
var ada = Assert.Single(roster, r => r.StudentName == "Ada Müller");
Assert.True(ada.HasAbsenceToday);
Assert.Contains("nicht entsch.", ada.AbsenceTooltip);
Assert.False(ada.HasRecentClassRegisterEntry);
var ben = Assert.Single(roster, r => r.StudentName == "Ben Schmidt");
Assert.False(ben.HasAbsenceToday);
Assert.True(ben.HasRecentClassRegisterEntry);
var cem = Assert.Single(roster, r => r.StudentName == "Cem Yilmaz");
Assert.False(cem.HasAbsenceToday);
Assert.False(cem.HasRecentClassRegisterEntry);
}
[Fact]
public void RosterBuild_ErkenntNamenAuchInVertauschterReihenfolge()
{
// WebUntis liefert Namen im Schülerreport als "Vorname Nachname", in Fehlzeiten-/
// Klassenbuchberichten aber typischerweise als "Nachname Vorname" - der Namensabgleich
// muss das auch ohne ExternKey erkennen (Regressionstest für genau diesen Bug).
var students = new[] { Student(externKey: null, displayName: "Ben Schmidt") };
var todayAbsences = new[]
{
new ClassAbsenceDaySummaryRow(new DateOnly(2026, 8, 25), "Schmidt Ben", null, 1, 45,
["Che"], [3], ["nicht entsch."], ["Absent"], null, null, false),
};
var recentClassRegister = new[]
{
new UntisForeignClassRegisterEventDto("6a", 20260824, "Deu", "Schmidt Ben", "mueller",
"Vergessen", "Organisation", "Buch vergessen"),
};
var roster = ClassTeacherRosterRow.Build(students, todayAbsences, recentClassRegister);
var ben = Assert.Single(roster);
Assert.True(ben.HasAbsenceToday);
Assert.True(ben.HasRecentClassRegisterEntry);
}
[Fact]
public void RosterBuild_BeschreibtVerspaetungNichtAlsNullFehlstunden()
{
var students = new[] { Student(1001, "Ada Müller") };
var todayAbsences = new[]
{
new ClassAbsenceDaySummaryRow(new DateOnly(2026, 8, 26), "Müller Ada", 1001, 0, 20,
["Deu"], [1], ["nicht entsch."], ["Verspätung"], null, null, false),
};
var row = Assert.Single(ClassTeacherRosterRow.Build(students, todayAbsences, [],
new DateOnly(2026, 8, 26)));
Assert.True(row.IsLate);
Assert.True(row.IsUnexcused);
Assert.Equal("20 Min. verspätet · Unentschuldigt", row.StatusText);
Assert.Equal(ClassTeacherStatusKind.Danger, row.StatusKind);
}
[Fact]
public void RosterBuild_PriorisiertHandlungsbedarfVorUnauffaelligenSchuelern()
{
var students = new[]
{
Student(1001, "Zora Unauffällig"),
Student(1002, "Ada Fehlzeit"),
Student(1003, "Ben Klassenbuch"),
};
var absences = new[]
{
new ClassAbsenceDaySummaryRow(new DateOnly(2026, 8, 26), "Fehlzeit Ada", 1002, 2, 90,
["Deu"], [1, 2], ["nicht entsch."], ["Absent"], null, null, false),
};
var register = new[]
{
new UntisForeignClassRegisterEventDto("10c", 20260825, "Deu", "Klassenbuch Ben", "test",
"Fehlende HA", "Negativ", "Hausaufgaben fehlen"),
};
var rows = ClassTeacherRosterRow.Build(students, absences, register, new DateOnly(2026, 8, 26));
Assert.Equal(["Ada Fehlzeit", "Ben Klassenbuch", "Zora Unauffällig"],
rows.Select(r => r.StudentName));
}
[Fact]
public void RosterBuild_BerechnetFehlquoteSeitSchuljahresbeginn()
{
var students = new[] { Student(1001, "Ada Müller") };
var yearAbsences = new[]
{
new ClassAbsenceDaySummaryRow(new DateOnly(2026, 8, 10), "Müller Ada", 1001, 2, 90,
["Che"], [1, 2], ["entsch."], ["Krank"], null, null, false),
new ClassAbsenceDaySummaryRow(new DateOnly(2026, 8, 17), "Müller Ada", 1001, 6, 270,
["Che"], [1, 2, 3, 4, 5, 6], ["nicht entsch."], ["Absent"], null, null, true),
};
var row = Assert.Single(ClassTeacherRosterRow.Build(students, [], [],
new DateOnly(2026, 8, 26), yearAbsences, schoolDaysElapsed: 20));
Assert.True(row.HasYearSummary);
Assert.Equal(2, row.YearAbsenceDayCount);
Assert.Equal(1, row.YearUnexcusedDayCount);
Assert.Equal(10, row.YearAbsenceRatePercent);
Assert.Contains("10 %", row.YearSummaryLabel);
Assert.Contains("2 von 20", row.YearSummaryTooltip);
Assert.Contains("1 unentschuldigt", row.YearSummaryTooltip);
}
[Fact]
public void RosterBuild_OhneJahresdatenZeigtKeineFehlquote()
{
var students = new[] { Student(1001, "Ada Müller") };
var row = Assert.Single(ClassTeacherRosterRow.Build(students, [], [], new DateOnly(2026, 8, 26)));
Assert.False(row.HasYearSummary);
Assert.Equal("", row.YearSummaryLabel);
Assert.Null(row.YearSummaryTooltip);
}
[Fact]
public void RosterBuild_OrdnetJahresfehlzeitenAuchOhneExternKeyUeberNamenZu()
{
var students = new[] { Student(externKey: null, displayName: "Ben Schmidt") };
var yearAbsences = new[]
{
new ClassAbsenceDaySummaryRow(new DateOnly(2026, 8, 10), "Schmidt Ben", null, 2, 90,
["Che"], [1, 2], ["entsch."], ["Krank"], null, null, false),
};
var row = Assert.Single(ClassTeacherRosterRow.Build(students, [], [], new DateOnly(2026, 8, 26),
yearAbsences, schoolDaysElapsed: 10));
Assert.Equal(1, row.YearAbsenceDayCount);
}
[Fact]
public void TrendDay_AnteileSummierenSichZurGesamtquote()
{
var day = new ClassTeacherTrendDay("Mo", 5, 2, 1, 2, 0.4d, 0.2d, 0.4d);
Assert.Equal(1.0d, day.TotalFraction, precision: 10);
}
[Fact]
public void RosterBuild_StelltGefahrUndWarnungMitSymbolVoran()
{
var students = new[] { Student(1001, "Ada Müller"), Student(1002, "Ben Schmidt") };
var todayAbsences = new[]
{
new ClassAbsenceDaySummaryRow(new DateOnly(2026, 8, 26), "Müller Ada", 1001, 2, 90,
["Che"], [1, 2], ["nicht entsch."], ["Absent"], null, null, false),
new ClassAbsenceDaySummaryRow(new DateOnly(2026, 8, 26), "Schmidt Ben", 1002, 2, 90,
["Che"], [1, 2], ["entsch."], ["Absent"], null, null, false),
};
var roster = ClassTeacherRosterRow.Build(students, todayAbsences, [], new DateOnly(2026, 8, 26));
var ada = Assert.Single(roster, r => r.StudentName == "Ada Müller");
Assert.Equal(ClassTeacherStatusKind.Danger, ada.StatusKind);
Assert.StartsWith("✕ ", ada.StatusTextWithGlyph);
var ben = Assert.Single(roster, r => r.StudentName == "Ben Schmidt");
Assert.Equal(ClassTeacherStatusKind.Warning, ben.StatusKind);
Assert.StartsWith("△ ", ben.StatusTextWithGlyph);
}
[Fact]
public void RosterBuild_LaesstUnauffaelligenStatusOhneSymbol()
{
var students = new[] { Student(1001, "Zora Unauffällig") };
var row = Assert.Single(ClassTeacherRosterRow.Build(students, [], [], new DateOnly(2026, 8, 26)));
Assert.Equal(row.StatusText, row.StatusTextWithGlyph);
}
// ── Offene Entschuldigungen (Feature-Idee 2) ─────────────────────────────
[Fact]
public void OpenExcuseBuild_SortiertNachTagenOffenAbsteigendUndIgnoriertEntschuldigte()
{
var absences = new[]
{
new ClassAbsenceDaySummaryRow(new DateOnly(2026, 8, 20), "Alt Ada", 1001, 2, 90,
["Che"], [1, 2], ["nicht entsch."], ["Absent"], null, null, false),
new ClassAbsenceDaySummaryRow(new DateOnly(2026, 8, 25), "Neu Ben", 1002, 1, 45,
["Deu"], [1], ["nicht entsch."], ["Absent"], null, null, false),
new ClassAbsenceDaySummaryRow(new DateOnly(2026, 8, 24), "Entschuldigt Cem", 1003, 1, 45,
["Deu"], [1], ["entsch."], ["Absent"], null, null, false),
};
var rows = ClassTeacherOpenExcuseRow.Build(absences, new DateOnly(2026, 8, 26));
// StudentName kommt aus StudentDisplayName (WebUntis liefert "Nachname Vorname",
// hier für die Anzeige auf "Vorname Nachname" gedreht - siehe ClassAbsenceDaySummaryRow).
Assert.Equal(["Ada Alt", "Ben Neu"], rows.Select(r => r.StudentName));
Assert.Equal(6, rows[0].DaysOpen);
Assert.True(rows[0].IsOverdue);
Assert.Equal(1, rows[1].DaysOpen);
Assert.False(rows[1].IsOverdue);
}
// ── Aggregierte Klassenbuch-Kategorien (Feature-Idee 5) ──────────────────
[Fact]
public void CategoryAggregateBuild_ZaehltProKategorieUndNenntTopSchueler()
{
var entries = new[]
{
new ClassTeacherClassRegisterRow(new DateOnly(2026, 8, 20), "Deu", "Schmidt Ben", "test",
"Hausaufgaben fehlen", "Negativ", "HA fehlt"),
new ClassTeacherClassRegisterRow(new DateOnly(2026, 8, 21), "Deu", "Schmidt Ben", "test",
"Hausaufgaben fehlen", "Negativ", "HA fehlt"),
new ClassTeacherClassRegisterRow(new DateOnly(2026, 8, 22), "Che", "Müller Ada", "test",
"Hausaufgaben fehlen", "Negativ", "HA fehlt"),
new ClassTeacherClassRegisterRow(new DateOnly(2026, 8, 23), "Che", "Müller Ada", "test",
"Lob", "Positiv", "Gute Mitarbeit"),
};
var rows = ClassTeacherCategoryAggregateRow.Build(entries);
Assert.Equal(["Hausaufgaben fehlen", "Lob"], rows.Select(r => r.CategoryName));
var homework = rows[0];
Assert.Equal(3, homework.Count);
Assert.Contains("2× Ben Schmidt", homework.TopStudentsLabel);
Assert.Contains("1× Ada Müller", homework.TopStudentsLabel);
}
// ── Erweiterte Mustererkennung: Wochentags-Häufung (Feature-Idee 4) ──────
[Fact]
public void DetectWeekdayPatterns_ErkenntDeutlicheHaeufungAnEinemWochentag()
{
// 24.08./31.08.2026 und 07.09.2026 sind allesamt Montage.
var absences = new[]
{
new ClassAbsenceDaySummaryRow(new DateOnly(2026, 8, 24), "Muster Mona", 1001, 1, 45,
["Deu"], [1], ["entsch."], ["Absent"], null, null, false),
new ClassAbsenceDaySummaryRow(new DateOnly(2026, 8, 31), "Muster Mona", 1001, 1, 45,
["Deu"], [1], ["entsch."], ["Absent"], null, null, false),
new ClassAbsenceDaySummaryRow(new DateOnly(2026, 9, 7), "Muster Mona", 1001, 1, 45,
["Deu"], [1], ["entsch."], ["Absent"], null, null, false),
};
var names = new Dictionary<string, string> { [UntisNameMatching.NameKey("Muster Mona")] = "Mona Muster" };
var notices = ClassTeacherOverviewViewModel.DetectWeekdayPatterns(
absences, names, new HashSet<string>());
var notice = Assert.Single(notices);
Assert.Equal("Mona Muster", notice.StudentName);
Assert.Contains("Montag", notice.Message);
Assert.Equal(ClassTeacherStatusKind.Info, notice.Kind);
}
[Fact]
public void DetectWeekdayPatterns_IgnoriertZuKleineStichprobe()
{
var absences = new[]
{
new ClassAbsenceDaySummaryRow(new DateOnly(2026, 8, 24), "Muster Mona", 1001, 1, 45,
["Deu"], [1], ["entsch."], ["Absent"], null, null, false),
new ClassAbsenceDaySummaryRow(new DateOnly(2026, 8, 31), "Muster Mona", 1001, 1, 45,
["Deu"], [1], ["entsch."], ["Absent"], null, null, false),
};
var notices = ClassTeacherOverviewViewModel.DetectWeekdayPatterns(
absences, new Dictionary<string, string>(), new HashSet<string>());
Assert.Empty(notices);
}
[Fact]
public void DetectWeekdayPatterns_LaesstBereitsGemeldeteSchuelerAus()
{
var absences = new[]
{
new ClassAbsenceDaySummaryRow(new DateOnly(2026, 8, 24), "Muster Mona", 1001, 1, 45,
["Deu"], [1], ["entsch."], ["Absent"], null, null, false),
new ClassAbsenceDaySummaryRow(new DateOnly(2026, 8, 31), "Muster Mona", 1001, 1, 45,
["Deu"], [1], ["entsch."], ["Absent"], null, null, false),
new ClassAbsenceDaySummaryRow(new DateOnly(2026, 9, 7), "Muster Mona", 1001, 1, 45,
["Deu"], [1], ["entsch."], ["Absent"], null, null, false),
};
var names = new Dictionary<string, string> { [UntisNameMatching.NameKey("Muster Mona")] = "Mona Muster" };
var notices = ClassTeacherOverviewViewModel.DetectWeekdayPatterns(
absences, names, new HashSet<string> { "Mona Muster" });
Assert.Empty(notices);
}
// ── Noten-/Mitarbeit-Brücke: Namensabgleich (Feature-Idee 7) ─────────────
[Fact]
public void MatchStudent_FindetUeberVorUndNachnameTrotzKommaInFullName()
{
// Student.FullName liefert "Nachname, Vorname" (mit Komma) - ein Abgleich darüber würde
// UntisNameMatching.NameKey verfälschen ("Müller," bliebe eigenes Wort). MatchStudent
// muss deshalb FirstName/LastName getrennt verwenden, nicht FullName.
var students = new List<Student>
{
new() { FirstName = "Ada", LastName = "Müller" },
new() { FirstName = "Ben", LastName = "Schmidt" },
};
var match = ClassTeacherOverviewViewModel.MatchStudent("Müller Ada", students);
Assert.NotNull(match);
Assert.Equal("Ada", match!.FirstName);
}
[Fact]
public void MatchStudent_LiefertNullOhneTreffer()
{
var students = new List<Student> { new() { FirstName = "Ada", LastName = "Müller" } };
Assert.Null(ClassTeacherOverviewViewModel.MatchStudent("Unbekannt Xyz", students));
}
private static UntisStudentRosterCacheEntry Student(int? externKey, string displayName) =>
new() { ClassName = "6a", ExternKey = externKey, DisplayName = displayName };
}
@@ -11,6 +11,22 @@ namespace LehrerApp.Desktop.Tests;
/// (offene Aufgaben, ...).
public sealed class DashboardViewModelTests
{
[Fact]
public void LeereHinweisbereiche_WerdenAusgeblendetUndZusammenfassungBleibtKompakt()
{
var group = new LearningGroup { Name = "9c" };
var today = DateOnly.FromDateTime(DateTime.Today);
var vm = BuildVm(group, new Lesson { GroupId = group.Id, Date = today });
Assert.False(vm.ExcusesCard.EffectiveIsVisible);
Assert.False(vm.CorrectionsCard.EffectiveIsVisible);
Assert.False(vm.AlertsCard.EffectiveIsVisible);
Assert.Equal("0 offene Punkte", vm.AttentionSummary);
Assert.True(vm.TodayCard.EffectiveIsVisible);
Assert.True(vm.CalendarCard.EffectiveIsVisible);
}
private static PeriodScheduleService NewPeriodSchedule()
{
var tempPath = System.IO.Path.Combine(
@@ -0,0 +1,65 @@
using LehrerApp.Core.Models;
using LehrerApp.Core.Services;
using LehrerApp.Desktop.ViewModels.Groups;
using Xunit;
namespace LehrerApp.Desktop.Tests;
/// Deckt 1.6.3 (Bulk-Aktion "Rest als abwesend markieren") ab — löst das Nutzer-Feedback, dass
/// Schüler, die nie nachschreiben, sonst dauerhaft eine leere, unberührte Zeile hinterlassen.
public sealed class ExamGradingDialogViewModelTests
{
[Fact]
public void MarkRemainingAbsent_MarktNurUnberührteZeilenAbwesend()
{
var group = new LearningGroup { Name = "Q1 Chemie" };
var studentWithPoints = new Student { FirstName = "Anna", LastName = "Bauer" };
var untouchedStudent1 = new Student { FirstName = "Ben", LastName = "Cordes" };
var untouchedStudent2 = new Student { FirstName = "Cara", LastName = "Diehl" };
var students = new FakeStudents([studentWithPoints, untouchedStudent1, untouchedStudent2]);
var memberships = new FakeMemberships(
[
new GroupMembership { GroupId = group.Id, StudentId = studentWithPoints.Id },
new GroupMembership { GroupId = group.Id, StudentId = untouchedStudent1.Id },
new GroupMembership { GroupId = group.Id, StudentId = untouchedStudent2.Id },
]);
var exam = new Exam
{
GroupId = group.Id,
Title = "Klausur 1",
Tasks = [new ExamTask { Nr = 1, MaxPoints = 10 }],
GradingKey = GradingService.DefaultKey1To6(),
};
var results = new FakeResults();
var vm = new ExamGradingDialogViewModel(results, students, memberships,
new GradingService(), exam, group.Id);
var rowWithPoints = vm.Rows.Single(r => r.StudentId == studentWithPoints.Id);
rowWithPoints.Cells[0].TrySetValue(7);
vm.MarkRemainingAbsentCommand.Execute(null);
Assert.False(rowWithPoints.Absent);
Assert.True(vm.Rows.Single(r => r.StudentId == untouchedStudent1.Id).Absent);
Assert.True(vm.Rows.Single(r => r.StudentId == untouchedStudent2.Id).Absent);
}
[Fact]
public void MarkRemainingAbsent_LässtBereitsAbwesendMarkierteUnverändert()
{
var group = new LearningGroup { Name = "Q1 Chemie" };
var student = new Student { FirstName = "Ben", LastName = "Cordes" };
var students = new FakeStudents([student]);
var memberships = new FakeMemberships(
[new GroupMembership { GroupId = group.Id, StudentId = student.Id }]);
var exam = new Exam { GroupId = group.Id, Title = "Klausur 1", Tasks = [new ExamTask { Nr = 1, MaxPoints = 10 }], GradingKey = GradingService.DefaultKey1To6() };
var vm = new ExamGradingDialogViewModel(new FakeResults(), students, memberships,
new GradingService(), exam, group.Id);
vm.Rows[0].Absent = true;
vm.MarkRemainingAbsentCommand.Execute(null);
Assert.True(vm.Rows[0].Absent);
}
}
@@ -0,0 +1,89 @@
using LehrerApp.Core.Models;
using LehrerApp.Core.Services;
using LehrerApp.Desktop.ViewModels.Exams;
using Xunit;
namespace LehrerApp.Desktop.Tests;
/// Deckt 1.6 (Klausuren-Hauptseite) ab: Priorisierung end-to-end über die ViewModel-Schicht,
/// Parallelkurs-Umschalter (1.6.4) und Genehmigung/Ankündigung (1.6.5).
public sealed class ExamsOverviewViewModelTests
{
private static ExamsOverviewViewModel BuildVm(List<LearningGroup> groups, List<Exam> exams,
FakeMemberships? memberships = null, FakeResults? results = null) =>
new(new FakeExams(exams), results ?? new FakeResults(), new FakeGroups(groups),
memberships ?? new FakeMemberships([]), new GradingService(), new SchoolYearService());
[Fact]
public void Load_UnbearbeiteteKorrekturStehtVorGeplanterKlausur()
{
var group = new LearningGroup { Name = "Q1 Chemie", SchoolYear = new SchoolYearService().CurrentSchoolYear() };
var today = DateOnly.FromDateTime(DateTime.Today);
var overdue = new Exam { GroupId = group.Id, Title = "Alte Klausur", Status = ExamStatus.Conducted, Date = today.AddDays(-10) };
var planned = new Exam { GroupId = group.Id, Title = "Neue Klausur", Status = ExamStatus.Planned, Date = today.AddDays(3) };
var vm = BuildVm([group], [overdue, planned]);
vm.LoadCommand.Execute(null);
Assert.Equal("Alte Klausur", vm.Rows[0].Title);
Assert.Equal("Neue Klausur", vm.Rows[1].Title);
}
[Fact]
public void Load_ParallelkurseWerdenAlsGeschwisterErkannt()
{
var groupA = new LearningGroup { Name = "Chemie GK Q1", SchoolYear = new SchoolYearService().CurrentSchoolYear() };
var groupB = new LearningGroup { Name = "Chemie GK Q1b", SchoolYear = groupA.SchoolYear };
var original = new Exam { GroupId = groupA.Id, Title = "Redox", Status = ExamStatus.Planned, Date = DateOnly.FromDateTime(DateTime.Today).AddDays(5) };
var duplicate = new Exam
{
GroupId = groupB.Id, Title = "Redox", Status = ExamStatus.Planned,
Date = DateOnly.FromDateTime(DateTime.Today).AddDays(6), SharedExamGroupId = original.Id,
};
var vm = BuildVm([groupA, groupB], [original, duplicate]);
vm.LoadCommand.Execute(null);
vm.SelectedRow = vm.Rows.Single(r => r.Exam.Id == original.Id);
Assert.True(vm.SelectedRow.HasSibling);
Assert.Single(vm.Siblings);
Assert.Equal(duplicate.Id, vm.Siblings[0].Exam.Id);
}
[Fact]
public void ToggleApproval_SetztUndLöschtDatum()
{
var group = new LearningGroup { Name = "Q1 Chemie", SchoolYear = new SchoolYearService().CurrentSchoolYear() };
var exam = new Exam { GroupId = group.Id, Title = "Klausur", Status = ExamStatus.Planned, Date = DateOnly.FromDateTime(DateTime.Today).AddDays(2) };
var vm = BuildVm([group], [exam]);
vm.LoadCommand.Execute(null);
vm.SelectedRow = vm.Rows[0];
vm.ToggleApprovalCommand.Execute(null);
Assert.NotNull(exam.ApprovalGrantedAt);
Assert.StartsWith("Genehmigt am", vm.ApprovalLabel);
vm.ToggleApprovalCommand.Execute(null);
Assert.Null(exam.ApprovalGrantedAt);
Assert.Equal("Genehmigung noch ausstehend", vm.ApprovalLabel);
}
[Fact]
public void Selection_ZeigtKorrekturfortschrittOderNotenspiegelJeNachStatus()
{
var group = new LearningGroup { Name = "Q1 Chemie", SchoolYear = new SchoolYearService().CurrentSchoolYear() };
var today = DateOnly.FromDateTime(DateTime.Today);
var conducted = new Exam { GroupId = group.Id, Title = "Läuft", Status = ExamStatus.Conducted, Date = today.AddDays(-2) };
var returned = new Exam { GroupId = group.Id, Title = "Fertig", Status = ExamStatus.Returned, Date = today.AddDays(-20) };
var vm = BuildVm([group], [conducted, returned]);
vm.LoadCommand.Execute(null);
vm.SelectedRow = vm.Rows.Single(r => r.Exam.Id == conducted.Id);
Assert.True(vm.ShowCorrectionProgress);
Assert.False(vm.ShowGradeSummary);
vm.SelectedRow = vm.Rows.Single(r => r.Exam.Id == returned.Id);
Assert.False(vm.ShowCorrectionProgress);
Assert.True(vm.ShowGradeSummary);
}
}
+50
View File
@@ -474,6 +474,56 @@ public class FakeAnnualPlanEvents : IAnnualPlanEventRepository
public void Delete(Guid id) => _all.RemoveAll(e => e.Id == id);
}
public class FakeUntisAbsenceCache : IUntisAbsenceCacheRepository
{
private readonly List<UntisAbsenceCacheEntry> _all = [];
public List<UntisAbsenceCacheEntry> GetByClassAndRange(string className, int startDate, int endDate) =>
_all.Where(e => e.ClassName == className && e.Date >= startDate && e.Date <= endDate).ToList();
public void ReplaceRange(string className, int startDate, int endDate, IEnumerable<UntisAbsenceCacheEntry> entries)
{
_all.RemoveAll(e => e.ClassName == className && e.Date >= startDate && e.Date <= endDate);
_all.AddRange(entries);
}
public void InsertRange(IEnumerable<UntisAbsenceCacheEntry> entries) => _all.AddRange(entries);
}
public class FakeUntisClassRegisterCache : IUntisClassRegisterCacheRepository
{
private readonly List<UntisClassRegisterCacheEntry> _all = [];
public List<UntisClassRegisterCacheEntry> GetByClassAndRange(string className, int startDate, int endDate) =>
_all.Where(e => e.ClassName == className && e.Date >= startDate && e.Date <= endDate).ToList();
public void ReplaceRange(string className, int startDate, int endDate, IEnumerable<UntisClassRegisterCacheEntry> entries)
{
_all.RemoveAll(e => e.ClassName == className && e.Date >= startDate && e.Date <= endDate);
_all.AddRange(entries);
}
public void InsertRange(IEnumerable<UntisClassRegisterCacheEntry> entries) => _all.AddRange(entries);
}
public class FakeUntisStudentRosterCache : IUntisStudentRosterCacheRepository
{
private readonly List<UntisStudentRosterCacheEntry> _all = [];
public List<UntisStudentRosterCacheEntry> GetByClass(string className) =>
_all.Where(e => e.ClassName == className).ToList();
public void ReplaceAll(string className, IEnumerable<UntisStudentRosterCacheEntry> entries)
{
_all.RemoveAll(e => e.ClassName == className);
_all.AddRange(entries);
}
}
public class FakeUntisCacheFetchStates : IUntisCacheFetchStateRepository
{
private readonly List<UntisCacheFetchState> _all = [];
public UntisCacheFetchState? Get(string className, UntisCacheKind kind) =>
_all.FirstOrDefault(s => s.ClassName == className && s.Kind == kind);
public void Save(UntisCacheFetchState state)
{
_all.RemoveAll(s => s.ClassName == state.ClassName && s.Kind == state.Kind);
_all.Add(state);
}
}
public class FakeWorkTasks : IWorkTaskRepository
{
private readonly List<WorkTask> _all = [];
@@ -0,0 +1,37 @@
using System.Globalization;
using LehrerApp.Desktop.Converters;
using Xunit;
namespace LehrerApp.Desktop.Tests;
public sealed class FractionWidthConverterTests
{
[Fact]
public void Convert_MultipliziertTrackbreiteMitAnteil()
{
var result = FractionWidthConverter.Instance.Convert(
[200d, 0.25d], typeof(double), null, CultureInfo.InvariantCulture);
Assert.Equal(50d, result);
}
[Fact]
public void Convert_LiefertNullBreiteBeiUngueltigenWerten()
{
// Bounds.Width kann vor dem ersten Layout-Pass NaN sein (Element noch nicht gemessen) —
// darf nicht zu einer negativen oder undefinierten Breite führen.
Assert.Equal(0d, FractionWidthConverter.Instance.Convert(
[double.NaN, 0.5d], typeof(double), null, CultureInfo.InvariantCulture));
Assert.Equal(0d, FractionWidthConverter.Instance.Convert(
["not a double", 0.5d], typeof(double), null, CultureInfo.InvariantCulture));
}
[Fact]
public void Convert_ClamptNegativeErgebnisseAufNull()
{
var result = FractionWidthConverter.Instance.Convert(
[100d, -0.2d], typeof(double), null, CultureInfo.InvariantCulture);
Assert.Equal(0d, result);
}
}
@@ -0,0 +1,74 @@
using LehrerApp.Core.Models;
using LehrerApp.Desktop.ViewModels;
using Xunit;
namespace LehrerApp.Desktop.Tests;
public sealed class GlobalSearchViewModelTests
{
[Fact]
public void LeereSuche_ZeigtSchnellaktionen()
{
var vm = BuildVm();
Assert.Collection(vm.Results,
item => Assert.Equal(GlobalSearchAction.NewTask, item.Action),
item => Assert.Equal(GlobalSearchAction.NewReminder, item.Action),
item => Assert.Equal(GlobalSearchAction.NewStudent, item.Action));
Assert.Same(vm.Results[0], vm.SelectedResult);
}
[Fact]
public void Suche_FindetSchuelerGruppeKlausurUndAufgabe()
{
var group = new LearningGroup { Name = "9 Chemie", SchoolYear = "2026/27", GradeLevel = 9 };
var student = new Student { FirstName = "Mia", LastName = "Chemie" };
var exam = new Exam { GroupId = group.Id, Title = "Chemie-Test", Date = new DateOnly(2026, 9, 1) };
var tasks = new FakeWorkTasks();
tasks.Add(new WorkTask { GroupId = group.Id, Title = "Chemie-Klausur korrigieren" });
var vm = BuildVm([student], [group], [exam], tasks);
vm.Query = "Chemie";
Assert.Contains(vm.Results, x => x.Kind == GlobalSearchResultKind.Student && x.EntityId == student.Id);
Assert.Contains(vm.Results, x => x.Kind == GlobalSearchResultKind.Group && x.EntityId == group.Id);
Assert.Contains(vm.Results, x => x.Kind == GlobalSearchResultKind.Exam && x.EntityId == exam.Id);
Assert.Contains(vm.Results, x => x.Kind == GlobalSearchResultKind.Task && x.Title.Contains("korrigieren"));
}
[Fact]
public async Task TrefferAusfuehren_NavigiertUndSchliesstPalette()
{
var student = new Student { FirstName = "Mia", LastName = "Muster" };
var vm = BuildVm([student]);
vm.Query = "Muster";
var result = Assert.Single(vm.Results, x => x.Kind == GlobalSearchResultKind.Student);
GlobalSearchResult? navigated = null;
var closed = false;
vm.OnNavigate = x => navigated = x;
vm.OnClose = () => closed = true;
await vm.ExecuteCommand.ExecuteAsync(result);
Assert.Same(result, navigated);
Assert.True(closed);
}
[Fact]
public async Task ErinnerungSchnellErfassen_StartetDialogAlsErinnerung()
{
var vm = BuildVm();
bool? reminder = null;
vm.OnQuickAddTask = value => { reminder = value; return Task.CompletedTask; };
var action = vm.Results.Single(x => x.Action == GlobalSearchAction.NewReminder);
await vm.ExecuteCommand.ExecuteAsync(action);
Assert.True(reminder);
}
private static GlobalSearchViewModel BuildVm(List<Student>? students = null,
List<LearningGroup>? groups = null, List<Exam>? exams = null, FakeWorkTasks? tasks = null) =>
new(new FakeStudents(students ?? []), new FakeGroups(groups ?? []),
new FakeExams(exams ?? []), tasks ?? new FakeWorkTasks());
}
@@ -5,76 +5,69 @@ using Xunit;
namespace LehrerApp.Desktop.Tests;
/// Tests für den Schnellüberblick im Auswahl-Panel der Gruppenliste (Nutzer-Feedback:
/// "oberhalb der Buttonliste ein paar Daten auswerfen. Nächste Stunde, nächste Arbeit,
/// wichtige Todos").
/// <summary>Tests für die direkte, einlagige Lerngruppen-Navigation.</summary>
public sealed class GroupListViewModelTests
{
private static GroupListViewModel BuildVm(LearningGroup group, FakeLessons? lessons = null,
FakeExams? exams = null, FakeWorkTasks? tasks = null) =>
new(new FakeGroups([group]), new FakeSubjects([]), new SchoolYearService(),
lessons ?? new FakeLessons(), exams ?? new FakeExams([]), tasks ?? new FakeWorkTasks());
private static GroupListViewModel BuildVm(params LearningGroup[] groups) =>
new(new FakeGroups([.. groups]), new FakeSubjects([]), new SchoolYearService());
[Fact]
public void SelectedGroup_OhneDatenZeigtKeinenSchnellueberblick()
public void Gruppenkarte_OeffnetDirektDieUebersicht()
{
var group = new LearningGroup { Name = "9c" };
var group = CurrentGroup("9c");
var vm = BuildVm(group);
Guid? openedId = null;
int? openedTab = null;
vm.OnNavigateToDetail = (id, tab) => { openedId = id; openedTab = tab; };
vm.SelectedGroup = vm.Groups.Count > 0 ? vm.Groups[0] : new GroupListItem(group, "");
Assert.Single(vm.Groups).OpenCommand.Execute(null);
Assert.False(vm.QuickHasAnything);
Assert.Equal(group.Id, openedId);
Assert.Equal(0, openedTab);
}
[Fact]
public void SelectedGroup_ZeigtNaechsteGeplanteStundeUndKlausur()
public async Task Verwaltungsmenue_BearbeitetDieGewaehlteKarteOhneZwischenauswahl()
{
var group = new LearningGroup { Name = "9c" };
var today = DateOnly.FromDateTime(DateTime.Today);
var lessons = new FakeLessons();
lessons.Add(new Lesson { GroupId = group.Id, Date = today.AddDays(3), Topic = "Redox", Status = LessonStatus.Planned });
var exams = new FakeExams([new Exam { GroupId = group.Id, Date = today.AddDays(10), Title = "Klausur 1" }]);
var vm = BuildVm(group, lessons: lessons, exams: exams);
var first = CurrentGroup("9a");
var second = CurrentGroup("9b");
var vm = BuildVm(first, second);
Guid? editedId = null;
vm.OnEditGroup = id => { editedId = id; return Task.CompletedTask; };
var secondItem = vm.Groups.Single(x => x.Id == second.Id);
vm.SelectedGroup = new GroupListItem(group, "");
await secondItem.EditCommand.ExecuteAsync(null);
Assert.True(vm.QuickHasNextLesson);
Assert.Contains("Redox", vm.QuickNextLessonLabel);
Assert.True(vm.QuickHasNextExam);
Assert.Contains("Klausur 1", vm.QuickNextExamLabel);
Assert.True(vm.QuickHasAnything);
Assert.Equal(second.Id, editedId);
}
[Fact]
public void SelectedGroup_ZeigtOffeneAufgabenDieserGruppeSortiertNachFaelligkeit()
public void Suche_FiltertWeiterhinNachGruppenname()
{
var group = new LearningGroup { Name = "9c" };
var today = DateOnly.FromDateTime(DateTime.Today);
var tasks = new FakeWorkTasks();
tasks.Add(new WorkTask { GroupId = group.Id, Title = "Bald fällig", DueDate = today.AddDays(1), Status = WorkTaskStatus.Open });
tasks.Add(new WorkTask { GroupId = group.Id, Title = "Erledigt", DueDate = today, Status = WorkTaskStatus.Done });
tasks.Add(new WorkTask { GroupId = Guid.NewGuid(), Title = "Andere Gruppe", DueDate = today, Status = WorkTaskStatus.Open });
var vm = BuildVm(group, tasks: tasks);
var vm = BuildVm(CurrentGroup("9 Chemie"), CurrentGroup("10 Mathematik"));
vm.SelectedGroup = new GroupListItem(group, "");
vm.SearchText = "Chemie";
Assert.True(vm.QuickHasTasks);
Assert.Equal("Bald fällig", Assert.Single(vm.QuickTasks).Title);
Assert.Equal("9 Chemie", Assert.Single(vm.Groups).Name);
}
[Fact]
public void SelectedGroup_Zuruecksetzen_LeertSchnellueberblick()
public void Archivieren_WirktAufDieKarteUndEntferntSieAusDerAktivenListe()
{
var group = new LearningGroup { Name = "9c" };
var today = DateOnly.FromDateTime(DateTime.Today);
var exams = new FakeExams([new Exam { GroupId = group.Id, Date = today.AddDays(10), Title = "Klausur 1" }]);
var vm = BuildVm(group, exams: exams);
vm.SelectedGroup = new GroupListItem(group, "");
Assert.True(vm.QuickHasAnything);
var group = CurrentGroup("9c");
var vm = BuildVm(group);
var item = Assert.Single(vm.Groups);
vm.SelectedGroup = null;
item.ToggleArchiveCommand.Execute(null);
Assert.False(vm.QuickHasAnything);
Assert.Empty(vm.QuickTasks);
Assert.False(group.IsActive);
Assert.Empty(vm.Groups);
}
private static LearningGroup CurrentGroup(string name) => new()
{
Name = name,
SchoolYear = new SchoolYearService().CurrentSchoolYear(),
IsActive = true,
};
}
@@ -543,6 +543,50 @@ public sealed class SeatingPlanViewModelTests
Assert.Equal(session.Id, vm.SelectedSession?.Id);
}
[Fact]
public async Task AssessStudent_SitzungBereitsUeberAndereInstanzVerknuepft_LegtKeineZweiteAn()
{
// Regressionstest: Der Unterrichtsmodus läuft auf einer eigenen, per DI frisch aufgelösten
// SeatingPlanTabViewModel-Instanz (TimetableView.ShowTeachingMode), getrennt von der
// Instanz im Sitzplan-Tab der Kursübersicht — beide teilen sich nur das Repository. Wurde
// (wie hier simuliert) die Sitzung bereits über den Unterrichtsmodus mit der Stunde
// verknüpft, bevor im echten Sitzplan-Tab bewertet wird, darf letzterer keine zweite,
// unverknüpfte "Sitzplan"-Sitzung für denselben Tag anlegen.
var groupId = Guid.NewGuid();
var student = new Student { FirstName = "Anna", LastName = "A" };
var plan = new SeatingPlan
{
GroupId = groupId, Name = "Standard", Rows = 1, Columns = 1,
Assignments = [new SeatAssignment { StudentId = student.Id }],
};
var sessions = new FakeSessions([]);
var memberships = new FakeMemberships([new GroupMembership { GroupId = groupId, StudentId = student.Id }]);
// Instanz A: Sitzplan-Tab der Kursübersicht, bereits initialisiert (kein SelectedSession).
var tabVm = new SeatingPlanTabViewModel(new FakeSeatingPlans([plan]), new FakeStudents([student]),
memberships, sessions, new FakeEntries(), new FakeAspects());
tabVm.Initialize(groupId, isReadOnly: false);
tabVm.OnAssessStudent = _ => Task.CompletedTask;
// Instanz B: eigene, frische Instanz wie im Unterrichtsmodus — verknüpft die Sitzung mit
// der Stunde, ohne dass Instanz A davon erfährt.
var lesson = new Lesson { GroupId = groupId, Date = DateOnly.FromDateTime(DateTime.Today), Topic = "Redox" };
var teachingModeVm = new SeatingPlanTabViewModel(new FakeSeatingPlans([plan]), new FakeStudents([student]),
memberships, sessions, new FakeEntries(), new FakeAspects());
teachingModeVm.Initialize(groupId, isReadOnly: false);
teachingModeVm.SelectOrCreateSessionForLesson(lesson);
var linked = Assert.Single(sessions.GetByGroup(groupId));
Assert.Equal(lesson.Id, linked.LessonId);
await tabVm.AssessStudent(tabVm.Seats.Single());
var session = Assert.Single(sessions.GetByGroup(groupId));
Assert.Equal(linked.Id, session.Id);
Assert.Equal(lesson.Id, session.LessonId);
Assert.Equal(session.Id, tabVm.SelectedSession?.Id);
}
[Fact]
public void ShuffleSeats_ErzeugtNeuenPlanMitVertauschtenPlaetzenUndBehaeltDenUrsprungsplan()
{
@@ -13,6 +13,17 @@ public sealed class TeachingModeViewModelTests
new FakeStudents(students ?? []), new FakeMemberships(memberships ?? []),
sessions ?? new FakeSessions([]), new FakeEntries(), new FakeAspects());
/// Dieselben Fakes wie <see cref="BuildSeatingPlan"/>, aber für ParticipationTabViewModel —
/// beide brauchen eigene IParticipationSessionRepository/IParticipationRepository-Instanzen
/// im Produktivcode (siehe TeachingModeWindow-Kommentar zu ReloadSeatBadgesFromRepository),
/// hier reicht für die Tests aber eine gemeinsame FakeSessions-Instanz, damit beide
/// ViewModels dieselbe verknüpfte Sitzung sehen.
private static ParticipationTabViewModel BuildParticipation(LearningGroup group,
FakeSessions? sessions = null) =>
new(sessions ?? new FakeSessions([]), new FakeEntries(), new FakeAspects(),
new FakeStudents([]), new FakeMemberships([]), new FakeGroups([group]),
new FakeCompetencyDomains());
[Fact]
public void Konstruktor_LaedtVerlaufsplanUndInitialisiertSitzplanFuerDieGruppe()
{
@@ -24,7 +35,8 @@ public sealed class TeachingModeViewModelTests
};
var seatingPlan = BuildSeatingPlan(group.Id);
var vm = new TeachingModeViewModel(lesson, group, new FakeAlternativeLessonPaths([]), seatingPlan);
var vm = new TeachingModeViewModel(lesson, group, new FakeAlternativeLessonPaths([]),
new FakeLessons(), seatingPlan, BuildParticipation(group));
Assert.Equal("Q1 Chemie", vm.GroupName);
Assert.Equal("Redox", vm.LessonInfo.Topic);
@@ -41,13 +53,37 @@ public sealed class TeachingModeViewModelTests
var sessions = new FakeSessions([]);
var seatingPlan = BuildSeatingPlan(group.Id, sessions);
var vm = new TeachingModeViewModel(lesson, group, new FakeAlternativeLessonPaths([]), seatingPlan);
var vm = new TeachingModeViewModel(lesson, group, new FakeAlternativeLessonPaths([]),
new FakeLessons(), seatingPlan, BuildParticipation(group, sessions));
var created = Assert.Single(sessions.GetByGroup(group.Id));
Assert.Equal(lesson.Id, created.LessonId);
Assert.Equal(vm.SeatingPlan.SelectedSession?.Id, created.Id);
}
/// Nutzer-Feedback: Schnellbewertungs-Dialoge (Mitarbeit, Anwesenheit/Hausaufgabe) sollen sich
/// im Unterrichtsmodus direkt öffnen lassen — das setzt voraus, dass die dafür verwendete
/// ParticipationTabViewModel-Instanz von Anfang an auf der zu dieser Stunde gehörenden
/// Sitzung steht, nicht auf einer beliebigen anderen Sitzung der Gruppe.
[Fact]
public void Konstruktor_SelektiertDieselbeSitzungAuchInDerParticipationViewModel()
{
var group = new LearningGroup { Name = "Q1 Chemie" };
var lesson = new Lesson
{ GroupId = group.Id, Date = DateOnly.FromDateTime(DateTime.Today), Topic = "Redox" };
var otherSession = new ParticipationSession
{ GroupId = group.Id, Date = lesson.Date.AddDays(-1), Comment = "Andere Stunde" };
var sessions = new FakeSessions([otherSession]);
var seatingPlan = BuildSeatingPlan(group.Id, sessions);
var vm = new TeachingModeViewModel(lesson, group, new FakeAlternativeLessonPaths([]),
new FakeLessons(), seatingPlan, BuildParticipation(group, sessions));
Assert.NotNull(vm.SeatingPlan.SelectedSession);
Assert.Equal(vm.SeatingPlan.SelectedSession!.Id, vm.Participation.SelectedSession?.Id);
Assert.NotEqual(otherSession.Id, vm.Participation.SelectedSession?.Id);
}
[Fact]
public void Konstruktor_BereitsExistierendeVerknuepfteSitzung_WirdWiederverwendet()
{
@@ -59,7 +95,8 @@ public sealed class TeachingModeViewModelTests
var sessions = new FakeSessions([existing]);
var seatingPlan = BuildSeatingPlan(group.Id, sessions);
var vm = new TeachingModeViewModel(lesson, group, new FakeAlternativeLessonPaths([]), seatingPlan);
var vm = new TeachingModeViewModel(lesson, group, new FakeAlternativeLessonPaths([]),
new FakeLessons(), seatingPlan, BuildParticipation(group, sessions));
Assert.Single(sessions.GetByGroup(group.Id));
Assert.Equal(existing.Id, vm.SeatingPlan.SelectedSession?.Id);
@@ -75,8 +112,92 @@ public sealed class TeachingModeViewModelTests
{ GroupId = group.Id, Date = DateOnly.FromDateTime(DateTime.Today), Topic = "Redox" };
var seatingPlan = BuildSeatingPlan(group.Id);
var vm = new TeachingModeViewModel(lesson, group, new FakeAlternativeLessonPaths([]), seatingPlan);
var vm = new TeachingModeViewModel(lesson, group, new FakeAlternativeLessonPaths([]),
new FakeLessons(), seatingPlan, BuildParticipation(group));
Assert.False(vm.SeatingPlan.IsEditable);
}
// ── Hausaufgabe: letzte Stunde ansehen/abhaken, aktuelle einsehen/ändern ─────
[Fact]
public void Konstruktor_FindetHausaufgabeDerVorherigenStunde()
{
var group = new LearningGroup { Name = "Q1 Chemie" };
var today = DateOnly.FromDateTime(DateTime.Today);
var previous = new Lesson
{
GroupId = group.Id, Date = today.AddDays(-7), Topic = "Säuren",
Homework = "S. 42, Aufgabe 3", HomeworkChecked = false,
};
var lesson = new Lesson { GroupId = group.Id, Date = today, Topic = "Redox" };
var lessons = new FakeLessons();
lessons.Add(previous);
var seatingPlan = BuildSeatingPlan(group.Id);
var vm = new TeachingModeViewModel(lesson, group, new FakeAlternativeLessonPaths([]),
lessons, seatingPlan, BuildParticipation(group));
Assert.True(vm.Homework.HasPreviousHomework);
Assert.Equal("S. 42, Aufgabe 3", vm.Homework.PreviousHomeworkText);
Assert.Contains("Säuren", vm.Homework.PreviousLessonLabel);
Assert.False(vm.Homework.PreviousHomeworkChecked);
}
[Fact]
public void Konstruktor_IgnoriertVorherigeStundeOhneHausaufgabe()
{
var group = new LearningGroup { Name = "Q1 Chemie" };
var today = DateOnly.FromDateTime(DateTime.Today);
var previous = new Lesson { GroupId = group.Id, Date = today.AddDays(-7), Topic = "Säuren" };
var lesson = new Lesson { GroupId = group.Id, Date = today, Topic = "Redox" };
var lessons = new FakeLessons();
lessons.Add(previous);
var seatingPlan = BuildSeatingPlan(group.Id);
var vm = new TeachingModeViewModel(lesson, group, new FakeAlternativeLessonPaths([]),
lessons, seatingPlan, BuildParticipation(group));
Assert.False(vm.Homework.HasPreviousHomework);
}
[Fact]
public void PreviousHomeworkChecked_SpeichertSofortAnDerVorherigenStunde()
{
var group = new LearningGroup { Name = "Q1 Chemie" };
var today = DateOnly.FromDateTime(DateTime.Today);
var previous = new Lesson
{ GroupId = group.Id, Date = today.AddDays(-7), Topic = "Säuren", Homework = "S. 42" };
var lesson = new Lesson { GroupId = group.Id, Date = today, Topic = "Redox" };
var lessons = new FakeLessons();
lessons.Add(previous);
var seatingPlan = BuildSeatingPlan(group.Id);
var vm = new TeachingModeViewModel(lesson, group, new FakeAlternativeLessonPaths([]),
lessons, seatingPlan, BuildParticipation(group));
vm.Homework.PreviousHomeworkChecked = true;
var saved = Assert.Single(lessons.GetByGroupAndRange(group.Id, today.AddDays(-120), today.AddDays(-1)));
Assert.True(saved.HomeworkChecked);
}
[Fact]
public void SaveCurrentHomework_SchreibtAufDieAktuelleStunde()
{
var group = new LearningGroup { Name = "Q1 Chemie" };
var lesson = new Lesson
{ GroupId = group.Id, Date = DateOnly.FromDateTime(DateTime.Today), Topic = "Redox" };
var lessons = new FakeLessons();
lessons.Add(lesson);
var seatingPlan = BuildSeatingPlan(group.Id);
var vm = new TeachingModeViewModel(lesson, group, new FakeAlternativeLessonPaths([]),
lessons, seatingPlan, BuildParticipation(group));
vm.Homework.CurrentHomework = "AB 7 fertigstellen";
vm.Homework.SaveCurrentHomeworkCommand.Execute(null);
var saved = Assert.Single(lessons.GetByGroupAndRange(group.Id, lesson.Date, lesson.Date));
Assert.Equal("AB 7 fertigstellen", saved.Homework);
Assert.Equal("Gespeichert.", vm.Homework.SaveStatus);
}
}
@@ -14,10 +14,11 @@ public sealed class TimetableViewModelTests
FakeSubjects? subjects = null, FakeLessons? lessons = null, FakeExams? exams = null,
SchoolCalendarSettingsService? calendarSettings = null,
FakeSupervisionDuties? supervisionDuties = null, FakeSubstitutionEntries? substitutions = null,
FakeUntisSlotMappings? untisMappings = null, WebUntisSettingsService? untisSettings = null)
FakeUntisSlotMappings? untisMappings = null, WebUntisSettingsService? untisSettings = null,
PeriodScheduleService? periodSchedule = null)
{
// Bewusst kein "using": SchoolCalendarSettingsService liest den Pfad nur bei Bedarf
// (SetState), das Verzeichnis muss über die Lebensdauer des ViewModels bestehen bleiben.
// Bewusst kein "using": SchoolCalendarSettingsService/PeriodScheduleService lesen den Pfad
// nur bei Bedarf, das Verzeichnis muss über die Lebensdauer des ViewModels bestehen bleiben.
var tempPath = System.IO.Path.Combine(
System.IO.Path.GetTempPath(), $"lehrerapp-timetablevm-tests-{Guid.NewGuid():N}");
Directory.CreateDirectory(tempPath);
@@ -28,7 +29,8 @@ public sealed class TimetableViewModelTests
calendarSettings ?? new SchoolCalendarSettingsService(tempPath),
new PublicHolidayService(), new SchoolYearService(),
supervisionDuties ?? new FakeSupervisionDuties(), substitutions ?? new FakeSubstitutionEntries(),
untisMappings ?? new FakeUntisSlotMappings(), untisSettings ?? TestSupport.BuildWebUntisSettingsService());
untisMappings ?? new FakeUntisSlotMappings(), untisSettings ?? TestSupport.BuildWebUntisSettingsService(),
periodSchedule ?? new PeriodScheduleService(tempPath));
}
/// Nächstes Datum ab (inkl.) <paramref name="from"/>, das auf einen Wochentag Mo-Fr fällt —
@@ -336,6 +338,88 @@ public sealed class TimetableViewModelTests
Assert.Equal(lesson.Id, openedLesson?.Id);
}
/// Nutzer-Feedback (zweite Runde): der Direktklick auf eine Wochenraster-Kachel soll
/// "einheitlicher" springen — bei einer Lesson HEUTE, während gerade Unterrichtszeit ist,
/// direkt in den Unterrichtsmodus statt in den (schreibgeschützten) Planungsviewer.
[Fact]
public async Task OpenWeekCell_LessonHeuteWaehrendUnterrichtszeit_OeffnetUnterrichtsmodus()
{
var group = new LearningGroup { Name = "Q1 Chemie" };
// Bewusst nicht der reale DateTime.Today-Wochentag: an einem Sa/So gäbe es dafür keine
// Kachel im Mo-Fr-Raster. Stattdessen ein beliebiger Wochentag der laufenden Kalenderwoche
// (Kachel existiert immer), dessen Datum per VM.Clock als "heute" simuliert wird (siehe
// TimetableViewModel.Clock - genau für solche Tests eingeführt).
var weekday = DayOfWeek.Monday;
var lessonDate = DateInCurrentWeek(weekday);
var slots = new FakeTimetableSlots();
slots.Add(new TimetableSlot { GroupId = group.Id, Weekday = weekday, PeriodNumber = 1 });
var lesson = new Lesson { GroupId = group.Id, Date = lessonDate, LessonNumber = 1, Topic = "Redox" };
var lessons = new FakeLessons();
lessons.Add(lesson);
var periodSchedule = BuildPeriodSchedule(1);
var vm = BuildViewModel(slots, new FakeGroups([group]), lessons: lessons, periodSchedule: periodSchedule);
// Gleiche Uhrzeit wie BuildPeriodSchedule (reales DateTime.Now), aber am simulierten
// "heutigen" Datum - die Stundenzeit-Prüfung (Uhrzeit) bleibt so unabhängig vom Datum.
vm.Clock = () => lessonDate.ToDateTime(TimeOnly.FromDateTime(DateTime.Now));
Lesson? teachingModeLesson = null;
Lesson? viewerLesson = null;
vm.OnOpenTeachingMode = l => { teachingModeLesson = l; return Task.CompletedTask; };
vm.OnOpenLessonViewer = l => { viewerLesson = l; return Task.CompletedTask; };
var cell = vm.WeekItems.Single(c => c.IsSlotCell && c.Weekday == weekday && c.PeriodNumber == 1);
await vm.OpenWeekCellCommand.ExecuteAsync(cell);
Assert.Equal(lesson.Id, teachingModeLesson?.Id);
Assert.Null(viewerLesson);
}
/// Dieselbe Stundenzeit passt, die Lesson liegt aber nicht heute — bleibt beim Viewer.
[Fact]
public async Task OpenWeekCell_LessonNichtHeuteTrotzPassenderUhrzeit_OeffnetViewer()
{
var group = new LearningGroup { Name = "Q1 Chemie" };
var today = DateOnly.FromDateTime(DateTime.Today);
var otherWeekday = today.DayOfWeek == DayOfWeek.Monday ? DayOfWeek.Tuesday : DayOfWeek.Monday;
var otherDate = DateInCurrentWeek(otherWeekday);
var slots = new FakeTimetableSlots();
slots.Add(new TimetableSlot { GroupId = group.Id, Weekday = otherWeekday, PeriodNumber = 1 });
var lesson = new Lesson { GroupId = group.Id, Date = otherDate, LessonNumber = 1, Topic = "Redox" };
var lessons = new FakeLessons();
lessons.Add(lesson);
var periodSchedule = BuildPeriodSchedule(1);
var vm = BuildViewModel(slots, new FakeGroups([group]), lessons: lessons, periodSchedule: periodSchedule);
Lesson? teachingModeLesson = null;
Lesson? viewerLesson = null;
vm.OnOpenTeachingMode = l => { teachingModeLesson = l; return Task.CompletedTask; };
vm.OnOpenLessonViewer = l => { viewerLesson = l; return Task.CompletedTask; };
var cell = vm.WeekItems.Single(c => c.IsSlotCell && c.Weekday == otherWeekday && c.PeriodNumber == 1);
await vm.OpenWeekCellCommand.ExecuteAsync(cell);
Assert.Equal(lesson.Id, viewerLesson?.Id);
Assert.Null(teachingModeLesson);
}
/// Weites Zeitfenster um <paramref name="around"/> herum (±2 Stunden), damit der Test nicht an
/// die exakte Toleranz von TimetableViewModel.TeachingTimeTolerance gebunden ist.
/// Start UND Ende exakt auf "jetzt" statt eines künstlich breiteren Fensters (z.B. ganzer Tag
/// via TimeOnly.MinValue/MaxValue): TimetableViewModel.IsAroundTeachingTime addiert selbst
/// noch ±10 Minuten Toleranz (TeachingTimeTolerance) auf Start/Ende — ein zusätzliches, vom
/// Test vorgegebenes Fenster würde nahe Mitternacht durch die TimeOnly-Arithmetik (wrappt bei
/// 24 Uhr) selbst kippen. "Jetzt" als Start/Ende bleibt nur dann riskant, wenn der Test
/// zufällig innerhalb der letzten/ersten 10 Minuten des Tages läuft — dasselbe inhärente
/// Randproblem hätte dann auch die Produktion, kein Testartefakt.
private static PeriodScheduleService BuildPeriodSchedule(int periodNumber)
{
var tempPath = System.IO.Path.Combine(
System.IO.Path.GetTempPath(), $"lehrerapp-periodschedule-tests-{Guid.NewGuid():N}");
Directory.CreateDirectory(tempPath);
var service = new PeriodScheduleService(tempPath);
var now = TimeOnly.FromDateTime(DateTime.Now);
service.SetPeriods([new PeriodTimeEntry { PeriodNumber = periodNumber, Start = now, End = now }]);
return service;
}
[Fact]
public void OpenSettings_RuftOnNavigateToSettingsAuf()
{
@@ -1177,3 +1261,43 @@ public sealed class TimetableViewModelTests
Assert.False(vm.HasUntisMismatch);
}
}
/// Nutzer-Feedback (zweite Runde): das Popup-Menü je Wochenraster-Kachel (TimetableView.axaml,
/// MenuFlyout) blendet "Unterrichtsansicht"/"Planungsviewer" per IsVisible="{Binding HasLesson}"
/// aus, wenn für den Slot noch keine Lesson existiert, und den ganzen Menü-Trigger per
/// IsVisible="{Binding HasGroupId}", wenn die Kachel gar keiner Gruppe zugeordnet ist (z.B. eine
/// GroupId-lose Vertretung). Diese beiden Properties sind die Grundlage dafür.
public sealed class WeekCellItemMenuVisibilityTests
{
[Fact]
public void ForSlot_MitLesson_HatHasLessonUndHasGroupId()
{
var lesson = new Lesson { Topic = "Redox" };
var cell = WeekCellItem.ForSlot(DayOfWeek.Monday, 3, isToday: true, "Che", "Q1 Chemie",
"R204", "Redox", "#4C8DFF", "", hasExam: false, isLastBeforeExam: false,
hasExperiment: false, Guid.NewGuid(), isHoliday: false, lesson: lesson);
Assert.True(cell.HasLesson);
Assert.True(cell.HasGroupId);
}
[Fact]
public void ForSlot_OhneLesson_HatKeinHasLesson()
{
var cell = WeekCellItem.ForSlot(DayOfWeek.Monday, 3, isToday: true, "Che", "Q1 Chemie",
"R204", "", "#4C8DFF", "", hasExam: false, isLastBeforeExam: false,
hasExperiment: false, Guid.NewGuid(), isHoliday: false);
Assert.False(cell.HasLesson);
Assert.True(cell.HasGroupId);
}
[Fact]
public void ForSubstitutionLesson_OhneGroupId_HatKeinHasGroupId()
{
var cell = WeekCellItem.ForSubstitutionLesson(DayOfWeek.Monday, 3, isToday: true,
new SubstitutionEntry { GroupId = null, GroupLabel = "Fremde Klasse", Description = "Vertretung" });
Assert.False(cell.HasGroupId);
}
}
@@ -0,0 +1,21 @@
using LehrerApp.Desktop.ViewModels.ClassTeacher;
using Xunit;
namespace LehrerApp.Desktop.Tests;
public sealed class UntisNameMatchingTests
{
[Theory]
[InlineData("Ben Schmidt", "Schmidt Ben")]
[InlineData("Ada Müller", "MÜLLER ADA")]
[InlineData("Max Mustermann", "mustermann max")]
public void NamesMatch_IstReihenfolgeUndGrossKleinschreibungUnabhaengig(string a, string b) =>
Assert.True(UntisNameMatching.NamesMatch(a, b));
[Theory]
[InlineData("Ben Schmidt", "Ben Müller")]
[InlineData(null, "Ben Schmidt")]
[InlineData("Ben Schmidt", "")]
public void NamesMatch_LehntUnterschiedlicheOderFehlendeNamenAb(string? a, string b) =>
Assert.False(UntisNameMatching.NamesMatch(a, b));
}
@@ -0,0 +1,208 @@
using System.Net;
using System.Text;
using LehrerApp.Core.Models;
using LehrerApp.Desktop.Services;
using Xunit;
namespace LehrerApp.Desktop.Tests;
public sealed class UntisReportCacheServiceTests
{
private static readonly DateOnly Today = new(2026, 8, 25);
private static readonly DateOnly HotStart = Today.AddDays(-14);
// ── Plan(): reine Entscheidungslogik, keine Repositories/HTTP nötig ──────────
[Fact]
public void Plan_ErsterAufruf_LaedtHeissesFensterUndDeckungReichtBisFensteranfang()
{
var plan = UntisReportCacheService.Plan(null, Today, Today, Today, forceRefresh: false, DateTime.UtcNow);
Assert.True(plan.RefreshHotWindow);
Assert.Equal(HotStart, plan.HotWindowStart);
Assert.Equal(Today, plan.HotWindowEnd);
Assert.False(plan.FetchColdRange);
Assert.Equal(Int(HotStart), plan.ResultingColdCoverageStartDate);
}
[Fact]
public void Plan_InnerhalbEinerStunde_KeinErneutesHeissesFenster()
{
var state = new UntisCacheFetchState
{
ClassName = "10c", Kind = UntisCacheKind.Absences,
HotWindowFetchedAt = DateTime.UtcNow.AddMinutes(-10), ColdCoverageStartDate = Int(HotStart),
};
var plan = UntisReportCacheService.Plan(state, Today, Today, Today, forceRefresh: false, DateTime.UtcNow);
Assert.False(plan.RefreshHotWindow);
Assert.False(plan.FetchColdRange);
}
[Fact]
public void Plan_ForceRefresh_LoestTrotzKuerzlichemAbrufAus()
{
var state = new UntisCacheFetchState
{
ClassName = "10c", Kind = UntisCacheKind.Absences,
HotWindowFetchedAt = DateTime.UtcNow.AddMinutes(-10), ColdCoverageStartDate = Int(HotStart),
};
var plan = UntisReportCacheService.Plan(state, Today, Today, Today, forceRefresh: true, DateTime.UtcNow);
Assert.True(plan.RefreshHotWindow);
}
[Fact]
public void Plan_MehrAlsEineStundeVergangen_FrischtHeissesFensterErneutAuf()
{
var state = new UntisCacheFetchState
{
ClassName = "10c", Kind = UntisCacheKind.Absences,
HotWindowFetchedAt = DateTime.UtcNow.AddHours(-2), ColdCoverageStartDate = Int(HotStart),
};
var plan = UntisReportCacheService.Plan(state, Today, Today, Today, forceRefresh: false, DateTime.UtcNow);
Assert.True(plan.RefreshHotWindow);
}
[Fact]
public void Plan_AnfrageVorDemHeissenFenster_KeinHeissesFensterAberKalterErstabruf()
{
var requestStart = HotStart.AddDays(-30);
var requestEnd = HotStart.AddDays(-5);
var plan = UntisReportCacheService.Plan(null, requestStart, requestEnd, Today, forceRefresh: false, DateTime.UtcNow);
Assert.False(plan.RefreshHotWindow);
Assert.True(plan.FetchColdRange);
Assert.Equal(requestStart, plan.ColdRangeStart);
Assert.Equal(HotStart.AddDays(-1), plan.ColdRangeEnd);
Assert.Equal(Int(requestStart), plan.ResultingColdCoverageStartDate);
}
[Fact]
public void Plan_BereitsKaltAbgedeckterBereich_KeinAbrufNoetig()
{
var coveredSince = HotStart.AddDays(-60);
var state = new UntisCacheFetchState
{
ClassName = "10c", Kind = UntisCacheKind.Absences,
HotWindowFetchedAt = DateTime.UtcNow, ColdCoverageStartDate = Int(coveredSince),
};
var plan = UntisReportCacheService.Plan(state, coveredSince.AddDays(10), HotStart.AddDays(-20),
Today, forceRefresh: false, DateTime.UtcNow);
Assert.False(plan.RefreshHotWindow);
Assert.False(plan.FetchColdRange);
}
[Fact]
public void Plan_ErweitertKalteAbdeckungNurBisZumBenoetigtenStart()
{
var coveredSince = HotStart.AddDays(-30);
var earlierStart = HotStart.AddDays(-90);
var state = new UntisCacheFetchState
{
ClassName = "10c", Kind = UntisCacheKind.Absences,
HotWindowFetchedAt = DateTime.UtcNow, ColdCoverageStartDate = Int(coveredSince),
};
var plan = UntisReportCacheService.Plan(state, earlierStart, HotStart.AddDays(-40),
Today, forceRefresh: false, DateTime.UtcNow);
Assert.False(plan.RefreshHotWindow);
Assert.True(plan.FetchColdRange);
Assert.Equal(earlierStart, plan.ColdRangeStart);
Assert.Equal(coveredSince.AddDays(-1), plan.ColdRangeEnd);
Assert.Equal(Int(earlierStart), plan.ResultingColdCoverageStartDate);
}
private static int Int(DateOnly date) => date.Year * 10000 + date.Month * 100 + date.Day;
// ── Ende-zu-Ende: erster Aufruf ruft WebUntis ab, zweiter Aufruf binnen einer Stunde nicht ──
[Fact]
public async Task GetClassRegisterEventsAsync_ZweiterAufrufBinnenEinerStunde_RuftWebUntisNichtErneutAb()
{
var todayLabel = DateTime.Today.ToString("dd.MM.yy");
var csv = Encoding.UTF8.GetBytes(
"Klasse\tDatum\tFach\tName\tBenutzer\tEintragskategorie\tKategoriegruppe\tText\r\n" +
$"10c\t{todayLabel}\tNAT\tMuster Max\tmueller\tMitarb. über Erwart.\tPositiv\tArbeitet gut mit.\r\n");
var handler = new QueueHandler(
Json("{\"result\":{\"sessionId\":\"s\"}}"),
Json("{\"result\":[{\"id\":1,\"name\":\"2026/27\",\"startDate\":20260801,\"endDate\":20270731}]}"),
Json("{\"data\":{\"finished\":true,\"error\":false," +
"\"reportParams\":\"get=rpt.tmp&name=ClassregEventPerStudent&format=csv\"}}"),
new HttpResponseMessage(HttpStatusCode.OK) { Content = new ByteArrayContent(csv) });
var settings = TestSupport.BuildWebUntisSettingsService();
await using var untis = new WebUntisIntegrationService(new HttpClient(handler), settings);
await untis.ConnectAsync(new WebUntisCredentials("bk-ostvest", "arche.webuntis.com", "lehrkraft", "geheim"));
var absenceCache = new FakeUntisAbsenceCache();
var classRegisterCache = new FakeUntisClassRegisterCache();
var rosterCache = new FakeUntisStudentRosterCache();
var fetchStates = new FakeUntisCacheFetchStates();
var cache = new UntisReportCacheService(untis, absenceCache, classRegisterCache, rosterCache, fetchStates);
var today = DateOnly.FromDateTime(DateTime.Today);
var first = await cache.GetClassRegisterEventsAsync("10c", today, today);
Assert.Single(first);
Assert.Equal(4, handler.Requests.Count);
var second = await cache.GetClassRegisterEventsAsync("10c", today, today);
Assert.Single(second);
Assert.Equal(4, handler.Requests.Count); // keine weiteren Anfragen - aus dem Cache bedient
}
[Fact]
public async Task GetStudentRosterAsync_ZweiterAufrufBinnenEinerStunde_RuftWebUntisNichtErneutAb()
{
var csv = Encoding.UTF8.GetBytes(
"id\texternKey\tklasse.name\tname\tlongName\tforeName\r\n" +
"1\t9001\t10c\tMUST\tMustermann\tMax\r\n");
var handler = new QueueHandler(
Json("{\"result\":{\"sessionId\":\"s\"}}"),
Json("{\"result\":[{\"id\":1,\"name\":\"2026/27\",\"startDate\":20260801,\"endDate\":20270731}]}"),
Json("{\"data\":{\"finished\":true,\"error\":false," +
"\"reportParams\":\"get=rpt.tmp&name=Student&format=csv\"}}"),
new HttpResponseMessage(HttpStatusCode.OK) { Content = new ByteArrayContent(csv) });
var settings = TestSupport.BuildWebUntisSettingsService();
await using var untis = new WebUntisIntegrationService(new HttpClient(handler), settings);
await untis.ConnectAsync(new WebUntisCredentials("bk-ostvest", "arche.webuntis.com", "lehrkraft", "geheim"));
var cache = new UntisReportCacheService(untis, new FakeUntisAbsenceCache(), new FakeUntisClassRegisterCache(),
new FakeUntisStudentRosterCache(), new FakeUntisCacheFetchStates());
var first = await cache.GetStudentRosterAsync("10c");
Assert.Single(first);
Assert.Equal("Max Mustermann", first[0].DisplayName);
Assert.Equal(4, handler.Requests.Count);
var second = await cache.GetStudentRosterAsync("10c");
Assert.Single(second);
Assert.Equal(4, handler.Requests.Count); // keine weiteren Anfragen - aus dem Cache bedient
}
private static HttpResponseMessage Json(string json) => new(HttpStatusCode.OK)
{
Content = new StringContent(json, Encoding.UTF8, "application/json"),
};
private sealed class QueueHandler(params HttpResponseMessage[] responses) : HttpMessageHandler
{
private readonly Queue<HttpResponseMessage> _responses = new(responses);
public List<string> Requests { get; } = [];
protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request,
CancellationToken cancellationToken)
{
Requests.Add(request.RequestUri?.ToString() ?? "");
await Task.Yield();
return _responses.Dequeue();
}
}
}
@@ -0,0 +1,88 @@
using System.Net;
using System.Text;
using LehrerApp.Desktop.Services;
using Xunit;
namespace LehrerApp.Desktop.Tests;
public sealed class WebUntisIntegrationServiceTests
{
[Fact]
public async Task ConnectUndAbruf_GehenDirektAnWebUntisUndVerwendenDieselbeSession()
{
var handler = new QueueHandler(
Json("{\"result\":{\"sessionId\":\"desktop-session\"}}"),
Json("{\"result\":[{\"id\":1,\"name\":\"2026/27\",\"startDate\":20260801,\"endDate\":20270731}]}"),
Json("{\"result\":[{\"id\":42,\"name\":\"KUE\",\"longName\":\"Kürbis\",\"active\":true}]}"),
Json("{\"result\":{}}"));
var settings = TestSupport.BuildWebUntisSettingsService();
await using var service = new WebUntisIntegrationService(new HttpClient(handler), settings);
await service.ConnectAsync(new WebUntisCredentials(
"bk-ostvest", "arche.webuntis.com", "lehrkraft", "geheim"));
var teachers = await service.GetTeachersAsync();
Assert.Equal("KUE", Assert.Single(teachers).Name);
Assert.Equal(3, handler.Requests.Count);
Assert.All(handler.Requests, request =>
Assert.StartsWith("https://arche.webuntis.com/WebUntis/", request.Uri));
Assert.DoesNotContain(handler.Requests, request => request.Uri.Contains("/api/webuntis"));
Assert.Single(handler.Requests, request => request.Body.Contains("\"method\":\"authenticate\""));
}
[Fact]
public async Task GetClassAbsencesAsync_LoestKlasseNamensbasiertAufUndAdressiertSieAlsKLPlusId()
{
var csv = Encoding.UTF8.GetBytes(
"Schüler*innen\tExterne Id\tText\tKlasse\tDatum\tWochentag\tFehlstd.\tFehlmin.\tLehrkraft\tFach\t" +
"Abwesenheitsgrund\tText\tENr\tErledigt\tAbwesenheit zählt\tEntschuldigungstext\tStundennr.\tStatus\tFehltage\r\n" +
"Muster Max\t12345\t\t10c\t24.08.26\tMo.\t1\t45\tHED\tChe\tAbsent\tKrank gemeldet\t555\t24.08.26\ttrue\t\t3\tnicht entsch.\t1\r\n");
var handler = new QueueHandler(
Json("{\"result\":{\"sessionId\":\"desktop-session\"}}"), // authenticate (ConnectAsync)
Json("{\"result\":[{\"id\":7,\"name\":\"2026/27\",\"startDate\":20260801,\"endDate\":20270731}]}"), // getSchoolyears (ConnectAsync-Validierung)
Json("{\"result\":[{\"id\":7,\"name\":\"2026/27\",\"startDate\":20260801,\"endDate\":20270731}]}"), // getSchoolyears (ResolveClassIdAsync)
Json("{\"result\":[{\"id\":874,\"name\":\"10c\"}]}"), // getKlassen
Json("{\"data\":{\"finished\":true,\"error\":false," +
"\"reportParams\":\"get=rpt.tmp&name=AbsencePerStudent&format=csv\"}}"),
new HttpResponseMessage(HttpStatusCode.OK) { Content = new ByteArrayContent(csv) },
Json("{\"result\":{}}")); // logout (Dispose)
var settings = TestSupport.BuildWebUntisSettingsService();
await using var service = new WebUntisIntegrationService(new HttpClient(handler), settings);
await service.ConnectAsync(new WebUntisCredentials(
"bk-ostvest", "arche.webuntis.com", "lehrkraft", "geheim"));
var entries = await service.GetClassAbsencesAsync("10c",
new DateOnly(2026, 8, 24), new DateOnly(2026, 8, 28));
var entry = Assert.Single(entries);
Assert.Equal("Muster Max", entry.StudentName);
Assert.Equal("nicht entsch.", entry.Status);
// Zweimal getSchoolyears: einmal als ConnectAsync-Validierung, einmal in ResolveClassIdAsync.
Assert.Equal(2, handler.Requests.Count(request => request.Body.Contains("\"method\":\"getSchoolyears\"")));
Assert.Single(handler.Requests, request => request.Body.Contains("\"method\":\"getKlassen\""));
Assert.Single(handler.Requests, request =>
request.Uri.Contains("reports.do?name=AbsencePerStudent") &&
request.Uri.Contains("klasseOrStudentgroupId=KL874"));
}
private static HttpResponseMessage Json(string json) => new(HttpStatusCode.OK)
{
Content = new StringContent(json, Encoding.UTF8, "application/json"),
};
private sealed class QueueHandler(params HttpResponseMessage[] responses) : HttpMessageHandler
{
private readonly Queue<HttpResponseMessage> _responses = new(responses);
public List<CapturedRequest> Requests { get; } = [];
protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request,
CancellationToken cancellationToken)
{
Requests.Add(new(request.RequestUri?.ToString() ?? "",
request.Content is null ? "" : await request.Content.ReadAsStringAsync(cancellationToken)));
return _responses.Dequeue();
}
}
private sealed record CapturedRequest(string Uri, string Body);
}
@@ -86,4 +86,39 @@ public sealed class WebUntisSettingsServiceTests
Assert.Equal(at, reloaded.LastSyncAt);
Assert.Equal("3 Vertretungen erkannt", reloaded.LastSyncStatus);
}
[Fact]
public void ApiCredentials_SindVerschluesseltUndPersistieren()
{
var path = BuildTempPath();
var credentials = new WebUntisCredentials("schule", "mese.webuntis.com", "lehrkraft", "sehr geheim");
var service = new WebUntisSettingsService(path);
service.SetApiCredentials(credentials);
service.SetTeacherUntisId(4711);
var raw = File.ReadAllText(Path.Combine(path, "webuntis-settings.json"));
var reloaded = new WebUntisSettingsService(path);
Assert.DoesNotContain("sehr geheim", raw);
Assert.True(reloaded.ApiIsConfigured);
Assert.Equal(credentials, reloaded.GetApiCredentials());
Assert.Equal(4711, reloaded.TeacherUntisId);
}
[Fact]
public void ClearApiCredentials_LaesstIcalKonfigurationUnveraendert()
{
var service = new WebUntisSettingsService(BuildTempPath());
service.SetIcalUrl(SampleUrl);
service.SetApiCredentials(new("schule", "", "user", "password"));
service.SetTeacherUntisId(12);
service.ClearApiCredentials();
Assert.False(service.ApiIsConfigured);
Assert.Null(service.GetApiCredentials());
Assert.Null(service.TeacherUntisId);
Assert.True(service.IsConfigured);
Assert.Equal(SampleUrl, service.GetIcalUrl());
}
}
+8
View File
@@ -2,6 +2,14 @@
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
x:Class="LehrerApp.Desktop.App"
RequestedThemeVariant="Default">
<Application.Resources>
<ResourceDictionary>
<ResourceDictionary.MergedDictionaries>
<ResourceInclude Source="avares://LehrerApp.Desktop/Styles/SemanticBrushes.axaml"/>
</ResourceDictionary.MergedDictionaries>
</ResourceDictionary>
</Application.Resources>
<Application.Styles>
<FluentTheme />
<StyleInclude Source="avares://Avalonia.Controls.DataGrid/Themes/Fluent.xaml"/>
+57 -4
View File
@@ -10,7 +10,9 @@ using LehrerApp.Desktop.ViewModels;
using LehrerApp.Desktop.ViewModels.Groups;
using LehrerApp.Desktop.ViewModels.Planning;
using LehrerApp.Desktop.ViewModels.Students;
using LehrerApp.Desktop.ViewModels.Workload;
using LehrerApp.Desktop.Views;
using LehrerApp.Desktop.Views.Workload;
using LehrerApp.Sync;
using Microsoft.Extensions.DependencyInjection;
@@ -79,6 +81,15 @@ public class App : Application
var mainVm = Services.GetRequiredService<MainWindowViewModel>();
WireCallbacks(mainVm);
// DI-Singletons werden erst beim ersten Auflösen erzeugt. Ohne dieses explizite
// Auflösen entstand der Timer des optionalen WebUntis-Abgleichs erst, wenn die
// Einstellungen geöffnet oder ein manueller Abruf gestartet wurde. Den Dienst beim
// Anwendungsstart aktivieren und wie den Jahresplan sofort einmal abgleichen; danach
// übernimmt sein stündlicher Timer.
if (Services.GetService<UntisSyncService>() is { } untisSync)
_ = untisSync.PollAsync();
var main = new MainWindow { DataContext = mainVm };
main.EnableWindowSizePersistence(Services.GetRequiredService<WindowSettingsService>());
if (Services.GetService<SyncEngine>() is { } syncEngine)
@@ -100,11 +111,12 @@ public class App : Application
private static void DisposeServices()
{
if (_serviceProvider is null) return;
var serviceProvider = Interlocked.Exchange(ref _serviceProvider, null);
if (serviceProvider is null) return;
try
{
_serviceProvider.GetService<LiteDbContext>()?.Checkpoint();
serviceProvider.GetService<LiteDbContext>()?.Checkpoint();
}
catch (Exception ex)
{
@@ -112,8 +124,19 @@ public class App : Application
}
finally
{
_serviceProvider.Dispose();
_serviceProvider = null;
// Der Exit-Handler läuft synchron auf dem Avalonia-UI-Thread. Die asynchrone
// Entsorgung darf dort nicht mit GetResult() gestartet werden: Fortsetzungen aus
// WebUntis/HttpClient könnten sonst auf den blockierten UI-Kontext zurückwarten.
try
{
Task.Run(async () =>
await serviceProvider.DisposeAsync().ConfigureAwait(false))
.GetAwaiter().GetResult();
}
catch (Exception ex)
{
AppBootstrapper.Logger.Error("Dienste konnten beim Beenden nicht vollständig freigegeben werden.", ex);
}
}
}
@@ -131,6 +154,23 @@ public class App : Application
dash.OnNavigateToExam = id => main.NavigateToGroupDetail(id, 4); // Tab "Klausuren"
dash.OnNavigateToUnplannedLesson = id => main.NavigateToGroupDetail(id, 6); // Tab "Planung"
// Globale Suche/Schnellerfassung (14.2): Navigation bleibt im MainWindow-VM, die
// vorhandenen Dialog-Helfer übernehmen Eingabe und Validierung.
var search = Services.GetRequiredService<GlobalSearchViewModel>();
search.OnNavigate = result =>
{
if (result.Kind == GlobalSearchResultKind.Student && result.EntityId is { } studentId)
main.NavigateToStudent(studentId);
else if (result.Kind == GlobalSearchResultKind.Group && result.GroupId is { } groupId)
main.NavigateToGroupDetail(groupId);
else if (result.Kind == GlobalSearchResultKind.Exam && result.GroupId is { } examGroupId)
main.NavigateToGroupDetail(examGroupId, 4);
else if (result.Kind == GlobalSearchResultKind.Task)
main.NavigateToWorkload();
};
search.OnQuickAddTask = startAsReminder => ShowQuickTaskDialog(startAsReminder, dash);
search.OnQuickAddStudent = ShowAddStudentDialog;
// StudentList → StudentDetail + Anlegen
var sl = Services.GetRequiredService<StudentListViewModel>();
sl.OnNavigateToDetail = id => main.NavigateToStudent(id);
@@ -150,4 +190,17 @@ public class App : Application
if (Application.Current?.ApplicationLifetime is IClassicDesktopStyleApplicationLifetime { MainWindow: { } owner })
await dialog.ShowDialog<bool>(owner);
}
private static async Task ShowQuickTaskDialog(bool startAsReminder, DashboardViewModel dashboard)
{
if (Application.Current?.ApplicationLifetime is not IClassicDesktopStyleApplicationLifetime
{ MainWindow: { } owner }) return;
var result = await WorkTaskDialogHelper.ShowDialog(owner, startAsReminder: startAsReminder);
if (result is null) return;
Services.GetRequiredService<IWorkTaskRepository>().Save(result);
dashboard.RefreshCommand.Execute(null);
Services.GetRequiredService<WorkTaskListViewModel>().Load();
}
}
+12
View File
@@ -5,6 +5,8 @@ using LehrerApp.Data;
using LehrerApp.Data.Repositories;
using LehrerApp.Desktop.Services;
using LehrerApp.Desktop.ViewModels;
using LehrerApp.Desktop.ViewModels.ClassTeacher;
using LehrerApp.Desktop.ViewModels.Exams;
using LehrerApp.Desktop.ViewModels.Groups;
using LehrerApp.Desktop.ViewModels.Planning;
using LehrerApp.Desktop.ViewModels.Settings;
@@ -168,6 +170,10 @@ public static class AppBootstrapper
services.AddSingleton<IUntisSnapshotRepository, UntisSnapshotRepository>();
services.AddSingleton<IUntisSlotMappingRepository, UntisSlotMappingRepository>();
services.AddSingleton<IAnnualPlanEventRepository, AnnualPlanEventRepository>();
services.AddSingleton<IUntisAbsenceCacheRepository, UntisAbsenceCacheRepository>();
services.AddSingleton<IUntisClassRegisterCacheRepository, UntisClassRegisterCacheRepository>();
services.AddSingleton<IUntisCacheFetchStateRepository, UntisCacheFetchStateRepository>();
services.AddSingleton<IUntisStudentRosterCacheRepository, UntisStudentRosterCacheRepository>();
// ── Services ──────────────────────────────────────────────────────────
services.AddSingleton<GradingService>();
@@ -223,6 +229,8 @@ public static class AppBootstrapper
services.AddSingleton(syncSettings);
services.AddSingleton(_ => new SyncAuthService(new HttpClient()));
services.AddSingleton(sp => new SchoolWeatherService(new HttpClient(), syncSettings));
services.AddSingleton(sp => new WebUntisIntegrationService(new HttpClient(), untisSettings));
services.AddSingleton<UntisReportCacheService>();
// War dieses Gerät schon eingeloggt, aber sync.key fehlt(e), wurde gerade eben (unten)
// stillschweigend ein neuer, unabhängiger Schlüssel erzeugt - bisher unter dem ALTEN
// Schlüssel synchronisierte Server-Daten sind für dieses Gerät dann nicht mehr lesbar.
@@ -284,6 +292,7 @@ public static class AppBootstrapper
// Singleton: einmal erstellt, überall dieselbe Instanz
services.AddSingleton<AppLockViewModel>();
services.AddSingleton<MainWindowViewModel>();
services.AddSingleton<GlobalSearchViewModel>();
services.AddSingleton<DashboardViewModel>();
services.AddSingleton(sp =>
new SyncStatusViewModel(
@@ -296,6 +305,9 @@ public static class AppBootstrapper
services.AddSingleton<TimeTrackingViewModel>();
services.AddSingleton<WorkloadEvaluationViewModel>();
services.AddSingleton<WorkloadViewModel>();
services.AddSingleton<ClassTeacherDetailsViewModel>();
services.AddSingleton<ClassTeacherOverviewViewModel>();
services.AddSingleton<ExamsOverviewViewModel>();
// Transient: neue Instanz pro Navigation (für Detailseiten)
services.AddTransient<GroupDetailViewModel>();
@@ -0,0 +1,24 @@
using System.Collections.Generic;
using System.Globalization;
using Avalonia.Data.Converters;
namespace LehrerApp.Desktop.Converters;
/// <summary>Multipliziert die gerenderte Breite eines Referenzelements (typischerweise die neutrale
/// Track-Leiste eines gestapelten Balkens) mit einem Anteil 0…1. Ersetzt eine im ViewModel hart
/// codierte Pixelkonstante (Quick-Win, Nutzer-Feedback) — die Segmentbreite bleibt damit auch
/// korrekt, wenn sich die Breite der Seitenspalte in XAML mal ändert, weil sie sich aus der
/// tatsächlichen <c>Bounds.Width</c> des Track-Elements ergibt statt aus einer angenommenen
/// festen Breite. `Grid.ColumnDefinitions` ließ sich dafür nicht binden (Avalonia bietet dort bei
/// kompilierten Bindings keinen Setter — <c>AVLN3000</c>), deshalb MultiBinding auf Pixelbreite
/// statt Grid-Sternspalten.</summary>
public sealed class FractionWidthConverter : IMultiValueConverter
{
public static readonly FractionWidthConverter Instance = new();
public object? Convert(IList<object?> values, Type targetType, object? parameter, CultureInfo culture)
{
if (values is not [double trackWidth, double fraction, ..] || double.IsNaN(trackWidth)) return 0d;
return Math.Max(0d, trackWidth * fraction);
}
}
@@ -9,6 +9,7 @@
<ProjectReference Include="..\LehrerApp.Core\LehrerApp.Core.csproj" />
<ProjectReference Include="..\LehrerApp.Data\LehrerApp.Data.csproj" />
<ProjectReference Include="..\LehrerApp.Sync\LehrerApp.Sync.csproj" />
<ProjectReference Include="..\LehrerApp.WebUntis\LehrerApp.WebUntis.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Avalonia" />
@@ -0,0 +1,171 @@
using LehrerApp.Core.Interfaces;
using LehrerApp.Core.Models;
namespace LehrerApp.Desktop.Services;
/// <summary>Ergebnis von <see cref="UntisReportCacheService.Plan"/>: was für eine Anfrage
/// [<c>RequestStart</c>..<c>RequestEnd</c>, nicht Teil dieses Records, siehe Aufrufer] tatsächlich
/// live nachgeladen werden muss, und welcher Deckungsstand danach gilt. Bewusst als reine, ohne
/// Repository/HTTP-Zugriff testbare Funktion ausgelagert (gleiches Muster wie
/// <see cref="ClassTeacher.ClassAbsenceDaySummaryRow.GroupByStudentAndDay"/>).</summary>
public readonly record struct UntisCacheRefreshPlan(
bool RefreshHotWindow, DateOnly HotWindowStart, DateOnly HotWindowEnd,
bool FetchColdRange, DateOnly ColdRangeStart, DateOnly ColdRangeEnd,
int? ResultingColdCoverageStartDate);
/// <summary>
/// Lokaler Cache für die beiden Klassenlehrer-Berichte (Fehlzeiten, Klassenbucheinträge anderer
/// Lehrkräfte, siehe TODO.md) - Nutzer-Feedback: Warnungen sollen sofort da sein, ohne bei jedem
/// Öffnen der Ansicht neu abzurufen, und der Bericht darf nicht mehrfach pro Stunde abgerufen
/// werden (Sorge, bei WebUntis aufzufallen, wenn dieselbe Ansicht mehrfach geöffnet wird). Ein
/// festes "heißes" Fenster der letzten <see cref="HotWindowDays"/> Tage wird höchstens stündlich
/// aufgefrischt (dort kann sich der Status noch ändern, z. B. "ausstehend" → "entschuldigt");
/// alles Ältere gilt als endgültig und wird, einmal abgerufen, dauerhaft aus dem Cache bedient.
/// Die Cache-Tabellen selbst sind bewusst nicht synchronisiert (siehe
/// <see cref="IUntisAbsenceCacheRepository"/>-Implementierung) - jedes Gerät füllt seinen Cache
/// über diesen Service selbst.
/// </summary>
public sealed class UntisReportCacheService(
WebUntisIntegrationService untis,
IUntisAbsenceCacheRepository absenceCache,
IUntisClassRegisterCacheRepository classRegisterCache,
IUntisStudentRosterCacheRepository rosterCache,
IUntisCacheFetchStateRepository fetchState)
{
private static readonly TimeSpan HotWindowRefreshInterval = TimeSpan.FromHours(1);
private const int HotWindowDays = 14;
/// Roster hat anders als Fehlzeiten/Klassenbuch keine Historie (nur "aktueller Stand einer
/// Klasse") - deshalb ohne heißes/kaltes Fenster, nur "gilt der letzte Abruf noch als frisch".
/// Behebt, dass die Klassenlehrer-Übersicht bei jedem Öffnen spürbar verzögert wirkte: der
/// Roster-Abruf lief bislang komplett am Cache vorbei live gegen WebUntis (Nutzer-Feedback).
public async Task<IReadOnlyList<UntisStudentRosterCacheEntry>> GetStudentRosterAsync(string className,
bool forceRefresh = false, CancellationToken token = default)
{
var state = fetchState.Get(className, UntisCacheKind.Roster);
if (NeedsRefresh(state, forceRefresh, DateTime.UtcNow))
{
var fresh = await untis.GetStudentsAsync(className, token);
rosterCache.ReplaceAll(className, fresh.Students.Select(s => new UntisStudentRosterCacheEntry
{
ClassName = className, ExternKey = s.ExternKey, DisplayName = s.DisplayName,
}));
fetchState.Save(new UntisCacheFetchState
{
Id = state?.Id ?? Guid.NewGuid(), ClassName = className, Kind = UntisCacheKind.Roster,
HotWindowFetchedAt = DateTime.UtcNow,
});
}
return rosterCache.GetByClass(className);
}
private static bool NeedsRefresh(UntisCacheFetchState? state, bool forceRefresh, DateTime utcNow) =>
forceRefresh || state?.HotWindowFetchedAt is null || utcNow - state.HotWindowFetchedAt >= HotWindowRefreshInterval;
public async Task<IReadOnlyList<UntisClassAbsenceEntryDto>> GetAbsencesAsync(string className,
DateOnly start, DateOnly end, bool forceRefresh = false, CancellationToken token = default)
{
var state = fetchState.Get(className, UntisCacheKind.Absences);
var plan = Plan(state, start, end, Today(), forceRefresh, DateTime.UtcNow);
if (plan.RefreshHotWindow)
{
var fresh = await untis.GetClassAbsencesAsync(className, plan.HotWindowStart, plan.HotWindowEnd, token);
absenceCache.ReplaceRange(className, Int(plan.HotWindowStart), Int(plan.HotWindowEnd), fresh.Select(ToEntry));
}
if (plan.FetchColdRange)
{
var older = await untis.GetClassAbsencesAsync(className, plan.ColdRangeStart, plan.ColdRangeEnd, token);
absenceCache.InsertRange(older.Select(ToEntry));
}
if (plan.RefreshHotWindow || plan.FetchColdRange)
fetchState.Save(NextState(state, className, UntisCacheKind.Absences, plan));
return absenceCache.GetByClassAndRange(className, Int(start), Int(end)).Select(ToDto).ToList();
}
public async Task<IReadOnlyList<UntisForeignClassRegisterEventDto>> GetClassRegisterEventsAsync(
string className, DateOnly start, DateOnly end, bool forceRefresh = false, CancellationToken token = default)
{
var state = fetchState.Get(className, UntisCacheKind.ClassRegister);
var plan = Plan(state, start, end, Today(), forceRefresh, DateTime.UtcNow);
if (plan.RefreshHotWindow)
{
var fresh = await untis.GetForeignClassRegisterEventsAsync(className, plan.HotWindowStart, plan.HotWindowEnd, token);
classRegisterCache.ReplaceRange(className, Int(plan.HotWindowStart), Int(plan.HotWindowEnd), fresh.Select(ToEntry));
}
if (plan.FetchColdRange)
{
var older = await untis.GetForeignClassRegisterEventsAsync(className, plan.ColdRangeStart, plan.ColdRangeEnd, token);
classRegisterCache.InsertRange(older.Select(ToEntry));
}
if (plan.RefreshHotWindow || plan.FetchColdRange)
fetchState.Save(NextState(state, className, UntisCacheKind.ClassRegister, plan));
return classRegisterCache.GetByClassAndRange(className, Int(start), Int(end)).Select(ToDto).ToList();
}
/// Reine Entscheidungslogik ohne Repository-/HTTP-Zugriff: was muss für eine Anfrage
/// [<paramref name="requestStart"/>..<paramref name="requestEnd"/>] live nachgeladen werden,
/// gegeben den zuletzt gespeicherten Zustand. Das heiße Fenster ist immer fest
/// [<paramref name="today"/> <see cref="HotWindowDays"/>, <paramref name="today"/>], unabhängig
/// von der angefragten Spanne. Nach einem heißen Refresh reicht die bekannte Abdeckung mindestens
/// bis zum Fensteranfang zurück; ein zusätzlicher kalter Abruf erweitert sie nur so weit wie
/// für <paramref name="requestStart"/> nötig, nie weiter.
public static UntisCacheRefreshPlan Plan(UntisCacheFetchState? state, DateOnly requestStart, DateOnly requestEnd,
DateOnly today, bool forceRefresh, DateTime utcNow)
{
var hotStart = today.AddDays(-HotWindowDays);
var refreshHot = requestEnd >= hotStart && (forceRefresh || state?.HotWindowFetchedAt is null
|| utcNow - state.HotWindowFetchedAt >= HotWindowRefreshInterval);
int? coverageStart = state?.ColdCoverageStartDate;
if (refreshHot)
coverageStart = coverageStart is { } existing ? Math.Min(existing, Int(hotStart)) : Int(hotStart);
var coldEnd = coverageStart is { } covered ? FromInt(covered).AddDays(-1) : hotStart.AddDays(-1);
var fetchCold = (coverageStart is null || requestStart < FromInt(coverageStart.Value)) && requestStart <= coldEnd;
if (fetchCold) coverageStart = Int(requestStart);
return new UntisCacheRefreshPlan(refreshHot, hotStart, today, fetchCold, requestStart, coldEnd, coverageStart);
}
private static UntisCacheFetchState NextState(UntisCacheFetchState? previous, string className,
UntisCacheKind kind, UntisCacheRefreshPlan plan) => new()
{
Id = previous?.Id ?? Guid.NewGuid(),
ClassName = className,
Kind = kind,
HotWindowFetchedAt = plan.RefreshHotWindow ? DateTime.UtcNow : previous?.HotWindowFetchedAt,
ColdCoverageStartDate = plan.ResultingColdCoverageStartDate,
};
private static DateOnly Today() => DateOnly.FromDateTime(DateTime.Today);
private static int Int(DateOnly date) => date.Year * 10000 + date.Month * 100 + date.Day;
private static DateOnly FromInt(int value) => new(value / 10000, value / 100 % 100, value % 100);
private static UntisAbsenceCacheEntry ToEntry(UntisClassAbsenceEntryDto x) => new()
{
ClassName = x.ClassName, StudentName = x.StudentName, ExternKey = x.ExternKey, Date = x.Date,
AbsentPeriods = x.AbsentPeriods, AbsentMinutes = x.AbsentMinutes, TeacherUsernames = x.TeacherUsernames,
Subject = x.Subject, AbsenceReason = x.AbsenceReason, Note = x.Note, EntryId = x.EntryId,
HandledOn = x.HandledOn, Counts = x.Counts, ExcuseNote = x.ExcuseNote, PeriodNumber = x.PeriodNumber,
Status = x.Status, CountsAsFullDay = x.CountsAsFullDay,
};
private static UntisClassAbsenceEntryDto ToDto(UntisAbsenceCacheEntry e) => new(
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);
private static UntisClassRegisterCacheEntry ToEntry(UntisForeignClassRegisterEventDto x) => new()
{
ClassName = x.ClassName, Date = x.Date, Subject = x.Subject, StudentName = x.StudentName,
TeacherUsername = x.TeacherUsername, CategoryName = x.CategoryName, CategoryGroup = x.CategoryGroup,
Text = x.Text,
};
private static UntisForeignClassRegisterEventDto ToDto(UntisClassRegisterCacheEntry e) => new(
e.ClassName, e.Date, e.Subject, e.StudentName, e.TeacherUsername, e.CategoryName, e.CategoryGroup, e.Text);
}
@@ -0,0 +1,238 @@
using LehrerApp.WebUntis;
namespace LehrerApp.Desktop.Services;
public sealed class WebUntisIntegrationException(string message) : Exception(message);
public sealed record UntisSchoolYearDto(int UntisId, string Name, int StartDate, int EndDate);
public sealed record UntisClassDto(int UntisId, string Name, string? LongName);
public sealed record UntisTeacherDto(int UntisId, string Name, string? ForeName, string? LongName, string? Title,
bool Active, IReadOnlyList<int> DepartmentUntisIds)
{
public string DisplayName => string.IsNullOrWhiteSpace(LongName)
? Name
: $"{ForeName} {LongName} ({Name})".Trim();
}
public sealed record UntisEntityDto(int Id, string Name, int? OriginalId, string? OriginalName, string? ExternalKey);
public sealed record UntisTimeUnitDto(string Name, int StartTime, int EndTime);
public sealed record UntisTimeGridDayDto(int Day, IReadOnlyList<UntisTimeUnitDto> TimeUnits);
public sealed record UntisTimetablePeriodDto(int Id, int Date, int StartTime, int EndTime, string? Code,
string? ActivityType, string? Info, string? LessonText, string? SubstitutionText, string? StudentGroup,
IReadOnlyList<UntisEntityDto> Classes, IReadOnlyList<UntisEntityDto> Teachers,
IReadOnlyList<UntisEntityDto> Subjects, IReadOnlyList<UntisEntityDto> Rooms);
public sealed record UntisStudentAddressDto(string? Email, string? Mobile, string? Phone, string? City,
string? PostCode, string? Street);
public sealed record UntisStudentDto(int UntisId, int? ExternKey, string ClassName, string? Name, string? LongName,
string? ForeName, string DisplayName, string? Gender, int? BirthDate, string? BirthDateRaw, int? EntryDate,
string? EntryDateRaw, int? ExitDate, string? ExitDateRaw, string? Text, string? MedicalReportDuty,
string? Schulpflicht, string? Majority, UntisStudentAddressDto Address, string? AttributeIL);
public sealed record UntisStudentReportDto(int Count, string? ClassNameFilter, IReadOnlyList<UntisStudentDto> Students);
public sealed record UntisLessonAbsenceDto(string StudentName, int Date, int AbsentPeriods,
int UnexcusedAbsentPeriods, int AbsentMinutes, int UnexcusedAbsentMinutes, int? StartTime, int? EndTime,
string? Reason, int? ExternKey, bool ExternKeyInParentheses, string? HandledOn, bool Counts);
public sealed record UntisClassRegisterEventDto(string ClassName, int Date, string? Subject,
string StudentName, string? CategoryName, string? CategoryGroup, string? Text);
public sealed record UntisForeignClassRegisterEventDto(string ClassName, int Date, string? Subject,
string StudentName, string? TeacherUsername, string? CategoryName, string? CategoryGroup, string? Text);
public sealed record UntisClassAbsenceEntryDto(string StudentName, int? ExternKey, string ClassName, int Date,
int AbsentPeriods, int AbsentMinutes, string? TeacherUsernames, string? Subject, string? AbsenceReason,
string? Note, int? EntryId, string? HandledOn, bool Counts, string? ExcuseNote, int? PeriodNumber,
string? Status, bool CountsAsFullDay);
/// <summary>Direkter WebUntis-Client des Desktops. Personenbezogene Antworten und der
/// unverschlüsselte CSV-Report passieren zu keinem Zeitpunkt den LehrerApp-Server.</summary>
public sealed class WebUntisIntegrationService(HttpClient http, WebUntisSettingsService settings) : IAsyncDisposable
{
private readonly SemaphoreSlim _clientGate = new(1, 1);
private WebUntisClient? _client;
public bool IsAvailable => settings.ApiIsConfigured;
public async Task ConnectAsync(WebUntisCredentials credentials, CancellationToken token = default)
{
var candidate = CreateClient(credentials);
try
{
await candidate.GetSchoolYearsAsync(token);
}
catch (Exception exception) when (IsUntisError(exception))
{
await candidate.DisposeAsync();
throw Translate(exception);
}
WebUntisClient? previous;
await _clientGate.WaitAsync(token).ConfigureAwait(false);
try { previous = _client; _client = candidate; }
finally { _clientGate.Release(); }
if (previous is not null) await previous.DisposeAsync().ConfigureAwait(false);
}
public async Task DisconnectAsync(CancellationToken token = default)
{
WebUntisClient? previous;
await _clientGate.WaitAsync(token).ConfigureAwait(false);
try { previous = _client; _client = null; }
finally { _clientGate.Release(); }
if (previous is not null) await previous.DisposeAsync().ConfigureAwait(false);
}
public Task<IReadOnlyList<UntisSchoolYearDto>> GetSchoolYearsAsync(CancellationToken token = default) => ExecuteAsync(
async client => (IReadOnlyList<UntisSchoolYearDto>)(await client.GetSchoolYearsAsync(token))
.Select(x => new UntisSchoolYearDto(x.UntisId, x.Name, x.StartDate, x.EndDate)).ToList(), token);
public Task<IReadOnlyList<UntisClassDto>> GetClassesAsync(int schoolYearId, CancellationToken token = default) => ExecuteAsync(
async client => (IReadOnlyList<UntisClassDto>)(await client.GetClassesAsync(schoolYearId, token))
.Select(x => new UntisClassDto(x.UntisId, x.Name, x.LongName)).ToList(), token);
public Task<IReadOnlyList<UntisTeacherDto>> GetTeachersAsync(CancellationToken token = default) => ExecuteAsync(
async client => (IReadOnlyList<UntisTeacherDto>)(await client.GetTeachersAsync(token))
.Select(x => new UntisTeacherDto(x.UntisId, x.Name, x.ForeName, x.LongName, x.Title, x.Active,
x.DepartmentUntisIds)).ToList(), token);
public Task<IReadOnlyList<UntisTimeGridDayDto>> GetTimeGridAsync(CancellationToken token = default) => ExecuteAsync(
async client => (IReadOnlyList<UntisTimeGridDayDto>)(await client.GetTimeGridAsync(token))
.Select(x => new UntisTimeGridDayDto(x.Day,
x.TimeUnits.Select(t => new UntisTimeUnitDto(t.Name, t.StartTime, t.EndTime)).ToList())).ToList(), token);
public Task<IReadOnlyList<UntisTimetablePeriodDto>> GetTimetableAsync(int teacherId, DateOnly start,
DateOnly end, CancellationToken token = default) => ExecuteAsync(async client =>
(IReadOnlyList<UntisTimetablePeriodDto>)(await client.GetTimetableAsync(
UntisTimetableElementType.Teacher, teacherId, Date(start), Date(end), token))
.Select(x => new UntisTimetablePeriodDto(x.Id, x.Date, x.StartTime, x.EndTime, x.Code,
x.ActivityType, x.Info, x.LessonText, x.SubstitutionText, x.StudentGroup,
Entities(x.Classes), Entities(x.Teachers), Entities(x.Subjects), Entities(x.Rooms))).ToList(), token);
public Task<UntisStudentReportDto> GetStudentsAsync(string className, CancellationToken token = default) => ExecuteAsync(
async client =>
{
var report = await client.GetStudentReportAsync(className, token);
return new UntisStudentReportDto(report.Count, report.ClassNameFilter, report.Students.Select(x =>
new UntisStudentDto(x.UntisId, x.ExternKey, x.ClassName, x.Name, x.LongName, x.ForeName,
x.DisplayName, x.Gender, x.BirthDate, x.BirthDateRaw, x.EntryDate, x.EntryDateRaw,
x.ExitDate, x.ExitDateRaw, x.Text, x.MedicalReportDuty, x.Schulpflicht, x.Majority,
new UntisStudentAddressDto(x.Address.Email, x.Address.Mobile, x.Address.Phone, x.Address.City,
x.Address.PostCode, x.Address.Street), x.AttributeIL)).ToList());
}, token);
public Task<IReadOnlyList<UntisLessonAbsenceDto>> GetLessonAbsencesAsync(int lessonId,
DateOnly start, DateOnly end, CancellationToken token = default) => ExecuteAsync(async client =>
{
var absences = await client.GetLessonAbsencesAsync(lessonId, Date(start), Date(end), token);
return (IReadOnlyList<UntisLessonAbsenceDto>)absences.Select(x => new UntisLessonAbsenceDto(
x.StudentName, x.Date, x.AbsentPeriods, x.UnexcusedAbsentPeriods, x.AbsentMinutes,
x.UnexcusedAbsentMinutes, x.StartTime, x.EndTime, x.Reason, x.ExternKey, x.ExternKeyInParentheses,
x.HandledOn, x.Counts)).ToList();
}, token);
// "-alle-"-Bericht, hier auf eigene Einträge gefiltert (Benutzer == eigener WebUntis-Login) - Einträge
// anderer Lehrkräfte zu Schülern der eigenen Klasse gehören zu einem eigenständigen, noch nicht
// gebauten "Klassenlehrer"-Feature (siehe TODO.md), nicht zum reinen Dokumentations-Abgleich.
public Task<IReadOnlyList<UntisClassRegisterEventDto>> GetOwnClassRegisterEventsAsync(DateOnly start,
DateOnly end, CancellationToken token = default) => ExecuteAsync(async client =>
{
var ownUsername = settings.GetApiCredentials()?.Username;
var entries = await client.GetClassRegisterEventsReportAsync(Date(start), Date(end), token);
return (IReadOnlyList<UntisClassRegisterEventDto>)entries
.Where(x => ownUsername is not null
&& string.Equals(x.TeacherUsername, ownUsername, StringComparison.OrdinalIgnoreCase))
.Select(x => new UntisClassRegisterEventDto(x.ClassName, x.Date, x.Subject, x.StudentName,
x.CategoryName, x.CategoryGroup, x.Text))
.ToList();
}, token);
// Spiegelbild von GetOwnClassRegisterEventsAsync für das "Klassenlehrer"-Feature (siehe TODO.md):
// dieselben "-alle-"-Rohdaten, aber Einträge anderer Lehrkräfte statt der eigenen, zusätzlich auf
// die übergebene Klasse eingeschränkt (der "-alle-"-Bericht liefert sonst auch andere Klassen mit,
// sobald eine Lehrkraft mehrere Klassen unterrichtet).
public Task<IReadOnlyList<UntisForeignClassRegisterEventDto>> GetForeignClassRegisterEventsAsync(
string className, DateOnly start, DateOnly end, CancellationToken token = default) => ExecuteAsync(async client =>
{
var ownUsername = settings.GetApiCredentials()?.Username;
var entries = await client.GetClassRegisterEventsReportAsync(Date(start), Date(end), token);
return (IReadOnlyList<UntisForeignClassRegisterEventDto>)entries
.Where(x => string.Equals(x.ClassName, className, StringComparison.OrdinalIgnoreCase)
// Nicht einfach "!=" gegen ownUsername: wäre ownUsername unbekannt (null), würde
// ein ebenfalls fehlender TeacherUsername sonst fälschlich als "eigen" erkannt
// (Equals(null,null)==true) und rausgefiltert statt als fremd angezeigt zu werden.
&& !(ownUsername is not null
&& string.Equals(x.TeacherUsername, ownUsername, StringComparison.OrdinalIgnoreCase)))
.Select(x => new UntisForeignClassRegisterEventDto(x.ClassName, x.Date, x.Subject, x.StudentName,
x.TeacherUsername, x.CategoryName, x.CategoryGroup, x.Text))
.ToList();
}, token);
// Fehlzeiten der ganzen Klasse über alle Fächer, Ergänzung zu GetForeignClassRegisterEventsAsync
// für das "Klassenlehrer"-Feature. Der Bericht adressiert die Klasse über "KL"+getKlassen-ID statt
// eines Namens, siehe ResolveClassIdAsync.
public Task<IReadOnlyList<UntisClassAbsenceEntryDto>> GetClassAbsencesAsync(string className,
DateOnly start, DateOnly end, CancellationToken token = default) => ExecuteAsync(async client =>
{
var classId = await ResolveClassIdAsync(client, className, token);
var entries = await client.GetClassAbsencesAsync(classId, Date(start), Date(end), token);
return (IReadOnlyList<UntisClassAbsenceEntryDto>)entries.Select(x => new UntisClassAbsenceEntryDto(
x.StudentName, x.ExternKey, x.ClassName, x.Date, x.AbsentPeriods, x.AbsentMinutes,
x.TeacherUsernames, x.Subject, x.AbsenceReason, x.Note, x.EntryId, x.HandledOn, x.Counts,
x.ExcuseNote, x.PeriodNumber, x.Status, x.CountsAsFullDay))
.ToList();
}, token);
// "KL" + getKlassen-ID, dasselbe Element-Kürzel-Schema wie kl/te/su/ro bei anderen Berichten
// (siehe Entities(...) unten). Unbestätigt (kein offizieller Report-Parameter dokumentiert), aber
// aus einem echten Browser-Request rückgeschlossen - schlägt die Zuordnung fehl, liefert der
// Bericht erkennbar leere/falsche Daten statt eines stillen Fehlers.
private static async Task<string> ResolveClassIdAsync(WebUntisClient client, string className,
CancellationToken token)
{
var years = await client.GetSchoolYearsAsync(token);
var today = Date(DateOnly.FromDateTime(DateTime.Today));
var year = years.FirstOrDefault(y => y.StartDate <= today && y.EndDate >= today)
?? years.OrderByDescending(y => y.StartDate).FirstOrDefault()
?? throw new WebUntisIntegrationException("WebUntis hat kein Schuljahr geliefert.");
var classes = await client.GetClassesAsync(year.UntisId, token);
var matches = classes.Where(c => string.Equals(c.Name, className, StringComparison.OrdinalIgnoreCase)).ToList();
return matches.Count switch
{
1 => $"KL{matches[0].UntisId}",
0 => throw new WebUntisIntegrationException($"WebUntis kennt keine Klasse namens \"{className}\"."),
_ => throw new WebUntisIntegrationException(
$"Mehrere WebUntis-Klassen heißen \"{className}\" - bitte manuell prüfen."),
};
}
private async Task<T> ExecuteAsync<T>(Func<WebUntisClient, Task<T>> operation, CancellationToken token)
{
try { return await operation(await GetClientAsync(token)); }
catch (Exception exception) when (IsUntisError(exception)) { throw Translate(exception); }
}
private async Task<WebUntisClient> GetClientAsync(CancellationToken token)
{
if (_client is not null) return _client;
await _clientGate.WaitAsync(token);
try
{
if (_client is not null) return _client;
var credentials = settings.GetApiCredentials()
?? throw new WebUntisIntegrationException("Bitte zuerst die WebUntis-Anmeldedaten einrichten.");
return _client = CreateClient(credentials);
}
finally { _clientGate.Release(); }
}
private WebUntisClient CreateClient(WebUntisCredentials credentials) => new(http, new WebUntisOptions
{
School = credentials.School, Host = credentials.Host, Username = credentials.Username,
Password = credentials.Password, Client = "LehrerApp-Desktop", SessionIdleTimeoutMinutes = 10,
});
private static int Date(DateOnly date) => date.Year * 10000 + date.Month * 100 + date.Day;
private static IReadOnlyList<UntisEntityDto> Entities(IReadOnlyList<UntisEntity> values) => values
.Select(x => new UntisEntityDto(x.Id, x.Name, x.OriginalId, x.OriginalName, x.ExternalKey)).ToList();
private static bool IsUntisError(Exception exception) => exception is WebUntisException
or WebUntisConfigurationException or InvalidDataException;
private static WebUntisIntegrationException Translate(Exception exception) =>
new(exception.Message);
public async ValueTask DisposeAsync() => await DisconnectAsync().ConfigureAwait(false);
}
@@ -9,8 +9,14 @@ internal class WebUntisSettingsConfig
public string? EncryptedIcalUrl { get; set; }
public DateTime? LastSyncAt { get; set; }
public string LastSyncStatus { get; set; } = "";
public string? EncryptedApiCredentials { get; set; }
public int? TeacherUntisId { get; set; }
public int? HomeroomClassUntisId { get; set; }
public string? HomeroomClassName { get; set; }
}
public sealed record WebUntisCredentials(string School, string Host, string Username, string Password);
/// <summary>
/// Einstellungen für den WebUntis-iCal-Abgleich (Nutzer-Feedback, siehe TODO.md). Liegt wie
/// AiSettingsService/SyncSettingsService in LehrerApp.Desktop statt LehrerApp.Core, da die
@@ -30,6 +36,10 @@ public class WebUntisSettingsService
public bool Enabled => _config.Enabled;
public bool IsConfigured => _config.EncryptedIcalUrl is not null;
public bool ApiIsConfigured => _config.EncryptedApiCredentials is not null;
public int? TeacherUntisId => _config.TeacherUntisId;
public int? HomeroomClassUntisId => _config.HomeroomClassUntisId;
public string? HomeroomClassName => _config.HomeroomClassName;
public DateTime? LastSyncAt => _config.LastSyncAt;
public string LastSyncStatus => _config.LastSyncStatus;
@@ -63,6 +73,41 @@ public class WebUntisSettingsService
Save();
}
public void SetApiCredentials(WebUntisCredentials credentials)
{
_config.EncryptedApiCredentials = SyncCrypto.EncryptObject(credentials, _urlKey);
Save();
}
public WebUntisCredentials? GetApiCredentials() => _config.EncryptedApiCredentials is null
? null
: SyncCrypto.DecryptObject<WebUntisCredentials>(_config.EncryptedApiCredentials, _urlKey);
public void ClearApiCredentials()
{
_config.EncryptedApiCredentials = null;
_config.TeacherUntisId = null;
_config.HomeroomClassUntisId = null;
_config.HomeroomClassName = null;
Save();
}
public void SetTeacherUntisId(int? teacherUntisId)
{
_config.TeacherUntisId = teacherUntisId;
Save();
}
// Die Klasse, deren Klassenlehrer/-in man ist - unabhängig von jeder LearningGroup (siehe
// TODO.md "Klassenlehrer"-Feature): man ist es für die ganze Klasse, nicht für einen einzelnen
// Unterricht/Kurs. HomeroomClassName ist wie TeacherUntisId unverschlüsselt, da kein Geheimnis.
public void SetHomeroomClass(int? untisId, string? name)
{
_config.HomeroomClassUntisId = untisId;
_config.HomeroomClassName = name;
Save();
}
public void SetLastSync(DateTime at, string status)
{
_config.LastSyncAt = at;
@@ -0,0 +1,74 @@
<ResourceDictionary xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
<!--
Semantische Pinsel für Flächen und Statusfarben (Klassenlehrer-Bereich, perspektivisch auch
die übrigen Ansichten). Hart codierte Hex-Werte in den Views waren auf das dunkle Theme
zugeschnitten und blieben beim Umschalten auf "Hell" stehen — dunkle Kartenflächen auf hellem
Fenstergrund, Statustexte ohne Kontrast. Über ThemeDictionaries tauscht Avalonia die Werte beim
Theme-Wechsel live aus, sofern die Views sie per DynamicResource (nicht StaticResource) holen.
-->
<ResourceDictionary.ThemeDictionaries>
<ResourceDictionary x:Key="Dark">
<!-- Flächen -->
<SolidColorBrush x:Key="AppCardBackgroundBrush" Color="#11151B"/>
<SolidColorBrush x:Key="AppCardBorderBrush" Color="#2A3038"/>
<SolidColorBrush x:Key="AppListRowBackgroundBrush" Color="#0E1217"/>
<SolidColorBrush x:Key="AppListRowHoverBrush" Color="#171D25"/>
<SolidColorBrush x:Key="AppListRowBorderBrush" Color="#262C34"/>
<SolidColorBrush x:Key="AppTrackBackgroundBrush" Color="#252B33"/>
<SolidColorBrush x:Key="AppChipBackgroundBrush" Color="#1C222A"/>
<SolidColorBrush x:Key="AppAvatarBorderBrush" Color="#4A515C"/>
<!-- Akzent -->
<SolidColorBrush x:Key="AppAccentTextBrush" Color="#5795F5"/>
<SolidColorBrush x:Key="AppAccentSoftBackgroundBrush" Color="#173765"/>
<SolidColorBrush x:Key="AppAccentOnSoftBrush" Color="#66A3FF"/>
<SolidColorBrush x:Key="AppFilterBorderBrush" Color="#343B45"/>
<SolidColorBrush x:Key="AppFilterActiveBackgroundBrush" Color="#173765"/>
<SolidColorBrush x:Key="AppFilterActiveBorderBrush" Color="#3C86F7"/>
<SolidColorBrush x:Key="AppFilterActiveForegroundBrush" Color="#FFFFFF"/>
<!-- Status: Nutzer-Feedback (August 2026) — Warnung (Amber) und Gefahr (Korallrot) waren
auf dem Bildschirm kaum auseinanderzuhalten. Per scripts/validate_palette.js der
dataviz-Skill gegen AppTrackBackgroundBrush/AppCardBackgroundBrush geprüft (adjacent
pairs, wie sie im gestapelten Balken tatsächlich aneinanderstoßen): die alte Kombi
lag bei ΔE nur 4.610.8 (protan/deutan), jetzt 10.224.4 — deutlich über der
Zielschwelle von 8. Ok/Warnung mussten zusätzlich im Tagesüberblick-Balken die
Reihenfolge tauschen (Info dazwischen), weil Grün und das validierte Dunkel-Gelb sonst
direkt aneinanderstießen (ΔE nur 3.0 unter Protanopie) — siehe
ClassTeacherOverviewView.axaml.
good=#008300, warning(dunkel)=#c98500, warning(hell)=#eda100, critical=#d03b3b
sind dieselben Stufen wie die validierte Kategorial-/Statuspalette der dataviz-Skill;
bewusst nicht wieder frei gewählt, um nicht erneut ins selbe Problem zu laufen. -->
<SolidColorBrush x:Key="AppStatusOkBrush" Color="#008300"/>
<SolidColorBrush x:Key="AppStatusInfoBrush" Color="#3987E5"/>
<SolidColorBrush x:Key="AppStatusWarningBrush" Color="#C98500"/>
<SolidColorBrush x:Key="AppStatusDangerBrush" Color="#D03B3B"/>
</ResourceDictionary>
<ResourceDictionary x:Key="Light">
<!-- Flächen: Karten heben sich hier heller vom grauen Fenstergrund ab, nicht dunkler. -->
<SolidColorBrush x:Key="AppCardBackgroundBrush" Color="#FFFFFF"/>
<SolidColorBrush x:Key="AppCardBorderBrush" Color="#DCE1E8"/>
<SolidColorBrush x:Key="AppListRowBackgroundBrush" Color="#FFFFFF"/>
<SolidColorBrush x:Key="AppListRowHoverBrush" Color="#EDF2F9"/>
<SolidColorBrush x:Key="AppListRowBorderBrush" Color="#E3E7ED"/>
<SolidColorBrush x:Key="AppTrackBackgroundBrush" Color="#E3E7ED"/>
<SolidColorBrush x:Key="AppChipBackgroundBrush" Color="#EDF0F5"/>
<SolidColorBrush x:Key="AppAvatarBorderBrush" Color="#B7BEC9"/>
<!-- Akzent: dunklere Töne, die Akzentfarben aus dem Dark-Theme wären auf Weiß zu blass. -->
<SolidColorBrush x:Key="AppAccentTextBrush" Color="#1B5FC7"/>
<SolidColorBrush x:Key="AppAccentSoftBackgroundBrush" Color="#DCE9FC"/>
<SolidColorBrush x:Key="AppAccentOnSoftBrush" Color="#14509F"/>
<SolidColorBrush x:Key="AppFilterBorderBrush" Color="#C7CDD6"/>
<SolidColorBrush x:Key="AppFilterActiveBackgroundBrush" Color="#D7E6FC"/>
<SolidColorBrush x:Key="AppFilterActiveBorderBrush" Color="#2C6FD8"/>
<SolidColorBrush x:Key="AppFilterActiveForegroundBrush" Color="#10243F"/>
<!-- Status: siehe Dark-Kommentar oben — dieselbe Validierung, helle Stufen. -->
<SolidColorBrush x:Key="AppStatusOkBrush" Color="#008300"/>
<SolidColorBrush x:Key="AppStatusInfoBrush" Color="#2A78D6"/>
<SolidColorBrush x:Key="AppStatusWarningBrush" Color="#EDA100"/>
<SolidColorBrush x:Key="AppStatusDangerBrush" Color="#D03B3B"/>
</ResourceDictionary>
</ResourceDictionary.ThemeDictionaries>
</ResourceDictionary>
@@ -0,0 +1,252 @@
using System.Collections.ObjectModel;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using LehrerApp.Desktop.Services;
namespace LehrerApp.Desktop.ViewModels.ClassTeacher;
/// <summary>Eine Zeile aus dem "-alle-"-Klassenbuchbericht, die eine andere Lehrkraft angelegt hat.
/// Rein zur Ansicht - anders als beim Dokumentations-Abgleich (eigene Einträge) gibt es hier kein
/// "Übernehmen", da es keine eigenen Einträge zum lokalen Synchronisieren sind.</summary>
public sealed record ClassTeacherClassRegisterRow(DateOnly Date, string? Subject, string StudentName,
string? TeacherUsername, string? CategoryName, string? CategoryGroup, string? Text)
{
public string DateLabel => Date.ToString("dd.MM.yyyy");
public string StudentDisplayName => DisplayName(StudentName);
public ClassTeacherStatusKind CategoryKind =>
CategoryGroup?.Contains("Negativ", StringComparison.OrdinalIgnoreCase) == true
? ClassTeacherStatusKind.Danger : ClassTeacherStatusKind.Info;
public bool IsDangerStatus => CategoryKind == ClassTeacherStatusKind.Danger;
public bool IsInfoStatus => CategoryKind == ClassTeacherStatusKind.Info;
private static string DisplayName(string value)
{
var parts = value.Split(' ', StringSplitOptions.RemoveEmptyEntries);
return parts.Length < 2 ? value : string.Join(" ", parts.Skip(1).Append(parts[0]));
}
}
/// <summary>Zusammenfassung "wie oft welche Klassenbuch-Kategorie im Zeitraum, und bei wem am
/// häufigsten" — Nutzer-Feedback: die reine Gesamtzahl ("12 Klassenbucheinträge") sagt wenig,
/// eine Aufschlüsselung nach Kategorie ist die eigentlich interessante Information ("Hausaufgaben
/// fehlen: 12×, davon 5× Ben"). Rein clientseitig aus den bereits geladenen <see cref="Entries"/>
/// berechnet, kein zusätzlicher WebUntis-Abruf.</summary>
public sealed record ClassTeacherCategoryAggregateRow(string CategoryName, int Count, string TopStudentsLabel)
{
public string SummaryLabel => string.IsNullOrEmpty(TopStudentsLabel)
? $"{Count}×" : $"{Count}× — {TopStudentsLabel}";
public static IReadOnlyList<ClassTeacherCategoryAggregateRow> Build(
IReadOnlyList<ClassTeacherClassRegisterRow> entries, int topStudentsPerCategory = 2) =>
entries.Where(e => !string.IsNullOrWhiteSpace(e.CategoryName))
.GroupBy(e => e.CategoryName!)
.Select(g =>
{
var topStudents = g.GroupBy(e => e.StudentDisplayName)
.Select(sg => (Name: sg.Key, Count: sg.Count()))
.OrderByDescending(sg => sg.Count).ThenBy(sg => sg.Name)
.Take(topStudentsPerCategory)
.Select(sg => $"{sg.Count}× {sg.Name}");
return new ClassTeacherCategoryAggregateRow(g.Key, g.Count(), string.Join(", ", topStudents));
})
.OrderByDescending(r => r.Count).ThenBy(r => r.CategoryName)
.ToList();
}
/// <summary>Fehlzeiten eines/einer Schüler*in an einem Tag, über alle Fächer zusammengefasst -
/// der WebUntis-Bericht liefert eine Zeile pro Fehlstunde, was bei einer ganzen Klasse über mehrere
/// Wochen zu unübersichtlich wäre (Nutzer-Feedback: kompakte "auf einen Blick"-Übersicht statt
/// Rohdaten). Die Rohzeilen (<see cref="UntisClassAbsenceEntryDto"/>) bleiben trotzdem erreichbar,
/// falls später ein Detail-Drill-down pro Schüler*in dazukommt (siehe TODO.md).</summary>
public sealed record ClassAbsenceDaySummaryRow(DateOnly Date, string StudentName, int? ExternKey,
int TotalAbsentPeriods, int TotalAbsentMinutes, IReadOnlyList<string> Subjects,
IReadOnlyList<int> PeriodNumbers, IReadOnlyList<string> Statuses, IReadOnlyList<string> AbsenceReasons,
string? Note, string? ExcuseNote, bool CountsAsFullDay)
{
public string DateLabel => Date.ToString("dd.MM.yyyy");
public string StudentDisplayName
{
get
{
var parts = StudentName.Split(' ', StringSplitOptions.RemoveEmptyEntries);
return parts.Length < 2 ? StudentName : string.Join(" ", parts.Skip(1).Append(parts[0]));
}
}
public string SubjectsLabel => string.Join(", ", Subjects);
public string PeriodsLabel => string.Join(", ", PeriodNumbers.Order());
public string StatusLabel => string.Join(", ", Statuses);
public string ReasonLabel => string.Join("; ", AbsenceReasons);
public bool IsLate => TotalAbsentPeriods == 0 || AbsenceReasons.Any(r =>
r.Contains("verspät", StringComparison.OrdinalIgnoreCase));
public bool IsUnexcused => Statuses.Any(s =>
s.Contains("nicht entsch", StringComparison.OrdinalIgnoreCase));
public string KindLabel => IsLate ? $"{TotalAbsentMinutes} Min. verspätet" :
CountsAsFullDay ? "Ganzer Fehltag" : $"{TotalAbsentPeriods} Fehlstunde{(TotalAbsentPeriods == 1 ? "" : "n")}";
public string FriendlyStatusLabel => IsUnexcused ? "Unentschuldigt" :
Statuses.Any(s => s.Contains("entsch", StringComparison.OrdinalIgnoreCase)) ? "Entschuldigt" : StatusLabel;
public ClassTeacherStatusKind StatusKind => IsUnexcused ? ClassTeacherStatusKind.Danger :
IsLate ? ClassTeacherStatusKind.Warning : ClassTeacherStatusKind.Ok;
public bool IsDangerStatus => StatusKind == ClassTeacherStatusKind.Danger;
public bool IsWarningStatus => StatusKind == ClassTeacherStatusKind.Warning;
public string DetailLabel => string.Join(" · ", new[] { ReasonLabel, Note, ExcuseNote }
.Where(value => !string.IsNullOrWhiteSpace(value)));
public static IReadOnlyList<ClassAbsenceDaySummaryRow> GroupByStudentAndDay(
IEnumerable<UntisClassAbsenceEntryDto> entries) => entries
.Select(e => (Entry: e, Date: TryDate(e.Date, out var d) ? d : (DateOnly?)null))
.Where(x => x.Date is not null)
.GroupBy(x => (Date: x.Date!.Value, x.Entry.StudentName, x.Entry.ExternKey))
.Select(g => new ClassAbsenceDaySummaryRow(
g.Key.Date, g.Key.StudentName, g.Key.ExternKey,
g.Sum(x => x.Entry.AbsentPeriods), g.Sum(x => x.Entry.AbsentMinutes),
Distinct(g.Select(x => x.Entry.Subject)),
g.Select(x => x.Entry.PeriodNumber).Where(p => p is not null).Select(p => p!.Value).Distinct().ToList(),
Distinct(g.Select(x => x.Entry.Status)),
Distinct(g.Select(x => x.Entry.AbsenceReason)),
g.Select(x => x.Entry.Note).FirstOrDefault(n => !string.IsNullOrWhiteSpace(n)),
g.Select(x => x.Entry.ExcuseNote).FirstOrDefault(n => !string.IsNullOrWhiteSpace(n)),
g.Any(x => x.Entry.CountsAsFullDay)))
.OrderByDescending(r => r.Date).ThenBy(r => r.StudentName)
.ToList();
private static List<string> Distinct(IEnumerable<string?> values) =>
values.Where(v => !string.IsNullOrWhiteSpace(v)).Select(v => v!).Distinct().ToList();
private static bool TryDate(int value, out DateOnly date) =>
DateOnly.TryParseExact(value.ToString(), "yyyyMMdd", out date);
}
/// <summary>
/// Details-Ansicht des Klassenlehrer-Bereichs: Klassenbucheinträge, die andere Lehrkräfte zu
/// Schülern der Klasse angelegt haben (WebUntis "-alle-"-Bericht, gefiltert auf fremde statt der
/// eigenen Benutzerkennung wie beim bestehenden Dokumentations-Abgleich), sowie die Fehlzeiten der
/// Klasse über alle Fächer — beide über <see cref="UntisReportCacheService"/> (lokal gecacht statt
/// bei jedem Öffnen neu abgerufen, siehe TODO.md). Beides rein zur Ansicht, kein Übernehmen in
/// lokale Daten. Bewusst unabhängig von jeder <see cref="Core.Models.LearningGroup"/> —
/// Klassenlehrer ist man für die ganze Klasse, nicht für einen einzelnen Unterricht; die Klasse
/// kommt aus <see cref="ClassTeacherOverviewViewModel"/> (letztlich aus den WebUntis-Einstellungen),
/// nicht aus einer Gruppen-ID.
/// </summary>
public partial class ClassTeacherDetailsViewModel : ObservableObject
{
private readonly UntisReportCacheService _cache;
private string _className = "";
public ObservableCollection<ClassTeacherClassRegisterRow> Entries { get; } = [];
public ObservableCollection<ClassAbsenceDaySummaryRow> AbsenceEntries { get; } = [];
public ObservableCollection<ClassTeacherCategoryAggregateRow> CategoryAggregates { get; } = [];
[ObservableProperty] private DateTimeOffset _startDate = DateTimeOffset.Now.AddDays(-6);
[ObservableProperty] private DateTimeOffset _endDate = DateTimeOffset.Now;
[ObservableProperty] private string _status = "Zeitraum wählen und laden.";
[ObservableProperty] private bool _busy;
/// Von der Übersicht gesetzt (Klick auf eine Roster-Zeile) - leer zeigt alle Schüler*innen.
[ObservableProperty] private string _studentFilter = "";
[ObservableProperty] private int _quickRangeIndex = 1;
public bool HasEntries => Entries.Count > 0;
public bool HasAbsenceEntries => AbsenceEntries.Count > 0;
public bool HasCategoryAggregates => CategoryAggregates.Count > 0;
public string ActiveFilterLabel => string.IsNullOrWhiteSpace(StudentFilter)
? "Alle Schüler*innen" : StudentFilter;
public ClassTeacherDetailsViewModel(UntisReportCacheService cache)
{
_cache = cache;
}
public void Initialize(string className)
{
_className = className;
StudentFilter = "";
Entries.Clear();
AbsenceEntries.Clear();
CategoryAggregates.Clear();
Status = "Zeitraum wählen und laden.";
NotifyListState();
}
partial void OnStudentFilterChanged(string value) => OnPropertyChanged(nameof(ActiveFilterLabel));
partial void OnQuickRangeIndexChanged(int value)
{
var days = value switch { 0 => 0, 1 => 6, 2 => 29, _ => 6 };
EndDate = DateTimeOffset.Now;
StartDate = DateTimeOffset.Now.AddDays(-days);
}
[RelayCommand]
private Task Load() => LoadInternal(forceRefresh: false);
/// Umgeht bewusst die Stunden-Sperre von <see cref="UntisReportCacheService"/> — für den Fall,
/// dass man sicher weiß, dass sich seit dem letzten automatischen Abruf etwas geändert hat.
[RelayCommand]
private Task Refresh() => LoadInternal(forceRefresh: true);
[RelayCommand]
private Task ApplyFilter() => LoadInternal(forceRefresh: false);
[RelayCommand]
private async Task ClearStudentFilter()
{
StudentFilter = "";
await LoadInternal(forceRefresh: false);
}
private async Task LoadInternal(bool forceRefresh)
{
var start = DateOnly.FromDateTime(StartDate.LocalDateTime);
var end = DateOnly.FromDateTime(EndDate.LocalDateTime);
if (end < start) { Status = "Das Enddatum darf nicht vor dem Startdatum liegen."; return; }
if (string.IsNullOrWhiteSpace(_className)) { Status = "Keine Klasse ausgewählt."; return; }
Busy = true; Entries.Clear(); AbsenceEntries.Clear(); CategoryAggregates.Clear(); NotifyListState();
try
{
var classRegisterTask = _cache.GetClassRegisterEventsAsync(_className, start, end, forceRefresh);
var absencesTask = _cache.GetAbsencesAsync(_className, start, end, forceRefresh);
await Task.WhenAll(classRegisterTask, absencesTask);
var ordered = classRegisterTask.Result
.Where(MatchesStudentFilter)
.Select(e => (Entry: e, Date: TryDate(e.Date, out var d) ? d : (DateOnly?)null))
.Where(x => x.Date is not null)
.OrderByDescending(x => x.Date).ThenBy(x => x.Entry.StudentName);
foreach (var (entry, date) in ordered)
{
Entries.Add(new ClassTeacherClassRegisterRow(date!.Value, entry.Subject, entry.StudentName,
entry.TeacherUsername, entry.CategoryName, entry.CategoryGroup, entry.Text));
}
foreach (var row in ClassTeacherCategoryAggregateRow.Build(Entries)) CategoryAggregates.Add(row);
var absences = absencesTask.Result.Where(MatchesStudentFilter);
foreach (var row in ClassAbsenceDaySummaryRow.GroupByStudentAndDay(absences))
AbsenceEntries.Add(row);
Status = $"{Entries.Count} Klassenbucheinträge anderer Lehrkräfte, " +
$"{AbsenceEntries.Count} Fehlzeiten-Tage im Zeitraum.";
NotifyListState();
}
catch (WebUntisIntegrationException ex) { Status = ex.Message; }
finally { Busy = false; NotifyListState(); }
}
// WebUntis liefert Namen je nach Bericht in anderer Reihenfolge als der Schülerreport, aus dem
// StudentFilter beim Klick in der Übersicht gesetzt wird (siehe UntisNameMatching) - ein
// exakter String-Vergleich hier ließ die gefilterten Listen fälschlich leer erscheinen.
private bool MatchesStudentFilter(UntisForeignClassRegisterEventDto entry) =>
string.IsNullOrWhiteSpace(StudentFilter) || UntisNameMatching.NamesMatch(entry.StudentName, StudentFilter);
private bool MatchesStudentFilter(UntisClassAbsenceEntryDto entry) =>
string.IsNullOrWhiteSpace(StudentFilter) || UntisNameMatching.NamesMatch(entry.StudentName, StudentFilter);
private static bool TryDate(int value, out DateOnly date) =>
DateOnly.TryParseExact(value.ToString(), "yyyyMMdd", out date);
private void NotifyListState()
{
OnPropertyChanged(nameof(HasEntries));
OnPropertyChanged(nameof(HasAbsenceEntries));
OnPropertyChanged(nameof(HasCategoryAggregates));
}
}
@@ -0,0 +1,670 @@
using System.Collections.ObjectModel;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using LehrerApp.Core.Interfaces;
using LehrerApp.Core.Models;
using LehrerApp.Core.Services;
using LehrerApp.Desktop.Services;
namespace LehrerApp.Desktop.ViewModels.ClassTeacher;
/// <summary>Semantische Statusstufe einer Zeile bzw. eines Hinweises. Bewusst ohne Farbwert im
/// ViewModel: Die Zuordnung zu einem Pinsel passiert über Style-Klassen in XAML
/// (<c>Styles/SemanticBrushes.axaml</c>), damit ein Theme-Wechsel hell/dunkel sie live mitnimmt —
/// ein hart codierter Hex-String aus dem ViewModel bliebe beim Wechsel unverändert stehen.</summary>
public enum ClassTeacherStatusKind { Ok, Info, Warning, Danger }
public sealed record ClassTeacherRosterRow(string StudentName, int? ExternKey, bool HasAbsenceToday,
string? AbsenceTooltip, bool HasRecentClassRegisterEntry)
{
public ClassAbsenceDaySummaryRow? TodayAbsence { get; init; }
public bool HasClassRegisterToday { get; init; }
public bool IsLate => TodayAbsence is { } a &&
(a.TotalAbsentPeriods == 0 || a.AbsenceReasons.Any(IsLateReason));
public bool IsUnexcused => TodayAbsence?.Statuses.Any(s =>
s.Contains("nicht entsch", StringComparison.OrdinalIgnoreCase)) == true;
public bool NeedsAttention => HasAbsenceToday || HasRecentClassRegisterEntry;
public int AttentionRank => IsUnexcused ? 0 : IsLate ? 1 : HasAbsenceToday ? 2 :
HasRecentClassRegisterEntry ? 3 : 4;
public string Initials
{
get
{
var parts = StudentName.Split(' ', StringSplitOptions.RemoveEmptyEntries);
return string.Concat(parts.Take(2).Select(p => char.ToUpperInvariant(p[0])));
}
}
public string StatusText
{
get
{
if (TodayAbsence is null)
return HasRecentClassRegisterEntry ? "Neuer Klassenbucheintrag" : "Keine gemeldete Fehlzeit";
var core = IsLate
? $"{TodayAbsence.TotalAbsentMinutes} Min. verspätet"
: TodayAbsence.CountsAsFullDay
? "Ganzer Fehltag"
: $"{TodayAbsence.TotalAbsentPeriods} Fehlstunde{(TodayAbsence.TotalAbsentPeriods == 1 ? "" : "n")}";
return IsUnexcused ? $"{core} · Unentschuldigt" : core;
}
}
public ClassTeacherStatusKind StatusKind => IsUnexcused ? ClassTeacherStatusKind.Danger :
HasAbsenceToday ? ClassTeacherStatusKind.Warning :
HasRecentClassRegisterEntry ? ClassTeacherStatusKind.Info : ClassTeacherStatusKind.Ok;
public bool IsDangerStatus => StatusKind == ClassTeacherStatusKind.Danger;
public bool IsWarningStatus => StatusKind == ClassTeacherStatusKind.Warning;
public bool IsInfoStatus => StatusKind == ClassTeacherStatusKind.Info;
public string ClassRegisterLabel => HasClassRegisterToday ? "Eintrag heute" :
HasRecentClassRegisterEntry ? "Eintrag diese Woche" : "";
/// Quick-Win Barrierefreiheit: der Status wurde bislang ausschließlich über die Farbe des
/// linken Balkens/Texts transportiert. Ein kleines vorangestelltes Symbol macht Warnung/Gefahr
/// auch ohne verlässliche Farbunterscheidung erkennbar (und beim schnellen Scannen der Liste).
public string StatusTextWithGlyph => StatusKind switch
{
ClassTeacherStatusKind.Danger => $"✕ {StatusText}",
ClassTeacherStatusKind.Warning => $"△ {StatusText}",
_ => StatusText,
};
/// Kumulierte Fehlzeiten seit Schuljahresbeginn (Nutzer-Feedback: der Heute-Snapshot allein
/// sagt für Zeugnis/Attestpflicht wenig aus). <see cref="SchoolDaysElapsed"/> zählt nur
/// Werktage, ohne Ferienkalender — eine bewusste Vereinfachung, siehe TODO.md 12.4-Nachtrag.
public int YearAbsenceDayCount { get; init; }
public int YearUnexcusedDayCount { get; init; }
public int SchoolDaysElapsed { get; init; }
public bool HasYearSummary => SchoolDaysElapsed > 0 && YearAbsenceDayCount > 0;
public int YearAbsenceRatePercent =>
SchoolDaysElapsed <= 0 ? 0 : (int)Math.Round(100d * YearAbsenceDayCount / SchoolDaysElapsed);
public string YearSummaryLabel => HasYearSummary
? $"{YearAbsenceRatePercent} % Fehlzeit seit Schuljahresbeginn" : "";
public string? YearSummaryTooltip => !HasYearSummary ? null :
$"{YearAbsenceDayCount} von {SchoolDaysElapsed} Schultagen mit Fehlzeit" +
(YearUnexcusedDayCount > 0 ? $" · {YearUnexcusedDayCount} unentschuldigt" : "");
private static bool IsLateReason(string reason) =>
reason.Contains("verspät", StringComparison.OrdinalIgnoreCase);
public static IReadOnlyList<ClassTeacherRosterRow> Build(
IReadOnlyList<UntisStudentRosterCacheEntry> students,
IReadOnlyList<ClassAbsenceDaySummaryRow> todayAbsences,
IReadOnlyList<UntisForeignClassRegisterEventDto> recentClassRegisterEntries,
DateOnly? today = null,
IReadOnlyList<ClassAbsenceDaySummaryRow>? yearAbsences = null,
int schoolDaysElapsed = 0)
{
var referenceDate = today ?? todayAbsences.FirstOrDefault()?.Date ?? DateOnly.FromDateTime(DateTime.Today);
var absenceByKey = todayAbsences.Where(a => a.ExternKey is not null)
.GroupBy(a => a.ExternKey!.Value).ToDictionary(g => g.Key, g => g.First());
var absenceByName = todayAbsences.GroupBy(a => UntisNameMatching.NameKey(a.StudentName))
.ToDictionary(g => g.Key, g => g.First());
var recentByName = recentClassRegisterEntries.GroupBy(e => UntisNameMatching.NameKey(e.StudentName))
.ToDictionary(g => g.Key, g => g.ToList());
var yearRows = yearAbsences ?? [];
var yearByKey = yearRows.Where(a => a.ExternKey is not null)
.GroupBy(a => a.ExternKey!.Value).ToDictionary(g => g.Key, g => g.ToList());
var yearByName = yearRows.GroupBy(a => UntisNameMatching.NameKey(a.StudentName))
.ToDictionary(g => g.Key, g => g.ToList());
return students.Select(student =>
{
var nameKey = UntisNameMatching.NameKey(student.DisplayName);
var absence = (student.ExternKey is { } key ? absenceByKey.GetValueOrDefault(key) : null)
?? absenceByName.GetValueOrDefault(nameKey);
var registerEntries = recentByName.GetValueOrDefault(nameKey) ?? [];
var yearEntries = (student.ExternKey is { } yearKey ? yearByKey.GetValueOrDefault(yearKey) : null)
?? yearByName.GetValueOrDefault(nameKey) ?? [];
return new ClassTeacherRosterRow(student.DisplayName, student.ExternKey, absence is not null,
absence is null ? null : $"{absence.TotalAbsentPeriods} Stunde(n) — {absence.StatusLabel}",
registerEntries.Count > 0)
{
TodayAbsence = absence,
HasClassRegisterToday = registerEntries.Any(e => TryDate(e.Date, out var date) && date == referenceDate),
YearAbsenceDayCount = yearEntries.Count,
YearUnexcusedDayCount = yearEntries.Count(r => r.IsUnexcused),
SchoolDaysElapsed = schoolDaysElapsed,
};
})
.OrderBy(r => r.AttentionRank).ThenBy(r => r.StudentName).ToList();
}
private static bool TryDate(int value, out DateOnly date) =>
DateOnly.TryParseExact(value.ToString(), "yyyyMMdd", out date);
}
/// Ein gestapelter Tages-Balken statt drei unabhängig skalierter (Nutzer-Feedback: die vorherige
/// Version zeigte Alerts/Unentschuldigt/Verspätet als drei separate, gleich hohe Balken, obwohl
/// Unentschuldigt und Verspätet Teilmengen von Alerts sind — das suggerierte fälschlich drei
/// unabhängige Größen). Die drei Segmente sind überschneidungsfrei (Unentschuldigt hat Vorrang
/// vor Verspätet, wie bei <see cref="ClassTeacherRosterRow.AttentionRank"/>).
public sealed record ClassTeacherTrendDay(string DayLabel, int AlertCount, int UnexcusedCount,
int LateExcusedCount, int ExcusedCount, double UnexcusedFraction, double LateExcusedFraction,
double ExcusedFraction)
{
/// Anteile 0…1 des höchsten Tages der letzten 7 Schultage statt fester Pixelbreiten
/// (Quick-Win, Nutzer-Feedback: die Balkenbreite hing bisher an einer im ViewModel hart
/// codierten Pixelkonstante, die mit der tatsächlichen Breite der Seitenspalte in XAML
/// synchron gehalten werden musste). Die View multipliziert diese Anteile per MultiBinding
/// (<see cref="LehrerApp.Desktop.Converters.FractionWidthConverter"/>) mit der tatsächlich gerenderten Breite
/// der Track-Leiste — bleibt damit auch dann korrekt, wenn die Seitenspalte mal eine andere
/// Breite bekommt. <c>Grid.ColumnDefinitions</c> ließ sich dafür nicht binden (kein Setter bei
/// kompilierten Bindings, AVLN3000), deshalb Pixelbreite statt Grid-Sternspalten.
public double TotalFraction => UnexcusedFraction + LateExcusedFraction + ExcusedFraction;
public string DetailTooltip =>
$"{UnexcusedCount} unentschuldigt · {LateExcusedCount} verspätet · {ExcusedCount} entschuldigt";
}
public sealed record ClassTeacherPatternNotice(string StudentName, string Message,
ClassTeacherStatusKind Kind)
{
public bool IsDangerStatus => Kind == ClassTeacherStatusKind.Danger;
public bool IsWarningStatus => Kind == ClassTeacherStatusKind.Warning;
public bool IsInfoStatus => Kind == ClassTeacherStatusKind.Info;
}
/// <summary>Ein Fehltag, der aktuell (Stand letztem Abruf) als unentschuldigt geführt wird —
/// Nutzer-Feedback: die Übersicht zeigte bislang nur den Heute-Snapshot und einen 7-Tage-Trend,
/// aber keine Liste konkreter offener Fälle, die man abarbeiten könnte. Läuft über die ohnehin
/// für die Jahresfehlquote geladenen Daten (<see cref="ClassTeacherOverviewViewModel.Load"/>),
/// kein zusätzlicher Abruf.</summary>
public sealed record ClassTeacherOpenExcuseRow(string StudentName, DateOnly Date, int DaysOpen)
{
/// Verbreitete Regelung in vielen Bundesländern: Entschuldigung "innerhalb von drei Tagen"
/// nach der Fehlzeit (z.B. § 43 Abs. 2 SchulG NRW). Nur ein Richtwert für die optische
/// Hervorhebung hier — WebUntis liefert keine schulspezifische Frist, und die tatsächliche
/// Regelung kann je nach Bundesland/Schulordnung abweichen.
public const int DeadlineDays = 3;
public string DateLabel => Date.ToString("dd.MM.");
public bool IsOverdue => DaysOpen > DeadlineDays;
public string DaysOpenLabel => $"seit {DaysOpen} Tag{(DaysOpen == 1 ? "" : "en")} unentschuldigt";
public static IReadOnlyList<ClassTeacherOpenExcuseRow> Build(
IReadOnlyList<ClassAbsenceDaySummaryRow> absenceDays, DateOnly today) =>
absenceDays.Where(a => a.IsUnexcused)
.Select(a => new ClassTeacherOpenExcuseRow(a.StudentDisplayName, a.Date,
today.DayNumber - a.Date.DayNumber))
.OrderByDescending(r => r.DaysOpen).ThenBy(r => r.StudentName)
.ToList();
}
public partial class ClassTeacherOverviewViewModel : ObservableObject
{
private readonly WebUntisSettingsService _settings;
private readonly UntisReportCacheService _cache;
private readonly SchoolYearService _schoolYear;
private readonly IWorkTaskRepository _workTasks;
private readonly IStudentRepository _students;
private readonly IParticipationRepository _participation;
private readonly IParticipationSessionRepository _participationSessions;
public ClassTeacherDetailsViewModel DetailsTab { get; }
public ObservableCollection<ClassTeacherRosterRow> Roster { get; } = [];
public ObservableCollection<ClassTeacherRosterRow> PrimaryRoster { get; } = [];
public ObservableCollection<ClassTeacherRosterRow> SecondaryRoster { get; } = [];
public ObservableCollection<ClassTeacherTrendDay> TrendDays { get; } = [];
public ObservableCollection<ClassTeacherPatternNotice> PatternNotices { get; } = [];
public ObservableCollection<ClassTeacherOpenExcuseRow> OpenExcuses { get; } = [];
[ObservableProperty] private string? _homeroomClassName;
[ObservableProperty] private int _activeTabIndex;
[ObservableProperty] private string _status = "";
[ObservableProperty] private bool _busy;
[ObservableProperty] private string _searchText = "";
[ObservableProperty] private int _selectedRosterFilter;
[ObservableProperty] private string _primarySectionTitle = "Heute auffällig";
[ObservableProperty] private string _secondarySectionTitle = "Weitere Schüler*innen";
[ObservableProperty] private int _studentCount;
[ObservableProperty] private int _todayAlertCount;
[ObservableProperty] private int _todayUnexcusedCount;
[ObservableProperty] private int _recentClassRegisterCount;
[ObservableProperty] private int _presentCount;
[ObservableProperty] private int _lateCount;
[ObservableProperty] private int _excusedAbsenceCount;
[ObservableProperty] private int _unexcusedAbsenceCount;
[ObservableProperty] private string _lastUpdatedLabel = "Noch nicht aktualisiert";
[ObservableProperty] private int _openExcuseOverflowCount;
public bool HomeroomClassConfigured => !string.IsNullOrWhiteSpace(HomeroomClassName);
public bool HasPrimaryRoster => PrimaryRoster.Count > 0;
public bool HasSecondaryRoster => SecondaryRoster.Count > 0;
public bool HasNoFilterResults => !Busy && PrimaryRoster.Count == 0 && SecondaryRoster.Count == 0;
public bool HasPatternNotices => PatternNotices.Count > 0;
public bool HasOpenExcuses => OpenExcuses.Count > 0;
public bool HasOpenExcuseOverflow => OpenExcuseOverflowCount > 0;
public bool AlertsFilterSelected => SelectedRosterFilter == 0;
public bool ClassRegisterFilterSelected => SelectedRosterFilter == 1;
public bool AllFilterSelected => SelectedRosterFilter == 2;
public int TodayUnexcusedPercent => Percent(TodayUnexcusedCount);
public int LatePercent => Percent(LateCount);
public int PresentPercent => Percent(PresentCount);
public int ExcusedAbsencePercent => Percent(ExcusedAbsenceCount);
public int UnexcusedAbsencePercent => Percent(UnexcusedAbsenceCount);
/// Quick-Win: <see cref="TodayUnexcusedPercent"/> wurde berechnet, aber nirgends gebunden —
/// jetzt als zweite Zeile in der "unentschuldigt"-Kennzahlkarte sichtbar.
public string TodayUnexcusedSummaryLabel => $"unentschuldigt · {TodayUnexcusedPercent} %";
/// Beschriftungen für den kompakten Tagesüberblick (Quick-Win: ein gestapelter 100-%-Balken
/// statt vier Einzelzeilen mit separater Prozentspalte — Zahl und Anteil jetzt in einer
/// Zeile). Die vier Kategorien sind exklusiv und summieren sich exakt zu StudentCount, siehe
/// die Herleitung von Present-/Late-/Excused-/UnexcusedAbsenceCount in <see cref="Load"/>.
public string PresentSummaryLabel => $"{PresentCount} Anwesend · {PresentPercent} %";
public string LateSummaryLabel => $"{LateCount} Verspätet · {LatePercent} %";
public string ExcusedAbsenceSummaryLabel => $"{ExcusedAbsenceCount} Entschuldigt · {ExcusedAbsencePercent} %";
public string UnexcusedAbsenceSummaryLabel => $"{UnexcusedAbsenceCount} Unentschuldigt · {UnexcusedAbsencePercent} %";
/// Anteile 0…1 für den gestapelten Tagesüberblick-Balken (View multipliziert per MultiBinding
/// mit der gerenderten Track-Breite, siehe <see cref="ClassTeacherTrendDay.TotalFraction"/>
/// für dasselbe Muster beim Trend-Chart). Die vier Kategorien sind exklusiv und summieren sich
/// zu 1, da StudentCount == Present + Late + ExcusedAbsence + UnexcusedAbsence.
public double PresentFraction => StudentCount == 0 ? 0 : (double)PresentCount / StudentCount;
public double LateFraction => StudentCount == 0 ? 0 : (double)LateCount / StudentCount;
public double ExcusedAbsenceFraction => StudentCount == 0 ? 0 : (double)ExcusedAbsenceCount / StudentCount;
public double UnexcusedAbsenceFraction => StudentCount == 0 ? 0 : (double)UnexcusedAbsenceCount / StudentCount;
public string DayOverviewTooltip => $"{PresentCount} anwesend · {LateCount} verspätet · " +
$"{ExcusedAbsenceCount} entschuldigt · {UnexcusedAbsenceCount} unentschuldigt";
public Func<Task>? OnNavigateToSettings { get; set; }
public Func<Task>? OnNavigateToWorkload { get; set; }
public ClassTeacherOverviewViewModel(WebUntisSettingsService settings,
UntisReportCacheService cache, SchoolYearService schoolYear, IWorkTaskRepository workTasks,
IStudentRepository students, IParticipationRepository participation,
IParticipationSessionRepository participationSessions, ClassTeacherDetailsViewModel detailsTab)
{
_settings = settings;
_cache = cache;
_schoolYear = schoolYear;
_workTasks = workTasks;
_students = students;
_participation = participation;
_participationSessions = participationSessions;
DetailsTab = detailsTab;
}
partial void OnHomeroomClassNameChanged(string? value) => OnPropertyChanged(nameof(HomeroomClassConfigured));
partial void OnOpenExcuseOverflowCountChanged(int value) => OnPropertyChanged(nameof(HasOpenExcuseOverflow));
partial void OnSearchTextChanged(string value) => ApplyRosterFilter();
partial void OnSelectedRosterFilterChanged(int value)
{
OnPropertyChanged(nameof(AlertsFilterSelected));
OnPropertyChanged(nameof(ClassRegisterFilterSelected));
OnPropertyChanged(nameof(AllFilterSelected));
ApplyRosterFilter();
}
[RelayCommand]
private async Task Load()
{
HomeroomClassName = _settings.HomeroomClassName;
ActiveTabIndex = 0;
Roster.Clear(); PrimaryRoster.Clear(); SecondaryRoster.Clear(); TrendDays.Clear(); PatternNotices.Clear();
OpenExcuses.Clear(); OpenExcuseOverflowCount = 0;
if (!HomeroomClassConfigured)
{
Status = "Noch keine Klasse ausgewählt.";
NotifyRosterState();
return;
}
var className = HomeroomClassName!;
DetailsTab.Initialize(className);
Busy = true;
NotifyRosterState();
try
{
var today = DateOnly.FromDateTime(DateTime.Today);
var sevenDayStart = today.AddDays(-6);
var trendDays = LastSchoolDays(today, 7);
// Fehlzeiten seit Schuljahresbeginn statt nur der letzten 7 Tage: liefert die
// Kennzahl unten (YearSummaryLabel) und den Nenner für die Fehlquote in einem Abruf.
// UntisReportCacheService cached diesen "kalten" Bereich dauerhaft (siehe dort) —
// teuer ist nur der erste Abruf pro Schuljahr, nicht jedes Öffnen der Ansicht.
var yearStart = _schoolYear.SchoolYearStart(_schoolYear.CurrentSchoolYear(today));
var studentsTask = _cache.GetStudentRosterAsync(className);
var absencesTask = _cache.GetAbsencesAsync(className, yearStart, today);
var classRegisterTask = _cache.GetClassRegisterEventsAsync(className, sevenDayStart, today);
await Task.WhenAll(studentsTask, absencesTask, classRegisterTask);
var absenceDaysYear = ClassAbsenceDaySummaryRow.GroupByStudentAndDay(absencesTask.Result);
var todayAbsences = absenceDaysYear.Where(a => a.Date == today).ToList();
var weekAbsenceDays = absenceDaysYear.Where(a => a.Date >= sevenDayStart).ToList();
var schoolDaysElapsed = CountWeekdays(yearStart, today);
foreach (var row in ClassTeacherRosterRow.Build(studentsTask.Result, todayAbsences,
classRegisterTask.Result, today, absenceDaysYear, schoolDaysElapsed)) Roster.Add(row);
StudentCount = Roster.Count;
TodayAlertCount = Roster.Count(r => r.HasAbsenceToday);
TodayUnexcusedCount = Roster.Count(r => r.IsUnexcused);
LateCount = Roster.Count(r => r.IsLate);
PresentCount = Roster.Count(r => !r.HasAbsenceToday);
ExcusedAbsenceCount = Roster.Count(r => r.HasAbsenceToday && !r.IsLate && !r.IsUnexcused);
UnexcusedAbsenceCount = Roster.Count(r => r.HasAbsenceToday && !r.IsLate && r.IsUnexcused);
RecentClassRegisterCount = classRegisterTask.Result.Count;
BuildTrend(absenceDaysYear, trendDays);
BuildPatternNotices(weekAbsenceDays);
BuildWeekdayPatternNotices(absenceDaysYear);
BuildAttendanceParticipationNotices();
BuildOpenExcuses(absenceDaysYear, today);
LastUpdatedLabel = $"Zuletzt aktualisiert: Heute, {DateTime.Now:HH:mm}";
Status = $"{StudentCount} Schüler*innen · {TodayAlertCount} heute auffällig";
ApplyRosterFilter();
NotifySummary();
}
catch (WebUntisIntegrationException ex) { Status = ex.Message; NotifyRosterState(); }
finally { Busy = false; NotifyRosterState(); }
}
[RelayCommand] private void ShowAlerts() => SelectedRosterFilter = 0;
[RelayCommand] private void ShowClassRegister() => SelectedRosterFilter = 1;
[RelayCommand] private void ShowAll() => SelectedRosterFilter = 2;
[RelayCommand]
private void OpenClassRegister()
{
DetailsTab.StudentFilter = "";
ActiveTabIndex = 1;
DetailsTab.LoadCommand.Execute(null);
}
[RelayCommand]
private void OpenAbsences()
{
DetailsTab.StudentFilter = "";
ActiveTabIndex = 2;
DetailsTab.LoadCommand.Execute(null);
}
[RelayCommand]
private void ShowDetailsForStudent(ClassTeacherRosterRow? row)
{
if (row is null) return;
DetailsTab.StudentFilter = row.StudentName;
ActiveTabIndex = row.HasAbsenceToday ? 2 : 1;
DetailsTab.LoadCommand.Execute(null);
}
[RelayCommand] private async Task GoToSettings()
{ if (OnNavigateToSettings is not null) await OnNavigateToSettings(); }
[RelayCommand] private async Task GoToWorkload()
{ if (OnNavigateToWorkload is not null) await OnNavigateToWorkload(); }
private void ApplyRosterFilter()
{
PrimaryRoster.Clear(); SecondaryRoster.Clear();
var query = Roster.Where(r => string.IsNullOrWhiteSpace(SearchText) ||
r.StudentName.Contains(SearchText.Trim(), StringComparison.CurrentCultureIgnoreCase));
if (SelectedRosterFilter == 0)
{
PrimarySectionTitle = "Heute auffällig";
foreach (var row in query.Where(r => r.HasAbsenceToday)) PrimaryRoster.Add(row);
}
else if (SelectedRosterFilter == 1)
{
PrimarySectionTitle = "Klassenbucheinträge der letzten 7 Tage";
foreach (var row in query.Where(r => r.HasRecentClassRegisterEntry)) PrimaryRoster.Add(row);
}
else
{
PrimarySectionTitle = "Heute auffällig";
SecondarySectionTitle = "Weitere Schüler*innen";
foreach (var row in query.Where(r => r.NeedsAttention)) PrimaryRoster.Add(row);
foreach (var row in query.Where(r => !r.NeedsAttention)) SecondaryRoster.Add(row);
}
NotifyRosterState();
}
/// Letzte <paramref name="days"/> statt letzte 7 Kalendertage (Nutzer-Feedback: an zwei von
/// sieben Tagen war der Trend bislang systematisch leer, weil Wochenenden mitzählten). Ohne
/// Ferienkalender — siehe <see cref="LastSchoolDays"/>.
private void BuildTrend(IReadOnlyList<ClassAbsenceDaySummaryRow> absenceDays, IReadOnlyList<DateOnly> days)
{
var today = days[^1];
var counts = days.Select(date =>
{
var rows = absenceDays.Where(r => r.Date == date).ToList();
var unexcused = rows.Count(r => r.IsUnexcused);
var lateExcused = rows.Count(r => !r.IsUnexcused && r.IsLate);
var excused = rows.Count(r => !r.IsUnexcused && !r.IsLate);
return (Date: date, Unexcused: unexcused, LateExcused: lateExcused, Excused: excused,
Total: unexcused + lateExcused + excused);
}).ToList();
var max = Math.Max(1, counts.Max(c => c.Total));
foreach (var day in counts)
TrendDays.Add(new ClassTeacherTrendDay(day.Date == today ? "Heute" : day.Date.ToString("ddd"),
day.Total, day.Unexcused, day.LateExcused, day.Excused,
(double)day.Unexcused / max, (double)day.LateExcused / max, (double)day.Excused / max));
}
/// Letzte <paramref name="count"/> Werktage bis einschließlich <paramref name="end"/>, ohne
/// Ferienkalender (bewusste Vereinfachung, siehe TODO.md 12.4-Nachtrag) — die App kennt keine
/// Schulferien, nur Wochenenden.
private static IReadOnlyList<DateOnly> LastSchoolDays(DateOnly end, int count)
{
var days = new List<DateOnly>();
for (var cursor = end; days.Count < count; cursor = cursor.AddDays(-1))
if (cursor.DayOfWeek is not (DayOfWeek.Saturday or DayOfWeek.Sunday))
days.Add(cursor);
days.Reverse();
return days;
}
/// Nenner für <see cref="ClassTeacherRosterRow.YearAbsenceRatePercent"/> — Werktage zwischen
/// Schuljahresbeginn und heute, ebenfalls ohne Ferienkalender.
private static int CountWeekdays(DateOnly start, DateOnly end)
{
if (end < start) return 0;
var count = 0;
for (var d = start; d <= end; d = d.AddDays(1))
if (d.DayOfWeek is not (DayOfWeek.Saturday or DayOfWeek.Sunday)) count++;
return count;
}
private void BuildPatternNotices(IReadOnlyList<ClassAbsenceDaySummaryRow> absenceDays)
{
foreach (var group in absenceDays.GroupBy(row => UntisNameMatching.NameKey(row.StudentName)))
{
var rows = group.ToList();
var displayName = Roster.FirstOrDefault(row =>
UntisNameMatching.NameKey(row.StudentName) == group.Key)?.StudentName ?? rows[0].StudentDisplayName;
var lateDays = rows.Count(row => row.IsLate);
var unexcusedDays = rows.Count(row => row.IsUnexcused && !row.IsLate);
if (unexcusedDays >= 2)
PatternNotices.Add(new ClassTeacherPatternNotice(displayName,
$"{unexcusedDays} unentschuldigte Fehltage in 7 Tagen", ClassTeacherStatusKind.Danger));
else if (lateDays >= 2)
PatternNotices.Add(new ClassTeacherPatternNotice(displayName,
$"{lateDays}-mal verspätet in 7 Tagen", ClassTeacherStatusKind.Warning));
}
OnPropertyChanged(nameof(HasPatternNotices));
}
/// Erweiterte Mustererkennung (Nutzer-Feedback): die obigen Regeln schauen nur auf die letzten
/// 7 Tage und nur auf unentschuldigt/verspätet. Ein Wochentags-Schwerpunkt über einen längeren
/// Zeitraum (z.B. "immer montags") ist ein eigenes, oft erst über mehrere Wochen sichtbares
/// Signal — deshalb mit den seit Schuljahresbeginn geladenen Daten, nicht nur den letzten 7
/// Tagen. Wer schon eine Notiz aus <see cref="BuildPatternNotices"/> hat, wird hier
/// ausgelassen, damit die Liste nicht zwei Hinweise für dieselbe Person zeigt.
private void BuildWeekdayPatternNotices(IReadOnlyList<ClassAbsenceDaySummaryRow> absenceDays)
{
var displayNames = Roster.GroupBy(r => UntisNameMatching.NameKey(r.StudentName))
.ToDictionary(g => g.Key, g => g.First().StudentName);
var alreadyNoted = PatternNotices.Select(n => n.StudentName).ToHashSet();
foreach (var notice in DetectWeekdayPatterns(absenceDays, displayNames, alreadyNoted))
PatternNotices.Add(notice);
OnPropertyChanged(nameof(HasPatternNotices));
}
/// Reine, ohne ViewModel-Zustand testbare Kernlogik (gleiches Muster wie
/// <see cref="ClassTeacherRosterRow.Build"/>). <paramref name="minSampleSize"/> verhindert,
/// dass zwei zufällig auf denselben Wochentag fallende Fehltage schon als "Muster" gelten;
/// <paramref name="clusterThreshold"/> verlangt eine deutliche Häufung, nicht nur eine leichte
/// Mehrheit.
public static IReadOnlyList<ClassTeacherPatternNotice> DetectWeekdayPatterns(
IReadOnlyList<ClassAbsenceDaySummaryRow> absenceDays,
IReadOnlyDictionary<string, string> displayNamesByKey,
ISet<string> excludeDisplayNames,
int minSampleSize = 3, double clusterThreshold = 0.6)
{
var notices = new List<ClassTeacherPatternNotice>();
foreach (var group in absenceDays.GroupBy(row => UntisNameMatching.NameKey(row.StudentName)))
{
var rows = group.ToList();
if (rows.Count < minSampleSize) continue;
var displayName = displayNamesByKey.GetValueOrDefault(group.Key) ?? rows[0].StudentDisplayName;
if (excludeDisplayNames.Contains(displayName)) continue;
var top = rows.GroupBy(r => r.Date.DayOfWeek)
.Select(g => (Day: g.Key, Count: g.Count()))
.OrderByDescending(g => g.Count).First();
if ((double)top.Count / rows.Count < clusterThreshold) continue;
notices.Add(new ClassTeacherPatternNotice(displayName,
$"{top.Count} von {rows.Count} Fehltagen an einem {WeekdayLabel(top.Day)}",
ClassTeacherStatusKind.Info));
}
return notices;
}
/// Nutzer-Feedback: "Hohe Fehlquote bei gleichzeitig fallender Mitarbeit ist ein Signal, das
/// WebUntis allein nicht liefert." Verknüpft die ohnehin geladene Jahresfehlquote
/// (<see cref="ClassTeacherRosterRow.YearAbsenceRatePercent"/>) mit dem Mitarbeit-Trend aus
/// dem Dokumentations-/Mitarbeitsmodul (<see cref="IParticipationRepository"/>, gruppen-
/// übergreifend über <c>GetByStudent</c>, gleiches Muster wie die Fehlzeitenbilanz in
/// StudentDetailViewModel). Bewusst zurückhaltend: nur bei bereits spürbar erhöhter Fehlquote
/// UND eindeutig fallendem Trend (erste vs. zweite Hälfte der letzten Bewertungen, gleiche
/// simple Heuristik wie <c>ParticipationGradeRow.ComputeTrend</c>), sonst würde die Liste bei
/// jeder kleinen Schwankung anschlagen. Reine Beobachtung, keine Kausalitätsaussage — deshalb
/// als neutraler Hinweis (Info), nicht als Warnung/Gefahr. Die WebUntis-Rohbewertungen werden
/// hier bewusst NICHT nach Aspekt-Gewichtung normiert (anders als in ParticipationGradeRow):
/// die Aspekt-Definitionen sind je Lerngruppe konfigurierbar, der Klassenlehrer-Bereich kennt
/// aber keine einzelne Gruppe — ein einfacher Rohwert-Durchschnitt über alle Bewertungen ist
/// hier die pragmatischere Näherung als ein Konfigurations-Mismatch zu riskieren.
private const int CorrelationAbsenceRateThreshold = 15;
private void BuildAttendanceParticipationNotices()
{
var students = _students.GetAll();
foreach (var row in Roster.Where(r => r.HasYearSummary &&
r.YearAbsenceRatePercent >= CorrelationAbsenceRateThreshold))
{
if (PatternNotices.Any(n => n.StudentName == row.StudentName)) continue;
var student = MatchStudent(row.StudentName, students);
if (student is null) continue;
var points = _participation.GetByStudent(student.Id)
.Select(e => (Session: _participationSessions.GetById(e.SessionId), Entry: e))
.Where(x => x.Session is not null && x.Entry.Ratings.Count > 0)
.OrderBy(x => x.Session!.Date)
.TakeLast(8)
.Select(x => x.Entry.Ratings.Average(r => r.Value))
.ToList();
if (points.Count < 4) continue;
var mid = points.Count / 2;
var diff = points.Skip(mid).Average() - points.Take(mid).Average();
if (diff <= -0.4)
PatternNotices.Add(new ClassTeacherPatternNotice(row.StudentName,
$"{row.YearAbsenceRatePercent} % Fehlzeit seit Schuljahresbeginn, dabei zuletzt sinkende Mitarbeit",
ClassTeacherStatusKind.Info));
}
OnPropertyChanged(nameof(HasPatternNotices));
}
/// Reine, ohne Repository-Zugriff testbare Zuordnungslogik. Nutzt <c>FirstName</c>/<c>LastName</c>
/// statt <see cref="Student.FullName"/>, weil dessen "Nachname, Vorname"-Format mit Komma den
/// leerzeichenbasierten Wortabgleich in <see cref="UntisNameMatching"/> verfälschen würde
/// ("Müller," bliebe ein eigenes Wort statt mit "Müller" aus dem WebUntis-Namen zu matchen).
public static Student? MatchStudent(string webUntisDisplayName, IReadOnlyList<Student> students)
{
var key = UntisNameMatching.NameKey(webUntisDisplayName);
return students.FirstOrDefault(s => UntisNameMatching.NameKey($"{s.FirstName} {s.LastName}") == key);
}
private static string WeekdayLabel(DayOfWeek day) => day switch
{
DayOfWeek.Monday => "Montag",
DayOfWeek.Tuesday => "Dienstag",
DayOfWeek.Wednesday => "Mittwoch",
DayOfWeek.Thursday => "Donnerstag",
DayOfWeek.Friday => "Freitag",
DayOfWeek.Saturday => "Samstag",
_ => "Sonntag",
};
/// Zeigt höchstens <see cref="OpenExcuseDisplayLimit"/> Zeilen — Nutzer-Feedback zu den
/// anderen Seitenpanels: eine lange Liste soll nicht die ganze Spalte einnehmen. Der Rest
/// zählt in <see cref="OpenExcuseOverflowCount"/> und wird als "+N weitere" angezeigt.
private const int OpenExcuseDisplayLimit = 6;
private void BuildOpenExcuses(IReadOnlyList<ClassAbsenceDaySummaryRow> absenceDays, DateOnly today)
{
var all = ClassTeacherOpenExcuseRow.Build(absenceDays, today);
foreach (var row in all.Take(OpenExcuseDisplayLimit)) OpenExcuses.Add(row);
OpenExcuseOverflowCount = Math.Max(0, all.Count - OpenExcuseDisplayLimit);
}
[RelayCommand]
private void ShowDetailsForOpenExcuse(ClassTeacherOpenExcuseRow? row)
{
if (row is null) return;
DetailsTab.StudentFilter = row.StudentName;
ActiveTabIndex = 2;
DetailsTab.LoadCommand.Execute(null);
}
/// Nutzer-Feedback: "Aufgaben & Wiedervorlagen öffnen" führt nur ins Modul, das eigentliche
/// Erstellen ("Eltern anrufen Ada Müller") musste man dort noch einmal von Hand eintippen.
/// Legt direkt eine Wiedervorlage an (Kind=Reminder, wie 6.1.1) statt einen Dialog zu öffnen —
/// der bearbeitet sie bei Bedarf im Aufgaben-Modul weiter, wo `OnEditTask` (siehe
/// WorkTaskListView) ohnehin nur dort verdrahtet ist. Bewusst ohne GroupId: Klassenlehrer ist
/// man für die ganze Klasse, nicht für einen einzelnen Kurs (siehe Klassendoku oben).
[RelayCommand]
private void CreateReminderForStudent(ClassTeacherRosterRow? row)
{
if (row is null) return;
SaveReminder(row.StudentName, row.StatusText, urgent: row.IsUnexcused);
}
[RelayCommand]
private void CreateReminderForOpenExcuse(ClassTeacherOpenExcuseRow? row)
{
if (row is null) return;
SaveReminder(row.StudentName, $"{row.DaysOpenLabel} (Fehltag vom {row.DateLabel}).",
urgent: row.IsOverdue);
}
private void SaveReminder(string studentName, string notes, bool urgent)
{
var task = new WorkTask
{
Title = $"Eltern kontaktieren {studentName}",
Notes = notes,
Kind = TaskKind.Reminder,
Priority = urgent ? TaskPriority.High : TaskPriority.Normal,
DueDate = DateOnly.FromDateTime(DateTime.Today).AddDays(1),
};
_workTasks.Save(task);
Status = $"Wiedervorlage „{task.Title}\" angelegt zu finden unter Aufgaben & Wiedervorlagen.";
}
private int Percent(int value) => StudentCount == 0 ? 0 : (int)Math.Round(100d * value / StudentCount);
private void NotifySummary()
{
OnPropertyChanged(nameof(TodayUnexcusedPercent)); OnPropertyChanged(nameof(LatePercent));
OnPropertyChanged(nameof(PresentPercent)); OnPropertyChanged(nameof(ExcusedAbsencePercent));
OnPropertyChanged(nameof(UnexcusedAbsencePercent));
OnPropertyChanged(nameof(TodayUnexcusedSummaryLabel));
OnPropertyChanged(nameof(PresentSummaryLabel)); OnPropertyChanged(nameof(LateSummaryLabel));
OnPropertyChanged(nameof(ExcusedAbsenceSummaryLabel)); OnPropertyChanged(nameof(UnexcusedAbsenceSummaryLabel));
OnPropertyChanged(nameof(PresentFraction)); OnPropertyChanged(nameof(LateFraction));
OnPropertyChanged(nameof(ExcusedAbsenceFraction)); OnPropertyChanged(nameof(UnexcusedAbsenceFraction));
OnPropertyChanged(nameof(DayOverviewTooltip));
}
private void NotifyRosterState()
{
OnPropertyChanged(nameof(HasPrimaryRoster)); OnPropertyChanged(nameof(HasSecondaryRoster));
OnPropertyChanged(nameof(HasNoFilterResults)); OnPropertyChanged(nameof(HasOpenExcuses));
}
}
@@ -0,0 +1,22 @@
namespace LehrerApp.Desktop.ViewModels.ClassTeacher;
/// <summary>
/// WebUntis liefert Schülernamen je nach Bericht in unterschiedlicher Reihenfolge — der
/// Schülerreport baut "Vorname Nachname" (<see cref="Services.UntisStudentDto.DisplayName"/>),
/// die Fehlzeiten-/Klassenbuchberichte liefern typischerweise "Nachname Vorname". Ein exakter
/// String-Vergleich zwischen diesen Quellen schlägt deshalb praktisch immer fehl (Bug: gefilterte
/// Listen im Klassenlehrer-Bereich blieben leer, Roster-Symbole waren durchgängig falsch). Der
/// Vergleich hier ist deshalb reihenfolge-unabhängig: beide Namen werden in Wörter zerlegt und als
/// sortierte Menge verglichen (kein Abgleich über eine externe Schülerkennung möglich, da der
/// Klassenbuch-Bericht keine liefert, siehe UntisForeignClassRegisterEventDto).
/// </summary>
public static class UntisNameMatching
{
public static bool NamesMatch(string? a, string? b) =>
!string.IsNullOrWhiteSpace(a) && !string.IsNullOrWhiteSpace(b) && NameKey(a) == NameKey(b);
public static string NameKey(string value) => string.Join(' ',
value.Split(' ', StringSplitOptions.RemoveEmptyEntries)
.Select(w => w.Trim().ToLowerInvariant())
.OrderBy(w => w, StringComparer.Ordinal));
}
@@ -107,6 +107,15 @@ public partial class DashboardViewModel : ObservableObject
public DashboardCardOption AttendanceCard => Card("attendance");
public DashboardCardOption SupportCard => Card("support");
public DashboardCardOption GroupsCard => Card("groups");
public int TodayLessonCount => TodaysLessons.Count;
public int OpenTaskCount => OpenTasks.Count;
public int UpcomingCount => UpcomingDates.Count;
public int AttentionCount => OpenExcuses.Count + AttendanceWarnings.Count + SupportPlanReviews.Count
+ OpenCorrections.Count + UnplannedLessons.Count + Alerts.Count;
public string TodayLessonSummary => TodayLessonCount == 1 ? "1 Stunde" : $"{TodayLessonCount} Stunden";
public string OpenTaskSummary => OpenTaskCount == 1 ? "1 Aufgabe" : $"{OpenTaskCount} Aufgaben";
public string AttentionSummary => AttentionCount == 1 ? "1 offener Punkt" : $"{AttentionCount} offene Punkte";
public string UpcomingSummary => UpcomingCount == 1 ? "1 Termin" : $"{UpcomingCount} Termine";
public DashboardViewModel(IGroupRepository groups, ISubjectRepository subjects, ILessonRepository lessons,
IExamRepository exams, IExamResultRepository examResults, IGradeRepository grades,
@@ -204,6 +213,31 @@ public partial class DashboardViewModel : ObservableObject
LoadOpenCorrections(groups, today);
LoadUnplannedLessons(groups, today);
LoadAlerts(groups, today);
UpdateDashboardSummary();
}
private void UpdateDashboardSummary()
{
TodayCard.IsEmpty = TodaysLessons.Count == 0;
TasksCard.IsEmpty = OpenTasks.Count == 0;
CalendarCard.IsEmpty = false;
ExcusesCard.IsEmpty = OpenExcuses.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;
SupportCard.IsEmpty = SupportPlanReviews.Count == 0;
GroupsCard.IsEmpty = CurrentGroups.Count == 0;
OnPropertyChanged(nameof(TodayLessonCount));
OnPropertyChanged(nameof(OpenTaskCount));
OnPropertyChanged(nameof(UpcomingCount));
OnPropertyChanged(nameof(AttentionCount));
OnPropertyChanged(nameof(TodayLessonSummary));
OnPropertyChanged(nameof(OpenTaskSummary));
OnPropertyChanged(nameof(AttentionSummary));
OnPropertyChanged(nameof(UpcomingSummary));
}
private async Task LoadWeatherAsync()
@@ -352,12 +386,10 @@ public partial class DashboardViewModel : ObservableObject
.Where(e => e.Status is ExamStatus.Conducted or ExamStatus.Graded)
.OrderBy(e => e.Date))
{
var expected = _memberships.GetByGroup(group.Id)
.Count(m => GroupMembershipService.IsActiveOn(m, exam.Date));
var evaluated = _examResults.GetByExam(exam.Id)
.Count(r => r.Absent || !string.IsNullOrWhiteSpace(r.Grade) || r.Points.Count > 0);
var (expected, evaluated) = ExamCorrectionCounter.Count(exam,
_memberships.GetByGroup(group.Id), _examResults.GetByExam(exam.Id));
OpenCorrections.Add(new CorrectionProgressItem(exam.Id, group.Id, exam.Title,
group.Name, exam.Date, Math.Min(evaluated, expected), expected, today));
group.Name, exam.Date, evaluated, expected, today));
}
}
@@ -592,6 +624,7 @@ public partial class DashboardViewModel : ObservableObject
entry.Attendance = status;
_participationEntries.Save(entry);
OpenExcuses.Remove(item);
UpdateDashboardSummary();
}
private void LoadCalendar()
@@ -1034,18 +1067,29 @@ public sealed class DashboardAlertItem(Guid studentId, Guid? groupId, string stu
public partial class DashboardCardOption : ObservableObject
{
[ObservableProperty] private bool _isVisible;
[ObservableProperty] private bool _isEmpty;
[ObservableProperty] private int _row;
[ObservableProperty] private int _column;
public string Key { get; }
public string Title { get; }
public bool HideWhenEmpty { get; }
public bool EffectiveIsVisible => IsVisible && (!HideWhenEmpty || !IsEmpty);
public Action? OnVisibilityChanged { get; set; }
public DashboardCardOption(string key, string title, bool isVisible)
{
Key = key;
Title = title;
HideWhenEmpty = key is "excuses" or "upcoming" or "corrections" or "unplanned"
or "alerts" or "attendance" or "support";
_isVisible = isVisible;
}
partial void OnIsVisibleChanged(bool value) => OnVisibilityChanged?.Invoke();
partial void OnIsVisibleChanged(bool value)
{
OnPropertyChanged(nameof(EffectiveIsVisible));
OnVisibilityChanged?.Invoke();
}
partial void OnIsEmptyChanged(bool value) => OnPropertyChanged(nameof(EffectiveIsVisible));
}
@@ -0,0 +1,253 @@
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using LehrerApp.Core.Interfaces;
using LehrerApp.Core.Models;
using LehrerApp.Core.Services;
using LehrerApp.Desktop.ViewModels.Groups;
using System.Collections.ObjectModel;
using System.Globalization;
namespace LehrerApp.Desktop.ViewModels.Exams;
/// Klausuren-Hauptseite (Sidebar "Klausuren"): gruppenübergreifende Liste aller Klausuren des
/// aktuellen Schuljahres, sortiert nach einem unsichtbaren Prioritäts-Score
/// (`ExamPriorityService`) statt nach Datum — unbearbeitete/überfällige Korrekturen oben,
/// erledigte unten. Oben ein Detailbereich zur ausgewählten Klausur, der sich je nach Status
/// unterscheidet (Korrekturfortschritt vs. Notenspiegel), inklusive Umschalter für Parallelkurse
/// (gleiche Arbeit, mehrere Kurse — siehe `Exam.SharedExamGroupId`).
public partial class ExamsOverviewViewModel : ObservableObject
{
private readonly IExamRepository _exams;
private readonly IExamResultRepository _examResults;
private readonly IGroupRepository _groups;
private readonly IGroupMembershipRepository _memberships;
private readonly GradingService _grading;
private readonly SchoolYearService _schoolYear;
public ObservableCollection<ExamListRowViewModel> Rows { get; } = [];
public ObservableCollection<ExamListRowViewModel> Siblings { get; } = [];
public ObservableCollection<GradeBarItem> DetailGradeDistribution { get; } = [];
[ObservableProperty] private ExamListRowViewModel? _selectedRow;
[ObservableProperty] private bool _hasSelection;
[ObservableProperty] private string _emptyHint = "";
[ObservableProperty] private bool _showCorrectionProgress;
[ObservableProperty] private string _progressLabel = "";
[ObservableProperty] private double _progressBarWidth;
[ObservableProperty] private bool _showGradeSummary;
[ObservableProperty] private string _averageLabel = "";
[ObservableProperty] private string _approvalLabel = "";
[ObservableProperty] private string _announcementLabel = "";
public Func<Exam, Task>? OnGradeExam { get; set; }
public Func<Exam, Task>? OnEvaluateExam { get; set; }
public Action<Guid>? OnNavigateToGroup { get; set; }
public ExamsOverviewViewModel(IExamRepository exams, IExamResultRepository examResults,
IGroupRepository groups, IGroupMembershipRepository memberships, GradingService grading,
SchoolYearService schoolYear)
{
_exams = exams; _examResults = examResults; _groups = groups;
_memberships = memberships; _grading = grading; _schoolYear = schoolYear;
}
[RelayCommand]
public void Load()
{
var selectedId = SelectedRow?.Exam.Id;
Rows.Clear();
var groups = _groups.GetBySchoolYear(_schoolYear.CurrentSchoolYear());
var today = DateOnly.FromDateTime(DateTime.Today);
var rows = new List<ExamListRowViewModel>();
foreach (var group in groups)
{
var groupMemberships = _memberships.GetByGroup(group.Id);
foreach (var exam in _exams.GetByGroup(group.Id))
{
var (expected, evaluated) = ExamCorrectionCounter.Count(exam, groupMemberships,
_examResults.GetByExam(exam.Id));
var info = ExamPriorityService.Evaluate(exam, expected, evaluated, today);
rows.Add(new ExamListRowViewModel(exam, group, expected, evaluated, info, today));
}
}
// Parallelkurse markieren, damit die Liste einen Verknüpfungs-Hinweis zeigen kann.
foreach (var group in rows.GroupBy(r => r.Exam.SharedExamGroupId ?? r.Exam.Id))
{
if (group.Count() <= 1) continue;
foreach (var row in group) row.HasSibling = true;
}
foreach (var row in rows.OrderByDescending(r => r.Score))
Rows.Add(row);
EmptyHint = Rows.Count == 0 ? "Keine Klausuren angelegt." : "";
SelectedRow = selectedId is { } id
? Rows.FirstOrDefault(r => r.Exam.Id == id) ?? Rows.FirstOrDefault()
: Rows.FirstOrDefault();
}
partial void OnSelectedRowChanged(ExamListRowViewModel? value) => UpdateDetail(value);
private void UpdateDetail(ExamListRowViewModel? row)
{
HasSelection = row is not null;
Siblings.Clear();
DetailGradeDistribution.Clear();
ShowCorrectionProgress = false;
ShowGradeSummary = false;
if (row is null) return;
ApprovalLabel = row.Exam.ApprovalGrantedAt is { } a
? $"Genehmigt am {a.ToString("dd.MM.yyyy", CultureInfo.InvariantCulture)}"
: "Genehmigung noch ausstehend";
AnnouncementLabel = row.Exam.AnnouncedAt is { } n
? $"Angekündigt am {n.ToString("dd.MM.yyyy", CultureInfo.InvariantCulture)}"
: "Noch nicht angekündigt";
if (row.HasSibling)
{
var anchor = row.Exam.SharedExamGroupId ?? row.Exam.Id;
foreach (var sibling in Rows.Where(r => r != row && (r.Exam.SharedExamGroupId ?? r.Exam.Id) == anchor))
Siblings.Add(sibling);
}
ShowCorrectionProgress = row.Status is ExamListStatus.AwaitingCorrection
or ExamListStatus.CorrectionInProgress or ExamListStatus.CorrectionStuck;
if (ShowCorrectionProgress)
{
ProgressLabel = $"{row.Evaluated} / {row.Expected} korrigiert";
var fraction = row.Expected <= 0 ? 0 : Math.Clamp((double)row.Evaluated / row.Expected, 0, 1);
ProgressBarWidth = fraction * 240.0;
}
ShowGradeSummary = row.Status is ExamListStatus.AwaitingReturn or ExamListStatus.Returned;
if (ShowGradeSummary) LoadGradeSummary(row);
}
private void LoadGradeSummary(ExamListRowViewModel row)
{
var grades = _examResults.GetByExam(row.Exam.Id)
.Where(r => !r.Absent && !string.IsNullOrWhiteSpace(r.Grade))
.Select(r => r.Grade!).ToList();
if (grades.Count == 0) { AverageLabel = "Noch keine Noten erfasst."; return; }
AverageLabel = "Ø " + _grading.WeightedAverage(grades.Select(g => (Grade: g, Weight: 1.0)).ToList())
.ToString("0.00", CultureInfo.InvariantCulture);
var counts = grades.GroupBy(g => g).ToDictionary(g => g.Key, g => g.Count());
var maxCount = counts.Count == 0 ? 0 : counts.Values.Max();
foreach (var entry in row.Exam.GradingKey.OrderByDescending(e => e.MinPercent))
{
counts.TryGetValue(entry.Grade, out var count);
DetailGradeDistribution.Add(new GradeBarItem(entry.Grade, count, grades.Count, maxCount));
}
}
[RelayCommand]
private void SelectExam(ExamListRowViewModel row) => SelectedRow = row;
[RelayCommand]
private async Task GradeSelected()
{
if (SelectedRow is null || OnGradeExam is null) return;
await OnGradeExam(SelectedRow.Exam);
Load();
}
[RelayCommand]
private async Task EvaluateSelected()
{
if (SelectedRow is null || OnEvaluateExam is null) return;
await OnEvaluateExam(SelectedRow.Exam);
Load();
}
[RelayCommand]
private void GoToGroup()
{
if (SelectedRow is null) return;
OnNavigateToGroup?.Invoke(SelectedRow.GroupId);
}
[RelayCommand]
private void ToggleApproval() => ToggleDate(SelectedRow?.Exam, e => e.ApprovalGrantedAt,
(e, v) => e.ApprovalGrantedAt = v);
[RelayCommand]
private void ToggleAnnouncement() => ToggleDate(SelectedRow?.Exam, e => e.AnnouncedAt,
(e, v) => e.AnnouncedAt = v);
private void ToggleDate(Exam? exam, Func<Exam, DateOnly?> get, Action<Exam, DateOnly?> set)
{
if (exam is null) return;
set(exam, get(exam) is null ? DateOnly.FromDateTime(DateTime.Today) : null);
exam.UpdatedAt = DateTime.UtcNow;
_exams.Save(exam);
UpdateDetail(SelectedRow);
}
}
// ── Zeile in der Klausurliste ────────────────────────────────────────────────
public class ExamListRowViewModel
{
public Exam Exam { get; }
public Guid GroupId { get; }
public string Title { get; }
public string GroupLabel { get; }
public int Expected { get; }
public int Evaluated { get; }
public ExamListStatus Status { get; }
public double Score { get; }
public string StatusLabel { get; }
public string SubLabel { get; }
public bool HasSibling { get; set; }
public bool IsOk { get; }
public bool IsInfo { get; }
public bool IsWarning { get; }
public bool IsDanger { get; }
public ExamListRowViewModel(Exam exam, LearningGroup group, int expected, int evaluated,
ExamPriorityInfo info, DateOnly today)
{
Exam = exam; GroupId = group.Id; Title = exam.Title; GroupLabel = group.Name;
Expected = expected; Evaluated = evaluated; Status = info.Status; Score = info.Score;
(StatusLabel, SubLabel, IsOk, IsInfo, IsWarning, IsDanger) = Status switch
{
ExamListStatus.Planned => ("Geplant", $"geplant für {RelativeDay(exam.Date, today)}",
false, false, false, false),
ExamListStatus.AwaitingCorrection when today.DayNumber - exam.Date.DayNumber <= 3 =>
("Korrektur ausstehend", $"geschrieben {RelativeDay(exam.Date, today)}",
false, false, true, false),
ExamListStatus.AwaitingCorrection =>
("überfällig", $"geschrieben {RelativeDay(exam.Date, today)}", false, false, false, true),
ExamListStatus.CorrectionInProgress =>
("Korrektur läuft", $"{evaluated}/{expected} korrigiert", false, false, true, false),
ExamListStatus.CorrectionStuck =>
("hängt fest", $"{evaluated}/{expected} korrigiert", false, false, false, false),
ExamListStatus.AwaitingReturn => ("Korrigiert", "Rückgabe aussteht", false, true, false, false),
ExamListStatus.Returned => ("Abgeschlossen",
exam.ReturnedAt is { } r ? $"zurückgegeben {r:dd.MM.yyyy}" : "zurückgegeben", true, false, false, false),
_ => ("", "", false, false, false, false),
};
}
private static string RelativeDay(DateOnly date, DateOnly today)
{
var diff = date.DayNumber - today.DayNumber;
return diff switch
{
0 => "heute",
1 => "morgen",
-1 => "gestern",
> 1 => $"in {diff} Tagen",
_ => $"vor {-diff} Tagen",
};
}
}
@@ -0,0 +1,194 @@
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using LehrerApp.Core.Interfaces;
using LehrerApp.Core.Models;
using System.Collections.ObjectModel;
namespace LehrerApp.Desktop.ViewModels;
/// <summary>
/// Globale Suche und Schnellerfassung (14.2). Die Projektion bleibt bewusst klein und lokal:
/// durchsucht werden die wichtigsten täglichen Ziele, ohne dafür einen zusätzlichen Suchindex
/// oder eine Netzwerkabhängigkeit einzuführen.
/// </summary>
public partial class GlobalSearchViewModel : ObservableObject
{
private const int MaxDataResults = 12;
private readonly IStudentRepository _students;
private readonly IGroupRepository _groups;
private readonly IExamRepository _exams;
private readonly IWorkTaskRepository _tasks;
[ObservableProperty] private string _query = "";
[ObservableProperty] private GlobalSearchResult? _selectedResult;
public ObservableCollection<GlobalSearchResult> Results { get; } = [];
public bool HasResults => Results.Count > 0;
public bool ShowNoResults => !string.IsNullOrWhiteSpace(Query) && Results.Count == 0;
public Action<GlobalSearchResult>? OnNavigate { get; set; }
public Func<bool, Task>? OnQuickAddTask { get; set; }
public Func<Task>? OnQuickAddStudent { get; set; }
public Action? OnClose { get; set; }
public GlobalSearchViewModel(IStudentRepository students, IGroupRepository groups,
IExamRepository exams, IWorkTaskRepository tasks)
{
_students = students;
_groups = groups;
_exams = exams;
_tasks = tasks;
RefreshResults();
}
partial void OnQueryChanged(string value) => RefreshResults();
public void Reset()
{
Query = "";
RefreshResults();
}
private void RefreshResults()
{
Results.Clear();
var query = Query.Trim();
AddQuickActions(query);
if (query.Length > 0)
{
var groups = _groups.GetAll(includeInactive: true);
var groupNames = groups.ToDictionary(g => g.Id, g => g.Name);
var candidates = new List<GlobalSearchResult>();
candidates.AddRange(_students.GetAll(includeInactive: true)
.Where(s => Matches(s.FullName, query))
.Select(s => GlobalSearchResult.ForStudent(s)));
candidates.AddRange(groups
.Where(g => Matches($"{g.Name} {g.SchoolYear} {g.GradeLevel}", query))
.Select(GlobalSearchResult.ForGroup));
candidates.AddRange(_exams.GetAll()
.Where(e => Matches($"{e.Title} {groupNames.GetValueOrDefault(e.GroupId)}", query))
.Select(e => GlobalSearchResult.ForExam(e, groupNames.GetValueOrDefault(e.GroupId) ?? "")));
candidates.AddRange(_tasks.GetAll()
.Where(t => Matches($"{t.Title} {t.Notes} {groupNames.GetValueOrDefault(t.GroupId ?? Guid.Empty)}", query))
.Select(t => GlobalSearchResult.ForTask(t, groupNames.GetValueOrDefault(t.GroupId ?? Guid.Empty) ?? "")));
foreach (var item in candidates
.OrderByDescending(x => x.Title.StartsWith(query, StringComparison.CurrentCultureIgnoreCase))
.ThenBy(x => x.KindSortOrder)
.ThenBy(x => x.Title)
.Take(MaxDataResults))
Results.Add(item);
}
SelectedResult = Results.FirstOrDefault();
OnPropertyChanged(nameof(HasResults));
OnPropertyChanged(nameof(ShowNoResults));
}
private void AddQuickActions(string query)
{
var actions = new[]
{
GlobalSearchResult.ForAction(GlobalSearchAction.NewTask, "Aufgabe anlegen", "Mit Fälligkeit, Gruppe und Priorität", ""),
GlobalSearchResult.ForAction(GlobalSearchAction.NewReminder, "Erinnerung anlegen", "Kurze Notiz ohne Zeiterfassung", "◷"),
GlobalSearchResult.ForAction(GlobalSearchAction.NewStudent, "Schüler anlegen", "Neue Stammdaten erfassen", ""),
};
foreach (var action in actions.Where(a => query.Length == 0 || Matches(a.Title, query)))
Results.Add(action);
}
private static bool Matches(string? value, string query) =>
value?.Contains(query, StringComparison.CurrentCultureIgnoreCase) == true;
[RelayCommand]
private async Task Execute(GlobalSearchResult? result)
{
if (result is null) return;
switch (result.Action)
{
case GlobalSearchAction.NewTask:
if (OnQuickAddTask is not null) await OnQuickAddTask(false);
break;
case GlobalSearchAction.NewReminder:
if (OnQuickAddTask is not null) await OnQuickAddTask(true);
break;
case GlobalSearchAction.NewStudent:
if (OnQuickAddStudent is not null) await OnQuickAddStudent();
break;
default:
OnNavigate?.Invoke(result);
break;
}
OnClose?.Invoke();
}
}
public enum GlobalSearchResultKind { Action, Student, Group, Exam, Task }
public enum GlobalSearchAction { None, NewTask, NewReminder, NewStudent }
public sealed class GlobalSearchResult
{
public GlobalSearchResultKind Kind { get; private init; }
public GlobalSearchAction Action { get; private init; }
public Guid? EntityId { get; private init; }
public Guid? GroupId { get; private init; }
public string Title { get; private init; } = "";
public string Subtitle { get; private init; } = "";
public string Icon { get; private init; } = "";
public int KindSortOrder => Kind switch
{
GlobalSearchResultKind.Student => 0,
GlobalSearchResultKind.Group => 1,
GlobalSearchResultKind.Exam => 2,
GlobalSearchResultKind.Task => 3,
_ => -1,
};
public string KindLabel => Kind switch
{
GlobalSearchResultKind.Student => "Schüler",
GlobalSearchResultKind.Group => "Lerngruppe",
GlobalSearchResultKind.Exam => "Klausur",
GlobalSearchResultKind.Task => "Aufgabe",
_ => "Schnellaktion",
};
public static GlobalSearchResult ForAction(GlobalSearchAction action, string title, string subtitle, string icon) =>
new() { Kind = GlobalSearchResultKind.Action, Action = action, Title = title, Subtitle = subtitle, Icon = icon };
public static GlobalSearchResult ForStudent(Student student) => new()
{
Kind = GlobalSearchResultKind.Student, EntityId = student.Id,
Title = student.FullName, Subtitle = student.IsActive ? "Aktiv" : "Inaktiv", Icon = "P",
};
public static GlobalSearchResult ForGroup(LearningGroup group) => new()
{
Kind = GlobalSearchResultKind.Group, EntityId = group.Id, GroupId = group.Id,
Title = group.Name, Subtitle = $"{group.SchoolYear} · Stufe {group.GradeLevel}", Icon = "G",
};
public static GlobalSearchResult ForExam(Exam exam, string groupName) => new()
{
Kind = GlobalSearchResultKind.Exam, EntityId = exam.Id, GroupId = exam.GroupId,
Title = exam.Title, Subtitle = $"{groupName} · {exam.Date:dd.MM.yyyy}", Icon = "K",
};
public static GlobalSearchResult ForTask(WorkTask task, string groupName) => new()
{
Kind = GlobalSearchResultKind.Task, EntityId = task.Id, GroupId = task.GroupId,
Title = task.Title,
Subtitle = string.Join(" · ", new[] { groupName, task.DueDate?.ToString("dd.MM.yyyy") ?? "" }
.Where(x => !string.IsNullOrWhiteSpace(x))),
Icon = task.Kind == TaskKind.Reminder ? "E" : "A",
};
}
@@ -1,4 +1,5 @@
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using LehrerApp.Core.Interfaces;
using LehrerApp.Core.Models;
using LehrerApp.Core.Services;
@@ -52,6 +53,16 @@ public partial class ExamGradingDialogViewModel : ObservableObject
private void SaveRow(ExamResultRow row) => _results.Save(row.ToModel(_exam.Id));
/// Rest als abwesend markieren (Nutzerwunsch): Schüler, die nie nachschreiben, blockieren
/// sonst dauerhaft den live abgeleiteten Korrekturstatus (ExamPriorityService), weil niemand
/// eine leere Zeile anfasst, für die es nichts einzutragen gibt. Rührt bewusst nur unberührte
/// Zeilen an — bereits eingetragene Punkte/Kommentare bleiben unangetastet.
[RelayCommand]
private void MarkRemainingAbsent()
{
foreach (var row in Rows.Where(r => !r.Absent && r.Cells.All(c => c.Value is null)))
row.Absent = true;
}
}
// ── Zeile im Punkteraster ──────────────────────────────────────────────────
@@ -21,6 +21,7 @@ public partial class ExamDialogViewModel : ObservableObject
private readonly int _gradeLevel;
private readonly GradingSystem _gradingSystem;
private readonly Exam? _editingExam;
private readonly Exam? _duplicateSource;
private readonly string _subjectName;
[ObservableProperty] private string _title = "";
@@ -68,6 +69,7 @@ public partial class ExamDialogViewModel : ObservableObject
_groupId = groupId; _subjectId = subjectId; _gradeLevel = gradeLevel;
_gradingSystem = gradingSystem;
_editingExam = editingExam;
_duplicateSource = duplicateSource;
_subjectName = defaultSubjectName;
IsDifferentiated = isDifferentiated;
@@ -304,6 +306,11 @@ public partial class ExamDialogViewModel : ObservableObject
Result.Niveau = NiveauDisplay.FromName(SelectedNiveauName);
Result.Tasks = Tasks.Select(t => t.ToModel()).ToList();
Result.GradingKey = gradingKey;
// Parallelkurse (gleiche Arbeit, mehrere Kurse): beim Duplizieren verknüpfen, damit die
// Klausuren-Hauptseite zwischen ihnen umschalten kann. Der Anker ist die Id der zuerst
// angelegten Klausur der Kette — schon verknüpfte Quellen geben ihren Anker weiter.
if (_duplicateSource is not null)
Result.SharedExamGroupId = _duplicateSource.SharedExamGroupId ?? _duplicateSource.Id;
_exams.Save(Result);
}
}
@@ -13,13 +13,8 @@ namespace LehrerApp.Desktop.ViewModels.Groups;
public partial class GroupListViewModel : ObservableObject
{
private const int QuickTasksMaxCount = 3;
private readonly IGroupRepository _groups;
private readonly ISubjectRepository _subjects;
private readonly ILessonRepository _lessons;
private readonly IExamRepository _exams;
private readonly IWorkTaskRepository _tasks;
public Action<Guid, int>? OnNavigateToDetail { get; set; }
public Func<Task>? OnAddGroup { get; set; }
@@ -29,11 +24,8 @@ public partial class GroupListViewModel : ObservableObject
[ObservableProperty] private string _selectedSchoolYear = "";
[ObservableProperty] private string _searchText = "";
[ObservableProperty] private GroupListItem? _selectedGroup;
[ObservableProperty] private bool _showArchived;
public string SelectedGroupDisplayName => SelectedGroup?.DisplayName ?? "";
public string SelectedGroupSubtitle => SelectedGroup?.Subtitle ?? "";
public string ListSummary => ShowArchived
? $"{Groups.Count} archivierte Gruppen · {SelectedSchoolYear}"
: $"{Groups.Count} aktive Gruppen · {SelectedSchoolYear}";
@@ -45,22 +37,10 @@ public partial class GroupListViewModel : ObservableObject
public ObservableCollection<string> SchoolYears { get; } = [];
public ObservableCollection<GroupListItem> Groups { get; } = [];
// ── Schnellüberblick im Auswahl-Panel (Nutzer-Feedback: "oberhalb der Buttonliste ein paar
// Daten auswerfen. Nächste Stunde, nächste Arbeit, wichtige Todos") — bewusst dieselben
// kompakten Kennzahlen wie die obersten Karten des Kurs-Dashboards (GroupOverviewViewModel),
// hier nur ohne eigenen Tab-Wechsel, da man ohnehin schon auf der Gruppenliste steht.
[ObservableProperty] private bool _quickHasNextLesson;
[ObservableProperty] private string _quickNextLessonLabel = "";
[ObservableProperty] private bool _quickHasNextExam;
[ObservableProperty] private string _quickNextExamLabel = "";
public ObservableCollection<GroupTaskItem> QuickTasks { get; } = [];
public bool QuickHasTasks => QuickTasks.Count > 0;
public bool QuickHasAnything => QuickHasNextLesson || QuickHasNextExam || QuickHasTasks;
public GroupListViewModel(IGroupRepository groups, ISubjectRepository subjects, SchoolYearService sy,
ILessonRepository lessons, IExamRepository exams, IWorkTaskRepository tasks)
public GroupListViewModel(IGroupRepository groups, ISubjectRepository subjects, SchoolYearService sy)
{
_groups = groups; _subjects = subjects; _lessons = lessons; _exams = exams; _tasks = tasks;
_groups = groups;
_subjects = subjects;
foreach (var y in sy.RecentSchoolYears()) SchoolYears.Add(y);
SelectedSchoolYear = sy.CurrentSchoolYear();
}
@@ -68,58 +48,8 @@ public partial class GroupListViewModel : ObservableObject
partial void OnSelectedSchoolYearChanged(string value) => LoadGroups();
partial void OnSearchTextChanged(string value) => LoadGroups();
partial void OnShowArchivedChanged(bool value) => LoadGroups();
partial void OnSelectedGroupChanged(GroupListItem? value)
{
OnPropertyChanged(nameof(SelectedGroupDisplayName));
OnPropertyChanged(nameof(SelectedGroupSubtitle));
NavigateToSectionCommand.NotifyCanExecuteChanged();
EditGroupCommand.NotifyCanExecuteChanged();
RollOverGroupCommand.NotifyCanExecuteChanged();
ToggleArchiveCommand.NotifyCanExecuteChanged();
DeleteGroupCommand.NotifyCanExecuteChanged();
LoadQuickInfo();
}
private void LoadQuickInfo()
{
QuickTasks.Clear();
if (SelectedGroup is null)
{
QuickHasNextLesson = false; QuickNextLessonLabel = "";
QuickHasNextExam = false; QuickNextExamLabel = "";
OnPropertyChanged(nameof(QuickHasTasks));
OnPropertyChanged(nameof(QuickHasAnything));
return;
}
var groupId = SelectedGroup.Id;
var today = DateOnly.FromDateTime(DateTime.Today);
var nextLesson = _lessons.GetByGroupAndRange(groupId, today, today.AddDays(90))
.Where(l => l.Status == LessonStatus.Planned)
.OrderBy(l => l.Date).ThenBy(l => l.LessonNumber ?? 0).FirstOrDefault();
QuickHasNextLesson = nextLesson is not null;
QuickNextLessonLabel = nextLesson is null ? ""
: string.IsNullOrWhiteSpace(nextLesson.Topic)
? nextLesson.Date.ToString("dd.MM.yyyy")
: $"{nextLesson.Date:dd.MM.yyyy} — {nextLesson.Topic}";
var nextExam = _exams.GetByGroup(groupId).Where(e => e.Date >= today).MinBy(e => e.Date);
QuickHasNextExam = nextExam is not null;
QuickNextExamLabel = nextExam is null ? "" : $"{nextExam.Date:dd.MM.yyyy} — {nextExam.Title}";
foreach (var t in _tasks.GetByGroup(groupId).Where(t => t.Status != WorkTaskStatus.Done)
.OrderBy(t => t.DueDate ?? DateOnly.MaxValue).Take(QuickTasksMaxCount))
QuickTasks.Add(new GroupTaskItem(t.Title, t.Kind == TaskKind.Reminder,
t.DueDate?.ToString("dd.MM.yyyy") ?? "", t.DueDate is { } d && d < today,
TaskPriorityDisplay.ColorHex(t.Priority), t.Priority == TaskPriority.High));
OnPropertyChanged(nameof(QuickHasTasks));
OnPropertyChanged(nameof(QuickHasAnything));
}
public void LoadGroups()
{
var selectedId = SelectedGroup?.Id;
Groups.Clear();
var all = _groups.GetBySchoolYear(SelectedSchoolYear, includeInactive: ShowArchived)
.Where(g => g.IsActive != ShowArchived);
@@ -129,8 +59,18 @@ public partial class GroupListViewModel : ObservableObject
|| (g.SubjectId is Guid id && subjectNames.GetValueOrDefault(id, "")
.Contains(SearchText, StringComparison.OrdinalIgnoreCase)));
foreach (var g in filtered.OrderBy(g => g.Name))
Groups.Add(new GroupListItem(g, g.SubjectId is Guid id ? subjectNames.GetValueOrDefault(id, "") : ""));
SelectedGroup = Groups.FirstOrDefault(g => g.Id == selectedId);
{
var item = new GroupListItem(g,
g.SubjectId is Guid id ? subjectNames.GetValueOrDefault(id, "") : "")
{
OnOpen = OpenGroup,
OnEdit = EditGroup,
OnRollOver = RollOverGroup,
OnToggleArchive = ToggleArchive,
OnDelete = DeleteGroup,
};
Groups.Add(item);
}
OnPropertyChanged(nameof(ListSummary));
OnPropertyChanged(nameof(HasNoGroups));
OnPropertyChanged(nameof(EmptyListMessage));
@@ -145,63 +85,57 @@ public partial class GroupListViewModel : ObservableObject
}
[RelayCommand] private void Refresh() => LoadGroups();
[RelayCommand(CanExecute = nameof(CanEditSelectedGroup))]
private async Task EditGroup()
[RelayCommand]
private void OpenGroup(GroupListItem? group)
{
if (SelectedGroup is null || OnEditGroup is null) return;
var id = SelectedGroup.Id;
await OnEditGroup(id);
LoadGroups();
SelectedGroup = Groups.FirstOrDefault(g => g.Id == id);
if (group is not null) OnNavigateToDetail?.Invoke(group.Id, 0);
}
[RelayCommand(CanExecute = nameof(HasSelectedGroup))]
private async Task RollOverGroup()
[RelayCommand]
private async Task EditGroup(GroupListItem? group)
{
if (SelectedGroup is null || OnRollOverGroup is null) return;
var targetId = await OnRollOverGroup(SelectedGroup.Id);
if (group?.IsActive != true || OnEditGroup is null) return;
var id = group.Id;
await OnEditGroup(id);
LoadGroups();
}
[RelayCommand]
private async Task RollOverGroup(GroupListItem? group)
{
if (group is null || OnRollOverGroup is null) return;
var targetId = await OnRollOverGroup(group.Id);
if (targetId is null) return;
var target = _groups.GetById(targetId.Value);
if (target is null) return;
if (!SchoolYears.Contains(target.SchoolYear)) SchoolYears.Insert(0, target.SchoolYear);
SelectedSchoolYear = target.SchoolYear;
LoadGroups();
SelectedGroup = Groups.FirstOrDefault(g => g.Id == target.Id);
OnNavigateToDetail?.Invoke(target.Id, 0);
}
[RelayCommand(CanExecute = nameof(HasSelectedGroup))]
private void ToggleArchive()
[RelayCommand]
private void ToggleArchive(GroupListItem? selected)
{
if (SelectedGroup is null) return;
var group = _groups.GetById(SelectedGroup.Id);
if (selected is null) return;
var group = _groups.GetById(selected.Id);
if (group is null) return;
group.IsActive = !group.IsActive;
_groups.Save(group);
LoadGroups();
}
[RelayCommand(CanExecute = nameof(CanEditSelectedGroup))]
private async Task DeleteGroup()
[RelayCommand]
private async Task DeleteGroup(GroupListItem? group)
{
if (SelectedGroup is null || OnConfirmDelete is null) return;
var selected = SelectedGroup;
if (!await OnConfirmDelete(selected)) return;
_groups.Delete(selected.Id);
if (group?.IsActive != true || OnConfirmDelete is null) return;
if (!await OnConfirmDelete(group)) return;
_groups.Delete(group.Id);
LoadGroups();
}
[RelayCommand(CanExecute = nameof(HasSelectedGroup))]
private void NavigateToSection(string? tabIndex)
{
if (SelectedGroup is null || !int.TryParse(tabIndex, out var tab)) return;
OnNavigateToDetail?.Invoke(SelectedGroup.Id, tab);
}
private bool HasSelectedGroup() => SelectedGroup is not null;
private bool CanEditSelectedGroup() => SelectedGroup?.IsActive == true;
}
public class GroupListItem
public partial class GroupListItem : ObservableObject
{
public Guid Id { get; }
public string Name { get; }
@@ -212,6 +146,13 @@ public class GroupListItem
public string Subtitle { get; }
public bool IsActive { get; }
public string ArchiveActionLabel => IsActive ? "Archivieren" : "Wieder aktivieren";
public string OpenAutomationName => $"Lerngruppe {DisplayName} öffnen";
public string ManageAutomationName => $"Lerngruppe {DisplayName} verwalten";
public Action<GroupListItem>? OnOpen { get; init; }
public Func<GroupListItem, Task>? OnEdit { get; init; }
public Func<GroupListItem, Task>? OnRollOver { get; init; }
public Action<GroupListItem>? OnToggleArchive { get; init; }
public Func<GroupListItem, Task>? OnDelete { get; init; }
public GroupListItem(LearningGroup g, string subjectName)
{
@@ -224,6 +165,12 @@ public class GroupListItem
DisplayName = string.IsNullOrEmpty(subjectName) ? g.Name : $"{g.Name} · {subjectName}";
Subtitle = $"{TypeLabel} · Stufe {g.GradeLevel} · Noten {GradingLabel} · {g.SchoolYear}";
}
[RelayCommand] private void Open() => OnOpen?.Invoke(this);
[RelayCommand] private Task Edit() => OnEdit?.Invoke(this) ?? Task.CompletedTask;
[RelayCommand] private Task RollOver() => OnRollOver?.Invoke(this) ?? Task.CompletedTask;
[RelayCommand] private void ToggleArchive() => OnToggleArchive?.Invoke(this);
[RelayCommand] private Task Delete() => OnDelete?.Invoke(this) ?? Task.CompletedTask;
}
// ── Gruppendetail ─────────────────────────────────────────────────────────────
@@ -878,6 +825,7 @@ public partial class AddGroupDialogViewModel : ObservableObject
[ObservableProperty] private bool _isOwnClass;
[ObservableProperty] private bool _isDifferentiated;
[ObservableProperty] private bool _requiresLessonPlanning = true;
[ObservableProperty] private int? _webUntisLessonId;
[ObservableProperty] private string _nameError = "";
[ObservableProperty] private string _gradeLevelError = "";
@@ -917,6 +865,7 @@ public partial class AddGroupDialogViewModel : ObservableObject
IsOwnClass = group.IsOwnClass;
IsDifferentiated = group.IsDifferentiated;
RequiresLessonPlanning = group.RequiresLessonPlanning;
WebUntisLessonId = group.WebUntisLessonId;
OnPropertyChanged(nameof(DialogTitle));
OnPropertyChanged(nameof(SaveButtonText));
}
@@ -963,6 +912,7 @@ public partial class AddGroupDialogViewModel : ObservableObject
Result.IsOwnClass = IsOwnClass;
Result.IsDifferentiated = IsDifferentiated;
Result.RequiresLessonPlanning = RequiresLessonPlanning;
Result.WebUntisLessonId = WebUntisLessonId;
_groups.Save(Result);
}
}
@@ -93,14 +93,37 @@ public partial class SeatingPlanTabViewModel : ObservableObject
/// Legt bei Bedarf die "Sitzung für heute" an, mit der Sitzplan-Bewertungen/-Markierungen
/// verknüpft werden — aber erst, wenn tatsächlich etwas bewertet/markiert wird (AssessStudent/
/// ToggleSituationTag), nicht schon beim Öffnen des Tabs.
///
/// Prüft dabei bewusst gegen das Repository und nicht nur gegen das lokal geladene
/// <see cref="TodaySessions"/> / <see cref="SelectedSession"/>: Der Unterrichtsmodus
/// (<see cref="SelectOrCreateSessionForLesson"/>) läuft auf einer eigenen, per DI frisch
/// aufgelösten <see cref="SeatingPlanTabViewModel"/>-Instanz (siehe TimetableView.ShowTeachingMode),
/// getrennt von der Instanz, die im Sitzplan-Tab der Kursübersicht hängt. Eine dort bereits
/// heute angelegte (ggf. verknüpfte) Sitzung ist dieser Instanz also unbekannt, solange sie nur
/// im lokalen Feld nachschaut — das führte dazu, dass eine zweite, unverknüpfte "Sitzplan"-
/// Sitzung für denselben Tag entstand, sobald im echten Sitzplan-Tab bewertet wurde.
private ParticipationSessionOption? EnsureTodaySession()
{
if (SelectedSession is not null) return SelectedSession;
if (!IsEditable) return null;
var today = DateOnly.FromDateTime(DateTime.Today);
var existing = _sessions.GetByGroup(_groupId).FirstOrDefault(s => s.Date == today);
if (existing is not null)
{
var found = TodaySessions.FirstOrDefault(o => o.Id == existing.Id);
if (found is null)
{
found = new ParticipationSessionOption(existing);
TodaySessions.Add(found);
}
SelectedSession = found;
return found;
}
var created = new ParticipationSession
{
GroupId = _groupId, Date = DateOnly.FromDateTime(DateTime.Today), Comment = "Sitzplan",
GroupId = _groupId, Date = today, Comment = "Sitzplan",
};
_sessions.Save(created);
var option = new ParticipationSessionOption(created);
@@ -213,6 +236,13 @@ public partial class SeatingPlanTabViewModel : ObservableObject
NotifyCommands();
}
/// Öffentlich statt intern (kein InternalsVisibleTo in dieser Codebasis, siehe
/// UntisSyncService-Kommentar): der Unterrichtsmodus bewertet über eine zweite, unabhängige
/// ParticipationTabViewModel-Instanz (Schnellbewertungs-Dialoge, siehe TeachingModeWindow) —
/// diese Instanz hier bekommt davon nichts automatisch mit und muss nach jedem Dialog explizit
/// neu aus dem Repository laden, damit die Sitzplatz-Badges nicht veraltet bleiben.
public void ReloadSeatBadgesFromRepository() => RefreshSeatLessonData();
private void RefreshSeatLessonData()
{
if (SelectedSession is null)
@@ -1,3 +1,5 @@
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using LehrerApp.Core.Interfaces;
using LehrerApp.Core.Models;
@@ -10,24 +12,104 @@ namespace LehrerApp.Desktop.ViewModels.Groups;
/// der "Heute"-Tagesliste heraus gestartet.
///
/// Bewusst keine eigene DI-Registrierung als Singleton/Transient: wie LessonViewerViewModel wird
/// diese ViewModel direkt vom Code-Behind konstruiert, das dafür nötige SeatingPlanTabViewModel
/// aber weiterhin über DI aufgelöst (transient, siehe AppBootstrapper) und hier per Konstruktor
/// entgegengenommen statt selbst aus App.Services zu ziehen - ViewModels greifen in diesem Code
/// nicht selbst auf den Service-Container zu, das bleibt Aufgabe des Code-Behind.
/// diese ViewModel direkt vom Code-Behind konstruiert, das dafür nötige SeatingPlanTabViewModel/
/// ParticipationTabViewModel aber weiterhin über DI aufgelöst (transient, siehe AppBootstrapper)
/// und hier per Konstruktor entgegengenommen statt selbst aus App.Services zu ziehen -
/// ViewModels greifen in diesem Code nicht selbst auf den Service-Container zu, das bleibt
/// Aufgabe des Code-Behind.
/// </summary>
public class TeachingModeViewModel
{
public string GroupName { get; }
public LessonViewerViewModel LessonInfo { get; }
public SeatingPlanTabViewModel SeatingPlan { get; }
/// <summary>Nutzer-Feedback: die Schnellbewertungs-Dialoge für Mitarbeit/Anwesenheit-Hausaufgabe
/// gab es bislang nur über den Mitarbeit-Tab der Gruppe — im Unterrichtsmodus musste man dafür
/// erst wieder raus. Hier dieselbe <see cref="ParticipationTabViewModel"/> wie im Mitarbeit-Tab,
/// aber auf die zu dieser Stunde gehörende Sitzung vorselektiert (siehe Konstruktor), damit die
/// über <c>OnQuickInput</c>/<c>OnStatusQuickInput</c> geöffneten Dialoge (Wiring in
/// TeachingModeWindow.axaml.cs, gleiches Muster wie ParticipationTabView.axaml.cs) direkt auf
/// der richtigen Sitzung starten.</summary>
public ParticipationTabViewModel Participation { get; }
public TeachingModeHomeworkViewModel Homework { get; }
public TeachingModeViewModel(Lesson lesson, LearningGroup group,
IAlternativeLessonPathRepository alternativePaths, SeatingPlanTabViewModel seatingPlan)
IAlternativeLessonPathRepository alternativePaths, ILessonRepository lessons,
SeatingPlanTabViewModel seatingPlan, ParticipationTabViewModel participation)
{
GroupName = group.Name;
LessonInfo = new LessonViewerViewModel(lesson, alternativePaths);
SeatingPlan = seatingPlan;
SeatingPlan.Initialize(group.Id, !group.IsActive);
SeatingPlan.SelectOrCreateSessionForLesson(lesson);
Participation = participation;
Participation.Initialize(group.Id, group.SchoolYear, !group.IsActive);
if (SeatingPlan.SelectedSession is { } linkedSession)
Participation.SelectedSession =
Participation.Sessions.FirstOrDefault(s => s.Id == linkedSession.Id);
// 4.5.4: dieselbe "letzte Stunde vor dieser"-Suche wie das Stundenplan-Badge
// "Hausaufgabe kontrollieren" (TimetableViewModel.HasUnhandledHomework) - 120 Tage
// Lookback deckt auch längere Ferienpausen ab, ohne unbegrenzt weit zurückzuscannen.
var previousLesson = lessons.GetByGroupAndRange(group.Id, lesson.Date.AddDays(-120), lesson.Date.AddDays(-1))
.OrderByDescending(l => l.Date).ThenByDescending(l => l.LessonNumber ?? 0)
.FirstOrDefault();
Homework = new TeachingModeHomeworkViewModel(lesson, previousLesson, lessons);
}
}
/// <summary>
/// Nutzer-Feedback: im Unterrichtsmodus sollte man die Hausaufgabe aus der letzten Stunde als
/// kontrolliert abhaken und die neue Hausaufgabe eintragen/ändern können, ohne dafür den vollen
/// <see cref="LehrerApp.Desktop.Views.Groups.LessonDialog"/>-Editor zu öffnen (der zusätzlich Phasen/Reflexion usw. zeigt)
/// - manchmal ergibt sich die Hausaufgabe erst während der Stunde. Bewusst eine eigene, kleine
/// <see cref="ObservableObject"/> statt Felder direkt auf <see cref="TeachingModeViewModel"/>, da
/// diese Klasse (wie <see cref="LessonViewerViewModel"/>) selbst keine Bindable-Basisklasse hat.
/// </summary>
public partial class TeachingModeHomeworkViewModel : ObservableObject
{
private readonly ILessonRepository _lessons;
private readonly Lesson _lesson;
private readonly Lesson? _previousLesson;
public bool HasPreviousHomework =>
_previousLesson is not null && !string.IsNullOrWhiteSpace(_previousLesson.Homework);
public string PreviousHomeworkText => _previousLesson?.Homework ?? "";
public string PreviousLessonLabel => _previousLesson is null ? "" :
$"{_previousLesson.Date:dd.MM.yyyy}" + (string.IsNullOrWhiteSpace(_previousLesson.Topic)
? "" : $" · {_previousLesson.Topic}");
[ObservableProperty] private bool _previousHomeworkChecked;
[ObservableProperty] private string _currentHomework;
[ObservableProperty] private string _saveStatus = "";
public TeachingModeHomeworkViewModel(Lesson lesson, Lesson? previousLesson, ILessonRepository lessons)
{
_lesson = lesson;
_previousLesson = previousLesson;
_lessons = lessons;
_previousHomeworkChecked = previousLesson?.HomeworkChecked ?? false;
_currentHomework = lesson.Homework ?? "";
}
/// Sofort beim Umschalten gespeichert (kein separater Speichern-Klick nötig) - dieselbe
/// Checkbox-Semantik wie im LessonDialog (4.5.4): "kontrolliert" und "nicht kontrollieren"
/// schließen sich aus.
partial void OnPreviousHomeworkCheckedChanged(bool value)
{
if (_previousLesson is null) return;
_previousLesson.HomeworkChecked = value;
if (value) _previousLesson.HomeworkCheckDismissed = false;
_lessons.Save(_previousLesson);
}
[RelayCommand]
private void SaveCurrentHomework()
{
_lesson.Homework = CurrentHomework;
_lessons.Save(_lesson);
SaveStatus = "Gespeichert.";
}
}
@@ -0,0 +1,56 @@
using System.Collections.ObjectModel;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using LehrerApp.Desktop.Services;
namespace LehrerApp.Desktop.ViewModels.Groups;
public partial class WebUntisClassSelectionViewModel(WebUntisIntegrationService untis) : ObservableObject
{
public ObservableCollection<UntisSchoolYearDto> SchoolYears { get; } = [];
public ObservableCollection<UntisClassDto> Classes { get; } = [];
[ObservableProperty] private UntisSchoolYearDto? _selectedSchoolYear;
[ObservableProperty] private UntisClassDto? _selectedClass;
[ObservableProperty] private string _status = "";
[ObservableProperty] private bool _busy;
public bool CanConfirm => SelectedClass is not null && !Busy;
partial void OnSelectedClassChanged(UntisClassDto? value) => OnPropertyChanged(nameof(CanConfirm));
partial void OnBusyChanged(bool value) => OnPropertyChanged(nameof(CanConfirm));
public async Task InitializeAsync()
{
Busy = true;
try
{
foreach (var year in (await untis.GetSchoolYearsAsync()).OrderByDescending(x => x.StartDate))
SchoolYears.Add(year);
SelectedSchoolYear = SchoolYears.FirstOrDefault(x => x.StartDate <= Today() && x.EndDate >= Today())
?? SchoolYears.FirstOrDefault();
await LoadClasses();
}
catch (WebUntisIntegrationException ex) { Status = ex.Message; }
finally { Busy = false; }
}
[RelayCommand]
private async Task LoadClasses()
{
if (SelectedSchoolYear is null) return;
Busy = true; Classes.Clear(); SelectedClass = null;
try
{
foreach (var entry in (await untis.GetClassesAsync(SelectedSchoolYear.UntisId)).OrderBy(x => x.Name))
Classes.Add(entry);
Status = Classes.Count == 0 ? "Keine Klassen gefunden." : $"{Classes.Count} Klassen gefunden.";
}
catch (WebUntisIntegrationException ex) { Status = ex.Message; }
finally { Busy = false; }
}
private static int Today()
{
var date = DateOnly.FromDateTime(DateTime.Today);
return date.Year * 10000 + date.Month * 100 + date.Day;
}
}
@@ -0,0 +1,238 @@
using System.Collections.ObjectModel;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using LehrerApp.Core.Importing;
using LehrerApp.Core.Interfaces;
using LehrerApp.Core.Models;
using LehrerApp.Desktop.Services;
namespace LehrerApp.Desktop.ViewModels.Groups;
/// <summary>Eine Zeile bleibt auch ohne automatische Zuordnung sichtbar (statt stillschweigend
/// weggefiltert zu werden) - <see cref="AssignedStudent"/> kann manuell per Auswahlliste gesetzt
/// werden, wenn weder `ENr` noch der Name eindeutig auf ein Kursmitglied passen.</summary>
public partial class WebUntisLessonAbsenceRow : ObservableObject
{
public required string UntisStudentName { get; init; }
public required DateOnly Date { get; init; }
public required string TimeLabel { get; init; }
public required string UntisStatus { get; init; }
public required AttendanceStatus TargetStatus { get; init; }
public required IReadOnlyList<Student> Candidates { get; init; }
internal Action<WebUntisLessonAbsenceRow>? OnAssignmentChanged { get; init; }
public string? Reason { get; init; }
public string DateLabel => Date.ToString("dd.MM.yyyy");
public bool CanApply => SessionId is not null;
[ObservableProperty] private Student? _assignedStudent;
[ObservableProperty] private string _localStatus = "ohne Zuordnung";
[ObservableProperty] private Guid? _sessionId;
[ObservableProperty] private bool _selected;
partial void OnAssignedStudentChanged(Student? value) => OnAssignmentChanged?.Invoke(this);
}
/// <summary>Fehlzeitenabgleich über den "Fehlzeiten pro Unterricht"-Bericht (<see cref="LearningGroup.WebUntisLessonId"/>).
/// Der früher genutzte, pro Schüler abgerufene Fehlzeiten-Report (getTimetableWithAbsences) brauchte
/// weitergehende WebUntis-Rechte als dieser Lerngruppen-Bericht und wurde deshalb entfernt.</summary>
public partial class WebUntisLessonAbsenceComparisonViewModel : ObservableObject
{
private readonly LearningGroup _group;
private readonly WebUntisIntegrationService _untis;
private readonly IStudentRepository _students;
private readonly IParticipationSessionRepository _sessions;
private readonly IParticipationRepository _participation;
private IReadOnlyList<Student> _loadedStudents = [];
private IReadOnlyDictionary<DateOnly, ParticipationSession> _loadedSessions =
new Dictionary<DateOnly, ParticipationSession>();
public ObservableCollection<WebUntisLessonAbsenceRow> Rows { get; } = [];
[ObservableProperty] private DateTimeOffset _startDate = DateTimeOffset.Now.AddMonths(-2);
[ObservableProperty] private DateTimeOffset _endDate = DateTimeOffset.Now;
[ObservableProperty] private string _status = "Zeitraum wählen und Fehlzeiten laden.";
[ObservableProperty] private bool _busy;
[ObservableProperty] private bool _markUnknownAsPresent;
public WebUntisLessonAbsenceComparisonViewModel(LearningGroup group, WebUntisIntegrationService untis,
IStudentRepository students, IParticipationSessionRepository sessions,
IParticipationRepository participation)
{
_group = group; _untis = untis; _students = students; _sessions = sessions;
_participation = participation;
}
[RelayCommand]
private async Task Load()
{
var lessonId = _group.WebUntisLessonId;
if (lessonId is null)
{
Status = "Für diese Lerngruppe ist noch keine WebUntis-Unterrichtsnummer hinterlegt " +
"(Gruppe bearbeiten).";
return;
}
var start = DateOnly.FromDateTime(StartDate.LocalDateTime);
var end = DateOnly.FromDateTime(EndDate.LocalDateTime);
if (end < start) { Status = "Das Enddatum darf nicht vor dem Startdatum liegen."; return; }
Busy = true; Rows.Clear();
try
{
var courseStudents = _students.GetByGroup(_group.Id);
var localSessions = _sessions.GetByGroup(_group.Id)
.Where(x => x.Date >= start && x.Date <= end).GroupBy(x => x.Date)
.ToDictionary(x => x.Key, x => x.First());
_loadedStudents = courseStudents;
_loadedSessions = localSessions;
// 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
// der Kursmitglieder eindeutig ist, sonst lieber unzugeordnet lassen als raten.
var byKey = courseStudents.Select(student => (Student: student, Key: StudentKey(student)))
.Where(x => x.Key is not null).ToDictionary(x => x.Key!.Value, x => x.Student);
var byName = courseStudents
.SelectMany(student => new[]
{
NameKey($"{student.LastName} {student.FirstName}"),
NameKey($"{student.FirstName} {student.LastName}"),
}.Select(key => (Key: key, Student: student)))
.GroupBy(x => x.Key)
.Where(group => group.Select(x => x.Student).Distinct().Count() == 1)
.ToDictionary(group => group.Key, group => group.First().Student);
var absences = await _untis.GetLessonAbsencesAsync(lessonId.Value, start, end);
var ordered = absences
.Select(absence => (Absence: absence, Date: TryDate(absence.Date, out var date) ? date : (DateOnly?)null))
.Where(x => x.Date is not null)
.OrderBy(x => x.Date).ThenBy(x => x.Absence.StudentName);
void ResolveLocalMatch(WebUntisLessonAbsenceRow row)
{
if (row.AssignedStudent is not { } student)
{
row.SessionId = null; row.LocalStatus = "ohne Zuordnung"; row.Selected = false;
return;
}
localSessions.TryGetValue(row.Date, out var session);
var entry = session is null ? null : _participation.GetBySessionAndStudent(session.Id, student.Id);
row.SessionId = session?.Id;
row.LocalStatus = session is null ? "keine lokale Stunde" : AttendanceDisplay.Label(entry?.Attendance);
row.Selected = session is not null && entry?.Attendance != row.TargetStatus;
}
foreach (var (absence, date) in ordered)
{
var match = absence.ExternKey is { } key && byKey.TryGetValue(key, out var byKeyStudent)
? byKeyStudent
: byName.GetValueOrDefault(NameKey(absence.StudentName));
var row = new WebUntisLessonAbsenceRow
{
UntisStudentName = absence.StudentName, Date = date!.Value,
TimeLabel = TimeLabel(absence.StartTime, absence.EndTime),
UntisStatus = DisplayUntisStatus(absence),
TargetStatus = MapStatus(absence), Reason = absence.Reason,
Candidates = courseStudents, OnAssignmentChanged = ResolveLocalMatch,
};
Rows.Add(row);
row.AssignedStudent = match; // löst OnAssignedStudentChanged aus und setzt SessionId/LocalStatus/Selected
}
var unresolved = Rows.Count(x => x.AssignedStudent is null);
Status = $"{absences.Count} Fehlzeiten von WebUntis erhalten, {Rows.Count - unresolved} automatisch zugeordnet" +
(unresolved > 0 ? $", {unresolved} bitte manuell zuordnen" : "") +
$". {Rows.Count(x => x.CanApply)} sind einer lokalen Kursstunde zuordenbar.";
}
catch (WebUntisIntegrationException ex) { Status = ex.Message; }
finally { Busy = false; }
}
[RelayCommand]
private void Apply()
{
var selected = Rows.Where(x => x.Selected && x.SessionId is not null && x.AssignedStudent is not null).ToList();
foreach (var row in selected)
{
var studentId = row.AssignedStudent!.Id;
var entry = _participation.GetBySessionAndStudent(row.SessionId!.Value, studentId)
?? new ParticipationEntry { SessionId = row.SessionId.Value, StudentId = studentId };
entry.Attendance = row.TargetStatus;
entry.UpdatedAt = DateTime.UtcNow;
_participation.Save(entry);
}
var presentCount = MarkUnknownAsPresent ? FillUnknownAsPresent() : 0;
Status = $"{selected.Count} Anwesenheitsstatus übernommen." +
(MarkUnknownAsPresent ? $" {presentCount} unbekannte Status auf anwesend gesetzt." : "");
foreach (var row in selected) row.Selected = false;
}
// "Identifiziert" heißt hier: WebUntis hat für diesen Schüler an diesem Tag überhaupt eine Zeile
// gemeldet - unabhängig davon, ob die Zeile markiert/übernommen wurde. Nur wer für den geladenen
// Zeitraum weder von WebUntis gemeldet noch lokal schon kontrolliert wurde, gilt als "unbekannt"
// und wird auf anwesend gesetzt; bereits erfasste Einträge (auch ohne Anwesenheitsstatus, z.B. nur
// mit Notiz) werden nicht überschrieben, wenn ihr Anwesenheitsstatus schon gesetzt ist.
private int FillUnknownAsPresent()
{
var identified = Rows.Where(x => x.AssignedStudent is not null)
.Select(x => (x.Date, StudentId: x.AssignedStudent!.Id))
.ToHashSet();
var filled = 0;
foreach (var session in _loadedSessions.Values)
foreach (var student in _loadedStudents)
{
if (identified.Contains((session.Date, student.Id))) continue;
var entry = _participation.GetBySessionAndStudent(session.Id, student.Id);
if (entry?.Attendance is not null) continue;
entry ??= new ParticipationEntry { SessionId = session.Id, StudentId = student.Id };
entry.Attendance = AttendanceStatus.Present;
entry.UpdatedAt = DateTime.UtcNow;
_participation.Save(entry);
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
// dokumentiert und schulabhängig unterschiedlich beobachtet wurde - beide Namensreihenfolgen
// werden beim Aufbau von `byName` registriert; hier wird nur normalisiert.
private static string NameKey(string value) => value.Trim().ToLowerInvariant();
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.
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
// (AttendanceDisplay.Label), statt eigene Statustexte zu erfinden.
private static string DisplayUntisStatus(UntisLessonAbsenceDto absence) =>
string.Join(" · ", new[] { AttendanceDisplay.Label(MapStatus(absence)), absence.Reason }
.Where(x => !string.IsNullOrWhiteSpace(x)));
private static bool TryDate(int value, out DateOnly date) => DateOnly.TryParseExact(value.ToString(), "yyyyMMdd", out date);
private static string TimeLabel(int? start, int? end) =>
start is null || end is null ? "" : $"{Time(start.Value)}{Time(end.Value)}";
private static string Time(int value) => $"{value / 100:00}:{value % 100:00}";
}
@@ -3,6 +3,8 @@ using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using LehrerApp.Core.Services;
using LehrerApp.Desktop.Services;
using LehrerApp.Desktop.ViewModels.ClassTeacher;
using LehrerApp.Desktop.ViewModels.Exams;
using LehrerApp.Desktop.ViewModels.Groups;
using LehrerApp.Desktop.ViewModels.Planning;
using LehrerApp.Desktop.ViewModels.Settings;
@@ -20,10 +22,12 @@ public partial class MainWindowViewModel : ObservableObject
[ObservableProperty] private ObservableObject? _currentPage;
[ObservableProperty] private NavItem _activeNavItem = NavItem.Dashboard;
[ObservableProperty] private string _currentSchoolYear = "";
[ObservableProperty] private bool _isCommandPaletteOpen;
public SyncStatusViewModel SyncStatus { get; }
public ObservableCollection<ToastItem> Toasts { get; }
public AppLockViewModel AppLock { get; }
public GlobalSearchViewModel CommandPalette { get; }
public bool IsDashboardActive => ActiveNavItem == NavItem.Dashboard;
public bool IsGroupsActive => ActiveNavItem == NavItem.Groups;
@@ -31,22 +35,37 @@ public partial class MainWindowViewModel : ObservableObject
public bool IsExamsActive => ActiveNavItem == NavItem.Exams;
public bool IsPlannerActive => ActiveNavItem == NavItem.Planner;
public bool IsWorkloadActive => ActiveNavItem == NavItem.Workload;
public bool IsClassTeacherActive => ActiveNavItem == NavItem.ClassTeacher;
public bool IsSettingsActive => ActiveNavItem == NavItem.Settings;
public MainWindowViewModel(IServiceProvider services,
DashboardViewModel dashboard, SchoolYearService sy,
SyncStatusViewModel syncStatus, NotificationService notifications, AppLockViewModel appLock)
SyncStatusViewModel syncStatus, NotificationService notifications, AppLockViewModel appLock,
GlobalSearchViewModel commandPalette)
{
_services = services;
SyncStatus = syncStatus;
Toasts = notifications.Toasts;
AppLock = appLock;
CommandPalette = commandPalette;
CommandPalette.OnClose = CloseCommandPalette;
CurrentSchoolYear = sy.CurrentSchoolYear();
CurrentPage = dashboard;
AppLock.ApplyConfig();
SyncStatus.DataChanged += OnSyncDataChanged;
}
[RelayCommand]
private void OpenCommandPalette()
{
if (AppLock.IsLocked) return;
CommandPalette.Reset();
IsCommandPaletteOpen = true;
}
[RelayCommand]
private void CloseCommandPalette() => IsCommandPaletteOpen = false;
// EventApplier schreibt bei eingehenden Sync-Ereignissen absichtlich direkt auf die rohe
// LiteDB-Collection, an jedem ViewModel vorbei (Ping-Pong-Vermeidung, siehe EventApplier-
// Klassenkommentar) - ohne diesen Hook blieb die gerade sichtbare Seite bis zum nächsten
@@ -71,6 +90,8 @@ public partial class MainWindowViewModel : ObservableObject
case TimetableViewModel vm: vm.Load(); break;
case WorkloadViewModel vm:
vm.Tasks.Load(); vm.TimeTracking.Load(); vm.Evaluation.Load(); break;
case ClassTeacherOverviewViewModel vm: vm.LoadCommand.Execute(null); break;
case ExamsOverviewViewModel vm: vm.LoadCommand.Execute(null); break;
case GroupDetailViewModel { Group: { } group } vm: vm.LoadGroup(group.Id); break;
// Inline-Bearbeitung (IsEditing) nicht überschreiben - anders als die Gruppenansicht
// laufen Namens-/Geschlechtsänderungen hier nicht über einen Dialog.
@@ -87,6 +108,7 @@ public partial class MainWindowViewModel : ObservableObject
OnPropertyChanged(nameof(IsExamsActive));
OnPropertyChanged(nameof(IsPlannerActive));
OnPropertyChanged(nameof(IsWorkloadActive));
OnPropertyChanged(nameof(IsClassTeacherActive));
OnPropertyChanged(nameof(IsSettingsActive));
}
@@ -99,9 +121,10 @@ public partial class MainWindowViewModel : ObservableObject
NavItem.Dashboard => GetDashboard(),
NavItem.Groups => _services.GetRequiredService<GroupListViewModel>(),
NavItem.Students => GetStudents(),
NavItem.Exams => new PlaceholderViewModel { Title = "Klausuren", Icon = "📝" },
NavItem.Exams => GetExams(),
NavItem.Planner => GetTimetable(),
NavItem.Workload => GetWorkload(),
NavItem.ClassTeacher => GetClassTeacherOverview(),
NavItem.Settings => _services.GetRequiredService<SettingsViewModel>(),
_ => CurrentPage,
};
@@ -141,6 +164,22 @@ public partial class MainWindowViewModel : ObservableObject
return workload;
}
private ExamsOverviewViewModel GetExams()
{
var exams = _services.GetRequiredService<ExamsOverviewViewModel>();
exams.LoadCommand.Execute(null);
return exams;
}
private ClassTeacherOverviewViewModel GetClassTeacherOverview()
{
var vm = _services.GetRequiredService<ClassTeacherOverviewViewModel>();
vm.OnNavigateToSettings = () => { NavigateToSettings(SettingsTab.WebUntis); return Task.CompletedTask; };
vm.OnNavigateToWorkload = () => { NavigateToWorkload(); return Task.CompletedTask; };
vm.LoadCommand.Execute(null);
return vm;
}
public void NavigateToGroupDetail(Guid groupId, int initialTab = 0)
{
ActiveNavItem = NavItem.Groups;
@@ -175,7 +214,7 @@ public partial class MainWindowViewModel : ObservableObject
public void NavigateToWorkload() => NavigateTo(NavItem.Workload);
}
public enum NavItem { Dashboard, Groups, Students, Exams, Planner, Workload, Settings }
public enum NavItem { Dashboard, Groups, Students, Exams, Planner, Workload, ClassTeacher, Settings }
public partial class PlaceholderViewModel : ObservableObject
{
@@ -65,6 +65,7 @@ public partial class TimetableViewModel : ObservableObject
private readonly ISubstitutionEntryRepository _substitutions;
private readonly IUntisSlotMappingRepository _untisMappings;
private readonly WebUntisSettingsService _untisSettings;
private readonly PeriodScheduleService _periodSchedule;
private readonly SchoolWeatherService? _schoolWeather;
private readonly SemaphoreSlim _weatherGate = new(1, 1);
private WeatherSnapshot? _weatherSnapshot;
@@ -103,23 +104,30 @@ public partial class TimetableViewModel : ObservableObject
public Func<TimetableCellItem, Task>? OnEditSlot { get; set; }
public Action<Guid>? OnNavigateToGroup { get; set; }
public Func<Task>? OnAddSubstitution { get; set; }
public Func<Task>? OnImportWebUntisTimetable { get; set; }
public Action<SettingsTab>? OnNavigateToSettings { get; set; }
public Func<Lesson, Task>? OnOpenLessonViewer { get; set; }
public Func<Lesson, Task>? OnOpenTeachingMode { get; set; }
/// Öffentlich statt intern (kein InternalsVisibleTo in dieser Codebasis) - erlaubt Tests, die
/// "heute"-abhängiges Verhalten (Wochenraster-Badges, Unterrichtszeit-Erkennung) prüfen, ohne
/// vom tatsächlichen Wochentag/der Uhrzeit im Testlauf abzuhängen.
public Func<DateTime> Clock { get; set; } = () => DateTime.Now;
public TimetableViewModel(ITimetableSlotRepository slots, IGroupRepository groups,
ISubjectRepository subjects, ILessonRepository lessons, IExamRepository exams,
ISchoolHolidayRepository schoolHolidays, SchoolCalendarSettingsService calendarSettings,
PublicHolidayService publicHolidays, SchoolYearService schoolYear,
ISupervisionDutyRepository supervisionDuties, ISubstitutionEntryRepository substitutions,
IUntisSlotMappingRepository untisMappings, WebUntisSettingsService untisSettings,
SchoolWeatherService? schoolWeather = null)
PeriodScheduleService periodSchedule, SchoolWeatherService? schoolWeather = null)
{
_slots = slots; _groups = groups; _subjects = subjects; _lessons = lessons; _exams = exams;
_schoolHolidays = schoolHolidays; _calendarSettings = calendarSettings;
_publicHolidays = publicHolidays; _schoolYear = schoolYear;
_supervisionDuties = supervisionDuties; _substitutions = substitutions;
_untisMappings = untisMappings; _untisSettings = untisSettings;
_periodSchedule = periodSchedule;
_schoolWeather = schoolWeather;
Load();
}
@@ -143,9 +151,17 @@ public partial class TimetableViewModel : ObservableObject
Load();
}
[RelayCommand]
private async Task ImportWebUntisTimetable()
{
if (OnImportWebUntisTimetable is null) return;
await OnImportWebUntisTimetable();
Load();
}
public void Load()
{
var today = DateOnly.FromDateTime(DateTime.Today);
var today = DateOnly.FromDateTime(Clock());
TodayLabel = today.ToString("dddd, dd.MM.yyyy", System.Globalization.CultureInfo.GetCultureInfo("de-DE"));
var publicHolidayDates = new HashSet<DateOnly>();
@@ -359,14 +375,42 @@ public partial class TimetableViewModel : ObservableObject
await OnOpenTeachingMode(lesson);
}
/// Nutzer-Feedback (zweite Runde): der Direktklick auf eine Wochenraster-Kachel soll
/// "einheitlicher" springen. Existiert eine Lesson UND ist gerade (heute, mit Toleranz vor/
/// nach der Stunde) Unterrichtszeit, geht es direkt in den Unterrichtsmodus — sonst wie
/// bisher in den (schreibgeschützten) Planungsviewer bzw., ohne Lesson, zur Einheitenplanung
/// der Gruppe. Das Popup-Menü (siehe TimetableView.axaml, MenuFlyout je Kachel) bietet
/// daneben immer alle vier Ziele explizit an, unabhängig von dieser Automatik.
[RelayCommand]
private async Task OpenWeekCell(WeekCellItem? item)
{
if (item is null || item.GroupId == Guid.Empty) return;
if (item.Lesson is { } lesson && OnOpenLessonViewer is not null) await OnOpenLessonViewer(lesson);
if (item.Lesson is { } lesson)
{
if (IsAroundTeachingTime(lesson.Date, item.PeriodNumber) && OnOpenTeachingMode is not null)
await OnOpenTeachingMode(lesson);
else if (OnOpenLessonViewer is not null) await OnOpenLessonViewer(lesson);
}
else OnNavigateToGroup?.Invoke(item.GroupId);
}
/// Bewusst mit Toleranz vor/nach der eingetragenen Stundenzeit (siehe
/// <see cref="PeriodScheduleService"/>, "Stundenraster" in den Einstellungen) statt exakt an
/// Start-/Endzeit gebunden — man klickt auch kurz vor Stundenbeginn oder in einer kurzen
/// Verzögerung danach noch typischerweise in Unterrichtsabsicht. Ohne konfiguriertes
/// Stundenraster oder an einem anderen Tag als heute bleibt es beim Planungsviewer.
/// Nimmt bewusst das Datum der Lesson selbst statt WeekCellItem.Date entgegen — Date ist dort
/// nur bei Kopfzeilen (WeekdayHeader) gesetzt, nicht bei regulären Stunden-Kacheln (ForSlot).
private static readonly TimeSpan TeachingTimeTolerance = TimeSpan.FromMinutes(10);
private bool IsAroundTeachingTime(DateOnly lessonDate, int periodNumber)
{
var nowSnapshot = Clock();
if (lessonDate != DateOnly.FromDateTime(nowSnapshot)) return false;
if (_periodSchedule.GetTimes(periodNumber) is not { } times) return false;
var now = TimeOnly.FromDateTime(nowSnapshot);
return now >= times.Start.Add(-TeachingTimeTolerance) && now <= times.End.Add(TeachingTimeTolerance);
}
[RelayCommand]
private void OpenSettings() => OnNavigateToSettings?.Invoke(SettingsTab.Holidays);
@@ -775,9 +819,11 @@ public partial class WeekCellItem : ObservableObject
public bool HasSupervision => SupervisionLocation.Length > 0;
public bool HasRoom => !string.IsNullOrWhiteSpace(Room);
public bool HasTopic => !string.IsNullOrWhiteSpace(Topic);
public bool HasGroupId => GroupId != Guid.Empty;
/// Nur bei einer regulären, zugewiesenen Zelle mit bereits existierender Lesson gesetzt —
/// Grundlage für den Direktsprung in den Verlaufsplan-Viewer (4.5.2).
public Lesson? Lesson { get; private init; }
public bool HasLesson => Lesson is not null;
public string OpenButtonLabel => Lesson is not null ? "Verlaufsplan ansehen" : "Zur Lerngruppe";
[ObservableProperty] private string _weatherSymbol = "";
[ObservableProperty] private string _weatherTooltip = "";
@@ -891,6 +937,9 @@ public class UpcomingExamItem(DateOnly date, string groupName, string title)
public string Title { get; } = title;
}
public enum TimetableLessonDestination { TeachingMode, Viewer, SeatingPlan, Planning }
public sealed record TimetableDestinationOption(TimetableLessonDestination Kind, string Label);
public class TodayLessonItem
{
public Guid GroupId { get; private init; }
@@ -0,0 +1,159 @@
using System.Collections.ObjectModel;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using LehrerApp.Core.Interfaces;
using LehrerApp.Core.Models;
using LehrerApp.Desktop.Services;
namespace LehrerApp.Desktop.ViewModels.Planning;
public sealed record WebUntisGroupOption(Guid Id, string DisplayName);
public partial class WebUntisTimetableRow : ObservableObject
{
public required DayOfWeek Weekday { get; init; }
public required int PeriodNumber { get; init; }
public required string TimeLabel { get; init; }
public required string UntisLabel { get; init; }
public required string SuggestedGroupName { get; init; }
public string? SubjectName { get; init; }
public string? Room { get; init; }
public ObservableCollection<WebUntisGroupOption> GroupOptions { get; } = [];
[ObservableProperty] private WebUntisGroupOption? _selectedGroup;
public string WeekdayLabel => Weekday switch
{
DayOfWeek.Monday => "Mo", DayOfWeek.Tuesday => "Di", DayOfWeek.Wednesday => "Mi",
DayOfWeek.Thursday => "Do", DayOfWeek.Friday => "Fr", _ => Weekday.ToString()[..2],
};
}
public partial class WebUntisTimetableImportViewModel : ObservableObject
{
private readonly WebUntisIntegrationService _untis;
private readonly WebUntisSettingsService _settings;
private readonly ITimetableSlotRepository _slots;
private readonly IGroupRepository _groups;
public ObservableCollection<UntisTeacherDto> Teachers { get; } = [];
public ObservableCollection<WebUntisTimetableRow> Rows { get; } = [];
[ObservableProperty] private UntisTeacherDto? _selectedTeacher;
[ObservableProperty] private DateTimeOffset _weekDate = DateTimeOffset.Now;
[ObservableProperty] private string _status = "Lehrkraft auswählen und Untis-Woche laden.";
[ObservableProperty] private bool _busy;
public bool Saved { get; private set; }
public Func<WebUntisTimetableRow, Task<LearningGroup?>>? OnCreateGroup { get; set; }
public WebUntisTimetableImportViewModel(WebUntisIntegrationService untis, WebUntisSettingsService settings,
ITimetableSlotRepository slots, IGroupRepository groups)
{
_untis = untis; _settings = settings; _slots = slots; _groups = groups;
}
public async Task InitializeAsync()
{
Busy = true;
try
{
foreach (var teacher in (await _untis.GetTeachersAsync()).Where(x => x.Active).OrderBy(x => x.DisplayName))
Teachers.Add(teacher);
SelectedTeacher = Teachers.FirstOrDefault(x => x.UntisId == _settings.TeacherUntisId)
?? Teachers.FirstOrDefault();
Status = Teachers.Count == 0 ? "WebUntis hat keine Lehrkräfte geliefert." : "Bereit zum Laden.";
}
catch (WebUntisIntegrationException ex) { Status = ex.Message; }
finally { Busy = false; }
}
[RelayCommand]
private async Task Load()
{
if (SelectedTeacher is null) { Status = "Bitte eine Lehrkraft auswählen."; return; }
Busy = true; Rows.Clear();
try
{
var selected = DateOnly.FromDateTime(WeekDate.LocalDateTime);
var monday = selected.AddDays(-(((int)selected.DayOfWeek + 6) % 7));
var periods = await _untis.GetTimetableAsync(SelectedTeacher.UntisId, monday, monday.AddDays(6));
var grid = await _untis.GetTimeGridAsync();
var groupOptions = BuildGroupOptions();
var localSlots = _slots.GetAll().ToDictionary(x => (x.Weekday, x.PeriodNumber));
foreach (var period in periods.Where(x => string.IsNullOrWhiteSpace(x.Code) || x.Code != "cancelled")
.GroupBy(x => (x.Date, x.StartTime, x.EndTime, x.StudentGroup,
Class: string.Join("/", x.Classes.Select(c => c.Name)),
Subject: string.Join("/", x.Subjects.Select(s => s.Name)),
Room: string.Join("/", x.Rooms.Select(r => r.Name))))
.Select(x => x.First()).OrderBy(x => x.Date).ThenBy(x => x.StartTime))
{
if (!TryDate(period.Date, out var date) || date.DayOfWeek is DayOfWeek.Saturday or DayOfWeek.Sunday)
continue;
var dayGrid = grid.FirstOrDefault(x => x.Day == UntisDay(date.DayOfWeek));
var number = dayGrid?.TimeUnits.ToList().FindIndex(x => x.StartTime == period.StartTime) + 1 ?? 0;
if (number <= 0) continue;
var className = period.Classes.FirstOrDefault()?.Name ?? "";
var subject = period.Subjects.FirstOrDefault()?.Name;
var suggested = !string.IsNullOrWhiteSpace(period.StudentGroup) ? period.StudentGroup! : className;
var row = new WebUntisTimetableRow
{
Weekday = date.DayOfWeek, PeriodNumber = number,
TimeLabel = $"{Time(period.StartTime)}{Time(period.EndTime)}",
UntisLabel = string.Join(" · ", new[] { subject, suggested, period.Rooms.FirstOrDefault()?.Name }
.Where(x => !string.IsNullOrWhiteSpace(x))),
SuggestedGroupName = suggested, SubjectName = subject,
Room = period.Rooms.FirstOrDefault()?.Name,
};
foreach (var option in groupOptions) row.GroupOptions.Add(option);
if (localSlots.TryGetValue((row.Weekday, row.PeriodNumber), out var existing))
row.SelectedGroup = groupOptions.FirstOrDefault(x => x.Id == existing.GroupId);
row.SelectedGroup ??= BestMatch(groupOptions, suggested, className, subject);
Rows.Add(row);
}
_settings.SetTeacherUntisId(SelectedTeacher.UntisId);
Status = Rows.Count == 0 ? "In dieser Woche wurde kein Unterricht gefunden."
: $"{Rows.Count} regelmäßige Termine gefunden. Zuordnung prüfen und übernehmen.";
}
catch (WebUntisIntegrationException ex) { Status = ex.Message; }
finally { Busy = false; }
}
[RelayCommand]
private async Task CreateGroup(WebUntisTimetableRow row)
{
if (OnCreateGroup is null) return;
var group = await OnCreateGroup(row);
if (group is null) return;
var option = new WebUntisGroupOption(group.Id, group.Name);
foreach (var item in Rows.Where(x => x.SuggestedGroupName == row.SuggestedGroupName))
{
item.GroupOptions.Add(option);
item.SelectedGroup = option;
}
}
[RelayCommand]
private void Save()
{
var selected = Rows.Where(x => x.SelectedGroup is not null).ToList();
foreach (var row in selected)
{
var existing = _slots.GetAll().FirstOrDefault(x => x.Weekday == row.Weekday && x.PeriodNumber == row.PeriodNumber);
var slot = existing ?? new TimetableSlot { Weekday = row.Weekday, PeriodNumber = row.PeriodNumber };
slot.GroupId = row.SelectedGroup!.Id;
slot.Room = string.IsNullOrWhiteSpace(row.Room) ? null : row.Room;
_slots.Save(slot);
}
Saved = true;
Status = $"{selected.Count} Stundenplan-Einträge übernommen.";
}
private List<WebUntisGroupOption> BuildGroupOptions() => _groups.GetAll().OrderBy(x => x.Name)
.Select(x => new WebUntisGroupOption(x.Id, x.Name)).ToList();
private static WebUntisGroupOption? BestMatch(IEnumerable<WebUntisGroupOption> options, params string?[] terms) =>
options.FirstOrDefault(x => terms.Any(term => !string.IsNullOrWhiteSpace(term) &&
(x.DisplayName.Equals(term, StringComparison.OrdinalIgnoreCase) ||
x.DisplayName.Contains(term, StringComparison.OrdinalIgnoreCase))));
private static int UntisDay(DayOfWeek day) => day == DayOfWeek.Sunday ? 7 : (int)day;
private static bool TryDate(int value, out DateOnly date) => DateOnly.TryParseExact(value.ToString(), "yyyyMMdd", out date);
private static string Time(int value) => $"{value / 100:00}:{value % 100:00}";
}
@@ -210,7 +210,21 @@ public partial class SettingsViewModel : ObservableObject
[ObservableProperty] private string _untisUrlError = "";
[ObservableProperty] private string _untisStatusDisplay = "";
[ObservableProperty] private bool _untisFetchBusy;
[ObservableProperty] private bool _untisApiIsConfigured;
[ObservableProperty] private string _untisSchool = "";
[ObservableProperty] private string _untisHost = "";
[ObservableProperty] private string _untisUsername = "";
[ObservableProperty] private string _untisPassword = "";
[ObservableProperty] private string _untisApiStatus = "";
[ObservableProperty] private bool _untisApiBusy;
[ObservableProperty] private string? _untisHomeroomClassName;
public string UntisHomeroomClassDisplay => UntisHomeroomClassName is { Length: > 0 } name
? $"Ausgewählt: {name}"
: "Keine Klasse ausgewählt.";
partial void OnUntisHomeroomClassNameChanged(string? value) => OnPropertyChanged(nameof(UntisHomeroomClassDisplay));
public Func<Task>? OnReviewUntisMapping { get; set; }
/// Vom Code-Behind gesetzt: öffnet den WebUntis-Klassenauswahldialog, liefert null bei Abbruch.
public Func<Task<(int UntisId, string Name)?>>? OnPickHomeroomClass { get; set; }
// ── Schulweiter Jahresplan (ClassyPlan-iCal) ─────────────────────────────
@@ -299,6 +313,7 @@ public partial class SettingsViewModel : ObservableObject
private readonly AiSettingsService _aiSettings;
private readonly AiPlanningService _aiPlanning;
private readonly WebUntisSettingsService _untisSettings;
private readonly WebUntisIntegrationService? _untisIntegration;
private readonly UntisSyncService? _untisSync;
private readonly AnnualPlanSettingsService _annualPlanSettings;
private readonly AnnualPlanSyncService? _annualPlanSync;
@@ -330,7 +345,7 @@ public partial class SettingsViewModel : ObservableObject
AppearanceSettingsService appearance, TrashViewModel trashTab,
SnapshotService? snapshotService = null, SyncEngine? syncEngine = null,
UntisSyncService? untisSync = null, AnnualPlanSyncService? annualPlanSync = null,
SchoolWeatherService? schoolWeather = null)
SchoolWeatherService? schoolWeather = null, WebUntisIntegrationService? untisIntegration = null)
{
_logger = logger;
_syncKeyRecovery = syncKeyRecovery;
@@ -360,6 +375,7 @@ public partial class SettingsViewModel : ObservableObject
_aiSettings = aiSettings;
_aiPlanning = aiPlanning;
_untisSettings = untisSettings;
_untisIntegration = untisIntegration;
_untisSync = untisSync;
_annualPlanSettings = annualPlanSettings;
_annualPlanSync = annualPlanSync;
@@ -542,6 +558,61 @@ public partial class SettingsViewModel : ObservableObject
UntisStatusDisplay = _untisSettings.LastSyncAt is { } at
? $"Letzter Abgleich: {at.ToLocalTime():dd.MM.yyyy HH:mm} — {_untisSettings.LastSyncStatus}"
: "Noch kein Abgleich durchgeführt.";
UntisApiIsConfigured = _untisSettings.ApiIsConfigured;
if (_untisSettings.GetApiCredentials() is { } credentials)
{
UntisSchool = credentials.School;
UntisHost = credentials.Host;
UntisUsername = credentials.Username;
UntisApiStatus = $"API-Zugang für {credentials.Username} ist lokal verschlüsselt gespeichert.";
}
UntisHomeroomClassName = _untisSettings.HomeroomClassName;
}
[RelayCommand]
private async Task UntisSaveApi()
{
UntisApiStatus = "";
if (_untisIntegration is null)
{
UntisApiStatus = "Die WebUntis-Integration ist nicht verfügbar.";
return;
}
if (string.IsNullOrWhiteSpace(UntisSchool) || string.IsNullOrWhiteSpace(UntisUsername) ||
string.IsNullOrWhiteSpace(UntisPassword))
{
UntisApiStatus = "Schule, Benutzername und Passwort sind erforderlich.";
return;
}
UntisApiBusy = true;
try
{
var credentials = new WebUntisCredentials(UntisSchool.Trim(), UntisHost.Trim(),
UntisUsername.Trim(), UntisPassword);
await _untisIntegration.ConnectAsync(credentials);
_untisSettings.SetApiCredentials(credentials);
UntisPassword = "";
UntisApiIsConfigured = true;
UntisApiStatus = "Anmeldung erfolgreich. Die WebUntis-Session bleibt bei Nutzung bis zu 10 Minuten offen.";
}
catch (WebUntisIntegrationException ex) { UntisApiStatus = ex.Message; }
finally { UntisApiBusy = false; }
}
[RelayCommand]
private async Task UntisRemoveApi()
{
UntisApiBusy = true;
try { if (_untisIntegration is not null) await _untisIntegration.DisconnectAsync(); }
catch (WebUntisIntegrationException) { /* lokale Zugangsdaten trotzdem sicher entfernen */ }
finally
{
_untisSettings.ClearApiCredentials();
UntisPassword = "";
UntisApiIsConfigured = false;
UntisApiStatus = "WebUntis-API-Zugang entfernt.";
UntisApiBusy = false;
}
}
[RelayCommand]
@@ -584,6 +655,23 @@ public partial class SettingsViewModel : ObservableObject
if (OnReviewUntisMapping is not null) await OnReviewUntisMapping();
}
[RelayCommand]
private async Task UntisPickHomeroomClass()
{
if (OnPickHomeroomClass is null) return;
var result = await OnPickHomeroomClass();
if (result is null) return;
_untisSettings.SetHomeroomClass(result.Value.UntisId, result.Value.Name);
LoadUntisSettings();
}
[RelayCommand]
private void UntisClearHomeroomClass()
{
_untisSettings.SetHomeroomClass(null, null);
LoadUntisSettings();
}
// ── Schulweiter Jahresplan: Laden / Speichern / Entfernen / Jetzt abrufen ─
private void LoadAnnualPlanSettings()
@@ -107,6 +107,7 @@ public partial class DocumentationDialogViewModel : ObservableObject
[ObservableProperty] private string _title = "";
[ObservableProperty] private string _content = "";
[ObservableProperty] private bool _isConfidential;
[ObservableProperty] private bool _excludeFromWebUntisSync;
[ObservableProperty] private string _newParticipant = "";
public ObservableCollection<string> Participants { get; } = [];
@@ -176,6 +177,7 @@ public partial class DocumentationDialogViewModel : ObservableObject
Title = editing.Title;
Content = editing.Content;
IsConfidential = editing.IsConfidential;
ExcludeFromWebUntisSync = editing.ExcludeFromWebUntisSync;
foreach (var p in editing.Participants) Participants.Add(p);
if (editing.AbsenceData is { } a)
{
@@ -343,6 +345,7 @@ public partial class DocumentationDialogViewModel : ObservableObject
// Schnellentwurf ab. Stunden- und Lesson-Bezug bleiben am bestehenden Objekt erhalten.
Result.IsDraft = false;
Result.IsConfidential = IsConfidential;
Result.ExcludeFromWebUntisSync = ExcludeFromWebUntisSync;
Result.Participants = type == DocumentationType.Conversation ? Participants.ToList() : [];
Result.AbsenceData = type == DocumentationType.Absence
? new AbsenceData
@@ -0,0 +1,192 @@
using System.Collections.ObjectModel;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using LehrerApp.Core.Interfaces;
using LehrerApp.Core.Models;
using LehrerApp.Core.Services;
using LehrerApp.Desktop.Services;
namespace LehrerApp.Desktop.ViewModels.Students;
/// <summary>Eine Zeile bleibt auch ohne automatische Zuordnung sichtbar - <see cref="AssignedStudent"/>
/// kann manuell per Auswahlliste gesetzt werden, wenn der Name nicht eindeutig auf ein/e Schüler*in
/// passt (siehe WebUntisLessonAbsenceComparisonViewModel für dasselbe Muster).</summary>
public partial class WebUntisDocumentationRow : ObservableObject
{
public required string ClassName { get; init; }
public required DateOnly Date { get; init; }
public required string UntisStudentName { get; init; }
public string? Subject { get; init; }
public string? CategoryName { get; init; }
public string? CategoryGroup { get; init; }
public string? Text { get; init; }
public required IReadOnlyList<Student> Candidates { get; init; }
public string DateLabel => Date.ToString("dd.MM.yyyy");
public bool CanApply => AssignedStudent is not null;
[ObservableProperty] private Student? _assignedStudent;
/// <summary>Titel/Text eines lokalen Eintrags, der am selben Tag für diese/n Schüler*in schon
/// existiert - nur nach Datum+Schüler*in erkannt, nicht nach Wortlaut (der unterscheidet sich oft
/// von der WebUntis-Kategorie). Deshalb Anzeige zum Vergleichen statt automatischem Ausblenden.</summary>
[ObservableProperty] private string? _existingLocalEntry;
[ObservableProperty] private bool _selected;
}
/// <summary>Lokaler Dokumentationseintrag im geladenen Zeitraum ohne passenden WebUntis-Eintrag - kann
/// nicht automatisch nach WebUntis geschrieben werden (bewusst keine schreibenden Aufrufe gegen die
/// undokumentierte API), deshalb nur als Kopiervorlage für die manuelle Nacherfassung dort.</summary>
public sealed record LocalOnlyDocumentationRow(
string StudentName, DateOnly Date, string? GroupName, string Title, string Content)
{
public string DateLabel => Date.ToString("dd.MM.yyyy");
public string ClipboardText =>
$"{DateLabel} {StudentName}" + (GroupName is null ? "" : $" ({GroupName})") +
$"\n{Title}\n{Content}";
}
/// <summary>Abgleich der WebUntis-Klassenbucheinträge (eigene, siehe
/// <see cref="WebUntisIntegrationService.GetOwnClassRegisterEventsAsync"/>) gegen die lokale
/// <see cref="Documentation"/> - Dashboard-weit statt pro Lerngruppe, weil der WebUntis-"-alle-"-
/// Bericht ebenfalls klassenübergreifend ist (siehe TODO.md, Nachtrag zu 4.3).</summary>
public partial class WebUntisDocumentationComparisonViewModel : ObservableObject
{
private readonly WebUntisIntegrationService _untis;
private readonly IStudentRepository _students;
private readonly IGroupRepository _groups;
private readonly IDocumentationRepository _documentation;
private readonly SchoolYearService _schoolYears;
public ObservableCollection<WebUntisDocumentationRow> Rows { get; } = [];
public ObservableCollection<LocalOnlyDocumentationRow> LocalOnlyRows { get; } = [];
[ObservableProperty] private DateTimeOffset _startDate = DateTimeOffset.Now.AddDays(-7);
[ObservableProperty] private DateTimeOffset _endDate = DateTimeOffset.Now;
[ObservableProperty] private string _status = "Zeitraum wählen und Klassenbucheinträge laden.";
[ObservableProperty] private bool _busy;
public WebUntisDocumentationComparisonViewModel(WebUntisIntegrationService untis,
IStudentRepository students, IGroupRepository groups, IDocumentationRepository documentation,
SchoolYearService schoolYears)
{
_untis = untis; _students = students; _groups = groups; _documentation = documentation;
_schoolYears = schoolYears;
}
[RelayCommand]
private async Task Load()
{
var start = DateOnly.FromDateTime(StartDate.LocalDateTime);
var end = DateOnly.FromDateTime(EndDate.LocalDateTime);
if (end < start) { Status = "Das Enddatum darf nicht vor dem Startdatum liegen."; return; }
Busy = true; Rows.Clear(); LocalOnlyRows.Clear();
try
{
var ownStudents = _students.GetAll();
var globalIndex = BuildNameIndex(ownStudents);
// Zusätzlich pro Klasse (aktuelles Schuljahr) indiziert: löst den Fall "gleicher Name in
// verschiedenen Klassen" auf, den ein rein globaler Namensabgleich nicht unterscheiden könnte.
var currentSchoolYear = _schoolYears.CurrentSchoolYear();
var classIndexes = _groups.GetAll()
.Where(g => g.Type == GroupType.Class && g.SchoolYear == currentSchoolYear)
.GroupBy(g => g.Name, StringComparer.OrdinalIgnoreCase)
.ToDictionary(
g => g.Key,
g => BuildNameIndex(g.SelectMany(x => _students.GetByGroup(x.Id)).Distinct().ToList()),
StringComparer.OrdinalIgnoreCase);
var entries = await _untis.GetOwnClassRegisterEventsAsync(start, end);
var localDocs = _documentation.GetAll().Where(d => d.Date >= start && d.Date <= end).ToList();
var ordered = entries
.Select(e => (Entry: e, Date: TryDate(e.Date, out var d) ? d : (DateOnly?)null))
.Where(x => x.Date is not null)
.OrderBy(x => x.Date).ThenBy(x => x.Entry.StudentName);
// Nur nach Schüler*in+Datum erkannt, nicht nach Wortlaut: ein lokaler Eintrag von vor dieser
// Funktion (oder frei formuliert) hat selten denselben Titel wie die WebUntis-Kategorie.
List<Documentation> LocalDocsFor(Guid studentId, DateOnly date) =>
localDocs.Where(d => d.StudentId == studentId && d.Date == date).ToList();
foreach (var (entry, date) in ordered)
{
var nameKey = NameKey(entry.StudentName);
var match = (classIndexes.TryGetValue(entry.ClassName, out var classIndex)
? classIndex.GetValueOrDefault(nameKey)
: null)
?? globalIndex.GetValueOrDefault(nameKey);
var existing = match is null ? [] : LocalDocsFor(match.Id, date!.Value);
Rows.Add(new WebUntisDocumentationRow
{
ClassName = entry.ClassName, Date = date!.Value, UntisStudentName = entry.StudentName,
Subject = entry.Subject, CategoryName = entry.CategoryName, CategoryGroup = entry.CategoryGroup,
Text = entry.Text, Candidates = ownStudents, AssignedStudent = match,
ExistingLocalEntry = existing.Count == 0 ? null
: string.Join(" | ", existing.Select(d => $"{d.Title}: {d.Content}")),
Selected = match is not null && existing.Count == 0,
});
}
foreach (var doc in localDocs)
{
if (doc.ExcludeFromWebUntisSync) continue;
var coveredByReport = Rows.Any(r => r.AssignedStudent?.Id == doc.StudentId && r.Date == doc.Date);
if (coveredByReport) continue;
var student = ownStudents.FirstOrDefault(s => s.Id == doc.StudentId);
if (student is null) continue;
LocalOnlyRows.Add(new LocalOnlyDocumentationRow(student.FullName, doc.Date,
doc.GroupId is { } groupId ? _groups.GetById(groupId)?.Name : null, doc.Title, doc.Content));
}
var unresolved = Rows.Count(x => x.AssignedStudent is null);
var possibleDuplicates = Rows.Count(x => x.ExistingLocalEntry is not null);
Status = $"{Rows.Count} WebUntis-Einträge erhalten, {possibleDuplicates} mit lokalem Eintrag am " +
"selben Tag (bitte vergleichen)" +
(unresolved > 0 ? $", {unresolved} bitte manuell zuordnen" : "") +
$". {LocalOnlyRows.Count} lokale Einträge ohne WebUntis-Gegenstück.";
}
catch (WebUntisIntegrationException ex) { Status = ex.Message; }
finally { Busy = false; }
}
[RelayCommand]
private void Apply()
{
var selected = Rows.Where(x => x.Selected && x.CanApply).ToList();
foreach (var row in selected)
{
_documentation.Save(new Documentation
{
StudentId = row.AssignedStudent!.Id,
Date = row.Date,
Type = DocumentationType.Incident,
Title = row.CategoryName ?? "WebUntis-Klassenbucheintrag",
Content = row.Text ?? "",
Tags = row.CategoryGroup is { Length: > 0 } group ? [group] : [],
});
}
foreach (var row in selected)
{
row.ExistingLocalEntry = $"{row.CategoryName}: {row.Text}";
row.Selected = false;
}
Status = $"{selected.Count} Einträge aus WebUntis übernommen.";
}
private static string NameKey(string value) => value.Trim().ToLowerInvariant();
// Wie beim Fehlzeiten-Abgleich: WebUntis liefert Namen nicht einheitlich in einer Reihenfolge,
// deshalb werden beide Reihenfolgen registriert, aber nur falls innerhalb der Kandidaten eindeutig.
private static Dictionary<string, Student> BuildNameIndex(IReadOnlyList<Student> candidates) =>
candidates
.SelectMany(student => new[]
{
NameKey($"{student.LastName} {student.FirstName}"),
NameKey($"{student.FirstName} {student.LastName}"),
}.Select(key => (Key: key, Student: student)))
.GroupBy(x => x.Key)
.Where(group => group.Select(x => x.Student).Distinct().Count() == 1)
.ToDictionary(group => group.Key, group => group.First().Student);
private static bool TryDate(int value, out DateOnly date) =>
DateOnly.TryParseExact(value.ToString(), "yyyyMMdd", out date);
}
@@ -0,0 +1,101 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="clr-namespace:LehrerApp.Desktop.ViewModels.ClassTeacher"
x:Class="LehrerApp.Desktop.Views.ClassTeacher.ClassTeacherAbsencesView"
x:DataType="vm:ClassTeacherDetailsViewModel">
<UserControl.Styles>
<!-- Siehe Styles/SemanticBrushes.axaml: Farben per DynamicResource, damit der Theme-Wechsel
sie live austauscht; die Statusfarbe je Zeile kommt als Style-Klasse aus dem ViewModel. -->
<Style Selector="Border.filterCard">
<Setter Property="Background" Value="{DynamicResource AppCardBackgroundBrush}"/>
<Setter Property="BorderBrush" Value="{DynamicResource AppCardBorderBrush}"/>
<Setter Property="BorderThickness" Value="1"/>
<Setter Property="CornerRadius" Value="8"/>
<Setter Property="Padding" Value="10"/>
</Style>
<Style Selector="TextBlock.status">
<Setter Property="Foreground" Value="{DynamicResource AppStatusOkBrush}"/>
</Style>
<Style Selector="TextBlock.status.info">
<Setter Property="Foreground" Value="{DynamicResource AppStatusInfoBrush}"/>
</Style>
<Style Selector="TextBlock.status.warning">
<Setter Property="Foreground" Value="{DynamicResource AppStatusWarningBrush}"/>
</Style>
<Style Selector="TextBlock.status.danger">
<Setter Property="Foreground" Value="{DynamicResource AppStatusDangerBrush}"/>
</Style>
</UserControl.Styles>
<Grid RowDefinitions="Auto,Auto,*,Auto" Margin="16" RowSpacing="10">
<Grid Grid.Row="0" ColumnDefinitions="*,Auto">
<StackPanel>
<TextBlock Text="Fehlzeiten" FontSize="22" FontWeight="SemiBold"/>
<TextBlock Text="Pro Schüler*in und Tag über alle Fächer zusammengefasst" FontSize="12" Opacity="0.55"/>
</StackPanel>
<Button Grid.Column="1" Content="↻ Aus WebUntis aktualisieren" Command="{Binding RefreshCommand}"
IsEnabled="{Binding !Busy}" Background="Transparent" Foreground="{DynamicResource AppAccentTextBrush}" VerticalAlignment="Center"/>
</Grid>
<Border Grid.Row="1" Classes="filterCard">
<Grid RowDefinitions="Auto,Auto" RowSpacing="8">
<Grid Grid.Row="0" ColumnDefinitions="155,Auto,*,Auto,*" ColumnSpacing="8">
<ComboBox Grid.Column="0" SelectedIndex="{Binding QuickRangeIndex, Mode=TwoWay}">
<ComboBoxItem Content="Heute"/><ComboBoxItem Content="Letzte 7 Tage"/><ComboBoxItem Content="Letzte 30 Tage"/>
</ComboBox>
<TextBlock Grid.Column="1" Text="von" VerticalAlignment="Center" Opacity="0.6"/>
<DatePicker Grid.Column="2" SelectedDate="{Binding StartDate}" HorizontalAlignment="Stretch"/>
<TextBlock Grid.Column="3" Text="bis" VerticalAlignment="Center" Opacity="0.6"/>
<DatePicker Grid.Column="4" SelectedDate="{Binding EndDate}" HorizontalAlignment="Stretch"/>
</Grid>
<Grid Grid.Row="1" ColumnDefinitions="*,Auto,Auto" ColumnSpacing="8">
<TextBox Grid.Column="0" Text="{Binding StudentFilter, Mode=TwoWay}" PlaceholderText="Schüler*in filtern…"/>
<Button Grid.Column="1" Content="Anzeigen" Command="{Binding ApplyFilterCommand}" IsEnabled="{Binding !Busy}"/>
<Button Grid.Column="2" Content="Filter löschen" Command="{Binding ClearStudentFilterCommand}"
IsVisible="{Binding StudentFilter, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
</Grid>
</Grid>
</Border>
<Grid Grid.Row="2">
<DataGrid ItemsSource="{Binding AbsenceEntries}" AutoGenerateColumns="False" IsReadOnly="True"
GridLinesVisibility="Horizontal" BorderBrush="{DynamicResource AppCardBorderBrush}" BorderThickness="1"
CanUserResizeColumns="True" CanUserSortColumns="True" RowHeight="48">
<DataGrid.Columns>
<DataGridTextColumn Header="Datum" Binding="{Binding DateLabel}" Width="105"/>
<DataGridTextColumn Header="Schüler*in" Binding="{Binding StudentDisplayName}" Width="1.3*"/>
<DataGridTemplateColumn Header="Art" Width="1.15*">
<DataGridTemplateColumn.CellTemplate>
<DataTemplate x:DataType="vm:ClassAbsenceDaySummaryRow">
<TextBlock Text="{Binding KindLabel}" Classes="status"
Classes.warning="{Binding IsWarningStatus}"
Classes.danger="{Binding IsDangerStatus}" VerticalAlignment="Center"/>
</DataTemplate>
</DataGridTemplateColumn.CellTemplate>
</DataGridTemplateColumn>
<DataGridTemplateColumn Header="Status" Width="1*">
<DataGridTemplateColumn.CellTemplate>
<DataTemplate x:DataType="vm:ClassAbsenceDaySummaryRow">
<Border Background="{DynamicResource AppChipBackgroundBrush}" CornerRadius="5"
Padding="7,3" HorizontalAlignment="Left">
<TextBlock Text="{Binding FriendlyStatusLabel}" Classes="status"
Classes.warning="{Binding IsWarningStatus}"
Classes.danger="{Binding IsDangerStatus}" FontSize="11"/>
</Border>
</DataTemplate>
</DataGridTemplateColumn.CellTemplate>
</DataGridTemplateColumn>
<DataGridTextColumn Header="Fächer" Binding="{Binding SubjectsLabel}" Width="1.2*"/>
<DataGridTextColumn Header="Stunden" Binding="{Binding PeriodsLabel}" Width="0.75*"/>
<DataGridTextColumn Header="Grund / Notiz" Binding="{Binding DetailLabel}" Width="2*"/>
</DataGrid.Columns>
</DataGrid>
<StackPanel IsVisible="{Binding !HasAbsenceEntries}" HorizontalAlignment="Center" VerticalAlignment="Center" Spacing="5">
<TextBlock Text="Keine Fehlzeiten im gewählten Zeitraum" FontWeight="SemiBold"/>
<TextBlock Text="Passe Zeitraum oder Schülerfilter an." FontSize="12" Opacity="0.55"/>
</StackPanel>
</Grid>
<TextBlock Grid.Row="3" Text="{Binding Status}" FontSize="11" Opacity="0.6"/>
</Grid>
</UserControl>
@@ -0,0 +1,8 @@
using Avalonia.Controls;
namespace LehrerApp.Desktop.Views.ClassTeacher;
public partial class ClassTeacherAbsencesView : UserControl
{
public ClassTeacherAbsencesView() => InitializeComponent();
}
@@ -0,0 +1,482 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="clr-namespace:LehrerApp.Desktop.ViewModels.ClassTeacher"
xmlns:views="clr-namespace:LehrerApp.Desktop.Views.ClassTeacher"
xmlns:conv="clr-namespace:LehrerApp.Desktop.Converters"
x:Class="LehrerApp.Desktop.Views.ClassTeacher.ClassTeacherOverviewView"
x:DataType="vm:ClassTeacherOverviewViewModel">
<!-- Flächen- und Statusfarben kommen aus Styles/SemanticBrushes.axaml und werden bewusst per
DynamicResource geholt, damit der Theme-Wechsel (hell/dunkel) sie live austauscht. Die
Statusfarbe einer Zeile setzt deshalb ein Style-Selektor über Klassen und nicht ein
Hex-String aus dem ViewModel — ein lokal gesetztes Attribut würde den Selektor überstimmen. -->
<UserControl.Styles>
<Style Selector="Border.metric">
<Setter Property="Background" Value="{DynamicResource AppCardBackgroundBrush}"/>
<Setter Property="BorderBrush" Value="{DynamicResource AppCardBorderBrush}"/>
<Setter Property="BorderThickness" Value="1"/>
<Setter Property="CornerRadius" Value="8"/>
<Setter Property="Padding" Value="14,12"/>
<Setter Property="Margin" Value="0,0,10,0"/>
</Style>
<Style Selector="Button.filter">
<Setter Property="Background" Value="Transparent"/>
<Setter Property="BorderBrush" Value="{DynamicResource AppFilterBorderBrush}"/>
<Setter Property="BorderThickness" Value="1"/>
<Setter Property="CornerRadius" Value="6"/>
<Setter Property="Padding" Value="16,6"/>
<Setter Property="FontSize" Value="12"/>
</Style>
<Style Selector="Button.filter.active">
<Setter Property="Background" Value="{DynamicResource AppFilterActiveBackgroundBrush}"/>
<Setter Property="BorderBrush" Value="{DynamicResource AppFilterActiveBorderBrush}"/>
<Setter Property="Foreground" Value="{DynamicResource AppFilterActiveForegroundBrush}"/>
</Style>
<Style Selector="Button.rosterRow">
<Setter Property="HorizontalAlignment" Value="Stretch"/>
<Setter Property="HorizontalContentAlignment" Value="Stretch"/>
<Setter Property="Background" Value="{DynamicResource AppListRowBackgroundBrush}"/>
<Setter Property="BorderBrush" Value="{DynamicResource AppListRowBorderBrush}"/>
<Setter Property="BorderThickness" Value="1,0,1,1"/>
<Setter Property="Padding" Value="0"/>
<Setter Property="MinHeight" Value="48"/>
</Style>
<Style Selector="Button.rosterRow:pointerover /template/ ContentPresenter">
<Setter Property="Background" Value="{DynamicResource AppListRowHoverBrush}"/>
</Style>
<Style Selector="Button.rosterRowAction">
<Setter Property="Width" Value="30"/>
<Setter Property="Height" Value="48"/>
<Setter Property="Background" Value="{DynamicResource AppListRowBackgroundBrush}"/>
<Setter Property="BorderBrush" Value="{DynamicResource AppListRowBorderBrush}"/>
<Setter Property="BorderThickness" Value="0,0,1,1"/>
<Setter Property="Foreground" Value="{DynamicResource AppAccentTextBrush}"/>
<Setter Property="FontSize" Value="16"/>
<Setter Property="Padding" Value="0"/>
<Setter Property="HorizontalContentAlignment" Value="Center"/>
<Setter Property="VerticalContentAlignment" Value="Center"/>
</Style>
<Style Selector="Button.rosterRowAction:pointerover /template/ ContentPresenter">
<Setter Property="Background" Value="{DynamicResource AppListRowHoverBrush}"/>
</Style>
<Style Selector="Border.sidePanel">
<Setter Property="Background" Value="{DynamicResource AppCardBackgroundBrush}"/>
<Setter Property="BorderBrush" Value="{DynamicResource AppCardBorderBrush}"/>
<Setter Property="BorderThickness" Value="1"/>
<Setter Property="CornerRadius" Value="8"/>
<Setter Property="Padding" Value="14"/>
<Setter Property="Margin" Value="0,0,0,10"/>
</Style>
<Style Selector="TextBlock.sectionLabel">
<Setter Property="FontSize" Value="13"/>
<Setter Property="FontWeight" Value="SemiBold"/>
<Setter Property="Margin" Value="4,10,0,6"/>
</Style>
<!-- Statusstufe: Basis ist "unauffällig", die Klassen aus dem ViewModel schalten hoch. -->
<Style Selector="TextBlock.status">
<Setter Property="Foreground" Value="{DynamicResource AppStatusOkBrush}"/>
</Style>
<Style Selector="TextBlock.status.info">
<Setter Property="Foreground" Value="{DynamicResource AppStatusInfoBrush}"/>
</Style>
<Style Selector="TextBlock.status.warning">
<Setter Property="Foreground" Value="{DynamicResource AppStatusWarningBrush}"/>
</Style>
<Style Selector="TextBlock.status.danger">
<Setter Property="Foreground" Value="{DynamicResource AppStatusDangerBrush}"/>
</Style>
<Style Selector="Border.statusFill">
<Setter Property="Background" Value="{DynamicResource AppStatusOkBrush}"/>
</Style>
<Style Selector="Border.statusFill.info">
<Setter Property="Background" Value="{DynamicResource AppStatusInfoBrush}"/>
</Style>
<Style Selector="Border.statusFill.warning">
<Setter Property="Background" Value="{DynamicResource AppStatusWarningBrush}"/>
</Style>
<Style Selector="Border.statusFill.danger">
<Setter Property="Background" Value="{DynamicResource AppStatusDangerBrush}"/>
</Style>
</UserControl.Styles>
<UserControl.Resources>
<!-- Eine Vorlage für beide Roster-Listen (auffällig / übrige): Sie waren Zeichen für Zeichen
identisch und mussten bisher doppelt gepflegt werden. -->
<DataTemplate x:Key="RosterRowTemplate" x:DataType="vm:ClassTeacherRosterRow">
<!-- Zwei Geschwister-Controls statt eines ContextMenu auf dem Zeilen-Button: Avalonia löst
$parent-Vorfahrenpfade in einem ContextMenu nicht zuverlässig auf, weil dessen Popup
nicht im normalen visuellen Baum unter dem ItemsControl hängt. Ein separater kleiner
Button daneben bleibt im normalen Baum und kann so ganz regulär auf das ViewModel des
ItemsControl zugreifen. -->
<Grid ColumnDefinitions="*,Auto">
<Button Grid.Column="0" Classes="rosterRow"
Command="{Binding $parent[ItemsControl].((vm:ClassTeacherOverviewViewModel)DataContext).ShowDetailsForStudentCommand}"
CommandParameter="{Binding}">
<Grid ColumnDefinitions="4,42,220,*,150,18">
<Border Grid.Column="0" Classes="statusFill"
Classes.info="{Binding IsInfoStatus}"
Classes.warning="{Binding IsWarningStatus}"
Classes.danger="{Binding IsDangerStatus}"/>
<Border Grid.Column="1" Width="28" Height="28" CornerRadius="14"
BorderBrush="{DynamicResource AppAvatarBorderBrush}" BorderThickness="1"
HorizontalAlignment="Center" VerticalAlignment="Center"
ToolTip.Tip="{Binding AbsenceTooltip}">
<TextBlock Text="{Binding Initials}" FontSize="10"
HorizontalAlignment="Center" VerticalAlignment="Center"/>
</Border>
<TextBlock Grid.Column="2" Text="{Binding StudentName}" VerticalAlignment="Center"
TextTrimming="CharacterEllipsis"/>
<StackPanel Grid.Column="3" VerticalAlignment="Center" Spacing="1"
ToolTip.Tip="{Binding YearSummaryTooltip}">
<!-- Quick-Win Barrierefreiheit: Status nicht ausschließlich über Farbe, sondern auch
über ein vorangestelltes Symbol bei Warnung/Gefahr (StatusTextWithGlyph). -->
<TextBlock Text="{Binding StatusTextWithGlyph}" Classes="status"
Classes.info="{Binding IsInfoStatus}"
Classes.warning="{Binding IsWarningStatus}"
Classes.danger="{Binding IsDangerStatus}"
FontSize="12" TextTrimming="CharacterEllipsis"/>
<!-- Fehlquote seit Schuljahresbeginn (Nutzer-Feedback: der Heute-Snapshot allein sagt
für Zeugnis/Attestpflicht wenig aus) — als dezente zweite Zeile statt eigener
Spalte, damit das bestehende Layout nicht neu vermessen werden muss. -->
<TextBlock Text="{Binding YearSummaryLabel}" IsVisible="{Binding HasYearSummary}"
FontSize="10" Opacity="0.5" TextTrimming="CharacterEllipsis"/>
</StackPanel>
<TextBlock Grid.Column="4" Text="{Binding ClassRegisterLabel}"
Foreground="{DynamicResource AppAccentTextBrush}"
VerticalAlignment="Center" FontSize="11"/>
<TextBlock Grid.Column="5" Text="" FontSize="18" Opacity="0.7" VerticalAlignment="Center"/>
</Grid>
</Button>
<!-- Quick-Win: "Eltern kontaktieren" als vorbefüllte Wiedervorlage direkt aus der Zeile
anlegen, statt erst ins Aufgaben-Modul zu wechseln und von Hand einzutragen. -->
<Button Grid.Column="1" Classes="rosterRowAction" Content="+"
ToolTip.Tip="Wiedervorlage „Eltern kontaktieren&#8220; anlegen"
Command="{Binding $parent[ItemsControl].((vm:ClassTeacherOverviewViewModel)DataContext).CreateReminderForStudentCommand}"
CommandParameter="{Binding}"/>
</Grid>
</DataTemplate>
</UserControl.Resources>
<Grid>
<StackPanel IsVisible="{Binding !HomeroomClassConfigured}" HorizontalAlignment="Center"
VerticalAlignment="Center" Spacing="12" MaxWidth="380">
<Border Width="56" Height="56" CornerRadius="28" HorizontalAlignment="Center"
Background="{DynamicResource AppAccentSoftBackgroundBrush}">
<TextBlock Text="KL" FontSize="18" FontWeight="Bold"
Foreground="{DynamicResource AppAccentOnSoftBrush}"
HorizontalAlignment="Center" VerticalAlignment="Center"/>
</Border>
<TextBlock Text="Noch keine Klasse ausgewählt" FontSize="18" FontWeight="SemiBold" HorizontalAlignment="Center"/>
<TextBlock TextWrapping="Wrap" TextAlignment="Center" FontSize="13" Opacity="0.65"
Text="Wähle in den WebUntis-Einstellungen die Klasse aus, deren Klassenlehrer/-in du bist."/>
<Button Content="Zu den Einstellungen" Command="{Binding GoToSettingsCommand}" HorizontalAlignment="Center"/>
</StackPanel>
<TabbedPage IsVisible="{Binding HomeroomClassConfigured}" TabPlacement="Top"
SelectedIndex="{Binding ActiveTabIndex, Mode=TwoWay}">
<ContentPage Header="Übersicht">
<Grid RowDefinitions="Auto,Auto,*" Margin="16,12,16,16" RowSpacing="10">
<Grid Grid.Row="0" ColumnDefinitions="*,Auto">
<StackPanel Grid.Column="0" Spacing="2">
<TextBlock Text="{Binding HomeroomClassName, StringFormat='Klasse {0}'}"
FontSize="24" FontWeight="SemiBold"/>
<TextBlock Text="Klassenlehrer-Übersicht" FontSize="13" Opacity="0.58"/>
</StackPanel>
<StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="10" VerticalAlignment="Center">
<TextBlock Text="{Binding LastUpdatedLabel}" FontSize="11" Opacity="0.55" VerticalAlignment="Center"/>
<Button Content="↻ Aktualisieren" Command="{Binding LoadCommand}" IsEnabled="{Binding !Busy}"
Background="Transparent" BorderThickness="0"
Foreground="{DynamicResource AppAccentTextBrush}"/>
</StackPanel>
</Grid>
<Grid Grid.Row="1" ColumnDefinitions="*,*,*,*">
<Border Grid.Column="0" Classes="metric">
<Grid ColumnDefinitions="36,*">
<TextBlock Text="◉" FontSize="22" VerticalAlignment="Center"
Foreground="{DynamicResource AppStatusInfoBrush}"/>
<StackPanel Grid.Column="1"><TextBlock Text="{Binding StudentCount}" FontSize="22" FontWeight="SemiBold"/><TextBlock Text="Schüler*innen" FontSize="11" Opacity="0.6"/></StackPanel>
</Grid>
</Border>
<Border Grid.Column="1" Classes="metric">
<Grid ColumnDefinitions="36,*">
<TextBlock Text="△" FontSize="24" VerticalAlignment="Center"
Foreground="{DynamicResource AppStatusWarningBrush}"/>
<StackPanel Grid.Column="1"><TextBlock Text="{Binding TodayAlertCount}" FontSize="22" FontWeight="SemiBold"/><TextBlock Text="heute auffällig" FontSize="11" Opacity="0.6"/></StackPanel>
</Grid>
</Border>
<Border Grid.Column="2" Classes="metric">
<Grid ColumnDefinitions="36,*">
<TextBlock Text="!" FontSize="22" VerticalAlignment="Center"
Foreground="{DynamicResource AppStatusDangerBrush}"/>
<StackPanel Grid.Column="1"><TextBlock Text="{Binding TodayUnexcusedCount}" FontSize="22" FontWeight="SemiBold"/><TextBlock Text="{Binding TodayUnexcusedSummaryLabel}" FontSize="11" Opacity="0.6"/></StackPanel>
</Grid>
</Border>
<Border Grid.Column="3" Classes="metric" Margin="0">
<Grid ColumnDefinitions="36,*">
<TextBlock Text="▤" FontSize="21" VerticalAlignment="Center"
Foreground="{DynamicResource AppStatusInfoBrush}"/>
<StackPanel Grid.Column="1"><TextBlock Text="{Binding RecentClassRegisterCount}" FontSize="22" FontWeight="SemiBold"/><TextBlock Text="Klassenbucheinträge · 7 Tage" FontSize="11" Opacity="0.6"/></StackPanel>
</Grid>
</Border>
</Grid>
<Grid Grid.Row="2" ColumnDefinitions="*,286" ColumnSpacing="12">
<Grid Grid.Column="0" RowDefinitions="Auto,*">
<Border Grid.Row="0" Background="{DynamicResource AppCardBackgroundBrush}"
BorderBrush="{DynamicResource AppCardBorderBrush}" BorderThickness="1"
CornerRadius="8" Padding="8" Margin="0,0,0,4">
<Grid ColumnDefinitions="Auto,Auto,Auto,*,250" ColumnSpacing="8">
<Button Grid.Column="0" Classes="filter" Classes.active="{Binding AlertsFilterSelected}"
Content="Auffällig" Command="{Binding ShowAlertsCommand}"/>
<Button Grid.Column="1" Classes="filter" Classes.active="{Binding ClassRegisterFilterSelected}"
Content="Klassenbuch" Command="{Binding ShowClassRegisterCommand}"/>
<Button Grid.Column="2" Classes="filter" Classes.active="{Binding AllFilterSelected}"
Content="Alle" Command="{Binding ShowAllCommand}"/>
<TextBox Grid.Column="4" Text="{Binding SearchText, Mode=TwoWay}" PlaceholderText="Schüler*in suchen…"
FontSize="12" MinHeight="32"/>
</Grid>
</Border>
<ScrollViewer Grid.Row="1">
<StackPanel>
<TextBlock Classes="sectionLabel" Text="{Binding PrimarySectionTitle}" IsVisible="{Binding HasPrimaryRoster}"/>
<ItemsControl ItemsSource="{Binding PrimaryRoster}" ItemTemplate="{StaticResource RosterRowTemplate}"/>
<TextBlock Classes="sectionLabel" Text="{Binding SecondarySectionTitle}" IsVisible="{Binding HasSecondaryRoster}"/>
<ItemsControl ItemsSource="{Binding SecondaryRoster}" ItemTemplate="{StaticResource RosterRowTemplate}"/>
<StackPanel IsVisible="{Binding HasNoFilterResults}" HorizontalAlignment="Center" Margin="0,54,0,0" Spacing="6">
<TextBlock Text="Keine passenden Schüler*innen" FontWeight="SemiBold" HorizontalAlignment="Center"/>
<TextBlock Text="Passe Filter oder Suche an." FontSize="12" Opacity="0.55"/>
</StackPanel>
</StackPanel>
</ScrollViewer>
</Grid>
<ScrollViewer Grid.Column="1">
<StackPanel>
<Border Classes="sidePanel">
<StackPanel Spacing="9">
<TextBlock Text="Tagesüberblick" FontSize="14" FontWeight="SemiBold"/>
<!-- Quick-Win: ein gestapelter 100-%-Balken statt vier Einzelzeilen mit
separater Prozentspalte — Verteilung auf einen Blick statt aus vier
Prozentzahlen erschlossen. Jedes Segment multipliziert per MultiBinding
die gerenderte Breite der Track-Leiste (x:Name="DayOverviewTrack") mit
seinem Anteil 0…1 (FractionWidthConverter) — bleibt dadurch korrekt, egal
wie breit die Seitenspalte in XAML gerade ist.
Reihenfolge Ok→Info→Warnung→Gefahr (nicht Ok→Warnung→Info→Gefahr):
Grün und das validierte Dunkel-Gelb liegen unter Protanopie zu nah
beieinander (ΔE 3.0), direkt benachbart wäre das dieselbe Verwechslung
wie beim ursprünglichen Nutzer-Feedback zu Warnung/Gefahr. Mit Info
dazwischen entspricht die Reihenfolge zudem der echten Dringlichkeit
(siehe ClassTeacherRosterRow.AttentionRank: Gefahr > Warnung > Info > Ok). -->
<Grid Height="10" ToolTip.Tip="{Binding DayOverviewTooltip}">
<Border x:Name="DayOverviewTrack" Background="{DynamicResource AppTrackBackgroundBrush}"
CornerRadius="5"/>
<Border CornerRadius="5" ClipToBounds="True" HorizontalAlignment="Left">
<StackPanel Orientation="Horizontal">
<Border Classes="statusFill">
<Border.Width>
<MultiBinding Converter="{x:Static conv:FractionWidthConverter.Instance}">
<Binding ElementName="DayOverviewTrack" Path="Bounds.Width"/>
<Binding Path="PresentFraction"/>
</MultiBinding>
</Border.Width>
</Border>
<Border Classes="statusFill info">
<Border.Width>
<MultiBinding Converter="{x:Static conv:FractionWidthConverter.Instance}">
<Binding ElementName="DayOverviewTrack" Path="Bounds.Width"/>
<Binding Path="ExcusedAbsenceFraction"/>
</MultiBinding>
</Border.Width>
</Border>
<Border Classes="statusFill warning">
<Border.Width>
<MultiBinding Converter="{x:Static conv:FractionWidthConverter.Instance}">
<Binding ElementName="DayOverviewTrack" Path="Bounds.Width"/>
<Binding Path="LateFraction"/>
</MultiBinding>
</Border.Width>
</Border>
<Border Classes="statusFill danger">
<Border.Width>
<MultiBinding Converter="{x:Static conv:FractionWidthConverter.Instance}">
<Binding ElementName="DayOverviewTrack" Path="Bounds.Width"/>
<Binding Path="UnexcusedAbsenceFraction"/>
</MultiBinding>
</Border.Width>
</Border>
</StackPanel>
</Border>
</Grid>
<StackPanel Spacing="5">
<StackPanel Orientation="Horizontal" Spacing="6"><Border Width="7" Height="7" CornerRadius="4" Classes="statusFill"/><TextBlock Text="{Binding PresentSummaryLabel}" FontSize="12"/></StackPanel>
<StackPanel Orientation="Horizontal" Spacing="6"><Border Width="7" Height="7" CornerRadius="4" Classes="statusFill info"/><TextBlock Text="{Binding ExcusedAbsenceSummaryLabel}" FontSize="12"/></StackPanel>
<StackPanel Orientation="Horizontal" Spacing="6"><Border Width="7" Height="7" CornerRadius="4" Classes="statusFill warning"/><TextBlock Text="{Binding LateSummaryLabel}" FontSize="12"/></StackPanel>
<StackPanel Orientation="Horizontal" Spacing="6"><Border Width="7" Height="7" CornerRadius="4" Classes="statusFill danger"/><TextBlock Text="{Binding UnexcusedAbsenceSummaryLabel}" FontSize="12"/></StackPanel>
</StackPanel>
</StackPanel>
</Border>
<Border Classes="sidePanel">
<StackPanel Spacing="8">
<TextBlock Text="Trend · letzte 7 Tage" FontSize="14" FontWeight="SemiBold"/>
<ItemsControl ItemsSource="{Binding TrendDays}">
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:ClassTeacherTrendDay">
<Grid ColumnDefinitions="42,*,24" Margin="0,2">
<TextBlock Text="{Binding DayLabel}" FontSize="11" Opacity="0.6" VerticalAlignment="Center"/>
<Grid Grid.Column="1" Height="8" VerticalAlignment="Center"
ToolTip.Tip="{Binding DetailTooltip}">
<Border x:Name="TrendTrack" Background="{DynamicResource AppTrackBackgroundBrush}"
CornerRadius="4"/>
<!-- Gestapelter Balken: drei sich nicht überschneidende Segmente
(siehe ClassTeacherTrendDay). Jedes Segment multipliziert per
MultiBinding die gerenderte Breite von "TrendTrack" mit seinem
Anteil 0…1 (FractionWidthConverter) — bleibt damit auch dann
korrekt proportional, wenn die Seitenspalte in XAML mal eine
andere Breite bekommt. Der äußere Wrapper ist auf CornerRadius
zugeschnitten, damit nur die äußeren Kanten rund sind, nicht
jedes Segment einzeln. -->
<Border CornerRadius="4" ClipToBounds="True" HorizontalAlignment="Left">
<Border.Width>
<MultiBinding Converter="{x:Static conv:FractionWidthConverter.Instance}">
<Binding ElementName="TrendTrack" Path="Bounds.Width"/>
<Binding Path="TotalFraction"/>
</MultiBinding>
</Border.Width>
<StackPanel Orientation="Horizontal">
<Border Classes="statusFill danger">
<Border.Width>
<MultiBinding Converter="{x:Static conv:FractionWidthConverter.Instance}">
<Binding ElementName="TrendTrack" Path="Bounds.Width"/>
<Binding Path="UnexcusedFraction"/>
</MultiBinding>
</Border.Width>
</Border>
<Border Classes="statusFill warning">
<Border.Width>
<MultiBinding Converter="{x:Static conv:FractionWidthConverter.Instance}">
<Binding ElementName="TrendTrack" Path="Bounds.Width"/>
<Binding Path="LateExcusedFraction"/>
</MultiBinding>
</Border.Width>
</Border>
<Border Classes="statusFill info">
<Border.Width>
<MultiBinding Converter="{x:Static conv:FractionWidthConverter.Instance}">
<Binding ElementName="TrendTrack" Path="Bounds.Width"/>
<Binding Path="ExcusedFraction"/>
</MultiBinding>
</Border.Width>
</Border>
</StackPanel>
</Border>
</Grid>
<TextBlock Grid.Column="2" Text="{Binding AlertCount}" FontSize="11" HorizontalAlignment="Right"/>
</Grid>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<StackPanel Orientation="Horizontal" Spacing="10">
<StackPanel Orientation="Horizontal" Spacing="4">
<Border Width="7" Height="7" CornerRadius="4" Classes="statusFill danger"/>
<TextBlock Text="Unentsch." FontSize="10" Opacity="0.55"/>
</StackPanel>
<StackPanel Orientation="Horizontal" Spacing="4">
<Border Width="7" Height="7" CornerRadius="4" Classes="statusFill warning"/>
<TextBlock Text="Verspätet" FontSize="10" Opacity="0.55"/>
</StackPanel>
<StackPanel Orientation="Horizontal" Spacing="4">
<Border Width="7" Height="7" CornerRadius="4" Classes="statusFill info"/>
<TextBlock Text="Entschuldigt" FontSize="10" Opacity="0.55"/>
</StackPanel>
</StackPanel>
</StackPanel>
</Border>
<Border Classes="sidePanel" IsVisible="{Binding HasOpenExcuses}">
<StackPanel Spacing="8">
<TextBlock Text="Offene Entschuldigungen" FontSize="14" FontWeight="SemiBold"/>
<ItemsControl ItemsSource="{Binding OpenExcuses}">
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:ClassTeacherOpenExcuseRow">
<Grid ColumnDefinitions="*,Auto">
<Button Grid.Column="0" Classes="rosterRow" Padding="6,5"
Command="{Binding $parent[ItemsControl].((vm:ClassTeacherOverviewViewModel)DataContext).ShowDetailsForOpenExcuseCommand}"
CommandParameter="{Binding}">
<Grid ColumnDefinitions="4,*,Auto" Margin="0,2">
<Border Grid.Column="0" CornerRadius="2" Margin="0,1,8,1"
Classes="statusFill" Classes.warning="{Binding !IsOverdue}"
Classes.danger="{Binding IsOverdue}"/>
<StackPanel Grid.Column="1">
<TextBlock Text="{Binding StudentName}" FontSize="12" FontWeight="SemiBold"/>
<TextBlock Text="{Binding DaysOpenLabel}" FontSize="11" Opacity="0.62"/>
</StackPanel>
<TextBlock Grid.Column="2" Text="{Binding DateLabel}" FontSize="11"
Opacity="0.5" VerticalAlignment="Center"/>
</Grid>
</Button>
<Button Grid.Column="1" Classes="rosterRowAction" Content="+"
ToolTip.Tip="Wiedervorlage „Eltern kontaktieren&#8220; anlegen"
Command="{Binding $parent[ItemsControl].((vm:ClassTeacherOverviewViewModel)DataContext).CreateReminderForOpenExcuseCommand}"
CommandParameter="{Binding}"/>
</Grid>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<TextBlock FontSize="11" Opacity="0.5" IsVisible="{Binding HasOpenExcuseOverflow}">
<Run Text="+"/><Run Text="{Binding OpenExcuseOverflowCount}"/><Run Text=" weitere"/>
</TextBlock>
</StackPanel>
</Border>
<Border Classes="sidePanel" IsVisible="{Binding HasPatternNotices}">
<StackPanel Spacing="8">
<TextBlock Text="Muster erkannt" FontSize="14" FontWeight="SemiBold"/>
<ItemsControl ItemsSource="{Binding PatternNotices}">
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:ClassTeacherPatternNotice">
<Grid ColumnDefinitions="4,*" Margin="0,3">
<Border CornerRadius="2" Margin="0,1,8,1" Classes="statusFill"
Classes.info="{Binding IsInfoStatus}"
Classes.warning="{Binding IsWarningStatus}"
Classes.danger="{Binding IsDangerStatus}"/>
<StackPanel Grid.Column="1">
<TextBlock Text="{Binding StudentName}" FontSize="12" FontWeight="SemiBold"/>
<TextBlock Text="{Binding Message}" FontSize="11" Opacity="0.62" TextWrapping="Wrap"/>
</StackPanel>
</Grid>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</StackPanel>
</Border>
<Border Classes="sidePanel">
<StackPanel Spacing="8">
<TextBlock Text="Nächste Schritte" FontSize="14" FontWeight="SemiBold"/>
<Button Content="Klassenbuch öffnen" Command="{Binding OpenClassRegisterCommand}" HorizontalAlignment="Stretch"/>
<Button Content="Fehlzeiten öffnen" Command="{Binding OpenAbsencesCommand}" HorizontalAlignment="Stretch"/>
<Button Content="Aufgaben &amp; Wiedervorlagen" Command="{Binding GoToWorkloadCommand}"
HorizontalAlignment="Stretch" Background="Transparent"/>
</StackPanel>
</Border>
<TextBlock Text="{Binding Status}" FontSize="11" TextWrapping="Wrap" Opacity="0.6"/>
</StackPanel>
</ScrollViewer>
</Grid>
</Grid>
</ContentPage>
<ContentPage Header="Klassenbuch">
<views:ClassTeacherRegisterView DataContext="{Binding DetailsTab}"/>
</ContentPage>
<ContentPage Header="Fehlzeiten">
<views:ClassTeacherAbsencesView DataContext="{Binding DetailsTab}"/>
</ContentPage>
</TabbedPage>
</Grid>
</UserControl>
@@ -0,0 +1,8 @@
using Avalonia.Controls;
namespace LehrerApp.Desktop.Views.ClassTeacher;
public partial class ClassTeacherOverviewView : UserControl
{
public ClassTeacherOverviewView() => InitializeComponent();
}
@@ -0,0 +1,110 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="clr-namespace:LehrerApp.Desktop.ViewModels.ClassTeacher"
x:Class="LehrerApp.Desktop.Views.ClassTeacher.ClassTeacherRegisterView"
x:DataType="vm:ClassTeacherDetailsViewModel">
<UserControl.Styles>
<!-- Siehe Styles/SemanticBrushes.axaml: Farben per DynamicResource, damit der Theme-Wechsel
sie live austauscht; die Statusfarbe je Zeile kommt als Style-Klasse aus dem ViewModel. -->
<Style Selector="Border.filterCard">
<Setter Property="Background" Value="{DynamicResource AppCardBackgroundBrush}"/>
<Setter Property="BorderBrush" Value="{DynamicResource AppCardBorderBrush}"/>
<Setter Property="BorderThickness" Value="1"/>
<Setter Property="CornerRadius" Value="8"/>
<Setter Property="Padding" Value="10"/>
</Style>
<Style Selector="TextBlock.status">
<Setter Property="Foreground" Value="{DynamicResource AppStatusOkBrush}"/>
</Style>
<Style Selector="TextBlock.status.info">
<Setter Property="Foreground" Value="{DynamicResource AppStatusInfoBrush}"/>
</Style>
<Style Selector="TextBlock.status.warning">
<Setter Property="Foreground" Value="{DynamicResource AppStatusWarningBrush}"/>
</Style>
<Style Selector="TextBlock.status.danger">
<Setter Property="Foreground" Value="{DynamicResource AppStatusDangerBrush}"/>
</Style>
</UserControl.Styles>
<Grid RowDefinitions="Auto,Auto,Auto,*,Auto" Margin="16" RowSpacing="10">
<Grid Grid.Row="0" ColumnDefinitions="*,Auto">
<StackPanel>
<TextBlock Text="Klassenbucheinträge" FontSize="22" FontWeight="SemiBold"/>
<TextBlock Text="Einträge anderer Lehrkräfte nur zur Ansicht" FontSize="12" Opacity="0.55"/>
</StackPanel>
<Button Grid.Column="1" Content="↻ Aus WebUntis aktualisieren" Command="{Binding RefreshCommand}"
IsEnabled="{Binding !Busy}" Background="Transparent" Foreground="{DynamicResource AppAccentTextBrush}" VerticalAlignment="Center"/>
</Grid>
<Border Grid.Row="1" Classes="filterCard">
<Grid RowDefinitions="Auto,Auto" RowSpacing="8">
<Grid Grid.Row="0" ColumnDefinitions="155,Auto,*,Auto,*" ColumnSpacing="8">
<ComboBox Grid.Column="0" SelectedIndex="{Binding QuickRangeIndex, Mode=TwoWay}">
<ComboBoxItem Content="Heute"/><ComboBoxItem Content="Letzte 7 Tage"/><ComboBoxItem Content="Letzte 30 Tage"/>
</ComboBox>
<TextBlock Grid.Column="1" Text="von" VerticalAlignment="Center" Opacity="0.6"/>
<DatePicker Grid.Column="2" SelectedDate="{Binding StartDate}" HorizontalAlignment="Stretch"/>
<TextBlock Grid.Column="3" Text="bis" VerticalAlignment="Center" Opacity="0.6"/>
<DatePicker Grid.Column="4" SelectedDate="{Binding EndDate}" HorizontalAlignment="Stretch"/>
</Grid>
<Grid Grid.Row="1" ColumnDefinitions="*,Auto,Auto" ColumnSpacing="8">
<TextBox Grid.Column="0" Text="{Binding StudentFilter, Mode=TwoWay}" PlaceholderText="Schüler*in filtern…"/>
<Button Grid.Column="1" Content="Anzeigen" Command="{Binding ApplyFilterCommand}" IsEnabled="{Binding !Busy}"/>
<Button Grid.Column="2" Content="Filter löschen" Command="{Binding ClearStudentFilterCommand}"
IsVisible="{Binding StudentFilter, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
</Grid>
</Grid>
</Border>
<!-- Nutzer-Feedback: die reine Gesamtzahl der Einträge sagt wenig, eine Aufschlüsselung nach
Kategorie ("Hausaufgaben fehlen: 12× — 5× Ben Schmidt, 3× Ada Müller") ist die eigentlich
interessante Information. Als Chip-Reihe statt eigener Spalte in der Tabelle, damit die
bestehenden Spalten unangetastet bleiben. -->
<Border Grid.Row="2" Classes="filterCard" IsVisible="{Binding HasCategoryAggregates}">
<StackPanel Spacing="6">
<TextBlock Text="Kategorien im Zeitraum" FontSize="12" FontWeight="SemiBold" Opacity="0.7"/>
<ItemsControl ItemsSource="{Binding CategoryAggregates}">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate>
<WrapPanel/>
</ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:ClassTeacherCategoryAggregateRow">
<Border Background="{DynamicResource AppChipBackgroundBrush}" CornerRadius="5"
Padding="8,4" Margin="0,0,6,6">
<StackPanel Orientation="Horizontal" Spacing="4">
<TextBlock Text="{Binding CategoryName}" FontSize="11" FontWeight="SemiBold"/>
<TextBlock Text="{Binding SummaryLabel}" FontSize="11" Opacity="0.6"/>
</StackPanel>
</Border>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</StackPanel>
</Border>
<Grid Grid.Row="3">
<DataGrid ItemsSource="{Binding Entries}" AutoGenerateColumns="False" IsReadOnly="True"
GridLinesVisibility="Horizontal" BorderBrush="{DynamicResource AppCardBorderBrush}" BorderThickness="1"
CanUserResizeColumns="True" CanUserSortColumns="True" RowHeight="46">
<DataGrid.Columns>
<DataGridTextColumn Header="Datum" Binding="{Binding DateLabel}" Width="105"/>
<DataGridTextColumn Header="Schüler*in" Binding="{Binding StudentDisplayName}" Width="1.25*"/>
<DataGridTextColumn Header="Fach" Binding="{Binding Subject}" Width="0.7*"/>
<DataGridTextColumn Header="Lehrkraft" Binding="{Binding TeacherUsername}" Width="0.8*"/>
<DataGridTextColumn Header="Kategorie" Binding="{Binding CategoryName}" Width="1*"/>
<DataGridTextColumn Header="Gruppe" Binding="{Binding CategoryGroup}" Width="0.8*"/>
<DataGridTextColumn Header="Eintrag" Binding="{Binding Text}" Width="2*"/>
</DataGrid.Columns>
</DataGrid>
<StackPanel IsVisible="{Binding !HasEntries}" HorizontalAlignment="Center" VerticalAlignment="Center" Spacing="5">
<TextBlock Text="Keine Klassenbucheinträge im gewählten Zeitraum" FontWeight="SemiBold"/>
<TextBlock Text="Passe Zeitraum oder Schülerfilter an." FontSize="12" Opacity="0.55"/>
</StackPanel>
</Grid>
<TextBlock Grid.Row="4" Text="{Binding Status}" FontSize="11" Opacity="0.6"/>
</Grid>
</UserControl>
@@ -0,0 +1,8 @@
using Avalonia.Controls;
namespace LehrerApp.Desktop.Views.ClassTeacher;
public partial class ClassTeacherRegisterView : UserControl
{
public ClassTeacherRegisterView() => InitializeComponent();
}
@@ -25,10 +25,40 @@
<TextBlock Text="{Binding Greeting}" FontSize="14" Opacity="0.6"/>
<TextBlock Text="{Binding CurrentDate}" FontSize="24" FontWeight="SemiBold"/>
</StackPanel>
<Button Grid.Column="1" Content="Dashboard anpassen" Command="{Binding ToggleDashboardSettingsCommand}"
<StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="8">
<Button Content="Klassenbuch abgleichen…" Click="OnCompareWebUntisDocumentationClick"
VerticalAlignment="Center"/>
<Button Content="Bereiche anpassen" Command="{Binding ToggleDashboardSettingsCommand}"
VerticalAlignment="Center"/>
</StackPanel>
</Grid>
<!-- Der Tagesfokus beantwortet zuerst die vier Fragen, die beim Öffnen der App zählen:
Was unterrichte ich, was ist zu tun, wo muss ich reagieren und was steht an? -->
<Border Background="{DynamicResource AppCardBackgroundBrush}"
BorderBrush="{DynamicResource AppCardBorderBrush}" BorderThickness="1"
CornerRadius="10" Padding="16,14">
<Grid ColumnDefinitions="*,*,*,*">
<StackPanel Grid.Column="0" Spacing="3">
<TextBlock Text="UNTERRICHT HEUTE" FontSize="10" FontWeight="Bold" Opacity="0.5"/>
<TextBlock Text="{Binding TodayLessonSummary}" FontSize="18" FontWeight="SemiBold"/>
</StackPanel>
<StackPanel Grid.Column="1" Spacing="3" Margin="18,0,0,0">
<TextBlock Text="AUFGABEN" FontSize="10" FontWeight="Bold" Opacity="0.5"/>
<TextBlock Text="{Binding OpenTaskSummary}" FontSize="18" FontWeight="SemiBold"/>
</StackPanel>
<StackPanel Grid.Column="2" Spacing="3" Margin="18,0,0,0">
<TextBlock Text="HANDLUNGSBEDARF" FontSize="10" FontWeight="Bold" Opacity="0.5"/>
<TextBlock Text="{Binding AttentionSummary}" FontSize="18" FontWeight="SemiBold"
Foreground="{DynamicResource AppStatusWarningBrush}"/>
</StackPanel>
<StackPanel Grid.Column="3" Spacing="3" Margin="18,0,0,0">
<TextBlock Text="NÄCHSTE 30 TAGE" FontSize="10" FontWeight="Bold" Opacity="0.5"/>
<TextBlock Text="{Binding UpcomingSummary}" FontSize="18" FontWeight="SemiBold"/>
</StackPanel>
</Grid>
</Border>
<Border Background="{DynamicResource SystemControlBackgroundAltHighBrush}" CornerRadius="8"
Padding="12" IsVisible="{Binding IsDashboardSettingsOpen}">
<ItemsControl ItemsSource="{Binding DashboardCards}">
@@ -55,14 +85,15 @@
</Border>
<!-- Serverseitig gecachte DWD-Daten für den in den Einstellungen hinterlegten Schulstandort. -->
<Border Background="{DynamicResource SystemControlBackgroundAltHighBrush}" CornerRadius="8"
Padding="16" IsVisible="{Binding IsWeatherPanelVisible}">
<StackPanel Spacing="10">
<Expander Header="{Binding WeatherSummary}" IsExpanded="{Binding HasWeatherWarnings}"
IsVisible="{Binding IsWeatherPanelVisible}"
Background="{DynamicResource AppCardBackgroundBrush}"
BorderBrush="{DynamicResource AppCardBorderBrush}" BorderThickness="1"
CornerRadius="8" Padding="12,6">
<StackPanel Spacing="10" Margin="6,8,6,6">
<Grid ColumnDefinitions="*,Auto">
<StackPanel>
<TextBlock Text="WETTER AM SCHULSTANDORT" FontSize="11" FontWeight="Bold" Opacity="0.5"/>
<TextBlock Text="{Binding WeatherSummary}" FontSize="20" FontWeight="SemiBold" Margin="0,5,0,0"
IsVisible="{Binding WeatherSummary, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
<TextBlock Text="{Binding WeatherDetails}" FontSize="11" Opacity="0.65" TextWrapping="Wrap"
IsVisible="{Binding WeatherDetails, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
</StackPanel>
@@ -92,17 +123,20 @@
</ItemsControl>
<TextBlock Text="Wetterdaten © Deutscher Wetterdienst" FontSize="10" Opacity="0.5"/>
</StackPanel>
</Border>
</Expander>
<Grid ColumnDefinitions="*,*" RowDefinitions="Auto,Auto,Auto,Auto,Auto,Auto">
<TextBlock Text="HEUTE UND HANDLUNGSBEDARF" FontSize="11" FontWeight="Bold" Opacity="0.5"
Margin="2,2,0,-8"/>
<Grid ColumnDefinitions="3*,2*" RowDefinitions="Auto,Auto,Auto,Auto,Auto,Auto">
<!-- Heutige Stunden -->
<Border Grid.Column="{Binding TodayCard.Column}" Grid.Row="{Binding TodayCard.Row}"
IsVisible="{Binding TodayCard.IsVisible}" Margin="0,0,8,8"
IsVisible="{Binding TodayCard.EffectiveIsVisible}" Margin="0,0,8,8"
Background="{DynamicResource SystemControlBackgroundAltHighBrush}"
CornerRadius="8" Padding="16">
<StackPanel>
<TextBlock Text="HEUTE" FontSize="11" FontWeight="Bold"
<TextBlock Text="Heute" FontSize="14" FontWeight="SemiBold"
Opacity="0.5" Margin="0,0,0,10"/>
<ItemsControl ItemsSource="{Binding TodaysLessons}">
<ItemsControl.ItemTemplate>
@@ -139,12 +173,12 @@
<!-- Offene Aufgaben -->
<Border Grid.Column="{Binding TasksCard.Column}" Grid.Row="{Binding TasksCard.Row}"
IsVisible="{Binding TasksCard.IsVisible}" Margin="8,0,0,8"
IsVisible="{Binding TasksCard.EffectiveIsVisible}" Margin="8,0,0,8"
Background="{DynamicResource SystemControlBackgroundAltHighBrush}"
CornerRadius="8" Padding="16">
<StackPanel>
<Grid ColumnDefinitions="*,Auto,Auto" Margin="0,0,0,10">
<TextBlock Grid.Column="0" Text="OFFENE AUFGABEN" FontSize="11" FontWeight="Bold"
<TextBlock Grid.Column="0" Text="Offene Aufgaben" FontSize="14" FontWeight="SemiBold"
Opacity="0.5" VerticalAlignment="Center"/>
<Button Grid.Column="1" Content="🔔+" FontSize="12" Padding="7,2" Margin="0,0,4,0"
ToolTip.Tip="Erinnerung anlegen" Command="{Binding AddReminderCommand}"/>
@@ -176,7 +210,7 @@
<!-- Kalender: feste Position direkt unter Heute/Aufgaben, damit die wachsende
Lerngruppen-Liste darunter ihn nicht nach unten verdrängt. -->
<Border Grid.Column="{Binding CalendarCard.Column}" Grid.Row="{Binding CalendarCard.Row}"
IsVisible="{Binding CalendarCard.IsVisible}" Margin="0,0,8,8"
IsVisible="{Binding CalendarCard.EffectiveIsVisible}" Margin="0,0,8,8"
Background="{DynamicResource SystemControlBackgroundAltHighBrush}"
CornerRadius="8" Padding="16">
<StackPanel Spacing="8">
@@ -317,7 +351,7 @@
<!-- Offene Entschuldigungen: neben dem Kalender, ebenfalls feste Position -->
<Border Grid.Column="{Binding ExcusesCard.Column}" Grid.Row="{Binding ExcusesCard.Row}"
IsVisible="{Binding ExcusesCard.IsVisible}" Margin="8,0,0,8" VerticalAlignment="Top"
IsVisible="{Binding ExcusesCard.EffectiveIsVisible}" Margin="8,0,0,8" VerticalAlignment="Top"
Background="{DynamicResource SystemControlBackgroundAltHighBrush}"
CornerRadius="8" Padding="16">
<StackPanel>
@@ -351,7 +385,7 @@
<!-- Fehlzeiten-Warnung (5.2.3) -->
<Border Grid.Column="{Binding AttendanceCard.Column}" Grid.Row="{Binding AttendanceCard.Row}"
IsVisible="{Binding AttendanceCard.IsVisible}" Margin="0,0,8,8" VerticalAlignment="Top"
IsVisible="{Binding AttendanceCard.EffectiveIsVisible}" Margin="0,0,8,8" VerticalAlignment="Top"
Background="{DynamicResource SystemControlBackgroundAltHighBrush}"
CornerRadius="8" Padding="16">
<StackPanel>
@@ -381,7 +415,7 @@
<!-- Förderplan-Wiedervorlage (5.3.2) -->
<Border Grid.Column="{Binding SupportCard.Column}" Grid.Row="{Binding SupportCard.Row}"
IsVisible="{Binding SupportCard.IsVisible}" Margin="8,0,0,8" VerticalAlignment="Top"
IsVisible="{Binding SupportCard.EffectiveIsVisible}" Margin="8,0,0,8" VerticalAlignment="Top"
Background="{DynamicResource SystemControlBackgroundAltHighBrush}"
CornerRadius="8" Padding="16">
<StackPanel>
@@ -412,7 +446,7 @@
<!-- Anstehende Termine (9.3) -->
<Border Grid.Column="{Binding UpcomingCard.Column}" Grid.Row="{Binding UpcomingCard.Row}"
IsVisible="{Binding UpcomingCard.IsVisible}" Margin="0,0,8,8" VerticalAlignment="Top"
IsVisible="{Binding UpcomingCard.EffectiveIsVisible}" Margin="0,0,8,8" VerticalAlignment="Top"
Background="{DynamicResource SystemControlBackgroundAltHighBrush}"
CornerRadius="8" Padding="16">
<StackPanel>
@@ -449,7 +483,7 @@
<!-- Offene Korrekturen (9.4) -->
<Border Grid.Column="{Binding CorrectionsCard.Column}" Grid.Row="{Binding CorrectionsCard.Row}"
IsVisible="{Binding CorrectionsCard.IsVisible}" Margin="8,0,0,8" VerticalAlignment="Top"
IsVisible="{Binding CorrectionsCard.EffectiveIsVisible}" Margin="8,0,0,8" VerticalAlignment="Top"
Background="{DynamicResource SystemControlBackgroundAltHighBrush}"
CornerRadius="8" Padding="16">
<StackPanel>
@@ -482,7 +516,7 @@
<!-- Ungeplante Stunden -->
<Border Grid.Column="{Binding UnplannedCard.Column}" Grid.Row="{Binding UnplannedCard.Row}"
IsVisible="{Binding UnplannedCard.IsVisible}" Margin="0,0,8,8" VerticalAlignment="Top"
IsVisible="{Binding UnplannedCard.EffectiveIsVisible}" Margin="0,0,8,8" VerticalAlignment="Top"
Background="{DynamicResource SystemControlBackgroundAltHighBrush}"
CornerRadius="8" Padding="16">
<StackPanel>
@@ -512,7 +546,7 @@
<!-- Auffälligkeiten (9.5) -->
<Border Grid.Column="{Binding AlertsCard.Column}" Grid.Row="{Binding AlertsCard.Row}"
IsVisible="{Binding AlertsCard.IsVisible}" Margin="0,0,8,8" VerticalAlignment="Top"
IsVisible="{Binding AlertsCard.EffectiveIsVisible}" Margin="0,0,8,8" VerticalAlignment="Top"
Background="{DynamicResource SystemControlBackgroundAltHighBrush}"
CornerRadius="8" Padding="16">
<StackPanel>
@@ -546,7 +580,7 @@
<!-- Meine Lerngruppen -->
<Border Grid.Column="{Binding GroupsCard.Column}" Grid.Row="{Binding GroupsCard.Row}"
IsVisible="{Binding GroupsCard.IsVisible}"
IsVisible="{Binding GroupsCard.EffectiveIsVisible}"
Background="{DynamicResource SystemControlBackgroundAltHighBrush}"
CornerRadius="8" Padding="16">
<StackPanel>
@@ -1,7 +1,14 @@
using Avalonia.Controls;
using Avalonia.Interactivity;
using LehrerApp.Core.Interfaces;
using LehrerApp.Core.Models;
using LehrerApp.Core.Services;
using LehrerApp.Desktop.Services;
using LehrerApp.Desktop.ViewModels;
using LehrerApp.Desktop.ViewModels.Students;
using LehrerApp.Desktop.Views.Students;
using LehrerApp.Desktop.Views.Workload;
using Microsoft.Extensions.DependencyInjection;
namespace LehrerApp.Desktop.Views.Dashboard;
@@ -22,4 +29,17 @@ public partial class DashboardView : UserControl
if (owner is null) return null;
return await WorkTaskDialogHelper.ShowDialog(owner, startAsReminder: startAsReminder);
}
private async void OnCompareWebUntisDocumentationClick(object? sender, RoutedEventArgs e)
{
var owner = TopLevel.GetTopLevel(this) as Window;
if (owner is null) return;
var dialogVm = new WebUntisDocumentationComparisonViewModel(
App.Services.GetRequiredService<WebUntisIntegrationService>(),
App.Services.GetRequiredService<IStudentRepository>(),
App.Services.GetRequiredService<IGroupRepository>(),
App.Services.GetRequiredService<IDocumentationRepository>(),
App.Services.GetRequiredService<SchoolYearService>());
await new WebUntisDocumentationComparisonDialog { DataContext = dialogVm }.ShowDialog(owner);
}
}
@@ -0,0 +1,222 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="clr-namespace:LehrerApp.Desktop.ViewModels.Exams"
xmlns:vmg="clr-namespace:LehrerApp.Desktop.ViewModels.Groups"
xmlns:shared="clr-namespace:LehrerApp.Desktop.Views.Shared"
x:Class="LehrerApp.Desktop.Views.Exams.ExamsOverviewView"
x:DataType="vm:ExamsOverviewViewModel">
<!-- Statusfarben kommen wie im Klassenlehrer-Bereich aus Styles/SemanticBrushes.axaml (siehe
ClassTeacherOverviewView) — Basis ist hier aber "neutral" statt "ok", weil geplante bzw.
hängengebliebene Klausuren (ExamPriorityService.CorrectionStuck) hier den Normalfall
bilden, nicht ein positives Ergebnis. -->
<UserControl.Styles>
<Style Selector="Border.examStatusBar">
<Setter Property="Background" Value="{DynamicResource AppListRowBorderBrush}"/>
</Style>
<Style Selector="Border.examStatusBar.ok">
<Setter Property="Background" Value="{DynamicResource AppStatusOkBrush}"/>
</Style>
<Style Selector="Border.examStatusBar.info">
<Setter Property="Background" Value="{DynamicResource AppStatusInfoBrush}"/>
</Style>
<Style Selector="Border.examStatusBar.warning">
<Setter Property="Background" Value="{DynamicResource AppStatusWarningBrush}"/>
</Style>
<Style Selector="Border.examStatusBar.danger">
<Setter Property="Background" Value="{DynamicResource AppStatusDangerBrush}"/>
</Style>
<Style Selector="Border.examStatusPill">
<Setter Property="Background" Value="{DynamicResource AppChipBackgroundBrush}"/>
<Setter Property="CornerRadius" Value="4"/>
<Setter Property="Padding" Value="8,2"/>
</Style>
<Style Selector="Border.examStatusPill TextBlock">
<Setter Property="FontSize" Value="12"/>
</Style>
<Style Selector="Border.examStatusPill.ok">
<Setter Property="Background" Value="{DynamicResource AppStatusOkBrush}"/>
</Style>
<Style Selector="Border.examStatusPill.ok TextBlock">
<Setter Property="Foreground" Value="White"/>
</Style>
<Style Selector="Border.examStatusPill.info">
<Setter Property="Background" Value="{DynamicResource AppStatusInfoBrush}"/>
</Style>
<Style Selector="Border.examStatusPill.info TextBlock">
<Setter Property="Foreground" Value="White"/>
</Style>
<Style Selector="Border.examStatusPill.warning">
<Setter Property="Background" Value="{DynamicResource AppStatusWarningBrush}"/>
</Style>
<Style Selector="Border.examStatusPill.warning TextBlock">
<Setter Property="Foreground" Value="White"/>
</Style>
<Style Selector="Border.examStatusPill.danger">
<Setter Property="Background" Value="{DynamicResource AppStatusDangerBrush}"/>
</Style>
<Style Selector="Border.examStatusPill.danger TextBlock">
<Setter Property="Foreground" Value="White"/>
</Style>
<Style Selector="Button.examRow">
<Setter Property="HorizontalAlignment" Value="Stretch"/>
<Setter Property="HorizontalContentAlignment" Value="Stretch"/>
<Setter Property="Background" Value="{DynamicResource AppListRowBackgroundBrush}"/>
<Setter Property="BorderBrush" Value="{DynamicResource AppListRowBorderBrush}"/>
<Setter Property="BorderThickness" Value="1,0,1,1"/>
<Setter Property="Padding" Value="0"/>
<Setter Property="CornerRadius" Value="0"/>
</Style>
<Style Selector="Button.examRow:pointerover /template/ ContentPresenter">
<Setter Property="Background" Value="{DynamicResource AppListRowHoverBrush}"/>
</Style>
<Style Selector="Button.siblingPill">
<Setter Property="Padding" Value="10,4"/>
<Setter Property="FontSize" Value="12"/>
<Setter Property="CornerRadius" Value="12"/>
</Style>
</UserControl.Styles>
<Grid RowDefinitions="Auto,Auto,*">
<Border Grid.Row="0" Padding="20,16"
BorderBrush="{DynamicResource SystemControlForegroundBaseLowBrush}"
BorderThickness="0,0,0,1">
<Grid ColumnDefinitions="*,Auto">
<shared:PageHeader Grid.Column="0" Title="Klausuren"
Subtitle="Alle Klausuren des Schuljahres, nach Dringlichkeit sortiert"/>
<Button Grid.Column="1" Content="↻ Aktualisieren" Command="{Binding LoadCommand}"
VerticalAlignment="Center"/>
</Grid>
</Border>
<!-- Detailbereich zur ausgewählten Klausur ─────────────────────────────────── -->
<Border Grid.Row="1" Margin="20,16,20,10" Padding="18"
Background="{DynamicResource AppCardBackgroundBrush}"
BorderBrush="{DynamicResource AppCardBorderBrush}" BorderThickness="1" CornerRadius="10"
IsVisible="{Binding HasSelection}">
<StackPanel Spacing="12">
<Grid ColumnDefinitions="*,Auto">
<StackPanel Grid.Column="0" Spacing="2">
<TextBlock Text="{Binding SelectedRow.Title}" FontSize="16" FontWeight="SemiBold"/>
<TextBlock FontSize="13" Opacity="0.6">
<Run Text="{Binding SelectedRow.GroupLabel}"/>
<Run Text=" · "/>
<Run Text="{Binding SelectedRow.SubLabel}"/>
</TextBlock>
</StackPanel>
<Border Grid.Column="1" Classes="examStatusPill" VerticalAlignment="Top"
Classes.ok="{Binding SelectedRow.IsOk}" Classes.info="{Binding SelectedRow.IsInfo}"
Classes.warning="{Binding SelectedRow.IsWarning}" Classes.danger="{Binding SelectedRow.IsDanger}">
<TextBlock Text="{Binding SelectedRow.StatusLabel}"/>
</Border>
</Grid>
<!-- Parallelkurs-Umschalter -->
<ItemsControl ItemsSource="{Binding Siblings}" IsVisible="{Binding SelectedRow.HasSibling}">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate>
<StackPanel Orientation="Horizontal" Spacing="8"/>
</ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:ExamListRowViewModel">
<Button Classes="siblingPill"
Command="{Binding $parent[ItemsControl].((vm:ExamsOverviewViewModel)DataContext).SelectExamCommand}"
CommandParameter="{Binding}">
<TextBlock>
<Run Text="{Binding GroupLabel}"/>
<Run Text=" · "/>
<Run Text="{Binding Evaluated}"/>
<Run Text="/"/>
<Run Text="{Binding Expected}"/>
</TextBlock>
</Button>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<!-- Korrekturfortschritt -->
<StackPanel Spacing="6" IsVisible="{Binding ShowCorrectionProgress}">
<TextBlock Text="{Binding ProgressLabel}" FontSize="13"/>
<Border Width="240" Height="6" CornerRadius="3" HorizontalAlignment="Left"
Background="{DynamicResource AppTrackBackgroundBrush}">
<Border Width="{Binding ProgressBarWidth}" Height="6" CornerRadius="3"
HorizontalAlignment="Left" Background="{DynamicResource AppStatusWarningBrush}"/>
</Border>
</StackPanel>
<!-- Notenspiegel -->
<StackPanel Spacing="6" IsVisible="{Binding ShowGradeSummary}">
<TextBlock Text="{Binding AverageLabel}" FontSize="14" FontWeight="SemiBold"/>
<ItemsControl ItemsSource="{Binding DetailGradeDistribution}">
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vmg:GradeBarItem">
<Grid ColumnDefinitions="30,*,60" Margin="0,1">
<TextBlock Grid.Column="0" Text="{Binding Grade}" FontSize="12" VerticalAlignment="Center"/>
<Border Grid.Column="1" Height="14" Width="{Binding BarWidth}" HorizontalAlignment="Left"
Background="{DynamicResource AppAccentTextBrush}" CornerRadius="3"/>
<TextBlock Grid.Column="2" Text="{Binding CountDisplay}" FontSize="11" Opacity="0.6"
VerticalAlignment="Center"/>
</Grid>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</StackPanel>
<!-- Genehmigung / Ankündigung -->
<StackPanel Orientation="Horizontal" Spacing="10">
<Button Content="{Binding ApprovalLabel}" Command="{Binding ToggleApprovalCommand}" FontSize="12"/>
<Button Content="{Binding AnnouncementLabel}" Command="{Binding ToggleAnnouncementCommand}" FontSize="12"/>
</StackPanel>
<StackPanel Orientation="Horizontal" Spacing="8">
<Button Content="Punkte eingeben" Command="{Binding GradeSelectedCommand}"
IsVisible="{Binding ShowCorrectionProgress}"/>
<Button Content="Auswertung" Command="{Binding EvaluateSelectedCommand}"
IsVisible="{Binding ShowGradeSummary}"/>
<Button Content="Zum Kurs →" Command="{Binding GoToGroupCommand}" HorizontalAlignment="Right"/>
</StackPanel>
</StackPanel>
</Border>
<!-- Liste aller Klausuren, nach Priorität sortiert (nicht nach Datum) ─────────── -->
<ScrollViewer Grid.Row="2" Margin="20,0,20,16">
<StackPanel>
<TextBlock Text="{Binding EmptyHint}" Classes="emptyhint" Margin="0,20,0,0"
IsVisible="{Binding EmptyHint, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
<ItemsControl ItemsSource="{Binding Rows}">
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:ExamListRowViewModel">
<Button Classes="examRow"
Command="{Binding $parent[ItemsControl].((vm:ExamsOverviewViewModel)DataContext).SelectExamCommand}"
CommandParameter="{Binding}">
<Grid ColumnDefinitions="4,*,Auto,Auto" MinHeight="52">
<Border Grid.Column="0" Classes="examStatusBar" Classes.ok="{Binding IsOk}"
Classes.info="{Binding IsInfo}" Classes.warning="{Binding IsWarning}"
Classes.danger="{Binding IsDanger}"/>
<StackPanel Grid.Column="1" VerticalAlignment="Center" Margin="12,6" Spacing="2">
<StackPanel Orientation="Horizontal" Spacing="6">
<TextBlock Text="{Binding Title}" FontSize="14" FontWeight="Medium"/>
<TextBlock Text="🔗" FontSize="11" Opacity="0.5" IsVisible="{Binding HasSibling}"
ToolTip.Tip="Parallelkurs vorhanden"/>
</StackPanel>
<TextBlock FontSize="12" Opacity="0.6">
<Run Text="{Binding GroupLabel}"/>
<Run Text=" · "/>
<Run Text="{Binding SubLabel}"/>
</TextBlock>
</StackPanel>
<Border Grid.Column="3" Classes="examStatusPill" VerticalAlignment="Center" Margin="0,0,12,0"
Classes.ok="{Binding IsOk}" Classes.info="{Binding IsInfo}"
Classes.warning="{Binding IsWarning}" Classes.danger="{Binding IsDanger}">
<TextBlock Text="{Binding StatusLabel}"/>
</Border>
</Grid>
</Button>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</StackPanel>
</ScrollViewer>
</Grid>
</UserControl>
@@ -0,0 +1,63 @@
using Avalonia.Controls;
using LehrerApp.Core.Interfaces;
using LehrerApp.Core.Models;
using LehrerApp.Core.Services;
using LehrerApp.Desktop.ViewModels;
using LehrerApp.Desktop.ViewModels.Exams;
using LehrerApp.Desktop.ViewModels.Groups;
using LehrerApp.Desktop.Views.Groups;
using Microsoft.Extensions.DependencyInjection;
namespace LehrerApp.Desktop.Views.Exams;
public partial class ExamsOverviewView : UserControl
{
private const int GroupDetailKlausurenTabIndex = 4;
public ExamsOverviewView() => InitializeComponent();
protected override void OnDataContextChanged(EventArgs e)
{
base.OnDataContextChanged(e);
if (DataContext is ExamsOverviewViewModel vm)
{
vm.OnGradeExam = ShowGradeExamDialog;
vm.OnEvaluateExam = ShowEvaluateExamDialog;
vm.OnNavigateToGroup = groupId => App.Services.GetRequiredService<MainWindowViewModel>()
.NavigateToGroupDetail(groupId, GroupDetailKlausurenTabIndex);
}
}
private async Task ShowGradeExamDialog(Exam exam)
{
var group = App.Services.GetRequiredService<IGroupRepository>().GetById(exam.GroupId);
if (group is null) return;
var dialogVm = new ExamGradingDialogViewModel(
App.Services.GetRequiredService<IExamResultRepository>(),
App.Services.GetRequiredService<IStudentRepository>(),
App.Services.GetRequiredService<IGroupMembershipRepository>(),
App.Services.GetRequiredService<GradingService>(),
exam, group.Id);
var dialog = new ExamGradingDialog { DataContext = dialogVm };
var owner = TopLevel.GetTopLevel(this) as Window;
if (owner is not null) await dialog.ShowDialog(owner);
}
private async Task ShowEvaluateExamDialog(Exam exam)
{
var group = App.Services.GetRequiredService<IGroupRepository>().GetById(exam.GroupId);
if (group is null) return;
var dialogVm = new ExamEvaluationDialogViewModel(
App.Services.GetRequiredService<IExamRepository>(),
App.Services.GetRequiredService<IExamResultRepository>(),
App.Services.GetRequiredService<GradingService>(),
exam, group.GradingSystem);
var dialog = new ExamEvaluationDialog { DataContext = dialogVm };
var owner = TopLevel.GetTopLevel(this) as Window;
if (owner is not null) await dialog.ShowDialog(owner);
}
}
@@ -77,6 +77,13 @@
<CheckBox Content="Benötigt Unterrichtsplanung" IsChecked="{Binding RequiresLessonPlanning}"
ToolTip.Tip="Deaktivieren für Gruppen ohne inhaltlichen Verlaufsplan (z.B. Klassenrat, Willkommenskreis) - blendet für diese Gruppe die Dashboard-Erinnerung &quot;Ungeplante Stunden&quot; aus."/>
<StackPanel Spacing="4">
<TextBlock Text="WebUntis-Unterrichtsnummer (optional)" FontSize="12" Opacity="0.7"/>
<NumericUpDown Value="{Binding WebUntisLessonId}" Minimum="1" FormatString="0"
HorizontalAlignment="Stretch"
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>
<Grid Grid.Row="1" ColumnDefinitions="*,8,*" Margin="0,20,0,0">
@@ -15,6 +15,9 @@
IsVisible="{Binding NiveauLabel, Converter={x:Static StringConverters.IsNotNullOrEmpty}}">
<TextBlock Text="{Binding NiveauLabel}" FontSize="12" Foreground="White"/>
</Border>
<Button Content="Rest als abwesend markieren" Command="{Binding MarkRemainingAbsentCommand}"
Margin="20,0,0,0" VerticalAlignment="Center"
ToolTip.Tip="Setzt bei allen noch leeren Zeilen 'Abwesend' — für Schüler, die nicht nachschreiben."/>
</StackPanel>
<DataGrid Grid.Row="1" Name="ResultGrid"
@@ -25,6 +25,9 @@
<Button Content=" Schüler" Command="{Binding AddStudentCommand}" IsEnabled="{Binding IsEditable}"/>
<Button Content="⇩ Teilnehmer importieren…" Click="OnImportParticipantsClick"
IsEnabled="{Binding IsEditable}"/>
<Button Content="↻ Aus WebUntis…" Click="OnImportParticipantsFromWebUntisClick"
IsEnabled="{Binding IsEditable}"/>
<Button Content="Fehlzeiten je Unterricht…" Click="OnCompareWebUntisLessonAbsencesClick"/>
<Button Content="{Binding SelectedStudent.WithdrawActionLabel}"
Command="{Binding WithdrawStudentCommand}"
IsVisible="{Binding SelectedStudent, Converter={x:Static ObjectConverters.IsNotNull}}"/>
@@ -13,6 +13,8 @@ using LehrerApp.Desktop.Views.Shared;
using LehrerApp.Desktop.Views.Students;
using LehrerApp.Desktop.Views.Workload;
using Microsoft.Extensions.DependencyInjection;
using System.Globalization;
using System.Text;
namespace LehrerApp.Desktop.Views.Groups;
@@ -113,9 +115,96 @@ public partial class GroupDetailView : UserControl
throw new InvalidDataException("Die Importdatei ist größer als 20 MB.");
var importFile = new ImportFile(files[0].Name, buffer.ToArray());
await ShowStudentImportPreview(owner, vm, importFile, files[0].Name);
}
catch (Exception ex) when (ex is ImportFormatException
or InvalidDataException
or IOException
or UnauthorizedAccessException)
{
App.Services.GetRequiredService<NotificationService>().ShowError(ex.Message);
}
}
private async void OnImportParticipantsFromWebUntisClick(object? sender, RoutedEventArgs e)
{
var owner = TopLevel.GetTopLevel(this) as Window;
if (owner is null || DataContext is not GroupDetailViewModel vm || vm.Group is null) return;
try
{
var untis = App.Services.GetRequiredService<WebUntisIntegrationService>();
var selectionVm = new WebUntisClassSelectionViewModel(untis);
var selection = new WebUntisClassSelectionDialog { DataContext = selectionVm };
await selectionVm.InitializeAsync();
if (!await selection.ShowDialog<bool>(owner) || selectionVm.SelectedClass is null) return;
var report = await untis.GetStudentsAsync(selectionVm.SelectedClass.Name);
var importFile = BuildWebUntisStudentImport(report);
await ShowStudentImportPreview(owner, vm, importFile,
$"WebUntis · {selectionVm.SelectedClass.Name}");
}
catch (WebUntisIntegrationException ex)
{
App.Services.GetRequiredService<NotificationService>().ShowError(ex.Message);
}
catch (Exception ex) when (ex is ImportFormatException or InvalidDataException)
{
App.Services.GetRequiredService<NotificationService>().ShowError(ex.Message);
}
}
private async void OnCompareWebUntisLessonAbsencesClick(object? sender, RoutedEventArgs e)
{
var owner = TopLevel.GetTopLevel(this) as Window;
if (owner is null || DataContext is not GroupDetailViewModel vm || vm.Group is null) return;
var dialogVm = new WebUntisLessonAbsenceComparisonViewModel(vm.Group,
App.Services.GetRequiredService<WebUntisIntegrationService>(),
App.Services.GetRequiredService<IStudentRepository>(),
App.Services.GetRequiredService<IParticipationSessionRepository>(),
App.Services.GetRequiredService<IParticipationRepository>());
await new WebUntisLessonAbsenceComparisonDialog { DataContext = dialogVm }.ShowDialog(owner);
vm.ParticipationTab.RefreshCurrentGrid();
}
private static ImportFile BuildWebUntisStudentImport(UntisStudentReportDto report)
{
var builder = new StringBuilder();
builder.AppendLine("longName\tforeName\tgender\tbirthDate\tklasse.name\texternKey\taddress.email\taddress.mobile\taddress.phone\taddress.city\taddress.postCode\taddress.street");
foreach (var student in report.Students)
{
var values = new[]
{
student.LongName ?? student.Name, student.ForeName, student.Gender,
FormatBirthDate(student), student.ClassName,
student.ExternKey.ToString(), student.Address.Email, student.Address.Mobile,
student.Address.Phone, student.Address.City, student.Address.PostCode, student.Address.Street,
};
builder.AppendLine(string.Join('\t', values.Select(SafeTsv)));
}
return new ImportFile("webuntis-students.csv", Encoding.UTF8.GetBytes(builder.ToString()));
}
private static string? FormatBirthDate(UntisStudentDto student)
{
if (student.BirthDate is not { } normalizedDate) return student.BirthDateRaw;
var value = normalizedDate.ToString("D8", CultureInfo.InvariantCulture);
return DateOnly.TryParseExact(value, "yyyyMMdd", CultureInfo.InvariantCulture,
DateTimeStyles.None, out var date)
? date.ToString("dd.MM.yyyy", CultureInfo.InvariantCulture)
: student.BirthDateRaw;
}
private static string SafeTsv(string? value) => (value ?? "").Replace('\t', ' ')
.Replace('\r', ' ').Replace('\n', ' ');
private static async Task ShowStudentImportPreview(Window owner, GroupDetailViewModel vm,
ImportFile importFile, string sourceName)
{
var service = App.Services.GetRequiredService<StudentImportService>();
var preview = await Task.Run(async () =>
await service.AnalyzeAsync(importFile, vm.Group.Id).ConfigureAwait(false));
var dialogVm = new StudentImportDialogViewModel(service, preview, files[0].Name);
await service.AnalyzeAsync(importFile, vm.Group!.Id).ConfigureAwait(false));
var dialogVm = new StudentImportDialogViewModel(service, preview, sourceName);
var dialog = new StudentImportDialog { DataContext = dialogVm };
if (!await dialog.ShowDialog<bool>(owner)) return;
@@ -126,14 +215,6 @@ public partial class GroupDetailView : UserControl
$"Teilnehmerimport abgeschlossen: {result.CreatedStudents} neu, "
+ $"{result.UpdatedStudents} ergänzt, {result.CreatedMemberships} zugeordnet.");
}
catch (Exception ex) when (ex is ImportFormatException
or InvalidDataException
or IOException
or UnauthorizedAccessException)
{
App.Services.GetRequiredService<NotificationService>().ShowError(ex.Message);
}
}
private async Task<bool> ShowWithdrawStudentDialog(StudentSummary student)
{
@@ -5,13 +5,6 @@
x:Class="LehrerApp.Desktop.Views.Groups.GroupListView"
x:DataType="vm:GroupListViewModel">
<UserControl.Styles>
<Style Selector="TextBlock.overdue">
<Setter Property="Foreground" Value="Red"/>
<Setter Property="FontWeight" Value="SemiBold"/>
</Style>
</UserControl.Styles>
<Grid RowDefinitions="Auto,*">
<!-- Kopfzeile mit Schuljahr-Wähler und Neu-Button -->
@@ -28,180 +21,85 @@
</Grid>
</Border>
<!-- Master-Detail: Liste links, Übersicht rechts -->
<Grid Grid.Row="1" ColumnDefinitions="260,*">
<!-- Linke Spalte: Sucheingabe + Listenansicht -->
<Border Grid.Column="0"
BorderBrush="{DynamicResource SystemControlForegroundBaseLowBrush}"
BorderThickness="0,0,1,0">
<DockPanel>
<StackPanel DockPanel.Dock="Top" Margin="12,8" Spacing="8">
<TextBox Text="{Binding SearchText}" PlaceholderText="Suchen…"/>
<ToggleSwitch Content="Archiv anzeigen" IsChecked="{Binding ShowArchived}"/>
</StackPanel>
<TextBlock Text="{Binding EmptyListMessage}" TextWrapping="Wrap"
Margin="16,12" FontSize="12" Opacity="0.45"
IsVisible="{Binding HasNoGroups}"/>
<ListBox ItemsSource="{Binding Groups}"
SelectedItem="{Binding SelectedGroup}">
<ListBox.ItemTemplate>
<DataTemplate DataType="vm:GroupListItem">
<Grid ColumnDefinitions="4,*" Margin="2,4">
<Border Grid.Column="0" Width="4" CornerRadius="2"
Background="{DynamicResource SystemAccentColor}"
Margin="0,0,10,0"/>
<StackPanel Grid.Column="1">
<Grid ColumnDefinitions="*,Auto">
<TextBlock Grid.Column="0" Text="{Binding Name}"
FontWeight="SemiBold" FontSize="13"/>
<TextBlock Grid.Column="1" Text="{Binding TypeLabel}"
FontSize="11" Opacity="0.5"/>
<!-- Eine Navigationsebene: Karten öffnen direkt das Gruppendetail. Die Verwaltung sitzt
am jeweiligen Eintrag und benötigt keine vorgeschaltete Bereichsauswahl mehr. -->
<Grid Grid.Row="1" RowDefinitions="Auto,*">
<Grid Grid.Row="0" ColumnDefinitions="*,Auto" Margin="24,18,24,8">
<TextBox Grid.Column="0" Text="{Binding SearchText}"
PlaceholderText="Name oder Fach suchen …" MaxWidth="560"
HorizontalAlignment="Stretch"/>
<ToggleSwitch Grid.Column="1" Content="Archiv anzeigen" IsChecked="{Binding ShowArchived}"
Margin="20,0,0,0" VerticalAlignment="Center"/>
</Grid>
<TextBlock Text="{Binding Subject}" FontSize="12" Opacity="0.65"
<StackPanel Grid.Row="1" HorizontalAlignment="Center" VerticalAlignment="Center" Spacing="10"
IsVisible="{Binding HasNoGroups}">
<TextBlock Text="{Binding EmptyListMessage}" FontSize="15" Opacity="0.55"
TextWrapping="Wrap" TextAlignment="Center"/>
<Button Content=" Erste Lerngruppe anlegen" Command="{Binding AddGroupCommand}"
IsVisible="{Binding !ShowArchived}" HorizontalAlignment="Center"/>
</StackPanel>
<ScrollViewer Grid.Row="1" IsVisible="{Binding !HasNoGroups}"
HorizontalScrollBarVisibility="Disabled">
<ItemsControl ItemsSource="{Binding Groups}" Margin="20,12,20,24">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate>
<WrapPanel Orientation="Horizontal"/>
</ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:GroupListItem">
<Border Width="330" MinHeight="116" Margin="6" CornerRadius="9"
Background="{DynamicResource AppCardBackgroundBrush}"
BorderBrush="{DynamicResource AppCardBorderBrush}" BorderThickness="1">
<Grid ColumnDefinitions="4,*,Auto">
<Border Grid.Column="0" Background="{DynamicResource SystemAccentColor}"
CornerRadius="9,0,0,9"/>
<Button Grid.Column="1" Background="Transparent" BorderThickness="0"
Padding="16,14" HorizontalAlignment="Stretch"
HorizontalContentAlignment="Stretch" VerticalContentAlignment="Stretch"
Command="{Binding OpenCommand}"
AutomationProperties.Name="{Binding OpenAutomationName}">
<StackPanel Spacing="6">
<TextBlock Text="{Binding Name}" FontSize="17" FontWeight="SemiBold"
TextTrimming="CharacterEllipsis"/>
<TextBlock Text="{Binding Subject}" FontSize="13" Opacity="0.7"
TextTrimming="CharacterEllipsis"
IsVisible="{Binding Subject, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
<TextBlock Text="{Binding GradingLabel}" FontSize="11" Opacity="0.4"/>
</StackPanel>
</Grid>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
</DockPanel>
<StackPanel Orientation="Horizontal" Spacing="7">
<Border Background="{DynamicResource AppChipBackgroundBrush}" CornerRadius="8" Padding="7,2">
<TextBlock Text="{Binding TypeLabel}" FontSize="10" Opacity="0.75"/>
</Border>
<!-- Rechte Spalte: Platzhalter wenn keine Auswahl -->
<StackPanel Grid.Column="1" HorizontalAlignment="Center"
VerticalAlignment="Center" Spacing="8"
IsVisible="{Binding SelectedGroup, Converter={x:Static ObjectConverters.IsNull}}">
<TextBlock Text="Gruppe auswählen" FontSize="16" Opacity="0.4"
HorizontalAlignment="Center"/>
<TextBlock Text="oder Neue Gruppe anlegen" FontSize="12" Opacity="0.3"
HorizontalAlignment="Center"/>
<TextBlock Text="{Binding GradingLabel}" FontSize="11" Opacity="0.5"
VerticalAlignment="Center"/>
</StackPanel>
<!-- Rechte Spalte: Gruppen-Übersicht wenn ausgewählt -->
<ScrollViewer Grid.Column="1"
IsVisible="{Binding SelectedGroup, Converter={x:Static ObjectConverters.IsNotNull}}">
<StackPanel Margin="28,24" Spacing="20">
<!-- Gruppenname und Kurzinfos -->
<Grid ColumnDefinitions="*,Auto">
<StackPanel Grid.Column="0" Spacing="4">
<TextBlock Text="{Binding SelectedGroupDisplayName}"
FontSize="24" FontWeight="SemiBold" TextWrapping="Wrap"/>
<TextBlock Text="{Binding SelectedGroupSubtitle}"
FontSize="12" Opacity="0.55"/>
</StackPanel>
<Button Grid.Column="1" Content="⋯ Verwalten" VerticalAlignment="Top"
Margin="16,0,0,0">
</Button>
<Button Grid.Column="2" Content="⋯" Width="36" Height="32" Margin="0,10,10,0"
Padding="0" VerticalAlignment="Top"
ToolTip.Tip="Lerngruppe verwalten"
AutomationProperties.Name="{Binding ManageAutomationName}">
<Button.Flyout>
<MenuFlyout>
<MenuItem Header="Details bearbeiten"
Command="{Binding EditGroupCommand}"/>
<MenuItem Header="Details bearbeiten" IsEnabled="{Binding IsActive}"
Command="{Binding EditCommand}"/>
<MenuItem Header="Ins nächste Schuljahr übernehmen …"
Command="{Binding RollOverGroupCommand}"/>
<MenuItem Header="{Binding SelectedGroup.ArchiveActionLabel}"
Command="{Binding RollOverCommand}"/>
<MenuItem Header="{Binding ArchiveActionLabel}"
Command="{Binding ToggleArchiveCommand}"/>
<Separator/>
<MenuItem Header="Teilnehmer importieren (bald)" IsEnabled="False"
ToolTip.Tip="Import aus dem Teilnehmerexport der Lernplattform folgt."/>
<Separator/>
<MenuItem Header="Lerngruppe löschen"
Command="{Binding DeleteGroupCommand}"/>
<MenuItem Header="Lerngruppe löschen" IsEnabled="{Binding IsActive}"
Command="{Binding DeleteCommand}"/>
</MenuFlyout>
</Button.Flyout>
</Button>
</Grid>
<Separator/>
<!-- Schnellüberblick (Nutzer-Feedback) -->
<StackPanel Spacing="8" IsVisible="{Binding QuickHasAnything}">
<TextBlock Text="SCHNELLÜBERBLICK" FontSize="10" FontWeight="Bold" Opacity="0.4"/>
<StackPanel Spacing="1" IsVisible="{Binding QuickHasNextLesson}">
<TextBlock Text="Nächste Stunde" FontSize="11" Opacity="0.55"/>
<TextBlock Text="{Binding QuickNextLessonLabel}" FontSize="13" TextWrapping="Wrap"/>
</StackPanel>
<StackPanel Spacing="1" IsVisible="{Binding QuickHasNextExam}">
<TextBlock Text="Nächste Klausur" FontSize="11" Opacity="0.55"/>
<TextBlock Text="{Binding QuickNextExamLabel}" FontSize="13" TextWrapping="Wrap"/>
</StackPanel>
<StackPanel Spacing="4" IsVisible="{Binding QuickHasTasks}">
<TextBlock Text="Wichtige Aufgaben" FontSize="11" Opacity="0.55"/>
<ItemsControl ItemsSource="{Binding QuickTasks}">
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:GroupTaskItem">
<Grid ColumnDefinitions="4,Auto,*,Auto" Margin="0,2">
<Border Grid.Column="0" Background="{Binding PriorityColorHex}" CornerRadius="2"
Margin="0,0,6,0" IsVisible="{Binding IsHighPriority}"/>
<TextBlock Grid.Column="1" Text="🔔" FontSize="11" Margin="0,0,4,0"
IsVisible="{Binding IsReminder}" VerticalAlignment="Center"/>
<TextBlock Grid.Column="2" Text="{Binding Title}" FontSize="12"
TextTrimming="CharacterEllipsis" VerticalAlignment="Center"/>
<TextBlock Grid.Column="3" Text="{Binding DueDateDisplay}" FontSize="11"
VerticalAlignment="Center" Classes.overdue="{Binding IsOverdue}"/>
</Grid>
</Border>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</StackPanel>
</StackPanel>
<Separator IsVisible="{Binding QuickHasAnything}"/>
<!-- Bereichs-Navigation -->
<TextBlock Text="BEREICHE" FontSize="10" FontWeight="Bold"
Opacity="0.4" Margin="0,0,0,2"/>
<StackPanel Spacing="6">
<Button Content="📊 Übersicht"
HorizontalAlignment="Stretch" HorizontalContentAlignment="Left"
Padding="12,9"
Command="{Binding NavigateToSectionCommand}"
CommandParameter="0"/>
<Button Content="👤 Schülerliste"
HorizontalAlignment="Stretch" HorizontalContentAlignment="Left"
Padding="12,9"
Command="{Binding NavigateToSectionCommand}"
CommandParameter="1"/>
<Button Content="🪑 Sitzpläne"
HorizontalAlignment="Stretch" HorizontalContentAlignment="Left"
Padding="12,9"
Command="{Binding NavigateToSectionCommand}"
CommandParameter="2"/>
<Button Content="✋ Mitarbeit"
HorizontalAlignment="Stretch" HorizontalContentAlignment="Left"
Padding="12,9"
Command="{Binding NavigateToSectionCommand}"
CommandParameter="3"/>
<Button Content="📝 Klausuren"
HorizontalAlignment="Stretch" HorizontalContentAlignment="Left"
Padding="12,9"
Command="{Binding NavigateToSectionCommand}"
CommandParameter="4"/>
<Button Content="🔢 Notenübersicht"
HorizontalAlignment="Stretch" HorizontalContentAlignment="Left"
Padding="12,9"
Command="{Binding NavigateToSectionCommand}"
CommandParameter="5"/>
<Button Content="📅 Unterrichtsplanung"
HorizontalAlignment="Stretch" HorizontalContentAlignment="Left"
Padding="12,9"
Command="{Binding NavigateToSectionCommand}"
CommandParameter="6"/>
<Button Content="🎯 Kompetenzübersicht"
HorizontalAlignment="Stretch" HorizontalContentAlignment="Left"
Padding="12,9"
Command="{Binding NavigateToSectionCommand}"
CommandParameter="7"/>
<Button Content="📋 Dokumentation"
HorizontalAlignment="Stretch" HorizontalContentAlignment="Left"
Padding="12,9"
Command="{Binding NavigateToSectionCommand}"
CommandParameter="8"/>
</StackPanel>
</StackPanel>
</ScrollViewer>
</Grid>
</Grid>
</UserControl>
@@ -27,6 +27,13 @@
</StackPanel>
</StackPanel>
<StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="8" VerticalAlignment="Center">
<!-- Nutzer-Feedback: die Schnellbewertungs-Dialoge gab es bisher nur über den
Mitarbeit-Tab der Gruppe — hier direkt auf die Sitzung dieser Stunde vorselektiert
(siehe TeachingModeViewModel), kein Umweg mehr über "Zur Mitarbeit". -->
<Button Content="⚡ Mitarbeit" Command="{Binding Participation.QuickInputCommand}"
ToolTip.Tip="Mitarbeit dieser Stunde schnell bewerten."/>
<Button Content="⚡ Anwesenheit/Hausaufgabe" Command="{Binding Participation.StatusQuickInputCommand}"
ToolTip.Tip="Anwesenheit und Hausaufgabenstatus dieser Stunde schnell erfassen."/>
<Button Content="Zur Mitarbeit" Command="{Binding LessonInfo.NavigateToParticipationCommand}"
ToolTip.Tip="Schließt den Unterrichtsmodus und springt zum Tab 'Mitarbeit' der Lerngruppe."/>
<Button Content="Zu den Noten" Command="{Binding LessonInfo.NavigateToGradesCommand}"/>
@@ -80,9 +87,32 @@
</ItemsControl.ItemTemplate>
</ItemsControl>
<StackPanel Spacing="4" IsVisible="{Binding LessonInfo.Homework, Converter={x:Static StringConverters.IsNotNullOrEmpty}}">
<TextBlock Text="Hausaufgabe" FontSize="12" FontWeight="SemiBold" Opacity="0.6"/>
<TextBlock Text="{Binding LessonInfo.Homework}" FontSize="13" TextWrapping="Wrap"/>
<!-- Nutzer-Feedback: Hausaufgabe der letzten Stunde ansehen/als kontrolliert abhaken
und die Hausaufgabe DIESER Stunde einsehen/ändern, ohne den vollen
Verlaufsplan-Editor öffnen zu müssen — manchmal ergibt sich die Hausaufgabe erst
während des Unterrichts. Ersetzt die bisherige rein lesbare Anzeige
(LessonInfo.Homework), die dasselbe Feld doppelt und nur lesend gezeigt hätte. -->
<Border Background="{DynamicResource SystemControlBackgroundAltHighBrush}"
CornerRadius="6" Padding="10,8" IsVisible="{Binding Homework.HasPreviousHomework}">
<StackPanel Spacing="4">
<TextBlock FontSize="12" FontWeight="SemiBold" Opacity="0.6">
<Run Text="Hausaufgabe letzte Stunde"/><Run Text=" · "/>
<Run Text="{Binding Homework.PreviousLessonLabel}"/>
</TextBlock>
<TextBlock Text="{Binding Homework.PreviousHomeworkText}" FontSize="13" TextWrapping="Wrap"/>
<CheckBox Content="Kontrolliert" IsChecked="{Binding Homework.PreviousHomeworkChecked}"/>
</StackPanel>
</Border>
<StackPanel Spacing="4">
<TextBlock Text="Hausaufgabe (diese Stunde)" FontSize="12" FontWeight="SemiBold" Opacity="0.6"/>
<TextBox Text="{Binding Homework.CurrentHomework, Mode=TwoWay}" AcceptsReturn="True"
TextWrapping="Wrap" MinHeight="52" PlaceholderText="Noch keine Hausaufgabe eingetragen…"/>
<Grid ColumnDefinitions="Auto,*">
<Button Grid.Column="0" Content="Speichern" Command="{Binding Homework.SaveCurrentHomeworkCommand}"/>
<TextBlock Grid.Column="1" Text="{Binding Homework.SaveStatus}" FontSize="11" Opacity="0.55"
VerticalAlignment="Center" Margin="8,0,0,0"/>
</Grid>
</StackPanel>
<StackPanel Spacing="4" IsVisible="{Binding LessonInfo.Reflection, Converter={x:Static StringConverters.IsNotNullOrEmpty}}">
@@ -16,12 +16,38 @@ public partial class TeachingModeWindow : Window
// Gleiches Muster wie LessonViewerDialog (4.5.3): Fenster schließt zuerst, dann Sprung
// über die MainWindowViewModel-Singleton-Navigation in den Ziel-Tab der Gruppe.
if (DataContext is TeachingModeViewModel vm)
{
vm.LessonInfo.OnNavigateToTab = tabIndex =>
{
Close();
App.Services.GetRequiredService<MainWindowViewModel>()
.NavigateToGroupDetail(vm.LessonInfo.GroupId, tabIndex);
};
// Nutzer-Feedback: Schnellbewertung (Mitarbeit) und Schnellanwesenheit/Hausaufgabe
// direkt aus dem Unterrichtsmodus statt erst zum Mitarbeit-Tab wechseln zu müssen —
// dieselben Dialoge wie in ParticipationTabView.axaml.cs, hier auf die zu dieser
// Stunde gehörende Sitzung vorselektiert (siehe TeachingModeViewModel-Konstruktor).
vm.Participation.OnQuickInput = ShowQuickInputDialog;
vm.Participation.OnStatusQuickInput = ShowStatusQuickInputDialog;
}
}
private async Task ShowQuickInputDialog(ParticipationTabViewModel tabVm)
{
if (tabVm.StudentRows.Count == 0) return;
var quickVm = new QuickInputViewModel(tabVm.StudentRows.ToList(), tabVm.Aspects.ToList());
var dialog = new ParticipationQuickInputDialog { DataContext = quickVm };
await dialog.ShowDialog(this);
(DataContext as TeachingModeViewModel)?.SeatingPlan.ReloadSeatBadgesFromRepository();
}
private async Task ShowStatusQuickInputDialog(ParticipationTabViewModel tabVm)
{
if (tabVm.StudentRows.Count == 0 || tabVm.SelectedSession is null) return;
var quickVm = new AttendanceHomeworkQuickInputViewModel(tabVm.StudentRows, tabVm.SelectedSessionDisplay);
var dialog = new AttendanceHomeworkQuickInputDialog { DataContext = quickVm };
await dialog.ShowDialog(this);
(DataContext as TeachingModeViewModel)?.SeatingPlan.ReloadSeatBadgesFromRepository();
}
private void OnClose(object? sender, RoutedEventArgs e) => Close();
@@ -0,0 +1,34 @@
<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="clr-namespace:LehrerApp.Desktop.ViewModels.Groups"
xmlns:svc="clr-namespace:LehrerApp.Desktop.Services"
x:Class="LehrerApp.Desktop.Views.Groups.WebUntisClassSelectionDialog"
x:DataType="vm:WebUntisClassSelectionViewModel"
Title="Schüler aus WebUntis" Width="480" SizeToContent="Height"
CanResize="False" WindowStartupLocation="CenterOwner">
<Grid RowDefinitions="*,Auto" Margin="24">
<StackPanel Grid.Row="0" Spacing="12">
<TextBlock Text="WebUntis-Klasse auswählen" Classes="dialogtitle"/>
<TextBlock Text="Die Schülerliste wird danach im gewohnten Importdialog geprüft."
FontSize="12" Opacity="0.65" TextWrapping="Wrap"/>
<Grid ColumnDefinitions="*,Auto" ColumnSpacing="8">
<ComboBox Grid.Column="0" ItemsSource="{Binding SchoolYears}" SelectedItem="{Binding SelectedSchoolYear}">
<ComboBox.ItemTemplate><DataTemplate x:DataType="svc:UntisSchoolYearDto"><TextBlock Text="{Binding Name}"/></DataTemplate></ComboBox.ItemTemplate>
</ComboBox>
<Button Grid.Column="1" Content="Klassen laden" Command="{Binding LoadClassesCommand}" IsEnabled="{Binding !Busy}"/>
</Grid>
<ComboBox ItemsSource="{Binding Classes}" SelectedItem="{Binding SelectedClass}" PlaceholderText="Klasse auswählen">
<ComboBox.ItemTemplate>
<DataTemplate x:DataType="svc:UntisClassDto">
<TextBlock><Run Text="{Binding Name}"/><Run Text=" — "/><Run Text="{Binding LongName}"/></TextBlock>
</DataTemplate>
</ComboBox.ItemTemplate>
</ComboBox>
<TextBlock Text="{Binding Status}" FontSize="12" TextWrapping="Wrap"/>
</StackPanel>
<Grid Grid.Row="1" ColumnDefinitions="*,8,*" Margin="0,20,0,0">
<Button Grid.Column="0" Content="Abbrechen" Click="OnCancel"/>
<Button Grid.Column="2" Content="Schülerliste laden" IsEnabled="{Binding CanConfirm}" Click="OnConfirm"/>
</Grid>
</Grid>
</Window>
@@ -0,0 +1,15 @@
using Avalonia.Controls;
using Avalonia.Interactivity;
using LehrerApp.Desktop.ViewModels.Groups;
namespace LehrerApp.Desktop.Views.Groups;
public partial class WebUntisClassSelectionDialog : Window
{
public WebUntisClassSelectionDialog() => InitializeComponent();
private void OnCancel(object? sender, RoutedEventArgs e) => Close(false);
private void OnConfirm(object? sender, RoutedEventArgs e)
{
if (DataContext is WebUntisClassSelectionViewModel { SelectedClass: not null }) Close(true);
}
}
@@ -0,0 +1,56 @@
<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.WebUntisLessonAbsenceComparisonDialog"
x:DataType="vm:WebUntisLessonAbsenceComparisonViewModel"
Title="Fehlzeiten je Unterricht mit WebUntis abgleichen" Width="1000" Height="640"
MinWidth="800" MinHeight="450" WindowStartupLocation="CenterOwner">
<Grid RowDefinitions="Auto,Auto,Auto,*,Auto,Auto" Margin="24" RowSpacing="8">
<StackPanel Grid.Row="0" Spacing="4">
<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."
FontSize="12" Opacity="0.65" TextWrapping="Wrap"/>
</StackPanel>
<StackPanel Grid.Row="1" Orientation="Horizontal" Spacing="8">
<DatePicker SelectedDate="{Binding StartDate}"/>
<TextBlock Text="bis" VerticalAlignment="Center"/>
<DatePicker SelectedDate="{Binding EndDate}"/>
<Button Content="Fehlzeiten laden" Command="{Binding LoadCommand}" IsEnabled="{Binding !Busy}"/>
</StackPanel>
<Grid Grid.Row="2" ColumnDefinitions="Auto,1.2*,1.2*,80,80,1.1*,1.1*" ColumnSpacing="8" Margin="4,0">
<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="3" Text="Datum" 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="6" Text="Lokaler Status" FontSize="11" Opacity="0.6"/>
</Grid>
<ScrollViewer Grid.Row="3">
<ItemsControl ItemsSource="{Binding Rows}">
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:WebUntisLessonAbsenceRow">
<Grid ColumnDefinitions="Auto,1.2*,1.2*,80,80,1.1*,1.1*" ColumnSpacing="8" Margin="0,3">
<CheckBox Grid.Column="0" IsChecked="{Binding Selected}" IsEnabled="{Binding CanApply}"/>
<TextBlock Grid.Column="1" Text="{Binding UntisStudentName}" VerticalAlignment="Center" TextWrapping="Wrap"/>
<ComboBox Grid.Column="2" ItemsSource="{Binding Candidates}" SelectedItem="{Binding AssignedStudent}"
DisplayMemberBinding="{Binding FullName}" PlaceholderText="Schüler wählen…"
HorizontalAlignment="Stretch"/>
<TextBlock Grid.Column="3" Text="{Binding DateLabel}" 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="6" Text="{Binding LocalStatus}" TextWrapping="Wrap" VerticalAlignment="Center"/>
</Grid>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</ScrollViewer>
<CheckBox Grid.Row="4" Content="Unbekannte im geladenen Zeitraum auf „Anwesend“ setzen"
IsChecked="{Binding MarkUnknownAsPresent}"
ToolTip.Tip="Gilt nur für Schüler*innen, die WebUntis für den jeweiligen Tag nicht gemeldet hat und die lokal noch keinen Anwesenheitsstatus haben."/>
<Grid Grid.Row="5" ColumnDefinitions="*,Auto,Auto" ColumnSpacing="8">
<TextBlock Grid.Column="0" Text="{Binding Status}" FontSize="12" TextWrapping="Wrap" VerticalAlignment="Center"/>
<Button Grid.Column="1" Content="Schließen" Click="OnClose"/>
<Button Grid.Column="2" Content="Markierte übernehmen" Command="{Binding ApplyCommand}" IsEnabled="{Binding !Busy}"/>
</Grid>
</Grid>
</Window>
@@ -0,0 +1,10 @@
using Avalonia.Controls;
using Avalonia.Interactivity;
namespace LehrerApp.Desktop.Views.Groups;
public partial class WebUntisLessonAbsenceComparisonDialog : Window
{
public WebUntisLessonAbsenceComparisonDialog() => InitializeComponent();
private void OnClose(object? sender, RoutedEventArgs e) => Close();
}
+105
View File
@@ -13,6 +13,10 @@
xmlns:vmp="clr-namespace:LehrerApp.Desktop.ViewModels.Planning"
xmlns:vw="clr-namespace:LehrerApp.Desktop.Views.Workload"
xmlns:vmw="clr-namespace:LehrerApp.Desktop.ViewModels.Workload"
xmlns:vct="clr-namespace:LehrerApp.Desktop.Views.ClassTeacher"
xmlns:vmct="clr-namespace:LehrerApp.Desktop.ViewModels.ClassTeacher"
xmlns:vex="clr-namespace:LehrerApp.Desktop.Views.Exams"
xmlns:vmex="clr-namespace:LehrerApp.Desktop.ViewModels.Exams"
xmlns:svc="clr-namespace:LehrerApp.Desktop.Services"
x:Class="LehrerApp.Desktop.Views.MainWindow"
x:DataType="vm:MainWindowViewModel"
@@ -58,6 +62,12 @@
<DataTemplate DataType="vmw:WorkloadViewModel">
<vw:WorkloadView/>
</DataTemplate>
<DataTemplate DataType="vmct:ClassTeacherOverviewViewModel">
<vct:ClassTeacherOverviewView/>
</DataTemplate>
<DataTemplate DataType="vmex:ExamsOverviewViewModel">
<vex:ExamsOverviewView/>
</DataTemplate>
<DataTemplate DataType="vm:PlaceholderViewModel">
<views:PlaceholderView/>
</DataTemplate>
@@ -141,6 +151,20 @@
<ScrollViewer>
<StackPanel Classes="navitems" Margin="8,12,8,0" Spacing="2">
<Button Classes="navitem" HorizontalAlignment="Stretch"
HorizontalContentAlignment="Left" CornerRadius="6"
Click="OnOpenCommandPaletteClick"
ToolTip.Tip="Suchen und schnell erfassen (Strg/⌘+K)"
AutomationProperties.Name="Suchen und schnell erfassen">
<Grid ColumnDefinitions="Auto,*,Auto">
<TextBlock Classes="navicon" Text="⌕" TextAlignment="Center"/>
<TextBlock Grid.Column="1" Classes="navlabel" Text="Suchen / Erfassen"/>
<TextBlock Grid.Column="2" Classes="navlabel" Text="⌘K" FontSize="10" Opacity="0.45"
VerticalAlignment="Center"/>
</Grid>
</Button>
<Separator Margin="4,6"/>
<Button Classes="navitem" Classes.active="{Binding IsDashboardActive}" HorizontalAlignment="Stretch"
HorizontalContentAlignment="Left"
CornerRadius="6"
@@ -209,6 +233,16 @@
<TextBlock Classes="navlabel" Text="Arbeitszeit"/>
</StackPanel>
</Button>
<Button Classes="navitem" Classes.active="{Binding IsClassTeacherActive}" HorizontalAlignment="Stretch"
HorizontalContentAlignment="Left"
CornerRadius="6"
Command="{Binding NavigateToCommand}"
CommandParameter="{x:Static vm:NavItem.ClassTeacher}">
<StackPanel Classes="navcontent" Orientation="Horizontal" Spacing="10">
<TextBlock Classes="navicon" Text="🎓" TextAlignment="Center"/>
<TextBlock Classes="navlabel" Text="Klassenlehrer"/>
</StackPanel>
</Button>
<Button Classes="navitem" Classes.active="{Binding IsSettingsActive}" HorizontalAlignment="Stretch"
HorizontalContentAlignment="Left"
CornerRadius="6"
@@ -226,6 +260,77 @@
</DrawerPage>
<!-- Globale Suche und Schnellerfassung (14.2). Bewusst als Overlay auf der aktuellen Seite:
Der Nutzer behält den Kontext und kann mit Escape ohne Navigation zurückkehren. -->
<Border Background="#A0000000" IsVisible="{Binding IsCommandPaletteOpen}"
AutomationProperties.Name="Globale Suche und Schnellerfassung">
<Grid>
<Button Background="Transparent" BorderThickness="0"
Command="{Binding CloseCommandPaletteCommand}"
AutomationProperties.Name="Suche schließen"/>
<Border Width="680" MaxHeight="570" Margin="24" Padding="0"
HorizontalAlignment="Center" VerticalAlignment="Top"
Background="{DynamicResource AppCardBackgroundBrush}"
BorderBrush="{DynamicResource AppCardBorderBrush}" BorderThickness="1"
CornerRadius="12">
<Grid RowDefinitions="Auto,Auto,*,Auto">
<Grid Grid.Row="0" ColumnDefinitions="Auto,*,Auto" Margin="18,16,18,10">
<TextBlock Text="⌕" FontSize="24" VerticalAlignment="Center" Margin="0,0,10,0"/>
<TextBox x:Name="CommandPaletteSearchBox" Grid.Column="1"
Text="{Binding CommandPalette.Query, UpdateSourceTrigger=PropertyChanged}"
PlaceholderText="Schüler, Lerngruppe, Klausur oder Aufgabe suchen …"
FontSize="16" BorderThickness="0" Background="Transparent"
AutomationProperties.Name="Suchbegriff"/>
<Button Grid.Column="2" Content="Esc" FontSize="10" Padding="8,3"
Command="{Binding CloseCommandPaletteCommand}"
AutomationProperties.Name="Suche schließen"/>
</Grid>
<Separator Grid.Row="1"/>
<ListBox Grid.Row="2" ItemsSource="{Binding CommandPalette.Results}"
SelectedItem="{Binding CommandPalette.SelectedResult}"
Background="Transparent" BorderThickness="0" Margin="8"
MaxHeight="420">
<ListBox.ItemTemplate>
<DataTemplate x:DataType="vm:GlobalSearchResult">
<Button Background="Transparent" BorderThickness="0" Padding="10,8"
HorizontalAlignment="Stretch" HorizontalContentAlignment="Stretch"
Command="{Binding $parent[Window].((vm:MainWindowViewModel)DataContext).CommandPalette.ExecuteCommand}"
CommandParameter="{Binding}">
<Grid ColumnDefinitions="38,*,Auto">
<Border Width="30" Height="30" CornerRadius="7"
Background="{DynamicResource AppAccentSoftBackgroundBrush}"
VerticalAlignment="Center">
<TextBlock Text="{Binding Icon}" FontWeight="SemiBold"
Foreground="{DynamicResource AppAccentOnSoftBrush}"
HorizontalAlignment="Center" VerticalAlignment="Center"/>
</Border>
<StackPanel Grid.Column="1" Margin="10,0">
<TextBlock Text="{Binding Title}" FontSize="14" FontWeight="SemiBold"/>
<TextBlock Text="{Binding Subtitle}" FontSize="11" Opacity="0.6"
TextTrimming="CharacterEllipsis"/>
</StackPanel>
<Border Grid.Column="2" Padding="7,3" CornerRadius="8"
Background="{DynamicResource AppChipBackgroundBrush}"
VerticalAlignment="Center">
<TextBlock Text="{Binding KindLabel}" FontSize="10" Opacity="0.7"/>
</Border>
</Grid>
</Button>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
<TextBlock Grid.Row="2" Text="Keine passenden Ergebnisse."
Classes="emptyhint" HorizontalAlignment="Center" Margin="20"
IsVisible="{Binding CommandPalette.ShowNoResults}"/>
<Grid Grid.Row="3" ColumnDefinitions="*,Auto" Margin="18,8,18,14">
<TextBlock Text="↑↓ auswählen · Enter öffnen · Esc schließen" FontSize="10" Opacity="0.5"/>
<TextBlock Grid.Column="1" Text="Strg/⌘ + K" FontSize="10" Opacity="0.5"/>
</Grid>
</Grid>
</Border>
</Grid>
</Border>
<!-- Dauerhafte Meldung bei einer echten Protokoll-Inkompatibilität. Als Overlay außerhalb
von DrawerPage.Content bleibt der DataContext das MainWindowViewModel; innerhalb des
ContentPresenters würde eine fehlgeschlagene Bindung IsVisible auf true stehen lassen. -->
@@ -1,5 +1,6 @@
using Avalonia.Controls;
using Avalonia.Input;
using Avalonia.Threading;
using LehrerApp.Desktop.Services;
using LehrerApp.Desktop.ViewModels;
using LehrerApp.Sync;
@@ -19,6 +20,62 @@ public partial class MainWindow : Window
PointerMoved += (_, _) => NotifyActivity();
PointerPressed += (_, _) => NotifyActivity();
KeyDown += (_, _) => NotifyActivity();
KeyDown += OnWindowKeyDown;
}
private void OnWindowKeyDown(object? sender, KeyEventArgs e)
{
if (DataContext is not MainWindowViewModel vm) return;
var commandModifier = (e.KeyModifiers & (KeyModifiers.Control | KeyModifiers.Meta)) != 0;
if (commandModifier && e.Key == Key.K)
{
vm.OpenCommandPaletteCommand.Execute(null);
FocusCommandPalette();
e.Handled = true;
return;
}
if (!vm.IsCommandPaletteOpen) return;
if (e.Key == Key.Escape)
{
vm.CloseCommandPaletteCommand.Execute(null);
e.Handled = true;
}
else if (e.Key == Key.Enter)
{
vm.CommandPalette.ExecuteCommand.Execute(vm.CommandPalette.SelectedResult);
e.Handled = true;
}
else if (e.Key is Key.Down or Key.Up)
{
MoveCommandPaletteSelection(vm, e.Key == Key.Down ? 1 : -1);
e.Handled = true;
}
}
private void OnOpenCommandPaletteClick(object? sender, Avalonia.Interactivity.RoutedEventArgs e)
{
if (DataContext is MainWindowViewModel vm)
vm.OpenCommandPaletteCommand.Execute(null);
FocusCommandPalette();
}
private void FocusCommandPalette() => Dispatcher.UIThread.Post(() =>
{
if (this.FindControl<TextBox>("CommandPaletteSearchBox") is { } search)
{
search.Focus();
search.SelectAll();
}
});
private static void MoveCommandPaletteSelection(MainWindowViewModel vm, int delta)
{
var results = vm.CommandPalette.Results;
if (results.Count == 0) return;
var current = vm.CommandPalette.SelectedResult is { } selected ? results.IndexOf(selected) : -1;
vm.CommandPalette.SelectedResult = results[Math.Clamp(current + delta, 0, results.Count - 1)];
}
public void EnableFinalSync(SyncEngine syncEngine)
@@ -21,6 +21,21 @@
<Style Selector="Border.supervisioncell.substitution">
<Setter Property="Background" Value="#8E24AA"/>
</Style>
<Style Selector="Button.weekCellMenuTrigger">
<Setter Property="Width" Value="18"/>
<Setter Property="Height" Value="16"/>
<Setter Property="Padding" Value="0"/>
<Setter Property="Margin" Value="2"/>
<Setter Property="Background" Value="#33000000"/>
<Setter Property="Foreground" Value="White"/>
<Setter Property="FontSize" Value="11"/>
<Setter Property="CornerRadius" Value="4"/>
<Setter Property="HorizontalContentAlignment" Value="Center"/>
<Setter Property="VerticalContentAlignment" Value="Center"/>
</Style>
<Style Selector="Button.weekCellMenuTrigger:pointerover /template/ ContentPresenter">
<Setter Property="Background" Value="#55000000"/>
</Style>
</UserControl.Styles>
<Grid RowDefinitions="Auto,Auto,*">
@@ -226,7 +241,8 @@
</Border>
<Border Classes="timetablecell" Classes.assigned="{Binding IsAssigned}"
CornerRadius="6" IsVisible="{Binding IsSlotCell}">
<Button Background="{Binding ColorHex}" IsVisible="{Binding IsAssigned}"
<Panel IsVisible="{Binding IsAssigned}">
<Button Background="{Binding ColorHex}"
HorizontalAlignment="Stretch" VerticalAlignment="Stretch"
HorizontalContentAlignment="Stretch" CornerRadius="6" Padding="6"
Command="{Binding $parent[ItemsControl;1].((vm:TimetableViewModel)DataContext).OpenWeekCellCommand}"
@@ -256,6 +272,34 @@
</StackPanel>
</StackPanel>
</Button>
<!-- Nutzer-Feedback (zweite Runde): Popup-Menü statt Dropdown, hier
im Wochenraster statt in der Tagesliste (dort waren die
bisherigen zwei Buttons schon in Ordnung). Sibling-Button statt
verschachteltem Button/ContextMenu — Routing über
MenuItem.Click in TimetableView.axaml.cs, siehe Kommentar dort
zu DataContext-Vererbung vs. $parent-Vorfahrensuche im Flyout. -->
<Button Classes="weekCellMenuTrigger" Content="⋮"
HorizontalAlignment="Right" VerticalAlignment="Top"
IsVisible="{Binding HasGroupId}"
ToolTip.Tip="Weitere Ziele…">
<Button.Flyout>
<MenuFlyout Placement="BottomEdgeAlignedRight">
<MenuItem Header="▶ Unterrichtsansicht" IsVisible="{Binding HasLesson}"
Tag="{x:Static vm:TimetableLessonDestination.TeachingMode}"
Click="OnWeekCellMenuItemClick"/>
<MenuItem Header="📋 Planungsviewer" IsVisible="{Binding HasLesson}"
Tag="{x:Static vm:TimetableLessonDestination.Viewer}"
Click="OnWeekCellMenuItemClick"/>
<MenuItem Header="🪑 Sitzplan"
Tag="{x:Static vm:TimetableLessonDestination.SeatingPlan}"
Click="OnWeekCellMenuItemClick"/>
<MenuItem Header="✏️ Planung"
Tag="{x:Static vm:TimetableLessonDestination.Planning}"
Click="OnWeekCellMenuItemClick"/>
</MenuFlyout>
</Button.Flyout>
</Button>
</Panel>
</Border>
</Grid>
</DataTemplate>
@@ -308,6 +352,11 @@
<ContentPage Header="Bearbeiten">
<ScrollViewer>
<StackPanel Margin="32,20,32,28" Spacing="10">
<StackPanel Orientation="Horizontal" Spacing="8" Margin="0,0,0,6">
<Button Content="Aus WebUntis laden…" Command="{Binding ImportWebUntisTimetableCommand}"/>
<TextBlock Text="Importiert oder vergleicht eine typische Unterrichtswoche; Vertretungen bleiben im iCal-Abgleich."
FontSize="11" Opacity="0.6" VerticalAlignment="Center"/>
</StackPanel>
<!-- Zeilenweise (GridRows) statt eines flachen UniformGrid, siehe Kommentar im
Wochenraster oben (Heute-Tab) - gleicher Grund (Grid.RowDefinitions lässt sich
nicht per {Binding} setzen). -->
@@ -1,10 +1,13 @@
using Avalonia.Controls;
using Avalonia.Interactivity;
using LehrerApp.Core.Interfaces;
using LehrerApp.Core.Models;
using LehrerApp.Core.Services;
using LehrerApp.Desktop.ViewModels;
using LehrerApp.Desktop.ViewModels.Planning;
using LehrerApp.Desktop.ViewModels.Groups;
using LehrerApp.Desktop.Views.Groups;
using LehrerApp.Desktop.Services;
using Microsoft.Extensions.DependencyInjection;
namespace LehrerApp.Desktop.Views.Planning;
@@ -20,11 +23,67 @@ public partial class TimetableView : UserControl
{
vm.OnEditSlot = ShowSlotDialog;
vm.OnAddSubstitution = ShowSubstitutionDialog;
vm.OnImportWebUntisTimetable = ShowWebUntisTimetableDialog;
vm.OnOpenLessonViewer = ShowLessonViewerDialog;
vm.OnOpenTeachingMode = ShowTeachingMode;
}
}
/// Nutzer-Feedback (zweite Runde — die erste Fassung hatte das Problem an der falschen
/// Stelle gelöst, in der "Heute"-Tagesliste statt im Wochenraster): im Wochenraster oben
/// führt ein Klick auf eine Stunden-Kachel bislang entweder in den Planungsviewer oder zur
/// Einheitenplanung — je nachdem, ob schon eine Lesson existiert, ohne dass das von außen
/// erkennbar wäre. Popup-Menü (MenuFlyout, kein ComboBox mehr) mit allen vier Zielen als
/// Alternative zum Direktklick, siehe <see cref="TimetableViewModel.OpenWeekCellCommand"/>
/// für den "einheitlicheren" Standard-Klick (Unterrichtsansicht bei laufender Stunde, sonst
/// Planungsviewer, sonst Einheitenplanung). MenuItem.Click statt Command-Binding: ein
/// $parent[ItemsControl]-Vorfahrenpfad (wie beim Zeilen-Button) funktioniert innerhalb eines
/// Flyouts nicht zuverlässig, weil dessen Popup nicht im normalen visuellen Baum hängt (siehe
/// TODO.md-Nachtrag zum Klassenlehrer-Bereich) — DataContext-Vererbung (kein Pfad-Suchen,
/// nur der normale Eltern-Wert) funktioniert dort aber sehr wohl, deshalb liest der Handler
/// die Zelle über <c>((MenuItem)sender).DataContext</c> statt über eine Binding.
private async void OnWeekCellMenuItemClick(object? sender, RoutedEventArgs e)
{
if (sender is not MenuItem { Tag: TimetableLessonDestination destination } menuItem) return;
if (menuItem.DataContext is not WeekCellItem cell) return;
if (DataContext is not TimetableViewModel vm) return;
switch (destination)
{
case TimetableLessonDestination.TeachingMode:
if (cell.Lesson is { } teachLesson && vm.OnOpenTeachingMode is not null)
await vm.OnOpenTeachingMode(teachLesson);
break;
case TimetableLessonDestination.Viewer:
if (cell.Lesson is { } viewLesson && vm.OnOpenLessonViewer is not null)
await vm.OnOpenLessonViewer(viewLesson);
break;
case TimetableLessonDestination.Planning:
if (cell.GroupId != Guid.Empty)
App.Services.GetRequiredService<MainWindowViewModel>().NavigateToGroupDetail(cell.GroupId, 6);
break;
case TimetableLessonDestination.SeatingPlan:
if (cell.GroupId != Guid.Empty)
App.Services.GetRequiredService<MainWindowViewModel>().NavigateToGroupDetail(cell.GroupId, 2);
break;
}
}
private async Task ShowWebUntisTimetableDialog()
{
var owner = TopLevel.GetTopLevel(this) as Window;
if (owner is null) return;
var vm = new WebUntisTimetableImportViewModel(
App.Services.GetRequiredService<WebUntisIntegrationService>(),
App.Services.GetRequiredService<WebUntisSettingsService>(),
App.Services.GetRequiredService<ITimetableSlotRepository>(),
App.Services.GetRequiredService<IGroupRepository>());
var dialog = new WebUntisTimetableImportDialog { DataContext = vm };
vm.OnCreateGroup = dialog.CreateGroupAsync;
await vm.InitializeAsync();
await dialog.ShowDialog<bool>(owner);
}
private async Task ShowTeachingMode(Lesson lesson)
{
var owner = TopLevel.GetTopLevel(this) as Window;
@@ -33,7 +92,9 @@ public partial class TimetableView : UserControl
var teachingModeVm = new TeachingModeViewModel(lesson, group,
App.Services.GetRequiredService<IAlternativeLessonPathRepository>(),
App.Services.GetRequiredService<SeatingPlanTabViewModel>());
App.Services.GetRequiredService<ILessonRepository>(),
App.Services.GetRequiredService<SeatingPlanTabViewModel>(),
App.Services.GetRequiredService<ParticipationTabViewModel>());
var window = new TeachingModeWindow { DataContext = teachingModeVm };
await window.ShowDialog(owner);
}
@@ -0,0 +1,56 @@
<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="clr-namespace:LehrerApp.Desktop.ViewModels.Planning"
xmlns:svc="clr-namespace:LehrerApp.Desktop.Services"
x:Class="LehrerApp.Desktop.Views.Planning.WebUntisTimetableImportDialog"
x:DataType="vm:WebUntisTimetableImportViewModel"
Title="Stundenplan aus WebUntis" Width="820" Height="650"
MinWidth="680" MinHeight="480" WindowStartupLocation="CenterOwner">
<Grid RowDefinitions="Auto,Auto,*,Auto" Margin="24" RowSpacing="14">
<StackPanel Grid.Row="0" Spacing="4">
<TextBlock Text="Stundenplan aus WebUntis" Classes="dialogtitle"/>
<TextBlock Text="Wähle eine typische Unterrichtswoche. Vorhandene Einträge werden vorgeschlagen und erst nach Bestätigung ersetzt."
FontSize="12" Opacity="0.65" TextWrapping="Wrap"/>
</StackPanel>
<Grid Grid.Row="1" ColumnDefinitions="2*,*,Auto" ColumnSpacing="10">
<ComboBox Grid.Column="0" ItemsSource="{Binding Teachers}" SelectedItem="{Binding SelectedTeacher}">
<ComboBox.ItemTemplate>
<DataTemplate x:DataType="svc:UntisTeacherDto"><TextBlock Text="{Binding DisplayName}"/></DataTemplate>
</ComboBox.ItemTemplate>
</ComboBox>
<DatePicker Grid.Column="1" SelectedDate="{Binding WeekDate}"/>
<Button Grid.Column="2" Content="Woche laden" Command="{Binding LoadCommand}" IsEnabled="{Binding !Busy}"/>
</Grid>
<ScrollViewer Grid.Row="2">
<ItemsControl ItemsSource="{Binding Rows}">
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:WebUntisTimetableRow">
<Grid ColumnDefinitions="48,85,2*,2*,Auto" ColumnSpacing="8" Margin="0,3">
<TextBlock Grid.Column="0" Text="{Binding WeekdayLabel}" VerticalAlignment="Center" FontWeight="SemiBold"/>
<StackPanel Grid.Column="1">
<TextBlock Text="{Binding PeriodNumber, StringFormat={}{0}. Std.}" FontSize="12"/>
<TextBlock Text="{Binding TimeLabel}" FontSize="10" Opacity="0.6"/>
</StackPanel>
<TextBlock Grid.Column="2" Text="{Binding UntisLabel}" TextWrapping="Wrap" VerticalAlignment="Center"/>
<ComboBox Grid.Column="3" ItemsSource="{Binding GroupOptions}" SelectedItem="{Binding SelectedGroup}"
PlaceholderText="nicht übernehmen">
<ComboBox.ItemTemplate>
<DataTemplate x:DataType="vm:WebUntisGroupOption"><TextBlock Text="{Binding DisplayName}"/></DataTemplate>
</ComboBox.ItemTemplate>
</ComboBox>
<Button Grid.Column="4" Content="Neue Gruppe…" FontSize="11"
Command="{Binding $parent[ItemsControl].((vm:WebUntisTimetableImportViewModel)DataContext).CreateGroupCommand}"
CommandParameter="{Binding}"/>
</Grid>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</ScrollViewer>
<Grid Grid.Row="3" ColumnDefinitions="*,Auto,Auto" ColumnSpacing="8">
<TextBlock Grid.Column="0" Text="{Binding Status}" FontSize="12" TextWrapping="Wrap" VerticalAlignment="Center"/>
<Button Grid.Column="1" Content="Abbrechen" Click="OnCancel"/>
<Button Grid.Column="2" Content="Zuordnung übernehmen" Command="{Binding SaveCommand}" Click="OnSave"
IsEnabled="{Binding !Busy}"/>
</Grid>
</Grid>
</Window>
@@ -0,0 +1,36 @@
using Avalonia.Controls;
using Avalonia.Interactivity;
using LehrerApp.Core.Models;
using LehrerApp.Desktop.ViewModels.Groups;
using LehrerApp.Desktop.ViewModels.Planning;
using LehrerApp.Desktop.Views.Groups;
using Microsoft.Extensions.DependencyInjection;
namespace LehrerApp.Desktop.Views.Planning;
public partial class WebUntisTimetableImportDialog : Window
{
public WebUntisTimetableImportDialog() => InitializeComponent();
private void OnCancel(object? sender, RoutedEventArgs e) => Close(false);
private void OnSave(object? sender, RoutedEventArgs e)
{
if (DataContext is WebUntisTimetableImportViewModel { Saved: true }) Close(true);
}
public async Task<LearningGroup?> CreateGroupAsync(WebUntisTimetableRow source)
{
var vm = App.Services.GetRequiredService<AddGroupDialogViewModel>();
vm.Name = source.SuggestedGroupName;
vm.Subject = source.SubjectName ?? "";
vm.GradeLevel = ParseGrade(source.SuggestedGroupName) ?? 10;
var dialog = new AddGroupDialog { DataContext = vm };
return await dialog.ShowDialog<bool>(this) ? vm.Result : null;
}
private static int? ParseGrade(string value)
{
var digits = new string(value.TakeWhile(char.IsDigit).ToArray());
return int.TryParse(digits, out var grade) && grade is >= 1 and <= 13 ? grade : null;
}
}
@@ -967,7 +967,53 @@
<TextBlock Text="Untis-Einbettung" FontSize="18" FontWeight="SemiBold"/>
<TextBlock FontSize="12" Opacity="0.6" TextWrapping="Wrap"
Text="Bindet den persönlichen WebUntis-Stundenplan und den schulweiten Jahresplan als zwei unabhängige iCal-Quellen ein."/>
Text="Bindet WebUntis-Daten über deinen persönlichen Zugang ein. Der bestehende iCal-Abgleich für Vertretungen bleibt davon unabhängig."/>
<TextBlock Text="WebUntis-API" FontSize="16" FontWeight="SemiBold"/>
<TextBlock FontSize="12" Opacity="0.6" TextWrapping="Wrap"
Text="Ermöglicht Stundenplan-, Schüler- und Fehlzeitenabgleich direkt zwischen diesem Gerät und WebUntis. Zugangsdaten werden lokal verschlüsselt gespeichert; weder sie noch Schülerdaten oder CSV-Reports passieren den LehrerApp-Server."/>
<Grid ColumnDefinitions="*,*" RowDefinitions="Auto,Auto" ColumnSpacing="8" RowSpacing="8">
<StackPanel Grid.Row="0" Grid.Column="0" Spacing="4">
<TextBlock Text="Schule" FontSize="12" Opacity="0.7"/>
<TextBox Text="{Binding UntisSchool}" PlaceholderText="Schulkennung, nicht Anzeigename"/>
</StackPanel>
<StackPanel Grid.Row="0" Grid.Column="1" Spacing="4">
<TextBlock Text="Server (optional)" FontSize="12" Opacity="0.7"/>
<TextBox Text="{Binding UntisHost}" PlaceholderText="z. B. arche.webuntis.com"/>
</StackPanel>
<StackPanel Grid.Row="1" Grid.Column="0" Spacing="4">
<TextBlock Text="Benutzername" FontSize="12" Opacity="0.7"/>
<TextBox Text="{Binding UntisUsername}"/>
</StackPanel>
<StackPanel Grid.Row="1" Grid.Column="1" Spacing="4">
<TextBlock Text="Passwort" FontSize="12" Opacity="0.7"/>
<TextBox Text="{Binding UntisPassword}" PasswordChar="●"/>
</StackPanel>
</Grid>
<TextBlock FontSize="11" Opacity="0.6" TextWrapping="Wrap"
Text="Die Schulkennung steht in der WebUntis-Anmelde-URL hinter ?school=. Als Server kannst du auch die vollständige Anmelde-URL einfügen; Server und Schulkennung sind häufig verschieden."/>
<TextBlock Text="{Binding UntisApiStatus}" FontSize="12" TextWrapping="Wrap"/>
<StackPanel Orientation="Horizontal" Spacing="8">
<Button Content="Anmeldung prüfen und speichern" Command="{Binding UntisSaveApiCommand}"
IsEnabled="{Binding !UntisApiBusy}"/>
<Button Content="API-Zugang entfernen" Command="{Binding UntisRemoveApiCommand}"
IsVisible="{Binding UntisApiIsConfigured}" IsEnabled="{Binding !UntisApiBusy}"/>
</StackPanel>
<StackPanel Spacing="8" IsVisible="{Binding UntisApiIsConfigured}">
<Separator Margin="0,8"/>
<TextBlock Text="Klassenlehrer" FontSize="16" FontWeight="SemiBold"/>
<TextBlock FontSize="12" Opacity="0.6" TextWrapping="Wrap"
Text="Die Klasse, deren Klassenlehrer/-in du bist — unabhängig von deinem eigenen Unterricht. Schaltet den Klassenlehrer-Bereich in der Seitenleiste frei."/>
<TextBlock FontSize="13" Text="{Binding UntisHomeroomClassDisplay}"/>
<StackPanel Orientation="Horizontal" Spacing="8">
<Button Content="Klasse auswählen…" Command="{Binding UntisPickHomeroomClassCommand}"/>
<Button Content="Entfernen" Command="{Binding UntisClearHomeroomClassCommand}"
IsVisible="{Binding UntisHomeroomClassName, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
</StackPanel>
</StackPanel>
<Separator Margin="0,8"/>
<TextBlock Text="WebUntis-Stundenplan-Abgleich" FontSize="16" FontWeight="SemiBold"/>
<TextBlock FontSize="12" Opacity="0.6" TextWrapping="Wrap"
@@ -4,8 +4,10 @@ using Avalonia.Platform.Storage;
using LehrerApp.Core.Interfaces;
using LehrerApp.Desktop.Services;
using LehrerApp.Desktop.ViewModels;
using LehrerApp.Desktop.ViewModels.Groups;
using LehrerApp.Desktop.ViewModels.Planning;
using LehrerApp.Desktop.ViewModels.Settings;
using LehrerApp.Desktop.Views.Groups;
using LehrerApp.Desktop.Views.Planning;
using LehrerApp.Desktop.Views.Shared;
using Microsoft.Extensions.DependencyInjection;
@@ -31,6 +33,7 @@ public partial class SettingsView : UserControl
vm.OnConfirmRecoveryRestore = ShowRecoveryRestoreConfirmDialog;
vm.OnThemeChanged = App.ApplyTheme;
vm.OnReviewUntisMapping = ShowUntisMappingReviewDialog;
vm.OnPickHomeroomClass = PickHomeroomClassAsync;
_ = vm.LoadSchoolLocationCommand.ExecuteAsync(null);
}
}
@@ -51,6 +54,20 @@ public partial class SettingsView : UserControl
await dialog.ShowDialog<bool>(owner);
}
private async Task<(int UntisId, string Name)?> PickHomeroomClassAsync()
{
var owner = TopLevel.GetTopLevel(this) as Window;
if (owner is null) return null;
var untis = App.Services.GetRequiredService<WebUntisIntegrationService>();
var selectionVm = new WebUntisClassSelectionViewModel(untis);
var selection = new WebUntisClassSelectionDialog { DataContext = selectionVm };
await selectionVm.InitializeAsync();
if (!await selection.ShowDialog<bool>(owner) || selectionVm.SelectedClass is null) return null;
return (selectionVm.SelectedClass.UntisId, selectionVm.SelectedClass.Name);
}
private async Task<bool> SaveRecoveryFile(string content)
{
var topLevel = TopLevel.GetTopLevel(this);
@@ -233,6 +233,9 @@
<CheckBox Content="Vertraulich" IsChecked="{Binding IsConfidential}"
ToolTip.Tip="Blendet den Inhalt in der Übersicht standardmäßig aus; erst nach Klick auf 'Anzeigen' sichtbar."/>
<CheckBox Content="Nicht mit WebUntis abgleichen" IsChecked="{Binding ExcludeFromWebUntisSync}"
ToolTip.Tip="Erscheint dann nicht als Vorschlag zum Nacherfassen im WebUntis-Klassenbuch-Abgleich (Dashboard)."/>
</StackPanel>
</ScrollViewer>
@@ -0,0 +1,88 @@
<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="clr-namespace:LehrerApp.Desktop.ViewModels.Students"
x:Class="LehrerApp.Desktop.Views.Students.WebUntisDocumentationComparisonDialog"
x:DataType="vm:WebUntisDocumentationComparisonViewModel"
Title="Klassenbucheinträge mit WebUntis abgleichen" Width="1150" Height="720"
MinWidth="900" MinHeight="500" WindowStartupLocation="CenterOwner">
<Grid RowDefinitions="Auto,Auto,Auto,2*,Auto,Auto,1*,Auto" Margin="24" RowSpacing="8">
<StackPanel Grid.Row="0" Spacing="4">
<TextBlock Text="Klassenbucheinträge mit WebUntis abgleichen" Classes="dialogtitle"/>
<TextBlock TextWrapping="Wrap" FontSize="12" Opacity="0.65"
Text="Nur eigene WebUntis-Einträge (Benutzer = eigener Login). Zeilen ohne automatische Zuordnung bitte manuell einem/einer Schüler*in zuweisen. Bereits lokal vorhandene Einträge sind gesperrt."/>
</StackPanel>
<StackPanel Grid.Row="1" Orientation="Horizontal" Spacing="8">
<DatePicker SelectedDate="{Binding StartDate}"/>
<TextBlock Text="bis" VerticalAlignment="Center"/>
<DatePicker SelectedDate="{Binding EndDate}"/>
<Button Content="Klassenbucheinträge laden" Command="{Binding LoadCommand}" IsEnabled="{Binding !Busy}"/>
</StackPanel>
<Grid Grid.Row="2" ColumnDefinitions="Auto,70,55,65,1.1*,1.1*,1*,1.3*,1.3*" ColumnSpacing="8" Margin="4,0">
<TextBlock Grid.Column="1" Text="Datum" FontSize="11" Opacity="0.6"/>
<TextBlock Grid.Column="2" Text="Klasse" FontSize="11" Opacity="0.6"/>
<TextBlock Grid.Column="3" Text="Fach" FontSize="11" Opacity="0.6"/>
<TextBlock Grid.Column="4" Text="Name (WebUntis)" FontSize="11" Opacity="0.6"/>
<TextBlock Grid.Column="5" Text="Zuordnung" FontSize="11" Opacity="0.6"/>
<TextBlock Grid.Column="6" Text="Kategorie" FontSize="11" Opacity="0.6"/>
<TextBlock Grid.Column="7" Text="Text" FontSize="11" Opacity="0.6"/>
<TextBlock Grid.Column="8" Text="Lokal am selben Tag" FontSize="11" Opacity="0.6"/>
</Grid>
<ScrollViewer Grid.Row="3">
<ItemsControl ItemsSource="{Binding Rows}">
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:WebUntisDocumentationRow">
<Grid ColumnDefinitions="Auto,70,55,65,1.1*,1.1*,1*,1.3*,1.3*" ColumnSpacing="8" Margin="0,3">
<CheckBox Grid.Column="0" IsChecked="{Binding Selected}" IsEnabled="{Binding CanApply}"/>
<TextBlock Grid.Column="1" Text="{Binding DateLabel}" VerticalAlignment="Center"/>
<TextBlock Grid.Column="2" Text="{Binding ClassName}" VerticalAlignment="Center"/>
<TextBlock Grid.Column="3" Text="{Binding Subject}" VerticalAlignment="Center"/>
<TextBlock Grid.Column="4" Text="{Binding UntisStudentName}" VerticalAlignment="Center" TextWrapping="Wrap"/>
<ComboBox Grid.Column="5" ItemsSource="{Binding Candidates}" SelectedItem="{Binding AssignedStudent}"
DisplayMemberBinding="{Binding FullName}" PlaceholderText="Schüler*in wählen…"
HorizontalAlignment="Stretch"/>
<StackPanel Grid.Column="6" Spacing="0">
<TextBlock Text="{Binding CategoryName}" TextWrapping="Wrap"/>
<TextBlock Text="{Binding CategoryGroup}" FontSize="11" Opacity="0.6"/>
</StackPanel>
<TextBlock Grid.Column="7" Text="{Binding Text}" TextWrapping="Wrap" VerticalAlignment="Center"/>
<TextBlock Grid.Column="8" Text="{Binding ExistingLocalEntry}" TextWrapping="Wrap"
Foreground="DarkOrange" VerticalAlignment="Center"
IsVisible="{Binding ExistingLocalEntry, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
</Grid>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</ScrollViewer>
<Grid Grid.Row="4" ColumnDefinitions="*,Auto" ColumnSpacing="8">
<TextBlock Grid.Column="0" Text="{Binding Status}" FontSize="12" TextWrapping="Wrap" VerticalAlignment="Center"/>
<Button Grid.Column="1" Content="Markierte übernehmen" Command="{Binding ApplyCommand}" IsEnabled="{Binding !Busy}"/>
</Grid>
<StackPanel Grid.Row="5" Spacing="2">
<TextBlock Text="Lokale Einträge im Zeitraum ohne WebUntis-Gegenstück" FontWeight="SemiBold" FontSize="13"/>
<TextBlock FontSize="12" Opacity="0.65" TextWrapping="Wrap"
Text="Kein Schreibzugriff auf WebUntis — Text zum manuellen Nacherfassen in die Zwischenablage kopieren."/>
</StackPanel>
<ScrollViewer Grid.Row="6">
<ItemsControl ItemsSource="{Binding LocalOnlyRows}">
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:LocalOnlyDocumentationRow">
<Grid ColumnDefinitions="80,1.2*,1.5*,2.5*,Auto" ColumnSpacing="8" Margin="0,3">
<TextBlock Grid.Column="0" Text="{Binding DateLabel}" VerticalAlignment="Center"/>
<TextBlock Grid.Column="1" Text="{Binding StudentName}" VerticalAlignment="Center" TextWrapping="Wrap"/>
<TextBlock Grid.Column="2" Text="{Binding GroupName}" VerticalAlignment="Center" TextWrapping="Wrap"/>
<StackPanel Grid.Column="3">
<TextBlock Text="{Binding Title}" FontWeight="SemiBold"/>
<TextBlock Text="{Binding Content}" TextWrapping="Wrap" FontSize="12" Opacity="0.8"/>
</StackPanel>
<Button Grid.Column="4" Content="In Zwischenablage" Click="OnCopyToClipboardClick"/>
</Grid>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</ScrollViewer>
<Button Grid.Row="7" Content="Schließen" Click="OnClose" HorizontalAlignment="Right"/>
</Grid>
</Window>
@@ -0,0 +1,24 @@
using Avalonia.Controls;
using Avalonia.Input.Platform;
using Avalonia.Interactivity;
using LehrerApp.Desktop.Services;
using LehrerApp.Desktop.ViewModels.Students;
using Microsoft.Extensions.DependencyInjection;
namespace LehrerApp.Desktop.Views.Students;
public partial class WebUntisDocumentationComparisonDialog : Window
{
public WebUntisDocumentationComparisonDialog() => InitializeComponent();
private void OnClose(object? sender, RoutedEventArgs e) => Close();
private async void OnCopyToClipboardClick(object? sender, RoutedEventArgs e)
{
if (sender is not Button { DataContext: LocalOnlyDocumentationRow row }) return;
var clipboard = TopLevel.GetTopLevel(this)?.Clipboard;
if (clipboard is null) return;
await clipboard.SetTextAsync(row.ClipboardText);
App.Services.GetRequiredService<NotificationService>().ShowSuccess("In die Zwischenablage kopiert.");
}
}
+89
View File
@@ -229,6 +229,95 @@ public sealed class SyncEngineTests
Assert.Equal(0, temp.Queue.PendingCount());
}
[Fact]
public async Task SyncNowAsync_MehrAlsEinPushBatch_LeertQueueInEinemSync()
{
using var temp = new TempEventQueue();
for (var i = 0; i < SyncProtocol.PushBatchSize + 5; i++)
temp.Queue.Enqueue("this-device", DeviceType.Desktop,
"Lesson", Guid.NewGuid().ToString(), "Save", $"payload-{i}");
var pushedBatchSizes = new List<int>();
long serverSeq = 0;
var handler = new FakeHttpMessageHandler(req =>
{
if (req.RequestUri!.AbsolutePath == "/api/sync/push")
{
var events = req.Content!.ReadFromJsonAsync<List<SyncEvent>>().GetAwaiter().GetResult()!;
pushedBatchSizes.Add(events.Count);
var assigned = events.ToDictionary(e => e.EventId, _ => ++serverSeq);
return new HttpResponseMessage(HttpStatusCode.OK)
{
Content = JsonContent.Create(new PushResponse
{ ServerSequenceNr = serverSeq, AssignedServerSeqs = assigned }),
};
}
return new HttpResponseMessage(HttpStatusCode.OK)
{ Content = JsonContent.Create(new PullResponse()) };
});
var engine = MakeEngine(temp, handler);
var result = await engine.SyncNowAsync();
Assert.True(result.Success);
Assert.Equal(SyncProtocol.PushBatchSize + 5, result.EventsPushed);
Assert.Equal([SyncProtocol.PushBatchSize, 5], pushedBatchSizes);
Assert.Equal(0, temp.Queue.PendingCount());
}
[Fact]
public async Task SyncNowAsync_VollerPullBatch_LaedtFolgebatchImSelbenSync()
{
using var temp = new TempEventQueue();
using var db = NewInMemoryContext();
var allEvents = Enumerable.Range(1, SyncProtocol.PullBatchSize + 3)
.Select(sequence =>
{
var student = new Student { FirstName = $"Vorname-{sequence}", LastName = "Beispiel" };
return new SyncEvent
{
DeviceId = "other-device", DeviceType = DeviceType.Desktop,
EntityType = nameof(Student), EntityId = student.Id.ToString(),
Operation = "Save", Payload = SyncCrypto.EncryptObject(student, Key),
SequenceNr = sequence,
};
}).ToList();
var pullRequests = 0;
var handler = new FakeHttpMessageHandler(req =>
{
if (req.RequestUri!.AbsolutePath == "/api/sync/pull")
{
pullRequests++;
var since = long.Parse(req.RequestUri.Query.TrimStart('?').Split('&')
.Select(part => part.Split('=')).Single(part => part[0] == "since")[1]);
var events = allEvents.Where(e => e.SequenceNr > since)
.Take(SyncProtocol.PullBatchSize).ToList();
return new HttpResponseMessage(HttpStatusCode.OK)
{
Content = JsonContent.Create(new PullResponse
{
Events = events,
ServerSequenceNr = events.Count == 0 ? since : events[^1].SequenceNr,
}),
};
}
return new HttpResponseMessage(HttpStatusCode.OK)
{ Content = JsonContent.Create(new PushResponse()) };
});
var engine = MakeEngine(temp, handler, new EventApplier(db, Key, versions: temp.Queue));
var dataChangedCount = 0;
engine.DataChanged += () => dataChangedCount++;
var result = await engine.SyncNowAsync();
Assert.True(result.Success);
Assert.Equal(SyncProtocol.PullBatchSize + 3, result.EventsPulled);
Assert.Equal(2, pullRequests);
Assert.Equal(SyncProtocol.PullBatchSize + 3, db.Students.Count());
Assert.Equal(SyncProtocol.PullBatchSize + 3, temp.Queue.GetLastServerSeq());
Assert.Equal(1, dataChangedCount);
}
[Fact]
public async Task PushAsync_SetztBasedOnServerSeqAusLokalerVersionsverfolgung()
{
+8 -2
View File
@@ -49,8 +49,14 @@ public class EventQueue : IDisposable
return evt;
}
public List<SyncEvent> GetPending(int max = 200) =>
_queue.Find(Query.All(nameof(SyncEvent.SequenceNr))).Take(max).ToList();
public List<SyncEvent> GetPending(int max = SyncProtocol.PushBatchSize,
IReadOnlySet<Guid>? excludedEventIds = null)
{
var pending = _queue.Find(Query.All(nameof(SyncEvent.SequenceNr))).AsEnumerable();
if (excludedEventIds is not null)
pending = pending.Where(e => !excludedEventIds.Contains(e.EventId));
return pending.Take(max).ToList();
}
public int PendingCount() => _queue.Count();
public void Acknowledge(IEnumerable<Guid> ids) { foreach (var id in ids) _queue.Delete(id); }
public long GetLastServerSeq() => _meta.FindById("serverSeq")?.Value ?? 0;
+53 -14
View File
@@ -86,14 +86,37 @@ public class SyncEngine : IDisposable
private async Task<SyncResult> RunSyncAsync(bool isAutomatic)
{
var pulledDataChanged = false;
SetState(SyncState.Syncing);
_logger?.Info($"Sync: Start ({(isAutomatic ? "automatisch" : "manuell")}), Gerät={_config.DeviceId}, " +
$"{_queue.PendingCount()} lokal ausstehend");
try
{
var (pushed, pushConflicts) = await PushAsync();
var pushed = 0;
var pushConflicts = 0;
var deferredPushEvents = new HashSet<Guid>();
PushBatchResult pushBatch;
do
{
pushBatch = await PushAsync(deferredPushEvents);
pushed += pushBatch.Pushed;
pushConflicts += pushBatch.Conflicts;
deferredPushEvents.UnionWith(pushBatch.DeferredEventIds);
} while (pushBatch.SourceEventCount == SyncProtocol.PushBatchSize);
await _attachments.UploadPendingAsync(_queue);
var (pulled, conflicts) = await PullAsync();
var pulled = 0;
var conflicts = 0;
PullBatchResult pullBatch;
do
{
pullBatch = await PullAsync();
pulled += pullBatch.Pulled;
conflicts += pullBatch.Conflicts;
pulledDataChanged |= pullBatch.Pulled > 0;
} while (pullBatch.Pulled == SyncProtocol.PullBatchSize);
_queue.SetLastSyncAt(DateTime.UtcNow);
SetState(SyncState.Idle);
_logger?.Info($"Sync: Fertig - {pushed} gepusht ({pushConflicts} Push-Konflikte), " +
@@ -121,12 +144,19 @@ public class SyncEngine : IDisposable
SetState(SyncState.Error, ex.Message);
return new() { Reason = ex.Message };
}
finally
{
// Auch wenn ein späterer Batch fehlschlägt, wurden frühere Batches bereits dauerhaft
// angewendet. Die Oberfläche muss diesen erfolgreich übernommenen Zwischenstand dann
// trotzdem neu laden; bei einem erfolgreichen Drain feuert der Hook genau einmal.
if (pulledDataChanged) DataChanged?.Invoke();
}
}
private async Task<(int Pushed, int Conflicts)> PushAsync()
private async Task<PushBatchResult> PushAsync(IReadOnlySet<Guid> excludedEventIds)
{
var pending = DeduplicatePending();
if (pending.Count == 0) return (0, 0);
var pending = DeduplicatePending(excludedEventIds, out var sourceEventCount);
if (pending.Count == 0) return new(0, 0, sourceEventCount, []);
// BasedOnServerSeq erst unmittelbar vor dem Senden setzen (nicht beim Enqueue) - zwischen
// Enqueue und Push kann ein Pull den lokal bekannten Stand dieser Entität aktualisiert
// haben (siehe EventApplier.ApplyAsync).
@@ -139,7 +169,11 @@ public class SyncEngine : IDisposable
using var resp = await _http.SendAsync(request);
await SyncProtocol.EnsureCompatibleSuccessAsync(resp);
var result = await resp.Content.ReadFromJsonAsync<PushResponse>();
if (result is null) { _logger?.Warn("Sync: Push - leere Server-Antwort."); return (0, 0); }
if (result is null)
{
_logger?.Warn("Sync: Push - leere Server-Antwort.");
return new(0, 0, sourceEventCount, pending.Select(e => e.EventId).ToList());
}
_queue.Acknowledge(pending
.Where(e => !result.ConflictingEventIds.Contains(e.EventId))
.Select(e => e.EventId));
@@ -161,8 +195,8 @@ public class SyncEngine : IDisposable
$"{result.ConflictingEventIds.Count} abgelehnt (Konflikt).");
if (result.ConflictingEventIds.Count > 0)
await HandleRejectedAsync(pending.Where(e => result.ConflictingEventIds.Contains(e.EventId)));
return (pending.Count - result.ConflictingEventIds.Count,
result.ConflictingEventIds.Count);
return new(pending.Count - result.ConflictingEventIds.Count,
result.ConflictingEventIds.Count, sourceEventCount, result.ConflictingEventIds);
}
// Payload ist immer ein vollständiges Entitäts-Snapshot (nie ein Delta, siehe
@@ -171,9 +205,11 @@ public class SyncEngine : IDisposable
// exakte BasedOnServerSeq-Prüfung: ohne Dedup könnten zwei Ereignisse derselben Entität im
// selben Batch mit demselben (veralteten) BasedOnServerSeq ankommen und sich gegenseitig ins
// Aus laufen.
private List<SyncEvent> DeduplicatePending()
private List<SyncEvent> DeduplicatePending(IReadOnlySet<Guid> excludedEventIds,
out int sourceEventCount)
{
var pending = _queue.GetPending();
var pending = _queue.GetPending(SyncProtocol.PushBatchSize, excludedEventIds);
sourceEventCount = pending.Count;
if (pending.Count == 0) return pending;
var latest = pending
.GroupBy(e => (e.EntityType, e.EntityId))
@@ -259,7 +295,7 @@ public class SyncEngine : IDisposable
}
}
private async Task<(int Pulled, int Conflicts)> PullAsync()
private async Task<PullBatchResult> PullAsync()
{
var since = _queue.GetLastServerSeq();
_logger?.Info($"Sync: Pull - frage Server nach Ereignissen seit ServerSequenceNr={since}.");
@@ -271,7 +307,7 @@ public class SyncEngine : IDisposable
if (resp is null || resp.Events.Count == 0)
{
_logger?.Info("Sync: Pull - keine neuen Ereignisse vom Server.");
return (0, 0);
return new(0, 0);
}
_logger?.Info($"Sync: Pull - {resp.Events.Count} Ereignis(se) vom Server erhalten: " +
string.Join(", ", resp.Events.Select(e => $"{e.EntityType}/{e.Operation}")));
@@ -287,10 +323,13 @@ public class SyncEngine : IDisposable
if (c.Resolution == "RemoteWon") await _applier.ApplyAsync(evt);
}
_queue.SetLastServerSeq(resp.ServerSequenceNr);
DataChanged?.Invoke();
return (resp.Events.Count, conflicts);
return new(resp.Events.Count, conflicts);
}
private readonly record struct PushBatchResult(int Pushed, int Conflicts,
int SourceEventCount, IReadOnlyCollection<Guid> DeferredEventIds);
private readonly record struct PullBatchResult(int Pulled, int Conflicts);
private void SetState(SyncState state, string? error = null)
{
Status = new SyncStatus
+2
View File
@@ -14,6 +14,8 @@ public static class SyncProtocol
{
public const string CurrentVersion = "1";
public const string VersionHeaderName = "X-LehrerApp-Sync-Version";
public const int PushBatchSize = 200;
public const int PullBatchSize = 500;
public static HttpRequestMessage CreateRequest(HttpMethod method, string requestUri)
{
+102
View File
@@ -0,0 +1,102 @@
using LehrerApp.Core.Models;
using LehrerApp.Core.Services;
using Xunit;
namespace LehrerApp.Tests;
public class ExamPriorityServiceTests
{
private static Exam MakeExam(ExamStatus status, DateOnly date) => new()
{
GroupId = Guid.NewGuid(), Title = "Klausur", Status = status, Date = date,
};
[Fact]
public void Evaluate_UnbearbeiteteKorrekturSteigtMitAlter()
{
var today = new DateOnly(2026, 8, 29);
var exam = MakeExam(ExamStatus.Conducted, today.AddDays(-10));
var result = ExamPriorityService.Evaluate(exam, expected: 20, evaluated: 0, today);
Assert.Equal(ExamListStatus.AwaitingCorrection, result.Status);
}
[Fact]
public void Evaluate_UnbearbeiteteKorrekturIstDringenderAlsLaufendeKorrektur()
{
var today = new DateOnly(2026, 8, 29);
var untouched = MakeExam(ExamStatus.Conducted, today.AddDays(-10));
var inProgress = MakeExam(ExamStatus.Conducted, today.AddDays(-10));
var untouchedResult = ExamPriorityService.Evaluate(untouched, expected: 20, evaluated: 0, today);
var progressResult = ExamPriorityService.Evaluate(inProgress, expected: 20, evaluated: 10, today);
Assert.True(untouchedResult.Score > progressResult.Score);
}
[Fact]
public void Evaluate_LaufendeKorrekturIstDringenderAlsGeplanteKlausur()
{
var today = new DateOnly(2026, 8, 29);
var inProgress = MakeExam(ExamStatus.Conducted, today.AddDays(-2));
var planned = MakeExam(ExamStatus.Planned, today.AddDays(1));
var progressResult = ExamPriorityService.Evaluate(inProgress, expected: 20, evaluated: 10, today);
var plannedResult = ExamPriorityService.Evaluate(planned, expected: 20, evaluated: 0, today);
Assert.True(progressResult.Score > plannedResult.Score);
}
[Fact]
public void Evaluate_HängengebliebeneKorrekturFälltAusDerDringlichenZone()
{
var today = new DateOnly(2026, 8, 29);
var stuck = MakeExam(ExamStatus.Conducted, today.AddDays(-(ExamPriorityService.StuckAfterDays + 5)));
var fresh = MakeExam(ExamStatus.Conducted, today.AddDays(-2));
// Ein einzelner nie nachschreibender Schüler: 19 von 20 erledigt, seit Wochen unverändert.
var stuckResult = ExamPriorityService.Evaluate(stuck, expected: 20, evaluated: 19, today);
var freshResult = ExamPriorityService.Evaluate(fresh, expected: 20, evaluated: 10, today);
Assert.Equal(ExamListStatus.CorrectionStuck, stuckResult.Status);
Assert.True(stuckResult.Score < freshResult.Score);
}
[Fact]
public void Evaluate_VollständigBewertetGiltAlsBereitZurRückgabe()
{
var today = new DateOnly(2026, 8, 29);
var exam = MakeExam(ExamStatus.Conducted, today.AddDays(-5));
var result = ExamPriorityService.Evaluate(exam, expected: 20, evaluated: 20, today);
Assert.Equal(ExamListStatus.AwaitingReturn, result.Status);
}
[Fact]
public void Evaluate_ZurückgegebenIstAmWenigstenDringlich()
{
var today = new DateOnly(2026, 8, 29);
var returned = MakeExam(ExamStatus.Returned, today.AddDays(-30));
var planned = MakeExam(ExamStatus.Planned, today.AddDays(60));
var returnedResult = ExamPriorityService.Evaluate(returned, expected: 20, evaluated: 20, today);
var plannedResult = ExamPriorityService.Evaluate(planned, expected: 20, evaluated: 0, today);
Assert.True(returnedResult.Score < plannedResult.Score);
}
[Fact]
public void Count_AbwesenderSchülerGiltAlsErledigt()
{
var exam = MakeExam(ExamStatus.Conducted, new DateOnly(2026, 8, 20));
var membership = new GroupMembership { GroupId = exam.GroupId, StudentId = Guid.NewGuid() };
var results = new List<ExamResult> { new() { ExamId = exam.Id, StudentId = membership.StudentId, Absent = true } };
var (expected, evaluated) = ExamCorrectionCounter.Count(exam, [membership], results);
Assert.Equal(1, expected);
Assert.Equal(1, evaluated);
}
}
+2 -1
View File
@@ -14,7 +14,7 @@ public sealed class GroupRolloverServiceTests
{
Name = "8a", SchoolYear = "2025/26", GradeLevel = 8,
Type = GroupType.Class, SubjectId = Guid.NewGuid(), GradingSystem = GradingSystem.Grades1To6,
HoursPerWeek = 4, IsOwnClass = true, IsDifferentiated = true,
HoursPerWeek = 4, IsOwnClass = true, IsDifferentiated = true, WebUntisLessonId = 38262,
};
var oldMembership = new GroupMembership
{
@@ -38,6 +38,7 @@ public sealed class GroupRolloverServiceTests
Assert.Equal(source.HoursPerWeek, target.HoursPerWeek);
Assert.True(target.IsOwnClass);
Assert.True(target.IsDifferentiated);
Assert.Null(target.WebUntisLessonId);
Assert.False(source.IsActive);
var copied = Assert.Single(memberships.GetByGroup(target.Id));
@@ -0,0 +1,18 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\LehrerApp.WebUntis\LehrerApp.WebUntis.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" />
<PackageReference Include="xunit" />
<PackageReference Include="xunit.runner.visualstudio">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
</ItemGroup>
</Project>
@@ -0,0 +1,45 @@
using Xunit;
using LehrerApp.WebUntis;
namespace LehrerApp.WebUntis.Tests;
public sealed class WebUntisClassRegisterEventReportParserTests
{
[Fact]
public void Parse_UebernimmtAlleFelderUndZweistelligesJahr()
{
const string report = "\uFEFFKlasse\tDatum\tFach\tName\tBenutzer\tEintragskategorie\tKategoriegruppe\tText\r\n" +
"10c\t24.08.26\tEng_G\tMuster Erika\ttownsend\tFehlende HA\tNegativ\tKeine Hausaufgaben gemacht.\r\n";
var entry = Assert.Single(WebUntisClassRegisterEventReportParser.Parse(report));
Assert.Equal("10c", entry.ClassName);
Assert.Equal(20260824, entry.Date);
Assert.Equal("Eng_G", entry.Subject);
Assert.Equal("Muster Erika", entry.StudentName);
Assert.Equal("townsend", entry.TeacherUsername);
Assert.Equal("Fehlende HA", entry.CategoryName);
Assert.Equal("Negativ", entry.CategoryGroup);
Assert.Equal("Keine Hausaufgaben gemacht.", entry.Text);
}
[Fact]
public void Parse_UeberspringtLeereZeilen()
{
const string report = "Klasse\tDatum\tFach\tName\tBenutzer\tEintragskategorie\tKategoriegruppe\tText\r\n" +
"6a\t24.08.26\tNAT\tMuster Max\thedtrich\tMitarb. über Erwart.\tPositiv\tGut.\r\n" +
"\t\t\t\t\t\t\t\r\n";
var entry = Assert.Single(WebUntisClassRegisterEventReportParser.Parse(report));
Assert.Equal("Muster Max", entry.StudentName);
}
[Fact]
public void Parse_LehntUngueltigesDatumAb()
{
const string report = "Klasse\tDatum\tFach\tName\tBenutzer\tEintragskategorie\tKategoriegruppe\tText\r\n" +
"6a\tkein-datum\tNAT\tMuster Max\thedtrich\tMitarb. über Erwart.\tPositiv\tGut.\r\n";
Assert.Throws<InvalidDataException>(() => WebUntisClassRegisterEventReportParser.Parse(report));
}
}
@@ -0,0 +1,357 @@
using System.Net;
using System.Diagnostics;
using System.Text;
using LehrerApp.WebUntis;
using Xunit;
namespace LehrerApp.WebUntis.Tests;
public sealed class WebUntisClientTests
{
[Fact]
public async Task AufeinanderfolgendeAbrufe_VerwendenDieselbeSessionBisZumDispose()
{
var latin1 = Encoding.Latin1.GetBytes(
"id\texternKey\tklasse.name\tlongName\tforeName\r\n" +
"1\t10\t7a\tMüller\tAda\r\n" +
"2\t20\t8b\tMeier\tBerta\r\n");
var handler = new QueueHandler(
Json("{\"jsonrpc\":\"2.0\",\"result\":{\"sessionId\":\"session-1\"}}"),
Json("{\"data\":{\"finished\":true,\"error\":false,\"reportParams\":\"foo=bar\"}}"),
new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new ByteArrayContent(latin1),
},
Json("{\"jsonrpc\":\"2.0\",\"result\":[{\"id\":7,\"name\":\"2026/27\",\"startDate\":20260801,\"endDate\":20270731}]}"),
Json("{\"jsonrpc\":\"2.0\",\"result\":{}}"));
var client = CreateClient(handler);
var result = await client.GetStudentReportAsync("7a", CancellationToken.None);
var schoolYears = await client.GetSchoolYearsAsync(CancellationToken.None);
var student = Assert.Single(result.Students);
Assert.Equal("Ada Müller", student.DisplayName);
Assert.Equal("7a", result.ClassNameFilter);
Assert.Equal("2026/27", Assert.Single(schoolYears).Name);
Assert.Equal(4, handler.Requests.Count);
Assert.Contains("jsonrpc.do?school=meine-schule", handler.Requests[0].Uri);
Assert.Contains("\"method\":\"authenticate\"", handler.Requests[0].Body);
Assert.Contains("reports.do?name=Student", handler.Requests[1].Uri);
Assert.Contains("schoolname=\"_", handler.Requests[1].Cookie);
Assert.EndsWith("reports.do?foo=bar", handler.Requests[2].Uri);
Assert.Contains("\"method\":\"getSchoolyears\"", handler.Requests[3].Body);
Assert.Single(handler.Requests,
request => request.Body.Contains("\"method\":\"authenticate\""));
await client.DisposeAsync();
Assert.Equal(5, handler.Requests.Count);
Assert.Contains("\"method\":\"logout\"", handler.Requests[4].Body);
}
[Fact]
public async Task GetTimetableAsync_SendetElementUndZeitraumAlsSeparateAbfrage()
{
var handler = new QueueHandler(
Json("{\"result\":{\"sessionId\":\"s\"}}"),
Json("{\"result\":[{\"id\":99,\"date\":20260824,\"startTime\":800,\"endTime\":845," +
"\"kl\":[{\"id\":4,\"name\":\"7a\"}],\"te\":[],\"su\":[],\"ro\":[]}]}"),
Json("{\"result\":{}}"));
var client = CreateClient(handler);
var periods = await client.GetTimetableAsync(UntisTimetableElementType.Teacher, 42,
20260824, 20260828, CancellationToken.None);
var period = Assert.Single(periods);
Assert.Equal(99, period.Id);
Assert.Equal("7a", Assert.Single(period.Classes).Name);
Assert.Contains("\"method\":\"getTimetable\"", handler.Requests[1].Body);
Assert.Contains("\"element\":{\"id\":42,\"type\":2}", handler.Requests[1].Body);
Assert.Contains("\"startDate\":20260824", handler.Requests[1].Body);
await client.DisposeAsync();
}
[Fact]
public async Task FehlendeKonfiguration_BrichtVorHttpRequestAb()
{
var handler = new QueueHandler();
var client = new WebUntisClient(new HttpClient(handler), new WebUntisOptions());
await Assert.ThrowsAsync<WebUntisConfigurationException>(
() => client.GetSchoolYearsAsync(CancellationToken.None));
Assert.Empty(handler.Requests);
await client.DisposeAsync();
}
[Fact]
public async Task Dispose_BegrenztEinenNichtAntwortendenLogout()
{
var handler = new HangingLogoutHandler();
var client = CreateClient(handler);
await client.GetSchoolYearsAsync(CancellationToken.None);
var elapsed = Stopwatch.StartNew();
await client.DisposeAsync();
Assert.True(elapsed.Elapsed < TimeSpan.FromSeconds(5));
Assert.True(handler.LogoutWasCancelled);
}
[Fact]
public async Task VollstaendigeLoginUrl_TrenntRegionalenServerUndSchulkennung()
{
var handler = new QueueHandler(
Json("{\"result\":{\"sessionId\":\"s\"}}"),
Json("{\"result\":[{\"id\":1,\"name\":\"2026/27\",\"startDate\":20260801,\"endDate\":20270731}]}"),
Json("{\"result\":{}}"));
var client = new WebUntisClient(new HttpClient(handler), new WebUntisOptions
{
School = "https://arche.webuntis.com/WebUntis/?school=bk-ostvest#/basic/login",
Username = "api-user",
Password = "secret",
});
await client.GetSchoolYearsAsync(CancellationToken.None);
Assert.StartsWith("https://arche.webuntis.com/WebUntis/jsonrpc.do", handler.Requests[0].Uri);
Assert.Contains("school=bk-ostvest", handler.Requests[0].Uri);
await client.DisposeAsync();
}
[Fact]
public async Task VollstaendigeServerUrl_WirdOhnePfadVerwendet()
{
var handler = new QueueHandler(
Json("{\"result\":{\"sessionId\":\"s\"}}"),
Json("{\"result\":[{\"id\":1,\"name\":\"2026/27\",\"startDate\":20260801,\"endDate\":20270731}]}"),
Json("{\"result\":{}}"));
var client = new WebUntisClient(new HttpClient(handler), new WebUntisOptions
{
School = "bk-ostvest",
Host = "https://arche.webuntis.com/WebUntis/?school=bk-ostvest#/basic/login",
Username = "api-user",
Password = "secret",
});
await client.GetSchoolYearsAsync(CancellationToken.None);
Assert.StartsWith("https://arche.webuntis.com/WebUntis/jsonrpc.do", handler.Requests[0].Uri);
Assert.Contains("school=bk-ostvest", handler.Requests[0].Uri);
await client.DisposeAsync();
}
[Fact]
public async Task Klassenbuch_WirdTypisiertAbgerufen()
{
var handler = new QueueHandler(
Json("{\"result\":{\"sessionId\":\"s\"}}"),
Json("{\"result\":[{\"studentid\":9001,\"surname\":\"Müller\",\"forname\":\"Ada\"," +
"\"date\":20260902,\"subject\":\"MA\",\"categoryId\":3,\"reason\":\"Material\"," +
"\"text\":\"Buch vergessen\"}]}"),
Json("{\"result\":[{\"id\":3,\"name\":\"Material\",\"longName\":\"Material vergessen\"," +
"\"groupId\":2}]}"),
Json("{\"result\":[{\"id\":2,\"name\":\"Organisation\"}]}"),
Json("{\"result\":{}}"));
var client = CreateClient(handler);
var entries = await client.GetClassRegisterEntriesAsync(17, 20260801, 20270731,
CancellationToken.None);
var categories = await client.GetClassRegisterCategoriesAsync(CancellationToken.None);
var groups = await client.GetClassRegisterCategoryGroupsAsync(CancellationToken.None);
var entry = Assert.Single(entries);
Assert.Equal("Ada Müller", entry.DisplayName);
Assert.Equal("Buch vergessen", entry.Text);
Assert.Equal(3, Assert.Single(categories).Id);
Assert.Equal("Organisation", Assert.Single(groups).Name);
Assert.Contains("\"method\":\"getClassregEvents\"", handler.Requests[1].Body);
Assert.Contains("\"id\":17,\"type\":5", handler.Requests[1].Body);
Assert.Single(handler.Requests,
request => request.Body.Contains("\"method\":\"authenticate\""));
await client.DisposeAsync();
Assert.Contains("\"method\":\"logout\"", handler.Requests[^1].Body);
}
[Fact]
public async Task GetLessonAbsencesAsync_LoestTeacherIdAusDerEigenenSessionAufUndTypisiertDasErgebnis()
{
var csv = Encoding.UTF8.GetBytes(
"Schüler*innen\tDatum\tFehlstd.\tUnentsch. Fehlstd.\tFehlmin.\tUnentsch. Fehlmin.\tZeit\t" +
"Abwesenheitsgrund\tENr\tErledigt\tAbwesenheit zählt\r\n" +
"Erika Mustermann\t09.12.25\t1\t1\t45\t45\t09:40-10:25\tKrank\t(185120)\t09.12.25\ttrue\r\n");
var handler = new QueueHandler(
Json("{\"result\":{\"sessionId\":\"s\",\"personId\":89}}"),
Json("{\"data\":{\"finished\":true,\"error\":false," +
"\"reportParams\":\"get=rpt1.tmp&name=AbsencePerLesson&format=csv\"}}"),
new HttpResponseMessage(HttpStatusCode.OK) { Content = new ByteArrayContent(csv) },
Json("{\"result\":{}}"));
var client = CreateClient(handler);
var absences = await client.GetLessonAbsencesAsync(38262, 20250811, 20260710, CancellationToken.None);
var absence = Assert.Single(absences);
Assert.Equal("Erika Mustermann", absence.StudentName);
Assert.Equal(20251209, absence.Date);
Assert.Equal(185120, absence.ExternKey);
Assert.True(absence.ExternKeyInParentheses);
Assert.Equal(940, absence.StartTime);
Assert.True(absence.Counts);
Assert.Contains("reports.do?name=AbsencePerLesson", handler.Requests[1].Uri);
Assert.Contains("lsid=38262", handler.Requests[1].Uri);
Assert.Contains("teacherId=89", handler.Requests[1].Uri);
Assert.Contains("rpt_sd=20250811", handler.Requests[1].Uri);
Assert.Contains("rpt_ed=20260710", handler.Requests[1].Uri);
Assert.EndsWith("reports.do?get=rpt1.tmp&name=AbsencePerLesson&format=csv", handler.Requests[2].Uri);
await client.DisposeAsync();
}
[Fact]
public async Task GetClassRegisterEventsReportAsync_BautDenBerichtsAbrufAufUndTypisiertDasErgebnis()
{
var csv = Encoding.UTF8.GetBytes(
"Klasse\tDatum\tFach\tName\tBenutzer\tEintragskategorie\tKategoriegruppe\tText\r\n" +
"6a\t24.08.26\tNAT\tMuster Max\thedtrich\tMitarb. über Erwart.\tPositiv\tArbeitet gut mit.\r\n");
var handler = new QueueHandler(
Json("{\"result\":{\"sessionId\":\"s\",\"personId\":89}}"),
Json("{\"data\":{\"finished\":true,\"error\":false," +
"\"reportParams\":\"get=rpt2.tmp&name=ClassregEventPerStudent&format=csv\"}}"),
new HttpResponseMessage(HttpStatusCode.OK) { Content = new ByteArrayContent(csv) },
Json("{\"result\":{}}"));
var client = CreateClient(handler);
var report = await client.GetClassRegisterEventsReportAsync(20260824, 20260828, CancellationToken.None);
var entry = Assert.Single(report);
Assert.Equal("6a", entry.ClassName);
Assert.Equal(20260824, entry.Date);
Assert.Equal("NAT", entry.Subject);
Assert.Equal("Muster Max", entry.StudentName);
Assert.Equal("hedtrich", entry.TeacherUsername);
Assert.Equal("Mitarb. über Erwart.", entry.CategoryName);
Assert.Equal("Positiv", entry.CategoryGroup);
Assert.Equal("Arbeitet gut mit.", entry.Text);
Assert.Contains("reports.do?name=ClassregEventPerStudent", handler.Requests[1].Uri);
Assert.Contains("klasseOrStudentgroupId=-1", handler.Requests[1].Uri);
Assert.Contains("studentId=-1", handler.Requests[1].Uri);
Assert.Contains("rpt_sd=20260824", handler.Requests[1].Uri);
Assert.Contains("rpt_ed=20260828", handler.Requests[1].Uri);
Assert.EndsWith("reports.do?get=rpt2.tmp&name=ClassregEventPerStudent&format=csv", handler.Requests[2].Uri);
await client.DisposeAsync();
}
[Fact]
public async Task GetClassAbsencesAsync_BautDenBerichtsAbrufAufUndTypisiertDasErgebnis()
{
// Kopfzeile "Text" kommt zweimal vor (Spalte 3 leer, Spalte 12 die eigentliche Tagesnotiz) -
// deckt ab, dass die spätere Spalte beim Parsen die frühere überschreibt (siehe Parser-Kommentar).
var csv = Encoding.UTF8.GetBytes(
"Schüler*innen\tExterne Id\tText\tKlasse\tDatum\tWochentag\tFehlstd.\tFehlmin.\tLehrkraft\tFach\t" +
"Abwesenheitsgrund\tText\tENr\tErledigt\tAbwesenheit zählt\tEntschuldigungstext\tStundennr.\tStatus\tFehltage\r\n" +
"Muster Max\t12345\t\t6a\t24.08.26\tMo.\t1\t45\tHED\tChe\tAbsent\tKrank gemeldet\t555\t24.08.26\ttrue\t\t3\tnicht entsch.\t1\r\n");
var handler = new QueueHandler(
Json("{\"result\":{\"sessionId\":\"s\",\"personId\":89}}"),
Json("{\"data\":{\"finished\":true,\"error\":false," +
"\"reportParams\":\"get=rpt3.tmp&name=AbsencePerStudent&format=csv\"}}"),
new HttpResponseMessage(HttpStatusCode.OK) { Content = new ByteArrayContent(csv) },
Json("{\"result\":{}}"));
var client = CreateClient(handler);
var entries = await client.GetClassAbsencesAsync("KL874", 20260824, 20260828, CancellationToken.None);
var entry = Assert.Single(entries);
Assert.Equal("Muster Max", entry.StudentName);
Assert.Equal(12345, entry.ExternKey);
Assert.Equal("6a", entry.ClassName);
Assert.Equal(20260824, entry.Date);
Assert.Equal(1, entry.AbsentPeriods);
Assert.Equal(45, entry.AbsentMinutes);
Assert.Equal("HED", entry.TeacherUsernames);
Assert.Equal("Che", entry.Subject);
Assert.Equal("Absent", entry.AbsenceReason);
Assert.Equal("Krank gemeldet", entry.Note);
Assert.Equal(555, entry.EntryId);
Assert.Equal("24.08.26", entry.HandledOn);
Assert.True(entry.Counts);
Assert.Null(entry.ExcuseNote);
Assert.Equal(3, entry.PeriodNumber);
Assert.Equal("nicht entsch.", entry.Status);
Assert.True(entry.CountsAsFullDay);
Assert.Contains("reports.do?name=AbsencePerStudent", handler.Requests[1].Uri);
Assert.Contains("klasseOrStudentgroupId=KL874", handler.Requests[1].Uri);
Assert.Contains("studentId=-1", handler.Requests[1].Uri);
Assert.Contains("rpt_sd=20260824", handler.Requests[1].Uri);
Assert.Contains("rpt_ed=20260828", handler.Requests[1].Uri);
Assert.EndsWith("reports.do?get=rpt3.tmp&name=AbsencePerStudent&format=csv", handler.Requests[2].Uri);
await client.DisposeAsync();
}
private static WebUntisClient CreateClient(HttpMessageHandler handler) => new(
new HttpClient(handler),
new WebUntisOptions
{
School = "meine-schule",
Host = "meine-schule.webuntis.com",
Username = "api-user",
Password = "secret",
Client = "tests",
});
private static HttpResponseMessage Json(string json) => new(HttpStatusCode.OK)
{
Content = new StringContent(json, Encoding.UTF8, "application/json"),
};
private sealed class QueueHandler(params HttpResponseMessage[] responses) : HttpMessageHandler
{
private readonly Queue<HttpResponseMessage> _responses = new(responses);
public List<CapturedRequest> Requests { get; } = [];
protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request,
CancellationToken cancellationToken)
{
Requests.Add(new CapturedRequest(
request.RequestUri?.ToString() ?? "",
request.Content is null ? "" : await request.Content.ReadAsStringAsync(cancellationToken),
string.Join("; ", request.Headers.TryGetValues("Cookie", out var cookies) ? cookies : [])));
return _responses.Count > 0
? _responses.Dequeue()
: throw new InvalidOperationException("Keine Testantwort mehr vorhanden.");
}
}
private sealed class HangingLogoutHandler : HttpMessageHandler
{
public bool LogoutWasCancelled { get; private set; }
protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request,
CancellationToken cancellationToken)
{
var body = request.Content is null
? ""
: await request.Content.ReadAsStringAsync(cancellationToken);
if (body.Contains("\"method\":\"authenticate\""))
return Json("{\"result\":{\"sessionId\":\"s\"}}");
if (body.Contains("\"method\":\"getSchoolyears\""))
return Json("{\"result\":[]}");
try
{
await Task.Delay(Timeout.InfiniteTimeSpan, cancellationToken);
throw new InvalidOperationException("Der simulierte Logout darf nicht regulär enden.");
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
LogoutWasCancelled = true;
throw;
}
}
}
private sealed record CapturedRequest(string Uri, string Body, string Cookie);
}
@@ -0,0 +1,54 @@
using Xunit;
using LehrerApp.WebUntis;
namespace LehrerApp.WebUntis.Tests;
public sealed class WebUntisLessonAbsenceParserTests
{
[Fact]
public void Parse_UebernimmtAlleFelderUndZweistelligesJahr()
{
const string report = "\uFEFFSchüler*innen\tDatum\tFehlstd.\tUnentsch. Fehlstd.\tFehlmin.\tUnentsch. Fehlmin.\tZeit\tAbwesenheitsgrund\tENr\tErledigt\tAbwesenheit zählt\r\n" +
"Erika Mustermann\t09.12.25\t1\t1\t45\t45\t09:40-10:25\tKrank\t(185120)\t09.12.25\ttrue\r\n";
var absence = Assert.Single(WebUntisLessonAbsenceParser.Parse(report));
Assert.Equal("Erika Mustermann", absence.StudentName);
Assert.Equal(20251209, absence.Date);
Assert.Equal(1, absence.AbsentPeriods);
Assert.Equal(1, absence.UnexcusedAbsentPeriods);
Assert.Equal(45, absence.AbsentMinutes);
Assert.Equal(45, absence.UnexcusedAbsentMinutes);
Assert.Equal(940, absence.StartTime);
Assert.Equal(1025, absence.EndTime);
Assert.Equal("Krank", absence.Reason);
Assert.Equal(185120, absence.ExternKey);
Assert.True(absence.ExternKeyInParentheses);
Assert.Equal("09.12.25", absence.HandledOn);
Assert.True(absence.Counts);
}
[Fact]
public void Parse_AkzeptiertExterneSchuelerkennungOhneKlammern()
{
const string report = "Schüler*innen\tDatum\tFehlstd.\tUnentsch. Fehlstd.\tFehlmin.\tUnentsch. Fehlmin.\tZeit\tAbwesenheitsgrund\tENr\tErledigt\tAbwesenheit zählt\r\n" +
"Max Muster\t27.01.26\t1\t0\t45\t0\t10:25-11:10\t\t178156\t\tfalse\r\n";
var absence = Assert.Single(WebUntisLessonAbsenceParser.Parse(report));
Assert.Equal(178156, absence.ExternKey);
Assert.False(absence.ExternKeyInParentheses);
Assert.Null(absence.Reason);
Assert.Null(absence.HandledOn);
Assert.False(absence.Counts);
}
[Fact]
public void Parse_LehntUngueltigesDatumAb()
{
const string report = "Schüler*innen\tDatum\tFehlstd.\tUnentsch. Fehlstd.\tFehlmin.\tUnentsch. Fehlmin.\tZeit\tAbwesenheitsgrund\tENr\tErledigt\tAbwesenheit zählt\r\n" +
"Max Muster\tkein-datum\t1\t0\t45\t0\t10:25-11:10\t\t178156\t\tfalse\r\n";
Assert.Throws<InvalidDataException>(() => WebUntisLessonAbsenceParser.Parse(report));
}
}
@@ -0,0 +1,76 @@
using Xunit;
using LehrerApp.WebUntis;
namespace LehrerApp.WebUntis.Tests;
public sealed class WebUntisStudentReportParserTests
{
[Fact]
public void Parse_UebernimmtAlleFelderUndDeutscheDatumswerte()
{
const string report = "\uFEFFid\texternKey\tklasse.name\tname\tlongName\tforeName\tgender\tbirthDate\tentryDate\texitDate\ttext\tmedicalReportDuty\tschulpflicht\tmajority\tadress.email\tadress.mobile\tadress.phone\tadress.city\tadress.postCode\tadress.street\tattribute.iL\r\n" +
"17\t9001\t10a\tMUST\tMustermann\tErika\tw\t03.02.2010\t01.08.2021\t\t\"Zeile 1\nZeile 2\"\tja\tja\tnein\terika@example.org\t0151\t030\tBerlin\t10115\tTestweg 1\tIL-A\r\n";
var student = Assert.Single(WebUntisStudentReportParser.Parse(report));
Assert.Equal(17, student.UntisId);
Assert.Equal(9001, student.ExternKey);
Assert.Equal("10a", student.ClassName);
Assert.Equal("Erika Mustermann", student.DisplayName);
Assert.Equal(20100203, student.BirthDate);
Assert.Equal(20210801, student.EntryDate);
Assert.Null(student.ExitDate);
Assert.Equal("Zeile 1\nZeile 2", student.Text);
Assert.Equal("erika@example.org", student.Address.Email);
Assert.Equal("IL-A", student.AttributeIL);
}
[Fact]
public void Parse_BehandeltEscapteAnfuehrungszeichenUndLeereZeilen()
{
const string report = "id\texternKey\tklasse.name\tname\r\n" +
"1\t2\t5b\t\"MUS\"\"T\"\r\n\t\t\t\r\n";
var student = Assert.Single(WebUntisStudentReportParser.Parse(report));
Assert.Equal("MUS\"T", student.Name);
Assert.Equal("MUS\"T", student.DisplayName);
}
[Fact]
public void Parse_AkzeptiertIsoDatumswerteOhneTrennzeichen()
{
const string report = "id\texternKey\tklasse.name\tbirthDate\tentryDate\texitDate\r\n" +
"17\t9001\t10a\t20100203\t20210801\t20270731\r\n";
var student = Assert.Single(WebUntisStudentReportParser.Parse(report));
Assert.Equal(20100203, student.BirthDate);
Assert.Equal("20100203", student.BirthDateRaw);
Assert.Equal(20210801, student.EntryDate);
Assert.Equal(20270731, student.ExitDate);
}
[Fact]
public void Parse_ErlaubtFehlendenExternKey()
{
// Reale Schuldaten enthalten Schüler*innen ohne gepflegten externen Schlüssel (z.B. frisch
// angelegt) - das darf nicht den Abruf der gesamten Klassenliste zum Absturz bringen.
const string report = "id\texternKey\tklasse.name\r\n17\t\t10a\r\n";
var student = Assert.Single(WebUntisStudentReportParser.Parse(report));
Assert.Equal(17, student.UntisId);
Assert.Null(student.ExternKey);
}
[Fact]
public void Parse_LehntUngueltigePflichtIdAb()
{
const string report = "id\texternKey\tklasse.name\r\nkeine-zahl\t1\t5b\r\n";
var error = Assert.Throws<InvalidDataException>(() => WebUntisStudentReportParser.Parse(report));
Assert.Contains("id", error.Message);
}
}
@@ -0,0 +1,5 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
</PropertyGroup>
</Project>

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