Compare commits
29
Commits
e6c30b0bc7
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c128fc6741 | ||
|
|
4542f263fc | ||
|
|
86a97d91a7 | ||
|
|
763d7744be | ||
|
|
77fe9b1c79 | ||
|
|
92e497b54f | ||
|
|
861f2c76ca | ||
|
|
ef3e9dbbb6 | ||
|
|
03a5e0dd9c | ||
|
|
6dccf96039 | ||
|
|
6e57bf407d | ||
|
|
0b5cfc5522 | ||
|
|
09cbbf1b77 | ||
|
|
1671796844 | ||
|
|
70dc78904c | ||
|
|
7b660c7152 | ||
|
|
3675b70004 | ||
|
|
eb1b2340b7 | ||
|
|
b8193be6ec | ||
|
|
0962845ead | ||
|
|
42c650518c | ||
|
|
2c791258a1 | ||
|
|
c528bd825f | ||
|
|
56ab5067e6 | ||
|
|
bfe214ecf2 | ||
|
|
d979a84e7b | ||
|
|
3f8813df51 | ||
|
|
19aa302487 | ||
|
|
a4733c156b |
@@ -76,6 +76,9 @@ public class AiLesson
|
|||||||
public List<AiPhaseStep> Phases { get; set; } = [];
|
public List<AiPhaseStep> Phases { get; set; } = [];
|
||||||
public string? Homework { get; set; }
|
public string? Homework { get; set; }
|
||||||
public string? Reflection { 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
|
public class AiPhaseStep
|
||||||
@@ -178,3 +181,54 @@ public class AiSubstanceResearchResponse
|
|||||||
public string ActivityRestriction { get; set; } = "";
|
public string ActivityRestriction { get; set; } = "";
|
||||||
public string Source { get; set; } = "";
|
public string Source { get; set; } = "";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Wire-Vertrag für den Fehlzeiten-Statusvorschlag (ai-backend/untis-status.php, Nutzer-Feedback
|
||||||
|
/// zum Untis-Hub: der aus WebUntis abgeleitete Zielstatus war über den Anzeigetext oft nicht
|
||||||
|
/// nachvollziehbar). Bewusst OHNE jeden Personenbezug - <see cref="AiUntisStatusRow.Id"/> ist eine
|
||||||
|
/// rein technische, für die KI bedeutungslose Kennung, über die der Desktop-Client die Antwort
|
||||||
|
/// zurückordnet; kein Name, keine Klasse, kein Datum verlässt damit die App. Mehrere Zeilen eines
|
||||||
|
/// Abgleichslaufs werden in einer Anfrage gebündelt statt je Zeile einzeln (Kosten/Latenz).
|
||||||
|
/// </summary>
|
||||||
|
public class AiUntisStatusRequest
|
||||||
|
{
|
||||||
|
public List<AiUntisStatusRow> Rows { get; set; } = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
public class AiUntisStatusRow
|
||||||
|
{
|
||||||
|
public string Id { get; set; } = "";
|
||||||
|
public string ReasonText { get; set; } = "";
|
||||||
|
public int AbsentMinutes { get; set; }
|
||||||
|
/// True, wenn WebUntis für den Eintrag bereits ein Bearbeitungsdatum führt (unabhängig vom
|
||||||
|
/// tatsächlichen Datum, das aus Datenschutzgründen nicht mitgeschickt wird).
|
||||||
|
public bool HandledOn { get; set; }
|
||||||
|
/// Schulinterne Konvention: Klammerung der Entschuldigungsnummer bedeutet unentschuldigt, ohne
|
||||||
|
/// Klammern entschuldigt; null, wenn keine Nummer hinterlegt ist (siehe UntisDiffService-Analog
|
||||||
|
/// in WebUntisLessonAbsenceComparisonViewModel.MapStatus).
|
||||||
|
public bool? ExternKeyInParentheses { get; set; }
|
||||||
|
/// Bereits regelbasiert ermittelter Status (siehe MapStatus) - der Systemprompt bittet die KI,
|
||||||
|
/// nur bei eindeutigem Widerspruch im Freitext davon abzuweichen, statt bei Unsicherheit zu raten.
|
||||||
|
public string CurrentGuess { get; set; } = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Enthält absichtlich GENAU eine Zeile je gesendeter <see cref="AiUntisStatusRow.Id"/> - der
|
||||||
|
/// Client verwirft die gesamte Antwort, wenn die zurückgegebene Id-Menge nicht exakt der
|
||||||
|
/// gesendeten entspricht (siehe AiPlanningService.RequestUntisStatusSuggestionsAsync), statt sich
|
||||||
|
/// auf die Reihenfolge zu verlassen. So bleibt eine Verwechslung zwischen Vorschlag und Zeile
|
||||||
|
/// strukturell ausgeschlossen, nicht nur im Regelfall vermieden.
|
||||||
|
/// </summary>
|
||||||
|
public class AiUntisStatusResponse
|
||||||
|
{
|
||||||
|
public List<AiUntisStatusSuggestion> Suggestions { get; set; } = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
public class AiUntisStatusSuggestion
|
||||||
|
{
|
||||||
|
public string Id { get; set; } = "";
|
||||||
|
/// Einer von "Present"/"Late"/"LeftDuringClass"/"ExcusePending"/"Excused"/"Unexcused" (siehe
|
||||||
|
/// Systemprompt in ai-backend/untis-status.php) - wird client-seitig gegen genau diese Menge
|
||||||
|
/// geprüft, bevor er als AttendanceStatus interpretiert wird.
|
||||||
|
public string Status { get; set; } = "";
|
||||||
|
}
|
||||||
|
|||||||
@@ -285,6 +285,10 @@ public interface ISubjectRepository
|
|||||||
public interface ICompetencyDomainRepository
|
public interface ICompetencyDomainRepository
|
||||||
{
|
{
|
||||||
List<CompetencyDomain> GetBySubjectAndGrade(Guid subjectId, int gradeLevel);
|
List<CompetencyDomain> GetBySubjectAndGrade(Guid subjectId, int gradeLevel);
|
||||||
|
// Über alle Klassenstufen eines Fachs hinweg - für einen Überblick über den gesamten Katalog
|
||||||
|
// eines Fachs (z.B. MCP-Tool get_competency_catalog ohne gradeLevel-Filter), ohne dass der
|
||||||
|
// Aufrufer erst jede vorkommende Klassenstufe einzeln erraten/abfragen müsste.
|
||||||
|
List<CompetencyDomain> GetBySubject(Guid subjectId);
|
||||||
CompetencyDomain? GetById(Guid id);
|
CompetencyDomain? GetById(Guid id);
|
||||||
void Save(CompetencyDomain domain);
|
void Save(CompetencyDomain domain);
|
||||||
void Delete(Guid id);
|
void Delete(Guid id);
|
||||||
|
|||||||
@@ -9,4 +9,29 @@ namespace LehrerApp.Core.Mcp;
|
|||||||
public static class McpPipeConstants
|
public static class McpPipeConstants
|
||||||
{
|
{
|
||||||
public const string PipeName = "LehrerApp.Mcp";
|
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>
|
/// </summary>
|
||||||
public int? WebUntisLessonId { get; set; }
|
public int? WebUntisLessonId { get; set; }
|
||||||
/// <summary>
|
/// <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"/>) —
|
/// Vorgängergruppe aus dem Hochstufen (<see cref="Services.GroupRolloverService"/>) —
|
||||||
/// ermöglicht einen Schuljahresvergleich (z.B. Klausurschnitt) über die Kette hinweg.
|
/// ermöglicht einen Schuljahresvergleich (z.B. Klausurschnitt) über die Kette hinweg.
|
||||||
/// Wird ausschließlich beim Hochstufen automatisch gesetzt; vor Einführung dieses Felds
|
/// 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
|
/// Optionaler Stundenbeginn — solange kein Stundenplan (Kapitel 4.3) existiert, manuell
|
||||||
/// gepflegt. Dient nur der abgeleiteten Uhrzeit-Anzeige je Phase in <see cref="Phases"/>.
|
/// gepflegt. Dient nur der abgeleiteten Uhrzeit-Anzeige je Phase in <see cref="Phases"/>.
|
||||||
public TimeOnly? StartTime { get; set; }
|
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 List<LessonPhaseStep> Phases { get; set; } = [];
|
||||||
|
public TeachingTimelineState? TeachingTimeline { get; set; }
|
||||||
public string? Homework { get; set; }
|
public string? Homework { get; set; }
|
||||||
/// Markiert, dass die hier eingetragene Hausaufgabe in einer Folgestunde besprochen/kontrolliert
|
/// Markiert, dass die hier eingetragene Hausaufgabe in einer Folgestunde besprochen/kontrolliert
|
||||||
/// wurde — treibt das Stundenplan-Badge "Hausaufgabe kontrollieren" (4.5.4).
|
/// wurde — treibt das Stundenplan-Badge "Hausaufgabe kontrollieren" (4.5.4).
|
||||||
@@ -63,6 +71,11 @@ public class Lesson : IHasAttachments
|
|||||||
/// wenn die Hausaufgabe absichtlich nicht mehr kontrolliert wird).
|
/// wenn die Hausaufgabe absichtlich nicht mehr kontrolliert wird).
|
||||||
public bool HomeworkCheckDismissed { get; set; }
|
public bool HomeworkCheckDismissed { get; set; }
|
||||||
public string? Reflection { get; set; }
|
public string? Reflection { get; set; }
|
||||||
|
/// Kompetenzcodes (<see cref="CompetencyItem.Code"/>) aus dem Katalog, die dieser Stunde als
|
||||||
|
/// Ganzes zugeordnet sind — analog zu <see cref="Unit.Competencies"/>, aber auf Stundenebene statt
|
||||||
|
/// Einheitenebene. Bewusst noch keine Verknüpfung je einzelner <see cref="LessonPhaseStep"/>
|
||||||
|
/// (siehe TODO.md 4.5.8, weiterhin offen); diese Liste deckt nur die gröbere Zuordnung ab.
|
||||||
|
public List<string> Competencies { get; set; } = [];
|
||||||
public LessonStatus Status { get; set; } = LessonStatus.Planned;
|
public LessonStatus Status { get; set; } = LessonStatus.Planned;
|
||||||
/// Material/Arbeitsblätter sowie fachspezifische Anhänge (z.B. Experiment- und
|
/// Material/Arbeitsblätter sowie fachspezifische Anhänge (z.B. Experiment- und
|
||||||
/// Gefährdungsbeurteilungs-Dokumente im Chemieunterricht) — dieselbe Anhang-Infrastruktur wie
|
/// Gefährdungsbeurteilungs-Dokumente im Chemieunterricht) — dieselbe Anhang-Infrastruktur wie
|
||||||
@@ -89,6 +102,14 @@ public class LessonPhaseStep
|
|||||||
public string Activity { get; set; } = "";
|
public string Activity { get; set; } = "";
|
||||||
public string Material { get; set; } = "";
|
public string Material { get; set; } = "";
|
||||||
public string Shorthand { 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>
|
/// <summary>
|
||||||
/// null = Hauptweg. Sonst Verweis auf einen benannten <see cref="AlternativeLessonPath"/> aus
|
/// null = Hauptweg. Sonst Verweis auf einen benannten <see cref="AlternativeLessonPath"/> aus
|
||||||
/// dem Katalog, über den Phasen alternativer Unterrichtsverläufe zusammengehören —
|
/// dem Katalog, über den Phasen alternativer Unterrichtsverläufe zusammengehören —
|
||||||
@@ -103,7 +124,7 @@ public enum UnitStatus { Planned, Active, Completed }
|
|||||||
// Planned=0 und Conducted=1 bleiben absichtlich an ihren bisherigen numerischen Positionen:
|
// Planned=0 und Conducted=1 bleiben absichtlich an ihren bisherigen numerischen Positionen:
|
||||||
// LiteDB hat diese Werte bereits gespeichert. Die neuen Zustände werden nur angehängt, damit
|
// LiteDB hat diese Werte bereits gespeichert. Die neuen Zustände werden nur angehängt, damit
|
||||||
// vorhandene Daten ohne Migration weiterhin korrekt gelesen werden.
|
// vorhandene Daten ohne Migration weiterhin korrekt gelesen werden.
|
||||||
public enum LessonStatus { Planned = 0, Conducted = 1, Draft = 2, Ready = 3 }
|
public enum LessonStatus { Planned = 0, Conducted = 1, Draft = 2, Ready = 3, Cancelled = 4 }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Katalogeintrag für einen wiederverwendbaren "alternativen Ablauf" (z.B. "Kurzversion" bei
|
/// Katalogeintrag für einen wiederverwendbaren "alternativen Ablauf" (z.B. "Kurzversion" bei
|
||||||
@@ -239,3 +260,21 @@ public class ReportGrade
|
|||||||
public bool IsLocked { get; set; }
|
public bool IsLocked { get; set; }
|
||||||
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
|
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; }
|
||||||
|
}
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ public sealed class LessonSchedulingService(ILessonRepository lessons)
|
|||||||
{
|
{
|
||||||
foreach (var other in lessons.GetByUnit(lesson.UnitId))
|
foreach (var other in lessons.GetByUnit(lesson.UnitId))
|
||||||
{
|
{
|
||||||
if (other.Id == lesson.Id || other.Status == LessonStatus.Conducted || other.Date <= oldDate)
|
if (other.Id == lesson.Id || other.Status is LessonStatus.Conducted or LessonStatus.Cancelled || other.Date <= oldDate)
|
||||||
continue;
|
continue;
|
||||||
other.Date = other.Date.AddDays(delta);
|
other.Date = other.Date.AddDays(delta);
|
||||||
lessons.Save(other);
|
lessons.Save(other);
|
||||||
@@ -67,7 +67,7 @@ public sealed class LessonSchedulingService(ILessonRepository lessons)
|
|||||||
HomeworkChecked = source.HomeworkChecked,
|
HomeworkChecked = source.HomeworkChecked,
|
||||||
HomeworkCheckDismissed = source.HomeworkCheckDismissed,
|
HomeworkCheckDismissed = source.HomeworkCheckDismissed,
|
||||||
Reflection = source.Reflection,
|
Reflection = source.Reflection,
|
||||||
Status = source.Status == LessonStatus.Conducted ? LessonStatus.Draft : source.Status,
|
Status = source.Status is LessonStatus.Conducted or LessonStatus.Cancelled ? LessonStatus.Draft : source.Status,
|
||||||
};
|
};
|
||||||
source.Homework = null;
|
source.Homework = null;
|
||||||
source.HomeworkChecked = false;
|
source.HomeworkChecked = false;
|
||||||
|
|||||||
@@ -57,4 +57,24 @@ public sealed class ChangeHookTests
|
|||||||
|
|
||||||
Assert.Null(exception);
|
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 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
|
public class CompetencyDomainRepository(LiteDbContext db) : ICompetencyDomainRepository
|
||||||
@@ -917,6 +921,11 @@ public class CompetencyDomainRepository(LiteDbContext db) : ICompetencyDomainRep
|
|||||||
.Find(d => d.SubjectId == subjectId && d.GradeLevel == gradeLevel)
|
.Find(d => d.SubjectId == subjectId && d.GradeLevel == gradeLevel)
|
||||||
.OrderBy(d => d.SortOrder)
|
.OrderBy(d => d.SortOrder)
|
||||||
.ToList();
|
.ToList();
|
||||||
|
public List<CompetencyDomain> GetBySubject(Guid subjectId) =>
|
||||||
|
db.CompetencyDomains
|
||||||
|
.Find(d => d.SubjectId == subjectId)
|
||||||
|
.OrderBy(d => d.GradeLevel).ThenBy(d => d.SortOrder)
|
||||||
|
.ToList();
|
||||||
public CompetencyDomain? GetById(Guid id) => db.CompetencyDomains.FindById(id);
|
public CompetencyDomain? GetById(Guid id) => db.CompetencyDomains.FindById(id);
|
||||||
public void Save(CompetencyDomain d)
|
public void Save(CompetencyDomain d)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -116,6 +116,72 @@ public sealed class AiPlanningServiceTests
|
|||||||
Assert.Equal("Gerettete Stunde", Assert.Single(lessons.GetByUnit(unit.Id)).Topic);
|
Assert.Equal("Gerettete Stunde", Assert.Single(lessons.GetByUnit(unit.Id)).Topic);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task RequestUntisStatusSuggestionsAsync_PassendeIds_LiefertZuordnung()
|
||||||
|
{
|
||||||
|
const string body = """
|
||||||
|
{"suggestions":[{"id":"0","status":"Excused"},{"id":"1","status":"Unexcused"}]}
|
||||||
|
""";
|
||||||
|
var service = BuildWithResponse(HttpStatusCode.OK, body);
|
||||||
|
var rows = new[]
|
||||||
|
{
|
||||||
|
new AiUntisStatusRow { Id = "0", CurrentGuess = "ExcusePending" },
|
||||||
|
new AiUntisStatusRow { Id = "1", CurrentGuess = "ExcusePending" },
|
||||||
|
};
|
||||||
|
|
||||||
|
var suggestions = await service.RequestUntisStatusSuggestionsAsync(rows, "token");
|
||||||
|
|
||||||
|
Assert.Equal("Excused", suggestions["0"]);
|
||||||
|
Assert.Equal("Unexcused", suggestions["1"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Kernabsicherung gegen Verwechslung (Nutzer-Feedback zum Untis-Hub, siehe
|
||||||
|
// AiPlanningService.RequestUntisStatusSuggestionsAsync): weicht die zurückgegebene Id-Menge
|
||||||
|
// auch nur minimal von der gesendeten ab (hier: eine erfundene Id "2" statt "1"), wird die
|
||||||
|
// gesamte Antwort verworfen statt sich auf eine möglicherweise vermischte Zuordnung zu verlassen.
|
||||||
|
[Fact]
|
||||||
|
public async Task RequestUntisStatusSuggestionsAsync_AbweichendeIdMenge_VerwirftKomplett()
|
||||||
|
{
|
||||||
|
const string body = """
|
||||||
|
{"suggestions":[{"id":"0","status":"Excused"},{"id":"2","status":"Unexcused"}]}
|
||||||
|
""";
|
||||||
|
var service = BuildWithResponse(HttpStatusCode.OK, body);
|
||||||
|
var rows = new[]
|
||||||
|
{
|
||||||
|
new AiUntisStatusRow { Id = "0", CurrentGuess = "ExcusePending" },
|
||||||
|
new AiUntisStatusRow { Id = "1", CurrentGuess = "ExcusePending" },
|
||||||
|
};
|
||||||
|
|
||||||
|
var suggestions = await service.RequestUntisStatusSuggestionsAsync(rows, "token");
|
||||||
|
|
||||||
|
Assert.Empty(suggestions);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task RequestUntisStatusSuggestionsAsync_DoppelteId_VerwirftKomplett()
|
||||||
|
{
|
||||||
|
const string body = """
|
||||||
|
{"suggestions":[{"id":"0","status":"Excused"},{"id":"0","status":"Unexcused"}]}
|
||||||
|
""";
|
||||||
|
var service = BuildWithResponse(HttpStatusCode.OK, body);
|
||||||
|
var rows = new[] { new AiUntisStatusRow { Id = "0", CurrentGuess = "ExcusePending" } };
|
||||||
|
|
||||||
|
var suggestions = await service.RequestUntisStatusSuggestionsAsync(rows, "token");
|
||||||
|
|
||||||
|
Assert.Empty(suggestions);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task RequestUntisStatusSuggestionsAsync_KeineZeilen_KeinNetzwerkaufruf()
|
||||||
|
{
|
||||||
|
var service = Build(new FakeLessons(), new FakeGroups([]), new FakeSubjects([]),
|
||||||
|
new FakeCompetencyDomains(), new FakeAlternativeLessonPaths([]));
|
||||||
|
|
||||||
|
var suggestions = await service.RequestUntisStatusSuggestionsAsync([], "token");
|
||||||
|
|
||||||
|
Assert.Empty(suggestions);
|
||||||
|
}
|
||||||
|
|
||||||
private static AiPlanningService BuildWithResponse(HttpStatusCode status, string body)
|
private static AiPlanningService BuildWithResponse(HttpStatusCode status, string body)
|
||||||
{
|
{
|
||||||
var http = new HttpClient(new StaticResponseHandler(status, body))
|
var http = new HttpClient(new StaticResponseHandler(status, body))
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ using LehrerApp.Core.Models;
|
|||||||
using LehrerApp.Core.Services;
|
using LehrerApp.Core.Services;
|
||||||
using LehrerApp.Desktop.Services;
|
using LehrerApp.Desktop.Services;
|
||||||
using LehrerApp.Desktop.ViewModels.ClassTeacher;
|
using LehrerApp.Desktop.ViewModels.ClassTeacher;
|
||||||
|
using LehrerApp.Templating;
|
||||||
using Xunit;
|
using Xunit;
|
||||||
|
|
||||||
namespace LehrerApp.Desktop.Tests;
|
namespace LehrerApp.Desktop.Tests;
|
||||||
@@ -71,6 +72,133 @@ public sealed class ClassTeacherViewModelsTests
|
|||||||
Assert.False(days[4].HasSignal); // Hausaufgaben bleiben im Widget bewusst ausgeblendet
|
Assert.False(days[4].HasSignal); // Hausaufgaben bleiben im Widget bewusst ausgeblendet
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Kompaktkalender_KannAufEinzelnenSchuelerEingeschraenktWerden()
|
||||||
|
{
|
||||||
|
var month = new DateOnly(2026, 9, 1);
|
||||||
|
var absences = new[]
|
||||||
|
{
|
||||||
|
new ClassAbsenceDaySummaryRow(new DateOnly(2026, 9, 3), "Müller Ada", 1, 2, 90,
|
||||||
|
["Deu"], [1, 2], ["nicht entsch."], ["Absent"], null, null, false),
|
||||||
|
new ClassAbsenceDaySummaryRow(new DateOnly(2026, 9, 4), "Schmidt Ben", 2, 2, 90,
|
||||||
|
["Mathe"], [1, 2], ["entsch."], ["Absent"], null, null, false),
|
||||||
|
};
|
||||||
|
|
||||||
|
var days = ClassTeacherOverviewViewModel.BuildCompactMonthDays(month, absences, [], month,
|
||||||
|
"Ada Müller");
|
||||||
|
|
||||||
|
Assert.Equal("U", days[2].SignalCode);
|
||||||
|
Assert.False(days[3].HasSignal);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ElternbriefKalender_LiefertPortablenDrawingPlatzhalterFuerSchueler()
|
||||||
|
{
|
||||||
|
var month = new DateOnly(2026, 9, 1);
|
||||||
|
var absences = new[]
|
||||||
|
{
|
||||||
|
new ClassAbsenceDaySummaryRow(new DateOnly(2026, 9, 3), "Müller Ada", 1, 2, 90,
|
||||||
|
["Deu"], [1, 2], ["nicht entsch."], ["Absent"], null, null, false),
|
||||||
|
new ClassAbsenceDaySummaryRow(new DateOnly(2026, 9, 4), "Schmidt Ben", 2, 2, 90,
|
||||||
|
["Mathe"], [1, 2], ["entsch."], ["Absent"], null, null, false),
|
||||||
|
};
|
||||||
|
|
||||||
|
var drawing = StudentAttendanceCalendarDrawingBuilder.Build("Ada Müller", month, absences, []);
|
||||||
|
|
||||||
|
Assert.True(drawing.ContentHeight > 0);
|
||||||
|
Assert.Contains(drawing.Commands.OfType<DrawStringEx>(), c => c.Text == "Ada Müller");
|
||||||
|
Assert.Contains(drawing.Commands.OfType<DrawStringEx>(), c => c.Text == "U");
|
||||||
|
Assert.DoesNotContain(drawing.Commands.OfType<DrawStringEx>(), c => c.Text == "E");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ElternbriefKalender_UnterstuetztEinBisDreiMonateUndGroessen()
|
||||||
|
{
|
||||||
|
var start = new DateOnly(2026, 9, 18);
|
||||||
|
var small = StudentAttendanceCalendarDrawingBuilder.Build("Ada Müller",
|
||||||
|
new AttendanceCalendarOptions(start, 3, AttendanceCalendarSize.Small), [], []);
|
||||||
|
var large = StudentAttendanceCalendarDrawingBuilder.Build("Ada Müller",
|
||||||
|
new AttendanceCalendarOptions(start, 3, AttendanceCalendarSize.Large), [], []);
|
||||||
|
var labels = large.Commands.OfType<DrawStringEx>().Select(c => c.Text).ToList();
|
||||||
|
|
||||||
|
Assert.Contains("September 2026", labels);
|
||||||
|
Assert.Contains("Oktober 2026", labels);
|
||||||
|
Assert.Contains("November 2026", labels);
|
||||||
|
Assert.True(large.ContentHeight > small.ContentHeight);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Regression: Breite und Höhe dürfen nur mit EINEM gemeinsamen Faktor skaliert werden (a =
|
||||||
|
// min(a_x, a_y)), nicht unabhängig voneinander - sonst verzerren die Tageskacheln, wenn das
|
||||||
|
// Seitenverhältnis der realen Box vom Referenzentwurf abweicht (z.B. eine sehr breite, aber
|
||||||
|
// niedrige DRAWBOX). Das Breiten-/Höhenverhältnis einer Kachel muss unabhängig von contentWidth
|
||||||
|
// gleich bleiben, solange die Höhe der begrenzende Faktor ist.
|
||||||
|
[Fact]
|
||||||
|
public void ElternbriefKalender_SkaliertKachelnGleichmaessigOhneVerzerrung()
|
||||||
|
{
|
||||||
|
var options = new AttendanceCalendarOptions(new DateOnly(2026, 9, 18), 3, AttendanceCalendarSize.Large);
|
||||||
|
var schmaleBox = StudentAttendanceCalendarDrawingBuilder.Build("Ada Müller", options, [], [],
|
||||||
|
contentWidth: 170, contentHeight: 60);
|
||||||
|
var sehrBreiteBox = StudentAttendanceCalendarDrawingBuilder.Build("Ada Müller", options, [], [],
|
||||||
|
contentWidth: 1000, contentHeight: 60);
|
||||||
|
|
||||||
|
var schmaleKachel = schmaleBox.Commands.OfType<DrawRoundedRectangle>().First();
|
||||||
|
var breiteKachel = sehrBreiteBox.Commands.OfType<DrawRoundedRectangle>().First();
|
||||||
|
var schmalesVerhaeltnis = schmaleKachel.Width / schmaleKachel.Height;
|
||||||
|
var breitesVerhaeltnis = breiteKachel.Width / breiteKachel.Height;
|
||||||
|
|
||||||
|
Assert.Equal(schmalesVerhaeltnis, breitesVerhaeltnis, 0.001f);
|
||||||
|
// Bei gleicher (begrenzender) Höhe muss die Kachelgröße selbst identisch sein - die
|
||||||
|
// zusätzliche Breite der zweiten Box bleibt ungenutzt (kein Verzerren, kein Zwang zum
|
||||||
|
// vollständigen Ausfüllen), statt die Kacheln künstlich in die Breite zu ziehen.
|
||||||
|
Assert.Equal(schmaleKachel.Width, breiteKachel.Width, 0.01f);
|
||||||
|
Assert.Equal(schmaleKachel.Height, breiteKachel.Height, 0.01f);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Regression: DRAWBOX bricht anders als FLOWDRAWBOX nicht automatisch um - Inhalt, der die von
|
||||||
|
// der Vorlage deklarierte Höhe überschreitet, wird von QuestTemplateRenderer stillschweigend am
|
||||||
|
// unteren Rand abgeschnitten (kein Fehler, keine Warnung). "Größe" muss deshalb automatisch so
|
||||||
|
// weit verkleinert werden, dass alle gewählten Monate innerhalb von contentHeight Platz finden.
|
||||||
|
[Fact]
|
||||||
|
public void ElternbriefKalender_VerkleinertGroesseAutomatischUmDieVorlagenHoeheEinzuhalten()
|
||||||
|
{
|
||||||
|
var start = new DateOnly(2026, 9, 18);
|
||||||
|
var uneingeschraenkt = StudentAttendanceCalendarDrawingBuilder.Build("Ada Müller",
|
||||||
|
new AttendanceCalendarOptions(start, 3, AttendanceCalendarSize.Large), [], []);
|
||||||
|
var engeBox = StudentAttendanceCalendarDrawingBuilder.Build("Ada Müller",
|
||||||
|
new AttendanceCalendarOptions(start, 3, AttendanceCalendarSize.Large), [], [],
|
||||||
|
contentHeight: 60);
|
||||||
|
|
||||||
|
Assert.True(uneingeschraenkt.ContentHeight > 60,
|
||||||
|
"Testvoraussetzung: 3 Monate in 'Groß' brauchen ohne Deckelung mehr als 60 Einheiten Höhe.");
|
||||||
|
Assert.True(engeBox.ContentHeight <= 60 + 0.01f);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ElternbriefFehltage_ListetDatumUmfangUndEntschuldigungsstatus()
|
||||||
|
{
|
||||||
|
var options = new AttendanceCalendarOptions(new DateOnly(2026, 9, 1), 1);
|
||||||
|
var absences = new[]
|
||||||
|
{
|
||||||
|
new ClassAbsenceDaySummaryRow(new DateOnly(2026, 9, 3), "Müller Ada", 1, 6, 270,
|
||||||
|
["Deu"], [1, 2, 3, 4, 5, 6], ["entsch."], ["Krank"], null, null, true),
|
||||||
|
new ClassAbsenceDaySummaryRow(new DateOnly(2026, 9, 8), "Müller Ada", 1, 2, 90,
|
||||||
|
["Mathe"], [1, 2], ["nicht entsch."], ["Absent"], null, null, false),
|
||||||
|
new ClassAbsenceDaySummaryRow(new DateOnly(2026, 9, 9), "Schmidt Ben", 2, 4, 180,
|
||||||
|
["Eng"], [1, 2, 3, 4], ["entsch."], ["Krank"], null, null, true),
|
||||||
|
};
|
||||||
|
|
||||||
|
var drawing = StudentAbsenceDayListDrawingBuilder.Build("Ada Müller", options, absences);
|
||||||
|
var labels = drawing.Commands.OfType<DrawStringEx>().Select(c => c.Text).ToList();
|
||||||
|
|
||||||
|
Assert.Contains("03.09.2026", labels);
|
||||||
|
Assert.Contains("Ganzer Fehltag", labels);
|
||||||
|
Assert.Contains("08.09.2026", labels);
|
||||||
|
Assert.Contains("Fehlzeit · 2 Std.", labels);
|
||||||
|
Assert.Contains("Entschuldigt", labels);
|
||||||
|
Assert.Contains("Unentschuldigt", labels);
|
||||||
|
Assert.DoesNotContain("09.09.2026", labels);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void GroupByStudentAndDay_FasstFehlstundenProSchuelerUndTagZusammen()
|
public void GroupByStudentAndDay_FasstFehlstundenProSchuelerUndTagZusammen()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using LehrerApp.Core.Models;
|
using LehrerApp.Core.Models;
|
||||||
|
using LehrerApp.Desktop.Services;
|
||||||
using LehrerApp.Desktop.ViewModels.Students;
|
using LehrerApp.Desktop.ViewModels.Students;
|
||||||
using LehrerApp.Templating;
|
using LehrerApp.Templating;
|
||||||
using Xunit;
|
using Xunit;
|
||||||
@@ -54,6 +55,246 @@ public sealed class CreateLetterDialogViewModelTests : IDisposable
|
|||||||
Assert.Equal("%PDF", System.Text.Encoding.ASCII.GetString(File.ReadAllBytes(output), 0, 4));
|
Assert.Equal("%PDF", System.Text.Encoding.ASCII.GetString(File.ReadAllBytes(output), 0, 4));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task AdvancedContent_Anwesenheitskalender_WirdAlsDrawingGerendert()
|
||||||
|
{
|
||||||
|
var store = StoreWithTemplate(new PlaceholderDefinition(
|
||||||
|
StudentAttendanceCalendarDrawingBuilder.PlaceholderName, PlaceholderType.Drawing, true));
|
||||||
|
var drawing = StudentAttendanceCalendarDrawingBuilder.Build("Lena Beispiel", new DateOnly(2026, 9, 1), [], []);
|
||||||
|
var vm = new CreateLetterDialogViewModel(StudentWithContact("Sehr geehrte Frau Muster,"), store,
|
||||||
|
new QuestTemplateRenderer(), new FakeMemberships([]), new FakeGroups([]), (_, _, _, _) => drawing);
|
||||||
|
var output = Path.Combine(_directory, "Kalender.pdf");
|
||||||
|
|
||||||
|
Assert.True(vm.UsesAttendanceCalendar);
|
||||||
|
await vm.SetAttendanceCalendarOptionsAsync(new AttendanceCalendarOptions(
|
||||||
|
new DateOnly(2026, 8, 19), 3, AttendanceCalendarSize.Large));
|
||||||
|
Assert.True(vm.AttendanceCalendarConfigured);
|
||||||
|
Assert.Contains("August 2026", vm.AttendanceCalendarSummary);
|
||||||
|
Assert.Contains("3 Monate", vm.AttendanceCalendarSummary);
|
||||||
|
Assert.Contains("Groß", vm.AttendanceCalendarSummary);
|
||||||
|
Assert.True(vm.Generate(output));
|
||||||
|
Assert.Equal("%PDF", System.Text.Encoding.ASCII.GetString(File.ReadAllBytes(output), 0, 4));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Regression: Der DrawingValue-Builder hat früher immer eine feste Breite (170) angenommen,
|
||||||
|
// unabhängig davon, wie breit die DRAWBOX in der jeweiligen Vorlage tatsächlich deklariert war -
|
||||||
|
// bei breiteren (oder in "pt" statt "mm" deklarierten) Boxen blieb dadurch ein Großteil der
|
||||||
|
// eigentlich verfügbaren Fläche ungenutzt leer (sichtbar in echten Elternbriefen als "Luft"
|
||||||
|
// rechts/unterhalb des Kalenders). Der Dialog muss die real deklarierte Breite ermitteln und an
|
||||||
|
// den Builder weiterreichen.
|
||||||
|
[Fact]
|
||||||
|
public void AdvancedContent_Anwesenheitskalender_NutztDieImLayoutDeklarierteBoxbreite()
|
||||||
|
{
|
||||||
|
var manifest = new TemplateManifest
|
||||||
|
{
|
||||||
|
Id = $"brief-{Guid.NewGuid():N}", Name = "Elternbrief",
|
||||||
|
Placeholders = [new(StudentAttendanceCalendarDrawingBuilder.PlaceholderName, PlaceholderType.Drawing, true)],
|
||||||
|
};
|
||||||
|
var source = Path.Combine(_directory, $"{Guid.NewGuid():N}.lavorlage");
|
||||||
|
TemplatePackage.Create(source, manifest,
|
||||||
|
$"PAGE 210 297 mm\nDRAWBOX 20 20 250 80 ${StudentAttendanceCalendarDrawingBuilder.PlaceholderName}",
|
||||||
|
new Dictionary<string, byte[]>());
|
||||||
|
var store = new TemplateStore(Path.Combine(_directory, $"store-{Guid.NewGuid():N}"));
|
||||||
|
store.Import(source);
|
||||||
|
float? receivedWidth = null; float? receivedMillimeterScale = null;
|
||||||
|
|
||||||
|
var vm = new CreateLetterDialogViewModel(StudentWithContact("Sehr geehrte Frau Muster,"), store,
|
||||||
|
new QuestTemplateRenderer(), new FakeMemberships([]), new FakeGroups([]),
|
||||||
|
(_, width, millimeterScale, _) => { receivedWidth = width; receivedMillimeterScale = millimeterScale; return new DrawingValue([], 0); });
|
||||||
|
|
||||||
|
Assert.True(vm.UsesAttendanceCalendar);
|
||||||
|
Assert.Equal(250, receivedWidth);
|
||||||
|
Assert.Equal(1, receivedMillimeterScale);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Regression: Eine reale, aus einem Word-Dokument übernommene Vorlage deklariert die Seite in
|
||||||
|
// "pt" statt "mm" (PAGE ... pt). Ohne Umrechnung würde das mm-entworfene Kalenderraster dort nur
|
||||||
|
// rund ein Drittel der vorgesehenen physischen Größe erreichen (1 "mm-Einheit" würde als 1pt statt
|
||||||
|
// als ~2.83pt gerendert).
|
||||||
|
[Fact]
|
||||||
|
public void AdvancedContent_Anwesenheitskalender_RechnetBoxbreiteBeiPunktBasierterVorlageUm()
|
||||||
|
{
|
||||||
|
var manifest = new TemplateManifest
|
||||||
|
{
|
||||||
|
Id = $"brief-{Guid.NewGuid():N}", Name = "Elternbrief",
|
||||||
|
Placeholders = [new(StudentAttendanceCalendarDrawingBuilder.PlaceholderName, PlaceholderType.Drawing, true)],
|
||||||
|
};
|
||||||
|
var source = Path.Combine(_directory, $"{Guid.NewGuid():N}.lavorlage");
|
||||||
|
TemplatePackage.Create(source, manifest,
|
||||||
|
$"PAGE 595.32 841.92 pt\nDRAWBOX 71 350 487 177 ${StudentAttendanceCalendarDrawingBuilder.PlaceholderName}",
|
||||||
|
new Dictionary<string, byte[]>());
|
||||||
|
var store = new TemplateStore(Path.Combine(_directory, $"store-{Guid.NewGuid():N}"));
|
||||||
|
store.Import(source);
|
||||||
|
float? receivedWidth = null; float? receivedMillimeterScale = null;
|
||||||
|
|
||||||
|
var vm = new CreateLetterDialogViewModel(StudentWithContact("Sehr geehrte Frau Muster,"), store,
|
||||||
|
new QuestTemplateRenderer(), new FakeMemberships([]), new FakeGroups([]),
|
||||||
|
(_, width, millimeterScale, _) => { receivedWidth = width; receivedMillimeterScale = millimeterScale; return new DrawingValue([], 0); });
|
||||||
|
|
||||||
|
Assert.True(vm.UsesAttendanceCalendar);
|
||||||
|
Assert.Equal(487, receivedWidth);
|
||||||
|
Assert.NotNull(receivedMillimeterScale);
|
||||||
|
Assert.Equal(2.8346f, receivedMillimeterScale!.Value, 0.001f);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task AttendanceKalenderKonfigurieren_LoestGezieltenDatenAbrufFuerDenZeitraumAus()
|
||||||
|
{
|
||||||
|
var store = StoreWithTemplate(new PlaceholderDefinition(
|
||||||
|
StudentAttendanceCalendarDrawingBuilder.PlaceholderName, PlaceholderType.Drawing, true));
|
||||||
|
AttendanceCalendarOptions? requested = null;
|
||||||
|
var vm = new CreateLetterDialogViewModel(StudentWithContact("Sehr geehrte Frau Muster,"), store,
|
||||||
|
new QuestTemplateRenderer(), new FakeMemberships([]), new FakeGroups([]),
|
||||||
|
(_, _, _, _) => new DrawingValue([], 0), attendanceDataRefresher: (options, _) =>
|
||||||
|
{ requested = options; return Task.CompletedTask; });
|
||||||
|
|
||||||
|
await vm.SetAttendanceCalendarOptionsAsync(new AttendanceCalendarOptions(
|
||||||
|
new DateOnly(2026, 9, 1), 2, AttendanceCalendarSize.Medium));
|
||||||
|
|
||||||
|
Assert.NotNull(requested);
|
||||||
|
Assert.Equal(new DateOnly(2026, 9, 1), requested!.NormalizedStartMonth);
|
||||||
|
Assert.Equal(2, requested.NormalizedMonthCount);
|
||||||
|
Assert.False(vm.IsRefreshingAttendanceData);
|
||||||
|
Assert.Equal("", vm.AttendanceRefreshError);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task AttendanceKalenderKonfigurieren_ZeigtFehlerBeiFehlgeschlagenemAbrufAnStattZuBlockieren()
|
||||||
|
{
|
||||||
|
var store = StoreWithTemplate(new PlaceholderDefinition(
|
||||||
|
StudentAttendanceCalendarDrawingBuilder.PlaceholderName, PlaceholderType.Drawing, true));
|
||||||
|
var vm = new CreateLetterDialogViewModel(StudentWithContact("Sehr geehrte Frau Muster,"), store,
|
||||||
|
new QuestTemplateRenderer(), new FakeMemberships([]), new FakeGroups([]),
|
||||||
|
(_, _, _, _) => new DrawingValue([], 0),
|
||||||
|
attendanceDataRefresher: (_, _) => throw new WebUntisIntegrationException("Keine Verbindung."));
|
||||||
|
|
||||||
|
await vm.SetAttendanceCalendarOptionsAsync(new AttendanceCalendarOptions(
|
||||||
|
new DateOnly(2026, 9, 1), 1, AttendanceCalendarSize.Medium));
|
||||||
|
|
||||||
|
Assert.True(vm.AttendanceCalendarConfigured);
|
||||||
|
Assert.Contains("Keine Verbindung.", vm.AttendanceRefreshError);
|
||||||
|
Assert.False(vm.IsRefreshingAttendanceData);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void AdvancedContent_Fehlzeitenliste_AktiviertKonfigurationsschritt()
|
||||||
|
{
|
||||||
|
var store = StoreWithTemplate(new PlaceholderDefinition(
|
||||||
|
StudentAbsenceDayListDrawingBuilder.PlaceholderName, PlaceholderType.Drawing, true));
|
||||||
|
var drawing = StudentAbsenceDayListDrawingBuilder.Build("Lena Beispiel",
|
||||||
|
new AttendanceCalendarOptions(new DateOnly(2026, 9, 1), 1), []);
|
||||||
|
var vm = new CreateLetterDialogViewModel(StudentWithContact("Sehr geehrte Frau Muster,"), store,
|
||||||
|
new QuestTemplateRenderer(), new FakeMemberships([]), new FakeGroups([]),
|
||||||
|
attendanceCalendarFactory: null, absenceDayListFactory: (_, _, _, _) => drawing);
|
||||||
|
|
||||||
|
Assert.False(vm.UsesAttendanceCalendar);
|
||||||
|
Assert.True(vm.UsesAbsenceDayList);
|
||||||
|
Assert.True(vm.UsesAttendanceAdvancedContent);
|
||||||
|
Assert.True(vm.CanGenerate);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void EigenerPlatzhalter_KannImDialogEingegebenWerden()
|
||||||
|
{
|
||||||
|
var store = StoreWithTemplate(new PlaceholderDefinition("Anrede", PlaceholderType.Text, true),
|
||||||
|
new PlaceholderDefinition("Datum", PlaceholderType.Date, true),
|
||||||
|
new PlaceholderDefinition("Brieftext", PlaceholderType.Multiline, true),
|
||||||
|
new PlaceholderDefinition("Betreff", PlaceholderType.Text, true));
|
||||||
|
var vm = Build(StudentWithContact("Sehr geehrte Frau Muster,"), store);
|
||||||
|
vm.LetterText = "Dies ist der Inhalt.";
|
||||||
|
|
||||||
|
var betreff = Assert.Single(vm.CustomPlaceholders);
|
||||||
|
Assert.Equal("Betreff", betreff.Name);
|
||||||
|
Assert.False(vm.CanGenerate);
|
||||||
|
Assert.Contains(vm.Issues, i => i.Message.Contains("Betreff", StringComparison.OrdinalIgnoreCase));
|
||||||
|
|
||||||
|
betreff.TextValue = "Wichtiger Termin";
|
||||||
|
var output = Path.Combine(_directory, "MitBetreff.pdf");
|
||||||
|
|
||||||
|
Assert.True(vm.CanGenerate);
|
||||||
|
Assert.True(vm.Generate(output));
|
||||||
|
Assert.Equal("%PDF", System.Text.Encoding.ASCII.GetString(File.ReadAllBytes(output), 0, 4));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void StudentName_WirdAutomatischMitVollemNamenBefuellt()
|
||||||
|
{
|
||||||
|
var store = StoreWithTemplate(new PlaceholderDefinition("Anrede", PlaceholderType.Text, true),
|
||||||
|
new PlaceholderDefinition("Datum", PlaceholderType.Date, true),
|
||||||
|
new PlaceholderDefinition("Brieftext", PlaceholderType.Multiline, true),
|
||||||
|
new PlaceholderDefinition("Student.Name", PlaceholderType.Text, true));
|
||||||
|
var vm = Build(StudentWithContact("Sehr geehrte Frau Muster,"), store);
|
||||||
|
vm.LetterText = "Dies ist der Inhalt.";
|
||||||
|
|
||||||
|
Assert.Empty(vm.CustomPlaceholders);
|
||||||
|
Assert.True(vm.CanGenerate);
|
||||||
|
var output = Path.Combine(_directory, "StudentName.pdf");
|
||||||
|
Assert.True(vm.Generate(output));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Anrede_WirdAusKontaktVorbelegtUndBleibtEditierbar()
|
||||||
|
{
|
||||||
|
var store = StoreWithTemplate(new PlaceholderDefinition("Anrede", PlaceholderType.Text, true),
|
||||||
|
new PlaceholderDefinition("Datum", PlaceholderType.Date, true),
|
||||||
|
new PlaceholderDefinition("Brieftext", PlaceholderType.Multiline, true));
|
||||||
|
var vm = Build(StudentWithContact(null), store);
|
||||||
|
vm.LetterText = "Dies ist der Inhalt.";
|
||||||
|
|
||||||
|
Assert.Equal("", vm.Anrede);
|
||||||
|
Assert.False(vm.CanGenerate);
|
||||||
|
Assert.Contains(vm.Issues, i => i.Message.Contains("Anrede", StringComparison.OrdinalIgnoreCase));
|
||||||
|
|
||||||
|
vm.Anrede = "Sehr geehrte Familie Beispiel,";
|
||||||
|
var output = Path.Combine(_directory, "AnredeManuell.pdf");
|
||||||
|
|
||||||
|
Assert.True(vm.CanGenerate);
|
||||||
|
Assert.True(vm.Generate(output));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void AddressPreview_FolgtDemGewaehltenKontaktUndAktualisiertSichBeimWechsel()
|
||||||
|
{
|
||||||
|
var student = new Student
|
||||||
|
{
|
||||||
|
FirstName = "Lena", LastName = "Beispiel",
|
||||||
|
Contacts =
|
||||||
|
[
|
||||||
|
new Contact { Name = "Frau Beispiel", Relation = "Mutter", Street = "Erste Str. 1", PostalCode = "11111", City = "Erststadt" },
|
||||||
|
new Contact { Name = "Herr Beispiel", Relation = "Vater", Street = "Zweite Str. 2", PostalCode = "22222", City = "Zweitstadt" },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
var vm = Build(student, StoreWithTemplate(new PlaceholderDefinition("Anrede", PlaceholderType.Text, true)));
|
||||||
|
|
||||||
|
Assert.Equal(2, vm.Contacts.Count);
|
||||||
|
Assert.Contains("Frau Beispiel", vm.AddressPreview);
|
||||||
|
Assert.Contains("Erste Str. 1", vm.AddressPreview);
|
||||||
|
|
||||||
|
vm.SelectedContact = vm.Contacts.Single(c => c.Model.Name == "Herr Beispiel");
|
||||||
|
|
||||||
|
Assert.Contains("Herr Beispiel", vm.AddressPreview);
|
||||||
|
Assert.Contains("Zweite Str. 2", vm.AddressPreview);
|
||||||
|
Assert.DoesNotContain("Frau Beispiel", vm.AddressPreview);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void SelectContactDialog_StartetBeimAktuellenKontaktUndAktualisiertVorschau()
|
||||||
|
{
|
||||||
|
var contacts = new List<LetterContactChoice>
|
||||||
|
{
|
||||||
|
new(new Contact { Name = "Frau Beispiel", Street = "Erste Str. 1", PostalCode = "11111", City = "Erststadt" }),
|
||||||
|
new(new Contact { Name = "Herr Beispiel", Street = "Zweite Str. 2", PostalCode = "22222", City = "Zweitstadt" }),
|
||||||
|
};
|
||||||
|
var dialogVm = new SelectContactDialogViewModel(contacts, contacts[1]);
|
||||||
|
|
||||||
|
Assert.Same(contacts[1], dialogVm.SelectedContact);
|
||||||
|
Assert.Contains("Zweite Str. 2", dialogVm.AddressPreview);
|
||||||
|
|
||||||
|
dialogVm.SelectedContact = contacts[0];
|
||||||
|
|
||||||
|
Assert.Contains("Erste Str. 1", dialogVm.AddressPreview);
|
||||||
|
}
|
||||||
|
|
||||||
private CreateLetterDialogViewModel Build(Student student, TemplateStore store) =>
|
private CreateLetterDialogViewModel Build(Student student, TemplateStore store) =>
|
||||||
new(student, store, new QuestTemplateRenderer(), new FakeMemberships([]), new FakeGroups([]));
|
new(student, store, new QuestTemplateRenderer(), new FakeMemberships([]), new FakeGroups([]));
|
||||||
|
|
||||||
@@ -65,8 +306,18 @@ public sealed class CreateLetterDialogViewModelTests : IDisposable
|
|||||||
var y = 20;
|
var y = 20;
|
||||||
foreach (var definition in definitions)
|
foreach (var definition in definitions)
|
||||||
{
|
{
|
||||||
var element = definition.Type == PlaceholderType.Multiline ? "TEXTBOX" : "TEXT";
|
var element = definition.Type switch
|
||||||
lines.Add(element == "TEXTBOX" ? $"TEXTBOX 20 {y} 170 80 ${definition.Name}" : $"TEXT 20 {y} ${definition.Name}");
|
{
|
||||||
|
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;
|
y += 20;
|
||||||
}
|
}
|
||||||
TemplatePackage.Create(source, manifest, string.Join('\n', lines), new Dictionary<string, byte[]>());
|
TemplatePackage.Create(source, manifest, string.Join('\n', lines), new Dictionary<string, byte[]>());
|
||||||
|
|||||||
@@ -373,9 +373,18 @@ public class FakeSubjects(List<Subject> all) : ISubjectRepository
|
|||||||
{
|
{
|
||||||
public List<Subject> GetAll() => all;
|
public List<Subject> GetAll() => all;
|
||||||
public Subject? GetById(Guid id) => all.FirstOrDefault(s => s.Id == id);
|
public Subject? GetById(Guid id) => all.FirstOrDefault(s => s.Id == id);
|
||||||
public Subject? GetByName(string name) => all.FirstOrDefault(s => s.Name == name);
|
public Subject? GetByName(string name) => all.FirstOrDefault(s =>
|
||||||
public void Save(Subject subject) { }
|
string.Equals(s.Name.Trim(), name.Trim(), StringComparison.OrdinalIgnoreCase));
|
||||||
public void Delete(Guid id) { }
|
public void Save(Subject subject)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(subject.Name)) throw new ArgumentException("Der Fachname darf nicht leer sein.");
|
||||||
|
var duplicate = GetByName(subject.Name);
|
||||||
|
if (duplicate is not null && duplicate.Id != subject.Id)
|
||||||
|
throw new InvalidOperationException("Ein Fach mit diesem Namen existiert bereits.");
|
||||||
|
all.RemoveAll(s => s.Id == subject.Id);
|
||||||
|
all.Add(subject);
|
||||||
|
}
|
||||||
|
public void Delete(Guid id) => all.RemoveAll(s => s.Id == id);
|
||||||
}
|
}
|
||||||
|
|
||||||
public class FakeCompetencyDomains : ICompetencyDomainRepository
|
public class FakeCompetencyDomains : ICompetencyDomainRepository
|
||||||
@@ -385,6 +394,9 @@ public class FakeCompetencyDomains : ICompetencyDomainRepository
|
|||||||
public List<CompetencyDomain> GetBySubjectAndGrade(Guid subjectId, int gradeLevel) =>
|
public List<CompetencyDomain> GetBySubjectAndGrade(Guid subjectId, int gradeLevel) =>
|
||||||
_all.Where(d => d.SubjectId == subjectId && d.GradeLevel == gradeLevel)
|
_all.Where(d => d.SubjectId == subjectId && d.GradeLevel == gradeLevel)
|
||||||
.OrderBy(d => d.SortOrder).ToList();
|
.OrderBy(d => d.SortOrder).ToList();
|
||||||
|
public List<CompetencyDomain> GetBySubject(Guid subjectId) =>
|
||||||
|
_all.Where(d => d.SubjectId == subjectId)
|
||||||
|
.OrderBy(d => d.GradeLevel).ThenBy(d => d.SortOrder).ToList();
|
||||||
public CompetencyDomain? GetById(Guid id) => _all.FirstOrDefault(d => d.Id == id);
|
public CompetencyDomain? GetById(Guid id) => _all.FirstOrDefault(d => d.Id == id);
|
||||||
public void Save(CompetencyDomain domain) { _all.RemoveAll(d => d.Id == domain.Id); _all.Add(domain); }
|
public void Save(CompetencyDomain domain) { _all.RemoveAll(d => d.Id == domain.Id); _all.Add(domain); }
|
||||||
public void Delete(Guid id) => _all.RemoveAll(d => d.Id == id);
|
public void Delete(Guid id) => _all.RemoveAll(d => d.Id == id);
|
||||||
@@ -610,11 +622,17 @@ public class FakeMcpConfirmation : IMcpConfirmationService
|
|||||||
public string? LastMessage { get; private set; }
|
public string? LastMessage { get; private set; }
|
||||||
public int CallCount { get; private set; }
|
public int CallCount { get; private set; }
|
||||||
|
|
||||||
public Task<bool> ConfirmAsync(string title, string message, CancellationToken ct)
|
public string? LastOperationKey { get; private set; }
|
||||||
|
public bool? LastAllowSessionTrust { get; private set; }
|
||||||
|
|
||||||
|
public Task<bool> ConfirmAsync(string title, string message, CancellationToken ct,
|
||||||
|
[System.Runtime.CompilerServices.CallerMemberName] string operationKey = "", bool allowSessionTrust = true)
|
||||||
{
|
{
|
||||||
CallCount++;
|
CallCount++;
|
||||||
LastTitle = title;
|
LastTitle = title;
|
||||||
LastMessage = message;
|
LastMessage = message;
|
||||||
|
LastOperationKey = operationKey;
|
||||||
|
LastAllowSessionTrust = allowSessionTrust;
|
||||||
return Task.FromResult(Response);
|
return Task.FromResult(Response);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,33 @@ namespace LehrerApp.Desktop.Tests;
|
|||||||
/// zusammen, was Planung/Klausuren/Mitarbeit/Dokumentation ohnehin schon verwalten.
|
/// zusammen, was Planung/Klausuren/Mitarbeit/Dokumentation ohnehin schon verwalten.
|
||||||
public sealed class GroupOverviewViewModelTests
|
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,
|
private static GroupOverviewViewModel NewVm(FakeLessons? lessons = null, FakeExams? exams = null,
|
||||||
FakeSessions? sessions = null, FakeEntries? entries = null, FakeStudents? students = null,
|
FakeSessions? sessions = null, FakeEntries? entries = null, FakeStudents? students = null,
|
||||||
FakeDocumentation? documentation = null, FakeWorkTasks? tasks = null,
|
FakeDocumentation? documentation = null, FakeWorkTasks? tasks = null,
|
||||||
|
|||||||
@@ -45,6 +45,39 @@ public sealed class LessonSchedulingServiceTests
|
|||||||
Assert.Equal(new DateOnly(2026, 9, 4), conducted.Date);
|
Assert.Equal(new DateOnly(2026, 9, 4), conducted.Date);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Move_RuecktAuchAusgefalleneFolgestundenNichtNach()
|
||||||
|
{
|
||||||
|
var unitId = Guid.NewGuid();
|
||||||
|
var groupId = Guid.NewGuid();
|
||||||
|
var moved = new Lesson { UnitId = unitId, GroupId = groupId, Date = new(2026, 9, 1) };
|
||||||
|
var cancelledFollowing = new Lesson { UnitId = unitId, GroupId = groupId, Date = new(2026, 9, 4), Status = LessonStatus.Cancelled };
|
||||||
|
var repo = new FakeLessons();
|
||||||
|
repo.Add(moved); repo.Add(cancelledFollowing);
|
||||||
|
|
||||||
|
new LessonSchedulingService(repo).Move(moved, new(2026, 9, 8), null, shiftFollowing: true);
|
||||||
|
|
||||||
|
Assert.Equal(new DateOnly(2026, 9, 4), cancelledFollowing.Date);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void SplitAndMoveSecondPart_AusgefalleneQuellstundeSetztFortsetzungAufEntwurfZurueck()
|
||||||
|
{
|
||||||
|
var source = new Lesson
|
||||||
|
{
|
||||||
|
UnitId = Guid.NewGuid(), GroupId = Guid.NewGuid(), Date = new(2026, 9, 1),
|
||||||
|
Status = LessonStatus.Cancelled,
|
||||||
|
Phases = [new LessonPhaseStep { Name = "Einstieg", DurationMinutes = 60 }],
|
||||||
|
};
|
||||||
|
var repo = new FakeLessons();
|
||||||
|
repo.Add(source);
|
||||||
|
|
||||||
|
var continuation = new LessonSchedulingService(repo).SplitAndMoveSecondPart(source, 30,
|
||||||
|
new(2026, 9, 3), 1, null);
|
||||||
|
|
||||||
|
Assert.Equal(LessonStatus.Draft, continuation.Status);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void SplitAndMoveSecondPart_TeiltAuchEineUeberDieGrenzeLaufendePhase()
|
public void SplitAndMoveSecondPart_TeiltAuchEineUeberDieGrenzeLaufendePhase()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
using LehrerApp.Core.Models;
|
||||||
|
using LehrerApp.Desktop.ViewModels.Groups;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace LehrerApp.Desktop.Tests;
|
||||||
|
|
||||||
|
public sealed class LessonStatusDisplayTests
|
||||||
|
{
|
||||||
|
[Theory]
|
||||||
|
[InlineData(LessonStatus.Draft, "Entwurf")]
|
||||||
|
[InlineData(LessonStatus.Ready, "Bereit")]
|
||||||
|
[InlineData(LessonStatus.Conducted, "Durchgeführt")]
|
||||||
|
[InlineData(LessonStatus.Cancelled, "Ausgefallen")]
|
||||||
|
[InlineData(LessonStatus.Planned, "Geplant")]
|
||||||
|
public void ToName_FromName_RoundTrip(LessonStatus status, string expectedName)
|
||||||
|
{
|
||||||
|
Assert.Equal(expectedName, LessonStatusDisplay.ToName(status));
|
||||||
|
Assert.Equal(status, LessonStatusDisplay.FromName(expectedName));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void UnitSummary_AusgefalleneStundenZaehlenWederAlsGehaltenNochAlsPensum()
|
||||||
|
{
|
||||||
|
var unit = new Unit { Title = "Optik" };
|
||||||
|
var lessons = new List<Lesson>
|
||||||
|
{
|
||||||
|
new() { Status = LessonStatus.Conducted },
|
||||||
|
new() { Status = LessonStatus.Cancelled },
|
||||||
|
new() { Status = LessonStatus.Ready },
|
||||||
|
};
|
||||||
|
|
||||||
|
var summary = new UnitSummary(unit, lessons);
|
||||||
|
|
||||||
|
Assert.Equal(2, summary.TotalCount);
|
||||||
|
Assert.Equal(1, summary.ConductedCount);
|
||||||
|
Assert.Equal(0.5, summary.ProgressFraction);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,9 +15,10 @@ public sealed class McpToolsTests
|
|||||||
Assert.Equal(
|
Assert.Equal(
|
||||||
new[]
|
new[]
|
||||||
{
|
{
|
||||||
"download_lesson_attachment", "get_exams", "get_grades", "get_lesson_plans",
|
"download_lesson_attachment", "get_competency_catalog", "get_exams", "get_grades",
|
||||||
"get_schedule", "get_students", "get_time_entries", "list_letter_templates",
|
"get_groups", "get_lesson_plans", "get_named_untis_absence_pattern", "get_schedule",
|
||||||
"render_letter",
|
"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));
|
McpToolScope.AllowedReadTools.OrderBy(n => n, StringComparer.Ordinal));
|
||||||
}
|
}
|
||||||
@@ -28,13 +29,24 @@ public sealed class McpToolsTests
|
|||||||
Assert.Equal(
|
Assert.Equal(
|
||||||
new[]
|
new[]
|
||||||
{
|
{
|
||||||
"add_lesson_attachment", "add_lesson_phase", "create_grade_entry", "create_lesson",
|
"add_competency_item", "add_lesson_attachment", "add_lesson_competency", "add_lesson_phase",
|
||||||
"create_time_entry", "create_unit", "remove_lesson_phase", "update_lesson",
|
"apply_untis_absence_status", "create_competency_domain", "create_grade_entry", "create_lesson",
|
||||||
"update_lesson_phase", "update_student_group_assignment", "update_unit",
|
"create_subject", "create_time_entry", "create_unit", "move_lesson", "remove_competency_item",
|
||||||
|
"remove_lesson_competency", "remove_lesson_phase", "update_competency_domain",
|
||||||
|
"update_competency_item", "update_lesson", "update_lesson_phase", "update_student_group_assignment",
|
||||||
|
"update_subject", "update_unit",
|
||||||
},
|
},
|
||||||
McpToolScope.AllowedWriteTools.OrderBy(n => n, StringComparer.Ordinal));
|
McpToolScope.AllowedWriteTools.OrderBy(n => n, StringComparer.Ordinal));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void AllowedDestructiveWriteTools_EnthaeltDeleteLessonSubjectUndCompetencyDomain()
|
||||||
|
{
|
||||||
|
Assert.Equal(
|
||||||
|
new[] { "delete_competency_domain", "delete_lesson", "delete_subject" },
|
||||||
|
McpToolScope.AllowedDestructiveWriteTools.OrderBy(n => n, StringComparer.Ordinal));
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void AllowedTools_EnthaeltKeineDokumentationstypen()
|
public void AllowedTools_EnthaeltKeineDokumentationstypen()
|
||||||
{
|
{
|
||||||
@@ -47,6 +59,26 @@ public sealed class McpToolsTests
|
|||||||
n.Contains("note", StringComparison.OrdinalIgnoreCase));
|
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 ─────────────────────────────────────────────────────────────────────────
|
// ── StudentTools ─────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
@@ -334,6 +366,25 @@ public sealed class McpToolsTests
|
|||||||
Assert.Equal(result.Id, phase.Id);
|
Assert.Equal(result.Id, phase.Id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task AddLessonPhase_UebergibtEigenenMethodennamenAlsOperationKey()
|
||||||
|
{
|
||||||
|
// Belegt, dass IMcpConfirmationService.ConfirmAsync den [CallerMemberName]-Mechanismus
|
||||||
|
// tatsächlich nutzt (Grundlage für die Sitzungsfreigabe "diese Aktion nicht mehr
|
||||||
|
// nachfragen" in AvaloniaMcpConfirmationService) - ohne echtes UI testbar, weil der Name
|
||||||
|
// vom Compiler an der Aufrufstelle in AddLessonPhase eingesetzt wird, unabhängig von der
|
||||||
|
// konkreten IMcpConfirmationService-Implementierung.
|
||||||
|
var lesson = new Lesson { Topic = "Brechung" };
|
||||||
|
var lessons = new FakeLessons();
|
||||||
|
lessons.Add(lesson);
|
||||||
|
var confirmation = new FakeMcpConfirmation();
|
||||||
|
var tool = BuildLessonPlanTools(lessons: lessons, confirmation: confirmation);
|
||||||
|
|
||||||
|
await tool.AddLessonPhase(lesson.Id, "Einstieg", 10);
|
||||||
|
|
||||||
|
Assert.Equal(nameof(LessonPlanTools.AddLessonPhase), confirmation.LastOperationKey);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task UpdateLessonPhase_AendertNurAngegebeneFelder()
|
public async Task UpdateLessonPhase_AendertNurAngegebeneFelder()
|
||||||
{
|
{
|
||||||
@@ -463,6 +514,110 @@ public sealed class McpToolsTests
|
|||||||
Assert.Equal(0, confirmation.CallCount);
|
Assert.Equal(0, confirmation.CallCount);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task MoveLesson_NutzerBestaetigt_VerschiebtDatum()
|
||||||
|
{
|
||||||
|
var lesson = new Lesson { Topic = "Brechung", Date = new DateOnly(2026, 1, 5) };
|
||||||
|
var lessons = new FakeLessons();
|
||||||
|
lessons.Add(lesson);
|
||||||
|
var tool = BuildLessonPlanTools(lessons: lessons);
|
||||||
|
|
||||||
|
var result = await tool.MoveLesson(lesson.Id, new DateOnly(2026, 1, 12));
|
||||||
|
|
||||||
|
Assert.True(result.Applied);
|
||||||
|
Assert.Equal(new DateOnly(2026, 1, 12), lessons.GetById(lesson.Id)!.Date);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task MoveLesson_ShiftFollowing_VerschiebtSpaetereStundenDerselbenEinheitMit()
|
||||||
|
{
|
||||||
|
var unitId = Guid.NewGuid();
|
||||||
|
var moved = new Lesson { UnitId = unitId, Topic = "Brechung", Date = new DateOnly(2026, 1, 5) };
|
||||||
|
var later = new Lesson { UnitId = unitId, Topic = "Reflexion", Date = new DateOnly(2026, 1, 7) };
|
||||||
|
var earlier = new Lesson { UnitId = unitId, Topic = "Einstieg", Date = new DateOnly(2026, 1, 1) };
|
||||||
|
var lessons = new FakeLessons();
|
||||||
|
lessons.Add(moved); lessons.Add(later); lessons.Add(earlier);
|
||||||
|
var tool = BuildLessonPlanTools(lessons: lessons);
|
||||||
|
|
||||||
|
// moved: 5.1. -> 12.1. (Delta +7 Tage), later (7.1., danach) muss mitwandern, earlier (1.1., davor) nicht.
|
||||||
|
var result = await tool.MoveLesson(moved.Id, new DateOnly(2026, 1, 12), shiftFollowingLessons: true);
|
||||||
|
|
||||||
|
Assert.True(result.Applied);
|
||||||
|
Assert.Equal(new DateOnly(2026, 1, 14), lessons.GetById(later.Id)!.Date);
|
||||||
|
Assert.Equal(new DateOnly(2026, 1, 1), lessons.GetById(earlier.Id)!.Date);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task MoveLesson_ZielStundeBelegt_LiefertFehlerNachBestaetigung()
|
||||||
|
{
|
||||||
|
var groupId = Guid.NewGuid();
|
||||||
|
var moved = new Lesson { GroupId = groupId, Topic = "Brechung", Date = new DateOnly(2026, 1, 5), LessonNumber = 1 };
|
||||||
|
var occupying = new Lesson { GroupId = groupId, Topic = "Anderer Kurs", Date = new DateOnly(2026, 1, 12), LessonNumber = 1 };
|
||||||
|
var lessons = new FakeLessons();
|
||||||
|
lessons.Add(moved); lessons.Add(occupying);
|
||||||
|
var confirmation = new FakeMcpConfirmation();
|
||||||
|
var tool = BuildLessonPlanTools(lessons: lessons, confirmation: confirmation);
|
||||||
|
|
||||||
|
var result = await tool.MoveLesson(moved.Id, new DateOnly(2026, 1, 12), newPeriod: 1);
|
||||||
|
|
||||||
|
Assert.False(result.Applied);
|
||||||
|
Assert.Equal(1, confirmation.CallCount); // Bestätigung lief bereits, Konflikt zeigt sich erst beim Speichern.
|
||||||
|
Assert.Equal(new DateOnly(2026, 1, 5), lessons.GetById(moved.Id)!.Date); // unverändert
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task MoveLesson_UnbekannteStunde_LiefertFehlerOhneNachfrage()
|
||||||
|
{
|
||||||
|
var confirmation = new FakeMcpConfirmation();
|
||||||
|
var tool = BuildLessonPlanTools(confirmation: confirmation);
|
||||||
|
|
||||||
|
var result = await tool.MoveLesson(Guid.NewGuid(), new DateOnly(2026, 1, 12));
|
||||||
|
|
||||||
|
Assert.False(result.Applied);
|
||||||
|
Assert.Equal(0, confirmation.CallCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task DeleteLesson_NutzerBestaetigt_LoeschtEndgueltig()
|
||||||
|
{
|
||||||
|
var lesson = new Lesson { Topic = "Brechung" };
|
||||||
|
var lessons = new FakeLessons();
|
||||||
|
lessons.Add(lesson);
|
||||||
|
var tool = BuildLessonPlanTools(lessons: lessons);
|
||||||
|
|
||||||
|
var result = await tool.DeleteLesson(lesson.Id);
|
||||||
|
|
||||||
|
Assert.True(result.Applied);
|
||||||
|
Assert.Null(lessons.GetById(lesson.Id));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task DeleteLesson_NutzerLehntAb_BleibtErhalten()
|
||||||
|
{
|
||||||
|
var lesson = new Lesson { Topic = "Brechung" };
|
||||||
|
var lessons = new FakeLessons();
|
||||||
|
lessons.Add(lesson);
|
||||||
|
var confirmation = new FakeMcpConfirmation { Response = false };
|
||||||
|
var tool = BuildLessonPlanTools(lessons: lessons, confirmation: confirmation);
|
||||||
|
|
||||||
|
var result = await tool.DeleteLesson(lesson.Id);
|
||||||
|
|
||||||
|
Assert.False(result.Applied);
|
||||||
|
Assert.NotNull(lessons.GetById(lesson.Id));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task DeleteLesson_UnbekannteStunde_LiefertFehlerOhneNachfrage()
|
||||||
|
{
|
||||||
|
var confirmation = new FakeMcpConfirmation();
|
||||||
|
var tool = BuildLessonPlanTools(confirmation: confirmation);
|
||||||
|
|
||||||
|
var result = await tool.DeleteLesson(Guid.NewGuid());
|
||||||
|
|
||||||
|
Assert.False(result.Applied);
|
||||||
|
Assert.Equal(0, confirmation.CallCount);
|
||||||
|
}
|
||||||
|
|
||||||
// ── GroupMembershipTools ─────────────────────────────────────────────────────────────────
|
// ── GroupMembershipTools ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
@@ -510,4 +665,237 @@ public sealed class McpToolsTests
|
|||||||
Assert.False(result.Applied);
|
Assert.False(result.Applied);
|
||||||
Assert.Equal(0, confirmation.CallCount);
|
Assert.Equal(0, confirmation.CallCount);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── LessonPlanTools — Kompetenzzuordnung ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task AddLessonCompetency_NutzerBestaetigt_OrdnetCodeZu()
|
||||||
|
{
|
||||||
|
var lesson = new Lesson { Topic = "Brechung" };
|
||||||
|
var lessons = new FakeLessons();
|
||||||
|
lessons.Add(lesson);
|
||||||
|
var tool = BuildLessonPlanTools(lessons: lessons);
|
||||||
|
|
||||||
|
var result = await tool.AddLessonCompetency(lesson.Id, "PH.9.1");
|
||||||
|
|
||||||
|
Assert.True(result.Applied);
|
||||||
|
Assert.Contains("PH.9.1", lessons.GetById(lesson.Id)!.Competencies);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task AddLessonCompetency_BereitsZugeordnet_FragtNichtNochmalNach()
|
||||||
|
{
|
||||||
|
var lesson = new Lesson { Topic = "Brechung" };
|
||||||
|
lesson.Competencies.Add("PH.9.1");
|
||||||
|
var lessons = new FakeLessons();
|
||||||
|
lessons.Add(lesson);
|
||||||
|
var confirmation = new FakeMcpConfirmation();
|
||||||
|
var tool = BuildLessonPlanTools(lessons: lessons, confirmation: confirmation);
|
||||||
|
|
||||||
|
var result = await tool.AddLessonCompetency(lesson.Id, "PH.9.1");
|
||||||
|
|
||||||
|
Assert.True(result.Applied);
|
||||||
|
Assert.Equal(0, confirmation.CallCount);
|
||||||
|
Assert.Single(lessons.GetById(lesson.Id)!.Competencies);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task RemoveLessonCompetency_NutzerBestaetigt_EntferntCode()
|
||||||
|
{
|
||||||
|
var lesson = new Lesson { Topic = "Brechung" };
|
||||||
|
lesson.Competencies.Add("PH.9.1");
|
||||||
|
var lessons = new FakeLessons();
|
||||||
|
lessons.Add(lesson);
|
||||||
|
var tool = BuildLessonPlanTools(lessons: lessons);
|
||||||
|
|
||||||
|
var result = await tool.RemoveLessonCompetency(lesson.Id, "PH.9.1");
|
||||||
|
|
||||||
|
Assert.True(result.Applied);
|
||||||
|
Assert.Empty(lessons.GetById(lesson.Id)!.Competencies);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task RemoveLessonCompetency_NichtZugeordnet_LiefertFehlerOhneNachfrage()
|
||||||
|
{
|
||||||
|
var lesson = new Lesson { Topic = "Brechung" };
|
||||||
|
var lessons = new FakeLessons();
|
||||||
|
lessons.Add(lesson);
|
||||||
|
var confirmation = new FakeMcpConfirmation();
|
||||||
|
var tool = BuildLessonPlanTools(lessons: lessons, confirmation: confirmation);
|
||||||
|
|
||||||
|
var result = await tool.RemoveLessonCompetency(lesson.Id, "PH.9.1");
|
||||||
|
|
||||||
|
Assert.False(result.Applied);
|
||||||
|
Assert.Equal(0, confirmation.CallCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── CompetencyTools ──────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void GetSubjects_LiefertAlleFaecher()
|
||||||
|
{
|
||||||
|
var subject = new Subject { Name = "Mathematik", ShortName = "Ma" };
|
||||||
|
var tool = new CompetencyTools(new FakeSubjects([subject]), new FakeCompetencyDomains(), new FakeMcpConfirmation());
|
||||||
|
|
||||||
|
var dto = Assert.Single(tool.GetSubjects());
|
||||||
|
|
||||||
|
Assert.Equal("Mathematik", dto.Name);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task CreateSubject_NutzerBestaetigt_SpeichertFach()
|
||||||
|
{
|
||||||
|
var subjects = new FakeSubjects([]);
|
||||||
|
var tool = new CompetencyTools(subjects, new FakeCompetencyDomains(), new FakeMcpConfirmation());
|
||||||
|
|
||||||
|
var result = await tool.CreateSubject("Mathematik", "Ma");
|
||||||
|
|
||||||
|
Assert.True(result.Applied);
|
||||||
|
var subject = Assert.Single(subjects.GetAll());
|
||||||
|
Assert.Equal("Mathematik", subject.Name);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task CreateSubject_NameBereitsVergeben_LiefertFehler()
|
||||||
|
{
|
||||||
|
var subjects = new FakeSubjects([new Subject { Name = "Mathematik" }]);
|
||||||
|
var tool = new CompetencyTools(subjects, new FakeCompetencyDomains(), new FakeMcpConfirmation());
|
||||||
|
|
||||||
|
var result = await tool.CreateSubject("Mathematik");
|
||||||
|
|
||||||
|
Assert.False(result.Applied);
|
||||||
|
Assert.Single(subjects.GetAll());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task UpdateSubject_AendertNurAngegebeneFelder()
|
||||||
|
{
|
||||||
|
var subject = new Subject { Name = "Mathematik", ShortName = "Ma" };
|
||||||
|
var subjects = new FakeSubjects([subject]);
|
||||||
|
var tool = new CompetencyTools(subjects, new FakeCompetencyDomains(), new FakeMcpConfirmation());
|
||||||
|
|
||||||
|
var result = await tool.UpdateSubject(subject.Id, shortName: "M");
|
||||||
|
|
||||||
|
Assert.True(result.Applied);
|
||||||
|
var updated = subjects.GetById(subject.Id)!;
|
||||||
|
Assert.Equal("Mathematik", updated.Name);
|
||||||
|
Assert.Equal("M", updated.ShortName);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task DeleteSubject_NutzerBestaetigt_LoeschtFach()
|
||||||
|
{
|
||||||
|
var subject = new Subject { Name = "Mathematik" };
|
||||||
|
var subjects = new FakeSubjects([subject]);
|
||||||
|
var tool = new CompetencyTools(subjects, new FakeCompetencyDomains(), new FakeMcpConfirmation());
|
||||||
|
|
||||||
|
var result = await tool.DeleteSubject(subject.Id);
|
||||||
|
|
||||||
|
Assert.True(result.Applied);
|
||||||
|
Assert.Empty(subjects.GetAll());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task DeleteSubject_UnbekannteId_LiefertFehlerOhneNachfrage()
|
||||||
|
{
|
||||||
|
var confirmation = new FakeMcpConfirmation();
|
||||||
|
var tool = new CompetencyTools(new FakeSubjects([]), new FakeCompetencyDomains(), confirmation);
|
||||||
|
|
||||||
|
var result = await tool.DeleteSubject(Guid.NewGuid());
|
||||||
|
|
||||||
|
Assert.False(result.Applied);
|
||||||
|
Assert.Equal(0, confirmation.CallCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void GetCompetencyCatalog_OhneKlassenstufe_LiefertAlleKlassenstufenDesFachs()
|
||||||
|
{
|
||||||
|
var subjectId = Guid.NewGuid();
|
||||||
|
var domains = new FakeCompetencyDomains();
|
||||||
|
domains.Add(new CompetencyDomain { SubjectId = subjectId, GradeLevel = 5, Name = "Zahlen" });
|
||||||
|
domains.Add(new CompetencyDomain { SubjectId = subjectId, GradeLevel = 9, Name = "Funktionen" });
|
||||||
|
var tool = new CompetencyTools(new FakeSubjects([]), domains, new FakeMcpConfirmation());
|
||||||
|
|
||||||
|
var result = tool.GetCompetencyCatalog(subjectId);
|
||||||
|
|
||||||
|
Assert.Equal(2, result.Count);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task CreateCompetencyDomain_NutzerBestaetigt_SpeichertBereich()
|
||||||
|
{
|
||||||
|
var subject = new Subject { Name = "Mathematik" };
|
||||||
|
var domains = new FakeCompetencyDomains();
|
||||||
|
var tool = new CompetencyTools(new FakeSubjects([subject]), domains, new FakeMcpConfirmation());
|
||||||
|
|
||||||
|
var result = await tool.CreateCompetencyDomain(subject.Id, 9, "Funktionen");
|
||||||
|
|
||||||
|
Assert.True(result.Applied);
|
||||||
|
var domain = Assert.Single(domains.GetBySubjectAndGrade(subject.Id, 9));
|
||||||
|
Assert.Equal("Funktionen", domain.Name);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task DeleteCompetencyDomain_NutzerBestaetigt_LoeschtBereichMitItems()
|
||||||
|
{
|
||||||
|
var domain = new CompetencyDomain { Name = "Funktionen" };
|
||||||
|
domain.Items.Add(new CompetencyItem { Code = "M.9.1", Description = "..." });
|
||||||
|
var domains = new FakeCompetencyDomains();
|
||||||
|
domains.Add(domain);
|
||||||
|
var tool = new CompetencyTools(new FakeSubjects([]), domains, new FakeMcpConfirmation());
|
||||||
|
|
||||||
|
var result = await tool.DeleteCompetencyDomain(domain.Id);
|
||||||
|
|
||||||
|
Assert.True(result.Applied);
|
||||||
|
Assert.Null(domains.GetById(domain.Id));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task AddCompetencyItem_NutzerBestaetigt_HaengtItemAn()
|
||||||
|
{
|
||||||
|
var domain = new CompetencyDomain { Name = "Funktionen" };
|
||||||
|
var domains = new FakeCompetencyDomains();
|
||||||
|
domains.Add(domain);
|
||||||
|
var tool = new CompetencyTools(new FakeSubjects([]), domains, new FakeMcpConfirmation());
|
||||||
|
|
||||||
|
var result = await tool.AddCompetencyItem(domain.Id, "M.9.1", "lineare Funktionen erkennen");
|
||||||
|
|
||||||
|
Assert.True(result.Applied);
|
||||||
|
var item = Assert.Single(domains.GetById(domain.Id)!.Items);
|
||||||
|
Assert.Equal("M.9.1", item.Code);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task UpdateCompetencyItem_AendertNurAngegebeneFelder()
|
||||||
|
{
|
||||||
|
var item = new CompetencyItem { Code = "M.9.1", Description = "alt" };
|
||||||
|
var domain = new CompetencyDomain { Name = "Funktionen" };
|
||||||
|
domain.Items.Add(item);
|
||||||
|
var domains = new FakeCompetencyDomains();
|
||||||
|
domains.Add(domain);
|
||||||
|
var tool = new CompetencyTools(new FakeSubjects([]), domains, new FakeMcpConfirmation());
|
||||||
|
|
||||||
|
var result = await tool.UpdateCompetencyItem(domain.Id, item.Id, description: "neu");
|
||||||
|
|
||||||
|
Assert.True(result.Applied);
|
||||||
|
var updated = domains.GetById(domain.Id)!.Items.Single();
|
||||||
|
Assert.Equal("M.9.1", updated.Code);
|
||||||
|
Assert.Equal("neu", updated.Description);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task RemoveCompetencyItem_NutzerBestaetigt_EntferntItem()
|
||||||
|
{
|
||||||
|
var item = new CompetencyItem { Code = "M.9.1", Description = "..." };
|
||||||
|
var domain = new CompetencyDomain { Name = "Funktionen" };
|
||||||
|
domain.Items.Add(item);
|
||||||
|
var domains = new FakeCompetencyDomains();
|
||||||
|
domains.Add(domain);
|
||||||
|
var tool = new CompetencyTools(new FakeSubjects([]), domains, new FakeMcpConfirmation());
|
||||||
|
|
||||||
|
var result = await tool.RemoveCompetencyItem(domain.Id, item.Id);
|
||||||
|
|
||||||
|
Assert.True(result.Applied);
|
||||||
|
Assert.Empty(domains.GetById(domain.Id)!.Items);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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([]),
|
new FakeDocumentation(), new FakeStudents([]), new FakeShorthandCodes([]),
|
||||||
holidays ?? new FakeSchoolHolidays(), new SchoolCalendarSettingsService(tempPath),
|
holidays ?? new FakeSchoolHolidays(), new SchoolCalendarSettingsService(tempPath),
|
||||||
new PeriodScheduleService(tempPath), supervisionDuties ?? new FakeSupervisionDuties(),
|
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.BuildMcpSettingsService(),
|
||||||
TestSupport.BuildMcpClientRegistrationService(),
|
TestSupport.BuildMcpClientRegistrationService(),
|
||||||
TestSupport.BuildWebUntisSettingsService(),
|
TestSupport.BuildWebUntisSettingsService(),
|
||||||
@@ -342,7 +342,7 @@ public sealed class SettingsViewModelTests
|
|||||||
new LiteDbContext(new MemoryStream()), new PrivacySettingsService(tempPath),
|
new LiteDbContext(new MemoryStream()), new PrivacySettingsService(tempPath),
|
||||||
new FakeDocumentation(), new FakeStudents([]), new FakeShorthandCodes([]),
|
new FakeDocumentation(), new FakeStudents([]), new FakeShorthandCodes([]),
|
||||||
new FakeSchoolHolidays(), calendarSettings, new PeriodScheduleService(tempPath),
|
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.BuildMcpSettingsService(),
|
||||||
TestSupport.BuildMcpClientRegistrationService(),
|
TestSupport.BuildMcpClientRegistrationService(),
|
||||||
TestSupport.BuildWebUntisSettingsService(),
|
TestSupport.BuildWebUntisSettingsService(),
|
||||||
@@ -372,7 +372,7 @@ public sealed class SettingsViewModelTests
|
|||||||
new LiteDbContext(new MemoryStream()), new PrivacySettingsService(tempPath),
|
new LiteDbContext(new MemoryStream()), new PrivacySettingsService(tempPath),
|
||||||
new FakeDocumentation(), new FakeStudents([]), new FakeShorthandCodes([]),
|
new FakeDocumentation(), new FakeStudents([]), new FakeShorthandCodes([]),
|
||||||
new FakeSchoolHolidays(), new SchoolCalendarSettingsService(tempPath), periodSchedule,
|
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.BuildMcpSettingsService(),
|
||||||
TestSupport.BuildMcpClientRegistrationService(),
|
TestSupport.BuildMcpClientRegistrationService(),
|
||||||
TestSupport.BuildWebUntisSettingsService(),
|
TestSupport.BuildWebUntisSettingsService(),
|
||||||
@@ -406,7 +406,7 @@ public sealed class SettingsViewModelTests
|
|||||||
new LiteDbContext(new MemoryStream()), new PrivacySettingsService(tempPath),
|
new LiteDbContext(new MemoryStream()), new PrivacySettingsService(tempPath),
|
||||||
new FakeDocumentation(), new FakeStudents([]), new FakeShorthandCodes([]),
|
new FakeDocumentation(), new FakeStudents([]), new FakeShorthandCodes([]),
|
||||||
new FakeSchoolHolidays(), new SchoolCalendarSettingsService(tempPath), periodSchedule,
|
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.BuildMcpSettingsService(),
|
||||||
TestSupport.BuildMcpClientRegistrationService(),
|
TestSupport.BuildMcpClientRegistrationService(),
|
||||||
TestSupport.BuildWebUntisSettingsService(),
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
using LehrerApp.Core.Models;
|
||||||
|
using LehrerApp.Desktop.Services;
|
||||||
|
using LehrerApp.Desktop.Services.Mcp.Tools;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace LehrerApp.Desktop.Tests;
|
||||||
|
|
||||||
|
/// Aus Konsistenz mit den drei bestehenden WebUntis-Abgleichs-ViewModels bewusst ohne Tests für
|
||||||
|
/// GetUntisAbsenceRows/GetNamedUntisAbsencePattern gelassen (siehe TODO.md) - beide rufen
|
||||||
|
/// WebUntisIntegrationService.GetLessonAbsencesAsync auf, was einen echten WebUntis-JSON-RPC-
|
||||||
|
/// Handshake voraussetzt. Getestet werden die Teile, die keinen WebUntis-Zugriff brauchen:
|
||||||
|
/// GetUntisHubStatus (reine Delegation an UntisHubService) und die Validierungspfade von
|
||||||
|
/// ApplyUntisAbsenceStatus, die vor jedem WebUntis-/Datenbankzugriff greifen.
|
||||||
|
public sealed class UntisComparisonToolsTests
|
||||||
|
{
|
||||||
|
private static UntisComparisonTools Build(
|
||||||
|
List<LearningGroup>? groups = null, List<Student>? students = null,
|
||||||
|
List<ParticipationSession>? sessions = null, FakeMcpConfirmation? confirmation = null) =>
|
||||||
|
new(new FakeGroups(groups ?? []), new FakeStudents(students ?? []), new FakeSessions(sessions ?? []),
|
||||||
|
new FakeEntries(), TestSupport.BuildWebUntisIntegrationService(),
|
||||||
|
TestSupport.BuildUntisHubService(groups ?? []), confirmation ?? new FakeMcpConfirmation());
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void GetUntisHubStatus_DelegiertAnUntisHubServiceUndMapptKindUndDueStateAlsText()
|
||||||
|
{
|
||||||
|
var group = new LearningGroup { Name = "9a", WebUntisLessonId = 42 };
|
||||||
|
var tool = Build([group]);
|
||||||
|
|
||||||
|
var rows = tool.GetUntisHubStatus();
|
||||||
|
|
||||||
|
Assert.Contains(rows, r => r.Kind == nameof(UntisHubJobKind.FehlzeitenKurz) && r.GroupName == "9a"
|
||||||
|
&& r.DueState == nameof(UntisHubDueState.Overdue));
|
||||||
|
Assert.Contains(rows, r => r.Kind == nameof(UntisHubJobKind.OffenePeriods) && r.GroupName == "Offene Stunden");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ApplyUntisAbsenceStatus_UnbekannterStatus_WirdOhneRowIdPruefungAbgelehnt()
|
||||||
|
{
|
||||||
|
var tool = Build();
|
||||||
|
|
||||||
|
var result = await tool.ApplyUntisAbsenceStatus("irgendeine-id", "Suspendiert");
|
||||||
|
|
||||||
|
Assert.False(result.Applied);
|
||||||
|
Assert.Contains("Status", result.Message);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ApplyUntisAbsenceStatus_NichtSelektierbarerAberGueltigerEnumWert_WirdAbgelehnt()
|
||||||
|
{
|
||||||
|
// "Truant"/"Suspended" etc. sind gültige AttendanceStatus-Werte, aber keine, die WebUntis
|
||||||
|
// hier je melden würde (siehe SelectableStatuses) - müssen trotzdem abgelehnt werden, damit
|
||||||
|
// ein KI-Client nicht versehentlich einen fachlich unpassenden Status setzen kann.
|
||||||
|
var tool = Build();
|
||||||
|
|
||||||
|
var result = await tool.ApplyUntisAbsenceStatus("irgendeine-id", nameof(AttendanceStatus.Truant));
|
||||||
|
|
||||||
|
Assert.False(result.Applied);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ApplyUntisAbsenceStatus_UnbekannteRowId_WirdAbgelehntOhneBestaetigung()
|
||||||
|
{
|
||||||
|
var confirmation = new FakeMcpConfirmation();
|
||||||
|
var tool = Build(confirmation: confirmation);
|
||||||
|
|
||||||
|
var result = await tool.ApplyUntisAbsenceStatus("nie-vergeben", nameof(AttendanceStatus.Excused));
|
||||||
|
|
||||||
|
Assert.False(result.Applied);
|
||||||
|
Assert.Contains("row-id", result.Message);
|
||||||
|
Assert.Equal(0, confirmation.CallCount); // erst gar nicht bis zur Bestätigung vorgedrungen
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
using LehrerApp.Core.Models;
|
using LehrerApp.Core.Models;
|
||||||
|
using LehrerApp.Core.Services;
|
||||||
using LehrerApp.Desktop.Services;
|
using LehrerApp.Desktop.Services;
|
||||||
using Xunit;
|
using Xunit;
|
||||||
|
|
||||||
@@ -108,4 +109,34 @@ public sealed class UntisHubServiceTests
|
|||||||
|
|
||||||
Assert.Equal(2, rows.Count(r => r.GroupId != null));
|
Assert.Equal(2, rows.Count(r => r.GroupId != null));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void RecordRun_FehlzeitenLang_SchliesstAuchFehlzeitenKurzDerselbenGruppeAb()
|
||||||
|
{
|
||||||
|
// Nutzer-Feedback: der Langzeit-Abgleich deckt das kurzfristige Zeitfenster als Teilmenge mit
|
||||||
|
// ab - ohne diese Kopplung bliebe die kurzfristige Kadenz trotz erledigtem Langzeit-Abgleich
|
||||||
|
// als fällig stehen.
|
||||||
|
var group = Group("9a", 42);
|
||||||
|
var jobStates = new FakeUntisHubJobStates();
|
||||||
|
var hub = new UntisHubService(new FakeGroups([group]), jobStates, new SchoolYearService());
|
||||||
|
|
||||||
|
hub.RecordRun(UntisHubJobKind.FehlzeitenLang, group.Id, "3 Fehlzeiten übernommen");
|
||||||
|
|
||||||
|
var kurzState = jobStates.Get(UntisHubJobKind.FehlzeitenKurz, group.Id);
|
||||||
|
var langState = jobStates.Get(UntisHubJobKind.FehlzeitenLang, group.Id);
|
||||||
|
Assert.NotNull(kurzState?.LastRunAt);
|
||||||
|
Assert.Equal(langState!.LastRunAt, kurzState!.LastRunAt);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void RecordRun_FehlzeitenKurz_LaesstFehlzeitenLangUnangetastet()
|
||||||
|
{
|
||||||
|
var group = Group("9a", 42);
|
||||||
|
var jobStates = new FakeUntisHubJobStates();
|
||||||
|
var hub = new UntisHubService(new FakeGroups([group]), jobStates, new SchoolYearService());
|
||||||
|
|
||||||
|
hub.RecordRun(UntisHubJobKind.FehlzeitenKurz, group.Id, "keine Abweichungen");
|
||||||
|
|
||||||
|
Assert.Null(jobStates.Get(UntisHubJobKind.FehlzeitenLang, group.Id));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,4 +18,18 @@ public sealed class UntisNameMatchingTests
|
|||||||
[InlineData("Ben Schmidt", "")]
|
[InlineData("Ben Schmidt", "")]
|
||||||
public void NamesMatch_LehntUnterschiedlicheOderFehlendeNamenAb(string? a, string b) =>
|
public void NamesMatch_LehntUnterschiedlicheOderFehlendeNamenAb(string? a, string b) =>
|
||||||
Assert.False(UntisNameMatching.NamesMatch(a, 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!;
|
public static IServiceProvider Services { get; private set; } = null!;
|
||||||
private static ServiceProvider? _serviceProvider;
|
private static ServiceProvider? _serviceProvider;
|
||||||
private static bool _exitHandlerAttached;
|
private static bool _exitHandlerAttached;
|
||||||
|
private static Task _initialPolls = Task.CompletedTask;
|
||||||
|
private static readonly CancellationTokenSource StartupCancellation = new();
|
||||||
|
|
||||||
public override void Initialize() => AvaloniaXamlLoader.Load(this);
|
public override void Initialize() => AvaloniaXamlLoader.Load(this);
|
||||||
|
|
||||||
@@ -65,7 +67,11 @@ public class App : Application
|
|||||||
promptVm.OnUnlocked = async password =>
|
promptVm.OnUnlocked = async password =>
|
||||||
{
|
{
|
||||||
AppBootstrapper.DbPassword = 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;
|
desktop.MainWindow = promptWindow;
|
||||||
promptWindow.Show();
|
promptWindow.Show();
|
||||||
@@ -79,14 +85,21 @@ public class App : Application
|
|||||||
private static async Task StartMainAppAsync(
|
private static async Task StartMainAppAsync(
|
||||||
IClassicDesktopStyleApplicationLifetime desktop, Window? windowToClose = null)
|
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 = _serviceProvider;
|
||||||
Services.GetRequiredService<AppLogger>().Info("Anwendung gestartet.");
|
Services.GetRequiredService<AppLogger>().Info("Anwendung gestartet.");
|
||||||
GlobalExceptionHandler.AttachNotifications(Services.GetRequiredService<NotificationService>());
|
GlobalExceptionHandler.AttachNotifications(Services.GetRequiredService<NotificationService>());
|
||||||
// Papierkorb (14.3): Einträge älter als 30 Tage endgültig entfernen. Beim Start statt per
|
// 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
|
// Timer - reicht für ein Werkzeug, das ohnehin nur "Fehlklick eben rückgängig machen" sein
|
||||||
// soll, kein dauerhaftes Archiv.
|
// 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)
|
if (!_exitHandlerAttached)
|
||||||
{
|
{
|
||||||
@@ -99,19 +112,13 @@ public class App : Application
|
|||||||
// Opt-in - ein Neustart der App richtet den Pipe-Listener neu ein.
|
// Opt-in - ein Neustart der App richtet den Pipe-Listener neu ein.
|
||||||
Services.GetRequiredService<Services.Mcp.McpServerHostedService>().Start();
|
Services.GetRequiredService<Services.Mcp.McpServerHostedService>().Start();
|
||||||
|
|
||||||
|
splash?.SetProgress(75, "Übersicht vorbereiten …");
|
||||||
|
await Dispatcher.UIThread.InvokeAsync(() => { }, DispatcherPriority.Background);
|
||||||
var mainVm = Services.GetRequiredService<MainWindowViewModel>();
|
var mainVm = Services.GetRequiredService<MainWindowViewModel>();
|
||||||
WireCallbacks(mainVm);
|
WireCallbacks(mainVm);
|
||||||
|
|
||||||
// Beide optionalen Erstabgleiche noch unter dem Splashscreen abschließen. Ihre
|
splash?.SetProgress(90, "Hauptfenster öffnen …");
|
||||||
// CPU-/Datenbankarbeit läuft innerhalb der Dienste im Threadpool; dadurch öffnet das
|
await Dispatcher.UIThread.InvokeAsync(() => { }, DispatcherPriority.Background);
|
||||||
// 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);
|
|
||||||
|
|
||||||
var main = new MainWindow { DataContext = mainVm };
|
var main = new MainWindow { DataContext = mainVm };
|
||||||
main.EnableWindowSizePersistence(Services.GetRequiredService<WindowSettingsService>());
|
main.EnableWindowSizePersistence(Services.GetRequiredService<WindowSettingsService>());
|
||||||
@@ -119,7 +126,30 @@ public class App : Application
|
|||||||
main.EnableFinalSync(syncEngine);
|
main.EnableFinalSync(syncEngine);
|
||||||
desktop.MainWindow = main;
|
desktop.MainWindow = main;
|
||||||
main.Show();
|
main.Show();
|
||||||
|
splash?.SetProgress(100, "Bereit");
|
||||||
windowToClose?.Close();
|
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
|
/// <summary>Wechselt die Darstellung sofort, ohne Neustart (12.4) — Avalonia stylt den
|
||||||
@@ -140,6 +170,8 @@ public class App : Application
|
|||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
|
StartupCancellation.Cancel();
|
||||||
|
_initialPolls.GetAwaiter().GetResult();
|
||||||
serviceProvider.GetService<LiteDbContext>()?.Checkpoint();
|
serviceProvider.GetService<LiteDbContext>()?.Checkpoint();
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
|
|||||||
@@ -101,7 +101,7 @@ public static class AppBootstrapper
|
|||||||
Environment.Exit(0);
|
Environment.Exit(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
public static ServiceProvider BuildServices()
|
public static ServiceProvider BuildServices(Action<int, string>? reportProgress = null)
|
||||||
{
|
{
|
||||||
var services = new ServiceCollection();
|
var services = new ServiceCollection();
|
||||||
|
|
||||||
@@ -123,13 +123,16 @@ public static class AppBootstrapper
|
|||||||
services.AddSingleton<ITemplateLoader, TemplateLoader>();
|
services.AddSingleton<ITemplateLoader, TemplateLoader>();
|
||||||
services.AddSingleton<ITemplateRenderer, QuestTemplateRenderer>();
|
services.AddSingleton<ITemplateRenderer, QuestTemplateRenderer>();
|
||||||
services.AddSingleton(_ => new TemplateStore(appData));
|
services.AddSingleton(_ => new TemplateStore(appData));
|
||||||
|
services.AddSingleton(_ => new WorksheetTemplateStore(new TemplateStore(appData, subfolder: "worksheet-template-packages")));
|
||||||
|
|
||||||
// ── Datensicherheit (13.3) ───────────────────────────────────────────
|
// ── Datensicherheit (13.3) ───────────────────────────────────────────
|
||||||
// Backup läuft vor dem Öffnen der Datenbank, rein dateibasiert (unabhängig von
|
// Backup läuft vor dem Öffnen der Datenbank, rein dateibasiert (unabhängig von
|
||||||
// Verschlüsselung) — reine Datei-Kopie, kein offener LiteDB-Handle nötig.
|
// Verschlüsselung) — reine Datei-Kopie, kein offener LiteDB-Handle nötig.
|
||||||
var backupSettings = new BackupSettingsService(appData);
|
var backupSettings = new BackupSettingsService(appData);
|
||||||
var backup = new BackupService(appData) { SecondaryBackupDirectory = backupSettings.LoadSecondaryDirectory() };
|
var backup = new BackupService(appData) { SecondaryBackupDirectory = backupSettings.LoadSecondaryDirectory() };
|
||||||
|
reportProgress?.Invoke(10, "Sicherung erstellen …");
|
||||||
var backupPath = backup.CreateBackup(DbPath);
|
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
|
// 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.
|
// Programmstart — ein beschädigtes Backup soll auffallen, nicht den Nutzer aufhalten.
|
||||||
if (backupPath is not null && !new DatabaseEncryptionService().CanOpenAndRead(backupPath, DbPassword))
|
if (backupPath is not null && !new DatabaseEncryptionService().CanOpenAndRead(backupPath, DbPassword))
|
||||||
@@ -226,6 +229,10 @@ public static class AppBootstrapper
|
|||||||
services.AddSingleton<LessonPlanTools>();
|
services.AddSingleton<LessonPlanTools>();
|
||||||
services.AddSingleton<GroupMembershipTools>();
|
services.AddSingleton<GroupMembershipTools>();
|
||||||
services.AddSingleton<LetterTemplateTools>();
|
services.AddSingleton<LetterTemplateTools>();
|
||||||
|
services.AddSingleton<StudentAttendanceCalendarService>();
|
||||||
|
services.AddSingleton<CompetencyTools>();
|
||||||
|
services.AddSingleton<GroupTools>();
|
||||||
|
services.AddSingleton<UntisComparisonTools>();
|
||||||
services.AddSingleton<McpServerHostedService>();
|
services.AddSingleton<McpServerHostedService>();
|
||||||
services.AddSingleton<McpClientRegistrationService>();
|
services.AddSingleton<McpClientRegistrationService>();
|
||||||
|
|
||||||
@@ -359,7 +366,9 @@ public static class AppBootstrapper
|
|||||||
services.AddTransient<TrashViewModel>();
|
services.AddTransient<TrashViewModel>();
|
||||||
services.AddTransient<SettingsViewModel>();
|
services.AddTransient<SettingsViewModel>();
|
||||||
|
|
||||||
|
reportProgress?.Invoke(40, "Datenbank öffnen und aktualisieren …");
|
||||||
var provider = services.BuildServiceProvider();
|
var provider = services.BuildServiceProvider();
|
||||||
|
provider.GetRequiredService<LiteDbContext>();
|
||||||
|
|
||||||
// Sync-agnostischer Hook auf LiteDbContext (siehe LiteDbContext.OnChange) wird erst hier,
|
// Sync-agnostischer Hook auf LiteDbContext (siehe LiteDbContext.OnChange) wird erst hier,
|
||||||
// außerhalb der Repository-Registrierung, mit der tatsächlichen Sync-Logik verbunden.
|
// außerhalb der Repository-Registrierung, mit der tatsächlichen Sync-Logik verbunden.
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using Avalonia;
|
using Avalonia;
|
||||||
|
using LehrerApp.Core.Mcp;
|
||||||
using LehrerApp.Desktop.Services;
|
using LehrerApp.Desktop.Services;
|
||||||
|
|
||||||
namespace LehrerApp.Desktop;
|
namespace LehrerApp.Desktop;
|
||||||
@@ -8,6 +9,10 @@ class Program
|
|||||||
[STAThread]
|
[STAThread]
|
||||||
public static void Main(string[] args)
|
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();
|
var logger = AppBootstrapper.EnsureLogger();
|
||||||
GlobalExceptionHandler.Install(logger);
|
GlobalExceptionHandler.Install(logger);
|
||||||
|
|
||||||
|
|||||||
@@ -188,6 +188,7 @@ public class AiPlanningService(HttpClient http, ILessonRepository lessons,
|
|||||||
StartTime = l.StartTime,
|
StartTime = l.StartTime,
|
||||||
Homework = l.Homework,
|
Homework = l.Homework,
|
||||||
Reflection = l.Reflection,
|
Reflection = l.Reflection,
|
||||||
|
PlanningIdeas = l.PlanningIdeas,
|
||||||
Phases = ToAiPhases(l.Phases, pathNames),
|
Phases = ToAiPhases(l.Phases, pathNames),
|
||||||
})
|
})
|
||||||
.ToList();
|
.ToList();
|
||||||
@@ -270,6 +271,9 @@ public class AiPlanningService(HttpClient http, ILessonRepository lessons,
|
|||||||
if (!string.Equals(existing.Reflection, proposed.Reflection, StringComparison.Ordinal))
|
if (!string.Equals(existing.Reflection, proposed.Reflection, StringComparison.Ordinal))
|
||||||
diffs.Add("Reflexion geändert");
|
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 pathNames = altPaths.GetAll().ToDictionary(p => p.Id, p => p.Name);
|
||||||
var existingPhases = ToAiPhases(existing.Phases, pathNames);
|
var existingPhases = ToAiPhases(existing.Phases, pathNames);
|
||||||
if (!PhasesEqual(existingPhases, proposed.Phases))
|
if (!PhasesEqual(existingPhases, proposed.Phases))
|
||||||
@@ -586,6 +590,7 @@ public class AiPlanningService(HttpClient http, ILessonRepository lessons,
|
|||||||
StartTime = ai.StartTime,
|
StartTime = ai.StartTime,
|
||||||
Homework = ai.Homework,
|
Homework = ai.Homework,
|
||||||
Reflection = ai.Reflection,
|
Reflection = ai.Reflection,
|
||||||
|
PlanningIdeas = ai.PlanningIdeas,
|
||||||
// Status bleibt bei einer Änderung erhalten — sonst würde eine bereits
|
// Status bleibt bei einer Änderung erhalten — sonst würde eine bereits
|
||||||
// durchgeführte Stunde durch eine KI-Anpassung stillschweigend auf "Geplant"
|
// durchgeführte Stunde durch eine KI-Anpassung stillschweigend auf "Geplant"
|
||||||
// zurückgesetzt (die KI kennt/liefert diesen Status gar nicht).
|
// zurückgesetzt (die KI kennt/liefert diesen Status gar nicht).
|
||||||
@@ -597,6 +602,10 @@ public class AiPlanningService(HttpClient http, ILessonRepository lessons,
|
|||||||
Activity = p.Activity,
|
Activity = p.Activity,
|
||||||
Material = p.Material,
|
Material = p.Material,
|
||||||
Shorthand = p.Shorthand,
|
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)
|
AlternativePathId = p.AlternativePathName is { } name && pathIdsByName.TryGetValue(name, out var pathId)
|
||||||
? pathId : null,
|
? pathId : null,
|
||||||
}).ToList(),
|
}).ToList(),
|
||||||
@@ -605,6 +614,59 @@ public class AiPlanningService(HttpClient http, ILessonRepository lessons,
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Fragt für eine Charge anonymisierter Fehlzeitenzeilen (ai-backend/untis-status.php,
|
||||||
|
/// Nutzer-Feedback zum Untis-Hub) einen Statusvorschlag ab - eine Anfrage für alle fraglichen
|
||||||
|
/// Zeilen eines Abgleichslaufs statt einer je Zeile (Kosten/Latenz-Überlegung aus der
|
||||||
|
/// Nutzerdiskussion). Liefert nur dann Vorschläge zurück, wenn die vom Backend gemeldete
|
||||||
|
/// Id-Menge exakt der gesendeten entspricht (keine fehlenden, zusätzlichen oder doppelten Ids) -
|
||||||
|
/// andernfalls eine leere Zuordnung, statt sich auf eine möglicherweise vermischte Reihenfolge
|
||||||
|
/// zu verlassen. Der Aufrufer behält für jede nicht zurückgelieferte Id den bisherigen
|
||||||
|
/// regelbasierten Status bei.
|
||||||
|
/// </summary>
|
||||||
|
public async Task<IReadOnlyDictionary<string, string>> RequestUntisStatusSuggestionsAsync(
|
||||||
|
IReadOnlyList<AiUntisStatusRow> rows, string token)
|
||||||
|
{
|
||||||
|
if (rows.Count == 0) return new Dictionary<string, string>();
|
||||||
|
|
||||||
|
using var req = new HttpRequestMessage(HttpMethod.Post, "untis-status.php")
|
||||||
|
{
|
||||||
|
Content = JsonContent.Create(new AiUntisStatusRequest { Rows = rows.ToList() }, options: JsonOptions),
|
||||||
|
};
|
||||||
|
req.Headers.Authorization = new("Bearer", token);
|
||||||
|
|
||||||
|
HttpResponseMessage resp;
|
||||||
|
try { resp = await http.SendAsync(req); }
|
||||||
|
catch (HttpRequestException)
|
||||||
|
{
|
||||||
|
throw new AiBackendException("Der KI-Dienst ist nicht erreichbar. Bitte Internetverbindung prüfen.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (resp.StatusCode == HttpStatusCode.Unauthorized)
|
||||||
|
throw new AiBackendException("Anmeldung abgelaufen. Bitte in den Einstellungen erneut anmelden.");
|
||||||
|
if (resp.StatusCode == (HttpStatusCode)402)
|
||||||
|
throw new AiBackendException("Nicht genügend KI-Guthaben. Bitte Guthaben aufladen.");
|
||||||
|
if (!resp.IsSuccessStatusCode)
|
||||||
|
throw await BuildRequestFailedExceptionAsync(resp);
|
||||||
|
|
||||||
|
AiUntisStatusResponse? result;
|
||||||
|
try { result = await resp.Content.ReadFromJsonAsync<AiUntisStatusResponse>(JsonOptions); }
|
||||||
|
catch (Exception ex) when (ex is not AiBackendException)
|
||||||
|
{
|
||||||
|
throw new AiBackendException("Die Antwort der KI konnte nicht verarbeitet werden. Bitte erneut versuchen.");
|
||||||
|
}
|
||||||
|
if (result is null)
|
||||||
|
throw new AiBackendException("Die Antwort der KI konnte nicht verarbeitet werden.");
|
||||||
|
|
||||||
|
var sentIds = rows.Select(r => r.Id).ToHashSet();
|
||||||
|
var receivedIds = result.Suggestions.Select(s => s.Id).ToList();
|
||||||
|
if (receivedIds.Count != sentIds.Count || receivedIds.Distinct().Count() != receivedIds.Count
|
||||||
|
|| !sentIds.SetEquals(receivedIds))
|
||||||
|
return new Dictionary<string, string>();
|
||||||
|
|
||||||
|
return result.Suggestions.ToDictionary(s => s.Id, s => s.Status);
|
||||||
|
}
|
||||||
|
|
||||||
private class LoginResult { public string Token { get; set; } = ""; }
|
private class LoginResult { public string Token { get; set; } = ""; }
|
||||||
private class BalanceResult { public decimal BalanceUsd { get; set; } }
|
private class BalanceResult { public decimal BalanceUsd { get; set; } }
|
||||||
private class BackendErrorResult
|
private class BackendErrorResult
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ public sealed class AnnualPlanSyncService : IDisposable
|
|||||||
_timer = new Timer(async _ => await PollAsync(), null, PollInterval, PollInterval);
|
_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;
|
if (!await _gate.WaitAsync(0).ConfigureAwait(false)) return;
|
||||||
try
|
try
|
||||||
@@ -47,7 +47,7 @@ public sealed class AnnualPlanSyncService : IDisposable
|
|||||||
string icsText;
|
string icsText;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
icsText = await _http.GetStringAsync(url).ConfigureAwait(false);
|
icsText = await _http.GetStringAsync(url, cancellationToken).ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
using Avalonia.Controls;
|
||||||
|
using LehrerApp.Core.Interfaces;
|
||||||
|
using LehrerApp.Core.Models;
|
||||||
|
using LehrerApp.Desktop.ViewModels.Students;
|
||||||
|
using LehrerApp.Desktop.Views.Students;
|
||||||
|
using LehrerApp.Templating;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
|
||||||
|
namespace LehrerApp.Desktop.Services;
|
||||||
|
|
||||||
|
/// <summary>Öffnet den bestehenden Elternbrief-Dialog für einen Schüler, egal ob der Aufruf aus
|
||||||
|
/// der Schülerdetailansicht (Student schon im DataContext) oder aus dem Formulare-Menü (Student
|
||||||
|
/// erst per Picker gewählt) kommt.</summary>
|
||||||
|
public static class LetterDialogs
|
||||||
|
{
|
||||||
|
public static async Task ShowCreateLetterDialogAsync(Window owner, Student student)
|
||||||
|
{
|
||||||
|
var vm = new CreateLetterDialogViewModel(student,
|
||||||
|
App.Services.GetRequiredService<TemplateStore>(),
|
||||||
|
App.Services.GetRequiredService<ITemplateRenderer>(),
|
||||||
|
App.Services.GetRequiredService<IGroupMembershipRepository>(),
|
||||||
|
App.Services.GetRequiredService<IGroupRepository>(),
|
||||||
|
(options, contentWidth, millimeterScale, contentHeight) => App.Services.GetRequiredService<StudentAttendanceCalendarService>()
|
||||||
|
.Build(student, options, contentWidth, millimeterScale, contentHeight),
|
||||||
|
(options, contentWidth, millimeterScale, contentHeight) => App.Services.GetRequiredService<StudentAttendanceCalendarService>()
|
||||||
|
.BuildAbsenceDayList(student, options, contentWidth, millimeterScale),
|
||||||
|
RefreshAttendanceDataAsync);
|
||||||
|
var dialog = new CreateLetterDialog { DataContext = vm };
|
||||||
|
var path = await dialog.ShowDialog<string?>(owner);
|
||||||
|
if (!string.IsNullOrEmpty(path) && File.Exists(path))
|
||||||
|
System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo(path) { UseShellExecute = true });
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Holt genau den im Anwesenheitskalender-Dialog gewählten Zeitraum gezielt per
|
||||||
|
/// WebUntis nach (ausgelöst durch den expliziten "Konfigurieren…"-Klick, kein Hintergrundabruf
|
||||||
|
/// beim bloßen Öffnen des Briefdialogs). Damit sieht <see cref="StudentAttendanceCalendarService"/>
|
||||||
|
/// anschließend frische Daten im lokalen Cache, statt stillschweigend "keine Fehltage" zu
|
||||||
|
/// melden, nur weil die Klassenlehrer-Übersicht für diesen Zeitraum noch nie geöffnet wurde.</summary>
|
||||||
|
private static async Task RefreshAttendanceDataAsync(AttendanceCalendarOptions options, CancellationToken token)
|
||||||
|
{
|
||||||
|
var className = App.Services.GetRequiredService<WebUntisSettingsService>().HomeroomClassName;
|
||||||
|
if (string.IsNullOrWhiteSpace(className)) return;
|
||||||
|
var cache = App.Services.GetRequiredService<UntisReportCacheService>();
|
||||||
|
var start = options.NormalizedStartMonth;
|
||||||
|
var end = start.AddMonths(options.NormalizedMonthCount).AddDays(-1);
|
||||||
|
await cache.GetAbsencesAsync(className, start, end, token: token);
|
||||||
|
await cache.GetClassRegisterEventsAsync(className, start, end, token: token);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,27 +15,70 @@ public static class LetterPlaceholderBuilder
|
|||||||
{
|
{
|
||||||
public static Dictionary<string, PlaceholderValue> BuildStandardValues(
|
public static Dictionary<string, PlaceholderValue> BuildStandardValues(
|
||||||
Student student, Contact? contact, LearningGroup? group, DateOnly date,
|
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 = FormatAddress(contact);
|
||||||
var address = string.Join(Environment.NewLine, new[] { contact?.Name, contact?.Street, cityLine }.Where(x => !string.IsNullOrWhiteSpace(x)));
|
|
||||||
return new Dictionary<string, PlaceholderValue>(StringComparer.Ordinal)
|
return new Dictionary<string, PlaceholderValue>(StringComparer.Ordinal)
|
||||||
{
|
{
|
||||||
["Datum"] = new DateValue(date), ["CurrentDate"] = new DateValue(date),
|
["Datum"] = new DateValue(date), ["CurrentDate"] = new DateValue(date),
|
||||||
["Empfaenger"] = new TextValue(contact?.Name ?? ""), ["Anrede"] = new TextValue(contact?.LetterSalutation ?? ""),
|
["Empfaenger"] = new TextValue(contact?.Name ?? ""), ["Anrede"] = new TextValue(contact?.LetterSalutation ?? ""),
|
||||||
["Brieftext"] = new MultilineValue(letterText), ["LehrerName"] = new TextValue(teacherName),
|
["Brieftext"] = new MultilineValue(letterText), ["LehrerName"] = new TextValue(teacherName),
|
||||||
["Student.FirstName"] = new TextValue(student.FirstName), ["Student.LastName"] = new TextValue(student.LastName),
|
["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.Name"] = new TextValue(contact?.Name ?? ""), ["Contact.Address"] = new MultilineValue(address),
|
||||||
["Contact.Street"] = new TextValue(contact?.Street ?? ""), ["Contact.PostalCode"] = new TextValue(contact?.PostalCode ?? ""),
|
["Contact.Street"] = new TextValue(contact?.Street ?? ""), ["Contact.PostalCode"] = new TextValue(contact?.PostalCode ?? ""),
|
||||||
["Contact.City"] = new TextValue(contact?.City ?? ""), ["Letter.Salutation"] = new TextValue(contact?.LetterSalutation ?? ""),
|
["Contact.City"] = new TextValue(contact?.City ?? ""), ["Letter.Salutation"] = new TextValue(contact?.LetterSalutation ?? ""),
|
||||||
["Group.Name"] = new TextValue(group?.Name ?? ""), ["SchoolYear"] = new TextValue(group?.SchoolYear ?? ""),
|
["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
|
public static bool IsEmpty(PlaceholderValue value) => value switch
|
||||||
{
|
{
|
||||||
TextValue x => string.IsNullOrWhiteSpace(x.Value),
|
TextValue x => string.IsNullOrWhiteSpace(x.Value),
|
||||||
MultilineValue x => string.IsNullOrWhiteSpace(x.Value),
|
MultilineValue x => string.IsNullOrWhiteSpace(x.Value),
|
||||||
|
DrawingValue x => x.ContentHeight <= 0 || x.Commands.Count == 0,
|
||||||
_ => false,
|
_ => false,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/// <summary>Tatsächliche DRAWBOX/FLOWDRAWBOX-Größe eines Platzhalters (in der Maßeinheit der
|
||||||
|
/// Vorlage) plus dem Umrechnungsfaktor "1mm in dieser Maßeinheit". DrawingValue-Builder wie
|
||||||
|
/// <see cref="StudentAttendanceCalendarDrawingBuilder"/> entwerfen ihr Raster in Millimetern;
|
||||||
|
/// ohne MillimeterScale bliebe das bei einer in "pt" (statt "mm") deklarierten Vorlage viel zu
|
||||||
|
/// klein (1 "mm-Einheit" würde als 1pt ≈ 0.35mm gerendert). IsFixed unterscheidet DRAWBOX (feste,
|
||||||
|
/// nicht umbrechende Box - Inhalt, der die Höhe sprengt, wird von QuestTemplateRenderer
|
||||||
|
/// stillschweigend am unteren Rand abgeschnitten statt umzubrechen) von FLOWDRAWBOX (fließt bei
|
||||||
|
/// Bedarf automatisch auf weitere Seiten, siehe DrawingElementRenderer.Slice) - nur bei
|
||||||
|
/// IsFixed=true darf/muss ein Builder seine Höhe an Height anpassen.</summary>
|
||||||
|
public readonly record struct DeclaredDrawingBox(float Width, float Height, float MillimeterScale, bool IsFixed);
|
||||||
|
|
||||||
|
public static DeclaredDrawingBox? FindDeclaredDrawingBox(LoadedTemplate template, string placeholderName) =>
|
||||||
|
FindDeclaredDrawingBox(template.Layout, placeholderName) ??
|
||||||
|
(template.ContinuationLayout is not null ? FindDeclaredDrawingBox(template.ContinuationLayout, placeholderName) : null);
|
||||||
|
|
||||||
|
private static DeclaredDrawingBox? FindDeclaredDrawingBox(TemplateLayout layout, string placeholderName)
|
||||||
|
{
|
||||||
|
var box = layout.Elements.Concat(layout.PageTemplates.SelectMany(p => p.Elements))
|
||||||
|
.Concat(layout.ContentFlows.SelectMany(f => f.Elements))
|
||||||
|
.Select(e => e switch
|
||||||
|
{
|
||||||
|
DrawBoxElement draw when draw.Placeholder == placeholderName => ((float Width, float Height, bool IsFixed)?)(draw.Width, draw.Height, true),
|
||||||
|
FlowDrawBoxElement flow when flow.Placeholder == placeholderName => (flow.Width, flow.Height, false),
|
||||||
|
_ => null,
|
||||||
|
})
|
||||||
|
.FirstOrDefault(b => b is not null);
|
||||||
|
if (box is null) return null;
|
||||||
|
var millimeterScale = UnitConverter.Points(1, "mm") / UnitConverter.Points(1, layout.Unit);
|
||||||
|
return new DeclaredDrawingBox(box.Value.Width, box.Value.Height, millimeterScale, box.Value.IsFixed);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,27 +1,59 @@
|
|||||||
|
using System.Runtime.CompilerServices;
|
||||||
using Avalonia;
|
using Avalonia;
|
||||||
using Avalonia.Controls;
|
using Avalonia.Controls;
|
||||||
using Avalonia.Controls.ApplicationLifetimes;
|
using Avalonia.Controls.ApplicationLifetimes;
|
||||||
using Avalonia.Threading;
|
using Avalonia.Threading;
|
||||||
using LehrerApp.Desktop.Views.Shared;
|
using LehrerApp.Core.Services;
|
||||||
|
using LehrerApp.Desktop.Views.Mcp;
|
||||||
|
|
||||||
namespace LehrerApp.Desktop.Services.Mcp;
|
namespace LehrerApp.Desktop.Services.Mcp;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Produktive <see cref="IMcpConfirmationService"/>-Implementierung: zeigt den bestehenden
|
/// Produktive <see cref="IMcpConfirmationService"/>-Implementierung: zeigt <see cref="McpConfirmDialog"/>
|
||||||
/// <see cref="ConfirmDialog"/> (Views/Shared) über dem Hauptfenster an. Der aufrufende Tool-Handler
|
/// über dem Hauptfenster an. Der aufrufende Tool-Handler läuft auf einem Hintergrund-Thread
|
||||||
/// läuft auf einem Hintergrund-Thread (MCP-Pipe-Session in <see cref="McpServerHostedService"/>),
|
/// (MCP-Pipe-Session in <see cref="McpServerHostedService"/>), deshalb Marshalling über
|
||||||
/// deshalb Marshalling über <see cref="Dispatcher.UIThread"/>.
|
/// <see cref="Dispatcher.UIThread"/>.
|
||||||
|
///
|
||||||
|
/// Nutzer-Feedback: der Dialog fiel zu wenig auf, wenn LehrerApp im Hintergrund lief (naheliegend,
|
||||||
|
/// da der Anstoß von einem KI-Client in einem anderen Fenster kommt) — deshalb wird das Hauptfenster
|
||||||
|
/// vor dem Anzeigen aus einer möglichen Minimierung geholt und aktiviert, und der Dialog selbst
|
||||||
|
/// läuft `Topmost`.
|
||||||
|
///
|
||||||
|
/// Nutzer-Feedback (Nachtrag): bei vielen gleichartigen Vorschlägen in Folge (z.B. 17x
|
||||||
|
/// "add_lesson_phase" für eine neu generierte Unterrichtseinheit) einzeln nachfragen zu müssen, ist
|
||||||
|
/// unzumutbar. Der Dialog bietet deshalb zwei Sitzungsfreigaben an ("diese Aktion" / "alle
|
||||||
|
/// Aktionen"), die als reines In-Memory-Bookkeeping auf dieser Singleton-Instanz leben — sie gelten
|
||||||
|
/// bis zum Beenden der App (neuer Prozess = neue Instanz = wieder alles ungetraut) und werden nie
|
||||||
|
/// persistiert. <paramref name="operationKey"/> ist der Name der aufrufenden Tool-Methode (siehe
|
||||||
|
/// <see cref="IMcpConfirmationService.ConfirmAsync"/>), nicht der MCP-Wire-Name.
|
||||||
///
|
///
|
||||||
/// Ohne Reaktion des Nutzers würde die Pipe-Session (und damit der wartende KI-Client) unbegrenzt
|
/// Ohne Reaktion des Nutzers würde die Pipe-Session (und damit der wartende KI-Client) unbegrenzt
|
||||||
/// hängen bleiben — nach <see cref="Timeout"/> wird der Dialog automatisch geschlossen und die
|
/// hängen bleiben — nach <see cref="Timeout"/> wird der Dialog automatisch geschlossen und die
|
||||||
/// Änderung als abgelehnt gewertet.
|
/// Änderung als abgelehnt gewertet.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class AvaloniaMcpConfirmationService : IMcpConfirmationService
|
public sealed class AvaloniaMcpConfirmationService(AppLogger logger) : IMcpConfirmationService
|
||||||
{
|
{
|
||||||
private static readonly TimeSpan Timeout = TimeSpan.FromMinutes(2);
|
private static readonly TimeSpan Timeout = TimeSpan.FromMinutes(2);
|
||||||
|
|
||||||
public async Task<bool> ConfirmAsync(string title, string message, CancellationToken ct)
|
private readonly object _trustLock = new();
|
||||||
|
private readonly HashSet<string> _trustedOperations = [];
|
||||||
|
private bool _trustAll;
|
||||||
|
|
||||||
|
public async Task<bool> ConfirmAsync(string title, string message, CancellationToken ct,
|
||||||
|
[CallerMemberName] string operationKey = "", bool allowSessionTrust = true)
|
||||||
{
|
{
|
||||||
|
if (allowSessionTrust)
|
||||||
|
{
|
||||||
|
bool alreadyTrusted;
|
||||||
|
lock (_trustLock) alreadyTrusted = _trustAll || _trustedOperations.Contains(operationKey);
|
||||||
|
if (alreadyTrusted)
|
||||||
|
{
|
||||||
|
logger.Info($"MCP: „{title}“ automatisch bestätigt (Sitzungsfreigabe für " +
|
||||||
|
$"{(_trustAll ? "alle Aktionen" : operationKey)}).");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (Application.Current?.ApplicationLifetime is not IClassicDesktopStyleApplicationLifetime { MainWindow: { } owner })
|
if (Application.Current?.ApplicationLifetime is not IClassicDesktopStyleApplicationLifetime { MainWindow: { } owner })
|
||||||
return false;
|
return false;
|
||||||
|
|
||||||
@@ -29,21 +61,38 @@ public sealed class AvaloniaMcpConfirmationService : IMcpConfirmationService
|
|||||||
// Avalonias Dispatcher-Synchronisationskontext sorgt dafür, dass die Fortsetzung nach
|
// Avalonias Dispatcher-Synchronisationskontext sorgt dafür, dass die Fortsetzung nach
|
||||||
// "await Task.WhenAny(...)" wieder auf dem UI-Thread läuft, sodass dialog.Close() dort
|
// "await Task.WhenAny(...)" wieder auf dem UI-Thread läuft, sodass dialog.Close() dort
|
||||||
// sicher aufgerufen werden kann.
|
// sicher aufgerufen werden kann.
|
||||||
return await Dispatcher.UIThread.InvokeAsync(async () =>
|
var result = await Dispatcher.UIThread.InvokeAsync(async () =>
|
||||||
{
|
{
|
||||||
var dialog = new ConfirmDialog
|
if (owner.WindowState == WindowState.Minimized) owner.WindowState = WindowState.Normal;
|
||||||
|
owner.Activate();
|
||||||
|
|
||||||
|
var dialog = new McpConfirmDialog
|
||||||
{
|
{
|
||||||
DataContext = new ConfirmDialogInfo { Title = title, Message = message, ConfirmText = "Übernehmen" },
|
DataContext = new McpConfirmDialogInfo
|
||||||
|
{
|
||||||
|
Title = title, Message = message, ConfirmText = "Übernehmen",
|
||||||
|
AllowSessionTrust = allowSessionTrust,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
var dialogTask = dialog.ShowDialog<bool>(owner);
|
var dialogTask = dialog.ShowDialog<McpConfirmDialogResult>(owner);
|
||||||
var timeoutTask = Task.Delay(Timeout, ct);
|
var timeoutTask = Task.Delay(Timeout, ct);
|
||||||
var completed = await Task.WhenAny(dialogTask, timeoutTask);
|
var completed = await Task.WhenAny(dialogTask, timeoutTask);
|
||||||
if (completed != dialogTask)
|
if (completed != dialogTask)
|
||||||
{
|
{
|
||||||
dialog.Close(false);
|
dialog.Close(McpConfirmDialogResult.Rejected);
|
||||||
return false;
|
return McpConfirmDialogResult.Rejected;
|
||||||
}
|
}
|
||||||
return await dialogTask;
|
return await dialogTask;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (result.Approved && allowSessionTrust)
|
||||||
|
{
|
||||||
|
lock (_trustLock)
|
||||||
|
{
|
||||||
|
if (result.TrustAll) _trustAll = true;
|
||||||
|
else if (result.TrustOperation) _trustedOperations.Add(operationKey);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result.Approved;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
using System.Runtime.CompilerServices;
|
||||||
|
|
||||||
namespace LehrerApp.Desktop.Services.Mcp;
|
namespace LehrerApp.Desktop.Services.Mcp;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -9,7 +11,20 @@ namespace LehrerApp.Desktop.Services.Mcp;
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public interface IMcpConfirmationService
|
public interface IMcpConfirmationService
|
||||||
{
|
{
|
||||||
/// <returns>true, wenn der Nutzer bestätigt hat; false bei Ablehnung, Timeout oder falls kein
|
/// <param name="operationKey">Identifiziert die Art der Operation für eine mögliche
|
||||||
/// Hauptfenster verfügbar ist (z.B. während des DB-Passwort-Prompts beim Start).</returns>
|
/// Sitzungsfreigabe ("diese Aktion für den Rest der Sitzung nicht mehr nachfragen") — bewusst
|
||||||
Task<bool> ConfirmAsync(string title, string message, CancellationToken ct);
|
/// per <see cref="CallerMemberNameAttribute"/> automatisch befüllt (der Name der aufrufenden
|
||||||
|
/// Tool-Methode, z.B. "AddLessonPhase"), damit kein Aufrufer diesen Parameter selbst pflegen
|
||||||
|
/// muss. Nicht Teil des MCP-Wire-Protokolls, rein internes Bestätigungs-Bookkeeping.</param>
|
||||||
|
/// <param name="allowSessionTrust">False für die eine bewusste Ausnahme, bei der eine
|
||||||
|
/// Sitzungsfreigabe nicht angeboten werden soll (Nutzer-Entscheidung zu
|
||||||
|
/// <c>get_named_untis_absence_pattern</c>: eine namentliche Fehlzeitenauskunft muss JEDES MAL
|
||||||
|
/// einzeln bestätigt werden, nie pauschal für die restliche Sitzung) — weder die vorherige
|
||||||
|
/// Prüfung auf eine bereits bestehende Freigabe noch das Setzen einer neuen finden dann statt,
|
||||||
|
/// unabhängig davon, ob zuvor schon "alle Aktionen" freigegeben wurde.</param>
|
||||||
|
/// <returns>true, wenn der Nutzer bestätigt hat (direkt oder über eine bereits erteilte
|
||||||
|
/// Sitzungsfreigabe); false bei Ablehnung, Timeout oder falls kein Hauptfenster verfügbar ist
|
||||||
|
/// (z.B. während des DB-Passwort-Prompts beim Start).</returns>
|
||||||
|
Task<bool> ConfirmAsync(string title, string message, CancellationToken ct,
|
||||||
|
[CallerMemberName] string operationKey = "", bool allowSessionTrust = true);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ using ModelContextProtocol.Server;
|
|||||||
namespace LehrerApp.Desktop.Services.Mcp;
|
namespace LehrerApp.Desktop.Services.Mcp;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// In-Process-MCP-Server (Phase 1–3, siehe Planungsdokument). Lauscht auf der Named Pipe
|
/// In-Process-MCP-Server (siehe Planungsdokument und TODO.md 4.5.25ff.). Lauscht auf der Named Pipe
|
||||||
/// <see cref="McpPipeConstants.PipeName"/> und bedient jede eingehende Verbindung (eine je
|
/// <see cref="McpPipeConstants.PipeName"/> und bedient jede eingehende Verbindung (eine je
|
||||||
/// LehrerApp.McpBridge-Instanz) als eigene MCP-Session über <see cref="StreamServerTransport"/> —
|
/// LehrerApp.McpBridge-Instanz) als eigene MCP-Session über <see cref="StreamServerTransport"/> —
|
||||||
/// ein <see cref="NamedPipeServerStream"/> ist ein normaler <see cref="Stream"/> und kann direkt
|
/// ein <see cref="NamedPipeServerStream"/> ist ein normaler <see cref="Stream"/> und kann direkt
|
||||||
@@ -31,13 +31,14 @@ public sealed class McpServerHostedService : IAsyncDisposable
|
|||||||
McpSettingsService settings, AppLogger logger,
|
McpSettingsService settings, AppLogger logger,
|
||||||
StudentTools studentTools, ExamTools examTools, GradeTools gradeTools,
|
StudentTools studentTools, ExamTools examTools, GradeTools gradeTools,
|
||||||
ScheduleTools scheduleTools, TimeEntryTools timeEntryTools, LessonPlanTools lessonPlanTools,
|
ScheduleTools scheduleTools, TimeEntryTools timeEntryTools, LessonPlanTools lessonPlanTools,
|
||||||
GroupMembershipTools groupMembershipTools, LetterTemplateTools letterTemplateTools)
|
GroupMembershipTools groupMembershipTools, LetterTemplateTools letterTemplateTools,
|
||||||
|
CompetencyTools competencyTools, UntisComparisonTools untisComparisonTools, GroupTools groupTools)
|
||||||
{
|
{
|
||||||
_settings = settings;
|
_settings = settings;
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
_serverOptions = BuildServerOptions(
|
_serverOptions = BuildServerOptions(
|
||||||
studentTools, examTools, gradeTools, scheduleTools, timeEntryTools, lessonPlanTools,
|
studentTools, examTools, gradeTools, scheduleTools, timeEntryTools, lessonPlanTools,
|
||||||
groupMembershipTools, letterTemplateTools);
|
groupMembershipTools, letterTemplateTools, competencyTools, untisComparisonTools, groupTools);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Setzt die Pipe-Server-Accept-Loop auf, falls aktiviert. Ohne Wirkung, falls
|
/// <summary>Setzt die Pipe-Server-Accept-Loop auf, falls aktiviert. Ohne Wirkung, falls
|
||||||
@@ -113,7 +114,8 @@ public sealed class McpServerHostedService : IAsyncDisposable
|
|||||||
private static McpServerOptions BuildServerOptions(
|
private static McpServerOptions BuildServerOptions(
|
||||||
StudentTools studentTools, ExamTools examTools, GradeTools gradeTools,
|
StudentTools studentTools, ExamTools examTools, GradeTools gradeTools,
|
||||||
ScheduleTools scheduleTools, TimeEntryTools timeEntryTools, LessonPlanTools lessonPlanTools,
|
ScheduleTools scheduleTools, TimeEntryTools timeEntryTools, LessonPlanTools lessonPlanTools,
|
||||||
GroupMembershipTools groupMembershipTools, LetterTemplateTools letterTemplateTools)
|
GroupMembershipTools groupMembershipTools, LetterTemplateTools letterTemplateTools,
|
||||||
|
CompetencyTools competencyTools, UntisComparisonTools untisComparisonTools, GroupTools groupTools)
|
||||||
{
|
{
|
||||||
var toolCollection = new McpServerPrimitiveCollection<McpServerTool>();
|
var toolCollection = new McpServerPrimitiveCollection<McpServerTool>();
|
||||||
|
|
||||||
@@ -129,7 +131,8 @@ public sealed class McpServerHostedService : IAsyncDisposable
|
|||||||
|
|
||||||
// Write-Tools schreiben nie direkt - jede Handler-Methode ruft selbst erst
|
// Write-Tools schreiben nie direkt - jede Handler-Methode ruft selbst erst
|
||||||
// IMcpConfirmationService auf (siehe die jeweilige Tool-Klasse). ReadOnly bewusst false,
|
// IMcpConfirmationService auf (siehe die jeweilige Tool-Klasse). ReadOnly bewusst false,
|
||||||
// Destructive bewusst false (keine der Phase-2-Schreiboperationen löscht etwas).
|
// Destructive bewusst false (keine dieser Schreiboperationen löscht etwas) - für die
|
||||||
|
// Ausnahme siehe AddDestructiveWriteTool/McpToolScope.AllowedDestructiveWriteTools.
|
||||||
void AddWriteTool(Delegate handler, string name, string description)
|
void AddWriteTool(Delegate handler, string name, string description)
|
||||||
{
|
{
|
||||||
toolCollection.Add(McpServerTool.Create(handler, new McpServerToolCreateOptions
|
toolCollection.Add(McpServerTool.Create(handler, new McpServerToolCreateOptions
|
||||||
@@ -141,8 +144,21 @@ public sealed class McpServerHostedService : IAsyncDisposable
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void AddDestructiveWriteTool(Delegate handler, string name, string description)
|
||||||
|
{
|
||||||
|
toolCollection.Add(McpServerTool.Create(handler, new McpServerToolCreateOptions
|
||||||
|
{
|
||||||
|
Name = name,
|
||||||
|
Description = description,
|
||||||
|
ReadOnly = false,
|
||||||
|
Destructive = true,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
AddReadTool(studentTools.GetStudents, "get_students",
|
AddReadTool(studentTools.GetStudents, "get_students",
|
||||||
"Listet Schüler, optional gefiltert nach Lerngruppe.");
|
"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",
|
AddReadTool(examTools.GetExams, "get_exams",
|
||||||
"Listet Klausuren, optional gefiltert nach Lerngruppe.");
|
"Listet Klausuren, optional gefiltert nach Lerngruppe.");
|
||||||
AddReadTool(gradeTools.GetGrades, "get_grades",
|
AddReadTool(gradeTools.GetGrades, "get_grades",
|
||||||
@@ -159,6 +175,16 @@ public sealed class McpServerHostedService : IAsyncDisposable
|
|||||||
"Listet importierte Elternbrief-Vorlagen mit ihren Platzhaltern.");
|
"Listet importierte Elternbrief-Vorlagen mit ihren Platzhaltern.");
|
||||||
AddReadTool(letterTemplateTools.RenderLetter, "render_letter",
|
AddReadTool(letterTemplateTools.RenderLetter, "render_letter",
|
||||||
"Erzeugt einen Elternbrief aus einer Vorlage für einen Schüler als Base64-PDF.");
|
"Erzeugt einen Elternbrief aus einer Vorlage für einen Schüler als Base64-PDF.");
|
||||||
|
AddReadTool(competencyTools.GetSubjects, "get_subjects",
|
||||||
|
"Listet alle Fächer.");
|
||||||
|
AddReadTool(competencyTools.GetCompetencyCatalog, "get_competency_catalog",
|
||||||
|
"Listet den Kompetenzkatalog eines Fachs, optional gefiltert auf eine Klassenstufe.");
|
||||||
|
AddReadTool(untisComparisonTools.GetUntisHubStatus, "get_untis_hub_status",
|
||||||
|
"Listet die Fälligkeit der Untis-Hub-Abgleiche, ohne selbst WebUntis anzufragen.");
|
||||||
|
AddReadTool(untisComparisonTools.GetUntisAbsenceRows, "get_untis_absence_rows",
|
||||||
|
"Listet anonymisierte Fehlzeiten-Diskrepanzen einer Lerngruppe gegenüber WebUntis (keine Schülernamen, nur eine row-id je Zeile).");
|
||||||
|
AddReadTool(untisComparisonTools.GetNamedUntisAbsencePattern, "get_named_untis_absence_pattern",
|
||||||
|
"Liefert Fehlzeiten MIT Schülername für explizit angegebene Schüler-IDs - Ausnahme von der sonstigen Anonymisierung, erfordert jedes Mal eine gesonderte Nutzerbestätigung.");
|
||||||
|
|
||||||
AddWriteTool(timeEntryTools.CreateTimeEntry, "create_time_entry",
|
AddWriteTool(timeEntryTools.CreateTimeEntry, "create_time_entry",
|
||||||
"Schlägt einen neuen Zeiterfassungs-Eintrag vor (Bestätigung durch den Nutzer nötig).");
|
"Schlägt einen neuen Zeiterfassungs-Eintrag vor (Bestätigung durch den Nutzer nötig).");
|
||||||
@@ -182,10 +208,40 @@ public sealed class McpServerHostedService : IAsyncDisposable
|
|||||||
"Entfernt eine Verlaufsplan-Phase aus einer Einzelstunde (Bestätigung durch den Nutzer nötig).");
|
"Entfernt eine Verlaufsplan-Phase aus einer Einzelstunde (Bestätigung durch den Nutzer nötig).");
|
||||||
AddWriteTool(lessonPlanTools.AddLessonAttachment, "add_lesson_attachment",
|
AddWriteTool(lessonPlanTools.AddLessonAttachment, "add_lesson_attachment",
|
||||||
"Fügt einer Einzelstunde ein neues Material als Base64-kodierten Anhang hinzu (Bestätigung durch den Nutzer nötig).");
|
"Fügt einer Einzelstunde ein neues Material als Base64-kodierten Anhang hinzu (Bestätigung durch den Nutzer nötig).");
|
||||||
|
AddWriteTool(lessonPlanTools.MoveLesson, "move_lesson",
|
||||||
|
"Verschiebt eine Einzelstunde auf ein neues Datum, optional mit Mitverschieben späterer Stunden derselben Einheit (Bestätigung durch den Nutzer nötig).");
|
||||||
|
AddWriteTool(lessonPlanTools.AddLessonCompetency, "add_lesson_competency",
|
||||||
|
"Ordnet einer Einzelstunde einen Kompetenzcode zu (Bestätigung durch den Nutzer nötig).");
|
||||||
|
AddWriteTool(lessonPlanTools.RemoveLessonCompetency, "remove_lesson_competency",
|
||||||
|
"Entfernt einen Kompetenzcode von einer Einzelstunde (Bestätigung durch den Nutzer nötig).");
|
||||||
|
AddWriteTool(competencyTools.CreateSubject, "create_subject",
|
||||||
|
"Legt ein neues Fach an (Bestätigung durch den Nutzer nötig).");
|
||||||
|
AddWriteTool(competencyTools.UpdateSubject, "update_subject",
|
||||||
|
"Ändert Name/Kurzform eines Fachs (Bestätigung durch den Nutzer nötig).");
|
||||||
|
AddWriteTool(competencyTools.CreateCompetencyDomain, "create_competency_domain",
|
||||||
|
"Legt einen neuen Kompetenzbereich für ein Fach/eine Klassenstufe an (Bestätigung durch den Nutzer nötig).");
|
||||||
|
AddWriteTool(competencyTools.UpdateCompetencyDomain, "update_competency_domain",
|
||||||
|
"Ändert Name/Kürzel eines Kompetenzbereichs (Bestätigung durch den Nutzer nötig).");
|
||||||
|
AddWriteTool(competencyTools.AddCompetencyItem, "add_competency_item",
|
||||||
|
"Fügt einem Kompetenzbereich eine Einzelkompetenz hinzu (Bestätigung durch den Nutzer nötig).");
|
||||||
|
AddWriteTool(competencyTools.UpdateCompetencyItem, "update_competency_item",
|
||||||
|
"Ändert Code/Beschreibung einer Einzelkompetenz (Bestätigung durch den Nutzer nötig).");
|
||||||
|
AddWriteTool(competencyTools.RemoveCompetencyItem, "remove_competency_item",
|
||||||
|
"Entfernt eine Einzelkompetenz aus einem Kompetenzbereich (Bestätigung durch den Nutzer nötig).");
|
||||||
|
AddWriteTool(untisComparisonTools.ApplyUntisAbsenceStatus, "apply_untis_absence_status",
|
||||||
|
"Übernimmt einen Statusvorschlag für eine über get_untis_absence_rows gelieferte row-id (Bestätigung durch den Nutzer nötig, nennt keinen Schülernamen).");
|
||||||
|
|
||||||
|
AddDestructiveWriteTool(lessonPlanTools.DeleteLesson, "delete_lesson",
|
||||||
|
"Löscht eine Einzelstunde endgültig, ohne Papierkorb (Bestätigung durch den Nutzer nötig).");
|
||||||
|
AddDestructiveWriteTool(competencyTools.DeleteSubject, "delete_subject",
|
||||||
|
"Löscht ein Fach endgültig, ohne Papierkorb (Bestätigung durch den Nutzer nötig).");
|
||||||
|
AddDestructiveWriteTool(competencyTools.DeleteCompetencyDomain, "delete_competency_domain",
|
||||||
|
"Löscht einen Kompetenzbereich endgültig samt Einzelkompetenzen, ohne Papierkorb (Bestätigung durch den Nutzer nötig).");
|
||||||
|
|
||||||
System.Diagnostics.Debug.Assert(
|
System.Diagnostics.Debug.Assert(
|
||||||
toolCollection.Select(t => t.ProtocolTool.Name).OrderBy(n => n)
|
toolCollection.Select(t => t.ProtocolTool.Name).OrderBy(n => n)
|
||||||
.SequenceEqual(McpToolScope.AllowedReadTools.Concat(McpToolScope.AllowedWriteTools).OrderBy(n => n)),
|
.SequenceEqual(McpToolScope.AllowedReadTools.Concat(McpToolScope.AllowedWriteTools)
|
||||||
|
.Concat(McpToolScope.AllowedDestructiveWriteTools).OrderBy(n => n)),
|
||||||
"Registrierte MCP-Tools weichen von McpToolScope ab.");
|
"Registrierte MCP-Tools weichen von McpToolScope ab.");
|
||||||
|
|
||||||
return new McpServerOptions
|
return new McpServerOptions
|
||||||
|
|||||||
@@ -7,12 +7,19 @@ namespace LehrerApp.Desktop.Services.Mcp;
|
|||||||
/// Tool-Klasse referenziert — ein KI-Client kann diese Daten technisch nicht erreichen, unabhängig
|
/// Tool-Klasse referenziert — ein KI-Client kann diese Daten technisch nicht erreichen, unabhängig
|
||||||
/// davon, wie vertrauenswürdig der lokale Modell-Client erscheint oder wie die Tool-Liste künftig
|
/// davon, wie vertrauenswürdig der lokale Modell-Client erscheint oder wie die Tool-Liste künftig
|
||||||
/// wächst. <see cref="McpServerHostedService"/> registriert nur exakt diese Namen.
|
/// wächst. <see cref="McpServerHostedService"/> registriert nur exakt diese Namen.
|
||||||
|
///
|
||||||
|
/// "get_named_untis_absence_pattern" ist die eine bewusste, eng begrenzte Ausnahme von der oben
|
||||||
|
/// beschriebenen Regel (Nutzer-Entscheidung, siehe TODO.md): es verknüpft Schülername mit
|
||||||
|
/// Fehlzeitendaten, aber nur für explizit angegebene Schüler-IDs und nur nach jedes Mal gesonderter,
|
||||||
|
/// prominenter Bestätigung ohne Sitzungsfreigabe (siehe UntisComparisonTools). Documentation/
|
||||||
|
/// Vorgang bleibt davon unberührt weiterhin vollständig ausgeschlossen.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static class McpToolScope
|
public static class McpToolScope
|
||||||
{
|
{
|
||||||
public static readonly IReadOnlyCollection<string> AllowedReadTools =
|
public static readonly IReadOnlyCollection<string> AllowedReadTools =
|
||||||
[
|
[
|
||||||
"get_students",
|
"get_students",
|
||||||
|
"get_groups",
|
||||||
"get_exams",
|
"get_exams",
|
||||||
"get_grades",
|
"get_grades",
|
||||||
"get_schedule",
|
"get_schedule",
|
||||||
@@ -21,6 +28,11 @@ public static class McpToolScope
|
|||||||
"download_lesson_attachment",
|
"download_lesson_attachment",
|
||||||
"list_letter_templates",
|
"list_letter_templates",
|
||||||
"render_letter",
|
"render_letter",
|
||||||
|
"get_subjects",
|
||||||
|
"get_competency_catalog",
|
||||||
|
"get_untis_hub_status",
|
||||||
|
"get_untis_absence_rows",
|
||||||
|
"get_named_untis_absence_pattern",
|
||||||
];
|
];
|
||||||
|
|
||||||
/// <summary>Write-Tools (Phase 2+3) — jeder Aufruf läuft über <see cref="IMcpConfirmationService"/>,
|
/// <summary>Write-Tools (Phase 2+3) — jeder Aufruf läuft über <see cref="IMcpConfirmationService"/>,
|
||||||
@@ -41,5 +53,29 @@ public static class McpToolScope
|
|||||||
"update_lesson_phase",
|
"update_lesson_phase",
|
||||||
"remove_lesson_phase",
|
"remove_lesson_phase",
|
||||||
"add_lesson_attachment",
|
"add_lesson_attachment",
|
||||||
|
"move_lesson",
|
||||||
|
"add_lesson_competency",
|
||||||
|
"remove_lesson_competency",
|
||||||
|
"create_subject",
|
||||||
|
"update_subject",
|
||||||
|
"create_competency_domain",
|
||||||
|
"update_competency_domain",
|
||||||
|
"add_competency_item",
|
||||||
|
"update_competency_item",
|
||||||
|
"remove_competency_item",
|
||||||
|
"apply_untis_absence_status",
|
||||||
|
];
|
||||||
|
|
||||||
|
/// <summary>Löschende Write-Tools — ursprünglich eine bewusste, gezielte Ausnahme von der sonst
|
||||||
|
/// geltenden "v1 ohne Lösch-Tools"-Regel für "delete_lesson" (siehe Planungsdokument), inzwischen
|
||||||
|
/// (Nutzer-Nachtrag) um die endgültigen Löschungen für Fächer/Kompetenzbereiche erweitert — beide
|
||||||
|
/// ohne Papierkorb, wie delete_lesson. Getrennt von <see cref="AllowedWriteTools"/> aufgeführt,
|
||||||
|
/// damit diese Ausnahme beim Lesen sofort auffällt. <see cref="McpServerHostedService"/>
|
||||||
|
/// registriert diese Tools zusätzlich mit <c>Destructive = true</c>.</summary>
|
||||||
|
public static readonly IReadOnlyCollection<string> AllowedDestructiveWriteTools =
|
||||||
|
[
|
||||||
|
"delete_lesson",
|
||||||
|
"delete_subject",
|
||||||
|
"delete_competency_domain",
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,252 @@
|
|||||||
|
using System.ComponentModel;
|
||||||
|
using System.Text;
|
||||||
|
using LehrerApp.Core.Interfaces;
|
||||||
|
using LehrerApp.Core.Models;
|
||||||
|
|
||||||
|
namespace LehrerApp.Desktop.Services.Mcp.Tools;
|
||||||
|
|
||||||
|
/// <summary>MCP-Tools für die Fach- und Kompetenzkatalog-Verwaltung (Nutzer-Nachtrag zu 4.5.28ff.):
|
||||||
|
/// <see cref="Subject"/> und <see cref="CompetencyDomain"/>/<see cref="CompetencyItem"/> vollständig
|
||||||
|
/// anlegen/ändern/löschen. Die Zuordnung von Kompetenzcodes zu einzelnen Stunden lebt bewusst in
|
||||||
|
/// <see cref="LessonPlanTools"/> (AddLessonCompetency/RemoveLessonCompetency), nicht hier - sie
|
||||||
|
/// gehört fachlich zur Stundenverwaltung, nicht zum Katalog selbst.
|
||||||
|
///
|
||||||
|
/// "delete_subject" und "delete_competency_domain" sind wie "delete_lesson" (LessonPlanTools)
|
||||||
|
/// endgültige Löschungen ohne Papierkorb (die Repository-Implementierungen löschen direkt in der
|
||||||
|
/// LiteDB-Collection statt über MoveToTrash) - deshalb als Destructive registriert (siehe
|
||||||
|
/// McpServerHostedService) und mit entsprechend deutlicher Bestätigungsnachricht.</summary>
|
||||||
|
public class CompetencyTools(
|
||||||
|
ISubjectRepository subjects, ICompetencyDomainRepository domains, IMcpConfirmationService confirmation)
|
||||||
|
{
|
||||||
|
// ── Lesen ────────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
[Description("Listet alle Fächer.")]
|
||||||
|
public List<SubjectDto> GetSubjects() =>
|
||||||
|
subjects.GetAll().Select(s => new SubjectDto(s.Id, s.Name, s.ShortName)).ToList();
|
||||||
|
|
||||||
|
[Description("Listet den Kompetenzkatalog (Bereiche mit Einzelkompetenzen) eines Fachs, optional gefiltert auf eine Klassenstufe.")]
|
||||||
|
public List<CompetencyDomainDto> GetCompetencyCatalog(
|
||||||
|
[Description("Fach-ID, aus get_subjects.")] Guid subjectId,
|
||||||
|
[Description("Optionale Klassenstufe. Weglassen: alle Klassenstufen dieses Fachs.")] int? gradeLevel = null)
|
||||||
|
{
|
||||||
|
var result = gradeLevel is not null
|
||||||
|
? domains.GetBySubjectAndGrade(subjectId, gradeLevel.Value)
|
||||||
|
: domains.GetBySubject(subjectId);
|
||||||
|
return result.Select(ToDto).ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Fächer (Subject) ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
[Description("Legt ein neues Fach an. Muss der Nutzer erst bestätigen.")]
|
||||||
|
public async Task<WriteResultDto> CreateSubject(
|
||||||
|
[Description("Name des Fachs, z.B. \"Mathematik\".")] string name,
|
||||||
|
[Description("Kurzform, z.B. \"Ma\".")] string shortName = "",
|
||||||
|
CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(name)) return new WriteResultDto(false, null, "Fachname darf nicht leer sein.");
|
||||||
|
|
||||||
|
var message = $"Neues Fach „{name.Trim()}“" +
|
||||||
|
(string.IsNullOrWhiteSpace(shortName) ? "" : $" ({shortName.Trim()})") + " anlegen?";
|
||||||
|
if (!await confirmation.ConfirmAsync("Fach anlegen?", message, ct))
|
||||||
|
return new WriteResultDto(false, null, "Vom Nutzer abgelehnt oder nicht bestätigt.");
|
||||||
|
|
||||||
|
var subject = new Subject { Name = name, ShortName = shortName };
|
||||||
|
try { subjects.Save(subject); }
|
||||||
|
catch (Exception ex) when (ex is ArgumentException or InvalidOperationException)
|
||||||
|
{
|
||||||
|
return new WriteResultDto(false, null, ex.Message);
|
||||||
|
}
|
||||||
|
return new WriteResultDto(true, subject.Id, "Fach gespeichert.");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Description("Ändert Name/Kurzform eines bestehenden Fachs. Nur angegebene Felder werden geändert.")]
|
||||||
|
public async Task<WriteResultDto> UpdateSubject(
|
||||||
|
[Description("Fach-ID.")] Guid subjectId,
|
||||||
|
[Description("Neuer Name. Unverändert lassen: weglassen.")] string? name = null,
|
||||||
|
[Description("Neue Kurzform. Unverändert lassen: weglassen.")] string? shortName = null,
|
||||||
|
CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var subject = subjects.GetById(subjectId);
|
||||||
|
if (subject is null) return new WriteResultDto(false, null, "Unbekannte Fach-ID.");
|
||||||
|
|
||||||
|
var changes = new StringBuilder();
|
||||||
|
if (name is not null && name != subject.Name) { changes.AppendLine($"Name: „{subject.Name}“ → „{name}“"); subject.Name = name; }
|
||||||
|
if (shortName is not null && shortName != subject.ShortName) { changes.AppendLine($"Kurzform: „{subject.ShortName}“ → „{shortName}“"); subject.ShortName = shortName; }
|
||||||
|
|
||||||
|
if (changes.Length == 0)
|
||||||
|
return new WriteResultDto(true, subject.Id, "Keine Änderung nötig.");
|
||||||
|
|
||||||
|
if (!await confirmation.ConfirmAsync("Fach ändern?", $"„{subject.Name}“\n{changes}".TrimEnd(), ct))
|
||||||
|
return new WriteResultDto(false, null, "Vom Nutzer abgelehnt oder nicht bestätigt.");
|
||||||
|
|
||||||
|
try { subjects.Save(subject); }
|
||||||
|
catch (Exception ex) when (ex is ArgumentException or InvalidOperationException)
|
||||||
|
{
|
||||||
|
return new WriteResultDto(false, null, ex.Message);
|
||||||
|
}
|
||||||
|
return new WriteResultDto(true, subject.Id, "Fach gespeichert.");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Description("Löscht ein Fach endgültig. Schlägt fehl, wenn noch eine Lerngruppe oder ein Kompetenzkatalog dieses Fach referenziert. Kein Papierkorb - nicht rückgängig zu machen. Muss der Nutzer erst bestätigen.")]
|
||||||
|
public async Task<WriteResultDto> DeleteSubject(
|
||||||
|
[Description("Fach-ID.")] Guid subjectId,
|
||||||
|
CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var subject = subjects.GetById(subjectId);
|
||||||
|
if (subject is null) return new WriteResultDto(false, null, "Unbekannte Fach-ID.");
|
||||||
|
|
||||||
|
var message = $"Das Fach „{subject.Name}“ wird endgültig gelöscht. Das kann NICHT rückgängig gemacht werden (kein Papierkorb für Fächer).";
|
||||||
|
if (!await confirmation.ConfirmAsync("Fach endgültig löschen?", message, ct))
|
||||||
|
return new WriteResultDto(false, null, "Vom Nutzer abgelehnt oder nicht bestätigt.");
|
||||||
|
|
||||||
|
try { subjects.Delete(subjectId); }
|
||||||
|
catch (InvalidOperationException ex)
|
||||||
|
{
|
||||||
|
return new WriteResultDto(false, null, ex.Message);
|
||||||
|
}
|
||||||
|
return new WriteResultDto(true, subjectId, "Fach gelöscht.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Kompetenzbereiche (CompetencyDomain) ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
[Description("Legt einen neuen Kompetenzbereich (z.B. \"Zahlen und Operationen\") für ein Fach und eine Klassenstufe an, zunächst ohne Einzelkompetenzen. Muss der Nutzer erst bestätigen.")]
|
||||||
|
public async Task<WriteResultDto> CreateCompetencyDomain(
|
||||||
|
[Description("Fach-ID, aus get_subjects.")] Guid subjectId,
|
||||||
|
[Description("Klassenstufe.")] int gradeLevel,
|
||||||
|
[Description("Name des Bereichs.")] string name,
|
||||||
|
[Description("Optionales Kürzel, z.B. \"ZO\".")] string code = "",
|
||||||
|
CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var subject = subjects.GetById(subjectId);
|
||||||
|
if (subject is null) return new WriteResultDto(false, null, "Unbekannte Fach-ID.");
|
||||||
|
if (string.IsNullOrWhiteSpace(name)) return new WriteResultDto(false, null, "Bereichsname darf nicht leer sein.");
|
||||||
|
|
||||||
|
var sortOrder = domains.GetBySubjectAndGrade(subjectId, gradeLevel).Count;
|
||||||
|
var message = $"Neuen Kompetenzbereich „{name.Trim()}“ für {subject.Name}, Klasse {gradeLevel}, anlegen?";
|
||||||
|
if (!await confirmation.ConfirmAsync("Kompetenzbereich anlegen?", message, ct))
|
||||||
|
return new WriteResultDto(false, null, "Vom Nutzer abgelehnt oder nicht bestätigt.");
|
||||||
|
|
||||||
|
var domain = new CompetencyDomain
|
||||||
|
{
|
||||||
|
SubjectId = subjectId, GradeLevel = gradeLevel, Name = name, Code = code, SortOrder = sortOrder,
|
||||||
|
};
|
||||||
|
domains.Save(domain);
|
||||||
|
return new WriteResultDto(true, domain.Id, "Kompetenzbereich gespeichert.");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Description("Ändert Name/Kürzel eines bestehenden Kompetenzbereichs. Nur angegebene Felder werden geändert.")]
|
||||||
|
public async Task<WriteResultDto> UpdateCompetencyDomain(
|
||||||
|
[Description("ID des Kompetenzbereichs, aus get_competency_catalog.")] Guid domainId,
|
||||||
|
[Description("Neuer Name. Unverändert lassen: weglassen.")] string? name = null,
|
||||||
|
[Description("Neues Kürzel. Unverändert lassen: weglassen.")] string? code = null,
|
||||||
|
CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var domain = domains.GetById(domainId);
|
||||||
|
if (domain is null) return new WriteResultDto(false, null, "Unbekannte Bereichs-ID.");
|
||||||
|
|
||||||
|
var changes = new StringBuilder();
|
||||||
|
if (name is not null && name != domain.Name) { changes.AppendLine($"Name: „{domain.Name}“ → „{name}“"); domain.Name = name; }
|
||||||
|
if (code is not null && code != domain.Code) { changes.AppendLine($"Kürzel: „{domain.Code}“ → „{code}“"); domain.Code = code; }
|
||||||
|
|
||||||
|
if (changes.Length == 0)
|
||||||
|
return new WriteResultDto(true, domain.Id, "Keine Änderung nötig.");
|
||||||
|
|
||||||
|
if (!await confirmation.ConfirmAsync("Kompetenzbereich ändern?", $"„{domain.Name}“\n{changes}".TrimEnd(), ct))
|
||||||
|
return new WriteResultDto(false, null, "Vom Nutzer abgelehnt oder nicht bestätigt.");
|
||||||
|
|
||||||
|
domains.Save(domain);
|
||||||
|
return new WriteResultDto(true, domain.Id, "Kompetenzbereich gespeichert.");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Description("Löscht einen Kompetenzbereich endgültig, inklusive aller darin enthaltenen Einzelkompetenzen. Bereits vergebene Kompetenzcodes an Einheiten/Stunden/Klausuraufgaben bleiben als Freitext bestehen, gelten aber danach als \"nicht im Katalog\". Kein Papierkorb - nicht rückgängig zu machen. Muss der Nutzer erst bestätigen.")]
|
||||||
|
public async Task<WriteResultDto> DeleteCompetencyDomain(
|
||||||
|
[Description("ID des Kompetenzbereichs.")] Guid domainId,
|
||||||
|
CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var domain = domains.GetById(domainId);
|
||||||
|
if (domain is null) return new WriteResultDto(false, null, "Unbekannte Bereichs-ID.");
|
||||||
|
|
||||||
|
var message = $"Der Kompetenzbereich „{domain.Name}“ mit {domain.Items.Count} Einzelkompetenz(en) wird endgültig gelöscht. " +
|
||||||
|
"Das kann NICHT rückgängig gemacht werden (kein Papierkorb für Kompetenzbereiche).";
|
||||||
|
if (!await confirmation.ConfirmAsync("Kompetenzbereich endgültig löschen?", message, ct))
|
||||||
|
return new WriteResultDto(false, null, "Vom Nutzer abgelehnt oder nicht bestätigt.");
|
||||||
|
|
||||||
|
domains.Delete(domainId);
|
||||||
|
return new WriteResultDto(true, domainId, "Kompetenzbereich gelöscht.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Einzelkompetenzen (CompetencyItem) ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
[Description("Fügt einem Kompetenzbereich eine neue Einzelkompetenz hinzu (ans Ende). Muss der Nutzer erst bestätigen.")]
|
||||||
|
public async Task<WriteResultDto> AddCompetencyItem(
|
||||||
|
[Description("ID des Kompetenzbereichs, aus get_competency_catalog.")] Guid domainId,
|
||||||
|
[Description("Kompetenzcode, z.B. \"M.5.1\".")] string code,
|
||||||
|
[Description("Beschreibung der Kompetenz.")] string description,
|
||||||
|
CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var domain = domains.GetById(domainId);
|
||||||
|
if (domain is null) return new WriteResultDto(false, null, "Unbekannte Bereichs-ID.");
|
||||||
|
if (string.IsNullOrWhiteSpace(description)) return new WriteResultDto(false, null, "Beschreibung darf nicht leer sein.");
|
||||||
|
|
||||||
|
var message = $"Neue Kompetenz „{code}“ zu Bereich „{domain.Name}“ hinzufügen?\n{description}";
|
||||||
|
if (!await confirmation.ConfirmAsync("Kompetenz hinzufügen?", message, ct))
|
||||||
|
return new WriteResultDto(false, null, "Vom Nutzer abgelehnt oder nicht bestätigt.");
|
||||||
|
|
||||||
|
var item = new CompetencyItem { Code = code.Trim(), Description = description.Trim(), SortOrder = domain.Items.Count };
|
||||||
|
domain.Items.Add(item);
|
||||||
|
domains.Save(domain);
|
||||||
|
return new WriteResultDto(true, item.Id, "Kompetenz hinzugefügt.");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Description("Ändert Code/Beschreibung einer bestehenden Einzelkompetenz. Nur angegebene Felder werden geändert.")]
|
||||||
|
public async Task<WriteResultDto> UpdateCompetencyItem(
|
||||||
|
[Description("ID des Kompetenzbereichs.")] Guid domainId,
|
||||||
|
[Description("ID der Einzelkompetenz, aus get_competency_catalog.")] Guid itemId,
|
||||||
|
[Description("Neuer Kompetenzcode. Unverändert lassen: weglassen.")] string? code = null,
|
||||||
|
[Description("Neue Beschreibung. Unverändert lassen: weglassen.")] string? description = null,
|
||||||
|
CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var domain = domains.GetById(domainId);
|
||||||
|
if (domain is null) return new WriteResultDto(false, null, "Unbekannte Bereichs-ID.");
|
||||||
|
var item = domain.Items.FirstOrDefault(i => i.Id == itemId);
|
||||||
|
if (item is null) return new WriteResultDto(false, null, "Unbekannte Kompetenz-ID in diesem Bereich.");
|
||||||
|
|
||||||
|
var changes = new StringBuilder();
|
||||||
|
if (code is not null && code != item.Code) { changes.AppendLine($"Code: „{item.Code}“ → „{code}“"); item.Code = code; }
|
||||||
|
if (description is not null && description != item.Description) { changes.AppendLine($"Beschreibung: „{item.Description}“ → „{description}“"); item.Description = description; }
|
||||||
|
|
||||||
|
if (changes.Length == 0)
|
||||||
|
return new WriteResultDto(true, item.Id, "Keine Änderung nötig.");
|
||||||
|
|
||||||
|
if (!await confirmation.ConfirmAsync("Kompetenz ändern?", $"Kompetenz „{item.Code}“ in Bereich „{domain.Name}“\n{changes}".TrimEnd(), ct))
|
||||||
|
return new WriteResultDto(false, null, "Vom Nutzer abgelehnt oder nicht bestätigt.");
|
||||||
|
|
||||||
|
domains.Save(domain);
|
||||||
|
return new WriteResultDto(true, item.Id, "Kompetenz gespeichert.");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Description("Entfernt eine Einzelkompetenz aus einem Kompetenzbereich. Muss der Nutzer erst bestätigen.")]
|
||||||
|
public async Task<WriteResultDto> RemoveCompetencyItem(
|
||||||
|
[Description("ID des Kompetenzbereichs.")] Guid domainId,
|
||||||
|
[Description("ID der Einzelkompetenz, aus get_competency_catalog.")] Guid itemId,
|
||||||
|
CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var domain = domains.GetById(domainId);
|
||||||
|
if (domain is null) return new WriteResultDto(false, null, "Unbekannte Bereichs-ID.");
|
||||||
|
var item = domain.Items.FirstOrDefault(i => i.Id == itemId);
|
||||||
|
if (item is null) return new WriteResultDto(false, null, "Unbekannte Kompetenz-ID in diesem Bereich.");
|
||||||
|
|
||||||
|
var message = $"Kompetenz „{item.Code}“ ({item.Description}) aus Bereich „{domain.Name}“ entfernen?";
|
||||||
|
if (!await confirmation.ConfirmAsync("Kompetenz entfernen?", message, ct))
|
||||||
|
return new WriteResultDto(false, null, "Vom Nutzer abgelehnt oder nicht bestätigt.");
|
||||||
|
|
||||||
|
domain.Items.Remove(item);
|
||||||
|
domains.Save(domain);
|
||||||
|
return new WriteResultDto(true, item.Id, "Kompetenz entfernt.");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static CompetencyDomainDto ToDto(CompetencyDomain d) => new(
|
||||||
|
d.Id, d.SubjectId, d.GradeLevel, d.Name, d.Code, d.SortOrder,
|
||||||
|
d.Items.OrderBy(i => i.SortOrder)
|
||||||
|
.Select(i => new CompetencyItemDto(i.Id, i.Code, i.Description, i.SortOrder)).ToList());
|
||||||
|
}
|
||||||
@@ -24,14 +24,20 @@ public record TimeEntryDto(
|
|||||||
Guid Id, Guid? TaskId, string Category, Guid? GroupId, DateOnly Date,
|
Guid Id, Guid? TaskId, string Category, Guid? GroupId, DateOnly Date,
|
||||||
TimeOnly? StartTime, TimeOnly? EndTime, int DurationMinutes, string? Description);
|
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 LessonAttachmentDto(string StorageId, string FileName, long SizeBytes);
|
||||||
|
|
||||||
public record LessonDto(
|
public record LessonDto(
|
||||||
Guid Id, Guid UnitId, Guid GroupId, DateOnly Date, int? LessonNumber, string Topic,
|
Guid Id, Guid UnitId, Guid GroupId, DateOnly Date, int? LessonNumber, string Topic,
|
||||||
string? Homework, LessonStatus Status, List<LessonPhaseDto> Phases,
|
string? Homework, string? PlanningIdeas, LessonStatus Status, List<string> Competencies,
|
||||||
List<LessonAttachmentDto> Attachments);
|
List<LessonPhaseDto> Phases, List<LessonAttachmentDto> Attachments);
|
||||||
|
|
||||||
/// <summary>Ergebnis von "download_lesson_attachment": Inhalt Base64-kodiert, weil MCP-Tool-Antworten
|
/// <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) -
|
/// als JSON/Text übertragen werden. Bewusst kein Ressourcen-URI-Mechanismus (siehe Planungsdokument) -
|
||||||
@@ -45,6 +51,14 @@ public record UnitDto(
|
|||||||
|
|
||||||
public record LessonPlanResultDto(List<UnitDto> Units, List<LessonDto> Lessons);
|
public record LessonPlanResultDto(List<UnitDto> Units, List<LessonDto> Lessons);
|
||||||
|
|
||||||
|
public record SubjectDto(Guid Id, string Name, string ShortName);
|
||||||
|
|
||||||
|
public record CompetencyItemDto(Guid Id, string Code, string Description, int SortOrder);
|
||||||
|
|
||||||
|
public record CompetencyDomainDto(
|
||||||
|
Guid Id, Guid SubjectId, int GradeLevel, string Name, string Code, int SortOrder,
|
||||||
|
List<CompetencyItemDto> Items);
|
||||||
|
|
||||||
public record GroupMembershipDto(
|
public record GroupMembershipDto(
|
||||||
Guid Id, Guid StudentId, Guid GroupId, MembershipPeriod Period,
|
Guid Id, Guid StudentId, Guid GroupId, MembershipPeriod Period,
|
||||||
DateOnly? JoinedAt, DateOnly? LeftAt, Niveau? Niveau);
|
DateOnly? JoinedAt, DateOnly? LeftAt, Niveau? Niveau);
|
||||||
@@ -61,3 +75,40 @@ public record PlaceholderInfoDto(string Name, string Type, bool Required, bool I
|
|||||||
public record LetterTemplateDto(string Id, string Name, string Description, List<PlaceholderInfoDto> Placeholders);
|
public record LetterTemplateDto(string Id, string Name, string Description, List<PlaceholderInfoDto> Placeholders);
|
||||||
|
|
||||||
public record LetterRenderResultDto(bool Success, string Message, string? Base64Pdf, string? SuggestedFileName);
|
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. 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, 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>
|
||||||
|
public record UntisAbsenceRowDto(
|
||||||
|
string RowId, DateOnly Date, string ReasonText, int AbsentMinutes, bool HandledOn,
|
||||||
|
bool? ExternKeyInParentheses, string CurrentLocalStatus, string CurrentGuessStatus);
|
||||||
|
|
||||||
|
/// <summary>Eine Zeile aus dem namentlichen Ausnahmeweg (get_named_untis_absence_pattern) - im
|
||||||
|
/// Unterschied zu <see cref="UntisAbsenceRowDto"/> bewusst MIT Schülername, da genau diese
|
||||||
|
/// Zusammenführung von Name und Fehlzeitendaten der Zweck des Aufrufs ist (z.B. Fehlmuster-Vergleich
|
||||||
|
/// zwischen zwei Schülern) und der Nutzer sie je Anfrage einzeln freigegeben hat.</summary>
|
||||||
|
public record NamedUntisAbsenceRowDto(
|
||||||
|
Guid StudentId, string StudentFullName, DateOnly Date, string ReasonText,
|
||||||
|
string CurrentLocalStatus, string CurrentGuessStatus);
|
||||||
|
|
||||||
|
/// <summary><see cref="Granted"/> ist false bei Ablehnung/Timeout oder wenn keine der angegebenen
|
||||||
|
/// Schüler-IDs bekannt war - <see cref="Rows"/> ist dann null, nicht nur leer, damit ein KI-Client
|
||||||
|
/// "abgelehnt" nicht mit "keine Fehlzeiten gefunden" verwechselt.</summary>
|
||||||
|
public record NamedUntisAbsenceResultDto(bool Granted, string Message, List<NamedUntisAbsenceRowDto>? Rows);
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ using System.ComponentModel;
|
|||||||
using System.Text;
|
using System.Text;
|
||||||
using LehrerApp.Core.Interfaces;
|
using LehrerApp.Core.Interfaces;
|
||||||
using LehrerApp.Core.Models;
|
using LehrerApp.Core.Models;
|
||||||
|
using LehrerApp.Core.Services;
|
||||||
|
|
||||||
namespace LehrerApp.Desktop.Services.Mcp.Tools;
|
namespace LehrerApp.Desktop.Services.Mcp.Tools;
|
||||||
|
|
||||||
@@ -16,7 +17,12 @@ namespace LehrerApp.Desktop.Services.Mcp.Tools;
|
|||||||
/// Stunde) und liefern einen für den Bestätigungsdialog tatsächlich lesbaren Diff statt eines
|
/// Stunde) und liefern einen für den Bestätigungsdialog tatsächlich lesbaren Diff statt eines
|
||||||
/// kompletten Objekt-Dumps. Ein Tool, das die ganze Stunde überschreibt, ist bewusst NICHT
|
/// kompletten Objekt-Dumps. Ein Tool, das die ganze Stunde überschreibt, ist bewusst NICHT
|
||||||
/// vorgesehen; wo es fehlt, ist die Kombination aus update_lesson (Metadaten) +
|
/// vorgesehen; wo es fehlt, ist die Kombination aus update_lesson (Metadaten) +
|
||||||
/// add/update/remove_lesson_phase (je eine Phase) der vorgesehene Weg.</summary>
|
/// add/update/remove_lesson_phase (je eine Phase) der vorgesehene Weg.
|
||||||
|
///
|
||||||
|
/// "delete_lesson" ist (Stand Nutzer-Nachtrag) das einzige Lösch-Tool im gesamten MCP-Katalog —
|
||||||
|
/// eine bewusste, gezielte Ausnahme von der sonst geltenden "v1 ohne Lösch-Tools"-Regel (siehe
|
||||||
|
/// Planungsdokument), nicht deren Aufhebung. Entsprechend als "Destructive" annotiert
|
||||||
|
/// (siehe McpServerHostedService) und mit besonders deutlicher Bestätigungsnachricht.</summary>
|
||||||
public class LessonPlanTools(
|
public class LessonPlanTools(
|
||||||
IUnitRepository units, ILessonRepository lessons, IGroupRepository groups,
|
IUnitRepository units, ILessonRepository lessons, IGroupRepository groups,
|
||||||
IAttachmentStorage attachments, IMcpConfirmationService confirmation)
|
IAttachmentStorage attachments, IMcpConfirmationService confirmation)
|
||||||
@@ -182,12 +188,13 @@ public class LessonPlanTools(
|
|||||||
return new WriteResultDto(true, lesson.Id, "Einzelstunde gespeichert.");
|
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(
|
public async Task<WriteResultDto> UpdateLesson(
|
||||||
[Description("ID der Einzelstunde.")] Guid lessonId,
|
[Description("ID der Einzelstunde.")] Guid lessonId,
|
||||||
[Description("Neues Thema. Unverändert lassen: weglassen.")] string? topic = null,
|
[Description("Neues Thema. Unverändert lassen: weglassen.")] string? topic = null,
|
||||||
[Description("Neue Hausaufgabe. Unverändert lassen: weglassen.")] string? homework = null,
|
[Description("Neue Hausaufgabe. Unverändert lassen: weglassen.")] string? homework = null,
|
||||||
[Description("Neuer Status: Planned, Conducted, Draft oder Ready. Unverändert lassen: weglassen.")] LessonStatus? status = 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("Neuer Stundenbeginn, Format HH:mm. Unverändert lassen: weglassen.")] TimeOnly? startTime = null,
|
||||||
[Description("Neue Stundennummer. Unverändert lassen: weglassen.")] int? lessonNumber = null,
|
[Description("Neue Stundennummer. Unverändert lassen: weglassen.")] int? lessonNumber = null,
|
||||||
CancellationToken ct = default)
|
CancellationToken ct = default)
|
||||||
@@ -198,6 +205,7 @@ public class LessonPlanTools(
|
|||||||
var changes = new StringBuilder();
|
var changes = new StringBuilder();
|
||||||
if (topic is not null && topic != lesson.Topic) { changes.AppendLine($"Thema: „{lesson.Topic}“ → „{topic}“"); lesson.Topic = topic; }
|
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 (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 (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 (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; }
|
if (lessonNumber is not null && lessonNumber != lesson.LessonNumber) { changes.AppendLine($"Nr.: {lesson.LessonNumber} → {lessonNumber}"); lesson.LessonNumber = lessonNumber; }
|
||||||
@@ -222,6 +230,7 @@ public class LessonPlanTools(
|
|||||||
[Description("Tätigkeit/Sozialform.")] string activity = "",
|
[Description("Tätigkeit/Sozialform.")] string activity = "",
|
||||||
[Description("Material.")] string material = "",
|
[Description("Material.")] string material = "",
|
||||||
[Description("Kurzsymbol, z.B. \"AB001->S\".")] string shorthand = "",
|
[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)
|
CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
var lesson = lessons.GetById(lessonId);
|
var lesson = lessons.GetById(lessonId);
|
||||||
@@ -234,7 +243,7 @@ public class LessonPlanTools(
|
|||||||
var phase = new LessonPhaseStep
|
var phase = new LessonPhaseStep
|
||||||
{
|
{
|
||||||
Name = name, DurationMinutes = durationMinutes, Activity = activity,
|
Name = name, DurationMinutes = durationMinutes, Activity = activity,
|
||||||
Material = material, Shorthand = shorthand,
|
Material = material, Shorthand = shorthand, MaterialPrompt = materialPrompt,
|
||||||
};
|
};
|
||||||
lesson.Phases.Add(phase);
|
lesson.Phases.Add(phase);
|
||||||
lessons.Save(lesson);
|
lessons.Save(lesson);
|
||||||
@@ -250,6 +259,7 @@ public class LessonPlanTools(
|
|||||||
[Description("Neue Tätigkeit. Unverändert lassen: weglassen.")] string? activity = null,
|
[Description("Neue Tätigkeit. Unverändert lassen: weglassen.")] string? activity = null,
|
||||||
[Description("Neues Material. Unverändert lassen: weglassen.")] string? material = null,
|
[Description("Neues Material. Unverändert lassen: weglassen.")] string? material = null,
|
||||||
[Description("Neues Kurzsymbol. Unverändert lassen: weglassen.")] string? shorthand = 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)
|
CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
var lesson = lessons.GetById(lessonId);
|
var lesson = lessons.GetById(lessonId);
|
||||||
@@ -263,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 (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 (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 (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)
|
if (changes.Length == 0)
|
||||||
return new WriteResultDto(true, phase.Id, "Keine Änderung nötig.");
|
return new WriteResultDto(true, phase.Id, "Keine Änderung nötig.");
|
||||||
@@ -294,8 +305,116 @@ public class LessonPlanTools(
|
|||||||
return new WriteResultDto(true, phase.Id, "Phase entfernt.");
|
return new WriteResultDto(true, phase.Id, "Phase entfernt.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Einzelstunden (Lesson) — Kompetenzzuordnung ──────────────────────────────────────────
|
||||||
|
// Auf Stundenebene (Lesson.Competencies), noch nicht je Phase - siehe TODO.md 4.5.8. Getrennte
|
||||||
|
// add/remove-Tools statt eines "set_lesson_competencies", das die ganze Liste ersetzt: dieselbe
|
||||||
|
// Race-Condition-Überlegung wie bei den Verlaufsplan-Phasen (add/update/remove_lesson_phase) -
|
||||||
|
// ein Tool-Aufruf ändert nur einen Code, kein "letzter Schreiber gewinnt" über die ganze Liste.
|
||||||
|
|
||||||
|
[Description("Ordnet einer Einzelstunde einen Kompetenzcode aus dem Katalog zu (siehe get_competency_catalog). Muss der Nutzer erst bestätigen.")]
|
||||||
|
public async Task<WriteResultDto> AddLessonCompetency(
|
||||||
|
[Description("ID der Einzelstunde.")] Guid lessonId,
|
||||||
|
[Description("Kompetenzcode, z.B. \"M.5.1\".")] string code,
|
||||||
|
CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var lesson = lessons.GetById(lessonId);
|
||||||
|
if (lesson is null) return new WriteResultDto(false, null, "Unbekannte Stunden-ID.");
|
||||||
|
code = code.Trim();
|
||||||
|
if (code.Length == 0) return new WriteResultDto(false, null, "Leerer Kompetenzcode.");
|
||||||
|
if (lesson.Competencies.Contains(code, StringComparer.OrdinalIgnoreCase))
|
||||||
|
return new WriteResultDto(true, lesson.Id, "Kompetenzcode ist bereits zugeordnet.");
|
||||||
|
|
||||||
|
var message = $"Kompetenz „{code}“ zu „{lesson.Topic}“ am {lesson.Date:dd.MM.yyyy} hinzufügen?";
|
||||||
|
if (!await confirmation.ConfirmAsync("Kompetenz zuordnen?", message, ct))
|
||||||
|
return new WriteResultDto(false, null, "Vom Nutzer abgelehnt oder nicht bestätigt.");
|
||||||
|
|
||||||
|
lesson.Competencies.Add(code);
|
||||||
|
lessons.Save(lesson);
|
||||||
|
return new WriteResultDto(true, lesson.Id, "Kompetenz zugeordnet.");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Description("Entfernt einen Kompetenzcode von einer Einzelstunde. Muss der Nutzer erst bestätigen.")]
|
||||||
|
public async Task<WriteResultDto> RemoveLessonCompetency(
|
||||||
|
[Description("ID der Einzelstunde.")] Guid lessonId,
|
||||||
|
[Description("Kompetenzcode, wie in get_lesson_plans hinterlegt.")] string code,
|
||||||
|
CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var lesson = lessons.GetById(lessonId);
|
||||||
|
if (lesson is null) return new WriteResultDto(false, null, "Unbekannte Stunden-ID.");
|
||||||
|
var existing = lesson.Competencies.FirstOrDefault(c => string.Equals(c, code, StringComparison.OrdinalIgnoreCase));
|
||||||
|
if (existing is null) return new WriteResultDto(false, null, "Dieser Kompetenzcode ist dieser Stunde nicht zugeordnet.");
|
||||||
|
|
||||||
|
var message = $"Kompetenz „{existing}“ von „{lesson.Topic}“ am {lesson.Date:dd.MM.yyyy} entfernen?";
|
||||||
|
if (!await confirmation.ConfirmAsync("Kompetenz entfernen?", message, ct))
|
||||||
|
return new WriteResultDto(false, null, "Vom Nutzer abgelehnt oder nicht bestätigt.");
|
||||||
|
|
||||||
|
lesson.Competencies.Remove(existing);
|
||||||
|
lessons.Save(lesson);
|
||||||
|
return new WriteResultDto(true, lesson.Id, "Kompetenz entfernt.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Einzelstunden (Lesson) — Verschieben & Löschen ───────────────────────────────────────
|
||||||
|
|
||||||
|
[Description("Verschiebt eine Einzelstunde auf ein neues Datum (und optional eine neue Stundennummer). Mit shiftFollowingLessons=true verschieben sich alle noch nicht durchgeführten, späteren Stunden derselben Einheit um denselben Tages-Versatz mit — so lässt sich eine Lücke für eine neue Stunde öffnen: diese Stunde auf den Termin der übernächsten verschieben (mit shiftFollowingLessons), dann create_lesson auf das dadurch freigewordene ursprüngliche Datum. Muss der Nutzer erst bestätigen.")]
|
||||||
|
public async Task<WriteResultDto> MoveLesson(
|
||||||
|
[Description("ID der zu verschiebenden Einzelstunde.")] Guid lessonId,
|
||||||
|
[Description("Neues Datum, Format YYYY-MM-DD.")] DateOnly newDate,
|
||||||
|
[Description("Neue Stundennummer im Tagesraster. Unverändert lassen: weglassen.")] int? newPeriod = null,
|
||||||
|
[Description("Alle späteren, noch nicht durchgeführten Stunden derselben Einheit um denselben Tages-Versatz mitverschieben.")] bool shiftFollowingLessons = false,
|
||||||
|
CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var lesson = lessons.GetById(lessonId);
|
||||||
|
if (lesson is null) return new WriteResultDto(false, null, "Unbekannte Stunden-ID.");
|
||||||
|
|
||||||
|
var oldDate = lesson.Date;
|
||||||
|
// Muss exakt dieselbe Schutzbedingung wie LessonSchedulingService.Move verwenden, sonst
|
||||||
|
// weicht die Vorschau-Zahl im Bestätigungstext von dem ab, was tatsächlich verschoben wird.
|
||||||
|
var affectedCount = shiftFollowingLessons
|
||||||
|
? lessons.GetByUnit(lesson.UnitId).Count(l =>
|
||||||
|
l.Id != lesson.Id && l.Status is not (LessonStatus.Conducted or LessonStatus.Cancelled) && l.Date > oldDate)
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
var message = $"„{lesson.Topic}“ von {oldDate:dd.MM.yyyy} auf {newDate:dd.MM.yyyy} verschieben?" +
|
||||||
|
(newPeriod is not null ? $"\nNeue Stundennummer: {newPeriod}." : "") +
|
||||||
|
(shiftFollowingLessons
|
||||||
|
? affectedCount > 0
|
||||||
|
? $"\n{affectedCount} spätere Stunde(n) derselben Einheit verschieben sich um denselben Versatz mit."
|
||||||
|
: "\nKeine späteren, noch offenen Stunden derselben Einheit betroffen."
|
||||||
|
: "");
|
||||||
|
|
||||||
|
if (!await confirmation.ConfirmAsync("Stunde verschieben?", message, ct))
|
||||||
|
return new WriteResultDto(false, null, "Vom Nutzer abgelehnt oder nicht bestätigt.");
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
new LessonSchedulingService(lessons).Move(lesson, newDate, newPeriod, shiftFollowingLessons);
|
||||||
|
}
|
||||||
|
catch (InvalidOperationException ex)
|
||||||
|
{
|
||||||
|
return new WriteResultDto(false, null, ex.Message);
|
||||||
|
}
|
||||||
|
return new WriteResultDto(true, lesson.Id, "Stunde verschoben.");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Description("Löscht eine Einzelstunde endgültig, inklusive ihrer Anhänge. Anders als die meisten anderen Löschvorgänge in LehrerApp landet eine gelöschte Stunde NICHT im Papierkorb — nicht rückgängig zu machen. Für eine Stunde, die nur ausgefallen ist (Exkursion, Feiertag, Vertretung ohne Ersatztermin) aber als Ereignis dokumentiert bleiben soll, ist update_lesson mit status=Cancelled meist die bessere Wahl. Muss der Nutzer erst bestätigen.")]
|
||||||
|
public async Task<WriteResultDto> DeleteLesson(
|
||||||
|
[Description("ID der zu löschenden Einzelstunde.")] Guid lessonId,
|
||||||
|
CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var lesson = lessons.GetById(lessonId);
|
||||||
|
if (lesson is null) return new WriteResultDto(false, null, "Unbekannte Stunden-ID.");
|
||||||
|
|
||||||
|
var message = $"Die Stunde „{lesson.Topic}“ vom {lesson.Date:dd.MM.yyyy} wird endgültig gelöscht. " +
|
||||||
|
"Das kann NICHT rückgängig gemacht werden (kein Papierkorb für Stunden).";
|
||||||
|
if (!await confirmation.ConfirmAsync("Stunde endgültig löschen?", message, ct))
|
||||||
|
return new WriteResultDto(false, null, "Vom Nutzer abgelehnt oder nicht bestätigt.");
|
||||||
|
|
||||||
|
lessons.Delete(lessonId);
|
||||||
|
return new WriteResultDto(true, lessonId, "Stunde gelöscht.");
|
||||||
|
}
|
||||||
|
|
||||||
private static LessonDto ToDto(Lesson l) => new(
|
private static LessonDto ToDto(Lesson l) => new(
|
||||||
l.Id, l.UnitId, l.GroupId, l.Date, l.LessonNumber, l.Topic, l.Homework, l.Status,
|
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)).ToList(),
|
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());
|
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>
|
/// tatsächlich nützlichen Fall ab: eine bestehende, vom Nutzer gestaltete Vorlage mit Werten füllen.</summary>
|
||||||
public class LetterTemplateTools(
|
public class LetterTemplateTools(
|
||||||
TemplateStore templates, ITemplateLoader loader, ITemplateRenderer renderer,
|
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).")]
|
[Description("Listet importierte Elternbrief-Vorlagen mit ihren deklarierten Platzhaltern (Name, Typ, Pflichtfeld, konstant).")]
|
||||||
public List<LetterTemplateDto> ListLetterTemplates()
|
public List<LetterTemplateDto> ListLetterTemplates()
|
||||||
@@ -73,7 +74,16 @@ public class LetterTemplateTools(
|
|||||||
var group = groupId is { } gid ? groups.GetById(gid) : null;
|
var group = groupId is { } gid ? groups.GetById(gid) : null;
|
||||||
var date = letterDate ?? DateOnly.FromDateTime(DateTime.Today);
|
var date = letterDate ?? DateOnly.FromDateTime(DateTime.Today);
|
||||||
|
|
||||||
var values = LetterPlaceholderBuilder.BuildStandardValues(student, contact, group, date, letterText, teacherName);
|
var calendarBox = LetterPlaceholderBuilder.FindDeclaredDrawingBox(loaded,
|
||||||
|
StudentAttendanceCalendarDrawingBuilder.PlaceholderName) ?? new(170, float.PositiveInfinity, 1, false);
|
||||||
|
var absenceDayListBox = LetterPlaceholderBuilder.FindDeclaredDrawingBox(loaded,
|
||||||
|
StudentAbsenceDayListDrawingBuilder.PlaceholderName) ?? new(170, float.PositiveInfinity, 1, false);
|
||||||
|
var values = LetterPlaceholderBuilder.BuildStandardValues(student, contact, group, date, letterText,
|
||||||
|
teacherName, attendanceCalendars?.Build(student, new AttendanceCalendarOptions(date, 1),
|
||||||
|
calendarBox.Width, calendarBox.MillimeterScale,
|
||||||
|
calendarBox.IsFixed ? calendarBox.Height : float.PositiveInfinity),
|
||||||
|
attendanceCalendars?.BuildAbsenceDayList(student, new AttendanceCalendarOptions(date, 1),
|
||||||
|
absenceDayListBox.Width, absenceDayListBox.MillimeterScale));
|
||||||
foreach (var (name, raw) in extraValues ?? [])
|
foreach (var (name, raw) in extraValues ?? [])
|
||||||
{
|
{
|
||||||
var definition = loaded.Manifest.Placeholders.FirstOrDefault(p => p.Name == name);
|
var definition = loaded.Manifest.Placeholders.FirstOrDefault(p => p.Name == name);
|
||||||
|
|||||||
@@ -0,0 +1,208 @@
|
|||||||
|
using System.Collections.Concurrent;
|
||||||
|
using System.ComponentModel;
|
||||||
|
using LehrerApp.Core.Interfaces;
|
||||||
|
using LehrerApp.Core.Models;
|
||||||
|
|
||||||
|
namespace LehrerApp.Desktop.Services.Mcp.Tools;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// MCP-Tools für die WebUntis-Abgleiche (Nutzer-Nachtrag zum Untis-Hub, siehe TODO.md), zweigleisig
|
||||||
|
/// wie vom Nutzer entschieden:
|
||||||
|
///
|
||||||
|
/// - <see cref="GetUntisAbsenceRows"/>/<see cref="ApplyUntisAbsenceStatus"/> (bevorzugter Weg): eine
|
||||||
|
/// rein technische <c>row-id</c> ordnet zurück, nie ein Schülername - nur Zeilen, die WebUntis
|
||||||
|
/// selbst eindeutig über die externe Schülernummer (ENr) einem Kursmitglied zuordnet, werden
|
||||||
|
/// überhaupt gelistet (unklare, nur namensbasiert auflösbare Fälle fließen bewusst NICHT hier
|
||||||
|
/// hinein, siehe <see cref="GetUntisAbsenceRows"/>).
|
||||||
|
/// - <see cref="GetNamedUntisAbsencePattern"/> (bewusste, eng begrenzte Ausnahme von der sonst in
|
||||||
|
/// <see cref="McpToolScope"/> geltenden Regel, dass personenbezogene Verhaltens-/Anwesenheitsdaten
|
||||||
|
/// nie mit einem Namen verknüpft nach außen gehen): exponiert Name UND Fehlzeiten gemeinsam, aber
|
||||||
|
/// nur für explizit angegebene Schüler-IDs und nur nach JEDES MAL gesonderter, prominenter
|
||||||
|
/// Bestätigung ohne Sitzungsfreigabe (<c>allowSessionTrust: false</c>).
|
||||||
|
///
|
||||||
|
/// <see cref="GetUntisHubStatus"/> ergänzt beide Wege um einen Überblick, welche Abgleiche laut
|
||||||
|
/// <see cref="UntisHubService"/> überhaupt fällig sind, ohne selbst WebUntis anzufragen.
|
||||||
|
/// </summary>
|
||||||
|
public class UntisComparisonTools(
|
||||||
|
IGroupRepository groups, IStudentRepository students, IParticipationSessionRepository sessions,
|
||||||
|
IParticipationRepository participation, WebUntisIntegrationService untis, UntisHubService hub,
|
||||||
|
IMcpConfirmationService confirmation)
|
||||||
|
{
|
||||||
|
// Statuswerte, die ApplyUntisAbsenceStatus akzeptiert - dieselbe Einschränkung wie
|
||||||
|
// WebUntisLessonAbsenceRow.SelectableStatuses im interaktiven Dialog (nicht z.B. "Geschwänzt"
|
||||||
|
// oder "Suspendiert", die WebUntis hier nie meldet). Eigenständig gehalten statt der Row-Klasse
|
||||||
|
// referenziert, da Tool-Klassen unter Services/Mcp nicht von ViewModel-Klassen abhängen sollen
|
||||||
|
// (siehe GradeTools).
|
||||||
|
private static readonly AttendanceStatus[] SelectableStatuses =
|
||||||
|
[
|
||||||
|
AttendanceStatus.ExcusePending, AttendanceStatus.Excused, AttendanceStatus.Unexcused,
|
||||||
|
AttendanceStatus.Late, AttendanceStatus.LeftDuringClass, AttendanceStatus.Present,
|
||||||
|
];
|
||||||
|
|
||||||
|
// In-Memory, pro Prozesslaufzeit - eine row-id aus GetUntisAbsenceRows ist nur bis zum nächsten
|
||||||
|
// Neustart von LehrerApp gültig; danach muss der KI-Client die Liste erneut abrufen. Bewusst
|
||||||
|
// keine Ablauf-/Größenbegrenzung (siehe Nutzerdiskussion: geringe Nutzungsfrequenz, winzige
|
||||||
|
// Einträge) - ein v1-Kompromiss, kein Deployment-Risiko wie bei den ai-backend-Endpunkten.
|
||||||
|
private readonly ConcurrentDictionary<string, PendingAbsenceRow> _pendingRows = new();
|
||||||
|
|
||||||
|
private sealed record PendingAbsenceRow(Guid StudentId, Guid SessionId, Guid GroupId, DateOnly Date);
|
||||||
|
|
||||||
|
[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.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:
|
||||||
|
enthält keinen Schülernamen, nur eine technische row-id je Zeile (für apply_untis_absence_status).
|
||||||
|
Enthält nur Zeilen, die WebUntis über die externe Schülernummer eindeutig einem Kursmitglied zuordnen
|
||||||
|
konnte - Zeilen, die nur über den Namen auflösbar wären, fehlen hier bewusst; für die braucht es
|
||||||
|
get_named_untis_absence_pattern (Namen exponierend, gesondert bestätigungspflichtig).
|
||||||
|
""")]
|
||||||
|
public async Task<List<UntisAbsenceRowDto>> GetUntisAbsenceRows(
|
||||||
|
[Description("Lerngruppen-ID.")] Guid groupId,
|
||||||
|
[Description("Startdatum, Format YYYY-MM-DD.")] DateOnly startDate,
|
||||||
|
[Description("Enddatum, Format YYYY-MM-DD.")] DateOnly endDate,
|
||||||
|
CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var group = groups.GetById(groupId);
|
||||||
|
if (group?.WebUntisLessonId is not { } lessonId) return [];
|
||||||
|
|
||||||
|
var courseStudents = students.GetByGroup(groupId);
|
||||||
|
var byExternKey = courseStudents
|
||||||
|
.Select(s => (Student: s, Key: UntisLessonAbsenceHelper.StudentExternKey(s)))
|
||||||
|
.Where(x => x.Key is not null)
|
||||||
|
.ToDictionary(x => x.Key!.Value, x => x.Student);
|
||||||
|
var localSessions = sessions.GetByGroup(groupId)
|
||||||
|
.Where(s => s.Date >= startDate && s.Date <= endDate)
|
||||||
|
.GroupBy(s => s.Date).ToDictionary(g => g.Key, g => g.First());
|
||||||
|
|
||||||
|
var absences = await untis.GetLessonAbsencesAsync(lessonId, startDate, endDate, ct);
|
||||||
|
var rows = new List<UntisAbsenceRowDto>();
|
||||||
|
foreach (var absence in absences)
|
||||||
|
{
|
||||||
|
if (absence.ExternKey is not { } key || !byExternKey.TryGetValue(key, out var student)) continue;
|
||||||
|
if (!TryParseDate(absence.Date, out var date) || !localSessions.TryGetValue(date, out var session)) continue;
|
||||||
|
|
||||||
|
var rowId = Guid.NewGuid().ToString("N");
|
||||||
|
_pendingRows[rowId] = new PendingAbsenceRow(student.Id, session.Id, groupId, date);
|
||||||
|
|
||||||
|
var entry = participation.GetBySessionAndStudent(session.Id, student.Id);
|
||||||
|
var guess = UntisLessonAbsenceHelper.MapStatus(absence);
|
||||||
|
rows.Add(new UntisAbsenceRowDto(
|
||||||
|
rowId, date, absence.Reason ?? "", absence.AbsentMinutes,
|
||||||
|
!string.IsNullOrWhiteSpace(absence.HandledOn),
|
||||||
|
absence.ExternKey is null ? null : absence.ExternKeyInParentheses,
|
||||||
|
LocalStatusLabel(entry?.Attendance), guess.ToString()));
|
||||||
|
}
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
[Description("Übernimmt einen Statusvorschlag für eine über get_untis_absence_rows gelieferte row-id in den lokalen Anwesenheitsstatus. Muss der Nutzer erst in einem Dialog in LehrerApp bestätigen; die Bestätigungsmeldung nennt bewusst KEINEN Schülernamen (nur Datum, Lerngruppe, Zielstatus).")]
|
||||||
|
public async Task<WriteResultDto> ApplyUntisAbsenceStatus(
|
||||||
|
[Description("row-id aus get_untis_absence_rows.")] string rowId,
|
||||||
|
[Description("Zielstatus: Present, Late, LeftDuringClass, ExcusePending, Excused oder Unexcused.")] string status,
|
||||||
|
CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
if (!Enum.TryParse<AttendanceStatus>(status, out var target) || !SelectableStatuses.Contains(target))
|
||||||
|
return new WriteResultDto(false, null, "Unbekannter oder nicht zulässiger Status.");
|
||||||
|
if (!_pendingRows.TryGetValue(rowId, out var row))
|
||||||
|
return new WriteResultDto(false, null, "Unbekannte oder abgelaufene row-id - zuerst get_untis_absence_rows erneut aufrufen.");
|
||||||
|
|
||||||
|
var groupName = groups.GetById(row.GroupId)?.Name ?? "?";
|
||||||
|
var message = $"Fehlzeile vom {row.Date:dd.MM.yyyy} in Lerngruppe „{groupName}“: " +
|
||||||
|
$"Anwesenheitsstatus auf „{LocalStatusLabel(target)}“ setzen?";
|
||||||
|
if (!await confirmation.ConfirmAsync("Fehlzeiten-Status übernehmen?", message, ct))
|
||||||
|
return new WriteResultDto(false, null, "Vom Nutzer abgelehnt oder nicht bestätigt.");
|
||||||
|
|
||||||
|
var entry = participation.GetBySessionAndStudent(row.SessionId, row.StudentId)
|
||||||
|
?? new ParticipationEntry { SessionId = row.SessionId, StudentId = row.StudentId };
|
||||||
|
entry.Attendance = target;
|
||||||
|
entry.UpdatedAt = DateTime.UtcNow;
|
||||||
|
participation.Save(entry);
|
||||||
|
_pendingRows.TryRemove(rowId, out _);
|
||||||
|
return new WriteResultDto(true, entry.Id, "Status übernommen.");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Description("""
|
||||||
|
Liefert Fehlzeiten für EXPLIZIT angegebene Schüler-IDs MIT Namen (z.B. für einen Bericht oder einen
|
||||||
|
Fehlmuster-Vergleich zwischen zwei Schülern) - bewusste, eng begrenzte Ausnahme von der sonst
|
||||||
|
geltenden Anonymisierung (siehe get_untis_absence_rows). So wenige studentIds wie für die Anfrage
|
||||||
|
nötig angeben, nicht den ganzen Kurs. Erfordert JEDES MAL eine gesonderte, prominente
|
||||||
|
Nutzerbestätigung ohne Sitzungsfreigabe - liefert bei Ablehnung granted:false und keine Zeilen.
|
||||||
|
""")]
|
||||||
|
public async Task<NamedUntisAbsenceResultDto> GetNamedUntisAbsencePattern(
|
||||||
|
[Description("Lerngruppen-ID.")] Guid groupId,
|
||||||
|
[Description("Ids der Schüler, für die Name UND Fehlzeiten gemeinsam offengelegt werden sollen.")] List<Guid> studentIds,
|
||||||
|
[Description("Startdatum, Format YYYY-MM-DD.")] DateOnly startDate,
|
||||||
|
[Description("Enddatum, Format YYYY-MM-DD.")] DateOnly endDate,
|
||||||
|
CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var group = groups.GetById(groupId);
|
||||||
|
if (group?.WebUntisLessonId is not { } lessonId)
|
||||||
|
return new NamedUntisAbsenceResultDto(false, "Für diese Lerngruppe ist keine WebUntis-Unterrichtsnummer hinterlegt.", null);
|
||||||
|
|
||||||
|
var resolvedStudents = studentIds.Distinct()
|
||||||
|
.Select(students.GetById).Where(s => s is not null).Cast<Student>().ToList();
|
||||||
|
if (resolvedStudents.Count == 0)
|
||||||
|
return new NamedUntisAbsenceResultDto(false, "Keine der angegebenen Schüler-IDs ist bekannt.", null);
|
||||||
|
|
||||||
|
var names = string.Join(", ", resolvedStudents.Select(s => s.FullName));
|
||||||
|
var message = $"Name UND Fehlzeiten gemeinsam an den KI-Assistenten weitergeben für:\n{names}\n\n" +
|
||||||
|
$"Zeitraum: {startDate:dd.MM.yyyy}–{endDate:dd.MM.yyyy}, Lerngruppe „{group.Name}“.";
|
||||||
|
if (!await confirmation.ConfirmAsync("Namentliche Fehlzeitenauskunft freigeben?", message, ct, allowSessionTrust: false))
|
||||||
|
return new NamedUntisAbsenceResultDto(false, "Vom Nutzer abgelehnt oder nicht bestätigt.", null);
|
||||||
|
|
||||||
|
var byExternKey = resolvedStudents
|
||||||
|
.Select(s => (Student: s, Key: UntisLessonAbsenceHelper.StudentExternKey(s)))
|
||||||
|
.Where(x => x.Key is not null)
|
||||||
|
.ToDictionary(x => x.Key!.Value, x => x.Student);
|
||||||
|
var byName = resolvedStudents
|
||||||
|
.SelectMany(s => new[]
|
||||||
|
{
|
||||||
|
NameKey($"{s.LastName} {s.FirstName}"), NameKey($"{s.FirstName} {s.LastName}"),
|
||||||
|
}.Select(key => (Key: key, Student: s)))
|
||||||
|
.GroupBy(x => x.Key).Where(g => g.Select(x => x.Student).Distinct().Count() == 1)
|
||||||
|
.ToDictionary(g => g.Key, g => g.First().Student);
|
||||||
|
var localSessions = sessions.GetByGroup(groupId)
|
||||||
|
.Where(s => s.Date >= startDate && s.Date <= endDate)
|
||||||
|
.GroupBy(s => s.Date).ToDictionary(g => g.Key, g => g.First());
|
||||||
|
|
||||||
|
var absences = await untis.GetLessonAbsencesAsync(lessonId, startDate, endDate, ct);
|
||||||
|
var rows = new List<NamedUntisAbsenceRowDto>();
|
||||||
|
foreach (var absence in absences)
|
||||||
|
{
|
||||||
|
var match = absence.ExternKey is { } key && byExternKey.TryGetValue(key, out var byKeyStudent)
|
||||||
|
? byKeyStudent
|
||||||
|
: byName.GetValueOrDefault(NameKey(absence.StudentName));
|
||||||
|
if (match is null) continue; // nur die explizit freigegebenen Schüler, nie "geraten"
|
||||||
|
if (!TryParseDate(absence.Date, out var date)) continue;
|
||||||
|
|
||||||
|
var entry = localSessions.TryGetValue(date, out var session)
|
||||||
|
? participation.GetBySessionAndStudent(session.Id, match.Id) : null;
|
||||||
|
rows.Add(new NamedUntisAbsenceRowDto(
|
||||||
|
match.Id, match.FullName, date, absence.Reason ?? "",
|
||||||
|
LocalStatusLabel(entry?.Attendance), UntisLessonAbsenceHelper.MapStatus(absence).ToString()));
|
||||||
|
}
|
||||||
|
return new NamedUntisAbsenceResultDto(true, $"{rows.Count} Fehlzeile(n) für {resolvedStudents.Count} Schüler.", rows);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string NameKey(string value) => value.Trim().ToLowerInvariant();
|
||||||
|
private static bool TryParseDate(int value, out DateOnly date) =>
|
||||||
|
DateOnly.TryParseExact(value.ToString(), "yyyyMMdd", out date);
|
||||||
|
|
||||||
|
// Eigene, schlanke Beschriftung statt AttendanceDisplay (Views-/ViewModel-Bezug) - Tool-Klassen
|
||||||
|
// unter Services/Mcp sollen nicht von ViewModel-Klassen abhängen (siehe GradeTools).
|
||||||
|
private static string LocalStatusLabel(AttendanceStatus? s) => s switch
|
||||||
|
{
|
||||||
|
null => "kein Status erfasst",
|
||||||
|
AttendanceStatus.Present => "Anwesend",
|
||||||
|
AttendanceStatus.ExcusePending => "Krank (Entschuldigung offen)",
|
||||||
|
AttendanceStatus.Excused => "Krank, entschuldigt",
|
||||||
|
AttendanceStatus.Unexcused => "Krank, unentschuldigt",
|
||||||
|
AttendanceStatus.Late => "Verspätet",
|
||||||
|
AttendanceStatus.LeftDuringClass => "Während des Unterrichts abgängig",
|
||||||
|
_ => s.ToString()!,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,298 @@
|
|||||||
|
using System.Globalization;
|
||||||
|
using LehrerApp.Core.Interfaces;
|
||||||
|
using LehrerApp.Core.Models;
|
||||||
|
using LehrerApp.Desktop.ViewModels.ClassTeacher;
|
||||||
|
using LehrerApp.Templating;
|
||||||
|
|
||||||
|
namespace LehrerApp.Desktop.Services;
|
||||||
|
|
||||||
|
public enum AttendanceCalendarSize { Small, Medium, Large }
|
||||||
|
|
||||||
|
public sealed record AttendanceCalendarOptions(DateOnly StartMonth, int MonthCount,
|
||||||
|
AttendanceCalendarSize Size = AttendanceCalendarSize.Medium)
|
||||||
|
{
|
||||||
|
public DateOnly NormalizedStartMonth => new(StartMonth.Year, StartMonth.Month, 1);
|
||||||
|
public int NormalizedMonthCount => Math.Clamp(MonthCount, 1, 3);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Erzeugt den portablen Advanced-Content-Platzhalter für Elternbriefe aus derselben
|
||||||
|
/// priorisierten Monatsansicht, die im Klassenlehrer-Sidebar-Widget verwendet wird.</summary>
|
||||||
|
public static class StudentAttendanceCalendarDrawingBuilder
|
||||||
|
{
|
||||||
|
public const string PlaceholderName = "Student.AttendanceCalendar";
|
||||||
|
|
||||||
|
public static DrawingValue Build(string studentName, DateOnly month,
|
||||||
|
IReadOnlyList<ClassAbsenceDaySummaryRow> absences,
|
||||||
|
IReadOnlyList<UntisForeignClassRegisterEventDto> registerEntries) =>
|
||||||
|
Build(studentName, new AttendanceCalendarOptions(month, 1), absences, registerEntries);
|
||||||
|
|
||||||
|
public static DrawingValue Build(string studentName, AttendanceCalendarOptions options,
|
||||||
|
IReadOnlyList<ClassAbsenceDaySummaryRow> absences,
|
||||||
|
IReadOnlyList<UntisForeignClassRegisterEventDto> registerEntries, float contentWidth = 170,
|
||||||
|
float millimeterScale = 1, float contentHeight = float.PositiveInfinity)
|
||||||
|
{
|
||||||
|
var requestedScale = options.Size switch
|
||||||
|
{
|
||||||
|
AttendanceCalendarSize.Small => .7f,
|
||||||
|
AttendanceCalendarSize.Large => 1f,
|
||||||
|
_ => .85f,
|
||||||
|
};
|
||||||
|
// contentWidth/contentHeight/millimeterScale kommen aus der tatsächlichen DRAWBOX/
|
||||||
|
// FLOWDRAWBOX-Deklaration der jeweiligen Vorlage (siehe
|
||||||
|
// LetterPlaceholderBuilder.FindDeclaredDrawingBox). Das gesamte Raster wird in einem festen
|
||||||
|
// Millimeter-Referenzentwurf (designWidth 170mm) gezeichnet und dann mit EINEM einzigen,
|
||||||
|
// gemeinsamen Faktor "k" in die Koordinaten-Einheit der Vorlage skaliert - k ist bewusst für
|
||||||
|
// X- und Y-Richtung identisch (kein separates a_x/a_y), sonst verzerren die Tageskacheln je
|
||||||
|
// nachdem, ob Breite oder Höhe der realen Box stärker vom Referenzentwurf abweicht.
|
||||||
|
var designWidth = 170f;
|
||||||
|
var designMonthGapX = 6f;
|
||||||
|
var first = options.NormalizedStartMonth;
|
||||||
|
var monthCount = options.NormalizedMonthCount;
|
||||||
|
|
||||||
|
// Wochenanzahl je Monat vorab ermitteln, unabhängig von "Größe" - nötig, um VOR dem
|
||||||
|
// eigentlichen Zeichnen zu wissen, wie viel Höhe das Raster im Referenzentwurf braucht.
|
||||||
|
var maxWeeks = 0;
|
||||||
|
for (var monthIndex = 0; monthIndex < monthCount; monthIndex++)
|
||||||
|
{
|
||||||
|
var current = first.AddMonths(monthIndex);
|
||||||
|
var days = ClassTeacherOverviewViewModel.BuildCompactMonthDays(current, absences, registerEntries,
|
||||||
|
DateOnly.FromDateTime(DateTime.Today), studentName);
|
||||||
|
var offset = ((int)current.DayOfWeek + 6) % 7;
|
||||||
|
maxWeeks = Math.Max(maxWeeks, (int)Math.Ceiling((offset + days.Count) / 7d));
|
||||||
|
}
|
||||||
|
// Höhe des Referenzentwurfs bei k=1 (mm): Titel+Name+Abstand (26) + Monats-/Wochentagskopf
|
||||||
|
// (20) + maxWeeks Wochenzeilen (10 je Woche) + monthGapX (6) + Legende/Nachlauf (9). Muss mit
|
||||||
|
// der Höhenberechnung am Ende dieser Methode ("y") übereinstimmen.
|
||||||
|
var designHeight = 26f + 20f + 10f * maxWeeks + designMonthGapX + 9f;
|
||||||
|
|
||||||
|
// a_x/a_y: wie viel größer/kleiner als der Referenzentwurf die reale Box in jeweils einer
|
||||||
|
// Richtung ist. Eine DRAWBOX bricht anders als FLOWDRAWBOX nicht automatisch auf Folgeseiten
|
||||||
|
// um (siehe DrawingElementRenderer.RenderFixed/Slice) - Inhalt, der contentHeight
|
||||||
|
// überschreitet, wird von QuestTemplateRenderer am unteren Rand stillschweigend
|
||||||
|
// abgeschnitten. a = min(a_x, a_y, "Größe") verkleinert nötigenfalls gleichmäßig, statt die
|
||||||
|
// Boxgrenzen zu verletzen oder die Kacheln durch getrenntes Skalieren zu verzerren.
|
||||||
|
var scaleForWidth = contentWidth / (designWidth * millimeterScale);
|
||||||
|
var scaleForHeight = float.IsFinite(contentHeight)
|
||||||
|
? contentHeight / (designHeight * millimeterScale)
|
||||||
|
: float.PositiveInfinity;
|
||||||
|
var finalScale = Math.Max(.35f, Math.Min(requestedScale, Math.Min(scaleForWidth, scaleForHeight)));
|
||||||
|
var k = finalScale * millimeterScale;
|
||||||
|
|
||||||
|
var cellHeight = 10 * k;
|
||||||
|
// War 10mm im Referenzentwurf - bei 3 Monaten nebeneinander größer als eine einzelne
|
||||||
|
// Tageskachel und zog damit spürbar Platz von den Kacheln ab, ohne selbst als Inhalt
|
||||||
|
// wahrgenommen zu werden.
|
||||||
|
var monthGapX = designMonthGapX * k;
|
||||||
|
var monthWidth = (designWidth * k - monthGapX * (monthCount - 1)) / monthCount;
|
||||||
|
var cellWidth = monthWidth / 7;
|
||||||
|
// Kachel-Zwischenraum (Trennung zu Nachbarzellen) deutlich knapper als vorher (war 1mm im
|
||||||
|
// Referenzentwurf - bei einer ~7mm breiten Zelle ein gutes Viertel reiner Leerraum). Die
|
||||||
|
// Kachel selbst füllt dadurch ihren Rasterplatz sichtbar besser aus.
|
||||||
|
var cellInset = .4f * k;
|
||||||
|
var cellCornerRadius = Math.Min(cellWidth, cellHeight) * .2f;
|
||||||
|
var commands = new List<DrawingCommand>();
|
||||||
|
var contentWidthDesign = designWidth * k;
|
||||||
|
var y = 0f;
|
||||||
|
commands.Add(new DrawStringEx(0, y, 14 * k, contentWidthDesign, "Anwesenheit",
|
||||||
|
DrawingTextAlignment.AlignLeft, 11 * finalScale, Color: "#1F2937", Bold: true));
|
||||||
|
y += 14 * k;
|
||||||
|
commands.Add(new DrawStringEx(0, y, 10 * k, contentWidthDesign, studentName,
|
||||||
|
DrawingTextAlignment.AlignLeft, 8 * finalScale, Color: "#6B7280"));
|
||||||
|
y += 10 * k + 2 * k;
|
||||||
|
var weekdays = new[] { "Mo", "Di", "Mi", "Do", "Fr", "Sa", "So" };
|
||||||
|
var gridStartY = y;
|
||||||
|
for (var monthIndex = 0; monthIndex < monthCount; monthIndex++)
|
||||||
|
{
|
||||||
|
var current = first.AddMonths(monthIndex);
|
||||||
|
var days = ClassTeacherOverviewViewModel.BuildCompactMonthDays(current, absences, registerEntries,
|
||||||
|
DateOnly.FromDateTime(DateTime.Today), studentName);
|
||||||
|
var offset = ((int)current.DayOfWeek + 6) % 7;
|
||||||
|
var xOffset = monthIndex * (monthWidth + monthGapX);
|
||||||
|
var monthY = gridStartY;
|
||||||
|
commands.Add(new DrawStringEx(xOffset, monthY, 11 * k, monthWidth,
|
||||||
|
current.ToString("MMMM yyyy", CultureInfo.GetCultureInfo("de-DE")),
|
||||||
|
DrawingTextAlignment.AlignLeft, 9 * finalScale, Color: "#374151", Bold: true));
|
||||||
|
monthY += 11 * k;
|
||||||
|
for (var column = 0; column < 7; column++)
|
||||||
|
commands.Add(new DrawStringEx(xOffset + column * cellWidth, monthY, 9 * k,
|
||||||
|
cellWidth, weekdays[column], DrawingTextAlignment.AlignCenter, 7 * finalScale, Color: "#6B7280",
|
||||||
|
Bold: true));
|
||||||
|
monthY += 9 * k;
|
||||||
|
|
||||||
|
foreach (var day in days)
|
||||||
|
{
|
||||||
|
var index = offset + day.Date.Day - 1;
|
||||||
|
var column = index % 7;
|
||||||
|
var row = index / 7;
|
||||||
|
var x = xOffset + column * cellWidth;
|
||||||
|
var cellY = monthY + row * cellHeight;
|
||||||
|
commands.Add(new DrawRoundedRectangle(x + cellInset, cellY, cellWidth - 2 * cellInset,
|
||||||
|
cellHeight - cellInset, cellCornerRadius, "#D1D5DB", .35f,
|
||||||
|
day.HasSignal ? day.SignalColorHex : "#FFFFFF"));
|
||||||
|
commands.Add(new DrawStringEx(x, cellY + k, cellHeight - 2 * k, cellWidth,
|
||||||
|
day.HasSignal ? day.SignalCode : day.DayNumber, DrawingTextAlignment.AlignCenter, 7 * finalScale,
|
||||||
|
Color: day.HasSignal ? "#FFFFFF" : "#374151", Bold: day.HasSignal));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
y = gridStartY + 11 * k + 9 * k + maxWeeks * cellHeight + monthGapX;
|
||||||
|
|
||||||
|
commands.Add(new DrawStringEx(0, y, 8 * k, contentWidthDesign,
|
||||||
|
"U unentschuldigt · A abwesend · V verspätet · E entschuldigt · ! Klassenbuch",
|
||||||
|
DrawingTextAlignment.AlignLeft, 6.5f * finalScale, Color: "#6B7280"));
|
||||||
|
return new DrawingValue(commands, y + 9 * k);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Chronologische, portable Fehlzeitenliste für Elternbriefvorlagen.</summary>
|
||||||
|
public static class StudentAbsenceDayListDrawingBuilder
|
||||||
|
{
|
||||||
|
public const string PlaceholderName = "Student.AbsenceDays";
|
||||||
|
|
||||||
|
public static DrawingValue Build(string studentName, AttendanceCalendarOptions options,
|
||||||
|
IReadOnlyList<ClassAbsenceDaySummaryRow> absences, float contentWidth = 170,
|
||||||
|
float millimeterScale = 1)
|
||||||
|
{
|
||||||
|
var scale = options.Size switch
|
||||||
|
{
|
||||||
|
AttendanceCalendarSize.Small => .75f,
|
||||||
|
AttendanceCalendarSize.Large => 1f,
|
||||||
|
_ => .88f,
|
||||||
|
};
|
||||||
|
// contentWidth/millimeterScale: siehe StudentAttendanceCalendarDrawingBuilder - contentWidth
|
||||||
|
// bleibt an die tatsächliche DRAWBOX/FLOWDRAWBOX-Deklaration im Layout-Skript gebunden,
|
||||||
|
// millimeterScale rechnet das mm-entworfene Raster in die Koordinaten-Einheit der Vorlage um.
|
||||||
|
var rowHeight = 12 * scale * millimeterScale;
|
||||||
|
var start = options.NormalizedStartMonth;
|
||||||
|
var end = start.AddMonths(options.NormalizedMonthCount).AddDays(-1);
|
||||||
|
var rows = absences
|
||||||
|
.Where(a => a.Date >= start && a.Date <= end &&
|
||||||
|
UntisNameMatching.NamesMatch(a.StudentName, studentName))
|
||||||
|
.OrderBy(a => a.Date)
|
||||||
|
.ToList();
|
||||||
|
var commands = new List<DrawingCommand>();
|
||||||
|
var y = 0f;
|
||||||
|
commands.Add(new DrawStringEx(0, y, 14 * scale * millimeterScale, contentWidth, "Fehltage",
|
||||||
|
DrawingTextAlignment.AlignLeft, 11 * scale, Color: "#1F2937", Bold: true));
|
||||||
|
y += 14 * scale * millimeterScale;
|
||||||
|
commands.Add(new DrawStringEx(0, y, 10 * scale * millimeterScale, contentWidth, studentName,
|
||||||
|
DrawingTextAlignment.AlignLeft, 8 * scale, Color: "#6B7280"));
|
||||||
|
y += 10 * scale * millimeterScale + 2 * scale * millimeterScale;
|
||||||
|
// Spaltenbreiten so gewählt, dass auch die Datenzeilen (nicht nur die kurzen Kopfzeilen-
|
||||||
|
// Labels) hineinpassen - "Datum" als Kopfzeile ist kürzer als "dd.MM.yyyy" und wurde bei
|
||||||
|
// 31 zu schmal bemessen, wodurch das Datum am rechten Rand abgeschnitten wurde.
|
||||||
|
var dateColumnX = 2 * millimeterScale; var dateColumnWidth = 38 * millimeterScale;
|
||||||
|
var extentColumnX = 44 * millimeterScale; var extentColumnWidth = 66 * millimeterScale;
|
||||||
|
var statusColumnX = 114 * millimeterScale; var statusColumnWidth = 54 * millimeterScale;
|
||||||
|
commands.Add(new DrawRectangle(0, y, contentWidth, rowHeight, "#CBD5E1", .4f, "#F3F4F6"));
|
||||||
|
commands.Add(new DrawStringEx(dateColumnX, y + scale * millimeterScale, rowHeight - scale * millimeterScale,
|
||||||
|
dateColumnWidth, "Datum", DrawingTextAlignment.AlignLeft, 7.5f * scale, Color: "#374151", Bold: true));
|
||||||
|
commands.Add(new DrawStringEx(extentColumnX, y + scale * millimeterScale, rowHeight - scale * millimeterScale,
|
||||||
|
extentColumnWidth, "Umfang", DrawingTextAlignment.AlignLeft, 7.5f * scale, Color: "#374151", Bold: true));
|
||||||
|
commands.Add(new DrawStringEx(statusColumnX, y + scale * millimeterScale, rowHeight - scale * millimeterScale,
|
||||||
|
statusColumnWidth, "Status", DrawingTextAlignment.AlignLeft, 7.5f * scale, Color: "#374151", Bold: true));
|
||||||
|
y += rowHeight;
|
||||||
|
|
||||||
|
if (rows.Count == 0)
|
||||||
|
{
|
||||||
|
commands.Add(new DrawStringEx(2 * millimeterScale, y + 2 * scale * millimeterScale, rowHeight,
|
||||||
|
contentWidth - 4 * millimeterScale, "Keine Fehltage im gewählten Zeitraum",
|
||||||
|
DrawingTextAlignment.AlignLeft, 8 * scale, Color: "#6B7280", Italic: true));
|
||||||
|
y += rowHeight + 3 * scale * millimeterScale;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
for (var index = 0; index < rows.Count; index++)
|
||||||
|
{
|
||||||
|
var row = rows[index];
|
||||||
|
var fill = index % 2 == 0 ? "#FFFFFF" : "#F9FAFB";
|
||||||
|
var extent = row.CountsAsFullDay
|
||||||
|
? "Ganzer Fehltag"
|
||||||
|
: row.TotalAbsentPeriods > 0
|
||||||
|
? $"Fehlzeit · {row.TotalAbsentPeriods} Std."
|
||||||
|
: $"Fehlzeit · {row.TotalAbsentMinutes} Min.";
|
||||||
|
var status = row.IsUnexcused
|
||||||
|
? "Unentschuldigt"
|
||||||
|
: row.FriendlyStatusLabel.Contains("Entschuldigt", StringComparison.OrdinalIgnoreCase)
|
||||||
|
? "Entschuldigt"
|
||||||
|
: row.FriendlyStatusLabel;
|
||||||
|
var statusColor = row.IsUnexcused ? "#C62828" : "#2E7D32";
|
||||||
|
commands.Add(new DrawRectangle(0, y, contentWidth, rowHeight, "#E5E7EB", .3f, fill));
|
||||||
|
commands.Add(new DrawStringEx(dateColumnX, y + scale * millimeterScale,
|
||||||
|
rowHeight - scale * millimeterScale, dateColumnWidth,
|
||||||
|
row.Date.ToString("dd.MM.yyyy"), DrawingTextAlignment.AlignLeft, 7.5f * scale,
|
||||||
|
Color: "#374151"));
|
||||||
|
commands.Add(new DrawStringEx(extentColumnX, y + scale * millimeterScale,
|
||||||
|
rowHeight - scale * millimeterScale, extentColumnWidth,
|
||||||
|
extent, DrawingTextAlignment.AlignLeft, 7.5f * scale, Color: "#374151"));
|
||||||
|
commands.Add(new DrawStringEx(statusColumnX, y + scale * millimeterScale,
|
||||||
|
rowHeight - scale * millimeterScale, statusColumnWidth,
|
||||||
|
status, DrawingTextAlignment.AlignLeft, 7.5f * scale, Color: statusColor,
|
||||||
|
Bold: row.IsUnexcused));
|
||||||
|
y += rowHeight;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return new DrawingValue(commands, y);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Liest ausschließlich den lokalen WebUntis-Cache. Das Öffnen oder Rendern eines
|
||||||
|
/// Elternbriefs löst dadurch keinen unerwarteten Netzwerkabruf aus.</summary>
|
||||||
|
public sealed class StudentAttendanceCalendarService(
|
||||||
|
WebUntisSettingsService settings,
|
||||||
|
IUntisAbsenceCacheRepository absenceCache,
|
||||||
|
IUntisClassRegisterCacheRepository registerCache,
|
||||||
|
IUntisStudentRosterCacheRepository rosterCache)
|
||||||
|
{
|
||||||
|
public DrawingValue Build(Student student, DateOnly month) =>
|
||||||
|
Build(student, new AttendanceCalendarOptions(month, 1));
|
||||||
|
|
||||||
|
public DrawingValue Build(Student student, AttendanceCalendarOptions options, float contentWidth = 170,
|
||||||
|
float millimeterScale = 1, float contentHeight = float.PositiveInfinity)
|
||||||
|
{
|
||||||
|
var className = settings.HomeroomClassName;
|
||||||
|
if (string.IsNullOrWhiteSpace(className)) return new DrawingValue([], 0);
|
||||||
|
|
||||||
|
var first = options.NormalizedStartMonth;
|
||||||
|
var last = first.AddMonths(options.NormalizedMonthCount).AddDays(-1);
|
||||||
|
var matchName = $"{student.FirstName} {student.LastName}";
|
||||||
|
var rosterName = rosterCache.GetByClass(className)
|
||||||
|
.FirstOrDefault(r => UntisNameMatching.NamesMatch(r.DisplayName, matchName))?.DisplayName
|
||||||
|
?? matchName;
|
||||||
|
var start = first.Year * 10000 + first.Month * 100 + first.Day;
|
||||||
|
var end = last.Year * 10000 + last.Month * 100 + last.Day;
|
||||||
|
var absences = absenceCache.GetByClassAndRange(className, start, end)
|
||||||
|
.Select(e => new UntisClassAbsenceEntryDto(e.StudentName, e.ExternKey, e.ClassName, e.Date,
|
||||||
|
e.AbsentPeriods, e.AbsentMinutes, e.TeacherUsernames, e.Subject, e.AbsenceReason, e.Note,
|
||||||
|
e.EntryId, e.HandledOn, e.Counts, e.ExcuseNote, e.PeriodNumber, e.Status, e.CountsAsFullDay))
|
||||||
|
.ToList();
|
||||||
|
var register = registerCache.GetByClassAndRange(className, start, end)
|
||||||
|
.Select(e => new UntisForeignClassRegisterEventDto(e.ClassName, e.Date, e.Subject, e.StudentName,
|
||||||
|
e.TeacherUsername, e.CategoryName, e.CategoryGroup, e.Text))
|
||||||
|
.ToList();
|
||||||
|
return StudentAttendanceCalendarDrawingBuilder.Build(rosterName, options,
|
||||||
|
ClassAbsenceDaySummaryRow.GroupByStudentAndDay(absences), register, contentWidth, millimeterScale,
|
||||||
|
contentHeight);
|
||||||
|
}
|
||||||
|
|
||||||
|
public DrawingValue BuildAbsenceDayList(Student student, AttendanceCalendarOptions options, float contentWidth = 170,
|
||||||
|
float millimeterScale = 1)
|
||||||
|
{
|
||||||
|
var className = settings.HomeroomClassName;
|
||||||
|
if (string.IsNullOrWhiteSpace(className)) return new DrawingValue([], 0);
|
||||||
|
|
||||||
|
var first = options.NormalizedStartMonth;
|
||||||
|
var last = first.AddMonths(options.NormalizedMonthCount).AddDays(-1);
|
||||||
|
var matchName = $"{student.FirstName} {student.LastName}";
|
||||||
|
var rosterName = rosterCache.GetByClass(className)
|
||||||
|
.FirstOrDefault(r => UntisNameMatching.NamesMatch(r.DisplayName, matchName))?.DisplayName
|
||||||
|
?? matchName;
|
||||||
|
var start = first.Year * 10000 + first.Month * 100 + first.Day;
|
||||||
|
var end = last.Year * 10000 + last.Month * 100 + last.Day;
|
||||||
|
var absences = absenceCache.GetByClassAndRange(className, start, end)
|
||||||
|
.Select(e => new UntisClassAbsenceEntryDto(e.StudentName, e.ExternKey, e.ClassName, e.Date,
|
||||||
|
e.AbsentPeriods, e.AbsentMinutes, e.TeacherUsernames, e.Subject, e.AbsenceReason, e.Note,
|
||||||
|
e.EntryId, e.HandledOn, e.Counts, e.ExcuseNote, e.PeriodNumber, e.Status, e.CountsAsFullDay));
|
||||||
|
return StudentAbsenceDayListDrawingBuilder.Build(rosterName, options,
|
||||||
|
ClassAbsenceDaySummaryRow.GroupByStudentAndDay(absences), contentWidth, millimeterScale);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -25,7 +25,9 @@ public static class UntisHubActions
|
|||||||
App.Services.GetRequiredService<WebUntisIntegrationService>(),
|
App.Services.GetRequiredService<WebUntisIntegrationService>(),
|
||||||
App.Services.GetRequiredService<IStudentRepository>(),
|
App.Services.GetRequiredService<IStudentRepository>(),
|
||||||
App.Services.GetRequiredService<IParticipationSessionRepository>(),
|
App.Services.GetRequiredService<IParticipationSessionRepository>(),
|
||||||
App.Services.GetRequiredService<IParticipationRepository>())
|
App.Services.GetRequiredService<IParticipationRepository>(),
|
||||||
|
App.Services.GetRequiredService<AiPlanningService>(),
|
||||||
|
App.Services.GetRequiredService<AiSettingsService>())
|
||||||
{ StartDate = start.ToDateTime(TimeOnly.MinValue), EndDate = end.ToDateTime(TimeOnly.MinValue) };
|
{ StartDate = start.ToDateTime(TimeOnly.MinValue), EndDate = end.ToDateTime(TimeOnly.MinValue) };
|
||||||
var loaded = TrackLoad(vm, v => v.Busy);
|
var loaded = TrackLoad(vm, v => v.Busy);
|
||||||
await new WebUntisLessonAbsenceComparisonDialog { DataContext = vm }.ShowDialog(owner);
|
await new WebUntisLessonAbsenceComparisonDialog { DataContext = vm }.ShowDialog(owner);
|
||||||
|
|||||||
@@ -40,17 +40,29 @@ public sealed class UntisHubService(
|
|||||||
public List<UntisHubJobRow> GetRows()
|
public List<UntisHubJobRow> GetRows()
|
||||||
{
|
{
|
||||||
var eligibleGroups = groups.GetBySchoolYear(schoolYears.CurrentSchoolYear())
|
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)
|
.OrderBy(g => g.Name)
|
||||||
.ToList();
|
.ToList();
|
||||||
return BuildRows(eligibleGroups, jobStates.GetAll(), DateTime.UtcNow);
|
return BuildRows(eligibleGroups, jobStates.GetAll(), DateTime.UtcNow);
|
||||||
}
|
}
|
||||||
|
|
||||||
public void RecordRun(UntisHubJobKind kind, Guid? groupId, string? summary) =>
|
/// <summary>Der Langzeit-Fehlzeitenabgleich deckt seit Schuljahresbeginn ein Zeitfenster ab, das
|
||||||
|
/// das kurzfristige vollständig einschließt - ohne das hier mitzuziehen bliebe die kurzfristige
|
||||||
|
/// Kadenz trotz erledigtem Langzeit-Abgleich als fällig stehen (Nutzer-Feedback). Umgekehrt deckt
|
||||||
|
/// ein Kurz-Lauf das lange Fenster nicht ab, bleibt also einseitig.
|
||||||
|
public void RecordRun(UntisHubJobKind kind, Guid? groupId, string? summary)
|
||||||
|
{
|
||||||
|
var now = DateTime.UtcNow;
|
||||||
|
Save(kind, groupId, now, summary);
|
||||||
|
if (kind == UntisHubJobKind.FehlzeitenLang)
|
||||||
|
Save(UntisHubJobKind.FehlzeitenKurz, groupId, now, "durch Abgleich seit Schuljahresbeginn mit erledigt");
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Save(UntisHubJobKind kind, Guid? groupId, DateTime at, string? summary) =>
|
||||||
jobStates.Save(new UntisHubJobState
|
jobStates.Save(new UntisHubJobState
|
||||||
{
|
{
|
||||||
Id = jobStates.Get(kind, groupId)?.Id ?? Guid.NewGuid(),
|
Id = jobStates.Get(kind, groupId)?.Id ?? Guid.NewGuid(),
|
||||||
Kind = kind, GroupId = groupId, LastRunAt = DateTime.UtcNow, LastResultSummary = summary,
|
Kind = kind, GroupId = groupId, LastRunAt = at, LastResultSummary = summary,
|
||||||
});
|
});
|
||||||
|
|
||||||
/// Reine Entscheidungslogik ohne Repository-Zugriff (gleiches Muster wie
|
/// Reine Entscheidungslogik ohne Repository-Zugriff (gleiches Muster wie
|
||||||
@@ -60,8 +72,14 @@ public sealed class UntisHubService(
|
|||||||
public static List<UntisHubJobRow> BuildRows(
|
public static List<UntisHubJobRow> BuildRows(
|
||||||
IReadOnlyList<LearningGroup> eligibleGroups, IReadOnlyList<UntisHubJobState> states, DateTime utcNow)
|
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) =>
|
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>();
|
var rows = new List<UntisHubJobRow>();
|
||||||
foreach (var group in eligibleGroups)
|
foreach (var group in eligibleGroups)
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
using LehrerApp.Core.Importing;
|
||||||
|
using LehrerApp.Core.Models;
|
||||||
|
|
||||||
|
namespace LehrerApp.Desktop.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Aus <c>WebUntisLessonAbsenceComparisonViewModel</c> herausgelöst (Nachtrag: die MCP-Tools in
|
||||||
|
/// <c>UntisComparisonTools</c> brauchen exakt dieselbe Regel für dieselbe Fehlzeile, siehe TODO.md) -
|
||||||
|
/// framework-frei (kein ObservableObject/Avalonia-Bezug), damit beide Aufrufer ohne Duplikat
|
||||||
|
/// garantiert denselben Statusvorschlag berechnen. Eine Abweichung zwischen Dialog und MCP-Tool für
|
||||||
|
/// dieselbe WebUntis-Zeile wäre verwirrender als die eine zusätzliche Indirektion hier.
|
||||||
|
/// </summary>
|
||||||
|
public static class UntisLessonAbsenceHelper
|
||||||
|
{
|
||||||
|
private const int FullLessonMinutes = 45;
|
||||||
|
|
||||||
|
// Der Bericht liefert keinen Entschuldigungstext, nur Minutenwerte, ein Bearbeitet-Datum und die
|
||||||
|
// (laut Schule) über Klammerung der ENr codierte Entscheidung des Klassenlehrers - ENr in
|
||||||
|
// Klammern bedeutet unentschuldigt, ohne Klammern abgeschlossen/entschuldigt. Reihenfolge ist
|
||||||
|
// wichtig: "nach Hause entlassen" zählt immer als vorzeitige Entlassung, unabhängig von der
|
||||||
|
// Dauer; darunter zählt jede Fehlzeit unter einer vollen Stunde (45 Min.) immer als Verspätung
|
||||||
|
// oder sonstiger Teilverlust, nie als komplette Abwesenheit - der Text "Verspätung" allein ist
|
||||||
|
// laut Schule nicht zuverlässig genug, deshalb primär über die Minutenschwelle erkannt.
|
||||||
|
public static AttendanceStatus MapStatus(UntisLessonAbsenceDto absence)
|
||||||
|
{
|
||||||
|
if (IsEarlyRelease(absence)) return AttendanceStatus.LeftDuringClass;
|
||||||
|
if (absence.AbsentMinutes < FullLessonMinutes) return AttendanceStatus.Late;
|
||||||
|
if (string.IsNullOrWhiteSpace(absence.HandledOn)) return AttendanceStatus.ExcusePending;
|
||||||
|
if (absence.ExternKey is null) return AttendanceStatus.ExcusePending;
|
||||||
|
return absence.ExternKeyInParentheses ? AttendanceStatus.Unexcused : AttendanceStatus.Excused;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsEarlyRelease(UntisLessonAbsenceDto absence) =>
|
||||||
|
absence.Reason?.Contains("entlassen", StringComparison.OrdinalIgnoreCase) == true;
|
||||||
|
|
||||||
|
/// Erste Wahl zum Zuordnen einer WebUntis-Fehlzeile: die externe Schülernummer (ENr), sofern der
|
||||||
|
/// Schüler eine hat (z.B. nicht bei manuell statt per WebUntis-Import angelegten Schülern).
|
||||||
|
public static int? StudentExternKey(Student student)
|
||||||
|
{
|
||||||
|
student.ExternalIds ??= [];
|
||||||
|
return student.ExternalIds.TryGetValue(StudentImportFormats.MasterDataCsv.Value, out var value)
|
||||||
|
&& int.TryParse(value, out var key) ? key : null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -65,7 +65,7 @@ public class UntisSyncService : IDisposable
|
|||||||
TimeSpan.FromMinutes(PollIntervalMinutes), TimeSpan.FromMinutes(PollIntervalMinutes));
|
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;
|
if (!await _gate.WaitAsync(0).ConfigureAwait(false)) return;
|
||||||
try
|
try
|
||||||
@@ -76,7 +76,7 @@ public class UntisSyncService : IDisposable
|
|||||||
string icsText;
|
string icsText;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
icsText = await _http.GetStringAsync(url).ConfigureAwait(false);
|
icsText = await _http.GetStringAsync(url, cancellationToken).ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
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>
|
/// Schlagwort gezielt dämpfen kann, siehe <see cref="ClassTeacherRosterRow.MatchDomains"/>.</summary>
|
||||||
public enum VorgangScoreDomain { Attendance, Lateness, Classbook }
|
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,
|
public sealed record ClassTeacherRosterRow(string StudentName, int? ExternKey, bool HasAbsenceToday,
|
||||||
string? AbsenceTooltip, bool HasRecentClassRegisterEntry)
|
string? AbsenceTooltip, bool HasRecentClassRegisterEntry)
|
||||||
{
|
{
|
||||||
@@ -424,6 +428,7 @@ public partial class ClassTeacherOverviewViewModel : ObservableObject
|
|||||||
public ObservableCollection<ClassTeacherPatternNotice> PatternNotices { get; } = [];
|
public ObservableCollection<ClassTeacherPatternNotice> PatternNotices { get; } = [];
|
||||||
public ObservableCollection<ClassTeacherOpenExcuseRow> OpenExcuses { get; } = [];
|
public ObservableCollection<ClassTeacherOpenExcuseRow> OpenExcuses { get; } = [];
|
||||||
public ObservableCollection<ClassTeacherCompactCalendarDay> CompactMonthDays { get; } = [];
|
public ObservableCollection<ClassTeacherCompactCalendarDay> CompactMonthDays { get; } = [];
|
||||||
|
public ObservableCollection<ClassTeacherCalendarScope> CalendarScopes { get; } = [];
|
||||||
|
|
||||||
[ObservableProperty] private string? _homeroomClassName;
|
[ObservableProperty] private string? _homeroomClassName;
|
||||||
[ObservableProperty] private int _activeTabIndex;
|
[ObservableProperty] private int _activeTabIndex;
|
||||||
@@ -453,6 +458,7 @@ public partial class ClassTeacherOverviewViewModel : ObservableObject
|
|||||||
/// den "Klassenbuch öffnen"-Button gehängt statt in einer eigenen Kennzahlkarte.
|
/// den "Klassenbuch öffnen"-Button gehängt statt in einer eigenen Kennzahlkarte.
|
||||||
[ObservableProperty] private int _ownDocumentationFollowUpCount;
|
[ObservableProperty] private int _ownDocumentationFollowUpCount;
|
||||||
[ObservableProperty] private int _ownDocumentationCriticalCount;
|
[ObservableProperty] private int _ownDocumentationCriticalCount;
|
||||||
|
[ObservableProperty] private ClassTeacherCalendarScope? _selectedCalendarScope;
|
||||||
|
|
||||||
// Zuletzt per Load() geholte WebUntis-Rohdaten, für RefreshFromLocalDataOnly() - damit ein
|
// Zuletzt per Load() geholte WebUntis-Rohdaten, für RefreshFromLocalDataOnly() - damit ein
|
||||||
// eingehendes Sync-Ereignis die Ansicht neu aufbauen kann, ohne selbst WebUntis anzufragen.
|
// 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 double UnexcusedAbsenceFraction => StudentCount == 0 ? 0 : (double)UnexcusedAbsenceCount / StudentCount;
|
||||||
public string DayOverviewTooltip => $"{PresentCount} anwesend · {LateCount} verspätet · " +
|
public string DayOverviewTooltip => $"{PresentCount} anwesend · {LateCount} verspätet · " +
|
||||||
$"{ExcusedAbsenceCount} entschuldigt · {UnexcusedAbsenceCount} unentschuldigt";
|
$"{ExcusedAbsenceCount} entschuldigt · {UnexcusedAbsenceCount} unentschuldigt";
|
||||||
public string CompactMonthLabel => DateOnly.FromDateTime(DateTime.Today).ToString("MMMM yyyy",
|
public string CompactMonthLabel
|
||||||
System.Globalization.CultureInfo.GetCultureInfo("de-DE"));
|
{
|
||||||
|
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>? OnNavigateToSettings { get; set; }
|
||||||
public Func<Task>? OnNavigateToWorkload { 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 OnOpenExcuseOverflowCountChanged(int value) => OnPropertyChanged(nameof(HasOpenExcuseOverflow));
|
||||||
partial void OnOwnDocumentationFollowUpCountChanged(int value) => OnPropertyChanged(nameof(HasOwnDocumentationAlerts));
|
partial void OnOwnDocumentationFollowUpCountChanged(int value) => OnPropertyChanged(nameof(HasOwnDocumentationAlerts));
|
||||||
partial void OnOwnDocumentationCriticalCountChanged(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 OnSearchTextChanged(string value) => ApplyRosterFilter();
|
||||||
partial void OnSelectedRosterFilterChanged(int value)
|
partial void OnSelectedRosterFilterChanged(int value)
|
||||||
{
|
{
|
||||||
@@ -548,7 +568,8 @@ public partial class ClassTeacherOverviewViewModel : ObservableObject
|
|||||||
{
|
{
|
||||||
ActiveTabIndex = 0;
|
ActiveTabIndex = 0;
|
||||||
Roster.Clear(); PrimaryRoster.Clear(); SecondaryRoster.Clear(); TrendDays.Clear(); PatternNotices.Clear();
|
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
|
// 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,
|
holidayWeekdaysExcluded, termStart, classRegisterEvents,
|
||||||
_patternScoreSettings.Load(), closedVorgaengeByNameKey)) Roster.Add(row);
|
_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;
|
StudentCount = Roster.Count;
|
||||||
TodayAlertCount = Roster.Count(r => r.HasAbsenceToday);
|
TodayAlertCount = Roster.Count(r => r.HasAbsenceToday);
|
||||||
TodayUnexcusedCount = Roster.Count(r => r.IsUnexcused);
|
TodayUnexcusedCount = Roster.Count(r => r.IsUnexcused);
|
||||||
@@ -724,7 +751,7 @@ public partial class ClassTeacherOverviewViewModel : ObservableObject
|
|||||||
private void OpenMonthlyCalendar()
|
private void OpenMonthlyCalendar()
|
||||||
{
|
{
|
||||||
var today = DateOnly.FromDateTime(DateTime.Today);
|
var today = DateOnly.FromDateTime(DateTime.Today);
|
||||||
DetailsTab.StudentFilter = "";
|
DetailsTab.StudentFilter = SelectedCalendarScope?.StudentName ?? "";
|
||||||
DetailsTab.CalendarMonth = new DateOnly(today.Year, today.Month, 1);
|
DetailsTab.CalendarMonth = new DateOnly(today.Year, today.Month, 1);
|
||||||
ActiveTabIndex = 2;
|
ActiveTabIndex = 2;
|
||||||
DetailsTab.ShowCalendarCommand.Execute(null);
|
DetailsTab.ShowCalendarCommand.Execute(null);
|
||||||
@@ -735,15 +762,29 @@ public partial class ClassTeacherOverviewViewModel : ObservableObject
|
|||||||
{
|
{
|
||||||
CompactMonthDays.Clear();
|
CompactMonthDays.Clear();
|
||||||
foreach (var day in BuildCompactMonthDays(new DateOnly(today.Year, today.Month, 1),
|
foreach (var day in BuildCompactMonthDays(new DateOnly(today.Year, today.Month, 1),
|
||||||
absences, registerEntries, today))
|
absences, registerEntries, today, SelectedCalendarScope?.StudentName))
|
||||||
CompactMonthDays.Add(day);
|
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,
|
public static IReadOnlyList<ClassTeacherCompactCalendarDay> BuildCompactMonthDays(DateOnly month,
|
||||||
IReadOnlyList<ClassAbsenceDaySummaryRow> absences,
|
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);
|
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
|
var registerRows = registerEntries
|
||||||
.Select(e => (Entry: e, Valid: TryDate(e.Date, out var date), Date: date))
|
.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)
|
.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;
|
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
|
private static int SignalPriority(ClassTeacherCalendarEventKind kind) => kind switch
|
||||||
{
|
{
|
||||||
ClassTeacherCalendarEventKind.Unexcused => 0,
|
ClassTeacherCalendarEventKind.Unexcused => 0,
|
||||||
|
|||||||
@@ -44,6 +44,15 @@ public partial class GroupOverviewViewModel : ObservableObject
|
|||||||
private readonly GradingService _grading;
|
private readonly GradingService _grading;
|
||||||
private readonly SchoolYearService _schoolYear;
|
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 Guid _groupId;
|
||||||
private string _groupName = "";
|
private string _groupName = "";
|
||||||
|
|
||||||
@@ -130,6 +139,16 @@ public partial class GroupOverviewViewModel : ObservableObject
|
|||||||
public void Refresh()
|
public void Refresh()
|
||||||
{
|
{
|
||||||
var today = DateOnly.FromDateTime(DateTime.Today);
|
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);
|
LoadNextLesson(today);
|
||||||
LoadNextExam(today);
|
LoadNextExam(today);
|
||||||
LoadYearComparison();
|
LoadYearComparison();
|
||||||
@@ -145,7 +164,7 @@ public partial class GroupOverviewViewModel : ObservableObject
|
|||||||
private void LoadNextLesson(DateOnly today)
|
private void LoadNextLesson(DateOnly today)
|
||||||
{
|
{
|
||||||
var next = _lessons.GetByGroupAndRange(_groupId, today, today.AddDays(NextLessonLookaheadDays))
|
var next = _lessons.GetByGroupAndRange(_groupId, today, today.AddDays(NextLessonLookaheadDays))
|
||||||
.Where(l => l.Status != LessonStatus.Conducted)
|
.Where(l => l.Status is not (LessonStatus.Conducted or LessonStatus.Cancelled))
|
||||||
.OrderBy(l => l.Date).ThenBy(l => l.LessonNumber ?? 0)
|
.OrderBy(l => l.Date).ThenBy(l => l.LessonNumber ?? 0)
|
||||||
.FirstOrDefault();
|
.FirstOrDefault();
|
||||||
|
|
||||||
@@ -220,6 +239,7 @@ public partial class GroupOverviewViewModel : ObservableObject
|
|||||||
private void LoadOpenHomeworkCheck(DateOnly today)
|
private void LoadOpenHomeworkCheck(DateOnly today)
|
||||||
{
|
{
|
||||||
var previous = _lessons.GetByGroupAndRange(_groupId, today.AddDays(-HomeworkCheckLookbackDays), today.AddDays(-1))
|
var previous = _lessons.GetByGroupAndRange(_groupId, today.AddDays(-HomeworkCheckLookbackDays), today.AddDays(-1))
|
||||||
|
.Where(l => l.Status != LessonStatus.Cancelled)
|
||||||
.OrderByDescending(l => l.Date).ThenByDescending(l => l.LessonNumber ?? 0)
|
.OrderByDescending(l => l.Date).ThenByDescending(l => l.LessonNumber ?? 0)
|
||||||
.FirstOrDefault();
|
.FirstOrDefault();
|
||||||
|
|
||||||
|
|||||||
@@ -826,6 +826,7 @@ public partial class AddGroupDialogViewModel : ObservableObject
|
|||||||
[ObservableProperty] private bool _isDifferentiated;
|
[ObservableProperty] private bool _isDifferentiated;
|
||||||
[ObservableProperty] private bool _requiresLessonPlanning = true;
|
[ObservableProperty] private bool _requiresLessonPlanning = true;
|
||||||
[ObservableProperty] private int? _webUntisLessonId;
|
[ObservableProperty] private int? _webUntisLessonId;
|
||||||
|
[ObservableProperty] private bool _excludedFromUntisHub;
|
||||||
[ObservableProperty] private string _nameError = "";
|
[ObservableProperty] private string _nameError = "";
|
||||||
[ObservableProperty] private string _gradeLevelError = "";
|
[ObservableProperty] private string _gradeLevelError = "";
|
||||||
|
|
||||||
@@ -866,6 +867,7 @@ public partial class AddGroupDialogViewModel : ObservableObject
|
|||||||
IsDifferentiated = group.IsDifferentiated;
|
IsDifferentiated = group.IsDifferentiated;
|
||||||
RequiresLessonPlanning = group.RequiresLessonPlanning;
|
RequiresLessonPlanning = group.RequiresLessonPlanning;
|
||||||
WebUntisLessonId = group.WebUntisLessonId;
|
WebUntisLessonId = group.WebUntisLessonId;
|
||||||
|
ExcludedFromUntisHub = group.ExcludedFromUntisHub;
|
||||||
OnPropertyChanged(nameof(DialogTitle));
|
OnPropertyChanged(nameof(DialogTitle));
|
||||||
OnPropertyChanged(nameof(SaveButtonText));
|
OnPropertyChanged(nameof(SaveButtonText));
|
||||||
}
|
}
|
||||||
@@ -913,6 +915,7 @@ public partial class AddGroupDialogViewModel : ObservableObject
|
|||||||
Result.IsDifferentiated = IsDifferentiated;
|
Result.IsDifferentiated = IsDifferentiated;
|
||||||
Result.RequiresLessonPlanning = RequiresLessonPlanning;
|
Result.RequiresLessonPlanning = RequiresLessonPlanning;
|
||||||
Result.WebUntisLessonId = WebUntisLessonId;
|
Result.WebUntisLessonId = WebUntisLessonId;
|
||||||
|
Result.ExcludedFromUntisHub = ExcludedFromUntisHub;
|
||||||
_groups.Save(Result);
|
_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";
|
||||||
|
}
|
||||||
@@ -376,7 +376,7 @@ public partial class PlanningTabViewModel : ObservableObject
|
|||||||
[RelayCommand(CanExecute = nameof(HasSelectedLesson))]
|
[RelayCommand(CanExecute = nameof(HasSelectedLesson))]
|
||||||
private void AdvanceLessonStatus()
|
private void AdvanceLessonStatus()
|
||||||
{
|
{
|
||||||
if (SelectedLesson is null || SelectedLesson.Model.Status == LessonStatus.Conducted) return;
|
if (SelectedLesson is null || SelectedLesson.Model.Status is LessonStatus.Conducted or LessonStatus.Cancelled) return;
|
||||||
var lesson = SelectedLesson.Model;
|
var lesson = SelectedLesson.Model;
|
||||||
lesson.Status = lesson.Status == LessonStatus.Ready
|
lesson.Status = lesson.Status == LessonStatus.Ready
|
||||||
? LessonStatus.Conducted
|
? LessonStatus.Conducted
|
||||||
@@ -467,8 +467,11 @@ public class UnitSummary
|
|||||||
};
|
};
|
||||||
CompetencyCountLabel = u.Competencies.Count == 0 ? "–" : $"{u.Competencies.Count} Kompetenz(en)";
|
CompetencyCountLabel = u.Competencies.Count == 0 ? "–" : $"{u.Competencies.Count} Kompetenz(en)";
|
||||||
|
|
||||||
TotalCount = lessons.Count;
|
// Ausgefallene Stunden zählen weder als gehalten noch als noch zu haltendes Pensum -
|
||||||
ConductedCount = lessons.Count(l => l.Status == LessonStatus.Conducted);
|
// sie fallen komplett aus dem Fortschritt heraus, statt den Nenner künstlich zu erhöhen.
|
||||||
|
var countableLessons = lessons.Where(l => l.Status != LessonStatus.Cancelled).ToList();
|
||||||
|
TotalCount = countableLessons.Count;
|
||||||
|
ConductedCount = countableLessons.Count(l => l.Status == LessonStatus.Conducted);
|
||||||
ProgressFraction = TotalCount == 0 ? 0 : (double)ConductedCount / TotalCount;
|
ProgressFraction = TotalCount == 0 ? 0 : (double)ConductedCount / TotalCount;
|
||||||
ProgressText = TotalCount == 0 ? "Keine Stunden" : $"{ConductedCount} / {TotalCount} Stunden gehalten";
|
ProgressText = TotalCount == 0 ? "Keine Stunden" : $"{ConductedCount} / {TotalCount} Stunden gehalten";
|
||||||
}
|
}
|
||||||
@@ -507,6 +510,7 @@ public class LessonSummary
|
|||||||
LessonStatus.Draft => "#78909C",
|
LessonStatus.Draft => "#78909C",
|
||||||
LessonStatus.Ready => "#1976D2",
|
LessonStatus.Ready => "#1976D2",
|
||||||
LessonStatus.Conducted => "#43A047",
|
LessonStatus.Conducted => "#43A047",
|
||||||
|
LessonStatus.Cancelled => "#C62828",
|
||||||
_ => "#9E9E9E",
|
_ => "#9E9E9E",
|
||||||
};
|
};
|
||||||
PhaseCountLabel = l.Phases.Count == 0 ? "–" : $"{l.Phases.Count} Phasen";
|
PhaseCountLabel = l.Phases.Count == 0 ? "–" : $"{l.Phases.Count} Phasen";
|
||||||
@@ -543,13 +547,14 @@ public static class UnitStatusDisplay
|
|||||||
|
|
||||||
public static class LessonStatusDisplay
|
public static class LessonStatusDisplay
|
||||||
{
|
{
|
||||||
public static string[] Options { get; } = ["Entwurf", "Geplant", "Bereit", "Durchgeführt"];
|
public static string[] Options { get; } = ["Entwurf", "Geplant", "Bereit", "Durchgeführt", "Ausgefallen"];
|
||||||
|
|
||||||
public static string ToName(LessonStatus s) => s switch
|
public static string ToName(LessonStatus s) => s switch
|
||||||
{
|
{
|
||||||
LessonStatus.Draft => "Entwurf",
|
LessonStatus.Draft => "Entwurf",
|
||||||
LessonStatus.Ready => "Bereit",
|
LessonStatus.Ready => "Bereit",
|
||||||
LessonStatus.Conducted => "Durchgeführt",
|
LessonStatus.Conducted => "Durchgeführt",
|
||||||
|
LessonStatus.Cancelled => "Ausgefallen",
|
||||||
_ => "Geplant",
|
_ => "Geplant",
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -558,6 +563,7 @@ public static class LessonStatusDisplay
|
|||||||
"Entwurf" => LessonStatus.Draft,
|
"Entwurf" => LessonStatus.Draft,
|
||||||
"Bereit" => LessonStatus.Ready,
|
"Bereit" => LessonStatus.Ready,
|
||||||
"Durchgeführt" => LessonStatus.Conducted,
|
"Durchgeführt" => LessonStatus.Conducted,
|
||||||
|
"Ausgefallen" => LessonStatus.Cancelled,
|
||||||
_ => LessonStatus.Planned,
|
_ => LessonStatus.Planned,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -731,6 +737,7 @@ public partial class LessonDialogViewModel : ObservableObject
|
|||||||
[ObservableProperty] private int? _lessonNumber;
|
[ObservableProperty] private int? _lessonNumber;
|
||||||
[ObservableProperty] private string _topic = "";
|
[ObservableProperty] private string _topic = "";
|
||||||
[ObservableProperty] private string _startTimeText = "";
|
[ObservableProperty] private string _startTimeText = "";
|
||||||
|
[ObservableProperty] private string _planningIdeas = "";
|
||||||
[ObservableProperty] private string _homework = "";
|
[ObservableProperty] private string _homework = "";
|
||||||
[ObservableProperty] private bool _homeworkChecked;
|
[ObservableProperty] private bool _homeworkChecked;
|
||||||
[ObservableProperty] private bool _homeworkCheckDismissed;
|
[ObservableProperty] private bool _homeworkCheckDismissed;
|
||||||
@@ -817,6 +824,7 @@ public partial class LessonDialogViewModel : ObservableObject
|
|||||||
LessonNumber = editingLesson.LessonNumber;
|
LessonNumber = editingLesson.LessonNumber;
|
||||||
Topic = editingLesson.Topic;
|
Topic = editingLesson.Topic;
|
||||||
StartTimeText = editingLesson.StartTime?.ToString("HH:mm") ?? "";
|
StartTimeText = editingLesson.StartTime?.ToString("HH:mm") ?? "";
|
||||||
|
PlanningIdeas = editingLesson.PlanningIdeas ?? "";
|
||||||
Homework = editingLesson.Homework ?? "";
|
Homework = editingLesson.Homework ?? "";
|
||||||
HomeworkChecked = editingLesson.HomeworkChecked;
|
HomeworkChecked = editingLesson.HomeworkChecked;
|
||||||
HomeworkCheckDismissed = editingLesson.HomeworkCheckDismissed;
|
HomeworkCheckDismissed = editingLesson.HomeworkCheckDismissed;
|
||||||
@@ -882,6 +890,7 @@ public partial class LessonDialogViewModel : ObservableObject
|
|||||||
Activity = source?.Activity ?? "",
|
Activity = source?.Activity ?? "",
|
||||||
Material = source?.Material ?? "",
|
Material = source?.Material ?? "",
|
||||||
Shorthand = source?.Shorthand ?? "",
|
Shorthand = source?.Shorthand ?? "",
|
||||||
|
MaterialPrompt = source?.MaterialPrompt,
|
||||||
};
|
};
|
||||||
item.OnChanged = RecomputeTimes;
|
item.OnChanged = RecomputeTimes;
|
||||||
item.OnRemove = RemovePhase;
|
item.OnRemove = RemovePhase;
|
||||||
@@ -1069,6 +1078,7 @@ public partial class LessonDialogViewModel : ObservableObject
|
|||||||
Result.LessonNumber = LessonNumber;
|
Result.LessonNumber = LessonNumber;
|
||||||
Result.Topic = Topic.Trim();
|
Result.Topic = Topic.Trim();
|
||||||
Result.StartTime = startTime;
|
Result.StartTime = startTime;
|
||||||
|
Result.PlanningIdeas = string.IsNullOrWhiteSpace(PlanningIdeas) ? null : PlanningIdeas.Trim();
|
||||||
Result.Phases = Phases.Select(p => p.ToModel()).ToList();
|
Result.Phases = Phases.Select(p => p.ToModel()).ToList();
|
||||||
Result.Homework = string.IsNullOrWhiteSpace(Homework) ? null : Homework.Trim();
|
Result.Homework = string.IsNullOrWhiteSpace(Homework) ? null : Homework.Trim();
|
||||||
Result.HomeworkChecked = HomeworkChecked;
|
Result.HomeworkChecked = HomeworkChecked;
|
||||||
@@ -1097,6 +1107,13 @@ public partial class PhaseStepEditItem : ObservableObject
|
|||||||
[ObservableProperty] private string _material = "";
|
[ObservableProperty] private string _material = "";
|
||||||
[ObservableProperty] private string _shorthand = "";
|
[ObservableProperty] private string _shorthand = "";
|
||||||
[ObservableProperty] private string _computedTimeDisplay = "";
|
[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
|
/// Checkbox-Zustand im Editor: unchecked→checked öffnet den Zuweisen-Dialog
|
||||||
/// (<see cref="OnAssignAlternativePath"/>); checked→unchecked entfernt die Zuordnung.
|
/// (<see cref="OnAssignAlternativePath"/>); checked→unchecked entfernt die Zuordnung.
|
||||||
@@ -1156,6 +1173,7 @@ public partial class PhaseStepEditItem : ObservableObject
|
|||||||
Activity = Activity.Trim(),
|
Activity = Activity.Trim(),
|
||||||
Material = Material.Trim(),
|
Material = Material.Trim(),
|
||||||
Shorthand = Shorthand.Trim(),
|
Shorthand = Shorthand.Trim(),
|
||||||
|
MaterialPrompt = MaterialPrompt,
|
||||||
AlternativePathId = AlternativePathId,
|
AlternativePathId = AlternativePathId,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,6 +16,45 @@ public partial class SeatingPlanTabViewModel : ObservableObject
|
|||||||
private readonly IParticipationRepository _participation;
|
private readonly IParticipationRepository _participation;
|
||||||
private readonly IParticipationAspectRepository _aspects;
|
private readonly IParticipationAspectRepository _aspects;
|
||||||
private readonly IDocumentationRepository? _documentation;
|
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 Guid _groupId;
|
||||||
private SeatingPlan? _currentPlan;
|
private SeatingPlan? _currentPlan;
|
||||||
private bool _isReadOnly;
|
private bool _isReadOnly;
|
||||||
@@ -235,7 +274,12 @@ public partial class SeatingPlanTabViewModel : ObservableObject
|
|||||||
?? StudentSeatOption.Empty;
|
?? StudentSeatOption.Empty;
|
||||||
var isHidden = hiddenSeats.Any(h => h.Row == row && h.Column == column);
|
var isHidden = hiddenSeats.Any(h => h.Row == row && h.Column == column);
|
||||||
Seats.Add(new SeatCellViewModel(row, column, StudentOptions, option, OnSeatChanged,
|
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();
|
UpdateAssignmentSummary();
|
||||||
RefreshSeatLessonData();
|
RefreshSeatLessonData();
|
||||||
@@ -488,6 +532,7 @@ public partial class SeatingPlanTabViewModel : ObservableObject
|
|||||||
|
|
||||||
partial void OnIsEditModeChanged(bool value)
|
partial void OnIsEditModeChanged(bool value)
|
||||||
{
|
{
|
||||||
|
if (value) QuickMode = "";
|
||||||
OnPropertyChanged(nameof(CanEditLayout));
|
OnPropertyChanged(nameof(CanEditLayout));
|
||||||
foreach (var seat in Seats)
|
foreach (var seat in Seats)
|
||||||
{
|
{
|
||||||
@@ -540,6 +585,32 @@ public sealed class ParticipationSessionOption(ParticipationSession session)
|
|||||||
|
|
||||||
public partial class SeatCellViewModel : ObservableObject
|
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 readonly Action<SeatCellViewModel> _onChanged;
|
||||||
private bool _suppressChange;
|
private bool _suppressChange;
|
||||||
private readonly Action<SeatCellViewModel, string> _toggleSituationTag;
|
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
|
/// 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
|
/// 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>
|
/// 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;
|
private readonly Action<SeatCellViewModel, bool> _tally;
|
||||||
|
|
||||||
@@ -615,6 +686,9 @@ public partial class SeatCellViewModel : ObservableObject
|
|||||||
|
|
||||||
partial void OnSelectedOptionChanged(StudentSeatOption value)
|
partial void OnSelectedOptionChanged(StudentSeatOption value)
|
||||||
{
|
{
|
||||||
|
OnPropertyChanged(nameof(ShowQuickCheck));
|
||||||
|
OnPropertyChanged(nameof(ShowNormalActions));
|
||||||
|
OnPropertyChanged(nameof(ShowSituationActions));
|
||||||
OnPropertyChanged(nameof(IsOccupied));
|
OnPropertyChanged(nameof(IsOccupied));
|
||||||
OnPropertyChanged(nameof(StudentName));
|
OnPropertyChanged(nameof(StudentName));
|
||||||
OnPropertyChanged(nameof(ShowLessonOverview));
|
OnPropertyChanged(nameof(ShowLessonOverview));
|
||||||
@@ -624,6 +698,8 @@ public partial class SeatCellViewModel : ObservableObject
|
|||||||
|
|
||||||
partial void OnCanEditChanged(bool value)
|
partial void OnCanEditChanged(bool value)
|
||||||
{
|
{
|
||||||
|
OnPropertyChanged(nameof(ShowNormalActions));
|
||||||
|
OnPropertyChanged(nameof(ShowSituationActions));
|
||||||
OnPropertyChanged(nameof(ShowLessonOverview));
|
OnPropertyChanged(nameof(ShowLessonOverview));
|
||||||
OnPropertyChanged(nameof(ShowSeat));
|
OnPropertyChanged(nameof(ShowSeat));
|
||||||
OnPropertyChanged(nameof(CanToggleHidden));
|
OnPropertyChanged(nameof(CanToggleHidden));
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ namespace LehrerApp.Desktop.ViewModels.Groups;
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public class TeachingModeViewModel
|
public class TeachingModeViewModel
|
||||||
{
|
{
|
||||||
|
public TeachingTimelineViewModel Timeline { get; }
|
||||||
public string GroupName { get; }
|
public string GroupName { get; }
|
||||||
public LessonViewerViewModel LessonInfo { get; }
|
public LessonViewerViewModel LessonInfo { get; }
|
||||||
public SeatingPlanTabViewModel SeatingPlan { get; }
|
public SeatingPlanTabViewModel SeatingPlan { get; }
|
||||||
@@ -38,9 +39,11 @@ public class TeachingModeViewModel
|
|||||||
SeatingPlanTabViewModel seatingPlan, ParticipationTabViewModel participation)
|
SeatingPlanTabViewModel seatingPlan, ParticipationTabViewModel participation)
|
||||||
{
|
{
|
||||||
GroupName = group.Name;
|
GroupName = group.Name;
|
||||||
|
Timeline = new TeachingTimelineViewModel(lesson, lessons, !group.IsActive);
|
||||||
LessonInfo = new LessonViewerViewModel(lesson, alternativePaths);
|
LessonInfo = new LessonViewerViewModel(lesson, alternativePaths);
|
||||||
|
|
||||||
SeatingPlan = seatingPlan;
|
SeatingPlan = seatingPlan;
|
||||||
|
SeatingPlan.IsTeachingMode = true;
|
||||||
SeatingPlan.Initialize(group.Id, !group.IsActive);
|
SeatingPlan.Initialize(group.Id, !group.IsActive);
|
||||||
SeatingPlan.SelectOrCreateSessionForLesson(lesson);
|
SeatingPlan.SelectOrCreateSessionForLesson(lesson);
|
||||||
|
|
||||||
@@ -102,14 +105,19 @@ public partial class TeachingModeHomeworkViewModel : ObservableObject
|
|||||||
if (_previousLesson is null) return;
|
if (_previousLesson is null) return;
|
||||||
_previousLesson.HomeworkChecked = value;
|
_previousLesson.HomeworkChecked = value;
|
||||||
if (value) _previousLesson.HomeworkCheckDismissed = false;
|
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]
|
[RelayCommand]
|
||||||
private void SaveCurrentHomework()
|
private void SaveCurrentHomework()
|
||||||
{
|
{
|
||||||
_lesson.Homework = CurrentHomework;
|
_lesson.Homework = CurrentHomework;
|
||||||
_lessons.Save(_lesson);
|
var latest = _lessons.GetById(_lesson.Id) ?? _lesson;
|
||||||
|
latest.Homework = CurrentHomework;
|
||||||
|
_lessons.Save(latest);
|
||||||
SaveStatus = "Gespeichert.";
|
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);
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
using System.Collections.ObjectModel;
|
using System.Collections.ObjectModel;
|
||||||
using CommunityToolkit.Mvvm.ComponentModel;
|
using CommunityToolkit.Mvvm.ComponentModel;
|
||||||
using CommunityToolkit.Mvvm.Input;
|
using CommunityToolkit.Mvvm.Input;
|
||||||
using LehrerApp.Core.Importing;
|
using LehrerApp.Core.AiPlanning;
|
||||||
using LehrerApp.Core.Interfaces;
|
using LehrerApp.Core.Interfaces;
|
||||||
using LehrerApp.Core.Models;
|
using LehrerApp.Core.Models;
|
||||||
using LehrerApp.Desktop.Services;
|
using LehrerApp.Desktop.Services;
|
||||||
@@ -24,10 +24,38 @@ public partial class WebUntisLessonAbsenceRow : ObservableObject
|
|||||||
public string DateLabel => Date.ToString("dd.MM.yyyy");
|
public string DateLabel => Date.ToString("dd.MM.yyyy");
|
||||||
public bool CanApply => SessionId is not null;
|
public bool CanApply => SessionId is not null;
|
||||||
|
|
||||||
|
// Nur für den optionalen KI-Statusvorschlag mitgeführt (siehe
|
||||||
|
// WebUntisLessonAbsenceComparisonViewModel.SuggestStatusWithAi) - dieselben Rohsignale, aus
|
||||||
|
// denen MapStatus den TargetStatus berechnet, bewusst OHNE Name/Klasse/Datum, damit die Anfrage
|
||||||
|
// an das KI-Backend personenbezogen leer bleibt.
|
||||||
|
internal int AbsentMinutes { get; init; }
|
||||||
|
internal bool HandledOn { get; init; }
|
||||||
|
internal bool? ExternKeyInParentheses { get; init; }
|
||||||
|
|
||||||
|
// Statusübernahme ist per ComboBox anpassbar (Nutzer-Feedback: der aus WebUntis abgeleitete
|
||||||
|
// TargetStatus war über den reinen Anzeigetext oft nicht eindeutig nachvollziehbar; Ablehnen der
|
||||||
|
// ganzen Zeile und der Status manuell nachtragen war die einzige Korrekturmöglichkeit) - die
|
||||||
|
// ComboBox ist mit TargetStatus vorbelegt, aber vor "Übernehmen" frei änderbar. Bindet wie bei
|
||||||
|
// GradeCategoryDisplay über einen String-Wrapper statt direkt ans Enum (sonst ToString() auf
|
||||||
|
// Englisch). Nur die Stati, die MapStatus tatsächlich liefert bzw. die als Korrektur plausibel
|
||||||
|
// sind (nicht z.B. "Geschwänzt" oder "Suspendiert", die WebUntis hier nie meldet). Internal statt
|
||||||
|
// private, damit SuggestStatusWithAi eine von der KI zurückgegebene Statusangabe dagegen validieren
|
||||||
|
// kann, statt jeden von der KI genannten Enum-Namen blind zu übernehmen.
|
||||||
|
internal static readonly AttendanceStatus[] SelectableStatuses =
|
||||||
|
[
|
||||||
|
AttendanceStatus.ExcusePending, AttendanceStatus.Excused, AttendanceStatus.Unexcused,
|
||||||
|
AttendanceStatus.Late, AttendanceStatus.LeftDuringClass, AttendanceStatus.Present,
|
||||||
|
];
|
||||||
|
public static string[] StatusOptions { get; } = SelectableStatuses.Select(s => AttendanceDisplay.Label(s)).ToArray();
|
||||||
|
|
||||||
[ObservableProperty] private Student? _assignedStudent;
|
[ObservableProperty] private Student? _assignedStudent;
|
||||||
[ObservableProperty] private string _localStatus = "ohne Zuordnung";
|
[ObservableProperty] private string _localStatus = "ohne Zuordnung";
|
||||||
[ObservableProperty] private Guid? _sessionId;
|
[ObservableProperty] private Guid? _sessionId;
|
||||||
[ObservableProperty] private bool _selected;
|
[ObservableProperty] private bool _selected;
|
||||||
|
[ObservableProperty] private string _selectedStatusName = "";
|
||||||
|
|
||||||
|
public AttendanceStatus SelectedStatus =>
|
||||||
|
SelectableStatuses.FirstOrDefault(s => AttendanceDisplay.Label(s) == SelectedStatusName, TargetStatus);
|
||||||
|
|
||||||
partial void OnAssignedStudentChanged(Student? value) => OnAssignmentChanged?.Invoke(this);
|
partial void OnAssignedStudentChanged(Student? value) => OnAssignmentChanged?.Invoke(this);
|
||||||
}
|
}
|
||||||
@@ -42,6 +70,8 @@ public partial class WebUntisLessonAbsenceComparisonViewModel : ObservableObject
|
|||||||
private readonly IStudentRepository _students;
|
private readonly IStudentRepository _students;
|
||||||
private readonly IParticipationSessionRepository _sessions;
|
private readonly IParticipationSessionRepository _sessions;
|
||||||
private readonly IParticipationRepository _participation;
|
private readonly IParticipationRepository _participation;
|
||||||
|
private readonly AiPlanningService _ai;
|
||||||
|
private readonly AiSettingsService _aiSettings;
|
||||||
|
|
||||||
private IReadOnlyList<Student> _loadedStudents = [];
|
private IReadOnlyList<Student> _loadedStudents = [];
|
||||||
private IReadOnlyDictionary<DateOnly, ParticipationSession> _loadedSessions =
|
private IReadOnlyDictionary<DateOnly, ParticipationSession> _loadedSessions =
|
||||||
@@ -52,14 +82,15 @@ public partial class WebUntisLessonAbsenceComparisonViewModel : ObservableObject
|
|||||||
[ObservableProperty] private DateTimeOffset? _endDate = DateTimeOffset.Now;
|
[ObservableProperty] private DateTimeOffset? _endDate = DateTimeOffset.Now;
|
||||||
[ObservableProperty] private string _status = "Zeitraum wählen und Fehlzeiten laden.";
|
[ObservableProperty] private string _status = "Zeitraum wählen und Fehlzeiten laden.";
|
||||||
[ObservableProperty] private bool _busy;
|
[ObservableProperty] private bool _busy;
|
||||||
|
[ObservableProperty] private bool _aiSuggestBusy;
|
||||||
[ObservableProperty] private bool _markUnknownAsPresent;
|
[ObservableProperty] private bool _markUnknownAsPresent;
|
||||||
|
|
||||||
public WebUntisLessonAbsenceComparisonViewModel(LearningGroup group, WebUntisIntegrationService untis,
|
public WebUntisLessonAbsenceComparisonViewModel(LearningGroup group, WebUntisIntegrationService untis,
|
||||||
IStudentRepository students, IParticipationSessionRepository sessions,
|
IStudentRepository students, IParticipationSessionRepository sessions,
|
||||||
IParticipationRepository participation)
|
IParticipationRepository participation, AiPlanningService ai, AiSettingsService aiSettings)
|
||||||
{
|
{
|
||||||
_group = group; _untis = untis; _students = students; _sessions = sessions;
|
_group = group; _untis = untis; _students = students; _sessions = sessions;
|
||||||
_participation = participation;
|
_participation = participation; _ai = ai; _aiSettings = aiSettings;
|
||||||
}
|
}
|
||||||
|
|
||||||
[RelayCommand]
|
[RelayCommand]
|
||||||
@@ -88,7 +119,7 @@ public partial class WebUntisLessonAbsenceComparisonViewModel : ObservableObject
|
|||||||
// Erste Wahl: WebUntis-Kennung (ENr). Nicht jeder Schüler hat eine (z.B. manuell statt
|
// Erste Wahl: WebUntis-Kennung (ENr). Nicht jeder Schüler hat eine (z.B. manuell statt
|
||||||
// per WebUntis-Import angelegt) - Fallback über den Namen, aber nur wenn er innerhalb
|
// per WebUntis-Import angelegt) - Fallback über den Namen, aber nur wenn er innerhalb
|
||||||
// der Kursmitglieder eindeutig ist, sonst lieber unzugeordnet lassen als raten.
|
// der Kursmitglieder eindeutig ist, sonst lieber unzugeordnet lassen als raten.
|
||||||
var byKey = courseStudents.Select(student => (Student: student, Key: StudentKey(student)))
|
var byKey = courseStudents.Select(student => (Student: student, Key: UntisLessonAbsenceHelper.StudentExternKey(student)))
|
||||||
.Where(x => x.Key is not null).ToDictionary(x => x.Key!.Value, x => x.Student);
|
.Where(x => x.Key is not null).ToDictionary(x => x.Key!.Value, x => x.Student);
|
||||||
var byName = courseStudents
|
var byName = courseStudents
|
||||||
.SelectMany(student => new[]
|
.SelectMany(student => new[]
|
||||||
@@ -125,13 +156,18 @@ public partial class WebUntisLessonAbsenceComparisonViewModel : ObservableObject
|
|||||||
var match = absence.ExternKey is { } key && byKey.TryGetValue(key, out var byKeyStudent)
|
var match = absence.ExternKey is { } key && byKey.TryGetValue(key, out var byKeyStudent)
|
||||||
? byKeyStudent
|
? byKeyStudent
|
||||||
: byName.GetValueOrDefault(NameKey(absence.StudentName));
|
: byName.GetValueOrDefault(NameKey(absence.StudentName));
|
||||||
|
var targetStatus = MapStatus(absence);
|
||||||
var row = new WebUntisLessonAbsenceRow
|
var row = new WebUntisLessonAbsenceRow
|
||||||
{
|
{
|
||||||
UntisStudentName = absence.StudentName, Date = date!.Value,
|
UntisStudentName = absence.StudentName, Date = date!.Value,
|
||||||
TimeLabel = TimeLabel(absence.StartTime, absence.EndTime),
|
TimeLabel = TimeLabel(absence.StartTime, absence.EndTime),
|
||||||
UntisStatus = DisplayUntisStatus(absence),
|
UntisStatus = DisplayUntisStatus(absence),
|
||||||
TargetStatus = MapStatus(absence), Reason = absence.Reason,
|
TargetStatus = targetStatus, Reason = absence.Reason,
|
||||||
|
AbsentMinutes = absence.AbsentMinutes,
|
||||||
|
HandledOn = !string.IsNullOrWhiteSpace(absence.HandledOn),
|
||||||
|
ExternKeyInParentheses = absence.ExternKey is null ? null : absence.ExternKeyInParentheses,
|
||||||
Candidates = courseStudents, OnAssignmentChanged = ResolveLocalMatch,
|
Candidates = courseStudents, OnAssignmentChanged = ResolveLocalMatch,
|
||||||
|
SelectedStatusName = AttendanceDisplay.Label(targetStatus),
|
||||||
};
|
};
|
||||||
Rows.Add(row);
|
Rows.Add(row);
|
||||||
row.AssignedStudent = match; // löst OnAssignedStudentChanged aus und setzt SessionId/LocalStatus/Selected
|
row.AssignedStudent = match; // löst OnAssignedStudentChanged aus und setzt SessionId/LocalStatus/Selected
|
||||||
@@ -146,6 +182,59 @@ public partial class WebUntisLessonAbsenceComparisonViewModel : ObservableObject
|
|||||||
finally { Busy = false; }
|
finally { Busy = false; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Fragt für alle geladenen, einer lokalen Kursstunde zuordenbaren Zeilen in einer gebündelten
|
||||||
|
/// Anfrage (Kosten/Latenz, siehe AiPlanningService.RequestUntisStatusSuggestionsAsync) einen
|
||||||
|
/// KI-Statusvorschlag ab und setzt ihn nur in der "Übernahme als"-ComboBox vor - Namen, Klasse
|
||||||
|
/// und Datum verlassen dafür nie die App (siehe AiUntisStatusRow), nur die je Zeile rein
|
||||||
|
/// technische Positions-Id sowie die bereits lokal bekannten Rohsignale. Ersetzt nie
|
||||||
|
/// eigenständig einen bestehenden Übernahme-Status ohne Zutun der Lehrkraft - "Markierte
|
||||||
|
/// übernehmen" bleibt der einzige schreibende Schritt.
|
||||||
|
/// </summary>
|
||||||
|
[RelayCommand]
|
||||||
|
private async Task SuggestStatusWithAi()
|
||||||
|
{
|
||||||
|
var token = _aiSettings.GetToken();
|
||||||
|
if (token is null)
|
||||||
|
{
|
||||||
|
Status = "Nicht angemeldet. Bitte in den Einstellungen bei der KI-Unterstützung anmelden.";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var candidates = Rows.Where(x => x.CanApply).ToList();
|
||||||
|
if (candidates.Count == 0)
|
||||||
|
{
|
||||||
|
Status = "Keine Zeilen mit lokaler Kursstunde geladen.";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
AiSuggestBusy = true;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var requestRows = candidates.Select((row, i) => new AiUntisStatusRow
|
||||||
|
{
|
||||||
|
Id = i.ToString(), ReasonText = row.Reason ?? "", AbsentMinutes = row.AbsentMinutes,
|
||||||
|
HandledOn = row.HandledOn, ExternKeyInParentheses = row.ExternKeyInParentheses,
|
||||||
|
CurrentGuess = row.TargetStatus.ToString(),
|
||||||
|
}).ToList();
|
||||||
|
|
||||||
|
var suggestions = await _ai.RequestUntisStatusSuggestionsAsync(requestRows, token);
|
||||||
|
var applied = 0;
|
||||||
|
for (var i = 0; i < candidates.Count; i++)
|
||||||
|
{
|
||||||
|
if (!suggestions.TryGetValue(i.ToString(), out var statusName)) continue;
|
||||||
|
if (!Enum.TryParse<AttendanceStatus>(statusName, out var status)) continue;
|
||||||
|
if (!WebUntisLessonAbsenceRow.SelectableStatuses.Contains(status)) continue;
|
||||||
|
candidates[i].SelectedStatusName = AttendanceDisplay.Label(status);
|
||||||
|
applied++;
|
||||||
|
}
|
||||||
|
Status = suggestions.Count == 0
|
||||||
|
? "Die KI hat keinen verwertbaren Vorschlag geliefert, die bisherige Vorbelegung bleibt unverändert."
|
||||||
|
: $"KI-Vorschlag für {applied} von {candidates.Count} Zeilen in \"Übernahme als\" vorbelegt - bitte prüfen.";
|
||||||
|
}
|
||||||
|
catch (AiBackendException ex) { Status = ex.Message; }
|
||||||
|
finally { AiSuggestBusy = false; }
|
||||||
|
}
|
||||||
|
|
||||||
[RelayCommand]
|
[RelayCommand]
|
||||||
private void Apply()
|
private void Apply()
|
||||||
{
|
{
|
||||||
@@ -155,7 +244,7 @@ public partial class WebUntisLessonAbsenceComparisonViewModel : ObservableObject
|
|||||||
var studentId = row.AssignedStudent!.Id;
|
var studentId = row.AssignedStudent!.Id;
|
||||||
var entry = _participation.GetBySessionAndStudent(row.SessionId!.Value, studentId)
|
var entry = _participation.GetBySessionAndStudent(row.SessionId!.Value, studentId)
|
||||||
?? new ParticipationEntry { SessionId = row.SessionId.Value, StudentId = studentId };
|
?? new ParticipationEntry { SessionId = row.SessionId.Value, StudentId = studentId };
|
||||||
entry.Attendance = row.TargetStatus;
|
entry.Attendance = row.SelectedStatus;
|
||||||
entry.UpdatedAt = DateTime.UtcNow;
|
entry.UpdatedAt = DateTime.UtcNow;
|
||||||
_participation.Save(entry);
|
_participation.Save(entry);
|
||||||
}
|
}
|
||||||
@@ -192,38 +281,16 @@ public partial class WebUntisLessonAbsenceComparisonViewModel : ObservableObject
|
|||||||
return filled;
|
return filled;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static int? StudentKey(Student student)
|
|
||||||
{
|
|
||||||
student.ExternalIds ??= [];
|
|
||||||
return student.ExternalIds.TryGetValue(StudentImportFormats.MasterDataCsv.Value, out var value)
|
|
||||||
&& int.TryParse(value, out var key) ? key : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Groß-/Kleinschreibung, Leerraum und - da die tatsächliche WebUntis-Reihenfolge nicht
|
// Groß-/Kleinschreibung, Leerraum und - da die tatsächliche WebUntis-Reihenfolge nicht
|
||||||
// dokumentiert und schulabhängig unterschiedlich beobachtet wurde - beide Namensreihenfolgen
|
// dokumentiert und schulabhängig unterschiedlich beobachtet wurde - beide Namensreihenfolgen
|
||||||
// werden beim Aufbau von `byName` registriert; hier wird nur normalisiert.
|
// werden beim Aufbau von `byName` registriert; hier wird nur normalisiert.
|
||||||
private static string NameKey(string value) => value.Trim().ToLowerInvariant();
|
private static string NameKey(string value) => value.Trim().ToLowerInvariant();
|
||||||
|
|
||||||
private const int FullLessonMinutes = 45;
|
// MapStatus/StudentKey leben jetzt in UntisLessonAbsenceHelper (framework-frei), damit
|
||||||
|
// UntisComparisonTools (MCP) exakt dieselbe Regel verwendet statt eines eigenen Duplikats, das
|
||||||
// Der Bericht liefert keinen Entschuldigungstext, nur Minutenwerte, ein Bearbeitet-Datum und die
|
// aus dem Tritt geraten könnte.
|
||||||
// (laut Schule) über Klammerung der ENr codierte Entscheidung des Klassenlehrers - ENr in
|
private static AttendanceStatus MapStatus(UntisLessonAbsenceDto absence) =>
|
||||||
// Klammern bedeutet unentschuldigt, ohne Klammern abgeschlossen/entschuldigt. Reihenfolge ist
|
UntisLessonAbsenceHelper.MapStatus(absence);
|
||||||
// wichtig: "nach Hause entlassen" zählt immer als vorzeitige Entlassung, unabhängig von der
|
|
||||||
// Dauer; darunter zählt jede Fehlzeit unter einer vollen Stunde (45 Min.) immer als Verspätung
|
|
||||||
// oder sonstiger Teilverlust, nie als komplette Abwesenheit - der Text "Verspätung" allein ist
|
|
||||||
// laut Schule nicht zuverlässig genug, deshalb primär über die Minutenschwelle erkannt.
|
|
||||||
private static AttendanceStatus MapStatus(UntisLessonAbsenceDto absence)
|
|
||||||
{
|
|
||||||
if (IsEarlyRelease(absence)) return AttendanceStatus.LeftDuringClass;
|
|
||||||
if (absence.AbsentMinutes < FullLessonMinutes) return AttendanceStatus.Late;
|
|
||||||
if (string.IsNullOrWhiteSpace(absence.HandledOn)) return AttendanceStatus.ExcusePending;
|
|
||||||
if (absence.ExternKey is null) return AttendanceStatus.ExcusePending;
|
|
||||||
return absence.ExternKeyInParentheses ? AttendanceStatus.Unexcused : AttendanceStatus.Excused;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static bool IsEarlyRelease(UntisLessonAbsenceDto absence) =>
|
|
||||||
absence.Reason?.Contains("entlassen", StringComparison.OrdinalIgnoreCase) == true;
|
|
||||||
|
|
||||||
// Nutzt dieselben deutschen Bezeichnungen wie die reguläre Mitarbeitserfassung
|
// Nutzt dieselben deutschen Bezeichnungen wie die reguläre Mitarbeitserfassung
|
||||||
// (AttendanceDisplay.Label), statt eigene Statustexte zu erfinden.
|
// (AttendanceDisplay.Label), statt eigene Statustexte zu erfinden.
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ using LehrerApp.Desktop.ViewModels.Students;
|
|||||||
using LehrerApp.Desktop.ViewModels.Workload;
|
using LehrerApp.Desktop.ViewModels.Workload;
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
using System.Collections.ObjectModel;
|
using System.Collections.ObjectModel;
|
||||||
|
using System.ComponentModel;
|
||||||
|
|
||||||
namespace LehrerApp.Desktop.ViewModels;
|
namespace LehrerApp.Desktop.ViewModels;
|
||||||
|
|
||||||
@@ -38,6 +39,9 @@ public partial class MainWindowViewModel : ObservableObject
|
|||||||
public bool IsClassTeacherActive => ActiveNavItem == NavItem.ClassTeacher;
|
public bool IsClassTeacherActive => ActiveNavItem == NavItem.ClassTeacher;
|
||||||
public bool IsSettingsActive => ActiveNavItem == NavItem.Settings;
|
public bool IsSettingsActive => ActiveNavItem == NavItem.Settings;
|
||||||
|
|
||||||
|
public GroupDetailViewModel? CurrentGroupDetail => CurrentPage as GroupDetailViewModel;
|
||||||
|
public bool CanPersonalizeWorksheet => CurrentGroupDetail?.Group is not null;
|
||||||
|
|
||||||
public MainWindowViewModel(IServiceProvider services,
|
public MainWindowViewModel(IServiceProvider services,
|
||||||
DashboardViewModel dashboard, SchoolYearService sy,
|
DashboardViewModel dashboard, SchoolYearService sy,
|
||||||
SyncStatusViewModel syncStatus, NotificationService notifications, AppLockViewModel appLock,
|
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)
|
partial void OnActiveNavItemChanged(NavItem value)
|
||||||
{
|
{
|
||||||
OnPropertyChanged(nameof(IsDashboardActive));
|
OnPropertyChanged(nameof(IsDashboardActive));
|
||||||
|
|||||||
@@ -612,6 +612,7 @@ public partial class TimetableViewModel : ObservableObject
|
|||||||
private bool HasUnhandledHomework(Guid groupId, DateOnly date)
|
private bool HasUnhandledHomework(Guid groupId, DateOnly date)
|
||||||
{
|
{
|
||||||
var previousLesson = _lessons.GetByGroupAndRange(groupId, date.AddDays(-120), date.AddDays(-1))
|
var previousLesson = _lessons.GetByGroupAndRange(groupId, date.AddDays(-120), date.AddDays(-1))
|
||||||
|
.Where(l => l.Status != LessonStatus.Cancelled)
|
||||||
.OrderByDescending(l => l.Date).ThenByDescending(l => l.LessonNumber ?? 0)
|
.OrderByDescending(l => l.Date).ThenByDescending(l => l.LessonNumber ?? 0)
|
||||||
.FirstOrDefault();
|
.FirstOrDefault();
|
||||||
if (previousLesson is null || string.IsNullOrWhiteSpace(previousLesson.Homework)) return false;
|
if (previousLesson is null || string.IsNullOrWhiteSpace(previousLesson.Homework)) return false;
|
||||||
|
|||||||
@@ -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 IStudentRepository _students;
|
||||||
private readonly IShorthandCodeRepository _shorthandCodes;
|
private readonly IShorthandCodeRepository _shorthandCodes;
|
||||||
private readonly TemplateStore _letterTemplates;
|
private readonly TemplateStore _letterTemplates;
|
||||||
|
private readonly WorksheetTemplateStore _worksheetTemplates;
|
||||||
|
|
||||||
[ObservableProperty] private int _activeTabIndex;
|
[ObservableProperty] private int _activeTabIndex;
|
||||||
|
|
||||||
@@ -102,6 +103,7 @@ public partial class SettingsViewModel : ObservableObject
|
|||||||
IShorthandCodeRepository shorthandCodes, ISchoolHolidayRepository schoolHolidays,
|
IShorthandCodeRepository shorthandCodes, ISchoolHolidayRepository schoolHolidays,
|
||||||
SchoolCalendarSettingsService calendarSettings, PeriodScheduleService periodSchedule,
|
SchoolCalendarSettingsService calendarSettings, PeriodScheduleService periodSchedule,
|
||||||
ISupervisionDutyRepository supervisionDuties, TemplateStore letterTemplates,
|
ISupervisionDutyRepository supervisionDuties, TemplateStore letterTemplates,
|
||||||
|
WorksheetTemplateStore worksheetTemplates,
|
||||||
AiSettingsService aiSettings, AiPlanningService aiPlanning, McpSettingsService mcpSettings,
|
AiSettingsService aiSettings, AiPlanningService aiPlanning, McpSettingsService mcpSettings,
|
||||||
Services.Mcp.McpClientRegistrationService mcpRegistration,
|
Services.Mcp.McpClientRegistrationService mcpRegistration,
|
||||||
WebUntisSettingsService untisSettings,
|
WebUntisSettingsService untisSettings,
|
||||||
@@ -139,6 +141,7 @@ public partial class SettingsViewModel : ObservableObject
|
|||||||
_periodSchedule = periodSchedule;
|
_periodSchedule = periodSchedule;
|
||||||
_supervisionDuties = supervisionDuties;
|
_supervisionDuties = supervisionDuties;
|
||||||
_letterTemplates = letterTemplates;
|
_letterTemplates = letterTemplates;
|
||||||
|
_worksheetTemplates = worksheetTemplates;
|
||||||
_aiSettings = aiSettings;
|
_aiSettings = aiSettings;
|
||||||
_aiPlanning = aiPlanning;
|
_aiPlanning = aiPlanning;
|
||||||
_mcpSettings = mcpSettings;
|
_mcpSettings = mcpSettings;
|
||||||
@@ -169,6 +172,7 @@ public partial class SettingsViewModel : ObservableObject
|
|||||||
LoadPeriodTimes();
|
LoadPeriodTimes();
|
||||||
LoadSupervisionDuties();
|
LoadSupervisionDuties();
|
||||||
LoadLetterTemplates();
|
LoadLetterTemplates();
|
||||||
|
LoadWorksheetTemplates();
|
||||||
LoadAiSettings();
|
LoadAiSettings();
|
||||||
LoadMcpSettings();
|
LoadMcpSettings();
|
||||||
LoadMcpRegistrationStatus();
|
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 Student _student;
|
||||||
private readonly TemplateStore _templates;
|
private readonly TemplateStore _templates;
|
||||||
private readonly ITemplateRenderer _renderer;
|
private readonly ITemplateRenderer _renderer;
|
||||||
|
private readonly Func<AttendanceCalendarOptions, float, float, float, DrawingValue>? _attendanceCalendarFactory;
|
||||||
|
private readonly Func<AttendanceCalendarOptions, float, float, float, DrawingValue>? _absenceDayListFactory;
|
||||||
|
private readonly Func<AttendanceCalendarOptions, CancellationToken, Task>? _attendanceDataRefresher;
|
||||||
|
private AttendanceCalendarOptions _attendanceCalendarOptions = new(
|
||||||
|
new DateOnly(DateTime.Today.Year, DateTime.Today.Month, 1), 1);
|
||||||
|
|
||||||
[ObservableProperty] private LetterTemplateChoice? _selectedTemplate;
|
[ObservableProperty] private LetterTemplateChoice? _selectedTemplate;
|
||||||
[ObservableProperty] private LetterContactChoice? _selectedContact;
|
[ObservableProperty] private LetterContactChoice? _selectedContact;
|
||||||
[ObservableProperty] private LetterGroupChoice? _selectedGroup;
|
[ObservableProperty] private LetterGroupChoice? _selectedGroup;
|
||||||
[ObservableProperty] private DateTimeOffset? _letterDate = DateTimeOffset.Now;
|
[ObservableProperty] private DateTimeOffset? _letterDate = DateTimeOffset.Now;
|
||||||
|
[ObservableProperty] private string _anrede = "";
|
||||||
[ObservableProperty] private string _letterText = "";
|
[ObservableProperty] private string _letterText = "";
|
||||||
[ObservableProperty] private string _teacherName = "";
|
[ObservableProperty] private string _teacherName = "";
|
||||||
[ObservableProperty] private string _generationError = "";
|
[ObservableProperty] private string _generationError = "";
|
||||||
[ObservableProperty] private bool _canGenerate;
|
[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 string StudentName => _student.FullName;
|
||||||
public ObservableCollection<LetterTemplateChoice> Templates { get; } = [];
|
public ObservableCollection<LetterTemplateChoice> Templates { get; } = [];
|
||||||
public ObservableCollection<LetterContactChoice> Contacts { get; } = [];
|
public ObservableCollection<LetterContactChoice> Contacts { get; } = [];
|
||||||
public ObservableCollection<LetterGroupChoice> Groups { get; } = [];
|
public ObservableCollection<LetterGroupChoice> Groups { get; } = [];
|
||||||
public ObservableCollection<LetterGenerationIssue> Issues { get; } = [];
|
public ObservableCollection<LetterGenerationIssue> Issues { get; } = [];
|
||||||
|
public ObservableCollection<LetterPlaceholderInput> CustomPlaceholders { get; } = [];
|
||||||
public bool HasIssues => Issues.Count > 0;
|
public bool HasIssues => Issues.Count > 0;
|
||||||
|
public bool HasCustomPlaceholders => CustomPlaceholders.Count > 0;
|
||||||
public bool HasNoTemplates => Templates.Count == 0;
|
public bool HasNoTemplates => Templates.Count == 0;
|
||||||
public bool HasNoContacts => Contacts.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(
|
public string SuggestedFileName => SanitizeFileName(
|
||||||
$"{SelectedTemplate?.Name ?? "Elternbrief"}_{_student.LastName}_{_student.FirstName}.pdf");
|
$"{SelectedTemplate?.Name ?? "Elternbrief"}_{_student.LastName}_{_student.FirstName}.pdf");
|
||||||
|
|
||||||
public CreateLetterDialogViewModel(Student student, TemplateStore templates, ITemplateRenderer renderer,
|
public CreateLetterDialogViewModel(Student student, TemplateStore templates, ITemplateRenderer renderer,
|
||||||
IGroupMembershipRepository memberships, IGroupRepository groups)
|
IGroupMembershipRepository memberships, IGroupRepository groups,
|
||||||
|
Func<AttendanceCalendarOptions, float, float, float, DrawingValue>? attendanceCalendarFactory = null,
|
||||||
|
Func<AttendanceCalendarOptions, float, float, float, DrawingValue>? absenceDayListFactory = null,
|
||||||
|
Func<AttendanceCalendarOptions, CancellationToken, Task>? attendanceDataRefresher = null)
|
||||||
{
|
{
|
||||||
_student = student; _templates = templates; _renderer = renderer;
|
_student = student; _templates = templates; _renderer = renderer;
|
||||||
|
_attendanceCalendarFactory = attendanceCalendarFactory;
|
||||||
|
_absenceDayListFactory = absenceDayListFactory;
|
||||||
|
_attendanceDataRefresher = attendanceDataRefresher;
|
||||||
foreach (var template in templates.GetTemplates()) Templates.Add(new(template));
|
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 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))
|
foreach (var membership in memberships.GetByStudent(student.Id))
|
||||||
@@ -45,10 +68,33 @@ public partial class CreateLetterDialogViewModel : ObservableObject
|
|||||||
RefreshValidation();
|
RefreshValidation();
|
||||||
}
|
}
|
||||||
|
|
||||||
partial void OnSelectedTemplateChanged(LetterTemplateChoice? value) { OnPropertyChanged(nameof(SuggestedFileName)); RefreshValidation(); }
|
partial void OnSelectedTemplateChanged(LetterTemplateChoice? value)
|
||||||
partial void OnSelectedContactChanged(LetterContactChoice? value) => RefreshValidation();
|
{
|
||||||
|
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 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 OnLetterTextChanged(string value) => RefreshValidation();
|
||||||
partial void OnTeacherNameChanged(string value) => RefreshValidation();
|
partial void OnTeacherNameChanged(string value) => RefreshValidation();
|
||||||
|
|
||||||
@@ -89,9 +135,129 @@ public partial class CreateLetterDialogViewModel : ObservableObject
|
|||||||
OnPropertyChanged(nameof(HasIssues));
|
OnPropertyChanged(nameof(HasIssues));
|
||||||
}
|
}
|
||||||
|
|
||||||
private IReadOnlyDictionary<string, PlaceholderValue> BuildValues() => LetterPlaceholderBuilder.BuildStandardValues(
|
private IReadOnlyDictionary<string, PlaceholderValue> BuildValues()
|
||||||
_student, SelectedContact?.Model, SelectedGroup?.Model,
|
{
|
||||||
DateOnly.FromDateTime((LetterDate ?? DateTimeOffset.Now).LocalDateTime), LetterText, TeacherName);
|
var calendarBox = DeclaredDrawingBox(StudentAttendanceCalendarDrawingBuilder.PlaceholderName);
|
||||||
|
var absenceDayListBox = DeclaredDrawingBox(StudentAbsenceDayListDrawingBuilder.PlaceholderName);
|
||||||
|
var values = LetterPlaceholderBuilder.BuildStandardValues(
|
||||||
|
_student, SelectedContact?.Model, SelectedGroup?.Model,
|
||||||
|
DateOnly.FromDateTime((LetterDate ?? DateTimeOffset.Now).LocalDateTime), LetterText, TeacherName,
|
||||||
|
UsesAttendanceCalendar ? _attendanceCalendarFactory?.Invoke(_attendanceCalendarOptions,
|
||||||
|
calendarBox.Width, calendarBox.MillimeterScale,
|
||||||
|
calendarBox.IsFixed ? calendarBox.Height : float.PositiveInfinity) : null,
|
||||||
|
UsesAbsenceDayList ? _absenceDayListFactory?.Invoke(_attendanceCalendarOptions,
|
||||||
|
absenceDayListBox.Width, absenceDayListBox.MillimeterScale,
|
||||||
|
absenceDayListBox.IsFixed ? absenceDayListBox.Height : float.PositiveInfinity) : null);
|
||||||
|
values["Anrede"] = new TextValue(Anrede); values["Letter.Salutation"] = new TextValue(Anrede);
|
||||||
|
foreach (var custom in CustomPlaceholders) values[custom.Name] = custom.ToPlaceholderValue();
|
||||||
|
return values;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Tatsächliche DRAWBOX/FLOWDRAWBOX-Größe der aktuell gewählten Vorlage für diesen
|
||||||
|
/// Platzhalter, damit der Kalender/die Fehltagesliste die real verfügbare Fläche ausfüllen statt
|
||||||
|
/// eine feste Breite zu raten (siehe LetterPlaceholderBuilder.FindDeclaredDrawingBox). Fällt auf
|
||||||
|
/// 170mm zurück, wenn keine Vorlage gewählt ist oder die Box nicht gefunden wird.</summary>
|
||||||
|
private LetterPlaceholderBuilder.DeclaredDrawingBox DeclaredDrawingBox(string placeholderName)
|
||||||
|
{
|
||||||
|
var fallback = new LetterPlaceholderBuilder.DeclaredDrawingBox(170, float.PositiveInfinity, 1, false);
|
||||||
|
if (SelectedTemplate is null) return fallback;
|
||||||
|
try { return LetterPlaceholderBuilder.FindDeclaredDrawingBox(_templates.Load(SelectedTemplate.Model), placeholderName) ?? fallback; }
|
||||||
|
catch (Exception ex) when (ex is IOException or InvalidDataException or TemplateValidationException) { return fallback; }
|
||||||
|
}
|
||||||
|
|
||||||
|
private void RebuildCustomPlaceholders(LetterTemplateChoice? choice)
|
||||||
|
{
|
||||||
|
foreach (var existing in CustomPlaceholders) existing.PropertyChanged -= OnCustomPlaceholderChanged;
|
||||||
|
CustomPlaceholders.Clear();
|
||||||
|
if (choice is not null)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var loaded = _templates.Load(choice.Model);
|
||||||
|
foreach (var placeholder in loaded.Manifest.Placeholders.Where(p => !p.IsConstant
|
||||||
|
&& !StandardPlaceholderNames.Contains(p.Name) && p.Type is PlaceholderType.Text
|
||||||
|
or PlaceholderType.Multiline or PlaceholderType.Date or PlaceholderType.Number))
|
||||||
|
{
|
||||||
|
var input = new LetterPlaceholderInput(placeholder.Name, placeholder.Type);
|
||||||
|
input.PropertyChanged += OnCustomPlaceholderChanged;
|
||||||
|
CustomPlaceholders.Add(input);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex) when (ex is IOException or InvalidDataException or TemplateValidationException) { }
|
||||||
|
}
|
||||||
|
OnPropertyChanged(nameof(HasCustomPlaceholders));
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnCustomPlaceholderChanged(object? sender, System.ComponentModel.PropertyChangedEventArgs e) => RefreshValidation();
|
||||||
|
|
||||||
|
private static readonly HashSet<string> StandardPlaceholderNames = new(StringComparer.Ordinal)
|
||||||
|
{
|
||||||
|
"Datum", "CurrentDate", "Empfaenger", "Anrede", "Brieftext", "LehrerName",
|
||||||
|
"Student.FirstName", "Student.LastName", "Student.Name", "Contact.Name", "Contact.Address", "Contact.Street",
|
||||||
|
"Contact.PostalCode", "Contact.City", "Letter.Salutation", "Group.Name", "SchoolYear",
|
||||||
|
StudentAttendanceCalendarDrawingBuilder.PlaceholderName, StudentAbsenceDayListDrawingBuilder.PlaceholderName,
|
||||||
|
};
|
||||||
|
|
||||||
|
public AttendanceCalendarOptions GetAttendanceCalendarOptions() => _attendanceCalendarOptions;
|
||||||
|
|
||||||
|
public async Task SetAttendanceCalendarOptionsAsync(AttendanceCalendarOptions options, CancellationToken token = default)
|
||||||
|
{
|
||||||
|
_attendanceCalendarOptions = options with { StartMonth = options.NormalizedStartMonth,
|
||||||
|
MonthCount = options.NormalizedMonthCount };
|
||||||
|
AttendanceCalendarConfigured = true;
|
||||||
|
AttendanceCalendarSummary = FormatAttendanceCalendarSummary(_attendanceCalendarOptions);
|
||||||
|
AttendanceRefreshError = "";
|
||||||
|
if (_attendanceDataRefresher is not null)
|
||||||
|
{
|
||||||
|
IsRefreshingAttendanceData = true;
|
||||||
|
try { await _attendanceDataRefresher(_attendanceCalendarOptions, token); }
|
||||||
|
catch (WebUntisIntegrationException ex)
|
||||||
|
{ AttendanceRefreshError = $"WebUntis-Daten konnten nicht aktualisiert werden: {ex.Message}"; }
|
||||||
|
finally { IsRefreshingAttendanceData = false; }
|
||||||
|
}
|
||||||
|
RefreshValidation();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ResetAttendanceCalendarOptions()
|
||||||
|
{
|
||||||
|
var date = DateOnly.FromDateTime((LetterDate ?? DateTimeOffset.Now).LocalDateTime);
|
||||||
|
_attendanceCalendarOptions = new AttendanceCalendarOptions(new(date.Year, date.Month, 1), 1);
|
||||||
|
AttendanceCalendarSummary = FormatAttendanceCalendarSummary(_attendanceCalendarOptions);
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool TemplateUsesPlaceholder(LetterTemplateChoice? choice, string placeholderName)
|
||||||
|
{
|
||||||
|
if (choice is null) return false;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var loaded = _templates.Load(choice.Model);
|
||||||
|
return UsesPlaceholder(loaded.Layout, placeholderName) ||
|
||||||
|
(loaded.ContinuationLayout is not null && UsesPlaceholder(loaded.ContinuationLayout, placeholderName));
|
||||||
|
}
|
||||||
|
catch (Exception ex) when (ex is IOException or InvalidDataException or TemplateValidationException)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool UsesPlaceholder(TemplateLayout layout, string placeholderName) =>
|
||||||
|
layout.Elements.Concat(layout.PageTemplates.SelectMany(p => p.Elements))
|
||||||
|
.Concat(layout.ContentFlows.SelectMany(f => f.Elements))
|
||||||
|
.Any(e => e is DrawBoxElement draw && draw.Placeholder == placeholderName ||
|
||||||
|
e is FlowDrawBoxElement flow && flow.Placeholder == placeholderName);
|
||||||
|
|
||||||
|
private static string FormatAttendanceCalendarSummary(AttendanceCalendarOptions options)
|
||||||
|
{
|
||||||
|
var month = options.NormalizedStartMonth.ToString("MMMM yyyy",
|
||||||
|
System.Globalization.CultureInfo.GetCultureInfo("de-DE"));
|
||||||
|
var size = options.Size switch
|
||||||
|
{
|
||||||
|
AttendanceCalendarSize.Small => "Klein",
|
||||||
|
AttendanceCalendarSize.Large => "Groß",
|
||||||
|
_ => "Standard",
|
||||||
|
};
|
||||||
|
return $"Ab {month} · {options.NormalizedMonthCount} Monat{(options.NormalizedMonthCount == 1 ? "" : "e")} · {size}";
|
||||||
|
}
|
||||||
|
|
||||||
private static bool IsEmpty(PlaceholderValue value) => LetterPlaceholderBuilder.IsEmpty(value);
|
private static bool IsEmpty(PlaceholderValue value) => LetterPlaceholderBuilder.IsEmpty(value);
|
||||||
private static string SanitizeFileName(string value)
|
private static string SanitizeFileName(string value)
|
||||||
@@ -105,3 +271,33 @@ public sealed class LetterContactChoice(Contact model) { public Contact Model {
|
|||||||
public sealed class LetterGroupChoice(LearningGroup model) { public LearningGroup Model { get; } = model; public string Display => $"{Model.Name} · {Model.SchoolYear}"; }
|
public sealed class LetterGroupChoice(LearningGroup model) { public LearningGroup Model { get; } = model; public string Display => $"{Model.Name} · {Model.SchoolYear}"; }
|
||||||
public sealed class LetterGenerationIssue(string message, bool isStrong)
|
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 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"
|
Background="Transparent" BorderThickness="0" Padding="6,2"
|
||||||
Foreground="{DynamicResource AppAccentTextBrush}"/>
|
Foreground="{DynamicResource AppAccentTextBrush}"/>
|
||||||
</Grid>
|
</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 ItemsSource="{Binding CompactMonthDays}" HorizontalAlignment="Center">
|
||||||
<ItemsControl.ItemsPanel>
|
<ItemsControl.ItemsPanel>
|
||||||
<ItemsPanelTemplate><UniformGrid Columns="7"/></ItemsPanelTemplate>
|
<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."/>
|
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>
|
</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>
|
</StackPanel>
|
||||||
|
|
||||||
<Grid Grid.Row="1" ColumnDefinitions="*,8,*" Margin="0,20,0,0">
|
<Grid Grid.Row="1" ColumnDefinitions="*,8,*" Margin="0,20,0,0">
|
||||||
|
|||||||
@@ -161,7 +161,9 @@ public partial class GroupDetailView : UserControl
|
|||||||
App.Services.GetRequiredService<WebUntisIntegrationService>(),
|
App.Services.GetRequiredService<WebUntisIntegrationService>(),
|
||||||
App.Services.GetRequiredService<IStudentRepository>(),
|
App.Services.GetRequiredService<IStudentRepository>(),
|
||||||
App.Services.GetRequiredService<IParticipationSessionRepository>(),
|
App.Services.GetRequiredService<IParticipationSessionRepository>(),
|
||||||
App.Services.GetRequiredService<IParticipationRepository>());
|
App.Services.GetRequiredService<IParticipationRepository>(),
|
||||||
|
App.Services.GetRequiredService<AiPlanningService>(),
|
||||||
|
App.Services.GetRequiredService<AiSettingsService>());
|
||||||
await new WebUntisLessonAbsenceComparisonDialog { DataContext = dialogVm }.ShowDialog(owner);
|
await new WebUntisLessonAbsenceComparisonDialog { DataContext = dialogVm }.ShowDialog(owner);
|
||||||
vm.ParticipationTab.RefreshCurrentGrid();
|
vm.ParticipationTab.RefreshCurrentGrid();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
<UserControl xmlns="https://github.com/avaloniaui"
|
<UserControl xmlns="https://github.com/avaloniaui"
|
||||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
|
xmlns:models="clr-namespace:LehrerApp.Core.Models;assembly=LehrerApp.Core"
|
||||||
xmlns:vm="clr-namespace:LehrerApp.Desktop.ViewModels.Groups"
|
xmlns:vm="clr-namespace:LehrerApp.Desktop.ViewModels.Groups"
|
||||||
xmlns:vmRoot="clr-namespace:LehrerApp.Desktop.ViewModels"
|
xmlns:vmRoot="clr-namespace:LehrerApp.Desktop.ViewModels"
|
||||||
x:Class="LehrerApp.Desktop.Views.Groups.GroupOverviewTabView"
|
x:Class="LehrerApp.Desktop.Views.Groups.GroupOverviewTabView"
|
||||||
@@ -42,6 +43,19 @@
|
|||||||
<StackPanel Spacing="0">
|
<StackPanel Spacing="0">
|
||||||
<WrapPanel>
|
<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 -->
|
<!-- Nächste Stunde -->
|
||||||
<Border Classes="card">
|
<Border Classes="card">
|
||||||
<StackPanel>
|
<StackPanel>
|
||||||
|
|||||||
@@ -1,8 +1,14 @@
|
|||||||
using Avalonia.Controls;
|
using Avalonia.Controls;
|
||||||
|
using LehrerApp.Desktop.ViewModels.Groups;
|
||||||
|
|
||||||
namespace LehrerApp.Desktop.Views.Groups;
|
namespace LehrerApp.Desktop.Views.Groups;
|
||||||
|
|
||||||
public partial class GroupOverviewTabView : UserControl
|
public partial class GroupOverviewTabView : UserControl
|
||||||
{
|
{
|
||||||
public GroupOverviewTabView() => InitializeComponent();
|
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}}"/>
|
IsVisible="{Binding TopicError, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
|
||||||
</StackPanel>
|
</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"/>
|
<Separator Margin="0,4"/>
|
||||||
|
|
||||||
<Grid ColumnDefinitions="*,Auto">
|
<Grid ColumnDefinitions="*,Auto">
|
||||||
@@ -66,7 +73,7 @@
|
|||||||
</Grid>
|
</Grid>
|
||||||
|
|
||||||
<!-- Tabellenkopf -->
|
<!-- 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="0" Text="Phase" FontSize="11" FontWeight="SemiBold" Opacity="0.6"/>
|
||||||
<TextBlock Grid.Column="1" Text="Pfad" 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."/>
|
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">
|
<DataTemplate x:DataType="vm:PhaseStepEditItem">
|
||||||
<Border BorderBrush="{DynamicResource SystemControlForegroundBaseLowBrush}"
|
<Border BorderBrush="{DynamicResource SystemControlForegroundBaseLowBrush}"
|
||||||
BorderThickness="0,0,0,1" Padding="0,8">
|
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"
|
<TextBox Grid.Column="0" Text="{Binding Name}" PlaceholderText="z.B. Erarbeitung"
|
||||||
VerticalAlignment="Top" Margin="0,0,6,0"/>
|
VerticalAlignment="Top" Margin="0,0,6,0"/>
|
||||||
<CheckBox Grid.Column="1" IsChecked="{Binding HasAlternativePath}"
|
<CheckBox Grid.Column="1" IsChecked="{Binding HasAlternativePath}"
|
||||||
@@ -110,11 +117,14 @@
|
|||||||
FilterMode="Contains" MinimumPrefixLength="0" VerticalAlignment="Top"
|
FilterMode="Contains" MinimumPrefixLength="0" VerticalAlignment="Top"
|
||||||
Margin="0,0,6,0" PlaceholderText="z.B. AB001->S, Plenum, LDE"
|
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)."/>
|
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"/>
|
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"/>
|
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"/>
|
VerticalAlignment="Top" Margin="2,0,0,0" ToolTip.Tip="Entfernen"/>
|
||||||
</Grid>
|
</Grid>
|
||||||
</Border>
|
</Border>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using Avalonia.Controls;
|
using Avalonia.Controls;
|
||||||
|
using Avalonia.Input.Platform;
|
||||||
using Avalonia.Interactivity;
|
using Avalonia.Interactivity;
|
||||||
using Avalonia.Platform.Storage;
|
using Avalonia.Platform.Storage;
|
||||||
using LehrerApp.Core.Interfaces;
|
using LehrerApp.Core.Interfaces;
|
||||||
@@ -48,6 +49,20 @@ public partial class LessonDialog : Window
|
|||||||
Close(false);
|
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)
|
private async void OnAddAttachment(object? sender, RoutedEventArgs e)
|
||||||
{
|
{
|
||||||
if (DataContext is not LessonDialogViewModel vm) return;
|
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"/>
|
<Setter Property="Background" Value="Transparent"/>
|
||||||
</Style>
|
</Style>
|
||||||
</UserControl.Styles>
|
</UserControl.Styles>
|
||||||
<Grid ColumnDefinitions="260,*">
|
<Grid ColumnDefinitions="Auto,*">
|
||||||
<Border Grid.Column="0" BorderBrush="{DynamicResource SystemControlForegroundBaseLowBrush}"
|
<Border Grid.Column="0" Width="260" IsVisible="{Binding !IsTeachingMode}" BorderBrush="{DynamicResource SystemControlForegroundBaseLowBrush}"
|
||||||
BorderThickness="0,0,1,0" Padding="16">
|
BorderThickness="0,0,1,0" Padding="16">
|
||||||
<Grid RowDefinitions="Auto,*,Auto">
|
<Grid RowDefinitions="Auto,*,Auto">
|
||||||
<StackPanel Grid.Row="0" Spacing="4" Margin="0,0,0,12">
|
<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 RowDefinitions="Auto,*" ColumnDefinitions="*,Auto" IsVisible="{Binding HasSelectedPlan}" Margin="24">
|
||||||
<Grid Grid.Row="0" Grid.ColumnSpan="2" ColumnDefinitions="*,Auto" Margin="0,0,0,18">
|
<Grid Grid.Row="0" Grid.ColumnSpan="2" ColumnDefinitions="*,Auto" Margin="0,0,0,18">
|
||||||
<StackPanel Spacing="3">
|
<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 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"
|
<StackPanel Orientation="Horizontal" Spacing="8" Margin="0,6,0,0"
|
||||||
IsVisible="{Binding IsEditMode, Converter={x:Static BoolConverters.Not}}">
|
IsVisible="{Binding IsEditMode, Converter={x:Static BoolConverters.Not}}">
|
||||||
<TextBlock Text="Unterricht:" VerticalAlignment="Center" FontSize="12" Opacity="0.65"/>
|
<TextBlock Text="Unterricht:" VerticalAlignment="Center" FontSize="12" Opacity="0.65"/>
|
||||||
<ComboBox ItemsSource="{Binding TodaySessions}" SelectedItem="{Binding SelectedSession}"
|
<ComboBox ItemsSource="{Binding TodaySessions}" SelectedItem="{Binding SelectedSession}"
|
||||||
DisplayMemberBinding="{Binding DisplayName}" MinWidth="210"/>
|
DisplayMemberBinding="{Binding DisplayName}" MinWidth="210" IsEnabled="{Binding !IsTeachingMode}"/>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
|
</Border>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
<StackPanel Grid.Column="1" VerticalAlignment="Bottom" Spacing="4">
|
<StackPanel Grid.Column="1" VerticalAlignment="Bottom" Spacing="4">
|
||||||
<StackPanel Orientation="Horizontal" Spacing="10" HorizontalAlignment="Right">
|
<StackPanel Orientation="Horizontal" Spacing="10" HorizontalAlignment="Right">
|
||||||
<Button Content="Als PDF" Click="OnExportPdfClick" VerticalAlignment="Center"/>
|
<Button Content="Als PDF" Click="OnExportPdfClick" VerticalAlignment="Center"/>
|
||||||
<ToggleSwitch Content="Bearbeitungsmodus" IsChecked="{Binding IsEditMode}"
|
<Border IsVisible="{Binding !IsTeachingMode}">
|
||||||
IsVisible="{Binding IsEditable}"/>
|
<ToggleSwitch Content="Bearbeitungsmodus" IsChecked="{Binding IsEditMode}"
|
||||||
|
IsVisible="{Binding IsEditable}"/>
|
||||||
|
</Border>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
<TextBlock Text="{Binding AssignmentSummary}" HorizontalAlignment="Right" FontSize="12" Opacity="0.6"/>
|
<TextBlock Text="{Binding AssignmentSummary}" HorizontalAlignment="Right" FontSize="12" Opacity="0.6"/>
|
||||||
<TextBlock Text="Ziehen: Platz ändern · Klicken: bewerten" HorizontalAlignment="Right"
|
<TextBlock Text="Ziehen: Platz ändern · Klicken: bewerten" HorizontalAlignment="Right"
|
||||||
FontSize="11" Opacity="0.5" IsVisible="{Binding IsEditMode}"/>
|
FontSize="11" Opacity="0.5" IsVisible="{Binding IsEditMode}"/>
|
||||||
<TextBlock Text="Klicken: bewerten" HorizontalAlignment="Right"
|
<Border IsVisible="{Binding !IsTeachingMode}">
|
||||||
FontSize="11" Opacity="0.5" IsVisible="{Binding IsEditMode, Converter={x:Static BoolConverters.Not}}"/>
|
<TextBlock Text="{Binding QuickModeDisplay}" HorizontalAlignment="Right"
|
||||||
|
FontSize="11" Opacity="0.5" IsVisible="{Binding IsEditMode, Converter={x:Static BoolConverters.Not}}"/>
|
||||||
|
</Border>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|
||||||
@@ -158,10 +167,20 @@
|
|||||||
IsVisible="{Binding HasDayHighlightBadge}"
|
IsVisible="{Binding HasDayHighlightBadge}"
|
||||||
ToolTip.Tip="Tagesflagge"/>
|
ToolTip.Tip="Tagesflagge"/>
|
||||||
</StackPanel>
|
</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
|
<!-- Strichliste Meldungen (Nutzer-Feedback): schnelles Mitzählen ohne den
|
||||||
vollen Bewertungsdialog zu öffnen -->
|
vollen Bewertungsdialog zu öffnen -->
|
||||||
<StackPanel Orientation="Horizontal" HorizontalAlignment="Center" Spacing="5"
|
<StackPanel Orientation="Horizontal" HorizontalAlignment="Center" Spacing="5"
|
||||||
IsVisible="{Binding ShowLessonOverview}">
|
IsVisible="{Binding ShowNormalActions}">
|
||||||
<Button Padding="6,2" FontSize="10"
|
<Button Padding="6,2" FontSize="10"
|
||||||
Command="{Binding TallyRaisedHandCommand}"
|
Command="{Binding TallyRaisedHandCommand}"
|
||||||
ToolTip.Tip="Meldung zählen">
|
ToolTip.Tip="Meldung zählen">
|
||||||
@@ -174,7 +193,7 @@
|
|||||||
</Button>
|
</Button>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
<Expander Header="+ Situation" FontSize="10"
|
<Expander Header="+ Situation" FontSize="10"
|
||||||
IsVisible="{Binding CanRecordLesson}">
|
IsVisible="{Binding ShowSituationActions}">
|
||||||
<ItemsControl ItemsSource="{Binding SituationTags}" Margin="0,4,0,0">
|
<ItemsControl ItemsSource="{Binding SituationTags}" Margin="0,4,0,0">
|
||||||
<ItemsControl.ItemsPanel>
|
<ItemsControl.ItemsPanel>
|
||||||
<ItemsPanelTemplate><WrapPanel ItemSpacing="3" LineSpacing="3"/></ItemsPanelTemplate>
|
<ItemsPanelTemplate><WrapPanel ItemSpacing="3" LineSpacing="3"/></ItemsPanelTemplate>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
<Window xmlns="https://github.com/avaloniaui"
|
<Window xmlns="https://github.com/avaloniaui"
|
||||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
|
xmlns:models="clr-namespace:LehrerApp.Core.Models;assembly=LehrerApp.Core"
|
||||||
xmlns:vm="clr-namespace:LehrerApp.Desktop.ViewModels.Groups"
|
xmlns:vm="clr-namespace:LehrerApp.Desktop.ViewModels.Groups"
|
||||||
xmlns:views="clr-namespace:LehrerApp.Desktop.Views.Groups"
|
xmlns:views="clr-namespace:LehrerApp.Desktop.Views.Groups"
|
||||||
x:Class="LehrerApp.Desktop.Views.Groups.TeachingModeWindow"
|
x:Class="LehrerApp.Desktop.Views.Groups.TeachingModeWindow"
|
||||||
@@ -8,8 +9,23 @@
|
|||||||
Width="1400" Height="850" MinWidth="1000" MinHeight="600"
|
Width="1400" Height="850" MinWidth="1000" MinHeight="600"
|
||||||
WindowState="Maximized" CanResize="True" WindowStartupLocation="CenterScreen">
|
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 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">
|
<StackPanel Grid.Column="0" Spacing="3">
|
||||||
<TextBlock FontSize="20" FontWeight="SemiBold">
|
<TextBlock FontSize="20" FontWeight="SemiBold">
|
||||||
<Run Text="{Binding GroupName}"/><Run Text=" · "/><Run Text="{Binding LessonInfo.Topic}"/>
|
<Run Text="{Binding GroupName}"/><Run Text=" · "/><Run Text="{Binding LessonInfo.Topic}"/>
|
||||||
@@ -26,19 +42,22 @@
|
|||||||
</TextBlock>
|
</TextBlock>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
</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
|
<!-- Nutzer-Feedback: die Schnellbewertungs-Dialoge gab es bisher nur über den
|
||||||
Mitarbeit-Tab der Gruppe — hier direkt auf die Sitzung dieser Stunde vorselektiert
|
Mitarbeit-Tab der Gruppe — hier direkt auf die Sitzung dieser Stunde vorselektiert
|
||||||
(siehe TeachingModeViewModel), kein Umweg mehr über "Zur Mitarbeit". -->
|
(siehe TeachingModeViewModel), kein Umweg mehr über "Zur Mitarbeit". -->
|
||||||
<Button Content="⚡ Mitarbeit" Command="{Binding Participation.QuickInputCommand}"
|
<Button Content="⚡ Mitarbeit" Command="{Binding Participation.QuickInputCommand}"
|
||||||
ToolTip.Tip="Mitarbeit dieser Stunde schnell bewerten."/>
|
ToolTip.Tip="Mitarbeit dieser Stunde schnell bewerten."/>
|
||||||
<Button Content="⚡ Anwesenheit/Hausaufgabe" Command="{Binding Participation.StatusQuickInputCommand}"
|
<Button Content="Anwesenheit kontrollieren" Command="{Binding SeatingPlan.CheckAttendanceCommand}" IsEnabled="{Binding SeatingPlan.IsEditable}"/>
|
||||||
ToolTip.Tip="Anwesenheit und Hausaufgabenstatus dieser Stunde schnell erfassen."/>
|
<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}"
|
<Button Content="Zur Mitarbeit" Command="{Binding LessonInfo.NavigateToParticipationCommand}"
|
||||||
ToolTip.Tip="Schließt den Unterrichtsmodus und springt zum Tab 'Mitarbeit' der Lerngruppe."/>
|
ToolTip.Tip="Schließt den Unterrichtsmodus und springt zum Tab 'Mitarbeit' der Lerngruppe."/>
|
||||||
<Button Content="Zu den Noten" Command="{Binding LessonInfo.NavigateToGradesCommand}"/>
|
<Button Content="Zu den Noten" Command="{Binding LessonInfo.NavigateToGradesCommand}"/>
|
||||||
<Button Content="Unterrichtsmodus beenden" Click="OnClose" Margin="16,0,0,0"/>
|
<Button Content="Unterrichtsmodus beenden" Click="OnClose" Margin="16,0,0,0"/>
|
||||||
</StackPanel>
|
</WrapPanel>
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|
||||||
<Grid Grid.Row="1" ColumnDefinitions="360,16,*">
|
<Grid Grid.Row="1" ColumnDefinitions="360,16,*">
|
||||||
@@ -48,44 +67,78 @@
|
|||||||
<StackPanel Spacing="14">
|
<StackPanel Spacing="14">
|
||||||
<TextBlock Text="Verlaufsplan" FontSize="14" FontWeight="SemiBold"/>
|
<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>
|
<ItemsControl.ItemTemplate>
|
||||||
<DataTemplate x:DataType="vm:PhaseGroupViewItem">
|
<DataTemplate x:DataType="vm:TeachingPhaseViewModel">
|
||||||
<StackPanel Margin="0,0,0,10">
|
<Border Classes="phase" Classes.active="{Binding IsActive}" Classes.overflow="{Binding IsOverflow}" CornerRadius="6" Padding="10,8" Margin="0,0,0,8">
|
||||||
<StackPanel IsVisible="{Binding $parent[ItemsControl].((vm:LessonViewerViewModel)DataContext).HasAlternatives}">
|
<StackPanel Spacing="5">
|
||||||
<TextBlock Text="{Binding Label}" FontSize="12" FontWeight="SemiBold" Opacity="0.75" Margin="0,6,0,2"/>
|
<TextBlock Text="{Binding Source.Name}" FontWeight="SemiBold" TextWrapping="Wrap"/>
|
||||||
<TextBlock Text="{Binding Description}" FontSize="11" Opacity="0.55" TextWrapping="Wrap" Margin="0,0,0,6"
|
<TextBlock Text="{Binding TimeDisplay}" FontSize="12"/>
|
||||||
IsVisible="{Binding Description, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
|
<TextBlock Text="{Binding Source.DurationMinutes, StringFormat='Geplant: {0} Min.'}" FontSize="11" Opacity="0.7"/>
|
||||||
|
<TextBlock Text="{Binding Source.Activity}" TextWrapping="Wrap" FontSize="12"
|
||||||
|
IsVisible="{Binding Source.Activity, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
|
||||||
|
<TextBlock Text="{Binding Source.Material}" TextWrapping="Wrap" FontSize="11" Opacity="0.7"
|
||||||
|
IsVisible="{Binding Source.Material, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
|
||||||
|
<TextBlock Text="{Binding Source.Shorthand}" FontSize="11" Opacity="0.7"
|
||||||
|
IsVisible="{Binding Source.Shorthand, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
|
||||||
|
<ProgressBar Minimum="0" Maximum="100" Value="{Binding Progress}" Height="3" IsVisible="{Binding IsActive}"/>
|
||||||
|
<TextBlock Text="Über dem geplanten Stundenende" FontSize="11" IsVisible="{Binding IsOverflow}"/>
|
||||||
|
<TextBlock Text="Erledigt" FontSize="11" Opacity="0.6" IsVisible="{Binding IsCompleted}"/>
|
||||||
|
<TextBlock Text="In Folgestunde kopiert" FontSize="11" IsVisible="{Binding IsTransferred}"/>
|
||||||
|
<StackPanel IsVisible="{Binding IsActive}" IsEnabled="{Binding IsEditable}" Spacing="4">
|
||||||
|
<WrapPanel ItemSpacing="4" LineSpacing="4">
|
||||||
|
<Button Content="Weiter" Command="{Binding FinishCommand}" FontSize="11" Padding="6,4"/>
|
||||||
|
<Button Content="+5 Min." Command="{Binding ExtendFiveCommand}" FontSize="11" Padding="6,4"/>
|
||||||
|
<Button Content="+10 Min." Command="{Binding ExtendTenCommand}" FontSize="11" Padding="6,4"/>
|
||||||
|
<Button Content="Halten bis Weiter" Command="{Binding HoldCommand}" FontSize="11" Padding="6,4" IsEnabled="{Binding !IsHeld}"/>
|
||||||
|
</WrapPanel>
|
||||||
|
<TextBlock Text="Gehalten – mit Weiter nächste Phase starten" FontSize="11" TextWrapping="Wrap" IsVisible="{Binding IsHeld}"/>
|
||||||
|
</StackPanel>
|
||||||
|
<Button Content="Jetzt vorziehen" Command="{Binding BringForwardCommand}" IsVisible="{Binding CanStartNow}" FontSize="11"/>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
<ItemsControl ItemsSource="{Binding Phases}">
|
</Border>
|
||||||
<ItemsControl.ItemTemplate>
|
|
||||||
<DataTemplate x:DataType="vm:PhaseViewItem">
|
|
||||||
<Border Background="{DynamicResource SystemControlBackgroundAltHighBrush}"
|
|
||||||
CornerRadius="6" Padding="10,8" Margin="0,0,0,6">
|
|
||||||
<StackPanel Spacing="2">
|
|
||||||
<Grid ColumnDefinitions="*,Auto">
|
|
||||||
<TextBlock Grid.Column="0" Text="{Binding Name}" FontSize="13" FontWeight="SemiBold"
|
|
||||||
TextWrapping="Wrap"/>
|
|
||||||
<TextBlock Grid.Column="1" FontSize="11" Opacity="0.6">
|
|
||||||
<Run Text="{Binding TimeDisplay}"/><Run Text=" · "/>
|
|
||||||
<Run Text="{Binding DurationMinutes, StringFormat='{}{0} Min.'}"/>
|
|
||||||
</TextBlock>
|
|
||||||
</Grid>
|
|
||||||
<TextBlock Text="{Binding Activity}" FontSize="12" TextWrapping="Wrap"
|
|
||||||
IsVisible="{Binding Activity, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
|
|
||||||
<TextBlock FontSize="11" Opacity="0.55" TextWrapping="Wrap"
|
|
||||||
IsVisible="{Binding Material, Converter={x:Static StringConverters.IsNotNullOrEmpty}}">
|
|
||||||
<Run Text="Material: "/><Run Text="{Binding Material}"/>
|
|
||||||
</TextBlock>
|
|
||||||
</StackPanel>
|
|
||||||
</Border>
|
|
||||||
</DataTemplate>
|
|
||||||
</ItemsControl.ItemTemplate>
|
|
||||||
</ItemsControl>
|
|
||||||
</StackPanel>
|
|
||||||
</DataTemplate>
|
</DataTemplate>
|
||||||
</ItemsControl.ItemTemplate>
|
</ItemsControl.ItemTemplate>
|
||||||
</ItemsControl>
|
</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
|
<!-- Nutzer-Feedback: Hausaufgabe der letzten Stunde ansehen/als kontrolliert abhaken
|
||||||
und die Hausaufgabe DIESER Stunde einsehen/ändern, ohne den vollen
|
und die Hausaufgabe DIESER Stunde einsehen/ändern, ohne den vollen
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
using Avalonia.Controls;
|
using Avalonia.Controls;
|
||||||
using Avalonia.Interactivity;
|
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;
|
||||||
using LehrerApp.Desktop.ViewModels.Groups;
|
using LehrerApp.Desktop.ViewModels.Groups;
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
@@ -8,7 +12,53 @@ namespace LehrerApp.Desktop.Views.Groups;
|
|||||||
|
|
||||||
public partial class TeachingModeWindow : Window
|
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)
|
protected override void OnDataContextChanged(EventArgs e)
|
||||||
{
|
{
|
||||||
@@ -35,6 +85,7 @@ public partial class TeachingModeWindow : Window
|
|||||||
private async Task ShowQuickInputDialog(ParticipationTabViewModel tabVm)
|
private async Task ShowQuickInputDialog(ParticipationTabViewModel tabVm)
|
||||||
{
|
{
|
||||||
if (tabVm.StudentRows.Count == 0) return;
|
if (tabVm.StudentRows.Count == 0) return;
|
||||||
|
tabVm.RefreshCurrentGrid();
|
||||||
var quickVm = new QuickInputViewModel(tabVm.StudentRows.ToList(), tabVm.Aspects.ToList());
|
var quickVm = new QuickInputViewModel(tabVm.StudentRows.ToList(), tabVm.Aspects.ToList());
|
||||||
var dialog = new ParticipationQuickInputDialog { DataContext = quickVm };
|
var dialog = new ParticipationQuickInputDialog { DataContext = quickVm };
|
||||||
await dialog.ShowDialog(this);
|
await dialog.ShowDialog(this);
|
||||||
@@ -44,6 +95,7 @@ public partial class TeachingModeWindow : Window
|
|||||||
private async Task ShowStatusQuickInputDialog(ParticipationTabViewModel tabVm)
|
private async Task ShowStatusQuickInputDialog(ParticipationTabViewModel tabVm)
|
||||||
{
|
{
|
||||||
if (tabVm.StudentRows.Count == 0 || tabVm.SelectedSession is null) return;
|
if (tabVm.StudentRows.Count == 0 || tabVm.SelectedSession is null) return;
|
||||||
|
tabVm.RefreshCurrentGrid();
|
||||||
var quickVm = new AttendanceHomeworkQuickInputViewModel(tabVm.StudentRows, tabVm.SelectedSessionDisplay);
|
var quickVm = new AttendanceHomeworkQuickInputViewModel(tabVm.StudentRows, tabVm.SelectedSessionDisplay);
|
||||||
var dialog = new AttendanceHomeworkQuickInputDialog { DataContext = quickVm };
|
var dialog = new AttendanceHomeworkQuickInputDialog { DataContext = quickVm };
|
||||||
await dialog.ShowDialog(this);
|
await dialog.ShowDialog(this);
|
||||||
|
|||||||
@@ -4,12 +4,12 @@
|
|||||||
xmlns:conv="clr-namespace:LehrerApp.Desktop.Converters"
|
xmlns:conv="clr-namespace:LehrerApp.Desktop.Converters"
|
||||||
x:Class="LehrerApp.Desktop.Views.Groups.WebUntisLessonAbsenceComparisonDialog"
|
x:Class="LehrerApp.Desktop.Views.Groups.WebUntisLessonAbsenceComparisonDialog"
|
||||||
x:DataType="vm:WebUntisLessonAbsenceComparisonViewModel"
|
x:DataType="vm:WebUntisLessonAbsenceComparisonViewModel"
|
||||||
Title="Fehlzeiten je Unterricht mit WebUntis abgleichen" Width="1000" Height="640"
|
Title="Fehlzeiten je Unterricht mit WebUntis abgleichen" Width="1150" Height="640"
|
||||||
MinWidth="800" MinHeight="450" WindowStartupLocation="CenterOwner">
|
MinWidth="900" MinHeight="450" WindowStartupLocation="CenterOwner">
|
||||||
<Grid RowDefinitions="Auto,Auto,Auto,*,Auto,Auto" Margin="24" RowSpacing="8">
|
<Grid RowDefinitions="Auto,Auto,Auto,*,Auto,Auto" Margin="24" RowSpacing="8">
|
||||||
<StackPanel Grid.Row="0" Spacing="4">
|
<StackPanel Grid.Row="0" Spacing="4">
|
||||||
<TextBlock Text="Fehlzeiten je Unterricht mit WebUntis abgleichen" Classes="dialogtitle"/>
|
<TextBlock Text="Fehlzeiten je Unterricht mit WebUntis abgleichen" Classes="dialogtitle"/>
|
||||||
<TextBlock Text="Nur markierte Zeilen mit einer vorhandenen lokalen Kursstunde werden übernommen. Zeilen ohne automatische Zuordnung bitte manuell einem Kursmitglied zuweisen."
|
<TextBlock Text="Nur markierte Zeilen mit einer vorhandenen lokalen Kursstunde werden übernommen. Zeilen ohne automatische Zuordnung bitte manuell einem Kursmitglied zuweisen. „Übernahme als“ ist mit dem aus WebUntis abgeleiteten Status vorbelegt, aber vor dem Übernehmen änderbar."
|
||||||
FontSize="12" Opacity="0.65" TextWrapping="Wrap"/>
|
FontSize="12" Opacity="0.65" TextWrapping="Wrap"/>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
<StackPanel Grid.Row="1" Orientation="Horizontal" Spacing="8">
|
<StackPanel Grid.Row="1" Orientation="Horizontal" Spacing="8">
|
||||||
@@ -17,20 +17,24 @@
|
|||||||
<TextBlock Text="bis" VerticalAlignment="Center"/>
|
<TextBlock Text="bis" VerticalAlignment="Center"/>
|
||||||
<CalendarDatePicker SelectedDate="{Binding EndDate, Converter={x:Static conv:DateTimeOffsetToDateTimeConverter.Instance}}"/>
|
<CalendarDatePicker SelectedDate="{Binding EndDate, Converter={x:Static conv:DateTimeOffsetToDateTimeConverter.Instance}}"/>
|
||||||
<Button Content="Fehlzeiten laden" Command="{Binding LoadCommand}" IsEnabled="{Binding !Busy}"/>
|
<Button Content="Fehlzeiten laden" Command="{Binding LoadCommand}" IsEnabled="{Binding !Busy}"/>
|
||||||
|
<Button Content="KI-Vorschlag für Übernahme laden" Command="{Binding SuggestStatusWithAiCommand}"
|
||||||
|
IsEnabled="{Binding !AiSuggestBusy}"
|
||||||
|
ToolTip.Tip="Schickt für jede geladene Zeile nur eine technische Positions-Id sowie Grund/Minuten/Bearbeitet-Kennzeichen ans KI-Backend - nie Namen, Klasse oder Datum. Setzt nur die "Übernahme als"-Auswahl vor, übernimmt nichts von selbst."/>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
<Grid Grid.Row="2" ColumnDefinitions="Auto,1.2*,1.2*,80,80,1.1*,1.1*" ColumnSpacing="8" Margin="4,0">
|
<Grid Grid.Row="2" ColumnDefinitions="Auto,1.1*,1.1*,80,80,1.1*,1.1*,1*" ColumnSpacing="8" Margin="4,0">
|
||||||
<TextBlock Grid.Column="1" Text="Name (WebUntis)" FontSize="11" Opacity="0.6"/>
|
<TextBlock Grid.Column="1" Text="Name (WebUntis)" FontSize="11" Opacity="0.6"/>
|
||||||
<TextBlock Grid.Column="2" Text="Zuordnung" FontSize="11" Opacity="0.6"/>
|
<TextBlock Grid.Column="2" Text="Zuordnung" FontSize="11" Opacity="0.6"/>
|
||||||
<TextBlock Grid.Column="3" Text="Datum" FontSize="11" Opacity="0.6"/>
|
<TextBlock Grid.Column="3" Text="Datum" FontSize="11" Opacity="0.6"/>
|
||||||
<TextBlock Grid.Column="4" Text="Zeit" FontSize="11" Opacity="0.6"/>
|
<TextBlock Grid.Column="4" Text="Zeit" FontSize="11" Opacity="0.6"/>
|
||||||
<TextBlock Grid.Column="5" Text="WebUntis-Status" FontSize="11" Opacity="0.6"/>
|
<TextBlock Grid.Column="5" Text="WebUntis-Status" FontSize="11" Opacity="0.6"/>
|
||||||
<TextBlock Grid.Column="6" Text="Lokaler Status" FontSize="11" Opacity="0.6"/>
|
<TextBlock Grid.Column="6" Text="Übernahme als" FontSize="11" Opacity="0.6"/>
|
||||||
|
<TextBlock Grid.Column="7" Text="Lokaler Status" FontSize="11" Opacity="0.6"/>
|
||||||
</Grid>
|
</Grid>
|
||||||
<ScrollViewer Grid.Row="3">
|
<ScrollViewer Grid.Row="3">
|
||||||
<ItemsControl ItemsSource="{Binding Rows}">
|
<ItemsControl ItemsSource="{Binding Rows}">
|
||||||
<ItemsControl.ItemTemplate>
|
<ItemsControl.ItemTemplate>
|
||||||
<DataTemplate x:DataType="vm:WebUntisLessonAbsenceRow">
|
<DataTemplate x:DataType="vm:WebUntisLessonAbsenceRow">
|
||||||
<Grid ColumnDefinitions="Auto,1.2*,1.2*,80,80,1.1*,1.1*" ColumnSpacing="8" Margin="0,3">
|
<Grid ColumnDefinitions="Auto,1.1*,1.1*,80,80,1.1*,1.1*,1*" ColumnSpacing="8" Margin="0,3">
|
||||||
<CheckBox Grid.Column="0" IsChecked="{Binding Selected}" IsEnabled="{Binding CanApply}"/>
|
<CheckBox Grid.Column="0" IsChecked="{Binding Selected}" IsEnabled="{Binding CanApply}"/>
|
||||||
<TextBlock Grid.Column="1" Text="{Binding UntisStudentName}" VerticalAlignment="Center" TextWrapping="Wrap"/>
|
<TextBlock Grid.Column="1" Text="{Binding UntisStudentName}" VerticalAlignment="Center" TextWrapping="Wrap"/>
|
||||||
<ComboBox Grid.Column="2" ItemsSource="{Binding Candidates}" SelectedItem="{Binding AssignedStudent}"
|
<ComboBox Grid.Column="2" ItemsSource="{Binding Candidates}" SelectedItem="{Binding AssignedStudent}"
|
||||||
@@ -39,7 +43,9 @@
|
|||||||
<TextBlock Grid.Column="3" Text="{Binding DateLabel}" VerticalAlignment="Center"/>
|
<TextBlock Grid.Column="3" Text="{Binding DateLabel}" VerticalAlignment="Center"/>
|
||||||
<TextBlock Grid.Column="4" Text="{Binding TimeLabel}" VerticalAlignment="Center"/>
|
<TextBlock Grid.Column="4" Text="{Binding TimeLabel}" VerticalAlignment="Center"/>
|
||||||
<TextBlock Grid.Column="5" Text="{Binding UntisStatus}" TextWrapping="Wrap" VerticalAlignment="Center"/>
|
<TextBlock Grid.Column="5" Text="{Binding UntisStatus}" TextWrapping="Wrap" VerticalAlignment="Center"/>
|
||||||
<TextBlock Grid.Column="6" Text="{Binding LocalStatus}" TextWrapping="Wrap" VerticalAlignment="Center"/>
|
<ComboBox Grid.Column="6" ItemsSource="{Binding StatusOptions}" SelectedItem="{Binding SelectedStatusName}"
|
||||||
|
HorizontalAlignment="Stretch"/>
|
||||||
|
<TextBlock Grid.Column="7" Text="{Binding LocalStatus}" TextWrapping="Wrap" VerticalAlignment="Center"/>
|
||||||
</Grid>
|
</Grid>
|
||||||
</DataTemplate>
|
</DataTemplate>
|
||||||
</ItemsControl.ItemTemplate>
|
</ItemsControl.ItemTemplate>
|
||||||
|
|||||||
@@ -40,6 +40,12 @@
|
|||||||
<MenuItem Header="Klassenbuchabgleich…" Click="OnCompareUntisKlassenbuch"/>
|
<MenuItem Header="Klassenbuchabgleich…" Click="OnCompareUntisKlassenbuch"/>
|
||||||
<MenuItem Header="Hausaufgabenabgleich…" Click="OnCompareUntisHausaufgaben"/>
|
<MenuItem Header="Hausaufgabenabgleich…" Click="OnCompareUntisHausaufgaben"/>
|
||||||
</MenuItem>
|
</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>
|
</Menu>
|
||||||
<DrawerPage x:Name="RootDrawer"
|
<DrawerPage x:Name="RootDrawer"
|
||||||
DrawerLength="220"
|
DrawerLength="220"
|
||||||
|
|||||||
@@ -1,10 +1,17 @@
|
|||||||
using Avalonia.Controls;
|
using Avalonia.Controls;
|
||||||
using Avalonia.Input;
|
using Avalonia.Input;
|
||||||
using Avalonia.Threading;
|
using Avalonia.Threading;
|
||||||
|
using LehrerApp.Core.Interfaces;
|
||||||
|
using LehrerApp.Core.Models;
|
||||||
using LehrerApp.Desktop.Services;
|
using LehrerApp.Desktop.Services;
|
||||||
using LehrerApp.Desktop.ViewModels;
|
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.Desktop.Views.UntisHub;
|
||||||
using LehrerApp.Sync;
|
using LehrerApp.Sync;
|
||||||
|
using LehrerApp.Templating;
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
|
||||||
namespace LehrerApp.Desktop.Views;
|
namespace LehrerApp.Desktop.Views;
|
||||||
@@ -45,6 +52,25 @@ public partial class MainWindow : Window
|
|||||||
await UntisHubActions.RunHausaufgabenAsync(this, App.Services.GetRequiredService<UntisHubService>());
|
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)
|
private void OnWindowKeyDown(object? sender, KeyEventArgs e)
|
||||||
{
|
{
|
||||||
if (DataContext is not MainWindowViewModel vm) return;
|
if (DataContext is not MainWindowViewModel vm) return;
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
<Window xmlns="https://github.com/avaloniaui"
|
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
|
x:Class="LehrerApp.Desktop.Views.Mcp.McpConfirmDialog"
|
||||||
|
x:CompileBindings="False"
|
||||||
|
Title="{Binding Title}"
|
||||||
|
Width="520" MinHeight="240" SizeToContent="Height"
|
||||||
|
CanResize="True" WindowStartupLocation="CenterOwner"
|
||||||
|
Topmost="True" ShowInTaskbar="True">
|
||||||
|
<Grid RowDefinitions="Auto,*,Auto,Auto">
|
||||||
|
<Border Grid.Row="0" Background="#FFF4E5" BorderBrush="#D97706" BorderThickness="0,0,0,3" Padding="24,16">
|
||||||
|
<StackPanel Spacing="4">
|
||||||
|
<TextBlock Text="Vorschlag deines KI-Assistenten — bitte prüfen" FontSize="12" FontWeight="SemiBold" Foreground="#8A5A00"/>
|
||||||
|
<TextBlock Text="{Binding Title}" FontSize="20" FontWeight="Bold" TextWrapping="Wrap"/>
|
||||||
|
</StackPanel>
|
||||||
|
</Border>
|
||||||
|
<ScrollViewer Grid.Row="1" MaxHeight="380" Margin="24,18">
|
||||||
|
<TextBlock Text="{Binding Message}" TextWrapping="Wrap" FontSize="14" LineHeight="20"/>
|
||||||
|
</ScrollViewer>
|
||||||
|
<StackPanel Grid.Row="2" Margin="24,0,24,14" Spacing="6" IsVisible="{Binding AllowSessionTrust}">
|
||||||
|
<CheckBox x:Name="TrustOperationCheck" FontSize="12"
|
||||||
|
Content="Diese Art von Aktion für den Rest der Sitzung nicht mehr nachfragen"/>
|
||||||
|
<CheckBox x:Name="TrustAllCheck" FontSize="12"
|
||||||
|
Content="Alle KI-Aktionen für den Rest der Sitzung nicht mehr nachfragen"/>
|
||||||
|
</StackPanel>
|
||||||
|
<Grid Grid.Row="3" ColumnDefinitions="*,10,*" Margin="24,0,24,20">
|
||||||
|
<Button Grid.Column="0" Content="Ablehnen" HorizontalAlignment="Stretch" Padding="0,12" FontSize="14" Click="OnCancel"/>
|
||||||
|
<Button Grid.Column="2" Content="{Binding ConfirmText}" HorizontalAlignment="Stretch" Padding="0,12" FontSize="14"
|
||||||
|
FontWeight="SemiBold" Background="#D97706" Foreground="White" Click="OnConfirm"/>
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
</Window>
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
using Avalonia.Controls;
|
||||||
|
using Avalonia.Interactivity;
|
||||||
|
|
||||||
|
namespace LehrerApp.Desktop.Views.Mcp;
|
||||||
|
|
||||||
|
public partial class McpConfirmDialog : Window
|
||||||
|
{
|
||||||
|
public McpConfirmDialog() => InitializeComponent();
|
||||||
|
|
||||||
|
private void OnConfirm(object? sender, RoutedEventArgs e) =>
|
||||||
|
Close(new McpConfirmDialogResult(true, TrustOperationCheck.IsChecked == true, TrustAllCheck.IsChecked == true));
|
||||||
|
|
||||||
|
// Checkbox-Zustand wird beim Ablehnen bewusst ignoriert (siehe McpConfirmDialogResult).
|
||||||
|
private void OnCancel(object? sender, RoutedEventArgs e) => Close(McpConfirmDialogResult.Rejected);
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
namespace LehrerApp.Desktop.Views.Mcp;
|
||||||
|
|
||||||
|
/// <summary>DataContext für <see cref="McpConfirmDialog"/> — bewusst ein eigener, auffälligerer
|
||||||
|
/// Dialog statt Wiederverwendung von <see cref="Views.Shared.ConfirmDialog"/>: Letzterer wird an
|
||||||
|
/// vielen Stellen der App für routinemäßige Bestätigungen verwendet, eine Änderung an dessen
|
||||||
|
/// Größe/Optik hätte all diese Stellen mitbetroffen. Ein von der KI vorgeschlagener Schreibzugriff
|
||||||
|
/// soll sich dagegen bewusst absetzen (Nutzer-Feedback: der ursprüngliche gemeinsame Dialog fiel zu
|
||||||
|
/// wenig auf, wenn LehrerApp gerade nicht im Vordergrund war).</summary>
|
||||||
|
public class McpConfirmDialogInfo
|
||||||
|
{
|
||||||
|
public string Title { get; init; } = "";
|
||||||
|
public string Message { get; init; } = "";
|
||||||
|
public string ConfirmText { get; init; } = "Übernehmen";
|
||||||
|
|
||||||
|
/// <summary>False blendet beide "nicht mehr nachfragen"-Kontrollkästchen aus (Nutzer-Entscheidung
|
||||||
|
/// zu <c>get_named_untis_absence_pattern</c>: eine namentliche Fehlzeitenauskunft muss jedes Mal
|
||||||
|
/// einzeln bestätigt werden) - AvaloniaMcpConfirmationService ignoriert eine Sitzungsfreigabe für
|
||||||
|
/// diesen Aufruf ohnehin serverseitig, das Ausblenden verhindert zusätzlich die falsche
|
||||||
|
/// Erwartung, ein angekreuztes Kästchen hätte hier irgendeine Wirkung.</summary>
|
||||||
|
public bool AllowSessionTrust { get; init; } = true;
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
namespace LehrerApp.Desktop.Views.Mcp;
|
||||||
|
|
||||||
|
/// <summary>Ergebnis von <see cref="McpConfirmDialog"/>. <see cref="TrustOperation"/>/
|
||||||
|
/// <see cref="TrustAll"/> sind nur bei <see cref="Approved"/>=true aussagekräftig — beim Ablehnen
|
||||||
|
/// werden sie ignoriert (siehe McpConfirmDialog.axaml.cs), Ablehnen + "nicht mehr nachfragen" wäre
|
||||||
|
/// widersprüchlich.</summary>
|
||||||
|
public record McpConfirmDialogResult(bool Approved, bool TrustOperation, bool TrustAll)
|
||||||
|
{
|
||||||
|
public static McpConfirmDialogResult Rejected { get; } = new(false, false, false);
|
||||||
|
}
|
||||||
@@ -185,19 +185,10 @@ public partial class TimetableView : UserControl
|
|||||||
await dialog.ShowDialog<bool>(owner);
|
await dialog.ShowDialog<bool>(owner);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task ShowTeachingMode(Lesson lesson)
|
private Task ShowTeachingMode(Lesson lesson)
|
||||||
{
|
{
|
||||||
var owner = TopLevel.GetTopLevel(this) as Window;
|
TeachingModeWindow.Open(lesson);
|
||||||
var group = App.Services.GetRequiredService<IGroupRepository>().GetById(lesson.GroupId);
|
return Task.CompletedTask;
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task ShowLessonViewerDialog(Lesson lesson)
|
private async Task ShowLessonViewerDialog(Lesson lesson)
|
||||||
|
|||||||
@@ -393,6 +393,66 @@
|
|||||||
<Separator/>
|
<Separator/>
|
||||||
<TextBlock Text="Vorlagen werden mit dem separaten LehrerApp Vorlagen-Designer erstellt. Designer und Hauptapp verwenden exakt dieselbe QuestPDF-Renderingbibliothek."
|
<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"/>
|
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>
|
</StackPanel>
|
||||||
</ScrollViewer>
|
</ScrollViewer>
|
||||||
</ContentPage>
|
</ContentPage>
|
||||||
|
|||||||
@@ -277,4 +277,26 @@ public partial class SettingsView : UserControl
|
|||||||
if (!File.Exists(path)) return;
|
if (!File.Exists(path)) return;
|
||||||
System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo(path) { UseShellExecute = true });
|
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"
|
ShowInTaskbar="False"
|
||||||
WindowDecorations="None"
|
WindowDecorations="None"
|
||||||
WindowStartupLocation="CenterScreen">
|
WindowStartupLocation="CenterScreen">
|
||||||
<Image Source="/Assets/SplashScreen.png"
|
<Viewbox Stretch="UniformToFill">
|
||||||
Stretch="UniformToFill"
|
<Canvas Width="1086" Height="1448">
|
||||||
HorizontalAlignment="Stretch"
|
<Image Source="/Assets/SplashScreen.png" Width="1086" Height="1448"/>
|
||||||
VerticalAlignment="Stretch"/>
|
<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>
|
</Window>
|
||||||
|
|||||||
@@ -5,4 +5,10 @@ namespace LehrerApp.Desktop.Views;
|
|||||||
public partial class SplashWindow : Window
|
public partial class SplashWindow : Window
|
||||||
{
|
{
|
||||||
public SplashWindow() => InitializeComponent();
|
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}"/>
|
Classes="emptyhint" IsVisible="{Binding HasNoTemplates}"/>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
<StackPanel Spacing="4">
|
<StackPanel Spacing="4">
|
||||||
<TextBlock Text="Kontakt *" FontSize="12" Opacity="0.7"/>
|
<TextBlock Text="Anschrift *" FontSize="12" Opacity="0.7"/>
|
||||||
<ComboBox ItemsSource="{Binding Contacts}" SelectedItem="{Binding SelectedContact}"
|
<Border BorderBrush="{DynamicResource AppCardBorderBrush}" BorderThickness="1" CornerRadius="6" Padding="10">
|
||||||
DisplayMemberBinding="{Binding Display}" HorizontalAlignment="Stretch"/>
|
<Grid ColumnDefinitions="*,Auto">
|
||||||
<TextBlock Text="Kein aktueller Kontakt vorhanden." Classes="emptyhint"
|
<TextBlock Grid.Column="0" Text="{Binding AddressPreview}" TextWrapping="Wrap"
|
||||||
IsVisible="{Binding HasNoContacts}"/>
|
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>
|
</StackPanel>
|
||||||
<Grid ColumnDefinitions="*,12,*">
|
<Grid ColumnDefinitions="*,12,*">
|
||||||
<StackPanel Grid.Column="0" Spacing="4">
|
<StackPanel Grid.Column="0" Spacing="4">
|
||||||
@@ -44,6 +54,23 @@
|
|||||||
<CalendarDatePicker SelectedDate="{Binding LetterDate, Converter={x:Static conv:DateTimeOffsetToDateTimeConverter.Instance}}" HorizontalAlignment="Stretch"/>
|
<CalendarDatePicker SelectedDate="{Binding LetterDate, Converter={x:Static conv:DateTimeOffsetToDateTimeConverter.Instance}}" HorizontalAlignment="Stretch"/>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
</Grid>
|
</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">
|
<StackPanel Spacing="4">
|
||||||
<TextBlock Text="Brieftext" FontSize="12" Opacity="0.7"/>
|
<TextBlock Text="Brieftext" FontSize="12" Opacity="0.7"/>
|
||||||
<TextBox Text="{Binding LetterText}" AcceptsReturn="True" TextWrapping="Wrap" MinHeight="110"
|
<TextBox Text="{Binding LetterText}" AcceptsReturn="True" TextWrapping="Wrap" MinHeight="110"
|
||||||
@@ -54,6 +81,26 @@
|
|||||||
<TextBox Text="{Binding TeacherName}" PlaceholderText="Name der Lehrkraft"/>
|
<TextBox Text="{Binding TeacherName}" PlaceholderText="Name der Lehrkraft"/>
|
||||||
</StackPanel>
|
</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"
|
<Border BorderBrush="#D97706" BorderThickness="1" CornerRadius="6" Padding="10"
|
||||||
IsVisible="{Binding HasIssues}">
|
IsVisible="{Binding HasIssues}">
|
||||||
<StackPanel Spacing="5">
|
<StackPanel Spacing="5">
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ public partial class CreateLetterDialog : Window
|
|||||||
private async void OnGenerate(object? sender, RoutedEventArgs e)
|
private async void OnGenerate(object? sender, RoutedEventArgs e)
|
||||||
{
|
{
|
||||||
if (DataContext is not CreateLetterDialogViewModel vm) return;
|
if (DataContext is not CreateLetterDialogViewModel vm) return;
|
||||||
|
if (vm.UsesAttendanceAdvancedContent && !vm.AttendanceCalendarConfigured &&
|
||||||
|
!await ConfigureAttendanceCalendar(vm)) return;
|
||||||
var file = await StorageProvider.SaveFilePickerAsync(new FilePickerSaveOptions
|
var file = await StorageProvider.SaveFilePickerAsync(new FilePickerSaveOptions
|
||||||
{
|
{
|
||||||
Title = "PDF-Brief speichern",
|
Title = "PDF-Brief speichern",
|
||||||
@@ -23,5 +25,27 @@ public partial class CreateLetterDialog : Window
|
|||||||
Close(file.Path.LocalPath);
|
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);
|
private void OnCancel(object? sender, RoutedEventArgs e) => Close(null);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
CanResize="True" WindowStartupLocation="CenterOwner">
|
CanResize="True" WindowStartupLocation="CenterOwner">
|
||||||
|
|
||||||
<Grid RowDefinitions="*,Auto" Margin="24">
|
<Grid RowDefinitions="*,Auto" Margin="24">
|
||||||
<ScrollViewer Grid.Row="0">
|
<ScrollViewer Grid.Row="0" MaxHeight="560">
|
||||||
<StackPanel Spacing="12">
|
<StackPanel Spacing="12">
|
||||||
<TextBlock Text="{Binding DialogTitle}" Classes="dialogtitle"/>
|
<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.ViewModels.Students;
|
||||||
using LehrerApp.Desktop.Views.Shared;
|
using LehrerApp.Desktop.Views.Shared;
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
using LehrerApp.Templating;
|
|
||||||
|
|
||||||
namespace LehrerApp.Desktop.Views.Students;
|
namespace LehrerApp.Desktop.Views.Students;
|
||||||
|
|
||||||
@@ -56,16 +55,7 @@ public partial class StudentDetailView : UserControl
|
|||||||
{
|
{
|
||||||
var owner = TopLevel.GetTopLevel(this) as Window;
|
var owner = TopLevel.GetTopLevel(this) as Window;
|
||||||
if (owner is null || DataContext is not StudentDetailViewModel { Student: { } student }) return;
|
if (owner is null || DataContext is not StudentDetailViewModel { Student: { } student }) return;
|
||||||
|
await LetterDialogs.ShowCreateLetterDialogAsync(owner, student);
|
||||||
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 });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private void ShowAddressViewer(ContactItem contact)
|
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);
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
using LehrerApp.Core.Mcp;
|
||||||
|
|
||||||
|
namespace LehrerApp.McpBridge;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Unterscheidet an jeder Stelle, an der die Bridge versucht, sich mit LehrerApp.Desktop zu
|
||||||
|
/// verbinden, zwischen dem harmlosen Regelfall "LehrerApp läuft gerade nicht" und einem echten
|
||||||
|
/// Verbindungsfehler. <see cref="NamedPipeClientStream.ConnectAsync"/> wirft bei einer lokalen
|
||||||
|
/// Named Pipe (kein Netzwerk, kein Handshake) genau dann eine <see cref="TimeoutException"/>, wenn
|
||||||
|
/// innerhalb der Frist kein Prozess auf den Pipe-Namen lauschte - praktisch immer, weil
|
||||||
|
/// LehrerApp.Desktop nicht läuft oder der MCP-Server dort in den Einstellungen nicht aktiviert ist.
|
||||||
|
/// Jede andere Exception (z.B. <see cref="UnauthorizedAccessException"/> bei einem Rechte-/
|
||||||
|
/// ACL-Mismatch zwischen den Prozessen) deutet auf ein echtes Konfigurationsproblem hin.
|
||||||
|
/// </summary>
|
||||||
|
internal static class ConnectionDiagnostics
|
||||||
|
{
|
||||||
|
public const int NotRunningErrorCode = -32001;
|
||||||
|
public const int UnexpectedErrorCode = -32002;
|
||||||
|
|
||||||
|
public static (int Code, string ClientMessage, string StderrLine) Describe(Exception ex) => ex switch
|
||||||
|
{
|
||||||
|
TimeoutException => (
|
||||||
|
NotRunningErrorCode,
|
||||||
|
"LehrerApp läuft nicht oder der MCP-Server ist in den Einstellungen nicht aktiviert.",
|
||||||
|
"LehrerApp.McpBridge: Kein Kontakt zu LehrerApp (Pipe „" + McpPipeConstants.PipeName +
|
||||||
|
"“ nicht erreichbar). Das ist normal, wenn LehrerApp.Desktop gerade nicht läuft oder der " +
|
||||||
|
"MCP-Server dort in den Einstellungen nicht aktiviert ist - kein Hinweis auf eine " +
|
||||||
|
"fehlerhafte Bridge-/Client-Konfiguration."),
|
||||||
|
_ => (
|
||||||
|
UnexpectedErrorCode,
|
||||||
|
$"Unerwarteter Verbindungsfehler ({ex.GetType().Name}): {ex.Message}",
|
||||||
|
$"LehrerApp.McpBridge: Unerwarteter Fehler beim Verbindungsaufbau zu LehrerApp " +
|
||||||
|
$"({ex.GetType().Name}: {ex.Message}). Das ist vermutlich KEIN einfaches \"LehrerApp " +
|
||||||
|
"läuft noch nicht\", sondern deutet auf ein Konfigurations- oder Rechteproblem hin."),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -5,5 +5,6 @@
|
|||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<ProjectReference Include="..\LehrerApp.Core\LehrerApp.Core.csproj" />
|
<ProjectReference Include="..\LehrerApp.Core\LehrerApp.Core.csproj" />
|
||||||
|
<PackageReference Include="ModelContextProtocol.Core" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user