Compare commits
20
Commits
bfe214ecf2
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
77fe9b1c79 | ||
|
|
92e497b54f | ||
|
|
861f2c76ca | ||
|
|
ef3e9dbbb6 | ||
|
|
03a5e0dd9c | ||
|
|
6dccf96039 | ||
|
|
6e57bf407d | ||
|
|
0b5cfc5522 | ||
|
|
09cbbf1b77 | ||
|
|
1671796844 | ||
|
|
70dc78904c | ||
|
|
7b660c7152 | ||
|
|
3675b70004 | ||
|
|
eb1b2340b7 | ||
|
|
b8193be6ec | ||
|
|
0962845ead | ||
|
|
42c650518c | ||
|
|
2c791258a1 | ||
|
|
c528bd825f | ||
|
|
56ab5067e6 |
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
@@ -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,87 @@ 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);
|
||||
}
|
||||
|
||||
[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,185 @@ 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));
|
||||
}
|
||||
|
||||
[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 +245,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,
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,48 @@
|
||||
using Avalonia.Controls;
|
||||
using LehrerApp.Core.Interfaces;
|
||||
using LehrerApp.Core.Models;
|
||||
using LehrerApp.Desktop.ViewModels.Students;
|
||||
using LehrerApp.Desktop.Views.Students;
|
||||
using LehrerApp.Templating;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace LehrerApp.Desktop.Services;
|
||||
|
||||
/// <summary>Öffnet den bestehenden Elternbrief-Dialog für einen Schüler, egal ob der Aufruf aus
|
||||
/// der Schülerdetailansicht (Student schon im DataContext) oder aus dem Formulare-Menü (Student
|
||||
/// erst per Picker gewählt) kommt.</summary>
|
||||
public static class LetterDialogs
|
||||
{
|
||||
public static async Task ShowCreateLetterDialogAsync(Window owner, Student student)
|
||||
{
|
||||
var vm = new CreateLetterDialogViewModel(student,
|
||||
App.Services.GetRequiredService<TemplateStore>(),
|
||||
App.Services.GetRequiredService<ITemplateRenderer>(),
|
||||
App.Services.GetRequiredService<IGroupMembershipRepository>(),
|
||||
App.Services.GetRequiredService<IGroupRepository>(),
|
||||
options => App.Services.GetRequiredService<StudentAttendanceCalendarService>().Build(student, options),
|
||||
options => App.Services.GetRequiredService<StudentAttendanceCalendarService>()
|
||||
.BuildAbsenceDayList(student, options),
|
||||
RefreshAttendanceDataAsync);
|
||||
var dialog = new CreateLetterDialog { DataContext = vm };
|
||||
var path = await dialog.ShowDialog<string?>(owner);
|
||||
if (!string.IsNullOrEmpty(path) && File.Exists(path))
|
||||
System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo(path) { UseShellExecute = true });
|
||||
}
|
||||
|
||||
/// <summary>Holt genau den im Anwesenheitskalender-Dialog gewählten Zeitraum gezielt per
|
||||
/// WebUntis nach (ausgelöst durch den expliziten "Konfigurieren…"-Klick, kein Hintergrundabruf
|
||||
/// beim bloßen Öffnen des Briefdialogs). Damit sieht <see cref="StudentAttendanceCalendarService"/>
|
||||
/// anschließend frische Daten im lokalen Cache, statt stillschweigend "keine Fehltage" zu
|
||||
/// melden, nur weil die Klassenlehrer-Übersicht für diesen Zeitraum noch nie geöffnet wurde.</summary>
|
||||
private static async Task RefreshAttendanceDataAsync(AttendanceCalendarOptions options, CancellationToken token)
|
||||
{
|
||||
var className = App.Services.GetRequiredService<WebUntisSettingsService>().HomeroomClassName;
|
||||
if (string.IsNullOrWhiteSpace(className)) return;
|
||||
var cache = App.Services.GetRequiredService<UntisReportCacheService>();
|
||||
var start = options.NormalizedStartMonth;
|
||||
var end = start.AddMonths(options.NormalizedMonthCount).AddDays(-1);
|
||||
await cache.GetAbsencesAsync(className, start, end, token: token);
|
||||
await cache.GetClassRegisterEventsAsync(className, start, end, token: token);
|
||||
}
|
||||
}
|
||||
@@ -15,27 +15,39 @@ 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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,9 @@ 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 values = LetterPlaceholderBuilder.BuildStandardValues(student, contact, group, date, letterText,
|
||||
teacherName, attendanceCalendars?.Build(student, date),
|
||||
attendanceCalendars?.BuildAbsenceDayList(student, new AttendanceCalendarOptions(date, 1)));
|
||||
foreach (var (name, raw) in extraValues ?? [])
|
||||
{
|
||||
var definition = loaded.Manifest.Placeholders.FirstOrDefault(p => p.Name == name);
|
||||
|
||||
@@ -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,251 @@
|
||||
using System.Globalization;
|
||||
using LehrerApp.Core.Interfaces;
|
||||
using LehrerApp.Core.Models;
|
||||
using LehrerApp.Desktop.ViewModels.ClassTeacher;
|
||||
using LehrerApp.Templating;
|
||||
|
||||
namespace LehrerApp.Desktop.Services;
|
||||
|
||||
public enum AttendanceCalendarSize { Small, Medium, Large }
|
||||
|
||||
public sealed record AttendanceCalendarOptions(DateOnly StartMonth, int MonthCount,
|
||||
AttendanceCalendarSize Size = AttendanceCalendarSize.Medium)
|
||||
{
|
||||
public DateOnly NormalizedStartMonth => new(StartMonth.Year, StartMonth.Month, 1);
|
||||
public int NormalizedMonthCount => Math.Clamp(MonthCount, 1, 3);
|
||||
}
|
||||
|
||||
/// <summary>Erzeugt den portablen Advanced-Content-Platzhalter für Elternbriefe aus derselben
|
||||
/// priorisierten Monatsansicht, die im Klassenlehrer-Sidebar-Widget verwendet wird.</summary>
|
||||
public static class StudentAttendanceCalendarDrawingBuilder
|
||||
{
|
||||
public const string PlaceholderName = "Student.AttendanceCalendar";
|
||||
|
||||
public static DrawingValue Build(string studentName, DateOnly month,
|
||||
IReadOnlyList<ClassAbsenceDaySummaryRow> absences,
|
||||
IReadOnlyList<UntisForeignClassRegisterEventDto> registerEntries) =>
|
||||
Build(studentName, new AttendanceCalendarOptions(month, 1), absences, registerEntries);
|
||||
|
||||
public static DrawingValue Build(string studentName, AttendanceCalendarOptions options,
|
||||
IReadOnlyList<ClassAbsenceDaySummaryRow> absences,
|
||||
IReadOnlyList<UntisForeignClassRegisterEventDto> registerEntries)
|
||||
{
|
||||
const float width = 170;
|
||||
var scale = options.Size switch
|
||||
{
|
||||
AttendanceCalendarSize.Small => .7f,
|
||||
AttendanceCalendarSize.Large => 1f,
|
||||
_ => .85f,
|
||||
};
|
||||
// Die Breite richtet sich nach der DRAWBOX-Deklaration im Layout-Skript (fest, unabhängig
|
||||
// von der Größenwahl) - nur Schrift/Zellenhöhe skalieren mit "Größe". Würde die Breite mit
|
||||
// skalieren, würde "Groß" (scale=1) exakt die Skript-Box ausfüllen, "Klein"/"Normal" aber
|
||||
// nur einen Teil davon - und eine größere Skalierung als 1 liefe über die Box hinaus und
|
||||
// würde am rechten Rand abgeschnitten (SVG overflow="hidden").
|
||||
var contentWidth = width;
|
||||
var cellHeight = 10 * scale;
|
||||
var monthGapX = 10f;
|
||||
var first = options.NormalizedStartMonth;
|
||||
var monthCount = options.NormalizedMonthCount;
|
||||
var monthWidth = (contentWidth - monthGapX * (monthCount - 1)) / monthCount;
|
||||
var cellWidth = monthWidth / 7;
|
||||
var commands = new List<DrawingCommand>();
|
||||
var y = 0f;
|
||||
commands.Add(new DrawStringEx(0, y, 14 * scale, contentWidth, "Anwesenheit",
|
||||
DrawingTextAlignment.AlignLeft, 11 * scale, Color: "#1F2937", Bold: true));
|
||||
y += 14 * scale;
|
||||
commands.Add(new DrawStringEx(0, y, 10 * scale, contentWidth, studentName,
|
||||
DrawingTextAlignment.AlignLeft, 8 * scale, Color: "#6B7280"));
|
||||
y += 10 * scale + 2 * scale;
|
||||
var weekdays = new[] { "Mo", "Di", "Mi", "Do", "Fr", "Sa", "So" };
|
||||
var gridStartY = y;
|
||||
var maxWeeks = 0;
|
||||
for (var monthIndex = 0; monthIndex < monthCount; monthIndex++)
|
||||
{
|
||||
var current = first.AddMonths(monthIndex);
|
||||
var days = ClassTeacherOverviewViewModel.BuildCompactMonthDays(current, absences, registerEntries,
|
||||
DateOnly.FromDateTime(DateTime.Today), studentName);
|
||||
var offset = ((int)current.DayOfWeek + 6) % 7;
|
||||
var weeks = (int)Math.Ceiling((offset + days.Count) / 7d);
|
||||
maxWeeks = Math.Max(maxWeeks, weeks);
|
||||
var xOffset = monthIndex * (monthWidth + monthGapX);
|
||||
var monthY = gridStartY;
|
||||
commands.Add(new DrawStringEx(xOffset, monthY, 11 * scale, monthWidth,
|
||||
current.ToString("MMMM yyyy", CultureInfo.GetCultureInfo("de-DE")),
|
||||
DrawingTextAlignment.AlignLeft, 9 * scale, Color: "#374151", Bold: true));
|
||||
monthY += 11 * scale;
|
||||
for (var column = 0; column < 7; column++)
|
||||
commands.Add(new DrawStringEx(xOffset + column * cellWidth, monthY, 9 * scale, cellWidth,
|
||||
weekdays[column], DrawingTextAlignment.AlignCenter, 7 * scale, Color: "#6B7280", Bold: true));
|
||||
monthY += 9 * scale;
|
||||
|
||||
foreach (var day in days)
|
||||
{
|
||||
var index = offset + day.Date.Day - 1;
|
||||
var column = index % 7;
|
||||
var row = index / 7;
|
||||
var x = xOffset + column * cellWidth;
|
||||
var cellY = monthY + row * cellHeight;
|
||||
commands.Add(new DrawRectangle(x + scale, cellY, cellWidth - 2 * scale, cellHeight - scale,
|
||||
"#D1D5DB", .35f, day.HasSignal ? day.SignalColorHex : "#FFFFFF"));
|
||||
commands.Add(new DrawStringEx(x, cellY + scale, cellHeight - 2 * scale, cellWidth,
|
||||
day.HasSignal ? day.SignalCode : day.DayNumber, DrawingTextAlignment.AlignCenter, 7 * scale,
|
||||
Color: day.HasSignal ? "#FFFFFF" : "#374151", Bold: day.HasSignal));
|
||||
}
|
||||
}
|
||||
y = gridStartY + 11 * scale + 9 * scale + maxWeeks * cellHeight + monthGapX;
|
||||
|
||||
commands.Add(new DrawStringEx(0, y, 8 * scale, contentWidth,
|
||||
"U unentschuldigt · A abwesend · V verspätet · E entschuldigt · ! Klassenbuch",
|
||||
DrawingTextAlignment.AlignLeft, 6.5f * scale, Color: "#6B7280"));
|
||||
return new DrawingValue(commands, y + 9 * scale);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Chronologische, portable Fehlzeitenliste für Elternbriefvorlagen.</summary>
|
||||
public static class StudentAbsenceDayListDrawingBuilder
|
||||
{
|
||||
public const string PlaceholderName = "Student.AbsenceDays";
|
||||
|
||||
public static DrawingValue Build(string studentName, AttendanceCalendarOptions options,
|
||||
IReadOnlyList<ClassAbsenceDaySummaryRow> absences)
|
||||
{
|
||||
const float width = 170;
|
||||
var scale = options.Size switch
|
||||
{
|
||||
AttendanceCalendarSize.Small => .75f,
|
||||
AttendanceCalendarSize.Large => 1f,
|
||||
_ => .88f,
|
||||
};
|
||||
// Breite bleibt an die DRAWBOX-Deklaration im Layout-Skript gebunden (siehe
|
||||
// StudentAttendanceCalendarDrawingBuilder) - nur Zeilenhöhe/Schrift skalieren mit "Größe".
|
||||
var contentWidth = width;
|
||||
var rowHeight = 12 * scale;
|
||||
var start = options.NormalizedStartMonth;
|
||||
var end = start.AddMonths(options.NormalizedMonthCount).AddDays(-1);
|
||||
var rows = absences
|
||||
.Where(a => a.Date >= start && a.Date <= end &&
|
||||
UntisNameMatching.NamesMatch(a.StudentName, studentName))
|
||||
.OrderBy(a => a.Date)
|
||||
.ToList();
|
||||
var commands = new List<DrawingCommand>();
|
||||
var y = 0f;
|
||||
commands.Add(new DrawStringEx(0, y, 14 * scale, contentWidth, "Fehltage",
|
||||
DrawingTextAlignment.AlignLeft, 11 * scale, Color: "#1F2937", Bold: true));
|
||||
y += 14 * scale;
|
||||
commands.Add(new DrawStringEx(0, y, 10 * scale, contentWidth, studentName,
|
||||
DrawingTextAlignment.AlignLeft, 8 * scale, Color: "#6B7280"));
|
||||
y += 10 * scale + 2 * scale;
|
||||
// Spaltenbreiten so gewählt, dass auch die Datenzeilen (nicht nur die kurzen Kopfzeilen-
|
||||
// Labels) hineinpassen - "Datum" als Kopfzeile ist kürzer als "dd.MM.yyyy" und wurde bei
|
||||
// 31 zu schmal bemessen, wodurch das Datum am rechten Rand abgeschnitten wurde.
|
||||
const float dateColumnX = 2, dateColumnWidth = 38;
|
||||
const float extentColumnX = 44, extentColumnWidth = 66;
|
||||
const float statusColumnX = 114, statusColumnWidth = 54;
|
||||
commands.Add(new DrawRectangle(0, y, contentWidth, rowHeight, "#CBD5E1", .4f, "#F3F4F6"));
|
||||
commands.Add(new DrawStringEx(dateColumnX, y + scale, rowHeight - scale, dateColumnWidth, "Datum",
|
||||
DrawingTextAlignment.AlignLeft, 7.5f * scale, Color: "#374151", Bold: true));
|
||||
commands.Add(new DrawStringEx(extentColumnX, y + scale, rowHeight - scale, extentColumnWidth, "Umfang",
|
||||
DrawingTextAlignment.AlignLeft, 7.5f * scale, Color: "#374151", Bold: true));
|
||||
commands.Add(new DrawStringEx(statusColumnX, y + scale, rowHeight - scale, statusColumnWidth, "Status",
|
||||
DrawingTextAlignment.AlignLeft, 7.5f * scale, Color: "#374151", Bold: true));
|
||||
y += rowHeight;
|
||||
|
||||
if (rows.Count == 0)
|
||||
{
|
||||
commands.Add(new DrawStringEx(2, y + 2 * scale, rowHeight, contentWidth - 4,
|
||||
"Keine Fehltage im gewählten Zeitraum", DrawingTextAlignment.AlignLeft, 8 * scale,
|
||||
Color: "#6B7280", Italic: true));
|
||||
y += rowHeight + 3 * scale;
|
||||
}
|
||||
else
|
||||
{
|
||||
for (var index = 0; index < rows.Count; index++)
|
||||
{
|
||||
var row = rows[index];
|
||||
var fill = index % 2 == 0 ? "#FFFFFF" : "#F9FAFB";
|
||||
var extent = row.CountsAsFullDay
|
||||
? "Ganzer Fehltag"
|
||||
: row.TotalAbsentPeriods > 0
|
||||
? $"Fehlzeit · {row.TotalAbsentPeriods} Std."
|
||||
: $"Fehlzeit · {row.TotalAbsentMinutes} Min.";
|
||||
var status = row.IsUnexcused
|
||||
? "Unentschuldigt"
|
||||
: row.FriendlyStatusLabel.Contains("Entschuldigt", StringComparison.OrdinalIgnoreCase)
|
||||
? "Entschuldigt"
|
||||
: row.FriendlyStatusLabel;
|
||||
var statusColor = row.IsUnexcused ? "#C62828" : "#2E7D32";
|
||||
commands.Add(new DrawRectangle(0, y, contentWidth, rowHeight, "#E5E7EB", .3f, fill));
|
||||
commands.Add(new DrawStringEx(dateColumnX, y + scale, rowHeight - scale, dateColumnWidth,
|
||||
row.Date.ToString("dd.MM.yyyy"), DrawingTextAlignment.AlignLeft, 7.5f * scale,
|
||||
Color: "#374151"));
|
||||
commands.Add(new DrawStringEx(extentColumnX, y + scale, rowHeight - scale, extentColumnWidth,
|
||||
extent, DrawingTextAlignment.AlignLeft, 7.5f * scale, Color: "#374151"));
|
||||
commands.Add(new DrawStringEx(statusColumnX, y + scale, rowHeight - scale, statusColumnWidth,
|
||||
status, DrawingTextAlignment.AlignLeft, 7.5f * scale, Color: statusColor,
|
||||
Bold: row.IsUnexcused));
|
||||
y += rowHeight;
|
||||
}
|
||||
}
|
||||
return new DrawingValue(commands, y);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Liest ausschließlich den lokalen WebUntis-Cache. Das Öffnen oder Rendern eines
|
||||
/// Elternbriefs löst dadurch keinen unerwarteten Netzwerkabruf aus.</summary>
|
||||
public sealed class StudentAttendanceCalendarService(
|
||||
WebUntisSettingsService settings,
|
||||
IUntisAbsenceCacheRepository absenceCache,
|
||||
IUntisClassRegisterCacheRepository registerCache,
|
||||
IUntisStudentRosterCacheRepository rosterCache)
|
||||
{
|
||||
public DrawingValue Build(Student student, DateOnly month) =>
|
||||
Build(student, new AttendanceCalendarOptions(month, 1));
|
||||
|
||||
public DrawingValue Build(Student student, AttendanceCalendarOptions options)
|
||||
{
|
||||
var className = settings.HomeroomClassName;
|
||||
if (string.IsNullOrWhiteSpace(className)) return new DrawingValue([], 0);
|
||||
|
||||
var first = options.NormalizedStartMonth;
|
||||
var last = first.AddMonths(options.NormalizedMonthCount).AddDays(-1);
|
||||
var matchName = $"{student.FirstName} {student.LastName}";
|
||||
var rosterName = rosterCache.GetByClass(className)
|
||||
.FirstOrDefault(r => UntisNameMatching.NamesMatch(r.DisplayName, matchName))?.DisplayName
|
||||
?? matchName;
|
||||
var start = first.Year * 10000 + first.Month * 100 + first.Day;
|
||||
var end = last.Year * 10000 + last.Month * 100 + last.Day;
|
||||
var absences = absenceCache.GetByClassAndRange(className, start, end)
|
||||
.Select(e => new UntisClassAbsenceEntryDto(e.StudentName, e.ExternKey, e.ClassName, e.Date,
|
||||
e.AbsentPeriods, e.AbsentMinutes, e.TeacherUsernames, e.Subject, e.AbsenceReason, e.Note,
|
||||
e.EntryId, e.HandledOn, e.Counts, e.ExcuseNote, e.PeriodNumber, e.Status, e.CountsAsFullDay))
|
||||
.ToList();
|
||||
var register = registerCache.GetByClassAndRange(className, start, end)
|
||||
.Select(e => new UntisForeignClassRegisterEventDto(e.ClassName, e.Date, e.Subject, e.StudentName,
|
||||
e.TeacherUsername, e.CategoryName, e.CategoryGroup, e.Text))
|
||||
.ToList();
|
||||
return StudentAttendanceCalendarDrawingBuilder.Build(rosterName, options,
|
||||
ClassAbsenceDaySummaryRow.GroupByStudentAndDay(absences), register);
|
||||
}
|
||||
|
||||
public DrawingValue BuildAbsenceDayList(Student student, AttendanceCalendarOptions options)
|
||||
{
|
||||
var className = settings.HomeroomClassName;
|
||||
if (string.IsNullOrWhiteSpace(className)) return new DrawingValue([], 0);
|
||||
|
||||
var first = options.NormalizedStartMonth;
|
||||
var last = first.AddMonths(options.NormalizedMonthCount).AddDays(-1);
|
||||
var matchName = $"{student.FirstName} {student.LastName}";
|
||||
var rosterName = rosterCache.GetByClass(className)
|
||||
.FirstOrDefault(r => UntisNameMatching.NamesMatch(r.DisplayName, matchName))?.DisplayName
|
||||
?? matchName;
|
||||
var start = first.Year * 10000 + first.Month * 100 + first.Day;
|
||||
var end = last.Year * 10000 + last.Month * 100 + last.Day;
|
||||
var absences = absenceCache.GetByClassAndRange(className, start, end)
|
||||
.Select(e => new UntisClassAbsenceEntryDto(e.StudentName, e.ExternKey, e.ClassName, e.Date,
|
||||
e.AbsentPeriods, e.AbsentMinutes, e.TeacherUsernames, e.Subject, e.AbsenceReason, e.Note,
|
||||
e.EntryId, e.HandledOn, e.Counts, e.ExcuseNote, e.PeriodNumber, e.Status, e.CountsAsFullDay));
|
||||
return StudentAbsenceDayListDrawingBuilder.Build(rosterName, options,
|
||||
ClassAbsenceDaySummaryRow.GroupByStudentAndDay(absences));
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
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, DrawingValue>? _attendanceCalendarFactory;
|
||||
private readonly Func<AttendanceCalendarOptions, DrawingValue>? _absenceDayListFactory;
|
||||
private readonly Func<AttendanceCalendarOptions, CancellationToken, Task>? _attendanceDataRefresher;
|
||||
private AttendanceCalendarOptions _attendanceCalendarOptions = new(
|
||||
new DateOnly(DateTime.Today.Year, DateTime.Today.Month, 1), 1);
|
||||
|
||||
[ObservableProperty] private LetterTemplateChoice? _selectedTemplate;
|
||||
[ObservableProperty] private 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, DrawingValue>? attendanceCalendarFactory = null,
|
||||
Func<AttendanceCalendarOptions, 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,111 @@ public partial class CreateLetterDialogViewModel : ObservableObject
|
||||
OnPropertyChanged(nameof(HasIssues));
|
||||
}
|
||||
|
||||
private IReadOnlyDictionary<string, PlaceholderValue> BuildValues() => LetterPlaceholderBuilder.BuildStandardValues(
|
||||
private IReadOnlyDictionary<string, PlaceholderValue> BuildValues()
|
||||
{
|
||||
var values = LetterPlaceholderBuilder.BuildStandardValues(
|
||||
_student, SelectedContact?.Model, SelectedGroup?.Model,
|
||||
DateOnly.FromDateTime((LetterDate ?? DateTimeOffset.Now).LocalDateTime), LetterText, TeacherName);
|
||||
DateOnly.FromDateTime((LetterDate ?? DateTimeOffset.Now).LocalDateTime), LetterText, TeacherName,
|
||||
UsesAttendanceCalendar ? _attendanceCalendarFactory?.Invoke(_attendanceCalendarOptions) : null,
|
||||
UsesAbsenceDayList ? _absenceDayListFactory?.Invoke(_attendanceCalendarOptions) : null);
|
||||
values["Anrede"] = new TextValue(Anrede); values["Letter.Salutation"] = new TextValue(Anrede);
|
||||
foreach (var custom in CustomPlaceholders) values[custom.Name] = custom.ToPlaceholderValue();
|
||||
return values;
|
||||
}
|
||||
|
||||
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 +253,33 @@ public sealed class LetterContactChoice(Contact model) { public Contact Model {
|
||||
public sealed class LetterGroupChoice(LearningGroup model) { public LearningGroup Model { get; } = model; public string Display => $"{Model.Name} · {Model.SchoolYear}"; }
|
||||
public sealed class 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 -> Mein Unterricht -> 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->S, Plenum, LDE"
|
||||
ToolTip.Tip="Freitext für den schnellen Überblick — mal ein Materialfluss-Pfeil (AB001->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"/>
|
||||
<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"
|
||||
<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>
|
||||
<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>
|
||||
<Button Content="Jetzt vorziehen" Command="{Binding BringForwardCommand}" IsVisible="{Binding CanStartNow}" FontSize="11"/>
|
||||
</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);
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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.",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
@@ -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()
|
||||
{
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 & 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);
|
||||
}
|
||||
}
|
||||
@@ -96,6 +96,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()
|
||||
{
|
||||
|
||||
@@ -531,21 +531,63 @@ 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, DrawImage i => i.Y, DrawLine ln => Math.Min(ln.Y1, ln.Y2), _ => 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 }, DrawImage i => i with { Y = i.Y - dy },
|
||||
DrawLine ln => ln with { Y1 = ln.Y1 - dy, Y2 = ln.Y2 - dy },
|
||||
_ => command,
|
||||
};
|
||||
|
||||
internal static List<IReadOnlyList<DrawingCommand>> RecordPages(PagedDrawingValue value,
|
||||
float width, float height, int maxPages)
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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>"
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user