Author SHA1 Message Date
admin 455c61c946 fix: Uhrzeit-Wraparound-Flakiness in MissingTeachingTime-Tests
CI / build-and-test (push) Canceled after 0s
TimeOnly.FromDateTime(DateTime.Now).AddHours(+-2) wickelt bei Mitternacht um (TimeOnly hat
keinen Datumsanteil) - je nach Ausfuehrungsuhrzeit (ca. 22:00-02:00) landete "+2 Stunden"
zeitlich vor statt nach "jetzt" oder umgekehrt, wodurch zwei Tests zufaellig rot wurden statt
das eigentliche Verhalten (Wartezeit nach Unterrichtsende) zu pruefen. Per Gegenprobe auf
unveraendertem main bestaetigt: dieselbe Flakiness besteht dort bereits.

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

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

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

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

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

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

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

Drei Regressionstests decken beide Faelle ab.
2026-09-11 00:12:10 +02:00
adminandClaude Sonnet 5 ba52109eaa feat: Untis-Hub für WebUntis-Sync-Gesundheitsstatus
CI / build-and-test (push) Canceled after 0s
Zeigt die Fälligkeit der vier bestehenden, manuell ausgelösten WebUntis-Abgleiche
(Fehlzeiten je Lerngruppe, offene Stunden, Klassenbuch-, Hausaufgabenabgleich) in
einem neuen Hub-Fenster, ohne selbst WebUntis anzufragen - nur gespeicherte
Zeitstempel werden ausgewertet. Konsolidiert die bisher verstreuten Einstiegspunkte
(Sidebar-Button, Dashboard-Buttons) in ein neues WebUntis-Menü plus einen
kompakten Gesundheits-Indikator auf dem Dashboard.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-10 23:41:12 +02:00
admin bd0d7e47ea fix: UntisLogo
CI / build-and-test (push) Canceled after 0s
2026-09-09 00:07:39 +02:00
admin 2ec8adac61 Webuntis stunden check
CI / build-and-test (push) Canceled after 0s
2026-09-08 23:59:07 +02:00
admin e86125e5f9 Hausaufgaben feature und Stundeplan fix
CI / build-and-test (push) Canceled after 0s
2026-09-08 18:01:42 +02:00
admin 86e114580f Fix: Sync Log und verwaise Einträge
CI / build-and-test (push) Canceled after 0s
2026-09-07 17:09:38 +02:00
admin 835cccadec ki Backend fix - Answer Size
CI / build-and-test (push) Canceled after 0s
2026-09-07 12:45:50 +02:00
admin 709ea88c2a Arbeitszeitnacherfassung
CI / build-and-test (push) Canceled after 0s
2026-09-06 13:51:17 +02:00
admin 73453fd88d Add Feature: Monatsübersicht Fehlzeiten Widget
CI / build-and-test (push) Canceled after 0s
2026-09-06 13:27:52 +02:00
admin 265190b3aa UI Improvements: Planungsdialog, Fehlzeitendarstellung, Vorauswahl der Einheit
CI / build-and-test (push) Canceled after 0s
2026-09-05 00:02:28 +02:00
admin fedfceb81d KI-Backend: Antwortretten - Funktion
CI / build-and-test (push) Canceled after 0s
2026-09-04 12:10:42 +02:00
admin 7ef0c95a73 Optimierung des Startvorgangs, lazy load, async im thread pool oder während des Startbildschirms vor dem Mainwindow.
CI / build-and-test (push) Canceled after 0s
2026-09-02 21:46:01 +02:00
adminandClaude Sonnet 5 94946ffefb Stundenplan: verwaiste Stunden per "FixIt"-Button verknüpfen
CI / build-and-test (push) Canceled after 0s
Der Stundenplan erkennt eine Stunde nur über Datum+Stundennummer
(kein gespeichertes Verknüpfungsfeld). Per JSON-Import oder KI ohne
Stundenplan-Bezug angelegte Stunden ohne passende Stundennummer
tauchten dort nie auf - ein Klick auf den Termin bot nur "neu anlegen"
an und hätte eine Dublette erzeugt. Neuer Button im Anlegen-Dialog
sucht stattdessen nach einer passenden vorhandenen Stunde und hängt
sie auf den geklickten Termin um.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-02 21:27:06 +02:00
admin 3cccb3226c Merge remote-tracking branch 'origin/main'
CI / build-and-test (push) Canceled after 0s
2026-09-02 20:09:24 +02:00
adminandClaude Sonnet 5 988a293c42 Planung: Stunde einer anderen Einheit zuweisen ("Einheit wechseln")
Bisher war die Einheit einer Stunde nach dem Anlegen fix - bei falscher
Auswahl im Erstellen-Dialog blieb nur Löschen+Neuanlegen. Neuer Button
öffnet einen Dialog zur Auswahl der Ziel-Einheit; die Ansicht springt
danach automatisch dorthin mit der Stunde vorausgewählt.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-02 20:07:53 +02:00
adminandClaude Sonnet 5 92f9a75ec1 E-Mail-Ordner-Triage: eM Client 11 MCP-Support als bevorzugten Weg ergänzen
CI / build-and-test (push) Canceled after 0s
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-02 18:59:41 +02:00
adminandClaude Sonnet 5 0a419c4c11 WebUntis: Browser-Header gegen HTTP 403, Klassenlehrer-Reports on-demand
CI / build-and-test (push) Canceled after 0s
Fehlzeiten-/Klassenbuch-Reports (reports.do, api/polling/REPORT) sowie
der JSON-RPC-Login schickten bisher keinen User-Agent, Referer oder die
übrigen Header, die WebUntis' eigene WebApp immer mitsendet - führte zu
HTTP 403. Header 1:1 aus einem echten Browser-Request übernommen und
zusätzliche Set-Cookie-Werte (z.B. Tenant-Id) aus der Login-Antwort
eingesammelt statt nur JSESSIONID/schoolname. Zusätzlich eine feste
kurze Pause zwischen Login und erstem Report-Request.

Die Klassenlehrer-Übersicht holt WebUntis-Daten jetzt nicht mehr eager
beim Navigieren oder bei jedem Sync-Ereignis, sondern nur noch über den
bestehenden "Aktualisieren"-Button - vermeidet spürbare Startlatenz und
unnötige WebUntis-Last. Eingehende Sync-Ereignisse (z.B. ein auf einem
anderen Gerät geschlossener Vorgang) bauen die Ansicht per
RefreshFromLocalDataOnly() weiterhin sofort neu auf, aber ausschließlich
aus den zuletzt geholten WebUntis-Daten plus aktuellen lokalen Daten -
ohne selbst je einen WebUntis-Request auszulösen.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-02 14:41:41 +02:00
adminandClaude Sonnet 5 216708e879 Gesamtbild: konfigurierbare Gewichtung, Dämpfung nach Vorgang, Rückfall-Warnung
CI / build-and-test (push) Canceled after 0s
Feste Score-Gewichte durch einen Einstellungsdialog ersetzt. Ein
geschlossener Vorgang dämpft ab seinem Schließ-Datum den zu seinen
Schlagwörtern passenden Score-Bereich (Anwesenheit/Verspätung/
Klassenbuch), ohne Schlagwort-Treffer vorsichtshalber alle drei — die
faktische Fehlquote bleibt davon unberührt. Verschlechtert sich die Lage
danach wieder spürbar, erscheint eine "erneut auffällig"-Warnung im
bestehenden "Muster erkannt"-Panel.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-01 23:52:38 +02:00
adminandClaude Sonnet 5 0d5fb6d146 Vorgänge: verknüpfte Dokumentation direkt zum Nachlesen öffnen
CI / build-and-test (push) Canceled after 0s
"Bearbeiten"-Button an jedem verknüpften bzw. verknüpfbaren Eintrag
öffnet den bestehenden Dokumentations-Dialog, ohne den Vorgänge-Tab zu
verlassen — bisher musste man dafür in den Schülerdatensatz wechseln.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-01 23:08:59 +02:00
admin 1d9521fc60 SplashScreen
CI / build-and-test (push) Canceled after 0s
2026-09-01 22:51:50 +02:00
admin 4af83c3e68 Update Launch.json 2026-09-01 22:43:10 +02:00
adminandClaude Sonnet 5 15463bc292 "Vorgang": Fallmappe für Klassenbuch- und Dokumentationseinträge
CI / build-and-test (push) Canceled after 0s
Neuer 4. Tab in der Klassenlehreransicht: bündelt Titel, Beschreibung,
Schlagwörter, verknüpfte Dokumentation und angeheftete (eingefrorene)
WebUntis-Klassenbucheinträge zu einem laufenden Problem mit einer/einem
oder mehreren Schüler*innen. Anheften direkt aus dem Klassenbuch-Tab per
Rechtsklick. Sync-fähig nach dem bestehenden Documentation-Muster.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-01 22:42:06 +02:00
adminandClaude Sonnet 5 987b49e761 Klassenlehrer: faire Fehlquote, Muster-Score statt Heute-Snapshot
CI / build-and-test (push) Canceled after 0s
Verspätung zählt in der Fehlquote nicht mehr wie ein voller Fehltag,
sondern separat mit reduziertem Gewicht. Neuer "Gesamtbild"-Reiter
(Standardansicht) sortiert die Klasse nach einem kumulierten
Auffälligkeits-Score übers Schuljahr statt nur nach dem heutigen Status,
und bezieht dabei auch Klassenbucheinträge anderer Lehrkräfte ein
(Schlüsselwörter für Hausaufgaben/Mitarbeit, "suspendiert" schießt sofort
deutlich hoch).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-01 21:36:34 +02:00
adminandClaude Sonnet 5 fb7a015b42 CalendarDatePicker: DateTimeOffset<->DateTime-Konvertierung per Converter
CI / build-and-test (push) Canceled after 0s
CalendarDatePicker.SelectedDate ist (WPF-Toolkit-Ursprung) DateTime?,
nicht DateTimeOffset? wie beim eingebauten DatePicker - Avalonia bietet
dafuer keine automatische Konvertierung. Ohne Converter blieb das Feld
leer und ein Klick auf einen Kalendertag warf eine InvalidCastException.
Betraf auch die schon bestehenden Bindings in WithdrawStudentDialog und
CreateLetterDialog, nur bislang unbemerkt.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-01 20:09:08 +02:00
adminandClaude Sonnet 5 09e97efb61 CalendarDatePicker: DateTimeOffset-Felder nullbar machen
CI / build-and-test (push) Canceled after 0s
CalendarDatePicker.SelectedDate ist DateTimeOffset?, die per DatePicker-
zu-CalendarDatePicker-Umstellung betroffenen ViewModel-Eigenschaften
waren aber nicht-nullbares DateTimeOffset. Der alte DatePicker tolerierte
das, CalendarDatePickers Bindung nicht - Feld blieb leer und ein
Konvertierungsfehler wurde beim Auswählen geworfen.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-01 17:19:07 +02:00
adminandClaude Sonnet 5 d297f9f109 Klassenbuch-Tab zum Dokumentations-Hub ausgebaut
CI / build-and-test (push) Canceled after 0s
Umschalter zwischen WebUntis-Klassenbuch und eigener Dokumentation statt
Merge beider strukturell unterschiedlicher Datensätze, mit Zahlen-Badges
je Quelle (kritisch/Nacharbeit hervorgehoben) im Tab und an der
"Klassenbuch öffnen"-Aktion der Übersicht.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-01 17:05:13 +02:00
admin de6d4236a2 Merge branch 'claude/epic-murdock-f9aa7b'
CI / build-and-test (push) Canceled after 0s
# Conflicts:
#	TODO.md
2026-09-01 15:34:41 +02:00
admin edf7510765 Datepicker update
CI / build-and-test (push) Canceled after 0s
2026-09-01 15:31:16 +02:00
110 changed files with 6726 additions and 599 deletions
+6 -5
View File
@@ -47,11 +47,12 @@
} }
}, },
{ {
"name": "Desktop + API (gleichzeitig)", "name": "API Tests starten",
"configurations": [ "type": "coreclr",
"Desktop App starten", "request": "launch",
"API Server starten" "preLaunchTask": "API bauen",
] "program": "${workspaceFolder}/LehrerApp.Api.Tests/bin/Debug/net10.0/LehrerApp.Api.Tests.dll",
"args": [],
} }
], ],
"compounds": [ "compounds": [
@@ -100,6 +100,10 @@ public class AiPlanningResponse
{ {
public List<AiLesson> Lessons { get; set; } = []; public List<AiLesson> Lessons { get; set; } = [];
public string? Summary { get; set; } public string? Summary { get; set; }
// Vom Backend zusätzlich mitgelieferte, unveränderte Modellantwort. Wird nur benötigt, falls
// die typisierte Deserialisierung scheitert; bei einer regulär verarbeiteten Antwort wird sie
// weder angezeigt noch gespeichert.
public string? RawResponse { get; set; }
} }
/// <summary> /// <summary>
@@ -170,6 +170,14 @@ public interface IUntisCacheFetchStateRepository
UntisCacheFetchState? Get(string className, UntisCacheKind kind); UntisCacheFetchState? Get(string className, UntisCacheKind kind);
void Save(UntisCacheFetchState state); void Save(UntisCacheFetchState state);
} }
/// Zuletzt-Lauf-Status der Untis-Hub-Jobs (siehe TODO.md) - je (Kind, GroupId), GroupId null bei
/// den dashboard-weiten Jobs.
public interface IUntisHubJobStateRepository
{
UntisHubJobState? Get(UntisHubJobKind kind, Guid? groupId);
List<UntisHubJobState> GetAll();
void Save(UntisHubJobState state);
}
/// Vom Nutzer bestätigte Zuordnungen WebUntis-Wochenmuster → LearningGroup. /// Vom Nutzer bestätigte Zuordnungen WebUntis-Wochenmuster → LearningGroup.
public interface IUntisSlotMappingRepository public interface IUntisSlotMappingRepository
{ {
@@ -200,6 +208,15 @@ public interface IDocumentationRepository
/// Entfernt den Eintrag endgültig — nur für die Löschfristen-Bereinigung (5.4.2). /// Entfernt den Eintrag endgültig — nur für die Löschfristen-Bereinigung (5.4.2).
void HardDelete(Guid id); void HardDelete(Guid id);
} }
public interface IVorgangRepository
{
List<Vorgang> GetAll();
List<Vorgang> GetByStudent(Guid studentId);
Vorgang? GetById(Guid id);
void Save(Vorgang vorgang);
/// Markiert den Vorgang als gelöscht, statt ihn hart zu entfernen (gleiches Muster wie Documentation).
void Delete(Guid id);
}
public interface IWorkTaskRepository public interface IWorkTaskRepository
{ {
List<WorkTask> GetByStatus(WorkTaskStatus status); List<WorkTask> GetByStatus(WorkTaskStatus status);
+24
View File
@@ -0,0 +1,24 @@
namespace LehrerApp.Core.Models;
public enum UntisHubJobKind
{
FehlzeitenKurz,
FehlzeitenLang,
OffenePeriods,
Klassenbuchabgleich,
Hausaufgabenabgleich,
}
/// <summary>Wann welcher WebUntis-Abgleich ("Untis-Hub"-Job, siehe TODO.md) zuletzt lief und mit
/// welchem Ergebnis - ein Datensatz pro (<see cref="Kind"/>, <see cref="GroupId"/>). Bei den drei
/// dashboard-weiten Jobs (<see cref="UntisHubJobKind.OffenePeriods"/>,
/// <see cref="UntisHubJobKind.Klassenbuchabgleich"/>, <see cref="UntisHubJobKind.Hausaufgabenabgleich"/>)
/// ist <see cref="GroupId"/> null; die beiden Fehlzeiten-Kadenzen sind je Lerngruppe getrennt.</summary>
public class UntisHubJobState
{
public Guid Id { get; set; } = Guid.NewGuid();
public UntisHubJobKind Kind { get; set; }
public Guid? GroupId { get; set; }
public DateTime? LastRunAt { get; set; }
public string? LastResultSummary { get; set; }
}
+49
View File
@@ -0,0 +1,49 @@
namespace LehrerApp.Core.Models;
/// <summary>
/// Fallmappe für ein konkretes, laufendes Problem mit einer/einem oder mehreren Schüler*innen
/// (z.B. Schuleschwänzen, wiederholte Hausaufgaben-Verweigerung, ein Konflikt) — bündelt Titel,
/// Problembeschreibung, Schlagwörter sowie die dazugehörigen <see cref="Documentation"/>-Einträge
/// und eingefrorene WebUntis-Klassenbucheinträge an einem Ort, statt sie über datumsgefilterte
/// Listen verstreut zu lassen. Bewusst nur Referenzen (<see cref="DocumentationIds"/>) statt
/// gecachtem Inhalt — die Datenmenge pro Lehrkraft ist klein genug, dass ein Cache nur
/// Invalidierungs-Komplexität einführen würde, ohne echten Nutzen (siehe TODO.md, Präzedenzfall
/// 5.3.3: eine Plan-ID-Gruppierung für Förderpläne wurde aus demselben Grund verworfen).
/// </summary>
public class Vorgang
{
public Guid Id { get; set; } = Guid.NewGuid();
public string Title { get; set; } = "";
public string Description { get; set; } = "";
public List<Guid> StudentIds { get; set; } = [];
/// Freie Labels, z.B. "Absentismus", "Hausaufgaben", "Verspätungen", "Konflikte" —
/// siehe VorgangTagDisplay.Suggestions im Desktop-Projekt für die Vorschlagsliste.
public List<string> Tags { get; set; } = [];
public VorgangStatus Status { get; set; } = VorgangStatus.Open;
public List<Guid> DocumentationIds { get; set; } = [];
public List<VorgangClassRegisterEntry> ClassRegisterEntries { get; set; } = [];
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
public DateTime? ClosedAt { get; set; }
public bool IsDeleted { get; set; }
public DateTime? DeletedAt { get; set; }
}
public enum VorgangStatus { Open, Closed }
/// <summary>
/// Eingefrorene Kopie einer WebUntis-Klassenbuchzeile zum Zeitpunkt des Anheftens an einen
/// <see cref="Vorgang"/> — keine Referenz per ID, da weder <c>UntisForeignClassRegisterEventDto</c>
/// noch <c>UntisClassRegisterCacheEntry</c> eine über einen Cache-Refresh hinweg stabile ID haben.
/// </summary>
public class VorgangClassRegisterEntry
{
public DateOnly Date { get; set; }
public string StudentName { get; set; } = "";
public string? Subject { get; set; }
public string? TeacherUsername { get; set; }
public string? CategoryName { get; set; }
public string? CategoryGroup { get; set; }
public string? Text { get; set; }
public DateTime PinnedAt { get; set; } = DateTime.UtcNow;
}
@@ -12,11 +12,13 @@ public sealed class DashboardCardSetting
/// <summary>Speichert Sichtbarkeit und Reihenfolge der Dashboard-Kacheln lokal.</summary> /// <summary>Speichert Sichtbarkeit und Reihenfolge der Dashboard-Kacheln lokal.</summary>
public sealed class DashboardSettingsService public sealed class DashboardSettingsService
{ {
// "attention" fasst die frueheren sieben Kacheln missingteachingtime/excuses/corrections/
// unplanned/alerts/attendance/support zusammen (siehe AttentionItem.cs im Desktop-Projekt).
// Alte gespeicherte dashboardsettings.json-Dateien mit den frueheren Keys sind unproblematisch:
// Load() unten verwirft unbekannte Keys ohnehin stillschweigend und ergaenzt neue als sichtbar.
public static readonly string[] DefaultCardOrder = public static readonly string[] DefaultCardOrder =
[ [
"today", "tasks", "calendar", "excuses", "upcoming", "today", "tasks", "attention", "calendar", "upcoming", "groups", "examload",
"corrections", "unplanned", "alerts", "attendance", "support", "groups", "examload",
"missingteachingtime",
]; ];
private readonly string _configPath; private readonly string _configPath;
@@ -0,0 +1,40 @@
using System.Text.Json;
namespace LehrerApp.Core.Services;
/// <summary>Gewichte für ClassTeacherRosterRow.PatternScore (Klassenlehrer-Bereich, "Gesamtbild")
/// — Default-Werte entsprechen den ursprünglich fest verdrahteten Konstanten.</summary>
public sealed class PatternScoreWeights
{
public double UnexcusedDayWeight { get; set; } = 3.0;
public double ExcusedDayWeight { get; set; } = 1.0;
public double LateDayWeight { get; set; } = 0.5;
public double NegativeClassRegisterEntryWeight { get; set; } = 1.0;
public double ConcerningKeywordBonus { get; set; } = 1.5;
public double SuspensionKeywordWeight { get; set; } = 15.0;
}
/// <summary>Speichert die Gewichte lokal, gleiches Muster wie WorkloadSettingsService/
/// DashboardSettingsService.</summary>
public sealed class PatternScoreSettingsService
{
private readonly string _configPath;
public PatternScoreSettingsService(string appDataPath) =>
_configPath = Path.Combine(appDataPath, "patternscoreweights.json");
public PatternScoreWeights Load()
{
try
{
if (File.Exists(_configPath))
return JsonSerializer.Deserialize<PatternScoreWeights>(File.ReadAllText(_configPath))
?? new PatternScoreWeights();
}
catch { /* beschädigte Konfiguration -> Standardgewichte */ }
return new PatternScoreWeights();
}
public void Save(PatternScoreWeights weights) =>
File.WriteAllText(_configPath, JsonSerializer.Serialize(weights));
}
@@ -193,4 +193,21 @@ public sealed class ChangeHookCascadeTests
var call = Assert.Single(calls); var call = Assert.Single(calls);
Assert.Equal((nameof(Documentation), "Delete"), call); Assert.Equal((nameof(Documentation), "Delete"), call);
} }
[Fact]
public void VorgangRepository_Delete_LoestSaveMitIsDeletedAus()
{
using var db = NewInMemoryContext();
var repo = new VorgangRepository(db);
var vorgang = new Vorgang { Title = "Konflikt" };
repo.Save(vorgang);
var calls = new List<(string EntityType, string Operation, object? Payload)>();
db.OnChange = (type, id, op, payload) => calls.Add((type, op, payload));
repo.Delete(vorgang.Id);
var call = Assert.Single(calls);
Assert.Equal((nameof(Vorgang), "Save"), (call.EntityType, call.Operation));
Assert.True(((Vorgang)call.Payload!).IsDeleted);
}
} }
+61
View File
@@ -715,6 +715,67 @@ public sealed class RepositoryTests
Assert.Null(storage.OpenRead(attachmentId)); Assert.Null(storage.OpenRead(attachmentId));
} }
// ── VorgangRepository ─────────────────────────────────────────────────────
[Fact]
public void VorgangRepository_Save_SetztUpdatedAt()
{
using var db = NewInMemoryContext();
var repo = new VorgangRepository(db);
var vorgang = new Vorgang { Title = "Schuleschwänzen Max" };
repo.Save(vorgang);
var raw = db.Vorgaenge.FindById(vorgang.Id);
Assert.NotNull(raw);
Assert.True((DateTime.UtcNow - raw!.UpdatedAt).TotalSeconds < 5);
}
[Fact]
public void VorgangRepository_GetByStudent_FindetVorgaengeMitMehrerenSchuelern()
{
using var db = NewInMemoryContext();
var repo = new VorgangRepository(db);
var ada = Guid.NewGuid();
var ben = Guid.NewGuid();
var other = Guid.NewGuid();
var shared = new Vorgang { Title = "Konflikt", StudentIds = [ada, ben] };
var unrelated = new Vorgang { Title = "Anderes", StudentIds = [other] };
repo.Save(shared);
repo.Save(unrelated);
Assert.Equal([shared.Id], repo.GetByStudent(ada).Select(v => v.Id));
Assert.Equal([shared.Id], repo.GetByStudent(ben).Select(v => v.Id));
Assert.Empty(repo.GetByStudent(Guid.NewGuid()));
}
[Fact]
public void VorgangRepository_Delete_MarkiertNurAlsGeloescht()
{
using var db = NewInMemoryContext();
var repo = new VorgangRepository(db);
var vorgang = new Vorgang { Title = "Hausaufgaben fehlen" };
repo.Save(vorgang);
repo.Delete(vorgang.Id);
Assert.Empty(repo.GetAll());
Assert.Null(repo.GetById(vorgang.Id));
var raw = db.Vorgaenge.FindById(vorgang.Id);
Assert.NotNull(raw);
Assert.True(raw!.IsDeleted);
Assert.NotNull(raw.DeletedAt);
}
[Fact]
public void VorgangRepository_GetById_LiefertNullFuerUnbekannteId()
{
using var db = NewInMemoryContext();
var repo = new VorgangRepository(db);
Assert.Null(repo.GetById(Guid.NewGuid()));
}
// ── LessonRepository ────────────────────────────────────────────────────── // ── LessonRepository ──────────────────────────────────────────────────────
[Fact] [Fact]
+2
View File
@@ -51,6 +51,7 @@ public class LiteDbContext : IDisposable
public ILiteCollection<Unit> Units => _db.GetCollection<Unit>("units"); public ILiteCollection<Unit> Units => _db.GetCollection<Unit>("units");
public ILiteCollection<Lesson> Lessons => _db.GetCollection<Lesson>("lessons"); public ILiteCollection<Lesson> Lessons => _db.GetCollection<Lesson>("lessons");
public ILiteCollection<Documentation> Documentation => _db.GetCollection<Documentation>("documentation"); public ILiteCollection<Documentation> Documentation => _db.GetCollection<Documentation>("documentation");
public ILiteCollection<Vorgang> Vorgaenge => _db.GetCollection<Vorgang>("vorgaenge");
public ILiteStorage<string> Attachments => _db.GetStorage<string>("attachments", "attachments_chunks"); public ILiteStorage<string> Attachments => _db.GetStorage<string>("attachments", "attachments_chunks");
public ILiteCollection<WorkTask> Tasks => _db.GetCollection<WorkTask>("tasks"); public ILiteCollection<WorkTask> Tasks => _db.GetCollection<WorkTask>("tasks");
public ILiteCollection<TimeEntry> TimeEntries => _db.GetCollection<TimeEntry>("time_entries"); public ILiteCollection<TimeEntry> TimeEntries => _db.GetCollection<TimeEntry>("time_entries");
@@ -71,6 +72,7 @@ public class LiteDbContext : IDisposable
public ILiteCollection<UntisAbsenceCacheEntry> UntisAbsenceCache => _db.GetCollection<UntisAbsenceCacheEntry>("untis_absence_cache"); public ILiteCollection<UntisAbsenceCacheEntry> UntisAbsenceCache => _db.GetCollection<UntisAbsenceCacheEntry>("untis_absence_cache");
public ILiteCollection<UntisClassRegisterCacheEntry> UntisClassRegisterCache => _db.GetCollection<UntisClassRegisterCacheEntry>("untis_classregister_cache"); public ILiteCollection<UntisClassRegisterCacheEntry> UntisClassRegisterCache => _db.GetCollection<UntisClassRegisterCacheEntry>("untis_classregister_cache");
public ILiteCollection<UntisCacheFetchState> UntisCacheFetchStates => _db.GetCollection<UntisCacheFetchState>("untis_cache_fetch_state"); public ILiteCollection<UntisCacheFetchState> UntisCacheFetchStates => _db.GetCollection<UntisCacheFetchState>("untis_cache_fetch_state");
public ILiteCollection<UntisHubJobState> UntisHubJobStates => _db.GetCollection<UntisHubJobState>("untis_hub_job_states");
public ILiteCollection<UntisStudentRosterCacheEntry> UntisStudentRosterCache => _db.GetCollection<UntisStudentRosterCacheEntry>("untis_student_roster_cache"); public ILiteCollection<UntisStudentRosterCacheEntry> UntisStudentRosterCache => _db.GetCollection<UntisStudentRosterCacheEntry>("untis_student_roster_cache");
public ILiteCollection<AnnualPlanEvent> AnnualPlanEvents => _db.GetCollection<AnnualPlanEvent>("annual_plan_events"); public ILiteCollection<AnnualPlanEvent> AnnualPlanEvents => _db.GetCollection<AnnualPlanEvent>("annual_plan_events");
public ILiteCollection<TrashedItem> TrashedItems => _db.GetCollection<TrashedItem>("trash"); public ILiteCollection<TrashedItem> TrashedItems => _db.GetCollection<TrashedItem>("trash");
@@ -357,6 +357,20 @@ public class UnitRepository(LiteDbContext db) : IUnitRepository
public void Save(Unit u) public void Save(Unit u)
{ {
ArchivedGroupWriteGuard.EnsureActive(db, u.GroupId); ArchivedGroupWriteGuard.EnsureActive(db, u.GroupId);
// Persistenz-Invariante: auch Importe und künftig hinzukommende Aufrufer dürfen keine
// zweite laufende Einheit in derselben Lerngruppe hinterlassen. Der Einheiten-Dialog
// kündigt diese automatische Ablösung vorher sichtbar an.
if (u.Status == UnitStatus.Active)
{
foreach (var previous in db.Units.Find(x => x.GroupId == u.GroupId &&
x.Status == UnitStatus.Active && x.Id != u.Id))
{
previous.Status = UnitStatus.Completed;
previous.UpdatedAt = DateTime.UtcNow;
db.Units.Upsert(previous);
db.OnChange?.Invoke(nameof(Unit), previous.Id.ToString(), "Save", previous);
}
}
u.UpdatedAt = DateTime.UtcNow; u.UpdatedAt = DateTime.UtcNow;
db.Units.Upsert(u); db.Units.Upsert(u);
db.OnChange?.Invoke(nameof(Unit), u.Id.ToString(), "Save", u); db.OnChange?.Invoke(nameof(Unit), u.Id.ToString(), "Save", u);
@@ -431,6 +445,37 @@ public class DocumentationRepository(LiteDbContext db) : IDocumentationRepositor
} }
} }
public class VorgangRepository(LiteDbContext db) : IVorgangRepository
{
public List<Vorgang> GetAll() =>
db.Vorgaenge.Find(v => !v.IsDeleted).OrderByDescending(v => v.UpdatedAt).ToList();
public List<Vorgang> GetByStudent(Guid studentId) =>
GetAll().Where(v => v.StudentIds.Contains(studentId)).ToList();
public Vorgang? GetById(Guid id)
{
var vorgang = db.Vorgaenge.FindById(id);
return vorgang is null || vorgang.IsDeleted ? null : vorgang;
}
public void Save(Vorgang vorgang)
{
vorgang.UpdatedAt = DateTime.UtcNow;
db.Vorgaenge.Upsert(vorgang);
db.OnChange?.Invoke(nameof(Vorgang), vorgang.Id.ToString(), "Save", vorgang);
}
public void Delete(Guid id)
{
var vorgang = db.Vorgaenge.FindById(id);
if (vorgang is null) return;
vorgang.IsDeleted = true;
vorgang.DeletedAt = DateTime.UtcNow;
db.Vorgaenge.Update(vorgang);
// Weiches Löschen ist inhaltlich eine Änderung, kein Entfernen -> "Save", damit ein
// anwendendes Gerät den IsDeleted-Stand einfach übernimmt statt den Datensatz zu entfernen
// (gleiches Muster wie DocumentationRepository.Delete).
db.OnChange?.Invoke(nameof(Vorgang), id.ToString(), "Save", vorgang);
}
}
public class WorkTaskRepository(LiteDbContext db) : IWorkTaskRepository public class WorkTaskRepository(LiteDbContext db) : IWorkTaskRepository
{ {
public List<WorkTask> GetByStatus(WorkTaskStatus s) => public List<WorkTask> GetByStatus(WorkTaskStatus s) =>
@@ -854,6 +899,16 @@ public class UntisCacheFetchStateRepository(LiteDbContext db) : IUntisCacheFetch
public void Save(UntisCacheFetchState state) => db.UntisCacheFetchStates.Upsert(state); public void Save(UntisCacheFetchState state) => db.UntisCacheFetchStates.Upsert(state);
} }
public class UntisHubJobStateRepository(LiteDbContext db) : IUntisHubJobStateRepository
{
public UntisHubJobState? Get(UntisHubJobKind kind, Guid? groupId) =>
db.UntisHubJobStates.FindOne(s => s.Kind == kind && s.GroupId == groupId);
public List<UntisHubJobState> GetAll() => db.UntisHubJobStates.FindAll().ToList();
public void Save(UntisHubJobState state) => db.UntisHubJobStates.Upsert(state);
}
public class CompetencyDomainRepository(LiteDbContext db) : ICompetencyDomainRepository public class CompetencyDomainRepository(LiteDbContext db) : ICompetencyDomainRepository
{ {
public List<CompetencyDomain> GetBySubjectAndGrade(Guid subjectId, int gradeLevel) => public List<CompetencyDomain> GetBySubjectAndGrade(Guid subjectId, int gradeLevel) =>
@@ -1,6 +1,9 @@
using LehrerApp.Core.AiPlanning; using LehrerApp.Core.AiPlanning;
using LehrerApp.Core.Models; using LehrerApp.Core.Models;
using LehrerApp.Desktop.Services; using LehrerApp.Desktop.Services;
using LehrerApp.Desktop.ViewModels.Groups;
using System.Net;
using System.Text;
using Xunit; using Xunit;
namespace LehrerApp.Desktop.Tests; namespace LehrerApp.Desktop.Tests;
@@ -15,6 +18,130 @@ public sealed class AiPlanningServiceTests
FakeCompetencyDomains competencyDomains, FakeAlternativeLessonPaths altPaths) => FakeCompetencyDomains competencyDomains, FakeAlternativeLessonPaths altPaths) =>
new(new HttpClient(), lessons, groups, subjects, competencyDomains, altPaths); new(new HttpClient(), lessons, groups, subjects, competencyDomains, altPaths);
[Fact]
public void ParsePlanningLessons_AkzeptiertNormaleAntwortUndDeutschesDatum()
{
const string json = """
{"lessons":[{"id":null,"date":"15.09.2026","lessonNumber":2,"topic":"Redox","startTime":"08:35","phases":[]}],"summary":"ok"}
""";
var lesson = Assert.Single(AiPlanningService.ParsePlanningLessons(json));
Assert.Equal("Redox", lesson.Topic);
Assert.Equal(new DateOnly(2026, 9, 15), lesson.Date);
Assert.Equal(new TimeOnly(8, 35), lesson.StartTime);
}
[Fact]
public void ParsePlanningLessons_AkzeptiertEinzelneMarkierteStunde()
{
const string json = """
{"topic":"Nur diese Stunde","phases":[{"name":"Einstieg","durationMinutes":5,"activity":"Impuls","material":"Bild","shorthand":"UG"}]}
""";
var lesson = Assert.Single(AiPlanningService.ParsePlanningLessons(json));
Assert.Equal("Nur diese Stunde", lesson.Topic);
Assert.Single(lesson.Phases);
}
[Fact]
public void ParsePlanningLessons_UngueltigeMarkierung_LiefertVerstaendlichenFehler()
{
var error = Assert.Throws<AiBackendException>(() =>
AiPlanningService.ParsePlanningLessons("Hier kommt das JSON: { kaputt }"));
Assert.Contains("noch kein gültiges Stunden-JSON", error.Message);
}
[Fact]
public async Task RequestPlanAsync_Backendfehler_BewahrtOriginalantwortFuerRettung()
{
const string body = """
{"error":"Kein gültiges JSON.","rawResponse":"Vorspann\n{kaputt}"}
""";
var service = BuildWithResponse(HttpStatusCode.BadGateway, body);
var error = await Assert.ThrowsAsync<AiBackendException>(() =>
service.RequestPlanAsync(new Unit(), "", "token"));
Assert.Equal("Vorspann\n{kaputt}", error.RawResponse);
}
[Fact]
public async Task RequestPlanAsync_Typfehler_BewahrtEingebetteteOriginalantwortFuerRettung()
{
const string body = """
{"lessons":[{"topic":"Test","date":"kein Datum"}],"rawResponse":"DIE ORIGINALANTWORT"}
""";
var service = BuildWithResponse(HttpStatusCode.OK, body);
var error = await Assert.ThrowsAsync<AiBackendException>(() =>
service.RequestPlanAsync(new Unit(), "", "token"));
Assert.Equal("DIE ORIGINALANTWORT", error.RawResponse);
}
[Fact]
public async Task RequestPlanAsync_Zeitueberschreitung_LiefertKonkreteProxyMeldung()
{
var http = new HttpClient(new CanceledResponseHandler())
{
BaseAddress = new Uri("https://example.invalid/"),
};
var service = new AiPlanningService(http, new FakeLessons(), new FakeGroups([]), new FakeSubjects([]),
new FakeCompetencyDomains(), new FakeAlternativeLessonPaths([]));
var error = await Assert.ThrowsAsync<AiBackendException>(() =>
service.RequestPlanAsync(new Unit(), "", "token"));
Assert.Contains("3½ Minuten", error.Message);
Assert.Contains("Webserver-Proxys", error.Message);
}
[Fact]
public void Rettungsdialog_ImportiertMarkierteStundeAlsNeu()
{
var group = new LearningGroup();
var unit = new Unit { GroupId = group.Id, Title = "T" };
var lessons = new FakeLessons();
var service = Build(lessons, new FakeGroups([group]), new FakeSubjects([]),
new FakeCompetencyDomains(), new FakeAlternativeLessonPaths([]));
var vm = new AiResponseRescueDialogViewModel(service, lessons, unit, "Rohantwort");
vm.ParseSelection("""{"topic":"Gerettete Stunde","phases":[]}""");
vm.ImportAsNewCommand.Execute(null);
Assert.True(vm.Result);
Assert.Equal("Gerettete Stunde", Assert.Single(lessons.GetByUnit(unit.Id)).Topic);
}
private static AiPlanningService BuildWithResponse(HttpStatusCode status, string body)
{
var http = new HttpClient(new StaticResponseHandler(status, body))
{
BaseAddress = new Uri("https://example.invalid/"),
};
return new AiPlanningService(http, new FakeLessons(), new FakeGroups([]), new FakeSubjects([]),
new FakeCompetencyDomains(), new FakeAlternativeLessonPaths([]));
}
private sealed class StaticResponseHandler(HttpStatusCode status, string body) : HttpMessageHandler
{
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request,
CancellationToken cancellationToken) => Task.FromResult(new HttpResponseMessage(status)
{
Content = new StringContent(body, Encoding.UTF8, "application/json"),
});
}
private sealed class CanceledResponseHandler : HttpMessageHandler
{
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request,
CancellationToken cancellationToken) => Task.FromCanceled<HttpResponseMessage>(
new CancellationToken(canceled: true));
}
[Fact] [Fact]
public void BuildContext_FuelltGruppenUndFachKontext() public void BuildContext_FuelltGruppenUndFachKontext()
{ {
@@ -1,4 +1,5 @@
using LehrerApp.Core.Models; using LehrerApp.Core.Models;
using LehrerApp.Core.Services;
using LehrerApp.Desktop.Services; using LehrerApp.Desktop.Services;
using LehrerApp.Desktop.ViewModels.ClassTeacher; using LehrerApp.Desktop.ViewModels.ClassTeacher;
using Xunit; using Xunit;
@@ -7,6 +8,69 @@ namespace LehrerApp.Desktop.Tests;
public sealed class ClassTeacherViewModelsTests public sealed class ClassTeacherViewModelsTests
{ {
[Fact]
public void Monatskalender_KodiertFehlzeitenUndBlendetHausaufgabenStandardmaessigAus()
{
var month = new DateOnly(2026, 9, 1);
var absences = new[]
{
new ClassAbsenceDaySummaryRow(new DateOnly(2026, 9, 2), "Müller Ada", 1, 0, 12,
["Deu"], [1], ["entsch."], ["Verspätung"], null, null, false),
new ClassAbsenceDaySummaryRow(new DateOnly(2026, 9, 3), "Schmidt Ben", 2, 2, 90,
["Mathe"], [1, 2], ["nicht entsch."], ["Absent"], null, null, false),
};
var register = new[]
{
new ClassTeacherClassRegisterRow(new DateOnly(2026, 9, 3), "Deu", "Schmidt Ben", "test",
"Störung", "Negativ", "Unterricht massiv gestört"),
new ClassTeacherClassRegisterRow(new DateOnly(2026, 9, 4), "Mathe", "Müller Ada", "test",
"Hausaufgaben", "Negativ", "Hausaufgaben fehlen"),
};
var days = ClassTeacherDetailsViewModel.BuildCalendarDays(month, absences, register, includeHomework: false);
Assert.Equal(0, days.Count % 5);
Assert.Contains(days.SelectMany(d => d.Events), e => e.Code == "V");
Assert.Contains(days.SelectMany(d => d.Events), e => e.Code == "U");
Assert.Contains(days.SelectMany(d => d.Events), e => e.Code == "!");
Assert.DoesNotContain(days.SelectMany(d => d.Events), e => e.Code == "H");
var withHomework = ClassTeacherDetailsViewModel.BuildCalendarDays(month, absences, register, includeHomework: true);
Assert.Contains(withHomework.SelectMany(d => d.Events), e => e.Code == "H");
}
[Fact]
public void Kompaktkalender_ZeigtNurMonatstageUndJeTagDasStaerksteSignal()
{
var month = new DateOnly(2026, 9, 1);
var absences = new[]
{
new ClassAbsenceDaySummaryRow(new DateOnly(2026, 9, 3), "Müller Ada", 1, 2, 90,
["Deu"], [1, 2], ["nicht entsch."], ["Absent"], null, null, false),
new ClassAbsenceDaySummaryRow(new DateOnly(2026, 9, 4), "Schmidt Ben", 2, 2, 90,
["Mathe"], [1, 2], ["entsch."], ["Absent"], null, null, false),
};
var register = new[]
{
new UntisForeignClassRegisterEventDto("6a", 20260903, "Deu", "Müller Ada", "test",
"Störung", "Negativ", "Unterricht gestört"),
new UntisForeignClassRegisterEventDto("6a", 20260905, "Mathe", "Schmidt Ben", "test",
"Hausaufgaben", "Negativ", "Hausaufgaben fehlen"),
};
var days = ClassTeacherOverviewViewModel.BuildCompactMonthDays(month, absences, register,
new DateOnly(2026, 9, 3));
Assert.Equal(30, days.Count);
Assert.False(days[0].HasSignal);
Assert.Equal("1", days[0].DayNumber);
Assert.Equal("U", days[2].SignalCode); // stärker als der parallele Klassenbucheintrag
Assert.True(days[2].IsToday);
Assert.Contains("Ada Müller", days[2].Tooltip);
Assert.Equal("E", days[3].SignalCode);
Assert.False(days[4].HasSignal); // Hausaufgaben bleiben im Widget bewusst ausgeblendet
}
[Fact] [Fact]
public void GroupByStudentAndDay_FasstFehlstundenProSchuelerUndTagZusammen() public void GroupByStudentAndDay_FasstFehlstundenProSchuelerUndTagZusammen()
{ {
@@ -228,6 +292,198 @@ public sealed class ClassTeacherViewModelsTests
Assert.Null(row.YearSummaryTooltip); Assert.Null(row.YearSummaryTooltip);
} }
// ── Fehlquote behandelt Verspätung nicht mehr wie einen ganzen Fehltag (Nutzer-Feedback) ────
[Fact]
public void RosterBuild_ReineVerspaetungZaehltNichtInDieFehlquote()
{
// Nutzer-Feedback: "10× 5 Min. verspätet" wurde bisher exakt wie "10× unentschuldigt
// gefehlt" behandelt. Reine (nicht unentschuldigte) Verspätung darf die Quote nicht mehr
// treiben, taucht aber weiterhin als eigene Zahl auf.
var students = new[] { Student(1001, "Ada Müller") };
var yearAbsences = Enumerable.Range(0, 3)
.Select(i => new ClassAbsenceDaySummaryRow(new DateOnly(2026, 8, 10 + i), "Müller Ada", 1001, 0, 5,
["Deu"], [1], ["entsch."], ["Verspätung"], null, null, false))
.ToArray();
var row = Assert.Single(ClassTeacherRosterRow.Build(students, [], [],
new DateOnly(2026, 8, 26), yearAbsences, schoolDaysElapsed: 20, termStart: new DateOnly(2026, 8, 1)));
Assert.Equal(0, row.YearAbsenceDayCount);
Assert.Equal(3, row.YearLateDayCount);
Assert.Equal(0, row.YearAbsenceRatePercent);
Assert.True(row.HasYearSummary);
Assert.Contains("verspätet", row.YearSummaryLabel);
Assert.DoesNotContain("%", row.YearSummaryLabel);
Assert.Contains("3 Tage verspätet", row.YearSummaryTooltip);
Assert.Contains("zählt nicht zur Quote", row.YearSummaryTooltip);
}
[Fact]
public void RosterBuild_UnentschuldigtUndVerspaetetZaehltAlsUnentschuldigtNichtAlsVerspaetung()
{
// Wer unentschuldigt UND verspätet ist, bleibt der schwerwiegendere Fall (dieselbe
// Rangfolge wie AttentionRank/StatusKind/BuildTrend: Unentschuldigt vor Verspätet) — zählt
// deshalb weiter voll in die Quote statt in YearLateDayCount zu verschwinden.
var students = new[] { Student(1001, "Ada Müller") };
var yearAbsences = new[]
{
new ClassAbsenceDaySummaryRow(new DateOnly(2026, 8, 10), "Müller Ada", 1001, 0, 60,
["Deu"], [1], ["nicht entsch."], ["Verspätung"], null, null, false),
};
var row = Assert.Single(ClassTeacherRosterRow.Build(students, [], [],
new DateOnly(2026, 8, 26), yearAbsences, schoolDaysElapsed: 20, termStart: new DateOnly(2026, 8, 1)));
Assert.Equal(1, row.YearAbsenceDayCount);
Assert.Equal(1, row.YearUnexcusedDayCount);
Assert.Equal(0, row.YearLateDayCount);
}
[Fact]
public void PatternScore_GewichtetUnentschuldigtDeutlichStaerkerAlsVerspaetung()
{
// Kernfall aus dem Nutzer-Feedback: 10× 5 Minuten verspätet darf nicht denselben Score
// ergeben wie 10× ganztägig unentschuldigt gefehlt.
var lateStudent = Student(1001, "Verpennt Timo");
var truantStudent = Student(1002, "Geschwaenzt Cem");
var yearAbsences = Enumerable.Range(0, 10)
.SelectMany(i => new[]
{
new ClassAbsenceDaySummaryRow(new DateOnly(2026, 8, 3 + i), "Verpennt Timo", 1001, 0, 5,
["Deu"], [1], ["entsch."], ["Verspätung"], null, null, false),
new ClassAbsenceDaySummaryRow(new DateOnly(2026, 8, 3 + i), "Geschwaenzt Cem", 1002, 6, 270,
["Deu"], [1, 2, 3, 4, 5, 6], ["nicht entsch."], ["Absent"], null, null, true),
})
.ToArray();
var roster = ClassTeacherRosterRow.Build([lateStudent, truantStudent], [], [],
new DateOnly(2026, 8, 26), yearAbsences, schoolDaysElapsed: 20, termStart: new DateOnly(2026, 8, 1));
var timo = Assert.Single(roster, r => r.StudentName == "Verpennt Timo");
var cem = Assert.Single(roster, r => r.StudentName == "Geschwaenzt Cem");
Assert.Equal(5.0, timo.PatternScore);
Assert.Equal(30.0, cem.PatternScore);
Assert.True(cem.PatternScore > timo.PatternScore * 5);
}
[Fact]
public void PatternScore_ErfasstMusterAuchWennHeuteAnwesend()
{
// Nutzer-Feedback: ein/e Schüler*in, die/der heute da ist, aber zuvor mehrfach unentschuldigt
// fehlte, soll nicht spurlos aus der Musterauswertung verschwinden — anders als bei der
// reinen "heute auffällig"-Sortierung (HasAbsenceToday).
var student = Student(1001, "Ada Müller");
var yearAbsences = Enumerable.Range(0, 5)
.Select(i => new ClassAbsenceDaySummaryRow(new DateOnly(2026, 8, 10 + i), "Müller Ada", 1001, 6, 270,
["Deu"], [1, 2, 3, 4, 5, 6], ["nicht entsch."], ["Absent"], null, null, true))
.ToArray();
// Kein todayAbsences-Eintrag: heute anwesend.
var row = Assert.Single(ClassTeacherRosterRow.Build([student], [], [],
new DateOnly(2026, 8, 26), yearAbsences, schoolDaysElapsed: 20, termStart: new DateOnly(2026, 8, 1)));
Assert.False(row.HasAbsenceToday);
Assert.True(row.PatternScore > 0);
}
[Fact]
public void PatternScore_IstNullOhneJahresdaten()
{
var students = new[] { Student(1001, "Ada Müller") };
var row = Assert.Single(ClassTeacherRosterRow.Build(students, [], [], new DateOnly(2026, 8, 26)));
Assert.Equal(0.0, row.PatternScore);
}
// ── Klassenbucheinträge anderer Lehrkräfte fließen ebenfalls in den Score ein ────────────────
[Fact]
public void PatternScore_NegativeKlassenbucheintraegeErhoehenDenScoreAuchOhneFehlzeiten()
{
var student = Student(1001, "Ada Müller");
var yearRegister = Enumerable.Range(0, 4)
.Select(i => new UntisForeignClassRegisterEventDto("6a", 20260810 + i, "Deu", "Müller Ada", "test",
"Unterrichtsstörung", "Negativ", "Gestört"))
.ToArray();
var row = Assert.Single(ClassTeacherRosterRow.Build([student], [], [],
new DateOnly(2026, 8, 26), schoolDaysElapsed: 20, termStart: new DateOnly(2026, 8, 1),
yearClassRegisterEntries: yearRegister));
Assert.Equal(4, row.YearNegativeClassRegisterCount);
Assert.Equal(4.0, row.PatternScore);
Assert.True(row.HasYearSummary);
Assert.Contains("negative Klassenbucheinträge", row.YearSummaryLabel);
}
[Fact]
public void PatternScore_HausaufgabenUndMitarbeitSchluesselwoerterZaehlenStaerkerAlsPlainNegativ()
{
var withKeyword = Student(1001, "Keyword Kim");
var plain = Student(1002, "Plain Priya");
var yearRegister = new[]
{
new UntisForeignClassRegisterEventDto("6a", 20260810, "Deu", "Keyword Kim", "test",
"Hausaufgaben fehlen", "Negativ", "HA nicht dabei"),
new UntisForeignClassRegisterEventDto("6a", 20260810, "Deu", "Plain Priya", "test",
"Sonstiges", "Negativ", "Gestört"),
};
var roster = ClassTeacherRosterRow.Build([withKeyword, plain], [], [],
new DateOnly(2026, 8, 26), schoolDaysElapsed: 20, termStart: new DateOnly(2026, 8, 1),
yearClassRegisterEntries: yearRegister);
var kim = Assert.Single(roster, r => r.StudentName == "Keyword Kim");
var priya = Assert.Single(roster, r => r.StudentName == "Plain Priya");
Assert.True(kim.PatternScore > priya.PatternScore);
}
[Fact]
public void PatternScore_SuspendiertSchiesstDenScoreWeitUeberVieleAndereEintraegeHinaus()
{
var suspended = Student(1001, "Suspendiert Sam");
var chronic = Student(1002, "Viele Vera");
var yearRegister = new List<UntisForeignClassRegisterEventDto>
{
new("6a", 20260810, "Deu", "Suspendiert Sam", "test", "Ordnungsmaßnahme", "Negativ",
"Vom Unterricht suspendiert"),
};
yearRegister.AddRange(Enumerable.Range(0, 6)
.Select(i => new UntisForeignClassRegisterEventDto("6a", 20260810 + i, "Deu", "Viele Vera", "test",
"Sonstiges", "Negativ", "Gestört")));
var roster = ClassTeacherRosterRow.Build([suspended, chronic], [], [],
new DateOnly(2026, 8, 26), schoolDaysElapsed: 20, termStart: new DateOnly(2026, 8, 1),
yearClassRegisterEntries: yearRegister);
var sam = Assert.Single(roster, r => r.StudentName == "Suspendiert Sam");
var vera = Assert.Single(roster, r => r.StudentName == "Viele Vera");
Assert.True(sam.HasSuspensionEntry);
Assert.True(sam.PatternScore > vera.PatternScore);
Assert.Contains("Suspendierung vermerkt", sam.YearSummaryTooltip);
}
[Fact]
public void PatternScore_OhneEigeneJahresklassenbuchlisteFaelltAufDenUebergebenenParameterZurueck()
{
// Rückwärtskompatibilität: Aufrufer, die (wie die bestehenden Tests) nur den ursprünglichen
// recentClassRegisterEntries-Parameter befüllen, sollen trotzdem einen Score aus diesen
// Einträgen bekommen statt stillschweigend leer auszugehen.
var student = Student(1001, "Ada Müller");
var entries = new[]
{
new UntisForeignClassRegisterEventDto("6a", 20260810, "Deu", "Müller Ada", "test",
"Sonstiges", "Negativ", "Gestört"),
};
var row = Assert.Single(ClassTeacherRosterRow.Build([student], [], entries,
new DateOnly(2026, 8, 26)));
Assert.True(row.PatternScore > 0);
}
[Fact] [Fact]
public void RosterBuild_OrdnetJahresfehlzeitenAuchOhneExternKeyUeberNamenZu() public void RosterBuild_OrdnetJahresfehlzeitenAuchOhneExternKeyUeberNamenZu()
{ {
@@ -584,4 +840,325 @@ public sealed class ClassTeacherViewModelsTests
private static UntisStudentRosterCacheEntry Student(int? externKey, string displayName) => private static UntisStudentRosterCacheEntry Student(int? externKey, string displayName) =>
new() { ClassName = "6a", ExternKey = externKey, DisplayName = displayName }; new() { ClassName = "6a", ExternKey = externKey, DisplayName = displayName };
// ── Klassenbuch-Tab als Dokumentations-Hub: eigene Dokumentation neben WebUntis ─────────────
[Fact]
public void FilterOwnDocumentation_ZeigtNurEintraegeZugeordneterSchuelerImZeitraum()
{
var ada = Guid.NewGuid();
var fremd = Guid.NewGuid();
var rosterMatches = new List<(Guid StudentId, string DisplayName)> { (ada, "Ada Müller") };
var docs = new List<Documentation>
{
new() { StudentId = ada, Date = new DateOnly(2026, 8, 20), Title = "Im Zeitraum" },
new() { StudentId = ada, Date = new DateOnly(2026, 7, 1), Title = "Vor dem Zeitraum" },
new() { StudentId = fremd, Date = new DateOnly(2026, 8, 20), Title = "Anderer Schüler" },
new() { StudentId = ada, Date = new DateOnly(2026, 8, 21), Title = "Gelöscht", IsDeleted = true },
};
var result = ClassTeacherDetailsViewModel.FilterOwnDocumentation(docs, rosterMatches,
new DateOnly(2026, 8, 1), new DateOnly(2026, 8, 31), "");
Assert.Equal(["Im Zeitraum"], result.Select(d => d.Title));
}
[Fact]
public void FilterOwnDocumentation_SortiertEntwuerfeVorNeuestenZuerst()
{
var id = Guid.NewGuid();
var rosterMatches = new List<(Guid StudentId, string DisplayName)> { (id, "Ada Müller") };
var docs = new List<Documentation>
{
new() { StudentId = id, Date = new DateOnly(2026, 8, 25), Title = "Neu, fertig" },
new() { StudentId = id, Date = new DateOnly(2026, 8, 10), Title = "Alt, Entwurf", IsDraft = true },
new() { StudentId = id, Date = new DateOnly(2026, 8, 20), Title = "Mittel, fertig" },
};
var result = ClassTeacherDetailsViewModel.FilterOwnDocumentation(docs, rosterMatches,
new DateOnly(2026, 8, 1), new DateOnly(2026, 8, 31), "");
Assert.Equal(["Alt, Entwurf", "Neu, fertig", "Mittel, fertig"], result.Select(d => d.Title));
}
[Fact]
public void FilterOwnDocumentation_SchuelerfilterErkenntVertauschteReihenfolge()
{
// Derselbe Namensabgleich wie bei den WebUntis-Klassenbuchzeilen (siehe
// RosterBuild_ErkenntNamenAuchInVertauschterReihenfolge) - der Filter kommt aus der
// Übersicht als "Nachname Vorname" oder "Vorname Nachname" je nach Bericht.
var ada = Guid.NewGuid();
var ben = Guid.NewGuid();
var rosterMatches = new List<(Guid StudentId, string DisplayName)>
{
(ada, "Ada Müller"), (ben, "Ben Schmidt"),
};
var docs = new List<Documentation>
{
new() { StudentId = ada, Date = new DateOnly(2026, 8, 20), Title = "Ada" },
new() { StudentId = ben, Date = new DateOnly(2026, 8, 20), Title = "Ben" },
};
var result = ClassTeacherDetailsViewModel.FilterOwnDocumentation(docs, rosterMatches,
new DateOnly(2026, 8, 1), new DateOnly(2026, 8, 31), "Müller Ada");
Assert.Equal(["Ada"], result.Select(d => d.Title));
}
[Fact]
public void FilterOwnDocumentation_ZeigtVerwaistenKlassenEintragUndFindetTeilnehmerPerVorname()
{
var rosterMatches = new List<(Guid StudentId, string DisplayName)>
{
(Guid.NewGuid(), "Karim Alshurbaji"), (Guid.NewGuid(), "Saleh Al-Anezi"),
};
var orphan = new Documentation
{
StudentId = Guid.Empty, GroupId = null, Date = new DateOnly(2026, 9, 7),
Title = "Vorfall", Participants = ["Karim", "Saleh"],
};
var result = ClassTeacherDetailsViewModel.FilterOwnDocumentation([orphan], rosterMatches,
new DateOnly(2026, 9, 1), new DateOnly(2026, 9, 30), "Karim");
Assert.Same(orphan, Assert.Single(result));
}
// ── Vorgang: Fallmappe für Klassenbuch- und Dokumentationseinträge ───────────────────────────
[Fact]
public void BuildClassRegisterSnapshot_KopiertAlleFelderDerZeile()
{
var row = new ClassTeacherClassRegisterRow(new DateOnly(2026, 8, 20), "Deu", "Schmidt Ben",
"mueller", "Fehlende HA", "Negativ", "Buch vergessen");
var snapshot = ClassTeacherDetailsViewModel.BuildClassRegisterSnapshot(row);
Assert.Equal(new DateOnly(2026, 8, 20), snapshot.Date);
Assert.Equal("Schmidt Ben", snapshot.StudentName);
Assert.Equal("Deu", snapshot.Subject);
Assert.Equal("mueller", snapshot.TeacherUsername);
Assert.Equal("Fehlende HA", snapshot.CategoryName);
Assert.Equal("Negativ", snapshot.CategoryGroup);
Assert.Equal("Buch vergessen", snapshot.Text);
}
[Fact]
public void FilterAvailableDocumentation_ZeigtNurUnverknuepfteDokumentationDerVorgangsSchueler()
{
var ada = Guid.NewGuid();
var ben = Guid.NewGuid();
var fremd = Guid.NewGuid();
var linked = new Documentation { StudentId = ada, Title = "Schon verknüpft" };
var unlinked = new Documentation { StudentId = ada, Title = "Noch offen" };
var otherStudentInCase = new Documentation { StudentId = ben, Title = "Ben, offen" };
var unrelated = new Documentation { StudentId = fremd, Title = "Anderer Schüler" };
var vorgang = new Vorgang { StudentIds = [ada, ben], DocumentationIds = [linked.Id] };
var allDocs = new List<Documentation> { linked, unlinked, otherStudentInCase, unrelated };
var result = ClassTeacherCasesViewModel.FilterAvailableDocumentation(allDocs, vorgang);
Assert.Equal(["Noch offen", "Ben, offen"], result.Select(d => d.Title));
}
[Fact]
public void FilterAvailableDocumentation_LeerOhneUnverknuepfteEintraege()
{
var ada = Guid.NewGuid();
var doc = new Documentation { StudentId = ada, Title = "Verknüpft" };
var vorgang = new Vorgang { StudentIds = [ada], DocumentationIds = [doc.Id] };
var result = ClassTeacherCasesViewModel.FilterAvailableDocumentation([doc], vorgang);
Assert.Empty(result);
}
// ── Konfigurierbare Gewichtung + Dämpfung/Rückfall-Warnung nach geschlossenem Vorgang ─────────
[Fact]
public void MatchDomains_ErkenntBereicheAnSchluesselwoertern()
{
Assert.Equal([VorgangScoreDomain.Attendance], ClassTeacherRosterRow.MatchDomains(["Absentismus"]));
Assert.Equal([VorgangScoreDomain.Lateness], ClassTeacherRosterRow.MatchDomains(["Verspätungen"]));
Assert.Equal([VorgangScoreDomain.Classbook], ClassTeacherRosterRow.MatchDomains(["Konflikte"]));
Assert.Equal([VorgangScoreDomain.Classbook], ClassTeacherRosterRow.MatchDomains(["Mitarbeit"]));
Assert.Equal([VorgangScoreDomain.Classbook], ClassTeacherRosterRow.MatchDomains(["Hausaufgaben"]));
Assert.Equal([VorgangScoreDomain.Classbook], ClassTeacherRosterRow.MatchDomains(["Eskalation"]));
}
[Fact]
public void MatchDomains_OhneErkanntesSchlagwortDaemptAlleBereiche()
{
// Nutzer-Entscheidung: ein Vorgang darf beim Schließen nie folgenlos bleiben, auch wenn sein
// Schlagwort (z.B. "Elternkontakt") keinem bekannten Score-Bereich zugeordnet werden kann.
var domains = ClassTeacherRosterRow.MatchDomains(["Elternkontakt"]);
Assert.Equal(3, domains.Count);
Assert.Contains(VorgangScoreDomain.Attendance, domains);
Assert.Contains(VorgangScoreDomain.Lateness, domains);
Assert.Contains(VorgangScoreDomain.Classbook, domains);
}
[Fact]
public void ComputeDampeningCutoffs_NimmtJeBereichDasJuengsteSchliessDatum()
{
var closed = new List<Vorgang>
{
new() { Tags = ["Absentismus"], Status = VorgangStatus.Closed, ClosedAt = new DateTime(2026, 8, 10) },
new() { Tags = ["Absentismus"], Status = VorgangStatus.Closed, ClosedAt = new DateTime(2026, 8, 20) },
new() { Tags = ["Verspätungen"], Status = VorgangStatus.Closed, ClosedAt = new DateTime(2026, 8, 5) },
};
var cutoffs = ClassTeacherRosterRow.ComputeDampeningCutoffs(closed);
Assert.Equal(new DateOnly(2026, 8, 20), cutoffs[VorgangScoreDomain.Attendance]);
Assert.Equal(new DateOnly(2026, 8, 5), cutoffs[VorgangScoreDomain.Lateness]);
Assert.False(cutoffs.ContainsKey(VorgangScoreDomain.Classbook));
}
[Fact]
public void ComputeDampeningCutoffs_IgnoriertVorgaengeOhneSchliessDatum()
{
var closed = new List<Vorgang> { new() { Tags = ["Absentismus"], Status = VorgangStatus.Open } };
var cutoffs = ClassTeacherRosterRow.ComputeDampeningCutoffs(closed);
Assert.Empty(cutoffs);
}
[Fact]
public void PatternScore_BenutztUebergebeneGewichteStattFesterKonstanten()
{
var student = Student(1001, "Ada Müller");
var yearAbsences = new[]
{
new ClassAbsenceDaySummaryRow(new DateOnly(2026, 8, 10), "Müller Ada", 1001, 6, 270,
["Deu"], [1, 2, 3, 4, 5, 6], ["nicht entsch."], ["Absent"], null, null, true),
};
var customWeights = new PatternScoreWeights { UnexcusedDayWeight = 10.0 };
var row = Assert.Single(ClassTeacherRosterRow.Build([student], [], [],
new DateOnly(2026, 8, 26), yearAbsences, schoolDaysElapsed: 20, termStart: new DateOnly(2026, 8, 1),
weights: customWeights));
Assert.Equal(10.0, row.PatternScore);
}
[Fact]
public void PatternScore_DaempftNurDenZumVorgangPassendenBereichNachAbschluss()
{
var student = Student(1001, "Ada Müller");
var yearAbsences = new[]
{
// Vor dem Abschluss: darf für den Score nicht mehr zählen.
new ClassAbsenceDaySummaryRow(new DateOnly(2026, 8, 5), "Müller Ada", 1001, 6, 270,
["Deu"], [1, 2, 3, 4, 5, 6], ["nicht entsch."], ["Absent"], null, null, true),
// Nach dem Abschluss: zählt weiter.
new ClassAbsenceDaySummaryRow(new DateOnly(2026, 8, 20), "Müller Ada", 1001, 6, 270,
["Deu"], [1, 2, 3, 4, 5, 6], ["nicht entsch."], ["Absent"], null, null, true),
// Verspätung, anderer Bereich: bleibt von der auf "Absentismus" beschränkten Dämpfung unberührt.
new ClassAbsenceDaySummaryRow(new DateOnly(2026, 8, 1), "Müller Ada", 1001, 0, 5,
["Deu"], [1], ["entsch."], ["Verspätung"], null, null, false),
};
var closedVorgaengeByNameKey = new Dictionary<string, IReadOnlyList<Vorgang>>
{
[UntisNameMatching.NameKey("Ada Müller")] = [new Vorgang
{
Title = "Schuleschwänzen", Tags = ["Absentismus"], Status = VorgangStatus.Closed,
ClosedAt = new DateTime(2026, 8, 12),
}],
};
var row = Assert.Single(ClassTeacherRosterRow.Build([student], [], [],
new DateOnly(2026, 8, 26), yearAbsences, schoolDaysElapsed: 20, termStart: new DateOnly(2026, 8, 1),
closedVorgaengeByNameKey: closedVorgaengeByNameKey));
// 1 unentschuldigter Tag nach Abschluss (3,0) + 1 unbeeinflusster Verspätungstag (0,5) = 3,5.
Assert.Equal(3.5, row.PatternScore);
// Die Quote bleibt von der Dämpfung unberührt: beide unentschuldigten Tage zählen weiter.
Assert.Equal(2, row.YearUnexcusedDayCount);
Assert.Equal(2, row.YearAbsenceDayCount);
}
[Fact]
public void PatternScore_VorgangOhneErkennbaresSchlagwortDaemptAlleBereiche()
{
var student = Student(1001, "Ada Müller");
var yearAbsences = new[]
{
new ClassAbsenceDaySummaryRow(new DateOnly(2026, 8, 5), "Müller Ada", 1001, 0, 5,
["Deu"], [1], ["entsch."], ["Verspätung"], null, null, false),
};
var closedVorgaengeByNameKey = new Dictionary<string, IReadOnlyList<Vorgang>>
{
[UntisNameMatching.NameKey("Ada Müller")] = [new Vorgang
{
Title = "Elterngespräch", Tags = ["Elternkontakt"], Status = VorgangStatus.Closed,
ClosedAt = new DateTime(2026, 8, 12),
}],
};
var row = Assert.Single(ClassTeacherRosterRow.Build([student], [], [],
new DateOnly(2026, 8, 26), yearAbsences, schoolDaysElapsed: 20, termStart: new DateOnly(2026, 8, 1),
closedVorgaengeByNameKey: closedVorgaengeByNameKey));
Assert.Equal(0.0, row.PatternScore);
}
[Fact]
public void RegressionScore_IstNullOhneNeueEreignisseNachAbschluss()
{
var student = Student(1001, "Ada Müller");
var yearAbsences = new[]
{
new ClassAbsenceDaySummaryRow(new DateOnly(2026, 8, 5), "Müller Ada", 1001, 6, 270,
["Deu"], [1, 2, 3, 4, 5, 6], ["nicht entsch."], ["Absent"], null, null, true),
};
var closedVorgaengeByNameKey = new Dictionary<string, IReadOnlyList<Vorgang>>
{
[UntisNameMatching.NameKey("Ada Müller")] = [new Vorgang
{
Title = "Schuleschwänzen", Tags = ["Absentismus"], Status = VorgangStatus.Closed,
ClosedAt = new DateTime(2026, 8, 12),
}],
};
var row = Assert.Single(ClassTeacherRosterRow.Build([student], [], [],
new DateOnly(2026, 8, 26), yearAbsences, schoolDaysElapsed: 20, termStart: new DateOnly(2026, 8, 1),
closedVorgaengeByNameKey: closedVorgaengeByNameKey));
Assert.Equal(0.0, row.RegressionScore);
Assert.Equal("Schuleschwänzen", row.RegressionVorgangTitle);
}
[Fact]
public void RegressionScore_ErkenntNeueUnentschuldigteTageNachAbschluss()
{
var student = Student(1001, "Ada Müller");
var yearAbsences = new[]
{
new ClassAbsenceDaySummaryRow(new DateOnly(2026, 8, 5), "Müller Ada", 1001, 6, 270,
["Deu"], [1, 2, 3, 4, 5, 6], ["nicht entsch."], ["Absent"], null, null, true),
// Nach Abschluss erneut unentschuldigt gefehlt.
new ClassAbsenceDaySummaryRow(new DateOnly(2026, 8, 20), "Müller Ada", 1001, 6, 270,
["Deu"], [1, 2, 3, 4, 5, 6], ["nicht entsch."], ["Absent"], null, null, true),
};
var closedVorgaengeByNameKey = new Dictionary<string, IReadOnlyList<Vorgang>>
{
[UntisNameMatching.NameKey("Ada Müller")] = [new Vorgang
{
Title = "Schuleschwänzen", Tags = ["Absentismus"], Status = VorgangStatus.Closed,
ClosedAt = new DateTime(2026, 8, 12),
}],
};
var row = Assert.Single(ClassTeacherRosterRow.Build([student], [], [],
new DateOnly(2026, 8, 26), yearAbsences, schoolDaysElapsed: 20, termStart: new DateOnly(2026, 8, 1),
closedVorgaengeByNameKey: closedVorgaengeByNameKey));
Assert.Equal(3.0, row.RegressionScore);
Assert.Equal("Schuleschwänzen", row.RegressionVorgangTitle);
// Quote weiterhin unangetastet.
Assert.Equal(2, row.YearUnexcusedDayCount);
}
} }
@@ -20,14 +20,75 @@ public sealed class DashboardViewModelTests
var vm = BuildVm(group, new Lesson { GroupId = group.Id, Date = today }); var vm = BuildVm(group, new Lesson { GroupId = group.Id, Date = today });
Assert.False(vm.ExcusesCard.EffectiveIsVisible); Assert.False(vm.AttentionCard.EffectiveIsVisible);
Assert.False(vm.CorrectionsCard.EffectiveIsVisible);
Assert.False(vm.AlertsCard.EffectiveIsVisible);
Assert.Equal("0 offene Punkte", vm.AttentionSummary); Assert.Equal("0 offene Punkte", vm.AttentionSummary);
Assert.True(vm.TodayCard.EffectiveIsVisible); Assert.True(vm.TodayCard.EffectiveIsVisible);
Assert.True(vm.CalendarCard.EffectiveIsVisible); Assert.True(vm.CalendarCard.EffectiveIsVisible);
} }
/// Regression: ApplyCardLayout zaehlte frueher IsVisible statt EffectiveIsVisible. Eine
/// eingeschaltete, aber leere HideWhenEmpty-Kachel belegte damit einen Rasterplatz, den das
/// Grid nie fuellt — im Alltag der Normalfall, weil meist mehrere Hinweiskacheln leer sind.
[Fact]
public void Kachelraster_LeereAusgeblendeteKacheln_HinterlassenKeineLuecke()
{
var group = new LearningGroup { Name = "9c" };
var today = DateOnly.FromDateTime(DateTime.Today);
var vm = BuildVm(group, new Lesson { GroupId = group.Id, Date = today });
// Ohne mindestens eine leer ausgeblendete Kachel wuerde der Test nichts pruefen.
Assert.False(vm.AttentionCard.EffectiveIsVisible);
var belegtePlaetze = vm.DashboardCards.Where(c => c.EffectiveIsVisible)
.Select(c => c.Row * 2 + c.Column).OrderBy(slot => slot).ToList();
Assert.Equal(Enumerable.Range(0, belegtePlaetze.Count), belegtePlaetze);
}
[Fact]
public void KachelMargin_FolgtDerBerechnetenSpalte()
{
var group = new LearningGroup { Name = "9c" };
var today = DateOnly.FromDateTime(DateTime.Today);
var vm = BuildVm(group, new Lesson { GroupId = group.Id, Date = today });
foreach (var card in vm.DashboardCards.Where(c => c.EffectiveIsVisible))
Assert.Equal(card.Column == 0
? new Avalonia.Thickness(0, 0, 8, 8)
: new Avalonia.Thickness(8, 0, 0, 8), card.Margin);
}
/// Regression: der Margin hing fest im XAML an der Kachel. Wandert sie durch Aus-/Einblenden
/// einer vorherigen Kachel in die andere Spalte, sass der Rinnstein auf der falschen Seite.
[Fact]
public void KachelAusblenden_DrehtDenMarginDerNachfolgendenKachel()
{
var group = new LearningGroup { Name = "9c" };
var today = DateOnly.FromDateTime(DateTime.Today);
var vm = BuildVm(group, new Lesson { GroupId = group.Id, Date = today });
Assert.Equal(1, vm.TasksCard.Column);
Assert.Equal(new Avalonia.Thickness(8, 0, 0, 8), vm.TasksCard.Margin);
vm.TodayCard.IsVisible = false;
Assert.Equal(0, vm.TasksCard.Column);
Assert.Equal(new Avalonia.Thickness(0, 0, 8, 8), vm.TasksCard.Margin);
}
/// Die frueheren sieben eigenen Listen (OpenExcuses, AttendanceWarnings, ...) sind zur
/// zusammengefassten Attention-Karte verschmolzen (siehe AttentionItem.cs) — Tests greifen
/// seither ueber Kind gefiltert zu statt ueber eine eigene Collection je Art.
private static IEnumerable<AttentionItem> Items(DashboardViewModel vm, AttentionKind kind) =>
vm.Attention.Where(g => g.Kind == kind).SelectMany(g => g.Items);
/// AttentionItem traegt fuer MissingTeachingTime kein eigenes Date-Feld (Title ist bereits der
/// formatierte Anzeigetext) — das genaue Datum steckt im mitgegebenen Action-Parameter
/// (derselbe MissingTeachingTimeItem, den auch der "Erfassen"-Dialog erhaelt).
private static DateOnly MissingTimeDate(AttentionItem item) =>
((MissingTeachingTimeItem)item.Actions.Single().Parameter!).Date;
private static PeriodScheduleService NewPeriodSchedule() private static PeriodScheduleService NewPeriodSchedule()
{ {
var tempPath = System.IO.Path.Combine( var tempPath = System.IO.Path.Combine(
@@ -61,7 +122,7 @@ public sealed class DashboardViewModelTests
FakeLessons? lessons = null, FakeSubstitutionEntries? substitutions = null, FakeLessons? lessons = null, FakeSubstitutionEntries? substitutions = null,
FakeSessions? sessions = null, FakeEntries? entries = null, FakeSessions? sessions = null, FakeEntries? entries = null,
FakeAnnualPlanEvents? annualPlanEvents = null, List<LearningGroup>? allGroups = null, FakeAnnualPlanEvents? annualPlanEvents = null, List<LearningGroup>? allGroups = null,
FakeTimeEntries? timeEntries = null) FakeTimeEntries? timeEntries = null, Func<DateTime>? now = null)
{ {
lessons ??= new FakeLessons(); lessons ??= new FakeLessons();
lessons.Add(lesson); lessons.Add(lesson);
@@ -74,7 +135,9 @@ public sealed class DashboardViewModelTests
slots ?? new FakeTimetableSlots(), periodSchedule ?? NewPeriodSchedule(), slots ?? new FakeTimetableSlots(), periodSchedule ?? NewPeriodSchedule(),
new AttendanceBalanceService(), new SchoolYearService(), dashboardSettings ?? NewDashboardSettings(), new AttendanceBalanceService(), new SchoolYearService(), dashboardSettings ?? NewDashboardSettings(),
schoolHolidays ?? new FakeSchoolHolidays(), new PublicHolidayService(), NewCalendarSettings(), schoolHolidays ?? new FakeSchoolHolidays(), new PublicHolidayService(), NewCalendarSettings(),
substitutions ?? new FakeSubstitutionEntries(), timeEntries ?? new FakeTimeEntries(), annualPlanEvents); substitutions ?? new FakeSubstitutionEntries(), timeEntries ?? new FakeTimeEntries(),
TestSupport.BuildUntisHubService(), TestSupport.BuildWebUntisIntegrationService(), annualPlanEvents,
annualPlanSync: null, schoolWeather: null, now: now);
} }
/// Montag einer Woche, die garantiert in der Zukunft liegt und innerhalb der /// Montag einer Woche, die garantiert in der Zukunft liegt und innerhalb der
@@ -152,8 +215,8 @@ public sealed class DashboardViewModelTests
var vm = BuildVm(group, new Lesson { GroupId = group.Id, Date = today }, slots: slots, periodSchedule: periodSchedule); var vm = BuildVm(group, new Lesson { GroupId = group.Id, Date = today }, slots: slots, periodSchedule: periodSchedule);
Assert.Contains(vm.MissingTeachingTimeEntries, i => i.Date == pastDay); Assert.Contains(Items(vm, AttentionKind.MissingTeachingTime), i => MissingTimeDate(i) == pastDay);
Assert.True(vm.MissingTeachingTimeCard.EffectiveIsVisible); Assert.True(vm.AttentionCard.EffectiveIsVisible);
} }
[Fact] [Fact]
@@ -173,7 +236,7 @@ public sealed class DashboardViewModelTests
var vm = BuildVm(group, new Lesson { GroupId = group.Id, Date = today }, var vm = BuildVm(group, new Lesson { GroupId = group.Id, Date = today },
slots: slots, periodSchedule: periodSchedule, timeEntries: timeEntries); slots: slots, periodSchedule: periodSchedule, timeEntries: timeEntries);
Assert.DoesNotContain(vm.MissingTeachingTimeEntries, i => i.Date == pastDay); Assert.DoesNotContain(Items(vm, AttentionKind.MissingTeachingTime), i => MissingTimeDate(i) == pastDay);
} }
[Fact] [Fact]
@@ -193,7 +256,7 @@ public sealed class DashboardViewModelTests
var vm = BuildVm(group, new Lesson { GroupId = group.Id, Date = today }, var vm = BuildVm(group, new Lesson { GroupId = group.Id, Date = today },
slots: slots, periodSchedule: periodSchedule, substitutions: substitutions); slots: slots, periodSchedule: periodSchedule, substitutions: substitutions);
Assert.DoesNotContain(vm.MissingTeachingTimeEntries, i => i.Date == pastDay); Assert.DoesNotContain(Items(vm, AttentionKind.MissingTeachingTime), i => MissingTimeDate(i) == pastDay);
} }
[Fact] [Fact]
@@ -213,43 +276,55 @@ public sealed class DashboardViewModelTests
var vm = BuildVm(group, new Lesson { GroupId = group.Id, Date = today }, var vm = BuildVm(group, new Lesson { GroupId = group.Id, Date = today },
slots: slots, periodSchedule: periodSchedule, schoolHolidays: schoolHolidays); slots: slots, periodSchedule: periodSchedule, schoolHolidays: schoolHolidays);
Assert.DoesNotContain(vm.MissingTeachingTimeEntries, i => i.Date == pastDay); Assert.DoesNotContain(Items(vm, AttentionKind.MissingTeachingTime), i => MissingTimeDate(i) == pastDay);
} }
/// Fixer Referenzzeitpunkt fuer die beiden "heute, kurz vor/nach Ablauf der Wartezeit"-Tests
/// unten. Regression: die Tests bauten "Unterrichtsende" bisher aus TimeOnly.FromDateTime(
/// DateTime.Now).AddHours(±2) — TimeOnly wickelt bei Mitternacht um, ausgefuehrt zwischen ca.
/// 22:00 und 02:00 Uhr wurde dadurch aus "+2h" ein Ende VOR "jetzt" (oder umgekehrt), je nach
/// Tageszeit zufaellig rot. September ist bewusst gewaehlt: keiner der bundesweiten oder
/// laenderspezifischen Feiertage (PublicHolidayService) faellt in diesen Monat, ein Dienstag
/// ist garantiert kein Wochenende — die injizierte Uhr (DashboardViewModel now:-Parameter)
/// macht "jetzt" fuer den Test unabhaengig von der tatsaechlichen Ausfuehrungsuhrzeit.
private static readonly DateTime FixedNow = new(2026, 9, 8, 10, 0, 0);
[Fact] [Fact]
public void MissingTeachingTime_HeuteVorAblaufDerWartezeit_WirdNichtGemeldet() public void MissingTeachingTime_HeuteVorAblaufDerWartezeit_WirdNichtGemeldet()
{ {
var group = new LearningGroup { Name = "9c" }; var group = new LearningGroup { Name = "9c" };
var today = DateOnly.FromDateTime(DateTime.Today); var today = DateOnly.FromDateTime(FixedNow);
var slots = new FakeTimetableSlots(); var slots = new FakeTimetableSlots();
slots.Add(new TimetableSlot { GroupId = group.Id, Weekday = today.DayOfWeek, PeriodNumber = 1 }); slots.Add(new TimetableSlot { GroupId = group.Id, Weekday = today.DayOfWeek, PeriodNumber = 1 });
var periodSchedule = NewPeriodSchedule(); var periodSchedule = NewPeriodSchedule();
// Unterrichtsende liegt garantiert noch keine 30 Minuten zurück. // Unterrichtsende liegt (relativ zur fixen Uhr FixedNow) noch keine 30 Minuten zurück.
var futureEnd = TimeOnly.FromDateTime(DateTime.Now).AddHours(2); var futureEnd = TimeOnly.FromDateTime(FixedNow).AddHours(2);
periodSchedule.SetPeriods([new PeriodTimeEntry periodSchedule.SetPeriods([new PeriodTimeEntry
{ PeriodNumber = 1, Start = futureEnd.AddHours(-1), End = futureEnd }]); { PeriodNumber = 1, Start = futureEnd.AddHours(-1), End = futureEnd }]);
var vm = BuildVm(group, new Lesson { GroupId = group.Id, Date = today }, slots: slots, periodSchedule: periodSchedule); var vm = BuildVm(group, new Lesson { GroupId = group.Id, Date = today }, slots: slots,
periodSchedule: periodSchedule, now: () => FixedNow);
Assert.DoesNotContain(vm.MissingTeachingTimeEntries, i => i.Date == today); Assert.DoesNotContain(Items(vm, AttentionKind.MissingTeachingTime), i => MissingTimeDate(i) == today);
} }
[Fact] [Fact]
public void MissingTeachingTime_HeuteNachAblaufDerWartezeit_WirdGemeldet() public void MissingTeachingTime_HeuteNachAblaufDerWartezeit_WirdGemeldet()
{ {
var group = new LearningGroup { Name = "9c" }; var group = new LearningGroup { Name = "9c" };
var today = DateOnly.FromDateTime(DateTime.Today); var today = DateOnly.FromDateTime(FixedNow);
var slots = new FakeTimetableSlots(); var slots = new FakeTimetableSlots();
slots.Add(new TimetableSlot { GroupId = group.Id, Weekday = today.DayOfWeek, PeriodNumber = 1 }); slots.Add(new TimetableSlot { GroupId = group.Id, Weekday = today.DayOfWeek, PeriodNumber = 1 });
var periodSchedule = NewPeriodSchedule(); var periodSchedule = NewPeriodSchedule();
// Unterrichtsende liegt garantiert mehr als 30 Minuten zurück. // Unterrichtsende liegt (relativ zur fixen Uhr FixedNow) mehr als 30 Minuten zurück.
var pastEnd = TimeOnly.FromDateTime(DateTime.Now).AddHours(-2); var pastEnd = TimeOnly.FromDateTime(FixedNow).AddHours(-2);
periodSchedule.SetPeriods([new PeriodTimeEntry periodSchedule.SetPeriods([new PeriodTimeEntry
{ PeriodNumber = 1, Start = pastEnd.AddHours(-1), End = pastEnd }]); { PeriodNumber = 1, Start = pastEnd.AddHours(-1), End = pastEnd }]);
var vm = BuildVm(group, new Lesson { GroupId = group.Id, Date = today }, slots: slots, periodSchedule: periodSchedule); var vm = BuildVm(group, new Lesson { GroupId = group.Id, Date = today }, slots: slots,
periodSchedule: periodSchedule, now: () => FixedNow);
Assert.Contains(vm.MissingTeachingTimeEntries, i => i.Date == today); Assert.Contains(Items(vm, AttentionKind.MissingTeachingTime), i => MissingTimeDate(i) == today);
} }
[Fact] [Fact]
@@ -376,10 +451,9 @@ public sealed class DashboardViewModelTests
exams: new FakeExams([exam]), results: results, memberships: memberships, exams: new FakeExams([exam]), results: results, memberships: memberships,
students: new FakeStudents([anna, ben])); students: new FakeStudents([anna, ben]));
var correction = Assert.Single(vm.OpenCorrections); var correction = Assert.Single(Items(vm, AttentionKind.Correction));
Assert.Equal(1, correction.Completed); Assert.Equal(50, correction.ProgressPercent);
Assert.Equal(2, correction.Total); Assert.Equal("1 von 2 Arbeiten bewertet", correction.ProgressLabel);
Assert.Equal(50, correction.Percent);
} }
[Fact] [Fact]
@@ -400,7 +474,7 @@ public sealed class DashboardViewModelTests
var vm = BuildVm(group, new Lesson { GroupId = group.Id, Date = today }, grades: grades, var vm = BuildVm(group, new Lesson { GroupId = group.Id, Date = today }, grades: grades,
memberships: memberships, students: new FakeStudents([student])); memberships: memberships, students: new FakeStudents([student]));
Assert.Contains(vm.Alerts, a => a.StudentId == student.Id && a.KindLabel == "Notenabfall"); Assert.Contains(Items(vm, AttentionKind.Alert), a => a.Title == student.FullName && a.TrailingText == "Notenabfall");
} }
[Fact] [Fact]
@@ -559,9 +633,8 @@ public sealed class DashboardViewModelTests
var vm = BuildVm(group, pastLesson, slots: slots); var vm = BuildVm(group, pastLesson, slots: slots);
var item = Assert.Single(vm.UnplannedLessons); var item = Assert.Single(Items(vm, AttentionKind.Unplanned));
Assert.Equal(group.Id, item.GroupId); Assert.Equal($"{group.Name} · 1. Stunde", item.Title);
Assert.Equal(1, item.PeriodNumber);
Assert.Equal("Heute", item.DateDisplay); Assert.Equal("Heute", item.DateDisplay);
} }
@@ -576,7 +649,7 @@ public sealed class DashboardViewModelTests
var vm = BuildVm(group, lesson, slots: slots); var vm = BuildVm(group, lesson, slots: slots);
Assert.Empty(vm.UnplannedLessons); Assert.Empty(Items(vm, AttentionKind.Unplanned));
} }
[Fact] [Fact]
@@ -590,7 +663,7 @@ public sealed class DashboardViewModelTests
var vm = BuildVm(group, pastLesson, slots: slots); var vm = BuildVm(group, pastLesson, slots: slots);
Assert.Empty(vm.UnplannedLessons); Assert.Empty(Items(vm, AttentionKind.Unplanned));
} }
[Fact] [Fact]
@@ -606,7 +679,7 @@ public sealed class DashboardViewModelTests
var vm = BuildVm(group, pastLesson, slots: slots, schoolHolidays: schoolHolidays); var vm = BuildVm(group, pastLesson, slots: slots, schoolHolidays: schoolHolidays);
Assert.Empty(vm.UnplannedLessons); Assert.Empty(Items(vm, AttentionKind.Unplanned));
} }
[Fact] [Fact]
@@ -621,7 +694,7 @@ public sealed class DashboardViewModelTests
var vm = BuildVm(group, lesson, slots: slots); var vm = BuildVm(group, lesson, slots: slots);
Assert.Empty(vm.UnplannedLessons); Assert.Empty(Items(vm, AttentionKind.Unplanned));
} }
[Fact] [Fact]
@@ -636,9 +709,10 @@ public sealed class DashboardViewModelTests
var vm = BuildVm(group, lesson, slots: slots); var vm = BuildVm(group, lesson, slots: slots);
Assert.Equal(2, vm.UnplannedLessons.Count); var unplanned = Items(vm, AttentionKind.Unplanned).ToList();
Assert.Contains(vm.UnplannedLessons, i => i.PeriodNumber == 3); Assert.Equal(2, unplanned.Count);
Assert.Contains(vm.UnplannedLessons, i => i.PeriodNumber == 4); Assert.Contains(unplanned, i => i.Title.Contains("3. Stunde"));
Assert.Contains(unplanned, i => i.Title.Contains("4. Stunde"));
} }
[Fact] [Fact]
@@ -656,7 +730,7 @@ public sealed class DashboardViewModelTests
var vm = BuildVm(group, pastLesson, slots: slots, substitutions: substitutions); var vm = BuildVm(group, pastLesson, slots: slots, substitutions: substitutions);
Assert.Empty(vm.UnplannedLessons); Assert.Empty(Items(vm, AttentionKind.Unplanned));
} }
[Fact] [Fact]
@@ -679,7 +753,7 @@ public sealed class DashboardViewModelTests
var vm = BuildVm(group, new Lesson { GroupId = group.Id, Date = today }, var vm = BuildVm(group, new Lesson { GroupId = group.Id, Date = today },
students: new FakeStudents([student]), sessions: sessions, entries: entries); students: new FakeStudents([student]), sessions: sessions, entries: entries);
Assert.Empty(vm.AttendanceWarnings); Assert.Empty(Items(vm, AttentionKind.Attendance));
} }
[Fact] [Fact]
@@ -703,9 +777,9 @@ public sealed class DashboardViewModelTests
var vm = BuildVm(group, new Lesson { GroupId = group.Id, Date = today }, var vm = BuildVm(group, new Lesson { GroupId = group.Id, Date = today },
students: new FakeStudents([student]), sessions: sessions, entries: entries); students: new FakeStudents([student]), sessions: sessions, entries: entries);
var item = Assert.Single(vm.AttendanceWarnings); var item = Assert.Single(Items(vm, AttentionKind.Attendance));
Assert.Equal(student.FullName, item.StudentName); Assert.Equal(student.FullName, item.Title);
Assert.Equal(30.0, item.AbsenceRatePercent); Assert.Equal("30 %", item.TrailingText);
} }
[Fact] [Fact]
@@ -228,6 +228,20 @@ public sealed class DocumentationDialogViewModelTests
Assert.NotEqual("", vm.StudentError); Assert.NotEqual("", vm.StudentError);
} }
[Fact]
public void OhneGeladeneSchuelerlisteUndOhneKontext_SaveErzeugtKeinenVerwaistenEintrag()
{
var vm = new DocumentationDialogViewModel(Guid.Empty, null, new FakeAttachmentStorage())
{
Title = "Vorfall", TypeName = "Vorkommnis",
};
vm.SaveCommand.Execute(null);
Assert.Null(vm.Result);
Assert.Contains("noch nicht geladen", vm.StudentError);
}
[Fact] [Fact]
public void MitStudentOptions_AuswahlGetroffen_SaveUebernimmtSchuelerUndGruppe() public void MitStudentOptions_AuswahlGetroffen_SaveUebernimmtSchuelerUndGruppe()
{ {
+21
View File
@@ -39,6 +39,14 @@ public static class TestSupport
return new WebUntisSettingsService(tempPath); return new WebUntisSettingsService(tempPath);
} }
/// Nicht konfiguriert (kein API-Login hinterlegt) - genügt für Tests, die nur den Konstruktor
/// bedienen müssen und keinen echten WebUntis-Zugriff auslösen.
public static WebUntisIntegrationService BuildWebUntisIntegrationService() =>
new(new HttpClient(), BuildWebUntisSettingsService());
public static UntisHubService BuildUntisHubService(List<LearningGroup>? groups = null) =>
new(new FakeGroups(groups ?? []), new FakeUntisHubJobStates(), new SchoolYearService());
/// Analog zu den übrigen dateibasierten Feed-Einstellungen: eigenes Temp-Verzeichnis. /// Analog zu den übrigen dateibasierten Feed-Einstellungen: eigenes Temp-Verzeichnis.
public static AnnualPlanSettingsService BuildAnnualPlanSettingsService() public static AnnualPlanSettingsService BuildAnnualPlanSettingsService()
{ {
@@ -525,6 +533,19 @@ public class FakeUntisCacheFetchStates : IUntisCacheFetchStateRepository
} }
} }
public class FakeUntisHubJobStates : IUntisHubJobStateRepository
{
private readonly List<UntisHubJobState> _all = [];
public UntisHubJobState? Get(UntisHubJobKind kind, Guid? groupId) =>
_all.FirstOrDefault(s => s.Kind == kind && s.GroupId == groupId);
public List<UntisHubJobState> GetAll() => _all.ToList();
public void Save(UntisHubJobState state)
{
_all.RemoveAll(s => s.Kind == state.Kind && s.GroupId == state.GroupId);
_all.Add(state);
}
}
public class FakeWorkTasks : IWorkTaskRepository public class FakeWorkTasks : IWorkTaskRepository
{ {
private readonly List<WorkTask> _all = []; private readonly List<WorkTask> _all = [];
@@ -0,0 +1,94 @@
using LehrerApp.Core.Models;
using LehrerApp.Desktop.ViewModels.Groups;
using Xunit;
namespace LehrerApp.Desktop.Tests;
/// Deckt die Kandidatensuche für den "Vorhandene Stunde verknüpfen"-Button im Stunden-anlegen-
/// Dialog ab (Nutzer-Feedback: per JSON-Import/KI ohne Stundennummer angelegte Stunden tauchen im
/// Stundenplan nicht auf, da der dort verwendete FindLessonForSlot per Datum+Stundennummer sucht).
public class LessonFixItSearchTests
{
[Fact]
public void FindCandidates_FindetVerwaisteStundeOhneStundennummerAmSelbenTag()
{
var lessons = new FakeLessons();
var groupId = Guid.NewGuid();
var unitId = Guid.NewGuid();
var date = new DateOnly(2026, 9, 8);
var orphan = new Lesson { UnitId = unitId, GroupId = groupId, Date = date, LessonNumber = null, Topic = "Reflexionsgesetz" };
lessons.Add(orphan);
var candidates = LessonFixItSearch.FindCandidates(lessons, groupId, date);
var found = Assert.Single(candidates);
Assert.Equal(orphan.Id, found.Id);
}
[Fact]
public void FindCandidates_FindetStundeAmSelbenTagMitAbweichenderStundennummer()
{
var lessons = new FakeLessons();
var groupId = Guid.NewGuid();
var unitId = Guid.NewGuid();
var date = new DateOnly(2026, 9, 8);
var wrongPeriod = new Lesson { UnitId = unitId, GroupId = groupId, Date = date, LessonNumber = 7, Topic = "Reflexionsgesetz" };
lessons.Add(wrongPeriod);
var candidates = LessonFixItSearch.FindCandidates(lessons, groupId, date);
var found = Assert.Single(candidates);
Assert.Equal(wrongPeriod.Id, found.Id);
}
[Fact]
public void FindCandidates_SortiertExaktesDatumVorNahenTerminenOhneStundennummer()
{
var lessons = new FakeLessons();
var groupId = Guid.NewGuid();
var unitId = Guid.NewGuid();
var date = new DateOnly(2026, 9, 8);
var near = new Lesson { UnitId = unitId, GroupId = groupId, Date = date.AddDays(-2), LessonNumber = null, Topic = "Nah" };
var exact = new Lesson { UnitId = unitId, GroupId = groupId, Date = date, LessonNumber = null, Topic = "Exakt" };
lessons.Add(near); lessons.Add(exact);
var candidates = LessonFixItSearch.FindCandidates(lessons, groupId, date);
Assert.Equal(2, candidates.Count);
Assert.Equal(exact.Id, candidates[0].Id);
Assert.Equal(near.Id, candidates[1].Id);
}
[Fact]
public void FindCandidates_IgnoriertStundenAusserhalbDesZeitfenstersUndAnderenGruppen()
{
var lessons = new FakeLessons();
var groupId = Guid.NewGuid();
var otherGroupId = Guid.NewGuid();
var unitId = Guid.NewGuid();
var date = new DateOnly(2026, 9, 8);
lessons.Add(new Lesson { UnitId = unitId, GroupId = groupId, Date = date.AddDays(-30), LessonNumber = null, Topic = "Zu weit weg" });
lessons.Add(new Lesson { UnitId = unitId, GroupId = otherGroupId, Date = date, LessonNumber = null, Topic = "Andere Gruppe" });
// Gesetzte Stundennummer, aber exakt am gesuchten Datum — bewusst als Kandidat enthalten
// (z.B. falsch nummerierter Import); der Nutzer entscheidet im Bestätigungs-/
// Auswahldialog, ob sie passt.
lessons.Add(new Lesson { UnitId = unitId, GroupId = groupId, Date = date, LessonNumber = 3, Topic = "Falsch nummeriert, aber am gesuchten Tag" });
var candidates = LessonFixItSearch.FindCandidates(lessons, groupId, date);
var found = Assert.Single(candidates);
Assert.Equal("Falsch nummeriert, aber am gesuchten Tag", found.Topic);
}
[Fact]
public void FindCandidates_LeerOhnePassendeStunde()
{
var lessons = new FakeLessons();
var groupId = Guid.NewGuid();
var date = new DateOnly(2026, 9, 8);
var candidates = LessonFixItSearch.FindCandidates(lessons, groupId, date);
Assert.Empty(candidates);
}
}
@@ -45,6 +45,21 @@ public class PlanningTabViewModelTests
Assert.Contains("2 / 3", summary.ProgressText); Assert.Contains("2 / 3", summary.ProgressText);
} }
[Fact]
public void Initialize_WaehltLaufendeEinheitAlsArbeitskontext()
{
var (vm, units, _, groupId) = BuildScenario();
units.Add(new Unit { GroupId = groupId, Title = "Früher", Status = UnitStatus.Completed,
StartDate = new DateOnly(2025, 8, 1) });
var active = new Unit { GroupId = groupId, Title = "Aktuell", Status = UnitStatus.Active,
StartDate = new DateOnly(2025, 9, 1) };
units.Add(active);
vm.Initialize(groupId);
Assert.Equal(active.Id, vm.SelectedUnit?.Id);
}
[Fact] [Fact]
public async Task MoveLesson_MitNachrueckenVerschiebtNurGeplanteFolgestundenNachDemStichtag() public async Task MoveLesson_MitNachrueckenVerschiebtNurGeplanteFolgestundenNachDemStichtag()
{ {
@@ -93,6 +108,30 @@ public class PlanningTabViewModelTests
Assert.Equal(new DateOnly(2025, 9, 8), byUnit[l2.Id].Date); // unverändert Assert.Equal(new DateOnly(2025, 9, 8), byUnit[l2.Id].Date); // unverändert
} }
[Fact]
public async Task ChangeLessonUnit_OrdnetStundeDerZieleinheitZuUndWaehltSieAus()
{
var (vm, units, lessons, groupId) = BuildScenario();
var unitA = new Unit { GroupId = groupId, Title = "Mechanik" };
var unitB = new Unit { GroupId = groupId, Title = "Optik" };
units.Add(unitA); units.Add(unitB);
var l1 = new Lesson { UnitId = unitA.Id, GroupId = groupId, Date = new DateOnly(2025, 9, 1), Status = LessonStatus.Planned, Topic = "1" };
lessons.Add(l1);
vm.Initialize(groupId);
vm.SelectedUnit = vm.Units.Single(u => u.Id == unitA.Id);
vm.SelectedLesson = vm.Lessons.Single(l => l.Id == l1.Id);
vm.OnPickUnitChange = _ => Task.FromResult<Unit?>(unitB);
await vm.ChangeLessonUnitCommand.ExecuteAsync(null);
Assert.Empty(lessons.GetByUnit(unitA.Id));
var moved = Assert.Single(lessons.GetByUnit(unitB.Id));
Assert.Equal(l1.Id, moved.Id);
Assert.Equal(unitB.Id, vm.SelectedUnit?.Id);
Assert.Equal(l1.Id, vm.SelectedLesson?.Id);
}
[Fact] [Fact]
public async Task CopyUnit_ErhaeltAbstaendeUndSetztGroupIdAufZielgruppe() public async Task CopyUnit_ErhaeltAbstaendeUndSetztGroupIdAufZielgruppe()
{ {
@@ -294,4 +333,44 @@ public class PlanningTabViewModelTests
Assert.Single(sessions.GetByGroup(groupId)); Assert.Single(sessions.GetByGroup(groupId));
Assert.Equal("Für diese Stunde existiert bereits eine Sitzung.", notified); Assert.Equal("Für diese Stunde existiert bereits eine Sitzung.", notified);
} }
[Fact]
public void CreateParticipationSession_AndereStundeAmSelbenTagBereitsVerknuepft_LegtKeineZweiteSitzungAn()
{
// Nutzer-Feedback: eine dritte Stunde am selben Tag (z.B. Vertretung) soll die bereits
// bestehende Sitzung des Tages weiterverwenden statt eine zweite anzulegen.
var groupId = Guid.NewGuid();
var group = new LearningGroup { Id = groupId, Name = "Testgruppe" };
var groups = new FakeGroups([group]);
var units = new FakeUnits();
var lessons = new FakeLessons();
var sessions = new FakeSessions([]);
var vm = new PlanningTabViewModel(units, lessons, groups, new FakeSubjects([]),
new FakeCompetencyDomains(), TestSupport.BuildAiSettingsService(), sessions);
vm.Initialize(groupId);
var unit = new Unit { GroupId = groupId, Title = "Optik" };
units.Add(unit);
var date = new DateOnly(2025, 9, 1);
var doubleLesson = new Lesson
{ UnitId = unit.Id, GroupId = groupId, Date = date, Topic = "Brechung" };
var thirdLesson = new Lesson
{ UnitId = unit.Id, GroupId = groupId, Date = date, Topic = "Vertretung" };
lessons.Add(doubleLesson);
lessons.Add(thirdLesson);
vm.RefreshPlanning(unit.Id, doubleLesson.Id);
vm.SelectedLesson = vm.Lessons.Single(l => l.Id == doubleLesson.Id);
vm.CreateParticipationSessionCommand.Execute(null);
string? notified = null;
vm.OnNotify = m => notified = m;
vm.RefreshPlanning(unit.Id, thirdLesson.Id);
vm.SelectedLesson = vm.Lessons.Single(l => l.Id == thirdLesson.Id);
vm.CreateParticipationSessionCommand.Execute(null);
var created = Assert.Single(sessions.GetByGroup(groupId));
Assert.Equal(doubleLesson.Id, created.LessonId);
Assert.Equal("Für diesen Tag existiert bereits eine Sitzung.", notified);
}
} }
@@ -256,6 +256,30 @@ public sealed class SeatingPlanViewModelTests
Assert.Single(sessions.GetByGroup(groupId)); Assert.Single(sessions.GetByGroup(groupId));
} }
[Fact]
public void SelectOrCreateSessionForLesson_AndereStundeAmSelbenTagBereitsVerknuepft_LegtKeineZweiteSitzungAn()
{
// Nutzer-Feedback: eine dritte Stunde am selben Tag (z.B. durch Vertretung, eigene Lesson-
// Id) soll die bereits bestehende Sitzung der Doppelstunde weiterverwenden statt eine
// zweite, unabhängige Sitzung für denselben Tag anzulegen.
var groupId = Guid.NewGuid();
var today = DateOnly.FromDateTime(DateTime.Today);
var doubleLesson = new Lesson { GroupId = groupId, Date = today, Topic = "Redox" };
var thirdLesson = new Lesson { GroupId = groupId, Date = today, Topic = "Vertretung" };
var existingSession = new ParticipationSession
{ GroupId = groupId, Date = today, LessonId = doubleLesson.Id, Comment = "Redox" };
var sessions = new FakeSessions([existingSession]);
var plan = new SeatingPlan { GroupId = groupId, Name = "Standard", Rows = 1, Columns = 1 };
var vm = new SeatingPlanTabViewModel(new FakeSeatingPlans([plan]), new FakeStudents([]),
new FakeMemberships([]), sessions, new FakeEntries(), new FakeAspects());
vm.Initialize(groupId, isReadOnly: false);
vm.SelectOrCreateSessionForLesson(thirdLesson);
Assert.Equal(existingSession.Id, vm.SelectedSession?.Id);
Assert.Single(sessions.GetByGroup(groupId));
}
[Fact] [Fact]
public void SelectOrCreateSessionForLesson_KeineSitzungVorhanden_LegtVerknuepfteAnUndWaehltSieAus() public void SelectOrCreateSessionForLesson_KeineSitzungVorhanden_LegtVerknuepfteAnUndWaehltSieAus()
{ {
@@ -219,6 +219,25 @@ public sealed class SettingsViewModelTests
Assert.Empty(queue.GetUnreviewed()); Assert.Empty(queue.GetUnreviewed());
} }
[Fact]
public void ClearSyncConflicts_LeertProtokollUndQueue()
{
var queue = TestSupport.BuildEventQueue();
foreach (var resolution in new[] { "LocalWon", "RemoteWon" })
queue.AddConflict(new ConflictEntry
{
LocalEvent = new SyncEvent { EntityType = "Student", EntityId = Guid.NewGuid().ToString() },
RemoteEvent = new SyncEvent { EntityType = "Student", EntityId = Guid.NewGuid().ToString() },
Resolution = resolution,
});
var vm = BuildViewModel(eventQueue: queue);
vm.ClearSyncConflictsCommand.Execute(null);
Assert.Empty(vm.SyncConflicts);
Assert.Empty(queue.GetUnreviewed());
}
[Fact] [Fact]
public void AddSchoolHoliday_GueltigeEingabe_WirdGespeichertUndInListeAngezeigt() public void AddSchoolHoliday_GueltigeEingabe_WirdGespeichertUndInListeAngezeigt()
{ {
@@ -25,4 +25,28 @@ public sealed class UnitDialogViewModelTests
Assert.Equal("10c · kein Fach hinterlegt (siehe Lerngruppe)", vm.GroupSubjectDisplay); Assert.Equal("10c · kein Fach hinterlegt (siehe Lerngruppe)", vm.GroupSubjectDisplay);
} }
[Fact]
public void Laufend_SetztBisherigeLaufendeEinheitAufAbgeschlossenUndWarntVorher()
{
var groupId = Guid.NewGuid();
var units = new FakeUnits();
var previous = new LehrerApp.Core.Models.Unit
{
GroupId = groupId, Title = "Bruchrechnung", Status = LehrerApp.Core.Models.UnitStatus.Active,
};
units.Add(previous);
var vm = new UnitDialogViewModel(units, new FakeCompetencyDomains(),
groupId, null, 6, "6a", "Mathematik", editingUnit: null)
{
Title = "Prozentrechnung",
StatusName = "Laufend",
};
Assert.Contains("Bruchrechnung", vm.StatusNotice);
vm.SaveCommand.Execute(null);
Assert.Equal(LehrerApp.Core.Models.UnitStatus.Completed, previous.Status);
Assert.Single(units.GetByGroup(groupId), u => u.Status == LehrerApp.Core.Models.UnitStatus.Active);
}
} }
@@ -0,0 +1,111 @@
using LehrerApp.Core.Models;
using LehrerApp.Desktop.Services;
using Xunit;
namespace LehrerApp.Desktop.Tests;
public sealed class UntisHubServiceTests
{
private static readonly DateTime UtcNow = new(2026, 9, 10, 8, 0, 0, DateTimeKind.Utc);
private static LearningGroup Group(string name, int? webUntisLessonId, bool isActive = true) =>
new() { Name = name, WebUntisLessonId = webUntisLessonId, IsActive = isActive };
[Fact]
public void BuildRows_ProGruppeMitLessonId_ZweiFehlzeitenzeilenPlusDreiGlobaleZeilen()
{
var group = Group("9a", 42);
var rows = UntisHubService.BuildRows([group], [], UtcNow);
Assert.Equal(5, rows.Count);
Assert.Equal(2, rows.Count(r => r.GroupId == group.Id));
Assert.Contains(rows, r => r.Kind == UntisHubJobKind.FehlzeitenKurz && r.GroupId == group.Id);
Assert.Contains(rows, r => r.Kind == UntisHubJobKind.FehlzeitenLang && r.GroupId == group.Id);
Assert.Contains(rows, r => r.Kind == UntisHubJobKind.OffenePeriods && r.GroupId == null);
Assert.Contains(rows, r => r.Kind == UntisHubJobKind.Klassenbuchabgleich && r.GroupId == null);
Assert.Contains(rows, r => r.Kind == UntisHubJobKind.Hausaufgabenabgleich && r.GroupId == null);
}
[Fact]
public void BuildRows_NieGeprueft_GiltAlsUeberfaellig()
{
var rows = UntisHubService.BuildRows([], [], UtcNow);
Assert.All(rows, r => Assert.Equal(UntisHubDueState.Overdue, r.DueState));
}
[Fact]
public void BuildRows_FehlzeitenKurzVorSechsTagen_GiltAlsOk()
{
var group = Group("9a", 42);
var state = new UntisHubJobState
{
Kind = UntisHubJobKind.FehlzeitenKurz, GroupId = group.Id, LastRunAt = UtcNow.AddDays(-6),
};
var rows = UntisHubService.BuildRows([group], [state], UtcNow);
var row = rows.Single(r => r.Kind == UntisHubJobKind.FehlzeitenKurz);
Assert.Equal(UntisHubDueState.Ok, row.DueState);
}
[Fact]
public void BuildRows_FehlzeitenKurzVorSechzehnTagen_GiltAlsFaellig()
{
var group = Group("9a", 42);
var state = new UntisHubJobState
{
Kind = UntisHubJobKind.FehlzeitenKurz, GroupId = group.Id, LastRunAt = UtcNow.AddDays(-16),
};
var rows = UntisHubService.BuildRows([group], [state], UtcNow);
var row = rows.Single(r => r.Kind == UntisHubJobKind.FehlzeitenKurz);
Assert.Equal(UntisHubDueState.Due, row.DueState);
}
[Fact]
public void BuildRows_FehlzeitenKurzVorZweiundzwanzigTagen_GiltAlsUeberfaellig()
{
var group = Group("9a", 42);
var state = new UntisHubJobState
{
Kind = UntisHubJobKind.FehlzeitenKurz, GroupId = group.Id, LastRunAt = UtcNow.AddDays(-22),
};
var rows = UntisHubService.BuildRows([group], [state], UtcNow);
var row = rows.Single(r => r.Kind == UntisHubJobKind.FehlzeitenKurz);
Assert.Equal(UntisHubDueState.Overdue, row.DueState);
}
[Fact]
public void BuildRows_FehlzeitenLangHatDeutlichLaengereKadenzAlsKurz()
{
var group = Group("9a", 42);
var lastRunAt = UtcNow.AddDays(-40);
var states = new List<UntisHubJobState>
{
new() { Kind = UntisHubJobKind.FehlzeitenKurz, GroupId = group.Id, LastRunAt = lastRunAt },
new() { Kind = UntisHubJobKind.FehlzeitenLang, GroupId = group.Id, LastRunAt = lastRunAt },
};
var rows = UntisHubService.BuildRows([group], states, UtcNow);
Assert.Equal(UntisHubDueState.Overdue, rows.Single(r => r.Kind == UntisHubJobKind.FehlzeitenKurz).DueState);
Assert.Equal(UntisHubDueState.Ok, rows.Single(r => r.Kind == UntisHubJobKind.FehlzeitenLang).DueState);
}
[Fact]
public void BuildRows_GruppeOhneWebUntisLessonId_WirdVonAufrufseiteAusgeschlossen()
{
// GetRows() filtert vorab auf WebUntisLessonId != null (siehe UntisHubService.GetRows) -
// BuildRows selbst bekommt bereits nur die eligible-Gruppen übergeben.
var eligible = new List<LearningGroup> { Group("9a", 42) };
var rows = UntisHubService.BuildRows(eligible, [], UtcNow);
Assert.Equal(2, rows.Count(r => r.GroupId != null));
}
}
@@ -61,7 +61,7 @@ public sealed class UntisSyncServiceTests
} }
[Fact] [Fact]
public void ProcessIcsText_AbweichendesFach_SchreibtVertretungUndAktualisiertBeiWiederholung() public void ProcessIcsText_AbweichendesFach_SchreibtVertretungUndUeberspringtUnveraenderteWiederholung()
{ {
var mappings = new FakeUntisSlotMappings(); var mappings = new FakeUntisSlotMappings();
mappings.Add(new UntisSlotMapping mappings.Add(new UntisSlotMapping
@@ -81,7 +81,7 @@ public sealed class UntisSyncServiceTests
// Eintrag erzeugen - Idempotenz über SubstitutionEntry.ExternalId. // Eintrag erzeugen - Idempotenz über SubstitutionEntry.ExternalId.
var second = service.ProcessIcsText(BuildIcs("1", "20260817T075000", "NAT", "10c HED")); var second = service.ProcessIcsText(BuildIcs("1", "20260817T075000", "NAT", "10c HED"));
Assert.Equal(1, second.SubstitutionCount); Assert.Equal(0, second.SubstitutionCount);
Assert.Single(substitutions.GetAll()); Assert.Single(substitutions.GetAll());
} }
@@ -278,7 +278,7 @@ public sealed class UntisSyncServiceTests
// Erneuter Poll mit demselben, weiterhin unverändert vorhandenen Termin darf keinen // Erneuter Poll mit demselben, weiterhin unverändert vorhandenen Termin darf keinen
// zweiten Eintrag erzeugen (Idempotenz über ExternalId=Uid). // zweiten Eintrag erzeugen (Idempotenz über ExternalId=Uid).
var second = service.ProcessIcsText(BuildIcs("v1", dtstart, null, "HED")); var second = service.ProcessIcsText(BuildIcs("v1", dtstart, null, "HED"));
Assert.Equal(1, second.SubstitutionCount); Assert.Equal(0, second.SubstitutionCount);
Assert.Single(substitutions.GetAll()); Assert.Single(substitutions.GetAll());
} }
@@ -572,6 +572,51 @@ public sealed class TimeTrackingViewModelTests
Assert.False(called); Assert.False(called);
} }
[Fact]
public void MissingTeachingTime_VergangenerUnterrichtstag_WirdInZeiterfassungAngezeigt()
{
var pastDay = DateOnly.FromDateTime(DateTime.Today).AddDays(-1);
var slots = new FakeTimetableSlots();
slots.Add(new TimetableSlot { Weekday = pastDay.DayOfWeek, PeriodNumber = 1 });
var periodSchedule = TestSupport.BuildPeriodScheduleService();
periodSchedule.SetPeriods([new PeriodTimeEntry
{ PeriodNumber = 1, Start = new TimeOnly(8, 0), End = new TimeOnly(8, 45) }]);
var vm = new TimeTrackingViewModel(
new FakeTimeEntries(), new FakeWorkTasks(), slots, periodSchedule);
var gap = Assert.Single(vm.MissingTeachingTimeEntries, i => i.Date == pastDay);
Assert.Equal(new TimeOnly(7, 45), gap.WindowStart);
Assert.Equal(new TimeOnly(8, 55), gap.WindowEnd);
}
[Fact]
public async Task AddMissingTeachingTime_SpeichertVorgefuelltenTagUndEntferntIhnAusOffenerListe()
{
var pastDay = DateOnly.FromDateTime(DateTime.Today).AddDays(-1);
var slots = new FakeTimetableSlots();
slots.Add(new TimetableSlot { Weekday = pastDay.DayOfWeek, PeriodNumber = 1 });
var periodSchedule = TestSupport.BuildPeriodScheduleService();
periodSchedule.SetPeriods([new PeriodTimeEntry
{ PeriodNumber = 1, Start = new TimeOnly(8, 0), End = new TimeOnly(8, 45) }]);
var entries = new FakeTimeEntries();
var vm = new TimeTrackingViewModel(entries, new FakeWorkTasks(), slots, periodSchedule);
var gap = Assert.Single(vm.MissingTeachingTimeEntries, i => i.Date == pastDay);
vm.OnAddMissingTeachingTime = item => Task.FromResult<TimeEntry?>(new TimeEntry
{
Date = item.Date,
Category = TaskCategoryDisplay.Label(TaskCategory.Teaching),
StartTime = item.WindowStart,
EndTime = item.WindowEnd,
DurationMinutes = (int)(item.WindowEnd - item.WindowStart).TotalMinutes,
});
await vm.AddMissingTeachingTimeCommand.ExecuteAsync(gap);
Assert.Single(entries.GetByDate(pastDay));
Assert.DoesNotContain(vm.MissingTeachingTimeEntries, i => i.Date == pastDay);
}
} }
public sealed class AddTimeEntryDialogViewModelTests public sealed class AddTimeEntryDialogViewModelTests
+2
View File
@@ -16,6 +16,8 @@
<StreamGeometry x:Key="IconCalendar">M3,4 H21 V22 H3 Z M5,10 V20 H19 V10 Z M7,2 H9 V7 H7 Z M15,2 H17 V7 H15 Z M7,12 H10 V15 H7 Z M12,12 H15 V15 H12 Z M7,17 H10 V19 H7 Z M12,17 H15 V19 H12 Z</StreamGeometry> <StreamGeometry x:Key="IconCalendar">M3,4 H21 V22 H3 Z M5,10 V20 H19 V10 Z M7,2 H9 V7 H7 Z M15,2 H17 V7 H15 Z M7,12 H10 V15 H7 Z M12,12 H15 V15 H12 Z M7,17 H10 V19 H7 Z M12,17 H15 V19 H12 Z</StreamGeometry>
<StreamGeometry x:Key="IconClock">M12,2 A10,10 0 1 0 12,22 A10,10 0 1 0 12,2 M12,5 A7,7 0 1 1 12,19 A7,7 0 1 1 12,5 M11,7 H13 V12 L17,14 L16,16 L11,13 Z</StreamGeometry> <StreamGeometry x:Key="IconClock">M12,2 A10,10 0 1 0 12,22 A10,10 0 1 0 12,2 M12,5 A7,7 0 1 1 12,19 A7,7 0 1 1 12,5 M11,7 H13 V12 L17,14 L16,16 L11,13 Z</StreamGeometry>
<StreamGeometry x:Key="IconClassTeacher">M2,8 L12,3 L22,8 L12,13 Z M6,11 V16 C9,19 15,19 18,16 V11 M22,8 V15</StreamGeometry> <StreamGeometry x:Key="IconClassTeacher">M2,8 L12,3 L22,8 L12,13 Z M6,11 V16 C9,19 15,19 18,16 V11 M22,8 V15</StreamGeometry>
<!-- Klassenbuch mit Ausrufezeichen für noch offene Einträge. -->
<StreamGeometry x:Key="IconOpenPeriods">M5,2 H19 A2,2 0 0 1 21,4 V20 A2,2 0 0 1 19,22 H5 A2,2 0 0 1 3,20 V4 A2,2 0 0 1 5,2 Z M7,4 V20 H19 V4 Z M11,7 H14 V13 H11 Z M11,15 H14 V18 H11 Z</StreamGeometry>
<StreamGeometry x:Key="IconSettings">M3,5 H21 V7 H3 Z M8,3 A3,3 0 1 0 8,9 A3,3 0 1 0 8,3 M3,11 H21 V13 H3 Z M16,9 A3,3 0 1 0 16,15 A3,3 0 1 0 16,9 M3,17 H21 V19 H3 Z M10,15 A3,3 0 1 0 10,21 A3,3 0 1 0 10,15</StreamGeometry> <StreamGeometry x:Key="IconSettings">M3,5 H21 V7 H3 Z M8,3 A3,3 0 1 0 8,9 A3,3 0 1 0 8,3 M3,11 H21 V13 H3 Z M16,9 A3,3 0 1 0 16,15 A3,3 0 1 0 16,9 M3,17 H21 V19 H3 Z M10,15 A3,3 0 1 0 10,21 A3,3 0 1 0 10,15</StreamGeometry>
<StreamGeometry x:Key="IconRefresh">M12,3 A9,9 0 0 1 21,12 H18 A6,6 0 0 0 8,7 L11,10 H3 V2 L6,5 A9,9 0 0 1 12,3 M12,21 A9,9 0 0 1 3,12 H6 A6,6 0 0 0 16,17 L13,14 H21 V22 L18,19 A9,9 0 0 1 12,21</StreamGeometry> <StreamGeometry x:Key="IconRefresh">M12,3 A9,9 0 0 1 21,12 H18 A6,6 0 0 0 8,7 L11,10 H3 V2 L6,5 A9,9 0 0 1 12,3 M12,21 A9,9 0 0 1 3,12 H6 A6,6 0 0 0 16,17 L13,14 H21 V22 L18,19 A9,9 0 0 1 12,21</StreamGeometry>
</ResourceDictionary> </ResourceDictionary>
+46 -28
View File
@@ -1,7 +1,9 @@
using Avalonia; using Avalonia;
using Avalonia.Controls;
using Avalonia.Controls.ApplicationLifetimes; using Avalonia.Controls.ApplicationLifetimes;
using Avalonia.Markup.Xaml; using Avalonia.Markup.Xaml;
using Avalonia.Styling; using Avalonia.Styling;
using Avalonia.Threading;
using LehrerApp.Core.Interfaces; using LehrerApp.Core.Interfaces;
using LehrerApp.Core.Models; using LehrerApp.Core.Models;
using LehrerApp.Core.Services; using LehrerApp.Core.Services;
@@ -38,32 +40,44 @@ public class App : Application
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop) if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
{ {
// Verschlüsselte Datenbank (13.3.4): Passwort abfragen, bevor die Datenbank // Erst den Splashscreen anzeigen und die synchrone Initialisierung in den ersten
// (und damit BuildServices, das sie öffnet) angefasst wird. // Dispatcher-Durchlauf verschieben, damit das Ladebild vorher gezeichnet wird.
if (AppBootstrapper.IsDbEncrypted()) var splash = new SplashWindow();
{ desktop.MainWindow = splash;
var promptVm = new DbPasswordPromptViewModel( Dispatcher.UIThread.Post(
new DatabaseEncryptionService(), AppBootstrapper.ResolveDbPath()); () => ContinueStartup(desktop, splash),
var promptWindow = new DbPasswordPromptWindow { DataContext = promptVm }; DispatcherPriority.Background);
promptVm.OnUnlocked = password =>
{
AppBootstrapper.DbPassword = password;
StartMainApp(desktop, showImmediately: true);
promptWindow.Close();
};
desktop.MainWindow = promptWindow;
}
else
{
StartMainApp(desktop, showImmediately: false);
}
} }
base.OnFrameworkInitializationCompleted(); base.OnFrameworkInitializationCompleted();
} }
private static void StartMainApp( private static async void ContinueStartup(
IClassicDesktopStyleApplicationLifetime desktop, bool showImmediately) IClassicDesktopStyleApplicationLifetime desktop, SplashWindow splash)
{
// Verschlüsselte Datenbank (13.3.4): Passwort abfragen, bevor die Datenbank
// (und damit BuildServices, das sie öffnet) angefasst wird.
if (AppBootstrapper.IsDbEncrypted())
{
var promptVm = new DbPasswordPromptViewModel(
new DatabaseEncryptionService(), AppBootstrapper.ResolveDbPath());
var promptWindow = new DbPasswordPromptWindow { DataContext = promptVm };
promptVm.OnUnlocked = async password =>
{
AppBootstrapper.DbPassword = password;
await StartMainAppAsync(desktop, promptWindow);
};
desktop.MainWindow = promptWindow;
promptWindow.Show();
splash.Close();
return;
}
await StartMainAppAsync(desktop, splash);
}
private static async Task StartMainAppAsync(
IClassicDesktopStyleApplicationLifetime desktop, Window? windowToClose = null)
{ {
_serviceProvider = AppBootstrapper.BuildServices(); _serviceProvider = AppBootstrapper.BuildServices();
Services = _serviceProvider; Services = _serviceProvider;
@@ -83,20 +97,24 @@ public class App : Application
var mainVm = Services.GetRequiredService<MainWindowViewModel>(); var mainVm = Services.GetRequiredService<MainWindowViewModel>();
WireCallbacks(mainVm); WireCallbacks(mainVm);
// DI-Singletons werden erst beim ersten Auflösen erzeugt. Ohne dieses explizite // Beide optionalen Erstabgleiche noch unter dem Splashscreen abschließen. Ihre
// Auflösen entstand der Timer des optionalen WebUntis-Abgleichs erst, wenn die // CPU-/Datenbankarbeit läuft innerhalb der Dienste im Threadpool; dadurch öffnet das
// Einstellungen geöffnet oder ein manueller Abruf gestartet wurde. Den Dienst beim // Hauptfenster mit fertigem Datenstand und friert nicht kurz danach ein. Das Auflösen
// Anwendungsstart aktivieren und wie den Jahresplan sofort einmal abgleichen; danach // aktiviert zugleich die periodischen Timer.
// übernimmt sein stündlicher Timer. var initialPolls = new List<Task>();
if (Services.GetService<UntisSyncService>() is { } untisSync) if (Services.GetService<UntisSyncService>() is { } untisSync)
_ = untisSync.PollAsync(); initialPolls.Add(untisSync.PollAsync());
if (Services.GetService<AnnualPlanSyncService>() is { } annualPlanSync)
initialPolls.Add(annualPlanSync.PollAsync());
await Task.WhenAll(initialPolls);
var main = new MainWindow { DataContext = mainVm }; var main = new MainWindow { DataContext = mainVm };
main.EnableWindowSizePersistence(Services.GetRequiredService<WindowSettingsService>()); main.EnableWindowSizePersistence(Services.GetRequiredService<WindowSettingsService>());
if (Services.GetService<SyncEngine>() is { } syncEngine) if (Services.GetService<SyncEngine>() is { } syncEngine)
main.EnableFinalSync(syncEngine); main.EnableFinalSync(syncEngine);
desktop.MainWindow = main; desktop.MainWindow = main;
if (showImmediately) main.Show(); main.Show();
windowToClose?.Close();
} }
/// <summary>Wechselt die Darstellung sofort, ohne Neustart (12.4) — Avalonia stylt den /// <summary>Wechselt die Darstellung sofort, ohne Neustart (12.4) — Avalonia stylt den
+12 -1
View File
@@ -162,6 +162,7 @@ public static class AppBootstrapper
services.AddSingleton<IUnitRepository, UnitRepository>(); services.AddSingleton<IUnitRepository, UnitRepository>();
services.AddSingleton<ILessonRepository, LessonRepository>(); services.AddSingleton<ILessonRepository, LessonRepository>();
services.AddSingleton<IDocumentationRepository, DocumentationRepository>(); services.AddSingleton<IDocumentationRepository, DocumentationRepository>();
services.AddSingleton<IVorgangRepository, VorgangRepository>();
services.AddSingleton<IWorkTaskRepository, WorkTaskRepository>(); services.AddSingleton<IWorkTaskRepository, WorkTaskRepository>();
services.AddSingleton<ITimeEntryRepository, TimeEntryRepository>(); services.AddSingleton<ITimeEntryRepository, TimeEntryRepository>();
services.AddSingleton<IParticipationSessionRepository, ParticipationSessionRepository>(); services.AddSingleton<IParticipationSessionRepository, ParticipationSessionRepository>();
@@ -184,6 +185,7 @@ public static class AppBootstrapper
services.AddSingleton<IUntisClassRegisterCacheRepository, UntisClassRegisterCacheRepository>(); services.AddSingleton<IUntisClassRegisterCacheRepository, UntisClassRegisterCacheRepository>();
services.AddSingleton<IUntisCacheFetchStateRepository, UntisCacheFetchStateRepository>(); services.AddSingleton<IUntisCacheFetchStateRepository, UntisCacheFetchStateRepository>();
services.AddSingleton<IUntisStudentRosterCacheRepository, UntisStudentRosterCacheRepository>(); services.AddSingleton<IUntisStudentRosterCacheRepository, UntisStudentRosterCacheRepository>();
services.AddSingleton<IUntisHubJobStateRepository, UntisHubJobStateRepository>();
// ── Services ────────────────────────────────────────────────────────── // ── Services ──────────────────────────────────────────────────────────
services.AddSingleton<GradingService>(); services.AddSingleton<GradingService>();
@@ -195,13 +197,20 @@ public static class AppBootstrapper
services.AddSingleton(_ => new PeriodScheduleService(appData)); services.AddSingleton(_ => new PeriodScheduleService(appData));
services.AddSingleton(_ => new WorkloadSettingsService(appData)); services.AddSingleton(_ => new WorkloadSettingsService(appData));
services.AddSingleton(_ => new DashboardSettingsService(appData)); services.AddSingleton(_ => new DashboardSettingsService(appData));
services.AddSingleton(_ => new PatternScoreSettingsService(appData));
services.AddSingleton(_ => new WindowSettingsService(appData)); services.AddSingleton(_ => new WindowSettingsService(appData));
services.AddSingleton(_ => new AppearanceSettingsService(appData)); services.AddSingleton(_ => new AppearanceSettingsService(appData));
services.AddSingleton<PlanningExchangeService>(); services.AddSingleton<PlanningExchangeService>();
// ── KI-Unterstützung (4.5.9, optional nur wenn in den Einstellungen aktiviert) ────── // ── KI-Unterstützung (4.5.9, optional nur wenn in den Einstellungen aktiviert) ──────
services.AddSingleton(_ => new AiSettingsService(appData)); services.AddSingleton(_ => new AiSettingsService(appData));
services.AddSingleton(_ => new HttpClient { BaseAddress = new Uri(AiBackendUrl) }); services.AddSingleton(_ => new HttpClient
{
BaseAddress = new Uri(AiBackendUrl),
// Das PHP-Backend wartet höchstens 180 s auf umfangreiche KI-Antworten. Der Client
// bleibt bewusst etwas länger offen, damit dessen konkrete Fehlermeldung noch ankommt.
Timeout = TimeSpan.FromSeconds(210),
});
services.AddSingleton<AiPlanningService>(); services.AddSingleton<AiPlanningService>();
// ── WebUntis-iCal-Abgleich (optional nur wenn URL hinterlegt und aktiviert) ───────── // ── WebUntis-iCal-Abgleich (optional nur wenn URL hinterlegt und aktiviert) ─────────
@@ -240,6 +249,7 @@ public static class AppBootstrapper
services.AddSingleton(sp => new SchoolWeatherService(new HttpClient(), syncSettings)); services.AddSingleton(sp => new SchoolWeatherService(new HttpClient(), syncSettings));
services.AddSingleton(sp => new WebUntisIntegrationService(new HttpClient(), untisSettings)); services.AddSingleton(sp => new WebUntisIntegrationService(new HttpClient(), untisSettings));
services.AddSingleton<UntisReportCacheService>(); services.AddSingleton<UntisReportCacheService>();
services.AddSingleton<UntisHubService>();
// War dieses Gerät schon eingeloggt, aber sync.key fehlt(e), wurde gerade eben (unten) // 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 // 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. // Schlüssel synchronisierte Server-Daten sind für dieses Gerät dann nicht mehr lesbar.
@@ -315,6 +325,7 @@ public static class AppBootstrapper
services.AddSingleton<WorkloadEvaluationViewModel>(); services.AddSingleton<WorkloadEvaluationViewModel>();
services.AddSingleton<WorkloadViewModel>(); services.AddSingleton<WorkloadViewModel>();
services.AddSingleton<ClassTeacherDetailsViewModel>(); services.AddSingleton<ClassTeacherDetailsViewModel>();
services.AddSingleton<ClassTeacherCasesViewModel>();
services.AddSingleton<ClassTeacherOverviewViewModel>(); services.AddSingleton<ClassTeacherOverviewViewModel>();
services.AddSingleton<ExamsOverviewViewModel>(); services.AddSingleton<ExamsOverviewViewModel>();
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 MiB

@@ -0,0 +1,22 @@
using System;
using System.Globalization;
using Avalonia.Data.Converters;
namespace LehrerApp.Desktop.Converters;
/// <summary>`CalendarDatePicker.SelectedDate` ist (anders als der eingebaute `DatePicker`) vom
/// WPF-Toolkit übernommenes `DateTime?`, während Datumsfelder in dieser Codebasis durchgängig
/// `DateTimeOffset`(?) sind. Avalonia bietet dafür keine automatische Konvertierung
/// (`TypeUtilities.TryConvert`/`TryConvertImplicit` unterstützen `DateTime`↔`DateTimeOffset`
/// nicht) — ohne diesen Converter bleibt die Bindung beim Anzeigen stumm leer und wirft beim
/// Zurückschreiben eine `InvalidCastException`.</summary>
public sealed class DateTimeOffsetToDateTimeConverter : IValueConverter
{
public static readonly DateTimeOffsetToDateTimeConverter Instance = new();
public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture) =>
value is DateTimeOffset offset ? offset.DateTime : null;
public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) =>
value is DateTime dateTime ? new DateTimeOffset(DateTime.SpecifyKind(dateTime, DateTimeKind.Local)) : null;
}
@@ -10,7 +10,14 @@ using System.Text.Json.Serialization;
namespace LehrerApp.Desktop.Services; namespace LehrerApp.Desktop.Services;
/// <summary>Fehler beim Aufruf des KI-Backends, Message ist bereits deutsch und nutzergerichtet.</summary> /// <summary>Fehler beim Aufruf des KI-Backends, Message ist bereits deutsch und nutzergerichtet.</summary>
public class AiBackendException(string userMessage) : Exception(userMessage); public class AiBackendException(string userMessage, string? rawResponse = null) : Exception(userMessage)
{
/// <summary>
/// Unveränderte Modellantwort, sofern der Fehler beim Lesen einer Planungsantwort entstand.
/// Sie wird ausschließlich für den ausdrücklich vom Nutzer gestarteten Rettungsdialog gehalten.
/// </summary>
public string? RawResponse { get; } = rawResponse;
}
/// <summary> /// <summary>
/// Der KI-Wire-Vertrag verwendet deutsches Datumsformat (siehe ai-backend/plan.php Systemprompt, /// Der KI-Wire-Vertrag verwendet deutsches Datumsformat (siehe ai-backend/plan.php Systemprompt,
@@ -87,16 +94,19 @@ public class AiPlanningService(HttpClient http, ILessonRepository lessons,
private static async Task<AiBackendException> BuildRequestFailedExceptionAsync(HttpResponseMessage resp) private static async Task<AiBackendException> BuildRequestFailedExceptionAsync(HttpResponseMessage resp)
{ {
string? backendReason = null; string? backendReason = null;
string? rawResponse = null;
try try
{ {
var body = await resp.Content.ReadFromJsonAsync<BackendErrorResult>(JsonOptions); var responseText = await resp.Content.ReadAsStringAsync();
var body = JsonSerializer.Deserialize<BackendErrorResult>(responseText, JsonOptions);
backendReason = string.IsNullOrWhiteSpace(body?.Error) ? null : body!.Error; backendReason = string.IsNullOrWhiteSpace(body?.Error) ? null : body!.Error;
rawResponse = string.IsNullOrWhiteSpace(body?.RawResponse) ? null : body!.RawResponse;
} }
catch { /* Antwortkörper war kein valides {"error": "..."}-JSON - Fallback unten greift. */ } catch { /* Antwortkörper war kein valides {"error": "..."}-JSON - Fallback unten greift. */ }
return new AiBackendException(backendReason is null return new AiBackendException(backendReason is null
? "Die Anfrage an den KI-Dienst ist fehlgeschlagen." ? "Die Anfrage an den KI-Dienst ist fehlgeschlagen."
: $"Die Anfrage an den KI-Dienst ist fehlgeschlagen: {backendReason}"); : $"Die Anfrage an den KI-Dienst ist fehlgeschlagen: {backendReason}", rawResponse);
} }
public async Task<string> LoginAsync(string username, string password) public async Task<string> LoginAsync(string username, string password)
@@ -340,6 +350,12 @@ public class AiPlanningService(HttpClient http, ILessonRepository lessons,
HttpResponseMessage resp; HttpResponseMessage resp;
try { resp = await http.SendAsync(req); } try { resp = await http.SendAsync(req); }
catch (TaskCanceledException)
{
throw new AiBackendException(
"Die KI-Anfrage hat länger als 3½ Minuten gedauert und wurde beendet. " +
"Falls das wiederholt passiert, bitte das Zeitlimit des Webserver-Proxys prüfen.");
}
catch (HttpRequestException) catch (HttpRequestException)
{ {
throw new AiBackendException("Der KI-Dienst ist nicht erreichbar. Bitte Internetverbindung prüfen."); throw new AiBackendException("Der KI-Dienst ist nicht erreichbar. Bitte Internetverbindung prüfen.");
@@ -352,14 +368,64 @@ public class AiPlanningService(HttpClient http, ILessonRepository lessons,
if (!resp.IsSuccessStatusCode) if (!resp.IsSuccessStatusCode)
throw await BuildRequestFailedExceptionAsync(resp); throw await BuildRequestFailedExceptionAsync(resp);
var responseText = await resp.Content.ReadAsStringAsync();
try try
{ {
var result = await resp.Content.ReadFromJsonAsync<AiPlanningResponse>(JsonOptions); var result = JsonSerializer.Deserialize<AiPlanningResponse>(responseText, JsonOptions);
return result ?? throw new AiBackendException("Die Antwort der KI konnte nicht verarbeitet werden."); return result ?? throw new AiBackendException("Die Antwort der KI konnte nicht verarbeitet werden.");
} }
catch (Exception ex) when (ex is not AiBackendException) catch (Exception ex) when (ex is not AiBackendException)
{ {
throw new AiBackendException("Die Antwort der KI konnte nicht verarbeitet werden. Bitte erneut versuchen."); string? rawModelResponse = null;
try
{
using var envelope = JsonDocument.Parse(responseText);
if (envelope.RootElement.TryGetProperty("rawResponse", out var rawElement)
&& rawElement.ValueKind == JsonValueKind.String)
rawModelResponse = rawElement.GetString();
}
catch (JsonException) { /* Die HTTP-Antwort selbst war ungültig; unten komplett zeigen. */ }
throw new AiBackendException(
"Die Antwort der KI konnte nicht verarbeitet werden. Du kannst die Antwort manuell retten.",
string.IsNullOrWhiteSpace(rawModelResponse) ? responseText : rawModelResponse);
}
}
/// <summary>
/// Liest eine von Hand markierte Modellantwort. Akzeptiert die normale Antwort-Hülle, ein
/// einzelnes Lesson-Objekt oder ein Array von Lessons, damit auch nur der relevante Ausschnitt
/// der Rohantwort markiert werden kann.
/// </summary>
public static List<AiLesson> ParsePlanningLessons(string json)
{
if (string.IsNullOrWhiteSpace(json))
throw new AiBackendException("Bitte markiere zuerst den JSON-Abschnitt der Antwort.");
try
{
using var document = JsonDocument.Parse(json);
var root = document.RootElement;
List<AiLesson>? parsed = root.ValueKind switch
{
JsonValueKind.Object when root.TryGetProperty("lessons", out _) =>
JsonSerializer.Deserialize<AiPlanningResponse>(json, JsonOptions)?.Lessons,
JsonValueKind.Object =>
[JsonSerializer.Deserialize<AiLesson>(json, JsonOptions)
?? throw new JsonException("Leere Stunde")],
JsonValueKind.Array => JsonSerializer.Deserialize<List<AiLesson>>(json, JsonOptions),
_ => null,
};
if (parsed is null || parsed.Count == 0)
throw new JsonException("Keine Stunde enthalten");
if (parsed.Any(l => string.IsNullOrWhiteSpace(l.Topic)))
throw new JsonException("Mindestens einer Stunde fehlt das Feld topic");
return parsed;
}
catch (JsonException ex)
{
throw new AiBackendException($"Der markierte Text ist noch kein gültiges Stunden-JSON: {ex.Message}");
} }
} }
@@ -541,5 +607,9 @@ public class AiPlanningService(HttpClient http, ILessonRepository lessons,
private class LoginResult { public string Token { get; set; } = ""; } private class LoginResult { public string Token { get; set; } = ""; }
private class BalanceResult { public decimal BalanceUsd { get; set; } } private class BalanceResult { public decimal BalanceUsd { get; set; } }
private class BackendErrorResult { public string? Error { get; set; } } private class BackendErrorResult
{
public string? Error { get; set; }
public string? RawResponse { get; set; }
}
} }
@@ -38,7 +38,7 @@ public sealed class AnnualPlanSyncService : IDisposable
public async Task PollAsync() public async Task PollAsync()
{ {
if (!await _gate.WaitAsync(0)) return; if (!await _gate.WaitAsync(0).ConfigureAwait(false)) return;
try try
{ {
var url = _settings.GetIcalUrl(); var url = _settings.GetIcalUrl();
@@ -47,7 +47,7 @@ public sealed class AnnualPlanSyncService : IDisposable
string icsText; string icsText;
try try
{ {
icsText = await _http.GetStringAsync(url); icsText = await _http.GetStringAsync(url).ConfigureAwait(false);
} }
catch (Exception ex) catch (Exception ex)
{ {
@@ -59,7 +59,7 @@ public sealed class AnnualPlanSyncService : IDisposable
AnnualPlanPollResult result; AnnualPlanPollResult result;
try try
{ {
result = ProcessIcsText(icsText); result = await Task.Run(() => ProcessIcsText(icsText)).ConfigureAwait(false);
} }
catch (Exception ex) catch (Exception ex)
{ {
@@ -0,0 +1,88 @@
using Avalonia.Controls;
using CommunityToolkit.Mvvm.ComponentModel;
using LehrerApp.Core.Interfaces;
using LehrerApp.Core.Models;
using LehrerApp.Core.Services;
using LehrerApp.Desktop.ViewModels.Groups;
using LehrerApp.Desktop.ViewModels.Students;
using LehrerApp.Desktop.Views;
using LehrerApp.Desktop.Views.Groups;
using LehrerApp.Desktop.Views.Students;
using Microsoft.Extensions.DependencyInjection;
namespace LehrerApp.Desktop.Services;
/// <summary>Führt einen der vier bestehenden WebUntis-Abgleiche (unverändert, über ihre bestehenden
/// Dialoge) aus und vermerkt das Ergebnis im <see cref="UntisHubService"/> - genutzt sowohl vom
/// Untis-Hub-Dialog ("Jetzt prüfen" je Zeile) als auch von den WebUntis-Menüpunkten in
/// <c>MainWindow</c>, damit beide Einstiegspunkte denselben Fälligkeitsstand pflegen.</summary>
public static class UntisHubActions
{
public static async Task RunFehlzeitenAsync(Window owner, LearningGroup group, UntisHubJobKind kind,
DateOnly start, DateOnly end, UntisHubService hub)
{
var vm = new WebUntisLessonAbsenceComparisonViewModel(group,
App.Services.GetRequiredService<WebUntisIntegrationService>(),
App.Services.GetRequiredService<IStudentRepository>(),
App.Services.GetRequiredService<IParticipationSessionRepository>(),
App.Services.GetRequiredService<IParticipationRepository>())
{ StartDate = start.ToDateTime(TimeOnly.MinValue), EndDate = end.ToDateTime(TimeOnly.MinValue) };
var loaded = TrackLoad(vm, v => v.Busy);
await new WebUntisLessonAbsenceComparisonDialog { DataContext = vm }.ShowDialog(owner);
if (loaded()) hub.RecordRun(kind, group.Id, vm.Status);
}
public static async Task RunKlassenbuchAsync(Window owner, UntisHubService hub)
{
var vm = new WebUntisDocumentationComparisonViewModel(
App.Services.GetRequiredService<WebUntisIntegrationService>(),
App.Services.GetRequiredService<IStudentRepository>(),
App.Services.GetRequiredService<IGroupRepository>(),
App.Services.GetRequiredService<IDocumentationRepository>(),
App.Services.GetRequiredService<SchoolYearService>());
var loaded = TrackLoad(vm, v => v.Busy);
await new WebUntisDocumentationComparisonDialog { DataContext = vm }.ShowDialog(owner);
if (loaded()) hub.RecordRun(UntisHubJobKind.Klassenbuchabgleich, null, vm.Status);
}
public static async Task RunHausaufgabenAsync(Window owner, UntisHubService hub)
{
var vm = new WebUntisHomeworkComparisonViewModel(
App.Services.GetRequiredService<WebUntisIntegrationService>(),
App.Services.GetRequiredService<IStudentRepository>(),
App.Services.GetRequiredService<IGroupRepository>(),
App.Services.GetRequiredService<ISubjectRepository>(),
App.Services.GetRequiredService<IGroupMembershipRepository>(),
App.Services.GetRequiredService<IParticipationSessionRepository>(),
App.Services.GetRequiredService<IParticipationRepository>(),
App.Services.GetRequiredService<SchoolYearService>());
var loaded = TrackLoad(vm, v => v.Busy);
await new WebUntisHomeworkComparisonDialog { DataContext = vm }.ShowDialog(owner);
if (loaded()) hub.RecordRun(UntisHubJobKind.Hausaufgabenabgleich, null, vm.Status);
}
public static async Task RunOffenePeriodsAsync(Window owner, UntisHubService hub)
{
var dialog = new OpenUntisPeriodsDialog();
await dialog.ShowDialog(owner);
hub.RecordRun(UntisHubJobKind.OffenePeriods, null, dialog.LastStatus);
}
/// <summary>Erkennt nicht-invasiv (ohne die Vergleichs-ViewModels zu ändern), ob im Dialog
/// tatsächlich ein Ladeversuch stattfand: <c>Busy</c> wechselt in <c>Load()</c> immer erst auf
/// true und im <c>finally</c>-Block zurück auf false, egal ob erfolgreich oder mit Fehler
/// abgebrochen - genau dieser Übergang wird hier beobachtet.</summary>
private static Func<bool> TrackLoad<T>(T vm, Func<T, bool> isBusy) where T : ObservableObject
{
var loaded = false;
var wasBusy = false;
vm.PropertyChanged += (_, e) =>
{
if (e.PropertyName != "Busy") return;
var busy = isBusy(vm);
if (wasBusy && !busy) loaded = true;
wasBusy = busy;
};
return () => loaded;
}
}
@@ -0,0 +1,97 @@
using LehrerApp.Core.Interfaces;
using LehrerApp.Core.Models;
using LehrerApp.Core.Services;
namespace LehrerApp.Desktop.Services;
public enum UntisHubDueState { Ok, Due, Overdue }
/// <summary>Eine Zeile im Untis-Hub: eine Job-Instanz (Fehlzeiten-Kadenz je Lerngruppe, oder einer
/// der drei dashboard-weiten Jobs) mit ihrer aktuellen Fälligkeit.</summary>
public sealed record UntisHubJobRow(
UntisHubJobKind Kind, Guid? GroupId, string GroupName,
DateTime? LastRunAt, string? LastResultSummary, UntisHubDueState DueState, string DueLabel);
/// <summary>
/// Zeigt, welche der bestehenden, rein manuell ausgelösten WebUntis-Abgleiche (Fehlzeiten pro
/// Lerngruppe, offene Stunden, Klassenbuch-/Hausaufgabenabgleich) fällig sind - ohne selbst
/// WebUntis anzufragen (Nutzer-Feedback: "nicht bei WebUntis auffallen", siehe
/// <see cref="UntisReportCacheService"/>). Die eigentlichen Abgleiche laufen weiterhin über die
/// bestehenden Vergleichsdialoge (siehe <see cref="UntisHubActions"/>); dieser Service verwaltet
/// nur die Fälligkeits-Zeitstempel dazu.
/// </summary>
public sealed class UntisHubService(
IGroupRepository groups, IUntisHubJobStateRepository jobStates, SchoolYearService schoolYears)
{
private static readonly TimeSpan FehlzeitenKurzDue = TimeSpan.FromDays(14);
private static readonly TimeSpan FehlzeitenKurzOverdue = TimeSpan.FromDays(21);
private static readonly TimeSpan FehlzeitenLangDue = TimeSpan.FromDays(60);
private static readonly TimeSpan FehlzeitenLangOverdue = TimeSpan.FromDays(90);
private static readonly TimeSpan GlobalDue = TimeSpan.FromDays(14);
private static readonly TimeSpan GlobalOverdue = TimeSpan.FromDays(21);
private static readonly (UntisHubJobKind Kind, string Label)[] GlobalJobs =
[
(UntisHubJobKind.OffenePeriods, "Offene Stunden"),
(UntisHubJobKind.Klassenbuchabgleich, "Klassenbuchabgleich"),
(UntisHubJobKind.Hausaufgabenabgleich, "Hausaufgabenabgleich"),
];
public List<UntisHubJobRow> GetRows()
{
var eligibleGroups = groups.GetBySchoolYear(schoolYears.CurrentSchoolYear())
.Where(g => g.WebUntisLessonId is not null)
.OrderBy(g => g.Name)
.ToList();
return BuildRows(eligibleGroups, jobStates.GetAll(), DateTime.UtcNow);
}
public void RecordRun(UntisHubJobKind kind, Guid? groupId, string? summary) =>
jobStates.Save(new UntisHubJobState
{
Id = jobStates.Get(kind, groupId)?.Id ?? Guid.NewGuid(),
Kind = kind, GroupId = groupId, LastRunAt = DateTime.UtcNow, LastResultSummary = summary,
});
/// Reine Entscheidungslogik ohne Repository-Zugriff (gleiches Muster wie
/// <see cref="UntisReportCacheService.Plan"/>): aus den fälligkeitsrelevanten Lerngruppen und den
/// zuletzt gespeicherten Job-Zuständen wird die vollständige Hub-Zeilenliste gebaut - zwei
/// Fehlzeiten-Zeilen je Gruppe plus die drei dashboard-weiten Zeilen.
public static List<UntisHubJobRow> BuildRows(
IReadOnlyList<LearningGroup> eligibleGroups, IReadOnlyList<UntisHubJobState> states, DateTime utcNow)
{
UntisHubJobState? State(UntisHubJobKind kind, Guid? groupId) =>
states.FirstOrDefault(s => s.Kind == kind && s.GroupId == groupId);
var rows = new List<UntisHubJobRow>();
foreach (var group in eligibleGroups)
{
rows.Add(Row(UntisHubJobKind.FehlzeitenKurz, group.Id, group.Name,
State(UntisHubJobKind.FehlzeitenKurz, group.Id), FehlzeitenKurzDue, FehlzeitenKurzOverdue, utcNow));
rows.Add(Row(UntisHubJobKind.FehlzeitenLang, group.Id, group.Name,
State(UntisHubJobKind.FehlzeitenLang, group.Id), FehlzeitenLangDue, FehlzeitenLangOverdue, utcNow));
}
foreach (var (kind, label) in GlobalJobs)
rows.Add(Row(kind, null, label, State(kind, null), GlobalDue, GlobalOverdue, utcNow));
return rows;
}
private static UntisHubJobRow Row(UntisHubJobKind kind, Guid? groupId, string groupName,
UntisHubJobState? state, TimeSpan due, TimeSpan overdue, DateTime utcNow)
{
var (dueState, dueLabel) = DueStatus(state?.LastRunAt, due, overdue, utcNow);
return new UntisHubJobRow(kind, groupId, groupName, state?.LastRunAt, state?.LastResultSummary,
dueState, dueLabel);
}
private static (UntisHubDueState, string) DueStatus(
DateTime? lastRunAt, TimeSpan due, TimeSpan overdue, DateTime utcNow)
{
if (lastRunAt is null) return (UntisHubDueState.Overdue, "noch nie geprüft");
var age = utcNow - lastRunAt.Value;
var days = (int)age.TotalDays;
if (age >= overdue) return (UntisHubDueState.Overdue, $"fällig seit {days} Tagen");
if (age >= due) return (UntisHubDueState.Due, $"fällig seit {days} Tagen");
return (UntisHubDueState.Ok, days == 0 ? "gerade eben geprüft" : $"vor {days} Tag(en) geprüft");
}
}
+22 -5
View File
@@ -67,7 +67,7 @@ public class UntisSyncService : IDisposable
public async Task PollAsync() public async Task PollAsync()
{ {
if (!await _gate.WaitAsync(0)) return; if (!await _gate.WaitAsync(0).ConfigureAwait(false)) return;
try try
{ {
var url = _settings.GetIcalUrl(); var url = _settings.GetIcalUrl();
@@ -76,7 +76,7 @@ public class UntisSyncService : IDisposable
string icsText; string icsText;
try try
{ {
icsText = await _http.GetStringAsync(url); icsText = await _http.GetStringAsync(url).ConfigureAwait(false);
} }
catch (Exception ex) catch (Exception ex)
{ {
@@ -88,7 +88,7 @@ public class UntisSyncService : IDisposable
UntisPollResult result; UntisPollResult result;
try try
{ {
result = ProcessIcsText(icsText); result = await Task.Run(() => ProcessIcsText(icsText)).ConfigureAwait(false);
} }
catch (Exception ex) catch (Exception ex)
{ {
@@ -118,11 +118,20 @@ public class UntisSyncService : IDisposable
var diffResult = _diffService.Diff(events, previousSnapshot, confirmedMappings, today, var diffResult = _diffService.Diff(events, previousSnapshot, confirmedMappings, today,
existingSupervisionDuties: _supervisionDuties.GetAll(), freeDates: BuildFreeDates(today)); existingSupervisionDuties: _supervisionDuties.GetAll(), freeDates: BuildFreeDates(today));
var savedSubstitutionCount = 0;
foreach (var candidate in diffResult.SubstitutionsToSave) foreach (var candidate in diffResult.SubstitutionsToSave)
{ {
var existing = candidate.ExternalId is not null ? _substitutions.GetByExternalId(candidate.ExternalId) : null; var existing = candidate.ExternalId is not null ? _substitutions.GetByExternalId(candidate.ExternalId) : null;
if (existing is not null) candidate.Id = existing.Id; // Der Diff liefert auch weiterhin bestehende WebUntis-Abweichungen als Kandidaten.
// Ein unverändertes Upsert würde über Repository.OnChange bei jedem Poll erneut ein
// Sync-Ereignis erzeugen und bei vielen aktiven Abweichungen das Protokoll fluten.
if (existing is not null)
{
candidate.Id = existing.Id;
if (SubstitutionContentEquals(existing, candidate)) continue;
}
_substitutions.Save(candidate); _substitutions.Save(candidate);
savedSubstitutionCount++;
} }
// Zuvor automatisch erzeugte Einträge, die jetzt nicht (mehr) gebraucht werden (siehe // Zuvor automatisch erzeugte Einträge, die jetzt nicht (mehr) gebraucht werden (siehe
// UntisDiffResult.SubstitutionExternalIdsToDelete) - existiert keiner mit dieser // UntisDiffResult.SubstitutionExternalIdsToDelete) - existiert keiner mit dieser
@@ -135,9 +144,17 @@ public class UntisSyncService : IDisposable
foreach (var snapshot in diffResult.SnapshotToSave) _snapshots.Save(snapshot); foreach (var snapshot in diffResult.SnapshotToSave) _snapshots.Save(snapshot);
foreach (var id in diffResult.SnapshotIdsToDelete) _snapshots.Delete(id); foreach (var id in diffResult.SnapshotIdsToDelete) _snapshots.Delete(id);
return new UntisPollResult(events.Count, diffResult.SubstitutionsToSave.Count); return new UntisPollResult(events.Count, savedSubstitutionCount);
} }
private static bool SubstitutionContentEquals(SubstitutionEntry left, SubstitutionEntry right) =>
left.Date == right.Date && left.Kind == right.Kind &&
left.PeriodNumber == right.PeriodNumber && left.AfterPeriod == right.AfterPeriod &&
left.FromPeriod == right.FromPeriod && left.ToPeriod == right.ToPeriod &&
left.IsAllDay == right.IsAllDay && left.GroupId == right.GroupId &&
left.GroupLabel == right.GroupLabel && left.Description == right.Description &&
left.Notes == right.Notes && left.ExternalId == right.ExternalId;
// Ferien-/Feiertagstage im relevanten Zeitfenster (deutlich über das Lookahead-Fenster // Ferien-/Feiertagstage im relevanten Zeitfenster (deutlich über das Lookahead-Fenster
// hinaus, kostet bei kleinen Ferienlisten nichts) - verhindert, dass die aktive // hinaus, kostet bei kleinen Ferienlisten nichts) - verhindert, dass die aktive
// "fehlt komplett im Feed"-Prüfung in UntisDiffService Ferientage fälschlich als Ausfall // "fehlt komplett im Feed"-Prüfung in UntisDiffService Ferientage fälschlich als Ausfall
@@ -205,6 +205,13 @@ public sealed class WebUntisIntegrationService(HttpClient http, WebUntisSettings
}; };
} }
public Task<UntisOpenPeriodsMeta> GetOpenPeriodsMetaAsync(int schoolYearId, CancellationToken token = default) =>
ExecuteAsync(client => client.GetOpenPeriodsMetaAsync(schoolYearId, token), token);
public Task<IReadOnlyList<UntisOpenPeriod>> GetOpenPeriodsAsync(int schoolYearId, int? teacherId,
int? classId, DateOnly start, DateOnly end, CancellationToken token = default) =>
ExecuteAsync(client => client.GetOpenPeriodsAsync(schoolYearId, teacherId, classId, start, end, token), token);
private async Task<T> ExecuteAsync<T>(Func<WebUntisClient, Task<T>> operation, CancellationToken token) private async Task<T> ExecuteAsync<T>(Func<WebUntisClient, Task<T>> operation, CancellationToken token)
{ {
try { return await operation(await GetClientAsync(token)); } try { return await operation(await GetClientAsync(token)); }
@@ -0,0 +1,106 @@
using CommunityToolkit.Mvvm.ComponentModel;
using System.Windows.Input;
namespace LehrerApp.Desktop.ViewModels;
/// <summary>Die sieben Dashboard-Kacheln, die tatsächlich "wo muss ich reagieren" beantworten
/// (siehe <see cref="DashboardViewModel.AttentionCount"/>), zusammengefasst zu einer Karte mit
/// Filter-Chips statt sieben eigenen Sichtbarkeits-Schaltern. Reihenfolge hier ist die Reihenfolge
/// der Gruppen in der zusammengefassten Ansicht (siehe DashboardViewModel.RebuildAttention) — sie
/// übernimmt bewusst die frühere Sortierung aus DashboardSettingsService.DefaultCardOrder, damit
/// sich nichts, was Nutzer bereits kennen, unvorhersehbar umsortiert.
/// "Klausurwochen" (examload) bleibt bewusst außen vor: die Einträge dort sind Wochen-Aggregate,
/// keine Einzelvorgänge, die sich abhaken oder anklicken lassen — sie passen nicht ins Item-Modell.</summary>
public enum AttentionKind { MissingTeachingTime, Excuse, Correction, Unplanned, Alert, Attendance, SupportPlan }
/// <summary>Eine Inline-Aktion auf einem Handlungsbedarf-Eintrag (z.B. "Entschuldigt"/"Unentschuldigt",
/// "Erfassen"). Verwendet ICommand statt Action, damit die bereits vorhandenen generierten
/// RelayCommands (OpenExcuseItem.MarkExcusedCommand, DashboardViewModel.AddMissingTeachingTimeCommand)
/// direkt weiterverwendet werden können, statt sie in einen zweiten Delegate-Typ zu verpacken.</summary>
public sealed record AttentionAction(string Label, ICommand Command, object? Parameter = null);
/// <summary>Gemeinsame Projektion für die sieben Handlungsbedarf-Arten. Trägt nur Anzeigedaten —
/// die eigentliche Beschaffung bleibt unverändert in den jeweiligen DashboardViewModel.LoadXxx-
/// Methoden; sie erzeugen am Ende ein AttentionItem statt (wie vorher) ihre eigene Item-Klasse in
/// ihre eigene ObservableCollection einzutragen.</summary>
public sealed class AttentionItem
{
public AttentionKind Kind { get; }
public string Title { get; }
public string Subtitle { get; }
/// <summary>Kurztext rechts neben dem Titel — Fehlzeitenquote, Alert-Kind-Label. Meist leer.</summary>
public string TrailingText { get; }
public string DateDisplay { get; }
public bool IsOverdue { get; }
public AlertSeverity? Severity { get; }
/// <summary>Nur bei Korrekturen gesetzt (0100); steuert die ProgressBar. Eigenes HasProgress statt
/// Nullability, damit die ProgressBar-Bindung ein einfaches int (kompatibel mit Value:double) bleibt.</summary>
public int ProgressPercent { get; }
public bool HasProgress { get; }
/// <summary>Text unter der ProgressBar, z.B. "3 von 5 Arbeiten bewertet". Nur bei Korrekturen gesetzt.</summary>
public string ProgressLabel { get; }
/// <summary>Färbt TrailingText wie die frühere Fehlzeiten-Warnung (fest "Foreground=Red") ein —
/// nur bei Attendance gesetzt, weil ein AttentionItem dieser Art per Konstruktion nur entsteht,
/// wenn die Fehlzeitenquote den Schwellenwert bereits überschreitet.</summary>
public bool IsWarningTrailing { get; }
public IReadOnlyList<AttentionAction> Actions { get; }
public Action? Navigate { get; }
public AttentionItem(AttentionKind kind, string title, string subtitle = "", string trailingText = "",
string dateDisplay = "", bool isOverdue = false, AlertSeverity? severity = null,
int progressPercent = 0, string progressLabel = "", bool hasProgress = false,
bool isWarningTrailing = false, IReadOnlyList<AttentionAction>? actions = null, Action? navigate = null)
{
Kind = kind;
Title = title;
Subtitle = subtitle;
TrailingText = trailingText;
DateDisplay = dateDisplay;
IsOverdue = isOverdue;
Severity = severity;
ProgressPercent = progressPercent;
ProgressLabel = progressLabel;
HasProgress = hasProgress;
IsWarningTrailing = isWarningTrailing;
Actions = actions ?? [];
Navigate = navigate;
}
public bool HasSubtitle => !string.IsNullOrWhiteSpace(Subtitle);
public bool HasTrailingText => !string.IsNullOrWhiteSpace(TrailingText);
public bool HasDate => !string.IsNullOrWhiteSpace(DateDisplay);
public bool HasActions => Actions.Count > 0;
public bool HasSeverity => Severity is not null;
public bool IsHighSeverity => Severity == AlertSeverity.High;
public bool IsMediumSeverity => Severity == AlertSeverity.Medium;
}
/// <summary>Eine Kind-Gruppe innerhalb der zusammengefassten Handlungsbedarf-Liste. Items behalten
/// die Sortierung, die ihre jeweilige LoadXxx-Methode schon immer produziert hat (z.B. Fehlzeiten
/// nach Quote absteigend, Korrekturen nach Datum) — ein zweites, generisches "Importance"-Sortieren
/// quer über alle Arten würde diese bewusst gewählten Reihenfolgen wieder zerstören.</summary>
public sealed class AttentionGroup(AttentionKind kind, string header, IReadOnlyList<AttentionItem> items)
{
public AttentionKind Kind { get; } = kind;
public string Header { get; } = header;
public IReadOnlyList<AttentionItem> Items { get; } = items;
public int Count => Items.Count;
}
/// <summary>Ein Filter-Chip für eine Handlungsbedarf-Art in der zusammengefassten Karte — ersetzt
/// die frühere Sichtbarkeit je Einzelkachel (siehe DashboardViewModel.LoadAttentionFilters).</summary>
public partial class AttentionFilterOption : ObservableObject
{
[ObservableProperty] private bool _isActive = true;
public AttentionKind Kind { get; }
public string Label { get; }
public Action? OnChanged { get; set; }
public AttentionFilterOption(AttentionKind kind, string label)
{
Kind = kind;
Label = label;
}
partial void OnIsActiveChanged(bool value) => OnChanged?.Invoke();
}
@@ -0,0 +1,249 @@
using System.Collections.ObjectModel;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using LehrerApp.Core.Interfaces;
using LehrerApp.Core.Models;
using LehrerApp.Desktop.Services;
using LehrerApp.Desktop.ViewModels.Students;
namespace LehrerApp.Desktop.ViewModels.ClassTeacher;
/// <summary>
/// "Vorgänge"-Tab der Klassenlehreransicht: Fallmappen für konkrete, laufende Probleme mit einer/
/// einem oder mehreren Schüler*innen der Klasse, die Dokumentation und (eingefrorene) WebUntis-
/// Klassenbucheinträge bündeln. Löst wie <see cref="ClassTeacherDetailsViewModel"/> die Untis-
/// Roster-Namen der Klasse auf lokale <see cref="Student"/>-Datensätze auf (bewusst ein eigener,
/// nicht geteilter Abgleich — dasselbe kleine Muster steckt schon zweimal im Code, siehe
/// <see cref="ClassTeacherOverviewViewModel.MatchStudent"/>, eine gemeinsame Abstraktion dafür ist
/// hier nicht Teil der Aufgabe).
/// </summary>
public partial class ClassTeacherCasesViewModel : ObservableObject
{
private readonly IVorgangRepository _vorgaenge;
private readonly IDocumentationRepository _documentation;
private readonly IStudentRepository _students;
private readonly UntisReportCacheService _cache;
private string _className = "";
private List<StudentOption> _rosterStudentOptions = [];
public ObservableCollection<VorgangItem> Cases { get; } = [];
[ObservableProperty] private bool _onlyOpen = true;
[ObservableProperty] private bool _busy;
[ObservableProperty] private string _status = "";
public bool HasCases => Cases.Count > 0;
public Func<List<StudentOption>, Vorgang?, Task<Vorgang?>>? OnEditVorgang { get; set; }
public Func<VorgangItem, Task<bool>>? OnConfirmDeleteVorgang { get; set; }
public Func<Guid, List<StudentOption>, Documentation?, Task<Documentation?>>? OnEditDocumentation { get; set; }
public ClassTeacherCasesViewModel(IVorgangRepository vorgaenge, IDocumentationRepository documentation,
IStudentRepository students, UntisReportCacheService cache)
{
_vorgaenge = vorgaenge;
_documentation = documentation;
_students = students;
_cache = cache;
}
public void Initialize(string className)
{
_className = className;
Cases.Clear();
Status = "Wird geladen…";
NotifyState();
_ = LoadInternal();
}
partial void OnOnlyOpenChanged(bool value) => _ = LoadInternal();
[RelayCommand]
private Task Refresh() => LoadInternal();
private async Task LoadInternal()
{
if (string.IsNullOrWhiteSpace(_className)) return;
// IsRevealed übersteht den Neuaufbau der Liste nicht automatisch (neue VorgangItem-Instanzen
// pro Load) - deshalb hier gemerkt und danach wiederhergestellt, sonst klappt eine gerade
// aufgeklappte Karte bei jeder Verknüpfungs-/Status-Aktion (die intern neu lädt) wieder zu.
var revealedIds = Cases.Where(c => c.IsRevealed).Select(c => c.Model.Id).ToHashSet();
Busy = true;
Cases.Clear();
NotifyState();
try
{
var roster = await _cache.GetStudentRosterAsync(_className);
var localStudents = _students.GetAll();
var matches = roster
.Select(r => (Roster: r, Student: ClassTeacherOverviewViewModel.MatchStudent(r.DisplayName, localStudents)))
.Where(x => x.Student is not null)
.Select(x => (StudentId: x.Student!.Id, DisplayName: x.Roster.DisplayName))
.DistinctBy(x => x.StudentId)
.ToList();
_rosterStudentOptions = matches.Select(m => new StudentOption(m.StudentId, m.DisplayName)).ToList();
var rosterIds = matches.Select(m => m.StudentId).ToHashSet();
var nameById = matches.ToDictionary(m => m.StudentId, m => m.DisplayName);
var relevant = _vorgaenge.GetAll().Where(v => v.StudentIds.Any(rosterIds.Contains));
if (OnlyOpen) relevant = relevant.Where(v => v.Status == VorgangStatus.Open);
var allDocs = _documentation.GetAll();
foreach (var vorgang in relevant.OrderByDescending(v => v.UpdatedAt))
{
var names = vorgang.StudentIds.Select(id => nameById.GetValueOrDefault(id, ""))
.Where(n => n != "");
var item = new VorgangItem(vorgang, string.Join(", ", names), LinkDocumentation,
UnlinkDocumentation, RemoveClassRegisterEntry, EditDocumentation)
{ IsRevealed = revealedIds.Contains(vorgang.Id) };
var linkedIds = vorgang.DocumentationIds.ToHashSet();
foreach (var doc in allDocs.Where(d => linkedIds.Contains(d.Id)))
item.LinkedDocumentation.Add(new DocumentationItem(doc, nameById.GetValueOrDefault(doc.StudentId, "")));
foreach (var doc in FilterAvailableDocumentation(allDocs, vorgang))
item.AvailableDocumentation.Add(new DocumentationItem(doc, nameById.GetValueOrDefault(doc.StudentId, "")));
foreach (var entry in vorgang.ClassRegisterEntries.OrderByDescending(e => e.Date))
item.ClassRegisterRows.Add(new VorgangClassRegisterEntryRow(entry));
Cases.Add(item);
}
Status = OnlyOpen ? $"{Cases.Count} offene Vorgänge" : $"{Cases.Count} Vorgänge";
}
finally { Busy = false; NotifyState(); }
}
private void NotifyState() => OnPropertyChanged(nameof(HasCases));
[RelayCommand]
private async Task AddVorgang()
{
if (OnEditVorgang is null) return;
var result = await OnEditVorgang(_rosterStudentOptions, null);
if (result is null) return;
_vorgaenge.Save(result);
await LoadInternal();
}
[RelayCommand]
private async Task EditVorgang(VorgangItem? item)
{
if (item is null || OnEditVorgang is null) return;
var result = await OnEditVorgang(_rosterStudentOptions, item.Model);
if (result is null) return;
_vorgaenge.Save(result);
await LoadInternal();
}
[RelayCommand]
private async Task ToggleStatus(VorgangItem? item)
{
if (item is null) return;
item.Model.Status = item.Model.Status == VorgangStatus.Open ? VorgangStatus.Closed : VorgangStatus.Open;
item.Model.ClosedAt = item.Model.Status == VorgangStatus.Closed ? DateTime.UtcNow : null;
_vorgaenge.Save(item.Model);
await LoadInternal();
}
[RelayCommand]
private async Task DeleteVorgang(VorgangItem? item)
{
if (item is null) return;
if (OnConfirmDeleteVorgang is not null && !await OnConfirmDeleteVorgang(item)) return;
_vorgaenge.Delete(item.Model.Id);
await LoadInternal();
}
[RelayCommand]
private async Task CreateAndLinkDocumentation(VorgangItem? item)
{
if (item is null || OnEditDocumentation is null) return;
var studentOptions = _rosterStudentOptions.Where(s => item.Model.StudentIds.Contains(s.Id)).ToList();
var defaultStudentId = item.Model.StudentIds.FirstOrDefault();
var result = await OnEditDocumentation(defaultStudentId, studentOptions, null);
if (result is null) return;
_documentation.Save(result);
item.Model.DocumentationIds.Add(result.Id);
_vorgaenge.Save(item.Model);
await LoadInternal();
}
/// Öffnet eine verknüpfte oder noch verknüpfbare Dokumentation im vollen Bearbeiten-Dialog, ohne
/// den Vorgänge-Tab zu verlassen (Nutzer-Feedback: bisher musste man dafür zum Nachlesen in den
/// Schülerdatensatz wechseln). Reine Bearbeitung der Dokumentation selbst — welche Vorgänge sie
/// verlinken, ändert sich dadurch nicht.
private async Task EditDocumentation(DocumentationItem doc)
{
if (OnEditDocumentation is null) return;
var result = await OnEditDocumentation(doc.Model.StudentId, _rosterStudentOptions, doc.Model);
if (result is null) return;
_documentation.Save(result);
await LoadInternal();
}
/// Dokumentation der Vorgangs-Schüler*innen, die noch nicht mit diesem Vorgang verknüpft ist —
/// reine, ohne Repository-Zugriff testbare Filterlogik (gleiches Muster wie
/// <see cref="ClassTeacherRosterRow.Build"/>).
public static List<Documentation> FilterAvailableDocumentation(IReadOnlyList<Documentation> allDocs, Vorgang vorgang)
{
var linkedIds = vorgang.DocumentationIds.ToHashSet();
return allDocs.Where(d => vorgang.StudentIds.Contains(d.StudentId) && !linkedIds.Contains(d.Id)).ToList();
}
private async Task LinkDocumentation(VorgangItem item, DocumentationItem doc)
{
item.Model.DocumentationIds.Add(doc.Model.Id);
_vorgaenge.Save(item.Model);
await LoadInternal();
}
private async Task UnlinkDocumentation(VorgangItem item, DocumentationItem doc)
{
item.Model.DocumentationIds.Remove(doc.Model.Id);
_vorgaenge.Save(item.Model);
await LoadInternal();
}
private async Task RemoveClassRegisterEntry(VorgangItem item, VorgangClassRegisterEntryRow row)
{
item.Model.ClassRegisterEntries.Remove(row.Model);
_vorgaenge.Save(item.Model);
await LoadInternal();
}
// ── Anheften eines Klassenbuch-Eintrags aus dem Klassenbuch-Tab (ClassTeacherDetailsViewModel,
// per Konstruktor-Injection dieser Instanz — kein Func-Hook, da hier keine UI im Spiel ist,
// nur Zugriff auf schon geladene Daten dieser Geschwister-ViewModel-Instanz) ────────────────
public IReadOnlyList<StudentOption> RosterStudentOptions => _rosterStudentOptions;
/// Offene Vorgänge, an die sich ein Klassenbuch-Eintrag für diese/n Schüler*in anheften lässt
/// (Namensabgleich wie beim restlichen Klassenlehrer-Bereich, siehe UntisNameMatching).
public List<VorgangItem> OpenCasesForStudent(string untisDisplayName)
{
var match = _rosterStudentOptions.FirstOrDefault(s =>
UntisNameMatching.NamesMatch(s.Name, untisDisplayName));
if (match is null) return [];
return Cases.Where(c => c.IsOpen && c.Model.StudentIds.Contains(match.Id)).ToList();
}
public async Task PinClassRegisterEntryAsync(Guid vorgangId, VorgangClassRegisterEntry entry)
{
var vorgang = _vorgaenge.GetById(vorgangId);
if (vorgang is null) return;
vorgang.ClassRegisterEntries.Add(entry);
_vorgaenge.Save(vorgang);
await LoadInternal();
}
public async Task<Vorgang> CreateAndPinAsync(string title, Guid studentId, VorgangClassRegisterEntry entry)
{
var vorgang = new Vorgang { Title = title, StudentIds = [studentId] };
vorgang.ClassRegisterEntries.Add(entry);
_vorgaenge.Save(vorgang);
await LoadInternal();
return vorgang;
}
}
@@ -1,7 +1,10 @@
using System.Collections.ObjectModel; using System.Collections.ObjectModel;
using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input; using CommunityToolkit.Mvvm.Input;
using LehrerApp.Core.Interfaces;
using LehrerApp.Core.Models;
using LehrerApp.Desktop.Services; using LehrerApp.Desktop.Services;
using LehrerApp.Desktop.ViewModels.Students;
namespace LehrerApp.Desktop.ViewModels.ClassTeacher; namespace LehrerApp.Desktop.ViewModels.ClassTeacher;
@@ -116,6 +119,24 @@ public sealed record ClassAbsenceDaySummaryRow(DateOnly Date, string StudentName
DateOnly.TryParseExact(value.ToString(), "yyyyMMdd", out date); DateOnly.TryParseExact(value.ToString(), "yyyyMMdd", out date);
} }
public enum ClassTeacherCalendarEventKind { Late, Absent, Unexcused, Excused, ClassRegister, Homework }
/// <summary>Kompakter, farblich codierter Marker in der Klassenlehrer-Monatsansicht.</summary>
public sealed record ClassTeacherCalendarEvent(string StudentName, string Code, string Label,
string ColorHex, ClassTeacherCalendarEventKind Kind)
{
public string Tooltip => $"{StudentName}: {Label}";
}
/// <summary>Ein Werktag im 5-Spalten-Monatsraster; Tage außerhalb des Monats sind Platzhalter.</summary>
public sealed record ClassTeacherCalendarDay(DateOnly? Date, IReadOnlyList<ClassTeacherCalendarEvent> Events)
{
public bool IsPlaceholder => Date is null;
public string DayLabel => Date?.ToString("dd.") ?? "";
public string WeekdayLabel => Date?.ToString("ddd", System.Globalization.CultureInfo.GetCultureInfo("de-DE")) ?? "";
public bool HasEvents => Events.Count > 0;
}
/// <summary> /// <summary>
/// Details-Ansicht des Klassenlehrer-Bereichs: Klassenbucheinträge, die andere Lehrkräfte zu /// 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 /// Schülern der Klasse angelegt haben (WebUntis "-alle-"-Bericht, gefiltert auf fremde statt der
@@ -130,29 +151,71 @@ public sealed record ClassAbsenceDaySummaryRow(DateOnly Date, string StudentName
public partial class ClassTeacherDetailsViewModel : ObservableObject public partial class ClassTeacherDetailsViewModel : ObservableObject
{ {
private readonly UntisReportCacheService _cache; private readonly UntisReportCacheService _cache;
private readonly IDocumentationRepository _documentation;
private readonly IStudentRepository _students;
private readonly ClassTeacherCasesViewModel _cases;
private string _className = ""; private string _className = "";
private DateOnly _loadedStart;
private DateOnly _loadedEnd;
/// Schüler*innen der Klasse, per Namensabgleich (<see cref="UntisNameMatching"/>) den lokalen
/// <see cref="Student"/>-Datensätzen zugeordnet — Grundlage für die "Eigene Dokumentation"-Ansicht,
/// die es (anders als der WebUntis-Klassenbuchbericht) nur lokal gibt. Nach jedem <see cref="LoadInternal"/>
/// neu aufgebaut.
private List<(Guid StudentId, string DisplayName)> _rosterMatches = [];
public ObservableCollection<ClassTeacherClassRegisterRow> Entries { get; } = []; public ObservableCollection<ClassTeacherClassRegisterRow> Entries { get; } = [];
public ObservableCollection<ClassAbsenceDaySummaryRow> AbsenceEntries { get; } = []; public ObservableCollection<ClassAbsenceDaySummaryRow> AbsenceEntries { get; } = [];
public ObservableCollection<ClassTeacherCategoryAggregateRow> CategoryAggregates { get; } = []; public ObservableCollection<ClassTeacherCategoryAggregateRow> CategoryAggregates { get; } = [];
public ObservableCollection<DocumentationItem> OwnDocumentationEntries { get; } = [];
public ObservableCollection<ClassTeacherCalendarDay> CalendarDays { get; } = [];
[ObservableProperty] private DateTimeOffset _startDate = DateTimeOffset.Now.AddDays(-6); /// Für das Kontextmenü "→ An Vorgang anheften" (DataGrid.SelectedItem, zweigleisig gebunden).
[ObservableProperty] private DateTimeOffset _endDate = DateTimeOffset.Now; [ObservableProperty] private ClassTeacherClassRegisterRow? _selectedEntry;
[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 string _status = "Zeitraum wählen und laden.";
[ObservableProperty] private bool _busy; [ObservableProperty] private bool _busy;
/// Von der Übersicht gesetzt (Klick auf eine Roster-Zeile) - leer zeigt alle Schüler*innen. /// Von der Übersicht gesetzt (Klick auf eine Roster-Zeile) - leer zeigt alle Schüler*innen.
[ObservableProperty] private string _studentFilter = ""; [ObservableProperty] private string _studentFilter = "";
[ObservableProperty] private int _quickRangeIndex = 1; [ObservableProperty] private int _quickRangeIndex = 1;
[ObservableProperty] private bool _showMonthlyCalendar;
[ObservableProperty] private bool _includeHomeworkInCalendar;
[ObservableProperty] private DateOnly _calendarMonth = new(DateTime.Today.Year, DateTime.Today.Month, 1);
/// Umschalter Klassenbuch (WebUntis, andere Lehrkräfte) ↔ eigene Dokumentation.
[ObservableProperty] private bool _showOwnDocumentation;
[ObservableProperty] private int _ownDocumentationCount;
[ObservableProperty] private int _ownDocumentationFollowUpCount;
[ObservableProperty] private int _ownDocumentationCriticalCount;
public Func<List<StudentOption>, Documentation?, Task<Documentation?>>? OnEditOwnDocumentation { get; set; }
public Func<DocumentationItem, Task<bool>>? OnConfirmDeleteOwnDocumentation { get; set; }
/// Zeigt die "→ Vorgang"-Auswahl für eine Klassenbuchzeile: bestehenden offenen Vorgang wählen
/// oder einen neuen Titel eingeben. Liefert null bei Abbruch.
public Func<ClassTeacherClassRegisterRow, Task<PinToVorgangChoice?>>? OnPickVorgangForPin { get; set; }
public bool HasEntries => Entries.Count > 0; public bool HasEntries => Entries.Count > 0;
public bool HasAbsenceEntries => AbsenceEntries.Count > 0; public bool HasAbsenceEntries => AbsenceEntries.Count > 0;
public bool ShowAbsenceListEmpty => !ShowMonthlyCalendar && !HasAbsenceEntries;
public bool HasCategoryAggregates => CategoryAggregates.Count > 0; public bool HasCategoryAggregates => CategoryAggregates.Count > 0;
public bool HasOwnDocumentationEntries => OwnDocumentationEntries.Count > 0;
public bool CanAddOwnDocumentation => !Busy && _rosterMatches.Count > 0;
/// Die Kategorien-Chipreihe fasst nur WebUntis-Kategorien zusammen (<see cref="CategoryAggregates"/>)
/// — im Modus "Eigene Dokumentation" ausgeblendet, dort gibt es kein Äquivalent zu CategoryGroup.
public bool ShowCategoryAggregates => HasCategoryAggregates && !ShowOwnDocumentation;
public int UntisCriticalCount => Entries.Count(e => e.IsDangerStatus);
public string ActiveFilterLabel => string.IsNullOrWhiteSpace(StudentFilter) public string ActiveFilterLabel => string.IsNullOrWhiteSpace(StudentFilter)
? "Alle Schüler*innen" : StudentFilter; ? "Alle Schüler*innen" : StudentFilter;
public string CalendarMonthLabel => CalendarMonth.ToString("MMMM yyyy",
System.Globalization.CultureInfo.GetCultureInfo("de-DE"));
public ClassTeacherDetailsViewModel(UntisReportCacheService cache) public ClassTeacherDetailsViewModel(UntisReportCacheService cache, IDocumentationRepository documentation,
IStudentRepository students, ClassTeacherCasesViewModel cases)
{ {
_cache = cache; _cache = cache;
_documentation = documentation;
_students = students;
_cases = cases;
} }
public void Initialize(string className) public void Initialize(string className)
@@ -161,13 +224,21 @@ public partial class ClassTeacherDetailsViewModel : ObservableObject
StudentFilter = ""; StudentFilter = "";
Entries.Clear(); Entries.Clear();
AbsenceEntries.Clear(); AbsenceEntries.Clear();
CalendarDays.Clear();
CategoryAggregates.Clear(); CategoryAggregates.Clear();
OwnDocumentationEntries.Clear();
_rosterMatches = [];
Status = "Zeitraum wählen und laden."; Status = "Zeitraum wählen und laden.";
NotifyListState(); NotifyListState();
} }
partial void OnStudentFilterChanged(string value) => OnPropertyChanged(nameof(ActiveFilterLabel)); partial void OnStudentFilterChanged(string value) => OnPropertyChanged(nameof(ActiveFilterLabel));
partial void OnShowOwnDocumentationChanged(bool value) => OnPropertyChanged(nameof(ShowCategoryAggregates));
partial void OnShowMonthlyCalendarChanged(bool value) => OnPropertyChanged(nameof(ShowAbsenceListEmpty));
partial void OnIncludeHomeworkInCalendarChanged(bool value) => BuildCalendar();
partial void OnCalendarMonthChanged(DateOnly value) => OnPropertyChanged(nameof(CalendarMonthLabel));
partial void OnQuickRangeIndexChanged(int value) partial void OnQuickRangeIndexChanged(int value)
{ {
var days = value switch { 0 => 0, 1 => 6, 2 => 29, _ => 6 }; var days = value switch { 0 => 0, 1 => 6, 2 => 29, _ => 6 };
@@ -178,6 +249,9 @@ public partial class ClassTeacherDetailsViewModel : ObservableObject
[RelayCommand] [RelayCommand]
private Task Load() => LoadInternal(forceRefresh: false); private Task Load() => LoadInternal(forceRefresh: false);
[RelayCommand] private void ShowUntisRegister() => ShowOwnDocumentation = false;
[RelayCommand] private void ShowOwnDocs() => ShowOwnDocumentation = true;
/// Umgeht bewusst die Stunden-Sperre von <see cref="UntisReportCacheService"/> — für den Fall, /// 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. /// dass man sicher weiß, dass sich seit dem letzten automatischen Abruf etwas geändert hat.
[RelayCommand] [RelayCommand]
@@ -186,6 +260,37 @@ public partial class ClassTeacherDetailsViewModel : ObservableObject
[RelayCommand] [RelayCommand]
private Task ApplyFilter() => LoadInternal(forceRefresh: false); private Task ApplyFilter() => LoadInternal(forceRefresh: false);
[RelayCommand] private void ShowAbsenceList() => ShowMonthlyCalendar = false;
[RelayCommand]
private async Task ShowCalendar()
{
ShowMonthlyCalendar = true;
await LoadCalendarMonth(forceRefresh: false);
}
[RelayCommand]
private async Task PreviousCalendarMonth()
{
CalendarMonth = CalendarMonth.AddMonths(-1);
await LoadCalendarMonth(forceRefresh: false);
}
[RelayCommand]
private async Task NextCalendarMonth()
{
CalendarMonth = CalendarMonth.AddMonths(1);
await LoadCalendarMonth(forceRefresh: false);
}
private Task LoadCalendarMonth(bool forceRefresh)
{
StartDate = new DateTimeOffset(CalendarMonth.ToDateTime(TimeOnly.MinValue));
var end = CalendarMonth.AddMonths(1).AddDays(-1);
EndDate = new DateTimeOffset(end.ToDateTime(TimeOnly.MinValue));
return LoadInternal(forceRefresh);
}
[RelayCommand] [RelayCommand]
private async Task ClearStudentFilter() private async Task ClearStudentFilter()
{ {
@@ -195,17 +300,20 @@ public partial class ClassTeacherDetailsViewModel : ObservableObject
private async Task LoadInternal(bool forceRefresh) private async Task LoadInternal(bool forceRefresh)
{ {
var start = DateOnly.FromDateTime(StartDate.LocalDateTime); var start = DateOnly.FromDateTime((StartDate ?? DateTimeOffset.Now).LocalDateTime);
var end = DateOnly.FromDateTime(EndDate.LocalDateTime); var end = DateOnly.FromDateTime((EndDate ?? DateTimeOffset.Now).LocalDateTime);
if (end < start) { Status = "Das Enddatum darf nicht vor dem Startdatum liegen."; return; } if (end < start) { Status = "Das Enddatum darf nicht vor dem Startdatum liegen."; return; }
if (string.IsNullOrWhiteSpace(_className)) { Status = "Keine Klasse ausgewählt."; return; } if (string.IsNullOrWhiteSpace(_className)) { Status = "Keine Klasse ausgewählt."; return; }
Busy = true; Entries.Clear(); AbsenceEntries.Clear(); CategoryAggregates.Clear(); NotifyListState(); Busy = true;
Entries.Clear(); AbsenceEntries.Clear(); CategoryAggregates.Clear(); OwnDocumentationEntries.Clear();
NotifyListState();
try try
{ {
var classRegisterTask = _cache.GetClassRegisterEventsAsync(_className, start, end, forceRefresh); var classRegisterTask = _cache.GetClassRegisterEventsAsync(_className, start, end, forceRefresh);
var absencesTask = _cache.GetAbsencesAsync(_className, start, end, forceRefresh); var absencesTask = _cache.GetAbsencesAsync(_className, start, end, forceRefresh);
await Task.WhenAll(classRegisterTask, absencesTask); var rosterTask = _cache.GetStudentRosterAsync(_className);
await Task.WhenAll(classRegisterTask, absencesTask, rosterTask);
var ordered = classRegisterTask.Result var ordered = classRegisterTask.Result
.Where(MatchesStudentFilter) .Where(MatchesStudentFilter)
@@ -223,14 +331,234 @@ public partial class ClassTeacherDetailsViewModel : ObservableObject
foreach (var row in ClassAbsenceDaySummaryRow.GroupByStudentAndDay(absences)) foreach (var row in ClassAbsenceDaySummaryRow.GroupByStudentAndDay(absences))
AbsenceEntries.Add(row); AbsenceEntries.Add(row);
BuildCalendar();
var localStudents = _students.GetAll();
_rosterMatches = rosterTask.Result
.Select(r => (Roster: r, Student: ClassTeacherOverviewViewModel.MatchStudent(r.DisplayName, localStudents)))
.Where(x => x.Student is not null)
.Select(x => (StudentId: x.Student!.Id, DisplayName: x.Roster.DisplayName))
.DistinctBy(x => x.StudentId)
.ToList();
_loadedStart = start;
_loadedEnd = end;
LoadOwnDocumentationEntries();
Status = $"{Entries.Count} Klassenbucheinträge anderer Lehrkräfte, " + Status = $"{Entries.Count} Klassenbucheinträge anderer Lehrkräfte, " +
$"{AbsenceEntries.Count} Fehlzeiten-Tage im Zeitraum."; $"{AbsenceEntries.Count} Fehlzeiten-Tage, {OwnDocumentationEntries.Count} eigene Dokumentation im Zeitraum.";
NotifyListState(); NotifyListState();
} }
catch (WebUntisIntegrationException ex) { Status = ex.Message; } catch (WebUntisIntegrationException ex) { Status = ex.Message; }
finally { Busy = false; NotifyListState(); } finally { Busy = false; NotifyListState(); }
} }
private void BuildCalendar()
{
CalendarDays.Clear();
foreach (var day in BuildCalendarDays(CalendarMonth, AbsenceEntries, Entries, IncludeHomeworkInCalendar))
CalendarDays.Add(day);
}
/// <summary>Baut ein Montag-bis-Freitag-Raster aus den ohnehin geladenen Berichten. Negative
/// Klassenbucheinträge erscheinen als !; Hausaufgaben sind eine bewusst optionale Sonderlage.</summary>
public static IReadOnlyList<ClassTeacherCalendarDay> BuildCalendarDays(DateOnly month,
IReadOnlyList<ClassAbsenceDaySummaryRow> absences,
IReadOnlyList<ClassTeacherClassRegisterRow> registerEntries, bool includeHomework)
{
var first = new DateOnly(month.Year, month.Month, 1);
var last = first.AddMonths(1).AddDays(-1);
var gridStart = first.AddDays(-(((int)first.DayOfWeek + 6) % 7));
var gridEnd = last.AddDays((7 - (int)last.DayOfWeek) % 7);
var result = new List<ClassTeacherCalendarDay>();
for (var date = gridStart; date <= gridEnd; date = date.AddDays(1))
{
if (date.DayOfWeek is DayOfWeek.Saturday or DayOfWeek.Sunday) continue;
if (date.Month != first.Month)
{
result.Add(new ClassTeacherCalendarDay(null, []));
continue;
}
result.Add(new ClassTeacherCalendarDay(date,
BuildDayEvents(date, absences, registerEntries, includeHomework)));
}
return result;
}
public static IReadOnlyList<ClassTeacherCalendarEvent> BuildDayEvents(DateOnly date,
IReadOnlyList<ClassAbsenceDaySummaryRow> absences,
IReadOnlyList<ClassTeacherClassRegisterRow> registerEntries, bool includeHomework)
{
var events = new List<ClassTeacherCalendarEvent>();
foreach (var absence in absences.Where(a => a.Date == date).OrderBy(a => a.StudentDisplayName))
{
var (code, label, color, kind) = absence.IsUnexcused
? ("U", "Unentschuldigt gefehlt", "#C62828", ClassTeacherCalendarEventKind.Unexcused)
: absence.IsLate
? ("V", "Verspätet", "#EF6C00", ClassTeacherCalendarEventKind.Late)
: absence.FriendlyStatusLabel.Contains("Entschuldigt", StringComparison.OrdinalIgnoreCase)
? ("E", "Entschuldigt gefehlt", "#2E7D32", ClassTeacherCalendarEventKind.Excused)
: ("A", "Abwesend", "#1565C0", ClassTeacherCalendarEventKind.Absent);
events.Add(new ClassTeacherCalendarEvent(absence.StudentDisplayName, code, label, color, kind));
}
foreach (var entry in registerEntries.Where(e => e.Date == date).OrderBy(e => e.StudentDisplayName))
{
var isHomework = ContainsHomework(entry);
if (isHomework && !includeHomework) continue;
if (!entry.IsDangerStatus && !isHomework) continue;
events.Add(new ClassTeacherCalendarEvent(entry.StudentDisplayName,
isHomework ? "H" : "!", isHomework ? "Hausaufgaben" : "Negativer Klassenbucheintrag",
isHomework ? "#6A1B9A" : "#8E24AA",
isHomework ? ClassTeacherCalendarEventKind.Homework : ClassTeacherCalendarEventKind.ClassRegister));
}
return events;
}
private static bool ContainsHomework(ClassTeacherClassRegisterRow entry) =>
entry.CategoryName?.Contains("Hausauf", StringComparison.OrdinalIgnoreCase) == true ||
entry.Text?.Contains("Hausauf", StringComparison.OrdinalIgnoreCase) == true;
/// Baut <see cref="OwnDocumentationEntries"/> aus <see cref="_rosterMatches"/> und dem zuletzt
/// geladenen Zeitraum neu auf — separat von <see cref="LoadInternal"/>, damit Anlegen/Bearbeiten/
/// Löschen eines eigenen Eintrags nicht auch die WebUntis-Berichte neu abruft. Die eigentliche
/// Filter-/Sortierlogik steckt in der reinen, ohne Repository-Zugriff testbaren
/// <see cref="FilterOwnDocumentation"/> — analog zu <see cref="ClassTeacherRosterRow.Build"/>.
private void LoadOwnDocumentationEntries()
{
OwnDocumentationEntries.Clear();
var entries = FilterOwnDocumentation(_documentation.GetAll(), _rosterMatches,
_loadedStart, _loadedEnd, StudentFilter);
foreach (var d in entries)
{
var name = DocumentationStudentDisplay(d, _rosterMatches);
OwnDocumentationEntries.Add(new DocumentationItem(d, name));
}
OwnDocumentationCount = OwnDocumentationEntries.Count;
OwnDocumentationFollowUpCount = entries.Count(d => d.IsDraft);
OwnDocumentationCriticalCount = entries.Count(d =>
d.Tags.Any(t => string.Equals(t, "Kritisch", StringComparison.OrdinalIgnoreCase)));
OnPropertyChanged(nameof(HasOwnDocumentationEntries));
}
/// Eigene Dokumentation zu Schüler*innen der Klasse im gewählten Zeitraum: nur Einträge
/// (nicht gelöschter) Schüler*innen, die sich per Namensabgleich der WebUntis-Klasse zuordnen
/// ließen (<paramref name="rosterMatches"/>, siehe <see cref="ClassTeacherOverviewViewModel.MatchStudent"/>),
/// mit demselben Schülerfilter wie die WebUntis-Klassenbuchzeilen. Entwürfe ("Nacharbeiten")
/// zuerst, danach neueste zuerst.
public static List<Documentation> FilterOwnDocumentation(IReadOnlyList<Documentation> all,
IReadOnlyList<(Guid StudentId, string DisplayName)> rosterMatches,
DateOnly start, DateOnly end, string studentFilter)
{
var matchedIds = rosterMatches.Select(m => m.StudentId).ToHashSet();
return all
.Where(d => !d.IsDeleted &&
(matchedIds.Contains(d.StudentId) || IsUnassignedClassDocumentation(d)) &&
d.Date >= start && d.Date <= end)
.Where(d => MatchesOwnDocStudentFilter(d, rosterMatches, studentFilter))
.OrderByDescending(d => d.IsDraft).ThenByDescending(d => d.Date)
.ToList();
}
private static bool MatchesOwnDocStudentFilter(Documentation d,
IReadOnlyList<(Guid StudentId, string DisplayName)> rosterMatches, string studentFilter)
{
if (string.IsNullOrWhiteSpace(studentFilter)) return true;
if (IsUnassignedClassDocumentation(d))
return d.Participants.Any(p => NameContainsSearchTerm(p, studentFilter));
var name = rosterMatches.FirstOrDefault(m => m.StudentId == d.StudentId).DisplayName;
return name is not null && NameContainsSearchTerm(name, studentFilter);
}
private static bool IsUnassignedClassDocumentation(Documentation d) =>
d.StudentId == Guid.Empty && d.GroupId is null;
private static bool NameContainsSearchTerm(string name, string search)
{
if (UntisNameMatching.NamesMatch(name, search)) return true;
var searchKey = UntisNameMatching.NameKey(search);
return searchKey.Length > 0 && UntisNameMatching.NameKey(name).Split(' ').Contains(searchKey);
}
private static string DocumentationStudentDisplay(Documentation d,
IReadOnlyList<(Guid StudentId, string DisplayName)> rosterMatches)
{
if (d.StudentId != Guid.Empty)
return rosterMatches.FirstOrDefault(m => m.StudentId == d.StudentId).DisplayName
?? "Unbekannter Schülerbezug";
return d.Participants.Count > 0
? $"Ohne festen Bezug · {string.Join(", ", d.Participants)}"
: "Ohne Schülerbezug";
}
[RelayCommand]
private async Task AddOwnDocumentation()
{
if (OnEditOwnDocumentation is null || !CanAddOwnDocumentation) return;
var options = _rosterMatches.Select(m => new StudentOption(m.StudentId, m.DisplayName)).ToList();
var result = await OnEditOwnDocumentation(options, null);
if (result is null) return;
_documentation.Save(result);
LoadOwnDocumentationEntries();
}
[RelayCommand]
private async Task EditOwnDocumentation(DocumentationItem? item)
{
if (item is null || OnEditOwnDocumentation is null) return;
var options = _rosterMatches.Select(m => new StudentOption(m.StudentId, m.DisplayName)).ToList();
var result = await OnEditOwnDocumentation(options, item.Model);
if (result is null) return;
_documentation.Save(result);
LoadOwnDocumentationEntries();
}
[RelayCommand]
private async Task DeleteOwnDocumentation(DocumentationItem? item)
{
if (item is null) return;
if (OnConfirmDeleteOwnDocumentation is not null && !await OnConfirmDeleteOwnDocumentation(item)) return;
_documentation.Delete(item.Model.Id);
LoadOwnDocumentationEntries();
}
/// Für den "→ Vorgang"-Auswahldialog im Code-behind: offene Vorgänge, an die sich diese
/// Klassenbuchzeile anheften lässt (Durchreiche zur Geschwister-ViewModel-Instanz, siehe _cases).
public List<VorgangItem> OpenCasesFor(string studentName) => _cases.OpenCasesForStudent(studentName);
/// Heftet eine WebUntis-Klassenbuchzeile als eingefrorene Kopie an einen (ggf. neuen) Vorgang
/// im "Vorgänge"-Tab (<see cref="ClassTeacherCasesViewModel"/>) an — die Zeile selbst hat keine
/// stabile ID, deshalb Werte-Kopie statt Referenz (siehe VorgangClassRegisterEntry).
[RelayCommand]
private async Task PinToVorgang(ClassTeacherClassRegisterRow? row)
{
if (row is null || OnPickVorgangForPin is null) return;
var choice = await OnPickVorgangForPin(row);
if (choice is null) return;
var entry = BuildClassRegisterSnapshot(row);
if (choice.ExistingVorgangId is { } vorgangId)
await _cases.PinClassRegisterEntryAsync(vorgangId, entry);
else if (!string.IsNullOrWhiteSpace(choice.NewVorgangTitle))
{
var student = _cases.RosterStudentOptions
.FirstOrDefault(s => UntisNameMatching.NamesMatch(s.Name, row.StudentName));
if (student is null) return;
await _cases.CreateAndPinAsync(choice.NewVorgangTitle, student.Id, entry);
}
}
/// Baut die eingefrorene Klassenbuch-Kopie aus einer Zeile — reine, ohne Repository-Zugriff
/// testbare Umwandlung.
public static VorgangClassRegisterEntry BuildClassRegisterSnapshot(ClassTeacherClassRegisterRow row) => new()
{
Date = row.Date, StudentName = row.StudentName, Subject = row.Subject,
TeacherUsername = row.TeacherUsername, CategoryName = row.CategoryName,
CategoryGroup = row.CategoryGroup, Text = row.Text,
};
// WebUntis liefert Namen je nach Bericht in anderer Reihenfolge als der Schülerreport, aus dem // 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 // StudentFilter beim Klick in der Übersicht gesetzt wird (siehe UntisNameMatching) - ein
// exakter String-Vergleich hier ließ die gefilterten Listen fälschlich leer erscheinen. // exakter String-Vergleich hier ließ die gefilterten Listen fälschlich leer erscheinen.
@@ -247,6 +575,11 @@ public partial class ClassTeacherDetailsViewModel : ObservableObject
{ {
OnPropertyChanged(nameof(HasEntries)); OnPropertyChanged(nameof(HasEntries));
OnPropertyChanged(nameof(HasAbsenceEntries)); OnPropertyChanged(nameof(HasAbsenceEntries));
OnPropertyChanged(nameof(ShowAbsenceListEmpty));
OnPropertyChanged(nameof(HasCategoryAggregates)); OnPropertyChanged(nameof(HasCategoryAggregates));
OnPropertyChanged(nameof(ShowCategoryAggregates));
OnPropertyChanged(nameof(HasOwnDocumentationEntries));
OnPropertyChanged(nameof(CanAddOwnDocumentation));
OnPropertyChanged(nameof(UntisCriticalCount));
} }
} }
@@ -14,6 +14,10 @@ namespace LehrerApp.Desktop.ViewModels.ClassTeacher;
/// ein hart codierter Hex-String aus dem ViewModel bliebe beim Wechsel unverändert stehen.</summary> /// ein hart codierter Hex-String aus dem ViewModel bliebe beim Wechsel unverändert stehen.</summary>
public enum ClassTeacherStatusKind { Ok, Info, Warning, Danger } public enum ClassTeacherStatusKind { Ok, Info, Warning, Danger }
/// <summary>Die drei Auffälligkeits-Achsen, die ein geschlossener <see cref="Vorgang"/> per
/// Schlagwort gezielt dämpfen kann, siehe <see cref="ClassTeacherRosterRow.MatchDomains"/>.</summary>
public enum VorgangScoreDomain { Attendance, Lateness, Classbook }
public sealed record ClassTeacherRosterRow(string StudentName, int? ExternKey, bool HasAbsenceToday, public sealed record ClassTeacherRosterRow(string StudentName, int? ExternKey, bool HasAbsenceToday,
string? AbsenceTooltip, bool HasRecentClassRegisterEntry) string? AbsenceTooltip, bool HasRecentClassRegisterEntry)
{ {
@@ -73,8 +77,35 @@ public sealed record ClassTeacherRosterRow(string StudentName, int? ExternKey, b
/// Kumulierte Fehlzeiten seit Schuljahresbeginn (Nutzer-Feedback: der Heute-Snapshot allein /// Kumulierte Fehlzeiten seit Schuljahresbeginn (Nutzer-Feedback: der Heute-Snapshot allein
/// sagt für Zeugnis/Attestpflicht wenig aus). <see cref="SchoolDaysElapsed"/> zählt Werktage /// sagt für Zeugnis/Attestpflicht wenig aus). <see cref="SchoolDaysElapsed"/> zählt Werktage
/// abzüglich der über WebUntis geladenen Ferien (<see cref="ClassTeacherOverviewViewModel.CountSchoolWeekdays"/>). /// abzüglich der über WebUntis geladenen Ferien (<see cref="ClassTeacherOverviewViewModel.CountSchoolWeekdays"/>).
/// Zählt bewusst NUR echte Fehltage (unentschuldigt oder entschuldigt abwesend), keine reinen
/// Verspätungstage mehr — Nutzer-Feedback: die Quote behandelte "10× 5 Min. verspätet" bisher
/// exakt wie "10× ganztägig unentschuldigt gefehlt", obwohl beides für die eigene Reaktion
/// (Elterngespräch vs. Achselzucken) grundverschieden ist. Reine Verspätung landet stattdessen
/// separat in <see cref="YearLateDayCount"/> (gleiche Kategorisierung wie schon länger im
/// Wochentrend, siehe <see cref="ClassTeacherOverviewViewModel.BuildTrend"/>: Unentschuldigt vor
/// Verspätet vor Entschuldigt, überschneidungsfrei).
public int YearAbsenceDayCount { get; init; } public int YearAbsenceDayCount { get; init; }
public int YearUnexcusedDayCount { get; init; } public int YearUnexcusedDayCount { get; init; }
/// Tage, die WebUntis als Verspätung führt (ohne Unentschuldigt-Status) — zählen bewusst nicht
/// in <see cref="YearAbsenceDayCount"/>/<see cref="YearAbsenceRatePercent"/>, tragen aber mit
/// reduziertem Gewicht zu <see cref="PatternScore"/> bei.
public int YearLateDayCount { get; init; }
/// Entschuldigt abwesende Tage (weder unentschuldigt noch nur verspätet) — Kehrwert von
/// <see cref="YearUnexcusedDayCount"/> innerhalb von <see cref="YearAbsenceDayCount"/>.
public int YearExcusedDayCount { get; init; }
/// Klassenbucheinträge anderer Lehrkräfte über das ganze bisherige Schuljahr mit
/// <c>CategoryGroup</c> "Negativ" (Nutzer-Feedback: sollen ebenfalls ins Auffälligkeitsbild
/// einfließen — "wenn die negativen Einträge durch die Decke gehen"). Anders als
/// <see cref="HasRecentClassRegisterEntry"/> (nur letzte 7 Tage, für die "Klassenbuch"-Badges)
/// bewusst über das ganze Jahr, damit sich ein Muster über die Zeit zeigen kann.
public int YearNegativeClassRegisterCount { get; init; }
/// Mindestens ein Klassenbucheintrag mit dem Schlüsselwort "suspendier(t)" im Zeitraum — soll
/// den Score unabhängig von allem anderen sofort deutlich nach oben treiben (Nutzer-Feedback:
/// "Suspendiert sollte direkt hochschießen").
public bool HasSuspensionEntry { get; init; }
/// Gewichtete Summe aus <see cref="ClassRegisterEntryWeight"/> über alle Klassenbucheinträge
/// des Schuljahres — fließt in <see cref="PatternScore"/> ein.
public double ClassRegisterScoreComponent { get; init; }
public int SchoolDaysElapsed { get; init; } public int SchoolDaysElapsed { get; init; }
/// Nutzer-Feedback: nachdem der Nenner zeitweise Ferientage mitzählte (siehe TODO.md), soll die /// Nutzer-Feedback: nachdem der Nenner zeitweise Ferientage mitzählte (siehe TODO.md), soll die
/// Herkunft der Zahl nachvollziehbar bleiben, ohne dafür die WebUntis-Ferienliste separat /// Herkunft der Zahl nachvollziehbar bleiben, ohne dafür die WebUntis-Ferienliste separat
@@ -85,19 +116,134 @@ public sealed record ClassTeacherRosterRow(string StudentName, int? ExternKey, b
/// sichtbar — WebUntis' Ferienkalender deckt die Sommerferien selbst nicht ab (siehe TODO.md), /// sichtbar — WebUntis' Ferienkalender deckt die Sommerferien selbst nicht ab (siehe TODO.md),
/// die Korrektur passiert also am Startpunkt, nicht an abgezogenen Tagen mittendrin. /// die Korrektur passiert also am Startpunkt, nicht an abgezogenen Tagen mittendrin.
public DateOnly TermStart { get; init; } public DateOnly TermStart { get; init; }
public bool HasYearSummary => SchoolDaysElapsed > 0 && YearAbsenceDayCount > 0; public bool HasYearSummary => SchoolDaysElapsed > 0 &&
(YearAbsenceDayCount > 0 || YearLateDayCount > 0 || YearNegativeClassRegisterCount > 0);
public int YearAbsenceRatePercent => public int YearAbsenceRatePercent =>
SchoolDaysElapsed <= 0 ? 0 : (int)Math.Round(100d * YearAbsenceDayCount / SchoolDaysElapsed); SchoolDaysElapsed <= 0 ? 0 : (int)Math.Round(100d * YearAbsenceDayCount / SchoolDaysElapsed);
public string YearSummaryLabel => HasYearSummary public string YearSummaryLabel => !HasYearSummary ? "" :
? $"{YearAbsenceRatePercent} % Fehlzeit seit Schuljahresbeginn" : ""; YearAbsenceDayCount > 0 ? $"{YearAbsenceRatePercent} % Fehlzeit seit Schuljahresbeginn"
: YearLateDayCount > 0 ? $"{YearLateDayCount}× verspätet, keine Fehltage seit Schuljahresbeginn"
: $"{YearNegativeClassRegisterCount} negative Klassenbucheinträge seit Schuljahresbeginn";
public string? YearSummaryTooltip => !HasYearSummary ? null : public string? YearSummaryTooltip => !HasYearSummary ? null :
$"{YearAbsenceDayCount} von {SchoolDaysElapsed} Schultagen seit {TermStart:dd.MM.} mit Fehlzeit" + (YearAbsenceDayCount > 0
(YearUnexcusedDayCount > 0 ? $" · {YearUnexcusedDayCount} unentschuldigt" : "") + ? $"{YearAbsenceDayCount} von {SchoolDaysElapsed} Schultagen seit {TermStart:dd.MM.} mit Fehlzeit" +
(YearUnexcusedDayCount > 0 ? $" · {YearUnexcusedDayCount} unentschuldigt" : "")
: $"Keine Fehltage seit {TermStart:dd.MM.}") +
(YearLateDayCount > 0 ? $" · {YearLateDayCount} Tage verspätet (zählt nicht zur Quote)" : "") +
(YearNegativeClassRegisterCount > 0 ? $" · {YearNegativeClassRegisterCount} negative Klassenbucheinträge" : "") +
(HasSuspensionEntry ? " · Suspendierung vermerkt" : "") +
(HolidayWeekdaysExcluded > 0 ? $" · {HolidayWeekdaysExcluded} Ferientage abgezogen" : ""); (HolidayWeekdaysExcluded > 0 ? $" · {HolidayWeekdaysExcluded} Ferientage abgezogen" : "");
private static readonly string[] ConcerningKeywords = ["hausaufgabe", "mitarbeit"];
private static readonly string[] SuspensionKeywords = ["suspendier"];
/// Auffälligkeits-Score über das ganze bisherige Schuljahr statt nur "heute" (Nutzer-Feedback:
/// wer heute da ist, aber davor 5 Tage unentschuldigt fehlte, verschwand bisher komplett aus der
/// Liste, während der Musterschüler beim ersten Fehltag des Jahres auf Rang eins landete, sobald
/// er der/die einzige "heute Auffällige" war). Reine Summe ohne zeitliche Abklingung — für die
/// vom Nutzer genannten Fälle (Muster über die letzten Tage/Wochen) reicht das, ohne die
/// zusätzliche Komplexität einer Verfallskurve. Bezieht neben Fehlzeiten auch Klassenbucheinträge
/// anderer Lehrkräfte mit ein (<see cref="ClassRegisterScoreComponent"/>) — beides sind
/// unabhängige Auffälligkeits-Achsen (Anwesenheit vs. Verhalten/Leistung), die sich addieren
/// statt sich gegenseitig zu verdrängen. Gewichte sind einstellbar (<see cref="PatternScoreWeights"/>,
/// Nutzer-Entscheidung nach anfänglich festen Konstanten) und die Bereiche eines geschlossenen
/// <see cref="Vorgang"/> sind ab dessen Schließ-Datum aus dem Score ausgeklammert (<see cref="Build"/>),
/// deshalb jetzt ein zur Build-Zeit berechneter Wert statt einer reinen Formel über die
/// (unveränderten, für die Quote weiter vollständigen) Year*-Zähler.
public double PatternScore { get; init; }
/// Score-Anteil, der NACH dem Schließen des zuletzt geschlossenen Vorgangs dieser/dieses
/// Schülers*in neu hinzugekommen ist, beschränkt auf dessen eigene Themenbereiche
/// (<see cref="MatchDomains"/>) — Grundlage für die "erneut auffällig"-Warnung. 0 ohne
/// geschlossenen Vorgang oder ohne neue Ereignisse seitdem.
public double RegressionScore { get; init; }
public string? RegressionVorgangTitle { get; init; }
private static bool IsLateReason(string reason) => private static bool IsLateReason(string reason) =>
reason.Contains("verspät", StringComparison.OrdinalIgnoreCase); reason.Contains("verspät", StringComparison.OrdinalIgnoreCase);
private static bool IsNegativeClassRegisterEntry(UntisForeignClassRegisterEventDto e) =>
e.CategoryGroup?.Contains("Negativ", StringComparison.OrdinalIgnoreCase) == true;
private static bool MatchesAnyKeyword(UntisForeignClassRegisterEventDto e, IReadOnlyList<string> keywords) =>
keywords.Any(k =>
e.CategoryName?.Contains(k, StringComparison.OrdinalIgnoreCase) == true ||
e.Text?.Contains(k, StringComparison.OrdinalIgnoreCase) == true);
private static double ClassRegisterEntryWeight(UntisForeignClassRegisterEventDto e, PatternScoreWeights weights)
{
var weight = 0.0;
if (IsNegativeClassRegisterEntry(e)) weight += weights.NegativeClassRegisterEntryWeight;
if (MatchesAnyKeyword(e, ConcerningKeywords)) weight += weights.ConcerningKeywordBonus;
if (MatchesAnyKeyword(e, SuspensionKeywords)) weight += weights.SuspensionKeywordWeight;
return weight;
}
// ── Dämpfung nach geschlossenem Vorgang (Nutzer-Entscheidung: bereichsbezogen statt komplett) ──
private static readonly string[] AttendanceDomainKeywords = ["absent", "fehlzeit", "schwänz", "unentschuldigt"];
private static readonly string[] LatenessDomainKeywords = ["versp"];
private static readonly string[] ClassbookDomainKeywords =
["konflikt", "eskalat", "mitarbeit", "hausaufgabe", "klassenbuch"];
/// Ordnet die Freitext-Schlagwörter eines Vorgangs den Score-Bereichen zu, die er dämpfen soll
/// (gleiche Contains-Konvention wie die "Negativ"/"verspät"-Erkennung andernorts). Kein Treffer
/// auf irgendein Schlagwort -> alle drei Bereiche, damit ein geschlossener Vorgang nie
/// folgenlos bleibt (z.B. bei einem reinen "Elternkontakt"-Schlagwort ohne klare Zuordnung).
public static IReadOnlySet<VorgangScoreDomain> MatchDomains(IReadOnlyList<string> tags)
{
var domains = new HashSet<VorgangScoreDomain>();
if (tags.Any(t => AttendanceDomainKeywords.Any(k => t.Contains(k, StringComparison.OrdinalIgnoreCase))))
domains.Add(VorgangScoreDomain.Attendance);
if (tags.Any(t => LatenessDomainKeywords.Any(k => t.Contains(k, StringComparison.OrdinalIgnoreCase))))
domains.Add(VorgangScoreDomain.Lateness);
if (tags.Any(t => ClassbookDomainKeywords.Any(k => t.Contains(k, StringComparison.OrdinalIgnoreCase))))
domains.Add(VorgangScoreDomain.Classbook);
if (domains.Count == 0)
domains = [VorgangScoreDomain.Attendance, VorgangScoreDomain.Lateness, VorgangScoreDomain.Classbook];
return domains;
}
/// Pro Bereich das jüngste Schließ-Datum unter den passenden geschlossenen Vorgängen — Ereignisse
/// vor diesem Datum zählen für diesen Bereich nicht mehr in <see cref="PatternScore"/>, die volle
/// Historie (Year*-Zähler, Quote) bleibt davon unberührt.
public static IReadOnlyDictionary<VorgangScoreDomain, DateOnly> ComputeDampeningCutoffs(
IReadOnlyList<Vorgang> closedVorgaenge)
{
var cutoffs = new Dictionary<VorgangScoreDomain, DateOnly>();
foreach (var v in closedVorgaenge)
{
if (v.ClosedAt is not { } closedAt) continue;
var closedDate = DateOnly.FromDateTime(closedAt);
foreach (var domain in MatchDomains(v.Tags))
if (!cutoffs.TryGetValue(domain, out var existing) || closedDate > existing)
cutoffs[domain] = closedDate;
}
return cutoffs;
}
/// Gemeinsame Score-Formel für <see cref="PatternScore"/> und <see cref="RegressionScore"/>:
/// <paramref name="cutoffFor"/> liefert je Bereich das früheste zählende Datum (Standard
/// <see cref="DateOnly.MinValue"/> = alles zählt, <see cref="DateOnly.MaxValue"/> = Bereich
/// bewusst ausgeklammert — so lässt sich derselbe Code für "gedämpfter Gesamt-Score" und
/// "nur die Bereiche eines einzelnen Vorgangs, nur danach" wiederverwenden).
private static double ComputeScore(IReadOnlyList<ClassAbsenceDaySummaryRow> yearEntries,
IReadOnlyList<UntisForeignClassRegisterEventDto> yearRegisterEntries,
Func<VorgangScoreDomain, DateOnly> cutoffFor, PatternScoreWeights weights)
{
var attendanceCutoff = cutoffFor(VorgangScoreDomain.Attendance);
var latenessCutoff = cutoffFor(VorgangScoreDomain.Lateness);
var classbookCutoff = cutoffFor(VorgangScoreDomain.Classbook);
var unexcused = yearEntries.Count(r => r.IsUnexcused && r.Date >= attendanceCutoff);
var excused = yearEntries.Count(r => !r.IsUnexcused && !r.IsLate && r.Date >= attendanceCutoff);
var late = yearEntries.Count(r => !r.IsUnexcused && r.IsLate && r.Date >= latenessCutoff);
var classRegister = yearRegisterEntries
.Where(e => TryDate(e.Date, out var d) && d >= classbookCutoff)
.Sum(e => ClassRegisterEntryWeight(e, weights));
return unexcused * weights.UnexcusedDayWeight + excused * weights.ExcusedDayWeight +
late * weights.LateDayWeight + classRegister;
}
public static IReadOnlyList<ClassTeacherRosterRow> Build( public static IReadOnlyList<ClassTeacherRosterRow> Build(
IReadOnlyList<UntisStudentRosterCacheEntry> students, IReadOnlyList<UntisStudentRosterCacheEntry> students,
IReadOnlyList<ClassAbsenceDaySummaryRow> todayAbsences, IReadOnlyList<ClassAbsenceDaySummaryRow> todayAbsences,
@@ -106,8 +252,12 @@ public sealed record ClassTeacherRosterRow(string StudentName, int? ExternKey, b
IReadOnlyList<ClassAbsenceDaySummaryRow>? yearAbsences = null, IReadOnlyList<ClassAbsenceDaySummaryRow>? yearAbsences = null,
int schoolDaysElapsed = 0, int schoolDaysElapsed = 0,
int holidayWeekdaysExcluded = 0, int holidayWeekdaysExcluded = 0,
DateOnly termStart = default) DateOnly termStart = default,
IReadOnlyList<UntisForeignClassRegisterEventDto>? yearClassRegisterEntries = null,
PatternScoreWeights? weights = null,
IReadOnlyDictionary<string, IReadOnlyList<Vorgang>>? closedVorgaengeByNameKey = null)
{ {
var effectiveWeights = weights ?? new PatternScoreWeights();
var referenceDate = today ?? todayAbsences.FirstOrDefault()?.Date ?? DateOnly.FromDateTime(DateTime.Today); var referenceDate = today ?? todayAbsences.FirstOrDefault()?.Date ?? DateOnly.FromDateTime(DateTime.Today);
var absenceByKey = todayAbsences.Where(a => a.ExternKey is not null) var absenceByKey = todayAbsences.Where(a => a.ExternKey is not null)
.GroupBy(a => a.ExternKey!.Value).ToDictionary(g => g.Key, g => g.First()); .GroupBy(a => a.ExternKey!.Value).ToDictionary(g => g.Key, g => g.First());
@@ -115,6 +265,12 @@ public sealed record ClassTeacherRosterRow(string StudentName, int? ExternKey, b
.ToDictionary(g => g.Key, g => g.First()); .ToDictionary(g => g.Key, g => g.First());
var recentByName = recentClassRegisterEntries.GroupBy(e => UntisNameMatching.NameKey(e.StudentName)) var recentByName = recentClassRegisterEntries.GroupBy(e => UntisNameMatching.NameKey(e.StudentName))
.ToDictionary(g => g.Key, g => g.ToList()); .ToDictionary(g => g.Key, g => g.ToList());
// Für den Score bewusst über das ganze Schuljahr statt nur die letzten 7 Tage (anders als
// recentClassRegisterEntries oben, das für HasRecentClassRegisterEntry/-Badges weiterhin nur
// die aktuelle Woche abbildet) — fällt auf recentClassRegisterEntries zurück, wenn der
// Aufrufer keine eigene Jahresliste mitgibt (z.B. bestehende Tests).
var yearRegisterByName = (yearClassRegisterEntries ?? recentClassRegisterEntries)
.GroupBy(e => UntisNameMatching.NameKey(e.StudentName)).ToDictionary(g => g.Key, g => g.ToList());
var yearRows = yearAbsences ?? []; var yearRows = yearAbsences ?? [];
var yearByKey = yearRows.Where(a => a.ExternKey is not null) var yearByKey = yearRows.Where(a => a.ExternKey is not null)
.GroupBy(a => a.ExternKey!.Value).ToDictionary(g => g.Key, g => g.ToList()); .GroupBy(a => a.ExternKey!.Value).ToDictionary(g => g.Key, g => g.ToList());
@@ -129,14 +285,44 @@ public sealed record ClassTeacherRosterRow(string StudentName, int? ExternKey, b
var registerEntries = recentByName.GetValueOrDefault(nameKey) ?? []; var registerEntries = recentByName.GetValueOrDefault(nameKey) ?? [];
var yearEntries = (student.ExternKey is { } yearKey ? yearByKey.GetValueOrDefault(yearKey) : null) var yearEntries = (student.ExternKey is { } yearKey ? yearByKey.GetValueOrDefault(yearKey) : null)
?? yearByName.GetValueOrDefault(nameKey) ?? []; ?? yearByName.GetValueOrDefault(nameKey) ?? [];
var yearRegisterEntries = yearRegisterByName.GetValueOrDefault(nameKey) ?? [];
// Dämpfung/Rückfall (Nutzer-Entscheidung: bereichsbezogen statt kompletter Reset).
var closedVorgaenge = closedVorgaengeByNameKey?.GetValueOrDefault(nameKey) ?? [];
var dampeningCutoffs = ComputeDampeningCutoffs(closedVorgaenge);
var patternScore = ComputeScore(yearEntries, yearRegisterEntries,
d => dampeningCutoffs.GetValueOrDefault(d, DateOnly.MinValue), effectiveWeights);
var mostRecentClosed = closedVorgaenge.Where(v => v.ClosedAt.HasValue)
.OrderByDescending(v => v.ClosedAt).FirstOrDefault();
var regressionScore = 0.0;
if (mostRecentClosed is not null)
{
var relevantDomains = MatchDomains(mostRecentClosed.Tags);
var closedDate = DateOnly.FromDateTime(mostRecentClosed.ClosedAt!.Value);
regressionScore = ComputeScore(yearEntries, yearRegisterEntries,
d => relevantDomains.Contains(d) ? closedDate : DateOnly.MaxValue, effectiveWeights);
}
return new ClassTeacherRosterRow(student.DisplayName, student.ExternKey, absence is not null, return new ClassTeacherRosterRow(student.DisplayName, student.ExternKey, absence is not null,
absence is null ? null : $"{absence.TotalAbsentPeriods} Stunde(n) — {absence.StatusLabel}", absence is null ? null : $"{absence.TotalAbsentPeriods} Stunde(n) — {absence.StatusLabel}",
registerEntries.Count > 0) registerEntries.Count > 0)
{ {
TodayAbsence = absence, TodayAbsence = absence,
HasClassRegisterToday = registerEntries.Any(e => TryDate(e.Date, out var date) && date == referenceDate), HasClassRegisterToday = registerEntries.Any(e => TryDate(e.Date, out var date) && date == referenceDate),
YearAbsenceDayCount = yearEntries.Count, // Unentschuldigt vor Verspätet vor Entschuldigt, überschneidungsfrei — dieselbe
// Kategorisierung wie im Wochentrend (BuildTrend). Nur Verspätung (nicht auch
// unentschuldigt) fällt bewusst aus YearAbsenceDayCount heraus, siehe dort.
YearAbsenceDayCount = yearEntries.Count(r => r.IsUnexcused || !r.IsLate),
YearUnexcusedDayCount = yearEntries.Count(r => r.IsUnexcused), YearUnexcusedDayCount = yearEntries.Count(r => r.IsUnexcused),
YearLateDayCount = yearEntries.Count(r => !r.IsUnexcused && r.IsLate),
YearExcusedDayCount = yearEntries.Count(r => !r.IsUnexcused && !r.IsLate),
YearNegativeClassRegisterCount = yearRegisterEntries.Count(IsNegativeClassRegisterEntry),
HasSuspensionEntry = yearRegisterEntries.Any(e => MatchesAnyKeyword(e, SuspensionKeywords)),
ClassRegisterScoreComponent = yearRegisterEntries.Sum(e => ClassRegisterEntryWeight(e, effectiveWeights)),
PatternScore = patternScore,
RegressionScore = regressionScore,
RegressionVorgangTitle = mostRecentClosed?.Title,
SchoolDaysElapsed = schoolDaysElapsed, SchoolDaysElapsed = schoolDaysElapsed,
HolidayWeekdaysExcluded = holidayWeekdaysExcluded, HolidayWeekdaysExcluded = holidayWeekdaysExcluded,
TermStart = termStart, TermStart = termStart,
@@ -206,6 +392,14 @@ public sealed record ClassTeacherOpenExcuseRow(string StudentName, DateOnly Date
.ToList(); .ToList();
} }
/// <summary>Eine einzelne, auf ein Minimum verdichtete Monatszelle im Übersichts-Widget.</summary>
public sealed record ClassTeacherCompactCalendarDay(DateOnly Date, string SignalCode,
string SignalColorHex, string Tooltip, bool IsToday)
{
public string DayNumber => Date.Day.ToString();
public bool HasSignal => !string.IsNullOrEmpty(SignalCode);
}
public partial class ClassTeacherOverviewViewModel : ObservableObject public partial class ClassTeacherOverviewViewModel : ObservableObject
{ {
private readonly WebUntisSettingsService _settings; private readonly WebUntisSettingsService _settings;
@@ -214,24 +408,33 @@ public partial class ClassTeacherOverviewViewModel : ObservableObject
private readonly SchoolYearService _schoolYear; private readonly SchoolYearService _schoolYear;
private readonly IWorkTaskRepository _workTasks; private readonly IWorkTaskRepository _workTasks;
private readonly IStudentRepository _students; private readonly IStudentRepository _students;
private readonly IDocumentationRepository _documentation;
private readonly IVorgangRepository _vorgaenge;
private readonly PatternScoreSettingsService _patternScoreSettings;
private readonly IParticipationRepository _participation; private readonly IParticipationRepository _participation;
private readonly IParticipationSessionRepository _participationSessions; private readonly IParticipationSessionRepository _participationSessions;
private readonly AppLogger? _logger; private readonly AppLogger? _logger;
public ClassTeacherDetailsViewModel DetailsTab { get; } public ClassTeacherDetailsViewModel DetailsTab { get; }
public ClassTeacherCasesViewModel CasesTab { get; }
public ObservableCollection<ClassTeacherRosterRow> Roster { get; } = []; public ObservableCollection<ClassTeacherRosterRow> Roster { get; } = [];
public ObservableCollection<ClassTeacherRosterRow> PrimaryRoster { get; } = []; public ObservableCollection<ClassTeacherRosterRow> PrimaryRoster { get; } = [];
public ObservableCollection<ClassTeacherRosterRow> SecondaryRoster { get; } = []; public ObservableCollection<ClassTeacherRosterRow> SecondaryRoster { get; } = [];
public ObservableCollection<ClassTeacherTrendDay> TrendDays { get; } = []; public ObservableCollection<ClassTeacherTrendDay> TrendDays { get; } = [];
public ObservableCollection<ClassTeacherPatternNotice> PatternNotices { get; } = []; public ObservableCollection<ClassTeacherPatternNotice> PatternNotices { get; } = [];
public ObservableCollection<ClassTeacherOpenExcuseRow> OpenExcuses { get; } = []; public ObservableCollection<ClassTeacherOpenExcuseRow> OpenExcuses { get; } = [];
public ObservableCollection<ClassTeacherCompactCalendarDay> CompactMonthDays { get; } = [];
[ObservableProperty] private string? _homeroomClassName; [ObservableProperty] private string? _homeroomClassName;
[ObservableProperty] private int _activeTabIndex; [ObservableProperty] private int _activeTabIndex;
[ObservableProperty] private string _status = ""; [ObservableProperty] private string _status = "";
[ObservableProperty] private bool _busy; [ObservableProperty] private bool _busy;
[ObservableProperty] private string _searchText = ""; [ObservableProperty] private string _searchText = "";
[ObservableProperty] private int _selectedRosterFilter; /// Standardmäßig der Muster-Reiter (3) statt "Auffällig" (0) — Nutzer-Feedback: die reine
/// Heute-Ansicht lässt Schüler*innen mit Vorgeschichte (z.B. 5 Tage unentschuldigt letzte
/// Woche, heute aber da) komplett verschwinden, während ein erstmaliger Einzelfehltag ganz oben
/// steht, sobald er der/die einzige "heute Auffällige" ist.
[ObservableProperty] private int _selectedRosterFilter = 3;
[ObservableProperty] private string _primarySectionTitle = "Heute auffällig"; [ObservableProperty] private string _primarySectionTitle = "Heute auffällig";
[ObservableProperty] private string _secondarySectionTitle = "Weitere Schüler*innen"; [ObservableProperty] private string _secondarySectionTitle = "Weitere Schüler*innen";
[ObservableProperty] private int _studentCount; [ObservableProperty] private int _studentCount;
@@ -244,6 +447,19 @@ public partial class ClassTeacherOverviewViewModel : ObservableObject
[ObservableProperty] private int _unexcusedAbsenceCount; [ObservableProperty] private int _unexcusedAbsenceCount;
[ObservableProperty] private string _lastUpdatedLabel = "Noch nicht aktualisiert"; [ObservableProperty] private string _lastUpdatedLabel = "Noch nicht aktualisiert";
[ObservableProperty] private int _openExcuseOverflowCount; [ObservableProperty] private int _openExcuseOverflowCount;
/// Eigene Dokumentation zu Schüler*innen der Klasse mit "Nacharbeiten"-Status bzw. dem
/// "Kritisch"-Tag — Kurzform derselben Zählung wie im Klassenbuch-Tab (siehe
/// <see cref="ClassTeacherDetailsViewModel.OwnDocumentationFollowUpCount"/>), hier direkt an
/// den "Klassenbuch öffnen"-Button gehängt statt in einer eigenen Kennzahlkarte.
[ObservableProperty] private int _ownDocumentationFollowUpCount;
[ObservableProperty] private int _ownDocumentationCriticalCount;
// Zuletzt per Load() geholte WebUntis-Rohdaten, für RefreshFromLocalDataOnly() - damit ein
// eingehendes Sync-Ereignis die Ansicht neu aufbauen kann, ohne selbst WebUntis anzufragen.
private IReadOnlyList<UntisStudentRosterCacheEntry>? _lastStudents;
private IReadOnlyList<UntisClassAbsenceEntryDto>? _lastAbsences;
private IReadOnlyList<UntisForeignClassRegisterEventDto>? _lastClassRegisterEvents;
private IReadOnlyList<CachedUntisHoliday>? _lastHolidays;
public bool HomeroomClassConfigured => !string.IsNullOrWhiteSpace(HomeroomClassName); public bool HomeroomClassConfigured => !string.IsNullOrWhiteSpace(HomeroomClassName);
public bool HasPrimaryRoster => PrimaryRoster.Count > 0; public bool HasPrimaryRoster => PrimaryRoster.Count > 0;
@@ -252,9 +468,11 @@ public partial class ClassTeacherOverviewViewModel : ObservableObject
public bool HasPatternNotices => PatternNotices.Count > 0; public bool HasPatternNotices => PatternNotices.Count > 0;
public bool HasOpenExcuses => OpenExcuses.Count > 0; public bool HasOpenExcuses => OpenExcuses.Count > 0;
public bool HasOpenExcuseOverflow => OpenExcuseOverflowCount > 0; public bool HasOpenExcuseOverflow => OpenExcuseOverflowCount > 0;
public bool HasOwnDocumentationAlerts => OwnDocumentationFollowUpCount > 0 || OwnDocumentationCriticalCount > 0;
public bool AlertsFilterSelected => SelectedRosterFilter == 0; public bool AlertsFilterSelected => SelectedRosterFilter == 0;
public bool ClassRegisterFilterSelected => SelectedRosterFilter == 1; public bool ClassRegisterFilterSelected => SelectedRosterFilter == 1;
public bool AllFilterSelected => SelectedRosterFilter == 2; public bool AllFilterSelected => SelectedRosterFilter == 2;
public bool PatternScoreFilterSelected => SelectedRosterFilter == 3;
public int TodayUnexcusedPercent => Percent(TodayUnexcusedCount); public int TodayUnexcusedPercent => Percent(TodayUnexcusedCount);
public int LatePercent => Percent(LateCount); public int LatePercent => Percent(LateCount);
public int PresentPercent => Percent(PresentCount); public int PresentPercent => Percent(PresentCount);
@@ -283,15 +501,18 @@ public partial class ClassTeacherOverviewViewModel : ObservableObject
public double UnexcusedAbsenceFraction => StudentCount == 0 ? 0 : (double)UnexcusedAbsenceCount / StudentCount; public double UnexcusedAbsenceFraction => StudentCount == 0 ? 0 : (double)UnexcusedAbsenceCount / StudentCount;
public string DayOverviewTooltip => $"{PresentCount} anwesend · {LateCount} verspätet · " + public string DayOverviewTooltip => $"{PresentCount} anwesend · {LateCount} verspätet · " +
$"{ExcusedAbsenceCount} entschuldigt · {UnexcusedAbsenceCount} unentschuldigt"; $"{ExcusedAbsenceCount} entschuldigt · {UnexcusedAbsenceCount} unentschuldigt";
public string CompactMonthLabel => DateOnly.FromDateTime(DateTime.Today).ToString("MMMM yyyy",
System.Globalization.CultureInfo.GetCultureInfo("de-DE"));
public Func<Task>? OnNavigateToSettings { get; set; } public Func<Task>? OnNavigateToSettings { get; set; }
public Func<Task>? OnNavigateToWorkload { get; set; } public Func<Task>? OnNavigateToWorkload { get; set; }
public ClassTeacherOverviewViewModel(WebUntisSettingsService settings, WebUntisIntegrationService untis, public ClassTeacherOverviewViewModel(WebUntisSettingsService settings, WebUntisIntegrationService untis,
UntisReportCacheService cache, SchoolYearService schoolYear, IWorkTaskRepository workTasks, UntisReportCacheService cache, SchoolYearService schoolYear, IWorkTaskRepository workTasks,
IStudentRepository students, IParticipationRepository participation, IStudentRepository students, IDocumentationRepository documentation, IVorgangRepository vorgaenge,
PatternScoreSettingsService patternScoreSettings, IParticipationRepository participation,
IParticipationSessionRepository participationSessions, ClassTeacherDetailsViewModel detailsTab, IParticipationSessionRepository participationSessions, ClassTeacherDetailsViewModel detailsTab,
AppLogger? logger = null) ClassTeacherCasesViewModel casesTab, AppLogger? logger = null)
{ {
_settings = settings; _settings = settings;
_untis = untis; _untis = untis;
@@ -299,30 +520,65 @@ public partial class ClassTeacherOverviewViewModel : ObservableObject
_schoolYear = schoolYear; _schoolYear = schoolYear;
_workTasks = workTasks; _workTasks = workTasks;
_students = students; _students = students;
_documentation = documentation;
_vorgaenge = vorgaenge;
_patternScoreSettings = patternScoreSettings;
_participation = participation; _participation = participation;
_participationSessions = participationSessions; _participationSessions = participationSessions;
_logger = logger; _logger = logger;
DetailsTab = detailsTab; DetailsTab = detailsTab;
CasesTab = casesTab;
} }
partial void OnHomeroomClassNameChanged(string? value) => OnPropertyChanged(nameof(HomeroomClassConfigured)); partial void OnHomeroomClassNameChanged(string? value) => OnPropertyChanged(nameof(HomeroomClassConfigured));
partial void OnOpenExcuseOverflowCountChanged(int value) => OnPropertyChanged(nameof(HasOpenExcuseOverflow)); partial void OnOpenExcuseOverflowCountChanged(int value) => OnPropertyChanged(nameof(HasOpenExcuseOverflow));
partial void OnOwnDocumentationFollowUpCountChanged(int value) => OnPropertyChanged(nameof(HasOwnDocumentationAlerts));
partial void OnOwnDocumentationCriticalCountChanged(int value) => OnPropertyChanged(nameof(HasOwnDocumentationAlerts));
partial void OnSearchTextChanged(string value) => ApplyRosterFilter(); partial void OnSearchTextChanged(string value) => ApplyRosterFilter();
partial void OnSelectedRosterFilterChanged(int value) partial void OnSelectedRosterFilterChanged(int value)
{ {
OnPropertyChanged(nameof(AlertsFilterSelected)); OnPropertyChanged(nameof(AlertsFilterSelected));
OnPropertyChanged(nameof(ClassRegisterFilterSelected)); OnPropertyChanged(nameof(ClassRegisterFilterSelected));
OnPropertyChanged(nameof(AllFilterSelected)); OnPropertyChanged(nameof(AllFilterSelected));
OnPropertyChanged(nameof(PatternScoreFilterSelected));
ApplyRosterFilter(); ApplyRosterFilter();
} }
private void ResetRosterState()
{
ActiveTabIndex = 0;
Roster.Clear(); PrimaryRoster.Clear(); SecondaryRoster.Clear(); TrendDays.Clear(); PatternNotices.Clear();
OpenExcuses.Clear(); CompactMonthDays.Clear(); OpenExcuseOverflowCount = 0;
}
// Wird beim Navigieren auf diese Seite aufgerufen statt LoadCommand: baut nur den lokalen
// Grundzustand auf (keine WebUntis-Anfrage), damit Öffnen der Ansicht nicht auf einen
// WebUntis-Login+Report-Abruf wartet. Die eigentlichen Fehlzeiten/Klassenbuch-Daten holt sich
// die Nutzerin bewusst über den "Aktualisieren"-Button (LoadCommand).
public void PrepareForDisplay()
{
if (Busy) return;
HomeroomClassName = _settings.HomeroomClassName;
ResetRosterState();
if (!HomeroomClassConfigured)
{
Status = "Noch keine Klasse ausgewählt.";
}
else
{
DetailsTab.Initialize(HomeroomClassName!);
CasesTab.Initialize(HomeroomClassName!);
if (StudentCount == 0)
Status = "Noch nicht geladen auf „Aktualisieren“ klicken, um aktuelle WebUntis-Daten zu holen.";
}
NotifyRosterState();
}
[RelayCommand] [RelayCommand]
private async Task Load() private async Task Load()
{ {
HomeroomClassName = _settings.HomeroomClassName; HomeroomClassName = _settings.HomeroomClassName;
ActiveTabIndex = 0; ResetRosterState();
Roster.Clear(); PrimaryRoster.Clear(); SecondaryRoster.Clear(); TrendDays.Clear(); PatternNotices.Clear();
OpenExcuses.Clear(); OpenExcuseOverflowCount = 0;
if (!HomeroomClassConfigured) if (!HomeroomClassConfigured)
{ {
Status = "Noch keine Klasse ausgewählt."; Status = "Noch keine Klasse ausgewählt.";
@@ -332,59 +588,121 @@ public partial class ClassTeacherOverviewViewModel : ObservableObject
var className = HomeroomClassName!; var className = HomeroomClassName!;
DetailsTab.Initialize(className); DetailsTab.Initialize(className);
CasesTab.Initialize(className);
Busy = true; Busy = true;
NotifyRosterState(); NotifyRosterState();
try 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 // Fehlzeiten seit Schuljahresbeginn statt nur der letzten 7 Tage: liefert die
// Kennzahl unten (YearSummaryLabel) und den Nenner für die Fehlquote in einem Abruf. // Kennzahl unten (YearSummaryLabel) und den Nenner für die Fehlquote in einem Abruf.
// UntisReportCacheService cached diesen "kalten" Bereich dauerhaft (siehe dort) — // UntisReportCacheService cached diesen "kalten" Bereich dauerhaft (siehe dort) —
// teuer ist nur der erste Abruf pro Schuljahr, nicht jedes Öffnen der Ansicht. // teuer ist nur der erste Abruf pro Schuljahr, nicht jedes Öffnen der Ansicht.
var yearStart = _schoolYear.SchoolYearStart(_schoolYear.CurrentSchoolYear(today)); var (today, yearStart, sevenDayStart, trendDays) = RosterDateWindow();
var studentsTask = _cache.GetStudentRosterAsync(className); var studentsTask = _cache.GetStudentRosterAsync(className);
var absencesTask = _cache.GetAbsencesAsync(className, yearStart, today); var absencesTask = _cache.GetAbsencesAsync(className, yearStart, today);
var classRegisterTask = _cache.GetClassRegisterEventsAsync(className, sevenDayStart, today); // Seit Schuljahresbeginn statt nur die letzte Woche (Nutzer-Feedback: Klassenbucheinträge
// anderer Lehrkräfte — z.B. fehlende Hausaufgaben, schlechte Mitarbeit, im Extremfall eine
// Suspendierung — sollen wie die Fehlzeiten in den Auffälligkeits-Score einfließen, nicht
// nur die letzten 7 Tage). Dieselbe "kalte Historie bleibt gecacht"-Logik wie bei den
// Fehlzeiten oben, kein zusätzliches Abruf-Risiko. Die "letzte 7 Tage"-Badges
// (RecentClassRegisterCount, HasRecentClassRegisterEntry) werden weiterhin unten aus
// genau diesem einen Abruf herausgefiltert statt separat erneut abgerufen.
var classRegisterTask = _cache.GetClassRegisterEventsAsync(className, yearStart, today);
var holidaysTask = GetHolidaysAsync(); var holidaysTask = GetHolidaysAsync();
await Task.WhenAll(studentsTask, absencesTask, classRegisterTask, holidaysTask); await Task.WhenAll(studentsTask, absencesTask, classRegisterTask, holidaysTask);
var absenceDaysYear = ClassAbsenceDaySummaryRow.GroupByStudentAndDay(absencesTask.Result); _lastStudents = studentsTask.Result;
var todayAbsences = absenceDaysYear.Where(a => a.Date == today).ToList(); _lastAbsences = absencesTask.Result;
var termStart = EstimateTermStart(absenceDaysYear, yearStart); _lastClassRegisterEvents = classRegisterTask.Result;
var rawWeekdaysElapsed = CountSchoolWeekdays(termStart, today, []); _lastHolidays = holidaysTask.Result;
var schoolDaysElapsed = CountSchoolWeekdays(termStart, today, holidaysTask.Result); BuildRosterFromCachedData(studentsTask.Result, absencesTask.Result, classRegisterTask.Result,
var holidayWeekdaysExcluded = rawWeekdaysElapsed - schoolDaysElapsed; holidaysTask.Result, today, yearStart, sevenDayStart, trendDays);
foreach (var row in ClassTeacherRosterRow.Build(studentsTask.Result, todayAbsences,
classRegisterTask.Result, today, absenceDaysYear, schoolDaysElapsed,
holidayWeekdaysExcluded, termStart)) 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(absenceDaysYear, sevenDayStart);
BuildWeekdayPatternNotices(absenceDaysYear);
BuildAttendanceParticipationNotices();
BuildOpenExcuses(absenceDaysYear, today);
LastUpdatedLabel = $"Zuletzt aktualisiert: Heute, {DateTime.Now:HH:mm}"; 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(); } catch (WebUntisIntegrationException ex) { Status = ex.Message; NotifyRosterState(); }
finally { Busy = false; NotifyRosterState(); } finally { Busy = false; NotifyRosterState(); }
} }
// Baut die Ansicht ausschließlich aus den zuletzt per Load() geholten WebUntis-Daten plus
// aktuellen lokalen Daten (Vorgänge/Dokumentation) neu auf - löst NIE einen WebUntis-Request
// aus, auch nicht über UntisReportCacheService' stündliches Hot-Window-Refresh. Wird bei
// eingehenden Sync-Ereignissen aufgerufen (siehe MainWindowViewModel.RefreshCurrentPage);
// echte WebUntis-Daten holt bewusst ausschließlich der "Aktualisieren"-Button (LoadCommand),
// wenn die Nutzerin sich dafür entscheidet.
public void RefreshFromLocalDataOnly()
{
if (Busy || _lastStudents is null || _lastAbsences is null ||
_lastClassRegisterEvents is null || _lastHolidays is null) return;
var (today, yearStart, sevenDayStart, trendDays) = RosterDateWindow();
ResetRosterState();
BuildRosterFromCachedData(_lastStudents, _lastAbsences, _lastClassRegisterEvents, _lastHolidays,
today, yearStart, sevenDayStart, trendDays);
NotifyRosterState();
}
private (DateOnly Today, DateOnly YearStart, DateOnly SevenDayStart, IReadOnlyList<DateOnly> TrendDays) RosterDateWindow()
{
var today = DateOnly.FromDateTime(DateTime.Today);
var yearStart = _schoolYear.SchoolYearStart(_schoolYear.CurrentSchoolYear(today));
return (today, yearStart, today.AddDays(-6), LastSchoolDays(today, 7));
}
private void BuildRosterFromCachedData(
IReadOnlyList<UntisStudentRosterCacheEntry> students,
IReadOnlyList<UntisClassAbsenceEntryDto> absences,
IReadOnlyList<UntisForeignClassRegisterEventDto> classRegisterEvents,
IReadOnlyList<CachedUntisHoliday> holidays,
DateOnly today, DateOnly yearStart, DateOnly sevenDayStart, IReadOnlyList<DateOnly> trendDays)
{
var absenceDaysYear = ClassAbsenceDaySummaryRow.GroupByStudentAndDay(absences);
var todayAbsences = absenceDaysYear.Where(a => a.Date == today).ToList();
var termStart = EstimateTermStart(absenceDaysYear, yearStart);
var rawWeekdaysElapsed = CountSchoolWeekdays(termStart, today, []);
var schoolDaysElapsed = CountSchoolWeekdays(termStart, today, holidays);
var holidayWeekdaysExcluded = rawWeekdaysElapsed - schoolDaysElapsed;
var recentRegisterEntries = classRegisterEvents
.Where(e => TryDate(e.Date, out var d) && d >= sevenDayStart).ToList();
// Geschlossene Vorgänge je Klassen-Roster-Namen (für die bereichsbezogene Score-Dämpfung/
// Rückfall-Erkennung in Build) — gleicher Namensabgleich wie bei den übrigen Build*-Methoden
// dieser Klasse (z.B. BuildAttendanceParticipationNotices).
var localStudents = _students.GetAll();
var closedVorgaengeByNameKey = students
.Select(r => (Roster: r, Student: MatchStudent(r.DisplayName, localStudents)))
.Where(x => x.Student is not null)
.ToDictionary(
x => UntisNameMatching.NameKey(x.Roster.DisplayName),
x => (IReadOnlyList<Vorgang>)_vorgaenge.GetByStudent(x.Student!.Id)
.Where(v => v.Status == VorgangStatus.Closed).ToList());
foreach (var row in ClassTeacherRosterRow.Build(students, todayAbsences,
recentRegisterEntries, today, absenceDaysYear, schoolDaysElapsed,
holidayWeekdaysExcluded, termStart, classRegisterEvents,
_patternScoreSettings.Load(), closedVorgaengeByNameKey)) 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 = recentRegisterEntries.Count;
BuildTrend(absenceDaysYear, trendDays);
BuildCompactMonth(absenceDaysYear, classRegisterEvents, today);
BuildPatternNotices(absenceDaysYear, sevenDayStart);
BuildWeekdayPatternNotices(absenceDaysYear);
BuildAttendanceParticipationNotices();
BuildVorgangRegressionNotices();
BuildOwnDocumentationCounts();
BuildOpenExcuses(absenceDaysYear, today);
Status = $"{StudentCount} Schüler*innen · {TodayAlertCount} heute auffällig";
ApplyRosterFilter();
NotifySummary();
}
[RelayCommand] private void ShowAlerts() => SelectedRosterFilter = 0; [RelayCommand] private void ShowAlerts() => SelectedRosterFilter = 0;
[RelayCommand] private void ShowClassRegister() => SelectedRosterFilter = 1; [RelayCommand] private void ShowClassRegister() => SelectedRosterFilter = 1;
[RelayCommand] private void ShowAll() => SelectedRosterFilter = 2; [RelayCommand] private void ShowAll() => SelectedRosterFilter = 2;
[RelayCommand] private void ShowPatternScore() => SelectedRosterFilter = 3;
[RelayCommand] [RelayCommand]
private void OpenClassRegister() private void OpenClassRegister()
@@ -402,6 +720,64 @@ public partial class ClassTeacherOverviewViewModel : ObservableObject
DetailsTab.LoadCommand.Execute(null); DetailsTab.LoadCommand.Execute(null);
} }
[RelayCommand]
private void OpenMonthlyCalendar()
{
var today = DateOnly.FromDateTime(DateTime.Today);
DetailsTab.StudentFilter = "";
DetailsTab.CalendarMonth = new DateOnly(today.Year, today.Month, 1);
ActiveTabIndex = 2;
DetailsTab.ShowCalendarCommand.Execute(null);
}
private void BuildCompactMonth(IReadOnlyList<ClassAbsenceDaySummaryRow> absences,
IReadOnlyList<UntisForeignClassRegisterEventDto> registerEntries, DateOnly today)
{
CompactMonthDays.Clear();
foreach (var day in BuildCompactMonthDays(new DateOnly(today.Year, today.Month, 1),
absences, registerEntries, today))
CompactMonthDays.Add(day);
}
public static IReadOnlyList<ClassTeacherCompactCalendarDay> BuildCompactMonthDays(DateOnly month,
IReadOnlyList<ClassAbsenceDaySummaryRow> absences,
IReadOnlyList<UntisForeignClassRegisterEventDto> registerEntries, DateOnly today)
{
var first = new DateOnly(month.Year, month.Month, 1);
var registerRows = registerEntries
.Select(e => (Entry: e, Valid: TryDate(e.Date, out var date), Date: date))
.Where(x => x.Valid && x.Date.Year == first.Year && x.Date.Month == first.Month)
.Select(x => new ClassTeacherClassRegisterRow(x.Date, x.Entry.Subject, x.Entry.StudentName,
x.Entry.TeacherUsername, x.Entry.CategoryName, x.Entry.CategoryGroup, x.Entry.Text))
.ToList();
var result = new List<ClassTeacherCompactCalendarDay>();
var days = DateTime.DaysInMonth(first.Year, first.Month);
for (var number = 1; number <= days; number++)
{
var date = new DateOnly(first.Year, first.Month, number);
var events = ClassTeacherDetailsViewModel.BuildDayEvents(date, absences, registerRows,
includeHomework: false);
var strongest = events.OrderBy(e => SignalPriority(e.Kind)).FirstOrDefault();
var tooltip = events.Count == 0
? $"{date:dd.MM.yyyy}: unauffällig"
: $"{date:dd.MM.yyyy}\n" + string.Join("\n", events.Select(e => e.Tooltip));
result.Add(new ClassTeacherCompactCalendarDay(date, strongest?.Code ?? "",
strongest?.ColorHex ?? "#00000000", tooltip, date == today));
}
return result;
}
private static int SignalPriority(ClassTeacherCalendarEventKind kind) => kind switch
{
ClassTeacherCalendarEventKind.Unexcused => 0,
ClassTeacherCalendarEventKind.ClassRegister => 1,
ClassTeacherCalendarEventKind.Absent => 2,
ClassTeacherCalendarEventKind.Late => 3,
ClassTeacherCalendarEventKind.Excused => 4,
_ => 5,
};
[RelayCommand] [RelayCommand]
private void ShowDetailsForStudent(ClassTeacherRosterRow? row) private void ShowDetailsForStudent(ClassTeacherRosterRow? row)
{ {
@@ -432,6 +808,18 @@ public partial class ClassTeacherOverviewViewModel : ObservableObject
PrimarySectionTitle = "Klassenbucheinträge der letzten 7 Tage"; PrimarySectionTitle = "Klassenbucheinträge der letzten 7 Tage";
foreach (var row in query.Where(r => r.HasRecentClassRegisterEntry)) PrimaryRoster.Add(row); foreach (var row in query.Where(r => r.HasRecentClassRegisterEntry)) PrimaryRoster.Add(row);
} }
else if (SelectedRosterFilter == 3)
{
// Nutzer-Feedback: "heute auffällig" allein lässt Schüler*innen mit echter Vorgeschichte
// verschwinden, sobald sie an einem Tag zufällig anwesend sind, und lässt einen
// einmaligen Erstfehltag ganz oben stehen, sobald er der/die einzige "heute Auffällige"
// ist. Deshalb eigene Sortierung nach dem kumulierten Score übers Schuljahr statt nach
// dem heutigen Status — bewusst unabhängig von HasAbsenceToday.
PrimarySectionTitle = "Nach Muster seit Schuljahresbeginn";
foreach (var row in query.Where(r => r.PatternScore > 0)
.OrderByDescending(r => r.PatternScore).ThenBy(r => r.StudentName))
PrimaryRoster.Add(row);
}
else else
{ {
PrimarySectionTitle = "Heute auffällig"; PrimarySectionTitle = "Heute auffällig";
@@ -464,6 +852,9 @@ public partial class ClassTeacherOverviewViewModel : ObservableObject
(double)day.Unexcused / max, (double)day.LateExcused / max, (double)day.Excused / max)); (double)day.Unexcused / max, (double)day.LateExcused / max, (double)day.Excused / max));
} }
private static bool TryDate(int value, out DateOnly date) =>
DateOnly.TryParseExact(value.ToString(), "yyyyMMdd", out date);
/// Letzte <paramref name="count"/> Werktage bis einschließlich <paramref name="end"/>, ohne /// 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 /// Ferienkalender (bewusste Vereinfachung, siehe TODO.md 12.4-Nachtrag) — die App kennt keine
/// Schulferien, nur Wochenenden. /// Schulferien, nur Wochenenden.
@@ -687,6 +1078,44 @@ public partial class ClassTeacherOverviewViewModel : ObservableObject
OnPropertyChanged(nameof(HasPatternNotices)); OnPropertyChanged(nameof(HasPatternNotices));
} }
/// Schwelle für die "erneut auffällig"-Warnung nach einem geschlossenen Vorgang (Nutzer-
/// Entscheidung): erst ab einem spürbaren Muster, nicht schon bei der ersten Kleinigkeit nach
/// Abschluss — ≈ 1 unentschuldigter Tag oder vergleichbar (<see cref="PatternScoreWeights.UnexcusedDayWeight"/>
/// Default 3,0).
private const double RegressionThreshold = 3.0;
private void BuildVorgangRegressionNotices()
{
foreach (var row in Roster.Where(r => r.RegressionScore >= RegressionThreshold &&
r.RegressionVorgangTitle is not null))
{
if (PatternNotices.Any(n => n.StudentName == row.StudentName)) continue;
PatternNotices.Add(new ClassTeacherPatternNotice(row.StudentName,
$"Nach Bearbeitung von \"{row.RegressionVorgangTitle}\" erneut auffällig",
ClassTeacherStatusKind.Danger, CanCreateReminder: true));
}
OnPropertyChanged(nameof(HasPatternNotices));
}
/// Nacharbeiten-/Kritisch-Zählung für die eigene Dokumentation der Klasse — bewusst nicht auf
/// die letzten 7 Tage begrenzt wie <see cref="RecentClassRegisterCount"/>: ein seit Wochen
/// offener Entwurf oder ein als "Kritisch" markierter Eintrag soll nicht aus der Kennzahl
/// verschwinden, nur weil er nicht mehr taufrisch ist. Selbe Namensabgleich-Logik wie
/// <see cref="BuildAttendanceParticipationNotices"/>.
private void BuildOwnDocumentationCounts()
{
var students = _students.GetAll();
var matchedIds = Roster
.Select(r => MatchStudent(r.StudentName, students))
.Where(s => s is not null)
.Select(s => s!.Id)
.ToHashSet();
var docs = _documentation.GetAll().Where(d => !d.IsDeleted && matchedIds.Contains(d.StudentId)).ToList();
OwnDocumentationFollowUpCount = docs.Count(d => d.IsDraft);
OwnDocumentationCriticalCount = docs.Count(d =>
d.Tags.Any(t => string.Equals(t, "Kritisch", StringComparison.OrdinalIgnoreCase)));
}
/// Reine, ohne Repository-Zugriff testbare Zuordnungslogik. Nutzt <c>FirstName</c>/<c>LastName</c> /// 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 /// statt <see cref="Student.FullName"/>, weil dessen "Nachname, Vorname"-Format mit Komma den
/// leerzeichenbasierten Wortabgleich in <see cref="UntisNameMatching"/> verfälschen würde /// leerzeichenbasierten Wortabgleich in <see cref="UntisNameMatching"/> verfälschen würde
@@ -0,0 +1,42 @@
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using LehrerApp.Core.Services;
namespace LehrerApp.Desktop.ViewModels.ClassTeacher;
/// <summary>Dialog zum Anpassen der <see cref="PatternScoreWeights"/> für den "Gesamtbild"-Reiter
/// der Klassenlehreransicht — ursprünglich feste Konstanten, auf Nutzerwunsch jetzt einstellbar.</summary>
public partial class PatternScoreWeightsDialogViewModel : ObservableObject
{
private readonly PatternScoreSettingsService _settings;
[ObservableProperty] private double _unexcusedDayWeight;
[ObservableProperty] private double _excusedDayWeight;
[ObservableProperty] private double _lateDayWeight;
[ObservableProperty] private double _negativeClassRegisterEntryWeight;
[ObservableProperty] private double _concerningKeywordBonus;
[ObservableProperty] private double _suspensionKeywordWeight;
public PatternScoreWeightsDialogViewModel(PatternScoreSettingsService settings)
{
_settings = settings;
var weights = settings.Load();
UnexcusedDayWeight = weights.UnexcusedDayWeight;
ExcusedDayWeight = weights.ExcusedDayWeight;
LateDayWeight = weights.LateDayWeight;
NegativeClassRegisterEntryWeight = weights.NegativeClassRegisterEntryWeight;
ConcerningKeywordBonus = weights.ConcerningKeywordBonus;
SuspensionKeywordWeight = weights.SuspensionKeywordWeight;
}
[RelayCommand]
private void Save() => _settings.Save(new PatternScoreWeights
{
UnexcusedDayWeight = UnexcusedDayWeight,
ExcusedDayWeight = ExcusedDayWeight,
LateDayWeight = LateDayWeight,
NegativeClassRegisterEntryWeight = NegativeClassRegisterEntryWeight,
ConcerningKeywordBonus = ConcerningKeywordBonus,
SuspensionKeywordWeight = SuspensionKeywordWeight,
});
}
@@ -0,0 +1,215 @@
using System.Collections.ObjectModel;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using LehrerApp.Core.Models;
using LehrerApp.Desktop.ViewModels.Students;
namespace LehrerApp.Desktop.ViewModels.ClassTeacher;
// ── Anzeige-Helfer ────────────────────────────────────────────────────────
public static class VorgangTagDisplay
{
// Vom Nutzer selbst genannte Schlagwörter — eigene Labels bleiben trotzdem frei möglich
// (AutoCompleteBox, gleiches Muster wie DocumentationTagDisplay.Suggestions).
public static string[] Suggestions { get; } =
["Absentismus", "Hausaufgaben", "Verspätungen", "Konflikte", "Mitarbeit", "Elternkontakt", "Eskalation"];
}
public static class VorgangStatusDisplay
{
public static string Label(VorgangStatus status) => status == VorgangStatus.Closed ? "Geschlossen" : "Offen";
}
/// Ergebnis des "→ Vorgang"-Auswahldialogs beim Anheften einer Klassenbuchzeile: entweder ein
/// bestehender, offener Vorgang oder der Titel für einen neu anzulegenden.
public sealed record PinToVorgangChoice(Guid? ExistingVorgangId, string? NewVorgangTitle);
/// Eingefrorene WebUntis-Klassenbuchzeile mit deutscher Anzeige-Aufbereitung — das Core-Modell
/// <see cref="VorgangClassRegisterEntry"/> bleibt bewusst framework-frei ohne Formatierungslogik.
public sealed record VorgangClassRegisterEntryRow(VorgangClassRegisterEntry Model)
{
public string DateLabel => Model.Date.ToString("dd.MM.yyyy");
public string StudentName => Model.StudentName;
public string SummaryLabel => string.Join(" · ", new[] { Model.Subject, Model.CategoryName, Model.Text }
.Where(v => !string.IsNullOrWhiteSpace(v)));
}
// ── Mehrfachauswahl Schüler*innen (Anlegen/Bearbeiten-Dialog) ────────────────
public partial class VorgangStudentOption(Guid studentId, string name) : ObservableObject
{
public Guid StudentId { get; } = studentId;
public string Name { get; } = name;
[ObservableProperty] private bool _isSelected;
}
// ── Listen-Eintrag mit aufklappbarer Detailansicht ───────────────────────────
/// <summary>
/// Ein Vorgang in der Liste des "Vorgänge"-Tabs. Verknüpfte Dokumentation/Klassenbucheinträge
/// werden von <see cref="ClassTeacherCasesViewModel"/> beim Laden befüllt; Link/Unlink-Aktionen
/// laufen über die hier übergebenen Callbacks zurück ins ViewModel (statt eigenem Repository-
/// Zugriff hier), damit dieser Wrapper ein reiner Anzeige-Baustein bleibt — analog
/// <see cref="DocumentationItem"/>.
/// </summary>
public partial class VorgangItem : ObservableObject
{
private readonly Func<VorgangItem, DocumentationItem, Task> _onLink;
private readonly Func<VorgangItem, DocumentationItem, Task> _onUnlink;
private readonly Func<VorgangItem, VorgangClassRegisterEntryRow, Task> _onRemoveClassRegisterEntry;
private readonly Func<DocumentationItem, Task> _onEditDocumentation;
public Vorgang Model { get; }
public string StudentNames { get; }
public List<TagChip> TagChips { get; }
public string StatusLabel => VorgangStatusDisplay.Label(Model.Status);
public bool IsOpen => Model.Status == VorgangStatus.Open;
public string CreatedLabel => $"Angelegt {Model.CreatedAt:dd.MM.yyyy}";
public ObservableCollection<DocumentationItem> LinkedDocumentation { get; } = [];
public ObservableCollection<DocumentationItem> AvailableDocumentation { get; } = [];
public ObservableCollection<VorgangClassRegisterEntryRow> ClassRegisterRows { get; } = [];
public bool HasLinkedDocumentation => LinkedDocumentation.Count > 0;
public bool HasAvailableDocumentation => AvailableDocumentation.Count > 0;
public bool HasClassRegisterRows => ClassRegisterRows.Count > 0;
[ObservableProperty] private bool _isRevealed;
public VorgangItem(Vorgang model, string studentNames,
Func<VorgangItem, DocumentationItem, Task> onLink,
Func<VorgangItem, DocumentationItem, Task> onUnlink,
Func<VorgangItem, VorgangClassRegisterEntryRow, Task> onRemoveClassRegisterEntry,
Func<DocumentationItem, Task> onEditDocumentation)
{
Model = model;
StudentNames = studentNames;
TagChips = model.Tags.Select(t => new TagChip(t)).ToList();
_onLink = onLink;
_onUnlink = onUnlink;
_onRemoveClassRegisterEntry = onRemoveClassRegisterEntry;
_onEditDocumentation = onEditDocumentation;
}
[RelayCommand] private void Reveal() => IsRevealed = !IsRevealed;
/// Öffnet den vollen Dokumentations-Dialog zum Nachlesen/Bearbeiten, ohne den Vorgänge-Tab zu
/// verlassen (Nutzer-Feedback: bisher musste man dafür in den Schülerdatensatz wechseln).
[RelayCommand]
private async Task EditDocumentation(DocumentationItem? doc)
{
if (doc is not null) await _onEditDocumentation(doc);
}
[RelayCommand]
private async Task Link(DocumentationItem? doc)
{
if (doc is not null) await _onLink(this, doc);
}
[RelayCommand]
private async Task Unlink(DocumentationItem? doc)
{
if (doc is not null) await _onUnlink(this, doc);
}
[RelayCommand]
private async Task RemoveClassRegisterEntry(VorgangClassRegisterEntryRow? row)
{
if (row is not null) await _onRemoveClassRegisterEntry(this, row);
}
}
// ── Dialog: Vorgang anlegen/bearbeiten ────────────────────────────────────
public partial class VorgangDialogViewModel : ObservableObject
{
private readonly Vorgang? _editing;
public ObservableCollection<VorgangStudentOption> StudentOptions { get; }
[ObservableProperty] private string _title = "";
[ObservableProperty] private string _description = "";
[ObservableProperty] private string _newTag = "";
public ObservableCollection<string> Tags { get; } = [];
public string[] TagSuggestions => VorgangTagDisplay.Suggestions;
[ObservableProperty] private string _titleError = "";
[ObservableProperty] private string _studentsError = "";
public string DialogTitle => _editing is null ? "Vorgang anlegen" : "Vorgang bearbeiten";
public Vorgang? Result { get; private set; }
public VorgangDialogViewModel(List<StudentOption> rosterStudents, Vorgang? editing)
{
_editing = editing;
StudentOptions = new ObservableCollection<VorgangStudentOption>(rosterStudents.Select(s =>
new VorgangStudentOption(s.Id, s.Name) { IsSelected = editing?.StudentIds.Contains(s.Id) == true }));
if (editing is null) return;
Title = editing.Title;
Description = editing.Description;
foreach (var tag in editing.Tags) Tags.Add(tag);
}
[RelayCommand]
private void AddTag()
{
if (string.IsNullOrWhiteSpace(NewTag)) return;
var tag = NewTag.Trim();
if (!Tags.Contains(tag)) Tags.Add(tag);
NewTag = "";
}
[RelayCommand] private void RemoveTag(string? tag) { if (tag is not null) Tags.Remove(tag); }
[RelayCommand]
private void Save()
{
TitleError = ""; StudentsError = "";
var valid = true;
if (string.IsNullOrWhiteSpace(Title)) { TitleError = "Titel erforderlich."; valid = false; }
var selectedIds = StudentOptions.Where(s => s.IsSelected).Select(s => s.StudentId).ToList();
if (selectedIds.Count == 0) { StudentsError = "Mindestens eine/n Schüler*in auswählen."; valid = false; }
if (!valid) return;
Result = _editing ?? new Vorgang();
Result.Title = Title.Trim();
Result.Description = (Description ?? "").Trim();
Result.StudentIds = selectedIds;
Result.Tags = Tags.ToList();
}
}
// ── Dialog: Klassenbuchzeile an (bestehenden oder neuen) Vorgang anheften ────
public partial class PinToVorgangDialogViewModel : ObservableObject
{
public string RowSummary { get; }
public List<VorgangItem> MatchingOpenCases { get; }
public bool HasMatchingOpenCases => MatchingOpenCases.Count > 0;
[ObservableProperty] private VorgangItem? _selectedCase;
[ObservableProperty] private string _newTitle = "";
[ObservableProperty] private string _error = "";
public PinToVorgangChoice? Result { get; private set; }
public PinToVorgangDialogViewModel(string rowSummary, List<VorgangItem> matchingOpenCases)
{
RowSummary = rowSummary;
MatchingOpenCases = matchingOpenCases;
}
[RelayCommand]
private void Confirm()
{
Error = "";
if (SelectedCase is not null) { Result = new PinToVorgangChoice(SelectedCase.Model.Id, null); return; }
if (!string.IsNullOrWhiteSpace(NewTitle)) { Result = new PinToVorgangChoice(null, NewTitle.Trim()); return; }
Error = "Bestehenden Vorgang wählen oder Titel für einen neuen Vorgang eingeben.";
}
}
+203 -134
View File
@@ -38,6 +38,9 @@ public partial class DashboardViewModel : ObservableObject
private readonly ITimeEntryRepository _timeEntries; private readonly ITimeEntryRepository _timeEntries;
private readonly IAnnualPlanEventRepository? _annualPlanEvents; private readonly IAnnualPlanEventRepository? _annualPlanEvents;
private readonly SchoolWeatherService? _schoolWeather; private readonly SchoolWeatherService? _schoolWeather;
private readonly UntisHubService _untisHub;
private readonly WebUntisIntegrationService _webUntis;
private readonly Func<DateTime> _now;
private const int OpenExcuseMaxAgeDays = 21; private const int OpenExcuseMaxAgeDays = 21;
private const int SupportPlanDueWithinDays = 14; private const int SupportPlanDueWithinDays = 14;
@@ -87,20 +90,43 @@ public partial class DashboardViewModel : ObservableObject
public ObservableCollection<TaskItem> OpenTasks { get; } = []; public ObservableCollection<TaskItem> OpenTasks { get; } = [];
public ObservableCollection<GroupChip> CurrentGroups { get; } = []; public ObservableCollection<GroupChip> CurrentGroups { get; } = [];
public ObservableCollection<CalendarDayCell> CalendarDays { get; } = []; public ObservableCollection<CalendarDayCell> CalendarDays { get; } = [];
public ObservableCollection<OpenExcuseItem> OpenExcuses { get; } = [];
public ObservableCollection<AttendanceWarningItem> AttendanceWarnings { get; } = [];
public ObservableCollection<ExamWeekLoadItem> ExamWeekLoads { get; } = []; public ObservableCollection<ExamWeekLoadItem> ExamWeekLoads { get; } = [];
public ObservableCollection<MissingTeachingTimeItem> MissingTeachingTimeEntries { get; } = [];
public ObservableCollection<SupportPlanDueItem> SupportPlanReviews { get; } = [];
public ObservableCollection<UpcomingDateItem> UpcomingDates { get; } = []; public ObservableCollection<UpcomingDateItem> UpcomingDates { get; } = [];
public ObservableCollection<CorrectionProgressItem> OpenCorrections { get; } = [];
public ObservableCollection<UnplannedLessonItem> UnplannedLessons { get; } = [];
public ObservableCollection<DashboardAlertItem> Alerts { get; } = [];
public ObservableCollection<CalendarEventItem> SelectedDayEvents { get; } = []; public ObservableCollection<CalendarEventItem> SelectedDayEvents { get; } = [];
public ObservableCollection<DashboardCardOption> DashboardCards { get; } = []; public ObservableCollection<DashboardCardOption> DashboardCards { get; } = [];
public ObservableCollection<DashboardWeatherWarningItem> WeatherWarnings { get; } = []; public ObservableCollection<DashboardWeatherWarningItem> WeatherWarnings { get; } = [];
// Zusammengefasste "Handlungsbedarf"-Karte (vormals sieben eigene Kacheln/Collections:
// Entschuldigungen, Fehlzeiten, Förderplan, Korrekturen, ungeplante Stunden, Auffälligkeiten,
// Unterrichtszeit-Nacherfassung — siehe AttentionItem.cs). Attention traegt nur, was nach
// Filterung (AttentionFilters) tatsaechlich angezeigt wird; die Rohdaten je Art liegen in den
// privaten _xyzItems-Feldern und werden von RebuildAttention() neu zusammengesetzt, sobald sich
// Daten oder Filter aendern — ohne die Repos erneut abzufragen.
public ObservableCollection<AttentionGroup> Attention { get; } = [];
public ObservableCollection<AttentionFilterOption> AttentionFilters { get; } = [];
public static string[] CalendarWeekdayHeaders { get; } = ["Mo", "Di", "Mi", "Do", "Fr", "Sa", "So"]; public static string[] CalendarWeekdayHeaders { get; } = ["Mo", "Di", "Mi", "Do", "Fr", "Sa", "So"];
private readonly List<OpenExcuseItem> _excuses = [];
private readonly List<AttentionItem> _attendanceItems = [];
private readonly List<AttentionItem> _supportItems = [];
private readonly List<AttentionItem> _correctionItems = [];
private readonly List<AttentionItem> _unplannedItems = [];
private readonly List<AttentionItem> _alertItems = [];
private readonly List<AttentionItem> _missingTimeItems = [];
/// Reihenfolge und Überschriften der Gruppen in der zusammengefassten Handlungsbedarf-Karte —
/// übernimmt die frühere Kachel-Reihenfolge aus DashboardSettingsService.DefaultCardOrder,
/// damit sich für Nutzer nichts unvorhersehbar umsortiert.
private static readonly (AttentionKind Kind, string Header)[] AttentionGroupOrder =
[
(AttentionKind.MissingTeachingTime, "Unterrichtszeit nacherfassen"),
(AttentionKind.Excuse, "Offene Entschuldigungen"),
(AttentionKind.Correction, "Offene Korrekturen"),
(AttentionKind.Unplanned, "Ungeplante Stunden"),
(AttentionKind.Alert, "Auffälligkeiten"),
(AttentionKind.Attendance, "Fehlzeiten-Warnung"),
(AttentionKind.SupportPlan, "Förderplan-Wiedervorlage"),
];
// Navigation-Callback wird von App.axaml.cs verdrahtet // Navigation-Callback wird von App.axaml.cs verdrahtet
public Action<Guid>? OnNavigateToGroup { get; set; } public Action<Guid>? OnNavigateToGroup { get; set; }
public Action<Guid>? OnNavigateToStudent { get; set; } public Action<Guid>? OnNavigateToStudent { get; set; }
@@ -123,26 +149,24 @@ public partial class DashboardViewModel : ObservableObject
public DashboardCardOption TodayCard => Card("today"); public DashboardCardOption TodayCard => Card("today");
public DashboardCardOption TasksCard => Card("tasks"); public DashboardCardOption TasksCard => Card("tasks");
public DashboardCardOption CalendarCard => Card("calendar"); public DashboardCardOption CalendarCard => Card("calendar");
public DashboardCardOption ExcusesCard => Card("excuses");
public DashboardCardOption UpcomingCard => Card("upcoming"); public DashboardCardOption UpcomingCard => Card("upcoming");
public DashboardCardOption CorrectionsCard => Card("corrections");
public DashboardCardOption UnplannedCard => Card("unplanned");
public DashboardCardOption AlertsCard => Card("alerts");
public DashboardCardOption AttendanceCard => Card("attendance");
public DashboardCardOption ExamLoadCard => Card("examload"); public DashboardCardOption ExamLoadCard => Card("examload");
public DashboardCardOption MissingTeachingTimeCard => Card("missingteachingtime");
public DashboardCardOption SupportCard => Card("support");
public DashboardCardOption GroupsCard => Card("groups"); public DashboardCardOption GroupsCard => Card("groups");
public DashboardCardOption AttentionCard => Card("attention");
public int TodayLessonCount => TodaysLessons.Count; public int TodayLessonCount => TodaysLessons.Count;
public int OpenTaskCount => OpenTasks.Count; public int OpenTaskCount => OpenTasks.Count;
public int UpcomingCount => UpcomingDates.Count; public int UpcomingCount => UpcomingDates.Count;
public int AttentionCount => OpenExcuses.Count + AttendanceWarnings.Count + SupportPlanReviews.Count public int AttentionCount => _excuses.Count + _attendanceItems.Count + _supportItems.Count
+ OpenCorrections.Count + UnplannedLessons.Count + Alerts.Count; + _correctionItems.Count + _unplannedItems.Count + _alertItems.Count + _missingTimeItems.Count;
public string TodayLessonSummary => TodayLessonCount == 1 ? "1 Stunde" : $"{TodayLessonCount} Stunden"; public string TodayLessonSummary => TodayLessonCount == 1 ? "1 Stunde" : $"{TodayLessonCount} Stunden";
public string OpenTaskSummary => OpenTaskCount == 1 ? "1 Aufgabe" : $"{OpenTaskCount} Aufgaben"; public string OpenTaskSummary => OpenTaskCount == 1 ? "1 Aufgabe" : $"{OpenTaskCount} Aufgaben";
public string AttentionSummary => AttentionCount == 1 ? "1 offener Punkt" : $"{AttentionCount} offene Punkte"; public string AttentionSummary => AttentionCount == 1 ? "1 offener Punkt" : $"{AttentionCount} offene Punkte";
public string UpcomingSummary => UpcomingCount == 1 ? "1 Termin" : $"{UpcomingCount} Termine"; public string UpcomingSummary => UpcomingCount == 1 ? "1 Termin" : $"{UpcomingCount} Termine";
[ObservableProperty] private string _webUntisHealthLabel = "";
[ObservableProperty] private bool _isWebUntisHealthWarning;
[ObservableProperty] private bool _isWebUntisHealthVisible;
public DashboardViewModel(IGroupRepository groups, ISubjectRepository subjects, ILessonRepository lessons, public DashboardViewModel(IGroupRepository groups, ISubjectRepository subjects, ILessonRepository lessons,
IExamRepository exams, IExamResultRepository examResults, IGradeRepository grades, IExamRepository exams, IExamResultRepository examResults, IGradeRepository grades,
IReportGradeRepository reportGrades, IGroupMembershipRepository memberships, IReportGradeRepository reportGrades, IGroupMembershipRepository memberships,
@@ -153,8 +177,10 @@ public partial class DashboardViewModel : ObservableObject
DashboardSettingsService dashboardSettings, ISchoolHolidayRepository schoolHolidays, DashboardSettingsService dashboardSettings, ISchoolHolidayRepository schoolHolidays,
PublicHolidayService publicHolidays, SchoolCalendarSettingsService calendarSettings, PublicHolidayService publicHolidays, SchoolCalendarSettingsService calendarSettings,
ISubstitutionEntryRepository substitutions, ITimeEntryRepository timeEntries, ISubstitutionEntryRepository substitutions, ITimeEntryRepository timeEntries,
UntisHubService untisHub, WebUntisIntegrationService webUntis,
IAnnualPlanEventRepository? annualPlanEvents = null, IAnnualPlanEventRepository? annualPlanEvents = null,
AnnualPlanSyncService? annualPlanSync = null, SchoolWeatherService? schoolWeather = null) AnnualPlanSyncService? annualPlanSync = null, SchoolWeatherService? schoolWeather = null,
Func<DateTime>? now = null)
{ {
_groups = groups; _subjects = subjects; _lessons = lessons; _exams = exams; _tasks = tasks; _groups = groups; _subjects = subjects; _lessons = lessons; _exams = exams; _tasks = tasks;
_examResults = examResults; _grades = grades; _reportGrades = reportGrades; _memberships = memberships; _examResults = examResults; _grades = grades; _reportGrades = reportGrades; _memberships = memberships;
@@ -167,13 +193,34 @@ public partial class DashboardViewModel : ObservableObject
_substitutions = substitutions; _substitutions = substitutions;
_annualPlanEvents = annualPlanEvents; _annualPlanEvents = annualPlanEvents;
_schoolWeather = schoolWeather; _schoolWeather = schoolWeather;
_untisHub = untisHub;
_webUntis = webUntis;
// Testbare Uhr statt direkter DateTime.Now-Aufrufe (siehe Load()/LoadMissingTeachingTime):
// TimeOnly.AddHours()/AddMinutes() wickelt bei Mitternacht um, ohne injizierbares "jetzt"
// waeren Tests fuer "kurz vor/nach Ablauf einer Wartezeit" je nach Ausfuehrungsuhrzeit
// zufaellig rot oder gruen (siehe TODO.md, Abschnitt 9, Nachtrag Uhrzeit-Wraparound).
_now = now ?? (() => DateTime.Now);
if (annualPlanSync is not null) if (annualPlanSync is not null)
{ {
annualPlanSync.DataChanged += () => Avalonia.Threading.Dispatcher.UIThread.Post(LoadCalendar); annualPlanSync.DataChanged += () => Avalonia.Threading.Dispatcher.UIThread.Post(LoadCalendar);
_ = annualPlanSync.PollAsync();
} }
LoadDashboardCards(); LoadDashboardCards();
LoadAttentionFilters();
Load(); Load();
RefreshWebUntisHealth();
}
/// <summary>Liest nur den gespeicherten Fälligkeitsstand der Untis-Hub-Jobs (kein
/// WebUntis-Zugriff, siehe <see cref="UntisHubService.GetRows"/>) - aufgerufen bei jedem
/// Dashboard-Refresh und erneut, nachdem der Nutzer den Hub geöffnet/einen Abgleich gemacht hat.</summary>
public void RefreshWebUntisHealth()
{
IsWebUntisHealthVisible = _webUntis.IsAvailable;
if (!IsWebUntisHealthVisible) return;
var rows = _untisHub.GetRows();
var due = rows.Count(r => r.DueState != UntisHubDueState.Ok);
IsWebUntisHealthWarning = due > 0;
WebUntisHealthLabel = due > 0 ? $"WebUntis ⚠ {due} fällig" : "WebUntis ✓";
} }
private DashboardCardOption Card(string key) => DashboardCards.First(c => c.Key == key); private DashboardCardOption Card(string key) => DashboardCards.First(c => c.Key == key);
@@ -182,7 +229,7 @@ public partial class DashboardViewModel : ObservableObject
private void Load() private void Load()
{ {
var now = DateTime.Now; var now = _now();
var today = DateOnly.FromDateTime(now); var today = DateOnly.FromDateTime(now);
CurrentDate = now.ToString("dddd, d. MMMM yyyy", De); CurrentDate = now.ToString("dddd, d. MMMM yyyy", De);
CurrentSchoolYear = _sy.CurrentSchoolYear(); CurrentSchoolYear = _sy.CurrentSchoolYear();
@@ -243,6 +290,7 @@ public partial class DashboardViewModel : ObservableObject
LoadOpenCorrections(groups, today); LoadOpenCorrections(groups, today);
LoadUnplannedLessons(groups, today); LoadUnplannedLessons(groups, today);
LoadAlerts(groups, today); LoadAlerts(groups, today);
RebuildAttention();
UpdateDashboardSummary(); UpdateDashboardSummary();
} }
@@ -251,16 +299,14 @@ public partial class DashboardViewModel : ObservableObject
TodayCard.IsEmpty = TodaysLessons.Count == 0; TodayCard.IsEmpty = TodaysLessons.Count == 0;
TasksCard.IsEmpty = OpenTasks.Count == 0; TasksCard.IsEmpty = OpenTasks.Count == 0;
CalendarCard.IsEmpty = false; CalendarCard.IsEmpty = false;
ExcusesCard.IsEmpty = OpenExcuses.Count == 0;
UpcomingCard.IsEmpty = UpcomingDates.Count == 0; UpcomingCard.IsEmpty = UpcomingDates.Count == 0;
CorrectionsCard.IsEmpty = OpenCorrections.Count == 0;
UnplannedCard.IsEmpty = UnplannedLessons.Count == 0;
AlertsCard.IsEmpty = Alerts.Count == 0;
AttendanceCard.IsEmpty = AttendanceWarnings.Count == 0;
ExamLoadCard.IsEmpty = ExamWeekLoads.Count == 0; ExamLoadCard.IsEmpty = ExamWeekLoads.Count == 0;
MissingTeachingTimeCard.IsEmpty = MissingTeachingTimeEntries.Count == 0;
SupportCard.IsEmpty = SupportPlanReviews.Count == 0;
GroupsCard.IsEmpty = CurrentGroups.Count == 0; GroupsCard.IsEmpty = CurrentGroups.Count == 0;
AttentionCard.IsEmpty = AttentionCount == 0;
// Erst nachdem alle IsEmpty-Werte stehen: welche Kachel tatsaechlich gerendert wird, haengt
// ueber EffectiveIsVisible daran, und davon wiederum die Zeilen-/Spaltenzuordnung.
ApplyCardLayout();
OnPropertyChanged(nameof(TodayLessonCount)); OnPropertyChanged(nameof(TodayLessonCount));
OnPropertyChanged(nameof(OpenTaskCount)); OnPropertyChanged(nameof(OpenTaskCount));
@@ -327,7 +373,7 @@ public partial class DashboardViewModel : ObservableObject
private void LoadAttendanceWarnings(DateOnly today) private void LoadAttendanceWarnings(DateOnly today)
{ {
AttendanceWarnings.Clear(); _attendanceItems.Clear();
var schoolYear = _sy.CurrentSchoolYear(); var schoolYear = _sy.CurrentSchoolYear();
var from = _sy.SchoolYearStart(schoolYear); var from = _sy.SchoolYearStart(schoolYear);
var to = _sy.SchoolYearEnd(schoolYear); var to = _sy.SchoolYearEnd(schoolYear);
@@ -350,9 +396,13 @@ public partial class DashboardViewModel : ObservableObject
items.Add(new AttendanceWarningItem(student.Id, student.FullName, balance.AbsenceRatePercent)); items.Add(new AttendanceWarningItem(student.Id, student.FullName, balance.AbsenceRatePercent));
} }
foreach (var item in items.OrderByDescending(i => i.AbsenceRatePercent)) foreach (var item in items.OrderByDescending(i => i.AbsenceRatePercent))
AttendanceWarnings.Add(item); _attendanceItems.Add(ToAttentionItem(item));
} }
private AttentionItem ToAttentionItem(AttendanceWarningItem w) => new(
AttentionKind.Attendance, w.StudentName, trailingText: $"{w.AbsenceRatePercent:0.#} %",
isWarningTrailing: true, navigate: () => OnNavigateToStudent?.Invoke(w.StudentId));
// ── Klausurwochen (Nutzer-Feedback) ─────────────────────────────────────── // ── Klausurwochen (Nutzer-Feedback) ───────────────────────────────────────
// //
// Persönliche Klausurlast über alle Kurse hinweg — anders als die klassenbezogene // Persönliche Klausurlast über alle Kurse hinweg — anders als die klassenbezogene
@@ -384,13 +434,13 @@ public partial class DashboardViewModel : ObservableObject
private void LoadMissingTeachingTime(DateOnly today) private void LoadMissingTeachingTime(DateOnly today)
{ {
MissingTeachingTimeEntries.Clear(); _missingTimeItems.Clear();
var firstDay = today.AddDays(-MissingTeachingTimeLookbackDays); var firstDay = today.AddDays(-MissingTeachingTimeLookbackDays);
var publicHolidayDates = Enumerable.Range(firstDay.Year, today.Year - firstDay.Year + 1) var publicHolidayDates = Enumerable.Range(firstDay.Year, today.Year - firstDay.Year + 1)
.SelectMany(y => _publicHolidays.GetHolidays(y, _calendarSettings.State)) .SelectMany(y => _publicHolidays.GetHolidays(y, _calendarSettings.State))
.Select(h => h.Date).ToHashSet(); .Select(h => h.Date).ToHashSet();
var schoolHolidays = _schoolHolidays.GetAll(); var schoolHolidays = _schoolHolidays.GetAll();
var nowTime = TimeOnly.FromDateTime(DateTime.Now); var nowTime = TimeOnly.FromDateTime(_now());
var items = new List<MissingTeachingTimeItem>(); var items = new List<MissingTeachingTimeItem>();
for (var date = firstDay; date <= today; date = date.AddDays(1)) for (var date = firstDay; date <= today; date = date.AddDays(1))
@@ -419,14 +469,15 @@ public partial class DashboardViewModel : ObservableObject
items.Add(new MissingTeachingTimeItem(date, windowStart, windowEnd)); items.Add(new MissingTeachingTimeItem(date, windowStart, windowEnd));
} }
foreach (var item in items.OrderBy(i => i.Date)) foreach (var item in items.OrderBy(i => i.Date))
MissingTeachingTimeEntries.Add(item); _missingTimeItems.Add(new AttentionItem(AttentionKind.MissingTeachingTime, item.DateDisplay,
actions: [new AttentionAction("Erfassen", AddMissingTeachingTimeCommand, item)]));
} }
// ── Förderplan-Wiedervorlage (5.3.2) ────────────────────────────────────── // ── Förderplan-Wiedervorlage (5.3.2) ──────────────────────────────────────
private void LoadSupportPlanReviews(DateOnly today) private void LoadSupportPlanReviews(DateOnly today)
{ {
SupportPlanReviews.Clear(); _supportItems.Clear();
var dueBy = today.AddDays(SupportPlanDueWithinDays); var dueBy = today.AddDays(SupportPlanDueWithinDays);
var due = _documentation.GetAll() var due = _documentation.GetAll()
@@ -439,8 +490,11 @@ public partial class DashboardViewModel : ObservableObject
{ {
var student = _students.GetById(d.StudentId); var student = _students.GetById(d.StudentId);
if (student is null) continue; if (student is null) continue;
SupportPlanReviews.Add(new SupportPlanDueItem( var reviewDate = d.SupportData!.ReviewDate!.Value;
d.StudentId, student.FullName, d.Title, d.SupportData!.ReviewDate!.Value, today)); var studentId = d.StudentId;
_supportItems.Add(new AttentionItem(AttentionKind.SupportPlan, student.FullName, subtitle: d.Title,
dateDisplay: reviewDate.ToString("dd.MM.yyyy"), isOverdue: reviewDate < today,
navigate: () => OnNavigateToStudent?.Invoke(studentId)));
} }
} }
@@ -485,7 +539,7 @@ public partial class DashboardViewModel : ObservableObject
private void LoadOpenCorrections(IReadOnlyDictionary<Guid, LearningGroup> groups, DateOnly today) private void LoadOpenCorrections(IReadOnlyDictionary<Guid, LearningGroup> groups, DateOnly today)
{ {
OpenCorrections.Clear(); _correctionItems.Clear();
foreach (var group in groups.Values) foreach (var group in groups.Values)
foreach (var exam in _exams.GetByGroup(group.Id) foreach (var exam in _exams.GetByGroup(group.Id)
.Where(e => e.Status is ExamStatus.Conducted or ExamStatus.Graded) .Where(e => e.Status is ExamStatus.Conducted or ExamStatus.Graded)
@@ -493,8 +547,13 @@ public partial class DashboardViewModel : ObservableObject
{ {
var (expected, evaluated) = ExamCorrectionCounter.Count(exam, var (expected, evaluated) = ExamCorrectionCounter.Count(exam,
_memberships.GetByGroup(group.Id), _examResults.GetByExam(exam.Id)); _memberships.GetByGroup(group.Id), _examResults.GetByExam(exam.Id));
OpenCorrections.Add(new CorrectionProgressItem(exam.Id, group.Id, exam.Title, var percent = expected == 0 ? 0 : (int)Math.Round(evaluated * 100.0 / expected);
group.Name, exam.Date, evaluated, expected, today)); var groupId = group.Id;
_correctionItems.Add(new AttentionItem(AttentionKind.Correction, exam.Title, subtitle: group.Name,
dateDisplay: exam.Date.ToString("dd.MM.yyyy"),
isOverdue: exam.Date < today.AddDays(-7) && evaluated < expected,
progressPercent: percent, progressLabel: $"{evaluated} von {expected} Arbeiten bewertet", hasProgress: true,
navigate: () => OnNavigateToExam?.Invoke(groupId)));
} }
} }
@@ -510,14 +569,14 @@ public partial class DashboardViewModel : ObservableObject
private void LoadUnplannedLessons(IReadOnlyDictionary<Guid, LearningGroup> groups, DateOnly today) private void LoadUnplannedLessons(IReadOnlyDictionary<Guid, LearningGroup> groups, DateOnly today)
{ {
UnplannedLessons.Clear(); _unplannedItems.Clear();
var lastDay = today.AddDays(UnplannedLessonsLookaheadDays); var lastDay = today.AddDays(UnplannedLessonsLookaheadDays);
var publicHolidayDates = Enumerable.Range(today.Year, lastDay.Year - today.Year + 1) var publicHolidayDates = Enumerable.Range(today.Year, lastDay.Year - today.Year + 1)
.SelectMany(y => _publicHolidays.GetHolidays(y, _calendarSettings.State)) .SelectMany(y => _publicHolidays.GetHolidays(y, _calendarSettings.State))
.Select(h => h.Date).ToHashSet(); .Select(h => h.Date).ToHashSet();
var schoolHolidays = _schoolHolidays.GetAll(); var schoolHolidays = _schoolHolidays.GetAll();
var items = new List<UnplannedLessonItem>(); var items = new List<(DateOnly Date, int PeriodNumber, LearningGroup Group)>();
foreach (var group in groups.Values.Where(g => g.RequiresLessonPlanning)) foreach (var group in groups.Values.Where(g => g.RequiresLessonPlanning))
{ {
var slots = _timetableSlots.GetByGroup(group.Id); var slots = _timetableSlots.GetByGroup(group.Id);
@@ -546,12 +605,19 @@ public partial class DashboardViewModel : ObservableObject
{ {
continue; continue;
} }
items.Add(new UnplannedLessonItem(group.Id, group.Name, date, slot.PeriodNumber, today)); items.Add((date, slot.PeriodNumber, group));
} }
} }
} }
foreach (var item in items.OrderBy(i => i.Date).ThenBy(i => i.PeriodNumber).ThenBy(i => i.GroupName)) foreach (var (date, periodNumber, group) in items.OrderBy(i => i.Date).ThenBy(i => i.PeriodNumber)
UnplannedLessons.Add(item); .ThenBy(i => i.Group.Name))
{
var dateDisplay = date == today ? "Heute" : date == today.AddDays(1) ? "Morgen" : date.ToString("dd.MM.");
var groupId = group.Id;
_unplannedItems.Add(new AttentionItem(AttentionKind.Unplanned,
$"{group.Name} · {periodNumber}. Stunde", dateDisplay: dateDisplay,
navigate: () => OnNavigateToUnplannedLesson?.Invoke(groupId)));
}
} }
/// <summary>Erkennt, ob eine Stunde ohne eigene Lesson bereits Teil einer Doppelstunde ist, die /// <summary>Erkennt, ob eine Stunde ohne eigene Lesson bereits Teil einer Doppelstunde ist, die
@@ -583,11 +649,11 @@ public partial class DashboardViewModel : ObservableObject
private void LoadAlerts(IReadOnlyDictionary<Guid, LearningGroup> groups, DateOnly today) private void LoadAlerts(IReadOnlyDictionary<Guid, LearningGroup> groups, DateOnly today)
{ {
Alerts.Clear(); _alertItems.Clear();
foreach (var warning in AttendanceWarnings) // Fehlzeiten-Auffälligkeiten erscheinen in der zusammengefassten Handlungsbedarf-Karte
Alerts.Add(new DashboardAlertItem(warning.StudentId, null, warning.StudentName, // bereits als eigene Gruppe "Fehlzeiten-Warnung" (LoadAttendanceWarnings) — eine weitere
"Fehlzeiten", $"Fehlzeitenquote {warning.AbsenceRatePercent:0.#} %", AlertSeverity.High)); // Kopie hier wäre jetzt eine sichtbare Dopplung derselben Schüler/Zahl, die vor dem Merge
// durch zwei getrennte Kacheln (Auffälligkeiten vs. Fehlzeiten-Warnung) nicht auffiel.
foreach (var group in groups.Values) foreach (var group in groups.Values)
{ {
var groupReportGrades = _reportGrades.GetByGroup(group.Id); var groupReportGrades = _reportGrades.GetByGroup(group.Id);
@@ -596,6 +662,7 @@ public partial class DashboardViewModel : ObservableObject
{ {
var student = _students.GetById(membership.StudentId); var student = _students.GetById(membership.StudentId);
if (student is null) continue; if (student is null) continue;
var studentId = student.Id;
var values = _grades.GetByStudentAndGroup(student.Id, group.Id) var values = _grades.GetByStudentAndGroup(student.Id, group.Id)
.OrderBy(g => g.Date) .OrderBy(g => g.Date)
.Select(g => int.TryParse(g.Value, out var value) ? (int?)value : null) .Select(g => int.TryParse(g.Value, out var value) ? (int?)value : null)
@@ -609,9 +676,10 @@ public partial class DashboardViewModel : ObservableObject
? recent - previous >= 1.0 ? recent - previous >= 1.0
: previous - recent >= 3.0; : previous - recent >= 3.0;
if (declined) if (declined)
Alerts.Add(new DashboardAlertItem(student.Id, group.Id, student.FullName, _alertItems.Add(new AttentionItem(AttentionKind.Alert, student.FullName,
"Notenabfall", $"{group.Name}: zuletzt {recent:0.0}, zuvor {previous:0.0}", subtitle: $"{group.Name}: zuletzt {recent:0.0}, zuvor {previous:0.0}",
AlertSeverity.Medium)); trailingText: "Notenabfall", severity: AlertSeverity.Medium,
navigate: () => OnNavigateToStudent?.Invoke(studentId)));
} }
var latestReport = groupReportGrades var latestReport = groupReportGrades
@@ -620,9 +688,10 @@ public partial class DashboardViewModel : ObservableObject
var effective = latestReport?.OverrideValue ?? latestReport?.CalculatedValue; var effective = latestReport?.OverrideValue ?? latestReport?.CalculatedValue;
if (int.TryParse(effective, out var reportValue) if (int.TryParse(effective, out var reportValue)
&& (group.GradingSystem == GradingSystem.Grades1To6 ? reportValue >= 5 : reportValue <= 4)) && (group.GradingSystem == GradingSystem.Grades1To6 ? reportValue >= 5 : reportValue <= 4))
Alerts.Add(new DashboardAlertItem(student.Id, group.Id, student.FullName, _alertItems.Add(new AttentionItem(AttentionKind.Alert, student.FullName,
"Versetzungsgefährdung", $"{group.Name}: aktueller Stand {reportValue}", subtitle: $"{group.Name}: aktueller Stand {reportValue}",
AlertSeverity.High)); trailingText: "Versetzungsgefährdung", severity: AlertSeverity.High,
navigate: () => OnNavigateToStudent?.Invoke(studentId)));
} }
} }
} }
@@ -644,19 +713,74 @@ public partial class DashboardViewModel : ObservableObject
private static string CardTitle(string key) => key switch private static string CardTitle(string key) => key switch
{ {
"today" => "Heute", "tasks" => "Offene Aufgaben", "calendar" => "Kalender", "today" => "Heute", "tasks" => "Offene Aufgaben", "calendar" => "Kalender",
"excuses" => "Offene Entschuldigungen", "upcoming" => "Anstehende Termine", "upcoming" => "Anstehende Termine", "groups" => "Meine Lerngruppen", "examload" => "Klausurwochen",
"corrections" => "Offene Korrekturen", "unplanned" => "Ungeplante Stunden", "alerts" => "Auffälligkeiten", "attention" => "Handlungsbedarf", _ => key,
"attendance" => "Fehlzeiten-Warnung", "support" => "Förderplan-Wiedervorlage",
"groups" => "Meine Lerngruppen", "examload" => "Klausurwochen",
"missingteachingtime" => "Unterrichtszeit nacherfassen", _ => key,
}; };
// ── Handlungsbedarf: Filter-Chips ─────────────────────────────────────────
//
// Ersetzt die frühere Sichtbarkeit je Einzelkachel (sieben Schalter im "Bereiche anpassen"-
// Panel) durch Filter-Chips innerhalb der zusammengefassten Karte — dichter, und der
// naheliegende Ort, weil alle sieben jetzt eine Karte sind. Bewusst nur für die Dauer der
// Sitzung (keine Persistenz über DashboardSettingsService): das JSON-Format dort ist eine
// flache Liste von Kachel-Einstellungen, eine zweite Objektform nur für diese sieben Filter
// hätte das Dateiformat aufgespalten, ohne dass "welche Handlungsbedarf-Art blende ich
// dauerhaft aus" bisher als Bedürfnis geäußert wurde.
private void LoadAttentionFilters()
{
AttentionFilters.Clear();
foreach (var (kind, header) in AttentionGroupOrder)
{
var option = new AttentionFilterOption(kind, header) { OnChanged = RebuildAttention };
AttentionFilters.Add(option);
}
}
private bool IsAttentionFilterActive(AttentionKind kind) =>
AttentionFilters.FirstOrDefault(f => f.Kind == kind)?.IsActive ?? true;
/// <summary>Setzt Attention aus den bereits geladenen _xyzItems-Feldern neu zusammen — reine
/// Umsortierung/Filterung im Speicher, kein Repo-Zugriff. Wird nach jedem Load() sowie nach
/// jeder punktuellen Änderung (Entschuldigung aufgelöst, Zeit nacherfasst, Filter-Chip
/// umgeschaltet) aufgerufen.</summary>
private void RebuildAttention()
{
var byKind = new Dictionary<AttentionKind, IReadOnlyList<AttentionItem>>
{
[AttentionKind.MissingTeachingTime] = _missingTimeItems,
[AttentionKind.Excuse] = _excuses.Select(ToAttentionItem).ToList(),
[AttentionKind.Correction] = _correctionItems,
[AttentionKind.Unplanned] = _unplannedItems,
[AttentionKind.Alert] = _alertItems,
[AttentionKind.Attendance] = _attendanceItems,
[AttentionKind.SupportPlan] = _supportItems,
};
Attention.Clear();
foreach (var (kind, header) in AttentionGroupOrder)
{
if (!IsAttentionFilterActive(kind)) continue;
var items = byKind[kind];
if (items.Count == 0) continue;
Attention.Add(new AttentionGroup(kind, header, items));
}
}
private static AttentionItem ToAttentionItem(OpenExcuseItem e) => new(
AttentionKind.Excuse, e.StudentName, subtitle: $"{e.GroupName} · {e.DateDisplay}",
actions: [new AttentionAction("Entschuldigt", e.MarkExcusedCommand),
new AttentionAction("Unentschuldigt", e.MarkUnexcusedCommand)]);
// Zaehlt bewusst EffectiveIsVisible, nicht IsVisible: eine eingeschaltete, aber gerade leere
// HideWhenEmpty-Kachel wird nicht gerendert und darf deshalb auch keinen Rasterplatz belegen,
// sonst bleibt an ihrer Stelle eine Luecke im zweispaltigen Grid.
private void ApplyCardLayout() private void ApplyCardLayout()
{ {
var visibleIndex = 0; var visibleIndex = 0;
foreach (var card in DashboardCards) foreach (var card in DashboardCards)
{ {
var index = card.IsVisible ? visibleIndex++ : 0; var index = card.EffectiveIsVisible ? visibleIndex++ : 0;
card.Row = index / 2; card.Row = index / 2;
card.Column = index % 2; card.Column = index % 2;
} }
@@ -692,11 +816,10 @@ public partial class DashboardViewModel : ObservableObject
SaveAndApplyCardLayout(); SaveAndApplyCardLayout();
} }
[RelayCommand] private void OpenStudentAttendance(AttendanceWarningItem? item) /// <summary>Ersetzt die früheren fünf eigenen Navigations-Commands (OpenStudentAttendance,
{ if (item is not null) OnNavigateToStudent?.Invoke(item.StudentId); } /// OpenStudentSupportPlan, OpenCorrection, OpenUnplannedLesson, OpenAlert) — jedes AttentionItem
/// trägt sein Sprungziel bereits als Closure in Navigate.</summary>
[RelayCommand] private void OpenStudentSupportPlan(SupportPlanDueItem? item) [RelayCommand] private void OpenAttentionItem(AttentionItem? item) => item?.Navigate?.Invoke();
{ if (item is not null) OnNavigateToStudent?.Invoke(item.StudentId); }
[RelayCommand] [RelayCommand]
private async Task AddMissingTeachingTime(MissingTeachingTimeItem? item) private async Task AddMissingTeachingTime(MissingTeachingTimeItem? item)
@@ -704,12 +827,13 @@ public partial class DashboardViewModel : ObservableObject
if (item is null || OnAddMissingTeachingTime is null) return; if (item is null || OnAddMissingTeachingTime is null) return;
await OnAddMissingTeachingTime(item); await OnAddMissingTeachingTime(item);
LoadMissingTeachingTime(DateOnly.FromDateTime(DateTime.Today)); LoadMissingTeachingTime(DateOnly.FromDateTime(DateTime.Today));
RebuildAttention();
UpdateDashboardSummary(); UpdateDashboardSummary();
} }
private void LoadOpenExcuses(List<LearningGroup> groups, DateOnly today) private void LoadOpenExcuses(List<LearningGroup> groups, DateOnly today)
{ {
OpenExcuses.Clear(); _excuses.Clear();
var cutoff = today.AddDays(-OpenExcuseMaxAgeDays); var cutoff = today.AddDays(-OpenExcuseMaxAgeDays);
var items = new List<OpenExcuseItem>(); var items = new List<OpenExcuseItem>();
@@ -728,8 +852,7 @@ public partial class DashboardViewModel : ObservableObject
} }
} }
} }
foreach (var item in items.OrderBy(i => i.Date)) _excuses.AddRange(items.OrderBy(i => i.Date));
OpenExcuses.Add(item);
} }
private void ResolveExcuse(OpenExcuseItem item, AttendanceStatus status) private void ResolveExcuse(OpenExcuseItem item, AttendanceStatus status)
@@ -738,7 +861,8 @@ public partial class DashboardViewModel : ObservableObject
if (entry is null) return; if (entry is null) return;
entry.Attendance = status; entry.Attendance = status;
_participationEntries.Save(entry); _participationEntries.Save(entry);
OpenExcuses.Remove(item); _excuses.Remove(item);
RebuildAttention();
UpdateDashboardSummary(); UpdateDashboardSummary();
} }
@@ -939,12 +1063,6 @@ public partial class DashboardViewModel : ObservableObject
else OnNavigateToGroup?.Invoke(groupId); else OnNavigateToGroup?.Invoke(groupId);
} }
} }
[RelayCommand] private void OpenCorrection(CorrectionProgressItem? item)
{ if (item is not null) OnNavigateToExam?.Invoke(item.GroupId); }
[RelayCommand] private void OpenUnplannedLesson(UnplannedLessonItem? item)
{ if (item is not null) OnNavigateToUnplannedLesson?.Invoke(item.GroupId); }
[RelayCommand] private void OpenAlert(DashboardAlertItem? item)
{ if (item is not null) OnNavigateToStudent?.Invoke(item.StudentId); }
[RelayCommand] private void Refresh() => Load(); [RelayCommand] private void Refresh() => Load();
[RelayCommand] private Task AddTask() => AddTaskInternal(startAsReminder: false); [RelayCommand] private Task AddTask() => AddTaskInternal(startAsReminder: false);
@@ -1093,26 +1211,6 @@ public class MissingTeachingTimeItem(DateOnly date, TimeOnly windowStart, TimeOn
public string DateDisplay { get; } = date.ToString("dddd, dd.MM.", De); public string DateDisplay { get; } = date.ToString("dddd, dd.MM.", De);
} }
// ── Förderplan-Wiedervorlage (5.3.2) ──────────────────────────────────────────
public class SupportPlanDueItem
{
public Guid StudentId { get; }
public string StudentName { get; }
public string Title { get; }
public string ReviewDateDisplay { get; }
public bool IsOverdue { get; }
public SupportPlanDueItem(Guid studentId, string studentName, string title, DateOnly reviewDate, DateOnly today)
{
StudentId = studentId;
StudentName = studentName;
Title = title;
ReviewDateDisplay = reviewDate.ToString("dd.MM.yyyy");
IsOverdue = reviewDate < today;
}
}
public partial class CalendarDayCell : ObservableObject public partial class CalendarDayCell : ObservableObject
{ {
[ObservableProperty] private bool _isSelected; [ObservableProperty] private bool _isSelected;
@@ -1188,46 +1286,8 @@ public sealed class UpcomingDateItem(UpcomingDateKind kind, DateOnly date, strin
}; };
} }
public sealed class CorrectionProgressItem(Guid examId, Guid groupId, string title, string groupName,
DateOnly date, int completed, int total, DateOnly today)
{
public Guid ExamId { get; } = examId;
public Guid GroupId { get; } = groupId;
public string Title { get; } = title;
public string GroupName { get; } = groupName;
public DateOnly Date { get; } = date;
public int Completed { get; } = completed;
public int Total { get; } = total;
public int Percent => Total == 0 ? 0 : (int)Math.Round(Completed * 100.0 / Total);
public string ProgressDisplay => $"{Completed} von {Total} Arbeiten bewertet";
public string DateDisplay => Date.ToString("dd.MM.yyyy");
public bool IsOverdue => Date < today.AddDays(-7) && Completed < Total;
}
public sealed class UnplannedLessonItem(Guid groupId, string groupName, DateOnly date, int periodNumber, DateOnly today)
{
public Guid GroupId { get; } = groupId;
public string GroupName { get; } = groupName;
public DateOnly Date { get; } = date;
public int PeriodNumber { get; } = periodNumber;
public string DateDisplay => Date == today ? "Heute" : Date == today.AddDays(1) ? "Morgen" : Date.ToString("dd.MM.");
public string Display => $"{GroupName} · {PeriodNumber}. Stunde";
}
public enum AlertSeverity { Medium, High } public enum AlertSeverity { Medium, High }
public sealed class DashboardAlertItem(Guid studentId, Guid? groupId, string studentName,
string kindLabel, string detail, AlertSeverity severity)
{
public Guid StudentId { get; } = studentId;
public Guid? GroupId { get; } = groupId;
public string StudentName { get; } = studentName;
public string KindLabel { get; } = kindLabel;
public string Detail { get; } = detail;
public AlertSeverity Severity { get; } = severity;
public string SeverityColor => Severity == AlertSeverity.High ? "#D32F2F" : "#F59E0B";
}
public partial class DashboardCardOption : ObservableObject public partial class DashboardCardOption : ObservableObject
{ {
[ObservableProperty] private bool _isVisible; [ObservableProperty] private bool _isVisible;
@@ -1240,12 +1300,19 @@ public partial class DashboardCardOption : ObservableObject
public bool EffectiveIsVisible => IsVisible && (!HideWhenEmpty || !IsEmpty); public bool EffectiveIsVisible => IsVisible && (!HideWhenEmpty || !IsEmpty);
public Action? OnVisibilityChanged { get; set; } public Action? OnVisibilityChanged { get; set; }
/// <summary>Rinnstein zur jeweils anderen Rasterspalte. Muss aus der berechneten
/// <see cref="Column"/> kommen und darf nicht im XAML fest an der Kachel haengen: welche Kachel
/// links und welche rechts landet, entscheidet sich erst zur Laufzeit aus Reihenfolge und
/// Sichtbarkeit, ein fester Margin sitzt dann bei jeder Umschaltung auf der falschen Seite.</summary>
public Avalonia.Thickness Margin => Column == 0
? new Avalonia.Thickness(0, 0, 8, 8)
: new Avalonia.Thickness(8, 0, 0, 8);
public DashboardCardOption(string key, string title, bool isVisible) public DashboardCardOption(string key, string title, bool isVisible)
{ {
Key = key; Key = key;
Title = title; Title = title;
HideWhenEmpty = key is "excuses" or "upcoming" or "corrections" or "unplanned" HideWhenEmpty = key is "upcoming" or "attention";
or "alerts" or "attendance" or "support";
_isVisible = isVisible; _isVisible = isVisible;
} }
@@ -1256,4 +1323,6 @@ public partial class DashboardCardOption : ObservableObject
} }
partial void OnIsEmptyChanged(bool value) => OnPropertyChanged(nameof(EffectiveIsVisible)); partial void OnIsEmptyChanged(bool value) => OnPropertyChanged(nameof(EffectiveIsVisible));
partial void OnColumnChanged(int value) => OnPropertyChanged(nameof(Margin));
} }
@@ -5,6 +5,7 @@ using LehrerApp.Core.Interfaces;
using LehrerApp.Core.Models; using LehrerApp.Core.Models;
using LehrerApp.Core.Services; using LehrerApp.Core.Services;
using LehrerApp.Desktop.Services; using LehrerApp.Desktop.Services;
using LehrerApp.Desktop.ViewModels.Planning;
using LehrerApp.Desktop.ViewModels.Students; using LehrerApp.Desktop.ViewModels.Students;
using System.Collections.ObjectModel; using System.Collections.ObjectModel;
using System.Globalization; using System.Globalization;
@@ -75,6 +76,7 @@ public partial class PlanningTabViewModel : ObservableObject
public Func<Lesson, List<string>, List<string>, Task<bool>>? OnEditLesson { get; set; } public Func<Lesson, List<string>, List<string>, Task<bool>>? OnEditLesson { get; set; }
public Func<LessonSummary, Task<bool>>? OnConfirmDeleteLesson { get; set; } public Func<LessonSummary, Task<bool>>? OnConfirmDeleteLesson { get; set; }
public Func<Lesson, Task<MoveLessonTarget?>>? OnPickMoveTarget { get; set; } public Func<Lesson, Task<MoveLessonTarget?>>? OnPickMoveTarget { get; set; }
public Func<Lesson, Task<Unit?>>? OnPickUnitChange { get; set; }
public Func<Lesson, Task>? OnShowLesson { get; set; } public Func<Lesson, Task>? OnShowLesson { get; set; }
public Func<Unit, Task<LessonSeriesResult?>>? OnGenerateLessonSeries { get; set; } public Func<Unit, Task<LessonSeriesResult?>>? OnGenerateLessonSeries { get; set; }
public Func<Unit, Task<bool>>? OnAiAssist { get; set; } public Func<Unit, Task<bool>>? OnAiAssist { get; set; }
@@ -100,10 +102,10 @@ public partial class PlanningTabViewModel : ObservableObject
GradeLevel = group?.GradeLevel ?? 0; GradeLevel = group?.GradeLevel ?? 0;
SubjectName = SubjectId is Guid sid ? _subjects.GetById(sid)?.Name ?? "" : ""; SubjectName = SubjectId is Guid sid ? _subjects.GetById(sid)?.Name ?? "" : "";
GroupLabel = group?.Name ?? ""; GroupLabel = group?.Name ?? "";
LoadUnits(); LoadUnits(preferActive: true);
} }
private void LoadUnits() private void LoadUnits(bool preferActive = false)
{ {
var selectedId = SelectedUnit?.Id; var selectedId = SelectedUnit?.Id;
Units.Clear(); Units.Clear();
@@ -126,7 +128,11 @@ public partial class PlanningTabViewModel : ObservableObject
KnownMaterials = materials.OrderBy(m => m, StringComparer.CurrentCultureIgnoreCase).ToList(); KnownMaterials = materials.OrderBy(m => m, StringComparer.CurrentCultureIgnoreCase).ToList();
KnownShorthands = shorthands.OrderBy(s => s, StringComparer.CurrentCultureIgnoreCase).ToList(); KnownShorthands = shorthands.OrderBy(s => s, StringComparer.CurrentCultureIgnoreCase).ToList();
SelectedUnit = Units.FirstOrDefault(u => u.Id == selectedId) ?? Units.FirstOrDefault(); // Beim ersten Öffnen bzw. beim Wechsel aus dem Stundenplan steht die laufende Einheit im
// Fokus. Eine bewusste Auswahl innerhalb derselben Gruppe bleibt bei Refreshes erhalten.
SelectedUnit = !preferActive && selectedId is not null
? Units.FirstOrDefault(u => u.Id == selectedId) ?? Units.FirstOrDefault(u => u.Status == UnitStatus.Active) ?? Units.FirstOrDefault()
: Units.FirstOrDefault(u => u.Status == UnitStatus.Active) ?? Units.FirstOrDefault();
} }
partial void OnSelectedUnitChanged(UnitSummary? value) partial void OnSelectedUnitChanged(UnitSummary? value)
@@ -160,6 +166,7 @@ public partial class PlanningTabViewModel : ObservableObject
EditLessonCommand.NotifyCanExecuteChanged(); EditLessonCommand.NotifyCanExecuteChanged();
DeleteLessonCommand.NotifyCanExecuteChanged(); DeleteLessonCommand.NotifyCanExecuteChanged();
MoveLessonCommand.NotifyCanExecuteChanged(); MoveLessonCommand.NotifyCanExecuteChanged();
ChangeLessonUnitCommand.NotifyCanExecuteChanged();
AdvanceLessonStatusCommand.NotifyCanExecuteChanged(); AdvanceLessonStatusCommand.NotifyCanExecuteChanged();
} }
@@ -349,6 +356,23 @@ public partial class PlanningTabViewModel : ObservableObject
new LessonSchedulingService(_lessons).Move(moved, newDate, newPeriod, shiftFollowing); new LessonSchedulingService(_lessons).Move(moved, newDate, newPeriod, shiftFollowing);
} }
/// Ordnet eine bereits angelegte Stunde einer anderen Einheit derselben Gruppe zu (Korrektur
/// einer im Erstellen-Dialog falsch gewählten Einheit, Nutzer-Feedback) — bisher war das nur
/// über Löschen + Neuanlegen möglich. Datum/Stundennummer bleiben unverändert, nur UnitId ändert
/// sich. Nach dem Speichern springt die Ansicht direkt zur Ziel-Einheit mit der Stunde
/// vorausgewählt, damit sichtbar bleibt, wohin sie verschoben wurde.
[RelayCommand(CanExecute = nameof(HasSelectedLesson))]
private async Task ChangeLessonUnit()
{
if (OnPickUnitChange is null || SelectedLesson is null) return;
var lesson = SelectedLesson.Model;
var targetUnit = await OnPickUnitChange(lesson);
if (targetUnit is null) return;
lesson.UnitId = targetUnit.Id;
_lessons.Save(lesson);
RefreshPlanning(selectUnitId: targetUnit.Id, selectLessonId: lesson.Id);
}
[RelayCommand(CanExecute = nameof(HasSelectedLesson))] [RelayCommand(CanExecute = nameof(HasSelectedLesson))]
private void AdvanceLessonStatus() private void AdvanceLessonStatus()
{ {
@@ -364,16 +388,25 @@ public partial class PlanningTabViewModel : ObservableObject
/// Übernimmt Datum + Thema der Stunde in eine neue Mitarbeitssitzung (3.3.1) — verknüpft über /// Übernimmt Datum + Thema der Stunde in eine neue Mitarbeitssitzung (3.3.1) — verknüpft über
/// das bisher ungenutzte Lesson.LessonId-Feld auf ParticipationSession, damit ein zweiter Klick /// das bisher ungenutzte Lesson.LessonId-Feld auf ParticipationSession, damit ein zweiter Klick
/// auf dieselbe Stunde keine doppelte Sitzung anlegt, sondern nur darauf hinweist. /// auf dieselbe Stunde keine doppelte Sitzung anlegt, sondern nur darauf hinweist.
///
/// Prüft dabei zusätzlich auf JEDE bereits an diesem Tag bestehende Sitzung, nicht nur eine
/// exakt mit `lesson.Id` verknüpfte (Nutzer-Feedback, analog
/// <see cref="SeatingPlanTabViewModel.SelectOrCreateSessionForLesson"/>): kommt neben einer
/// Doppelstunde noch eine dritte Stunde desselben Tages hinzu (eigene `Lesson`, z.B. durch
/// Vertretung), soll das nicht zu einer zweiten Mitarbeitssitzung für den Tag führen.
[RelayCommand(CanExecute = nameof(HasSelectedLesson))] [RelayCommand(CanExecute = nameof(HasSelectedLesson))]
private void CreateParticipationSession() private void CreateParticipationSession()
{ {
if (SelectedLesson is null) return; if (SelectedLesson is null) return;
var lesson = SelectedLesson.Model; var lesson = SelectedLesson.Model;
var existing = _participationSessions.GetByGroup(lesson.GroupId) var sessionsForGroup = _participationSessions.GetByGroup(lesson.GroupId);
.FirstOrDefault(s => s.LessonId == lesson.Id); var existing = sessionsForGroup.FirstOrDefault(s => s.LessonId == lesson.Id)
?? sessionsForGroup.FirstOrDefault(s => s.Date == lesson.Date);
if (existing is not null) if (existing is not null)
{ {
OnNotify?.Invoke("Für diese Stunde existiert bereits eine Sitzung."); OnNotify?.Invoke(existing.LessonId == lesson.Id
? "Für diese Stunde existiert bereits eine Sitzung."
: "Für diesen Tag existiert bereits eine Sitzung.");
return; return;
} }
@@ -545,6 +578,7 @@ public partial class UnitDialogViewModel : ObservableObject
[ObservableProperty] private string _startDateText = ""; [ObservableProperty] private string _startDateText = "";
[ObservableProperty] private string _endDateText = ""; [ObservableProperty] private string _endDateText = "";
[ObservableProperty] private string _statusName = UnitStatusDisplay.Options[0]; [ObservableProperty] private string _statusName = UnitStatusDisplay.Options[0];
[ObservableProperty] private string _statusNotice = "";
[ObservableProperty] private string _notes = ""; [ObservableProperty] private string _notes = "";
[ObservableProperty] private bool _isCompetencyPanelOpen; [ObservableProperty] private bool _isCompetencyPanelOpen;
[ObservableProperty] private string _titleError = ""; [ObservableProperty] private string _titleError = "";
@@ -565,6 +599,20 @@ public partial class UnitDialogViewModel : ObservableObject
public string DialogTitle => _editingUnit is null ? "Neue Einheit anlegen" : "Einheit bearbeiten"; public string DialogTitle => _editingUnit is null ? "Neue Einheit anlegen" : "Einheit bearbeiten";
public string SaveButtonText => _editingUnit is null ? "Anlegen" : "Speichern"; public string SaveButtonText => _editingUnit is null ? "Anlegen" : "Speichern";
partial void OnStatusNameChanged(string value)
{
if (UnitStatusDisplay.FromName(value) != UnitStatus.Active)
{
StatusNotice = "";
return;
}
var previous = _units.GetByGroup(_groupId)
.FirstOrDefault(u => u.Status == UnitStatus.Active && u.Id != _editingUnit?.Id);
StatusNotice = previous is null ? ""
: $"„{previous.Title}“ wird beim Speichern automatisch abgeschlossen.";
}
public UnitDialogViewModel(IUnitRepository units, ICompetencyDomainRepository competencyDomains, public UnitDialogViewModel(IUnitRepository units, ICompetencyDomainRepository competencyDomains,
Guid groupId, Guid? subjectId, int gradeLevel, string groupName, string subjectName, Unit? editingUnit) Guid groupId, Guid? subjectId, int gradeLevel, string groupName, string subjectName, Unit? editingUnit)
{ {
@@ -650,6 +698,17 @@ public partial class UnitDialogViewModel : ObservableObject
Result.Status = UnitStatusDisplay.FromName(StatusName); Result.Status = UnitStatusDisplay.FromName(StatusName);
Result.Notes = string.IsNullOrWhiteSpace(Notes) ? null : Notes.Trim(); Result.Notes = string.IsNullOrWhiteSpace(Notes) ? null : Notes.Trim();
Result.Competencies = _competencyCodes; Result.Competencies = _competencyCodes;
if (Result.Status == UnitStatus.Active)
{
// Pro Lerngruppe gibt es genau einen aktuellen Arbeitskontext. Frühere laufende
// Einheiten werden nicht verworfen, sondern fachlich sauber abgeschlossen.
foreach (var previous in _units.GetByGroup(_groupId)
.Where(u => u.Status == UnitStatus.Active && u.Id != Result.Id))
{
previous.Status = UnitStatus.Completed;
_units.Save(previous);
}
}
_units.Save(Result); _units.Save(Result);
} }
} }
@@ -716,8 +775,10 @@ public partial class LessonDialogViewModel : ObservableObject
// gespeicherte Stunden sinnvoll, eine gerade erst angelegte, noch ungespeicherte Stunde hat // gespeicherte Stunden sinnvoll, eine gerade erst angelegte, noch ungespeicherte Stunde hat
// keine echte Id, auf die sich die KI beziehen könnte. // keine echte Id, auf die sich die KI beziehen könnte.
public Guid UnitId => _unitId; public Guid UnitId => _unitId;
public Guid GroupId => _groupId;
public Lesson? EditingLesson => _editingLesson; public Lesson? EditingLesson => _editingLesson;
public bool CanAiAssist => _editingLesson is not null; public bool CanAiAssist => _editingLesson is not null;
public bool IsNewLesson => _editingLesson is null;
/// Vom Code-Behind nach einer über die KI angewendeten Änderung aufgerufen: der Dialog schließt /// Vom Code-Behind nach einer über die KI angewendeten Änderung aufgerufen: der Dialog schließt
/// sich danach mit Result != null, damit die aufrufende Liste neu lädt — die eigenen, jetzt /// sich danach mit Result != null, damit die aufrufende Liste neu lädt — die eigenen, jetzt
@@ -1197,6 +1258,88 @@ public partial class MoveLessonDialogViewModel : ObservableObject
} }
} }
// ── Dialog: Stunde einer anderen Einheit zuweisen (Korrektur der Einheit) ────
public partial class ChangeLessonUnitDialogViewModel : ObservableObject
{
[ObservableProperty] private TimetableUnitOption? _selectedUnit;
[ObservableProperty] private string _error = "";
public string CurrentUnitLabel { get; }
public ObservableCollection<TimetableUnitOption> Units { get; } = [];
public bool HasUnits => Units.Count > 0;
public Unit? Result { get; private set; }
public ChangeLessonUnitDialogViewModel(IUnitRepository units, Guid groupId, Guid currentUnitId,
string currentUnitTitle)
{
CurrentUnitLabel = currentUnitTitle;
foreach (var unit in units.GetByGroup(groupId).Where(u => u.Id != currentUnitId)
.OrderBy(u => u.Status == UnitStatus.Active ? 0 : u.Status == UnitStatus.Planned ? 1 : 2)
.ThenByDescending(u => u.StartDate)
.ThenBy(u => u.Title, StringComparer.CurrentCultureIgnoreCase))
Units.Add(new TimetableUnitOption(unit));
}
[RelayCommand]
private void Save()
{
Error = "";
if (SelectedUnit is null) { Error = "Bitte eine Ziel-Einheit auswählen."; return; }
Result = SelectedUnit.Model;
}
}
// ── Dialog: Vorhandene Stunde zum Verknüpfen auswählen (Stundenplan-FixIt) ────
/// <summary>
/// Sucht Stunden, die zu einem Stundenplan-Termin gehören könnten, aber (z.B. durch JSON-Import
/// oder KI-Übernahme ohne Stundenplan-Bezug) keine passende <see cref="Lesson.LessonNumber"/>
/// haben und deshalb von <c>TimetableViewModel.FindLessonForSlot</c> nicht gefunden werden.
/// Als eigene, von Avalonia unabhängige Methode extrahiert, damit die Zuordnungslogik ohne Fenster
/// testbar ist — Aufrufer ist <c>LessonDialog.axaml.cs</c> (Button "Vorhandene Stunde verknüpfen").
/// </summary>
public static class LessonFixItSearch
{
public static List<Lesson> FindCandidates(ILessonRepository lessons, Guid groupId, DateOnly date) =>
[.. lessons.GetByGroupAndRange(groupId, date.AddDays(-14), date.AddDays(14))
.Where(l => l.LessonNumber is null || l.Date == date)
.OrderBy(l => l.Date == date ? 0 : 1)
.ThenBy(l => Math.Abs(l.Date.DayNumber - date.DayNumber))];
}
public sealed class LessonLinkOption(Lesson lesson, string unitTitle)
{
public Lesson Model { get; } = lesson;
public string Label { get; } = string.IsNullOrWhiteSpace(lesson.Topic) ? "(ohne Thema)" : lesson.Topic;
public string Detail { get; } =
$"{lesson.Date:dd.MM.yyyy} · {(lesson.LessonNumber is int n ? $"{n}. Stunde" : "keine Stundennummer")} · Einheit „{unitTitle}“";
}
public partial class LinkExistingLessonDialogViewModel : ObservableObject
{
[ObservableProperty] private LessonLinkOption? _selectedOption;
[ObservableProperty] private string _error = "";
public ObservableCollection<LessonLinkOption> Options { get; } = [];
public Lesson? Result { get; private set; }
public LinkExistingLessonDialogViewModel(List<Lesson> candidates, IUnitRepository units)
{
foreach (var lesson in candidates)
Options.Add(new LessonLinkOption(lesson, units.GetById(lesson.UnitId)?.Title ?? "?"));
SelectedOption = Options.FirstOrDefault();
}
[RelayCommand]
private void Save()
{
Error = "";
if (SelectedOption is null) { Error = "Bitte eine Stunde auswählen."; return; }
Result = SelectedOption.Model;
}
}
// ── Dialog: Stunden serienweise aus dem Stundenplan erzeugen (4.2.5) ──────── // ── Dialog: Stunden serienweise aus dem Stundenplan erzeugen (4.2.5) ────────
public partial class GenerateLessonSeriesDialogViewModel : ObservableObject public partial class GenerateLessonSeriesDialogViewModel : ObservableObject
@@ -1378,10 +1521,16 @@ public partial class AiAssistDialogViewModel : ObservableObject
[ObservableProperty] private string _errorMessage = ""; [ObservableProperty] private string _errorMessage = "";
[ObservableProperty] private bool _hasResults; [ObservableProperty] private bool _hasResults;
[ObservableProperty] private string? _summary; [ObservableProperty] private string? _summary;
[ObservableProperty] private string? _rawResponse;
public string UnitSummary { get; } public string UnitSummary { get; }
public ObservableCollection<AiLessonReviewItem> ReviewItems { get; } = []; public ObservableCollection<AiLessonReviewItem> ReviewItems { get; } = [];
public bool Result { get; private set; } public bool Result { get; private set; }
public Unit Unit => _unit;
public Guid? FocusLessonId => _focusLesson?.Id;
public bool CanRescueResponse => !string.IsNullOrWhiteSpace(RawResponse);
partial void OnRawResponseChanged(string? value) => OnPropertyChanged(nameof(CanRescueResponse));
/// Aus dem Editor einer einzelnen Stunde heraus gestartet (statt aus der Einheiten-Übersicht, /// Aus dem Editor einer einzelnen Stunde heraus gestartet (statt aus der Einheiten-Übersicht,
/// Nutzer-Feedback nach den ersten Live-Tests) — die KI darf dann ausschließlich diese eine /// Nutzer-Feedback nach den ersten Live-Tests) — die KI darf dann ausschließlich diese eine
@@ -1420,7 +1569,7 @@ public partial class AiAssistDialogViewModel : ObservableObject
? ReviewItems.Where(i => i.Accepted).Select(i => i.Source).ToList() ? ReviewItems.Where(i => i.Accepted).Select(i => i.Source).ToList()
: null; : null;
ErrorMessage = ""; IsBusy = true; ErrorMessage = ""; RawResponse = null; IsBusy = true;
try try
{ {
var response = await _aiPlanning.RequestPlanAsync(_unit, Instruction, token, AllowModifyingExisting, var response = await _aiPlanning.RequestPlanAsync(_unit, Instruction, token, AllowModifyingExisting,
@@ -1452,7 +1601,11 @@ public partial class AiAssistDialogViewModel : ObservableObject
Summary = response.Summary; Summary = response.Summary;
HasResults = true; HasResults = true;
} }
catch (AiBackendException ex) { ErrorMessage = ex.Message; } catch (AiBackendException ex)
{
ErrorMessage = ex.Message;
RawResponse = ex.RawResponse;
}
finally { IsBusy = false; } finally { IsBusy = false; }
} }
@@ -1466,6 +1619,132 @@ public partial class AiAssistDialogViewModel : ObservableObject
} }
[RelayCommand] private void Cancel() => Result = false; [RelayCommand] private void Cancel() => Result = false;
public void MarkRescueImported() => Result = true;
}
/// <summary>Aus einer manuell geprüften KI-Antwort auswählbare Stunde.</summary>
public record AiRescueLessonOption(AiLesson Lesson, string Label);
/// <summary>Ziel für den manuellen Import in eine bereits vorhandene Stunde.</summary>
public record AiRescueTargetOption(Lesson Lesson, string Label);
/// <summary>
/// Rettungsdialog für syntaktisch fehlerhafte oder mit Freitext vermischte Modellantworten. Der
/// Nutzer entscheidet selbst, welcher Textabschnitt geparst und wohin die Stunde importiert wird.
/// </summary>
public partial class AiResponseRescueDialogViewModel : ObservableObject
{
private readonly AiPlanningService _aiPlanning;
private readonly ILessonRepository _lessons;
private readonly Unit _unit;
[ObservableProperty] private string _responseText;
[ObservableProperty] private string _parseMessage =
"Markiere den gültigen JSON-Abschnitt oder bearbeite den Text und klicke auf „Markierung prüfen“.";
[ObservableProperty] private AiRescueLessonOption? _selectedParsedLesson;
[ObservableProperty] private AiRescueTargetOption? _selectedTarget;
public ObservableCollection<AiRescueLessonOption> ParsedLessons { get; } = [];
public ObservableCollection<AiRescueTargetOption> ExistingLessons { get; } = [];
public bool HasParsedLesson => SelectedParsedLesson is not null;
public bool CanImportIntoExisting => SelectedParsedLesson is not null && SelectedTarget is not null;
public bool Result { get; private set; }
partial void OnSelectedParsedLessonChanged(AiRescueLessonOption? value)
{
OnPropertyChanged(nameof(HasParsedLesson));
OnPropertyChanged(nameof(CanImportIntoExisting));
}
partial void OnSelectedTargetChanged(AiRescueTargetOption? value) =>
OnPropertyChanged(nameof(CanImportIntoExisting));
public AiResponseRescueDialogViewModel(AiPlanningService aiPlanning, ILessonRepository lessons,
Unit unit, string rawResponse, Guid? preferredTargetId = null)
{
_aiPlanning = aiPlanning;
_lessons = lessons;
_unit = unit;
_responseText = rawResponse;
foreach (var lesson in lessons.GetByUnit(unit.Id).OrderBy(l => l.Date).ThenBy(l => l.LessonNumber))
{
var date = lesson.Date.ToString("dd.MM.yyyy", CultureInfo.InvariantCulture);
var number = lesson.LessonNumber is { } n ? $", Stunde {n}" : "";
ExistingLessons.Add(new AiRescueTargetOption(lesson, $"{date}{number}: {lesson.Topic}"));
}
SelectedTarget = ExistingLessons.FirstOrDefault(x => x.Lesson.Id == preferredTargetId)
?? ExistingLessons.FirstOrDefault();
}
public void ParseSelection(string selectedText)
{
ParsedLessons.Clear();
SelectedParsedLesson = null;
try
{
var parsed = AiPlanningService.ParsePlanningLessons(selectedText);
foreach (var lesson in parsed)
{
var date = lesson.Date?.ToString("dd.MM.yyyy", CultureInfo.InvariantCulture) ?? "ohne Datum";
ParsedLessons.Add(new AiRescueLessonOption(lesson, $"{date}: {lesson.Topic}"));
}
SelectedParsedLesson = ParsedLessons[0];
ParseMessage = parsed.Count == 1
? "Eine gültige Stunde erkannt."
: $"{parsed.Count} gültige Stunden erkannt. Bitte die gewünschte Stunde auswählen.";
}
catch (AiBackendException ex)
{
ParseMessage = ex.Message;
}
}
[RelayCommand]
private void ImportIntoExisting()
{
if (SelectedParsedLesson is null || SelectedTarget is null) return;
var lesson = CloneForImport(SelectedParsedLesson.Lesson, SelectedTarget.Lesson.Id);
Save([lesson], focusLessonId: SelectedTarget.Lesson.Id);
}
[RelayCommand]
private void ImportAsNew()
{
if (SelectedParsedLesson is null) return;
Save([CloneForImport(SelectedParsedLesson.Lesson, null)]);
}
private void Save(List<AiLesson> source, Guid? focusLessonId = null)
{
foreach (var lesson in _aiPlanning.ApplyResponse(_unit, source,
allowModifyingExistingLessons: true, focusLessonId))
_lessons.Save(lesson);
Result = true;
}
private static AiLesson CloneForImport(AiLesson source, Guid? id) => new()
{
Id = id,
Date = source.Date,
LessonNumber = source.LessonNumber,
Topic = source.Topic,
StartTime = source.StartTime,
Homework = source.Homework,
Reflection = source.Reflection,
Phases = source.Phases.Select(p => new AiPhaseStep
{
Name = p.Name,
DurationMinutes = p.DurationMinutes,
Activity = p.Activity,
Material = p.Material,
Shorthand = p.Shorthand,
AlternativePathName = p.AlternativePathName,
MaterialSuggestion = p.MaterialSuggestion,
}).ToList(),
};
} }
// ── Dialog: Einheit als Vorlage in andere Gruppe kopieren (4.1.4) ──────────── // ── Dialog: Einheit als Vorlage in andere Gruppe kopieren (4.1.4) ────────────
@@ -141,10 +141,18 @@ public partial class SeatingPlanTabViewModel : ObservableObject
/// erlaubt: welche Stunde gemeint ist, steht durch die explizite Auswahl der Lehrkraft /// erlaubt: welche Stunde gemeint ist, steht durch die explizite Auswahl der Lehrkraft
/// (Klick auf "Unterrichtsmodus starten" für genau diese Stunde) bereits unzweideutig fest - /// (Klick auf "Unterrichtsmodus starten" für genau diese Stunde) bereits unzweideutig fest -
/// keine Geistersitzungs-Gefahr wie beim bloßen Öffnen eines Tabs. /// keine Geistersitzungs-Gefahr wie beim bloßen Öffnen eines Tabs.
///
/// Fällt bewusst auf JEDE an diesem Tag bereits bestehende Sitzung zurück, nicht nur auf eine
/// exakt mit `lesson.Id` verknüpfte (Nutzer-Feedback): kommt neben einer Doppelstunde noch eine
/// dritte Stunde am selben Tag hinzu (z.B. Vertretung, eigene `Lesson` mit eigener Id), soll
/// keine zweite Mitarbeitssitzung für denselben Tag entstehen — die Lehrkraft passt stattdessen
/// die Einschätzung der bereits bestehenden Sitzung an. Das entspricht dem Verhalten von
/// <see cref="EnsureTodaySession"/>, das schon immer pro Tag statt pro Stunde arbeitet.
/// </summary> /// </summary>
public void SelectOrCreateSessionForLesson(Lesson lesson) public void SelectOrCreateSessionForLesson(Lesson lesson)
{ {
var existing = TodaySessions.FirstOrDefault(s => s.LessonId == lesson.Id); var existing = TodaySessions.FirstOrDefault(s => s.LessonId == lesson.Id)
?? TodaySessions.FirstOrDefault(s => s.Date == lesson.Date);
if (existing is not null) { SelectedSession = existing; return; } if (existing is not null) { SelectedSession = existing; return; }
if (!IsEditable) return; if (!IsEditable) return;
@@ -0,0 +1,229 @@
using System.Collections.ObjectModel;
using System.Text.RegularExpressions;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using LehrerApp.Core.Interfaces;
using LehrerApp.Core.Models;
using LehrerApp.Core.Services;
using LehrerApp.Desktop.Services;
namespace LehrerApp.Desktop.ViewModels.Groups;
/// <summary>Eine Zeile bleibt auch ohne automatische Zuordnung sichtbar - <see cref="AssignedStudent"/>
/// kann manuell per Auswahlliste gesetzt werden, gleiches Muster wie
/// <see cref="WebUntisLessonAbsenceComparisonViewModel"/> und
/// <see cref="LehrerApp.Desktop.ViewModels.Students.WebUntisDocumentationComparisonViewModel"/>.</summary>
public partial class WebUntisHomeworkRow : ObservableObject
{
public required string ClassName { get; init; }
public required DateOnly Date { get; init; }
public required string UntisStudentName { get; init; }
public string? SubjectLabel { get; init; }
public string? Text { get; init; }
public required IReadOnlyList<Student> Candidates { get; init; }
internal Guid? MatchedSubjectId { get; init; }
internal Action<WebUntisHomeworkRow>? OnAssignmentChanged { get; init; }
public string DateLabel => Date.ToString("dd.MM.yyyy");
public bool CanApply => SessionId is not null;
[ObservableProperty] private Student? _assignedStudent;
[ObservableProperty] private string _localStatus = "ohne Zuordnung";
[ObservableProperty] private Guid? _sessionId;
[ObservableProperty] private bool _selected;
partial void OnAssignedStudentChanged(Student? value) => OnAssignmentChanged?.Invoke(this);
}
/// <summary>Abgleich negativer Klassenbucheinträge zum Stichwort "Hausaufgabe" (eigene, siehe
/// <see cref="WebUntisIntegrationService.GetOwnClassRegisterEventsAsync"/>) gegen
/// <see cref="ParticipationEntry.Homework"/> - Dashboard-weit statt pro Lerngruppe, aus demselben
/// Grund wie beim Dokumentations-Abgleich (der WebUntis-"-alle-"-Bericht ist klassenübergreifend,
/// siehe TODO.md). Nutzer-Feedback: WebUntis liefert für Klassenbucheinträge weder eine externe
/// Schülerkennung noch eine feste Fach-/Lerngruppenzuordnung, aber der Lehrkraft-eigene Text enthält
/// praktisch immer das Untis-Fachkürzel - darüber wird die passende Lerngruppe (SubjectId + aktive
/// Mitgliedschaft am Eintragsdatum) aufgelöst, mehrdeutige Treffer bleiben unaufgelöst statt zu raten.
/// Ein Eintrag setzt lokal ausschließlich <see cref="HomeworkStatus.MissingOpen"/> vor, und auch nur,
/// wenn dort noch gar kein Status hinterlegt ist - bereits vorhandene, feinere Erfassungen ("Teilweise
/// angefertigt", "nachgereicht" usw.) werden nie automatisch überschrieben, sondern nur zum Vergleich
/// danebengestellt (Nutzerwunsch).</summary>
public partial class WebUntisHomeworkComparisonViewModel : ObservableObject
{
private readonly WebUntisIntegrationService _untis;
private readonly IStudentRepository _students;
private readonly IGroupRepository _groups;
private readonly ISubjectRepository _subjects;
private readonly IGroupMembershipRepository _memberships;
private readonly IParticipationSessionRepository _sessions;
private readonly IParticipationRepository _participation;
private readonly SchoolYearService _schoolYears;
public ObservableCollection<WebUntisHomeworkRow> Rows { get; } = [];
[ObservableProperty] private DateTimeOffset? _startDate = DateTimeOffset.Now.AddDays(-7);
[ObservableProperty] private DateTimeOffset? _endDate = DateTimeOffset.Now;
[ObservableProperty] private string _status = "Zeitraum wählen und Klassenbucheinträge laden.";
[ObservableProperty] private bool _busy;
public WebUntisHomeworkComparisonViewModel(WebUntisIntegrationService untis, IStudentRepository students,
IGroupRepository groups, ISubjectRepository subjects, IGroupMembershipRepository memberships,
IParticipationSessionRepository sessions, IParticipationRepository participation,
SchoolYearService schoolYears)
{
_untis = untis; _students = students; _groups = groups; _subjects = subjects;
_memberships = memberships; _sessions = sessions; _participation = participation;
_schoolYears = schoolYears;
}
[RelayCommand]
private async Task Load()
{
var start = DateOnly.FromDateTime((StartDate ?? DateTimeOffset.Now).LocalDateTime);
var end = DateOnly.FromDateTime((EndDate ?? DateTimeOffset.Now).LocalDateTime);
if (end < start) { Status = "Das Enddatum darf nicht vor dem Startdatum liegen."; return; }
Busy = true; Rows.Clear();
try
{
var ownStudents = _students.GetAll();
var globalIndex = BuildNameIndex(ownStudents);
// Wie beim Dokumentations-Abgleich zusätzlich pro Klasse indiziert, um gleiche Namen in
// verschiedenen Klassen unterscheiden zu können.
var classIndexes = _groups.GetAll()
.Where(g => g.Type == GroupType.Class)
.GroupBy(g => g.Name, StringComparer.OrdinalIgnoreCase)
.ToDictionary(
g => g.Key,
g => BuildNameIndex(g.SelectMany(x => _students.GetByGroup(x.Id)).Distinct().ToList()),
StringComparer.OrdinalIgnoreCase);
var shortNameIndex = _subjects.GetAll()
.Where(s => !string.IsNullOrWhiteSpace(s.ShortName))
.GroupBy(s => s.ShortName.Trim().ToUpperInvariant())
.Where(g => g.Count() == 1) // mehrdeutiges Kürzel lieber nicht zuordnen als raten
.ToDictionary(g => g.Key, g => g.First());
var entries = await _untis.GetOwnClassRegisterEventsAsync(start, end);
var ordered = entries
.Where(IsMissingHomeworkEntry)
.Select(e => (Entry: e, Date: TryDate(e.Date, out var d) ? d : (DateOnly?)null))
.Where(x => x.Date is not null)
.OrderBy(x => x.Date).ThenBy(x => x.Entry.StudentName);
void ResolveLocalMatch(WebUntisHomeworkRow row)
{
if (row.AssignedStudent is not { } student)
{
row.SessionId = null; row.LocalStatus = "ohne Zuordnung"; row.Selected = false;
return;
}
var group = ResolveGroup(student, row.MatchedSubjectId, row.Date);
var session = group is null ? null
: _sessions.GetByGroup(group.Id).FirstOrDefault(s => s.Date == row.Date);
var entry = session is null ? null : _participation.GetBySessionAndStudent(session.Id, student.Id);
var current = entry is null ? null : HomeworkDisplay.Effective(entry);
row.SessionId = session?.Id;
row.LocalStatus = session is null
? row.MatchedSubjectId is null ? "Fachkürzel nicht erkannt"
: group is null ? "kein passender Kurs gefunden" : "keine lokale Stunde"
: HomeworkDisplay.Label(current);
// Nur vorbelegen, wenn lokal noch überhaupt nichts erfasst ist - jeder vorhandene
// Status (auch ein bereits gesetztes "fehlt") bleibt unangetastet, siehe Klassenkommentar.
row.Selected = session is not null && current is null;
}
foreach (var (entry, date) in ordered)
{
var nameKey = NameKey(entry.StudentName);
var match = (classIndexes.TryGetValue(entry.ClassName, out var classIndex)
? classIndex.GetValueOrDefault(nameKey)
: null)
?? globalIndex.GetValueOrDefault(nameKey);
var subject = MatchSubject(entry.Text, shortNameIndex) ?? MatchSubject(entry.CategoryName, shortNameIndex);
var row = new WebUntisHomeworkRow
{
ClassName = entry.ClassName, Date = date!.Value, UntisStudentName = entry.StudentName,
SubjectLabel = subject?.ShortName, Text = entry.Text, Candidates = ownStudents,
MatchedSubjectId = subject?.Id, OnAssignmentChanged = ResolveLocalMatch,
};
Rows.Add(row);
row.AssignedStudent = match; // löst OnAssignedStudentChanged aus und setzt SessionId/LocalStatus/Selected
}
var unresolvedStudent = Rows.Count(x => x.AssignedStudent is null);
var unresolvedSubject = Rows.Count(x => x.MatchedSubjectId is null);
Status = $"{Rows.Count} Einträge \"fehlende Hausaufgabe\" erhalten" +
(unresolvedStudent > 0 ? $", {unresolvedStudent} bitte manuell zuordnen" : "") +
(unresolvedSubject > 0 ? $", bei {unresolvedSubject} kein Fachkürzel im Text erkannt" : "") +
$". {Rows.Count(x => x.CanApply)} einer lokalen Stunde zuordenbar.";
}
catch (WebUntisIntegrationException ex) { Status = ex.Message; }
finally { Busy = false; }
}
[RelayCommand]
private void Apply()
{
var selected = Rows.Where(x => x.Selected && x.SessionId is not null && x.AssignedStudent is not null).ToList();
foreach (var row in selected)
{
var studentId = row.AssignedStudent!.Id;
var entry = _participation.GetBySessionAndStudent(row.SessionId!.Value, studentId)
?? new ParticipationEntry { SessionId = row.SessionId.Value, StudentId = studentId };
entry.Homework = HomeworkStatus.MissingOpen;
entry.HomeworkMissing = true;
entry.UpdatedAt = DateTime.UtcNow;
_participation.Save(entry);
row.LocalStatus = HomeworkDisplay.Label(HomeworkStatus.MissingOpen);
row.Selected = false;
}
Status = $"{selected.Count} Hausaufgaben-Status übernommen.";
}
/// Nur Lerngruppen (nicht die Klasse selbst), bei denen der/die Schüler*in am Eintragsdatum aktiv
/// Mitglied ist und deren Fach zum erkannten Kürzel passt - mehr als ein Treffer bleibt bewusst
/// unaufgelöst statt irgendeinen davon zu wählen.
private LearningGroup? ResolveGroup(Student student, Guid? subjectId, DateOnly date)
{
if (subjectId is null) return null;
var schoolYear = _schoolYears.CurrentSchoolYear(date);
var candidates = _memberships.GetByStudent(student.Id)
.Where(m => GroupMembershipService.IsActiveOn(m, date))
.Select(m => _groups.GetById(m.GroupId))
.Where(g => g is not null && g.SubjectId == subjectId && g.SchoolYear == schoolYear)
.Cast<LearningGroup>()
.ToList();
return candidates.Count == 1 ? candidates[0] : null;
}
// Gleiche Heuristik wie ClassTeacherDetailsViewModel.ContainsHomework, zusätzlich auf negative
// Einträge eingeschränkt (eine positive "Hausaufgabe"-Kategorie wäre kein Fehlen-Signal).
private static bool IsMissingHomeworkEntry(UntisClassRegisterEventDto entry) =>
string.Equals(entry.CategoryGroup, "Negativ", StringComparison.OrdinalIgnoreCase) &&
(entry.CategoryName?.Contains("Hausauf", StringComparison.OrdinalIgnoreCase) == true ||
entry.Text?.Contains("Hausauf", StringComparison.OrdinalIgnoreCase) == true);
private static Subject? MatchSubject(string? text, IReadOnlyDictionary<string, Subject> shortNameIndex)
{
if (string.IsNullOrWhiteSpace(text)) return null;
foreach (var token in Regex.Split(text, @"[^\p{L}\p{Nd}]+"))
if (token.Length > 0 && shortNameIndex.TryGetValue(token.ToUpperInvariant(), out var subject))
return subject;
return null;
}
private static string NameKey(string value) => value.Trim().ToLowerInvariant();
// Wie bei den übrigen WebUntis-Abgleichen: beide Namensreihenfolgen registriert, aber nur falls
// innerhalb der Kandidaten eindeutig.
private static Dictionary<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);
}
@@ -48,8 +48,8 @@ public partial class WebUntisLessonAbsenceComparisonViewModel : ObservableObject
new Dictionary<DateOnly, ParticipationSession>(); new Dictionary<DateOnly, ParticipationSession>();
public ObservableCollection<WebUntisLessonAbsenceRow> Rows { get; } = []; public ObservableCollection<WebUntisLessonAbsenceRow> Rows { get; } = [];
[ObservableProperty] private DateTimeOffset _startDate = DateTimeOffset.Now.AddMonths(-2); [ObservableProperty] private DateTimeOffset? _startDate = DateTimeOffset.Now.AddMonths(-2);
[ObservableProperty] private DateTimeOffset _endDate = DateTimeOffset.Now; [ObservableProperty] private DateTimeOffset? _endDate = DateTimeOffset.Now;
[ObservableProperty] private string _status = "Zeitraum wählen und Fehlzeiten laden."; [ObservableProperty] private string _status = "Zeitraum wählen und Fehlzeiten laden.";
[ObservableProperty] private bool _busy; [ObservableProperty] private bool _busy;
[ObservableProperty] private bool _markUnknownAsPresent; [ObservableProperty] private bool _markUnknownAsPresent;
@@ -73,8 +73,8 @@ public partial class WebUntisLessonAbsenceComparisonViewModel : ObservableObject
return; return;
} }
var start = DateOnly.FromDateTime(StartDate.LocalDateTime); var start = DateOnly.FromDateTime((StartDate ?? DateTimeOffset.Now).LocalDateTime);
var end = DateOnly.FromDateTime(EndDate.LocalDateTime); var end = DateOnly.FromDateTime((EndDate ?? DateTimeOffset.Now).LocalDateTime);
if (end < start) { Status = "Das Enddatum darf nicht vor dem Startdatum liegen."; return; } if (end < start) { Status = "Das Enddatum darf nicht vor dem Startdatum liegen."; return; }
Busy = true; Rows.Clear(); Busy = true; Rows.Clear();
try try
@@ -90,7 +90,10 @@ public partial class MainWindowViewModel : ObservableObject
case TimetableViewModel vm: vm.Load(); break; case TimetableViewModel vm: vm.Load(); break;
case WorkloadViewModel vm: case WorkloadViewModel vm:
vm.Tasks.Load(); vm.TimeTracking.Load(); vm.Evaluation.Load(); break; vm.Tasks.Load(); vm.TimeTracking.Load(); vm.Evaluation.Load(); break;
case ClassTeacherOverviewViewModel vm: vm.LoadCommand.Execute(null); break; // RefreshFromLocalDataOnly() statt LoadCommand: baut die Ansicht aus den zuletzt
// geholten WebUntis-Daten + aktuellen lokalen Daten neu auf, löst aber selbst nie einen
// WebUntis-Request aus - den holt bewusst ausschließlich der "Aktualisieren"-Button.
case ClassTeacherOverviewViewModel vm: vm.RefreshFromLocalDataOnly(); break;
case ExamsOverviewViewModel vm: vm.LoadCommand.Execute(null); break; case ExamsOverviewViewModel vm: vm.LoadCommand.Execute(null); break;
case GroupDetailViewModel { Group: { } group } vm: vm.LoadGroup(group.Id); break; case GroupDetailViewModel { Group: { } group } vm: vm.LoadGroup(group.Id); break;
// Inline-Bearbeitung (IsEditing) nicht überschreiben - anders als die Gruppenansicht // Inline-Bearbeitung (IsEditing) nicht überschreiben - anders als die Gruppenansicht
@@ -147,8 +150,11 @@ public partial class MainWindowViewModel : ObservableObject
private TimetableViewModel GetTimetable() private TimetableViewModel GetTimetable()
{ {
// WeekOffset bewusst nicht zurücksetzen: TimetableViewModel ist ein DI-Singleton, die
// zuletzt angezeigte Woche bleibt also automatisch über Tab-Wechsel hinweg erhalten
// (Nutzer-Feedback: beim Planen der nächsten Woche nervt es, nach jedem kurzen Abstecher
// in einen anderen Bereich wieder manuell dorthin zurückblättern zu müssen).
var timetable = _services.GetRequiredService<TimetableViewModel>(); var timetable = _services.GetRequiredService<TimetableViewModel>();
timetable.WeekOffset = 0;
timetable.Load(); timetable.Load();
timetable.ActiveTabIndex = 0; timetable.ActiveTabIndex = 0;
return timetable; return timetable;
@@ -176,7 +182,11 @@ public partial class MainWindowViewModel : ObservableObject
var vm = _services.GetRequiredService<ClassTeacherOverviewViewModel>(); var vm = _services.GetRequiredService<ClassTeacherOverviewViewModel>();
vm.OnNavigateToSettings = () => { NavigateToSettings(SettingsTab.WebUntis); return Task.CompletedTask; }; vm.OnNavigateToSettings = () => { NavigateToSettings(SettingsTab.WebUntis); return Task.CompletedTask; };
vm.OnNavigateToWorkload = () => { NavigateToWorkload(); return Task.CompletedTask; }; vm.OnNavigateToWorkload = () => { NavigateToWorkload(); return Task.CompletedTask; };
vm.LoadCommand.Execute(null); // Kein automatischer WebUntis-Abruf beim Navigieren: PrepareForDisplay() baut nur den
// lokalen Grundzustand auf, die eigentlichen Report-Daten holt die Nutzerin bewusst über
// den "Aktualisieren"-Button (LoadCommand) - vermeidet WebUntis-Login+Report-Latenz beim
// bloßen Öffnen der Seite.
vm.PrepareForDisplay();
return vm; return vm;
} }
@@ -37,7 +37,7 @@ public partial class WebUntisTimetableImportViewModel : ObservableObject
public ObservableCollection<UntisTeacherDto> Teachers { get; } = []; public ObservableCollection<UntisTeacherDto> Teachers { get; } = [];
public ObservableCollection<WebUntisTimetableRow> Rows { get; } = []; public ObservableCollection<WebUntisTimetableRow> Rows { get; } = [];
[ObservableProperty] private UntisTeacherDto? _selectedTeacher; [ObservableProperty] private UntisTeacherDto? _selectedTeacher;
[ObservableProperty] private DateTimeOffset _weekDate = DateTimeOffset.Now; [ObservableProperty] private DateTimeOffset? _weekDate = DateTimeOffset.Now;
[ObservableProperty] private string _status = "Lehrkraft auswählen und Untis-Woche laden."; [ObservableProperty] private string _status = "Lehrkraft auswählen und Untis-Woche laden.";
[ObservableProperty] private bool _busy; [ObservableProperty] private bool _busy;
public bool Saved { get; private set; } public bool Saved { get; private set; }
@@ -71,7 +71,7 @@ public partial class WebUntisTimetableImportViewModel : ObservableObject
Busy = true; Rows.Clear(); Busy = true; Rows.Clear();
try try
{ {
var selected = DateOnly.FromDateTime(WeekDate.LocalDateTime); var selected = DateOnly.FromDateTime((WeekDate ?? DateTimeOffset.Now).LocalDateTime);
var monday = selected.AddDays(-(((int)selected.DayOfWeek + 6) % 7)); var monday = selected.AddDays(-(((int)selected.DayOfWeek + 6) % 7));
var periods = await _untis.GetTimetableAsync(SelectedTeacher.UntisId, monday, monday.AddDays(6)); var periods = await _untis.GetTimetableAsync(SelectedTeacher.UntisId, monday, monday.AddDays(6));
var grid = await _untis.GetTimeGridAsync(); var grid = await _untis.GetTimeGridAsync();
@@ -229,6 +229,13 @@ public partial class SettingsViewModel
_eventQueue.MarkReviewed(item.Id); _eventQueue.MarkReviewed(item.Id);
SyncConflicts.Remove(item); SyncConflicts.Remove(item);
} }
[RelayCommand]
private void ClearSyncConflicts()
{
_eventQueue.ClearConflicts();
SyncConflicts.Clear();
}
} }
public class SyncConflictListItem(ConflictEntry c) public class SyncConflictListItem(ConflictEntry c)
@@ -304,6 +304,11 @@ public partial class DocumentationDialogViewModel : ObservableObject
var valid = true; var valid = true;
if (CanPickStudent && SelectedStudent is null) { StudentError = "Bezug auswählen."; valid = false; } if (CanPickStudent && SelectedStudent is null) { StudentError = "Bezug auswählen."; valid = false; }
if (!CanPickStudent && _studentId == Guid.Empty && _contextGroupId is null)
{
StudentError = "Die Schülerliste ist noch nicht geladen. Dialog schließen und erneut öffnen.";
valid = false;
}
if (string.IsNullOrWhiteSpace(Title)) { TitleError = "Titel erforderlich."; valid = false; } if (string.IsNullOrWhiteSpace(Title)) { TitleError = "Titel erforderlich."; valid = false; }
@@ -58,8 +58,8 @@ public partial class WebUntisDocumentationComparisonViewModel : ObservableObject
public ObservableCollection<WebUntisDocumentationRow> Rows { get; } = []; public ObservableCollection<WebUntisDocumentationRow> Rows { get; } = [];
public ObservableCollection<LocalOnlyDocumentationRow> LocalOnlyRows { get; } = []; public ObservableCollection<LocalOnlyDocumentationRow> LocalOnlyRows { get; } = [];
[ObservableProperty] private DateTimeOffset _startDate = DateTimeOffset.Now.AddDays(-7); [ObservableProperty] private DateTimeOffset? _startDate = DateTimeOffset.Now.AddDays(-7);
[ObservableProperty] private DateTimeOffset _endDate = DateTimeOffset.Now; [ObservableProperty] private DateTimeOffset? _endDate = DateTimeOffset.Now;
[ObservableProperty] private string _status = "Zeitraum wählen und Klassenbucheinträge laden."; [ObservableProperty] private string _status = "Zeitraum wählen und Klassenbucheinträge laden.";
[ObservableProperty] private bool _busy; [ObservableProperty] private bool _busy;
@@ -74,8 +74,8 @@ public partial class WebUntisDocumentationComparisonViewModel : ObservableObject
[RelayCommand] [RelayCommand]
private async Task Load() private async Task Load()
{ {
var start = DateOnly.FromDateTime(StartDate.LocalDateTime); var start = DateOnly.FromDateTime((StartDate ?? DateTimeOffset.Now).LocalDateTime);
var end = DateOnly.FromDateTime(EndDate.LocalDateTime); var end = DateOnly.FromDateTime((EndDate ?? DateTimeOffset.Now).LocalDateTime);
if (end < start) { Status = "Das Enddatum darf nicht vor dem Startdatum liegen."; return; } if (end < start) { Status = "Das Enddatum darf nicht vor dem Startdatum liegen."; return; }
Busy = true; Rows.Clear(); LocalOnlyRows.Clear(); Busy = true; Rows.Clear(); LocalOnlyRows.Clear();
try try
@@ -0,0 +1,78 @@
using System.Collections.ObjectModel;
using CommunityToolkit.Mvvm.ComponentModel;
using LehrerApp.Core.Interfaces;
using LehrerApp.Core.Models;
using LehrerApp.Desktop.Services;
namespace LehrerApp.Desktop.ViewModels.UntisHub;
/// <summary>Eine Zeile im Untis-Hub-Fenster - reine Anzeige-Projektion von
/// <see cref="UntisHubJobRow"/>, neu aufgebaut bei jedem <see cref="UntisHubViewModel.Load"/>.</summary>
public sealed class UntisHubRowViewModel
{
public UntisHubJobKind Kind { get; }
public Guid? GroupId { get; }
public string GroupName { get; }
public string JobLabel { get; }
public string DueLabel { get; }
public string? LastResultSummary { get; }
public bool IsWarning { get; }
public bool IsDanger { get; }
public UntisHubRowViewModel(UntisHubJobRow row)
{
Kind = row.Kind; GroupId = row.GroupId; GroupName = row.GroupName;
JobLabel = Label(row.Kind); DueLabel = row.DueLabel; LastResultSummary = row.LastResultSummary;
IsWarning = row.DueState == UntisHubDueState.Due;
IsDanger = row.DueState == UntisHubDueState.Overdue;
}
private static string Label(UntisHubJobKind kind) => kind switch
{
UntisHubJobKind.FehlzeitenKurz => "Fehlzeiten (kurzfristig)",
UntisHubJobKind.FehlzeitenLang => "Fehlzeiten (seit Schuljahresbeginn)",
UntisHubJobKind.OffenePeriods => "Offene Stunden",
UntisHubJobKind.Klassenbuchabgleich => "Klassenbuchabgleich",
UntisHubJobKind.Hausaufgabenabgleich => "Hausaufgabenabgleich",
_ => kind.ToString(),
};
}
/// <summary>ViewModel des Untis-Hub-Fensters (siehe TODO.md) - zeigt nur den gespeicherten
/// Fälligkeitsstand an (<see cref="UntisHubService.GetRows"/>, rein lesend aus LiteDB). Das
/// tatsächliche Ausführen eines Jobs (inkl. WebUntis-Anfrage) übernimmt die Code-Behind-Klasse über
/// <see cref="UntisHubActions"/>, weil dafür ein Fenster-Owner für <c>ShowDialog</c> gebraucht wird.</summary>
public partial class UntisHubViewModel : ObservableObject
{
private readonly UntisHubService _hub;
private readonly IGroupRepository _groups;
public ObservableCollection<UntisHubRowViewModel> Rows { get; } = [];
[ObservableProperty] private bool _isAvailable;
[ObservableProperty] private string _status = "";
public UntisHubViewModel(UntisHubService hub, IGroupRepository groups, WebUntisIntegrationService untis)
{
_hub = hub; _groups = groups;
IsAvailable = untis.IsAvailable;
Load();
}
public void Load()
{
Rows.Clear();
if (!IsAvailable)
{
Status = "WebUntis ist nicht konfiguriert (siehe Einstellungen).";
return;
}
foreach (var row in _hub.GetRows()) Rows.Add(new UntisHubRowViewModel(row));
var overdue = Rows.Count(r => r.IsDanger);
var due = Rows.Count(r => r.IsWarning);
Status = overdue > 0 || due > 0
? $"{overdue + due} von {Rows.Count} Prüfungen fällig ({overdue} überfällig)."
: $"Alle {Rows.Count} Prüfungen aktuell.";
}
public LearningGroup? FindGroup(Guid id) => _groups.GetById(id);
}
@@ -467,6 +467,10 @@ public partial class TimeTrackingViewModel : ObservableObject
private readonly IWorkTaskRepository _tasks; private readonly IWorkTaskRepository _tasks;
private readonly ITimetableSlotRepository _timetableSlots; private readonly ITimetableSlotRepository _timetableSlots;
private readonly PeriodScheduleService _periodSchedule; private readonly PeriodScheduleService _periodSchedule;
private readonly ISchoolHolidayRepository? _schoolHolidays;
private readonly PublicHolidayService? _publicHolidays;
private readonly SchoolCalendarSettingsService? _calendarSettings;
private readonly ISubstitutionEntryRepository? _substitutions;
// Nutzer-Feedback: "man beginnt ja auch vermutlich vor 7:50" (erste Stunde) und "wird auch // Nutzer-Feedback: "man beginnt ja auch vermutlich vor 7:50" (erste Stunde) und "wird auch
// nicht aus dem Unterricht nach Hause rennen" (nach der letzten) - grobe, aber plausible // nicht aus dem Unterricht nach Hause rennen" (nach der letzten) - grobe, aber plausible
@@ -474,6 +478,8 @@ public partial class TimeTrackingViewModel : ObservableObject
// vor, gespeichert wird erst nach ausdrücklicher Bestätigung dort (siehe SuggestTeachingTime). // vor, gespeichert wird erst nach ausdrücklicher Bestätigung dort (siehe SuggestTeachingTime).
private const int BufferBeforeFirstPeriodMinutes = 15; private const int BufferBeforeFirstPeriodMinutes = 15;
private const int BufferAfterLastPeriodMinutes = 10; private const int BufferAfterLastPeriodMinutes = 10;
private const int MissingTeachingTimeLookbackDays = 14;
private const int MissingTeachingTimeTodayDelayMinutes = 30;
public const string NoTaskOption = "Keine Aufgabe"; public const string NoTaskOption = "Keine Aufgabe";
@@ -490,6 +496,7 @@ public partial class TimeTrackingViewModel : ObservableObject
public ObservableCollection<TimeEntryListItem> WeekEntries { get; } = []; public ObservableCollection<TimeEntryListItem> WeekEntries { get; } = [];
public ObservableCollection<CategoryTimeSummary> CategorySummaries { get; } = []; public ObservableCollection<CategoryTimeSummary> CategorySummaries { get; } = [];
public ObservableCollection<TeachingTimeGapItem> MissingTeachingTimeEntries { get; } = [];
public string TotalWeekMinutesDisplay => $"{WeekEntries.Sum(e => e.Model.DurationMinutes)} min diese Woche"; public string TotalWeekMinutesDisplay => $"{WeekEntries.Sum(e => e.Model.DurationMinutes)} min diese Woche";
/// Ob heute laut Stundenplan überhaupt Unterricht ansteht - steuert, ob der /// Ob heute laut Stundenplan überhaupt Unterricht ansteht - steuert, ob der
@@ -502,14 +509,22 @@ public partial class TimeTrackingViewModel : ObservableObject
/// im Dialog bleibt aber immer nötig, nichts wird automatisch gespeichert (siehe Puffer- /// im Dialog bleibt aber immer nötig, nichts wird automatisch gespeichert (siehe Puffer-
/// Konstanten oben). /// Konstanten oben).
public Func<TimeOnly, TimeOnly, Task<TimeEntry?>>? OnSuggestTeachingTime { get; set; } public Func<TimeOnly, TimeOnly, Task<TimeEntry?>>? OnSuggestTeachingTime { get; set; }
public Func<TeachingTimeGapItem, Task<TimeEntry?>>? OnAddMissingTeachingTime { get; set; }
public TimeTrackingViewModel(ITimeEntryRepository entries, IWorkTaskRepository tasks, public TimeTrackingViewModel(ITimeEntryRepository entries, IWorkTaskRepository tasks,
ITimetableSlotRepository timetableSlots, PeriodScheduleService periodSchedule) ITimetableSlotRepository timetableSlots, PeriodScheduleService periodSchedule,
ISchoolHolidayRepository? schoolHolidays = null, PublicHolidayService? publicHolidays = null,
SchoolCalendarSettingsService? calendarSettings = null,
ISubstitutionEntryRepository? substitutions = null)
{ {
_entries = entries; _entries = entries;
_tasks = tasks; _tasks = tasks;
_timetableSlots = timetableSlots; _timetableSlots = timetableSlots;
_periodSchedule = periodSchedule; _periodSchedule = periodSchedule;
_schoolHolidays = schoolHolidays;
_publicHolidays = publicHolidays;
_calendarSettings = calendarSettings;
_substitutions = substitutions;
Load(); Load();
} }
@@ -550,6 +565,7 @@ public partial class TimeTrackingViewModel : ObservableObject
minutes, maxMinutes > 0 ? minutes / (double)maxMinutes : 0)); minutes, maxMinutes > 0 ? minutes / (double)maxMinutes : 0));
} }
RefreshMissingTeachingTime(today);
OnPropertyChanged(nameof(TotalWeekMinutesDisplay)); OnPropertyChanged(nameof(TotalWeekMinutesDisplay));
} }
@@ -605,6 +621,59 @@ public partial class TimeTrackingViewModel : ObservableObject
Refresh(); Refresh();
} }
[RelayCommand]
private async Task AddMissingTeachingTime(TeachingTimeGapItem? item)
{
if (item is null || OnAddMissingTeachingTime is null) return;
var result = await OnAddMissingTeachingTime(item);
if (result is null) return;
_entries.Save(result);
Refresh();
}
private void RefreshMissingTeachingTime(DateOnly today)
{
MissingTeachingTimeEntries.Clear();
var firstDay = today.AddDays(-MissingTeachingTimeLookbackDays);
var timetableSlots = _timetableSlots.GetAll();
var schoolHolidays = _schoolHolidays?.GetAll() ?? [];
var publicHolidayDates = _publicHolidays is not null && _calendarSettings is not null
? Enumerable.Range(firstDay.Year, today.Year - firstDay.Year + 1)
.SelectMany(y => _publicHolidays.GetHolidays(y, _calendarSettings.State))
.Select(h => h.Date).ToHashSet()
: [];
var nowTime = TimeOnly.FromDateTime(DateTime.Now);
var teachingCategory = TaskCategoryDisplay.Label(TaskCategory.Teaching);
for (var date = firstDay; date <= today; date = date.AddDays(1))
{
if (publicHolidayDates.Contains(date)
|| schoolHolidays.Any(h => date >= h.StartDate && date <= h.EndDate))
continue;
var daySlots = timetableSlots.Where(s => s.Weekday == date.DayOfWeek).ToList();
if (daySlots.Count == 0) continue;
var cancelledPeriods = (_substitutions?.GetByDate(date) ?? [])
.Where(s => s.Kind == SubstitutionKind.Cancelled)
.Select(s => s.PeriodNumber).ToHashSet();
var periodTimes = daySlots.Where(s => !cancelledPeriods.Contains(s.PeriodNumber))
.Select(s => _periodSchedule.GetTimes(s.PeriodNumber))
.Where(t => t is not null).Select(t => t!.Value).ToList();
if (periodTimes.Count == 0) continue;
var lastPeriodEnd = periodTimes.Max(t => t.End);
if (date == today && nowTime < lastPeriodEnd.AddMinutes(MissingTeachingTimeTodayDelayMinutes))
continue;
if (_entries.GetByDate(date).Any(e => e.Category == teachingCategory)) continue;
MissingTeachingTimeEntries.Add(new TeachingTimeGapItem(
date,
periodTimes.Min(t => t.Start).AddMinutes(-BufferBeforeFirstPeriodMinutes),
lastPeriodEnd.AddMinutes(BufferAfterLastPeriodMinutes)));
}
}
/// <summary> /// <summary>
/// Frühester Beginn / spätestes Ende aller heutigen Stundenplan-Perioden (alle Gruppen, nicht /// Frühester Beginn / spätestes Ende aller heutigen Stundenplan-Perioden (alle Gruppen, nicht
/// auf eine einzelne beschränkt - der Unterrichtstag als Ganzes), je um die oben definierten /// auf eine einzelne beschränkt - der Unterrichtstag als Ganzes), je um die oben definierten
@@ -647,6 +716,17 @@ public class TimeEntryListItem(TimeEntry model, string? taskTitle)
public string Description => Model.Description ?? ""; public string Description => Model.Description ?? "";
} }
public sealed class TeachingTimeGapItem(DateOnly date, TimeOnly windowStart, TimeOnly windowEnd)
{
private static readonly CultureInfo De = new("de-DE");
public DateOnly Date { get; } = date;
public TimeOnly WindowStart { get; } = windowStart;
public TimeOnly WindowEnd { get; } = windowEnd;
public string DateDisplay { get; } = date.ToString("dddd, dd.MM.", De);
public string TimeDisplay { get; } = $"{windowStart:HH:mm}{windowEnd:HH:mm} Uhr";
}
public class CategoryTimeSummary(string category, int minutes, double barFraction) public class CategoryTimeSummary(string category, int minutes, double barFraction)
{ {
public string Category { get; } = category; public string Category { get; } = category;
@@ -1,6 +1,7 @@
<UserControl xmlns="https://github.com/avaloniaui" <UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="clr-namespace:LehrerApp.Desktop.ViewModels.ClassTeacher" xmlns:vm="clr-namespace:LehrerApp.Desktop.ViewModels.ClassTeacher"
xmlns:conv="clr-namespace:LehrerApp.Desktop.Converters"
x:Class="LehrerApp.Desktop.Views.ClassTeacher.ClassTeacherAbsencesView" x:Class="LehrerApp.Desktop.Views.ClassTeacher.ClassTeacherAbsencesView"
x:DataType="vm:ClassTeacherDetailsViewModel"> x:DataType="vm:ClassTeacherDetailsViewModel">
@@ -26,9 +27,20 @@
<Style Selector="TextBlock.status.danger"> <Style Selector="TextBlock.status.danger">
<Setter Property="Foreground" Value="{DynamicResource AppStatusDangerBrush}"/> <Setter Property="Foreground" Value="{DynamicResource AppStatusDangerBrush}"/>
</Style> </Style>
<Style Selector="Button.viewMode">
<Setter Property="Background" Value="Transparent"/>
<Setter Property="BorderBrush" Value="{DynamicResource AppFilterBorderBrush}"/>
<Setter Property="BorderThickness" Value="1"/>
<Setter Property="Padding" Value="12,6"/>
</Style>
<Style Selector="Button.viewMode.active">
<Setter Property="Background" Value="{DynamicResource AppFilterActiveBackgroundBrush}"/>
<Setter Property="BorderBrush" Value="{DynamicResource AppFilterActiveBorderBrush}"/>
<Setter Property="Foreground" Value="{DynamicResource AppFilterActiveForegroundBrush}"/>
</Style>
</UserControl.Styles> </UserControl.Styles>
<Grid RowDefinitions="Auto,Auto,*,Auto" Margin="16" RowSpacing="10"> <Grid RowDefinitions="Auto,Auto,Auto,*,Auto" Margin="16" RowSpacing="10">
<Grid Grid.Row="0" ColumnDefinitions="*,Auto"> <Grid Grid.Row="0" ColumnDefinitions="*,Auto">
<StackPanel> <StackPanel>
<TextBlock Text="Fehlzeiten" FontSize="22" FontWeight="SemiBold"/> <TextBlock Text="Fehlzeiten" FontSize="22" FontWeight="SemiBold"/>
@@ -38,16 +50,29 @@
IsEnabled="{Binding !Busy}" Background="Transparent" Foreground="{DynamicResource AppAccentTextBrush}" VerticalAlignment="Center"/> IsEnabled="{Binding !Busy}" Background="Transparent" Foreground="{DynamicResource AppAccentTextBrush}" VerticalAlignment="Center"/>
</Grid> </Grid>
<Border Grid.Row="1" Classes="filterCard"> <Grid Grid.Row="1" ColumnDefinitions="Auto,Auto,*,Auto,Auto,Auto" ColumnSpacing="6">
<Button Grid.Column="0" Content="Liste" Classes="viewMode" Classes.active="{Binding !ShowMonthlyCalendar}"
Command="{Binding ShowAbsenceListCommand}"/>
<Button Grid.Column="1" Content="Monatsübersicht" Classes="viewMode" Classes.active="{Binding ShowMonthlyCalendar}"
Command="{Binding ShowCalendarCommand}"/>
<Button Grid.Column="3" Content="" Width="36" Command="{Binding PreviousCalendarMonthCommand}"
IsVisible="{Binding ShowMonthlyCalendar}" AutomationProperties.Name="Vorheriger Monat"/>
<TextBlock Grid.Column="4" Text="{Binding CalendarMonthLabel}" FontSize="15" FontWeight="SemiBold"
VerticalAlignment="Center" Margin="8,0" IsVisible="{Binding ShowMonthlyCalendar}"/>
<Button Grid.Column="5" Content="" Width="36" Command="{Binding NextCalendarMonthCommand}"
IsVisible="{Binding ShowMonthlyCalendar}" AutomationProperties.Name="Nächster Monat"/>
</Grid>
<Border Grid.Row="2" Classes="filterCard" IsVisible="{Binding !ShowMonthlyCalendar}">
<Grid RowDefinitions="Auto,Auto" RowSpacing="8"> <Grid RowDefinitions="Auto,Auto" RowSpacing="8">
<Grid Grid.Row="0" ColumnDefinitions="155,Auto,*,Auto,*" ColumnSpacing="8"> <Grid Grid.Row="0" ColumnDefinitions="155,Auto,*,Auto,*" ColumnSpacing="8">
<ComboBox Grid.Column="0" SelectedIndex="{Binding QuickRangeIndex, Mode=TwoWay}"> <ComboBox Grid.Column="0" SelectedIndex="{Binding QuickRangeIndex, Mode=TwoWay}">
<ComboBoxItem Content="Heute"/><ComboBoxItem Content="Letzte 7 Tage"/><ComboBoxItem Content="Letzte 30 Tage"/> <ComboBoxItem Content="Heute"/><ComboBoxItem Content="Letzte 7 Tage"/><ComboBoxItem Content="Letzte 30 Tage"/>
</ComboBox> </ComboBox>
<TextBlock Grid.Column="1" Text="von" VerticalAlignment="Center" Opacity="0.6"/> <TextBlock Grid.Column="1" Text="von" VerticalAlignment="Center" Opacity="0.6"/>
<CalendarDatePicker Grid.Column="2" SelectedDate="{Binding StartDate}" HorizontalAlignment="Stretch"/> <CalendarDatePicker Grid.Column="2" SelectedDate="{Binding StartDate, Converter={x:Static conv:DateTimeOffsetToDateTimeConverter.Instance}}" HorizontalAlignment="Stretch"/>
<TextBlock Grid.Column="3" Text="bis" VerticalAlignment="Center" Opacity="0.6"/> <TextBlock Grid.Column="3" Text="bis" VerticalAlignment="Center" Opacity="0.6"/>
<CalendarDatePicker Grid.Column="4" SelectedDate="{Binding EndDate}" HorizontalAlignment="Stretch"/> <CalendarDatePicker Grid.Column="4" SelectedDate="{Binding EndDate, Converter={x:Static conv:DateTimeOffsetToDateTimeConverter.Instance}}" HorizontalAlignment="Stretch"/>
</Grid> </Grid>
<Grid Grid.Row="1" ColumnDefinitions="*,Auto,Auto" ColumnSpacing="8"> <Grid Grid.Row="1" ColumnDefinitions="*,Auto,Auto" ColumnSpacing="8">
<TextBox Grid.Column="0" Text="{Binding StudentFilter, Mode=TwoWay}" PlaceholderText="Schüler*in filtern…"/> <TextBox Grid.Column="0" Text="{Binding StudentFilter, Mode=TwoWay}" PlaceholderText="Schüler*in filtern…"/>
@@ -58,10 +83,11 @@
</Grid> </Grid>
</Border> </Border>
<Grid Grid.Row="2"> <Grid Grid.Row="3">
<DataGrid ItemsSource="{Binding AbsenceEntries}" AutoGenerateColumns="False" IsReadOnly="True" <DataGrid ItemsSource="{Binding AbsenceEntries}" AutoGenerateColumns="False" IsReadOnly="True"
GridLinesVisibility="Horizontal" BorderBrush="{DynamicResource AppCardBorderBrush}" BorderThickness="1" GridLinesVisibility="Horizontal" BorderBrush="{DynamicResource AppCardBorderBrush}" BorderThickness="1"
CanUserResizeColumns="True" CanUserSortColumns="True" RowHeight="48"> CanUserResizeColumns="True" CanUserSortColumns="True" RowHeight="48"
IsVisible="{Binding !ShowMonthlyCalendar}">
<DataGrid.Columns> <DataGrid.Columns>
<DataGridTextColumn Header="Datum" Binding="{Binding DateLabel}" Width="105"/> <DataGridTextColumn Header="Datum" Binding="{Binding DateLabel}" Width="105"/>
<DataGridTextColumn Header="Schüler*in" Binding="{Binding StudentDisplayName}" Width="1.3*"/> <DataGridTextColumn Header="Schüler*in" Binding="{Binding StudentDisplayName}" Width="1.3*"/>
@@ -91,11 +117,61 @@
<DataGridTextColumn Header="Grund / Notiz" Binding="{Binding DetailLabel}" Width="2*"/> <DataGridTextColumn Header="Grund / Notiz" Binding="{Binding DetailLabel}" Width="2*"/>
</DataGrid.Columns> </DataGrid.Columns>
</DataGrid> </DataGrid>
<StackPanel IsVisible="{Binding !HasAbsenceEntries}" HorizontalAlignment="Center" VerticalAlignment="Center" Spacing="5"> <StackPanel IsVisible="{Binding ShowAbsenceListEmpty}" HorizontalAlignment="Center" VerticalAlignment="Center" Spacing="5">
<TextBlock Text="Keine Fehlzeiten im gewählten Zeitraum" FontWeight="SemiBold"/> <TextBlock Text="Keine Fehlzeiten im gewählten Zeitraum" FontWeight="SemiBold"/>
<TextBlock Text="Passe Zeitraum oder Schülerfilter an." FontSize="12" Opacity="0.55"/> <TextBlock Text="Passe Zeitraum oder Schülerfilter an." FontSize="12" Opacity="0.55"/>
</StackPanel> </StackPanel>
<Grid IsVisible="{Binding ShowMonthlyCalendar}" RowDefinitions="Auto,Auto,*" RowSpacing="8">
<WrapPanel Grid.Row="0" ItemSpacing="12" LineSpacing="6">
<TextBlock Text="V verspätet" Foreground="#EF6C00" FontSize="11" FontWeight="SemiBold"/>
<TextBlock Text="A abwesend" Foreground="#1565C0" FontSize="11" FontWeight="SemiBold"/>
<TextBlock Text="U unentschuldigt" Foreground="#C62828" FontSize="11" FontWeight="SemiBold"/>
<TextBlock Text="E entschuldigt" Foreground="#2E7D32" FontSize="11" FontWeight="SemiBold"/>
<TextBlock Text="! negativer Klassenbucheintrag" Foreground="#8E24AA" FontSize="11" FontWeight="SemiBold"/>
<CheckBox Content="Hausaufgaben (H) einblenden" IsChecked="{Binding IncludeHomeworkInCalendar}"
FontSize="11" VerticalAlignment="Center"/>
</WrapPanel>
<Grid Grid.Row="1" ColumnDefinitions="*,*,*,*,*" Margin="0,2,0,0">
<TextBlock Grid.Column="0" Text="Montag" FontWeight="SemiBold" HorizontalAlignment="Center"/>
<TextBlock Grid.Column="1" Text="Dienstag" FontWeight="SemiBold" HorizontalAlignment="Center"/>
<TextBlock Grid.Column="2" Text="Mittwoch" FontWeight="SemiBold" HorizontalAlignment="Center"/>
<TextBlock Grid.Column="3" Text="Donnerstag" FontWeight="SemiBold" HorizontalAlignment="Center"/>
<TextBlock Grid.Column="4" Text="Freitag" FontWeight="SemiBold" HorizontalAlignment="Center"/>
</Grid>
<ScrollViewer Grid.Row="2">
<ItemsControl ItemsSource="{Binding CalendarDays}">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate><UniformGrid Columns="5"/></ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:ClassTeacherCalendarDay">
<Border BorderBrush="{DynamicResource AppCardBorderBrush}" BorderThickness="1"
Background="{DynamicResource AppCardBackgroundBrush}" MinHeight="112" Padding="7" Margin="2">
<StackPanel Spacing="5">
<TextBlock Text="{Binding DayLabel}" FontWeight="SemiBold" HorizontalAlignment="Right"/>
<ItemsControl ItemsSource="{Binding Events}">
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:ClassTeacherCalendarEvent">
<Grid ColumnDefinitions="22,*" Margin="0,1" ToolTip.Tip="{Binding Tooltip}">
<Border Width="18" Height="18" CornerRadius="9" Background="{Binding ColorHex}">
<TextBlock Text="{Binding Code}" Foreground="White" FontSize="10" FontWeight="Bold"
HorizontalAlignment="Center" VerticalAlignment="Center"/>
</Border>
<TextBlock Grid.Column="1" Text="{Binding StudentName}" FontSize="10"
TextTrimming="CharacterEllipsis" VerticalAlignment="Center"/>
</Grid>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</StackPanel>
</Border>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</ScrollViewer>
</Grid>
</Grid> </Grid>
<TextBlock Grid.Row="3" Text="{Binding Status}" FontSize="11" Opacity="0.6"/> <TextBlock Grid.Row="4" Text="{Binding Status}" FontSize="11" Opacity="0.6"/>
</Grid> </Grid>
</UserControl> </UserControl>
@@ -0,0 +1,197 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="clr-namespace:LehrerApp.Desktop.ViewModels.ClassTeacher"
xmlns:svm="clr-namespace:LehrerApp.Desktop.ViewModels.Students"
x:Class="LehrerApp.Desktop.Views.ClassTeacher.ClassTeacherCasesView"
x:DataType="vm:ClassTeacherCasesViewModel">
<UserControl.Styles>
<Style Selector="Border.statusPill">
<Setter Property="CornerRadius" Value="10"/>
<Setter Property="Padding" Value="8,2"/>
</Style>
<Style Selector="Border.statusPill.open">
<Setter Property="Background" Value="#FB8C00"/>
</Style>
<Style Selector="Border.statusPill.closed">
<Setter Property="Background" Value="#43A047"/>
</Style>
<Style Selector="Border.statusPill TextBlock">
<Setter Property="Foreground" Value="White"/>
<Setter Property="FontSize" Value="11"/>
<Setter Property="FontWeight" Value="SemiBold"/>
</Style>
</UserControl.Styles>
<Grid RowDefinitions="Auto,Auto,*" Margin="16" RowSpacing="10">
<Grid Grid.Row="0" ColumnDefinitions="*,Auto,Auto">
<StackPanel Grid.Column="0">
<TextBlock Text="Vorgänge" FontSize="22" FontWeight="SemiBold"/>
<TextBlock Text="Fallmappen für laufende Probleme mit einer/einem oder mehreren Schüler*innen der Klasse"
FontSize="12" Opacity="0.55"/>
</StackPanel>
<CheckBox Grid.Column="1" Content="Nur offene" IsChecked="{Binding OnlyOpen}"
VerticalAlignment="Center" Margin="0,0,12,0"/>
<Button Grid.Column="2" Content=" Vorgang" Command="{Binding AddVorgangCommand}"/>
</Grid>
<TextBlock Grid.Row="1" Text="{Binding Status}" FontSize="11" Opacity="0.6"/>
<ScrollViewer Grid.Row="2">
<StackPanel>
<ItemsControl ItemsSource="{Binding Cases}">
<ItemsControl.ItemTemplate>
<DataTemplate DataType="vm:VorgangItem">
<Border Background="{DynamicResource AppCardBackgroundBrush}"
BorderBrush="{DynamicResource AppCardBorderBrush}" BorderThickness="1"
CornerRadius="8" Padding="12,10" Margin="0,0,0,8">
<StackPanel Spacing="8">
<Grid ColumnDefinitions="*,Auto,Auto,Auto,Auto,Auto">
<StackPanel Grid.Column="0">
<StackPanel Orientation="Horizontal" Spacing="8">
<Border Classes="statusPill" Classes.open="{Binding IsOpen}" Classes.closed="{Binding !IsOpen}">
<TextBlock Text="{Binding StatusLabel}"/>
</Border>
<TextBlock Text="{Binding Model.Title}" FontWeight="SemiBold" FontSize="14"/>
</StackPanel>
<TextBlock Text="{Binding StudentNames}" FontSize="12" Opacity="0.7" Margin="0,2,0,0"/>
<ItemsControl ItemsSource="{Binding TagChips}">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate><WrapPanel ItemSpacing="6" LineSpacing="4"/></ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
<ItemsControl.ItemTemplate>
<DataTemplate DataType="svm:TagChip">
<Border Background="{Binding ColorHex}" CornerRadius="10" Padding="8,2" Margin="0,4,0,0">
<TextBlock Text="{Binding Text}" FontSize="10" Foreground="White"/>
</Border>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</StackPanel>
<StackPanel Grid.Column="1" Orientation="Horizontal">
<Button Content="Details anzeigen" FontSize="11" Padding="8,3"
Command="{Binding RevealCommand}" IsVisible="{Binding !IsRevealed}"/>
<Button Content="Details ausblenden" FontSize="11" Padding="8,3"
Command="{Binding RevealCommand}" IsVisible="{Binding IsRevealed}"/>
</StackPanel>
<Button Grid.Column="2" Content="Bearbeiten" FontSize="11" Padding="8,3"
Command="{Binding $parent[ItemsControl].((vm:ClassTeacherCasesViewModel)DataContext).EditVorgangCommand}"
CommandParameter="{Binding}"/>
<StackPanel Grid.Column="3" Orientation="Horizontal">
<Button Content="Schließen" FontSize="11" Padding="8,3" IsVisible="{Binding IsOpen}"
Command="{Binding $parent[ItemsControl].((vm:ClassTeacherCasesViewModel)DataContext).ToggleStatusCommand}"
CommandParameter="{Binding}"/>
<Button Content="Wieder öffnen" FontSize="11" Padding="8,3" IsVisible="{Binding !IsOpen}"
Command="{Binding $parent[ItemsControl].((vm:ClassTeacherCasesViewModel)DataContext).ToggleStatusCommand}"
CommandParameter="{Binding}"/>
</StackPanel>
<Button Grid.Column="4" Content="Löschen" FontSize="11" Padding="8,3"
Command="{Binding $parent[ItemsControl].((vm:ClassTeacherCasesViewModel)DataContext).DeleteVorgangCommand}"
CommandParameter="{Binding}"/>
</Grid>
<StackPanel Spacing="10" IsVisible="{Binding IsRevealed}" Margin="0,4,0,0">
<TextBlock Text="{Binding Model.Description}" FontSize="12" TextWrapping="Wrap" Opacity="0.85"
IsVisible="{Binding Model.Description, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
<StackPanel Spacing="4">
<TextBlock Text="Verknüpfte Dokumentation" FontSize="12" FontWeight="SemiBold" Opacity="0.7"/>
<ItemsControl ItemsSource="{Binding LinkedDocumentation}">
<ItemsControl.ItemTemplate>
<DataTemplate DataType="svm:DocumentationItem">
<Grid ColumnDefinitions="70,*,Auto,Auto,Auto" Margin="0,2">
<TextBlock Grid.Column="0" Text="{Binding DateDisplay}" FontSize="11" Opacity="0.5"/>
<StackPanel Grid.Column="1">
<StackPanel Orientation="Horizontal" Spacing="6">
<TextBlock Text="{Binding StudentName}" FontSize="12" FontWeight="SemiBold"/>
<TextBlock Text="{Binding Model.Title}" FontSize="12" IsVisible="{Binding IsRevealed}"/>
<TextBlock Text="Vertraulich" FontSize="12" Opacity="0.6" IsVisible="{Binding !IsRevealed}"/>
</StackPanel>
<TextBlock Text="{Binding TypeLabel}" FontSize="10" Opacity="0.5"/>
</StackPanel>
<Button Grid.Column="2" Content="Anzeigen" FontSize="10" Padding="6,2" Margin="0,0,4,0"
Command="{Binding RevealCommand}" IsVisible="{Binding !IsRevealed}"/>
<!-- Nutzer-Feedback: verknüpfte Einträge sollen sich zum Nachlesen direkt
hier öffnen lassen, ohne in den Schülerdatensatz wechseln zu müssen. -->
<Button Grid.Column="3" Content="Bearbeiten" FontSize="10" Padding="6,2" Margin="0,0,4,0"
IsVisible="{Binding IsRevealed}"
Command="{Binding $parent[ItemsControl].((vm:VorgangItem)DataContext).EditDocumentationCommand}"
CommandParameter="{Binding}"/>
<Button Grid.Column="4" Content="Entfernen" FontSize="10" Padding="6,2"
Command="{Binding $parent[ItemsControl].((vm:VorgangItem)DataContext).UnlinkCommand}"
CommandParameter="{Binding}"/>
</Grid>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<TextBlock Text="Noch keine Dokumentation verknüpft." FontSize="11" Opacity="0.5"
IsVisible="{Binding !HasLinkedDocumentation}"/>
<Button Content=" Neue Dokumentation anlegen" FontSize="11" Padding="8,3"
HorizontalAlignment="Left"
Command="{Binding $parent[ItemsControl].((vm:ClassTeacherCasesViewModel)DataContext).CreateAndLinkDocumentationCommand}"
CommandParameter="{Binding}"/>
</StackPanel>
<StackPanel Spacing="4" IsVisible="{Binding HasAvailableDocumentation}">
<TextBlock Text="Bestehende Dokumentation der Schüler*innen verknüpfen" FontSize="12"
FontWeight="SemiBold" Opacity="0.7"/>
<ItemsControl ItemsSource="{Binding AvailableDocumentation}">
<ItemsControl.ItemTemplate>
<DataTemplate DataType="svm:DocumentationItem">
<Grid ColumnDefinitions="70,*,Auto,Auto,Auto" Margin="0,2">
<TextBlock Grid.Column="0" Text="{Binding DateDisplay}" FontSize="11" Opacity="0.5"/>
<StackPanel Grid.Column="1">
<StackPanel Orientation="Horizontal" Spacing="6">
<TextBlock Text="{Binding StudentName}" FontSize="12" FontWeight="SemiBold"/>
<TextBlock Text="{Binding Model.Title}" FontSize="12" IsVisible="{Binding IsRevealed}"/>
<TextBlock Text="Vertraulich" FontSize="12" Opacity="0.6" IsVisible="{Binding !IsRevealed}"/>
</StackPanel>
<TextBlock Text="{Binding TypeLabel}" FontSize="10" Opacity="0.5"/>
</StackPanel>
<Button Grid.Column="2" Content="Anzeigen" FontSize="10" Padding="6,2" Margin="0,0,4,0"
Command="{Binding RevealCommand}" IsVisible="{Binding !IsRevealed}"/>
<Button Grid.Column="3" Content="Bearbeiten" FontSize="10" Padding="6,2" Margin="0,0,4,0"
IsVisible="{Binding IsRevealed}"
Command="{Binding $parent[ItemsControl].((vm:VorgangItem)DataContext).EditDocumentationCommand}"
CommandParameter="{Binding}"/>
<Button Grid.Column="4" Content="Verknüpfen" FontSize="10" Padding="6,2"
Command="{Binding $parent[ItemsControl].((vm:VorgangItem)DataContext).LinkCommand}"
CommandParameter="{Binding}"/>
</Grid>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</StackPanel>
<StackPanel Spacing="4">
<TextBlock Text="Angeheftete Klassenbucheinträge" FontSize="12" FontWeight="SemiBold" Opacity="0.7"/>
<ItemsControl ItemsSource="{Binding ClassRegisterRows}">
<ItemsControl.ItemTemplate>
<DataTemplate DataType="vm:VorgangClassRegisterEntryRow">
<Grid ColumnDefinitions="70,*,Auto" Margin="0,2">
<TextBlock Grid.Column="0" Text="{Binding DateLabel}" FontSize="11" Opacity="0.5"/>
<StackPanel Grid.Column="1">
<TextBlock Text="{Binding StudentName}" FontSize="12" FontWeight="SemiBold"/>
<TextBlock Text="{Binding SummaryLabel}" FontSize="11" Opacity="0.7" TextWrapping="Wrap"/>
</StackPanel>
<Button Grid.Column="2" Content="Entfernen" FontSize="10" Padding="6,2"
Command="{Binding $parent[ItemsControl].((vm:VorgangItem)DataContext).RemoveClassRegisterEntryCommand}"
CommandParameter="{Binding}"/>
</Grid>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<TextBlock Text="Noch keine Klassenbucheinträge angeheftet — im Klassenbuch-Tab über „→ Vorgang&#8220; möglich."
FontSize="11" Opacity="0.5" IsVisible="{Binding !HasClassRegisterRows}" TextWrapping="Wrap"/>
</StackPanel>
</StackPanel>
</StackPanel>
</Border>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<TextBlock Text="Keine Vorgänge." Classes="emptyhint" IsVisible="{Binding !HasCases}"/>
</StackPanel>
</ScrollViewer>
</Grid>
</UserControl>
@@ -0,0 +1,63 @@
using Avalonia.Controls;
using LehrerApp.Core.Interfaces;
using LehrerApp.Core.Models;
using LehrerApp.Desktop.ViewModels.ClassTeacher;
using LehrerApp.Desktop.ViewModels.Students;
using LehrerApp.Desktop.Views.Shared;
using LehrerApp.Desktop.Views.Students;
using Microsoft.Extensions.DependencyInjection;
namespace LehrerApp.Desktop.Views.ClassTeacher;
public partial class ClassTeacherCasesView : UserControl
{
public ClassTeacherCasesView() => InitializeComponent();
protected override void OnDataContextChanged(EventArgs e)
{
base.OnDataContextChanged(e);
if (DataContext is not ClassTeacherCasesViewModel vm) return;
vm.OnEditVorgang = ShowVorgangDialog;
vm.OnConfirmDeleteVorgang = ShowDeleteVorgangDialog;
vm.OnEditDocumentation = ShowDocumentationDialog;
}
private async Task<Vorgang?> ShowVorgangDialog(List<StudentOption> rosterStudents, Vorgang? editing)
{
var owner = TopLevel.GetTopLevel(this) as Window;
if (owner is null) return null;
var vm = new VorgangDialogViewModel(rosterStudents, editing);
var dialog = new VorgangDialog { DataContext = vm };
var saved = await dialog.ShowDialog<bool>(owner);
return saved ? vm.Result : null;
}
private async Task<bool> ShowDeleteVorgangDialog(VorgangItem item)
{
var info = new ConfirmDialogInfo
{
Title = "Vorgang löschen?",
Message = $"\"{item.Model.Title}\" wird als gelöscht markiert und nicht mehr angezeigt. " +
"Verknüpfte Dokumentation bleibt erhalten.",
ConfirmText = "Löschen",
};
var dialog = new ConfirmDialog { DataContext = info };
var owner = TopLevel.GetTopLevel(this) as Window;
return owner is not null && await dialog.ShowDialog<bool>(owner);
}
private async Task<Documentation?> ShowDocumentationDialog(
Guid defaultStudentId, List<StudentOption> studentOptions, Documentation? editing)
{
var owner = TopLevel.GetTopLevel(this) as Window;
if (owner is null) return null;
var vm = new DocumentationDialogViewModel(defaultStudentId, editing,
App.Services.GetRequiredService<IAttachmentStorage>(), studentOptions);
var dialog = new DocumentationDialog { DataContext = vm };
var saved = await dialog.ShowDialog<bool>(owner);
if (!saved) vm.DiscardUnsavedAttachments();
return saved ? vm.Result : null;
}
}
@@ -98,6 +98,19 @@
<Style Selector="Border.statusFill.danger"> <Style Selector="Border.statusFill.danger">
<Setter Property="Background" Value="{DynamicResource AppStatusDangerBrush}"/> <Setter Property="Background" Value="{DynamicResource AppStatusDangerBrush}"/>
</Style> </Style>
<Style Selector="Border.compactDay">
<Setter Property="Width" Value="28"/>
<Setter Property="Height" Value="28"/>
<Setter Property="CornerRadius" Value="6"/>
<Setter Property="Background" Value="{DynamicResource AppChipBackgroundBrush}"/>
<Setter Property="BorderBrush" Value="{DynamicResource AppCardBorderBrush}"/>
<Setter Property="BorderThickness" Value="1"/>
<Setter Property="Margin" Value="2"/>
</Style>
<Style Selector="Border.compactDay.today">
<Setter Property="BorderBrush" Value="{DynamicResource AppAccentTextBrush}"/>
<Setter Property="BorderThickness" Value="2"/>
</Style>
</UserControl.Styles> </UserControl.Styles>
<UserControl.Resources> <UserControl.Resources>
@@ -227,14 +240,26 @@
<Border Grid.Row="0" Background="{DynamicResource AppCardBackgroundBrush}" <Border Grid.Row="0" Background="{DynamicResource AppCardBackgroundBrush}"
BorderBrush="{DynamicResource AppCardBorderBrush}" BorderThickness="1" BorderBrush="{DynamicResource AppCardBorderBrush}" BorderThickness="1"
CornerRadius="8" Padding="8" Margin="0,0,0,4"> CornerRadius="8" Padding="8" Margin="0,0,0,4">
<Grid ColumnDefinitions="Auto,Auto,Auto,*,250" ColumnSpacing="8"> <Grid ColumnDefinitions="Auto,Auto,Auto,Auto,Auto,*,250" ColumnSpacing="8">
<Button Grid.Column="0" Classes="filter" Classes.active="{Binding AlertsFilterSelected}" <Button Grid.Column="0" Classes="filter" Classes.active="{Binding AlertsFilterSelected}"
Content="Auffällig" Command="{Binding ShowAlertsCommand}"/> Content="Auffällig" Command="{Binding ShowAlertsCommand}"/>
<Button Grid.Column="1" Classes="filter" Classes.active="{Binding ClassRegisterFilterSelected}" <Button Grid.Column="1" Classes="filter" Classes.active="{Binding ClassRegisterFilterSelected}"
Content="Klassenbuch" Command="{Binding ShowClassRegisterCommand}"/> Content="Klassenbuch" Command="{Binding ShowClassRegisterCommand}"/>
<Button Grid.Column="2" Classes="filter" Classes.active="{Binding AllFilterSelected}" <Button Grid.Column="2" Classes="filter" Classes.active="{Binding AllFilterSelected}"
Content="Alle" Command="{Binding ShowAllCommand}"/> Content="Alle" Command="{Binding ShowAllCommand}"/>
<TextBox Grid.Column="4" Text="{Binding SearchText, Mode=TwoWay}" PlaceholderText="Schüler*in suchen…" <!-- Nutzer-Feedback: "Auffällig" zeigt nur den Heute-Snapshot — wer heute zufällig
da ist, aber zuvor mehrfach unentschuldigt fehlte, taucht dort gar nicht auf,
während ein einmaliger Erstfehltag ganz oben landet. Neuer Reiter sortiert
stattdessen nach dem kumulierten Score seit Schuljahresbeginn
(ClassTeacherRosterRow.PatternScore: unentschuldigt > entschuldigt >
Verspätung) und ist deshalb der beim Öffnen aktive Standard-Reiter. -->
<Button Grid.Column="3" Classes="filter" Classes.active="{Binding PatternScoreFilterSelected}"
Content="Gesamtbild" Command="{Binding ShowPatternScoreCommand}"
ToolTip.Tip="Sortiert nach Auffälligkeits-Score seit Schuljahresbeginn (unentschuldigt zählt am stärksten, Verspätung am wenigsten) statt nur nach dem heutigen Status."/>
<Button Grid.Column="4" Content="⚙ Gewichtung" FontSize="12" Padding="10,4"
Background="Transparent" Click="OnEditPatternScoreWeights"
ToolTip.Tip="Legt fest, wie stark einzelne Ereignisse in den Auffälligkeits-Score der 'Gesamtbild'-Sortierung einfließen."/>
<TextBox Grid.Column="6" Text="{Binding SearchText, Mode=TwoWay}" PlaceholderText="Schüler*in suchen…"
FontSize="12" MinHeight="32"/> FontSize="12" MinHeight="32"/>
</Grid> </Grid>
</Border> </Border>
@@ -397,6 +422,48 @@
</StackPanel> </StackPanel>
</Border> </Border>
<!-- Verdichtete Monatsansicht: exakt eine Zelle je Kalendertag. Ohne Ereignis
bleibt nur die Tageszahl sichtbar; bei Auffälligkeiten ersetzt das stärkste
Statuskürzel die Zahl. Alle Details bleiben per Tooltip und Drill-down erhalten. -->
<Border Classes="sidePanel">
<StackPanel Spacing="8">
<Grid ColumnDefinitions="*,Auto">
<StackPanel>
<TextBlock Text="Monat auf einen Blick" FontSize="14" FontWeight="SemiBold"/>
<TextBlock Text="{Binding CompactMonthLabel}" FontSize="11" Opacity="0.55"/>
</StackPanel>
<Button Grid.Column="1" Content="Öffnen " Command="{Binding OpenMonthlyCalendarCommand}"
Background="Transparent" BorderThickness="0" Padding="6,2"
Foreground="{DynamicResource AppAccentTextBrush}"/>
</Grid>
<ItemsControl ItemsSource="{Binding CompactMonthDays}" HorizontalAlignment="Center">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate><UniformGrid Columns="7"/></ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:ClassTeacherCompactCalendarDay">
<Border Classes="compactDay" Classes.today="{Binding IsToday}"
ToolTip.Tip="{Binding Tooltip}">
<Grid>
<TextBlock Text="{Binding DayNumber}" FontSize="11" Opacity="0.7"
HorizontalAlignment="Center" VerticalAlignment="Center"
IsVisible="{Binding !HasSignal}"/>
<Border Background="{Binding SignalColorHex}" CornerRadius="5"
IsVisible="{Binding HasSignal}">
<TextBlock Text="{Binding SignalCode}" Foreground="White" FontSize="11"
FontWeight="Bold" HorizontalAlignment="Center"
VerticalAlignment="Center"/>
</Border>
</Grid>
</Border>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<TextBlock Text="U unentsch. · ! Klassenbuch · A abwesend · V verspätet · E entsch."
FontSize="9" Opacity="0.5" TextWrapping="Wrap"/>
</StackPanel>
</Border>
<Border Classes="sidePanel" IsVisible="{Binding HasOpenExcuses}"> <Border Classes="sidePanel" IsVisible="{Binding HasOpenExcuses}">
<StackPanel Spacing="8"> <StackPanel Spacing="8">
<TextBlock Text="Offene Entschuldigungen" FontSize="14" FontWeight="SemiBold"/> <TextBlock Text="Offene Entschuldigungen" FontSize="14" FontWeight="SemiBold"/>
@@ -464,6 +531,22 @@
<StackPanel Spacing="8"> <StackPanel Spacing="8">
<TextBlock Text="Nächste Schritte" FontSize="14" FontWeight="SemiBold"/> <TextBlock Text="Nächste Schritte" FontSize="14" FontWeight="SemiBold"/>
<Button Content="Klassenbuch öffnen" Command="{Binding OpenClassRegisterCommand}" HorizontalAlignment="Stretch"/> <Button Content="Klassenbuch öffnen" Command="{Binding OpenClassRegisterCommand}" HorizontalAlignment="Stretch"/>
<!-- Nutzer-Feedback: Zahlen-Badges für offene eigene Dokumentation direkt am
Button, der zum Klassenbuch-Tab (jetzt Dokumentations-Hub) führt, statt in
einer eigenen Kennzahlkarte in der bereits vollen 4-Spalten-Reihe oben. -->
<StackPanel Orientation="Horizontal" Spacing="6" Margin="0,-4,0,0"
IsVisible="{Binding HasOwnDocumentationAlerts}">
<Border Background="#FB8C00" CornerRadius="9" Padding="7,2"
IsVisible="{Binding !!OwnDocumentationFollowUpCount}">
<TextBlock Text="{Binding OwnDocumentationFollowUpCount, StringFormat='Nacharbeit: {0}'}"
Foreground="White" FontSize="10" FontWeight="SemiBold"/>
</Border>
<Border Background="#E53935" CornerRadius="9" Padding="7,2"
IsVisible="{Binding !!OwnDocumentationCriticalCount}">
<TextBlock Text="{Binding OwnDocumentationCriticalCount, StringFormat='kritisch: {0}'}"
Foreground="White" FontSize="10" FontWeight="SemiBold"/>
</Border>
</StackPanel>
<Button Content="Fehlzeiten öffnen" Command="{Binding OpenAbsencesCommand}" HorizontalAlignment="Stretch"/> <Button Content="Fehlzeiten öffnen" Command="{Binding OpenAbsencesCommand}" HorizontalAlignment="Stretch"/>
<Button Content="Aufgaben &amp; Wiedervorlagen" Command="{Binding GoToWorkloadCommand}" <Button Content="Aufgaben &amp; Wiedervorlagen" Command="{Binding GoToWorkloadCommand}"
HorizontalAlignment="Stretch" Background="Transparent"/> HorizontalAlignment="Stretch" Background="Transparent"/>
@@ -482,6 +565,9 @@
<ContentPage Header="Fehlzeiten"> <ContentPage Header="Fehlzeiten">
<views:ClassTeacherAbsencesView DataContext="{Binding DetailsTab}"/> <views:ClassTeacherAbsencesView DataContext="{Binding DetailsTab}"/>
</ContentPage> </ContentPage>
<ContentPage Header="Vorgänge">
<views:ClassTeacherCasesView DataContext="{Binding CasesTab}"/>
</ContentPage>
</TabbedPage> </TabbedPage>
</Grid> </Grid>
</UserControl> </UserControl>
@@ -1,8 +1,26 @@
using Avalonia.Controls; using Avalonia.Controls;
using Avalonia.Interactivity;
using LehrerApp.Core.Services;
using LehrerApp.Desktop.ViewModels.ClassTeacher;
using Microsoft.Extensions.DependencyInjection;
namespace LehrerApp.Desktop.Views.ClassTeacher; namespace LehrerApp.Desktop.Views.ClassTeacher;
public partial class ClassTeacherOverviewView : UserControl public partial class ClassTeacherOverviewView : UserControl
{ {
public ClassTeacherOverviewView() => InitializeComponent(); public ClassTeacherOverviewView() => InitializeComponent();
private async void OnEditPatternScoreWeights(object? sender, RoutedEventArgs e)
{
var owner = TopLevel.GetTopLevel(this) as Window;
if (owner is null) return;
var vm = new PatternScoreWeightsDialogViewModel(App.Services.GetRequiredService<PatternScoreSettingsService>());
var dialog = new PatternScoreWeightsDialog { DataContext = vm };
await dialog.ShowDialog(owner);
// Neue Gewichtung soll sofort in der "Gesamtbild"-Sortierung wirken, nicht erst beim
// nächsten manuellen Aktualisieren.
if (DataContext is ClassTeacherOverviewViewModel overviewVm) overviewVm.LoadCommand.Execute(null);
}
} }
@@ -1,6 +1,8 @@
<UserControl xmlns="https://github.com/avaloniaui" <UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="clr-namespace:LehrerApp.Desktop.ViewModels.ClassTeacher" xmlns:vm="clr-namespace:LehrerApp.Desktop.ViewModels.ClassTeacher"
xmlns:svm="clr-namespace:LehrerApp.Desktop.ViewModels.Students"
xmlns:conv="clr-namespace:LehrerApp.Desktop.Converters"
x:Class="LehrerApp.Desktop.Views.ClassTeacher.ClassTeacherRegisterView" x:Class="LehrerApp.Desktop.Views.ClassTeacher.ClassTeacherRegisterView"
x:DataType="vm:ClassTeacherDetailsViewModel"> x:DataType="vm:ClassTeacherDetailsViewModel">
@@ -26,28 +28,86 @@
<Style Selector="TextBlock.status.danger"> <Style Selector="TextBlock.status.danger">
<Setter Property="Foreground" Value="{DynamicResource AppStatusDangerBrush}"/> <Setter Property="Foreground" Value="{DynamicResource AppStatusDangerBrush}"/>
</Style> </Style>
<Style Selector="Button.regSource">
<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="14,6"/>
<Setter Property="FontSize" Value="12"/>
</Style>
<Style Selector="Button.regSource.active">
<Setter Property="Background" Value="{DynamicResource AppFilterActiveBackgroundBrush}"/>
<Setter Property="BorderBrush" Value="{DynamicResource AppFilterActiveBorderBrush}"/>
<Setter Property="Foreground" Value="{DynamicResource AppFilterActiveForegroundBrush}"/>
</Style>
<Style Selector="Border.countBadge">
<Setter Property="Background" Value="{DynamicResource AppChipBackgroundBrush}"/>
<Setter Property="CornerRadius" Value="11"/>
<Setter Property="Padding" Value="9,3"/>
</Style>
<Style Selector="Border.countBadge.attention">
<Setter Property="Background" Value="#E53935"/>
</Style>
<Style Selector="Border.countBadge TextBlock">
<Setter Property="FontSize" Value="11"/>
<Setter Property="FontWeight" Value="SemiBold"/>
</Style>
<Style Selector="Border.countBadge.attention TextBlock">
<Setter Property="Foreground" Value="White"/>
</Style>
</UserControl.Styles> </UserControl.Styles>
<Grid RowDefinitions="Auto,Auto,Auto,*,Auto" Margin="16" RowSpacing="10"> <Grid RowDefinitions="Auto,Auto,Auto,Auto,*,Auto" Margin="16" RowSpacing="10">
<Grid Grid.Row="0" ColumnDefinitions="*,Auto"> <Grid Grid.Row="0" ColumnDefinitions="*,Auto">
<StackPanel> <StackPanel>
<TextBlock Text="Klassenbucheinträge" FontSize="22" FontWeight="SemiBold"/> <TextBlock Text="Klassenbuch" FontSize="22" FontWeight="SemiBold"/>
<TextBlock Text="Einträge anderer Lehrkräfte nur zur Ansicht" FontSize="12" Opacity="0.55"/> <TextBlock Text="Einträge anderer Lehrkräfte nur zur Ansicht. Rechtsklick auf einen Eintrag heftet ihn an einen Vorgang an."
FontSize="12" Opacity="0.55" IsVisible="{Binding !ShowOwnDocumentation}"/>
<TextBlock Text="Eigene Dokumentation zu Schüler*innen dieser Klasse" FontSize="12" Opacity="0.55"
IsVisible="{Binding ShowOwnDocumentation}"/>
</StackPanel> </StackPanel>
<Button Grid.Column="1" Content="↻ Aus WebUntis aktualisieren" Command="{Binding RefreshCommand}" <Button Grid.Column="1" Content="↻ Aus WebUntis aktualisieren" Command="{Binding RefreshCommand}"
IsEnabled="{Binding !Busy}" Background="Transparent" Foreground="{DynamicResource AppAccentTextBrush}" VerticalAlignment="Center"/> IsEnabled="{Binding !Busy}" Background="Transparent" Foreground="{DynamicResource AppAccentTextBrush}" VerticalAlignment="Center"/>
</Grid> </Grid>
<Border Grid.Row="1" Classes="filterCard"> <!-- Nutzer-Feedback: Klassenbuch-Tab als Dokumentations-Hub — Umschalter zwischen dem
WebUntis-Bericht anderer Lehrkräfte und der eigenen Dokumentation zu Schüler*innen dieser
Klasse, statt beide (strukturell unterschiedliche) Datensätze in eine Liste zu zwingen.
Zahlen-Badges links/rechts zeigen die Anzahl je Quelle, in Rot hervorgehoben, wenn kritische
bzw. nacharbeitsbedürftige Einträge dabei sind. -->
<StackPanel Grid.Row="1" Orientation="Horizontal" Spacing="10" HorizontalAlignment="Center">
<Border Classes="countBadge" Classes.attention="{Binding !!UntisCriticalCount}">
<TextBlock Text="{Binding Entries.Count, StringFormat='Klassenbuch: {0}'}"/>
</Border>
<Border Classes="countBadge" Classes.attention="{Binding !!UntisCriticalCount}" IsVisible="{Binding !!UntisCriticalCount}">
<TextBlock Text="{Binding UntisCriticalCount, StringFormat='davon kritisch: {0}'}"/>
</Border>
<Button Classes="regSource" Classes.active="{Binding !ShowOwnDocumentation}"
Content="Klassenbuch (Untis)" Command="{Binding ShowUntisRegisterCommand}"/>
<Button Classes="regSource" Classes.active="{Binding ShowOwnDocumentation}"
Content="Eigene Dokumentation" Command="{Binding ShowOwnDocsCommand}"/>
<Border Classes="countBadge">
<TextBlock Text="{Binding OwnDocumentationCount, StringFormat='Dokumentation: {0}'}"/>
</Border>
<Border Classes="countBadge attention" IsVisible="{Binding !!OwnDocumentationFollowUpCount}">
<TextBlock Text="{Binding OwnDocumentationFollowUpCount, StringFormat='Nacharbeit: {0}'}"/>
</Border>
<Border Classes="countBadge attention" IsVisible="{Binding !!OwnDocumentationCriticalCount}">
<TextBlock Text="{Binding OwnDocumentationCriticalCount, StringFormat='kritisch: {0}'}"/>
</Border>
</StackPanel>
<Border Grid.Row="2" Classes="filterCard">
<Grid RowDefinitions="Auto,Auto" RowSpacing="8"> <Grid RowDefinitions="Auto,Auto" RowSpacing="8">
<Grid Grid.Row="0" ColumnDefinitions="155,Auto,*,Auto,*" ColumnSpacing="8"> <Grid Grid.Row="0" ColumnDefinitions="155,Auto,*,Auto,*" ColumnSpacing="8">
<ComboBox Grid.Column="0" SelectedIndex="{Binding QuickRangeIndex, Mode=TwoWay}"> <ComboBox Grid.Column="0" SelectedIndex="{Binding QuickRangeIndex, Mode=TwoWay}">
<ComboBoxItem Content="Heute"/><ComboBoxItem Content="Letzte 7 Tage"/><ComboBoxItem Content="Letzte 30 Tage"/> <ComboBoxItem Content="Heute"/><ComboBoxItem Content="Letzte 7 Tage"/><ComboBoxItem Content="Letzte 30 Tage"/>
</ComboBox> </ComboBox>
<TextBlock Grid.Column="1" Text="von" VerticalAlignment="Center" Opacity="0.6"/> <TextBlock Grid.Column="1" Text="von" VerticalAlignment="Center" Opacity="0.6"/>
<CalendarDatePicker Grid.Column="2" SelectedDate="{Binding StartDate}" HorizontalAlignment="Stretch"/> <CalendarDatePicker Grid.Column="2" SelectedDate="{Binding StartDate, Converter={x:Static conv:DateTimeOffsetToDateTimeConverter.Instance}}" HorizontalAlignment="Stretch"/>
<TextBlock Grid.Column="3" Text="bis" VerticalAlignment="Center" Opacity="0.6"/> <TextBlock Grid.Column="3" Text="bis" VerticalAlignment="Center" Opacity="0.6"/>
<CalendarDatePicker Grid.Column="4" SelectedDate="{Binding EndDate}" HorizontalAlignment="Stretch"/> <CalendarDatePicker Grid.Column="4" SelectedDate="{Binding EndDate, Converter={x:Static conv:DateTimeOffsetToDateTimeConverter.Instance}}" HorizontalAlignment="Stretch"/>
</Grid> </Grid>
<Grid Grid.Row="1" ColumnDefinitions="*,Auto,Auto" ColumnSpacing="8"> <Grid Grid.Row="1" ColumnDefinitions="*,Auto,Auto" ColumnSpacing="8">
<TextBox Grid.Column="0" Text="{Binding StudentFilter, Mode=TwoWay}" PlaceholderText="Schüler*in filtern…"/> <TextBox Grid.Column="0" Text="{Binding StudentFilter, Mode=TwoWay}" PlaceholderText="Schüler*in filtern…"/>
@@ -62,7 +122,7 @@
Kategorie ("Hausaufgaben fehlen: 12× — 5× Ben Schmidt, 3× Ada Müller") ist die eigentlich 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 interessante Information. Als Chip-Reihe statt eigener Spalte in der Tabelle, damit die
bestehenden Spalten unangetastet bleiben. --> bestehenden Spalten unangetastet bleiben. -->
<Border Grid.Row="2" Classes="filterCard" IsVisible="{Binding HasCategoryAggregates}"> <Border Grid.Row="3" Classes="filterCard" IsVisible="{Binding ShowCategoryAggregates}">
<StackPanel Spacing="6"> <StackPanel Spacing="6">
<TextBlock Text="Kategorien im Zeitraum" FontSize="12" FontWeight="SemiBold" Opacity="0.7"/> <TextBlock Text="Kategorien im Zeitraum" FontSize="12" FontWeight="SemiBold" Opacity="0.7"/>
<ItemsControl ItemsSource="{Binding CategoryAggregates}"> <ItemsControl ItemsSource="{Binding CategoryAggregates}">
@@ -86,8 +146,9 @@
</StackPanel> </StackPanel>
</Border> </Border>
<Grid Grid.Row="3"> <Grid Grid.Row="4" IsVisible="{Binding !ShowOwnDocumentation}">
<DataGrid ItemsSource="{Binding Entries}" AutoGenerateColumns="False" IsReadOnly="True" <DataGrid ItemsSource="{Binding Entries}" SelectedItem="{Binding SelectedEntry, Mode=TwoWay}"
AutoGenerateColumns="False" IsReadOnly="True"
GridLinesVisibility="Horizontal" BorderBrush="{DynamicResource AppCardBorderBrush}" BorderThickness="1" GridLinesVisibility="Horizontal" BorderBrush="{DynamicResource AppCardBorderBrush}" BorderThickness="1"
CanUserResizeColumns="True" CanUserSortColumns="True" RowHeight="46"> CanUserResizeColumns="True" CanUserSortColumns="True" RowHeight="46">
<DataGrid.Columns> <DataGrid.Columns>
@@ -99,12 +160,90 @@
<DataGridTextColumn Header="Gruppe" Binding="{Binding CategoryGroup}" Width="0.8*"/> <DataGridTextColumn Header="Gruppe" Binding="{Binding CategoryGroup}" Width="0.8*"/>
<DataGridTextColumn Header="Eintrag" Binding="{Binding Text}" Width="2*"/> <DataGridTextColumn Header="Eintrag" Binding="{Binding Text}" Width="2*"/>
</DataGrid.Columns> </DataGrid.Columns>
<DataGrid.ContextMenu>
<ContextMenu>
<MenuItem Header="→ An Vorgang anheften" Command="{Binding PinToVorgangCommand}"
CommandParameter="{Binding SelectedEntry}"/>
</ContextMenu>
</DataGrid.ContextMenu>
</DataGrid> </DataGrid>
<StackPanel IsVisible="{Binding !HasEntries}" HorizontalAlignment="Center" VerticalAlignment="Center" Spacing="5"> <StackPanel IsVisible="{Binding !HasEntries}" HorizontalAlignment="Center" VerticalAlignment="Center" Spacing="5">
<TextBlock Text="Keine Klassenbucheinträge im gewählten Zeitraum" FontWeight="SemiBold"/> <TextBlock Text="Keine Klassenbucheinträge im gewählten Zeitraum" FontWeight="SemiBold"/>
<TextBlock Text="Passe Zeitraum oder Schülerfilter an." FontSize="12" Opacity="0.55"/> <TextBlock Text="Passe Zeitraum oder Schülerfilter an." FontSize="12" Opacity="0.55"/>
</StackPanel> </StackPanel>
</Grid> </Grid>
<TextBlock Grid.Row="4" Text="{Binding Status}" FontSize="11" Opacity="0.6"/>
<Grid Grid.Row="4" IsVisible="{Binding ShowOwnDocumentation}" RowDefinitions="Auto,*">
<Button Grid.Row="0" Content=" Eintrag" Command="{Binding AddOwnDocumentationCommand}"
IsEnabled="{Binding CanAddOwnDocumentation}"
HorizontalAlignment="Right" Margin="0,0,0,8"/>
<ScrollViewer Grid.Row="1">
<StackPanel>
<ItemsControl ItemsSource="{Binding OwnDocumentationEntries}">
<ItemsControl.ItemTemplate>
<DataTemplate DataType="svm:DocumentationItem">
<Border Background="{DynamicResource SystemControlBackgroundAltHighBrush}"
CornerRadius="6" Padding="12,10" Margin="0,0,0,8">
<StackPanel Spacing="4">
<Grid ColumnDefinitions="80,*,Auto,Auto,Auto,Auto">
<TextBlock Grid.Column="0" Text="{Binding DateDisplay}" Opacity="0.5" FontSize="12"/>
<StackPanel Grid.Column="1" Margin="8,0">
<StackPanel Orientation="Horizontal" Spacing="6">
<Border Background="#FB8C00" CornerRadius="8" Padding="6,1"
IsVisible="{Binding IsDraft}">
<TextBlock Text="ENTWURF" Foreground="White" FontSize="9" FontWeight="Bold"/>
</Border>
<TextBlock Text="{Binding StudentName}" FontWeight="SemiBold" FontSize="13"/>
<TextBlock Text="{Binding Model.Title}" FontSize="13" Opacity="0.8"
IsVisible="{Binding IsRevealed}"/>
<TextBlock Text="Vertraulich" FontSize="13" Opacity="0.6"
IsVisible="{Binding !IsRevealed}"/>
</StackPanel>
<TextBlock Text="{Binding TypeLabel}" FontSize="11" Opacity="0.5"/>
</StackPanel>
<TextBlock Grid.Column="2" Text="🔒" FontSize="14" VerticalAlignment="Center"
IsVisible="{Binding IsConfidential}" ToolTip.Tip="Vertraulich"/>
<Button Grid.Column="3" Content="Anzeigen" FontSize="11" Padding="8,3" Margin="6,0,0,0"
Command="{Binding RevealCommand}" IsVisible="{Binding !IsRevealed}"/>
<StackPanel Grid.Column="4" Orientation="Horizontal" Spacing="4" Margin="6,0,0,0"
IsVisible="{Binding IsRevealed}">
<Button Content="Bearbeiten" FontSize="11" Padding="8,3"
Command="{Binding $parent[ItemsControl].((vm:ClassTeacherDetailsViewModel)DataContext).EditOwnDocumentationCommand}"
CommandParameter="{Binding}"/>
<Button Content="Löschen" FontSize="11" Padding="8,3"
Command="{Binding $parent[ItemsControl].((vm:ClassTeacherDetailsViewModel)DataContext).DeleteOwnDocumentationCommand}"
CommandParameter="{Binding}"/>
</StackPanel>
</Grid>
<TextBlock Text="{Binding Model.Content}" FontSize="12" TextWrapping="Wrap" Opacity="0.8"
IsVisible="{Binding IsRevealed}"/>
<StackPanel Orientation="Horizontal" Spacing="8" IsVisible="{Binding IsRevealed}">
<TextBlock Text="{Binding StatusLabel}" FontSize="11" Opacity="0.55"
IsVisible="{Binding StatusLabel, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
<TextBlock Text="📎 Anhang" FontSize="11" Opacity="0.55" IsVisible="{Binding HasAttachments}"/>
</StackPanel>
<ItemsControl ItemsSource="{Binding TagChips}" IsVisible="{Binding IsRevealed}">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate><WrapPanel ItemSpacing="6" LineSpacing="4"/></ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
<ItemsControl.ItemTemplate>
<DataTemplate DataType="svm:TagChip">
<Border Background="{Binding ColorHex}" CornerRadius="10" Padding="8,2">
<TextBlock Text="{Binding Text}" FontSize="10" Foreground="White"/>
</Border>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</StackPanel>
</Border>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<TextBlock Text="Keine eigene Dokumentation im gewählten Zeitraum." Classes="emptyhint"
IsVisible="{Binding !HasOwnDocumentationEntries}"/>
</StackPanel>
</ScrollViewer>
</Grid>
<TextBlock Grid.Row="5" Text="{Binding Status}" FontSize="11" Opacity="0.6"/>
</Grid> </Grid>
</UserControl> </UserControl>
@@ -1,8 +1,65 @@
using Avalonia.Controls; using Avalonia.Controls;
using LehrerApp.Core.Interfaces;
using LehrerApp.Core.Models;
using LehrerApp.Desktop.ViewModels.ClassTeacher;
using LehrerApp.Desktop.ViewModels.Students;
using LehrerApp.Desktop.Views.Shared;
using LehrerApp.Desktop.Views.Students;
using Microsoft.Extensions.DependencyInjection;
namespace LehrerApp.Desktop.Views.ClassTeacher; namespace LehrerApp.Desktop.Views.ClassTeacher;
public partial class ClassTeacherRegisterView : UserControl public partial class ClassTeacherRegisterView : UserControl
{ {
public ClassTeacherRegisterView() => InitializeComponent(); public ClassTeacherRegisterView() => InitializeComponent();
protected override void OnDataContextChanged(EventArgs e)
{
base.OnDataContextChanged(e);
if (DataContext is not ClassTeacherDetailsViewModel vm) return;
vm.OnEditOwnDocumentation = ShowDocumentationDialog;
vm.OnConfirmDeleteOwnDocumentation = ShowDeleteDocumentationDialog;
vm.OnPickVorgangForPin = ShowPinToVorgangDialog;
}
private async Task<PinToVorgangChoice?> ShowPinToVorgangDialog(ClassTeacherClassRegisterRow row)
{
if (DataContext is not ClassTeacherDetailsViewModel vm) return null;
var owner = TopLevel.GetTopLevel(this) as Window;
if (owner is null) return null;
var summary = $"{row.DateLabel} · {row.StudentDisplayName} · {row.CategoryName}";
var matchingCases = vm.OpenCasesFor(row.StudentName);
var dialogVm = new PinToVorgangDialogViewModel(summary, matchingCases);
var dialog = new PinToVorgangDialog { DataContext = dialogVm };
var confirmed = await dialog.ShowDialog<bool>(owner);
return confirmed ? dialogVm.Result : null;
}
private async Task<Documentation?> ShowDocumentationDialog(
List<StudentOption> studentOptions, Documentation? editing)
{
var owner = TopLevel.GetTopLevel(this) as Window;
if (owner is null) return null;
var vm = new DocumentationDialogViewModel(editing?.StudentId ?? Guid.Empty, editing,
App.Services.GetRequiredService<IAttachmentStorage>(), studentOptions);
var dialog = new DocumentationDialog { DataContext = vm };
var saved = await dialog.ShowDialog<bool>(owner);
if (!saved) vm.DiscardUnsavedAttachments();
return saved ? vm.Result : null;
}
private async Task<bool> ShowDeleteDocumentationDialog(DocumentationItem item)
{
var info = new ConfirmDialogInfo
{
Title = "Eintrag löschen?",
Message = $"\"{item.Model.Title}\" ({item.StudentName}) wird als gelöscht markiert und nicht mehr angezeigt.",
ConfirmText = "Löschen",
};
var dialog = new ConfirmDialog { DataContext = info };
var owner = TopLevel.GetTopLevel(this) as Window;
return owner is not null && await dialog.ShowDialog<bool>(owner);
}
} }
@@ -0,0 +1,48 @@
<Window 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.PatternScoreWeightsDialog"
x:DataType="vm:PatternScoreWeightsDialogViewModel"
Title="Gewichtung „Gesamtbild&#8220;"
Width="440" SizeToContent="Height" MinHeight="300"
CanResize="True" WindowStartupLocation="CenterOwner">
<Grid RowDefinitions="*,Auto" Margin="24">
<StackPanel Grid.Row="0" Spacing="12">
<TextBlock Text="Gewichtung „Gesamtbild&#8220;" Classes="dialogtitle"/>
<TextBlock TextWrapping="Wrap" FontSize="12" Opacity="0.6"
Text="Bestimmt, wie stark einzelne Ereignisse in den Auffälligkeits-Score einfließen, nach dem der „Gesamtbild&#8220;-Reiter sortiert. Wirkt erst nach erneutem Laden der Übersicht."/>
<Grid ColumnDefinitions="*,110" RowDefinitions="Auto,Auto,Auto,Auto,Auto,Auto" RowSpacing="10">
<TextBlock Grid.Row="0" Grid.Column="0" Text="Unentschuldigt (pro Tag)" VerticalAlignment="Center"/>
<NumericUpDown Grid.Row="0" Grid.Column="1" Value="{Binding UnexcusedDayWeight}"
Minimum="0" Maximum="50" FormatString="0.0" Increment="0.1" ShowButtonSpinner="False"/>
<TextBlock Grid.Row="1" Grid.Column="0" Text="Entschuldigt abwesend (pro Tag)" VerticalAlignment="Center"/>
<NumericUpDown Grid.Row="1" Grid.Column="1" Value="{Binding ExcusedDayWeight}"
Minimum="0" Maximum="50" FormatString="0.0" Increment="0.1" ShowButtonSpinner="False"/>
<TextBlock Grid.Row="2" Grid.Column="0" Text="Nur verspätet (pro Tag)" VerticalAlignment="Center"/>
<NumericUpDown Grid.Row="2" Grid.Column="1" Value="{Binding LateDayWeight}"
Minimum="0" Maximum="50" FormatString="0.0" Increment="0.1" ShowButtonSpinner="False"/>
<TextBlock Grid.Row="3" Grid.Column="0" Text="Negativer Klassenbucheintrag" VerticalAlignment="Center"
TextWrapping="Wrap"/>
<NumericUpDown Grid.Row="3" Grid.Column="1" Value="{Binding NegativeClassRegisterEntryWeight}"
Minimum="0" Maximum="50" FormatString="0.0" Increment="0.1" ShowButtonSpinner="False"/>
<TextBlock Grid.Row="4" Grid.Column="0" Text="+ Hausaufgaben-/Mitarbeit-Schlüsselwort" VerticalAlignment="Center"
TextWrapping="Wrap"/>
<NumericUpDown Grid.Row="4" Grid.Column="1" Value="{Binding ConcerningKeywordBonus}"
Minimum="0" Maximum="50" FormatString="0.0" Increment="0.1" ShowButtonSpinner="False"/>
<TextBlock Grid.Row="5" Grid.Column="0" Text="+ „Suspendiert&#8220;-Schlüsselwort" VerticalAlignment="Center"
TextWrapping="Wrap"/>
<NumericUpDown Grid.Row="5" Grid.Column="1" Value="{Binding SuspensionKeywordWeight}"
Minimum="0" Maximum="100" FormatString="0.0" Increment="0.5" ShowButtonSpinner="False"/>
</Grid>
</StackPanel>
<Button Grid.Row="1" Content="Fertig" HorizontalAlignment="Right" Margin="0,20,0,0" Click="OnClose"/>
</Grid>
</Window>
@@ -0,0 +1,16 @@
using Avalonia.Controls;
using Avalonia.Interactivity;
using LehrerApp.Desktop.ViewModels.ClassTeacher;
namespace LehrerApp.Desktop.Views.ClassTeacher;
public partial class PatternScoreWeightsDialog : Window
{
public PatternScoreWeightsDialog() => InitializeComponent();
private void OnClose(object? sender, RoutedEventArgs e)
{
if (DataContext is PatternScoreWeightsDialogViewModel vm) vm.SaveCommand.Execute(null);
Close();
}
}
@@ -0,0 +1,44 @@
<Window 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.PinToVorgangDialog"
x:DataType="vm:PinToVorgangDialogViewModel"
Title="An Vorgang anheften"
Width="420" SizeToContent="Height" MinHeight="260"
CanResize="True" WindowStartupLocation="CenterOwner">
<Grid RowDefinitions="*,Auto" Margin="24">
<StackPanel Grid.Row="0" Spacing="12">
<TextBlock Text="An Vorgang anheften" Classes="dialogtitle"/>
<TextBlock Text="{Binding RowSummary}" FontSize="12" Opacity="0.6" TextWrapping="Wrap"/>
<StackPanel Spacing="4" IsVisible="{Binding HasMatchingOpenCases}">
<TextBlock Text="Bestehenden offenen Vorgang wählen" FontSize="12" Opacity="0.7"/>
<ComboBox ItemsSource="{Binding MatchingOpenCases}" SelectedItem="{Binding SelectedCase}"
HorizontalAlignment="Stretch">
<ComboBox.ItemTemplate>
<DataTemplate x:DataType="vm:VorgangItem">
<TextBlock Text="{Binding Model.Title}"/>
</DataTemplate>
</ComboBox.ItemTemplate>
</ComboBox>
</StackPanel>
<TextBlock Text="— oder —" FontSize="11" Opacity="0.5" HorizontalAlignment="Center"
IsVisible="{Binding HasMatchingOpenCases}"/>
<StackPanel Spacing="4">
<TextBlock Text="Neuen Vorgang anlegen mit Titel" FontSize="12" Opacity="0.7"/>
<TextBox Text="{Binding NewTitle}" PlaceholderText="z.B. Schuleschwänzen"/>
</StackPanel>
<TextBlock Text="{Binding Error}" Foreground="Red" FontSize="11"
IsVisible="{Binding Error, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
</StackPanel>
<Grid Grid.Row="1" ColumnDefinitions="*,8,*" Margin="0,20,0,0">
<Button Grid.Column="0" Content="Abbrechen" HorizontalAlignment="Stretch" Click="OnCancel"/>
<Button Grid.Column="2" Content="Anheften" HorizontalAlignment="Stretch" Click="OnConfirm"/>
</Grid>
</Grid>
</Window>
@@ -0,0 +1,21 @@
using Avalonia.Controls;
using Avalonia.Interactivity;
using LehrerApp.Desktop.ViewModels.ClassTeacher;
namespace LehrerApp.Desktop.Views.ClassTeacher;
public partial class PinToVorgangDialog : Window
{
public PinToVorgangDialog() => InitializeComponent();
private void OnConfirm(object? sender, RoutedEventArgs e)
{
if (DataContext is PinToVorgangDialogViewModel vm)
{
vm.ConfirmCommand.Execute(null);
if (vm.Result is not null) Close(true);
}
}
private void OnCancel(object? sender, RoutedEventArgs e) => Close(false);
}
@@ -0,0 +1,80 @@
<Window 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.VorgangDialog"
x:DataType="vm:VorgangDialogViewModel"
Title="{Binding DialogTitle}"
Width="480" SizeToContent="Height" MinHeight="360"
CanResize="True" WindowStartupLocation="CenterOwner">
<Grid RowDefinitions="*,Auto" Margin="24">
<ScrollViewer Grid.Row="0">
<StackPanel Spacing="12">
<TextBlock Text="{Binding DialogTitle}" Classes="dialogtitle"/>
<StackPanel Spacing="4">
<TextBlock Text="Titel *" FontSize="12" Opacity="0.7"/>
<TextBox Text="{Binding Title}"/>
<TextBlock Text="{Binding TitleError}" Foreground="Red" FontSize="11"
IsVisible="{Binding TitleError, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
</StackPanel>
<StackPanel Spacing="4">
<TextBlock Text="Beschreibung" FontSize="12" Opacity="0.7"/>
<TextBox Text="{Binding Description}" AcceptsReturn="True" Height="90" TextWrapping="Wrap"
PlaceholderText="Worum geht es? Was ist bisher passiert?"/>
</StackPanel>
<StackPanel Spacing="6">
<TextBlock Text="Schlagwörter" FontSize="12" Opacity="0.7"/>
<ItemsControl ItemsSource="{Binding Tags}">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate><WrapPanel ItemSpacing="6" LineSpacing="6"/></ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
<ItemsControl.ItemTemplate>
<DataTemplate>
<Border Background="{DynamicResource SystemControlBackgroundBaseMediumBrush}"
CornerRadius="10" Padding="8,3">
<StackPanel Orientation="Horizontal" Spacing="5">
<TextBlock Text="{Binding}" FontSize="11"/>
<Button Content="×" FontSize="11" Padding="0" Background="Transparent"
Command="{Binding $parent[ItemsControl].((vm:VorgangDialogViewModel)DataContext).RemoveTagCommand}"
CommandParameter="{Binding}"/>
</StackPanel>
</Border>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<Grid ColumnDefinitions="*,8,Auto">
<AutoCompleteBox Grid.Column="0" Text="{Binding NewTag}"
ItemsSource="{Binding TagSuggestions}"
FilterMode="Contains" MinimumPrefixLength="0"
PlaceholderText="Schlagwort (z.B. Absentismus)"/>
<Button Grid.Column="2" Content="" Command="{Binding AddTagCommand}"/>
</Grid>
</StackPanel>
<StackPanel Spacing="4">
<TextBlock Text="Betroffene Schüler*innen *" FontSize="12" Opacity="0.7"/>
<ItemsControl ItemsSource="{Binding StudentOptions}">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate><WrapPanel/></ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:VorgangStudentOption">
<CheckBox Content="{Binding Name}" IsChecked="{Binding IsSelected}" Margin="0,2,14,2"/>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<TextBlock Text="{Binding StudentsError}" Foreground="Red" FontSize="11"
IsVisible="{Binding StudentsError, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
</StackPanel>
</StackPanel>
</ScrollViewer>
<Grid Grid.Row="1" ColumnDefinitions="*,8,*" Margin="0,20,0,0">
<Button Grid.Column="0" Content="Abbrechen" HorizontalAlignment="Stretch" Click="OnCancel"/>
<Button Grid.Column="2" Content="Speichern" HorizontalAlignment="Stretch" Click="OnSave"/>
</Grid>
</Grid>
</Window>
@@ -0,0 +1,21 @@
using Avalonia.Controls;
using Avalonia.Interactivity;
using LehrerApp.Desktop.ViewModels.ClassTeacher;
namespace LehrerApp.Desktop.Views.ClassTeacher;
public partial class VorgangDialog : Window
{
public VorgangDialog() => InitializeComponent();
private void OnSave(object? sender, RoutedEventArgs e)
{
if (DataContext is VorgangDialogViewModel vm)
{
vm.SaveCommand.Execute(null);
if (vm.Result is not null) Close(true);
}
}
private void OnCancel(object? sender, RoutedEventArgs e) => Close(false);
}
@@ -9,11 +9,39 @@
<Setter Property="Foreground" Value="Red"/> <Setter Property="Foreground" Value="Red"/>
<Setter Property="FontWeight" Value="SemiBold"/> <Setter Property="FontWeight" Value="SemiBold"/>
</Style> </Style>
<Style Selector="Button.webuntishealth">
<Setter Property="Foreground" Value="{DynamicResource AppStatusOkBrush}"/>
</Style>
<Style Selector="Button.webuntishealth.warning">
<Setter Property="Foreground" Value="{DynamicResource AppStatusWarningBrush}"/>
</Style>
<Style Selector="Border.daycell.selected"> <Style Selector="Border.daycell.selected">
<Setter Property="Background" Value="{DynamicResource SystemControlBackgroundAccentBrush}"/> <Setter Property="Background" Value="{DynamicResource SystemControlBackgroundAccentBrush}"/>
<Setter Property="BorderThickness" Value="2"/> <Setter Property="BorderThickness" Value="2"/>
<Setter Property="BorderBrush" Value="{DynamicResource SystemAccentColor}"/> <Setter Property="BorderBrush" Value="{DynamicResource SystemAccentColor}"/>
</Style> </Style>
<!-- Handlungsbedarf-Karte: Schweregrad-Streifen (nur Auffälligkeiten) und die rot
hervorgehobene Fehlzeitenquote — ersetzen die früheren fest kodierten Hex-Farben
(#D32F2F/#F59E0B, Foreground="Red") durch die validierte Status-Palette. -->
<Style Selector="Border.severitybar">
<Setter Property="Width" Value="4"/>
<Setter Property="Background" Value="Transparent"/>
</Style>
<Style Selector="Border.severitybar.high">
<Setter Property="Background" Value="{DynamicResource AppStatusDangerBrush}"/>
</Style>
<Style Selector="Border.severitybar.medium">
<Setter Property="Background" Value="{DynamicResource AppStatusWarningBrush}"/>
</Style>
<Style Selector="TextBlock.attentionTrailing">
<Setter Property="FontSize" Value="10"/>
<Setter Property="Opacity" Value="0.6"/>
</Style>
<Style Selector="TextBlock.attentionTrailing.warning">
<Setter Property="FontSize" Value="12"/>
<Setter Property="Opacity" Value="1"/>
<Setter Property="Foreground" Value="{DynamicResource AppStatusDangerBrush}"/>
</Style>
</UserControl.Styles> </UserControl.Styles>
<ScrollViewer Padding="24"> <ScrollViewer Padding="24">
@@ -26,7 +54,11 @@
<TextBlock Text="{Binding CurrentDate}" FontSize="24" FontWeight="SemiBold"/> <TextBlock Text="{Binding CurrentDate}" FontSize="24" FontWeight="SemiBold"/>
</StackPanel> </StackPanel>
<WrapPanel Grid.Row="1" Orientation="Horizontal" ItemSpacing="8" LineSpacing="8" Margin="0,10,0,0"> <WrapPanel Grid.Row="1" Orientation="Horizontal" ItemSpacing="8" LineSpacing="8" Margin="0,10,0,0">
<Button Content="Klassenbuch abgleichen…" Click="OnCompareWebUntisDocumentationClick" <!-- Fasst die vier WebUntis-Abgleiche zusammen, die jetzt nur noch im Menü "WebUntis"
erreichbar sind (siehe TODO.md, Untis-Hub) - Klick öffnet den Hub. -->
<Button Content="{Binding WebUntisHealthLabel}" Click="OnOpenUntisHubClick"
IsVisible="{Binding IsWebUntisHealthVisible}"
Classes="webuntishealth" Classes.warning="{Binding IsWebUntisHealthWarning}"
VerticalAlignment="Center"/> VerticalAlignment="Center"/>
<Button Content="Bereiche anpassen" Command="{Binding ToggleDashboardSettingsCommand}" <Button Content="Bereiche anpassen" Command="{Binding ToggleDashboardSettingsCommand}"
VerticalAlignment="Center"/> VerticalAlignment="Center"/>
@@ -132,11 +164,12 @@
<TextBlock Text="HEUTE UND HANDLUNGSBEDARF" FontSize="11" FontWeight="Bold" Opacity="0.5" <TextBlock Text="HEUTE UND HANDLUNGSBEDARF" FontSize="11" FontWeight="Bold" Opacity="0.5"
Margin="2,2,0,-8"/> Margin="2,2,0,-8"/>
<Grid ColumnDefinitions="3*,2*" RowDefinitions="Auto,Auto,Auto,Auto,Auto,Auto"> <!-- 7 Kacheln (today/tasks/calendar/examload/upcoming/groups/attention) auf 2 Spalten -> 4 Zeilen. -->
<Grid ColumnDefinitions="3*,2*" RowDefinitions="Auto,Auto,Auto,Auto">
<!-- Heutige Stunden --> <!-- Heutige Stunden -->
<Border Grid.Column="{Binding TodayCard.Column}" Grid.Row="{Binding TodayCard.Row}" <Border Grid.Column="{Binding TodayCard.Column}" Grid.Row="{Binding TodayCard.Row}"
IsVisible="{Binding TodayCard.EffectiveIsVisible}" Margin="0,0,8,8" IsVisible="{Binding TodayCard.EffectiveIsVisible}" Margin="{Binding TodayCard.Margin}"
Background="{DynamicResource SystemControlBackgroundAltHighBrush}" Background="{DynamicResource SystemControlBackgroundAltHighBrush}"
CornerRadius="8" Padding="16"> CornerRadius="8" Padding="16">
<StackPanel> <StackPanel>
@@ -177,7 +210,7 @@
<!-- Offene Aufgaben --> <!-- Offene Aufgaben -->
<Border Grid.Column="{Binding TasksCard.Column}" Grid.Row="{Binding TasksCard.Row}" <Border Grid.Column="{Binding TasksCard.Column}" Grid.Row="{Binding TasksCard.Row}"
IsVisible="{Binding TasksCard.EffectiveIsVisible}" Margin="8,0,0,8" IsVisible="{Binding TasksCard.EffectiveIsVisible}" Margin="{Binding TasksCard.Margin}"
Background="{DynamicResource SystemControlBackgroundAltHighBrush}" Background="{DynamicResource SystemControlBackgroundAltHighBrush}"
CornerRadius="8" Padding="16"> CornerRadius="8" Padding="16">
<StackPanel> <StackPanel>
@@ -214,7 +247,7 @@
<!-- Kalender: feste Position direkt unter Heute/Aufgaben, damit die wachsende <!-- Kalender: feste Position direkt unter Heute/Aufgaben, damit die wachsende
Lerngruppen-Liste darunter ihn nicht nach unten verdrängt. --> Lerngruppen-Liste darunter ihn nicht nach unten verdrängt. -->
<Border Grid.Column="{Binding CalendarCard.Column}" Grid.Row="{Binding CalendarCard.Row}" <Border Grid.Column="{Binding CalendarCard.Column}" Grid.Row="{Binding CalendarCard.Row}"
IsVisible="{Binding CalendarCard.EffectiveIsVisible}" Margin="0,0,8,8" IsVisible="{Binding CalendarCard.EffectiveIsVisible}" Margin="{Binding CalendarCard.Margin}"
Background="{DynamicResource SystemControlBackgroundAltHighBrush}" Background="{DynamicResource SystemControlBackgroundAltHighBrush}"
CornerRadius="8" Padding="16" HorizontalAlignment="Left" MaxWidth="320"> CornerRadius="8" Padding="16" HorizontalAlignment="Left" MaxWidth="320">
<StackPanel Spacing="8"> <StackPanel Spacing="8">
@@ -356,73 +389,9 @@
</StackPanel> </StackPanel>
</Border> </Border>
<!-- Offene Entschuldigungen: neben dem Kalender, ebenfalls feste Position -->
<Border Grid.Column="{Binding ExcusesCard.Column}" Grid.Row="{Binding ExcusesCard.Row}"
IsVisible="{Binding ExcusesCard.EffectiveIsVisible}" Margin="8,0,0,8" VerticalAlignment="Top"
Background="{DynamicResource SystemControlBackgroundAltHighBrush}"
CornerRadius="8" Padding="16">
<StackPanel>
<TextBlock Text="OFFENE ENTSCHULDIGUNGEN" FontSize="11" FontWeight="Bold"
Opacity="0.5" Margin="0,0,0,10"/>
<ItemsControl ItemsSource="{Binding OpenExcuses}">
<ItemsControl.ItemTemplate>
<DataTemplate DataType="vm:OpenExcuseItem">
<Grid ColumnDefinitions="*,Auto,Auto" Margin="0,4">
<StackPanel Grid.Column="0">
<TextBlock Text="{Binding StudentName}" FontSize="13" FontWeight="SemiBold"
TextTrimming="CharacterEllipsis"/>
<TextBlock FontSize="11" Opacity="0.6">
<Run Text="{Binding GroupName}"/>
<Run Text=" · "/>
<Run Text="{Binding DateDisplay}"/>
</TextBlock>
</StackPanel>
<Button Grid.Column="1" Content="Entschuldigt" FontSize="11" Padding="7,3"
Command="{Binding MarkExcusedCommand}" Margin="0,0,4,0"/>
<Button Grid.Column="2" Content="Unentschuldigt" FontSize="11" Padding="7,3"
Command="{Binding MarkUnexcusedCommand}"/>
</Grid>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<TextBlock Text="Keine offenen Entschuldigungen." Classes="emptyhint"
IsVisible="{Binding !OpenExcuses.Count}"/>
</StackPanel>
</Border>
<!-- Fehlzeiten-Warnung (5.2.3) -->
<Border Grid.Column="{Binding AttendanceCard.Column}" Grid.Row="{Binding AttendanceCard.Row}"
IsVisible="{Binding AttendanceCard.EffectiveIsVisible}" Margin="0,0,8,8" VerticalAlignment="Top"
Background="{DynamicResource SystemControlBackgroundAltHighBrush}"
CornerRadius="8" Padding="16">
<StackPanel>
<TextBlock Text="FEHLZEITEN-WARNUNG" FontSize="11" FontWeight="Bold"
Opacity="0.5" Margin="0,0,0,10"/>
<ItemsControl ItemsSource="{Binding AttendanceWarnings}">
<ItemsControl.ItemTemplate>
<DataTemplate DataType="vm:AttendanceWarningItem">
<Grid ColumnDefinitions="*,Auto" Margin="0,4">
<Button Grid.Column="0" Content="{Binding StudentName}" FontSize="13"
HorizontalAlignment="Left" HorizontalContentAlignment="Left"
Background="Transparent" Padding="0"
Command="{Binding $parent[ItemsControl].((vm:DashboardViewModel)DataContext).OpenStudentAttendanceCommand}"
CommandParameter="{Binding}"/>
<TextBlock Grid.Column="1" Foreground="Red" FontSize="12" VerticalAlignment="Center">
<Run Text="{Binding AbsenceRatePercent}"/>
<Run Text=" %"/>
</TextBlock>
</Grid>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<TextBlock Text="Keine Fehlzeiten über dem Schwellenwert." Classes="emptyhint"
IsVisible="{Binding !AttendanceWarnings.Count}"/>
</StackPanel>
</Border>
<!-- Klausurwochen (Nutzer-Feedback): eigene Klausurlast über alle Kurse hinweg --> <!-- Klausurwochen (Nutzer-Feedback): eigene Klausurlast über alle Kurse hinweg -->
<Border Grid.Column="{Binding ExamLoadCard.Column}" Grid.Row="{Binding ExamLoadCard.Row}" <Border Grid.Column="{Binding ExamLoadCard.Column}" Grid.Row="{Binding ExamLoadCard.Row}"
IsVisible="{Binding ExamLoadCard.EffectiveIsVisible}" Margin="0,0,8,8" VerticalAlignment="Top" IsVisible="{Binding ExamLoadCard.EffectiveIsVisible}" Margin="{Binding ExamLoadCard.Margin}" VerticalAlignment="Top"
Background="{DynamicResource SystemControlBackgroundAltHighBrush}" Background="{DynamicResource SystemControlBackgroundAltHighBrush}"
CornerRadius="8" Padding="16"> CornerRadius="8" Padding="16">
<StackPanel> <StackPanel>
@@ -444,66 +413,9 @@
</StackPanel> </StackPanel>
</Border> </Border>
<!-- Unterrichtszeit nacherfassen (Nutzer-Feedback) -->
<Border Grid.Column="{Binding MissingTeachingTimeCard.Column}" Grid.Row="{Binding MissingTeachingTimeCard.Row}"
IsVisible="{Binding MissingTeachingTimeCard.EffectiveIsVisible}" Margin="0,0,8,8" VerticalAlignment="Top"
Background="{DynamicResource SystemControlBackgroundAltHighBrush}"
CornerRadius="8" Padding="16">
<StackPanel>
<TextBlock Text="UNTERRICHTSZEIT NACHERFASSEN" FontSize="11" FontWeight="Bold"
Opacity="0.5" Margin="0,0,0,10"/>
<ItemsControl ItemsSource="{Binding MissingTeachingTimeEntries}">
<ItemsControl.ItemTemplate>
<DataTemplate DataType="vm:MissingTeachingTimeItem">
<Grid ColumnDefinitions="*,Auto" Margin="0,4">
<TextBlock Grid.Column="0" Text="{Binding DateDisplay}" FontSize="13"
VerticalAlignment="Center"/>
<Button Grid.Column="1" Content="Erfassen" FontSize="12" Padding="10,4"
Command="{Binding $parent[ItemsControl].((vm:DashboardViewModel)DataContext).AddMissingTeachingTimeCommand}"
CommandParameter="{Binding}"/>
</Grid>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<TextBlock Text="Keine fehlende Unterrichtszeit-Erfassung." Classes="emptyhint"
IsVisible="{Binding !MissingTeachingTimeEntries.Count}"/>
</StackPanel>
</Border>
<!-- Förderplan-Wiedervorlage (5.3.2) -->
<Border Grid.Column="{Binding SupportCard.Column}" Grid.Row="{Binding SupportCard.Row}"
IsVisible="{Binding SupportCard.EffectiveIsVisible}" Margin="8,0,0,8" VerticalAlignment="Top"
Background="{DynamicResource SystemControlBackgroundAltHighBrush}"
CornerRadius="8" Padding="16">
<StackPanel>
<TextBlock Text="FÖRDERPLAN-WIEDERVORLAGE" FontSize="11" FontWeight="Bold"
Opacity="0.5" Margin="0,0,0,10"/>
<ItemsControl ItemsSource="{Binding SupportPlanReviews}">
<ItemsControl.ItemTemplate>
<DataTemplate DataType="vm:SupportPlanDueItem">
<Grid ColumnDefinitions="*,Auto" Margin="0,4">
<StackPanel Grid.Column="0">
<Button Content="{Binding StudentName}" FontSize="13" FontWeight="SemiBold"
HorizontalAlignment="Left" HorizontalContentAlignment="Left"
Background="Transparent" Padding="0"
Command="{Binding $parent[ItemsControl].((vm:DashboardViewModel)DataContext).OpenStudentSupportPlanCommand}"
CommandParameter="{Binding}"/>
<TextBlock Text="{Binding Title}" FontSize="11" Opacity="0.6"/>
</StackPanel>
<TextBlock Grid.Column="1" Text="{Binding ReviewDateDisplay}" FontSize="12"
VerticalAlignment="Center" Classes.overdue="{Binding IsOverdue}"/>
</Grid>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<TextBlock Text="Keine fälligen Überprüfungen." Classes="emptyhint"
IsVisible="{Binding !SupportPlanReviews.Count}"/>
</StackPanel>
</Border>
<!-- Anstehende Termine (9.3) --> <!-- Anstehende Termine (9.3) -->
<Border Grid.Column="{Binding UpcomingCard.Column}" Grid.Row="{Binding UpcomingCard.Row}" <Border Grid.Column="{Binding UpcomingCard.Column}" Grid.Row="{Binding UpcomingCard.Row}"
IsVisible="{Binding UpcomingCard.EffectiveIsVisible}" Margin="0,0,8,8" VerticalAlignment="Top" IsVisible="{Binding UpcomingCard.EffectiveIsVisible}" Margin="{Binding UpcomingCard.Margin}" VerticalAlignment="Top"
Background="{DynamicResource SystemControlBackgroundAltHighBrush}" Background="{DynamicResource SystemControlBackgroundAltHighBrush}"
CornerRadius="8" Padding="16"> CornerRadius="8" Padding="16">
<StackPanel> <StackPanel>
@@ -538,106 +450,107 @@
</StackPanel> </StackPanel>
</Border> </Border>
<!-- Offene Korrekturen (9.4) --> <!-- Handlungsbedarf: zusammengefasste Karte für die frueheren sieben Kacheln
<Border Grid.Column="{Binding CorrectionsCard.Column}" Grid.Row="{Binding CorrectionsCard.Row}" (Entschuldigungen, Fehlzeiten, Foerderplan, Korrekturen, ungeplante Stunden,
IsVisible="{Binding CorrectionsCard.EffectiveIsVisible}" Margin="8,0,0,8" VerticalAlignment="Top" Auffaelligkeiten, Unterrichtszeit-Nacherfassung) — siehe AttentionItem.cs.
Filter-Chips ersetzen die vorherige Sichtbarkeit je Einzelkachel. x:Name traegt den
Weg zum DashboardViewModel durch zwei ItemsControl-Verschachtelungen (Gruppe -> Item)
hindurch, ohne $parent[ItemsControl] mit Ancestor-Level zaehlen zu muessen. -->
<Border Grid.Column="{Binding AttentionCard.Column}" Grid.Row="{Binding AttentionCard.Row}"
IsVisible="{Binding AttentionCard.EffectiveIsVisible}" Margin="{Binding AttentionCard.Margin}" VerticalAlignment="Top"
Background="{DynamicResource SystemControlBackgroundAltHighBrush}" Background="{DynamicResource SystemControlBackgroundAltHighBrush}"
CornerRadius="8" Padding="16"> CornerRadius="8" Padding="16">
<StackPanel> <StackPanel Spacing="10">
<TextBlock Text="OFFENE KORREKTUREN" FontSize="11" FontWeight="Bold" <TextBlock Text="HANDLUNGSBEDARF" FontSize="11" FontWeight="Bold" Opacity="0.5"/>
Opacity="0.5" Margin="0,0,0,10"/>
<ItemsControl ItemsSource="{Binding OpenCorrections}">
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:CorrectionProgressItem">
<Button Background="Transparent" BorderThickness="0" Padding="0" Margin="0,5"
HorizontalAlignment="Stretch" HorizontalContentAlignment="Stretch"
Command="{Binding $parent[ItemsControl].((vm:DashboardViewModel)DataContext).OpenCorrectionCommand}"
CommandParameter="{Binding}">
<StackPanel Spacing="3">
<Grid ColumnDefinitions="*,Auto">
<TextBlock Text="{Binding Title}" FontSize="13" FontWeight="SemiBold"/>
<TextBlock Grid.Column="1" Text="{Binding DateDisplay}" FontSize="11" Opacity="0.6"/>
</Grid>
<TextBlock Text="{Binding GroupName}" FontSize="11" Opacity="0.6"/>
<ProgressBar Minimum="0" Maximum="100" Value="{Binding Percent}" Height="6"/>
<TextBlock Text="{Binding ProgressDisplay}" FontSize="10" Opacity="0.65"/>
</StackPanel>
</Button>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<TextBlock Text="Keine offenen Korrekturen." Classes="emptyhint"
IsVisible="{Binding !OpenCorrections.Count}"/>
</StackPanel>
</Border>
<!-- Ungeplante Stunden --> <ItemsControl ItemsSource="{Binding AttentionFilters}">
<Border Grid.Column="{Binding UnplannedCard.Column}" Grid.Row="{Binding UnplannedCard.Row}" <ItemsControl.ItemsPanel>
IsVisible="{Binding UnplannedCard.EffectiveIsVisible}" Margin="0,0,8,8" VerticalAlignment="Top" <ItemsPanelTemplate><WrapPanel Orientation="Horizontal" ItemSpacing="6" LineSpacing="6"/></ItemsPanelTemplate>
Background="{DynamicResource SystemControlBackgroundAltHighBrush}" </ItemsControl.ItemsPanel>
CornerRadius="8" Padding="16">
<StackPanel>
<TextBlock Text="UNGEPLANTE STUNDEN" FontSize="11" FontWeight="Bold"
Opacity="0.5" Margin="0,0,0,10"/>
<ItemsControl ItemsSource="{Binding UnplannedLessons}">
<ItemsControl.ItemTemplate> <ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:UnplannedLessonItem"> <DataTemplate x:DataType="vm:AttentionFilterOption">
<Button Background="Transparent" BorderThickness="0" Padding="0" Margin="0,5" <ToggleButton Content="{Binding Label}" IsChecked="{Binding IsActive}"
HorizontalAlignment="Stretch" HorizontalContentAlignment="Stretch" FontSize="11" Padding="8,3"/>
Command="{Binding $parent[ItemsControl].((vm:DashboardViewModel)DataContext).OpenUnplannedLessonCommand}"
CommandParameter="{Binding}">
<Grid ColumnDefinitions="*,Auto">
<TextBlock Text="{Binding Display}" FontSize="13"
TextTrimming="CharacterEllipsis"/>
<TextBlock Grid.Column="1" Text="{Binding DateDisplay}" FontSize="12"
VerticalAlignment="Center" Opacity="0.6"/>
</Grid>
</Button>
</DataTemplate> </DataTemplate>
</ItemsControl.ItemTemplate> </ItemsControl.ItemTemplate>
</ItemsControl> </ItemsControl>
<TextBlock Text="Keine ungeplanten Stunden." Classes="emptyhint"
IsVisible="{Binding !UnplannedLessons.Count}"/>
</StackPanel>
</Border>
<!-- Auffälligkeiten (9.5) --> <ItemsControl x:Name="AttentionGroupsList" ItemsSource="{Binding Attention}">
<Border Grid.Column="{Binding AlertsCard.Column}" Grid.Row="{Binding AlertsCard.Row}"
IsVisible="{Binding AlertsCard.EffectiveIsVisible}" Margin="0,0,8,8" VerticalAlignment="Top"
Background="{DynamicResource SystemControlBackgroundAltHighBrush}"
CornerRadius="8" Padding="16">
<StackPanel>
<TextBlock Text="AUFFÄLLIGKEITEN" FontSize="11" FontWeight="Bold"
Opacity="0.5" Margin="0,0,0,10"/>
<ItemsControl ItemsSource="{Binding Alerts}">
<ItemsControl.ItemTemplate> <ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:DashboardAlertItem"> <DataTemplate x:DataType="vm:AttentionGroup">
<Button Background="Transparent" BorderThickness="0" Padding="0" Margin="0,4" <StackPanel Margin="0,6,0,0" Spacing="4">
HorizontalAlignment="Stretch" HorizontalContentAlignment="Stretch" <TextBlock FontSize="11" FontWeight="SemiBold" Opacity="0.7">
Command="{Binding $parent[ItemsControl].((vm:DashboardViewModel)DataContext).OpenAlertCommand}" <Run Text="{Binding Header}"/>
CommandParameter="{Binding}"> <Run Text=" ("/>
<Grid ColumnDefinitions="4,*"> <Run Text="{Binding Count}"/>
<Border Background="{Binding SeverityColor}" CornerRadius="2" Margin="0,0,9,0"/> <Run Text=")"/>
<StackPanel Grid.Column="1"> </TextBlock>
<Grid ColumnDefinitions="*,Auto"> <ItemsControl ItemsSource="{Binding Items}">
<TextBlock Text="{Binding StudentName}" FontSize="13" FontWeight="SemiBold"/> <ItemsControl.ItemTemplate>
<TextBlock Grid.Column="1" Text="{Binding KindLabel}" FontSize="10" Opacity="0.6"/> <DataTemplate x:DataType="vm:AttentionItem">
</Grid> <StackPanel Margin="0,4">
<TextBlock Text="{Binding Detail}" FontSize="11" Opacity="0.65"/> <Grid ColumnDefinitions="4,*">
</StackPanel> <Border Grid.Column="0" Classes="severitybar"
</Grid> Classes.high="{Binding IsHighSeverity}"
</Button> Classes.medium="{Binding IsMediumSeverity}" Margin="0,0,9,0"/>
<!-- Bewusst immer als Button (statt je nach IsClickable ein anderes Root-Element):
ein einheitliches Template für alle sieben Arten. Bei Excuse/MissingTeachingTime
ist Navigate null, der Klick auf die Zeile selbst ist dann folgenlos — die
eigentliche Aktion liegt dort in Actions (Entschuldigt/Erfassen) darunter. -->
<Button Grid.Column="1" Background="Transparent" BorderThickness="0" Padding="0"
HorizontalAlignment="Stretch" HorizontalContentAlignment="Stretch"
Command="{Binding #AttentionGroupsList.((vm:DashboardViewModel)DataContext).OpenAttentionItemCommand}"
CommandParameter="{Binding}">
<StackPanel Spacing="3">
<Grid ColumnDefinitions="*,Auto">
<TextBlock Text="{Binding Title}" FontSize="13" FontWeight="SemiBold"
TextTrimming="CharacterEllipsis"/>
<TextBlock Grid.Column="1" Text="{Binding TrailingText}"
Classes="attentionTrailing" Classes.warning="{Binding IsWarningTrailing}"
VerticalAlignment="Center" Margin="8,0,0,0"
IsVisible="{Binding HasTrailingText}"/>
<TextBlock Grid.Column="1" Text="{Binding DateDisplay}" FontSize="12"
VerticalAlignment="Center" Margin="8,0,0,0"
Classes.overdue="{Binding IsOverdue}"
IsVisible="{Binding HasDate}"/>
</Grid>
<TextBlock Text="{Binding Subtitle}" FontSize="11" Opacity="0.6"
IsVisible="{Binding HasSubtitle}"/>
<ProgressBar Minimum="0" Maximum="100" Value="{Binding ProgressPercent}"
Height="6" IsVisible="{Binding HasProgress}"/>
<TextBlock Text="{Binding ProgressLabel}" FontSize="10" Opacity="0.65"
IsVisible="{Binding HasProgress}"/>
</StackPanel>
</Button>
</Grid>
<ItemsControl ItemsSource="{Binding Actions}" Margin="13,4,0,0"
IsVisible="{Binding HasActions}">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate><StackPanel Orientation="Horizontal" Spacing="4"/></ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:AttentionAction">
<Button Content="{Binding Label}" FontSize="11" Padding="7,3"
Command="{Binding Command}" CommandParameter="{Binding Parameter}"/>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</StackPanel>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</StackPanel>
</DataTemplate> </DataTemplate>
</ItemsControl.ItemTemplate> </ItemsControl.ItemTemplate>
</ItemsControl> </ItemsControl>
<TextBlock Text="Keine Auffälligkeiten erkannt." Classes="emptyhint" <TextBlock Text="Keine offenen Punkte." Classes="emptyhint"
IsVisible="{Binding !Alerts.Count}"/> IsVisible="{Binding !Attention.Count}"/>
</StackPanel> </StackPanel>
</Border> </Border>
<!-- Meine Lerngruppen --> <!-- Meine Lerngruppen -->
<Border Grid.Column="{Binding GroupsCard.Column}" Grid.Row="{Binding GroupsCard.Row}" <Border Grid.Column="{Binding GroupsCard.Column}" Grid.Row="{Binding GroupsCard.Row}"
IsVisible="{Binding GroupsCard.EffectiveIsVisible}" IsVisible="{Binding GroupsCard.EffectiveIsVisible}" Margin="{Binding GroupsCard.Margin}"
Background="{DynamicResource SystemControlBackgroundAltHighBrush}" Background="{DynamicResource SystemControlBackgroundAltHighBrush}"
CornerRadius="8" Padding="16"> CornerRadius="8" Padding="16">
<StackPanel> <StackPanel>
@@ -1,12 +1,9 @@
using Avalonia.Controls; using Avalonia.Controls;
using Avalonia.Interactivity; using Avalonia.Interactivity;
using LehrerApp.Core.Interfaces;
using LehrerApp.Core.Models; using LehrerApp.Core.Models;
using LehrerApp.Core.Services;
using LehrerApp.Desktop.Services; using LehrerApp.Desktop.Services;
using LehrerApp.Desktop.ViewModels; using LehrerApp.Desktop.ViewModels;
using LehrerApp.Desktop.ViewModels.Students; using LehrerApp.Desktop.Views.UntisHub;
using LehrerApp.Desktop.Views.Students;
using LehrerApp.Desktop.Views.Workload; using LehrerApp.Desktop.Views.Workload;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
@@ -30,16 +27,11 @@ public partial class DashboardView : UserControl
return await WorkTaskDialogHelper.ShowDialog(owner, startAsReminder: startAsReminder); return await WorkTaskDialogHelper.ShowDialog(owner, startAsReminder: startAsReminder);
} }
private async void OnCompareWebUntisDocumentationClick(object? sender, RoutedEventArgs e) private async void OnOpenUntisHubClick(object? sender, RoutedEventArgs e)
{ {
var owner = TopLevel.GetTopLevel(this) as Window; var owner = TopLevel.GetTopLevel(this) as Window;
if (owner is null) return; if (owner is null) return;
var dialogVm = new WebUntisDocumentationComparisonViewModel( await new UntisHubDialog().ShowDialog(owner);
App.Services.GetRequiredService<WebUntisIntegrationService>(), if (DataContext is DashboardViewModel vm) vm.RefreshWebUntisHealth();
App.Services.GetRequiredService<IStudentRepository>(),
App.Services.GetRequiredService<IGroupRepository>(),
App.Services.GetRequiredService<IDocumentationRepository>(),
App.Services.GetRequiredService<SchoolYearService>());
await new WebUntisDocumentationComparisonDialog { DataContext = dialogVm }.ShowDialog(owner);
} }
} }
@@ -78,8 +78,12 @@
<TextBlock Text="Anfrage läuft…" FontSize="12" Opacity="0.6"/> <TextBlock Text="Anfrage läuft…" FontSize="12" Opacity="0.6"/>
<ProgressBar IsIndeterminate="True" Height="4"/> <ProgressBar IsIndeterminate="True" Height="4"/>
</StackPanel> </StackPanel>
<TextBlock Text="{Binding ErrorMessage}" Foreground="Red" FontSize="12" TextWrapping="Wrap" <StackPanel Spacing="8" IsVisible="{Binding ErrorMessage, Converter={x:Static StringConverters.IsNotNullOrEmpty}}">
IsVisible="{Binding ErrorMessage, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/> <TextBlock Text="{Binding ErrorMessage}" Foreground="Red" FontSize="12" TextWrapping="Wrap"/>
<Button Content="🛟 Anfrage retten…" HorizontalAlignment="Left" Click="OnRescue"
IsVisible="{Binding CanRescueResponse}"
ToolTip.Tip="Vollständige KI-Antwort öffnen, JSON markieren oder korrigieren und manuell importieren."/>
</StackPanel>
</StackPanel> </StackPanel>
</ScrollViewer> </ScrollViewer>
@@ -27,6 +27,22 @@ public partial class AiAssistDialog : Window
await vm.SendCommand.ExecuteAsync(null); await vm.SendCommand.ExecuteAsync(null);
} }
private async void OnRescue(object? s, RoutedEventArgs e)
{
if (DataContext is not AiAssistDialogViewModel { RawResponse: { } raw } vm) return;
var rescueVm = new AiResponseRescueDialogViewModel(
App.Services.GetRequiredService<AiPlanningService>(),
App.Services.GetRequiredService<LehrerApp.Core.Interfaces.ILessonRepository>(),
vm.Unit, raw, vm.FocusLessonId);
var rescue = new AiResponseRescueDialog { DataContext = rescueVm };
if (await rescue.ShowDialog<bool>(this))
{
vm.MarkRescueImported();
Close(true);
}
}
private void OnApply(object? s, RoutedEventArgs e) private void OnApply(object? s, RoutedEventArgs e)
{ {
if (DataContext is AiAssistDialogViewModel vm && vm.ApplyCommand.CanExecute(null)) if (DataContext is AiAssistDialogViewModel vm && vm.ApplyCommand.CanExecute(null))
@@ -0,0 +1,50 @@
<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.AiResponseRescueDialog"
x:DataType="vm:AiResponseRescueDialogViewModel"
Title="KI-Antwort retten"
Width="820" Height="720" MinWidth="620" MinHeight="520"
CanResize="True" WindowStartupLocation="CenterOwner">
<Grid RowDefinitions="Auto,*,Auto,Auto,Auto" Margin="24">
<StackPanel Grid.Row="0" Spacing="6" Margin="0,0,0,12">
<TextBlock Text="KI-Antwort retten" Classes="dialogtitle"/>
<TextBlock Text="Die vollständige Antwort steht unten. Markiere ein vollständiges JSON-Objekt, ein Array von Stunden oder die normale Antwort mit dem Feld „lessons“. Du kannst den Text vorher auch korrigieren. Ohne Markierung wird der gesamte Text geprüft."
TextWrapping="Wrap" FontSize="12" Opacity="0.75"/>
</StackPanel>
<TextBox x:Name="ResponseTextBox" Grid.Row="1" Text="{Binding ResponseText}"
AcceptsReturn="True" AcceptsTab="True" TextWrapping="NoWrap"
FontFamily="Monospace" FontSize="12"
ScrollViewer.HorizontalScrollBarVisibility="Auto"
ScrollViewer.VerticalScrollBarVisibility="Auto"/>
<Grid Grid.Row="2" ColumnDefinitions="Auto,*" Margin="0,12,0,0">
<Button Grid.Column="0" Content="Markierung prüfen" Click="OnValidateSelection"/>
<TextBlock Grid.Column="1" Text="{Binding ParseMessage}" Margin="12,0,0,0"
VerticalAlignment="Center" TextWrapping="Wrap" FontSize="12"/>
</Grid>
<Grid Grid.Row="3" ColumnDefinitions="*,12,*" Margin="0,14,0,0">
<StackPanel Grid.Column="0" Spacing="5">
<TextBlock Text="Erkannte Stunde" FontSize="12" Opacity="0.7"/>
<ComboBox ItemsSource="{Binding ParsedLessons}" SelectedItem="{Binding SelectedParsedLesson}"
DisplayMemberBinding="{Binding Label}" IsEnabled="{Binding HasParsedLesson}"/>
</StackPanel>
<StackPanel Grid.Column="2" Spacing="5">
<TextBlock Text="Vorhandene Zielstunde" FontSize="12" Opacity="0.7"/>
<ComboBox ItemsSource="{Binding ExistingLessons}" SelectedItem="{Binding SelectedTarget}"
DisplayMemberBinding="{Binding Label}"/>
</StackPanel>
</Grid>
<Grid Grid.Row="4" ColumnDefinitions="Auto,*,Auto,10,Auto" Margin="0,20,0,0">
<Button Grid.Column="0" Content="Abbrechen" Click="OnCancel"/>
<Button Grid.Column="2" Content="In Zielstunde importieren" Click="OnImportIntoExisting"
IsEnabled="{Binding CanImportIntoExisting}"/>
<Button Grid.Column="4" Content="Als neue Stunde importieren" Click="OnImportAsNew"
IsEnabled="{Binding HasParsedLesson}"/>
</Grid>
</Grid>
</Window>
@@ -0,0 +1,35 @@
using Avalonia.Controls;
using Avalonia.Interactivity;
using LehrerApp.Desktop.ViewModels.Groups;
namespace LehrerApp.Desktop.Views.Groups;
public partial class AiResponseRescueDialog : Window
{
public AiResponseRescueDialog() => InitializeComponent();
private void OnValidateSelection(object? sender, RoutedEventArgs e)
{
if (DataContext is not AiResponseRescueDialogViewModel vm) return;
var candidate = string.IsNullOrWhiteSpace(ResponseTextBox.SelectedText)
? ResponseTextBox.Text ?? ""
: ResponseTextBox.SelectedText;
vm.ParseSelection(candidate);
}
private void OnImportIntoExisting(object? sender, RoutedEventArgs e)
{
if (DataContext is not AiResponseRescueDialogViewModel vm || !vm.CanImportIntoExisting) return;
vm.ImportIntoExistingCommand.Execute(null);
if (vm.Result) Close(true);
}
private void OnImportAsNew(object? sender, RoutedEventArgs e)
{
if (DataContext is not AiResponseRescueDialogViewModel vm || !vm.HasParsedLesson) return;
vm.ImportAsNewCommand.Execute(null);
if (vm.Result) Close(true);
}
private void OnCancel(object? sender, RoutedEventArgs e) => Close(false);
}
@@ -0,0 +1,47 @@
<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="clr-namespace:LehrerApp.Desktop.ViewModels.Groups"
xmlns:vmp="clr-namespace:LehrerApp.Desktop.ViewModels.Planning"
x:Class="LehrerApp.Desktop.Views.Groups.ChangeLessonUnitDialog"
x:DataType="vm:ChangeLessonUnitDialogViewModel"
Title="Einheit wechseln"
Width="440" Height="360" MinWidth="400" MinHeight="320"
CanResize="False" WindowStartupLocation="CenterOwner">
<Grid RowDefinitions="*,Auto" Margin="24">
<StackPanel Grid.Row="0" Spacing="14">
<TextBlock Text="Einheit wechseln" Classes="dialogtitle"/>
<TextBlock FontSize="12" Opacity="0.6">
<Run Text="Bisherige Einheit: "/>
<Run Text="{Binding CurrentUnitLabel}"/>
</TextBlock>
<StackPanel Spacing="5" IsVisible="{Binding HasUnits}">
<TextBlock Text="Neue Einheit" FontSize="12" Opacity="0.7"/>
<ComboBox ItemsSource="{Binding Units}" SelectedItem="{Binding SelectedUnit}"
HorizontalAlignment="Stretch">
<ComboBox.ItemTemplate>
<DataTemplate x:DataType="vmp:TimetableUnitOption">
<StackPanel>
<TextBlock Text="{Binding Label}" FontWeight="SemiBold"/>
<TextBlock Text="{Binding Detail}" FontSize="11" Opacity="0.6"/>
</StackPanel>
</DataTemplate>
</ComboBox.ItemTemplate>
</ComboBox>
</StackPanel>
<TextBlock Text="Keine andere Einheit in dieser Gruppe vorhanden."
FontSize="12" Opacity="0.7" TextWrapping="Wrap"
IsVisible="{Binding !HasUnits}"/>
<TextBlock Text="{Binding Error}" Foreground="Red" FontSize="11"
IsVisible="{Binding Error, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
</StackPanel>
<Grid Grid.Row="1" ColumnDefinitions="*,8,*" Margin="0,20,0,0">
<Button Grid.Column="0" Content="Abbrechen" HorizontalAlignment="Stretch" Click="OnCancel"/>
<Button Grid.Column="2" Content="Verschieben" HorizontalAlignment="Stretch" Click="OnSave"
IsEnabled="{Binding HasUnits}"/>
</Grid>
</Grid>
</Window>
@@ -0,0 +1,21 @@
using Avalonia.Controls;
using Avalonia.Interactivity;
using LehrerApp.Desktop.ViewModels.Groups;
namespace LehrerApp.Desktop.Views.Groups;
public partial class ChangeLessonUnitDialog : Window
{
public ChangeLessonUnitDialog() => InitializeComponent();
private void OnSave(object? s, RoutedEventArgs e)
{
if (DataContext is ChangeLessonUnitDialogViewModel vm && vm.SaveCommand.CanExecute(null))
{
vm.SaveCommand.Execute(null);
if (vm.Result is not null) Close(true);
}
}
private void OnCancel(object? s, RoutedEventArgs e) => Close(false);
}
@@ -185,6 +185,9 @@
<Button Grid.Column="2" Content="🤖 KI-Unterstützung für diese Stunde" HorizontalAlignment="Stretch" <Button Grid.Column="2" Content="🤖 KI-Unterstützung für diese Stunde" HorizontalAlignment="Stretch"
Click="OnAiAssist" IsVisible="{Binding CanAiAssist}" Click="OnAiAssist" IsVisible="{Binding CanAiAssist}"
ToolTip.Tip="Fragt die KI gezielt zu genau dieser Stunde — Speicherstand geht dabei direkt in die Datenbank, nicht über die Felder hier."/> ToolTip.Tip="Fragt die KI gezielt zu genau dieser Stunde — Speicherstand geht dabei direkt in die Datenbank, nicht über die Felder hier."/>
<Button Grid.Column="2" Content="🔧 Vorhandene Stunde verknüpfen" HorizontalAlignment="Stretch"
Click="OnFixIt" IsVisible="{Binding IsNewLesson}"
ToolTip.Tip="Sucht nach einer bereits vorhandenen Stunde (z.B. durch Import oder KI ohne Stundenplan-Bezug angelegt) und verknüpft sie mit diesem Termin, statt eine neue Stunde anzulegen."/>
<Button Grid.Column="4" Content="{Binding SaveButtonText}" <Button Grid.Column="4" Content="{Binding SaveButtonText}"
HorizontalAlignment="Stretch" Click="OnSave"/> HorizontalAlignment="Stretch" Click="OnSave"/>
</Grid> </Grid>
@@ -6,7 +6,9 @@ using LehrerApp.Core.Models;
using LehrerApp.Desktop.Services; using LehrerApp.Desktop.Services;
using LehrerApp.Desktop.ViewModels.Groups; using LehrerApp.Desktop.ViewModels.Groups;
using LehrerApp.Desktop.ViewModels.Students; using LehrerApp.Desktop.ViewModels.Students;
using LehrerApp.Desktop.Views.Shared;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using System.Globalization;
namespace LehrerApp.Desktop.Views.Groups; namespace LehrerApp.Desktop.Views.Groups;
@@ -200,4 +202,59 @@ public partial class LessonDialog : Window
if (updated is not null) vm.MarkAppliedExternally(updated); if (updated is not null) vm.MarkAppliedExternally(updated);
Close(true); Close(true);
} }
/// Deckt die Lücke ab, dass der Stundenplan eine Stunde nur über Datum+Stundennummer findet
/// (kein gespeichertes Verknüpfungsfeld) — eine per JSON-Import oder KI ohne Stundennummer
/// angelegte Stunde taucht dort nie auf und würde sonst beim Klick auf den Termin dupliziert.
/// Statt neu anzulegen, wird die gefundene vorhandene Stunde auf diesen Termin umgehängt.
private async void OnFixIt(object? s, RoutedEventArgs e)
{
if (DataContext is not LessonDialogViewModel vm) return;
if (!DateOnly.TryParseExact(vm.DateText, "dd.MM.yyyy", null, DateTimeStyles.None, out var date))
{
App.Services.GetRequiredService<NotificationService>()
.ShowError("Bitte zuerst ein gültiges Datum eintragen.");
return;
}
var lessonRepo = App.Services.GetRequiredService<ILessonRepository>();
var candidates = LessonFixItSearch.FindCandidates(lessonRepo, vm.GroupId, date);
if (candidates.Count == 0)
{
App.Services.GetRequiredService<NotificationService>()
.ShowError("Keine passende bestehende Stunde gefunden.");
return;
}
Lesson chosen;
if (candidates.Count == 1)
{
var info = new ConfirmDialogInfo
{
Title = "Vorhandene Stunde verknüpfen?",
Message = $"Vorhandene Stunde „{candidates[0].Topic}“ vom {candidates[0].Date:dd.MM.yyyy} gefunden. " +
"Mit diesem Termin verknüpfen, statt eine neue Stunde anzulegen?",
ConfirmText = "Verknüpfen",
};
var confirm = new ConfirmDialog { DataContext = info };
if (!await confirm.ShowDialog<bool>(this)) return;
chosen = candidates[0];
}
else
{
var pickerVm = new LinkExistingLessonDialogViewModel(candidates,
App.Services.GetRequiredService<IUnitRepository>());
var picker = new LinkExistingLessonDialog { DataContext = pickerVm };
if (!await picker.ShowDialog<bool>(this) || pickerVm.Result is not { } picked) return;
chosen = picked;
}
chosen.UnitId = vm.UnitId;
chosen.Date = date;
chosen.LessonNumber = vm.LessonNumber;
lessonRepo.Save(chosen);
vm.MarkAppliedExternally(chosen);
Close(true);
}
} }
@@ -0,0 +1,38 @@
<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.LinkExistingLessonDialog"
x:DataType="vm:LinkExistingLessonDialogViewModel"
Title="Vorhandene Stunde verknüpfen"
Width="470" Height="360" MinWidth="420" MinHeight="320"
CanResize="False" WindowStartupLocation="CenterOwner">
<Grid RowDefinitions="*,Auto" Margin="24">
<StackPanel Grid.Row="0" Spacing="16">
<TextBlock Text="Vorhandene Stunde verknüpfen" Classes="dialogtitle"/>
<TextBlock Text="Mehrere Stunden kommen für diesen Termin infrage — bitte die passende auswählen."
FontSize="12" TextWrapping="Wrap" Opacity="0.75"/>
<StackPanel Spacing="5">
<TextBlock Text="Stunde" FontSize="12" Opacity="0.7"/>
<ComboBox ItemsSource="{Binding Options}" SelectedItem="{Binding SelectedOption}"
HorizontalAlignment="Stretch">
<ComboBox.ItemTemplate>
<DataTemplate x:DataType="vm:LessonLinkOption">
<StackPanel>
<TextBlock Text="{Binding Label}" FontWeight="SemiBold"/>
<TextBlock Text="{Binding Detail}" FontSize="11" Opacity="0.6"/>
</StackPanel>
</DataTemplate>
</ComboBox.ItemTemplate>
</ComboBox>
</StackPanel>
<TextBlock Text="{Binding Error}" Foreground="Red" FontSize="11"
IsVisible="{Binding Error, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
</StackPanel>
<Grid Grid.Row="1" ColumnDefinitions="*,8,*" Margin="0,20,0,0">
<Button Grid.Column="0" Content="Abbrechen" HorizontalAlignment="Stretch" Click="OnCancel"/>
<Button Grid.Column="2" Content="Verknüpfen" HorizontalAlignment="Stretch" Click="OnSave"/>
</Grid>
</Grid>
</Window>
@@ -0,0 +1,21 @@
using Avalonia.Controls;
using Avalonia.Interactivity;
using LehrerApp.Desktop.ViewModels.Groups;
namespace LehrerApp.Desktop.Views.Groups;
public partial class LinkExistingLessonDialog : Window
{
public LinkExistingLessonDialog() => InitializeComponent();
private void OnSave(object? s, RoutedEventArgs e)
{
if (DataContext is LinkExistingLessonDialogViewModel vm && vm.SaveCommand.CanExecute(null))
{
vm.SaveCommand.Execute(null);
if (vm.Result is not null) Close(true);
}
}
private void OnCancel(object? s, RoutedEventArgs e) => Close(false);
}
@@ -6,9 +6,10 @@
<Grid RowDefinitions="Auto,2*,Auto,Auto,2*" Margin="16"> <Grid RowDefinitions="Auto,2*,Auto,Auto,2*" Margin="16">
<!-- Einheiten-Toolbar --> <!-- Einheiten-Toolbar: Aktionen bewusst unter der Überschrift und in umbruchfähigen
<Grid Grid.Row="0" ColumnDefinitions="*,Auto" Margin="0,0,0,8"> Gruppen. So kollidieren Beschriftung und Buttons auch in schmaleren Fenstern nicht. -->
<StackPanel Grid.Column="0" Orientation="Horizontal" Spacing="8" VerticalAlignment="Center"> <Grid Grid.Row="0" RowDefinitions="Auto,Auto" Margin="0,0,0,8" RowSpacing="6">
<StackPanel Grid.Row="0" Orientation="Horizontal" Spacing="8" VerticalAlignment="Center">
<TextBlock Text="Unterrichtseinheiten" FontSize="14" FontWeight="SemiBold" <TextBlock Text="Unterrichtseinheiten" FontSize="14" FontWeight="SemiBold"
VerticalAlignment="Center"/> VerticalAlignment="Center"/>
<Button Content="↕ JSON" FontSize="11" Padding="7,3" <Button Content="↕ JSON" FontSize="11" Padding="7,3"
@@ -30,13 +31,16 @@
</Button.Flyout> </Button.Flyout>
</Button> </Button>
</StackPanel> </StackPanel>
<StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="8"> <WrapPanel Grid.Row="1" ItemSpacing="6" LineSpacing="6">
<Button Content=" Einheit" Command="{Binding AddUnitCommand}" IsEnabled="{Binding !IsReadOnly}"/> <Button Content=" Einheit" Command="{Binding AddUnitCommand}" IsEnabled="{Binding !IsReadOnly}"/>
<Button Content="Bearbeiten" Command="{Binding EditUnitCommand}" IsEnabled="{Binding !IsReadOnly}"/> <Button Content="✎" Width="38" Command="{Binding EditUnitCommand}" IsEnabled="{Binding !IsReadOnly}"
<Button Content="Als Vorlage kopieren" Command="{Binding CopyUnitCommand}"/> ToolTip.Tip="Ausgewählte Einheit bearbeiten" AutomationProperties.Name="Einheit bearbeiten"/>
<Button Content="Löschen" Command="{Binding DeleteUnitCommand}" IsEnabled="{Binding !IsReadOnly}"/> <Button Content="⧉" Width="38" Command="{Binding CopyUnitCommand}"
<Button Content="🤖 KI-Unterstützung" Command="{Binding AiAssistCommand}"/> ToolTip.Tip="Als Vorlage in eine andere Lerngruppe kopieren" AutomationProperties.Name="Einheit als Vorlage kopieren"/>
</StackPanel> <Button Content="⌫" Width="38" Command="{Binding DeleteUnitCommand}" IsEnabled="{Binding !IsReadOnly}"
ToolTip.Tip="Ausgewählte Einheit löschen" AutomationProperties.Name="Einheit löschen"/>
<Button Content="✦ KI-Unterstützung" Command="{Binding AiAssistCommand}"/>
</WrapPanel>
</Grid> </Grid>
<!-- Einheiten-Tabelle --> <!-- Einheiten-Tabelle -->
@@ -76,28 +80,34 @@
<Separator Grid.Row="2" Margin="0,0,0,8"/> <Separator Grid.Row="2" Margin="0,0,0,8"/>
<!-- Stunden-Toolbar --> <!-- Stunden-Toolbar: häufige Aktionen bleiben beschriftet, sekundäre Aktionen sind als
<Grid Grid.Row="3" ColumnDefinitions="*,Auto" Margin="0,0,0,8"> kompakte Symbolbuttons gruppiert. Das WrapPanel darf bei Bedarf umbrechen. -->
<StackPanel Grid.Column="0" Orientation="Horizontal" VerticalAlignment="Center"> <Grid Grid.Row="3" RowDefinitions="Auto,Auto" Margin="0,0,0,8" RowSpacing="6">
<StackPanel Grid.Row="0" Orientation="Horizontal" VerticalAlignment="Center">
<TextBlock Text="Stunden" FontSize="14" FontWeight="SemiBold"/> <TextBlock Text="Stunden" FontSize="14" FontWeight="SemiBold"/>
<TextBlock Text="{Binding SelectedUnitTitleSuffix}" FontSize="14" FontWeight="SemiBold"/> <TextBlock Text="{Binding SelectedUnitTitleSuffix}" FontSize="14" FontWeight="SemiBold"/>
</StackPanel> </StackPanel>
<StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="8"> <WrapPanel Grid.Row="1" ItemSpacing="6" LineSpacing="6">
<Button Content=" Stunde" Command="{Binding AddLessonCommand}" IsEnabled="{Binding !IsReadOnly}"/> <Button Content=" Stunde" Command="{Binding AddLessonCommand}" IsEnabled="{Binding !IsReadOnly}"/>
<Button Content="Serie erzeugen" Command="{Binding GenerateLessonSeriesCommand}" <Button Content=" Serie" Command="{Binding GenerateLessonSeriesCommand}"
IsEnabled="{Binding !IsReadOnly}" IsEnabled="{Binding !IsReadOnly}"
ToolTip.Tip="Stunden für alle Termine aus dem Stundenplan im gewählten Zeitraum anlegen."/> ToolTip.Tip="Stunden für alle Termine aus dem Stundenplan im gewählten Zeitraum anlegen."/>
<Button Content="Anzeigen" Command="{Binding ShowLessonCommand}" <Button Content="Anzeigen" Command="{Binding ShowLessonCommand}"
ToolTip.Tip="Verlaufsplan schreibgeschützt und größer anzeigen — zum Mitnehmen in den Unterricht."/> ToolTip.Tip="Verlaufsplan schreibgeschützt und größer anzeigen — zum Mitnehmen in den Unterricht."/>
<Button Content="Bearbeiten" Command="{Binding EditLessonCommand}" IsEnabled="{Binding !IsReadOnly}"/> <Button Content="✎" Width="38" Command="{Binding EditLessonCommand}" IsEnabled="{Binding !IsReadOnly}"
<Button Content="Verschieben" Command="{Binding MoveLessonCommand}" IsEnabled="{Binding !IsReadOnly}"/> ToolTip.Tip="Ausgewählte Stunde bearbeiten" AutomationProperties.Name="Stunde bearbeiten"/>
<Button Content="Status weiter" Command="{Binding AdvanceLessonStatusCommand}" IsEnabled="{Binding !IsReadOnly}" <Button Content="↔" Width="38" Command="{Binding MoveLessonCommand}" IsEnabled="{Binding !IsReadOnly}"
ToolTip.Tip="Ausgewählte Stunde verschieben" AutomationProperties.Name="Stunde verschieben"/>
<Button Content="⇄" Width="38" Command="{Binding ChangeLessonUnitCommand}" IsEnabled="{Binding !IsReadOnly}"
ToolTip.Tip="Ordnet die ausgewählte Stunde einer anderen Einheit derselben Gruppe zu."/>
<Button Content="Status " Command="{Binding AdvanceLessonStatusCommand}" IsEnabled="{Binding !IsReadOnly}"
ToolTip.Tip="Entwurf/Geplant → Bereit → Durchgeführt"/> ToolTip.Tip="Entwurf/Geplant → Bereit → Durchgeführt"/>
<Button Content="Sitzung erzeugen" Command="{Binding CreateParticipationSessionCommand}" <Button Content=" Sitzung" Command="{Binding CreateParticipationSessionCommand}"
IsEnabled="{Binding !IsReadOnly}" IsEnabled="{Binding !IsReadOnly}"
ToolTip.Tip="Legt eine Mitarbeitssitzung mit Datum und Thema dieser Stunde an."/> ToolTip.Tip="Legt eine Mitarbeitssitzung mit Datum und Thema dieser Stunde an."/>
<Button Content="Löschen" Command="{Binding DeleteLessonCommand}" IsEnabled="{Binding !IsReadOnly}"/> <Button Content="⌫" Width="38" Command="{Binding DeleteLessonCommand}" IsEnabled="{Binding !IsReadOnly}"
</StackPanel> ToolTip.Tip="Ausgewählte Stunde löschen" AutomationProperties.Name="Stunde löschen"/>
</WrapPanel>
</Grid> </Grid>
<!-- Stunden-Tabelle --> <!-- Stunden-Tabelle -->
@@ -38,6 +38,7 @@ public partial class PlanningTabView : UserControl
ShowLessonDialog(lesson.UnitId, lesson.GroupId, materials, shorthands, editingLesson: lesson); ShowLessonDialog(lesson.UnitId, lesson.GroupId, materials, shorthands, editingLesson: lesson);
vm.OnConfirmDeleteLesson = ShowDeleteLessonDialog; vm.OnConfirmDeleteLesson = ShowDeleteLessonDialog;
vm.OnPickMoveTarget = ShowMoveLessonDialog; vm.OnPickMoveTarget = ShowMoveLessonDialog;
vm.OnPickUnitChange = ShowChangeLessonUnitDialog;
vm.OnShowLesson = ShowLessonViewerDialog; vm.OnShowLesson = ShowLessonViewerDialog;
vm.OnGenerateLessonSeries = ShowGenerateLessonSeriesDialog; vm.OnGenerateLessonSeries = ShowGenerateLessonSeriesDialog;
vm.OnAiAssist = ShowAiAssistDialog; vm.OnAiAssist = ShowAiAssistDialog;
@@ -136,6 +137,20 @@ public partial class PlanningTabView : UserControl
return ok ? dialogVm.Result : null; return ok ? dialogVm.Result : null;
} }
private async Task<Unit?> ShowChangeLessonUnitDialog(Lesson lesson)
{
var dialogVm = new ChangeLessonUnitDialogViewModel(
App.Services.GetRequiredService<IUnitRepository>(),
lesson.GroupId, lesson.UnitId,
(DataContext as PlanningTabViewModel)?.SelectedUnit?.Title ?? "");
var dialog = new ChangeLessonUnitDialog { DataContext = dialogVm };
var owner = TopLevel.GetTopLevel(this) as Window;
if (owner is null) return null;
var ok = await dialog.ShowDialog<bool>(owner);
return ok ? dialogVm.Result : null;
}
private async Task ShowLessonViewerDialog(Lesson lesson) private async Task ShowLessonViewerDialog(Lesson lesson)
{ {
var viewerVm = new LessonViewerViewModel(lesson, var viewerVm = new LessonViewerViewModel(lesson,
@@ -41,6 +41,10 @@
<TextBlock Text="Status" FontSize="12" Opacity="0.7"/> <TextBlock Text="Status" FontSize="12" Opacity="0.7"/>
<ComboBox ItemsSource="{Binding StatusOptions}" SelectedItem="{Binding StatusName}" <ComboBox ItemsSource="{Binding StatusOptions}" SelectedItem="{Binding StatusName}"
HorizontalAlignment="Stretch"/> HorizontalAlignment="Stretch"/>
<Border Background="#FFF3CD" BorderBrush="#D97706" BorderThickness="1" CornerRadius="5"
Padding="9,6" IsVisible="{Binding StatusNotice, Converter={x:Static StringConverters.IsNotNullOrEmpty}}">
<TextBlock Text="{Binding StatusNotice}" Foreground="#92400E" FontSize="11" TextWrapping="Wrap"/>
</Border>
</StackPanel> </StackPanel>
<StackPanel Spacing="4"> <StackPanel Spacing="4">
@@ -0,0 +1,58 @@
<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="clr-namespace:LehrerApp.Desktop.ViewModels.Groups"
xmlns:conv="clr-namespace:LehrerApp.Desktop.Converters"
x:Class="LehrerApp.Desktop.Views.Groups.WebUntisHomeworkComparisonDialog"
x:DataType="vm:WebUntisHomeworkComparisonViewModel"
Title="Fehlende Hausaufgaben mit WebUntis abgleichen" Width="1100" Height="680"
MinWidth="900" MinHeight="480" WindowStartupLocation="CenterOwner">
<Grid RowDefinitions="Auto,Auto,Auto,*,Auto" Margin="24" RowSpacing="8">
<StackPanel Grid.Row="0" Spacing="4">
<TextBlock Text="Fehlende Hausaufgaben mit WebUntis abgleichen" Classes="dialogtitle"/>
<TextBlock TextWrapping="Wrap" FontSize="12" Opacity="0.65"
Text="Nur eigene, negative Klassenbucheinträge zum Stichwort „Hausaufgabe“. Fach wird über das Untis-Fachkürzel im Text erkannt. Zeilen mit bereits vorhandenem lokalem Hausaufgaben-Status (z.B. „Teilweise“, „nachgereicht“) werden nie überschrieben, nur zum Vergleich angezeigt."/>
</StackPanel>
<StackPanel Grid.Row="1" Orientation="Horizontal" Spacing="8">
<CalendarDatePicker SelectedDate="{Binding StartDate, Converter={x:Static conv:DateTimeOffsetToDateTimeConverter.Instance}}"/>
<TextBlock Text="bis" VerticalAlignment="Center"/>
<CalendarDatePicker SelectedDate="{Binding EndDate, Converter={x:Static conv:DateTimeOffsetToDateTimeConverter.Instance}}"/>
<Button Content="Klassenbucheinträge laden" Command="{Binding LoadCommand}" IsEnabled="{Binding !Busy}"/>
</StackPanel>
<Grid Grid.Row="2" ColumnDefinitions="Auto,70,60,55,1.1*,1.1*,1.4*,1.2*" 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="Text" FontSize="11" Opacity="0.6"/>
<TextBlock Grid.Column="7" Text="Lokaler Hausaufgaben-Status" FontSize="11" Opacity="0.6"/>
</Grid>
<ScrollViewer Grid.Row="3">
<ItemsControl ItemsSource="{Binding Rows}">
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:WebUntisHomeworkRow">
<Grid ColumnDefinitions="Auto,70,60,55,1.1*,1.1*,1.4*,1.2*" 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 SubjectLabel}" 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"/>
<TextBlock Grid.Column="6" Text="{Binding Text}" TextWrapping="Wrap" VerticalAlignment="Center"/>
<TextBlock Grid.Column="7" Text="{Binding LocalStatus}" TextWrapping="Wrap" VerticalAlignment="Center"/>
</Grid>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</ScrollViewer>
<Grid Grid.Row="4" 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,11 @@
using Avalonia.Controls;
using Avalonia.Interactivity;
namespace LehrerApp.Desktop.Views.Groups;
public partial class WebUntisHomeworkComparisonDialog : Window
{
public WebUntisHomeworkComparisonDialog() => InitializeComponent();
private void OnClose(object? sender, RoutedEventArgs e) => Close();
}
@@ -1,6 +1,7 @@
<Window xmlns="https://github.com/avaloniaui" <Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="clr-namespace:LehrerApp.Desktop.ViewModels.Groups" xmlns:vm="clr-namespace:LehrerApp.Desktop.ViewModels.Groups"
xmlns:conv="clr-namespace:LehrerApp.Desktop.Converters"
x:Class="LehrerApp.Desktop.Views.Groups.WebUntisLessonAbsenceComparisonDialog" x:Class="LehrerApp.Desktop.Views.Groups.WebUntisLessonAbsenceComparisonDialog"
x:DataType="vm:WebUntisLessonAbsenceComparisonViewModel" x:DataType="vm:WebUntisLessonAbsenceComparisonViewModel"
Title="Fehlzeiten je Unterricht mit WebUntis abgleichen" Width="1000" Height="640" Title="Fehlzeiten je Unterricht mit WebUntis abgleichen" Width="1000" Height="640"
@@ -12,9 +13,9 @@
FontSize="12" Opacity="0.65" TextWrapping="Wrap"/> FontSize="12" Opacity="0.65" TextWrapping="Wrap"/>
</StackPanel> </StackPanel>
<StackPanel Grid.Row="1" Orientation="Horizontal" Spacing="8"> <StackPanel Grid.Row="1" Orientation="Horizontal" Spacing="8">
<DatePicker SelectedDate="{Binding StartDate}"/> <CalendarDatePicker SelectedDate="{Binding StartDate, Converter={x:Static conv:DateTimeOffsetToDateTimeConverter.Instance}}"/>
<TextBlock Text="bis" VerticalAlignment="Center"/> <TextBlock Text="bis" VerticalAlignment="Center"/>
<DatePicker SelectedDate="{Binding EndDate}"/> <CalendarDatePicker SelectedDate="{Binding EndDate, Converter={x:Static conv:DateTimeOffsetToDateTimeConverter.Instance}}"/>
<Button Content="Fehlzeiten laden" Command="{Binding LoadCommand}" IsEnabled="{Binding !Busy}"/> <Button Content="Fehlzeiten laden" Command="{Binding LoadCommand}" IsEnabled="{Binding !Busy}"/>
</StackPanel> </StackPanel>
<Grid Grid.Row="2" ColumnDefinitions="Auto,1.2*,1.2*,80,80,1.1*,1.1*" ColumnSpacing="8" Margin="4,0"> <Grid Grid.Row="2" ColumnDefinitions="Auto,1.2*,1.2*,80,80,1.1*,1.1*" ColumnSpacing="8" Margin="4,0">
@@ -1,6 +1,7 @@
<Window xmlns="https://github.com/avaloniaui" <Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="clr-namespace:LehrerApp.Desktop.ViewModels.Groups" xmlns:vm="clr-namespace:LehrerApp.Desktop.ViewModels.Groups"
xmlns:conv="clr-namespace:LehrerApp.Desktop.Converters"
x:Class="LehrerApp.Desktop.Views.Groups.WithdrawStudentDialog" x:Class="LehrerApp.Desktop.Views.Groups.WithdrawStudentDialog"
x:DataType="vm:WithdrawStudentDialogViewModel" x:DataType="vm:WithdrawStudentDialogViewModel"
Title="Schüler austragen" Title="Schüler austragen"
@@ -23,8 +24,8 @@
<StackPanel Spacing="5"> <StackPanel Spacing="5">
<TextBlock Text="Austrittsdatum" FontSize="12" Opacity="0.7"/> <TextBlock Text="Austrittsdatum" FontSize="12" Opacity="0.7"/>
<CalendarDatePicker SelectedDate="{Binding SelectedDate}" <CalendarDatePicker SelectedDate="{Binding SelectedDate, Converter={x:Static conv:DateTimeOffsetToDateTimeConverter.Instance}}"
DisplayDateStart="{Binding EarliestDate}" DisplayDateStart="{Binding EarliestDate, Converter={x:Static conv:DateTimeOffsetToDateTimeConverter.Instance}}"
HorizontalAlignment="Stretch"/> HorizontalAlignment="Stretch"/>
<TextBlock Text="{Binding DateError}" Foreground="Red" FontSize="12" <TextBlock Text="{Binding DateError}" Foreground="Red" FontSize="12"
IsVisible="{Binding DateError, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/> IsVisible="{Binding DateError, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
+11
View File
@@ -31,6 +31,16 @@
darunter als Overlay-Drawer mit Hamburger-Button (automatisch). darunter als Overlay-Drawer mit Hamburger-Button (automatisch).
Kein eigener Code nötig. Kein eigener Code nötig.
--> -->
<DockPanel>
<Menu DockPanel.Dock="Top">
<MenuItem Header="WebUntis">
<MenuItem Header="Untis-Hub…" Click="OnOpenUntisHub"/>
<Separator/>
<MenuItem Header="Offene Stunden…" Click="OnOpenUntisPeriods"/>
<MenuItem Header="Klassenbuchabgleich…" Click="OnCompareUntisKlassenbuch"/>
<MenuItem Header="Hausaufgabenabgleich…" Click="OnCompareUntisHausaufgaben"/>
</MenuItem>
</Menu>
<DrawerPage x:Name="RootDrawer" <DrawerPage x:Name="RootDrawer"
DrawerLength="220" DrawerLength="220"
DrawerBehavior="Auto" DrawerBehavior="Auto"
@@ -267,6 +277,7 @@
</DrawerPage.Drawer> </DrawerPage.Drawer>
</DrawerPage> </DrawerPage>
</DockPanel>
<!-- Globale Suche und Schnellerfassung (14.2). Bewusst als Overlay auf der aktuellen Seite: <!-- 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. --> Der Nutzer behält den Kontext und kann mit Escape ohne Navigation zurückkehren. -->
@@ -3,7 +3,9 @@ using Avalonia.Input;
using Avalonia.Threading; using Avalonia.Threading;
using LehrerApp.Desktop.Services; using LehrerApp.Desktop.Services;
using LehrerApp.Desktop.ViewModels; using LehrerApp.Desktop.ViewModels;
using LehrerApp.Desktop.Views.UntisHub;
using LehrerApp.Sync; using LehrerApp.Sync;
using Microsoft.Extensions.DependencyInjection;
namespace LehrerApp.Desktop.Views; namespace LehrerApp.Desktop.Views;
@@ -23,6 +25,26 @@ public partial class MainWindow : Window
KeyDown += OnWindowKeyDown; KeyDown += OnWindowKeyDown;
} }
private async void OnOpenUntisHub(object? sender, Avalonia.Interactivity.RoutedEventArgs e)
{
await new UntisHubDialog().ShowDialog(this);
}
private async void OnOpenUntisPeriods(object? sender, Avalonia.Interactivity.RoutedEventArgs e)
{
await UntisHubActions.RunOffenePeriodsAsync(this, App.Services.GetRequiredService<UntisHubService>());
}
private async void OnCompareUntisKlassenbuch(object? sender, Avalonia.Interactivity.RoutedEventArgs e)
{
await UntisHubActions.RunKlassenbuchAsync(this, App.Services.GetRequiredService<UntisHubService>());
}
private async void OnCompareUntisHausaufgaben(object? sender, Avalonia.Interactivity.RoutedEventArgs e)
{
await UntisHubActions.RunHausaufgabenAsync(this, App.Services.GetRequiredService<UntisHubService>());
}
private void OnWindowKeyDown(object? sender, KeyEventArgs e) private void OnWindowKeyDown(object? sender, KeyEventArgs e)
{ {
if (DataContext is not MainWindowViewModel vm) return; if (DataContext is not MainWindowViewModel vm) return;
@@ -0,0 +1,129 @@
using Avalonia;
using Avalonia.Controls;
using LehrerApp.Desktop.Services;
using LehrerApp.WebUntis;
using Microsoft.Extensions.DependencyInjection;
namespace LehrerApp.Desktop.Views;
public sealed class OpenUntisPeriodsDialog : Window
{
private readonly WebUntisIntegrationService _service = App.Services.GetRequiredService<WebUntisIntegrationService>();
private readonly WebUntisSettingsService _settings = App.Services.GetRequiredService<WebUntisSettingsService>();
private readonly CancellationTokenSource _closed = new();
private readonly ComboBox _mode = new() { ItemsSource = new[] { "Mein Unterricht", "Meine Klasse" }, SelectedIndex = 0 };
private readonly ComboBox _element = new() { MinWidth = 150 };
private readonly DatePicker _start = new();
private readonly DatePicker _end = new() { SelectedDate = DateTimeOffset.Now };
private readonly Button _load = new() { Content = "Abrufen" };
private readonly Button _schoolYear = new() { Content = "Seit Schuljahresbeginn" };
private readonly TextBlock _status = new() { TextWrapping = Avalonia.Media.TextWrapping.Wrap };
private readonly StackPanel _rows = new() { Spacing = 12 };
private readonly StackPanel _filters = new() { Spacing = 10, IsEnabled = false };
private UntisOpenPeriodsMeta? _meta;
private int _yearId;
/// <summary>Für den Untis-Hub (<see cref="UntisHubActions.RunOffenePeriodsAsync"/>): letzter
/// Status-Text nach dem automatischen Laden beim Öffnen.</summary>
public string? LastStatus => _status.Text;
public OpenUntisPeriodsDialog()
{
Title = "Offene WebUntis-Stunden";
Width = 850; Height = 650; MinWidth = 600; MinHeight = 450;
WindowStartupLocation = WindowStartupLocation.CenterOwner;
var root = new DockPanel { Margin = new Thickness(20) };
var header = new StackPanel { Spacing = 12 };
header.Children.Add(new TextBlock { Text = Title, FontSize = 22 });
var selection = new StackPanel { Orientation = Avalonia.Layout.Orientation.Horizontal, Spacing = 12 };
selection.Children.Add(_mode); selection.Children.Add(_element);
_filters.Children.Add(selection);
var dates = new WrapPanel { Orientation = Avalonia.Layout.Orientation.Horizontal };
dates.Children.Add(new TextBlock { Text = "Von ", VerticalAlignment = Avalonia.Layout.VerticalAlignment.Center });
dates.Children.Add(_start);
dates.Children.Add(new TextBlock { Text = " bis ", VerticalAlignment = Avalonia.Layout.VerticalAlignment.Center });
dates.Children.Add(_end);
_filters.Children.Add(dates);
var actions = new StackPanel { Orientation = Avalonia.Layout.Orientation.Horizontal, Spacing = 12 };
actions.Children.Add(_schoolYear); actions.Children.Add(_load);
_filters.Children.Add(actions);
header.Children.Add(_filters); header.Children.Add(_status);
DockPanel.SetDock(header, Dock.Top); root.Children.Add(header);
root.Children.Add(new ScrollViewer { Content = _rows, Margin = new Thickness(0, 20, 0, 0) });
Content = root;
_mode.SelectionChanged += (_, _) => SelectMode();
_element.SelectionChanged += (_, _) => ClearResults();
_start.SelectedDateChanged += (_, _) => ClearResults();
_end.SelectedDateChanged += (_, _) => ClearResults();
_schoolYear.Click += (_, _) => { if (_meta is not null) _start.SelectedDate = new DateTimeOffset(_meta.SchoolYearStart.ToDateTime(TimeOnly.MinValue)); };
_load.Click += async (_, _) => await LoadAsync();
Opened += async (_, _) => await InitializeAsync();
Closed += (_, _) => _closed.Cancel();
}
private void ClearResults() { _rows.Children.Clear(); _status.Text = "Auswahl festlegen und Abrufen wählen."; }
private void SelectMode()
{
if (_meta is null) return;
var own = _mode.SelectedIndex == 0;
var elements = own ? _meta.Teachers : _meta.Classes;
_element.ItemsSource = elements;
var preferred = own ? _settings.TeacherUntisId ?? _meta.OwnTeacherId
: _settings.HomeroomClassUntisId ?? _meta.MyClassIds.Cast<int?>().FirstOrDefault();
_element.SelectedItem = elements.FirstOrDefault(x => x.Id == preferred)
?? (!own ? elements.FirstOrDefault(x => _meta.MyClassIds.Contains(x.Id)) : null);
ClearResults();
}
private async Task InitializeAsync()
{
_status.Text = "WebUntis-Auswahl wird geladen …";
try
{
var today = DateOnly.FromDateTime(DateTime.Today);
var date = today.Year * 10000 + today.Month * 100 + today.Day;
var years = await _service.GetSchoolYearsAsync(_closed.Token);
var year = years.FirstOrDefault(x => x.StartDate <= date && date <= x.EndDate)
?? throw new InvalidDataException("Kein aktuelles WebUntis-Schuljahr gefunden.");
_yearId = year.UntisId;
_meta = await _service.GetOpenPeriodsMetaAsync(_yearId, _closed.Token);
_start.SelectedDate = new DateTimeOffset(_meta.SchoolYearStart.ToDateTime(TimeOnly.MinValue));
SelectMode();
_filters.IsEnabled = true;
if (_element.SelectedItem is not null) await LoadAsync();
}
catch (OperationCanceledException) when (_closed.IsCancellationRequested) { }
catch (Exception ex) { _status.Text = $"Abruf fehlgeschlagen: {ex.Message}"; }
}
private async Task LoadAsync()
{
_rows.Children.Clear();
if (_element.SelectedItem is not UntisOpenElement element || _start.SelectedDate is null || _end.SelectedDate is null)
{ _status.Text = "Bitte Lehrkraft oder Klasse und Zeitraum auswählen."; return; }
var start = DateOnly.FromDateTime(_start.SelectedDate.Value.DateTime);
var end = DateOnly.FromDateTime(_end.SelectedDate.Value.DateTime);
if (start > end || start < _meta!.SchoolYearStart || end > _meta.SchoolYearEnd)
{ _status.Text = "Bitte einen gültigen Zeitraum innerhalb des Schuljahres auswählen."; return; }
_filters.IsEnabled = false;
_status.Text = "Offene Stunden werden geladen …";
try
{
var own = _mode.SelectedIndex == 0;
var periods = await _service.GetOpenPeriodsAsync(_yearId, own ? element.Id : null,
own ? null : element.Id, start, end, _closed.Token);
foreach (var period in periods)
_rows.Children.Add(new TextBlock
{
Text = $"{period.When}\n{period.Classes} · {period.Subject} · {period.Teachers}\n{period.Missing}",
TextWrapping = Avalonia.Media.TextWrapping.Wrap
});
_status.Text = periods.Count == 0 ? "Keine offenen Stunden im gewählten Zeitraum."
: $"{periods.Count} offene Stunde(n) · Stand {DateTime.Now:HH:mm}";
}
catch (OperationCanceledException) when (_closed.IsCancellationRequested) { }
catch (Exception ex) { _status.Text = $"Abruf fehlgeschlagen: {ex.Message}"; }
finally { _filters.IsEnabled = true; }
}
}
@@ -2,6 +2,7 @@
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="clr-namespace:LehrerApp.Desktop.ViewModels.Planning" xmlns:vm="clr-namespace:LehrerApp.Desktop.ViewModels.Planning"
xmlns:svc="clr-namespace:LehrerApp.Desktop.Services" xmlns:svc="clr-namespace:LehrerApp.Desktop.Services"
xmlns:conv="clr-namespace:LehrerApp.Desktop.Converters"
x:Class="LehrerApp.Desktop.Views.Planning.WebUntisTimetableImportDialog" x:Class="LehrerApp.Desktop.Views.Planning.WebUntisTimetableImportDialog"
x:DataType="vm:WebUntisTimetableImportViewModel" x:DataType="vm:WebUntisTimetableImportViewModel"
Title="Stundenplan aus WebUntis" Width="820" Height="650" Title="Stundenplan aus WebUntis" Width="820" Height="650"
@@ -18,7 +19,7 @@
<DataTemplate x:DataType="svc:UntisTeacherDto"><TextBlock Text="{Binding DisplayName}"/></DataTemplate> <DataTemplate x:DataType="svc:UntisTeacherDto"><TextBlock Text="{Binding DisplayName}"/></DataTemplate>
</ComboBox.ItemTemplate> </ComboBox.ItemTemplate>
</ComboBox> </ComboBox>
<CalendarDatePicker Grid.Column="1" SelectedDate="{Binding WeekDate}"/> <CalendarDatePicker Grid.Column="1" SelectedDate="{Binding WeekDate, Converter={x:Static conv:DateTimeOffsetToDateTimeConverter.Instance}}"/>
<Button Grid.Column="2" Content="Woche laden" Command="{Binding LoadCommand}" IsEnabled="{Binding !Busy}"/> <Button Grid.Column="2" Content="Woche laden" Command="{Binding LoadCommand}" IsEnabled="{Binding !Busy}"/>
</Grid> </Grid>
<ScrollViewer Grid.Row="2"> <ScrollViewer Grid.Row="2">
@@ -895,11 +895,15 @@
IsVisible="{Binding PairingStatus, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/> IsVisible="{Binding PairingStatus, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
</StackPanel> </StackPanel>
<TextBlock Text="Konflikte" FontSize="14" FontWeight="SemiBold" Margin="0,10,0,0" <Grid ColumnDefinitions="*,Auto" Margin="0,10,0,0" IsVisible="{Binding SyncConflicts.Count}">
IsVisible="{Binding SyncConflicts.Count}"/> <TextBlock Grid.Column="0" Text="Automatisch gelöste Konflikte" FontSize="14"
FontWeight="SemiBold" VerticalAlignment="Center"/>
<Button Grid.Column="1" Content="Protokoll leeren" FontSize="11" Padding="8,3"
Command="{Binding ClearSyncConflictsCommand}"/>
</Grid>
<TextBlock FontSize="12" Opacity="0.6" TextWrapping="Wrap" <TextBlock FontSize="12" Opacity="0.6" TextWrapping="Wrap"
IsVisible="{Binding SyncConflicts.Count}" IsVisible="{Binding SyncConflicts.Count}"
Text="Ein anderes Gerät hat dieselbe Änderung gleichzeitig gemacht — hier steht, welche Seite jeweils übernommen wurde."/> Text="Ein anderes Gerät hat dieselbe Änderung gleichzeitig gemacht. Der Konflikt ist bereits automatisch aufgelöst; hier steht nur das Ergebnis zur Kontrolle."/>
<ItemsControl ItemsSource="{Binding SyncConflicts}"> <ItemsControl ItemsSource="{Binding SyncConflicts}">
<ItemsControl.ItemTemplate> <ItemsControl.ItemTemplate>
<DataTemplate DataType="vm:SyncConflictListItem"> <DataTemplate DataType="vm:SyncConflictListItem">
@@ -0,0 +1,14 @@
<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
x:Class="LehrerApp.Desktop.Views.SplashWindow"
Title="LehrerPlaner Organizer wird geladen"
Width="450" Height="600"
CanResize="False"
ShowInTaskbar="False"
WindowDecorations="None"
WindowStartupLocation="CenterScreen">
<Image Source="/Assets/SplashScreen.png"
Stretch="UniformToFill"
HorizontalAlignment="Stretch"
VerticalAlignment="Stretch"/>
</Window>
@@ -0,0 +1,8 @@
using Avalonia.Controls;
namespace LehrerApp.Desktop.Views;
public partial class SplashWindow : Window
{
public SplashWindow() => InitializeComponent();
}
@@ -1,6 +1,7 @@
<Window xmlns="https://github.com/avaloniaui" <Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="clr-namespace:LehrerApp.Desktop.ViewModels.Students" xmlns:vm="clr-namespace:LehrerApp.Desktop.ViewModels.Students"
xmlns:conv="clr-namespace:LehrerApp.Desktop.Converters"
x:Class="LehrerApp.Desktop.Views.Students.CreateLetterDialog" x:Class="LehrerApp.Desktop.Views.Students.CreateLetterDialog"
x:DataType="vm:CreateLetterDialogViewModel" x:DataType="vm:CreateLetterDialogViewModel"
Title="PDF-Brief erstellen" Width="580" Height="760" Title="PDF-Brief erstellen" Width="580" Height="760"
@@ -40,7 +41,7 @@
</StackPanel> </StackPanel>
<StackPanel Grid.Column="2" Spacing="4"> <StackPanel Grid.Column="2" Spacing="4">
<TextBlock Text="Briefdatum *" FontSize="12" Opacity="0.7"/> <TextBlock Text="Briefdatum *" FontSize="12" Opacity="0.7"/>
<CalendarDatePicker SelectedDate="{Binding LetterDate}" HorizontalAlignment="Stretch"/> <CalendarDatePicker SelectedDate="{Binding LetterDate, Converter={x:Static conv:DateTimeOffsetToDateTimeConverter.Instance}}" HorizontalAlignment="Stretch"/>
</StackPanel> </StackPanel>
</Grid> </Grid>
<StackPanel Spacing="4"> <StackPanel Spacing="4">
@@ -1,6 +1,7 @@
<Window xmlns="https://github.com/avaloniaui" <Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="clr-namespace:LehrerApp.Desktop.ViewModels.Students" xmlns:vm="clr-namespace:LehrerApp.Desktop.ViewModels.Students"
xmlns:conv="clr-namespace:LehrerApp.Desktop.Converters"
x:Class="LehrerApp.Desktop.Views.Students.WebUntisDocumentationComparisonDialog" x:Class="LehrerApp.Desktop.Views.Students.WebUntisDocumentationComparisonDialog"
x:DataType="vm:WebUntisDocumentationComparisonViewModel" x:DataType="vm:WebUntisDocumentationComparisonViewModel"
Title="Klassenbucheinträge mit WebUntis abgleichen" Width="1150" Height="720" Title="Klassenbucheinträge mit WebUntis abgleichen" Width="1150" Height="720"
@@ -12,9 +13,9 @@
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."/> 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>
<StackPanel Grid.Row="1" Orientation="Horizontal" Spacing="8"> <StackPanel Grid.Row="1" Orientation="Horizontal" Spacing="8">
<CalendarDatePicker SelectedDate="{Binding StartDate}"/> <CalendarDatePicker SelectedDate="{Binding StartDate, Converter={x:Static conv:DateTimeOffsetToDateTimeConverter.Instance}}"/>
<TextBlock Text="bis" VerticalAlignment="Center"/> <TextBlock Text="bis" VerticalAlignment="Center"/>
<CalendarDatePicker SelectedDate="{Binding EndDate}"/> <CalendarDatePicker SelectedDate="{Binding EndDate, Converter={x:Static conv:DateTimeOffsetToDateTimeConverter.Instance}}"/>
<Button Content="Klassenbucheinträge laden" Command="{Binding LoadCommand}" IsEnabled="{Binding !Busy}"/> <Button Content="Klassenbucheinträge laden" Command="{Binding LoadCommand}" IsEnabled="{Binding !Busy}"/>
</StackPanel> </StackPanel>
@@ -0,0 +1,61 @@
<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="clr-namespace:LehrerApp.Desktop.ViewModels.UntisHub"
x:Class="LehrerApp.Desktop.Views.UntisHub.UntisHubDialog"
x:DataType="vm:UntisHubViewModel"
Title="Untis-Hub"
Width="900" Height="600" MinWidth="640" MinHeight="400"
WindowStartupLocation="CenterOwner">
<Window.Styles>
<Style Selector="TextBlock.duestatus">
<Setter Property="Foreground" Value="{DynamicResource AppStatusOkBrush}"/>
</Style>
<Style Selector="TextBlock.duestatus.warning">
<Setter Property="Foreground" Value="{DynamicResource AppStatusWarningBrush}"/>
</Style>
<Style Selector="TextBlock.duestatus.danger">
<Setter Property="Foreground" Value="{DynamicResource AppStatusDangerBrush}"/>
</Style>
</Window.Styles>
<DockPanel Margin="20">
<StackPanel DockPanel.Dock="Top" Spacing="10" Margin="0,0,0,14">
<Grid ColumnDefinitions="*,Auto">
<TextBlock Text="Untis-Hub" FontSize="22" FontWeight="SemiBold"/>
<Button Grid.Column="1" Content="Aktualisieren" Click="OnRefreshClick"/>
</Grid>
<TextBlock Text="{Binding Status}" FontSize="12" Opacity="0.7" TextWrapping="Wrap"/>
</StackPanel>
<DataGrid ItemsSource="{Binding Rows}" AutoGenerateColumns="False" IsReadOnly="True"
GridLinesVisibility="Horizontal" BorderBrush="{DynamicResource AppCardBorderBrush}"
BorderThickness="1" CanUserResizeColumns="True" CanUserSortColumns="True" RowHeight="46"
IsVisible="{Binding IsAvailable}">
<DataGrid.Columns>
<DataGridTextColumn Header="Bereich" Binding="{Binding GroupName}" Width="1.3*"/>
<DataGridTextColumn Header="Prüfung" Binding="{Binding JobLabel}" Width="1.3*"/>
<DataGridTemplateColumn Header="Status" Width="1.1*">
<DataGridTemplateColumn.CellTemplate>
<DataTemplate x:DataType="vm:UntisHubRowViewModel">
<TextBlock Text="{Binding DueLabel}" Classes="duestatus"
Classes.warning="{Binding IsWarning}" Classes.danger="{Binding IsDanger}"
VerticalAlignment="Center"/>
</DataTemplate>
</DataGridTemplateColumn.CellTemplate>
</DataGridTemplateColumn>
<DataGridTextColumn Header="Letztes Ergebnis" Binding="{Binding LastResultSummary}" Width="2*"/>
<DataGridTemplateColumn Header="" Width="130">
<DataGridTemplateColumn.CellTemplate>
<DataTemplate x:DataType="vm:UntisHubRowViewModel">
<Button Content="Jetzt prüfen" Click="OnCheckClick" DataContext="{Binding}"/>
</DataTemplate>
</DataGridTemplateColumn.CellTemplate>
</DataGridTemplateColumn>
</DataGrid.Columns>
</DataGrid>
<TextBlock IsVisible="{Binding !IsAvailable}" Text="WebUntis ist nicht konfiguriert. Bitte zuerst in den Einstellungen hinterlegen."
FontSize="14" Opacity="0.7" HorizontalAlignment="Center" VerticalAlignment="Center"/>
</DockPanel>
</Window>
@@ -0,0 +1,58 @@
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.UntisHub;
using Microsoft.Extensions.DependencyInjection;
namespace LehrerApp.Desktop.Views.UntisHub;
public partial class UntisHubDialog : Window
{
public UntisHubDialog()
{
InitializeComponent();
DataContext = new UntisHubViewModel(
App.Services.GetRequiredService<UntisHubService>(),
App.Services.GetRequiredService<IGroupRepository>(),
App.Services.GetRequiredService<WebUntisIntegrationService>());
}
private void OnRefreshClick(object? sender, RoutedEventArgs e)
{
if (DataContext is UntisHubViewModel vm) vm.Load();
}
private async void OnCheckClick(object? sender, RoutedEventArgs e)
{
if (DataContext is not UntisHubViewModel vm || sender is not Button { DataContext: UntisHubRowViewModel row })
return;
var hub = App.Services.GetRequiredService<UntisHubService>();
var today = DateOnly.FromDateTime(DateTime.Today);
switch (row.Kind)
{
case UntisHubJobKind.FehlzeitenKurz or UntisHubJobKind.FehlzeitenLang:
var group = row.GroupId is { } id ? vm.FindGroup(id) : null;
if (group is null) return;
var schoolYears = App.Services.GetRequiredService<SchoolYearService>();
var start = row.Kind == UntisHubJobKind.FehlzeitenKurz
? today.AddDays(-30)
: schoolYears.SchoolYearStart(schoolYears.CurrentSchoolYear());
await UntisHubActions.RunFehlzeitenAsync(this, group, row.Kind, start, today, hub);
break;
case UntisHubJobKind.OffenePeriods:
await UntisHubActions.RunOffenePeriodsAsync(this, hub);
break;
case UntisHubJobKind.Klassenbuchabgleich:
await UntisHubActions.RunKlassenbuchAsync(this, hub);
break;
case UntisHubJobKind.Hausaufgabenabgleich:
await UntisHubActions.RunHausaufgabenAsync(this, hub);
break;
}
vm.Load();
}
}
@@ -65,6 +65,30 @@
</StackPanel> </StackPanel>
</Border> </Border>
<Border Background="{DynamicResource SystemControlBackgroundAltHighBrush}" CornerRadius="8" Padding="16">
<StackPanel Spacing="10">
<TextBlock Text="Unterrichtszeit nacherfassen" FontWeight="SemiBold" FontSize="14"/>
<TextBlock Text="Arbeitstage der letzten 14 Tage, an denen laut Stundenplan Unterricht war, aber noch keine Unterrichtszeit erfasst wurde."
FontSize="12" Opacity="0.7" TextWrapping="Wrap"/>
<ItemsControl ItemsSource="{Binding MissingTeachingTimeEntries}">
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:TeachingTimeGapItem">
<Grid ColumnDefinitions="*,Auto,Auto" Margin="0,3">
<TextBlock Grid.Column="0" Text="{Binding DateDisplay}" FontSize="13" VerticalAlignment="Center"/>
<TextBlock Grid.Column="1" Text="{Binding TimeDisplay}" FontSize="12" Opacity="0.65"
Margin="12,0" VerticalAlignment="Center"/>
<Button Grid.Column="2" Content="Erfassen" FontSize="12" Padding="10,4"
Command="{Binding $parent[ItemsControl].((vm:TimeTrackingViewModel)DataContext).AddMissingTeachingTimeCommand}"
CommandParameter="{Binding}"/>
</Grid>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<TextBlock Text="Keine Unterrichtszeit offen." Opacity="0.6" FontSize="12"
IsVisible="{Binding !MissingTeachingTimeEntries.Count}"/>
</StackPanel>
</Border>
<!-- Einträge / Nacherfassung (6.2.2) --> <!-- Einträge / Nacherfassung (6.2.2) -->
<Grid ColumnDefinitions="*,Auto,Auto"> <Grid ColumnDefinitions="*,Auto,Auto">
<TextBlock Grid.Column="0" Text="Einträge" FontWeight="SemiBold" FontSize="14" VerticalAlignment="Center"/> <TextBlock Grid.Column="0" Text="Einträge" FontWeight="SemiBold" FontSize="14" VerticalAlignment="Center"/>
@@ -18,11 +18,13 @@ public partial class TimeTrackingView : UserControl
vm.OnAddEntry = () => ShowAddEntryDialog(); vm.OnAddEntry = () => ShowAddEntryDialog();
vm.OnSuggestTeachingTime = (start, end) => ShowAddEntryDialog(prefillCategory: "Unterricht", vm.OnSuggestTeachingTime = (start, end) => ShowAddEntryDialog(prefillCategory: "Unterricht",
prefillStart: start, prefillEnd: end); prefillStart: start, prefillEnd: end);
vm.OnAddMissingTeachingTime = item => ShowAddEntryDialog(prefillCategory: "Unterricht",
prefillDate: item.Date, prefillStart: item.WindowStart, prefillEnd: item.WindowEnd);
} }
} }
private async Task<TimeEntry?> ShowAddEntryDialog(string? prefillCategory = null, private async Task<TimeEntry?> ShowAddEntryDialog(string? prefillCategory = null,
TimeOnly? prefillStart = null, TimeOnly? prefillEnd = null) DateOnly? prefillDate = null, TimeOnly? prefillStart = null, TimeOnly? prefillEnd = null)
{ {
var owner = TopLevel.GetTopLevel(this) as Window; var owner = TopLevel.GetTopLevel(this) as Window;
if (owner is null) return null; if (owner is null) return null;
@@ -31,6 +33,7 @@ public partial class TimeTrackingView : UserControl
.GetAll().Where(t => t.Status != WorkTaskStatus.Done).ToList(); .GetAll().Where(t => t.Status != WorkTaskStatus.Done).ToList();
var vm = new AddTimeEntryDialogViewModel(tasks); var vm = new AddTimeEntryDialogViewModel(tasks);
if (prefillCategory is not null) vm.SelectedCategory = prefillCategory; if (prefillCategory is not null) vm.SelectedCategory = prefillCategory;
if (prefillDate is { } date) vm.DateText = date.ToString("dd.MM.yyyy");
if (prefillStart is { } s) vm.StartTimeText = s.ToString("HH:mm"); if (prefillStart is { } s) vm.StartTimeText = s.ToString("HH:mm");
if (prefillEnd is { } e) vm.EndTimeText = e.ToString("HH:mm"); if (prefillEnd is { } e) vm.EndTimeText = e.ToString("HH:mm");
var dialog = new AddTimeEntryDialog { DataContext = vm }; var dialog = new AddTimeEntryDialog { DataContext = vm };
+19
View File
@@ -33,6 +33,25 @@ public sealed class EventQueueTests
Assert.Null(exception); Assert.Null(exception);
} }
[Fact]
public void ClearConflicts_EntferntAlleKonflikte()
{
using var temp = new TempEventQueue();
temp.Queue.AddConflict(new ConflictEntry
{
LocalEvent = MakeEvent(), RemoteEvent = MakeEvent(), Resolution = "LocalWon",
});
temp.Queue.AddConflict(new ConflictEntry
{
LocalEvent = MakeEvent(), RemoteEvent = MakeEvent(), Resolution = "RemoteWon",
});
temp.Queue.ClearConflicts();
Assert.Empty(temp.Queue.GetUnreviewed());
Assert.Equal(0, temp.Queue.ConflictCount());
}
[Fact] [Fact]
public void GetKnownServerSeq_UnbekannteEntitaet_LiefertNull() public void GetKnownServerSeq_UnbekannteEntitaet_LiefertNull()
{ {
+1
View File
@@ -146,6 +146,7 @@ public class EventApplier(LiteDbContext db, byte[] syncKey, HttpClient? http = n
Simple<SupervisionDuty>(context => context.SupervisionDuties); Simple<SupervisionDuty>(context => context.SupervisionDuties);
Simple<SubstitutionEntry>(context => context.SubstitutionEntries); Simple<SubstitutionEntry>(context => context.SubstitutionEntries);
Simple<CompetencyDomain>(context => context.CompetencyDomains); Simple<CompetencyDomain>(context => context.CompetencyDomains);
Simple<Vorgang>(context => context.Vorgaenge);
// Kaskaden-Fälle: dieselben internen LiteDbContext-Hilfsmethoden wie die jeweiligen // Kaskaden-Fälle: dieselben internen LiteDbContext-Hilfsmethoden wie die jeweiligen
// Repositories, damit die Kaskade nur an einer Stelle im Code existiert. // Repositories, damit die Kaskade nur an einer Stelle im Code existiert.

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