Author SHA1 Message Date
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
53 changed files with 3179 additions and 123 deletions
+6 -5
View File
@@ -47,11 +47,12 @@
}
},
{
"name": "Desktop + API (gleichzeitig)",
"configurations": [
"Desktop App starten",
"API Server starten"
]
"name": "API Tests starten",
"type": "coreclr",
"request": "launch",
"preLaunchTask": "API bauen",
"program": "${workspaceFolder}/LehrerApp.Api.Tests/bin/Debug/net10.0/LehrerApp.Api.Tests.dll",
"args": [],
}
],
"compounds": [
@@ -200,6 +200,15 @@ public interface IDocumentationRepository
/// Entfernt den Eintrag endgültig — nur für die Löschfristen-Bereinigung (5.4.2).
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
{
List<WorkTask> GetByStatus(WorkTaskStatus status);
+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;
}
@@ -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);
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));
}
// ── 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 ──────────────────────────────────────────────────────
[Fact]
+1
View File
@@ -51,6 +51,7 @@ public class LiteDbContext : IDisposable
public ILiteCollection<Unit> Units => _db.GetCollection<Unit>("units");
public ILiteCollection<Lesson> Lessons => _db.GetCollection<Lesson>("lessons");
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 ILiteCollection<WorkTask> Tasks => _db.GetCollection<WorkTask>("tasks");
public ILiteCollection<TimeEntry> TimeEntries => _db.GetCollection<TimeEntry>("time_entries");
@@ -431,6 +431,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 List<WorkTask> GetByStatus(WorkTaskStatus s) =>
@@ -1,4 +1,5 @@
using LehrerApp.Core.Models;
using LehrerApp.Core.Services;
using LehrerApp.Desktop.Services;
using LehrerApp.Desktop.ViewModels.ClassTeacher;
using Xunit;
@@ -228,6 +229,198 @@ public sealed class ClassTeacherViewModelsTests
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]
public void RosterBuild_OrdnetJahresfehlzeitenAuchOhneExternKeyUeberNamenZu()
{
@@ -584,4 +777,306 @@ public sealed class ClassTeacherViewModelsTests
private static UntisStudentRosterCacheEntry Student(int? externKey, string 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));
}
// ── 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);
}
}
@@ -93,6 +93,30 @@ public class PlanningTabViewModelTests
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]
public async Task CopyUnit_ErhaeltAbstaendeUndSetztGroupIdAufZielgruppe()
{
@@ -294,4 +318,44 @@ public class PlanningTabViewModelTests
Assert.Single(sessions.GetByGroup(groupId));
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));
}
[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]
public void SelectOrCreateSessionForLesson_KeineSitzungVorhanden_LegtVerknuepfteAnUndWaehltSieAus()
{
+37 -21
View File
@@ -1,7 +1,9 @@
using Avalonia;
using Avalonia.Controls;
using Avalonia.Controls.ApplicationLifetimes;
using Avalonia.Markup.Xaml;
using Avalonia.Styling;
using Avalonia.Threading;
using LehrerApp.Core.Interfaces;
using LehrerApp.Core.Models;
using LehrerApp.Core.Services;
@@ -38,32 +40,45 @@ public class App : Application
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
{
// 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 = password =>
{
AppBootstrapper.DbPassword = password;
StartMainApp(desktop, showImmediately: true);
promptWindow.Close();
};
desktop.MainWindow = promptWindow;
}
else
{
StartMainApp(desktop, showImmediately: false);
}
// Erst den Splashscreen anzeigen und die synchrone Initialisierung in den ersten
// Dispatcher-Durchlauf verschieben, damit das Ladebild vorher gezeichnet wird.
var splash = new SplashWindow();
desktop.MainWindow = splash;
Dispatcher.UIThread.Post(
() => ContinueStartup(desktop, splash),
DispatcherPriority.Background);
}
base.OnFrameworkInitializationCompleted();
}
private static void ContinueStartup(
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 = password =>
{
AppBootstrapper.DbPassword = password;
StartMainApp(desktop, splash);
promptWindow.Close();
};
desktop.MainWindow = promptWindow;
promptWindow.Show();
splash.Close();
return;
}
StartMainApp(desktop, splash);
}
private static void StartMainApp(
IClassicDesktopStyleApplicationLifetime desktop, bool showImmediately)
IClassicDesktopStyleApplicationLifetime desktop, Window? windowToClose = null)
{
_serviceProvider = AppBootstrapper.BuildServices();
Services = _serviceProvider;
@@ -96,7 +111,8 @@ public class App : Application
if (Services.GetService<SyncEngine>() is { } syncEngine)
main.EnableFinalSync(syncEngine);
desktop.MainWindow = main;
if (showImmediately) main.Show();
main.Show();
windowToClose?.Close();
}
/// <summary>Wechselt die Darstellung sofort, ohne Neustart (12.4) — Avalonia stylt den
+3
View File
@@ -162,6 +162,7 @@ public static class AppBootstrapper
services.AddSingleton<IUnitRepository, UnitRepository>();
services.AddSingleton<ILessonRepository, LessonRepository>();
services.AddSingleton<IDocumentationRepository, DocumentationRepository>();
services.AddSingleton<IVorgangRepository, VorgangRepository>();
services.AddSingleton<IWorkTaskRepository, WorkTaskRepository>();
services.AddSingleton<ITimeEntryRepository, TimeEntryRepository>();
services.AddSingleton<IParticipationSessionRepository, ParticipationSessionRepository>();
@@ -195,6 +196,7 @@ public static class AppBootstrapper
services.AddSingleton(_ => new PeriodScheduleService(appData));
services.AddSingleton(_ => new WorkloadSettingsService(appData));
services.AddSingleton(_ => new DashboardSettingsService(appData));
services.AddSingleton(_ => new PatternScoreSettingsService(appData));
services.AddSingleton(_ => new WindowSettingsService(appData));
services.AddSingleton(_ => new AppearanceSettingsService(appData));
services.AddSingleton<PlanningExchangeService>();
@@ -315,6 +317,7 @@ public static class AppBootstrapper
services.AddSingleton<WorkloadEvaluationViewModel>();
services.AddSingleton<WorkloadViewModel>();
services.AddSingleton<ClassTeacherDetailsViewModel>();
services.AddSingleton<ClassTeacherCasesViewModel>();
services.AddSingleton<ClassTeacherOverviewViewModel>();
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;
}
@@ -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 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;
@@ -130,29 +133,63 @@ public sealed record ClassAbsenceDaySummaryRow(DateOnly Date, string StudentName
public partial class ClassTeacherDetailsViewModel : ObservableObject
{
private readonly UntisReportCacheService _cache;
private readonly IDocumentationRepository _documentation;
private readonly IStudentRepository _students;
private readonly ClassTeacherCasesViewModel _cases;
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<ClassAbsenceDaySummaryRow> AbsenceEntries { get; } = [];
public ObservableCollection<ClassTeacherCategoryAggregateRow> CategoryAggregates { get; } = [];
public ObservableCollection<DocumentationItem> OwnDocumentationEntries { get; } = [];
[ObservableProperty] private DateTimeOffset _startDate = DateTimeOffset.Now.AddDays(-6);
[ObservableProperty] private DateTimeOffset _endDate = DateTimeOffset.Now;
/// Für das Kontextmenü "→ An Vorgang anheften" (DataGrid.SelectedItem, zweigleisig gebunden).
[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 bool _busy;
/// Von der Übersicht gesetzt (Klick auf eine Roster-Zeile) - leer zeigt alle Schüler*innen.
[ObservableProperty] private string _studentFilter = "";
[ObservableProperty] private int _quickRangeIndex = 1;
/// 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 HasAbsenceEntries => AbsenceEntries.Count > 0;
public bool HasCategoryAggregates => CategoryAggregates.Count > 0;
public bool HasOwnDocumentationEntries => OwnDocumentationEntries.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)
? "Alle Schüler*innen" : StudentFilter;
public ClassTeacherDetailsViewModel(UntisReportCacheService cache)
public ClassTeacherDetailsViewModel(UntisReportCacheService cache, IDocumentationRepository documentation,
IStudentRepository students, ClassTeacherCasesViewModel cases)
{
_cache = cache;
_documentation = documentation;
_students = students;
_cases = cases;
}
public void Initialize(string className)
@@ -162,12 +199,16 @@ public partial class ClassTeacherDetailsViewModel : ObservableObject
Entries.Clear();
AbsenceEntries.Clear();
CategoryAggregates.Clear();
OwnDocumentationEntries.Clear();
_rosterMatches = [];
Status = "Zeitraum wählen und laden.";
NotifyListState();
}
partial void OnStudentFilterChanged(string value) => OnPropertyChanged(nameof(ActiveFilterLabel));
partial void OnShowOwnDocumentationChanged(bool value) => OnPropertyChanged(nameof(ShowCategoryAggregates));
partial void OnQuickRangeIndexChanged(int value)
{
var days = value switch { 0 => 0, 1 => 6, 2 => 29, _ => 6 };
@@ -178,6 +219,9 @@ public partial class ClassTeacherDetailsViewModel : ObservableObject
[RelayCommand]
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,
/// dass man sicher weiß, dass sich seit dem letzten automatischen Abruf etwas geändert hat.
[RelayCommand]
@@ -195,17 +239,20 @@ public partial class ClassTeacherDetailsViewModel : ObservableObject
private async Task LoadInternal(bool forceRefresh)
{
var start = DateOnly.FromDateTime(StartDate.LocalDateTime);
var end = DateOnly.FromDateTime(EndDate.LocalDateTime);
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; }
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
{
var classRegisterTask = _cache.GetClassRegisterEventsAsync(_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
.Where(MatchesStudentFilter)
@@ -223,14 +270,139 @@ public partial class ClassTeacherDetailsViewModel : ObservableObject
foreach (var row in ClassAbsenceDaySummaryRow.GroupByStudentAndDay(absences))
AbsenceEntries.Add(row);
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, " +
$"{AbsenceEntries.Count} Fehlzeiten-Tage im Zeitraum.";
$"{AbsenceEntries.Count} Fehlzeiten-Tage, {OwnDocumentationEntries.Count} eigene Dokumentation im Zeitraum.";
NotifyListState();
}
catch (WebUntisIntegrationException ex) { Status = ex.Message; }
finally { Busy = false; NotifyListState(); }
}
/// 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 = _rosterMatches.First(m => m.StudentId == d.StudentId).DisplayName;
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) && 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;
var name = rosterMatches.FirstOrDefault(m => m.StudentId == d.StudentId).DisplayName;
return name is not null && UntisNameMatching.NamesMatch(name, studentFilter);
}
[RelayCommand]
private async Task AddOwnDocumentation()
{
if (OnEditOwnDocumentation is null) 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
// StudentFilter beim Klick in der Übersicht gesetzt wird (siehe UntisNameMatching) - ein
// exakter String-Vergleich hier ließ die gefilterten Listen fälschlich leer erscheinen.
@@ -248,5 +420,8 @@ public partial class ClassTeacherDetailsViewModel : ObservableObject
OnPropertyChanged(nameof(HasEntries));
OnPropertyChanged(nameof(HasAbsenceEntries));
OnPropertyChanged(nameof(HasCategoryAggregates));
OnPropertyChanged(nameof(ShowCategoryAggregates));
OnPropertyChanged(nameof(HasOwnDocumentationEntries));
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>
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,
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
/// sagt für Zeugnis/Attestpflicht wenig aus). <see cref="SchoolDaysElapsed"/> zählt Werktage
/// 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 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; }
/// 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
@@ -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),
/// die Korrektur passiert also am Startpunkt, nicht an abgezogenen Tagen mittendrin.
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 =>
SchoolDaysElapsed <= 0 ? 0 : (int)Math.Round(100d * YearAbsenceDayCount / SchoolDaysElapsed);
public string YearSummaryLabel => HasYearSummary
? $"{YearAbsenceRatePercent} % Fehlzeit seit Schuljahresbeginn" : "";
public string YearSummaryLabel => !HasYearSummary ? "" :
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 :
$"{YearAbsenceDayCount} von {SchoolDaysElapsed} Schultagen seit {TermStart:dd.MM.} mit Fehlzeit" +
(YearUnexcusedDayCount > 0 ? $" · {YearUnexcusedDayCount} unentschuldigt" : "") +
(YearAbsenceDayCount > 0
? $"{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" : "");
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) =>
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(
IReadOnlyList<UntisStudentRosterCacheEntry> students,
IReadOnlyList<ClassAbsenceDaySummaryRow> todayAbsences,
@@ -106,8 +252,12 @@ public sealed record ClassTeacherRosterRow(string StudentName, int? ExternKey, b
IReadOnlyList<ClassAbsenceDaySummaryRow>? yearAbsences = null,
int schoolDaysElapsed = 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 absenceByKey = todayAbsences.Where(a => a.ExternKey is not null)
.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());
var recentByName = recentClassRegisterEntries.GroupBy(e => UntisNameMatching.NameKey(e.StudentName))
.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 yearByKey = yearRows.Where(a => a.ExternKey is not null)
.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 yearEntries = (student.ExternKey is { } yearKey ? yearByKey.GetValueOrDefault(yearKey) : null)
?? 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,
absence is null ? null : $"{absence.TotalAbsentPeriods} Stunde(n) — {absence.StatusLabel}",
registerEntries.Count > 0)
{
TodayAbsence = absence,
HasClassRegisterToday = registerEntries.Any(e => TryDate(e.Date, out var date) && date == referenceDate),
YearAbsenceDayCount = yearEntries.Count,
// 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),
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,
HolidayWeekdaysExcluded = holidayWeekdaysExcluded,
TermStart = termStart,
@@ -214,11 +400,15 @@ public partial class ClassTeacherOverviewViewModel : ObservableObject
private readonly SchoolYearService _schoolYear;
private readonly IWorkTaskRepository _workTasks;
private readonly IStudentRepository _students;
private readonly IDocumentationRepository _documentation;
private readonly IVorgangRepository _vorgaenge;
private readonly PatternScoreSettingsService _patternScoreSettings;
private readonly IParticipationRepository _participation;
private readonly IParticipationSessionRepository _participationSessions;
private readonly AppLogger? _logger;
public ClassTeacherDetailsViewModel DetailsTab { get; }
public ClassTeacherCasesViewModel CasesTab { get; }
public ObservableCollection<ClassTeacherRosterRow> Roster { get; } = [];
public ObservableCollection<ClassTeacherRosterRow> PrimaryRoster { get; } = [];
public ObservableCollection<ClassTeacherRosterRow> SecondaryRoster { get; } = [];
@@ -231,7 +421,11 @@ public partial class ClassTeacherOverviewViewModel : ObservableObject
[ObservableProperty] private string _status = "";
[ObservableProperty] private bool _busy;
[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 _secondarySectionTitle = "Weitere Schüler*innen";
[ObservableProperty] private int _studentCount;
@@ -244,6 +438,19 @@ public partial class ClassTeacherOverviewViewModel : ObservableObject
[ObservableProperty] private int _unexcusedAbsenceCount;
[ObservableProperty] private string _lastUpdatedLabel = "Noch nicht aktualisiert";
[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 HasPrimaryRoster => PrimaryRoster.Count > 0;
@@ -252,9 +459,11 @@ public partial class ClassTeacherOverviewViewModel : ObservableObject
public bool HasPatternNotices => PatternNotices.Count > 0;
public bool HasOpenExcuses => OpenExcuses.Count > 0;
public bool HasOpenExcuseOverflow => OpenExcuseOverflowCount > 0;
public bool HasOwnDocumentationAlerts => OwnDocumentationFollowUpCount > 0 || OwnDocumentationCriticalCount > 0;
public bool AlertsFilterSelected => SelectedRosterFilter == 0;
public bool ClassRegisterFilterSelected => SelectedRosterFilter == 1;
public bool AllFilterSelected => SelectedRosterFilter == 2;
public bool PatternScoreFilterSelected => SelectedRosterFilter == 3;
public int TodayUnexcusedPercent => Percent(TodayUnexcusedCount);
public int LatePercent => Percent(LateCount);
public int PresentPercent => Percent(PresentCount);
@@ -289,9 +498,10 @@ public partial class ClassTeacherOverviewViewModel : ObservableObject
public ClassTeacherOverviewViewModel(WebUntisSettingsService settings, WebUntisIntegrationService untis,
UntisReportCacheService cache, SchoolYearService schoolYear, IWorkTaskRepository workTasks,
IStudentRepository students, IParticipationRepository participation,
IStudentRepository students, IDocumentationRepository documentation, IVorgangRepository vorgaenge,
PatternScoreSettingsService patternScoreSettings, IParticipationRepository participation,
IParticipationSessionRepository participationSessions, ClassTeacherDetailsViewModel detailsTab,
AppLogger? logger = null)
ClassTeacherCasesViewModel casesTab, AppLogger? logger = null)
{
_settings = settings;
_untis = untis;
@@ -299,30 +509,65 @@ public partial class ClassTeacherOverviewViewModel : ObservableObject
_schoolYear = schoolYear;
_workTasks = workTasks;
_students = students;
_documentation = documentation;
_vorgaenge = vorgaenge;
_patternScoreSettings = patternScoreSettings;
_participation = participation;
_participationSessions = participationSessions;
_logger = logger;
DetailsTab = detailsTab;
CasesTab = casesTab;
}
partial void OnHomeroomClassNameChanged(string? value) => OnPropertyChanged(nameof(HomeroomClassConfigured));
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 OnSelectedRosterFilterChanged(int value)
{
OnPropertyChanged(nameof(AlertsFilterSelected));
OnPropertyChanged(nameof(ClassRegisterFilterSelected));
OnPropertyChanged(nameof(AllFilterSelected));
OnPropertyChanged(nameof(PatternScoreFilterSelected));
ApplyRosterFilter();
}
private void ResetRosterState()
{
ActiveTabIndex = 0;
Roster.Clear(); PrimaryRoster.Clear(); SecondaryRoster.Clear(); TrendDays.Clear(); PatternNotices.Clear();
OpenExcuses.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]
private async Task Load()
{
HomeroomClassName = _settings.HomeroomClassName;
ActiveTabIndex = 0;
Roster.Clear(); PrimaryRoster.Clear(); SecondaryRoster.Clear(); TrendDays.Clear(); PatternNotices.Clear();
OpenExcuses.Clear(); OpenExcuseOverflowCount = 0;
ResetRosterState();
if (!HomeroomClassConfigured)
{
Status = "Noch keine Klasse ausgewählt.";
@@ -332,59 +577,120 @@ public partial class ClassTeacherOverviewViewModel : ObservableObject
var className = HomeroomClassName!;
DetailsTab.Initialize(className);
CasesTab.Initialize(className);
Busy = true;
NotifyRosterState();
try
{
var today = DateOnly.FromDateTime(DateTime.Today);
var sevenDayStart = today.AddDays(-6);
var trendDays = LastSchoolDays(today, 7);
// Fehlzeiten seit Schuljahresbeginn statt nur der letzten 7 Tage: liefert die
// Kennzahl unten (YearSummaryLabel) und den Nenner für die Fehlquote in einem Abruf.
// UntisReportCacheService cached diesen "kalten" Bereich dauerhaft (siehe dort) —
// teuer ist nur der erste Abruf pro Schuljahr, nicht jedes Öffnen der Ansicht.
var yearStart = _schoolYear.SchoolYearStart(_schoolYear.CurrentSchoolYear(today));
var (today, yearStart, sevenDayStart, trendDays) = RosterDateWindow();
var studentsTask = _cache.GetStudentRosterAsync(className);
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();
await Task.WhenAll(studentsTask, absencesTask, classRegisterTask, holidaysTask);
var absenceDaysYear = ClassAbsenceDaySummaryRow.GroupByStudentAndDay(absencesTask.Result);
var todayAbsences = absenceDaysYear.Where(a => a.Date == today).ToList();
var termStart = EstimateTermStart(absenceDaysYear, yearStart);
var rawWeekdaysElapsed = CountSchoolWeekdays(termStart, today, []);
var schoolDaysElapsed = CountSchoolWeekdays(termStart, today, holidaysTask.Result);
var holidayWeekdaysExcluded = rawWeekdaysElapsed - schoolDaysElapsed;
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);
_lastStudents = studentsTask.Result;
_lastAbsences = absencesTask.Result;
_lastClassRegisterEvents = classRegisterTask.Result;
_lastHolidays = holidaysTask.Result;
BuildRosterFromCachedData(studentsTask.Result, absencesTask.Result, classRegisterTask.Result,
holidaysTask.Result, today, yearStart, sevenDayStart, trendDays);
LastUpdatedLabel = $"Zuletzt aktualisiert: Heute, {DateTime.Now:HH:mm}";
Status = $"{StudentCount} Schüler*innen · {TodayAlertCount} heute auffällig";
ApplyRosterFilter();
NotifySummary();
}
catch (WebUntisIntegrationException ex) { Status = ex.Message; NotifyRosterState(); }
finally { Busy = false; NotifyRosterState(); }
}
// 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);
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 ShowClassRegister() => SelectedRosterFilter = 1;
[RelayCommand] private void ShowAll() => SelectedRosterFilter = 2;
[RelayCommand] private void ShowPatternScore() => SelectedRosterFilter = 3;
[RelayCommand]
private void OpenClassRegister()
@@ -432,6 +738,18 @@ public partial class ClassTeacherOverviewViewModel : ObservableObject
PrimarySectionTitle = "Klassenbucheinträge der letzten 7 Tage";
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
{
PrimarySectionTitle = "Heute auffällig";
@@ -464,6 +782,9 @@ public partial class ClassTeacherOverviewViewModel : ObservableObject
(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
/// Ferienkalender (bewusste Vereinfachung, siehe TODO.md 12.4-Nachtrag) — die App kennt keine
/// Schulferien, nur Wochenenden.
@@ -687,6 +1008,44 @@ public partial class ClassTeacherOverviewViewModel : ObservableObject
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>
/// statt <see cref="Student.FullName"/>, weil dessen "Nachname, Vorname"-Format mit Komma den
/// 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.";
}
}
@@ -5,6 +5,7 @@ using LehrerApp.Core.Interfaces;
using LehrerApp.Core.Models;
using LehrerApp.Core.Services;
using LehrerApp.Desktop.Services;
using LehrerApp.Desktop.ViewModels.Planning;
using LehrerApp.Desktop.ViewModels.Students;
using System.Collections.ObjectModel;
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<LessonSummary, Task<bool>>? OnConfirmDeleteLesson { 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<Unit, Task<LessonSeriesResult?>>? OnGenerateLessonSeries { get; set; }
public Func<Unit, Task<bool>>? OnAiAssist { get; set; }
@@ -160,6 +162,7 @@ public partial class PlanningTabViewModel : ObservableObject
EditLessonCommand.NotifyCanExecuteChanged();
DeleteLessonCommand.NotifyCanExecuteChanged();
MoveLessonCommand.NotifyCanExecuteChanged();
ChangeLessonUnitCommand.NotifyCanExecuteChanged();
AdvanceLessonStatusCommand.NotifyCanExecuteChanged();
}
@@ -349,6 +352,23 @@ public partial class PlanningTabViewModel : ObservableObject
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))]
private void AdvanceLessonStatus()
{
@@ -364,16 +384,25 @@ public partial class PlanningTabViewModel : ObservableObject
/// Ü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
/// 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))]
private void CreateParticipationSession()
{
if (SelectedLesson is null) return;
var lesson = SelectedLesson.Model;
var existing = _participationSessions.GetByGroup(lesson.GroupId)
.FirstOrDefault(s => s.LessonId == lesson.Id);
var sessionsForGroup = _participationSessions.GetByGroup(lesson.GroupId);
var existing = sessionsForGroup.FirstOrDefault(s => s.LessonId == lesson.Id)
?? sessionsForGroup.FirstOrDefault(s => s.Date == lesson.Date);
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;
}
@@ -1197,6 +1226,38 @@ 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: Stunden serienweise aus dem Stundenplan erzeugen (4.2.5) ────────
public partial class GenerateLessonSeriesDialogViewModel : ObservableObject
@@ -141,10 +141,18 @@ public partial class SeatingPlanTabViewModel : ObservableObject
/// erlaubt: welche Stunde gemeint ist, steht durch die explizite Auswahl der Lehrkraft
/// (Klick auf "Unterrichtsmodus starten" für genau diese Stunde) bereits unzweideutig fest -
/// 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>
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 (!IsEditable) return;
@@ -48,8 +48,8 @@ public partial class WebUntisLessonAbsenceComparisonViewModel : ObservableObject
new Dictionary<DateOnly, ParticipationSession>();
public ObservableCollection<WebUntisLessonAbsenceRow> Rows { get; } = [];
[ObservableProperty] private DateTimeOffset _startDate = DateTimeOffset.Now.AddMonths(-2);
[ObservableProperty] private DateTimeOffset _endDate = DateTimeOffset.Now;
[ObservableProperty] private DateTimeOffset? _startDate = DateTimeOffset.Now.AddMonths(-2);
[ObservableProperty] private DateTimeOffset? _endDate = DateTimeOffset.Now;
[ObservableProperty] private string _status = "Zeitraum wählen und Fehlzeiten laden.";
[ObservableProperty] private bool _busy;
[ObservableProperty] private bool _markUnknownAsPresent;
@@ -73,8 +73,8 @@ public partial class WebUntisLessonAbsenceComparisonViewModel : ObservableObject
return;
}
var start = DateOnly.FromDateTime(StartDate.LocalDateTime);
var end = DateOnly.FromDateTime(EndDate.LocalDateTime);
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
@@ -90,7 +90,10 @@ public partial class MainWindowViewModel : ObservableObject
case TimetableViewModel vm: vm.Load(); break;
case WorkloadViewModel vm:
vm.Tasks.Load(); vm.TimeTracking.Load(); vm.Evaluation.Load(); break;
case ClassTeacherOverviewViewModel vm: vm.LoadCommand.Execute(null); break;
// 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 GroupDetailViewModel { Group: { } group } vm: vm.LoadGroup(group.Id); break;
// Inline-Bearbeitung (IsEditing) nicht überschreiben - anders als die Gruppenansicht
@@ -176,7 +179,11 @@ public partial class MainWindowViewModel : ObservableObject
var vm = _services.GetRequiredService<ClassTeacherOverviewViewModel>();
vm.OnNavigateToSettings = () => { NavigateToSettings(SettingsTab.WebUntis); 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;
}
@@ -37,7 +37,7 @@ public partial class WebUntisTimetableImportViewModel : ObservableObject
public ObservableCollection<UntisTeacherDto> Teachers { get; } = [];
public ObservableCollection<WebUntisTimetableRow> Rows { get; } = [];
[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 bool _busy;
public bool Saved { get; private set; }
@@ -71,7 +71,7 @@ public partial class WebUntisTimetableImportViewModel : ObservableObject
Busy = true; Rows.Clear();
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 periods = await _untis.GetTimetableAsync(SelectedTeacher.UntisId, monday, monday.AddDays(6));
var grid = await _untis.GetTimeGridAsync();
@@ -58,8 +58,8 @@ public partial class WebUntisDocumentationComparisonViewModel : ObservableObject
public ObservableCollection<WebUntisDocumentationRow> Rows { get; } = [];
public ObservableCollection<LocalOnlyDocumentationRow> LocalOnlyRows { get; } = [];
[ObservableProperty] private DateTimeOffset _startDate = DateTimeOffset.Now.AddDays(-7);
[ObservableProperty] private DateTimeOffset _endDate = DateTimeOffset.Now;
[ObservableProperty] private 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;
@@ -74,8 +74,8 @@ public partial class WebUntisDocumentationComparisonViewModel : ObservableObject
[RelayCommand]
private async Task Load()
{
var start = DateOnly.FromDateTime(StartDate.LocalDateTime);
var end = DateOnly.FromDateTime(EndDate.LocalDateTime);
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(); LocalOnlyRows.Clear();
try
@@ -1,6 +1,7 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="clr-namespace:LehrerApp.Desktop.ViewModels.ClassTeacher"
xmlns:conv="clr-namespace:LehrerApp.Desktop.Converters"
x:Class="LehrerApp.Desktop.Views.ClassTeacher.ClassTeacherAbsencesView"
x:DataType="vm:ClassTeacherDetailsViewModel">
@@ -45,9 +46,9 @@
<ComboBoxItem Content="Heute"/><ComboBoxItem Content="Letzte 7 Tage"/><ComboBoxItem Content="Letzte 30 Tage"/>
</ComboBox>
<TextBlock Grid.Column="1" Text="von" VerticalAlignment="Center" Opacity="0.6"/>
<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"/>
<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.Row="1" ColumnDefinitions="*,Auto,Auto" ColumnSpacing="8">
<TextBox Grid.Column="0" Text="{Binding StudentFilter, Mode=TwoWay}" PlaceholderText="Schüler*in filtern…"/>
@@ -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;
}
}
@@ -227,14 +227,26 @@
<Border Grid.Row="0" Background="{DynamicResource AppCardBackgroundBrush}"
BorderBrush="{DynamicResource AppCardBorderBrush}" BorderThickness="1"
CornerRadius="8" Padding="8" Margin="0,0,0,4">
<Grid ColumnDefinitions="Auto,Auto,Auto,*,250" ColumnSpacing="8">
<Grid ColumnDefinitions="Auto,Auto,Auto,Auto,Auto,*,250" ColumnSpacing="8">
<Button Grid.Column="0" Classes="filter" Classes.active="{Binding AlertsFilterSelected}"
Content="Auffällig" Command="{Binding ShowAlertsCommand}"/>
<Button Grid.Column="1" Classes="filter" Classes.active="{Binding ClassRegisterFilterSelected}"
Content="Klassenbuch" Command="{Binding ShowClassRegisterCommand}"/>
<Button Grid.Column="2" Classes="filter" Classes.active="{Binding AllFilterSelected}"
Content="Alle" Command="{Binding ShowAllCommand}"/>
<TextBox Grid.Column="4" Text="{Binding SearchText, Mode=TwoWay}" PlaceholderText="Schüler*in suchen…"
<!-- 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"/>
</Grid>
</Border>
@@ -464,6 +476,22 @@
<StackPanel Spacing="8">
<TextBlock Text="Nächste Schritte" FontSize="14" FontWeight="SemiBold"/>
<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="Aufgaben &amp; Wiedervorlagen" Command="{Binding GoToWorkloadCommand}"
HorizontalAlignment="Stretch" Background="Transparent"/>
@@ -482,6 +510,9 @@
<ContentPage Header="Fehlzeiten">
<views:ClassTeacherAbsencesView DataContext="{Binding DetailsTab}"/>
</ContentPage>
<ContentPage Header="Vorgänge">
<views:ClassTeacherCasesView DataContext="{Binding CasesTab}"/>
</ContentPage>
</TabbedPage>
</Grid>
</UserControl>
@@ -1,8 +1,26 @@
using Avalonia.Controls;
using Avalonia.Interactivity;
using LehrerApp.Core.Services;
using LehrerApp.Desktop.ViewModels.ClassTeacher;
using Microsoft.Extensions.DependencyInjection;
namespace LehrerApp.Desktop.Views.ClassTeacher;
public partial class ClassTeacherOverviewView : UserControl
{
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"
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"
xmlns:conv="clr-namespace:LehrerApp.Desktop.Converters"
x:Class="LehrerApp.Desktop.Views.ClassTeacher.ClassTeacherRegisterView"
x:DataType="vm:ClassTeacherDetailsViewModel">
@@ -26,28 +28,86 @@
<Style Selector="TextBlock.status.danger">
<Setter Property="Foreground" Value="{DynamicResource AppStatusDangerBrush}"/>
</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>
<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">
<StackPanel>
<TextBlock Text="Klassenbucheinträge" FontSize="22" FontWeight="SemiBold"/>
<TextBlock Text="Einträge anderer Lehrkräfte nur zur Ansicht" FontSize="12" Opacity="0.55"/>
<TextBlock Text="Klassenbuch" FontSize="22" FontWeight="SemiBold"/>
<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>
<Button Grid.Column="1" Content="↻ Aus WebUntis aktualisieren" Command="{Binding RefreshCommand}"
IsEnabled="{Binding !Busy}" Background="Transparent" Foreground="{DynamicResource AppAccentTextBrush}" VerticalAlignment="Center"/>
</Grid>
<Border Grid.Row="1" Classes="filterCard">
<!-- 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 Grid.Row="0" ColumnDefinitions="155,Auto,*,Auto,*" ColumnSpacing="8">
<ComboBox Grid.Column="0" SelectedIndex="{Binding QuickRangeIndex, Mode=TwoWay}">
<ComboBoxItem Content="Heute"/><ComboBoxItem Content="Letzte 7 Tage"/><ComboBoxItem Content="Letzte 30 Tage"/>
</ComboBox>
<TextBlock Grid.Column="1" Text="von" VerticalAlignment="Center" Opacity="0.6"/>
<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"/>
<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.Row="1" ColumnDefinitions="*,Auto,Auto" ColumnSpacing="8">
<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
interessante Information. Als Chip-Reihe statt eigener Spalte in der Tabelle, damit die
bestehenden Spalten unangetastet bleiben. -->
<Border Grid.Row="2" Classes="filterCard" IsVisible="{Binding HasCategoryAggregates}">
<Border Grid.Row="3" Classes="filterCard" IsVisible="{Binding ShowCategoryAggregates}">
<StackPanel Spacing="6">
<TextBlock Text="Kategorien im Zeitraum" FontSize="12" FontWeight="SemiBold" Opacity="0.7"/>
<ItemsControl ItemsSource="{Binding CategoryAggregates}">
@@ -86,8 +146,9 @@
</StackPanel>
</Border>
<Grid Grid.Row="3">
<DataGrid ItemsSource="{Binding Entries}" AutoGenerateColumns="False" IsReadOnly="True"
<Grid Grid.Row="4" IsVisible="{Binding !ShowOwnDocumentation}">
<DataGrid ItemsSource="{Binding Entries}" SelectedItem="{Binding SelectedEntry, Mode=TwoWay}"
AutoGenerateColumns="False" IsReadOnly="True"
GridLinesVisibility="Horizontal" BorderBrush="{DynamicResource AppCardBorderBrush}" BorderThickness="1"
CanUserResizeColumns="True" CanUserSortColumns="True" RowHeight="46">
<DataGrid.Columns>
@@ -99,12 +160,89 @@
<DataGridTextColumn Header="Gruppe" Binding="{Binding CategoryGroup}" Width="0.8*"/>
<DataGridTextColumn Header="Eintrag" Binding="{Binding Text}" Width="2*"/>
</DataGrid.Columns>
<DataGrid.ContextMenu>
<ContextMenu>
<MenuItem Header="→ An Vorgang anheften" Command="{Binding PinToVorgangCommand}"
CommandParameter="{Binding SelectedEntry}"/>
</ContextMenu>
</DataGrid.ContextMenu>
</DataGrid>
<StackPanel IsVisible="{Binding !HasEntries}" HorizontalAlignment="Center" VerticalAlignment="Center" Spacing="5">
<TextBlock Text="Keine Klassenbucheinträge im gewählten Zeitraum" FontWeight="SemiBold"/>
<TextBlock Text="Passe Zeitraum oder Schülerfilter an." FontSize="12" Opacity="0.55"/>
</StackPanel>
</Grid>
<TextBlock Grid.Row="4" Text="{Binding Status}" FontSize="11" Opacity="0.6"/>
<Grid Grid.Row="4" IsVisible="{Binding ShowOwnDocumentation}" RowDefinitions="Auto,*">
<Button Grid.Row="0" Content=" Eintrag" Command="{Binding AddOwnDocumentationCommand}"
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>
</UserControl>
@@ -1,8 +1,65 @@
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 ClassTeacherRegisterView : UserControl
{
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);
}
@@ -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);
}
@@ -91,6 +91,8 @@
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="Verschieben" Command="{Binding MoveLessonCommand}" IsEnabled="{Binding !IsReadOnly}"/>
<Button Content="Einheit wechseln" Command="{Binding ChangeLessonUnitCommand}" IsEnabled="{Binding !IsReadOnly}"
ToolTip.Tip="Ordnet die ausgewählte Stunde einer anderen Einheit derselben Gruppe zu."/>
<Button Content="Status weiter" Command="{Binding AdvanceLessonStatusCommand}" IsEnabled="{Binding !IsReadOnly}"
ToolTip.Tip="Entwurf/Geplant → Bereit → Durchgeführt"/>
<Button Content="Sitzung erzeugen" Command="{Binding CreateParticipationSessionCommand}"
@@ -38,6 +38,7 @@ public partial class PlanningTabView : UserControl
ShowLessonDialog(lesson.UnitId, lesson.GroupId, materials, shorthands, editingLesson: lesson);
vm.OnConfirmDeleteLesson = ShowDeleteLessonDialog;
vm.OnPickMoveTarget = ShowMoveLessonDialog;
vm.OnPickUnitChange = ShowChangeLessonUnitDialog;
vm.OnShowLesson = ShowLessonViewerDialog;
vm.OnGenerateLessonSeries = ShowGenerateLessonSeriesDialog;
vm.OnAiAssist = ShowAiAssistDialog;
@@ -136,6 +137,20 @@ public partial class PlanningTabView : UserControl
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)
{
var viewerVm = new LessonViewerViewModel(lesson,
@@ -1,6 +1,7 @@
<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.WebUntisLessonAbsenceComparisonDialog"
x:DataType="vm:WebUntisLessonAbsenceComparisonViewModel"
Title="Fehlzeiten je Unterricht mit WebUntis abgleichen" Width="1000" Height="640"
@@ -12,9 +13,9 @@
FontSize="12" Opacity="0.65" TextWrapping="Wrap"/>
</StackPanel>
<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"/>
<DatePicker SelectedDate="{Binding EndDate}"/>
<CalendarDatePicker SelectedDate="{Binding EndDate, Converter={x:Static conv:DateTimeOffsetToDateTimeConverter.Instance}}"/>
<Button Content="Fehlzeiten laden" Command="{Binding LoadCommand}" IsEnabled="{Binding !Busy}"/>
</StackPanel>
<Grid Grid.Row="2" ColumnDefinitions="Auto,1.2*,1.2*,80,80,1.1*,1.1*" ColumnSpacing="8" Margin="4,0">
@@ -1,6 +1,7 @@
<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.WithdrawStudentDialog"
x:DataType="vm:WithdrawStudentDialogViewModel"
Title="Schüler austragen"
@@ -23,8 +24,8 @@
<StackPanel Spacing="5">
<TextBlock Text="Austrittsdatum" FontSize="12" Opacity="0.7"/>
<CalendarDatePicker SelectedDate="{Binding SelectedDate}"
DisplayDateStart="{Binding EarliestDate}"
<CalendarDatePicker SelectedDate="{Binding SelectedDate, Converter={x:Static conv:DateTimeOffsetToDateTimeConverter.Instance}}"
DisplayDateStart="{Binding EarliestDate, Converter={x:Static conv:DateTimeOffsetToDateTimeConverter.Instance}}"
HorizontalAlignment="Stretch"/>
<TextBlock Text="{Binding DateError}" Foreground="Red" FontSize="12"
IsVisible="{Binding DateError, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
@@ -2,6 +2,7 @@
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="clr-namespace:LehrerApp.Desktop.ViewModels.Planning"
xmlns:svc="clr-namespace:LehrerApp.Desktop.Services"
xmlns:conv="clr-namespace:LehrerApp.Desktop.Converters"
x:Class="LehrerApp.Desktop.Views.Planning.WebUntisTimetableImportDialog"
x:DataType="vm:WebUntisTimetableImportViewModel"
Title="Stundenplan aus WebUntis" Width="820" Height="650"
@@ -18,7 +19,7 @@
<DataTemplate x:DataType="svc:UntisTeacherDto"><TextBlock Text="{Binding DisplayName}"/></DataTemplate>
</ComboBox.ItemTemplate>
</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}"/>
</Grid>
<ScrollViewer Grid.Row="2">
@@ -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"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="clr-namespace:LehrerApp.Desktop.ViewModels.Students"
xmlns:conv="clr-namespace:LehrerApp.Desktop.Converters"
x:Class="LehrerApp.Desktop.Views.Students.CreateLetterDialog"
x:DataType="vm:CreateLetterDialogViewModel"
Title="PDF-Brief erstellen" Width="580" Height="760"
@@ -40,7 +41,7 @@
</StackPanel>
<StackPanel Grid.Column="2" Spacing="4">
<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>
</Grid>
<StackPanel Spacing="4">
@@ -1,6 +1,7 @@
<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="clr-namespace:LehrerApp.Desktop.ViewModels.Students"
xmlns:conv="clr-namespace:LehrerApp.Desktop.Converters"
x:Class="LehrerApp.Desktop.Views.Students.WebUntisDocumentationComparisonDialog"
x:DataType="vm:WebUntisDocumentationComparisonViewModel"
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."/>
</StackPanel>
<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"/>
<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}"/>
</StackPanel>
+1
View File
@@ -146,6 +146,7 @@ public class EventApplier(LiteDbContext db, byte[] syncKey, HttpClient? http = n
Simple<SupervisionDuty>(context => context.SupervisionDuties);
Simple<SubstitutionEntry>(context => context.SubstitutionEntries);
Simple<CompetencyDomain>(context => context.CompetencyDomains);
Simple<Vorgang>(context => context.Vorgaenge);
// Kaskaden-Fälle: dieselben internen LiteDbContext-Hilfsmethoden wie die jeweiligen
// Repositories, damit die Kaskade nur an einer Stelle im Code existiert.
+59 -4
View File
@@ -10,12 +10,26 @@ public sealed class WebUntisClient : IAsyncDisposable
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
private static readonly UTF8Encoding StrictUtf8 = new(false, true);
private static readonly TimeSpan LogoutTimeout = TimeSpan.FromSeconds(2);
// Feste (nicht zufällige) kurze Pause zwischen Login und erstem Report-Request, damit beides
// nicht im Millisekundenabstand hintereinander passiert.
private static readonly TimeSpan PostLoginDelay = TimeSpan.FromSeconds(1);
// WebUntis blockt Anfragen ohne die Header, die die eigene WebApp immer mitschickt, inzwischen
// serverseitig mit HTTP 403. Header-Satz per "Copy as cURL" aus einem echten Browser-Request
// 1:1 übernommen (kein X-Requested-With, WebUntis' Angular-Frontend sendet das nicht).
private const string BrowserUserAgent =
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.6.2 Safari/605.1.15";
private readonly HttpClient _http;
private readonly WebUntisOptions _options;
private readonly SemaphoreSlim _sessionGate = new(1, 1);
private readonly Timer _sessionExpiryTimer;
private string? _sessionId;
private int? _myTeacherUntisId;
// Cookies, die WebUntis beim Login per Set-Cookie mitschickt (z.B. Tenant-Id, traceId) und die
// die WebApp bei jedem weiteren Request 1:1 zurücksendet. JSESSIONID wird separat verwaltet
// (siehe _sessionId), deshalb hier ausgeklammert.
private string? _serverCookies;
private string? _tenantId;
private DateTimeOffset _sessionExpiresAt;
private int _activeRequests;
private bool _disposed;
@@ -330,13 +344,14 @@ public sealed class WebUntisClient : IAsyncDisposable
user = configuration.Username,
password = configuration.Password,
client = configuration.Client,
}, null, cancellationToken);
}, null, cancellationToken, CaptureServerCookies);
_sessionId = OptionalString(result, "sessionId")
?? throw new WebUntisException(
$"WebUntis-Login fehlgeschlagen: {configuration.Host} hat für die Schulkennung " +
$"„{configuration.School}“ keine Sitzung geliefert. Bitte insbesondere Server und " +
"Schulkennung mit der WebUntis-Anmeldeseite vergleichen.");
_myTeacherUntisId = OptionalInt(result, "personId");
await Task.Delay(PostLoginDelay, cancellationToken);
}
_activeRequests++;
@@ -424,7 +439,7 @@ public sealed class WebUntisClient : IAsyncDisposable
TimeSpan.FromMinutes(Math.Clamp(_options.SessionIdleTimeoutMinutes, 1, 30));
private async Task<JsonElement> RpcAsync(string method, object parameters, string? sessionId,
CancellationToken cancellationToken)
CancellationToken cancellationToken, Action<HttpResponseMessage>? onResponse = null)
{
var configuration = GetConfiguration();
var uri = $"https://{configuration.Host}/WebUntis/jsonrpc.do?school={Uri.EscapeDataString(configuration.School)}";
@@ -433,9 +448,11 @@ public sealed class WebUntisClient : IAsyncDisposable
Content = JsonContent.Create(new { id = "lehrerapp-webuntis", method, @params = parameters, jsonrpc = "2.0" },
options: JsonOptions),
};
if (sessionId is not null) request.Headers.Add("Cookie", $"JSESSIONID={sessionId}");
ApplyBrowserHeaders(request, configuration.Host);
if (sessionId is not null) request.Headers.Add("Cookie", SessionCookie(sessionId));
using var response = await SendAsync(request, TimeSpan.FromSeconds(20), cancellationToken);
onResponse?.Invoke(response);
var payload = await ReadJsonAsync(response, cancellationToken);
if (!response.IsSuccessStatusCode)
throw new WebUntisException($"WebUntis RPC ({method}) fehlgeschlagen: {ErrorMessage(payload, response)}");
@@ -521,10 +538,48 @@ public sealed class WebUntisClient : IAsyncDisposable
{
var request = new HttpRequestMessage(HttpMethod.Get, uri);
if (acceptJson) request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
request.Headers.Add("Cookie", $"JSESSIONID={sessionId}; schoolname=\"_{SchoolCookie()}\"");
ApplyBrowserHeaders(request, GetConfiguration().Host);
request.Headers.Add("Cookie", SessionCookie(sessionId));
if (_tenantId is not null) request.Headers.Add("Tenant-Id", _tenantId);
return request;
}
private string SessionCookie(string sessionId)
{
var cookie = $"JSESSIONID={sessionId}; schoolname=\"_{SchoolCookie()}\"";
return _serverCookies is null ? cookie : $"{cookie}; {_serverCookies}";
}
// Sammelt Set-Cookie-Werte aus der Login-Antwort ein (z.B. Tenant-Id, traceId), die WebUntis'
// WebApp danach bei jedem Request unverändert zurückschickt. JSESSIONID wird ausgeklammert, da
// sie bereits separat über _sessionId verwaltet wird.
private void CaptureServerCookies(HttpResponseMessage response)
{
if (!response.Headers.TryGetValues("Set-Cookie", out var setCookieHeaders)) return;
var pairs = new List<string>();
foreach (var header in setCookieHeaders)
{
var nameValue = header.Split(';', 2)[0].Trim();
var name = nameValue.Split('=', 2)[0];
if (name.Equals("JSESSIONID", StringComparison.OrdinalIgnoreCase)) continue;
pairs.Add(nameValue);
if (name.Equals("Tenant-Id", StringComparison.OrdinalIgnoreCase) &&
nameValue.Split('=', 2) is [_, var value])
_tenantId = value.Trim('"');
}
_serverCookies = pairs.Count > 0 ? string.Join("; ", pairs) : null;
}
private static void ApplyBrowserHeaders(HttpRequestMessage request, string host)
{
request.Headers.UserAgent.ParseAdd(BrowserUserAgent);
request.Headers.Referrer = new Uri($"https://{host}/WebUntis/embedded.do?showSidebar=true");
request.Headers.Add("Accept-Language", "de-DE,de;q=0.9");
request.Headers.Add("Sec-Fetch-Site", "same-origin");
request.Headers.Add("Sec-Fetch-Mode", "cors");
request.Headers.Add("Sec-Fetch-Dest", "empty");
}
private async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, TimeSpan timeout,
CancellationToken cancellationToken)
{
+207 -3
View File
@@ -1399,11 +1399,31 @@ Zeitraum-Datumsfelder mit diesem `DatePicker` auf `CalendarDatePicker` umgestell
`DateTimeOffset?`-Bindung, kein Typwechsel nötig) — bereits das etablierte Muster für frei wählbare
Einzeldatumsfelder in dieser Codebasis (`WithdrawStudentDialog`, `CreateLetterDialog`, 7.2.3), dort
ohne dieses Problem, da ein Klick auf einen Kalendertag sofort übernimmt statt einen
Bestätigungsschritt zu verlangen. Betroffen: [ClassTeacherRegisterView.axaml](LehrerApp.Desktop/Views/ClassTeacher/ClassTeacherRegisterView.axaml),
Bestätigungsschritt zu verlangen. Betroffen: [WebUntisLessonAbsenceComparisonDialog.axaml](LehrerApp.Desktop/Views/Groups/WebUntisLessonAbsenceComparisonDialog.axaml),
[ClassTeacherRegisterView.axaml](LehrerApp.Desktop/Views/ClassTeacher/ClassTeacherRegisterView.axaml),
[ClassTeacherAbsencesView.axaml](LehrerApp.Desktop/Views/ClassTeacher/ClassTeacherAbsencesView.axaml),
[WebUntisTimetableImportDialog.axaml](LehrerApp.Desktop/Views/Planning/WebUntisTimetableImportDialog.axaml)
und [WebUntisDocumentationComparisonDialog.axaml](LehrerApp.Desktop/Views/Students/WebUntisDocumentationComparisonDialog.axaml).
**Nachtrag (September 2026, Nutzer-Feedback) — `CalendarDatePicker` blieb nach der Umstellung leer
und warf beim Auswählen eine `InvalidCastException`:** Der erste Erklärungsversuch (Nullability der
ViewModel-Eigenschaften) war falsch und hat das Problem nicht behoben. Per Reflection gegen die
tatsächliche Avalonia-12.0.4-DLL verifiziert: `CalendarDatePicker.SelectedDate` (und
`DisplayDateStart`/`DisplayDateEnd`) sind — vom WPF-Toolkit-Ursprung dieses Controls geerbt —
`DateTime?`, nicht `DateTimeOffset?` wie beim eingebauten `DatePicker`. Avalonias
`TypeUtilities.TryConvert`/`TryConvertImplicit` unterstützen keine automatische Konvertierung
zwischen `DateTime` und `DateTimeOffset` (in keiner Richtung, nullbar oder nicht) — mit einem
`DateTimeOffset`-Feld direkt gebunden bleibt `CalendarDatePicker` beim Anzeigen stumm leer und
wirft beim Zurückschreiben (Klick auf einen Kalendertag) eine `InvalidCastException`. Das betraf
nicht nur die fünf oben umgestellten Zeitraum-Felder, sondern genauso die bereits bestehenden
`CalendarDatePicker`-Bindungen in `WithdrawStudentDialog` (`SelectedDate`, `DisplayDateStart`) und
`CreateLetterDialog` (`LetterDate`) — nur bislang unbemerkt, weil dort niemand konsequent ein Datum
ausgewählt hatte. Behoben mit einem neuen [DateTimeOffsetToDateTimeConverter](LehrerApp.Desktop/Converters/DateTimeOffsetToDateTimeConverter.cs),
an allen elf betroffenen Bindings in den sieben Dialogen/Views eingehängt (`Converter={x:Static
conv:DateTimeOffsetToDateTimeConverter.Instance}`). End-to-End mit einer echten kompilierten
Ansicht in einer Avalonia.Headless-Instanz gegengeprüft (Anzeige des Anfangswerts und
Zurückschreiben nach Kalenderauswahl, beides ohne Exception).
**Nachtrag zu 4.3, Fehlzeiten je Unterricht (August 2026):** Der ursprüngliche Fehlzeitenabgleich
rief `getTimetableWithAbsences` ohne Element auf und bekam damit den kompletten Lehrer-Stundenplan
zurück (einmal pro Kursmitglied, siehe damalige Ineffizienz-Korrektur) — das erfordert mehr
@@ -1760,6 +1780,155 @@ eigenen Unterricht abfragt und deshalb mit den regulären Lehrkraft-Rechten funk
Fehlzeiten-Einträge an Tagen mit tatsächlichem Unterricht) — von echter Anwesenheit ist das aus
dem WebUntis-Fehlzeitenbericht allein nicht unterscheidbar, dafür bräuchte es Daten darüber, ob
für eine Stunde überhaupt eine Anwesenheitsprüfung stattfand.
- [x] **"Klassenlehrer"-Feature — Klassenbuch-Tab als Dokumentations-Hub (September 2026):**
Nutzer-Feedback: der Klassenbuch-Tab zeigte bisher nur den WebUntis-Bericht anderer Lehrkräfte —
die eigene Dokumentation zu Schüler*innen der Klasse (Gespräche, Vorkommnisse, Förderpläne, …)
hatte dort keinen Platz, obwohl der reale Arbeitsablauf beides mischt. Erwogen wurde ein Merge
beider Datensätze in eine Liste, verworfen: `ClassTeacherClassRegisterRow` (flache,
ID-lose WebUntis-Berichtszeile) und `Documentation` (LiteDB-Entität mit typspezifischen
Unterdaten, Anhängen, Vertraulichkeits-Freigabe, Tags) sind strukturell zu verschieden — ein
Merge hätte genau die vom Nutzer selbst befürchtete fummelige Konverter-Klasse gebraucht, für
zwei Dinge, die inhaltlich verschieden sind (was eine Kollegin notiert hat vs. was ich selbst
dokumentiert habe).
Stattdessen ein Umschalter `ClassTeacherDetailsViewModel.ShowOwnDocumentation` zwischen
"Klassenbuch (Untis)" und "Eigene Dokumentation", mit Zahlen-Badges links/rechts (Gesamtzahl je
Quelle, in Rot hervorgehoben bei kritischen WebUntis-Kategorien bzw. eigenen Einträgen mit dem
Tag "Kritisch"/`IsDraft`-Status "Nacharbeiten") — dieselben Badges zusätzlich klein am
"Klassenbuch öffnen"-Button der Übersicht (`ClassTeacherOverviewViewModel.BuildOwnDocumentationCounts`).
Die eigene Dokumentation gibt es nur lokal, nicht in WebUntis — `Documentation.StudentId` lässt
sich der (rein WebUntis-basierten) Klasse deshalb nur über denselben Namensabgleich zuordnen, den
die Übersicht schon für ihr Roster nutzt (`ClassTeacherOverviewViewModel.MatchStudent`). Liste,
Anlegen/Bearbeiten/Löschen und Vertraulichkeits-Freigabe sind eins zu eins vom Gruppen-Tab
übernommen (`DocumentationItem`, `DocumentationDialogViewModel`/`DocumentationDialog`,
`ConfirmDialog`) statt neu gebaut. Die Filter-/Sortierlogik steckt in der reinen, ohne
Repository-Zugriff testbaren `ClassTeacherDetailsViewModel.FilterOwnDocumentation` (gleiches
Muster wie `ClassTeacherRosterRow.Build`), damit Anlegen/Bearbeiten/Löschen eines eigenen
Eintrags nicht auch die WebUntis-Berichte neu abruft.
**Bewusst zurückgestellt:** Kategorien-Chipreihe (bisher nur WebUntis-`CategoryGroup`) auf eigene
Tags erweitern; "Gespräch begleiten" (Elternanruf-Durchführung) aus dem Gruppen-Tab wurde hier
nicht übernommen, da dieser Hub bewusst schlank gehalten wurde.
- [x] **"Klassenlehrer"-Feature — Fehlquote behandelte Verspätung wie Schwänzen, Roster-Sortierung
ignorierte Muster über "heute" hinaus (September 2026):** Zwei verwandte Nutzer-Feedback-Punkte
zur selben Ursache: kein Unterschied zwischen "10× 5 Min. verspätet" und "10× ganztägig
unentschuldigt gefehlt".
- **Fehlquote:** `ClassTeacherRosterRow.YearAbsenceDayCount` (Zähler von
`YearAbsenceRatePercent`, angezeigt als "X % Fehlzeit seit Schuljahresbeginn") zählte bisher
jeden Tag mit irgendeinem Fehlzeiten-Eintrag gleich, egal ob ganztägig unentschuldigt oder nur
5 Minuten verspätet. Jetzt dieselbe überschneidungsfreie Kategorisierung wie im Wochentrend
(`BuildTrend`: Unentschuldigt vor Verspätet vor Entschuldigt) — reine Verspätung (nicht auch
unentschuldigt) zählt nicht mehr in die Quote, sondern separat in neuem
`YearLateDayCount`/`YearExcusedDayCount`. Ist jemand unentschuldigt UND verspätet, bleibt das
der schwerwiegendere Fall und zählt weiter voll in die Quote (gleiche Rangfolge wie
`AttentionRank`/`StatusKind`). `YearSummaryLabel`/`-Tooltip` zeigen reine Verspätungstage jetzt
als eigene Zeile statt sie in die Prozentzahl einzurechnen.
- **Roster-Sortierung:** die bisherige "Auffällig"-Ansicht filtert rein auf den Heute-Snapshot
(`HasAbsenceToday`) — ein/e Schüler*in mit 5 Tagen unentschuldigter Vorgeschichte, aber heute
zufällig anwesend, verschwand komplett aus der Liste, während ein einmaliger Erstfehltag ganz
oben landete, sobald er der/die einzige "heute Auffällige" war. Neuer vierter Reiter
"Gesamtbild" (`SelectedRosterFilter == 3`, jetzt der beim Öffnen aktive Standard-Reiter statt
"Auffällig") sortiert stattdessen nach neuem `ClassTeacherRosterRow.PatternScore` — kumulierte,
gewichtete Summe über das ganze bisherige Schuljahr (unentschuldigt × 3,0, entschuldigt
abwesend × 1,0, nur verspätet × 0,5; feste statt einstellbare Gewichte, Nutzer-Entscheidung),
bewusst ohne zeitliche Abklingkurve (für die genannten Fälle reicht die reine Summe). Die drei
bestehenden Reiter (Auffällig/Klassenbuch/Alle) blieben unverändert.
- **Nachtrag (September 2026):** `PatternScore` bezog zunächst nur Fehlzeiten ein — Nutzer-Feedback:
Klassenbucheinträge anderer Lehrkräfte (fehlende Hausaufgaben, schlechte Mitarbeit, im
Extremfall eine Suspendierung) sollen ebenfalls einfließen, "wenn die negativen Einträge durch
die Decke gehen". `ClassTeacherRosterRow` bekam dafür `YearNegativeClassRegisterCount`/
`HasSuspensionEntry`/`ClassRegisterScoreComponent`: jeder Klassenbucheintrag mit
`CategoryGroup` "Negativ" zählt mit Gewicht 1,0 (ein einzelner Eintrag fällt kaum ins Gewicht,
erst die Häufung), `CategoryName`/`Text`-Schlüsselwörter "hausaufgabe"/"mitarbeit" zählen mit
zusätzlichem Bonus 1,5, das Schlüsselwort "suspendier" mit Ausreißer-Gewicht 15,0 (schießt den
Score sofort deutlich über jedes normale Muster, wie gewünscht) — alles einfache
case-insensitive `Contains`-Prüfungen, gleiches Muster wie die bestehende "Negativ"/"verspät"-
Erkennung. Dafür musste `ClassTeacherOverviewViewModel.Load` den Klassenbuch-Abruf von "letzte
7 Tage" auf "seit Schuljahresbeginn" ausweiten (`_cache.GetClassRegisterEventsAsync(...,
yearStart, today)`, dieselbe dauerhaft gecachte "kalte Historie" wie bei den Fehlzeiten) — die
bestehenden 7-Tage-Badges (`RecentClassRegisterCount`, `HasRecentClassRegisterEntry`,
"Klassenbuch"-Reiter) filtern seitdem clientseitig aus genau diesem einen Abruf heraus, statt
weiterhin separat und enger abzurufen, damit sich an ihrem Verhalten nichts ändert.
`ClassTeacherRosterRow.Build` bekam dafür einen neuen optionalen Parameter
`yearClassRegisterEntries`, der ohne Angabe auf den bisherigen `recentClassRegisterEntries`-
Parameter zurückfällt (Rückwärtskompatibilität für bestehende Aufrufer/Tests).
**Bewusst zurückgestellt:** einstellbare Gewichte (Schieberegler o.ä.) für `PatternScore`
Nutzer-Entscheidung für feste Werte zunächst, lässt sich bei Bedarf später ergänzen.
- [x] **"Klassenlehrer"-Feature — "Vorgang": Fallmappe für Klassenbuch- und Dokumentationseinträge
(September 2026):** Nutzer-Feedback: manche Probleme (Schuleschwänzen, wiederholte Hausaufgaben-
Verweigerung, ein Konflikt) müssen intensiver abgearbeitet werden, dafür fehlte ein Ort, an dem
Titel, Problembeschreibung, Schlagwörter, alle dazugehörigen `Documentation`-Einträge und die
relevanten Klassenbucheinträge gebündelt einsehbar sind (auch für den Nachweis der Maßnahmen
gegenüber der Schulleitung) — statt über datumsgefilterte Listen verstreut.
Neues, eigenständiges Modell `Vorgang` (`LehrerApp.Core/Models/Vorgang.cs`): Titel, Beschreibung,
`StudentIds` (eine/r oder mehrere), freie `Tags` (`VorgangTagDisplay.Suggestions`: Absentismus,
Hausaufgaben, Verspätungen, Konflikte, Mitarbeit, Elternkontakt, Eskalation), Status Offen/
Geschlossen, `DocumentationIds` (**nur Referenzen, kein gecachter Inhalt trotz Nutzer-Idee einer
"Uploadversionsnummer"** — es gibt keine app-seitig nutzbare Versionsnummer pro Objekt, nur einen
internen Server-Sequenzzähler in `EventQueue`, und die Datenmenge pro Lehrkraft ist klein genug,
dass ein Cache nur Invalidierungs-Komplexität eingeführt hätte; gleicher Präzedenzfall wie die
bewusst verworfene Plan-ID-Gruppierung für Förderpläne, 5.3.3), sowie eingefrorene
`ClassRegisterEntries` (**Werte-Kopie statt Referenz** — `UntisForeignClassRegisterEventDto`/
`UntisClassRegisterCacheEntry` haben keine über einen Cache-Refresh hinweg stabile ID). Sync-fähig
nach demselben Muster wie `Documentation` (`VorgangRepository.Save`/`Delete` feuern `db.OnChange`,
`EventApplier` nutzt dafür den generischen `Simple<Vorgang>`-Helfer statt eines Kaskaden-
Sonderfalls, da kein Hard-Delete existiert).
Neuer vierter Tab "Vorgänge" in der Klassenlehreransicht (`ClassTeacherCasesViewModel`/
`ClassTeacherCasesView`, Geschwister-ViewModel zu `DetailsTab`, löst wie dieses die Untis-Roster-
Namen der Klasse auf lokale `Student`s auf — bewusst ein dritter, nicht geteilter Abgleich nach
demselben kleinen Muster, siehe `ClassTeacherOverviewViewModel.MatchStudent`). Jede Fallmappe ist
eine aufklappbare Karte (`VorgangItem`, gleiches Reveal-Muster wie `DocumentationItem`) mit
verknüpfter Dokumentation (neu anlegen-und-verknüpfen über den bestehenden `DocumentationDialog`,
oder bestehende Einträge der Vorgangs-Schüler*innen nachträglich verknüpfen/entfernen) und
angehefteten Klassenbucheinträgen. Anheften läuft vom Klassenbuch-Tab aus: Rechtsklick auf eine
Zeile → Kontextmenü "→ An Vorgang anheften" (bewusst kein `DataGridTemplateColumn`-Button, da ein
`$parent[DataGrid]`-Bindungspfad in Zellen-Templates in diesem Codebasis-Stand nirgends erprobt
ist — `DataGrid.SelectedItem` zweigleisig gebunden + `ContextMenu.CommandParameter` ist das
bereits an anderer Stelle (`GroupDetailView`) bewährte Muster für Zeilen-Aktionen), öffnet einen
Auswahldialog (bestehenden offenen, zur/zum Schüler*in passenden Vorgang wählen oder per Titel
einen neuen anlegen). `ClassTeacherDetailsViewModel` bekommt dafür `ClassTeacherCasesViewModel`
per Konstruktor-Injection (Geschwister-VM-Zugriff für reine Datenabfragen statt Func-Hook, da
keine UI im Spiel ist).
- **Nachtrag:** Nutzer-Feedback — verknüpfte Dokumentation ließ sich nur als kurze Karte sehen,
zum Nachlesen musste man in den Schülerdatensatz wechseln. Statt eines neuen Seitenpanels
(`DockPanel`/`Dock="Right"`) oder eines eigenen Lese-Fensters wird der bereits vorhandene
`DocumentationDialog` wiederverwendet: "Bearbeiten"-Button an jedem verknüpften bzw. noch
verknüpfbaren Eintrag (nur sichtbar nach `IsRevealed`, gleiche Vertraulichkeits-Gate-Konvention
wie im Gruppen-Tab) öffnet ihn zum vollständigen Lesen/Bearbeiten, ohne den Vorgänge-Tab zu
verlassen. `VorgangItem` bekam dafür einen weiteren Callback (`onEditDocumentation`, gleiches
Konstruktor-Callback-Muster wie `onLink`/`onUnlink`), `ClassTeacherCasesViewModel.EditDocumentation`
speichert das Ergebnis direkt über `IDocumentationRepository`, unabhängig davon, welche Vorgänge
den Eintrag verlinken.
**"Runde 2" (September 2026, direkt im Anschluss umgesetzt):** einstellbare Gewichte plus
Score-Dämpfung nach geschlossenem Vorgang inkl. Rückfall-Warnung — siehe eigener Eintrag unten.
- [x] **"Klassenlehrer"-Feature — konfigurierbare Gewichtung + Score-Dämpfung/Rückfall-Warnung nach
geschlossenem Vorgang (September 2026, "Runde 2"):**
- **Gewichtung:** `PatternScoreWeights`/`PatternScoreSettingsService`
(`LehrerApp.Core/Services/PatternScoreSettingsService.cs`, gleiches JSON-in-appData-Muster wie
`WorkloadSettingsService`/`DashboardSettingsService`) ersetzen die bisherigen festen Konstanten.
Neuer Dialog `PatternScoreWeightsDialog` (Vorbild `ParticipationAspectsDialog`: `NumericUpDown`
pro Gewicht), erreichbar über einen neuen "⚙ Gewichtung"-Button neben dem "Gesamtbild"-Filter;
beim Schließen wird `LoadCommand` erneut ausgeführt, damit die neue Gewichtung sofort wirkt.
- **Dämpfung (Nutzer-Entscheidung: bereichsbezogen statt komplett):** die Schlagwörter eines
geschlossenen Vorgangs bestimmen per Schlüsselwort-Erkennung (`ClassTeacherRosterRow.MatchDomains`,
gleiche Contains-Konvention wie die bestehende "Negativ"/"verspät"-Erkennung), welcher der drei
`VorgangScoreDomain`-Bereiche (Attendance/Lateness/Classbook) gedämpft wird — Ereignisse vor dem
Schließ-Datum zählen ab dann nicht mehr in `PatternScore`. Kein Schlagwort erkannt (z.B. reines
"Elternkontakt") → vorsichtshalber alle drei Bereiche gedämpft, damit ein geschlossener Vorgang
nie folgenlos bleibt. **Bewusst unverändert:** die faktische "% Fehlzeit"-Quote
(`YearAbsenceDayCount`/`YearAbsenceRatePercent`) — nur die `PatternScore`-Sortierung wird
gedämpft, nicht die für Zeugnis/Attestpflicht relevante Historie.
- **Rückfall-Warnung:** neues `RegressionScore`/`RegressionVorgangTitle` je Roster-Zeile — der
Score-Anteil, der seit dem Schließ-Datum des zuletzt geschlossenen Vorgangs (nur in dessen
eigenen Bereichen) neu hinzugekommen ist. `ClassTeacherOverviewViewModel.BuildVorgangRegressionNotices`
(Vorbild `BuildAttendanceParticipationNotices`) meldet ab einer Schwelle von 3,0 (≈ 1
unentschuldigter Tag, Nutzer-Entscheidung: "erst ab spürbarem Muster", nicht bei jeder
Kleinigkeit) einen Danger-Hinweis "Nach Bearbeitung von '{Titel}' erneut auffällig" in die
bestehende "Muster erkannt"-Seitenleiste, inkl. vorhandenem "+"-Wiedervorlage-Button — keine
neue UI dafür nötig.
- `ClassTeacherRosterRow.Build` bekam dafür zwei weitere optionale Parameter (`weights`,
`closedVorgaengeByNameKey`, beide mit Default = heutiges Verhalten, volle Rückwärtskompatibilität
für bestehende Tests/Aufrufer). `ClassTeacherOverviewViewModel.Load` löst die geschlossenen
Vorgänge je Klassen-Roster-Namen über denselben Namensabgleich wie die übrigen `Build*`-Methoden
dieser Klasse auf (`IVorgangRepository.GetByStudent`).
### 4.4 Wochen-/Tagesansicht
- [x] **4.4.1** Kalenderansicht über alle Gruppen: Woche und Tag — siehe Nachtrag zu 4.3
@@ -2153,6 +2322,22 @@ folgenden Punkte gehören direkt in `LehrerApp.Desktop`:
den vollen Verlaufsplan-Editor öffnen müssen. Neue kleine
`TeachingModeHomeworkViewModel : ObservableObject`, da `TeachingModeViewModel` selbst (wie
`LessonViewerViewModel`) keine Bindable-Basisklasse hat.
**Nachtrag (September 2026, Nutzer-Feedback) — zweite Sitzung bei zusätzlicher Stunde am
selben Tag:** `SelectOrCreateSessionForLesson` prüfte bisher ausschließlich auf eine bereits
exakt mit `lesson.Id` verknüpfte Sitzung. Kam neben einer Doppelstunde noch eine dritte
Stunde desselben Tages hinzu (eigene `Lesson`-Id, z.B. durch Vertretung), fand die Methode
keinen Treffer und legte eine zweite, unabhängige Mitarbeitssitzung für denselben Tag an —
dadurch zeigten Unterrichtsmodus und der normale Mitarbeit-Tab der Gruppe (der über
`ParticipationTabViewModel.LoadSessions()` unabhängig davon die nach Datum jüngste Sitzung
wählt) je nach Reihenfolge unterschiedliche Sitzungen für denselben Tag. Nutzer-Entscheidung:
strikt nach Datum statt nach Stunde gehen — eine zusätzliche Stunde am selben Tag (Vertretung
o.ä.) bekommt keine eigene Sitzung, sondern die Lehrkraft passt die Einschätzung der bereits
bestehenden Sitzung des Tages an. `SelectOrCreateSessionForLesson` fällt jetzt, wenn keine
exakt verknüpfte Sitzung existiert, zusätzlich auf jede andere Sitzung desselben Tages
zurück (Verhalten analog zu `EnsureTodaySession`, das schon immer pro Tag statt pro Stunde
arbeitet). Dieselbe Lücke bestand in `PlanningTabViewModel.CreateParticipationSession`
("Sitzung aus der Stunde erstellen", 3.3.1) — gleicher Fallback dort ergänzt.
- [x] **4.5.24** Popup-Menü im Wochenraster für Unterrichtsansicht/Sitzplan/Planung/Planungsviewer
(August 2026, Nutzer-Feedback, zweite Runde). Die erste Fassung hatte das Problem am
falschen Ort gelöst — ein Dropdown in der "Heute"-**Tagesliste** (unten angedockt), obwohl
@@ -2428,8 +2613,27 @@ CSV-Exports der Auswertung (6.3.3) über die gemeinsame Export-Infrastruktur aus
genau der manuelle "Mail raussuchen und archivieren"-Schritt entfällt damit. Echtes Löschen
bleibt eine separate, bewusst zusätzliche Aktion (eigener Button, nicht automatisch beim
Archivieren mitgemacht).
- **Architektur-Vorbild:** wie `LehrerApp.WebUntis` ein eigenständiger, serverunabhängiger
Client — neues `LehrerApp.Mail`-Projekt (IMAP-Verbindung + Nachrichtenliste + Move/Delete,
- **Bevorzugt zu prüfen — eM Client 11 MCP-Support (Stand 2026-09-02, laut Ankündigung des
Herstellers noch in der Beta):** eM Client kündigt für v11 einen MCP-Server an ("find
accounts and folders, search and retrieve emails or conversations, perform selected mailbox
actions, draft and send messages, and more"). Klingt breit genug, um genau die hier
benötigten Primitive abzudecken (Ordner ansprechen, Mails lesen, Mailbox-Aktion ausführen)
— und würde, wenn der Funktionsumfang das hergibt, die IMAP-Variante unten komplett
überflüssig machen: LehrerApp bräuchte dann **gar keine eigenen Postfach-Zugangsdaten**,
sondern spräche als MCP-Client die bereits eingeloggte eM-Client-Instanz auf demselben
Gerät an. Löst den Mehrgeräte-Fall (siehe unten) sogar eleganter als der IMAP-Ansatz: die
Aktion ist automatisch nur dort verfügbar, wo eM Client lokal läuft und angemeldet ist —
ganz ohne eigenen `SourceMailAccount`-Abgleich. Vor einer Festlegung zu klären, sobald die
Beta zugänglich ist: (1) deckt "perform selected mailbox actions" wirklich gezieltes
Verschieben in einen bestimmten Zielordner ab, oder nur eine feste Auswahl (z.B.
Lesen/Flag/Junk)? (2) welcher Transport (lokaler Prozess über stdio, lokaler HTTP-Port?) und
gibt es dafür schon eine nutzbare .NET/C#-MCP-Client-Bibliothek? (3) muss eM Client dafür im
Vordergrund laufen oder reicht ein im Hintergrund laufender Prozess? (4) Stabilität der
Schnittstelle vor Verlassen der Beta. Solange das offen ist, bleibt der IMAP-Weg unten der
Fallback-Plan — funktioniert unabhängig vom Mail-Programm und auch, falls der Nutzer eM
Client je wechselt.
- **Fallback-Architektur (IMAP via MailKit):** wie `LehrerApp.WebUntis` ein eigenständiger,
serverunabhängiger Client — neues `LehrerApp.Mail`-Projekt (IMAP-Verbindung + Nachrichtenliste + Move/Delete,
via `MailKit` — .NET hat kein natives IMAP in der BCL, `System.Net.Mail` deckt nur SMTP ab;
MailKit ist der De-facto-Standard, MIT-lizenziert, Eintrag in `Directory.Packages.props`),
Desktop bindet es analog zu `LehrerApp.WebUntis` direkt ein. Zugangsdaten