23 Commits
Author SHA1 Message Date
adminandClaude Sonnet 5 4542f263fc fix: Anwesenheitskalender respektiert die von der Vorlage vorgegebene Boxhöhe
CI / build-and-test (push) Waiting to run
Nachdem die Kalender-Breite jetzt an die reale DRAWBOX/FLOWDRAWBOX-Breite
gekoppelt ist, konnte das (korrekt größer werdende) Raster die von der
Vorlage deklarierte Höhe überschreiten - eine DRAWBOX bricht anders als eine
FLOWDRAWBOX nicht automatisch auf Folgeseiten um, überschüssiger Inhalt wird
von QuestTemplateRenderer stillschweigend am unteren Rand abgeschnitten.

StudentAttendanceCalendarDrawingBuilder ermittelt jetzt vorab, wie viele
Wochen die gewählten Monate brauchen, und verkleinert "Größe" automatisch so
weit, dass der Kalender innerhalb der deklarierten Höhe bleibt (nur bei
DRAWBOX/IsFixed - eine FLOWDRAWBOX darf weiterhin frei wachsen, da sie bei
Bedarf auf weitere Seiten fließt statt abzuschneiden).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-17 14:39:45 +02:00
adminandClaude Sonnet 5 86a97d91a7 fix: Anwesenheitskalender/Fehltagesliste nutzen die real deklarierte Boxgröße
CI / build-and-test (push) Waiting to run
Die DrawingValue-Builder gingen bisher immer von einer festen Breite (170)
aus, unabhängig davon, wie breit die DRAWBOX/FLOWDRAWBOX in der jeweiligen
Vorlage tatsächlich deklariert ist. Bei einer breiteren Box (oder einer in
"pt" statt "mm" deklarierten Seite, wie im echten Fehlzeitenbrief-Template)
blieb dadurch ein Großteil der eigentlich verfügbaren Fläche ungenutzt leer
- sichtbar als "Luft" rechts und unterhalb des Kalenders im generierten PDF.

CreateLetterDialogViewModel/LetterTemplateTools ermitteln jetzt die real
deklarierte Boxbreite samt Maßeinheit aus dem geladenen Template
(LetterPlaceholderBuilder.FindDeclaredDrawingBox) und reichen sie an die
Builder durch, die ihr mm-entworfenes Raster entsprechend skalieren.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-17 14:27:05 +02:00
adminandClaude Sonnet 5 763d7744be feat: Circle/RoundedRectangle-Zeichenbefehle im Canvas-Backend, Fehlzeitenkalender-Kacheln größer
CI / build-and-test (push) Waiting to run
Neue DrawingCommand-Primitives DrawCircle und DrawRoundedRectangle (SVG-Rendering
plus FLOWDRAWBOX-Paginierung). Anwesenheitskalender nutzt RoundedRectangle für die
Tageskacheln und verkleinert Monatsabstand/Kachel-Innenabstand, damit die Kacheln
den Rasterplatz sichtbar besser ausfüllen.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-17 13:59:08 +02:00
adminandClaude Sonnet 5 77fe9b1c79 fix: Dokumentationsdialog wächst bei Elternbrief über den Bildschirm
CI / build-and-test (push) Waiting to run
SizeToContent="Height" ohne Begrenzung ließ das Fenster beim Typ
"Elternbrief" (drei zusätzliche Felder) so hoch werden, dass der
Speichern-Button auf kleineren Bildschirmen (z.B. MacBook) nicht mehr
erreichbar war. ScrollViewer bekommt eine MaxHeight, ab der intern
gescrollt wird - analog zum bestehenden Muster in McpConfirmDialog.

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

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

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

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

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

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

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

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-13 00:21:08 +02:00
97 changed files with 3548 additions and 173 deletions
@@ -76,6 +76,9 @@ public class AiLesson
public List<AiPhaseStep> Phases { get; set; } = [];
public string? Homework { get; set; }
public string? Reflection { get; set; }
/// Rohentwurf/erste Ideen der Lehrkraft vor der Feinplanung (siehe Lesson.PlanningIdeas) — reiner
/// Kontext für die KI wie Homework/Reflection, wird symmetrisch übernommen/zurückgegeben.
public string? PlanningIdeas { get; set; }
}
public class AiPhaseStep
+25
View File
@@ -9,4 +9,29 @@ namespace LehrerApp.Core.Mcp;
public static class McpPipeConstants
{
public const string PipeName = "LehrerApp.Mcp";
/// <summary>
/// Unter macOS/Linux emuliert .NET Named Pipes über eine Socket-Datei unter
/// <see cref="Path.GetTempPath"/> (intern "CoreFxPipe_" + Name), und die liest dafür die
/// TMPDIR-Umgebungsvariable des jeweiligen Prozesses. LehrerApp.Desktop (von Finder/Dock
/// gestartet) bekommt das reguläre per-Login-Session-TMPDIR unter /var/folders/.../T,
/// während LehrerApp.McpBridge als Kindprozess von Claude Desktop oft eine reduzierte
/// Umgebung mit TMPDIR=/tmp erbt - beide Prozesse suchen die Pipe-Datei dann an
/// unterschiedlichen Orten und finden sich nie ("MCP nicht erreichbar, obwohl LehrerApp
/// läuft", auch wenn beide Prozesse laufen und die Pipe grundsätzlich offen ist). Fix: TMPDIR
/// für beide Prozesse hart auf denselben Ordner setzen, bevor die erste
/// NamedPipeServerStream/-ClientStream-Instanz entsteht - dieselbe ApplicationData-Basis wie
/// AppBootstrapper (siehe dort) wird anders als TMPDIR zuverlässig an Kindprozesse
/// weitergereicht. Muss als eine der ersten Anweisungen in Main aufgerufen werden (Desktop wie
/// Bridge), auf Windows ein No-op (dort nutzen Named Pipes den Kernel-Namespace, keine
/// Socket-Datei).
/// </summary>
public static void EnsureStableUnixSocketDirectory()
{
if (OperatingSystem.IsWindows()) return;
var dir = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
"LehrerApp", "ipc");
Directory.CreateDirectory(dir);
Environment.SetEnvironmentVariable("TMPDIR", dir);
}
}
+8
View File
@@ -27,6 +27,14 @@ public class LearningGroup
/// </summary>
public int? WebUntisLessonId { get; set; }
/// <summary>
/// Blendet diese Lerngruppe im UntisHub (siehe <see cref="Services"/>-Layer im Desktop-Projekt:
/// UntisHubService) aus, auch wenn <see cref="WebUntisLessonId"/> gesetzt ist - z.B. für
/// Klassenrat/AGs, die zwar eine WebUntis-Unterrichtsnummer haben, aber nicht auf
/// Fehlzeiten/offene Periods hin überwacht werden sollen. Default false (alte Datensätze
/// verhalten sich unverändert wie bisher).
/// </summary>
public bool ExcludedFromUntisHub { get; set; }
/// <summary>
/// Vorgängergruppe aus dem Hochstufen (<see cref="Services.GroupRolloverService"/>) —
/// ermöglicht einen Schuljahresvergleich (z.B. Klausurschnitt) über die Kette hinweg.
/// Wird ausschließlich beim Hochstufen automatisch gesetzt; vor Einführung dieses Felds
+34
View File
@@ -54,7 +54,15 @@ public class Lesson : IHasAttachments
/// Optionaler Stundenbeginn — solange kein Stundenplan (Kapitel 4.3) existiert, manuell
/// gepflegt. Dient nur der abgeleiteten Uhrzeit-Anzeige je Phase in <see cref="Phases"/>.
public TimeOnly? StartTime { get; set; }
/// Grobe Ideen/erster Entwurf, festgehalten lange bevor der Verlaufsplan (<see cref="Phases"/>)
/// feingeplant wird (Nutzer-Feedback: die eigentliche Ideenfindung liegt zeitlich oft weit vor
/// der Feinplanung) — bewusst ein eigenes Feld statt Zweckentfremdung von <see cref="Reflection"/>
/// (die ist nach der Stunde) oder <see cref="Unit.Notes"/> (die ist auf Einheitenebene, nicht je
/// Stunde). Wird auch der KI-Planungsunterstützung (4.5.9) als Kontext mitgegeben, damit erste
/// eigene Ideen bei der KI-gestützten Weiterplanung nicht erneut abgetippt werden müssen.
public string? PlanningIdeas { get; set; }
public List<LessonPhaseStep> Phases { get; set; } = [];
public TeachingTimelineState? TeachingTimeline { get; set; }
public string? Homework { get; set; }
/// Markiert, dass die hier eingetragene Hausaufgabe in einer Folgestunde besprochen/kontrolliert
/// wurde — treibt das Stundenplan-Badge "Hausaufgabe kontrollieren" (4.5.4).
@@ -94,6 +102,14 @@ public class LessonPhaseStep
public string Activity { get; set; } = "";
public string Material { get; set; } = "";
public string Shorthand { get; set; } = "";
/// Der über <see cref="AiPlanning.AiPlanningService.BuildMaterialPrompt"/> erzeugte, vollständige
/// Prompt für diese Phase (4.5.20), sofern die KI beim letzten "Übernehmen" einen Medienvorschlag
/// gemacht hatte — anders als der reine Vorschlagstext (<c>AiPhaseStep.MaterialSuggestion</c>,
/// nur transient während der Review) bewusst hier persistiert (Nutzer-Feedback: der Prompt soll
/// auch nach dem Übernehmen noch abrufbar/erneut kopierbar bleiben, statt nur einmalig im
/// Review-Dialog verfügbar zu sein). Rein informativ, kein Datenmodell-Bezug zu einem separaten
/// "Material"-Konzept, das es weiterhin nicht gibt — siehe TODO.md 4.5.26.
public string? MaterialPrompt { get; set; }
/// <summary>
/// null = Hauptweg. Sonst Verweis auf einen benannten <see cref="AlternativeLessonPath"/> aus
/// dem Katalog, über den Phasen alternativer Unterrichtsverläufe zusammengehören —
@@ -244,3 +260,21 @@ public class ReportGrade
public bool IsLocked { get; set; }
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
}
/// <summary>Live timing is separate from the original lesson plan.</summary>
public class TeachingTimelineState
{
public DateTime StartUtc { get; set; }
public DateTime EndUtc { get; set; }
public DateTime? HeldSinceUtc { get; set; }
public Guid? HeldPhaseId { get; set; }
public List<TeachingPhaseTiming> Phases { get; set; } = [];
public List<Guid> TransferredPhaseIds { get; set; } = [];
}
public class TeachingPhaseTiming
{
public Guid PhaseId { get; set; }
public double Minutes { get; set; }
public bool ExplicitlyStarted { get; set; }
}
+20
View File
@@ -57,4 +57,24 @@ public sealed class ChangeHookTests
Assert.Null(exception);
}
// Kein eigener Fall in ChangeHookMatrixTests: UntisHubJobStateRepository kennt kein Delete
// (siehe IUntisHubJobStateRepository), passt also nicht in deren Save+Delete-Tabellenform.
[Fact]
public void UntisHubJobStateRepository_Save_LoestOnChangeAus()
{
using var db = NewInMemoryContext();
var calls = new List<(string EntityType, string EntityId, string Operation, object? Payload)>();
db.OnChange = (type, id, op, payload) => calls.Add((type, id, op, payload));
var repo = new UntisHubJobStateRepository(db);
var state = new UntisHubJobState { Kind = UntisHubJobKind.OffenePeriods, LastRunAt = DateTime.UtcNow };
repo.Save(state);
var call = Assert.Single(calls);
Assert.Equal(nameof(UntisHubJobState), call.EntityType);
Assert.Equal(state.Id.ToString(), call.EntityId);
Assert.Equal("Save", call.Operation);
Assert.Same(state, call.Payload);
}
}
@@ -907,7 +907,11 @@ public class UntisHubJobStateRepository(LiteDbContext db) : IUntisHubJobStateRep
public List<UntisHubJobState> GetAll() => db.UntisHubJobStates.FindAll().ToList();
public void Save(UntisHubJobState state) => db.UntisHubJobStates.Upsert(state);
public void Save(UntisHubJobState state)
{
db.UntisHubJobStates.Upsert(state);
db.OnChange?.Invoke(nameof(UntisHubJobState), state.Id.ToString(), "Save", state);
}
}
public class CompetencyDomainRepository(LiteDbContext db) : ICompetencyDomainRepository
@@ -2,6 +2,7 @@ using LehrerApp.Core.Models;
using LehrerApp.Core.Services;
using LehrerApp.Desktop.Services;
using LehrerApp.Desktop.ViewModels.ClassTeacher;
using LehrerApp.Templating;
using Xunit;
namespace LehrerApp.Desktop.Tests;
@@ -71,6 +72,106 @@ public sealed class ClassTeacherViewModelsTests
Assert.False(days[4].HasSignal); // Hausaufgaben bleiben im Widget bewusst ausgeblendet
}
[Fact]
public void Kompaktkalender_KannAufEinzelnenSchuelerEingeschraenktWerden()
{
var month = new DateOnly(2026, 9, 1);
var absences = new[]
{
new ClassAbsenceDaySummaryRow(new DateOnly(2026, 9, 3), "Müller Ada", 1, 2, 90,
["Deu"], [1, 2], ["nicht entsch."], ["Absent"], null, null, false),
new ClassAbsenceDaySummaryRow(new DateOnly(2026, 9, 4), "Schmidt Ben", 2, 2, 90,
["Mathe"], [1, 2], ["entsch."], ["Absent"], null, null, false),
};
var days = ClassTeacherOverviewViewModel.BuildCompactMonthDays(month, absences, [], month,
"Ada Müller");
Assert.Equal("U", days[2].SignalCode);
Assert.False(days[3].HasSignal);
}
[Fact]
public void ElternbriefKalender_LiefertPortablenDrawingPlatzhalterFuerSchueler()
{
var month = new DateOnly(2026, 9, 1);
var absences = new[]
{
new ClassAbsenceDaySummaryRow(new DateOnly(2026, 9, 3), "Müller Ada", 1, 2, 90,
["Deu"], [1, 2], ["nicht entsch."], ["Absent"], null, null, false),
new ClassAbsenceDaySummaryRow(new DateOnly(2026, 9, 4), "Schmidt Ben", 2, 2, 90,
["Mathe"], [1, 2], ["entsch."], ["Absent"], null, null, false),
};
var drawing = StudentAttendanceCalendarDrawingBuilder.Build("Ada Müller", month, absences, []);
Assert.True(drawing.ContentHeight > 0);
Assert.Contains(drawing.Commands.OfType<DrawStringEx>(), c => c.Text == "Ada Müller");
Assert.Contains(drawing.Commands.OfType<DrawStringEx>(), c => c.Text == "U");
Assert.DoesNotContain(drawing.Commands.OfType<DrawStringEx>(), c => c.Text == "E");
}
[Fact]
public void ElternbriefKalender_UnterstuetztEinBisDreiMonateUndGroessen()
{
var start = new DateOnly(2026, 9, 18);
var small = StudentAttendanceCalendarDrawingBuilder.Build("Ada Müller",
new AttendanceCalendarOptions(start, 3, AttendanceCalendarSize.Small), [], []);
var large = StudentAttendanceCalendarDrawingBuilder.Build("Ada Müller",
new AttendanceCalendarOptions(start, 3, AttendanceCalendarSize.Large), [], []);
var labels = large.Commands.OfType<DrawStringEx>().Select(c => c.Text).ToList();
Assert.Contains("September 2026", labels);
Assert.Contains("Oktober 2026", labels);
Assert.Contains("November 2026", labels);
Assert.True(large.ContentHeight > small.ContentHeight);
}
// Regression: DRAWBOX bricht anders als FLOWDRAWBOX nicht automatisch um - Inhalt, der die von
// der Vorlage deklarierte Höhe überschreitet, wird von QuestTemplateRenderer stillschweigend am
// unteren Rand abgeschnitten (kein Fehler, keine Warnung). "Größe" muss deshalb automatisch so
// weit verkleinert werden, dass alle gewählten Monate innerhalb von contentHeight Platz finden.
[Fact]
public void ElternbriefKalender_VerkleinertGroesseAutomatischUmDieVorlagenHoeheEinzuhalten()
{
var start = new DateOnly(2026, 9, 18);
var uneingeschraenkt = StudentAttendanceCalendarDrawingBuilder.Build("Ada Müller",
new AttendanceCalendarOptions(start, 3, AttendanceCalendarSize.Large), [], []);
var engeBox = StudentAttendanceCalendarDrawingBuilder.Build("Ada Müller",
new AttendanceCalendarOptions(start, 3, AttendanceCalendarSize.Large), [], [],
contentHeight: 60);
Assert.True(uneingeschraenkt.ContentHeight > 60,
"Testvoraussetzung: 3 Monate in 'Groß' brauchen ohne Deckelung mehr als 60 Einheiten Höhe.");
Assert.True(engeBox.ContentHeight <= 60 + 0.01f);
}
[Fact]
public void ElternbriefFehltage_ListetDatumUmfangUndEntschuldigungsstatus()
{
var options = new AttendanceCalendarOptions(new DateOnly(2026, 9, 1), 1);
var absences = new[]
{
new ClassAbsenceDaySummaryRow(new DateOnly(2026, 9, 3), "Müller Ada", 1, 6, 270,
["Deu"], [1, 2, 3, 4, 5, 6], ["entsch."], ["Krank"], null, null, true),
new ClassAbsenceDaySummaryRow(new DateOnly(2026, 9, 8), "Müller Ada", 1, 2, 90,
["Mathe"], [1, 2], ["nicht entsch."], ["Absent"], null, null, false),
new ClassAbsenceDaySummaryRow(new DateOnly(2026, 9, 9), "Schmidt Ben", 2, 4, 180,
["Eng"], [1, 2, 3, 4], ["entsch."], ["Krank"], null, null, true),
};
var drawing = StudentAbsenceDayListDrawingBuilder.Build("Ada Müller", options, absences);
var labels = drawing.Commands.OfType<DrawStringEx>().Select(c => c.Text).ToList();
Assert.Contains("03.09.2026", labels);
Assert.Contains("Ganzer Fehltag", labels);
Assert.Contains("08.09.2026", labels);
Assert.Contains("Fehlzeit · 2 Std.", labels);
Assert.Contains("Entschuldigt", labels);
Assert.Contains("Unentschuldigt", labels);
Assert.DoesNotContain("09.09.2026", labels);
}
[Fact]
public void GroupByStudentAndDay_FasstFehlstundenProSchuelerUndTagZusammen()
{
@@ -1,4 +1,5 @@
using LehrerApp.Core.Models;
using LehrerApp.Desktop.Services;
using LehrerApp.Desktop.ViewModels.Students;
using LehrerApp.Templating;
using Xunit;
@@ -54,6 +55,246 @@ public sealed class CreateLetterDialogViewModelTests : IDisposable
Assert.Equal("%PDF", System.Text.Encoding.ASCII.GetString(File.ReadAllBytes(output), 0, 4));
}
[Fact]
public async Task AdvancedContent_Anwesenheitskalender_WirdAlsDrawingGerendert()
{
var store = StoreWithTemplate(new PlaceholderDefinition(
StudentAttendanceCalendarDrawingBuilder.PlaceholderName, PlaceholderType.Drawing, true));
var drawing = StudentAttendanceCalendarDrawingBuilder.Build("Lena Beispiel", new DateOnly(2026, 9, 1), [], []);
var vm = new CreateLetterDialogViewModel(StudentWithContact("Sehr geehrte Frau Muster,"), store,
new QuestTemplateRenderer(), new FakeMemberships([]), new FakeGroups([]), (_, _, _, _) => drawing);
var output = Path.Combine(_directory, "Kalender.pdf");
Assert.True(vm.UsesAttendanceCalendar);
await vm.SetAttendanceCalendarOptionsAsync(new AttendanceCalendarOptions(
new DateOnly(2026, 8, 19), 3, AttendanceCalendarSize.Large));
Assert.True(vm.AttendanceCalendarConfigured);
Assert.Contains("August 2026", vm.AttendanceCalendarSummary);
Assert.Contains("3 Monate", vm.AttendanceCalendarSummary);
Assert.Contains("Groß", vm.AttendanceCalendarSummary);
Assert.True(vm.Generate(output));
Assert.Equal("%PDF", System.Text.Encoding.ASCII.GetString(File.ReadAllBytes(output), 0, 4));
}
// Regression: Der DrawingValue-Builder hat früher immer eine feste Breite (170) angenommen,
// unabhängig davon, wie breit die DRAWBOX in der jeweiligen Vorlage tatsächlich deklariert war -
// bei breiteren (oder in "pt" statt "mm" deklarierten) Boxen blieb dadurch ein Großteil der
// eigentlich verfügbaren Fläche ungenutzt leer (sichtbar in echten Elternbriefen als "Luft"
// rechts/unterhalb des Kalenders). Der Dialog muss die real deklarierte Breite ermitteln und an
// den Builder weiterreichen.
[Fact]
public void AdvancedContent_Anwesenheitskalender_NutztDieImLayoutDeklarierteBoxbreite()
{
var manifest = new TemplateManifest
{
Id = $"brief-{Guid.NewGuid():N}", Name = "Elternbrief",
Placeholders = [new(StudentAttendanceCalendarDrawingBuilder.PlaceholderName, PlaceholderType.Drawing, true)],
};
var source = Path.Combine(_directory, $"{Guid.NewGuid():N}.lavorlage");
TemplatePackage.Create(source, manifest,
$"PAGE 210 297 mm\nDRAWBOX 20 20 250 80 ${StudentAttendanceCalendarDrawingBuilder.PlaceholderName}",
new Dictionary<string, byte[]>());
var store = new TemplateStore(Path.Combine(_directory, $"store-{Guid.NewGuid():N}"));
store.Import(source);
float? receivedWidth = null; float? receivedMillimeterScale = null;
var vm = new CreateLetterDialogViewModel(StudentWithContact("Sehr geehrte Frau Muster,"), store,
new QuestTemplateRenderer(), new FakeMemberships([]), new FakeGroups([]),
(_, width, millimeterScale, _) => { receivedWidth = width; receivedMillimeterScale = millimeterScale; return new DrawingValue([], 0); });
Assert.True(vm.UsesAttendanceCalendar);
Assert.Equal(250, receivedWidth);
Assert.Equal(1, receivedMillimeterScale);
}
// Regression: Eine reale, aus einem Word-Dokument übernommene Vorlage deklariert die Seite in
// "pt" statt "mm" (PAGE ... pt). Ohne Umrechnung würde das mm-entworfene Kalenderraster dort nur
// rund ein Drittel der vorgesehenen physischen Größe erreichen (1 "mm-Einheit" würde als 1pt statt
// als ~2.83pt gerendert).
[Fact]
public void AdvancedContent_Anwesenheitskalender_RechnetBoxbreiteBeiPunktBasierterVorlageUm()
{
var manifest = new TemplateManifest
{
Id = $"brief-{Guid.NewGuid():N}", Name = "Elternbrief",
Placeholders = [new(StudentAttendanceCalendarDrawingBuilder.PlaceholderName, PlaceholderType.Drawing, true)],
};
var source = Path.Combine(_directory, $"{Guid.NewGuid():N}.lavorlage");
TemplatePackage.Create(source, manifest,
$"PAGE 595.32 841.92 pt\nDRAWBOX 71 350 487 177 ${StudentAttendanceCalendarDrawingBuilder.PlaceholderName}",
new Dictionary<string, byte[]>());
var store = new TemplateStore(Path.Combine(_directory, $"store-{Guid.NewGuid():N}"));
store.Import(source);
float? receivedWidth = null; float? receivedMillimeterScale = null;
var vm = new CreateLetterDialogViewModel(StudentWithContact("Sehr geehrte Frau Muster,"), store,
new QuestTemplateRenderer(), new FakeMemberships([]), new FakeGroups([]),
(_, width, millimeterScale, _) => { receivedWidth = width; receivedMillimeterScale = millimeterScale; return new DrawingValue([], 0); });
Assert.True(vm.UsesAttendanceCalendar);
Assert.Equal(487, receivedWidth);
Assert.NotNull(receivedMillimeterScale);
Assert.Equal(2.8346f, receivedMillimeterScale!.Value, 0.001f);
}
[Fact]
public async Task AttendanceKalenderKonfigurieren_LoestGezieltenDatenAbrufFuerDenZeitraumAus()
{
var store = StoreWithTemplate(new PlaceholderDefinition(
StudentAttendanceCalendarDrawingBuilder.PlaceholderName, PlaceholderType.Drawing, true));
AttendanceCalendarOptions? requested = null;
var vm = new CreateLetterDialogViewModel(StudentWithContact("Sehr geehrte Frau Muster,"), store,
new QuestTemplateRenderer(), new FakeMemberships([]), new FakeGroups([]),
(_, _, _, _) => new DrawingValue([], 0), attendanceDataRefresher: (options, _) =>
{ requested = options; return Task.CompletedTask; });
await vm.SetAttendanceCalendarOptionsAsync(new AttendanceCalendarOptions(
new DateOnly(2026, 9, 1), 2, AttendanceCalendarSize.Medium));
Assert.NotNull(requested);
Assert.Equal(new DateOnly(2026, 9, 1), requested!.NormalizedStartMonth);
Assert.Equal(2, requested.NormalizedMonthCount);
Assert.False(vm.IsRefreshingAttendanceData);
Assert.Equal("", vm.AttendanceRefreshError);
}
[Fact]
public async Task AttendanceKalenderKonfigurieren_ZeigtFehlerBeiFehlgeschlagenemAbrufAnStattZuBlockieren()
{
var store = StoreWithTemplate(new PlaceholderDefinition(
StudentAttendanceCalendarDrawingBuilder.PlaceholderName, PlaceholderType.Drawing, true));
var vm = new CreateLetterDialogViewModel(StudentWithContact("Sehr geehrte Frau Muster,"), store,
new QuestTemplateRenderer(), new FakeMemberships([]), new FakeGroups([]),
(_, _, _, _) => new DrawingValue([], 0),
attendanceDataRefresher: (_, _) => throw new WebUntisIntegrationException("Keine Verbindung."));
await vm.SetAttendanceCalendarOptionsAsync(new AttendanceCalendarOptions(
new DateOnly(2026, 9, 1), 1, AttendanceCalendarSize.Medium));
Assert.True(vm.AttendanceCalendarConfigured);
Assert.Contains("Keine Verbindung.", vm.AttendanceRefreshError);
Assert.False(vm.IsRefreshingAttendanceData);
}
[Fact]
public void AdvancedContent_Fehlzeitenliste_AktiviertKonfigurationsschritt()
{
var store = StoreWithTemplate(new PlaceholderDefinition(
StudentAbsenceDayListDrawingBuilder.PlaceholderName, PlaceholderType.Drawing, true));
var drawing = StudentAbsenceDayListDrawingBuilder.Build("Lena Beispiel",
new AttendanceCalendarOptions(new DateOnly(2026, 9, 1), 1), []);
var vm = new CreateLetterDialogViewModel(StudentWithContact("Sehr geehrte Frau Muster,"), store,
new QuestTemplateRenderer(), new FakeMemberships([]), new FakeGroups([]),
attendanceCalendarFactory: null, absenceDayListFactory: (_, _, _, _) => drawing);
Assert.False(vm.UsesAttendanceCalendar);
Assert.True(vm.UsesAbsenceDayList);
Assert.True(vm.UsesAttendanceAdvancedContent);
Assert.True(vm.CanGenerate);
}
[Fact]
public void EigenerPlatzhalter_KannImDialogEingegebenWerden()
{
var store = StoreWithTemplate(new PlaceholderDefinition("Anrede", PlaceholderType.Text, true),
new PlaceholderDefinition("Datum", PlaceholderType.Date, true),
new PlaceholderDefinition("Brieftext", PlaceholderType.Multiline, true),
new PlaceholderDefinition("Betreff", PlaceholderType.Text, true));
var vm = Build(StudentWithContact("Sehr geehrte Frau Muster,"), store);
vm.LetterText = "Dies ist der Inhalt.";
var betreff = Assert.Single(vm.CustomPlaceholders);
Assert.Equal("Betreff", betreff.Name);
Assert.False(vm.CanGenerate);
Assert.Contains(vm.Issues, i => i.Message.Contains("Betreff", StringComparison.OrdinalIgnoreCase));
betreff.TextValue = "Wichtiger Termin";
var output = Path.Combine(_directory, "MitBetreff.pdf");
Assert.True(vm.CanGenerate);
Assert.True(vm.Generate(output));
Assert.Equal("%PDF", System.Text.Encoding.ASCII.GetString(File.ReadAllBytes(output), 0, 4));
}
[Fact]
public void StudentName_WirdAutomatischMitVollemNamenBefuellt()
{
var store = StoreWithTemplate(new PlaceholderDefinition("Anrede", PlaceholderType.Text, true),
new PlaceholderDefinition("Datum", PlaceholderType.Date, true),
new PlaceholderDefinition("Brieftext", PlaceholderType.Multiline, true),
new PlaceholderDefinition("Student.Name", PlaceholderType.Text, true));
var vm = Build(StudentWithContact("Sehr geehrte Frau Muster,"), store);
vm.LetterText = "Dies ist der Inhalt.";
Assert.Empty(vm.CustomPlaceholders);
Assert.True(vm.CanGenerate);
var output = Path.Combine(_directory, "StudentName.pdf");
Assert.True(vm.Generate(output));
}
[Fact]
public void Anrede_WirdAusKontaktVorbelegtUndBleibtEditierbar()
{
var store = StoreWithTemplate(new PlaceholderDefinition("Anrede", PlaceholderType.Text, true),
new PlaceholderDefinition("Datum", PlaceholderType.Date, true),
new PlaceholderDefinition("Brieftext", PlaceholderType.Multiline, true));
var vm = Build(StudentWithContact(null), store);
vm.LetterText = "Dies ist der Inhalt.";
Assert.Equal("", vm.Anrede);
Assert.False(vm.CanGenerate);
Assert.Contains(vm.Issues, i => i.Message.Contains("Anrede", StringComparison.OrdinalIgnoreCase));
vm.Anrede = "Sehr geehrte Familie Beispiel,";
var output = Path.Combine(_directory, "AnredeManuell.pdf");
Assert.True(vm.CanGenerate);
Assert.True(vm.Generate(output));
}
[Fact]
public void AddressPreview_FolgtDemGewaehltenKontaktUndAktualisiertSichBeimWechsel()
{
var student = new Student
{
FirstName = "Lena", LastName = "Beispiel",
Contacts =
[
new Contact { Name = "Frau Beispiel", Relation = "Mutter", Street = "Erste Str. 1", PostalCode = "11111", City = "Erststadt" },
new Contact { Name = "Herr Beispiel", Relation = "Vater", Street = "Zweite Str. 2", PostalCode = "22222", City = "Zweitstadt" },
],
};
var vm = Build(student, StoreWithTemplate(new PlaceholderDefinition("Anrede", PlaceholderType.Text, true)));
Assert.Equal(2, vm.Contacts.Count);
Assert.Contains("Frau Beispiel", vm.AddressPreview);
Assert.Contains("Erste Str. 1", vm.AddressPreview);
vm.SelectedContact = vm.Contacts.Single(c => c.Model.Name == "Herr Beispiel");
Assert.Contains("Herr Beispiel", vm.AddressPreview);
Assert.Contains("Zweite Str. 2", vm.AddressPreview);
Assert.DoesNotContain("Frau Beispiel", vm.AddressPreview);
}
[Fact]
public void SelectContactDialog_StartetBeimAktuellenKontaktUndAktualisiertVorschau()
{
var contacts = new List<LetterContactChoice>
{
new(new Contact { Name = "Frau Beispiel", Street = "Erste Str. 1", PostalCode = "11111", City = "Erststadt" }),
new(new Contact { Name = "Herr Beispiel", Street = "Zweite Str. 2", PostalCode = "22222", City = "Zweitstadt" }),
};
var dialogVm = new SelectContactDialogViewModel(contacts, contacts[1]);
Assert.Same(contacts[1], dialogVm.SelectedContact);
Assert.Contains("Zweite Str. 2", dialogVm.AddressPreview);
dialogVm.SelectedContact = contacts[0];
Assert.Contains("Erste Str. 1", dialogVm.AddressPreview);
}
private CreateLetterDialogViewModel Build(Student student, TemplateStore store) =>
new(student, store, new QuestTemplateRenderer(), new FakeMemberships([]), new FakeGroups([]));
@@ -65,8 +306,18 @@ public sealed class CreateLetterDialogViewModelTests : IDisposable
var y = 20;
foreach (var definition in definitions)
{
var element = definition.Type == PlaceholderType.Multiline ? "TEXTBOX" : "TEXT";
lines.Add(element == "TEXTBOX" ? $"TEXTBOX 20 {y} 170 80 ${definition.Name}" : $"TEXT 20 {y} ${definition.Name}");
var element = definition.Type switch
{
PlaceholderType.Multiline => "TEXTBOX",
PlaceholderType.Drawing => "DRAWBOX",
_ => "TEXT",
};
lines.Add(element switch
{
"TEXTBOX" => $"TEXTBOX 20 {y} 170 80 ${definition.Name}",
"DRAWBOX" => $"DRAWBOX 20 {y} 170 80 ${definition.Name}",
_ => $"TEXT 20 {y} ${definition.Name}",
});
y += 20;
}
TemplatePackage.Create(source, manifest, string.Join('\n', lines), new Dictionary<string, byte[]>());
@@ -10,6 +10,33 @@ namespace LehrerApp.Desktop.Tests;
/// zusammen, was Planung/Klausuren/Mitarbeit/Dokumentation ohnehin schon verwalten.
public sealed class GroupOverviewViewModelTests
{
[Fact]
public void UnterrichtHeute_BietetNurHeutigeNichtAusgefalleneStundenUndOeffnetAuswahl()
{
var group = new LearningGroup { IsActive = true };
var today = DateOnly.FromDateTime(DateTime.Today);
var lessons = new FakeLessons();
var first = new Lesson { GroupId = group.Id, Date = today, Topic = "Erste Stunde" };
var second = new Lesson { GroupId = group.Id, Date = today, Topic = "Zweite Stunde" };
lessons.Add(first);
lessons.Add(second);
lessons.Add(new Lesson { GroupId = group.Id, Date = today, Status = LessonStatus.Cancelled });
lessons.Add(new Lesson { GroupId = group.Id, Date = today.AddDays(1) });
lessons.Add(new Lesson { GroupId = Guid.NewGuid(), Date = today });
var vm = NewVm(lessons: lessons, groups: new FakeGroups([group]));
vm.Initialize(group.Id, group.Name);
Assert.True(vm.HasTodayLessons);
Assert.Equal(2, vm.TodayLessons.Count);
Lesson? opened = null;
vm.OnOpenTeachingMode = lesson => opened = lesson;
vm.SelectedTeachingLesson = second;
vm.StartTeachingModeCommand.Execute(null);
Assert.Same(second, opened);
group.IsActive = false;
vm.Refresh();
Assert.False(vm.HasTodayLessons);
}
private static GroupOverviewViewModel NewVm(FakeLessons? lessons = null, FakeExams? exams = null,
FakeSessions? sessions = null, FakeEntries? entries = null, FakeStudents? students = null,
FakeDocumentation? documentation = null, FakeWorkTasks? tasks = null,
+23 -3
View File
@@ -16,9 +16,9 @@ public sealed class McpToolsTests
new[]
{
"download_lesson_attachment", "get_competency_catalog", "get_exams", "get_grades",
"get_lesson_plans", "get_named_untis_absence_pattern", "get_schedule", "get_students",
"get_subjects", "get_time_entries", "get_untis_absence_rows", "get_untis_hub_status",
"list_letter_templates", "render_letter",
"get_groups", "get_lesson_plans", "get_named_untis_absence_pattern", "get_schedule",
"get_students", "get_subjects", "get_time_entries", "get_untis_absence_rows",
"get_untis_hub_status", "list_letter_templates", "render_letter",
},
McpToolScope.AllowedReadTools.OrderBy(n => n, StringComparer.Ordinal));
}
@@ -59,6 +59,26 @@ public sealed class McpToolsTests
n.Contains("note", StringComparison.OrdinalIgnoreCase));
}
// ── GroupTools ───────────────────────────────────────────────────────────────────────────
[Fact]
public void GetGroups_LiefertIdNameUndSchuljahrJeGruppe()
{
var group = new LearningGroup
{
Name = "9a", Type = GroupType.Class, SchoolYear = "2025/26", GradeLevel = 9, IsActive = true,
};
var tool = new GroupTools(new FakeGroups([group]));
var result = Assert.Single(tool.GetGroups());
Assert.Equal(group.Id, result.Id);
Assert.Equal("9a", result.Name);
Assert.Equal("2025/26", result.SchoolYear);
Assert.Equal(9, result.GradeLevel);
Assert.True(result.IsActive);
}
// ── StudentTools ─────────────────────────────────────────────────────────────────────────
[Fact]
@@ -0,0 +1,76 @@
using LehrerApp.Core.Models;
using LehrerApp.Desktop.ViewModels.Groups;
using LehrerApp.Templating;
using Xunit;
namespace LehrerApp.Desktop.Tests;
public sealed class PersonalizeWorksheetDialogViewModelTests : IDisposable
{
private readonly string _directory = Path.Combine(Path.GetTempPath(), $"lehrerapp-worksheet-vm-tests-{Guid.NewGuid():N}");
public PersonalizeWorksheetDialogViewModelTests() => Directory.CreateDirectory(_directory);
[Fact]
public void Generate_ErzeugtEinePdfJeAusgewaehltemSchueler()
{
var group = new LearningGroup { Name = "8a", SchoolYear = "2025/26" };
var anna = new Student { FirstName = "Anna", LastName = "Adler" };
var ben = new Student { FirstName = "Ben", LastName = "Bauer" };
var vm = Build(group, [anna, ben], StoreWithTemplate());
var output = Path.Combine(_directory, "output");
vm.Generate(output);
Assert.Equal(2, vm.Results.Count);
Assert.All(vm.Results, r => Assert.True(r.Success));
Assert.Equal(2, Directory.GetFiles(output, "*.pdf").Length);
}
[Fact]
public void Generate_AbgewaehlterSchueler_WirdUebersprungen()
{
var group = new LearningGroup { Name = "8a", SchoolYear = "2025/26" };
var anna = new Student { FirstName = "Anna", LastName = "Adler" };
var ben = new Student { FirstName = "Ben", LastName = "Bauer" };
var vm = Build(group, [anna, ben], StoreWithTemplate());
vm.Students.Single(s => s.Model.Id == ben.Id).IsIncluded = false;
var output = Path.Combine(_directory, "output");
vm.Generate(output);
Assert.Single(vm.Results);
Assert.Equal(anna.FullName, vm.Results[0].StudentName);
}
[Fact]
public void OhneVorlage_GenerateTutNichts()
{
var group = new LearningGroup { Name = "8a", SchoolYear = "2025/26" };
var anna = new Student { FirstName = "Anna", LastName = "Adler" };
var vm = Build(group, [anna], new WorksheetTemplateStore(new TemplateStore(Path.Combine(_directory, "empty-store"))));
var output = Path.Combine(_directory, "output");
vm.Generate(output);
Assert.Empty(vm.Results);
}
private static PersonalizeWorksheetDialogViewModel Build(LearningGroup group, List<Student> students, WorksheetTemplateStore store) =>
new(group, [.. students.Select(s => s.Id)], new FakeStudents(students), store, new QuestTemplateRenderer());
private WorksheetTemplateStore StoreWithTemplate()
{
var source = Path.Combine(_directory, $"{Guid.NewGuid():N}.lavorlage");
var manifest = new TemplateManifest
{
Id = $"arbeitsblatt-{Guid.NewGuid():N}", Name = "Arbeitsblatt",
Placeholders = [new PlaceholderDefinition("Student.FirstName", PlaceholderType.Text)],
};
TemplatePackage.Create(source, manifest, "PAGE 210 297 mm\nTEXT 20 20 $Student.FirstName", new Dictionary<string, byte[]>());
var store = new WorksheetTemplateStore(new TemplateStore(Path.Combine(_directory, $"store-{Guid.NewGuid():N}")));
store.Store.Import(source);
return store;
}
public void Dispose() { if (Directory.Exists(_directory)) Directory.Delete(_directory, true); }
}
@@ -0,0 +1,81 @@
using LehrerApp.Core.Models;
using LehrerApp.Desktop.ViewModels.Groups;
using Xunit;
namespace LehrerApp.Desktop.Tests;
public sealed class SeatingQuickCheckTests
{
private static (SeatingPlanTabViewModel Vm, FakeEntries Entries, ParticipationSession Session, Student Student) Build(bool readOnly = false)
{
var group = Guid.NewGuid();
var student = new Student { FirstName = "Anna", LastName = "Beispiel" };
var session = new ParticipationSession { GroupId = group, Date = DateOnly.FromDateTime(DateTime.Today) };
var entries = new FakeEntries();
var plan = new SeatingPlan { GroupId = group, Rows = 1, Columns = 2,
Assignments = [new SeatAssignment { Row = 0, Column = 0, StudentId = student.Id }] };
var vm = new SeatingPlanTabViewModel(new FakeSeatingPlans([plan]), new FakeStudents([student]),
new FakeMemberships([new GroupMembership { GroupId = group, StudentId = student.Id }]),
new FakeSessions([session]), entries, new FakeAspects());
vm.Initialize(group, readOnly);
return (vm, entries, session, student);
}
[Fact] public void Attendance_UsesSeatAndPreservesExistingHomeworkAndCounters()
{
var (vm, entries, session, student) = Build();
entries.Save(new ParticipationEntry { SessionId = session.Id, StudentId = student.Id,
Homework = HomeworkStatus.Completed, RaisedHandCount = 3, CalledOnCount = 2 });
vm.CheckAttendanceCommand.Execute(null);
var seat = vm.Seats[0];
Assert.True(seat.ShowQuickCheck);
Assert.False(vm.Seats[1].ShowQuickCheck);
seat.QuickNegativeCommand.Execute(null);
var entry = entries.GetBySessionAndStudent(session.Id, student.Id)!;
Assert.Equal(AttendanceStatus.ExcusePending, entry.Attendance);
Assert.Equal(HomeworkStatus.Completed, entry.Homework);
Assert.Equal(3, entry.RaisedHandCount);
Assert.Equal(2, entry.CalledOnCount);
Assert.Equal(1, seat.DisplayOpacity);
seat.QuickPositiveCommand.Execute(null);
Assert.Equal(AttendanceStatus.Present, entry.Attendance);
}
[Fact] public void Homework_UpdatesLegacyFlagAndPreservesAttendance()
{
var (vm, entries, session, student) = Build();
entries.Save(new ParticipationEntry { SessionId = session.Id, StudentId = student.Id, Attendance = AttendanceStatus.Late });
vm.CheckHomeworkCommand.Execute(null);
vm.Seats[0].QuickNegativeCommand.Execute(null);
var entry = entries.GetBySessionAndStudent(session.Id, student.Id)!;
Assert.Equal(HomeworkStatus.MissingOpen, entry.Homework);
Assert.True(entry.HomeworkMissing);
Assert.Equal(AttendanceStatus.Late, entry.Attendance);
vm.Seats[0].QuickPositiveCommand.Execute(null);
Assert.Equal(HomeworkStatus.Completed, entry.Homework);
Assert.False(entry.HomeworkMissing);
vm.EndQuickCheckCommand.Execute(null);
Assert.False(vm.Seats[0].ShowQuickCheck);
Assert.True(vm.Seats[0].ShowNormalActions);
}
[Fact] public async Task SpecialCases_OpenAssessmentForClickedStudentAndSession()
{
var (vm, _, _, _) = Build();
var calls = 0;
vm.OnAssessStudent = _ => { calls++; return Task.CompletedTask; };
vm.CheckAttendanceCommand.Execute(null);
await vm.Seats[0].QuickSpecialCommand.ExecuteAsync(null);
Assert.Equal(1, calls);
}
[Fact] public void ReadOnlyAndEmptySeats_CannotWriteQuickChecks()
{
var (vm, entries, session, _) = Build(readOnly: true);
vm.CheckHomeworkCommand.Execute(null);
vm.Seats[0].QuickNegativeCommand.Execute(null);
vm.Seats[1].QuickPositiveCommand.Execute(null);
Assert.False(vm.Seats[0].ShowQuickCheck);
Assert.Empty(entries.GetBySession(session.Id));
}
}
@@ -35,7 +35,7 @@ public sealed class SettingsViewModelTests
new FakeDocumentation(), new FakeStudents([]), new FakeShorthandCodes([]),
holidays ?? new FakeSchoolHolidays(), new SchoolCalendarSettingsService(tempPath),
new PeriodScheduleService(tempPath), supervisionDuties ?? new FakeSupervisionDuties(),
new TemplateStore(tempPath), TestSupport.BuildAiSettingsService(), TestSupport.BuildAiPlanningService(),
new TemplateStore(tempPath), new WorksheetTemplateStore(new TemplateStore(tempPath, subfolder: "worksheet-template-packages")), TestSupport.BuildAiSettingsService(), TestSupport.BuildAiPlanningService(),
TestSupport.BuildMcpSettingsService(),
TestSupport.BuildMcpClientRegistrationService(),
TestSupport.BuildWebUntisSettingsService(),
@@ -342,7 +342,7 @@ public sealed class SettingsViewModelTests
new LiteDbContext(new MemoryStream()), new PrivacySettingsService(tempPath),
new FakeDocumentation(), new FakeStudents([]), new FakeShorthandCodes([]),
new FakeSchoolHolidays(), calendarSettings, new PeriodScheduleService(tempPath),
new FakeSupervisionDuties(), new TemplateStore(tempPath), TestSupport.BuildAiSettingsService(), TestSupport.BuildAiPlanningService(),
new FakeSupervisionDuties(), new TemplateStore(tempPath), new WorksheetTemplateStore(new TemplateStore(tempPath, subfolder: "worksheet-template-packages")), TestSupport.BuildAiSettingsService(), TestSupport.BuildAiPlanningService(),
TestSupport.BuildMcpSettingsService(),
TestSupport.BuildMcpClientRegistrationService(),
TestSupport.BuildWebUntisSettingsService(),
@@ -372,7 +372,7 @@ public sealed class SettingsViewModelTests
new LiteDbContext(new MemoryStream()), new PrivacySettingsService(tempPath),
new FakeDocumentation(), new FakeStudents([]), new FakeShorthandCodes([]),
new FakeSchoolHolidays(), new SchoolCalendarSettingsService(tempPath), periodSchedule,
new FakeSupervisionDuties(), new TemplateStore(tempPath), TestSupport.BuildAiSettingsService(), TestSupport.BuildAiPlanningService(),
new FakeSupervisionDuties(), new TemplateStore(tempPath), new WorksheetTemplateStore(new TemplateStore(tempPath, subfolder: "worksheet-template-packages")), TestSupport.BuildAiSettingsService(), TestSupport.BuildAiPlanningService(),
TestSupport.BuildMcpSettingsService(),
TestSupport.BuildMcpClientRegistrationService(),
TestSupport.BuildWebUntisSettingsService(),
@@ -406,7 +406,7 @@ public sealed class SettingsViewModelTests
new LiteDbContext(new MemoryStream()), new PrivacySettingsService(tempPath),
new FakeDocumentation(), new FakeStudents([]), new FakeShorthandCodes([]),
new FakeSchoolHolidays(), new SchoolCalendarSettingsService(tempPath), periodSchedule,
new FakeSupervisionDuties(), new TemplateStore(tempPath), TestSupport.BuildAiSettingsService(), TestSupport.BuildAiPlanningService(),
new FakeSupervisionDuties(), new TemplateStore(tempPath), new WorksheetTemplateStore(new TemplateStore(tempPath, subfolder: "worksheet-template-packages")), TestSupport.BuildAiSettingsService(), TestSupport.BuildAiPlanningService(),
TestSupport.BuildMcpSettingsService(),
TestSupport.BuildMcpClientRegistrationService(),
TestSupport.BuildWebUntisSettingsService(),
@@ -0,0 +1,49 @@
using LehrerApp.Core.Models;
using LehrerApp.Desktop.ViewModels.Students;
using Xunit;
namespace LehrerApp.Desktop.Tests;
public sealed class StudentPickerDialogViewModelTests
{
private static (StudentPickerDialogViewModel Vm, Student Anna, Student Ben) Build()
{
var anna = new Student { FirstName = "Anna", LastName = "Adler" };
var ben = new Student { FirstName = "Ben", LastName = "Bauer" };
var vm = new StudentPickerDialogViewModel(new FakeStudents([anna, ben]));
return (vm, anna, ben);
}
[Fact]
public void SearchText_FiltertNachTeilstring()
{
var (vm, anna, _) = Build();
vm.SearchText = "ann";
Assert.Single(vm.Students);
Assert.Equal(anna.FullName, vm.Students[0].FullName);
}
[Fact]
public void Select_OhneAuswahl_SetztValidationMessage()
{
var (vm, _, _) = Build();
vm.SelectCommand.Execute(null);
Assert.Null(vm.Result);
Assert.NotEqual("", vm.ValidationMessage);
}
[Fact]
public void Select_MitAuswahl_LiefertVollenSchueler()
{
var (vm, anna, _) = Build();
vm.SelectedStudent = vm.Students.Single(s => s.Id == anna.Id);
vm.SelectCommand.Execute(null);
Assert.Equal(anna.Id, vm.Result?.Id);
}
}
@@ -0,0 +1,183 @@
using LehrerApp.Core.Models;
using LehrerApp.Desktop.ViewModels.Groups;
using Xunit;
namespace LehrerApp.Desktop.Tests;
public sealed class TeachingTimelineViewModelTests
{
private readonly DateTime _start = new(2026, 9, 14, 8, 0, 0, DateTimeKind.Utc);
private DateTime _now;
private readonly FakeLessons _lessons = new();
private (Lesson Lesson, TeachingTimelineViewModel Vm) Build(bool scheduled = true)
{
_now = _start.AddMinutes(5);
var local = _start.ToLocalTime();
var lesson = new Lesson
{
Date = DateOnly.FromDateTime(local),
StartTime = scheduled ? TimeOnly.FromDateTime(local) : null,
Phases = [new() { Name = "Einstieg", DurationMinutes = 10 },
new() { Name = "Arbeit", DurationMinutes = 10 },
new() { Name = "Sicherung", DurationMinutes = 10 }]
};
_lessons.Add(lesson);
return (lesson, new TeachingTimelineViewModel(lesson, _lessons, utcNow: () => _now));
}
[Fact] public void Clock_HighlightsExactlyOnePhaseAtBoundary()
{
var (_, vm) = Build();
Assert.True(vm.Phases[0].IsActive);
Assert.Equal(50, vm.Phases[0].Progress);
_now = _start.AddMinutes(10);
vm.Refresh();
Assert.True(vm.Phases[0].IsCompleted);
Assert.True(vm.Phases[1].IsActive);
Assert.Single(vm.Phases, p => p.IsActive);
}
[Fact] public void NoStartTime_RequiresExplicitStart()
{
var (_, vm) = Build(scheduled: false);
Assert.True(vm.NeedsStart);
Assert.DoesNotContain(vm.Phases, p => p.IsActive);
vm.StartNowCommand.Execute(null);
Assert.False(vm.NeedsStart);
Assert.True(vm.Phases[0].IsActive);
Assert.Equal(_now, vm.Phases[0].StartUtc);
}
[Fact] public void Extend_ShiftsLaterPhasesAndPreservesOriginalPlan()
{
var (lesson, vm) = Build();
vm.Phases[0].ExtendTenCommand.Execute(null);
Assert.Equal(_start.AddMinutes(20), vm.Phases[1].StartUtc);
Assert.True(vm.Phases[2].IsOverflow);
Assert.Equal(10, lesson.Phases[0].DurationMinutes);
Assert.NotNull(_lessons.GetById(lesson.Id)!.TeachingTimeline);
}
[Fact] public void FinishEarly_StartsNextPhaseNow()
{
var (_, vm) = Build();
vm.Phases[0].FinishCommand.Execute(null);
Assert.True(vm.Phases[0].IsCompleted);
Assert.True(vm.Phases[1].IsActive);
Assert.Equal(_now, vm.Phases[1].StartUtc);
}
[Fact] public void Hold_SurvivesReopenAndContinuesOnlyOnNext()
{
var (lesson, vm) = Build();
vm.Phases[0].HoldCommand.Execute(null);
_now = _start.AddMinutes(65);
var reopened = new TeachingTimelineViewModel(lesson, _lessons, utcNow: () => _now);
Assert.True(reopened.Phases[0].IsHeld);
Assert.True(reopened.Phases[0].IsActive);
Assert.Single(reopened.Phases, p => p.IsActive);
reopened.Phases[0].FinishCommand.Execute(null);
Assert.True(reopened.Phases[1].IsActive);
Assert.False(reopened.Phases[0].IsHeld);
Assert.Equal(_now, reopened.Phases[1].StartUtc);
}
[Fact] public void BringForward_KeepsSkippedPendingPhasesBelowChosenPhase()
{
var (_, vm) = Build();
var chosen = vm.Phases[2];
chosen.BringForwardCommand.Execute(null);
Assert.Equal(new[] { "Einstieg", "Sicherung", "Arbeit" }, vm.Phases.Select(p => p.Source.Name));
Assert.True(chosen.IsActive);
Assert.Equal(_now, chosen.StartUtc);
Assert.False(vm.Phases[2].IsCompleted);
}
[Fact] public void Overflow_RemainsPendingTheNextDayAndCanBeStartedNow()
{
var (lesson, vm) = Build();
vm.Phases[0].ExtendTenCommand.Execute(null);
_now = _start.AddDays(1);
var reopened = new TeachingTimelineViewModel(lesson, _lessons, utcNow: () => _now);
var remainder = reopened.Phases[2];
Assert.True(remainder.IsOverflow);
Assert.False(remainder.IsCompleted);
Assert.False(remainder.IsActive);
Assert.True(reopened.HasRemainder);
remainder.BringForwardCommand.Execute(null);
Assert.True(remainder.IsActive);
Assert.Equal(_now, remainder.StartUtc);
}
[Fact] public void Transfer_CopiesIntoSelectedLessonOnlyOnceAndKeepsSource()
{
var (lesson, _) = Build();
var target = new Lesson { GroupId = lesson.GroupId, Date = lesson.Date.AddDays(1), Topic = "Folgestunde" };
_lessons.Add(target);
var vm = new TeachingTimelineViewModel(lesson, _lessons, utcNow: () => _now);
vm.Phases[0].ExtendTenCommand.Execute(null);
vm.TransferRemainderCommand.Execute(null);
vm.TransferRemainderCommand.Execute(null);
var copy = Assert.Single(target.Phases);
Assert.Equal("Sicherung", copy.Name);
Assert.NotEqual(lesson.Phases[2].Id, copy.Id);
Assert.Equal(3, lesson.Phases.Count);
Assert.True(vm.Phases[2].IsTransferred);
}
[Fact] public void PartiallyOverflowingPhase_PreservesOnlyUnfinishedMinutesAfterLessonEnds()
{
var (lesson, vm) = Build();
vm.Phases[0].ExtendFiveCommand.Execute(null);
_now = _start.AddMinutes(28);
vm.Refresh();
Assert.True(vm.Phases[2].IsActive);
_now = _start.AddDays(1);
var reopened = new TeachingTimelineViewModel(lesson, _lessons, utcNow: () => _now);
var remainder = reopened.Phases[2];
Assert.False(remainder.IsCompleted);
Assert.False(remainder.IsActive);
Assert.True(reopened.HasRemainder);
Assert.Equal(5, remainder.RemainingMinutes);
remainder.BringForwardCommand.Execute(null);
Assert.True(remainder.IsActive);
Assert.Equal(_now.AddMinutes(5), remainder.EndUtc);
}
[Fact] public void ReadOnly_DoesNotChangeTimingOrStartClock()
{
var (lesson, _) = Build(scheduled: false);
var vm = new TeachingTimelineViewModel(lesson, _lessons, readOnly: true, utcNow: () => _now);
vm.StartNowCommand.Execute(null);
Assert.Null(lesson.TeachingTimeline);
Assert.True(vm.NeedsStart);
}
[Fact] public void AlternativePhases_AreNotRunAlongsideMainPath()
{
var (lesson, _) = Build();
lesson.Phases.Add(new LessonPhaseStep { AlternativePathId = Guid.NewGuid(), DurationMinutes = 30 });
var vm = new TeachingTimelineViewModel(lesson, _lessons, utcNow: () => _now);
Assert.Equal(3, vm.Phases.Count);
Assert.Equal(_start.AddMinutes(30), vm.Phases[^1].EndUtc);
}
[Fact] public void HeldTiming_RoundTripsThroughLiteDbWithoutTimezoneShift()
{
using var stream = new MemoryStream();
using var db = new LehrerApp.Data.LiteDbContext(stream);
var repository = new LehrerApp.Data.Repositories.LessonRepository(db);
var (lesson, _) = Build();
repository.Save(lesson);
var vm = new TeachingTimelineViewModel(lesson, repository, utcNow: () => _now);
vm.Phases[0].HoldCommand.Execute(null);
_now = _start.AddMinutes(40);
var loaded = repository.GetById(lesson.Id)!;
var reopened = new TeachingTimelineViewModel(loaded, repository, utcNow: () => _now);
Assert.True(reopened.Phases[0].IsActive);
Assert.True(reopened.Phases[0].IsHeld);
Assert.Equal(_start, reopened.Phases[0].StartUtc);
Assert.Equal(_start.AddMinutes(45), reopened.Phases[0].EndUtc);
}
}
@@ -18,4 +18,18 @@ public sealed class UntisNameMatchingTests
[InlineData("Ben Schmidt", "")]
public void NamesMatch_LehntUnterschiedlicheOderFehlendeNamenAb(string? a, string b) =>
Assert.False(UntisNameMatching.NamesMatch(a, b));
// Regression: Student.FullName liefert "Nachname, Vorname" (mit Komma) für Anzeigezwecke.
// Wird dieser String direkt an NamesMatch übergeben, bleibt das Komma am Wort kleben
// ("gerste," != "gerste") und der Abgleich gegen WebUntis-Namen (immer ohne Komma) schlägt
// fehl - genau das ließ den Anwesenheitskalender/die Fehlzeitenliste im Elternbrief leer
// bleiben, obwohl echte Fehlzeiten vorlagen. Aufrufer müssen deshalb "Vorname Nachname" ohne
// Komma bilden (siehe StudentAttendanceCalendarDrawingBuilder.StudentAttendanceCalendarService
// und ClassTeacherOverviewViewModel.cs:1173), statt Student.FullName direkt zu verwenden.
[Fact]
public void NamesMatch_KommaGetrennterAnzeigename_PasstNichtOhneUmformung()
{
Assert.False(UntisNameMatching.NamesMatch("Gerste, Amelia", "Gerste Amelia"));
Assert.True(UntisNameMatching.NamesMatch("Amelia Gerste", "Gerste Amelia"));
}
}
@@ -0,0 +1,40 @@
using LehrerApp.Templating;
using Xunit;
namespace LehrerApp.Desktop.Tests;
public sealed class _TempFlowCheck2
{
[Fact]
public void MarkerTest()
{
var outDir = @"C:\Users\SHedt\AppData\Local\Temp\claude\d--source-LehrerApp\fb3681df-caaf-4f2d-9996-e343e44f5554\scratchpad";
Directory.CreateDirectory(outDir);
var commands = new List<DrawingCommand>
{
new DrawStringEx(0, 0, 12, 170, "TOP-0", DrawingTextAlignment.AlignLeft, 10, Color: "#000000"),
new DrawStringEx(0, 90, 12, 170, "NEARBOTTOM-90", DrawingTextAlignment.AlignLeft, 10, Color: "#000000"),
new DrawStringEx(0, 105, 12, 170, "AFTERBOUNDARY-105", DrawingTextAlignment.AlignLeft, 10, Color: "#000000"),
new DrawStringEx(0, 190, 12, 170, "BOTTOM-190", DrawingTextAlignment.AlignLeft, 10, Color: "#000000"),
};
var drawing = new DrawingValue(commands, 200);
var manifest = new TemplateManifest
{
Id = "marker", Name = "Marker",
Placeholders = [new("Marker", PlaceholderType.Drawing, true)],
};
var layout = "PAGE 210 297 mm\nFLOWDRAWBOX 20 20 170 100 $Marker\n";
var loadedLayout = new LayoutParser().Parse(layout);
var loaded = new LoadedTemplate(manifest, loadedLayout, new Dictionary<string, byte[]>());
var provider = new FakeProvider(new Dictionary<string, PlaceholderValue> { ["Marker"] = drawing });
var pngs = new QuestTemplateRenderer().RenderPagesToPng(loaded, provider, dpi: 150);
for (var i = 0; i < pngs.Count; i++)
File.WriteAllBytes(Path.Combine(outDir, $"marker2-{i}.png"), pngs[i]);
}
private sealed class FakeProvider(IReadOnlyDictionary<string, PlaceholderValue> values) : ITemplateDataProvider
{ public IReadOnlyDictionary<string, PlaceholderValue> GetValues() => values; }
}
+45 -13
View File
@@ -26,6 +26,8 @@ public class App : Application
public static IServiceProvider Services { get; private set; } = null!;
private static ServiceProvider? _serviceProvider;
private static bool _exitHandlerAttached;
private static Task _initialPolls = Task.CompletedTask;
private static readonly CancellationTokenSource StartupCancellation = new();
public override void Initialize() => AvaloniaXamlLoader.Load(this);
@@ -65,7 +67,11 @@ public class App : Application
promptVm.OnUnlocked = async password =>
{
AppBootstrapper.DbPassword = password;
await StartMainAppAsync(desktop, promptWindow);
var unlockedSplash = new SplashWindow();
desktop.MainWindow = unlockedSplash;
unlockedSplash.Show();
promptWindow.Close();
await StartMainAppAsync(desktop, unlockedSplash);
};
desktop.MainWindow = promptWindow;
promptWindow.Show();
@@ -79,14 +85,21 @@ public class App : Application
private static async Task StartMainAppAsync(
IClassicDesktopStyleApplicationLifetime desktop, Window? windowToClose = null)
{
_serviceProvider = AppBootstrapper.BuildServices();
var splash = windowToClose as SplashWindow;
var timer = System.Diagnostics.Stopwatch.StartNew();
var progress = new Progress<(int Value, string Text)>(step =>
splash?.SetProgress(step.Value, step.Text));
_serviceProvider = await Task.Run(() => AppBootstrapper.BuildServices(
(value, text) => ((IProgress<(int, string)>)progress).Report((value, text))));
Services = _serviceProvider;
Services.GetRequiredService<AppLogger>().Info("Anwendung gestartet.");
GlobalExceptionHandler.AttachNotifications(Services.GetRequiredService<NotificationService>());
// Papierkorb (14.3): Einträge älter als 30 Tage endgültig entfernen. Beim Start statt per
// Timer - reicht für ein Werkzeug, das ohnehin nur "Fehlklick eben rückgängig machen" sein
// soll, kein dauerhaftes Archiv.
Services.GetRequiredService<ITrashRepository>().PurgeOlderThan(DateTime.UtcNow.AddDays(-30));
splash?.SetProgress(60, "Papierkorb aufräumen …");
await Task.Run(() => Services.GetRequiredService<ITrashRepository>()
.PurgeOlderThan(DateTime.UtcNow.AddDays(-30)));
if (!_exitHandlerAttached)
{
@@ -99,19 +112,13 @@ public class App : Application
// Opt-in - ein Neustart der App richtet den Pipe-Listener neu ein.
Services.GetRequiredService<Services.Mcp.McpServerHostedService>().Start();
splash?.SetProgress(75, "Übersicht vorbereiten …");
await Dispatcher.UIThread.InvokeAsync(() => { }, DispatcherPriority.Background);
var mainVm = Services.GetRequiredService<MainWindowViewModel>();
WireCallbacks(mainVm);
// Beide optionalen Erstabgleiche noch unter dem Splashscreen abschließen. Ihre
// CPU-/Datenbankarbeit läuft innerhalb der Dienste im Threadpool; dadurch öffnet das
// Hauptfenster mit fertigem Datenstand und friert nicht kurz danach ein. Das Auflösen
// aktiviert zugleich die periodischen Timer.
var initialPolls = new List<Task>();
if (Services.GetService<UntisSyncService>() is { } untisSync)
initialPolls.Add(untisSync.PollAsync());
if (Services.GetService<AnnualPlanSyncService>() is { } annualPlanSync)
initialPolls.Add(annualPlanSync.PollAsync());
await Task.WhenAll(initialPolls);
splash?.SetProgress(90, "Hauptfenster öffnen …");
await Dispatcher.UIThread.InvokeAsync(() => { }, DispatcherPriority.Background);
var main = new MainWindow { DataContext = mainVm };
main.EnableWindowSizePersistence(Services.GetRequiredService<WindowSettingsService>());
@@ -119,7 +126,30 @@ public class App : Application
main.EnableFinalSync(syncEngine);
desktop.MainWindow = main;
main.Show();
splash?.SetProgress(100, "Bereit");
windowToClose?.Close();
AppBootstrapper.Logger.Info($"Start: Hauptfenster nach {timer.ElapsedMilliseconds} ms geöffnet.");
// Vorhandene lokale Daten sind sofort nutzbar; Netzwerkzugriffe blockieren den Start nicht.
var untis = Services.GetService<UntisSyncService>();
var annualPlan = Services.GetService<AnnualPlanSyncService>();
if (untis is not null)
untis.DataChanged += () => Dispatcher.UIThread.Post(() =>
{
Services.GetRequiredService<TimetableViewModel>().Load();
Services.GetRequiredService<DashboardViewModel>().RefreshCommand.Execute(null);
});
_initialPolls = Task.Run(async () =>
{
try
{
await Task.WhenAll(
untis?.PollAsync(StartupCancellation.Token) ?? Task.CompletedTask,
annualPlan?.PollAsync(StartupCancellation.Token) ?? Task.CompletedTask);
}
catch (OperationCanceledException) when (StartupCancellation.IsCancellationRequested) { }
catch (Exception ex) { AppBootstrapper.Logger.Error("Erstabgleich fehlgeschlagen.", ex); }
});
}
/// <summary>Wechselt die Darstellung sofort, ohne Neustart (12.4) — Avalonia stylt den
@@ -140,6 +170,8 @@ public class App : Application
try
{
StartupCancellation.Cancel();
_initialPolls.GetAwaiter().GetResult();
serviceProvider.GetService<LiteDbContext>()?.Checkpoint();
}
catch (Exception ex)
+8 -1
View File
@@ -101,7 +101,7 @@ public static class AppBootstrapper
Environment.Exit(0);
}
public static ServiceProvider BuildServices()
public static ServiceProvider BuildServices(Action<int, string>? reportProgress = null)
{
var services = new ServiceCollection();
@@ -123,13 +123,16 @@ public static class AppBootstrapper
services.AddSingleton<ITemplateLoader, TemplateLoader>();
services.AddSingleton<ITemplateRenderer, QuestTemplateRenderer>();
services.AddSingleton(_ => new TemplateStore(appData));
services.AddSingleton(_ => new WorksheetTemplateStore(new TemplateStore(appData, subfolder: "worksheet-template-packages")));
// ── Datensicherheit (13.3) ───────────────────────────────────────────
// Backup läuft vor dem Öffnen der Datenbank, rein dateibasiert (unabhängig von
// Verschlüsselung) — reine Datei-Kopie, kein offener LiteDB-Handle nötig.
var backupSettings = new BackupSettingsService(appData);
var backup = new BackupService(appData) { SecondaryBackupDirectory = backupSettings.LoadSecondaryDirectory() };
reportProgress?.Invoke(10, "Sicherung erstellen …");
var backupPath = backup.CreateBackup(DbPath);
reportProgress?.Invoke(25, "Sicherung prüfen …");
// Best-effort-Prüfung des automatischen Startbackups: nur geloggt, kein Blocker für den
// Programmstart — ein beschädigtes Backup soll auffallen, nicht den Nutzer aufhalten.
if (backupPath is not null && !new DatabaseEncryptionService().CanOpenAndRead(backupPath, DbPassword))
@@ -226,7 +229,9 @@ public static class AppBootstrapper
services.AddSingleton<LessonPlanTools>();
services.AddSingleton<GroupMembershipTools>();
services.AddSingleton<LetterTemplateTools>();
services.AddSingleton<StudentAttendanceCalendarService>();
services.AddSingleton<CompetencyTools>();
services.AddSingleton<GroupTools>();
services.AddSingleton<UntisComparisonTools>();
services.AddSingleton<McpServerHostedService>();
services.AddSingleton<McpClientRegistrationService>();
@@ -361,7 +366,9 @@ public static class AppBootstrapper
services.AddTransient<TrashViewModel>();
services.AddTransient<SettingsViewModel>();
reportProgress?.Invoke(40, "Datenbank öffnen und aktualisieren …");
var provider = services.BuildServiceProvider();
provider.GetRequiredService<LiteDbContext>();
// Sync-agnostischer Hook auf LiteDbContext (siehe LiteDbContext.OnChange) wird erst hier,
// außerhalb der Repository-Registrierung, mit der tatsächlichen Sync-Logik verbunden.
+5
View File
@@ -1,4 +1,5 @@
using Avalonia;
using LehrerApp.Core.Mcp;
using LehrerApp.Desktop.Services;
namespace LehrerApp.Desktop;
@@ -8,6 +9,10 @@ class Program
[STAThread]
public static void Main(string[] args)
{
// Muss vor der ersten NamedPipeServerStream-Instanz laufen (siehe
// McpServerHostedService), sonst löst TMPDIR-Auseinanderdriften zwischen diesem Prozess
// und LehrerApp.McpBridge auf macOS/Linux "MCP nicht erreichbar" aus - siehe Doku dort.
McpPipeConstants.EnsureStableUnixSocketDirectory();
var logger = AppBootstrapper.EnsureLogger();
GlobalExceptionHandler.Install(logger);
@@ -188,6 +188,7 @@ public class AiPlanningService(HttpClient http, ILessonRepository lessons,
StartTime = l.StartTime,
Homework = l.Homework,
Reflection = l.Reflection,
PlanningIdeas = l.PlanningIdeas,
Phases = ToAiPhases(l.Phases, pathNames),
})
.ToList();
@@ -270,6 +271,9 @@ public class AiPlanningService(HttpClient http, ILessonRepository lessons,
if (!string.Equals(existing.Reflection, proposed.Reflection, StringComparison.Ordinal))
diffs.Add("Reflexion geändert");
if (!string.Equals(existing.PlanningIdeas, proposed.PlanningIdeas, StringComparison.Ordinal))
diffs.Add("Planungsideen geändert");
var pathNames = altPaths.GetAll().ToDictionary(p => p.Id, p => p.Name);
var existingPhases = ToAiPhases(existing.Phases, pathNames);
if (!PhasesEqual(existingPhases, proposed.Phases))
@@ -586,6 +590,7 @@ public class AiPlanningService(HttpClient http, ILessonRepository lessons,
StartTime = ai.StartTime,
Homework = ai.Homework,
Reflection = ai.Reflection,
PlanningIdeas = ai.PlanningIdeas,
// Status bleibt bei einer Änderung erhalten — sonst würde eine bereits
// durchgeführte Stunde durch eine KI-Anpassung stillschweigend auf "Geplant"
// zurückgesetzt (die KI kennt/liefert diesen Status gar nicht).
@@ -597,6 +602,10 @@ public class AiPlanningService(HttpClient http, ILessonRepository lessons,
Activity = p.Activity,
Material = p.Material,
Shorthand = p.Shorthand,
// Persistiert den Prompt (4.5.20/4.5.36), damit er nach dem Übernehmen weiterhin
// im Stundeneditor kopierbar bleibt statt nur einmalig im Review-Dialog.
MaterialPrompt = string.IsNullOrWhiteSpace(p.MaterialSuggestion)
? null : BuildMaterialPrompt(unit, ai, p),
AlternativePathId = p.AlternativePathName is { } name && pathIdsByName.TryGetValue(name, out var pathId)
? pathId : null,
}).ToList(),
@@ -36,7 +36,7 @@ public sealed class AnnualPlanSyncService : IDisposable
_timer = new Timer(async _ => await PollAsync(), null, PollInterval, PollInterval);
}
public async Task PollAsync()
public async Task PollAsync(CancellationToken cancellationToken = default)
{
if (!await _gate.WaitAsync(0).ConfigureAwait(false)) return;
try
@@ -47,7 +47,7 @@ public sealed class AnnualPlanSyncService : IDisposable
string icsText;
try
{
icsText = await _http.GetStringAsync(url).ConfigureAwait(false);
icsText = await _http.GetStringAsync(url, cancellationToken).ConfigureAwait(false);
}
catch (Exception ex)
{
@@ -0,0 +1,49 @@
using Avalonia.Controls;
using LehrerApp.Core.Interfaces;
using LehrerApp.Core.Models;
using LehrerApp.Desktop.ViewModels.Students;
using LehrerApp.Desktop.Views.Students;
using LehrerApp.Templating;
using Microsoft.Extensions.DependencyInjection;
namespace LehrerApp.Desktop.Services;
/// <summary>Öffnet den bestehenden Elternbrief-Dialog für einen Schüler, egal ob der Aufruf aus
/// der Schülerdetailansicht (Student schon im DataContext) oder aus dem Formulare-Menü (Student
/// erst per Picker gewählt) kommt.</summary>
public static class LetterDialogs
{
public static async Task ShowCreateLetterDialogAsync(Window owner, Student student)
{
var vm = new CreateLetterDialogViewModel(student,
App.Services.GetRequiredService<TemplateStore>(),
App.Services.GetRequiredService<ITemplateRenderer>(),
App.Services.GetRequiredService<IGroupMembershipRepository>(),
App.Services.GetRequiredService<IGroupRepository>(),
(options, contentWidth, millimeterScale, contentHeight) => App.Services.GetRequiredService<StudentAttendanceCalendarService>()
.Build(student, options, contentWidth, millimeterScale, contentHeight),
(options, contentWidth, millimeterScale, contentHeight) => App.Services.GetRequiredService<StudentAttendanceCalendarService>()
.BuildAbsenceDayList(student, options, contentWidth, millimeterScale),
RefreshAttendanceDataAsync);
var dialog = new CreateLetterDialog { DataContext = vm };
var path = await dialog.ShowDialog<string?>(owner);
if (!string.IsNullOrEmpty(path) && File.Exists(path))
System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo(path) { UseShellExecute = true });
}
/// <summary>Holt genau den im Anwesenheitskalender-Dialog gewählten Zeitraum gezielt per
/// WebUntis nach (ausgelöst durch den expliziten "Konfigurieren…"-Klick, kein Hintergrundabruf
/// beim bloßen Öffnen des Briefdialogs). Damit sieht <see cref="StudentAttendanceCalendarService"/>
/// anschließend frische Daten im lokalen Cache, statt stillschweigend "keine Fehltage" zu
/// melden, nur weil die Klassenlehrer-Übersicht für diesen Zeitraum noch nie geöffnet wurde.</summary>
private static async Task RefreshAttendanceDataAsync(AttendanceCalendarOptions options, CancellationToken token)
{
var className = App.Services.GetRequiredService<WebUntisSettingsService>().HomeroomClassName;
if (string.IsNullOrWhiteSpace(className)) return;
var cache = App.Services.GetRequiredService<UntisReportCacheService>();
var start = options.NormalizedStartMonth;
var end = start.AddMonths(options.NormalizedMonthCount).AddDays(-1);
await cache.GetAbsencesAsync(className, start, end, token: token);
await cache.GetClassRegisterEventsAsync(className, start, end, token: token);
}
}
@@ -15,27 +15,70 @@ public static class LetterPlaceholderBuilder
{
public static Dictionary<string, PlaceholderValue> BuildStandardValues(
Student student, Contact? contact, LearningGroup? group, DateOnly date,
string letterText, string teacherName)
string letterText, string teacherName, DrawingValue? attendanceCalendar = null,
DrawingValue? absenceDays = null)
{
var cityLine = string.Join(" ", new[] { contact?.PostalCode, contact?.City }.Where(x => !string.IsNullOrWhiteSpace(x)));
var address = string.Join(Environment.NewLine, new[] { contact?.Name, contact?.Street, cityLine }.Where(x => !string.IsNullOrWhiteSpace(x)));
var address = FormatAddress(contact);
return new Dictionary<string, PlaceholderValue>(StringComparer.Ordinal)
{
["Datum"] = new DateValue(date), ["CurrentDate"] = new DateValue(date),
["Empfaenger"] = new TextValue(contact?.Name ?? ""), ["Anrede"] = new TextValue(contact?.LetterSalutation ?? ""),
["Brieftext"] = new MultilineValue(letterText), ["LehrerName"] = new TextValue(teacherName),
["Student.FirstName"] = new TextValue(student.FirstName), ["Student.LastName"] = new TextValue(student.LastName),
["Student.Name"] = new TextValue(student.FullName),
["Contact.Name"] = new TextValue(contact?.Name ?? ""), ["Contact.Address"] = new MultilineValue(address),
["Contact.Street"] = new TextValue(contact?.Street ?? ""), ["Contact.PostalCode"] = new TextValue(contact?.PostalCode ?? ""),
["Contact.City"] = new TextValue(contact?.City ?? ""), ["Letter.Salutation"] = new TextValue(contact?.LetterSalutation ?? ""),
["Group.Name"] = new TextValue(group?.Name ?? ""), ["SchoolYear"] = new TextValue(group?.SchoolYear ?? ""),
[StudentAttendanceCalendarDrawingBuilder.PlaceholderName] = attendanceCalendar ?? new DrawingValue([], 0),
[StudentAbsenceDayListDrawingBuilder.PlaceholderName] = absenceDays ?? new DrawingValue([], 0),
};
}
/// <summary>Mehrzeilige Anschrift (Name/Straße/PLZ Ort) für Adressvorschau im Dialog und
/// den <c>Contact.Address</c>-Platzhalter - eine Formatierung für beide Verwendungen.</summary>
public static string FormatAddress(Contact? contact)
{
var cityLine = string.Join(" ", new[] { contact?.PostalCode, contact?.City }.Where(x => !string.IsNullOrWhiteSpace(x)));
return string.Join(Environment.NewLine, new[] { contact?.Name, contact?.Street, cityLine }.Where(x => !string.IsNullOrWhiteSpace(x)));
}
public static bool IsEmpty(PlaceholderValue value) => value switch
{
TextValue x => string.IsNullOrWhiteSpace(x.Value),
MultilineValue x => string.IsNullOrWhiteSpace(x.Value),
DrawingValue x => x.ContentHeight <= 0 || x.Commands.Count == 0,
_ => false,
};
/// <summary>Tatsächliche DRAWBOX/FLOWDRAWBOX-Größe eines Platzhalters (in der Maßeinheit der
/// Vorlage) plus dem Umrechnungsfaktor "1mm in dieser Maßeinheit". DrawingValue-Builder wie
/// <see cref="StudentAttendanceCalendarDrawingBuilder"/> entwerfen ihr Raster in Millimetern;
/// ohne MillimeterScale bliebe das bei einer in "pt" (statt "mm") deklarierten Vorlage viel zu
/// klein (1 "mm-Einheit" würde als 1pt ≈ 0.35mm gerendert). IsFixed unterscheidet DRAWBOX (feste,
/// nicht umbrechende Box - Inhalt, der die Höhe sprengt, wird von QuestTemplateRenderer
/// stillschweigend am unteren Rand abgeschnitten statt umzubrechen) von FLOWDRAWBOX (fließt bei
/// Bedarf automatisch auf weitere Seiten, siehe DrawingElementRenderer.Slice) - nur bei
/// IsFixed=true darf/muss ein Builder seine Höhe an Height anpassen.</summary>
public readonly record struct DeclaredDrawingBox(float Width, float Height, float MillimeterScale, bool IsFixed);
public static DeclaredDrawingBox? FindDeclaredDrawingBox(LoadedTemplate template, string placeholderName) =>
FindDeclaredDrawingBox(template.Layout, placeholderName) ??
(template.ContinuationLayout is not null ? FindDeclaredDrawingBox(template.ContinuationLayout, placeholderName) : null);
private static DeclaredDrawingBox? FindDeclaredDrawingBox(TemplateLayout layout, string placeholderName)
{
var box = layout.Elements.Concat(layout.PageTemplates.SelectMany(p => p.Elements))
.Concat(layout.ContentFlows.SelectMany(f => f.Elements))
.Select(e => e switch
{
DrawBoxElement draw when draw.Placeholder == placeholderName => ((float Width, float Height, bool IsFixed)?)(draw.Width, draw.Height, true),
FlowDrawBoxElement flow when flow.Placeholder == placeholderName => (flow.Width, flow.Height, false),
_ => null,
})
.FirstOrDefault(b => b is not null);
if (box is null) return null;
var millimeterScale = UnitConverter.Points(1, "mm") / UnitConverter.Points(1, layout.Unit);
return new DeclaredDrawingBox(box.Value.Width, box.Value.Height, millimeterScale, box.Value.IsFixed);
}
}
@@ -32,13 +32,13 @@ public sealed class McpServerHostedService : IAsyncDisposable
StudentTools studentTools, ExamTools examTools, GradeTools gradeTools,
ScheduleTools scheduleTools, TimeEntryTools timeEntryTools, LessonPlanTools lessonPlanTools,
GroupMembershipTools groupMembershipTools, LetterTemplateTools letterTemplateTools,
CompetencyTools competencyTools, UntisComparisonTools untisComparisonTools)
CompetencyTools competencyTools, UntisComparisonTools untisComparisonTools, GroupTools groupTools)
{
_settings = settings;
_logger = logger;
_serverOptions = BuildServerOptions(
studentTools, examTools, gradeTools, scheduleTools, timeEntryTools, lessonPlanTools,
groupMembershipTools, letterTemplateTools, competencyTools, untisComparisonTools);
groupMembershipTools, letterTemplateTools, competencyTools, untisComparisonTools, groupTools);
}
/// <summary>Setzt die Pipe-Server-Accept-Loop auf, falls aktiviert. Ohne Wirkung, falls
@@ -115,7 +115,7 @@ public sealed class McpServerHostedService : IAsyncDisposable
StudentTools studentTools, ExamTools examTools, GradeTools gradeTools,
ScheduleTools scheduleTools, TimeEntryTools timeEntryTools, LessonPlanTools lessonPlanTools,
GroupMembershipTools groupMembershipTools, LetterTemplateTools letterTemplateTools,
CompetencyTools competencyTools, UntisComparisonTools untisComparisonTools)
CompetencyTools competencyTools, UntisComparisonTools untisComparisonTools, GroupTools groupTools)
{
var toolCollection = new McpServerPrimitiveCollection<McpServerTool>();
@@ -157,6 +157,8 @@ public sealed class McpServerHostedService : IAsyncDisposable
AddReadTool(studentTools.GetStudents, "get_students",
"Listet Schüler, optional gefiltert nach Lerngruppe.");
AddReadTool(groupTools.GetGroups, "get_groups",
"Listet Lerngruppen (Klassen/Kurse) mit ihrer Id, optional gefiltert nach Schuljahr.");
AddReadTool(examTools.GetExams, "get_exams",
"Listet Klausuren, optional gefiltert nach Lerngruppe.");
AddReadTool(gradeTools.GetGrades, "get_grades",
@@ -19,6 +19,7 @@ public static class McpToolScope
public static readonly IReadOnlyCollection<string> AllowedReadTools =
[
"get_students",
"get_groups",
"get_exams",
"get_grades",
"get_schedule",
+22 -5
View File
@@ -24,14 +24,20 @@ public record TimeEntryDto(
Guid Id, Guid? TaskId, string Category, Guid? GroupId, DateOnly Date,
TimeOnly? StartTime, TimeOnly? EndTime, int DurationMinutes, string? Description);
public record LessonPhaseDto(Guid Id, string Name, int DurationMinutes, string Activity, string Material, string Shorthand);
/// <summary><see cref="MaterialPrompt"/> ist der beim letzten KI-"Übernehmen" gespeicherte
/// Materialerstellungs-Prompt (4.5.20/4.5.36) — nur gesetzt, wenn die KI für diese Phase einen
/// Medienvorschlag gemacht hatte. Ein MCP-Client kann ihn direkt zur Materialerzeugung nutzen, ohne
/// erst den Desktop-Dialog öffnen zu müssen.</summary>
public record LessonPhaseDto(
Guid Id, string Name, int DurationMinutes, string Activity, string Material, string Shorthand,
string? MaterialPrompt);
public record LessonAttachmentDto(string StorageId, string FileName, long SizeBytes);
public record LessonDto(
Guid Id, Guid UnitId, Guid GroupId, DateOnly Date, int? LessonNumber, string Topic,
string? Homework, LessonStatus Status, List<string> Competencies, List<LessonPhaseDto> Phases,
List<LessonAttachmentDto> Attachments);
string? Homework, string? PlanningIdeas, LessonStatus Status, List<string> Competencies,
List<LessonPhaseDto> Phases, List<LessonAttachmentDto> Attachments);
/// <summary>Ergebnis von "download_lesson_attachment": Inhalt Base64-kodiert, weil MCP-Tool-Antworten
/// als JSON/Text übertragen werden. Bewusst kein Ressourcen-URI-Mechanismus (siehe Planungsdokument) -
@@ -71,11 +77,22 @@ public record LetterTemplateDto(string Id, string Name, string Description, List
public record LetterRenderResultDto(bool Success, string Message, string? Base64Pdf, string? SuggestedFileName);
/// <summary>Eine Zeile des Untis-Hub (siehe UntisHubService) - "Kind"/"DueState" als Text statt
/// Enum-Wert, damit ein KI-Client sie ohne Kenntnis des internen Enums lesen kann.</summary>
/// Enum-Wert, damit ein KI-Client sie ohne Kenntnis des internen Enums lesen kann. GroupId ist bei
/// den drei dashboard-weiten Zeilen (offene Stunden, Klassenbuch-/Hausaufgabenabgleich) null - siehe
/// UntisHubJobRow. Enthalten, damit ein KI-Client die Id nicht erst über get_groups nachschlagen
/// muss, um get_untis_absence_rows/get_named_untis_absence_pattern für eine hier gelistete Gruppe
/// aufzurufen (Nutzer-Feedback: die GroupId war nirgends exponiert).</summary>
public record UntisHubStatusRowDto(
string Kind, string GroupName, string DueState, string DueLabel,
string Kind, Guid? GroupId, string GroupName, string DueState, string DueLabel,
DateTime? LastRunAt, string? LastResultSummary);
/// <summary>Lerngruppe (Klasse oder Kurs) - siehe GroupTools.GetGroups. Bewusst kein Verweis auf
/// SubjectId->Name aufgelöst (dafür get_subjects), um nicht bei jeder Gruppe implizit einen ganzen
/// Fach-Datensatz mitzuschleppen.</summary>
public record GroupDto(
Guid Id, string Name, GroupType Type, string SchoolYear, int GradeLevel,
Guid? SubjectId, bool IsActive);
/// <summary>Anonymisierte Fehlzeiten-Diskrepanz (siehe UntisComparisonTools.GetUntisAbsenceRows):
/// bewusst KEIN Schülername/keine Klasse - <see cref="RowId"/> ist die einzige Kennung, über die
/// UntisComparisonTools.ApplyUntisAbsenceStatus später zurückordnet.</summary>
@@ -0,0 +1,23 @@
using System.ComponentModel;
using LehrerApp.Core.Interfaces;
namespace LehrerApp.Desktop.Services.Mcp.Tools;
/// <summary>MCP-Read-Tool "get_groups" (Nutzer-Nachtrag: die GroupId, die praktisch jedes andere
/// Tool als Pflichtparameter verlangt - get_grades, get_schedule, get_lesson_plans,
/// get_untis_absence_rows usw. - war nirgends über MCP auflösbar; ein KI-Client kannte bestenfalls
/// den Klarnamen einer Lerngruppe aus dem Gespräch, nie ihre Id). Reiner Lesezugriff auf das
/// bestehende Repository, keine eigene Datenzugriffslogik.</summary>
public class GroupTools(IGroupRepository groups)
{
[Description("Listet Lerngruppen (Klassen/Kurse) mit ihrer Id - Voraussetzung, um andere Tools (z.B. get_grades, get_schedule, get_untis_absence_rows) für eine bestimmte Gruppe aufzurufen, wenn nur ihr Name bekannt ist. Ohne schoolYear werden alle Schuljahre zurückgegeben.")]
public List<GroupDto> GetGroups(
[Description("Optionales Schuljahr zum Filtern, Format \"2025/26\". Ohne Angabe alle Schuljahre.")] string? schoolYear = null,
[Description("Auch inaktive/archivierte Gruppen einbeziehen.")] bool includeInactive = false)
{
var list = schoolYear is null
? groups.GetAll(includeInactive)
: groups.GetBySchoolYear(schoolYear, includeInactive);
return list.Select(g => new GroupDto(g.Id, g.Name, g.Type, g.SchoolYear, g.GradeLevel, g.SubjectId, g.IsActive)).ToList();
}
}
@@ -188,11 +188,12 @@ public class LessonPlanTools(
return new WriteResultDto(true, lesson.Id, "Einzelstunde gespeichert.");
}
[Description("Ändert Metadaten einer bestehenden Einzelstunde (Thema, Hausaufgabe, Status, Beginn, Stundennummer) — der Verlaufsplan (Phasen) bleibt unverändert. Nur angegebene Felder werden geändert.")]
[Description("Ändert Metadaten einer bestehenden Einzelstunde (Thema, Hausaufgabe, Planungsideen, Status, Beginn, Stundennummer) — der Verlaufsplan (Phasen) bleibt unverändert. Nur angegebene Felder werden geändert.")]
public async Task<WriteResultDto> UpdateLesson(
[Description("ID der Einzelstunde.")] Guid lessonId,
[Description("Neues Thema. Unverändert lassen: weglassen.")] string? topic = null,
[Description("Neue Hausaufgabe. Unverändert lassen: weglassen.")] string? homework = null,
[Description("Neue Planungsideen (grober Entwurf vor der Feinplanung). Unverändert lassen: weglassen.")] string? planningIdeas = null,
[Description("Neuer Status: Planned, Conducted, Draft, Ready oder Cancelled (ausgefallen, z.B. Exkursion/Feiertag - Alternative zu delete_lesson, wenn die Stunde als Ereignis dokumentiert bleiben soll). Unverändert lassen: weglassen.")] LessonStatus? status = null,
[Description("Neuer Stundenbeginn, Format HH:mm. Unverändert lassen: weglassen.")] TimeOnly? startTime = null,
[Description("Neue Stundennummer. Unverändert lassen: weglassen.")] int? lessonNumber = null,
@@ -204,6 +205,7 @@ public class LessonPlanTools(
var changes = new StringBuilder();
if (topic is not null && topic != lesson.Topic) { changes.AppendLine($"Thema: „{lesson.Topic}“ → „{topic}“"); lesson.Topic = topic; }
if (homework is not null && homework != lesson.Homework) { changes.AppendLine($"Hausaufgabe: „{lesson.Homework}“ → „{homework}“"); lesson.Homework = homework; }
if (planningIdeas is not null && planningIdeas != lesson.PlanningIdeas) { changes.AppendLine($"Planungsideen: „{lesson.PlanningIdeas}“ → „{planningIdeas}“"); lesson.PlanningIdeas = planningIdeas; }
if (status is not null && status != lesson.Status) { changes.AppendLine($"Status: {lesson.Status} → {status}"); lesson.Status = status.Value; }
if (startTime is not null && startTime != lesson.StartTime) { changes.AppendLine($"Beginn: {lesson.StartTime:HH\\:mm} → {startTime:HH\\:mm}"); lesson.StartTime = startTime; }
if (lessonNumber is not null && lessonNumber != lesson.LessonNumber) { changes.AppendLine($"Nr.: {lesson.LessonNumber} → {lessonNumber}"); lesson.LessonNumber = lessonNumber; }
@@ -228,6 +230,7 @@ public class LessonPlanTools(
[Description("Tätigkeit/Sozialform.")] string activity = "",
[Description("Material.")] string material = "",
[Description("Kurzsymbol, z.B. \"AB001->S\".")] string shorthand = "",
[Description("Optionaler, vollständiger Prompt zur Materialerstellung für diese Phase (siehe get_lesson_plans, LessonPhaseDto.MaterialPrompt) - z.B. wenn eine externe KI-Sitzung ihn selbst formuliert hat und er zur Wiederverwendung gespeichert werden soll.")] string? materialPrompt = null,
CancellationToken ct = default)
{
var lesson = lessons.GetById(lessonId);
@@ -240,7 +243,7 @@ public class LessonPlanTools(
var phase = new LessonPhaseStep
{
Name = name, DurationMinutes = durationMinutes, Activity = activity,
Material = material, Shorthand = shorthand,
Material = material, Shorthand = shorthand, MaterialPrompt = materialPrompt,
};
lesson.Phases.Add(phase);
lessons.Save(lesson);
@@ -256,6 +259,7 @@ public class LessonPlanTools(
[Description("Neue Tätigkeit. Unverändert lassen: weglassen.")] string? activity = null,
[Description("Neues Material. Unverändert lassen: weglassen.")] string? material = null,
[Description("Neues Kurzsymbol. Unverändert lassen: weglassen.")] string? shorthand = null,
[Description("Neuer Prompt zur Materialerstellung (siehe get_lesson_plans, LessonPhaseDto.MaterialPrompt). Unverändert lassen: weglassen.")] string? materialPrompt = null,
CancellationToken ct = default)
{
var lesson = lessons.GetById(lessonId);
@@ -269,6 +273,7 @@ public class LessonPlanTools(
if (activity is not null && activity != phase.Activity) { changes.AppendLine($"Tätigkeit: „{phase.Activity}“ → „{activity}“"); phase.Activity = activity; }
if (material is not null && material != phase.Material) { changes.AppendLine($"Material: „{phase.Material}“ → „{material}“"); phase.Material = material; }
if (shorthand is not null && shorthand != phase.Shorthand) { changes.AppendLine($"Kürzel: „{phase.Shorthand}“ → „{shorthand}“"); phase.Shorthand = shorthand; }
if (materialPrompt is not null && materialPrompt != phase.MaterialPrompt) { changes.AppendLine("Materialerstellungs-Prompt geändert."); phase.MaterialPrompt = materialPrompt; }
if (changes.Length == 0)
return new WriteResultDto(true, phase.Id, "Keine Änderung nötig.");
@@ -409,7 +414,7 @@ public class LessonPlanTools(
}
private static LessonDto ToDto(Lesson l) => new(
l.Id, l.UnitId, l.GroupId, l.Date, l.LessonNumber, l.Topic, l.Homework, l.Status, l.Competencies,
l.Phases.Select(p => new LessonPhaseDto(p.Id, p.Name, p.DurationMinutes, p.Activity, p.Material, p.Shorthand)).ToList(),
l.Id, l.UnitId, l.GroupId, l.Date, l.LessonNumber, l.Topic, l.Homework, l.PlanningIdeas, l.Status, l.Competencies,
l.Phases.Select(p => new LessonPhaseDto(p.Id, p.Name, p.DurationMinutes, p.Activity, p.Material, p.Shorthand, p.MaterialPrompt)).ToList(),
l.Attachments.Select(a => new LessonAttachmentDto(a.StorageId, a.FileName, a.SizeBytes)).ToList());
}
@@ -19,7 +19,8 @@ namespace LehrerApp.Desktop.Services.Mcp.Tools;
/// tatsächlich nützlichen Fall ab: eine bestehende, vom Nutzer gestaltete Vorlage mit Werten füllen.</summary>
public class LetterTemplateTools(
TemplateStore templates, ITemplateLoader loader, ITemplateRenderer renderer,
IStudentRepository students, IGroupRepository groups)
IStudentRepository students, IGroupRepository groups,
StudentAttendanceCalendarService? attendanceCalendars = null)
{
[Description("Listet importierte Elternbrief-Vorlagen mit ihren deklarierten Platzhaltern (Name, Typ, Pflichtfeld, konstant).")]
public List<LetterTemplateDto> ListLetterTemplates()
@@ -73,7 +74,16 @@ public class LetterTemplateTools(
var group = groupId is { } gid ? groups.GetById(gid) : null;
var date = letterDate ?? DateOnly.FromDateTime(DateTime.Today);
var values = LetterPlaceholderBuilder.BuildStandardValues(student, contact, group, date, letterText, teacherName);
var calendarBox = LetterPlaceholderBuilder.FindDeclaredDrawingBox(loaded,
StudentAttendanceCalendarDrawingBuilder.PlaceholderName) ?? new(170, float.PositiveInfinity, 1, false);
var absenceDayListBox = LetterPlaceholderBuilder.FindDeclaredDrawingBox(loaded,
StudentAbsenceDayListDrawingBuilder.PlaceholderName) ?? new(170, float.PositiveInfinity, 1, false);
var values = LetterPlaceholderBuilder.BuildStandardValues(student, contact, group, date, letterText,
teacherName, attendanceCalendars?.Build(student, new AttendanceCalendarOptions(date, 1),
calendarBox.Width, calendarBox.MillimeterScale,
calendarBox.IsFixed ? calendarBox.Height : float.PositiveInfinity),
attendanceCalendars?.BuildAbsenceDayList(student, new AttendanceCalendarOptions(date, 1),
absenceDayListBox.Width, absenceDayListBox.MillimeterScale));
foreach (var (name, raw) in extraValues ?? [])
{
var definition = loaded.Manifest.Placeholders.FirstOrDefault(p => p.Name == name);
@@ -50,7 +50,8 @@ public class UntisComparisonTools(
[Description("Listet die Fälligkeit der Untis-Hub-Abgleiche (Fehlzeiten je Lerngruppe, offene Stunden, Klassenbuch-/Hausaufgabenabgleich) - reine Lesefunktion aus der lokalen Fälligkeits-Historie, kein eigener WebUntis-Zugriff.")]
public List<UntisHubStatusRowDto> GetUntisHubStatus() =>
hub.GetRows().Select(r => new UntisHubStatusRowDto(
r.Kind.ToString(), r.GroupName, r.DueState.ToString(), r.DueLabel, r.LastRunAt, r.LastResultSummary)).ToList();
r.Kind.ToString(), r.GroupId, r.GroupName, r.DueState.ToString(), r.DueLabel,
r.LastRunAt, r.LastResultSummary)).ToList();
[Description("""
Listet Fehlzeiten-Diskrepanzen einer Lerngruppe gegenüber WebUntis in einem Zeitraum, ANONYMISIERT:
@@ -0,0 +1,301 @@
using System.Globalization;
using LehrerApp.Core.Interfaces;
using LehrerApp.Core.Models;
using LehrerApp.Desktop.ViewModels.ClassTeacher;
using LehrerApp.Templating;
namespace LehrerApp.Desktop.Services;
public enum AttendanceCalendarSize { Small, Medium, Large }
public sealed record AttendanceCalendarOptions(DateOnly StartMonth, int MonthCount,
AttendanceCalendarSize Size = AttendanceCalendarSize.Medium)
{
public DateOnly NormalizedStartMonth => new(StartMonth.Year, StartMonth.Month, 1);
public int NormalizedMonthCount => Math.Clamp(MonthCount, 1, 3);
}
/// <summary>Erzeugt den portablen Advanced-Content-Platzhalter für Elternbriefe aus derselben
/// priorisierten Monatsansicht, die im Klassenlehrer-Sidebar-Widget verwendet wird.</summary>
public static class StudentAttendanceCalendarDrawingBuilder
{
public const string PlaceholderName = "Student.AttendanceCalendar";
public static DrawingValue Build(string studentName, DateOnly month,
IReadOnlyList<ClassAbsenceDaySummaryRow> absences,
IReadOnlyList<UntisForeignClassRegisterEventDto> registerEntries) =>
Build(studentName, new AttendanceCalendarOptions(month, 1), absences, registerEntries);
public static DrawingValue Build(string studentName, AttendanceCalendarOptions options,
IReadOnlyList<ClassAbsenceDaySummaryRow> absences,
IReadOnlyList<UntisForeignClassRegisterEventDto> registerEntries, float contentWidth = 170,
float millimeterScale = 1, float contentHeight = float.PositiveInfinity)
{
var requestedScale = options.Size switch
{
AttendanceCalendarSize.Small => .7f,
AttendanceCalendarSize.Large => 1f,
_ => .85f,
};
// contentWidth/contentHeight/millimeterScale kommen aus der tatsächlichen DRAWBOX/
// FLOWDRAWBOX-Deklaration der jeweiligen Vorlage (siehe
// LetterPlaceholderBuilder.FindDeclaredDrawingBox). Das Raster ist in Millimetern entworfen;
// millimeterScale rechnet das in die Koordinaten-Einheit der Vorlage um (1, wenn die Vorlage
// schon "mm" nutzt; ≈2.83 bei "pt") - ohne das würde das Raster bei einer in "pt" deklarierten
// Vorlage nur rund ein Drittel der vorgesehenen Größe erreichen. Schriftgrößen (FontSize) sind
// davon unabhängig immer echte Punktgrößen. contentWidth ist unabhängig von der Größenwahl
// fest - nur Schrift/Zellenhöhe skalieren mit "Größe". Würde die Breite mit skalieren, würde
// "Groß" (scale=1) exakt die Skript-Box ausfüllen, "Klein"/"Normal" aber nur einen Teil davon
// - und eine größere Skalierung als 1 liefe über die Box hinaus und würde am rechten Rand
// abgeschnitten (SVG overflow="hidden"). Die Defaults (170/1/unendlich) greifen nur, wenn die
// Vorlage nicht ermittelt werden kann (z.B. Vorschau ohne Kontext).
var first = options.NormalizedStartMonth;
var monthCount = options.NormalizedMonthCount;
// Wochenanzahl je Monat vorab ermitteln, unabhängig von "Größe" - nötig, um VOR dem
// eigentlichen Zeichnen zu wissen, wie viel Höhe das Raster braucht. Eine DRAWBOX bricht
// anders als FLOWDRAWBOX nicht automatisch auf Folgeseiten um (siehe
// DrawingElementRenderer.RenderFixed/Slice): Inhalt, der contentHeight überschreitet, wird
// von QuestTemplateRenderer am unteren Rand stillschweigend abgeschnitten. "Größe" wird
// deshalb nötigenfalls automatisch verkleinert, statt die von der Vorlage vorgegebene
// Boxhöhe zu verletzen.
var maxWeeks = 0;
for (var monthIndex = 0; monthIndex < monthCount; monthIndex++)
{
var current = first.AddMonths(monthIndex);
var days = ClassTeacherOverviewViewModel.BuildCompactMonthDays(current, absences, registerEntries,
DateOnly.FromDateTime(DateTime.Today), studentName);
var offset = ((int)current.DayOfWeek + 6) % 7;
maxWeeks = Math.Max(maxWeeks, (int)Math.Ceiling((offset + days.Count) / 7d));
}
// Muss mit der Höhenformel am Ende dieser Methode übereinstimmen (dort als "y" berechnet):
// Titel+Name+Abstand (26) + Monats-/Wochentagskopf (20) + maxWeeks Wochenzeilen (10 je Woche)
// + Legende/Nachlauf (9), alles mit scale*millimeterScale skaliert, plus der einmalige,
// größenunabhängige monthGapX-Abstand (6*millimeterScale).
float RequiredHeight(float s) => s * millimeterScale * (55 + 10 * maxWeeks) + 6f * millimeterScale;
var scale = requestedScale;
if (float.IsFinite(contentHeight) && RequiredHeight(requestedScale) > contentHeight)
{
var perScaleUnit = RequiredHeight(1) - RequiredHeight(0);
scale = perScaleUnit > 0
? Math.Clamp((contentHeight - RequiredHeight(0)) / perScaleUnit, .35f, requestedScale)
: requestedScale;
}
var cellHeight = 10 * scale * millimeterScale;
// War 10mm - bei 3 Monaten nebeneinander größer als eine einzelne Tageskachel und zog damit
// spürbar Platz von den Kacheln ab, ohne selbst als Inhalt wahrgenommen zu werden.
var monthGapX = 6f * millimeterScale;
var monthWidth = (contentWidth - monthGapX * (monthCount - 1)) / monthCount;
var cellWidth = monthWidth / 7;
// Kachel-Zwischenraum (Trennung zu Nachbarzellen) deutlich knapper als vorher (war "scale"
// mm, also bis zu 1mm auf jeder Seite - bei einer ~7mm breiten Zelle ein gutes Viertel
// reiner Leerraum). Die Kachel selbst füllt dadurch ihren Rasterplatz sichtbar besser aus.
var cellInset = scale * .4f * millimeterScale;
var cellCornerRadius = Math.Min(cellWidth, cellHeight) * .2f;
var commands = new List<DrawingCommand>();
var y = 0f;
commands.Add(new DrawStringEx(0, y, 14 * scale * millimeterScale, contentWidth, "Anwesenheit",
DrawingTextAlignment.AlignLeft, 11 * scale, Color: "#1F2937", Bold: true));
y += 14 * scale * millimeterScale;
commands.Add(new DrawStringEx(0, y, 10 * scale * millimeterScale, contentWidth, studentName,
DrawingTextAlignment.AlignLeft, 8 * scale, Color: "#6B7280"));
y += 10 * scale * millimeterScale + 2 * scale * millimeterScale;
var weekdays = new[] { "Mo", "Di", "Mi", "Do", "Fr", "Sa", "So" };
var gridStartY = y;
for (var monthIndex = 0; monthIndex < monthCount; monthIndex++)
{
var current = first.AddMonths(monthIndex);
var days = ClassTeacherOverviewViewModel.BuildCompactMonthDays(current, absences, registerEntries,
DateOnly.FromDateTime(DateTime.Today), studentName);
var offset = ((int)current.DayOfWeek + 6) % 7;
var xOffset = monthIndex * (monthWidth + monthGapX);
var monthY = gridStartY;
commands.Add(new DrawStringEx(xOffset, monthY, 11 * scale * millimeterScale, monthWidth,
current.ToString("MMMM yyyy", CultureInfo.GetCultureInfo("de-DE")),
DrawingTextAlignment.AlignLeft, 9 * scale, Color: "#374151", Bold: true));
monthY += 11 * scale * millimeterScale;
for (var column = 0; column < 7; column++)
commands.Add(new DrawStringEx(xOffset + column * cellWidth, monthY, 9 * scale * millimeterScale,
cellWidth, weekdays[column], DrawingTextAlignment.AlignCenter, 7 * scale, Color: "#6B7280",
Bold: true));
monthY += 9 * scale * millimeterScale;
foreach (var day in days)
{
var index = offset + day.Date.Day - 1;
var column = index % 7;
var row = index / 7;
var x = xOffset + column * cellWidth;
var cellY = monthY + row * cellHeight;
commands.Add(new DrawRoundedRectangle(x + cellInset, cellY, cellWidth - 2 * cellInset,
cellHeight - cellInset, cellCornerRadius, "#D1D5DB", .35f,
day.HasSignal ? day.SignalColorHex : "#FFFFFF"));
commands.Add(new DrawStringEx(x, cellY + scale * millimeterScale,
cellHeight - 2 * scale * millimeterScale, cellWidth,
day.HasSignal ? day.SignalCode : day.DayNumber, DrawingTextAlignment.AlignCenter, 7 * scale,
Color: day.HasSignal ? "#FFFFFF" : "#374151", Bold: day.HasSignal));
}
}
y = gridStartY + 11 * scale * millimeterScale + 9 * scale * millimeterScale + maxWeeks * cellHeight + monthGapX;
commands.Add(new DrawStringEx(0, y, 8 * scale * millimeterScale, contentWidth,
"U unentschuldigt · A abwesend · V verspätet · E entschuldigt · ! Klassenbuch",
DrawingTextAlignment.AlignLeft, 6.5f * scale, Color: "#6B7280"));
return new DrawingValue(commands, y + 9 * scale * millimeterScale);
}
}
/// <summary>Chronologische, portable Fehlzeitenliste für Elternbriefvorlagen.</summary>
public static class StudentAbsenceDayListDrawingBuilder
{
public const string PlaceholderName = "Student.AbsenceDays";
public static DrawingValue Build(string studentName, AttendanceCalendarOptions options,
IReadOnlyList<ClassAbsenceDaySummaryRow> absences, float contentWidth = 170,
float millimeterScale = 1)
{
var scale = options.Size switch
{
AttendanceCalendarSize.Small => .75f,
AttendanceCalendarSize.Large => 1f,
_ => .88f,
};
// contentWidth/millimeterScale: siehe StudentAttendanceCalendarDrawingBuilder - contentWidth
// bleibt an die tatsächliche DRAWBOX/FLOWDRAWBOX-Deklaration im Layout-Skript gebunden,
// millimeterScale rechnet das mm-entworfene Raster in die Koordinaten-Einheit der Vorlage um.
var rowHeight = 12 * scale * millimeterScale;
var start = options.NormalizedStartMonth;
var end = start.AddMonths(options.NormalizedMonthCount).AddDays(-1);
var rows = absences
.Where(a => a.Date >= start && a.Date <= end &&
UntisNameMatching.NamesMatch(a.StudentName, studentName))
.OrderBy(a => a.Date)
.ToList();
var commands = new List<DrawingCommand>();
var y = 0f;
commands.Add(new DrawStringEx(0, y, 14 * scale * millimeterScale, contentWidth, "Fehltage",
DrawingTextAlignment.AlignLeft, 11 * scale, Color: "#1F2937", Bold: true));
y += 14 * scale * millimeterScale;
commands.Add(new DrawStringEx(0, y, 10 * scale * millimeterScale, contentWidth, studentName,
DrawingTextAlignment.AlignLeft, 8 * scale, Color: "#6B7280"));
y += 10 * scale * millimeterScale + 2 * scale * millimeterScale;
// Spaltenbreiten so gewählt, dass auch die Datenzeilen (nicht nur die kurzen Kopfzeilen-
// Labels) hineinpassen - "Datum" als Kopfzeile ist kürzer als "dd.MM.yyyy" und wurde bei
// 31 zu schmal bemessen, wodurch das Datum am rechten Rand abgeschnitten wurde.
var dateColumnX = 2 * millimeterScale; var dateColumnWidth = 38 * millimeterScale;
var extentColumnX = 44 * millimeterScale; var extentColumnWidth = 66 * millimeterScale;
var statusColumnX = 114 * millimeterScale; var statusColumnWidth = 54 * millimeterScale;
commands.Add(new DrawRectangle(0, y, contentWidth, rowHeight, "#CBD5E1", .4f, "#F3F4F6"));
commands.Add(new DrawStringEx(dateColumnX, y + scale * millimeterScale, rowHeight - scale * millimeterScale,
dateColumnWidth, "Datum", DrawingTextAlignment.AlignLeft, 7.5f * scale, Color: "#374151", Bold: true));
commands.Add(new DrawStringEx(extentColumnX, y + scale * millimeterScale, rowHeight - scale * millimeterScale,
extentColumnWidth, "Umfang", DrawingTextAlignment.AlignLeft, 7.5f * scale, Color: "#374151", Bold: true));
commands.Add(new DrawStringEx(statusColumnX, y + scale * millimeterScale, rowHeight - scale * millimeterScale,
statusColumnWidth, "Status", DrawingTextAlignment.AlignLeft, 7.5f * scale, Color: "#374151", Bold: true));
y += rowHeight;
if (rows.Count == 0)
{
commands.Add(new DrawStringEx(2 * millimeterScale, y + 2 * scale * millimeterScale, rowHeight,
contentWidth - 4 * millimeterScale, "Keine Fehltage im gewählten Zeitraum",
DrawingTextAlignment.AlignLeft, 8 * scale, Color: "#6B7280", Italic: true));
y += rowHeight + 3 * scale * millimeterScale;
}
else
{
for (var index = 0; index < rows.Count; index++)
{
var row = rows[index];
var fill = index % 2 == 0 ? "#FFFFFF" : "#F9FAFB";
var extent = row.CountsAsFullDay
? "Ganzer Fehltag"
: row.TotalAbsentPeriods > 0
? $"Fehlzeit · {row.TotalAbsentPeriods} Std."
: $"Fehlzeit · {row.TotalAbsentMinutes} Min.";
var status = row.IsUnexcused
? "Unentschuldigt"
: row.FriendlyStatusLabel.Contains("Entschuldigt", StringComparison.OrdinalIgnoreCase)
? "Entschuldigt"
: row.FriendlyStatusLabel;
var statusColor = row.IsUnexcused ? "#C62828" : "#2E7D32";
commands.Add(new DrawRectangle(0, y, contentWidth, rowHeight, "#E5E7EB", .3f, fill));
commands.Add(new DrawStringEx(dateColumnX, y + scale * millimeterScale,
rowHeight - scale * millimeterScale, dateColumnWidth,
row.Date.ToString("dd.MM.yyyy"), DrawingTextAlignment.AlignLeft, 7.5f * scale,
Color: "#374151"));
commands.Add(new DrawStringEx(extentColumnX, y + scale * millimeterScale,
rowHeight - scale * millimeterScale, extentColumnWidth,
extent, DrawingTextAlignment.AlignLeft, 7.5f * scale, Color: "#374151"));
commands.Add(new DrawStringEx(statusColumnX, y + scale * millimeterScale,
rowHeight - scale * millimeterScale, statusColumnWidth,
status, DrawingTextAlignment.AlignLeft, 7.5f * scale, Color: statusColor,
Bold: row.IsUnexcused));
y += rowHeight;
}
}
return new DrawingValue(commands, y);
}
}
/// <summary>Liest ausschließlich den lokalen WebUntis-Cache. Das Öffnen oder Rendern eines
/// Elternbriefs löst dadurch keinen unerwarteten Netzwerkabruf aus.</summary>
public sealed class StudentAttendanceCalendarService(
WebUntisSettingsService settings,
IUntisAbsenceCacheRepository absenceCache,
IUntisClassRegisterCacheRepository registerCache,
IUntisStudentRosterCacheRepository rosterCache)
{
public DrawingValue Build(Student student, DateOnly month) =>
Build(student, new AttendanceCalendarOptions(month, 1));
public DrawingValue Build(Student student, AttendanceCalendarOptions options, float contentWidth = 170,
float millimeterScale = 1, float contentHeight = float.PositiveInfinity)
{
var className = settings.HomeroomClassName;
if (string.IsNullOrWhiteSpace(className)) return new DrawingValue([], 0);
var first = options.NormalizedStartMonth;
var last = first.AddMonths(options.NormalizedMonthCount).AddDays(-1);
var matchName = $"{student.FirstName} {student.LastName}";
var rosterName = rosterCache.GetByClass(className)
.FirstOrDefault(r => UntisNameMatching.NamesMatch(r.DisplayName, matchName))?.DisplayName
?? matchName;
var start = first.Year * 10000 + first.Month * 100 + first.Day;
var end = last.Year * 10000 + last.Month * 100 + last.Day;
var absences = absenceCache.GetByClassAndRange(className, start, end)
.Select(e => new UntisClassAbsenceEntryDto(e.StudentName, e.ExternKey, e.ClassName, e.Date,
e.AbsentPeriods, e.AbsentMinutes, e.TeacherUsernames, e.Subject, e.AbsenceReason, e.Note,
e.EntryId, e.HandledOn, e.Counts, e.ExcuseNote, e.PeriodNumber, e.Status, e.CountsAsFullDay))
.ToList();
var register = registerCache.GetByClassAndRange(className, start, end)
.Select(e => new UntisForeignClassRegisterEventDto(e.ClassName, e.Date, e.Subject, e.StudentName,
e.TeacherUsername, e.CategoryName, e.CategoryGroup, e.Text))
.ToList();
return StudentAttendanceCalendarDrawingBuilder.Build(rosterName, options,
ClassAbsenceDaySummaryRow.GroupByStudentAndDay(absences), register, contentWidth, millimeterScale,
contentHeight);
}
public DrawingValue BuildAbsenceDayList(Student student, AttendanceCalendarOptions options, float contentWidth = 170,
float millimeterScale = 1)
{
var className = settings.HomeroomClassName;
if (string.IsNullOrWhiteSpace(className)) return new DrawingValue([], 0);
var first = options.NormalizedStartMonth;
var last = first.AddMonths(options.NormalizedMonthCount).AddDays(-1);
var matchName = $"{student.FirstName} {student.LastName}";
var rosterName = rosterCache.GetByClass(className)
.FirstOrDefault(r => UntisNameMatching.NamesMatch(r.DisplayName, matchName))?.DisplayName
?? matchName;
var start = first.Year * 10000 + first.Month * 100 + first.Day;
var end = last.Year * 10000 + last.Month * 100 + last.Day;
var absences = absenceCache.GetByClassAndRange(className, start, end)
.Select(e => new UntisClassAbsenceEntryDto(e.StudentName, e.ExternKey, e.ClassName, e.Date,
e.AbsentPeriods, e.AbsentMinutes, e.TeacherUsernames, e.Subject, e.AbsenceReason, e.Note,
e.EntryId, e.HandledOn, e.Counts, e.ExcuseNote, e.PeriodNumber, e.Status, e.CountsAsFullDay));
return StudentAbsenceDayListDrawingBuilder.Build(rosterName, options,
ClassAbsenceDaySummaryRow.GroupByStudentAndDay(absences), contentWidth, millimeterScale);
}
}
@@ -40,7 +40,7 @@ public sealed class UntisHubService(
public List<UntisHubJobRow> GetRows()
{
var eligibleGroups = groups.GetBySchoolYear(schoolYears.CurrentSchoolYear())
.Where(g => g.WebUntisLessonId is not null)
.Where(g => g.WebUntisLessonId is not null && !g.ExcludedFromUntisHub)
.OrderBy(g => g.Name)
.ToList();
return BuildRows(eligibleGroups, jobStates.GetAll(), DateTime.UtcNow);
@@ -72,8 +72,14 @@ public sealed class UntisHubService(
public static List<UntisHubJobRow> BuildRows(
IReadOnlyList<LearningGroup> eligibleGroups, IReadOnlyList<UntisHubJobState> states, DateTime utcNow)
{
// Nach Sync-Aktivierung von UntisHubJobState (siehe TODO.md) können zwei Geräte, die
// denselben, noch nie gelaufenen Job unabhängig voneinander zum ersten Mal ausführen,
// bevor sie sich gegenseitig gesehen haben, kurzzeitig zwei Datensätze für dasselbe
// (Kind, GroupId) anlegen - hier den zuletzt gelaufenen wählen statt einen beliebigen.
UntisHubJobState? State(UntisHubJobKind kind, Guid? groupId) =>
states.FirstOrDefault(s => s.Kind == kind && s.GroupId == groupId);
states.Where(s => s.Kind == kind && s.GroupId == groupId)
.OrderByDescending(s => s.LastRunAt)
.FirstOrDefault();
var rows = new List<UntisHubJobRow>();
foreach (var group in eligibleGroups)
@@ -65,7 +65,7 @@ public class UntisSyncService : IDisposable
TimeSpan.FromMinutes(PollIntervalMinutes), TimeSpan.FromMinutes(PollIntervalMinutes));
}
public async Task PollAsync()
public async Task PollAsync(CancellationToken cancellationToken = default)
{
if (!await _gate.WaitAsync(0).ConfigureAwait(false)) return;
try
@@ -76,7 +76,7 @@ public class UntisSyncService : IDisposable
string icsText;
try
{
icsText = await _http.GetStringAsync(url).ConfigureAwait(false);
icsText = await _http.GetStringAsync(url, cancellationToken).ConfigureAwait(false);
}
catch (Exception ex)
{
@@ -18,6 +18,10 @@ public enum ClassTeacherStatusKind { Ok, Info, Warning, Danger }
/// Schlagwort gezielt dämpfen kann, siehe <see cref="ClassTeacherRosterRow.MatchDomains"/>.</summary>
public enum VorgangScoreDomain { Attendance, Lateness, Classbook }
/// <summary>Auswahlbereich des kompakten Monatskalenders. Ein leerer Schülername steht für die
/// bisherige Klassen-Gesamtansicht.</summary>
public sealed record ClassTeacherCalendarScope(string? StudentName, string DisplayName);
public sealed record ClassTeacherRosterRow(string StudentName, int? ExternKey, bool HasAbsenceToday,
string? AbsenceTooltip, bool HasRecentClassRegisterEntry)
{
@@ -424,6 +428,7 @@ public partial class ClassTeacherOverviewViewModel : ObservableObject
public ObservableCollection<ClassTeacherPatternNotice> PatternNotices { get; } = [];
public ObservableCollection<ClassTeacherOpenExcuseRow> OpenExcuses { get; } = [];
public ObservableCollection<ClassTeacherCompactCalendarDay> CompactMonthDays { get; } = [];
public ObservableCollection<ClassTeacherCalendarScope> CalendarScopes { get; } = [];
[ObservableProperty] private string? _homeroomClassName;
[ObservableProperty] private int _activeTabIndex;
@@ -453,6 +458,7 @@ public partial class ClassTeacherOverviewViewModel : ObservableObject
/// den "Klassenbuch öffnen"-Button gehängt statt in einer eigenen Kennzahlkarte.
[ObservableProperty] private int _ownDocumentationFollowUpCount;
[ObservableProperty] private int _ownDocumentationCriticalCount;
[ObservableProperty] private ClassTeacherCalendarScope? _selectedCalendarScope;
// Zuletzt per Load() geholte WebUntis-Rohdaten, für RefreshFromLocalDataOnly() - damit ein
// eingehendes Sync-Ereignis die Ansicht neu aufbauen kann, ohne selbst WebUntis anzufragen.
@@ -501,8 +507,17 @@ public partial class ClassTeacherOverviewViewModel : ObservableObject
public double UnexcusedAbsenceFraction => StudentCount == 0 ? 0 : (double)UnexcusedAbsenceCount / StudentCount;
public string DayOverviewTooltip => $"{PresentCount} anwesend · {LateCount} verspätet · " +
$"{ExcusedAbsenceCount} entschuldigt · {UnexcusedAbsenceCount} unentschuldigt";
public string CompactMonthLabel => DateOnly.FromDateTime(DateTime.Today).ToString("MMMM yyyy",
System.Globalization.CultureInfo.GetCultureInfo("de-DE"));
public string CompactMonthLabel
{
get
{
var month = DateOnly.FromDateTime(DateTime.Today).ToString("MMMM yyyy",
System.Globalization.CultureInfo.GetCultureInfo("de-DE"));
return SelectedCalendarScope?.StudentName is { Length: > 0 }
? $"{month} · {SelectedCalendarScope.DisplayName}"
: $"{month} · gesamte Klasse";
}
}
public Func<Task>? OnNavigateToSettings { get; set; }
public Func<Task>? OnNavigateToWorkload { get; set; }
@@ -534,6 +549,11 @@ public partial class ClassTeacherOverviewViewModel : ObservableObject
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 OnSelectedCalendarScopeChanged(ClassTeacherCalendarScope? value)
{
OnPropertyChanged(nameof(CompactMonthLabel));
if (CalendarScopes.Count > 0) RebuildCompactMonthFromCachedData();
}
partial void OnSearchTextChanged(string value) => ApplyRosterFilter();
partial void OnSelectedRosterFilterChanged(int value)
{
@@ -548,7 +568,8 @@ public partial class ClassTeacherOverviewViewModel : ObservableObject
{
ActiveTabIndex = 0;
Roster.Clear(); PrimaryRoster.Clear(); SecondaryRoster.Clear(); TrendDays.Clear(); PatternNotices.Clear();
OpenExcuses.Clear(); CompactMonthDays.Clear(); OpenExcuseOverflowCount = 0;
OpenExcuses.Clear(); CompactMonthDays.Clear(); CalendarScopes.Clear(); SelectedCalendarScope = null;
OpenExcuseOverflowCount = 0;
}
// Wird beim Navigieren auf diese Seite aufgerufen statt LoadCommand: baut nur den lokalen
@@ -678,6 +699,12 @@ public partial class ClassTeacherOverviewViewModel : ObservableObject
holidayWeekdaysExcluded, termStart, classRegisterEvents,
_patternScoreSettings.Load(), closedVorgaengeByNameKey)) Roster.Add(row);
CalendarScopes.Add(new ClassTeacherCalendarScope(null, "Gesamte Klasse"));
foreach (var student in students.OrderBy(s => s.DisplayName))
CalendarScopes.Add(new ClassTeacherCalendarScope(student.DisplayName,
ClassTeacherOverviewViewModel.DisplayStudentName(student.DisplayName)));
SelectedCalendarScope = CalendarScopes[0];
StudentCount = Roster.Count;
TodayAlertCount = Roster.Count(r => r.HasAbsenceToday);
TodayUnexcusedCount = Roster.Count(r => r.IsUnexcused);
@@ -724,7 +751,7 @@ public partial class ClassTeacherOverviewViewModel : ObservableObject
private void OpenMonthlyCalendar()
{
var today = DateOnly.FromDateTime(DateTime.Today);
DetailsTab.StudentFilter = "";
DetailsTab.StudentFilter = SelectedCalendarScope?.StudentName ?? "";
DetailsTab.CalendarMonth = new DateOnly(today.Year, today.Month, 1);
ActiveTabIndex = 2;
DetailsTab.ShowCalendarCommand.Execute(null);
@@ -735,15 +762,29 @@ public partial class ClassTeacherOverviewViewModel : ObservableObject
{
CompactMonthDays.Clear();
foreach (var day in BuildCompactMonthDays(new DateOnly(today.Year, today.Month, 1),
absences, registerEntries, today))
absences, registerEntries, today, SelectedCalendarScope?.StudentName))
CompactMonthDays.Add(day);
}
private void RebuildCompactMonthFromCachedData()
{
if (_lastAbsences is null || _lastClassRegisterEvents is null) return;
var today = DateOnly.FromDateTime(DateTime.Today);
BuildCompactMonth(ClassAbsenceDaySummaryRow.GroupByStudentAndDay(_lastAbsences),
_lastClassRegisterEvents, today);
}
public static IReadOnlyList<ClassTeacherCompactCalendarDay> BuildCompactMonthDays(DateOnly month,
IReadOnlyList<ClassAbsenceDaySummaryRow> absences,
IReadOnlyList<UntisForeignClassRegisterEventDto> registerEntries, DateOnly today)
IReadOnlyList<UntisForeignClassRegisterEventDto> registerEntries, DateOnly today,
string? studentName = null)
{
var first = new DateOnly(month.Year, month.Month, 1);
if (!string.IsNullOrWhiteSpace(studentName))
{
absences = absences.Where(a => UntisNameMatching.NamesMatch(a.StudentName, studentName)).ToList();
registerEntries = registerEntries.Where(e => UntisNameMatching.NamesMatch(e.StudentName, studentName)).ToList();
}
var registerRows = registerEntries
.Select(e => (Entry: e, Valid: TryDate(e.Date, out var date), Date: date))
.Where(x => x.Valid && x.Date.Year == first.Year && x.Date.Month == first.Month)
@@ -768,6 +809,12 @@ public partial class ClassTeacherOverviewViewModel : ObservableObject
return result;
}
internal static string DisplayStudentName(string value)
{
var parts = value.Split(' ', StringSplitOptions.RemoveEmptyEntries);
return parts.Length < 2 ? value : string.Join(" ", parts.Skip(1).Append(parts[0]));
}
private static int SignalPriority(ClassTeacherCalendarEventKind kind) => kind switch
{
ClassTeacherCalendarEventKind.Unexcused => 0,
@@ -44,6 +44,15 @@ public partial class GroupOverviewViewModel : ObservableObject
private readonly GradingService _grading;
private readonly SchoolYearService _schoolYear;
public ObservableCollection<Lesson> TodayLessons { get; } = [];
[ObservableProperty] private Lesson? _selectedTeachingLesson;
public bool HasTodayLessons => TodayLessons.Count > 0;
public Action<Lesson>? OnOpenTeachingMode { get; set; }
[RelayCommand] private void StartTeachingMode()
{
if (SelectedTeachingLesson is { } lesson) OnOpenTeachingMode?.Invoke(lesson);
}
private Guid _groupId;
private string _groupName = "";
@@ -130,6 +139,16 @@ public partial class GroupOverviewViewModel : ObservableObject
public void Refresh()
{
var today = DateOnly.FromDateTime(DateTime.Today);
TodayLessons.Clear();
if (_groups.GetById(_groupId)?.IsActive == true)
foreach (var lesson in _lessons.GetByGroupAndRange(_groupId, today, today)
.Where(l => l.Status != LessonStatus.Cancelled)
.OrderBy(l => l.StartTime).ThenBy(l => l.LessonNumber))
TodayLessons.Add(lesson);
var now = TimeOnly.FromDateTime(DateTime.Now);
SelectedTeachingLesson = TodayLessons.LastOrDefault(l => l.StartTime <= now)
?? TodayLessons.FirstOrDefault();
OnPropertyChanged(nameof(HasTodayLessons));
LoadNextLesson(today);
LoadNextExam(today);
LoadYearComparison();
@@ -826,6 +826,7 @@ public partial class AddGroupDialogViewModel : ObservableObject
[ObservableProperty] private bool _isDifferentiated;
[ObservableProperty] private bool _requiresLessonPlanning = true;
[ObservableProperty] private int? _webUntisLessonId;
[ObservableProperty] private bool _excludedFromUntisHub;
[ObservableProperty] private string _nameError = "";
[ObservableProperty] private string _gradeLevelError = "";
@@ -866,6 +867,7 @@ public partial class AddGroupDialogViewModel : ObservableObject
IsDifferentiated = group.IsDifferentiated;
RequiresLessonPlanning = group.RequiresLessonPlanning;
WebUntisLessonId = group.WebUntisLessonId;
ExcludedFromUntisHub = group.ExcludedFromUntisHub;
OnPropertyChanged(nameof(DialogTitle));
OnPropertyChanged(nameof(SaveButtonText));
}
@@ -913,6 +915,7 @@ public partial class AddGroupDialogViewModel : ObservableObject
Result.IsDifferentiated = IsDifferentiated;
Result.RequiresLessonPlanning = RequiresLessonPlanning;
Result.WebUntisLessonId = WebUntisLessonId;
Result.ExcludedFromUntisHub = ExcludedFromUntisHub;
_groups.Save(Result);
}
}
@@ -0,0 +1,86 @@
using CommunityToolkit.Mvvm.ComponentModel;
using LehrerApp.Core.Interfaces;
using LehrerApp.Core.Models;
using LehrerApp.Desktop.Services;
using LehrerApp.Desktop.ViewModels.Students;
using LehrerApp.Templating;
using System.Collections.ObjectModel;
namespace LehrerApp.Desktop.ViewModels.Groups;
/// <summary>Erzeugt aus einer in TemplateDesigner gebauten Arbeitsblatt-.lavorlage-Vorlage ein
/// personalisiertes PDF je aktivem Mitglied der aktuell geöffneten Lerngruppe. Nutzt bewusst
/// dieselbe Platzhalter-/Rendering-Infrastruktur wie der Elternbrief-Dialog
/// (<see cref="LetterPlaceholderBuilder"/>, <see cref="ITemplateRenderer"/>), aber eine getrennte
/// <see cref="WorksheetTemplateStore"/>-Vorlagenbibliothek.</summary>
public partial class PersonalizeWorksheetDialogViewModel : ObservableObject
{
private readonly LearningGroup _group;
private readonly WorksheetTemplateStore _templates;
private readonly ITemplateRenderer _renderer;
[ObservableProperty] private LetterTemplateChoice? _selectedTemplate;
[ObservableProperty] private string _statusMessage = "";
public string GroupName => $"{_group.Name} · {_group.SchoolYear}";
public ObservableCollection<LetterTemplateChoice> Templates { get; } = [];
public ObservableCollection<WorksheetStudentChoice> Students { get; } = [];
public ObservableCollection<WorksheetGenerationResult> Results { get; } = [];
public bool HasNoTemplates => Templates.Count == 0;
public bool HasResults => Results.Count > 0;
public PersonalizeWorksheetDialogViewModel(LearningGroup group, IReadOnlyList<Guid> studentIds,
IStudentRepository students, WorksheetTemplateStore templates, ITemplateRenderer renderer)
{
_group = group; _templates = templates; _renderer = renderer;
foreach (var template in templates.Store.GetTemplates()) Templates.Add(new(template));
SelectedTemplate = Templates.FirstOrDefault();
foreach (var id in studentIds)
if (students.GetById(id) is { } student) Students.Add(new(student));
}
public void Generate(string outputFolder)
{
Results.Clear();
if (SelectedTemplate is null) return;
LoadedTemplate loaded;
try { loaded = _templates.Store.Load(SelectedTemplate.Model); }
catch (Exception ex) when (ex is IOException or InvalidDataException or TemplateValidationException)
{ StatusMessage = $"Vorlage ist ungültig: {ex.Message}"; return; }
Directory.CreateDirectory(outputFolder);
foreach (var choice in Students.Where(x => x.IsIncluded))
{
var student = choice.Model;
try
{
var values = LetterPlaceholderBuilder.BuildStandardValues(student, contact: null, _group,
DateOnly.FromDateTime(DateTime.Now), "", "");
var pdf = _renderer.RenderToPdf(loaded, new LetterDataProvider(values));
var fileName = SanitizeFileName($"{SelectedTemplate.Name}_{student.LastName}_{student.FirstName}.pdf");
File.WriteAllBytes(Path.Combine(outputFolder, fileName), pdf);
Results.Add(new(student.FullName, true, ""));
}
catch (Exception ex) when (ex is IOException or InvalidDataException or UnauthorizedAccessException or TemplateValidationException)
{ Results.Add(new(student.FullName, false, ex.Message)); }
}
OnPropertyChanged(nameof(HasResults));
StatusMessage = $"{Results.Count(x => x.Success)} von {Results.Count} Arbeitsblättern erzeugt.";
}
private static string SanitizeFileName(string value)
{ foreach (var character in Path.GetInvalidFileNameChars()) value = value.Replace(character, '_'); return value; }
}
public partial class WorksheetStudentChoice(Student model) : ObservableObject
{
public Student Model { get; } = model;
public string FullName => Model.FullName;
[ObservableProperty] private bool _isIncluded = true;
}
public sealed record WorksheetGenerationResult(string StudentName, bool Success, string ErrorMessage)
{
public string Icon => Success ? "✓" : "⚠";
public string Color => Success ? "SeaGreen" : "#D97706";
}
@@ -737,6 +737,7 @@ public partial class LessonDialogViewModel : ObservableObject
[ObservableProperty] private int? _lessonNumber;
[ObservableProperty] private string _topic = "";
[ObservableProperty] private string _startTimeText = "";
[ObservableProperty] private string _planningIdeas = "";
[ObservableProperty] private string _homework = "";
[ObservableProperty] private bool _homeworkChecked;
[ObservableProperty] private bool _homeworkCheckDismissed;
@@ -823,6 +824,7 @@ public partial class LessonDialogViewModel : ObservableObject
LessonNumber = editingLesson.LessonNumber;
Topic = editingLesson.Topic;
StartTimeText = editingLesson.StartTime?.ToString("HH:mm") ?? "";
PlanningIdeas = editingLesson.PlanningIdeas ?? "";
Homework = editingLesson.Homework ?? "";
HomeworkChecked = editingLesson.HomeworkChecked;
HomeworkCheckDismissed = editingLesson.HomeworkCheckDismissed;
@@ -888,6 +890,7 @@ public partial class LessonDialogViewModel : ObservableObject
Activity = source?.Activity ?? "",
Material = source?.Material ?? "",
Shorthand = source?.Shorthand ?? "",
MaterialPrompt = source?.MaterialPrompt,
};
item.OnChanged = RecomputeTimes;
item.OnRemove = RemovePhase;
@@ -1075,6 +1078,7 @@ public partial class LessonDialogViewModel : ObservableObject
Result.LessonNumber = LessonNumber;
Result.Topic = Topic.Trim();
Result.StartTime = startTime;
Result.PlanningIdeas = string.IsNullOrWhiteSpace(PlanningIdeas) ? null : PlanningIdeas.Trim();
Result.Phases = Phases.Select(p => p.ToModel()).ToList();
Result.Homework = string.IsNullOrWhiteSpace(Homework) ? null : Homework.Trim();
Result.HomeworkChecked = HomeworkChecked;
@@ -1103,6 +1107,13 @@ public partial class PhaseStepEditItem : ObservableObject
[ObservableProperty] private string _material = "";
[ObservableProperty] private string _shorthand = "";
[ObservableProperty] private string _computedTimeDisplay = "";
/// Gespeicherter Materialerstellungs-Prompt (4.5.20/4.5.36) — nur gesetzt, wenn die KI beim
/// letzten "Übernehmen" einen Medienvorschlag für diese Phase gemacht hatte. Steuert die
/// Sichtbarkeit des Kopieren-Buttons im Verlaufsplan-Editor.
[ObservableProperty] private string? _materialPrompt;
public bool HasMaterialPrompt => !string.IsNullOrWhiteSpace(MaterialPrompt);
partial void OnMaterialPromptChanged(string? value) => OnPropertyChanged(nameof(HasMaterialPrompt));
/// Checkbox-Zustand im Editor: unchecked→checked öffnet den Zuweisen-Dialog
/// (<see cref="OnAssignAlternativePath"/>); checked→unchecked entfernt die Zuordnung.
@@ -1162,6 +1173,7 @@ public partial class PhaseStepEditItem : ObservableObject
Activity = Activity.Trim(),
Material = Material.Trim(),
Shorthand = Shorthand.Trim(),
MaterialPrompt = MaterialPrompt,
AlternativePathId = AlternativePathId,
};
}
@@ -16,6 +16,45 @@ public partial class SeatingPlanTabViewModel : ObservableObject
private readonly IParticipationRepository _participation;
private readonly IParticipationAspectRepository _aspects;
private readonly IDocumentationRepository? _documentation;
[ObservableProperty] private bool _isTeachingMode;
[ObservableProperty] private string _quickMode = "";
public bool IsQuickMode => QuickMode.Length > 0;
public string QuickModeDisplay => QuickMode switch
{
"Attendance" => "Anwesenheit kontrollieren · Fehlend = Entschuldigung offen",
"Homework" => "Hausaufgaben kontrollieren",
_ => "Klicken: bewerten"
};
[RelayCommand] private void CheckAttendance() => QuickMode = "Attendance";
[RelayCommand] private void CheckHomework() => QuickMode = "Homework";
[RelayCommand] private void EndQuickCheck() => QuickMode = "";
partial void OnQuickModeChanged(string value)
{
if (value.Length > 0) IsEditMode = false;
foreach (var seat in Seats) seat.QuickMode = value;
OnPropertyChanged(nameof(IsQuickMode));
OnPropertyChanged(nameof(QuickModeDisplay));
}
private void SaveQuickStatus(SeatCellViewModel seat, bool positive)
{
if (!IsEditable || !IsQuickMode || seat.SelectedOption.StudentId is not Guid studentId) return;
var session = EnsureTodaySession();
if (session is null) return;
// Always merge with the latest entry, so quick checks preserve ratings and counters.
var entry = _participation.GetBySessionAndStudent(session.Id, studentId)
?? new ParticipationEntry { SessionId = session.Id, StudentId = studentId };
if (QuickMode == "Attendance") entry.Attendance = positive ? AttendanceStatus.Present : AttendanceStatus.ExcusePending;
else
{
entry.Homework = positive ? HomeworkStatus.Completed : HomeworkStatus.MissingOpen;
entry.HomeworkMissing = HomeworkDisplay.CountsAsMissing(entry.Homework);
}
_participation.Save(entry);
RefreshSeatLessonData();
OnAssessmentChanged?.Invoke();
}
private Guid _groupId;
private SeatingPlan? _currentPlan;
private bool _isReadOnly;
@@ -235,7 +274,12 @@ public partial class SeatingPlanTabViewModel : ObservableObject
?? StudentSeatOption.Empty;
var isHidden = hiddenSeats.Any(h => h.Row == row && h.Column == column);
Seats.Add(new SeatCellViewModel(row, column, StudentOptions, option, OnSeatChanged,
CanEditLayout, ToggleSituationTag, IsEditable, isHidden, ToggleSeatHidden, TallyParticipation));
CanEditLayout, ToggleSituationTag, IsEditable, isHidden, ToggleSeatHidden, TallyParticipation)
{
QuickMode = QuickMode,
OnQuickStatus = SaveQuickStatus,
OnQuickSpecial = AssessStudent
});
}
UpdateAssignmentSummary();
RefreshSeatLessonData();
@@ -488,6 +532,7 @@ public partial class SeatingPlanTabViewModel : ObservableObject
partial void OnIsEditModeChanged(bool value)
{
if (value) QuickMode = "";
OnPropertyChanged(nameof(CanEditLayout));
foreach (var seat in Seats)
{
@@ -540,6 +585,32 @@ public sealed class ParticipationSessionOption(ParticipationSession session)
public partial class SeatCellViewModel : ObservableObject
{
[ObservableProperty] private string _quickMode = "";
public bool ShowQuickCheck => QuickMode.Length > 0 && IsOccupied && CanRecordLesson;
public bool ShowNormalActions => ShowLessonOverview && QuickMode.Length == 0;
public bool ShowSituationActions => ShowNormalActions && CanRecordLesson;
public string QuickPositiveLabel => QuickMode == "Attendance" ? "Anwesend" : "Gemacht";
public string QuickNegativeLabel => QuickMode == "Attendance" ? "Fehlend" : "Fehlt";
public Action<SeatCellViewModel, bool>? OnQuickStatus { get; init; }
public Func<SeatCellViewModel, Task>? OnQuickSpecial { get; init; }
[RelayCommand] private void QuickPositive() => OnQuickStatus?.Invoke(this, true);
[RelayCommand] private void QuickNegative() => OnQuickStatus?.Invoke(this, false);
[RelayCommand] private Task QuickSpecial() => OnQuickSpecial?.Invoke(this) ?? Task.CompletedTask;
partial void OnQuickModeChanged(string value)
{
OnPropertyChanged(nameof(ShowQuickCheck));
OnPropertyChanged(nameof(ShowNormalActions));
OnPropertyChanged(nameof(ShowSituationActions));
OnPropertyChanged(nameof(QuickPositiveLabel));
OnPropertyChanged(nameof(QuickNegativeLabel));
OnPropertyChanged(nameof(DisplayOpacity));
}
partial void OnCanRecordLessonChanged(bool value)
{
OnPropertyChanged(nameof(ShowQuickCheck));
OnPropertyChanged(nameof(ShowSituationActions));
}
private readonly Action<SeatCellViewModel> _onChanged;
private bool _suppressChange;
private readonly Action<SeatCellViewModel, string> _toggleSituationTag;
@@ -581,7 +652,7 @@ public partial class SeatCellViewModel : ObservableObject
/// Opacity ist im DataTemplate bereits lokal an LessonOpacity gebunden gewesen; ein lokal
/// gebundener Wert überschreibt aber jeden Style-Setter für dieselbe Eigenschaft, daher muss
/// die Abblendung für ausgeblendete Plätze hier statt per CSS-Klasse erfolgen.</summary>
public double DisplayOpacity => IsHidden ? 0.4 : LessonOpacity;
public double DisplayOpacity => IsHidden ? 0.4 : ShowQuickCheck ? 1 : LessonOpacity;
private readonly Action<SeatCellViewModel, bool> _tally;
@@ -615,6 +686,9 @@ public partial class SeatCellViewModel : ObservableObject
partial void OnSelectedOptionChanged(StudentSeatOption value)
{
OnPropertyChanged(nameof(ShowQuickCheck));
OnPropertyChanged(nameof(ShowNormalActions));
OnPropertyChanged(nameof(ShowSituationActions));
OnPropertyChanged(nameof(IsOccupied));
OnPropertyChanged(nameof(StudentName));
OnPropertyChanged(nameof(ShowLessonOverview));
@@ -624,6 +698,8 @@ public partial class SeatCellViewModel : ObservableObject
partial void OnCanEditChanged(bool value)
{
OnPropertyChanged(nameof(ShowNormalActions));
OnPropertyChanged(nameof(ShowSituationActions));
OnPropertyChanged(nameof(ShowLessonOverview));
OnPropertyChanged(nameof(ShowSeat));
OnPropertyChanged(nameof(CanToggleHidden));
@@ -20,6 +20,7 @@ namespace LehrerApp.Desktop.ViewModels.Groups;
/// </summary>
public class TeachingModeViewModel
{
public TeachingTimelineViewModel Timeline { get; }
public string GroupName { get; }
public LessonViewerViewModel LessonInfo { get; }
public SeatingPlanTabViewModel SeatingPlan { get; }
@@ -38,9 +39,11 @@ public class TeachingModeViewModel
SeatingPlanTabViewModel seatingPlan, ParticipationTabViewModel participation)
{
GroupName = group.Name;
Timeline = new TeachingTimelineViewModel(lesson, lessons, !group.IsActive);
LessonInfo = new LessonViewerViewModel(lesson, alternativePaths);
SeatingPlan = seatingPlan;
SeatingPlan.IsTeachingMode = true;
SeatingPlan.Initialize(group.Id, !group.IsActive);
SeatingPlan.SelectOrCreateSessionForLesson(lesson);
@@ -102,14 +105,19 @@ public partial class TeachingModeHomeworkViewModel : ObservableObject
if (_previousLesson is null) return;
_previousLesson.HomeworkChecked = value;
if (value) _previousLesson.HomeworkCheckDismissed = false;
_lessons.Save(_previousLesson);
var latest = _lessons.GetById(_previousLesson.Id) ?? _previousLesson;
latest.HomeworkChecked = value;
if (value) latest.HomeworkCheckDismissed = false;
_lessons.Save(latest);
}
[RelayCommand]
private void SaveCurrentHomework()
{
_lesson.Homework = CurrentHomework;
_lessons.Save(_lesson);
var latest = _lessons.GetById(_lesson.Id) ?? _lesson;
latest.Homework = CurrentHomework;
_lessons.Save(latest);
SaveStatus = "Gespeichert.";
}
}
@@ -0,0 +1,224 @@
using System.Collections.ObjectModel;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using LehrerApp.Core.Interfaces;
using LehrerApp.Core.Models;
namespace LehrerApp.Desktop.ViewModels.Groups;
public partial class TeachingTimelineViewModel : ObservableObject
{
private readonly Lesson _lesson;
private readonly ILessonRepository _lessons;
private readonly Func<DateTime> _utcNow;
private TeachingTimelineState? _state;
public bool IsEditable { get; }
public ObservableCollection<TeachingPhaseViewModel> Phases { get; } = [];
public ObservableCollection<Lesson> TransferTargets { get; } = [];
[ObservableProperty] private Lesson? _transferTarget;
[ObservableProperty] private string _status = "";
[ObservableProperty] private bool _needsStart;
public bool HasPhases => Phases.Count > 0;
public bool HasTransferTargets => TransferTargets.Count > 0;
public bool HasRemainder => Phases.Any(p => p.IsOverflow && !p.IsCompleted && !p.IsTransferred);
public TeachingTimelineViewModel(Lesson lesson, ILessonRepository lessons, bool readOnly = false,
Func<DateTime>? utcNow = null)
{
_lesson = lesson;
_lessons = lessons;
_utcNow = utcNow ?? (() => DateTime.UtcNow);
IsEditable = !readOnly;
_state = lesson.TeachingTimeline;
if (_state is not null)
{
// LiteDB returns local DateTimes by default; arithmetic below uses UTC.
_state.StartUtc = _state.StartUtc.ToUniversalTime();
_state.EndUtc = _state.EndUtc.ToUniversalTime();
_state.HeldSinceUtc = _state.HeldSinceUtc?.ToUniversalTime();
}
foreach (var phase in lesson.Phases.Where(p => p.AlternativePathId is null))
Phases.Add(new TeachingPhaseViewModel(phase, this));
if (_state is null && lesson.StartTime is { } start)
CreateState(lesson.Date.ToDateTime(start).ToUniversalTime());
foreach (var target in lessons.GetByGroupAndRange(lesson.GroupId, lesson.Date, lesson.Date.AddDays(120))
.Where(l => l.Id != lesson.Id && l.Status is not (LessonStatus.Cancelled or LessonStatus.Conducted)
&& (l.Date > lesson.Date || l.StartTime > lesson.StartTime || l.LessonNumber > lesson.LessonNumber))
.OrderBy(l => l.Date).ThenBy(l => l.StartTime).ThenBy(l => l.LessonNumber))
TransferTargets.Add(target);
TransferTarget = TransferTargets.FirstOrDefault();
Refresh();
}
private void CreateState(DateTime start)
{
_state = new TeachingTimelineState
{
StartUtc = start,
EndUtc = start.AddMinutes(Phases.Sum(p => Math.Max(0, p.Source.DurationMinutes))),
Phases = Phases.Select(p => new TeachingPhaseTiming
{ PhaseId = p.Source.Id, Minutes = Math.Max(0, p.Source.DurationMinutes) }).ToList()
};
}
[RelayCommand] private void StartNow()
{
if (!IsEditable || _state is not null) return;
CreateState(_utcNow());
Save();
Refresh();
}
public void Refresh()
{
NeedsStart = _state is null && HasPhases;
if (_state is null) return;
var now = _utcNow();
var cursor = _state.StartUtc;
_state.Phases.RemoveAll(t => !Phases.Any(p => p.Source.Id == t.PhaseId));
var ordered = _state.Phases.Select(t => Phases.FirstOrDefault(p => p.Source.Id == t.PhaseId))
.OfType<TeachingPhaseViewModel>().ToList();
// Keep surviving IDs in their live order when a plan was edited between openings.
foreach (var phase in Phases.Where(p => !ordered.Contains(p)).ToList())
{
_state.Phases.Add(new TeachingPhaseTiming { PhaseId = phase.Source.Id, Minutes = Math.Max(0, phase.Source.DurationMinutes) });
ordered.Add(phase);
}
for (var i = 0; i < ordered.Count; i++)
if (Phases.IndexOf(ordered[i]) != i) Phases.Move(Phases.IndexOf(ordered[i]), i);
foreach (var phase in Phases)
{
var timing = _state.Phases.First(t => t.PhaseId == phase.Source.Id);
var held = _state.HeldPhaseId == phase.Source.Id && _state.HeldSinceUtc.HasValue;
var extension = held ? Math.Max(0, (now - _state.HeldSinceUtc!.Value).TotalMinutes) : 0;
var end = cursor.AddMinutes(timing.Minutes + extension);
// Phases pushed entirely out of the lesson remain pending, even after closing
// the window overnight. They run only if explicitly brought forward.
var canRun = cursor < _state.EndUtc || timing.ExplicitlyStarted || held;
phase.StartUtc = cursor;
phase.EndUtc = end;
phase.IsActive = canRun && cursor <= now && (now < end || held)
&& (now < _state.EndUtc || timing.ExplicitlyStarted || held);
phase.IsCompleted = canRun && !held && end <= now
&& (end <= _state.EndUtc || timing.ExplicitlyStarted);
phase.IsOverflow = end > _state.EndUtc && !phase.IsCompleted;
phase.IsTransferred = _state.TransferredPhaseIds.Contains(phase.Source.Id);
phase.IsHeld = held;
var effectiveNow = timing.ExplicitlyStarted || held || now < _state.EndUtc ? now : _state.EndUtc;
var elapsed = Math.Clamp((effectiveNow - cursor).TotalMinutes, 0, timing.Minutes + extension);
phase.RemainingMinutes = phase.IsCompleted ? 0 : timing.Minutes + extension - elapsed;
phase.Progress = !canRun ? 0 : phase.IsCompleted ? 100 : elapsed / Math.Max(0.01, timing.Minutes + extension) * 100;
phase.TimeDisplay = $"{cursor.ToLocalTime():HH:mm}{end.ToLocalTime():HH:mm}";
phase.CanStartNow = IsEditable && !phase.IsCompleted && !phase.IsActive && !phase.IsTransferred;
cursor = end;
}
OnPropertyChanged(nameof(HasRemainder));
}
public void Extend(TeachingPhaseViewModel phase, int minutes)
{
Refresh();
if (!IsEditable || !phase.IsActive || _state is null) return;
var timing = _state.Phases.First(p => p.PhaseId == phase.Source.Id);
timing.Minutes += minutes;
timing.ExplicitlyStarted = true;
Save(); Refresh();
}
public void Hold(TeachingPhaseViewModel phase)
{
Refresh();
if (!IsEditable || !phase.IsActive || phase.IsHeld || _state is null) return;
_state.HeldPhaseId = phase.Source.Id;
_state.HeldSinceUtc = _utcNow();
_state.Phases.First(p => p.PhaseId == phase.Source.Id).ExplicitlyStarted = true;
Save(); Refresh();
}
public void Finish(TeachingPhaseViewModel phase, bool advanceNext = true)
{
Refresh();
if (!IsEditable || !phase.IsActive || _state is null) return;
_state.Phases.First(p => p.PhaseId == phase.Source.Id).Minutes = Math.Max(0, (_utcNow() - phase.StartUtc).TotalMinutes);
_state.HeldPhaseId = null;
_state.HeldSinceUtc = null;
if (advanceNext)
{
var nextIndex = _state.Phases.FindIndex(p => p.PhaseId == phase.Source.Id) + 1;
if (nextIndex < _state.Phases.Count) _state.Phases[nextIndex].ExplicitlyStarted = true;
}
Save(); Refresh();
}
public void BringForward(TeachingPhaseViewModel phase)
{
Refresh();
if (!phase.CanStartNow || _state is null) return;
var active = Phases.FirstOrDefault(p => p.IsActive);
if (active is not null) Finish(active, advanceNext: false);
var timing = _state.Phases.First(p => p.PhaseId == phase.Source.Id);
timing.Minutes = phase.RemainingMinutes;
_state.Phases.Remove(timing);
var completed = Phases.TakeWhile(p => p.IsCompleted).Count();
_state.Phases.Insert(Math.Min(completed, _state.Phases.Count), timing);
timing.ExplicitlyStarted = true;
var now = _utcNow();
// A pending phase may be selected long after the scheduled end. Anchor it to
// now instead of letting yesterday's timestamps immediately complete it.
var prefix = _state.Phases.Take(completed).Sum(p => p.Minutes);
var delay = (now - _state.StartUtc.AddMinutes(prefix)).TotalMinutes;
if (completed == 0) _state.StartUtc = now;
else if (delay > 0) _state.Phases[completed - 1].Minutes += delay;
Save(); Refresh();
}
[RelayCommand] private void TransferRemainder()
{
Refresh();
if (!IsEditable || _state is null || TransferTarget is null) return;
var target = _lessons.GetById(TransferTarget.Id);
if (target is null || target.Status is LessonStatus.Cancelled or LessonStatus.Conducted) return;
var remainder = Phases.Where(p => p.IsOverflow && !p.IsCompleted && !p.IsTransferred).ToList();
if (remainder.Count == 0) return;
foreach (var phase in remainder)
{
var source = phase.Source;
target.Phases.Add(new LessonPhaseStep { Name = source.Name, DurationMinutes = (int)Math.Ceiling(phase.RemainingMinutes),
Activity = source.Activity, Material = source.Material, Shorthand = source.Shorthand });
}
_lessons.Save(target);
_state.TransferredPhaseIds.AddRange(remainder.Select(p => p.Source.Id));
Save(); Refresh();
Status = $"{remainder.Count} Phase(n) nach {target.Date:dd.MM.yyyy} · {target.Topic} kopiert.";
}
private void Save()
{
var latest = _lessons.GetById(_lesson.Id) ?? _lesson;
latest.TeachingTimeline = _state;
_lesson.TeachingTimeline = _state;
_lessons.Save(latest);
}
}
public partial class TeachingPhaseViewModel(LessonPhaseStep source, TeachingTimelineViewModel owner) : ObservableObject
{
public LessonPhaseStep Source { get; } = source;
public DateTime StartUtc { get; set; }
public DateTime EndUtc { get; set; }
public double RemainingMinutes { get; set; }
[ObservableProperty] private bool _isActive;
[ObservableProperty] private bool _isCompleted;
[ObservableProperty] private bool _isOverflow;
[ObservableProperty] private bool _isHeld;
[ObservableProperty] private bool _isTransferred;
[ObservableProperty] private bool _canStartNow;
[ObservableProperty] private double _progress;
[ObservableProperty] private string _timeDisplay = "";
public bool IsEditable => owner.IsEditable;
[RelayCommand] private void ExtendFive() => owner.Extend(this, 5);
[RelayCommand] private void ExtendTen() => owner.Extend(this, 10);
[RelayCommand] private void Hold() => owner.Hold(this);
[RelayCommand] private void Finish() => owner.Finish(this);
[RelayCommand] private void BringForward() => owner.BringForward(this);
}
@@ -12,6 +12,7 @@ using LehrerApp.Desktop.ViewModels.Students;
using LehrerApp.Desktop.ViewModels.Workload;
using Microsoft.Extensions.DependencyInjection;
using System.Collections.ObjectModel;
using System.ComponentModel;
namespace LehrerApp.Desktop.ViewModels;
@@ -38,6 +39,9 @@ public partial class MainWindowViewModel : ObservableObject
public bool IsClassTeacherActive => ActiveNavItem == NavItem.ClassTeacher;
public bool IsSettingsActive => ActiveNavItem == NavItem.Settings;
public GroupDetailViewModel? CurrentGroupDetail => CurrentPage as GroupDetailViewModel;
public bool CanPersonalizeWorksheet => CurrentGroupDetail?.Group is not null;
public MainWindowViewModel(IServiceProvider services,
DashboardViewModel dashboard, SchoolYearService sy,
SyncStatusViewModel syncStatus, NotificationService notifications, AppLockViewModel appLock,
@@ -103,6 +107,25 @@ public partial class MainWindowViewModel : ObservableObject
}
}
// GroupDetailViewModel.Group wird erst nach dem Wechsel von CurrentPage per LoadGroup gesetzt
// (siehe Kommentar in NavigateToGroupDetail) - ohne dieses Abonnement bliebe
// CanPersonalizeWorksheet bis zum nächsten Seitenwechsel auf dem alten Stand.
private GroupDetailViewModel? _observedGroupDetail;
partial void OnCurrentPageChanged(ObservableObject? value)
{
if (_observedGroupDetail is not null) _observedGroupDetail.PropertyChanged -= OnGroupDetailPropertyChanged;
_observedGroupDetail = value as GroupDetailViewModel;
if (_observedGroupDetail is not null) _observedGroupDetail.PropertyChanged += OnGroupDetailPropertyChanged;
OnPropertyChanged(nameof(CurrentGroupDetail));
OnPropertyChanged(nameof(CanPersonalizeWorksheet));
}
private void OnGroupDetailPropertyChanged(object? sender, PropertyChangedEventArgs e)
{
if (e.PropertyName == nameof(GroupDetailViewModel.Group)) OnPropertyChanged(nameof(CanPersonalizeWorksheet));
}
partial void OnActiveNavItemChanged(NavItem value)
{
OnPropertyChanged(nameof(IsDashboardActive));
@@ -0,0 +1,60 @@
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using LehrerApp.Templating;
using System.Collections.ObjectModel;
namespace LehrerApp.Desktop.ViewModels.Settings;
public partial class SettingsViewModel
{
[ObservableProperty] private string _worksheetTemplateStatus = "";
public ObservableCollection<LetterTemplateListItem> WorksheetTemplateList { get; } = [];
private void LoadWorksheetTemplates()
{
WorksheetTemplateList.Clear();
foreach (var template in _worksheetTemplates.Store.GetTemplates()) WorksheetTemplateList.Add(CreateWorksheetItem(template));
}
public void ImportWorksheetTemplate(string path)
{
WorksheetTemplateStatus = "";
try
{
var template = _worksheetTemplates.Store.Import(path);
LoadWorksheetTemplates();
WorksheetTemplateStatus = $"„{template.Name}“ wurde geprüft und lokal importiert.";
}
catch (Exception ex) when (ex is IOException or InvalidDataException or UnauthorizedAccessException or TemplateValidationException)
{ WorksheetTemplateStatus = $"Import fehlgeschlagen: {ex.Message}"; }
}
[RelayCommand]
private void ValidateWorksheetTemplate(LetterTemplateListItem? item)
{
if (item is null) return;
try
{
var refreshed = CreateWorksheetItem(item.Model);
var index = WorksheetTemplateList.IndexOf(item);
if (index >= 0) WorksheetTemplateList[index] = refreshed;
WorksheetTemplateStatus = $"„{item.Name}“ ist gültig (Schema {refreshed.SchemaVersion}).";
}
catch (Exception ex) when (ex is IOException or InvalidDataException or TemplateValidationException)
{ WorksheetTemplateStatus = $"„{item.Name}“ ist ungültig: {ex.Message}"; }
}
[RelayCommand]
private void DeleteWorksheetTemplate(LetterTemplateListItem? item)
{
if (item is null) return;
_worksheetTemplates.Store.Delete(item.Id); WorksheetTemplateList.Remove(item); WorksheetTemplateStatus = "Vorlage gelöscht.";
}
private LetterTemplateListItem CreateWorksheetItem(InstalledTemplate template)
{
var loaded = _worksheetTemplates.Store.Load(template);
return new(template, loaded.Manifest.SchemaVersion, loaded.Manifest.Placeholders.Count(x => !x.IsConstant),
loaded.Manifest.Placeholders.Count(x => !x.IsConstant && x.Required));
}
}
@@ -62,6 +62,7 @@ public partial class SettingsViewModel : ObservableObject
private readonly IStudentRepository _students;
private readonly IShorthandCodeRepository _shorthandCodes;
private readonly TemplateStore _letterTemplates;
private readonly WorksheetTemplateStore _worksheetTemplates;
[ObservableProperty] private int _activeTabIndex;
@@ -102,6 +103,7 @@ public partial class SettingsViewModel : ObservableObject
IShorthandCodeRepository shorthandCodes, ISchoolHolidayRepository schoolHolidays,
SchoolCalendarSettingsService calendarSettings, PeriodScheduleService periodSchedule,
ISupervisionDutyRepository supervisionDuties, TemplateStore letterTemplates,
WorksheetTemplateStore worksheetTemplates,
AiSettingsService aiSettings, AiPlanningService aiPlanning, McpSettingsService mcpSettings,
Services.Mcp.McpClientRegistrationService mcpRegistration,
WebUntisSettingsService untisSettings,
@@ -139,6 +141,7 @@ public partial class SettingsViewModel : ObservableObject
_periodSchedule = periodSchedule;
_supervisionDuties = supervisionDuties;
_letterTemplates = letterTemplates;
_worksheetTemplates = worksheetTemplates;
_aiSettings = aiSettings;
_aiPlanning = aiPlanning;
_mcpSettings = mcpSettings;
@@ -169,6 +172,7 @@ public partial class SettingsViewModel : ObservableObject
LoadPeriodTimes();
LoadSupervisionDuties();
LoadLetterTemplates();
LoadWorksheetTemplates();
LoadAiSettings();
LoadMcpSettings();
LoadMcpRegistrationStatus();
@@ -0,0 +1,35 @@
using CommunityToolkit.Mvvm.ComponentModel;
using LehrerApp.Desktop.Services;
namespace LehrerApp.Desktop.ViewModels.Students;
public sealed record AttendanceCalendarSizeChoice(AttendanceCalendarSize Value, string DisplayName);
public partial class AttendanceCalendarConfigurationViewModel : ObservableObject
{
[ObservableProperty] private DateTimeOffset? _startMonth;
[ObservableProperty] private int _monthCount;
[ObservableProperty] private AttendanceCalendarSizeChoice _selectedSize;
public IReadOnlyList<int> MonthCounts { get; } = [1, 2, 3];
public IReadOnlyList<AttendanceCalendarSizeChoice> Sizes { get; } =
[
new(AttendanceCalendarSize.Small, "Klein"),
new(AttendanceCalendarSize.Medium, "Standard"),
new(AttendanceCalendarSize.Large, "Groß"),
];
public AttendanceCalendarConfigurationViewModel(AttendanceCalendarOptions options)
{
StartMonth = new DateTimeOffset(options.NormalizedStartMonth.ToDateTime(TimeOnly.MinValue));
MonthCount = options.NormalizedMonthCount;
SelectedSize = Sizes.First(s => s.Value == options.Size);
}
public AttendanceCalendarOptions BuildResult()
{
var date = DateOnly.FromDateTime((StartMonth ?? DateTimeOffset.Now).LocalDateTime);
return new AttendanceCalendarOptions(new DateOnly(date.Year, date.Month, 1), MonthCount,
SelectedSize.Value);
}
}
@@ -12,31 +12,54 @@ public partial class CreateLetterDialogViewModel : ObservableObject
private readonly Student _student;
private readonly TemplateStore _templates;
private readonly ITemplateRenderer _renderer;
private readonly Func<AttendanceCalendarOptions, float, float, float, DrawingValue>? _attendanceCalendarFactory;
private readonly Func<AttendanceCalendarOptions, float, float, float, DrawingValue>? _absenceDayListFactory;
private readonly Func<AttendanceCalendarOptions, CancellationToken, Task>? _attendanceDataRefresher;
private AttendanceCalendarOptions _attendanceCalendarOptions = new(
new DateOnly(DateTime.Today.Year, DateTime.Today.Month, 1), 1);
[ObservableProperty] private LetterTemplateChoice? _selectedTemplate;
[ObservableProperty] private LetterContactChoice? _selectedContact;
[ObservableProperty] private LetterGroupChoice? _selectedGroup;
[ObservableProperty] private DateTimeOffset? _letterDate = DateTimeOffset.Now;
[ObservableProperty] private string _anrede = "";
[ObservableProperty] private string _letterText = "";
[ObservableProperty] private string _teacherName = "";
[ObservableProperty] private string _generationError = "";
[ObservableProperty] private bool _canGenerate;
[ObservableProperty] private bool _usesAttendanceCalendar;
[ObservableProperty] private bool _usesAbsenceDayList;
[ObservableProperty] private bool _attendanceCalendarConfigured;
[ObservableProperty] private string _attendanceCalendarSummary = "1 Monat · Standardgröße";
[ObservableProperty] private bool _isRefreshingAttendanceData;
[ObservableProperty] private string _attendanceRefreshError = "";
public string StudentName => _student.FullName;
public ObservableCollection<LetterTemplateChoice> Templates { get; } = [];
public ObservableCollection<LetterContactChoice> Contacts { get; } = [];
public ObservableCollection<LetterGroupChoice> Groups { get; } = [];
public ObservableCollection<LetterGenerationIssue> Issues { get; } = [];
public ObservableCollection<LetterPlaceholderInput> CustomPlaceholders { get; } = [];
public bool HasIssues => Issues.Count > 0;
public bool HasCustomPlaceholders => CustomPlaceholders.Count > 0;
public bool HasNoTemplates => Templates.Count == 0;
public bool HasNoContacts => Contacts.Count == 0;
public string AddressPreview => LetterPlaceholderBuilder.FormatAddress(SelectedContact?.Model);
public bool HasAddressPreview => !string.IsNullOrWhiteSpace(AddressPreview);
public bool UsesAttendanceAdvancedContent => UsesAttendanceCalendar || UsesAbsenceDayList;
public string SuggestedFileName => SanitizeFileName(
$"{SelectedTemplate?.Name ?? "Elternbrief"}_{_student.LastName}_{_student.FirstName}.pdf");
public CreateLetterDialogViewModel(Student student, TemplateStore templates, ITemplateRenderer renderer,
IGroupMembershipRepository memberships, IGroupRepository groups)
IGroupMembershipRepository memberships, IGroupRepository groups,
Func<AttendanceCalendarOptions, float, float, float, DrawingValue>? attendanceCalendarFactory = null,
Func<AttendanceCalendarOptions, float, float, float, DrawingValue>? absenceDayListFactory = null,
Func<AttendanceCalendarOptions, CancellationToken, Task>? attendanceDataRefresher = null)
{
_student = student; _templates = templates; _renderer = renderer;
_attendanceCalendarFactory = attendanceCalendarFactory;
_absenceDayListFactory = absenceDayListFactory;
_attendanceDataRefresher = attendanceDataRefresher;
foreach (var template in templates.GetTemplates()) Templates.Add(new(template));
foreach (var contact in student.Contacts.Where(c => !c.InvalidSince.HasValue).OrderBy(c => c.Name)) Contacts.Add(new(contact));
foreach (var membership in memberships.GetByStudent(student.Id))
@@ -45,10 +68,33 @@ public partial class CreateLetterDialogViewModel : ObservableObject
RefreshValidation();
}
partial void OnSelectedTemplateChanged(LetterTemplateChoice? value) { OnPropertyChanged(nameof(SuggestedFileName)); RefreshValidation(); }
partial void OnSelectedContactChanged(LetterContactChoice? value) => RefreshValidation();
partial void OnSelectedTemplateChanged(LetterTemplateChoice? value)
{
OnPropertyChanged(nameof(SuggestedFileName));
UsesAttendanceCalendar = TemplateUsesPlaceholder(value,
StudentAttendanceCalendarDrawingBuilder.PlaceholderName);
UsesAbsenceDayList = TemplateUsesPlaceholder(value,
StudentAbsenceDayListDrawingBuilder.PlaceholderName);
OnPropertyChanged(nameof(UsesAttendanceAdvancedContent));
AttendanceCalendarConfigured = false;
ResetAttendanceCalendarOptions();
RebuildCustomPlaceholders(value);
RefreshValidation();
}
partial void OnSelectedContactChanged(LetterContactChoice? value)
{
Anrede = value?.Model.LetterSalutation ?? "";
OnPropertyChanged(nameof(AddressPreview));
OnPropertyChanged(nameof(HasAddressPreview));
RefreshValidation();
}
partial void OnAnredeChanged(string value) => RefreshValidation();
partial void OnSelectedGroupChanged(LetterGroupChoice? value) => RefreshValidation();
partial void OnLetterDateChanged(DateTimeOffset? value) => RefreshValidation();
partial void OnLetterDateChanged(DateTimeOffset? value)
{
if (!AttendanceCalendarConfigured) ResetAttendanceCalendarOptions();
RefreshValidation();
}
partial void OnLetterTextChanged(string value) => RefreshValidation();
partial void OnTeacherNameChanged(string value) => RefreshValidation();
@@ -89,9 +135,129 @@ public partial class CreateLetterDialogViewModel : ObservableObject
OnPropertyChanged(nameof(HasIssues));
}
private IReadOnlyDictionary<string, PlaceholderValue> BuildValues() => LetterPlaceholderBuilder.BuildStandardValues(
_student, SelectedContact?.Model, SelectedGroup?.Model,
DateOnly.FromDateTime((LetterDate ?? DateTimeOffset.Now).LocalDateTime), LetterText, TeacherName);
private IReadOnlyDictionary<string, PlaceholderValue> BuildValues()
{
var calendarBox = DeclaredDrawingBox(StudentAttendanceCalendarDrawingBuilder.PlaceholderName);
var absenceDayListBox = DeclaredDrawingBox(StudentAbsenceDayListDrawingBuilder.PlaceholderName);
var values = LetterPlaceholderBuilder.BuildStandardValues(
_student, SelectedContact?.Model, SelectedGroup?.Model,
DateOnly.FromDateTime((LetterDate ?? DateTimeOffset.Now).LocalDateTime), LetterText, TeacherName,
UsesAttendanceCalendar ? _attendanceCalendarFactory?.Invoke(_attendanceCalendarOptions,
calendarBox.Width, calendarBox.MillimeterScale,
calendarBox.IsFixed ? calendarBox.Height : float.PositiveInfinity) : null,
UsesAbsenceDayList ? _absenceDayListFactory?.Invoke(_attendanceCalendarOptions,
absenceDayListBox.Width, absenceDayListBox.MillimeterScale,
absenceDayListBox.IsFixed ? absenceDayListBox.Height : float.PositiveInfinity) : null);
values["Anrede"] = new TextValue(Anrede); values["Letter.Salutation"] = new TextValue(Anrede);
foreach (var custom in CustomPlaceholders) values[custom.Name] = custom.ToPlaceholderValue();
return values;
}
/// <summary>Tatsächliche DRAWBOX/FLOWDRAWBOX-Größe der aktuell gewählten Vorlage für diesen
/// Platzhalter, damit der Kalender/die Fehltagesliste die real verfügbare Fläche ausfüllen statt
/// eine feste Breite zu raten (siehe LetterPlaceholderBuilder.FindDeclaredDrawingBox). Fällt auf
/// 170mm zurück, wenn keine Vorlage gewählt ist oder die Box nicht gefunden wird.</summary>
private LetterPlaceholderBuilder.DeclaredDrawingBox DeclaredDrawingBox(string placeholderName)
{
var fallback = new LetterPlaceholderBuilder.DeclaredDrawingBox(170, float.PositiveInfinity, 1, false);
if (SelectedTemplate is null) return fallback;
try { return LetterPlaceholderBuilder.FindDeclaredDrawingBox(_templates.Load(SelectedTemplate.Model), placeholderName) ?? fallback; }
catch (Exception ex) when (ex is IOException or InvalidDataException or TemplateValidationException) { return fallback; }
}
private void RebuildCustomPlaceholders(LetterTemplateChoice? choice)
{
foreach (var existing in CustomPlaceholders) existing.PropertyChanged -= OnCustomPlaceholderChanged;
CustomPlaceholders.Clear();
if (choice is not null)
{
try
{
var loaded = _templates.Load(choice.Model);
foreach (var placeholder in loaded.Manifest.Placeholders.Where(p => !p.IsConstant
&& !StandardPlaceholderNames.Contains(p.Name) && p.Type is PlaceholderType.Text
or PlaceholderType.Multiline or PlaceholderType.Date or PlaceholderType.Number))
{
var input = new LetterPlaceholderInput(placeholder.Name, placeholder.Type);
input.PropertyChanged += OnCustomPlaceholderChanged;
CustomPlaceholders.Add(input);
}
}
catch (Exception ex) when (ex is IOException or InvalidDataException or TemplateValidationException) { }
}
OnPropertyChanged(nameof(HasCustomPlaceholders));
}
private void OnCustomPlaceholderChanged(object? sender, System.ComponentModel.PropertyChangedEventArgs e) => RefreshValidation();
private static readonly HashSet<string> StandardPlaceholderNames = new(StringComparer.Ordinal)
{
"Datum", "CurrentDate", "Empfaenger", "Anrede", "Brieftext", "LehrerName",
"Student.FirstName", "Student.LastName", "Student.Name", "Contact.Name", "Contact.Address", "Contact.Street",
"Contact.PostalCode", "Contact.City", "Letter.Salutation", "Group.Name", "SchoolYear",
StudentAttendanceCalendarDrawingBuilder.PlaceholderName, StudentAbsenceDayListDrawingBuilder.PlaceholderName,
};
public AttendanceCalendarOptions GetAttendanceCalendarOptions() => _attendanceCalendarOptions;
public async Task SetAttendanceCalendarOptionsAsync(AttendanceCalendarOptions options, CancellationToken token = default)
{
_attendanceCalendarOptions = options with { StartMonth = options.NormalizedStartMonth,
MonthCount = options.NormalizedMonthCount };
AttendanceCalendarConfigured = true;
AttendanceCalendarSummary = FormatAttendanceCalendarSummary(_attendanceCalendarOptions);
AttendanceRefreshError = "";
if (_attendanceDataRefresher is not null)
{
IsRefreshingAttendanceData = true;
try { await _attendanceDataRefresher(_attendanceCalendarOptions, token); }
catch (WebUntisIntegrationException ex)
{ AttendanceRefreshError = $"WebUntis-Daten konnten nicht aktualisiert werden: {ex.Message}"; }
finally { IsRefreshingAttendanceData = false; }
}
RefreshValidation();
}
private void ResetAttendanceCalendarOptions()
{
var date = DateOnly.FromDateTime((LetterDate ?? DateTimeOffset.Now).LocalDateTime);
_attendanceCalendarOptions = new AttendanceCalendarOptions(new(date.Year, date.Month, 1), 1);
AttendanceCalendarSummary = FormatAttendanceCalendarSummary(_attendanceCalendarOptions);
}
private bool TemplateUsesPlaceholder(LetterTemplateChoice? choice, string placeholderName)
{
if (choice is null) return false;
try
{
var loaded = _templates.Load(choice.Model);
return UsesPlaceholder(loaded.Layout, placeholderName) ||
(loaded.ContinuationLayout is not null && UsesPlaceholder(loaded.ContinuationLayout, placeholderName));
}
catch (Exception ex) when (ex is IOException or InvalidDataException or TemplateValidationException)
{
return false;
}
}
private static bool UsesPlaceholder(TemplateLayout layout, string placeholderName) =>
layout.Elements.Concat(layout.PageTemplates.SelectMany(p => p.Elements))
.Concat(layout.ContentFlows.SelectMany(f => f.Elements))
.Any(e => e is DrawBoxElement draw && draw.Placeholder == placeholderName ||
e is FlowDrawBoxElement flow && flow.Placeholder == placeholderName);
private static string FormatAttendanceCalendarSummary(AttendanceCalendarOptions options)
{
var month = options.NormalizedStartMonth.ToString("MMMM yyyy",
System.Globalization.CultureInfo.GetCultureInfo("de-DE"));
var size = options.Size switch
{
AttendanceCalendarSize.Small => "Klein",
AttendanceCalendarSize.Large => "Groß",
_ => "Standard",
};
return $"Ab {month} · {options.NormalizedMonthCount} Monat{(options.NormalizedMonthCount == 1 ? "" : "e")} · {size}";
}
private static bool IsEmpty(PlaceholderValue value) => LetterPlaceholderBuilder.IsEmpty(value);
private static string SanitizeFileName(string value)
@@ -105,3 +271,33 @@ public sealed class LetterContactChoice(Contact model) { public Contact Model {
public sealed class LetterGroupChoice(LearningGroup model) { public LearningGroup Model { get; } = model; public string Display => $"{Model.Name} · {Model.SchoolYear}"; }
public sealed class LetterGenerationIssue(string message, bool isStrong)
{ public string Icon { get; } = isStrong ? "⚠" : "ⓘ"; public string Message { get; } = message; public string Color { get; } = isStrong ? "#D97706" : "#6B7280"; }
public sealed partial class LetterPlaceholderInput : ObservableObject
{
public string Name { get; }
public PlaceholderType Type { get; }
public string Label => Type switch
{
PlaceholderType.Date => $"{Name} (Datum)",
PlaceholderType.Number => $"{Name} (Zahl)",
_ => Name,
};
public bool IsTextType => Type == PlaceholderType.Text;
public bool IsMultilineType => Type == PlaceholderType.Multiline;
public bool IsDateType => Type == PlaceholderType.Date;
public bool IsNumberType => Type == PlaceholderType.Number;
[ObservableProperty] private string _textValue = "";
[ObservableProperty] private DateTimeOffset? _dateValue;
[ObservableProperty] private decimal? _numberValue;
public LetterPlaceholderInput(string name, PlaceholderType type) { Name = name; Type = type; }
public PlaceholderValue ToPlaceholderValue() => Type switch
{
PlaceholderType.Multiline => new MultilineValue(TextValue),
PlaceholderType.Date => new DateValue(DateValue.HasValue ? DateOnly.FromDateTime(DateValue.Value.LocalDateTime) : default),
PlaceholderType.Number => new NumberValue(NumberValue ?? 0),
_ => new TextValue(TextValue),
};
}
@@ -0,0 +1,25 @@
using CommunityToolkit.Mvvm.ComponentModel;
using LehrerApp.Desktop.Services;
namespace LehrerApp.Desktop.ViewModels.Students;
public partial class SelectContactDialogViewModel : ObservableObject
{
[ObservableProperty] private LetterContactChoice? _selectedContact;
public IReadOnlyList<LetterContactChoice> Contacts { get; }
public string AddressPreview => LetterPlaceholderBuilder.FormatAddress(SelectedContact?.Model);
public bool HasAddressPreview => !string.IsNullOrWhiteSpace(AddressPreview);
public SelectContactDialogViewModel(IReadOnlyList<LetterContactChoice> contacts, LetterContactChoice? current)
{
Contacts = contacts;
SelectedContact = current ?? contacts.FirstOrDefault();
}
partial void OnSelectedContactChanged(LetterContactChoice? value)
{
OnPropertyChanged(nameof(AddressPreview));
OnPropertyChanged(nameof(HasAddressPreview));
}
}
@@ -0,0 +1,48 @@
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using LehrerApp.Core.Interfaces;
using LehrerApp.Core.Models;
using LehrerApp.Desktop.ViewModels.Groups;
using System.Collections.ObjectModel;
namespace LehrerApp.Desktop.ViewModels.Students;
/// <summary>Einfacher "einen Schüler wählen"-Dialog für Einstiegspunkte ohne bereits geöffneten
/// Schüler (z.B. das Formulare-Menü) - anders als <see cref="Groups.AddStudentToGroupDialogViewModel"/>
/// ohne Gruppenbezug/Mitgliedschaftszeitraum, einfach alle Schüler durchsuchbar.</summary>
public partial class StudentPickerDialogViewModel : ObservableObject
{
private readonly IStudentRepository _students;
[ObservableProperty] private string _searchText = "";
[ObservableProperty] private StudentPickerItem? _selectedStudent;
[ObservableProperty] private string _validationMessage = "";
public ObservableCollection<StudentPickerItem> Students { get; } = [];
public Student? Result { get; private set; }
public StudentPickerDialogViewModel(IStudentRepository students)
{
_students = students;
LoadStudents();
}
partial void OnSearchTextChanged(string value) => LoadStudents();
private void LoadStudents()
{
var matches = _students.GetAll()
.Where(s => string.IsNullOrWhiteSpace(SearchText) ||
s.FullName.Contains(SearchText, StringComparison.OrdinalIgnoreCase))
.OrderBy(s => s.FullName, StringComparer.CurrentCultureIgnoreCase);
Students.Clear();
foreach (var student in matches) Students.Add(new StudentPickerItem(student));
}
[RelayCommand]
private void Select()
{
if (SelectedStudent is null) { ValidationMessage = "Bitte einen Schüler auswählen."; return; }
Result = _students.GetById(SelectedStudent.Id);
}
}
@@ -436,6 +436,11 @@
Background="Transparent" BorderThickness="0" Padding="6,2"
Foreground="{DynamicResource AppAccentTextBrush}"/>
</Grid>
<ComboBox ItemsSource="{Binding CalendarScopes}"
SelectedItem="{Binding SelectedCalendarScope, Mode=TwoWay}"
DisplayMemberBinding="{Binding DisplayName}"
HorizontalAlignment="Stretch"
AutomationProperties.Name="Schüler für Monatskalender auswählen"/>
<ItemsControl ItemsSource="{Binding CompactMonthDays}" HorizontalAlignment="Center">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate><UniformGrid Columns="7"/></ItemsPanelTemplate>
@@ -84,6 +84,9 @@
ToolTip.Tip="lsid aus WebUntis (Unterricht -&gt; Mein Unterricht -&gt; Berichte-Symbol der Zeile). Wird von WebUntis pro Schuljahr neu vergeben und muss deshalb jedes Schuljahr aktualisiert werden."/>
</StackPanel>
<CheckBox Content="Nicht im Untis-Hub verfolgen" IsChecked="{Binding ExcludedFromUntisHub}"
ToolTip.Tip="Blendet diese Gruppe im Untis-Hub aus (keine Fälligkeits-Erinnerungen für Fehlzeiten/Abgleiche), auch wenn eine WebUntis-Unterrichtsnummer hinterlegt ist - z.B. für Klassenrat oder AGs."/>
</StackPanel>
<Grid Grid.Row="1" ColumnDefinitions="*,8,*" Margin="0,20,0,0">
@@ -1,5 +1,6 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:models="clr-namespace:LehrerApp.Core.Models;assembly=LehrerApp.Core"
xmlns:vm="clr-namespace:LehrerApp.Desktop.ViewModels.Groups"
xmlns:vmRoot="clr-namespace:LehrerApp.Desktop.ViewModels"
x:Class="LehrerApp.Desktop.Views.Groups.GroupOverviewTabView"
@@ -42,6 +43,19 @@
<StackPanel Spacing="0">
<WrapPanel>
<Border Classes="card" IsVisible="{Binding HasTodayLessons}">
<StackPanel Spacing="8">
<TextBlock Text="UNTERRICHT HEUTE" Classes="cardTitle"/>
<ComboBox ItemsSource="{Binding TodayLessons}" SelectedItem="{Binding SelectedTeachingLesson}" HorizontalAlignment="Stretch">
<ComboBox.ItemTemplate>
<DataTemplate x:DataType="models:Lesson">
<TextBlock><Run Text="{Binding StartTime}"/><Run Text=" · "/><Run Text="{Binding Topic}"/></TextBlock>
</DataTemplate>
</ComboBox.ItemTemplate>
</ComboBox>
<Button Content="Unterrichtsansicht öffnen" Command="{Binding StartTeachingModeCommand}"/>
</StackPanel>
</Border>
<!-- Nächste Stunde -->
<Border Classes="card">
<StackPanel>
@@ -1,8 +1,14 @@
using Avalonia.Controls;
using LehrerApp.Desktop.ViewModels.Groups;
namespace LehrerApp.Desktop.Views.Groups;
public partial class GroupOverviewTabView : UserControl
{
public GroupOverviewTabView() => InitializeComponent();
protected override void OnDataContextChanged(EventArgs e)
{
base.OnDataContextChanged(e);
if (DataContext is GroupOverviewViewModel vm) vm.OnOpenTeachingMode = TeachingModeWindow.Open;
}
}
@@ -49,6 +49,13 @@
IsVisible="{Binding TopicError, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
</StackPanel>
<StackPanel Spacing="4">
<TextBlock Text="Planungsideen (Rohentwurf)" FontSize="12" Opacity="0.7"
ToolTip.Tip="Erste, noch grobe Ideen — lange bevor der Verlaufsplan unten feingeplant wird. Fließt auch als Kontext in die KI-Unterstützung (Backend und MCP) ein."/>
<TextBox Text="{Binding PlanningIdeas}" AcceptsReturn="True" Height="64" TextWrapping="Wrap"
PlaceholderText="Grobe Ideen, mögliche Aufhänger, erste Materialgedanken ..."/>
</StackPanel>
<Separator Margin="0,4"/>
<Grid ColumnDefinitions="*,Auto">
@@ -66,7 +73,7 @@
</Grid>
<!-- Tabellenkopf -->
<Grid ColumnDefinitions="130,95,120,*,100,95,26,26,26" Margin="4,0,0,0">
<Grid ColumnDefinitions="130,95,120,*,100,95,26,26,26,26" Margin="4,0,0,0">
<TextBlock Grid.Column="0" Text="Phase" FontSize="11" FontWeight="SemiBold" Opacity="0.6"/>
<TextBlock Grid.Column="1" Text="Pfad" FontSize="11" FontWeight="SemiBold" Opacity="0.6"
ToolTip.Tip="Ankreuzen, wenn diese Phase zu einem alternativen Ablauf gehört (z.B. Kurzversion bei Zeitnot) — öffnet die Zuweisung. Phasen mit demselben Ablauf werden im Verlaufsplan-Viewer gruppiert."/>
@@ -83,7 +90,7 @@
<DataTemplate x:DataType="vm:PhaseStepEditItem">
<Border BorderBrush="{DynamicResource SystemControlForegroundBaseLowBrush}"
BorderThickness="0,0,0,1" Padding="0,8">
<Grid ColumnDefinitions="130,95,120,*,100,95,26,26,26">
<Grid ColumnDefinitions="130,95,120,*,100,95,26,26,26,26">
<TextBox Grid.Column="0" Text="{Binding Name}" PlaceholderText="z.B. Erarbeitung"
VerticalAlignment="Top" Margin="0,0,6,0"/>
<CheckBox Grid.Column="1" IsChecked="{Binding HasAlternativePath}"
@@ -110,11 +117,14 @@
FilterMode="Contains" MinimumPrefixLength="0" VerticalAlignment="Top"
Margin="0,0,6,0" PlaceholderText="z.B. AB001-&gt;S, Plenum, LDE"
ToolTip.Tip="Freitext für den schnellen Überblick — mal ein Materialfluss-Pfeil (AB001-&gt;S), mal nur eine Sozialform (Plenum, LDE)."/>
<Button Grid.Column="6" Content="" Command="{Binding MoveUpCommand}" Padding="4,2"
<Button Grid.Column="6" Content="📋" Padding="4,2" VerticalAlignment="Top" Margin="0,0,2,0"
IsVisible="{Binding HasMaterialPrompt}" Click="OnCopyMaterialPrompt"
ToolTip.Tip="Gespeicherten Prompt zur Materialerstellung erneut in die Zwischenablage kopieren."/>
<Button Grid.Column="7" Content="↑" Command="{Binding MoveUpCommand}" Padding="4,2"
VerticalAlignment="Top" ToolTip.Tip="Nach oben"/>
<Button Grid.Column="7" Content="↓" Command="{Binding MoveDownCommand}" Padding="4,2"
<Button Grid.Column="8" Content="↓" Command="{Binding MoveDownCommand}" Padding="4,2"
VerticalAlignment="Top" Margin="2,0,0,0" ToolTip.Tip="Nach unten"/>
<Button Grid.Column="8" Content="✕" Command="{Binding RemoveCommand}" Padding="4,2"
<Button Grid.Column="9" Content="✕" Command="{Binding RemoveCommand}" Padding="4,2"
VerticalAlignment="Top" Margin="2,0,0,0" ToolTip.Tip="Entfernen"/>
</Grid>
</Border>
@@ -1,4 +1,5 @@
using Avalonia.Controls;
using Avalonia.Input.Platform;
using Avalonia.Interactivity;
using Avalonia.Platform.Storage;
using LehrerApp.Core.Interfaces;
@@ -48,6 +49,20 @@ public partial class LessonDialog : Window
Close(false);
}
/// Kopiert den beim letzten KI-"Übernehmen" gespeicherten Materialerstellungs-Prompt (4.5.36,
/// Nachtrag zu 4.5.20) erneut in die Zwischenablage — derselbe Mechanismus wie im AiAssistDialog,
/// hier aber für einen bereits gespeicherten, nicht mehr nur transienten Vorschlag.
private async void OnCopyMaterialPrompt(object? sender, RoutedEventArgs e)
{
if (sender is not Button { DataContext: PhaseStepEditItem item } || string.IsNullOrWhiteSpace(item.MaterialPrompt))
return;
var clipboard = TopLevel.GetTopLevel(this)?.Clipboard;
if (clipboard is null) return;
await clipboard.SetTextAsync(item.MaterialPrompt);
App.Services.GetRequiredService<NotificationService>().ShowSuccess("Prompt in die Zwischenablage kopiert.");
}
private async void OnAddAttachment(object? sender, RoutedEventArgs e)
{
if (DataContext is not LessonDialogViewModel vm) return;
@@ -0,0 +1,62 @@
<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="clr-namespace:LehrerApp.Desktop.ViewModels.Groups"
x:Class="LehrerApp.Desktop.Views.Groups.PersonalizeWorksheetDialog"
x:DataType="vm:PersonalizeWorksheetDialogViewModel"
Title="Arbeitsblatt personalisieren"
Width="460" Height="620"
CanResize="False" WindowStartupLocation="CenterOwner">
<Grid RowDefinitions="Auto,Auto,*,Auto,Auto" Margin="24">
<StackPanel Grid.Row="0" Spacing="4" Margin="0,0,0,14">
<TextBlock Text="Arbeitsblatt personalisieren" Classes="dialogtitle"/>
<TextBlock Text="{Binding GroupName}" FontSize="12" Opacity="0.65"/>
</StackPanel>
<StackPanel Grid.Row="1" Spacing="10" Margin="0,0,0,14">
<TextBlock Text="Vorlage" FontSize="12" FontWeight="SemiBold" Opacity="0.7"/>
<ComboBox ItemsSource="{Binding Templates}" SelectedItem="{Binding SelectedTemplate}"
HorizontalAlignment="Stretch" DisplayMemberBinding="{Binding Name}"/>
<TextBlock Text="Noch keine Arbeitsblatt-Vorlage importiert — in den Einstellungen unter „Briefvorlagen“ eine .lavorlage-Datei aus dem Vorlagen-Designer hinzufügen."
Classes="emptyhint" TextWrapping="Wrap"
IsVisible="{Binding HasNoTemplates}"/>
</StackPanel>
<ScrollViewer Grid.Row="2">
<StackPanel Spacing="4">
<TextBlock Text="Für diese Schüler erzeugen" FontSize="12" FontWeight="SemiBold" Opacity="0.7" Margin="0,0,0,4"/>
<ItemsControl ItemsSource="{Binding Students}">
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:WorksheetStudentChoice">
<CheckBox Content="{Binding FullName}" IsChecked="{Binding IsIncluded}" Margin="0,3"/>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<ItemsControl ItemsSource="{Binding Results}" Margin="0,10,0,0" IsVisible="{Binding HasResults}">
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:WorksheetGenerationResult">
<Grid ColumnDefinitions="20,*" Margin="0,2">
<TextBlock Grid.Column="0" Text="{Binding Icon}" Foreground="{Binding Color}"/>
<TextBlock Grid.Column="1" FontSize="12">
<Run Text="{Binding StudentName}"/><Run Text=" "/><Run Text="{Binding ErrorMessage}" Foreground="#D97706"/>
</TextBlock>
</Grid>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</StackPanel>
</ScrollViewer>
<TextBlock Grid.Row="3" Text="{Binding StatusMessage}" FontSize="12" Margin="0,10,0,0" TextWrapping="Wrap"
IsVisible="{Binding StatusMessage, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
<Grid Grid.Row="4" ColumnDefinitions="*,8,*" Margin="0,16,0,0">
<Button Grid.Column="0" Content="Schließen" HorizontalAlignment="Stretch" Click="OnClose"/>
<Button Grid.Column="2" Content="Erzeugen…" HorizontalAlignment="Stretch" Click="OnGenerate"
IsEnabled="{Binding !HasNoTemplates}"/>
</Grid>
</Grid>
</Window>
@@ -0,0 +1,25 @@
using Avalonia.Controls;
using Avalonia.Interactivity;
using Avalonia.Platform.Storage;
using LehrerApp.Desktop.ViewModels.Groups;
namespace LehrerApp.Desktop.Views.Groups;
public partial class PersonalizeWorksheetDialog : Window
{
public PersonalizeWorksheetDialog() => InitializeComponent();
private async void OnGenerate(object? sender, RoutedEventArgs e)
{
if (DataContext is not PersonalizeWorksheetDialogViewModel vm) return;
var folders = await StorageProvider.OpenFolderPickerAsync(new FolderPickerOpenOptions
{
Title = "Zielordner für die personalisierten Arbeitsblätter wählen",
AllowMultiple = false,
});
if (folders.Count == 0) return;
vm.Generate(folders[0].Path.LocalPath);
}
private void OnClose(object? sender, RoutedEventArgs e) => Close();
}
@@ -24,8 +24,8 @@
<Setter Property="Background" Value="Transparent"/>
</Style>
</UserControl.Styles>
<Grid ColumnDefinitions="260,*">
<Border Grid.Column="0" BorderBrush="{DynamicResource SystemControlForegroundBaseLowBrush}"
<Grid ColumnDefinitions="Auto,*">
<Border Grid.Column="0" Width="260" IsVisible="{Binding !IsTeachingMode}" BorderBrush="{DynamicResource SystemControlForegroundBaseLowBrush}"
BorderThickness="0,0,1,0" Padding="16">
<Grid RowDefinitions="Auto,*,Auto">
<StackPanel Grid.Row="0" Spacing="4" Margin="0,0,0,12">
@@ -72,26 +72,35 @@
<Grid RowDefinitions="Auto,*" ColumnDefinitions="*,Auto" IsVisible="{Binding HasSelectedPlan}" Margin="24">
<Grid Grid.Row="0" Grid.ColumnSpan="2" ColumnDefinitions="*,Auto" Margin="0,0,0,18">
<StackPanel Spacing="3">
<TextBlock Text="{Binding PlanTitle}" FontSize="22" FontWeight="SemiBold"/>
<ComboBox ItemsSource="{Binding Plans}" SelectedItem="{Binding SelectedPlan}" DisplayMemberBinding="{Binding Name}"
IsVisible="{Binding IsTeachingMode}" MinWidth="180"/>
<TextBlock Text="{Binding PlanTitle}" FontSize="22" FontWeight="SemiBold" IsVisible="{Binding !IsTeachingMode}"/>
<TextBlock Text="{Binding PlanSubtitle}" Opacity="0.65"/>
<TextBlock Text="{Binding QuickModeDisplay}" FontSize="12" TextWrapping="Wrap" IsVisible="{Binding IsTeachingMode}"/>
<Border IsVisible="{Binding !IsTeachingMode}">
<StackPanel Orientation="Horizontal" Spacing="8" Margin="0,6,0,0"
IsVisible="{Binding IsEditMode, Converter={x:Static BoolConverters.Not}}">
<TextBlock Text="Unterricht:" VerticalAlignment="Center" FontSize="12" Opacity="0.65"/>
<ComboBox ItemsSource="{Binding TodaySessions}" SelectedItem="{Binding SelectedSession}"
DisplayMemberBinding="{Binding DisplayName}" MinWidth="210"/>
DisplayMemberBinding="{Binding DisplayName}" MinWidth="210" IsEnabled="{Binding !IsTeachingMode}"/>
</StackPanel>
</Border>
</StackPanel>
<StackPanel Grid.Column="1" VerticalAlignment="Bottom" Spacing="4">
<StackPanel Orientation="Horizontal" Spacing="10" HorizontalAlignment="Right">
<Button Content="Als PDF" Click="OnExportPdfClick" VerticalAlignment="Center"/>
<ToggleSwitch Content="Bearbeitungsmodus" IsChecked="{Binding IsEditMode}"
IsVisible="{Binding IsEditable}"/>
<Border IsVisible="{Binding !IsTeachingMode}">
<ToggleSwitch Content="Bearbeitungsmodus" IsChecked="{Binding IsEditMode}"
IsVisible="{Binding IsEditable}"/>
</Border>
</StackPanel>
<TextBlock Text="{Binding AssignmentSummary}" HorizontalAlignment="Right" FontSize="12" Opacity="0.6"/>
<TextBlock Text="Ziehen: Platz ändern · Klicken: bewerten" HorizontalAlignment="Right"
FontSize="11" Opacity="0.5" IsVisible="{Binding IsEditMode}"/>
<TextBlock Text="Klicken: bewerten" HorizontalAlignment="Right"
FontSize="11" Opacity="0.5" IsVisible="{Binding IsEditMode, Converter={x:Static BoolConverters.Not}}"/>
<Border IsVisible="{Binding !IsTeachingMode}">
<TextBlock Text="{Binding QuickModeDisplay}" HorizontalAlignment="Right"
FontSize="11" Opacity="0.5" IsVisible="{Binding IsEditMode, Converter={x:Static BoolConverters.Not}}"/>
</Border>
</StackPanel>
</Grid>
@@ -158,10 +167,20 @@
IsVisible="{Binding HasDayHighlightBadge}"
ToolTip.Tip="Tagesflagge"/>
</StackPanel>
<StackPanel Spacing="4" IsVisible="{Binding ShowQuickCheck}">
<Grid ColumnDefinitions="*,*">
<Button Content="{Binding QuickPositiveLabel}" Command="{Binding QuickPositiveCommand}"
FontSize="11" Padding="5,5" HorizontalAlignment="Stretch" Margin="0,0,3,0"/>
<Button Grid.Column="1" Content="{Binding QuickNegativeLabel}" Command="{Binding QuickNegativeCommand}"
FontSize="11" Padding="5,5" HorizontalAlignment="Stretch"/>
</Grid>
<Button Content="Sonderfälle …" Command="{Binding QuickSpecialCommand}"
FontSize="10" Padding="5,3" HorizontalAlignment="Stretch"/>
</StackPanel>
<!-- Strichliste Meldungen (Nutzer-Feedback): schnelles Mitzählen ohne den
vollen Bewertungsdialog zu öffnen -->
<StackPanel Orientation="Horizontal" HorizontalAlignment="Center" Spacing="5"
IsVisible="{Binding ShowLessonOverview}">
IsVisible="{Binding ShowNormalActions}">
<Button Padding="6,2" FontSize="10"
Command="{Binding TallyRaisedHandCommand}"
ToolTip.Tip="Meldung zählen">
@@ -174,7 +193,7 @@
</Button>
</StackPanel>
<Expander Header=" Situation" FontSize="10"
IsVisible="{Binding CanRecordLesson}">
IsVisible="{Binding ShowSituationActions}">
<ItemsControl ItemsSource="{Binding SituationTags}" Margin="0,4,0,0">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate><WrapPanel ItemSpacing="3" LineSpacing="3"/></ItemsPanelTemplate>
@@ -1,5 +1,6 @@
<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:models="clr-namespace:LehrerApp.Core.Models;assembly=LehrerApp.Core"
xmlns:vm="clr-namespace:LehrerApp.Desktop.ViewModels.Groups"
xmlns:views="clr-namespace:LehrerApp.Desktop.Views.Groups"
x:Class="LehrerApp.Desktop.Views.Groups.TeachingModeWindow"
@@ -8,8 +9,23 @@
Width="1400" Height="850" MinWidth="1000" MinHeight="600"
WindowState="Maximized" CanResize="True" WindowStartupLocation="CenterScreen">
<Window.Styles>
<Style Selector="Border.phase">
<Setter Property="Background" Value="{DynamicResource SystemControlBackgroundAltHighBrush}"/>
<Setter Property="BorderBrush" Value="Transparent"/>
<Setter Property="BorderThickness" Value="2"/>
</Style>
<Style Selector="Border.phase.overflow">
<Setter Property="Background" Value="#22E57373"/>
<Setter Property="BorderBrush" Value="#99E57373"/>
</Style>
<Style Selector="Border.phase.active">
<Setter Property="Background" Value="#223BA6C8"/>
<Setter Property="BorderBrush" Value="#3BA6C8"/>
</Style>
</Window.Styles>
<Grid RowDefinitions="Auto,*" Margin="20">
<Grid Grid.Row="0" ColumnDefinitions="*,Auto" Margin="0,0,0,14">
<Grid Grid.Row="0" RowDefinitions="Auto,Auto" Margin="0,0,0,14">
<StackPanel Grid.Column="0" Spacing="3">
<TextBlock FontSize="20" FontWeight="SemiBold">
<Run Text="{Binding GroupName}"/><Run Text=" · "/><Run Text="{Binding LessonInfo.Topic}"/>
@@ -26,19 +42,22 @@
</TextBlock>
</StackPanel>
</StackPanel>
<StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="8" VerticalAlignment="Center">
<WrapPanel Grid.Row="1" ItemSpacing="8" LineSpacing="6" Margin="0,10,0,0">
<!-- Nutzer-Feedback: die Schnellbewertungs-Dialoge gab es bisher nur über den
Mitarbeit-Tab der Gruppe — hier direkt auf die Sitzung dieser Stunde vorselektiert
(siehe TeachingModeViewModel), kein Umweg mehr über "Zur Mitarbeit". -->
<Button Content="⚡ Mitarbeit" Command="{Binding Participation.QuickInputCommand}"
ToolTip.Tip="Mitarbeit dieser Stunde schnell bewerten."/>
<Button Content="Anwesenheit/Hausaufgabe" Command="{Binding Participation.StatusQuickInputCommand}"
ToolTip.Tip="Anwesenheit und Hausaufgabenstatus dieser Stunde schnell erfassen."/>
<Button Content="Anwesenheit kontrollieren" Command="{Binding SeatingPlan.CheckAttendanceCommand}" IsEnabled="{Binding SeatingPlan.IsEditable}"/>
<Button Content="Hausaufgaben kontrollieren" Command="{Binding SeatingPlan.CheckHomeworkCommand}" IsEnabled="{Binding SeatingPlan.IsEditable}"/>
<Button Content="Kontrolle beenden" Command="{Binding SeatingPlan.EndQuickCheckCommand}" IsVisible="{Binding SeatingPlan.IsQuickMode}"/>
<Button Content="Listenansicht …" Command="{Binding Participation.StatusQuickInputCommand}" ToolTip.Tip="Auch Schüler ohne Sitzplatz erfassen."/>
<Button Content="Vollbild ↔" Click="OnFullScreen" ToolTip.Tip="Vollbild umschalten (F11); mit Escape verlassen."/>
<Button Content="Zur Mitarbeit" Command="{Binding LessonInfo.NavigateToParticipationCommand}"
ToolTip.Tip="Schließt den Unterrichtsmodus und springt zum Tab 'Mitarbeit' der Lerngruppe."/>
<Button Content="Zu den Noten" Command="{Binding LessonInfo.NavigateToGradesCommand}"/>
<Button Content="Unterrichtsmodus beenden" Click="OnClose" Margin="16,0,0,0"/>
</StackPanel>
</WrapPanel>
</Grid>
<Grid Grid.Row="1" ColumnDefinitions="360,16,*">
@@ -48,44 +67,78 @@
<StackPanel Spacing="14">
<TextBlock Text="Verlaufsplan" FontSize="14" FontWeight="SemiBold"/>
<ItemsControl ItemsSource="{Binding LessonInfo.PhaseGroups}">
<TextBlock Text="Hauptweg · Live-Verlauf" FontSize="12" Opacity="0.65"/>
<TextBlock Text="Noch keine Phasen geplant." IsVisible="{Binding !Timeline.HasPhases}"/>
<Button Content="Zeitmessung jetzt starten" Command="{Binding Timeline.StartNowCommand}" IsVisible="{Binding Timeline.NeedsStart}" IsEnabled="{Binding Timeline.IsEditable}"/>
<ItemsControl ItemsSource="{Binding Timeline.Phases}">
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:PhaseGroupViewItem">
<StackPanel Margin="0,0,0,10">
<StackPanel IsVisible="{Binding $parent[ItemsControl].((vm:LessonViewerViewModel)DataContext).HasAlternatives}">
<TextBlock Text="{Binding Label}" FontSize="12" FontWeight="SemiBold" Opacity="0.75" Margin="0,6,0,2"/>
<TextBlock Text="{Binding Description}" FontSize="11" Opacity="0.55" TextWrapping="Wrap" Margin="0,0,0,6"
IsVisible="{Binding Description, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
<DataTemplate x:DataType="vm:TeachingPhaseViewModel">
<Border Classes="phase" Classes.active="{Binding IsActive}" Classes.overflow="{Binding IsOverflow}" CornerRadius="6" Padding="10,8" Margin="0,0,0,8">
<StackPanel Spacing="5">
<TextBlock Text="{Binding Source.Name}" FontWeight="SemiBold" TextWrapping="Wrap"/>
<TextBlock Text="{Binding TimeDisplay}" FontSize="12"/>
<TextBlock Text="{Binding Source.DurationMinutes, StringFormat='Geplant: {0} Min.'}" FontSize="11" Opacity="0.7"/>
<TextBlock Text="{Binding Source.Activity}" TextWrapping="Wrap" FontSize="12"
IsVisible="{Binding Source.Activity, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
<TextBlock Text="{Binding Source.Material}" TextWrapping="Wrap" FontSize="11" Opacity="0.7"
IsVisible="{Binding Source.Material, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
<TextBlock Text="{Binding Source.Shorthand}" FontSize="11" Opacity="0.7"
IsVisible="{Binding Source.Shorthand, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
<ProgressBar Minimum="0" Maximum="100" Value="{Binding Progress}" Height="3" IsVisible="{Binding IsActive}"/>
<TextBlock Text="Über dem geplanten Stundenende" FontSize="11" IsVisible="{Binding IsOverflow}"/>
<TextBlock Text="Erledigt" FontSize="11" Opacity="0.6" IsVisible="{Binding IsCompleted}"/>
<TextBlock Text="In Folgestunde kopiert" FontSize="11" IsVisible="{Binding IsTransferred}"/>
<StackPanel IsVisible="{Binding IsActive}" IsEnabled="{Binding IsEditable}" Spacing="4">
<WrapPanel ItemSpacing="4" LineSpacing="4">
<Button Content="Weiter" Command="{Binding FinishCommand}" FontSize="11" Padding="6,4"/>
<Button Content="+5 Min." Command="{Binding ExtendFiveCommand}" FontSize="11" Padding="6,4"/>
<Button Content="+10 Min." Command="{Binding ExtendTenCommand}" FontSize="11" Padding="6,4"/>
<Button Content="Halten bis Weiter" Command="{Binding HoldCommand}" FontSize="11" Padding="6,4" IsEnabled="{Binding !IsHeld}"/>
</WrapPanel>
<TextBlock Text="Gehalten mit Weiter nächste Phase starten" FontSize="11" TextWrapping="Wrap" IsVisible="{Binding IsHeld}"/>
</StackPanel>
<Button Content="Jetzt vorziehen" Command="{Binding BringForwardCommand}" IsVisible="{Binding CanStartNow}" FontSize="11"/>
</StackPanel>
<ItemsControl ItemsSource="{Binding Phases}">
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:PhaseViewItem">
<Border Background="{DynamicResource SystemControlBackgroundAltHighBrush}"
CornerRadius="6" Padding="10,8" Margin="0,0,0,6">
<StackPanel Spacing="2">
<Grid ColumnDefinitions="*,Auto">
<TextBlock Grid.Column="0" Text="{Binding Name}" FontSize="13" FontWeight="SemiBold"
TextWrapping="Wrap"/>
<TextBlock Grid.Column="1" FontSize="11" Opacity="0.6">
<Run Text="{Binding TimeDisplay}"/><Run Text=" · "/>
<Run Text="{Binding DurationMinutes, StringFormat='{}{0} Min.'}"/>
</TextBlock>
</Grid>
<TextBlock Text="{Binding Activity}" FontSize="12" TextWrapping="Wrap"
IsVisible="{Binding Activity, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
<TextBlock FontSize="11" Opacity="0.55" TextWrapping="Wrap"
IsVisible="{Binding Material, Converter={x:Static StringConverters.IsNotNullOrEmpty}}">
<Run Text="Material: "/><Run Text="{Binding Material}"/>
</TextBlock>
</StackPanel>
</Border>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</StackPanel>
</Border>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<StackPanel Spacing="6" IsVisible="{Binding Timeline.HasRemainder}" IsEnabled="{Binding Timeline.IsEditable}">
<TextBlock Text="Rest für eine Folgestunde" FontWeight="SemiBold"/>
<ComboBox ItemsSource="{Binding Timeline.TransferTargets}" SelectedItem="{Binding Timeline.TransferTarget}" HorizontalAlignment="Stretch">
<ComboBox.ItemTemplate>
<DataTemplate x:DataType="models:Lesson">
<TextBlock><Run Text="{Binding Date, StringFormat='{}{0:dd.MM.yyyy}'}"/><Run Text=" · "/><Run Text="{Binding Topic}"/></TextBlock>
</DataTemplate>
</ComboBox.ItemTemplate>
</ComboBox>
<Button Content="Rötlich markierte Phasen kopieren" Command="{Binding Timeline.TransferRemainderCommand}" IsEnabled="{Binding Timeline.HasTransferTargets}"/>
<TextBlock Text="Zuerst eine Folgestunde in der Planung anlegen." IsVisible="{Binding !Timeline.HasTransferTargets}" TextWrapping="Wrap"/>
</StackPanel>
<TextBlock Text="{Binding Timeline.Status}" TextWrapping="Wrap" FontSize="11"/>
<Expander Header="Alternative Abläufe" IsVisible="{Binding LessonInfo.HasAlternatives}">
<ItemsControl ItemsSource="{Binding LessonInfo.PhaseGroups}">
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:PhaseGroupViewItem">
<StackPanel IsVisible="{Binding !IsMainPath}" Spacing="4" Margin="0,4">
<TextBlock Text="{Binding Label}" FontWeight="SemiBold"/>
<TextBlock Text="{Binding Description}" TextWrapping="Wrap"/>
<ItemsControl ItemsSource="{Binding Phases}">
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:PhaseViewItem">
<StackPanel Margin="0,4">
<TextBlock><Run Text="{Binding Name}"/><Run Text="{Binding DurationMinutes, StringFormat=' · {0} Min.'}"/></TextBlock>
<TextBlock Text="{Binding Activity}" TextWrapping="Wrap" FontSize="12"/>
<TextBlock Text="{Binding Material}" TextWrapping="Wrap" FontSize="11"/>
</StackPanel>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</StackPanel>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</Expander>
<!-- Nutzer-Feedback: Hausaufgabe der letzten Stunde ansehen/als kontrolliert abhaken
und die Hausaufgabe DIESER Stunde einsehen/ändern, ohne den vollen
@@ -1,5 +1,9 @@
using Avalonia.Controls;
using Avalonia.Interactivity;
using Avalonia.Input;
using Avalonia.Threading;
using LehrerApp.Core.Models;
using LehrerApp.Core.Interfaces;
using LehrerApp.Desktop.ViewModels;
using LehrerApp.Desktop.ViewModels.Groups;
using Microsoft.Extensions.DependencyInjection;
@@ -8,7 +12,53 @@ namespace LehrerApp.Desktop.Views.Groups;
public partial class TeachingModeWindow : Window
{
public TeachingModeWindow() => InitializeComponent();
private static readonly Dictionary<Guid, TeachingModeWindow> OpenWindows = [];
private readonly DispatcherTimer _clock = new() { Interval = TimeSpan.FromSeconds(1) };
private WindowState _previousState = WindowState.Maximized;
public static void Open(Lesson lesson)
{
if (OpenWindows.TryGetValue(lesson.Id, out var existing))
{
if (existing.WindowState == WindowState.Minimized) existing.WindowState = WindowState.Normal;
existing.Activate();
return;
}
var group = App.Services.GetRequiredService<IGroupRepository>().GetById(lesson.GroupId);
if (group is null) return;
var window = new TeachingModeWindow
{
DataContext = new TeachingModeViewModel(lesson, group,
App.Services.GetRequiredService<IAlternativeLessonPathRepository>(),
App.Services.GetRequiredService<ILessonRepository>(),
App.Services.GetRequiredService<SeatingPlanTabViewModel>(),
App.Services.GetRequiredService<ParticipationTabViewModel>())
};
OpenWindows.Add(lesson.Id, window);
window.Closed += (_, _) => OpenWindows.Remove(lesson.Id);
window.Show();
}
public TeachingModeWindow()
{
InitializeComponent();
_clock.Tick += (_, _) => (DataContext as TeachingModeViewModel)?.Timeline.Refresh();
Opened += (_, _) => _clock.Start();
Closed += (_, _) => _clock.Stop();
KeyDown += (_, e) =>
{
if (e.Key == Key.F11) { ToggleFullScreen(); e.Handled = true; }
else if (e.Key == Key.Escape && WindowState == WindowState.FullScreen)
{ WindowState = _previousState; e.Handled = true; }
};
}
private void ToggleFullScreen()
{
if (WindowState == WindowState.FullScreen) WindowState = _previousState;
else { _previousState = WindowState; WindowState = WindowState.FullScreen; }
}
private void OnFullScreen(object? sender, RoutedEventArgs e) => ToggleFullScreen();
protected override void OnDataContextChanged(EventArgs e)
{
@@ -35,6 +85,7 @@ public partial class TeachingModeWindow : Window
private async Task ShowQuickInputDialog(ParticipationTabViewModel tabVm)
{
if (tabVm.StudentRows.Count == 0) return;
tabVm.RefreshCurrentGrid();
var quickVm = new QuickInputViewModel(tabVm.StudentRows.ToList(), tabVm.Aspects.ToList());
var dialog = new ParticipationQuickInputDialog { DataContext = quickVm };
await dialog.ShowDialog(this);
@@ -44,6 +95,7 @@ public partial class TeachingModeWindow : Window
private async Task ShowStatusQuickInputDialog(ParticipationTabViewModel tabVm)
{
if (tabVm.StudentRows.Count == 0 || tabVm.SelectedSession is null) return;
tabVm.RefreshCurrentGrid();
var quickVm = new AttendanceHomeworkQuickInputViewModel(tabVm.StudentRows, tabVm.SelectedSessionDisplay);
var dialog = new AttendanceHomeworkQuickInputDialog { DataContext = quickVm };
await dialog.ShowDialog(this);
+6
View File
@@ -40,6 +40,12 @@
<MenuItem Header="Klassenbuchabgleich…" Click="OnCompareUntisKlassenbuch"/>
<MenuItem Header="Hausaufgabenabgleich…" Click="OnCompareUntisHausaufgaben"/>
</MenuItem>
<MenuItem Header="Formulare">
<MenuItem Header="Elternbrief erzeugen…" Click="OnCreateParentLetter"/>
<MenuItem Header="Arbeitsblatt personalisieren…" Click="OnPersonalizeWorksheet"
IsEnabled="{Binding CanPersonalizeWorksheet}"
ToolTip.Tip="Nur verfügbar, während eine einzelne Lerngruppe geöffnet ist"/>
</MenuItem>
</Menu>
<DrawerPage x:Name="RootDrawer"
DrawerLength="220"
@@ -1,10 +1,17 @@
using Avalonia.Controls;
using Avalonia.Input;
using Avalonia.Threading;
using LehrerApp.Core.Interfaces;
using LehrerApp.Core.Models;
using LehrerApp.Desktop.Services;
using LehrerApp.Desktop.ViewModels;
using LehrerApp.Desktop.ViewModels.Groups;
using LehrerApp.Desktop.ViewModels.Students;
using LehrerApp.Desktop.Views.Groups;
using LehrerApp.Desktop.Views.Students;
using LehrerApp.Desktop.Views.UntisHub;
using LehrerApp.Sync;
using LehrerApp.Templating;
using Microsoft.Extensions.DependencyInjection;
namespace LehrerApp.Desktop.Views;
@@ -45,6 +52,25 @@ public partial class MainWindow : Window
await UntisHubActions.RunHausaufgabenAsync(this, App.Services.GetRequiredService<UntisHubService>());
}
private async void OnCreateParentLetter(object? sender, Avalonia.Interactivity.RoutedEventArgs e)
{
var picker = new StudentPickerDialog
{ DataContext = new StudentPickerDialogViewModel(App.Services.GetRequiredService<IStudentRepository>()) };
if (await picker.ShowDialog<Student?>(this) is { } student)
await LetterDialogs.ShowCreateLetterDialogAsync(this, student);
}
private async void OnPersonalizeWorksheet(object? sender, Avalonia.Interactivity.RoutedEventArgs e)
{
if (DataContext is not MainWindowViewModel { CurrentGroupDetail.Group: { } group } vm) return;
var studentIds = vm.CurrentGroupDetail!.Students.Select(s => s.Id).ToList();
var dialogVm = new PersonalizeWorksheetDialogViewModel(group, studentIds,
App.Services.GetRequiredService<IStudentRepository>(),
App.Services.GetRequiredService<WorksheetTemplateStore>(),
App.Services.GetRequiredService<ITemplateRenderer>());
await new PersonalizeWorksheetDialog { DataContext = dialogVm }.ShowDialog(this);
}
private void OnWindowKeyDown(object? sender, KeyEventArgs e)
{
if (DataContext is not MainWindowViewModel vm) return;
@@ -185,19 +185,10 @@ public partial class TimetableView : UserControl
await dialog.ShowDialog<bool>(owner);
}
private async Task ShowTeachingMode(Lesson lesson)
private Task ShowTeachingMode(Lesson lesson)
{
var owner = TopLevel.GetTopLevel(this) as Window;
var group = App.Services.GetRequiredService<IGroupRepository>().GetById(lesson.GroupId);
if (owner is null || group is null) return;
var teachingModeVm = new TeachingModeViewModel(lesson, group,
App.Services.GetRequiredService<IAlternativeLessonPathRepository>(),
App.Services.GetRequiredService<ILessonRepository>(),
App.Services.GetRequiredService<SeatingPlanTabViewModel>(),
App.Services.GetRequiredService<ParticipationTabViewModel>());
var window = new TeachingModeWindow { DataContext = teachingModeVm };
await window.ShowDialog(owner);
TeachingModeWindow.Open(lesson);
return Task.CompletedTask;
}
private async Task ShowLessonViewerDialog(Lesson lesson)
@@ -393,6 +393,66 @@
<Separator/>
<TextBlock Text="Vorlagen werden mit dem separaten LehrerApp Vorlagen-Designer erstellt. Designer und Hauptapp verwenden exakt dieselbe QuestPDF-Renderingbibliothek."
FontSize="12" Opacity="0.65" TextWrapping="Wrap"/>
<Separator Margin="0,8"/>
<TextBlock Text="Arbeitsblatt-Vorlagen" FontSize="15" FontWeight="SemiBold"/>
<TextBlock Text="Eigene Vorlagenbibliothek für „Arbeitsblatt personalisieren…“ im Formulare-Menü — getrennt von den Elternbrief-Vorlagen oben, damit sich beide Listen nicht vermischen."
FontSize="12" Opacity="0.65" TextWrapping="Wrap"/>
<Grid ColumnDefinitions="Auto,*" ColumnSpacing="12">
<Button Grid.Column="0" Content=" .lavorlage importieren" Click="OnImportWorksheetTemplateClick"/>
<TextBlock Grid.Column="1" Text="{Binding WorksheetTemplateStatus}" FontSize="12"
VerticalAlignment="Center" TextWrapping="Wrap"
IsVisible="{Binding WorksheetTemplateStatus, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
</Grid>
<TextBlock Text="Noch keine Arbeitsblatt-Vorlage importiert." Classes="emptyhint"
IsVisible="{Binding !WorksheetTemplateList.Count}"/>
<ItemsControl ItemsSource="{Binding WorksheetTemplateList}">
<ItemsControl.ItemTemplate>
<DataTemplate DataType="vm:LetterTemplateListItem">
<Border BorderBrush="{DynamicResource SystemControlForegroundBaseLowBrush}"
BorderThickness="1" CornerRadius="7" Padding="14,12" Margin="0,0,0,9">
<StackPanel Spacing="8">
<Grid ColumnDefinitions="*,Auto,Auto,Auto">
<StackPanel Grid.Column="0">
<TextBlock Text="{Binding Name}" FontWeight="SemiBold" FontSize="14"/>
<TextBlock FontSize="11" Opacity="0.55">
<Run Text="{Binding PackageFileName}"/><Run Text=" · "/>
<Run Text="{Binding ValidationSummary}"/>
</TextBlock>
</StackPanel>
<Button Grid.Column="1" Content="Öffnen" FontSize="12" Padding="10,4"
Margin="8,0,0,0" Tag="{Binding}" Click="OnOpenWorksheetTemplateClick"/>
<Button Grid.Column="2" Content="Neu prüfen" FontSize="12" Padding="10,4"
Margin="8,0,0,0"
Command="{Binding $parent[ItemsControl].((vm:SettingsViewModel)DataContext).ValidateWorksheetTemplateCommand}"
CommandParameter="{Binding}"/>
<Button Grid.Column="3" Content="Löschen" FontSize="12" Padding="10,4"
Margin="8,0,0,0"
Command="{Binding $parent[ItemsControl].((vm:SettingsViewModel)DataContext).DeleteWorksheetTemplateCommand}"
CommandParameter="{Binding}"/>
</Grid>
<ItemsControl ItemsSource="{Binding Issues}" IsVisible="{Binding HasIssues}">
<ItemsControl.ItemTemplate>
<DataTemplate DataType="vm:LetterTemplateIssueItem">
<Grid ColumnDefinitions="24,*" Margin="0,2">
<TextBlock Grid.Column="0" Text="{Binding Icon}" Foreground="{Binding Color}"/>
<TextBlock Grid.Column="1" Text="{Binding Message}" Foreground="{Binding Color}"
FontSize="12" TextWrapping="Wrap"/>
</Grid>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<TextBlock Text="✓ Vorlage ohne Auffälligkeiten" Foreground="SeaGreen" FontSize="12"
IsVisible="{Binding HasNoIssues}"/>
</StackPanel>
</Border>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</StackPanel>
</ScrollViewer>
</ContentPage>
@@ -277,4 +277,26 @@ public partial class SettingsView : UserControl
if (!File.Exists(path)) return;
System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo(path) { UseShellExecute = true });
}
private async void OnImportWorksheetTemplateClick(object? sender, RoutedEventArgs e)
{
var topLevel = TopLevel.GetTopLevel(this);
if (topLevel is null || DataContext is not SettingsViewModel vm) return;
var files = await topLevel.StorageProvider.OpenFilePickerAsync(new FilePickerOpenOptions
{
Title = "LehrerApp-Arbeitsblattvorlage importieren",
AllowMultiple = false,
FileTypeFilter = [new FilePickerFileType("LehrerApp-Vorlagen") { Patterns = ["*.lavorlage"] }],
});
if (files.Count > 0) vm.ImportWorksheetTemplate(files[0].Path.LocalPath);
}
private void OnOpenWorksheetTemplateClick(object? sender, RoutedEventArgs e)
{
if (sender is not Button { Tag: LetterTemplateListItem item }) return;
var path = item.Model.PackagePath;
if (!File.Exists(path)) return;
System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo(path) { UseShellExecute = true });
}
}
+17 -4
View File
@@ -7,8 +7,21 @@
ShowInTaskbar="False"
WindowDecorations="None"
WindowStartupLocation="CenterScreen">
<Image Source="/Assets/SplashScreen.png"
Stretch="UniformToFill"
HorizontalAlignment="Stretch"
VerticalAlignment="Stretch"/>
<Viewbox Stretch="UniformToFill">
<Canvas Width="1086" Height="1448">
<Image Source="/Assets/SplashScreen.png" Width="1086" Height="1448"/>
<Border Canvas.Left="326" Canvas.Top="1004" Width="480" Height="31"
Background="#303234" CornerRadius="16" ClipToBounds="True">
<ProgressBar x:Name="StartupProgress" Minimum="0" Maximum="100" Value="0"
Height="31" Background="#303234" Foreground="#229DDD"
ShowProgressText="False"/>
</Border>
<Border Canvas.Left="295" Canvas.Top="1042" Width="520" Height="58"
Background="#202326" CornerRadius="12">
<TextBlock x:Name="StartupStatus" Text="Start wird vorbereitet …"
Foreground="White" FontSize="24" TextAlignment="Center"
VerticalAlignment="Center"/>
</Border>
</Canvas>
</Viewbox>
</Window>
@@ -5,4 +5,10 @@ namespace LehrerApp.Desktop.Views;
public partial class SplashWindow : Window
{
public SplashWindow() => InitializeComponent();
public void SetProgress(int value, string status)
{
StartupProgress.Value = value;
StartupStatus.Text = status;
}
}
@@ -0,0 +1,39 @@
<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.AttendanceCalendarConfigurationDialog"
x:DataType="vm:AttendanceCalendarConfigurationViewModel"
Title="Anwesenheitsdaten konfigurieren" Width="440" Height="360"
CanResize="False" WindowStartupLocation="CenterOwner">
<Grid RowDefinitions="*,Auto" Margin="24,20">
<StackPanel Spacing="16">
<TextBlock Text="Anwesenheitsdaten" Classes="dialogtitle"/>
<TextBlock Text="Diese Angaben steuern Kalender und Fehlzeitenliste im Elternbrief. Der Starttag wird automatisch auf den ersten Tag des gewählten Monats gesetzt."
FontSize="12" Opacity="0.65" TextWrapping="Wrap"/>
<StackPanel Spacing="4">
<TextBlock Text="Erster Monat" FontSize="12" Opacity="0.7"/>
<CalendarDatePicker SelectedDate="{Binding StartMonth, Converter={x:Static conv:DateTimeOffsetToDateTimeConverter.Instance}}"
HorizontalAlignment="Stretch"/>
</StackPanel>
<Grid ColumnDefinitions="*,12,*">
<StackPanel Spacing="4">
<TextBlock Text="Anzahl Monate" FontSize="12" Opacity="0.7"/>
<ComboBox ItemsSource="{Binding MonthCounts}" SelectedItem="{Binding MonthCount, Mode=TwoWay}"
HorizontalAlignment="Stretch"/>
</StackPanel>
<StackPanel Grid.Column="2" Spacing="4">
<TextBlock Text="Darstellungsgröße" FontSize="12" Opacity="0.7"/>
<ComboBox ItemsSource="{Binding Sizes}" SelectedItem="{Binding SelectedSize, Mode=TwoWay}"
DisplayMemberBinding="{Binding DisplayName}" HorizontalAlignment="Stretch"/>
</StackPanel>
</Grid>
<TextBlock Text="Bei mehreren Monaten wächst der Zeicheninhalt nach unten. Für drei große Monate sollte die Vorlage eine ausreichend hohe DRAWBOX oder eine FLOWDRAWBOX verwenden."
FontSize="11" Opacity="0.55" TextWrapping="Wrap"/>
</StackPanel>
<Grid Grid.Row="1" ColumnDefinitions="*,10,*" Margin="0,18,0,0">
<Button Grid.Column="0" Content="Abbrechen" HorizontalAlignment="Stretch" Click="OnCancel"/>
<Button Grid.Column="2" Content="Übernehmen" HorizontalAlignment="Stretch" Click="OnApply"/>
</Grid>
</Grid>
</Window>
@@ -0,0 +1,12 @@
using Avalonia.Controls;
using Avalonia.Interactivity;
namespace LehrerApp.Desktop.Views.Students;
public partial class AttendanceCalendarConfigurationDialog : Window
{
public AttendanceCalendarConfigurationDialog() => InitializeComponent();
private void OnApply(object? sender, RoutedEventArgs e) => Close(true);
private void OnCancel(object? sender, RoutedEventArgs e) => Close(false);
}
@@ -26,11 +26,21 @@
Classes="emptyhint" IsVisible="{Binding HasNoTemplates}"/>
</StackPanel>
<StackPanel Spacing="4">
<TextBlock Text="Kontakt *" FontSize="12" Opacity="0.7"/>
<ComboBox ItemsSource="{Binding Contacts}" SelectedItem="{Binding SelectedContact}"
DisplayMemberBinding="{Binding Display}" HorizontalAlignment="Stretch"/>
<TextBlock Text="Kein aktueller Kontakt vorhanden." Classes="emptyhint"
IsVisible="{Binding HasNoContacts}"/>
<TextBlock Text="Anschrift *" FontSize="12" Opacity="0.7"/>
<Border BorderBrush="{DynamicResource AppCardBorderBrush}" BorderThickness="1" CornerRadius="6" Padding="10">
<Grid ColumnDefinitions="*,Auto">
<TextBlock Grid.Column="0" Text="{Binding AddressPreview}" TextWrapping="Wrap"
VerticalAlignment="Center" IsVisible="{Binding HasAddressPreview}"/>
<TextBlock Grid.Column="0" Text="Kein aktueller Kontakt vorhanden." Classes="emptyhint"
VerticalAlignment="Center" IsVisible="{Binding HasNoContacts}"/>
<Button Grid.Column="1" Content="Kontakt wechseln …" Click="OnSelectContact"
IsEnabled="{Binding !HasNoContacts}" VerticalAlignment="Center"/>
</Grid>
</Border>
</StackPanel>
<StackPanel Spacing="4">
<TextBlock Text="Anrede *" FontSize="12" Opacity="0.7"/>
<TextBox Text="{Binding Anrede}" PlaceholderText="z.B. Sehr geehrte Frau Mustermann,"/>
</StackPanel>
<Grid ColumnDefinitions="*,12,*">
<StackPanel Grid.Column="0" Spacing="4">
@@ -44,6 +54,23 @@
<CalendarDatePicker SelectedDate="{Binding LetterDate, Converter={x:Static conv:DateTimeOffsetToDateTimeConverter.Instance}}" HorizontalAlignment="Stretch"/>
</StackPanel>
</Grid>
<Border IsVisible="{Binding UsesAttendanceAdvancedContent}" BorderBrush="{DynamicResource AppCardBorderBrush}"
BorderThickness="1" CornerRadius="6" Padding="12">
<StackPanel Spacing="6">
<Grid ColumnDefinitions="*,Auto">
<StackPanel Spacing="3">
<TextBlock Text="Anwesenheitsdaten im Brief" FontWeight="SemiBold"/>
<TextBlock Text="{Binding AttendanceCalendarSummary}" FontSize="12" Opacity="0.65"/>
</StackPanel>
<Button Grid.Column="1" Content="Konfigurieren …" Click="OnConfigureAttendanceCalendar"
IsEnabled="{Binding !IsRefreshingAttendanceData}" VerticalAlignment="Center"/>
</Grid>
<TextBlock Text="Aktualisiere WebUntis-Daten für den gewählten Zeitraum …" FontSize="12"
Foreground="#6B7280" IsVisible="{Binding IsRefreshingAttendanceData}"/>
<TextBlock Text="{Binding AttendanceRefreshError}" FontSize="12" Foreground="#D97706" TextWrapping="Wrap"
IsVisible="{Binding AttendanceRefreshError, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
</StackPanel>
</Border>
<StackPanel Spacing="4">
<TextBlock Text="Brieftext" FontSize="12" Opacity="0.7"/>
<TextBox Text="{Binding LetterText}" AcceptsReturn="True" TextWrapping="Wrap" MinHeight="110"
@@ -54,6 +81,26 @@
<TextBox Text="{Binding TeacherName}" PlaceholderText="Name der Lehrkraft"/>
</StackPanel>
<StackPanel Spacing="8" IsVisible="{Binding HasCustomPlaceholders}">
<TextBlock Text="Zusätzliche Felder der Vorlage" FontSize="12" Opacity="0.7"/>
<ItemsControl ItemsSource="{Binding CustomPlaceholders}">
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:LetterPlaceholderInput">
<StackPanel Spacing="4" Margin="0,0,0,8">
<TextBlock Text="{Binding Label}" FontSize="12" Opacity="0.7"/>
<TextBox Text="{Binding TextValue}" IsVisible="{Binding IsTextType}"/>
<TextBox Text="{Binding TextValue}" AcceptsReturn="True" TextWrapping="Wrap" MinHeight="70"
IsVisible="{Binding IsMultilineType}"/>
<CalendarDatePicker SelectedDate="{Binding DateValue, Converter={x:Static conv:DateTimeOffsetToDateTimeConverter.Instance}}"
HorizontalAlignment="Stretch" IsVisible="{Binding IsDateType}"/>
<NumericUpDown Value="{Binding NumberValue}" FormatString="0.##" HorizontalAlignment="Stretch"
IsVisible="{Binding IsNumberType}"/>
</StackPanel>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</StackPanel>
<Border BorderBrush="#D97706" BorderThickness="1" CornerRadius="6" Padding="10"
IsVisible="{Binding HasIssues}">
<StackPanel Spacing="5">
@@ -12,6 +12,8 @@ public partial class CreateLetterDialog : Window
private async void OnGenerate(object? sender, RoutedEventArgs e)
{
if (DataContext is not CreateLetterDialogViewModel vm) return;
if (vm.UsesAttendanceAdvancedContent && !vm.AttendanceCalendarConfigured &&
!await ConfigureAttendanceCalendar(vm)) return;
var file = await StorageProvider.SaveFilePickerAsync(new FilePickerSaveOptions
{
Title = "PDF-Brief speichern",
@@ -23,5 +25,27 @@ public partial class CreateLetterDialog : Window
Close(file.Path.LocalPath);
}
private async void OnConfigureAttendanceCalendar(object? sender, RoutedEventArgs e)
{
if (DataContext is CreateLetterDialogViewModel vm) await ConfigureAttendanceCalendar(vm);
}
private async void OnSelectContact(object? sender, RoutedEventArgs e)
{
if (DataContext is not CreateLetterDialogViewModel vm) return;
var dialogVm = new SelectContactDialogViewModel(vm.Contacts, vm.SelectedContact);
var dialog = new SelectContactDialog { DataContext = dialogVm };
if (await dialog.ShowDialog<bool>(this)) vm.SelectedContact = dialogVm.SelectedContact;
}
private async Task<bool> ConfigureAttendanceCalendar(CreateLetterDialogViewModel vm)
{
var configVm = new AttendanceCalendarConfigurationViewModel(vm.GetAttendanceCalendarOptions());
var dialog = new AttendanceCalendarConfigurationDialog { DataContext = configVm };
if (!await dialog.ShowDialog<bool>(this)) return false;
await vm.SetAttendanceCalendarOptionsAsync(configVm.BuildResult());
return true;
}
private void OnCancel(object? sender, RoutedEventArgs e) => Close(null);
}
@@ -8,7 +8,7 @@
CanResize="True" WindowStartupLocation="CenterOwner">
<Grid RowDefinitions="*,Auto" Margin="24">
<ScrollViewer Grid.Row="0">
<ScrollViewer Grid.Row="0" MaxHeight="560">
<StackPanel Spacing="12">
<TextBlock Text="{Binding DialogTitle}" Classes="dialogtitle"/>
@@ -0,0 +1,33 @@
<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="clr-namespace:LehrerApp.Desktop.ViewModels.Students"
x:Class="LehrerApp.Desktop.Views.Students.SelectContactDialog"
x:DataType="vm:SelectContactDialogViewModel"
Title="Kontakt wählen" Width="380" Height="440"
CanResize="False" WindowStartupLocation="CenterOwner">
<Grid RowDefinitions="Auto,*,Auto,Auto" Margin="24">
<TextBlock Grid.Row="0" Text="Kontakt für Anrede und Anschrift" Classes="dialogtitle" Margin="0,0,0,12"/>
<ListBox Grid.Row="1" ItemsSource="{Binding Contacts}" SelectedItem="{Binding SelectedContact}"
BorderThickness="1">
<ListBox.ItemTemplate>
<DataTemplate x:DataType="vm:LetterContactChoice">
<TextBlock Text="{Binding Display}" Padding="4,2"/>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
<Border Grid.Row="2" BorderBrush="{DynamicResource AppCardBorderBrush}" BorderThickness="1" CornerRadius="6"
Padding="10" Margin="0,12,0,0" IsVisible="{Binding HasAddressPreview}">
<TextBlock Text="{Binding AddressPreview}" TextWrapping="Wrap" FontSize="12"/>
</Border>
<Grid Grid.Row="3" ColumnDefinitions="*,10,*" Margin="0,16,0,0">
<Button Grid.Column="0" Content="Abbrechen" HorizontalAlignment="Stretch" Click="OnCancel"/>
<Button Grid.Column="2" Content="Übernehmen" HorizontalAlignment="Stretch" Click="OnApply"/>
</Grid>
</Grid>
</Window>
@@ -0,0 +1,12 @@
using Avalonia.Controls;
using Avalonia.Interactivity;
namespace LehrerApp.Desktop.Views.Students;
public partial class SelectContactDialog : Window
{
public SelectContactDialog() => InitializeComponent();
private void OnApply(object? sender, RoutedEventArgs e) => Close(true);
private void OnCancel(object? sender, RoutedEventArgs e) => Close(false);
}
@@ -8,7 +8,6 @@ using LehrerApp.Desktop.Services;
using LehrerApp.Desktop.ViewModels.Students;
using LehrerApp.Desktop.Views.Shared;
using Microsoft.Extensions.DependencyInjection;
using LehrerApp.Templating;
namespace LehrerApp.Desktop.Views.Students;
@@ -56,16 +55,7 @@ public partial class StudentDetailView : UserControl
{
var owner = TopLevel.GetTopLevel(this) as Window;
if (owner is null || DataContext is not StudentDetailViewModel { Student: { } student }) return;
var vm = new CreateLetterDialogViewModel(student,
App.Services.GetRequiredService<TemplateStore>(),
App.Services.GetRequiredService<ITemplateRenderer>(),
App.Services.GetRequiredService<IGroupMembershipRepository>(),
App.Services.GetRequiredService<IGroupRepository>());
var dialog = new CreateLetterDialog { DataContext = vm };
var path = await dialog.ShowDialog<string?>(owner);
if (!string.IsNullOrEmpty(path) && File.Exists(path))
System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo(path) { UseShellExecute = true });
await LetterDialogs.ShowCreateLetterDialogAsync(owner, student);
}
private void ShowAddressViewer(ContactItem contact)
@@ -0,0 +1,41 @@
<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="clr-namespace:LehrerApp.Desktop.ViewModels.Students"
xmlns:vmg="clr-namespace:LehrerApp.Desktop.ViewModels.Groups"
x:Class="LehrerApp.Desktop.Views.Students.StudentPickerDialog"
x:DataType="vm:StudentPickerDialogViewModel"
Title="Schüler wählen"
Width="380" Height="480"
CanResize="False" WindowStartupLocation="CenterOwner">
<Grid RowDefinitions="Auto,*,Auto" Margin="24">
<StackPanel Grid.Row="0" Spacing="12" Margin="0,0,0,12">
<TextBlock Text="Schüler wählen" Classes="dialogtitle"/>
<TextBox Text="{Binding SearchText}"
PlaceholderText="Schüler suchen …"
x:Name="SearchBox"/>
</StackPanel>
<ListBox Grid.Row="1"
ItemsSource="{Binding Students}"
SelectedItem="{Binding SelectedStudent}"
BorderThickness="1">
<ListBox.ItemTemplate>
<DataTemplate x:DataType="vmg:StudentPickerItem">
<TextBlock Text="{Binding FullName}" Padding="4,2"/>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
<StackPanel Grid.Row="2" Spacing="12" Margin="0,16,0,0">
<TextBlock Text="{Binding ValidationMessage}" Foreground="Red" FontSize="12"
IsVisible="{Binding ValidationMessage, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
<Grid ColumnDefinitions="*,8,*">
<Button Grid.Column="0" Content="Abbrechen" HorizontalAlignment="Stretch" Click="OnCancel"/>
<Button Grid.Column="2" Content="Weiter" HorizontalAlignment="Stretch" Click="OnSave"/>
</Grid>
</StackPanel>
</Grid>
</Window>
@@ -0,0 +1,25 @@
using Avalonia.Controls;
using Avalonia.Interactivity;
using LehrerApp.Desktop.ViewModels.Students;
namespace LehrerApp.Desktop.Views.Students;
public partial class StudentPickerDialog : Window
{
public StudentPickerDialog() => InitializeComponent();
protected override void OnOpened(EventArgs e)
{
base.OnOpened(e);
this.FindControl<TextBox>("SearchBox")?.Focus();
}
private void OnSave(object? sender, RoutedEventArgs e)
{
if (DataContext is not StudentPickerDialogViewModel vm) return;
vm.SelectCommand.Execute(null);
if (vm.Result is not null) Close(vm.Result);
}
private void OnCancel(object? sender, RoutedEventArgs e) => Close(null);
}
+6
View File
@@ -1,3 +1,4 @@
using LehrerApp.Core.Mcp;
using LehrerApp.McpBridge;
using ModelContextProtocol.Protocol;
using ModelContextProtocol.Server;
@@ -9,6 +10,11 @@ using ModelContextProtocol.Server;
// Nachladen der echten Werkzeugliste, sobald LehrerApp erreichbar ist, übernimmt
// RealServerBridge - Details und Begründung siehe dort.
// Muss vor der ersten NamedPipeClientStream-Instanz laufen (siehe RealServerBridge), sonst löst
// TMPDIR-Auseinanderdriften zwischen diesem als Claude-Desktop-Kindprozess gestarteten Prozess und
// LehrerApp.Desktop auf macOS/Linux "MCP nicht erreichbar" aus, obwohl LehrerApp läuft.
McpPipeConstants.EnsureStableUnixSocketDirectory();
var bridge = new RealServerBridge();
await bridge.TryReconnectAsync(CancellationToken.None);
bridge.EnsureFallbackTool();
+15 -1
View File
@@ -30,12 +30,14 @@ namespace LehrerApp.McpBridge;
internal sealed class RealServerBridge : IAsyncDisposable
{
private const int ConnectTimeoutMs = 3000;
private static readonly TimeSpan ReconnectCooldown = TimeSpan.FromSeconds(3);
public McpServerPrimitiveCollection<McpServerTool> Tools { get; } = new();
private readonly SemaphoreSlim _gate = new(1, 1);
private McpClient? _client;
private NamedPipeClientStream? _pipe;
private DateTime? _lastFailedConnectAttemptUtc;
/// <summary>Reicht einen Tool-Aufruf an die echte Verbindung weiter (aufgerufen aus
/// <see cref="ProxyMcpServerTool"/>). Bricht die Verbindung erst hier ab, statt sie proaktiv vor
@@ -66,19 +68,29 @@ internal sealed class RealServerBridge : IAsyncDisposable
/// <summary>Versucht, sofern noch nicht verbunden, jetzt sofort eine Verbindung aufzubauen -
/// aufgerufen sowohl beim Start der Bridge als auch aus <see cref="StatusMcpServerTool"/>
/// (expliziter Nutzer-/Assistenten-Wunsch, jetzt nachzusehen) und aus dem Hintergrund-Loop.</summary>
/// (expliziter Nutzer-/Assistenten-Wunsch, jetzt nachzusehen) und aus dem Hintergrund-Loop.
/// Nach einem fehlgeschlagenen Versuch schlägt ein weiterer Aufruf innerhalb von
/// <see cref="ReconnectCooldown"/> sofort fehl, statt erneut den vollen Pipe-Connect-Timeout
/// abzuwarten - sonst kostet ein versehentlicher Doppelaufruf (z.B. weil ein KI-Client
/// "lehrerapp_status" trotz gegenteiligem Hinweis im selben Zug erneut aufruft) unnötig weitere
/// mehrere Sekunden.</summary>
public async Task<bool> TryReconnectAsync(CancellationToken ct)
{
if (_client is not null) return true;
if (IsInCooldown()) return false;
await _gate.WaitAsync(ct);
try
{
if (_client is not null) return true; // ein anderer Aufrufer war währenddessen schneller
if (IsInCooldown()) return false;
return await ConnectCoreAsync(ct);
}
finally { _gate.Release(); }
}
private bool IsInCooldown() =>
_lastFailedConnectAttemptUtc is { } last && DateTime.UtcNow - last < ReconnectCooldown;
private async Task<bool> ConnectCoreAsync(CancellationToken ct)
{
var pipe = new NamedPipeClientStream(
@@ -102,6 +114,7 @@ internal sealed class RealServerBridge : IAsyncDisposable
_client = client;
_pipe = pipe;
_lastFailedConnectAttemptUtc = null;
await Console.Error.WriteLineAsync(
$"LehrerApp.McpBridge: mit LehrerApp verbunden, {tools.Count} Werkzeug(e) übernommen.");
return true;
@@ -111,6 +124,7 @@ internal sealed class RealServerBridge : IAsyncDisposable
await pipe.DisposeAsync();
var (_, _, stderrLine) = ConnectionDiagnostics.Describe(ex);
await Console.Error.WriteLineAsync(stderrLine);
_lastFailedConnectAttemptUtc = DateTime.UtcNow;
EnsureFallbackTool();
return false;
}
+9 -3
View File
@@ -24,7 +24,12 @@ internal sealed class StatusMcpServerTool(RealServerBridge bridge) : McpServerTo
"(Schüler, Noten, Stundenplanung, Kompetenzen, ...) nicht verfügbar - sie erscheinen erst " +
"nach einer erfolgreichen Verbindung in der Werkzeugliste. Bei einer Fehlermeldung eines " +
"anderen LehrerApp-Werkzeugs, die auf eine fehlende Verbindung hindeutet, dieses Werkzeug " +
"aufrufen, um den Grund zu klären, statt den Nutzer ohne Erklärung zu vertrösten.",
"aufrufen, um den Grund zu klären, statt den Nutzer ohne Erklärung zu vertrösten. Nach " +
"einem erfolglosen Versuch NICHT im selben Zug erneut aufrufen (ein Verbindungsversuch " +
"dauert bis zu mehrere Sekunden, wiederholte Aufrufe direkt hintereinander können vom " +
"Client blockiert werden) - stattdessen den Nutzer informieren; ein Hintergrund-Prozess " +
"verbindet automatisch neu, sobald LehrerApp läuft, und die Werkzeugliste aktualisiert " +
"sich dann von selbst.",
InputSchema = JsonDocument.Parse("""{"type":"object","properties":{}}""").RootElement,
};
public override IReadOnlyList<object> Metadata { get; } = [];
@@ -44,8 +49,9 @@ internal sealed class StatusMcpServerTool(RealServerBridge bridge) : McpServerTo
"sind jetzt verfügbar."
: "LehrerApp läuft nicht oder der MCP-Server ist in den Einstellungen nicht " +
"aktiviert. Bitte den Nutzer bitten, LehrerApp zu starten und den " +
"MCP-Server in den Einstellungen zu aktivieren - dieses Werkzeug danach " +
"erneut aufrufen, um zu prüfen, ob die Verbindung jetzt klappt.",
"MCP-Server in den Einstellungen zu aktivieren. Dieses Werkzeug NICHT im " +
"selben Zug erneut aufrufen - die Bridge verbindet im Hintergrund " +
"automatisch neu, sobald LehrerApp läuft, und meldet das von selbst.",
},
],
};
+14
View File
@@ -38,6 +38,20 @@ public sealed class EventApplierTests
Assert.Null(db.Students.FindById(student.Id));
}
[Fact]
public async Task ApplyAsync_UntisHubJobStateSave_SchreibtEntitaetDirektInDieCollection()
{
using var db = NewInMemoryContext();
var applier = new EventApplier(db, Key);
var state = new UntisHubJobState { Kind = UntisHubJobKind.OffenePeriods, LastRunAt = DateTime.UtcNow };
await applier.ApplyAsync(MakeEvent(nameof(UntisHubJobState), state.Id.ToString(), "Save", state));
var saved = db.UntisHubJobStates.FindById(state.Id);
Assert.NotNull(saved);
Assert.Equal(UntisHubJobKind.OffenePeriods, saved!.Kind);
}
[Fact]
public async Task ApplyAsync_UnbekannterEntityType_TutNichtsUndWirftNicht()
{
+1
View File
@@ -147,6 +147,7 @@ public class EventApplier(LiteDbContext db, byte[] syncKey, HttpClient? http = n
Simple<SubstitutionEntry>(context => context.SubstitutionEntries);
Simple<CompetencyDomain>(context => context.CompetencyDomains);
Simple<Vorgang>(context => context.Vorgaenge);
Simple<UntisHubJobState>(context => context.UntisHubJobStates);
// Kaskaden-Fälle: dieselben internen LiteDbContext-Hilfsmethoden wie die jeweiligen
// Repositories, damit die Kaskade nur an einer Stelle im Code existiert.
@@ -80,6 +80,40 @@ public sealed class ProjectLifecycleTests
Assert.True(viewModel.HasNoSelectedPlaceholder);
}
[Theory]
[InlineData("Anwesenheitskalender", "Student.AttendanceCalendar", "DRAWBOX", "90")]
[InlineData("Fehltage nach Datum", "Student.AbsenceDays", "FLOWDRAWBOX", "140")]
public void Sonderinhalt_AusKatalog_BereitetPlatzhalterUndElementVor(
string displayName, string placeholderName, string elementType, string height)
{
var viewModel = new DesignerViewModel();
viewModel.Placeholders.Clear();
viewModel.SelectedSpecialContent = viewModel.SpecialContents.Single(x => x.DisplayName == displayName);
viewModel.PrepareSelectedSpecialContent();
var placeholder = Assert.Single(viewModel.Placeholders);
Assert.Equal(placeholderName, placeholder.Name);
Assert.Equal(LehrerApp.Templating.PlaceholderType.Drawing, placeholder.Type);
Assert.Same(placeholder, viewModel.SelectedPlaceholder);
Assert.Equal(elementType, viewModel.NewElementType);
Assert.Equal("$" + placeholderName, viewModel.NewContent);
Assert.Equal("170", viewModel.NewWidth);
Assert.Equal(height, viewModel.NewHeight);
}
[Fact]
public void Sonderinhalt_WirdNichtDoppeltDeklariert()
{
var viewModel = new DesignerViewModel();
viewModel.SelectedSpecialContent = viewModel.SpecialContents.Single(x =>
x.PlaceholderName == "Student.AttendanceCalendar");
viewModel.PrepareSelectedSpecialContent();
Assert.Single(viewModel.Placeholders, x => x.Name == "Student.AttendanceCalendar");
}
[Fact]
public void DoppeltePlatzhalternamen_WerdenVerstaendlichAbgelehnt()
{
@@ -48,12 +48,15 @@ public partial class DesignerViewModel : ObservableObject
[ObservableProperty] private string _overlayPageUnit = "mm";
[ObservableProperty] private string _selectedPageTemplate = "first";
[ObservableProperty] private DesignerPreviewPage? _selectedPreviewPage;
[ObservableProperty] private SpecialContentItem? _selectedSpecialContent;
[ObservableProperty] private bool _isLegacyContinuationSupported = true;
public IReadOnlyList<string> Units { get; } = ["mm", "cm", "pt", "in"];
public IReadOnlyList<PlaceholderType> PlaceholderTypes { get; } = Enum.GetValues<PlaceholderType>();
public IReadOnlyList<string> ElementTypes { get; } =
["TEXT", "TEXTBOX", "FLOWBOX", "DRAWBOX", "FLOWDRAWBOX", "IMG", "TABLE", "CHART"];
public IReadOnlyList<string> ElementScopes { get; } = ["Seitenvorlage (fest)", "Content-Flow"];
public IReadOnlyList<SpecialContentItem> SpecialContents { get; } = SpecialContentCatalog.Items;
public ObservableCollection<DesignerPlaceholder> Placeholders { get; } =
[
new("Datum", PlaceholderType.Date, true, DateTime.Today.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture)),
@@ -61,6 +64,8 @@ public partial class DesignerViewModel : ObservableObject
new("Anrede", PlaceholderType.Text, true, "Frau Beispiel"),
new("Brieftext", PlaceholderType.Multiline, true, "hiermit informieren wir Sie über einen wichtigen Termin.\n\nMit freundlichen Grüßen"),
new("LehrerName", PlaceholderType.Text, true, "M. Mustermann"),
new("Student.AttendanceCalendar", PlaceholderType.Drawing, false, ""),
new("Student.AbsenceDays", PlaceholderType.Drawing, false, ""),
];
public ObservableCollection<DesignerMetadata> MetadataItems { get; } =
[
@@ -76,7 +81,11 @@ public partial class DesignerViewModel : ObservableObject
public bool HasSelectedPlaceholder => SelectedPlaceholder is not null;
public bool HasNoSelectedPlaceholder => SelectedPlaceholder is null;
public DesignerViewModel() => SelectedPlaceholder = Placeholders.FirstOrDefault();
public DesignerViewModel()
{
SelectedPlaceholder = Placeholders.FirstOrDefault();
SelectedSpecialContent = SpecialContents.FirstOrDefault();
}
partial void OnSelectedPlaceholderChanged(DesignerPlaceholder? value)
{
@@ -196,6 +205,32 @@ public partial class DesignerViewModel : ObservableObject
return placeholder;
}
/// <summary>Übernimmt einen lesbar benannten LehrerApp-Sonderinhalt in das normale
/// Elementformular und legt seine Drawing-Deklaration an, falls sie noch fehlt.</summary>
public void PrepareSelectedSpecialContent()
{
if (SelectedSpecialContent is not { } special)
throw new InvalidOperationException("Bitte zuerst einen Sonderinhalt auswählen.");
var placeholder = Placeholders.FirstOrDefault(p => p.Name == special.PlaceholderName);
if (placeholder is not null && placeholder.Type != PlaceholderType.Drawing)
throw new InvalidDataException(
$"Der vorhandene Platzhalter „{special.PlaceholderName}“ hat nicht den Typ Drawing.");
if (placeholder is null)
{
placeholder = new DesignerPlaceholder(special.PlaceholderName, PlaceholderType.Drawing,
false, special.Description);
Placeholders.Add(placeholder);
}
SelectedPlaceholder = placeholder;
NewElementType = special.RecommendedElementType;
NewContent = "$" + special.PlaceholderName;
NewWidth = special.RecommendedWidth;
NewHeight = special.RecommendedHeight;
NewAttributes = "";
CanExport = false;
SetStatus($"„{special.DisplayName}“ vorbereitet. Position und Größe prüfen, dann ins Layout übernehmen.", false);
}
public void RemoveSelectedPlaceholder()
{
if (SelectedPlaceholder is not { } selected) return;
@@ -552,6 +587,8 @@ public partial class DesignerViewModel : ObservableObject
try
{
var page = new LayoutParser().Parse(value);
IsLegacyContinuationSupported = !page.UsesPageTemplates;
if (page.UsesPageTemplates) UseContinuationLayout = false;
OverlayPageWidth = page.Width; OverlayPageHeight = page.Height; OverlayPageUnit = page.Unit;
var names = page.PageTemplates.Select(x => x.Name).ToList();
if (names.Count == 0) names.Add("legacy");
@@ -701,6 +738,48 @@ public sealed record DesignerPreviewPage(int Number, Bitmap Image)
public string Display => $"Dokumentseite {Number}";
}
public sealed record SpecialContentItem(string DisplayName, string PlaceholderName, string Description,
string RecommendedElementType, string RecommendedWidth, string RecommendedHeight);
public static class SpecialContentCatalog
{
public static IReadOnlyList<SpecialContentItem> Items { get; } =
[
new("Anwesenheitskalender", "Student.AttendanceCalendar",
"Monatskalender mit farbigen Anwesenheitsmarkern; Zeitraum und Größe werden beim Erstellen des Briefs gewählt.",
"DRAWBOX", "170", "90"),
new("Fehltage nach Datum", "Student.AbsenceDays",
"Chronologische Liste mit Datum, Fehlzeit oder ganzem Fehltag sowie Entschuldigungsstatus.",
"FLOWDRAWBOX", "170", "140"),
];
public static DrawingValue SampleDrawing(string placeholderName) => placeholderName switch
{
"Student.AttendanceCalendar" => new DrawingValue(
[
new DrawString(2, 2, "Anwesenheit · Erika Beispiel", 10, "#1F2937", Bold: true),
new DrawRectangle(2, 14, 22, 12, "#D1D5DB", .4f, "#FFFFFF"),
new DrawString(9, 16, "12", 7, "#374151"),
new DrawRectangle(26, 14, 22, 12, "#C62828", .4f, "#C62828"),
new DrawString(34, 16, "U", 7, "#FFFFFF", Bold: true),
new DrawRectangle(50, 14, 22, 12, "#2E7D32", .4f, "#2E7D32"),
new DrawString(58, 16, "E", 7, "#FFFFFF", Bold: true),
], 28),
"Student.AbsenceDays" => new DrawingValue(
[
new DrawString(2, 2, "Fehltage · Erika Beispiel", 10, "#1F2937", Bold: true),
new DrawRectangle(2, 14, 166, 11, "#CBD5E1", .4f, "#F3F4F6"),
new DrawString(4, 15, "Datum", 7, "#374151", Bold: true),
new DrawString(42, 15, "Umfang", 7, "#374151", Bold: true),
new DrawString(116, 15, "Status", 7, "#374151", Bold: true),
new DrawString(4, 27, "03.09.2026", 7, "#374151"),
new DrawString(42, 27, "Ganzer Fehltag", 7, "#374151"),
new DrawString(116, 27, "Entschuldigt", 7, "#2E7D32"),
], 38),
_ => DesignerPlaceholder.GenericSampleDrawing(),
};
}
public partial class DesignerAsset(string name, int pixelWidth, int pixelHeight, long byteCount) : ObservableObject
{
[ObservableProperty] private int _usageCount;
@@ -755,7 +834,7 @@ public partial class DesignerPlaceholder : ObservableObject
PlaceholderType.Image => new ImageValue([], "image/png"),
PlaceholderType.Table => ParseTable(Sample),
PlaceholderType.Chart => ParseChart(Sample),
PlaceholderType.Drawing => SampleDrawing(),
PlaceholderType.Drawing => SpecialContentCatalog.SampleDrawing(Name),
_ => new TextValue(Sample),
};
public static string SampleFor(PlaceholderType type) => type switch
@@ -770,7 +849,7 @@ public partial class DesignerPlaceholder : ObservableObject
}
private static ChartValue ParseChart(string value) => new([new("Werte", value.Split(';', StringSplitOptions.RemoveEmptyEntries)
.Select((x, i) => { var parts = x.Split(':', 2); return new ChartPoint(parts[0], parts.Length == 2 && decimal.TryParse(parts[1], CultureInfo.InvariantCulture, out var y) ? y : i + 1); }).ToList())]);
private static DrawingValue SampleDrawing() => new DrawingValue(
internal static DrawingValue GenericSampleDrawing() => new DrawingValue(
[new DrawRectangle(0, 0, 80, 24, "#2563EB", 0.8f, "#EFF6FF"),
new DrawString(4, 4, "Dynamischer Inhalt", 10, "#1E3A8A", Bold: true),
new MoveTo(4, 19), new LineTo(76, 19, "#93C5FD", 0.6f)], 24);
+17 -2
View File
@@ -213,6 +213,21 @@
<ScrollViewer Padding="8">
<StackPanel Spacing="12">
<TextBlock Text="Element hinzufügen" Classes="section"/>
<Border BorderBrush="#93C5FD" BorderThickness="1" Background="#EFF6FF"
CornerRadius="6" Padding="12">
<StackPanel Spacing="7">
<TextBlock Text="LehrerApp-Sonderinhalte" FontWeight="SemiBold" Foreground="#1E3A8A"/>
<TextBlock Text="Kein technischer Platzhaltername nötig: Inhalt auswählen und übernehmen."
FontSize="11" Foreground="#475569" TextWrapping="Wrap"/>
<ComboBox ItemsSource="{Binding SpecialContents}"
SelectedItem="{Binding SelectedSpecialContent, Mode=TwoWay}"
DisplayMemberBinding="{Binding DisplayName}"/>
<TextBlock Text="{Binding SelectedSpecialContent.Description}" FontSize="11"
Foreground="#475569" TextWrapping="Wrap"/>
<Button Content="Für Layout vorbereiten" HorizontalAlignment="Left"
Click="OnPrepareSpecialContent"/>
</StackPanel>
</Border>
<StackPanel><TextBlock Text="Einfügen in" Classes="label"/>
<ComboBox ItemsSource="{Binding ElementScopes}" SelectedItem="{Binding NewElementScope}"/></StackPanel>
<Grid ColumnDefinitions="*,8,*">
@@ -256,12 +271,12 @@
<Button Grid.Column="1" Content="Prüfen &amp; Vorschau" Click="OnPreview"/>
<Button Grid.Column="3" Content="Als PDF exportieren …" Click="OnExportCurrentPdf"/></Grid>
<TabControl Grid.Row="1" Margin="12">
<TabItem Header="Seite 1">
<TabItem Header="Skript">
<TextBox Text="{Binding LayoutSource}" AcceptsReturn="True" TextWrapping="NoWrap"
FontFamily="Menlo,Consolas,monospace" FontSize="13" VerticalContentAlignment="Top"
ScrollViewer.HorizontalScrollBarVisibility="Auto" ScrollViewer.VerticalScrollBarVisibility="Auto"/>
</TabItem>
<TabItem Header="Folgeseiten">
<TabItem Header="Folgeseiten (Legacy)" IsVisible="{Binding IsLegacyContinuationSupported}">
<Grid RowDefinitions="Auto,*">
<CheckBox Margin="8" Content="Eigenes Layout für Seite 2 und alle weiteren Seiten im Paket speichern"
IsChecked="{Binding UseContinuationLayout}"/>
@@ -58,6 +58,8 @@ public partial class MainWindow : Window
}
private void OnAddElement(object? sender, RoutedEventArgs e)
{ try { _viewModel.AddElement(); } catch (Exception ex) { _viewModel.SetStatus(ex.Message, true); } }
private void OnPrepareSpecialContent(object? sender, RoutedEventArgs e)
{ try { _viewModel.PrepareSelectedSpecialContent(); } catch (Exception ex) { _viewModel.SetStatus(ex.Message, true); } }
private void OnAddPageTemplate(object? sender, RoutedEventArgs e)
{ try { _viewModel.AddPageTemplate(); } catch (Exception ex) { _viewModel.SetStatus(ex.Message, true); } }
private void OnAddContentFlow(object? sender, RoutedEventArgs e)
@@ -108,7 +108,7 @@ public sealed class PdfImportPipeline
var blank = templatePath is null ? null : Extract(templatePath);
EnsureCompatible(example, blank);
if (example.Pages.Count > 1)
throw new InvalidDataException("Der automatische Import unterstützt in v1 einseitige Vorlagen. Weitere PDF-Seiten können anschließend als Folgeseitenlayout ergänzt werden.");
throw new InvalidDataException("Der automatische Import unterstützt in v1 einseitige Vorlagen. Weitere PDF-Seiten können anschließend im Reiter „Seiten & Flows“ als zusätzliche Seitenvorlage (page-template continuation) ergänzt werden.");
var candidates = FindCandidates(example, blank);
if (!string.IsNullOrWhiteSpace(username) && !string.IsNullOrWhiteSpace(password))
{
@@ -124,7 +124,7 @@ public sealed class PdfImportPipeline
{
if (document.Pages.Count == 0) throw new InvalidDataException("Das PDF enthält keine Seiten.");
if (document.Pages.Count > 1)
throw new InvalidDataException("Der automatische Import unterstützt in v1 einseitige Vorlagen. Weitere PDF-Seiten können anschließend als Folgeseitenlayout ergänzt werden.");
throw new InvalidDataException("Der automatische Import unterstützt in v1 einseitige Vorlagen. Weitere PDF-Seiten können anschließend im Reiter „Seiten & Flows“ als zusätzliche Seitenvorlage (page-template continuation) ergänzt werden.");
var first = document.Pages[0];
var assets = new Dictionary<string, byte[]>(StringComparer.OrdinalIgnoreCase)
{
@@ -134,6 +134,8 @@ public sealed class PdfImportPipeline
var lines = new List<string>
{
$"PAGE {N(first.Width)} {N(first.Height)} pt",
"#pragma format-version 3",
"#pragma page-template first",
"BG pdf-import-background.png",
};
var definitions = new List<PlaceholderDefinition>();
@@ -157,6 +159,7 @@ public sealed class PdfImportPipeline
definitions.Add(new(name, multiline ? PlaceholderType.Multiline : candidate.Type, false));
candidate.Name = name;
}
lines.Add("#pragma end-page-template");
var manifest = new TemplateManifest
{
Id = "pdf-import", Name = "PDF-Import", Description = "Automatisch aus einem PDF rekonstruiert",
@@ -3,6 +3,7 @@
<ItemGroup>
<ProjectReference Include="..\LehrerApp.Templating\LehrerApp.Templating.csproj" />
<PackageReference Include="Microsoft.NET.Test.Sdk" />
<PackageReference Include="PdfPig" />
<PackageReference Include="xunit" />
<PackageReference Include="xunit.runner.visualstudio"><PrivateAssets>all</PrivateAssets></PackageReference>
</ItemGroup>
@@ -0,0 +1,29 @@
using LehrerApp.Templating;
using Xunit;
namespace LehrerApp.Templating.Tests;
public sealed class TemplateStoreTests : IDisposable
{
private readonly string _appData = Path.Combine(Path.GetTempPath(), $"lavorlage-store-{Guid.NewGuid():N}");
[Fact]
public void ZweiStoresMitUnterschiedlichemUnterordner_TeilenSichKeineVorlagen()
{
var letters = new TemplateStore(_appData);
var worksheets = new TemplateStore(_appData, subfolder: "worksheet-template-packages");
var packagePath = Path.Combine(_appData, "source.lavorlage");
TemplatePackage.Create(packagePath, new TemplateManifest { Id = "brief", Name = "Brief" },
"PAGE 210 297 mm\nTEXT 20 20 \"Text\"", new Dictionary<string, byte[]>());
letters.Import(packagePath);
Assert.Single(letters.GetTemplates());
Assert.Empty(worksheets.GetTemplates());
}
public void Dispose()
{
if (Directory.Exists(_appData)) Directory.Delete(_appData, recursive: true);
}
}
@@ -85,6 +85,8 @@ public sealed class TemplatingTests : IDisposable
new Dictionary<string, byte[]>());
var drawing = new DrawingValue(
[new DrawRectangle(0, 0, 100, 60, "#1D4ED8", 1, "#EFF6FF"),
new DrawRoundedRectangle(2, 2, 30, 15, 3, "#1D4ED8", 1, "#EFF6FF"),
new DrawCircle(90, 10, 6, "#1D4ED8", 1, "#EFF6FF"),
new DrawString(5, 5, "Externer Inhalt", 11, Bold: true),
new MoveTo(5, 25), new LineTo(95, 25, "#DC2626", 1.5f),
new DrawLine(5, 35, 95, 50, "#059669", 1)], 60);
@@ -96,6 +98,43 @@ public sealed class TemplatingTests : IDisposable
System.Text.Encoding.ASCII.GetString(pdf), @"/Type\s*/Page\b"));
}
// Regression: RenderFlow fensterte Seiten bisher nur über einen SVG-viewBox-Y-Offset, den
// QuestPDFs Svg()-Renderer nicht respektiert - dadurch landete auf jeder Folgeseite (nahezu)
// der komplette Zeicheninhalt erneut, statt nur des jeweiligen Ausschnitts (siehe Slice() in
// QuestTemplateRenderer.DrawingElementRenderer). Prüft per echter PDF-Textextraktion, dass
// seitenspezifische Marker nur auf ihrer jeweiligen Seite auftauchen.
[Fact]
public void FlowDrawBox_ZerschneidetInhaltEchtStattIhnAufFolgeseitenZuWiederholen()
{
var template = new LoadedTemplate(new TemplateManifest
{
Placeholders = [new("Marker", PlaceholderType.Drawing, true)],
}, new LayoutParser().Parse("PAGE 210 297 mm\nFLOWDRAWBOX 20 20 170 100 $Marker"),
new Dictionary<string, byte[]>());
var drawing = new DrawingValue(
[
new DrawStringEx(0, 0, 12, 170, "MARKERTOP", DrawingTextAlignment.AlignLeft, 10),
new DrawStringEx(0, 90, 12, 170, "MARKERNEARBOTTOM", DrawingTextAlignment.AlignLeft, 10),
new DrawStringEx(0, 105, 12, 170, "MARKERAFTERBOUNDARY", DrawingTextAlignment.AlignLeft, 10),
new DrawStringEx(0, 190, 12, 170, "MARKERBOTTOM", DrawingTextAlignment.AlignLeft, 10),
], 200);
var pdf = new QuestTemplateRenderer().RenderToPdf(template,
new DictionaryProvider(new Dictionary<string, PlaceholderValue> { ["Marker"] = drawing }));
using var document = UglyToad.PdfPig.PdfDocument.Open(pdf);
var pages = document.GetPages().Select(p => p.Text).ToList();
Assert.Equal(2, pages.Count);
Assert.Contains("MARKERTOP", pages[0]);
Assert.Contains("MARKERNEARBOTTOM", pages[0]);
Assert.DoesNotContain("MARKERAFTERBOUNDARY", pages[0]);
Assert.DoesNotContain("MARKERBOTTOM", pages[0]);
Assert.DoesNotContain("MARKERTOP", pages[1]);
Assert.DoesNotContain("MARKERNEARBOTTOM", pages[1]);
Assert.Contains("MARKERAFTERBOUNDARY", pages[1]);
Assert.Contains("MARKERBOTTOM", pages[1]);
}
[Fact]
public void FlowDrawBox_PaginertHohenDeklarativenZeichenraum()
{
+11
View File
@@ -30,6 +30,10 @@ public sealed record DrawLine(float X1, float Y1, float X2, float Y2,
string Color = "#000000", float StrokeWidth = 1) : DrawingCommand;
public sealed record DrawRectangle(float X, float Y, float Width, float Height,
string StrokeColor = "#000000", float StrokeWidth = 1, string FillColor = "none") : DrawingCommand;
public sealed record DrawRoundedRectangle(float X, float Y, float Width, float Height, float CornerRadius,
string StrokeColor = "#000000", float StrokeWidth = 1, string FillColor = "none") : DrawingCommand;
public sealed record DrawCircle(float CenterX, float CenterY, float Radius,
string StrokeColor = "#000000", float StrokeWidth = 1, string FillColor = "none") : DrawingCommand;
public sealed record DrawImage(float X, float Y, float Width, float Height, byte[] Data, string MimeType) : DrawingCommand;
/// <summary>Declarative drawing supplied by an external data provider. Coordinates use the template layout unit; font sizes use points.</summary>
public sealed record DrawingValue(IReadOnlyList<DrawingCommand> Commands, float ContentHeight)
@@ -56,6 +60,13 @@ public sealed class DrawingCanvas
public void DrawRectangle(float x, float y, float width, float height, string strokeColor = "#000000",
float strokeWidth = 1, string fillColor = "none") =>
_commands.Add(new LehrerApp.Templating.DrawRectangle(x, y, width, height, strokeColor, strokeWidth, fillColor));
public void DrawRoundedRectangle(float x, float y, float width, float height, float cornerRadius,
string strokeColor = "#000000", float strokeWidth = 1, string fillColor = "none") =>
_commands.Add(new LehrerApp.Templating.DrawRoundedRectangle(x, y, width, height, cornerRadius,
strokeColor, strokeWidth, fillColor));
public void DrawCircle(float centerX, float centerY, float radius, string strokeColor = "#000000",
float strokeWidth = 1, string fillColor = "none") =>
_commands.Add(new LehrerApp.Templating.DrawCircle(centerX, centerY, radius, strokeColor, strokeWidth, fillColor));
public void DrawImage(float x, float y, float width, float height, byte[] data, string mimeType) =>
_commands.Add(new LehrerApp.Templating.DrawImage(x, y, width, height, data, mimeType));
}
+63 -5
View File
@@ -531,22 +531,67 @@ internal static class DrawingElementRenderer
};
container.Column(column =>
{
for (var page = 0; page < pages.Count; page++)
// Kein manueller PageBreak() zwischen den Ausschnitten: RenderFlow sitzt in einer
// ohnehin automatisch paginierenden Column (Flow-Dokument) bzw. füllt im Legacy-Modell
// fast die ganze Seite aus. Ein erzwungener Break hier sprang unabhängig vom
// tatsächlich verbleibenden Platz auf der aktuellen physischen Seite auf eine neue -
// z.B. begann der zweite Monat der Fehlzeitenliste auf Seite 3, obwohl auf Seite 2
// noch reichlich Platz war. QuestPDF schiebt einen Ausschnitt, der nicht mehr passt,
// ohnehin automatisch auf die nächste Seite.
foreach (var page in pages)
{
if (page > 0) column.Item().PageBreak();
var offset = value is DrawingValue ? page * pageHeight : 0;
// Koordinaten sind nach Slice()/RecordPages() bereits seitenlokal (bei 0 beginnend) -
// kein zusätzlicher viewBox-Y-Offset nötig oder sinnvoll (siehe Slice()).
column.Item().Height(UnitConverter.Points(pageHeight, unit)).Svg(
BuildSvg(pages[page], width, pageHeight, offset, unit));
BuildSvg(page, width, pageHeight, 0, unit));
}
});
}
// Schneidet die Zeichenbefehle anhand ihrer Y-Koordinate in Seiten auf und verschiebt sie auf
// seitenlokale Koordinaten (Y=0 am Seitenanfang). Ein SVG-Element mit "viewBox" und
// "overflow=hidden" wird von QuestPDFs Svg()-Renderer NICHT zuverlässig geclippt - ein
// (Y-)Offset im viewBox allein reicht nicht, um pro Seite nur den jeweiligen Ausschnitt zu
// zeigen; ohne dieses echte Zerschneiden landet auf jeder Seite (nahezu) der komplette
// Zeicheninhalt erneut (Bug: doppelte/überlappende Fehlzeitenliste über mehrere Seiten).
private static List<DrawingValue> Slice(DrawingValue value, float pageHeight)
{
var pageCount = Math.Max(1, (int)Math.Ceiling(Math.Max(0, value.ContentHeight) / pageHeight));
return Enumerable.Repeat(value, pageCount).ToList();
var pages = new List<List<DrawingCommand>>();
for (var i = 0; i < pageCount; i++) pages.Add([]);
var pendingLinePage = 0;
foreach (var command in value.Commands)
{
var y = CommandY(command);
if (y is null) { pages[0].Add(command); continue; }
var page = Math.Clamp((int)(y.Value / pageHeight), 0, pageCount - 1);
// LineTo gehört inhaltlich zum vorherigen MoveTo (eine Linie) - beide müssen auf
// derselben Seite landen, sonst fehlt beim Rendern der Linie der Startpunkt.
if (command is MoveTo) pendingLinePage = page;
else if (command is LineTo) page = pendingLinePage;
pages[page].Add(ShiftY(command, page * pageHeight));
}
return pages.Select(commands => new DrawingValue(commands, pageHeight)).ToList();
}
private static float? CommandY(DrawingCommand command) => command switch
{
DrawString s => s.Y, DrawStringEx s => s.Y, MoveTo m => m.Y, LineTo l => l.Y,
DrawRectangle r => r.Y, DrawRoundedRectangle r => r.Y, DrawImage i => i.Y,
DrawLine ln => Math.Min(ln.Y1, ln.Y2), DrawCircle c => c.CenterY - c.Radius, _ => null,
};
private static DrawingCommand ShiftY(DrawingCommand command, float dy) => command switch
{
DrawString s => s with { Y = s.Y - dy }, DrawStringEx s => s with { Y = s.Y - dy },
MoveTo m => m with { Y = m.Y - dy }, LineTo l => l with { Y = l.Y - dy },
DrawRectangle r => r with { Y = r.Y - dy }, DrawRoundedRectangle r => r with { Y = r.Y - dy },
DrawImage i => i with { Y = i.Y - dy },
DrawLine ln => ln with { Y1 = ln.Y1 - dy, Y2 = ln.Y2 - dy },
DrawCircle c => c with { CenterY = c.CenterY - dy },
_ => command,
};
internal static List<IReadOnlyList<DrawingCommand>> RecordPages(PagedDrawingValue value,
float width, float height, int maxPages)
{
@@ -607,6 +652,19 @@ internal static class DrawingElementRenderer
svg.Append(CultureInfo.InvariantCulture,
$"<rect x=\"{rectangle.X}\" y=\"{rectangle.Y}\" width=\"{rectangle.Width}\" height=\"{rectangle.Height}\" stroke=\"{Attribute(rectangle.StrokeColor)}\" stroke-width=\"{rectangle.StrokeWidth}\" fill=\"{Attribute(rectangle.FillColor)}\"/>");
break;
case DrawRoundedRectangle rectangle:
Validate(rectangle.X, rectangle.Y, rectangle.Width, rectangle.Height, rectangle.CornerRadius,
rectangle.StrokeWidth);
Positive(rectangle.Width, rectangle.Height, rectangle.CornerRadius, rectangle.StrokeWidth);
svg.Append(CultureInfo.InvariantCulture,
$"<rect x=\"{rectangle.X}\" y=\"{rectangle.Y}\" width=\"{rectangle.Width}\" height=\"{rectangle.Height}\" rx=\"{rectangle.CornerRadius}\" ry=\"{rectangle.CornerRadius}\" stroke=\"{Attribute(rectangle.StrokeColor)}\" stroke-width=\"{rectangle.StrokeWidth}\" fill=\"{Attribute(rectangle.FillColor)}\"/>");
break;
case DrawCircle circle:
Validate(circle.CenterX, circle.CenterY, circle.Radius, circle.StrokeWidth);
Positive(circle.Radius, circle.StrokeWidth);
svg.Append(CultureInfo.InvariantCulture,
$"<circle cx=\"{circle.CenterX}\" cy=\"{circle.CenterY}\" r=\"{circle.Radius}\" stroke=\"{Attribute(circle.StrokeColor)}\" stroke-width=\"{circle.StrokeWidth}\" fill=\"{Attribute(circle.FillColor)}\"/>");
break;
case DrawString text:
Validate(text.X, text.Y, text.FontSize);
Positive(text.FontSize);
+10 -2
View File
@@ -70,9 +70,9 @@ public sealed class TemplateStore
private readonly string _directory;
private readonly ITemplateLoader _loader;
public TemplateStore(string appDataPath, ITemplateLoader? loader = null)
public TemplateStore(string appDataPath, ITemplateLoader? loader = null, string subfolder = "letter-template-packages")
{
_directory = Path.Combine(appDataPath, "letter-template-packages");
_directory = Path.Combine(appDataPath, subfolder);
Directory.CreateDirectory(_directory);
_loader = loader ?? new TemplateLoader();
}
@@ -112,3 +112,11 @@ public sealed class TemplateStore
if (match is not null && File.Exists(match.PackagePath)) File.Delete(match.PackagePath);
}
}
/// <summary>Getrennter Vorlagen-Speicher für Arbeitsblätter (eigener Unterordner) - reiner
/// Marker-Typ, damit DI die Elternbrief- und die Arbeitsblatt-Vorlagenbibliothek als zwei
/// unabhängige <see cref="TemplateStore"/>-Instanzen registrieren kann.</summary>
public sealed class WorksheetTemplateStore(TemplateStore store)
{
public TemplateStore Store { get; } = store;
}
+114
View File
@@ -2086,6 +2086,48 @@ vollständig enthält.
(unbekannter/nicht zulässiger Status, unbekannte/abgelaufene row-id, jeweils ohne Bestätigung
erreicht) sind in `UntisComparisonToolsTests.cs` abgedeckt.
**Nachtrag (aus echtem Live-Test, 2026-09-13):** Nutzer versuchte die neuen Tools tatsächlich zu
benutzen und stieß darauf, dass `groupId` überall Pflichtparameter ist, aber nirgends über MCP
auflösbar war — ein KI-Client kannte bestenfalls den Klarnamen einer Lerngruppe aus dem Gespräch,
nie ihre Id (betrifft nicht nur die Untis-Tools, sondern z.B. auch `get_grades`/`get_schedule`/
`get_lesson_plans`). Beide vom Nutzer vorgeschlagenen Wege umgesetzt:
- Neues [GroupTools.cs](LehrerApp.Desktop/Services/Mcp/Tools/GroupTools.cs) mit `get_groups`
(Read; Id/Name/Typ/Schuljahr/Klassenstufe/SubjectId/IsActive, optional nach Schuljahr gefiltert).
- `UntisHubStatusRowDto` bekommt zusätzlich `GroupId` (null bei den drei dashboard-weiten
Zeilen), damit ein KI-Client für eine dort gelistete Gruppe nicht zusätzlich `get_groups`
aufrufen muss, nur um den Fehlzeitenabgleich für sie auszulösen.
**Nachtrag (Nutzer-Feedback, 2026-09-13):** `UntisHubJobState` (die Fälligkeits-Zeitstempel
hinter dem Hub) lief bislang außerhalb des Sync — jedes Gerät führte seine eigene Buchhaltung.
In der Praxis zeigte das auf allen Instanzen dieselben offenen Punkte, was dem Zweck des Hubs
widerspricht (möglichst wenig WebUntis-Traffic: ein bereits auf Gerät A erledigter Abgleich soll
auf Gerät B nicht erneut als fällig erscheinen und zu einem zweiten, unnötigen Abruf verleiten).
Anders als die drei bewusst unsynchronisierten Report-Caches (`UntisAbsenceCacheRepository` &
Co., siehe oben) enthält `UntisHubJobState` keine WebUntis-Rohdaten, sondern nur Zeitstempel +
Kurztext — unkritisch für Sync.
- `UntisHubJobStateRepository.Save` ruft jetzt `db.OnChange` wie die übrigen ~28 synchronisierten
Repositories auf; `EventApplier` bekommt dafür einen zusätzlichen `Simple<UntisHubJobState>`-
Eintrag. Kein `Delete` nötig (Interface kennt keins) — Zeilen werden nur überschrieben.
- Geräte-Pairing (`SnapshotService`) war bereits unberührt, da es die komplette DB-Datei kopiert.
- Race-Härtung: `UntisHubService.RecordRun` legt bei einem (Kind, GroupId), das lokal noch nie
lief, eine neue `Id` an; laufen zwei Geräte offline denselben, noch nie ausgeführten Job
unabhängig voneinander, entstehen dadurch kurzzeitig zwei Datensätze für dasselbe Paar (kein
Unique-Index darauf). `UntisHubService.BuildRows` wählt deshalb jetzt den Datensatz mit dem
jüngsten `LastRunAt` statt eines beliebigen — der verwaiste zweite Datensatz bleibt harmlos in
der DB stehen (gleiches akzeptiertes v1-Verhalten wie bei anderen Entitäten ohne serverseitige
Merge-Logik, siehe TODO 10.3).
**Nachtrag (Nutzer-Feedback, 2026-09-13):** Manche Lerngruppen mit `WebUntisLessonId` (z.B.
Klassenrat, AGs) sollen trotzdem nie im Untis-Hub auftauchen. Neues Feld
`LearningGroup.ExcludedFromUntisHub` (Default `false`, siehe
[LehrerApp.Core/Models/LearningGroup.cs](LehrerApp.Core/Models/LearningGroup.cs)) —
`UntisHubService.GetRows()` filtert damit zusätzlich zu `WebUntisLessonId is not null`. Checkbox
"Nicht im Untis-Hub verfolgen" im Gruppendialog
([AddGroupDialog.axaml](LehrerApp.Desktop/Views/Groups/AddGroupDialog.axaml)). Kein eigener
Sync-Aufwand nötig, da `LearningGroup` bereits als Ganzes synchronisiert wird
(`EventApplier.Simple<LearningGroup>`); `get_untis_hub_status` (MCP) profitiert automatisch mit,
da es nur an `GetRows()` delegiert.
### 4.4 Wochen-/Tagesansicht
- [x] **4.4.1** Kalenderansicht über alle Gruppen: Woche und Tag — siehe Nachtrag zu 4.3
("Heute"-Tab: Tagesliste unten angedockt, gruppenübergreifendes Wochenraster darüber, inkl.
@@ -2874,6 +2916,40 @@ folgenden Punkte gehören direkt in `LehrerApp.Desktop`:
- Kein zusätzliches Feature wie Windows-Autostart für LehrerApp.Desktop selbst oder ein
Tray-Icon (auf Nachfrage bewusst nicht Teil dieser Änderung) — die Bridge löst das Problem
rein auf Protokollebene, unabhängig davon, wann der Nutzer LehrerApp tatsächlich startet.
- [x] **4.5.35** Freitextfeld "Planungsideen" je Stunde (2026-09-14, Nutzer-Feedback): erste, noch
grobe Ideen zu einer Stunde entstehen erfahrungsgemäß lange bevor der Verlaufsplan (4.2.2)
feingeplant wird — dafür gab es bisher kein Feld, nur `Reflection` (nach der Stunde) und
`Unit.Notes` (auf Einheiten- statt Stundenebene). Neues `Lesson.PlanningIdeas` (`string?`),
im [LessonDialog](LehrerApp.Desktop/Views/Groups/LessonDialog.axaml) direkt unter "Thema" als
mehrzeiliges Freitextfeld, noch vor dem Verlaufsplan — bewusste Platzierung, damit die Idee
zuerst und die Feinplanung erst danach kommt. Keine Migration nötig (LiteDB, neues Feld).
Auch als Kontext für die KI-Planungsunterstützung (4.5.9) durchgereicht, wie von der
Lehrkraft gewünscht ("auch nützlich für die KI-gestützte Weiterplanung"): `AiLesson`/
`AiUnitContext` (Core/AiPlanning) bekommen ein neues, zu `Homework`/`Reflection` symmetrisches
Feld `planningIdeas` (gelesen in `AiPlanningService.BuildContext`, übernommen in
`ApplyResponse`, im Planungsdiff berücksichtigt in `DescribeChanges`); `ai-backend/plan.php`
bekommt das Feld in Ein- und Ausgabeschema samt Hinweis an die KI, es als starken Kontext zu
nutzen und unverändert zurückzugeben, außer die Anweisung verlangt ausdrücklich eine
Überarbeitung. Für die MCP-gestützte Weiterplanung (4.5.28) zusätzlich in `LessonDto`
(Services/Mcp/Tools/Dto.cs) exponiert und über einen neuen optionalen `planningIdeas`-
Parameter von `update_lesson` (LessonPlanTools.cs) schreibbar — bewusst kein neues, eigenes
MCP-Tool nur für dieses eine Feld, passt in das bestehende kleinteilige Muster.
- [x] **4.5.36** Materialerstellungs-Prompt bleibt nach dem Übernehmen erhalten (2026-09-14,
Nachtrag zu 4.5.20, Nutzer-Feedback): der in 4.5.20 gebaute Prompt war bisher nur im
`AiAssistDialog` (Review vor dem Speichern) kopierbar — einmal übernommen, war er weg, obwohl
die Lehrkraft ihn oft erst später in einer externen KI-Sitzung tatsächlich einlöst. Neues
`LessonPhaseStep.MaterialPrompt` (`string?`, keine Migration nötig): `AiPlanningService.
ApplyResponse` befüllt es automatisch mit dem von `BuildMaterialPrompt` erzeugten Text, sobald
die Phase einen `MaterialSuggestion`-Vorschlag hatte — bewusst weiterhin kein separates
"Material"-Domänenmodell (siehe 4.5.26), nur ein zusätzliches Feld an der bestehenden Zeile.
Im [LessonDialog](LehrerApp.Desktop/Views/Groups/LessonDialog.axaml) erscheint dafür in der
Verlaufsplan-Tabelle ein neuer Button "📋" je Phasenzeile, nur sichtbar wenn ein Prompt
gespeichert ist (`PhaseStepEditItem.HasMaterialPrompt`) — derselbe Zwischenablage-Mechanismus
wie im `AiAssistDialog` (`LessonDialog.axaml.cs:OnCopyMaterialPrompt`). Für die MCP-gestützte
Weiterplanung zusätzlich in `LessonPhaseDto.MaterialPrompt` gelesen und über einen neuen
optionalen Parameter an `add_lesson_phase`/`update_lesson_phase` (LessonPlanTools.cs)
schreibbar, damit auch ein extern (z.B. in Claude Desktop) formulierter Prompt zur
Wiederverwendung gespeichert werden kann, nicht nur ein vom eigenen KI-Backend erzeugter.
**Wichtige Abweichung von der ursprünglichen Planung (5.2):** Vor der Umsetzung zeigte sich,
dass 5.2 wie ursprünglich beschrieben eine zweite, parallele Fehlzeiten-Erfassung neben dem
@@ -4324,6 +4400,35 @@ beides vor dem ersten produktiven Zwei-Geräte-Einsatz empfehlenswert nachzuhole
Erzeugung, damit fehlerhafte Briefe nicht unbemerkt vervielfältigt werden.
- [ ] **11.5b** Stapelerzeugung für eine ganze Lerngruppe mit Kontaktauswahl je Schüler und
Ergebnisübersicht. Bewusst nachgelagert; der validierte Einzelbrief bildet die Grundlage.
- [x] **11.5c** Formulare-Menü in der Menüleiste (`MainWindow.axaml`, bisher nur "WebUntis"):
"Elternbrief erzeugen…" ohne vorher geöffneten Schüler sowie neu "Arbeitsblatt
personalisieren…", das nur aktiv ist, während eine einzelne Lerngruppe in der Detailansicht
geöffnet ist (Nutzer-Feedback).
**Elternbrief-Einstieg:** neuer `StudentPickerDialog`
([StudentPickerDialogViewModel.cs](LehrerApp.Desktop/ViewModels/Students/StudentPickerDialogViewModel.cs))
mit demselben Such-/Filtermuster wie `AddStudentToGroupDialogViewModel`, aber ohne
Gruppenbezug. Danach läuft unverändert der bestehende `CreateLetterDialog`; der gemeinsame
Öffnen-Ablauf wurde aus `StudentDetailView` in `LetterDialogs.ShowCreateLetterDialogAsync`
ausgelagert, damit beide Einstiegspunkte (Schülerdetail + Menü) nicht auseinanderlaufen.
Deckt weiterhin nur einen Schüler auf einmal ab — die in 11.5b beschriebene
Stapelerzeugung für eine ganze Lerngruppe bleibt offen.
**Arbeitsblatt personalisieren:** nutzt dieselbe .lavorlage/QuestPDF-Infrastruktur wie
Elternbriefe (`LehrerApp.Templating`), aber eine eigene `WorksheetTemplateStore`-Bibliothek
(`TemplateStore` bekam dafür einen optionalen `subfolder`-Konstruktorparameter) mit
eigener Verwaltung unter Einstellungen → Briefvorlagen → "Arbeitsblatt-Vorlagen" — bewusst
getrennt von den Elternbrief-Vorlagen, damit sich beide Listen nicht vermischen. Der
.lavorlage-Import selbst (PDF-Beispiel einlesen, Platzhalter erkennen) passiert weiterhin
ausschließlich im externen `LehrerApp.TemplateDesigner` (siehe 4.5.29); die Hauptapp lädt nur
eine fertige Vorlage, befüllt sie je aktivem Gruppenmitglied mit den bereits für Elternbriefe
etablierten `LetterPlaceholderBuilder`-Platzhaltern (Student.FirstName/LastName, Group.Name,
Datum, …) und schreibt je Schüler ein PDF in einen gewählten Zielordner
(`PersonalizeWorksheetDialogViewModel`). Bewusst kein Sammel-PDF und keine grafische
Platzhalter-Positionierung in der Hauptapp — beides bleibt Aufgabe des Vorlagen-Designers.
`MainWindowViewModel.CanPersonalizeWorksheet` beobachtet dafür `GroupDetailViewModel.Group`
per PropertyChanged-Abo, weil dessen Wert erst nach dem Seitenwechsel per `LoadGroup` gesetzt
wird (siehe Kommentar in `NavigateToGroupDetail`).
- [ ] **11.6** Vollständiger Datenexport eines Schuljahres (Archivierung).
---
@@ -4761,3 +4866,12 @@ Die Abschnitte sind thematisch, nicht chronologisch nummeriert. Sinnvolle Bearbe
**→ nächster sinnvoller Schritt:** offene größere Kapitel wie **3.1** (Mitarbeit-Aspekte
pro Gruppe verwalten), **9** (Dashboard-Kacheln), **10** (Sync) oder **11** (Export) — oder
gezielt eines der oben zurückgestellten 4.5.x-Punkte, falls der Bedarf danach entsteht.
### Startoptimierung (September 2026)
- [x] Backup, Backup-Pruefung und Datenbankinitialisierung laufen ausserhalb des UI-Threads.
- [x] Splashscreen mit echtem, phasenbasiertem Ladebalken und aktuellem Arbeitsschritt.
- [x] Optionale iCal-Erstabgleiche starten nach dem Hauptfenster; lokale Daten sind sofort nutzbar.
Laufende Erstabgleiche werden vor der Freigabe der Datenbank beendet; HTTP-Abrufe beim Beenden abgebrochen.
Die Zeit bis zum Hauptfenster wird im App-Log protokolliert.
+2 -1
View File
@@ -39,7 +39,8 @@ Unterricht einsetzen kann, auch wenn sie nicht ihre eigene Idee war.
"phases": [
{ "name": "<Phasenname>", "durationMinutes": <Zahl>, "activity": "<Tätigkeit>", "material": "<Material>", "shorthand": "<Kurzsymbol>" }
],
"homework": "<Hausaufgabe oder null>", "reflection": "<Reflexion oder null>"
"homework": "<Hausaufgabe oder null>", "reflection": "<Reflexion oder null>",
"planningIdeas": "<grobe Ideen der Lehrkraft vor der Feinplanung oder null, nur Lesekontext>"
}
}
+9 -2
View File
@@ -65,7 +65,8 @@ Die Eingabe hat exakt diese Struktur:
}
],
"homework": "<Hausaufgabe oder null>",
"reflection": "<Reflexion oder null>"
"reflection": "<Reflexion oder null>",
"planningIdeas": "<Grobe Ideen/Rohentwurf der Lehrkraft vor der Feinplanung oder null>"
}
]
}
@@ -111,6 +112,11 @@ Stunde zur Prüfung, alles andere würde ihr gar nicht angezeigt.
"AB" Arbeitsblatt) — kein Fließtext.
- "durationMinutes" je Phase sollte in Summe zur für die Stunde realistischen Zeit passen
(i.d.R. rund 45 Minuten je Einzelstunde, abzüglich organisatorischer Zeit).
- "planningIdeas" einer Stunde enthält oft schon vor der Feinplanung festgehaltene grobe Ideen der
Lehrkraft (Stichpunkte, erste Gedanken, mögliche Aufhänger) — nutze das, wo vorhanden, als starken
Hinweis für Thema/Verlaufsplan dieser Stunde, ohne jedes Stichwort wörtlich übernehmen zu müssen.
Gib das Feld in deiner Antwort unverändert zurück, außer die Anweisung der Lehrkraft verlangt
ausdrücklich eine Überarbeitung dieser Ideen selbst.
- "materialSuggestion" je Phase (nur in deiner Antwort, nicht in der Eingabe): ein kurzer Vorschlag
(1-2 Sätze), WAS ein zu dieser Phase passendes Medium/Material konkret zeigen oder enthalten
sollte (z.B. Aufbau eines Tafelbilds, Inhalt eines Arbeitsblatts) — nicht nur "ein Tafelbild
@@ -142,7 +148,8 @@ Antworte AUSSCHLIESSLICH mit gültigem JSON (kein Freitext davor/danach) in gena
}
],
"homework": "<Hausaufgabe oder null>",
"reflection": "<Reflexion oder null>"
"reflection": "<Reflexion oder null>",
"planningIdeas": "<unverändert aus der Eingabe übernommen, siehe oben, oder null>"
}
],
"summary": "<kurze menschenlesbare Zusammenfassung, was du getan hast>"
+9
View File
@@ -129,6 +129,15 @@ statt der typisierten `Lessons`-Collection — nach jeder Modelländerung kennt
`Lesson`-Klasse die alten Feldnamen nicht mehr, ein Zugriff darüber hätte sie beim Deserialisieren
bereits verworfen, bevor sie gelesen werden können.
`LessonPhaseStep.MaterialPrompt` (4.5.36, Nachtrag zu 4.5.20) ist kein von Hand gepflegtes Feld:
`AiPlanningService.ApplyResponse` befüllt es automatisch mit dem vollständigen, lokal erzeugten
Prompt (`BuildMaterialPrompt`), sobald die KI beim letzten "Übernehmen" für diese Phase einen
Medienvorschlag gemacht hatte — ursprünglich (4.5.20) bewusst NICHT persistiert, weil der Vorschlag
nur für die Review-Anzeige gedacht war; Nutzer-Feedback nach erstem Einsatz war, dass der Prompt
auch nach dem Übernehmen noch abrufbar bleiben soll. Kein neues "Material"-Domänenmodell dafür (das
gibt es weiterhin nicht, siehe 4.5.26) — der Prompt hängt einfach als zusätzliches Freitextfeld an
der bereits bestehenden `LessonPhaseStep`-Zeile.
## Eindeutige Schlüssel
Die Datenbank schützt folgende Kombinationen mit eindeutigen Indizes: