Compare commits
42
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
92f9a75ec1 | ||
|
|
0a419c4c11 | ||
|
|
216708e879 | ||
|
|
0d5fb6d146 | ||
|
|
1d9521fc60 | ||
|
|
4af83c3e68 | ||
|
|
15463bc292 | ||
|
|
987b49e761 | ||
|
|
fb7a015b42 | ||
|
|
09e97efb61 | ||
|
|
d297f9f109 | ||
|
|
de6d4236a2 | ||
|
|
edf7510765 | ||
|
|
2ccc3201d2 | ||
|
|
ab1feba0f0 | ||
|
|
b2d0ccd30d | ||
|
|
c6efdab9ef | ||
|
|
11ef73ab1f | ||
|
|
4b4a746e17 | ||
|
|
e2e2bfb854 | ||
|
|
3e5f197bdb | ||
|
|
95e3f677e4 | ||
|
|
cdbed69d8b | ||
|
|
527c186090 | ||
|
|
28469bf547 | ||
|
|
9dce6576e2 | ||
|
|
9f28081931 | ||
|
|
6468596bf3 | ||
|
|
23abe7af63 | ||
|
|
3f71f7f371 | ||
|
|
2f2761caf8 | ||
|
|
a088823b30 | ||
|
|
1215d4ac22 | ||
|
|
84c03962ef | ||
|
|
b7c7d46ee4 | ||
|
|
da4b88c93d | ||
|
|
538ad65a3a | ||
|
|
dade41ee8d | ||
|
|
2b299fc940 | ||
|
|
dbd8777e64 | ||
|
|
89a6376fb6 | ||
|
|
b7a105af73 |
@@ -0,0 +1,24 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
jobs:
|
||||
build-and-test:
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: mcr.microsoft.com/dotnet/sdk:10.0
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Restore
|
||||
run: dotnet restore LehrerApp.sln
|
||||
|
||||
- name: Build
|
||||
run: dotnet build LehrerApp.sln --no-restore --configuration Release
|
||||
|
||||
- name: Test
|
||||
run: dotnet test LehrerApp.sln --no-build --configuration Release --logger "console;verbosity=normal"
|
||||
Vendored
+21
-6
@@ -5,7 +5,7 @@
|
||||
"name": "Desktop App starten",
|
||||
"type": "coreclr",
|
||||
"request": "launch",
|
||||
"preLaunchTask": "Desktop bauen",
|
||||
"preLaunchTask": "TemplateDesigner bauen",
|
||||
"program": "${workspaceFolder}/LehrerApp.Desktop/bin/Debug/net10.0/LehrerApp.Desktop.dll",
|
||||
"args": [],
|
||||
"cwd": "${workspaceFolder}/LehrerApp.Desktop",
|
||||
@@ -15,6 +15,20 @@
|
||||
"DOTNET_ENVIRONMENT": "Development"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "TemplateDesigner App starten",
|
||||
"type": "coreclr",
|
||||
"request": "launch",
|
||||
"preLaunchTask": "Desktop bauen",
|
||||
"program": "${workspaceFolder}/LehrerApp.TemplateDesigner/bin/Debug/net10.0/LehrerApp.TemplateDesigner.dll",
|
||||
"args": [],
|
||||
"cwd": "${workspaceFolder}/LehrerApp.TemplateDesigner",
|
||||
"stopAtEntry": false,
|
||||
"console": "internalConsole",
|
||||
"env": {
|
||||
"DOTNET_ENVIRONMENT": "Development"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "API Server starten",
|
||||
"type": "coreclr",
|
||||
@@ -33,11 +47,12 @@
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "Desktop + API (gleichzeitig)",
|
||||
"configurations": [
|
||||
"Desktop App starten",
|
||||
"API Server starten"
|
||||
]
|
||||
"name": "API Tests starten",
|
||||
"type": "coreclr",
|
||||
"request": "launch",
|
||||
"preLaunchTask": "API bauen",
|
||||
"program": "${workspaceFolder}/LehrerApp.Api.Tests/bin/Debug/net10.0/LehrerApp.Api.Tests.dll",
|
||||
"args": [],
|
||||
}
|
||||
],
|
||||
"compounds": [
|
||||
|
||||
Vendored
+13
@@ -27,6 +27,19 @@
|
||||
"problemMatcher": "$msCompile",
|
||||
"group": "build"
|
||||
},
|
||||
{
|
||||
"label": "TemplateDesigner bauen",
|
||||
"command": "dotnet",
|
||||
"type": "process",
|
||||
"args": [
|
||||
"build",
|
||||
"${workspaceFolder}/LehrerApp.TemplateDesigner/LehrerApp.TemplateDesigner.csproj",
|
||||
"--configuration", "Debug",
|
||||
"--verbosity", "minimal"
|
||||
],
|
||||
"problemMatcher": "$msCompile",
|
||||
"group": "build"
|
||||
},
|
||||
{
|
||||
"label": "Solution bauen",
|
||||
"command": "dotnet",
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<LangVersion>latest</LangVersion>
|
||||
<TreatWarningsAsErrors>false</TreatWarningsAsErrors>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
<!-- CS8618: falsch-positiv durch [ObservableProperty] Source Generator -->
|
||||
<NoWarn>CS8618</NoWarn>
|
||||
</PropertyGroup>
|
||||
|
||||
@@ -17,9 +17,10 @@
|
||||
<!-- Datenbank -->
|
||||
<PackageVersion Include="LiteDB" Version="5.0.21" />
|
||||
|
||||
<!-- PDF-Erzeugung (11.3) - nur LehrerApp.Desktop: zieht SkiaSharp-Native-Binaries mit,
|
||||
die im Server-Image (LehrerApp.Api) nichts verloren haben. -->
|
||||
<!-- PDF-Erzeugung: Desktop-Exporte und die unabhängige Templating-Library. -->
|
||||
<PackageVersion Include="QuestPDF" Version="2025.7.0" />
|
||||
<PackageVersion Include="PDFtoImage" Version="5.4.0" />
|
||||
<PackageVersion Include="PdfPig" Version="0.1.13" />
|
||||
|
||||
<!-- API -->
|
||||
<PackageVersion Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.0" />
|
||||
|
||||
@@ -200,6 +200,15 @@ public interface IDocumentationRepository
|
||||
/// Entfernt den Eintrag endgültig — nur für die Löschfristen-Bereinigung (5.4.2).
|
||||
void HardDelete(Guid id);
|
||||
}
|
||||
public interface IVorgangRepository
|
||||
{
|
||||
List<Vorgang> GetAll();
|
||||
List<Vorgang> GetByStudent(Guid studentId);
|
||||
Vorgang? GetById(Guid id);
|
||||
void Save(Vorgang vorgang);
|
||||
/// Markiert den Vorgang als gelöscht, statt ihn hart zu entfernen (gleiches Muster wie Documentation).
|
||||
void Delete(Guid id);
|
||||
}
|
||||
public interface IWorkTaskRepository
|
||||
{
|
||||
List<WorkTask> GetByStatus(WorkTaskStatus status);
|
||||
@@ -225,6 +234,9 @@ public interface IParticipationSessionRepository
|
||||
{
|
||||
List<ParticipationSession> GetByGroup(Guid groupId);
|
||||
ParticipationSession? GetById(Guid id);
|
||||
// Für Aggregationen über alle Gruppen (z.B. Dashboard-Fehlzeiten-Warnung), die sonst pro
|
||||
// Eintrag einzeln GetById aufrufen müssten — ein Bulk-Laden vermeidet dieses N+1-Muster.
|
||||
List<ParticipationSession> GetAll();
|
||||
void Save(ParticipationSession session);
|
||||
void Delete(Guid id);
|
||||
}
|
||||
|
||||
@@ -26,6 +26,13 @@ public class LearningGroup
|
||||
/// deshalb händisch je Lerngruppe/Schuljahr gepflegt werden - keine JSON-RPC-Methode liefert sie.
|
||||
/// </summary>
|
||||
public int? WebUntisLessonId { get; set; }
|
||||
/// <summary>
|
||||
/// Vorgängergruppe aus dem Hochstufen (<see cref="Services.GroupRolloverService"/>) —
|
||||
/// ermöglicht einen Schuljahresvergleich (z.B. Klausurschnitt) über die Kette hinweg.
|
||||
/// Wird ausschließlich beim Hochstufen automatisch gesetzt; vor Einführung dieses Felds
|
||||
/// hochgestufte Gruppen bleiben unverknüpft (kein rückwirkendes Verknüpfen vorgesehen).
|
||||
/// </summary>
|
||||
public Guid? PreviousGroupId { get; set; }
|
||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
@@ -24,9 +24,26 @@ public class ParticipationEntry
|
||||
// Bleibt für bereits gespeicherte Daten erhalten. Neue Schreibvorgänge setzen beide Felder.
|
||||
public bool HomeworkMissing { get; set; }
|
||||
public AttendanceStatus? Attendance { get; set; }
|
||||
// Strichliste im Sitzplan (Nutzer-Feedback): RaisedHandCount zählt jede beobachtete Meldung,
|
||||
// CalledOnCount die Teilmenge davon, bei der der Schüler auch drangekommen ist —
|
||||
// "drangekommen" ist immer auch eine Meldung und erhöht daher immer beide Zähler zugleich,
|
||||
// nie CalledOnCount allein. Ergibt nebenbei eine Aufrufgerechtigkeits-Quote
|
||||
// (CalledOnCount / RaisedHandCount) und schlägt die Quantitätsbewertung vor, siehe
|
||||
// ParticipationCountSuggestion.
|
||||
public int RaisedHandCount { get; set; }
|
||||
public int CalledOnCount { get; set; }
|
||||
/// <summary>
|
||||
/// Tagesflagge (Nutzer-Feedback): markiert eine session-bezogene Bewertung als besonders
|
||||
/// herausragend oder besonders schwach, unabhängig von der Richtung ("egal in welche
|
||||
/// Richtung, herausragend festhalten"). Rein deskriptiv — fließt nirgends in eine Berechnung
|
||||
/// (Mitarbeitsnote 3.2, Aufrufgerechtigkeit) ein, nur zur Erinnerung/Dokumentation.
|
||||
/// </summary>
|
||||
public DayHighlightKind? DayHighlight { get; set; }
|
||||
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
public enum DayHighlightKind { Standout, Sleepy, Rough }
|
||||
|
||||
/// <summary>
|
||||
/// Hausaufgabenstatus einer Sitzung; null bedeutet, dass für diesen Termin keine
|
||||
/// Hausaufgabe erfasst wurde. MissingOpen kann später in MissingOverdue oder SubmittedLate
|
||||
@@ -160,3 +177,27 @@ public static class ParticipationRatingScale
|
||||
_ => value,
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Nutzer-Feedback: die Strichliste im Sitzplan (<see cref="ParticipationEntry.RaisedHandCount"/>)
|
||||
/// schlägt direkt eine Quantitätsbewertung vor — wie oft sich jemand meldet, ist ja bereits die
|
||||
/// Quantität. Nur ein Richtwert, kein fester Automatismus: die Lehrkraft übernimmt den Vorschlag
|
||||
/// per Klick oder ignoriert ihn. Bewusst nur für <see cref="AspectValueType.Scale5"/> (der
|
||||
/// Standardtyp des "quantity"-Aspekts) — für individuell umkonfigurierte Aspekttypen gäbe es keine
|
||||
/// sinnvolle, unmissverständliche Abbildung.
|
||||
/// </summary>
|
||||
public static class ParticipationCountSuggestion
|
||||
{
|
||||
public static int? SuggestQuantity(AspectValueType type, int raisedHandCount)
|
||||
{
|
||||
if (type != AspectValueType.Scale5) return null;
|
||||
return raisedHandCount switch
|
||||
{
|
||||
0 => -2,
|
||||
1 => -1,
|
||||
2 or 3 => 0,
|
||||
4 or 5 => 1,
|
||||
_ => 2,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -100,7 +100,10 @@ public class LessonPhaseStep
|
||||
}
|
||||
|
||||
public enum UnitStatus { Planned, Active, Completed }
|
||||
public enum LessonStatus { Planned, Conducted }
|
||||
// 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
|
||||
// vorhandene Daten ohne Migration weiterhin korrekt gelesen werden.
|
||||
public enum LessonStatus { Planned = 0, Conducted = 1, Draft = 2, Ready = 3 }
|
||||
|
||||
/// <summary>
|
||||
/// Katalogeintrag für einen wiederverwendbaren "alternativen Ablauf" (z.B. "Kurzversion" bei
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
namespace LehrerApp.Core.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Fallmappe für ein konkretes, laufendes Problem mit einer/einem oder mehreren Schüler*innen
|
||||
/// (z.B. Schuleschwänzen, wiederholte Hausaufgaben-Verweigerung, ein Konflikt) — bündelt Titel,
|
||||
/// Problembeschreibung, Schlagwörter sowie die dazugehörigen <see cref="Documentation"/>-Einträge
|
||||
/// und eingefrorene WebUntis-Klassenbucheinträge an einem Ort, statt sie über datumsgefilterte
|
||||
/// Listen verstreut zu lassen. Bewusst nur Referenzen (<see cref="DocumentationIds"/>) statt
|
||||
/// gecachtem Inhalt — die Datenmenge pro Lehrkraft ist klein genug, dass ein Cache nur
|
||||
/// Invalidierungs-Komplexität einführen würde, ohne echten Nutzen (siehe TODO.md, Präzedenzfall
|
||||
/// 5.3.3: eine Plan-ID-Gruppierung für Förderpläne wurde aus demselben Grund verworfen).
|
||||
/// </summary>
|
||||
public class Vorgang
|
||||
{
|
||||
public Guid Id { get; set; } = Guid.NewGuid();
|
||||
public string Title { get; set; } = "";
|
||||
public string Description { get; set; } = "";
|
||||
public List<Guid> StudentIds { get; set; } = [];
|
||||
/// Freie Labels, z.B. "Absentismus", "Hausaufgaben", "Verspätungen", "Konflikte" —
|
||||
/// siehe VorgangTagDisplay.Suggestions im Desktop-Projekt für die Vorschlagsliste.
|
||||
public List<string> Tags { get; set; } = [];
|
||||
public VorgangStatus Status { get; set; } = VorgangStatus.Open;
|
||||
public List<Guid> DocumentationIds { get; set; } = [];
|
||||
public List<VorgangClassRegisterEntry> ClassRegisterEntries { get; set; } = [];
|
||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
|
||||
public DateTime? ClosedAt { get; set; }
|
||||
public bool IsDeleted { get; set; }
|
||||
public DateTime? DeletedAt { get; set; }
|
||||
}
|
||||
|
||||
public enum VorgangStatus { Open, Closed }
|
||||
|
||||
/// <summary>
|
||||
/// Eingefrorene Kopie einer WebUntis-Klassenbuchzeile zum Zeitpunkt des Anheftens an einen
|
||||
/// <see cref="Vorgang"/> — keine Referenz per ID, da weder <c>UntisForeignClassRegisterEventDto</c>
|
||||
/// noch <c>UntisClassRegisterCacheEntry</c> eine über einen Cache-Refresh hinweg stabile ID haben.
|
||||
/// </summary>
|
||||
public class VorgangClassRegisterEntry
|
||||
{
|
||||
public DateOnly Date { get; set; }
|
||||
public string StudentName { get; set; } = "";
|
||||
public string? Subject { get; set; }
|
||||
public string? TeacherUsername { get; set; }
|
||||
public string? CategoryName { get; set; }
|
||||
public string? CategoryGroup { get; set; }
|
||||
public string? Text { get; set; }
|
||||
public DateTime PinnedAt { get; set; } = DateTime.UtcNow;
|
||||
}
|
||||
@@ -93,7 +93,7 @@ public interface IHasAttachments
|
||||
List<DocumentAttachment> Attachments { get; }
|
||||
}
|
||||
// Hinten angefügt, damit die numerischen Werte bereits gespeicherter LiteDB-Einträge stabil bleiben.
|
||||
public enum DocumentationType { Conversation, Incident, SupportPlan, Absence, ParentCall, ParentLetter }
|
||||
public enum DocumentationType { Conversation, Incident, SupportPlan, Absence, ParentCall, ParentLetter, Planning }
|
||||
public enum SupportStatus { Active, Completed, Paused }
|
||||
|
||||
public class WorkTask
|
||||
|
||||
@@ -14,6 +14,13 @@ public class BackupService
|
||||
|
||||
public string BackupDirectory => _backupDirectory;
|
||||
|
||||
/// Optionales zweites Sicherungsziel (z.B. USB-Stick, Netzlaufwerk) — Backups liegen sonst
|
||||
/// ausschließlich neben der Live-Datenbank und sind bei einem Plattenausfall oder einem
|
||||
/// versehentlich gelöschten Profilverzeichnis wertlos. Wird von außen gesetzt (Desktop-Settings,
|
||||
/// siehe BackupSettingsService); bewusst best-effort — ist das Ziel beim Sichern nicht
|
||||
/// erreichbar (Stick nicht eingesteckt, Freigabe offline), bleibt nur das Hauptbackup bestehen.
|
||||
public string? SecondaryBackupDirectory { get; set; }
|
||||
|
||||
public BackupService(string appDataPath)
|
||||
{
|
||||
_backupDirectory = Path.Combine(appDataPath, "backups");
|
||||
@@ -34,9 +41,36 @@ public class BackupService
|
||||
File.Copy(databasePath, target, overwrite: true);
|
||||
|
||||
PruneOldBackups(keepCount);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(SecondaryBackupDirectory))
|
||||
TryMirrorToSecondary(target, fileName, keepCount);
|
||||
|
||||
return target;
|
||||
}
|
||||
|
||||
private void TryMirrorToSecondary(string sourcePath, string fileName, int keepCount)
|
||||
{
|
||||
try
|
||||
{
|
||||
Directory.CreateDirectory(SecondaryBackupDirectory!);
|
||||
File.Copy(sourcePath, Path.Combine(SecondaryBackupDirectory!, fileName), overwrite: true);
|
||||
|
||||
foreach (var stale in Directory.GetFiles(SecondaryBackupDirectory!, "lehrerapp-*.db")
|
||||
.Select(p => new FileInfo(p))
|
||||
.OrderByDescending(f => f.LastWriteTime)
|
||||
.Skip(keepCount))
|
||||
{
|
||||
try { stale.Delete(); }
|
||||
catch { /* nächster Lauf versucht es erneut */ }
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Sicherungsziel nicht erreichbar — das Hauptbackup (_backupDirectory) ist davon
|
||||
// unberührt, ein manuelles/automatisches Backup soll daran nicht scheitern.
|
||||
}
|
||||
}
|
||||
|
||||
public List<BackupInfo> ListBackups() =>
|
||||
Directory.GetFiles(_backupDirectory, "lehrerapp-*.db")
|
||||
.Select(p => new BackupInfo(p, File.GetLastWriteTime(p), new FileInfo(p).Length))
|
||||
|
||||
@@ -15,7 +15,8 @@ public sealed class DashboardSettingsService
|
||||
public static readonly string[] DefaultCardOrder =
|
||||
[
|
||||
"today", "tasks", "calendar", "excuses", "upcoming",
|
||||
"corrections", "unplanned", "alerts", "attendance", "support", "groups",
|
||||
"corrections", "unplanned", "alerts", "attendance", "support", "groups", "examload",
|
||||
"missingteachingtime",
|
||||
];
|
||||
|
||||
private readonly string _configPath;
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
using System.Globalization;
|
||||
using LehrerApp.Core.Models;
|
||||
|
||||
namespace LehrerApp.Core.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Zählt eigene Klausuren pro ISO-Kalenderwoche über alle Lerngruppen hinweg. Nutzer-Feedback:
|
||||
/// die klassenbezogene Kollisionsprüfung (mehrere Klausuren einer einzelnen Klasse in einer
|
||||
/// Woche) übernimmt bereits der schulische Klausurplaner (externes Klassenbuch) — was dort
|
||||
/// fehlt, ist die persönliche Belastung der Lehrkraft: mehr eigene Klausuren in derselben Woche
|
||||
/// bedeuten unabhängig von der Klasse mehr Erstellungsaufwand vorher und mehr Korrekturaufwand
|
||||
/// danach. Da eine LiteDB-Datei genau einem Nutzer gehört (siehe CLAUDE.md), sind "alle
|
||||
/// Klausuren in der Datenbank" bereits gleichbedeutend mit "meine Klausuren".
|
||||
/// </summary>
|
||||
public static class ExamWeekLoadService
|
||||
{
|
||||
/// Ab dieser Anzahl eigener Klausuren in derselben Woche gilt die Woche als spürbar belastet.
|
||||
/// Nutzer-Feedback: zwei Klausuren aus zwei unterrichteten Fächern in derselben Woche sind
|
||||
/// normal, eine dritte ist die eigentliche Häufung.
|
||||
public const int WarningThreshold = 3;
|
||||
|
||||
/// Anzahl Klausuren (ohne <paramref name="excludeExamId"/>, z.B. die gerade im Dialog
|
||||
/// bearbeitete), die in dieselbe ISO-Kalenderwoche wie <paramref name="date"/> fallen.
|
||||
public static int CountInSameWeek(IEnumerable<Exam> allExams, DateOnly date, Guid? excludeExamId = null)
|
||||
{
|
||||
var (year, week) = IsoWeek(date);
|
||||
return allExams.Count(e => e.Id != excludeExamId && IsoWeek(e.Date) == (year, week));
|
||||
}
|
||||
|
||||
/// Gruppiert die übergebenen Klausuren nach ISO-Kalenderwoche und liefert nur die Wochen mit
|
||||
/// mindestens <see cref="WarningThreshold"/> Klausuren, chronologisch sortiert — für einen
|
||||
/// vorausschauenden Überblick (z.B. Dashboard), nicht für die Einzelprüfung beim Anlegen.
|
||||
public static List<ExamWeekLoad> FindOverloadedWeeks(IEnumerable<Exam> exams) =>
|
||||
exams
|
||||
.GroupBy(e => IsoWeek(e.Date))
|
||||
.Where(g => g.Count() >= WarningThreshold)
|
||||
.Select(g => new ExamWeekLoad(g.Key.Year, g.Key.Week, WeekStart(g.Key.Year, g.Key.Week), g.Count()))
|
||||
.OrderBy(w => w.WeekStart)
|
||||
.ToList();
|
||||
|
||||
private static (int Year, int Week) IsoWeek(DateOnly date)
|
||||
{
|
||||
var dt = date.ToDateTime(TimeOnly.MinValue);
|
||||
return (ISOWeek.GetYear(dt), ISOWeek.GetWeekOfYear(dt));
|
||||
}
|
||||
|
||||
private static DateOnly WeekStart(int isoYear, int isoWeek) =>
|
||||
DateOnly.FromDateTime(ISOWeek.ToDateTime(isoYear, isoWeek, DayOfWeek.Monday));
|
||||
}
|
||||
|
||||
public readonly record struct ExamWeekLoad(int IsoYear, int IsoWeek, DateOnly WeekStart, int ExamCount)
|
||||
{
|
||||
public DateOnly WeekEnd => WeekStart.AddDays(6);
|
||||
}
|
||||
@@ -77,13 +77,29 @@ public class GradingService
|
||||
public double WeightedAverage(List<(string Grade, double Weight)> grades)
|
||||
{
|
||||
var numeric = grades
|
||||
.Select(g => (Value: int.TryParse(g.Grade, out var n) ? (int?)n : null, g.Weight))
|
||||
.Select(g => (Value: ParseGradeValue(g.Grade), g.Weight))
|
||||
.Where(g => g.Value.HasValue).ToList();
|
||||
if (numeric.Count == 0) return 0;
|
||||
var total = numeric.Sum(g => g.Weight);
|
||||
return total == 0 ? 0 : numeric.Sum(g => g.Value!.Value * g.Weight) / total;
|
||||
}
|
||||
|
||||
/// Wandelt eine Notenangabe mit optionaler Tendenz ("3+", "2-") in einen rechnerischen
|
||||
/// Notenwert um. Tendenzen verschieben um ein Drittel in Richtung der besseren Note
|
||||
/// (z.B. "3+" → 2,67, "2-" → 2,33) — der in deutschen Notensystemen übliche Drittelabstand.
|
||||
private static double? ParseGradeValue(string grade)
|
||||
{
|
||||
var trimmed = grade.Trim();
|
||||
if (trimmed.Length == 0) return null;
|
||||
|
||||
var tendency = 0.0;
|
||||
var core = trimmed;
|
||||
if (core.EndsWith('+')) { tendency = -1.0 / 3.0; core = core[..^1].TrimEnd(); }
|
||||
else if (core.EndsWith('-')) { tendency = 1.0 / 3.0; core = core[..^1].TrimEnd(); }
|
||||
|
||||
return int.TryParse(core, out var n) ? n + tendency : null;
|
||||
}
|
||||
|
||||
/// Rundet einen rechnerischen Notenwert auf eine ganze Note/Punktzahl.
|
||||
/// "Kaufmännisch" rundet bei genau 0,5 immer vom Nullpunkt weg (Standard).
|
||||
/// "Pädagogisch" rundet bei genau 0,5 in Richtung der besseren Note
|
||||
|
||||
@@ -60,6 +60,7 @@ public sealed class GroupRolloverService(
|
||||
IsActive = true,
|
||||
IsOwnClass = source.IsOwnClass,
|
||||
IsDifferentiated = source.IsDifferentiated,
|
||||
PreviousGroupId = source.Id,
|
||||
// WebUntisLessonId bewusst nicht übernommen: WebUntis vergibt sie pro Schuljahr neu,
|
||||
// eine übernommene alte lsid würde in der Folgegruppe stumm falsche Fehlzeiten liefern.
|
||||
};
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
using LehrerApp.Core.Interfaces;
|
||||
using LehrerApp.Core.Models;
|
||||
|
||||
namespace LehrerApp.Core.Services;
|
||||
|
||||
/// <summary>Gemeinsame Terminlogik für Verschieben und Trennen von Stunden.</summary>
|
||||
public sealed class LessonSchedulingService(ILessonRepository lessons)
|
||||
{
|
||||
public void Move(Lesson lesson, DateOnly newDate, int? newPeriod, bool shiftFollowing)
|
||||
{
|
||||
EnsureTargetIsFree(lesson, newDate, newPeriod);
|
||||
var oldDate = lesson.Date;
|
||||
var delta = newDate.DayNumber - oldDate.DayNumber;
|
||||
|
||||
if (shiftFollowing && delta != 0)
|
||||
{
|
||||
foreach (var other in lessons.GetByUnit(lesson.UnitId))
|
||||
{
|
||||
if (other.Id == lesson.Id || other.Status == LessonStatus.Conducted || other.Date <= oldDate)
|
||||
continue;
|
||||
other.Date = other.Date.AddDays(delta);
|
||||
lessons.Save(other);
|
||||
}
|
||||
}
|
||||
|
||||
lesson.Date = newDate;
|
||||
if (newPeriod.HasValue) lesson.LessonNumber = newPeriod;
|
||||
lessons.Save(lesson);
|
||||
}
|
||||
|
||||
public Lesson SplitAndMoveSecondPart(Lesson source, int splitAfterMinutes, DateOnly newDate,
|
||||
int newPeriod, TimeOnly? newStartTime)
|
||||
{
|
||||
if (splitAfterMinutes <= 0 || source.Phases.Sum(p => p.DurationMinutes) <= splitAfterMinutes)
|
||||
throw new InvalidOperationException("Der Verlauf reicht nicht über die erste Stunde hinaus.");
|
||||
EnsureTargetIsFree(source, newDate, newPeriod);
|
||||
|
||||
var first = new List<LessonPhaseStep>();
|
||||
var second = new List<LessonPhaseStep>();
|
||||
var elapsed = 0;
|
||||
foreach (var phase in source.Phases)
|
||||
{
|
||||
var remainingInFirst = splitAfterMinutes - elapsed;
|
||||
if (remainingInFirst <= 0)
|
||||
second.Add(Clone(phase, phase.DurationMinutes));
|
||||
else if (phase.DurationMinutes <= remainingInFirst)
|
||||
first.Add(Clone(phase, phase.DurationMinutes));
|
||||
else
|
||||
{
|
||||
first.Add(Clone(phase, remainingInFirst));
|
||||
second.Add(Clone(phase, phase.DurationMinutes - remainingInFirst));
|
||||
}
|
||||
elapsed += phase.DurationMinutes;
|
||||
}
|
||||
|
||||
source.Phases = first;
|
||||
var continuation = new Lesson
|
||||
{
|
||||
UnitId = source.UnitId,
|
||||
GroupId = source.GroupId,
|
||||
Date = newDate,
|
||||
LessonNumber = newPeriod,
|
||||
StartTime = newStartTime,
|
||||
Topic = string.IsNullOrWhiteSpace(source.Topic) ? "Fortsetzung" : $"{source.Topic} – Fortsetzung",
|
||||
Phases = second,
|
||||
Homework = source.Homework,
|
||||
HomeworkChecked = source.HomeworkChecked,
|
||||
HomeworkCheckDismissed = source.HomeworkCheckDismissed,
|
||||
Reflection = source.Reflection,
|
||||
Status = source.Status == LessonStatus.Conducted ? LessonStatus.Draft : source.Status,
|
||||
};
|
||||
source.Homework = null;
|
||||
source.HomeworkChecked = false;
|
||||
source.HomeworkCheckDismissed = false;
|
||||
source.Reflection = null;
|
||||
lessons.Save(source);
|
||||
lessons.Save(continuation);
|
||||
return continuation;
|
||||
}
|
||||
|
||||
private void EnsureTargetIsFree(Lesson source, DateOnly date, int? period)
|
||||
{
|
||||
if (period is null) return;
|
||||
var occupied = lessons.GetByGroupAndDate(source.GroupId, date)
|
||||
.Any(l => l.Id != source.Id && l.LessonNumber == period);
|
||||
if (occupied)
|
||||
throw new InvalidOperationException($"Für die Lerngruppe existiert am {date:dd.MM.yyyy} in der {period}. Stunde bereits eine Planung.");
|
||||
}
|
||||
|
||||
private static LessonPhaseStep Clone(LessonPhaseStep source, int duration) => new()
|
||||
{
|
||||
Name = source.Name,
|
||||
DurationMinutes = duration,
|
||||
Activity = source.Activity,
|
||||
Material = source.Material,
|
||||
Shorthand = source.Shorthand,
|
||||
AlternativePathId = source.AlternativePathId,
|
||||
};
|
||||
}
|
||||
@@ -1,316 +0,0 @@
|
||||
using System.IO.Compression;
|
||||
using System.Text.Json;
|
||||
using System.Xml.Linq;
|
||||
|
||||
namespace LehrerApp.Core.Services;
|
||||
|
||||
public enum TemplateIssueSeverity
|
||||
{
|
||||
Warning,
|
||||
StrongWarning,
|
||||
Error,
|
||||
}
|
||||
|
||||
public sealed record LetterPlaceholder(string Tag, string Description);
|
||||
|
||||
public sealed record TemplateValidationIssue(
|
||||
TemplateIssueSeverity Severity,
|
||||
string Message,
|
||||
string? Tag = null,
|
||||
string? SuggestedTag = null);
|
||||
|
||||
public sealed record TemplateValidationResult(
|
||||
IReadOnlyList<string> Tags,
|
||||
IReadOnlyList<TemplateValidationIssue> Issues)
|
||||
{
|
||||
public bool CanGenerate => Issues.All(i => i.Severity != TemplateIssueSeverity.Error);
|
||||
public bool HasStrongWarnings => Issues.Any(i => i.Severity == TemplateIssueSeverity.StrongWarning);
|
||||
}
|
||||
|
||||
public sealed class LetterTemplateInfo
|
||||
{
|
||||
public Guid Id { get; set; } = Guid.NewGuid();
|
||||
public string Name { get; set; } = "";
|
||||
public string StoredFileName { get; set; } = "";
|
||||
public string OriginalFileName { get; set; } = "";
|
||||
public DateTime ImportedAt { get; set; } = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verwaltet DOCX-Briefvorlagen und befüllt Word-Inhaltssteuerelemente anhand ihres Tags.
|
||||
/// Die Originalvorlage wird nie verändert.
|
||||
/// </summary>
|
||||
public sealed class LetterTemplateService
|
||||
{
|
||||
private static readonly XNamespace W = "http://schemas.openxmlformats.org/wordprocessingml/2006/main";
|
||||
private static readonly JsonSerializerOptions JsonOptions = new() { WriteIndented = true };
|
||||
|
||||
public static IReadOnlyList<LetterPlaceholder> SupportedPlaceholders { get; } =
|
||||
[
|
||||
new("Student.FirstName", "Vorname des Schülers"),
|
||||
new("Student.LastName", "Nachname des Schülers"),
|
||||
new("Contact.Name", "Name des ausgewählten Kontakts"),
|
||||
new("Contact.Address", "Vollständige Anschrift (mehrzeilig)"),
|
||||
new("Contact.Street", "Straße und Hausnummer"),
|
||||
new("Contact.PostalCode", "Postleitzahl"),
|
||||
new("Contact.City", "Ort"),
|
||||
new("Letter.Salutation", "Gespeicherte Briefanrede des Kontakts"),
|
||||
new("Group.Name", "Ausgewählte Lerngruppe"),
|
||||
new("SchoolYear", "Schuljahr der ausgewählten Gruppe"),
|
||||
new("CurrentDate", "Gewähltes Briefdatum"),
|
||||
];
|
||||
|
||||
private readonly string _templateDirectory;
|
||||
private readonly string _indexPath;
|
||||
|
||||
public LetterTemplateService(string appDataPath)
|
||||
{
|
||||
_templateDirectory = Path.Combine(appDataPath, "letter-templates");
|
||||
_indexPath = Path.Combine(_templateDirectory, "templates.json");
|
||||
Directory.CreateDirectory(_templateDirectory);
|
||||
}
|
||||
|
||||
public IReadOnlyList<LetterTemplateInfo> GetTemplates() => LoadIndex()
|
||||
.OrderBy(t => t.Name, StringComparer.CurrentCultureIgnoreCase)
|
||||
.ToList();
|
||||
|
||||
public string GetTemplatePath(LetterTemplateInfo template) =>
|
||||
Path.Combine(_templateDirectory, template.StoredFileName);
|
||||
|
||||
public LetterTemplateInfo Import(string sourcePath, string? displayName = null)
|
||||
{
|
||||
var validation = Validate(sourcePath);
|
||||
if (!validation.CanGenerate)
|
||||
throw new InvalidDataException(validation.Issues.First(i => i.Severity == TemplateIssueSeverity.Error).Message);
|
||||
|
||||
var templates = LoadIndex();
|
||||
var info = new LetterTemplateInfo
|
||||
{
|
||||
Name = string.IsNullOrWhiteSpace(displayName)
|
||||
? Path.GetFileNameWithoutExtension(sourcePath)
|
||||
: displayName.Trim(),
|
||||
OriginalFileName = Path.GetFileName(sourcePath),
|
||||
};
|
||||
info.StoredFileName = $"{info.Id:N}.docx";
|
||||
File.Copy(sourcePath, GetTemplatePath(info), overwrite: false);
|
||||
templates.Add(info);
|
||||
SaveIndex(templates);
|
||||
return info;
|
||||
}
|
||||
|
||||
public void Delete(Guid id)
|
||||
{
|
||||
var templates = LoadIndex();
|
||||
var template = templates.FirstOrDefault(t => t.Id == id);
|
||||
if (template is null) return;
|
||||
var path = GetTemplatePath(template);
|
||||
if (File.Exists(path)) File.Delete(path);
|
||||
templates.Remove(template);
|
||||
SaveIndex(templates);
|
||||
}
|
||||
|
||||
public TemplateValidationResult Validate(LetterTemplateInfo template) =>
|
||||
Validate(GetTemplatePath(template));
|
||||
|
||||
public TemplateValidationResult Validate(string path)
|
||||
{
|
||||
var tags = new List<string>();
|
||||
var issues = new List<TemplateValidationIssue>();
|
||||
|
||||
if (!File.Exists(path))
|
||||
return Error("Die Vorlagendatei wurde nicht gefunden.");
|
||||
if (!string.Equals(Path.GetExtension(path), ".docx", StringComparison.OrdinalIgnoreCase))
|
||||
return Error("Die Vorlage muss eine DOCX-Datei sein.");
|
||||
|
||||
try
|
||||
{
|
||||
using var archive = ZipFile.OpenRead(path);
|
||||
var xmlEntries = WordXmlEntries(archive).ToList();
|
||||
if (xmlEntries.All(e => !string.Equals(e.FullName, "word/document.xml", StringComparison.OrdinalIgnoreCase)))
|
||||
return Error("Die Datei ist kein gültiges Word-DOCX-Dokument.");
|
||||
|
||||
foreach (var entry in xmlEntries)
|
||||
{
|
||||
using var stream = entry.Open();
|
||||
var document = XDocument.Load(stream, LoadOptions.PreserveWhitespace);
|
||||
foreach (var control in document.Descendants(W + "sdt"))
|
||||
{
|
||||
var tag = control.Element(W + "sdtPr")?.Element(W + "tag")?.Attribute(W + "val")?.Value?.Trim();
|
||||
if (string.IsNullOrWhiteSpace(tag))
|
||||
{
|
||||
issues.Add(new(TemplateIssueSeverity.StrongWarning,
|
||||
$"Ein Inhaltssteuerelement in {PartLabel(entry.FullName)} besitzt keinen Tag und kann nicht befüllt werden."));
|
||||
continue;
|
||||
}
|
||||
tags.Add(tag);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (InvalidDataException)
|
||||
{
|
||||
return Error("Die Datei ist beschädigt oder kein gültiges DOCX-Dokument.");
|
||||
}
|
||||
catch (System.Xml.XmlException)
|
||||
{
|
||||
return Error("Die Word-Vorlage enthält ungültiges XML und kann nicht gelesen werden.");
|
||||
}
|
||||
catch (IOException ex)
|
||||
{
|
||||
return Error($"Die Vorlage konnte nicht geöffnet werden: {ex.Message}");
|
||||
}
|
||||
|
||||
if (tags.Count == 0 && issues.Count == 0)
|
||||
issues.Add(new(TemplateIssueSeverity.Warning,
|
||||
"Die Vorlage enthält keine Inhaltssteuerelemente. Es werden keine Felder befüllt."));
|
||||
|
||||
foreach (var tag in tags.Distinct(StringComparer.Ordinal))
|
||||
{
|
||||
if (SupportedPlaceholders.Any(p => p.Tag == tag)) continue;
|
||||
var suggestion = FindSuggestion(tag);
|
||||
issues.Add(suggestion is null
|
||||
? new(TemplateIssueSeverity.Warning,
|
||||
$"Der unbekannte Tag „{tag}“ wird nicht befüllt.", tag)
|
||||
: new(TemplateIssueSeverity.StrongWarning,
|
||||
$"Wahrscheinlicher Schreibfehler: „{tag}“. Meinten Sie „{suggestion}“?", tag, suggestion));
|
||||
}
|
||||
|
||||
return new(tags.Distinct(StringComparer.Ordinal).OrderBy(t => t).ToList(), issues);
|
||||
|
||||
TemplateValidationResult Error(string message) =>
|
||||
new([], [new(TemplateIssueSeverity.Error, message)]);
|
||||
}
|
||||
|
||||
public void Generate(string templatePath, string outputPath,
|
||||
IReadOnlyDictionary<string, string?> values)
|
||||
{
|
||||
var validation = Validate(templatePath);
|
||||
if (!validation.CanGenerate)
|
||||
throw new InvalidDataException(validation.Issues.First(i => i.Severity == TemplateIssueSeverity.Error).Message);
|
||||
|
||||
var outputDirectory = Path.GetDirectoryName(outputPath);
|
||||
if (!string.IsNullOrEmpty(outputDirectory)) Directory.CreateDirectory(outputDirectory);
|
||||
|
||||
using var source = ZipFile.OpenRead(templatePath);
|
||||
using var target = ZipFile.Open(outputPath, ZipArchiveMode.Create);
|
||||
foreach (var entry in source.Entries)
|
||||
{
|
||||
var targetEntry = target.CreateEntry(entry.FullName, CompressionLevel.Optimal);
|
||||
targetEntry.LastWriteTime = entry.LastWriteTime;
|
||||
using var input = entry.Open();
|
||||
using var output = targetEntry.Open();
|
||||
|
||||
if (!IsWordXmlEntry(entry))
|
||||
{
|
||||
input.CopyTo(output);
|
||||
continue;
|
||||
}
|
||||
|
||||
var document = XDocument.Load(input, LoadOptions.PreserveWhitespace);
|
||||
foreach (var control in document.Descendants(W + "sdt").ToList())
|
||||
{
|
||||
var tag = control.Element(W + "sdtPr")?.Element(W + "tag")?.Attribute(W + "val")?.Value?.Trim();
|
||||
if (tag is null || !values.TryGetValue(tag, out var value)) continue;
|
||||
ReplaceContent(control.Element(W + "sdtContent"), value ?? "");
|
||||
}
|
||||
document.Save(output, SaveOptions.DisableFormatting);
|
||||
}
|
||||
}
|
||||
|
||||
private static void ReplaceContent(XElement? content, string value)
|
||||
{
|
||||
if (content is null) return;
|
||||
var paragraph = content.Elements(W + "p").FirstOrDefault();
|
||||
XElement run;
|
||||
|
||||
if (paragraph is not null)
|
||||
{
|
||||
run = paragraph.Descendants(W + "r").FirstOrDefault() ?? new XElement(W + "r");
|
||||
var paragraphProperties = paragraph.Element(W + "pPr");
|
||||
var runProperties = run.Element(W + "rPr") is { } rp ? new XElement(rp) : null;
|
||||
paragraph.RemoveNodes();
|
||||
if (paragraphProperties is not null) paragraph.Add(paragraphProperties);
|
||||
run = new XElement(W + "r");
|
||||
if (runProperties is not null) run.Add(runProperties);
|
||||
paragraph.Add(run);
|
||||
foreach (var extra in content.Elements(W + "p").Skip(1).ToList()) extra.Remove();
|
||||
foreach (var node in content.Nodes().Where(n => n is XElement e && e.Name != W + "p").ToList()) node.Remove();
|
||||
}
|
||||
else
|
||||
{
|
||||
var firstRun = content.Descendants(W + "r").FirstOrDefault();
|
||||
var runProperties = firstRun?.Element(W + "rPr") is { } rp ? new XElement(rp) : null;
|
||||
content.RemoveNodes();
|
||||
run = new XElement(W + "r");
|
||||
if (runProperties is not null) run.Add(runProperties);
|
||||
content.Add(run);
|
||||
}
|
||||
|
||||
var lines = value.Replace("\r\n", "\n", StringComparison.Ordinal).Replace('\r', '\n').Split('\n');
|
||||
for (var i = 0; i < lines.Length; i++)
|
||||
{
|
||||
if (i > 0) run.Add(new XElement(W + "br"));
|
||||
run.Add(new XElement(W + "t", new XAttribute(XNamespace.Xml + "space", "preserve"), lines[i]));
|
||||
}
|
||||
}
|
||||
|
||||
private static IEnumerable<ZipArchiveEntry> WordXmlEntries(ZipArchive archive) =>
|
||||
archive.Entries.Where(IsWordXmlEntry);
|
||||
|
||||
private static bool IsWordXmlEntry(ZipArchiveEntry entry) =>
|
||||
entry.FullName.StartsWith("word/", StringComparison.OrdinalIgnoreCase)
|
||||
&& entry.FullName.EndsWith(".xml", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
private static string PartLabel(string path) => path switch
|
||||
{
|
||||
"word/document.xml" => "Dokumenttext",
|
||||
var p when p.StartsWith("word/header", StringComparison.OrdinalIgnoreCase) => "einer Kopfzeile",
|
||||
var p when p.StartsWith("word/footer", StringComparison.OrdinalIgnoreCase) => "einer Fußzeile",
|
||||
_ => $"dem Dokumentteil „{Path.GetFileName(path)}“",
|
||||
};
|
||||
|
||||
private static string? FindSuggestion(string unknown)
|
||||
{
|
||||
var candidate = SupportedPlaceholders
|
||||
.Select(p => (p.Tag, Distance: Levenshtein(unknown.ToLowerInvariant(), p.Tag.ToLowerInvariant())))
|
||||
.OrderBy(x => x.Distance)
|
||||
.ThenBy(x => x.Tag)
|
||||
.First();
|
||||
var threshold = candidate.Tag.Length >= 12 ? 3 : 2;
|
||||
return candidate.Distance <= threshold ? candidate.Tag : null;
|
||||
}
|
||||
|
||||
private static int Levenshtein(string left, string right)
|
||||
{
|
||||
var previous = Enumerable.Range(0, right.Length + 1).ToArray();
|
||||
for (var i = 1; i <= left.Length; i++)
|
||||
{
|
||||
var current = new int[right.Length + 1];
|
||||
current[0] = i;
|
||||
for (var j = 1; j <= right.Length; j++)
|
||||
current[j] = Math.Min(Math.Min(current[j - 1] + 1, previous[j] + 1),
|
||||
previous[j - 1] + (left[i - 1] == right[j - 1] ? 0 : 1));
|
||||
previous = current;
|
||||
}
|
||||
return previous[right.Length];
|
||||
}
|
||||
|
||||
private List<LetterTemplateInfo> LoadIndex()
|
||||
{
|
||||
if (!File.Exists(_indexPath)) return [];
|
||||
try
|
||||
{
|
||||
return JsonSerializer.Deserialize<List<LetterTemplateInfo>>(File.ReadAllText(_indexPath), JsonOptions) ?? [];
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
private void SaveIndex(List<LetterTemplateInfo> templates)
|
||||
{
|
||||
var temporaryPath = _indexPath + ".tmp";
|
||||
File.WriteAllText(temporaryPath, JsonSerializer.Serialize(templates, JsonOptions));
|
||||
File.Move(temporaryPath, _indexPath, overwrite: true);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
using System.Text.Json;
|
||||
|
||||
namespace LehrerApp.Core.Services;
|
||||
|
||||
/// <summary>Gewichte für ClassTeacherRosterRow.PatternScore (Klassenlehrer-Bereich, "Gesamtbild")
|
||||
/// — Default-Werte entsprechen den ursprünglich fest verdrahteten Konstanten.</summary>
|
||||
public sealed class PatternScoreWeights
|
||||
{
|
||||
public double UnexcusedDayWeight { get; set; } = 3.0;
|
||||
public double ExcusedDayWeight { get; set; } = 1.0;
|
||||
public double LateDayWeight { get; set; } = 0.5;
|
||||
public double NegativeClassRegisterEntryWeight { get; set; } = 1.0;
|
||||
public double ConcerningKeywordBonus { get; set; } = 1.5;
|
||||
public double SuspensionKeywordWeight { get; set; } = 15.0;
|
||||
}
|
||||
|
||||
/// <summary>Speichert die Gewichte lokal, gleiches Muster wie WorkloadSettingsService/
|
||||
/// DashboardSettingsService.</summary>
|
||||
public sealed class PatternScoreSettingsService
|
||||
{
|
||||
private readonly string _configPath;
|
||||
|
||||
public PatternScoreSettingsService(string appDataPath) =>
|
||||
_configPath = Path.Combine(appDataPath, "patternscoreweights.json");
|
||||
|
||||
public PatternScoreWeights Load()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (File.Exists(_configPath))
|
||||
return JsonSerializer.Deserialize<PatternScoreWeights>(File.ReadAllText(_configPath))
|
||||
?? new PatternScoreWeights();
|
||||
}
|
||||
catch { /* beschädigte Konfiguration -> Standardgewichte */ }
|
||||
return new PatternScoreWeights();
|
||||
}
|
||||
|
||||
public void Save(PatternScoreWeights weights) =>
|
||||
File.WriteAllText(_configPath, JsonSerializer.Serialize(weights));
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
using LehrerApp.Core.Models;
|
||||
|
||||
namespace LehrerApp.Core.Services;
|
||||
|
||||
/// Welcher der drei Leistungsbereiche (siehe GradingScheme) bei der Zielnoten-Frage der
|
||||
/// gesuchte/unbekannte ist.
|
||||
public enum GradeBucketKind { Exams, Participation, Other }
|
||||
|
||||
public readonly record struct TargetGradeResult(double RequiredAverage, bool IsAchievable);
|
||||
|
||||
/// <summary>
|
||||
/// Beantwortet die Zielnoten-Frage "Was brauche ich noch, um Zeugnisnote X zu erreichen?"
|
||||
/// (Nutzer-Feedback) — kehrt <see cref="GradingService.CalculateReportGrade"/> um: löst nach
|
||||
/// dem Durchschnitt eines gewählten Bereichs auf, der zusammen mit den bekannten Durchschnitten
|
||||
/// der übrigen Bereiche (gewichtet nach <see cref="GradingScheme"/>) genau die Zielnote ergibt.
|
||||
/// Bereiche ohne bekannten Durchschnitt (z.B. noch keine Mitarbeitsnote im Halbjahr) fließen
|
||||
/// nicht in die bekannte Seite der Gleichung ein — exakt dieselbe Normierung wie
|
||||
/// <see cref="GradingService.CalculateReportGrade"/> bei fehlenden Bereichen.
|
||||
/// </summary>
|
||||
public static class ReportGradeTargetCalculator
|
||||
{
|
||||
public static TargetGradeResult? SolveRequiredAverage(double target, GradeBucketKind solveFor,
|
||||
double? examsAverage, double? participationAverage, double? otherAverage,
|
||||
GradingScheme scheme, GradingSystem system)
|
||||
{
|
||||
var solvePercent = solveFor switch
|
||||
{
|
||||
GradeBucketKind.Exams => scheme.ExamsPercent,
|
||||
GradeBucketKind.Participation => scheme.ParticipationPercent,
|
||||
_ => scheme.OtherPercent,
|
||||
};
|
||||
// Dieser Bereich fließt laut Gewichtungsschema gar nicht in die Zeugnisnote ein —
|
||||
// keine Zielnoten-Frage für ihn beantwortbar.
|
||||
if (solvePercent <= 0) return null;
|
||||
|
||||
var known = new List<(double Avg, double Percent)>();
|
||||
if (solveFor != GradeBucketKind.Exams && examsAverage is { } e) known.Add((e, scheme.ExamsPercent));
|
||||
if (solveFor != GradeBucketKind.Participation && participationAverage is { } p) known.Add((p, scheme.ParticipationPercent));
|
||||
if (solveFor != GradeBucketKind.Other && otherAverage is { } o) known.Add((o, scheme.OtherPercent));
|
||||
|
||||
var totalPercent = known.Sum(k => k.Percent) + solvePercent;
|
||||
var knownContribution = known.Sum(k => k.Avg * k.Percent);
|
||||
var required = (target * totalPercent - knownContribution) / solvePercent;
|
||||
|
||||
var (min, max) = system == GradingSystem.Grades1To6 ? (1.0, 6.0) : (0.0, 15.0);
|
||||
return new TargetGradeResult(required, required >= min && required <= max);
|
||||
}
|
||||
}
|
||||
@@ -193,4 +193,21 @@ public sealed class ChangeHookCascadeTests
|
||||
var call = Assert.Single(calls);
|
||||
Assert.Equal((nameof(Documentation), "Delete"), call);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void VorgangRepository_Delete_LoestSaveMitIsDeletedAus()
|
||||
{
|
||||
using var db = NewInMemoryContext();
|
||||
var repo = new VorgangRepository(db);
|
||||
var vorgang = new Vorgang { Title = "Konflikt" };
|
||||
repo.Save(vorgang);
|
||||
var calls = new List<(string EntityType, string Operation, object? Payload)>();
|
||||
db.OnChange = (type, id, op, payload) => calls.Add((type, op, payload));
|
||||
|
||||
repo.Delete(vorgang.Id);
|
||||
|
||||
var call = Assert.Single(calls);
|
||||
Assert.Equal((nameof(Vorgang), "Save"), (call.EntityType, call.Operation));
|
||||
Assert.True(((Vorgang)call.Payload!).IsDeleted);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -60,6 +60,65 @@ public sealed class DatabaseEncryptionServiceTests
|
||||
Assert.Single(reopened.GetCollection<BsonDocument>("t").FindAll());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CanOpenAndRead_OhneVorhandeneDatei_GibtFalseZurueck()
|
||||
{
|
||||
using var temp = new TempDatabase();
|
||||
var service = new DatabaseEncryptionService();
|
||||
|
||||
Assert.False(service.CanOpenAndRead(temp.Path, null));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CanOpenAndRead_GueltigeUnverschluesselteDatenbank_GibtTrueZurueck()
|
||||
{
|
||||
using var temp = new TempDatabase();
|
||||
using (var db = new LiteDatabase(temp.Path))
|
||||
db.GetCollection<BsonDocument>("t").Insert(new BsonDocument { ["x"] = 1 });
|
||||
|
||||
var service = new DatabaseEncryptionService();
|
||||
|
||||
Assert.True(service.CanOpenAndRead(temp.Path, null));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CanOpenAndRead_GueltigeVerschluesselteDatenbankMitRichtigemPasswort_GibtTrueZurueck()
|
||||
{
|
||||
using var temp = new TempDatabase();
|
||||
using (var db = new LiteDatabase(new ConnectionString(temp.Path) { Password = "geheim123" }))
|
||||
db.GetCollection<BsonDocument>("t").Insert(new BsonDocument { ["x"] = 1 });
|
||||
|
||||
var service = new DatabaseEncryptionService();
|
||||
|
||||
Assert.True(service.CanOpenAndRead(temp.Path, "geheim123"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CanOpenAndRead_FalschesPasswort_GibtFalseZurueck()
|
||||
{
|
||||
using var temp = new TempDatabase();
|
||||
using (var db = new LiteDatabase(new ConnectionString(temp.Path) { Password = "geheim123" }))
|
||||
db.GetCollection<BsonDocument>("t").Insert(new BsonDocument { ["x"] = 1 });
|
||||
|
||||
var service = new DatabaseEncryptionService();
|
||||
|
||||
Assert.False(service.CanOpenAndRead(temp.Path, "falsch"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CanOpenAndRead_BeschaedigteDatei_GibtFalseZurueck()
|
||||
{
|
||||
using var temp = new TempDatabase();
|
||||
// Eine kurze Textdatei initialisiert LiteDB stillschweigend als neue, leere Datenbank
|
||||
// (kleiner als eine Seite) — erst genug Datenmüll jenseits der Seitengröße lässt das
|
||||
// Datei-Layout tatsächlich fehlschlagen und simuliert eine echte Beschädigung
|
||||
// (z.B. durch einen vollen Datenträger während der Backup-Kopie).
|
||||
File.WriteAllBytes(temp.Path, Enumerable.Repeat((byte)0x42, 32_768).ToArray());
|
||||
var service = new DatabaseEncryptionService();
|
||||
|
||||
Assert.False(service.CanOpenAndRead(temp.Path, null));
|
||||
}
|
||||
|
||||
private sealed class TempDatabase : IDisposable
|
||||
{
|
||||
private readonly string _directory = System.IO.Path.Combine(
|
||||
|
||||
@@ -715,6 +715,67 @@ public sealed class RepositoryTests
|
||||
Assert.Null(storage.OpenRead(attachmentId));
|
||||
}
|
||||
|
||||
// ── VorgangRepository ─────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void VorgangRepository_Save_SetztUpdatedAt()
|
||||
{
|
||||
using var db = NewInMemoryContext();
|
||||
var repo = new VorgangRepository(db);
|
||||
var vorgang = new Vorgang { Title = "Schuleschwänzen Max" };
|
||||
|
||||
repo.Save(vorgang);
|
||||
|
||||
var raw = db.Vorgaenge.FindById(vorgang.Id);
|
||||
Assert.NotNull(raw);
|
||||
Assert.True((DateTime.UtcNow - raw!.UpdatedAt).TotalSeconds < 5);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void VorgangRepository_GetByStudent_FindetVorgaengeMitMehrerenSchuelern()
|
||||
{
|
||||
using var db = NewInMemoryContext();
|
||||
var repo = new VorgangRepository(db);
|
||||
var ada = Guid.NewGuid();
|
||||
var ben = Guid.NewGuid();
|
||||
var other = Guid.NewGuid();
|
||||
var shared = new Vorgang { Title = "Konflikt", StudentIds = [ada, ben] };
|
||||
var unrelated = new Vorgang { Title = "Anderes", StudentIds = [other] };
|
||||
repo.Save(shared);
|
||||
repo.Save(unrelated);
|
||||
|
||||
Assert.Equal([shared.Id], repo.GetByStudent(ada).Select(v => v.Id));
|
||||
Assert.Equal([shared.Id], repo.GetByStudent(ben).Select(v => v.Id));
|
||||
Assert.Empty(repo.GetByStudent(Guid.NewGuid()));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void VorgangRepository_Delete_MarkiertNurAlsGeloescht()
|
||||
{
|
||||
using var db = NewInMemoryContext();
|
||||
var repo = new VorgangRepository(db);
|
||||
var vorgang = new Vorgang { Title = "Hausaufgaben fehlen" };
|
||||
repo.Save(vorgang);
|
||||
|
||||
repo.Delete(vorgang.Id);
|
||||
|
||||
Assert.Empty(repo.GetAll());
|
||||
Assert.Null(repo.GetById(vorgang.Id));
|
||||
var raw = db.Vorgaenge.FindById(vorgang.Id);
|
||||
Assert.NotNull(raw);
|
||||
Assert.True(raw!.IsDeleted);
|
||||
Assert.NotNull(raw.DeletedAt);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void VorgangRepository_GetById_LiefertNullFuerUnbekannteId()
|
||||
{
|
||||
using var db = NewInMemoryContext();
|
||||
var repo = new VorgangRepository(db);
|
||||
|
||||
Assert.Null(repo.GetById(Guid.NewGuid()));
|
||||
}
|
||||
|
||||
// ── LessonRepository ──────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -37,6 +37,29 @@ public class DatabaseEncryptionService
|
||||
}
|
||||
}
|
||||
|
||||
/// Öffnet die Datei probeweise und liest die Sammlungsliste, um eine strukturell gültige
|
||||
/// (nicht nur vorhandene) LiteDB-Datei zu bestätigen — z.B. direkt nach dem Anlegen eines
|
||||
/// Backups, das durch einen vollen Datenträger oder einen vorzeitig entfernten USB-Stick
|
||||
/// beschädigt worden sein könnte. Bewusst breites Catch: jede Art von Fehlschlag beim Öffnen
|
||||
/// oder Lesen bedeutet hier "Backup nicht verifizierbar", nicht nur ein falsches Passwort.
|
||||
public bool CanOpenAndRead(string dbPath, string? password)
|
||||
{
|
||||
if (!File.Exists(dbPath)) return false;
|
||||
try
|
||||
{
|
||||
var connection = password is null
|
||||
? new ConnectionString(dbPath)
|
||||
: new ConnectionString(dbPath) { Password = password };
|
||||
using var db = new LiteDatabase(connection);
|
||||
_ = db.GetCollectionNames().ToList();
|
||||
return true;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Setzt (newPassword != null), ändert oder entfernt (newPassword == null) das
|
||||
/// Passwort der Datenbank. Der Aufrufer muss sicherstellen, dass keine andere
|
||||
/// Verbindung (z.B. der laufende <see cref="LiteDbContext"/>) die Datei offen hält.
|
||||
|
||||
@@ -51,6 +51,7 @@ public class LiteDbContext : IDisposable
|
||||
public ILiteCollection<Unit> Units => _db.GetCollection<Unit>("units");
|
||||
public ILiteCollection<Lesson> Lessons => _db.GetCollection<Lesson>("lessons");
|
||||
public ILiteCollection<Documentation> Documentation => _db.GetCollection<Documentation>("documentation");
|
||||
public ILiteCollection<Vorgang> Vorgaenge => _db.GetCollection<Vorgang>("vorgaenge");
|
||||
public ILiteStorage<string> Attachments => _db.GetStorage<string>("attachments", "attachments_chunks");
|
||||
public ILiteCollection<WorkTask> Tasks => _db.GetCollection<WorkTask>("tasks");
|
||||
public ILiteCollection<TimeEntry> TimeEntries => _db.GetCollection<TimeEntry>("time_entries");
|
||||
|
||||
@@ -431,6 +431,37 @@ public class DocumentationRepository(LiteDbContext db) : IDocumentationRepositor
|
||||
}
|
||||
}
|
||||
|
||||
public class VorgangRepository(LiteDbContext db) : IVorgangRepository
|
||||
{
|
||||
public List<Vorgang> GetAll() =>
|
||||
db.Vorgaenge.Find(v => !v.IsDeleted).OrderByDescending(v => v.UpdatedAt).ToList();
|
||||
public List<Vorgang> GetByStudent(Guid studentId) =>
|
||||
GetAll().Where(v => v.StudentIds.Contains(studentId)).ToList();
|
||||
public Vorgang? GetById(Guid id)
|
||||
{
|
||||
var vorgang = db.Vorgaenge.FindById(id);
|
||||
return vorgang is null || vorgang.IsDeleted ? null : vorgang;
|
||||
}
|
||||
public void Save(Vorgang vorgang)
|
||||
{
|
||||
vorgang.UpdatedAt = DateTime.UtcNow;
|
||||
db.Vorgaenge.Upsert(vorgang);
|
||||
db.OnChange?.Invoke(nameof(Vorgang), vorgang.Id.ToString(), "Save", vorgang);
|
||||
}
|
||||
public void Delete(Guid id)
|
||||
{
|
||||
var vorgang = db.Vorgaenge.FindById(id);
|
||||
if (vorgang is null) return;
|
||||
vorgang.IsDeleted = true;
|
||||
vorgang.DeletedAt = DateTime.UtcNow;
|
||||
db.Vorgaenge.Update(vorgang);
|
||||
// Weiches Löschen ist inhaltlich eine Änderung, kein Entfernen -> "Save", damit ein
|
||||
// anwendendes Gerät den IsDeleted-Stand einfach übernimmt statt den Datensatz zu entfernen
|
||||
// (gleiches Muster wie DocumentationRepository.Delete).
|
||||
db.OnChange?.Invoke(nameof(Vorgang), id.ToString(), "Save", vorgang);
|
||||
}
|
||||
}
|
||||
|
||||
public class WorkTaskRepository(LiteDbContext db) : IWorkTaskRepository
|
||||
{
|
||||
public List<WorkTask> GetByStatus(WorkTaskStatus s) =>
|
||||
@@ -492,6 +523,7 @@ public class ParticipationSessionRepository(LiteDbContext db) : IParticipationSe
|
||||
public List<ParticipationSession> GetByGroup(Guid groupId) =>
|
||||
db.ParticipationSessions.Find(s => s.GroupId == groupId).OrderByDescending(s => s.Date).ToList();
|
||||
public ParticipationSession? GetById(Guid id) => db.ParticipationSessions.FindById(id);
|
||||
public List<ParticipationSession> GetAll() => db.ParticipationSessions.FindAll().ToList();
|
||||
public void Save(ParticipationSession s)
|
||||
{
|
||||
ArchivedGroupWriteGuard.EnsureActive(db, s.GroupId);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using LehrerApp.Core.Models;
|
||||
using LehrerApp.Core.Services;
|
||||
using LehrerApp.Desktop.Services;
|
||||
using LehrerApp.Desktop.ViewModels.ClassTeacher;
|
||||
using Xunit;
|
||||
@@ -184,7 +185,8 @@ public sealed class ClassTeacherViewModelsTests
|
||||
};
|
||||
|
||||
var row = Assert.Single(ClassTeacherRosterRow.Build(students, [], [],
|
||||
new DateOnly(2026, 8, 26), yearAbsences, schoolDaysElapsed: 20));
|
||||
new DateOnly(2026, 8, 26), yearAbsences, schoolDaysElapsed: 20,
|
||||
termStart: new DateOnly(2026, 8, 1)));
|
||||
|
||||
Assert.True(row.HasYearSummary);
|
||||
Assert.Equal(2, row.YearAbsenceDayCount);
|
||||
@@ -192,9 +194,29 @@ public sealed class ClassTeacherViewModelsTests
|
||||
Assert.Equal(10, row.YearAbsenceRatePercent);
|
||||
Assert.Contains("10 %", row.YearSummaryLabel);
|
||||
Assert.Contains("2 von 20", row.YearSummaryTooltip);
|
||||
Assert.Contains("seit 01.08.", row.YearSummaryTooltip);
|
||||
Assert.Contains("1 unentschuldigt", row.YearSummaryTooltip);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RosterBuild_ZeigtAbgezogeneFerientageImTooltip()
|
||||
{
|
||||
// Nutzer-Nachfrage: die Herkunft des Nenners soll sich ohne Blick in die WebUntis-Ferienliste
|
||||
// direkt in der App nachvollziehen lassen, nachdem er zeitweise Ferientage mitzählte.
|
||||
var students = new[] { Student(1001, "Ada Müller") };
|
||||
var yearAbsences = new[]
|
||||
{
|
||||
new ClassAbsenceDaySummaryRow(new DateOnly(2026, 8, 17), "Müller Ada", 1001, 2, 90,
|
||||
["Che"], [1, 2], ["entsch."], ["Krank"], null, null, false),
|
||||
};
|
||||
|
||||
var row = Assert.Single(ClassTeacherRosterRow.Build(students, [], [],
|
||||
new DateOnly(2026, 8, 26), yearAbsences, schoolDaysElapsed: 14, holidayWeekdaysExcluded: 7,
|
||||
termStart: new DateOnly(2026, 8, 1)));
|
||||
|
||||
Assert.Contains("7 Ferientage abgezogen", row.YearSummaryTooltip);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RosterBuild_OhneJahresdatenZeigtKeineFehlquote()
|
||||
{
|
||||
@@ -207,6 +229,198 @@ public sealed class ClassTeacherViewModelsTests
|
||||
Assert.Null(row.YearSummaryTooltip);
|
||||
}
|
||||
|
||||
// ── Fehlquote behandelt Verspätung nicht mehr wie einen ganzen Fehltag (Nutzer-Feedback) ────
|
||||
|
||||
[Fact]
|
||||
public void RosterBuild_ReineVerspaetungZaehltNichtInDieFehlquote()
|
||||
{
|
||||
// Nutzer-Feedback: "10× 5 Min. verspätet" wurde bisher exakt wie "10× unentschuldigt
|
||||
// gefehlt" behandelt. Reine (nicht unentschuldigte) Verspätung darf die Quote nicht mehr
|
||||
// treiben, taucht aber weiterhin als eigene Zahl auf.
|
||||
var students = new[] { Student(1001, "Ada Müller") };
|
||||
var yearAbsences = Enumerable.Range(0, 3)
|
||||
.Select(i => new ClassAbsenceDaySummaryRow(new DateOnly(2026, 8, 10 + i), "Müller Ada", 1001, 0, 5,
|
||||
["Deu"], [1], ["entsch."], ["Verspätung"], null, null, false))
|
||||
.ToArray();
|
||||
|
||||
var row = Assert.Single(ClassTeacherRosterRow.Build(students, [], [],
|
||||
new DateOnly(2026, 8, 26), yearAbsences, schoolDaysElapsed: 20, termStart: new DateOnly(2026, 8, 1)));
|
||||
|
||||
Assert.Equal(0, row.YearAbsenceDayCount);
|
||||
Assert.Equal(3, row.YearLateDayCount);
|
||||
Assert.Equal(0, row.YearAbsenceRatePercent);
|
||||
Assert.True(row.HasYearSummary);
|
||||
Assert.Contains("verspätet", row.YearSummaryLabel);
|
||||
Assert.DoesNotContain("%", row.YearSummaryLabel);
|
||||
Assert.Contains("3 Tage verspätet", row.YearSummaryTooltip);
|
||||
Assert.Contains("zählt nicht zur Quote", row.YearSummaryTooltip);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RosterBuild_UnentschuldigtUndVerspaetetZaehltAlsUnentschuldigtNichtAlsVerspaetung()
|
||||
{
|
||||
// Wer unentschuldigt UND verspätet ist, bleibt der schwerwiegendere Fall (dieselbe
|
||||
// Rangfolge wie AttentionRank/StatusKind/BuildTrend: Unentschuldigt vor Verspätet) — zählt
|
||||
// deshalb weiter voll in die Quote statt in YearLateDayCount zu verschwinden.
|
||||
var students = new[] { Student(1001, "Ada Müller") };
|
||||
var yearAbsences = new[]
|
||||
{
|
||||
new ClassAbsenceDaySummaryRow(new DateOnly(2026, 8, 10), "Müller Ada", 1001, 0, 60,
|
||||
["Deu"], [1], ["nicht entsch."], ["Verspätung"], null, null, false),
|
||||
};
|
||||
|
||||
var row = Assert.Single(ClassTeacherRosterRow.Build(students, [], [],
|
||||
new DateOnly(2026, 8, 26), yearAbsences, schoolDaysElapsed: 20, termStart: new DateOnly(2026, 8, 1)));
|
||||
|
||||
Assert.Equal(1, row.YearAbsenceDayCount);
|
||||
Assert.Equal(1, row.YearUnexcusedDayCount);
|
||||
Assert.Equal(0, row.YearLateDayCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PatternScore_GewichtetUnentschuldigtDeutlichStaerkerAlsVerspaetung()
|
||||
{
|
||||
// Kernfall aus dem Nutzer-Feedback: 10× 5 Minuten verspätet darf nicht denselben Score
|
||||
// ergeben wie 10× ganztägig unentschuldigt gefehlt.
|
||||
var lateStudent = Student(1001, "Verpennt Timo");
|
||||
var truantStudent = Student(1002, "Geschwaenzt Cem");
|
||||
var yearAbsences = Enumerable.Range(0, 10)
|
||||
.SelectMany(i => new[]
|
||||
{
|
||||
new ClassAbsenceDaySummaryRow(new DateOnly(2026, 8, 3 + i), "Verpennt Timo", 1001, 0, 5,
|
||||
["Deu"], [1], ["entsch."], ["Verspätung"], null, null, false),
|
||||
new ClassAbsenceDaySummaryRow(new DateOnly(2026, 8, 3 + i), "Geschwaenzt Cem", 1002, 6, 270,
|
||||
["Deu"], [1, 2, 3, 4, 5, 6], ["nicht entsch."], ["Absent"], null, null, true),
|
||||
})
|
||||
.ToArray();
|
||||
|
||||
var roster = ClassTeacherRosterRow.Build([lateStudent, truantStudent], [], [],
|
||||
new DateOnly(2026, 8, 26), yearAbsences, schoolDaysElapsed: 20, termStart: new DateOnly(2026, 8, 1));
|
||||
|
||||
var timo = Assert.Single(roster, r => r.StudentName == "Verpennt Timo");
|
||||
var cem = Assert.Single(roster, r => r.StudentName == "Geschwaenzt Cem");
|
||||
Assert.Equal(5.0, timo.PatternScore);
|
||||
Assert.Equal(30.0, cem.PatternScore);
|
||||
Assert.True(cem.PatternScore > timo.PatternScore * 5);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PatternScore_ErfasstMusterAuchWennHeuteAnwesend()
|
||||
{
|
||||
// Nutzer-Feedback: ein/e Schüler*in, die/der heute da ist, aber zuvor mehrfach unentschuldigt
|
||||
// fehlte, soll nicht spurlos aus der Musterauswertung verschwinden — anders als bei der
|
||||
// reinen "heute auffällig"-Sortierung (HasAbsenceToday).
|
||||
var student = Student(1001, "Ada Müller");
|
||||
var yearAbsences = Enumerable.Range(0, 5)
|
||||
.Select(i => new ClassAbsenceDaySummaryRow(new DateOnly(2026, 8, 10 + i), "Müller Ada", 1001, 6, 270,
|
||||
["Deu"], [1, 2, 3, 4, 5, 6], ["nicht entsch."], ["Absent"], null, null, true))
|
||||
.ToArray();
|
||||
|
||||
// Kein todayAbsences-Eintrag: heute anwesend.
|
||||
var row = Assert.Single(ClassTeacherRosterRow.Build([student], [], [],
|
||||
new DateOnly(2026, 8, 26), yearAbsences, schoolDaysElapsed: 20, termStart: new DateOnly(2026, 8, 1)));
|
||||
|
||||
Assert.False(row.HasAbsenceToday);
|
||||
Assert.True(row.PatternScore > 0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PatternScore_IstNullOhneJahresdaten()
|
||||
{
|
||||
var students = new[] { Student(1001, "Ada Müller") };
|
||||
|
||||
var row = Assert.Single(ClassTeacherRosterRow.Build(students, [], [], new DateOnly(2026, 8, 26)));
|
||||
|
||||
Assert.Equal(0.0, row.PatternScore);
|
||||
}
|
||||
|
||||
// ── Klassenbucheinträge anderer Lehrkräfte fließen ebenfalls in den Score ein ────────────────
|
||||
|
||||
[Fact]
|
||||
public void PatternScore_NegativeKlassenbucheintraegeErhoehenDenScoreAuchOhneFehlzeiten()
|
||||
{
|
||||
var student = Student(1001, "Ada Müller");
|
||||
var yearRegister = Enumerable.Range(0, 4)
|
||||
.Select(i => new UntisForeignClassRegisterEventDto("6a", 20260810 + i, "Deu", "Müller Ada", "test",
|
||||
"Unterrichtsstörung", "Negativ", "Gestört"))
|
||||
.ToArray();
|
||||
|
||||
var row = Assert.Single(ClassTeacherRosterRow.Build([student], [], [],
|
||||
new DateOnly(2026, 8, 26), schoolDaysElapsed: 20, termStart: new DateOnly(2026, 8, 1),
|
||||
yearClassRegisterEntries: yearRegister));
|
||||
|
||||
Assert.Equal(4, row.YearNegativeClassRegisterCount);
|
||||
Assert.Equal(4.0, row.PatternScore);
|
||||
Assert.True(row.HasYearSummary);
|
||||
Assert.Contains("negative Klassenbucheinträge", row.YearSummaryLabel);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PatternScore_HausaufgabenUndMitarbeitSchluesselwoerterZaehlenStaerkerAlsPlainNegativ()
|
||||
{
|
||||
var withKeyword = Student(1001, "Keyword Kim");
|
||||
var plain = Student(1002, "Plain Priya");
|
||||
var yearRegister = new[]
|
||||
{
|
||||
new UntisForeignClassRegisterEventDto("6a", 20260810, "Deu", "Keyword Kim", "test",
|
||||
"Hausaufgaben fehlen", "Negativ", "HA nicht dabei"),
|
||||
new UntisForeignClassRegisterEventDto("6a", 20260810, "Deu", "Plain Priya", "test",
|
||||
"Sonstiges", "Negativ", "Gestört"),
|
||||
};
|
||||
|
||||
var roster = ClassTeacherRosterRow.Build([withKeyword, plain], [], [],
|
||||
new DateOnly(2026, 8, 26), schoolDaysElapsed: 20, termStart: new DateOnly(2026, 8, 1),
|
||||
yearClassRegisterEntries: yearRegister);
|
||||
|
||||
var kim = Assert.Single(roster, r => r.StudentName == "Keyword Kim");
|
||||
var priya = Assert.Single(roster, r => r.StudentName == "Plain Priya");
|
||||
Assert.True(kim.PatternScore > priya.PatternScore);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PatternScore_SuspendiertSchiesstDenScoreWeitUeberVieleAndereEintraegeHinaus()
|
||||
{
|
||||
var suspended = Student(1001, "Suspendiert Sam");
|
||||
var chronic = Student(1002, "Viele Vera");
|
||||
var yearRegister = new List<UntisForeignClassRegisterEventDto>
|
||||
{
|
||||
new("6a", 20260810, "Deu", "Suspendiert Sam", "test", "Ordnungsmaßnahme", "Negativ",
|
||||
"Vom Unterricht suspendiert"),
|
||||
};
|
||||
yearRegister.AddRange(Enumerable.Range(0, 6)
|
||||
.Select(i => new UntisForeignClassRegisterEventDto("6a", 20260810 + i, "Deu", "Viele Vera", "test",
|
||||
"Sonstiges", "Negativ", "Gestört")));
|
||||
|
||||
var roster = ClassTeacherRosterRow.Build([suspended, chronic], [], [],
|
||||
new DateOnly(2026, 8, 26), schoolDaysElapsed: 20, termStart: new DateOnly(2026, 8, 1),
|
||||
yearClassRegisterEntries: yearRegister);
|
||||
|
||||
var sam = Assert.Single(roster, r => r.StudentName == "Suspendiert Sam");
|
||||
var vera = Assert.Single(roster, r => r.StudentName == "Viele Vera");
|
||||
Assert.True(sam.HasSuspensionEntry);
|
||||
Assert.True(sam.PatternScore > vera.PatternScore);
|
||||
Assert.Contains("Suspendierung vermerkt", sam.YearSummaryTooltip);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PatternScore_OhneEigeneJahresklassenbuchlisteFaelltAufDenUebergebenenParameterZurueck()
|
||||
{
|
||||
// Rückwärtskompatibilität: Aufrufer, die (wie die bestehenden Tests) nur den ursprünglichen
|
||||
// recentClassRegisterEntries-Parameter befüllen, sollen trotzdem einen Score aus diesen
|
||||
// Einträgen bekommen statt stillschweigend leer auszugehen.
|
||||
var student = Student(1001, "Ada Müller");
|
||||
var entries = new[]
|
||||
{
|
||||
new UntisForeignClassRegisterEventDto("6a", 20260810, "Deu", "Müller Ada", "test",
|
||||
"Sonstiges", "Negativ", "Gestört"),
|
||||
};
|
||||
|
||||
var row = Assert.Single(ClassTeacherRosterRow.Build([student], [], entries,
|
||||
new DateOnly(2026, 8, 26)));
|
||||
|
||||
Assert.True(row.PatternScore > 0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RosterBuild_OrdnetJahresfehlzeitenAuchOhneExternKeyUeberNamenZu()
|
||||
{
|
||||
@@ -264,6 +478,79 @@ public sealed class ClassTeacherViewModelsTests
|
||||
Assert.Equal(row.StatusText, row.StatusTextWithGlyph);
|
||||
}
|
||||
|
||||
// ── Nenner der Jahresfehlquote: echter WebUntis-Ferienkalender statt grober Werktagszählung ──
|
||||
|
||||
[Fact]
|
||||
public void CountSchoolWeekdays_SchliesstFerientageAus()
|
||||
{
|
||||
// 1.-31.08.2026 hat 21 Werktage; die (fiktiven) Sommerferien bis 11.08. nehmen davon 7
|
||||
// Werktage weg (Mo 03. - Fr 07., Mo 10. - Di 11.) - Nutzer-Feedback: Schüler, die seit
|
||||
// Unterrichtsbeginn jeden Tag fehlten, kamen wegen dieser mitgezählten Ferienzeit nur auf
|
||||
// ~57 % statt ~100 % Fehlquote.
|
||||
var holidays = new[] { new CachedUntisHoliday("Sommerferien", new DateOnly(2026, 7, 1), new DateOnly(2026, 8, 11)) };
|
||||
|
||||
var schoolDays = ClassTeacherOverviewViewModel.CountSchoolWeekdays(
|
||||
new DateOnly(2026, 8, 1), new DateOnly(2026, 8, 31), holidays);
|
||||
|
||||
Assert.Equal(14, schoolDays);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CountSchoolWeekdays_OhneFerienVerhaeltSichWieReineWerktagszaehlung()
|
||||
{
|
||||
var schoolDays = ClassTeacherOverviewViewModel.CountSchoolWeekdays(
|
||||
new DateOnly(2026, 8, 1), new DateOnly(2026, 8, 31), []);
|
||||
|
||||
Assert.Equal(21, schoolDays);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EstimateTermStart_NutztFruehestenFehlzeitEintragDerGanzenKlasse()
|
||||
{
|
||||
// Realer Befund (Nutzer-Feedback): WebUntis' getHolidays liefert für Bremen nie einen
|
||||
// Sommerferien-Eintrag (über 11 Jahre Kontohistorie geprüft, kein einziger Juli-/August-
|
||||
// Zeitraum dabei) - vermutlich weil die Sommerferien WebUntis-intern zwischen zwei
|
||||
// Schuljahres-Datensätzen liegen (1.8./31.7.-Grenze), nicht "in" einem davon. Für den
|
||||
// Schuljahresbeginn bleibt deshalb weiterhin der früheste Fehlzeiten-Eintrag der Klasse
|
||||
// nötig statt CountSchoolWeekdays allein.
|
||||
var absences = new[]
|
||||
{
|
||||
new ClassAbsenceDaySummaryRow(new DateOnly(2026, 8, 13), "Fehlt Cem", 1001, 1, 45,
|
||||
["Deu"], [1], ["nicht entsch."], ["Absent"], null, null, false),
|
||||
new ClassAbsenceDaySummaryRow(new DateOnly(2026, 8, 19), "Andere Ada", 1002, 1, 45,
|
||||
["Deu"], [1], ["entsch."], ["Absent"], null, null, false),
|
||||
};
|
||||
|
||||
var termStart = ClassTeacherOverviewViewModel.EstimateTermStart(absences, new DateOnly(2026, 8, 1));
|
||||
|
||||
Assert.Equal(new DateOnly(2026, 8, 13), termStart);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EstimateTermStart_FaelltOhneFehlzeitenAufSchuljahresbeginnZurueck()
|
||||
{
|
||||
var termStart = ClassTeacherOverviewViewModel.EstimateTermStart([], new DateOnly(2026, 8, 1));
|
||||
|
||||
Assert.Equal(new DateOnly(2026, 8, 1), termStart);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CountSchoolWeekdays_AbEchtemTerminstartOhneSommerferienEintragStimmtMitBeobachtungUeberein()
|
||||
{
|
||||
// Regressionstest für den konkret gemeldeten Fall: 13 tatsächliche Schultage seit
|
||||
// Unterrichtsbeginn (13.08.2026, ein Donnerstag) bis 31.08.2026, keine WebUntis-Ferien im
|
||||
// Bereich (die nächste, "Herbstferien", liegt erst im Oktober) - Kombination aus
|
||||
// EstimateTermStart (Sommerferien-Lücke) und CountSchoolWeekdays (übrige Ferien) muss auf
|
||||
// die vom Nutzer nachgezählte Zahl kommen, nicht auf die volle Werktagszahl ab 1.8. (21).
|
||||
var termStart = new DateOnly(2026, 8, 13);
|
||||
var noHolidaysInRange = Array.Empty<CachedUntisHoliday>();
|
||||
|
||||
var schoolDays = ClassTeacherOverviewViewModel.CountSchoolWeekdays(
|
||||
termStart, new DateOnly(2026, 8, 31), noHolidaysInRange);
|
||||
|
||||
Assert.Equal(13, schoolDays);
|
||||
}
|
||||
|
||||
// ── Offene Entschuldigungen (Feature-Idee 2) ─────────────────────────────
|
||||
|
||||
[Fact]
|
||||
@@ -290,6 +577,87 @@ public sealed class ClassTeacherViewModelsTests
|
||||
Assert.False(rows[1].IsOverdue);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OpenExcuseBuild_IgnoriertUnentschuldigteVerspaetungen()
|
||||
{
|
||||
// Verspätungen sind i.d.R. nicht entschuldigungsfähig - "unentschuldigt" darf hier keine
|
||||
// Erinnerung an eine fehlende Entschuldigung auslösen (Nutzer-Feedback).
|
||||
var absences = new[]
|
||||
{
|
||||
new ClassAbsenceDaySummaryRow(new DateOnly(2026, 8, 25), "Neu Ben", 1002, 0, 15,
|
||||
["Deu"], [1], ["nicht entsch."], ["Verspätung"], null, null, false),
|
||||
new ClassAbsenceDaySummaryRow(new DateOnly(2026, 8, 24), "Fehlt Cem", 1003, 1, 45,
|
||||
["Deu"], [1], ["nicht entsch."], ["Absent"], null, null, false),
|
||||
};
|
||||
|
||||
var rows = ClassTeacherOpenExcuseRow.Build(absences, new DateOnly(2026, 8, 26));
|
||||
|
||||
Assert.Equal(["Cem Fehlt"], rows.Select(r => r.StudentName));
|
||||
}
|
||||
|
||||
// ── Verspätungsmuster: Elterngespräch-Eskalation (Nutzer-Feedback) ───────
|
||||
|
||||
[Fact]
|
||||
public void DetectLatePatterns_EskaliertBeiHoherJahressummeMitWiedervorlageOption()
|
||||
{
|
||||
var absences = Enumerable.Range(0, 5)
|
||||
.Select(i => new ClassAbsenceDaySummaryRow(new DateOnly(2026, 8, 3 + i), "Spaet Timo", 1001, 0, 10,
|
||||
["Deu"], [1], ["entsch."], ["Verspätung"], null, null, false))
|
||||
.ToArray();
|
||||
var names = new Dictionary<string, string> { [UntisNameMatching.NameKey("Spaet Timo")] = "Timo Spät" };
|
||||
|
||||
var notices = ClassTeacherOverviewViewModel.DetectLatePatterns(
|
||||
absences, names, sevenDayStart: new DateOnly(2026, 8, 31));
|
||||
|
||||
var notice = Assert.Single(notices);
|
||||
Assert.Equal("Timo Spät", notice.StudentName);
|
||||
Assert.Contains("5 Verspätungen seit Schuljahresbeginn", notice.Message);
|
||||
Assert.Equal(ClassTeacherStatusKind.Danger, notice.Kind);
|
||||
Assert.True(notice.CanCreateReminder);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DetectLatePatterns_KurzfristigesClusterBleibtHinweisOhneAktion()
|
||||
{
|
||||
var absences = new[]
|
||||
{
|
||||
new ClassAbsenceDaySummaryRow(new DateOnly(2026, 8, 24), "Spaet Timo", 1001, 0, 10,
|
||||
["Deu"], [1], ["entsch."], ["Verspätung"], null, null, false),
|
||||
new ClassAbsenceDaySummaryRow(new DateOnly(2026, 8, 25), "Spaet Timo", 1001, 0, 10,
|
||||
["Deu"], [1], ["entsch."], ["Verspätung"], null, null, false),
|
||||
};
|
||||
var names = new Dictionary<string, string> { [UntisNameMatching.NameKey("Spaet Timo")] = "Timo Spät" };
|
||||
|
||||
var notices = ClassTeacherOverviewViewModel.DetectLatePatterns(
|
||||
absences, names, sevenDayStart: new DateOnly(2026, 8, 20));
|
||||
|
||||
var notice = Assert.Single(notices);
|
||||
Assert.Equal("2-mal verspätet in 7 Tagen", notice.Message);
|
||||
Assert.Equal(ClassTeacherStatusKind.Warning, notice.Kind);
|
||||
Assert.False(notice.CanCreateReminder);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DetectLatePatterns_UnentschuldigteFehltageGehenVorVerspaetungseskalation()
|
||||
{
|
||||
var absences = new[]
|
||||
{
|
||||
new ClassAbsenceDaySummaryRow(new DateOnly(2026, 8, 24), "Fehlt Cem", 1001, 1, 45,
|
||||
["Deu"], [1], ["nicht entsch."], ["Absent"], null, null, false),
|
||||
new ClassAbsenceDaySummaryRow(new DateOnly(2026, 8, 25), "Fehlt Cem", 1001, 1, 45,
|
||||
["Deu"], [1], ["nicht entsch."], ["Absent"], null, null, false),
|
||||
};
|
||||
var names = new Dictionary<string, string> { [UntisNameMatching.NameKey("Fehlt Cem")] = "Cem Fehlt" };
|
||||
|
||||
var notices = ClassTeacherOverviewViewModel.DetectLatePatterns(
|
||||
absences, names, sevenDayStart: new DateOnly(2026, 8, 20));
|
||||
|
||||
var notice = Assert.Single(notices);
|
||||
Assert.Contains("unentschuldigte Fehltage", notice.Message);
|
||||
Assert.Equal(ClassTeacherStatusKind.Danger, notice.Kind);
|
||||
Assert.False(notice.CanCreateReminder);
|
||||
}
|
||||
|
||||
// ── Aggregierte Klassenbuch-Kategorien (Feature-Idee 5) ──────────────────
|
||||
|
||||
[Fact]
|
||||
@@ -409,4 +777,306 @@ public sealed class ClassTeacherViewModelsTests
|
||||
|
||||
private static UntisStudentRosterCacheEntry Student(int? externKey, string displayName) =>
|
||||
new() { ClassName = "6a", ExternKey = externKey, DisplayName = displayName };
|
||||
|
||||
// ── Klassenbuch-Tab als Dokumentations-Hub: eigene Dokumentation neben WebUntis ─────────────
|
||||
|
||||
[Fact]
|
||||
public void FilterOwnDocumentation_ZeigtNurEintraegeZugeordneterSchuelerImZeitraum()
|
||||
{
|
||||
var ada = Guid.NewGuid();
|
||||
var fremd = Guid.NewGuid();
|
||||
var rosterMatches = new List<(Guid StudentId, string DisplayName)> { (ada, "Ada Müller") };
|
||||
var docs = new List<Documentation>
|
||||
{
|
||||
new() { StudentId = ada, Date = new DateOnly(2026, 8, 20), Title = "Im Zeitraum" },
|
||||
new() { StudentId = ada, Date = new DateOnly(2026, 7, 1), Title = "Vor dem Zeitraum" },
|
||||
new() { StudentId = fremd, Date = new DateOnly(2026, 8, 20), Title = "Anderer Schüler" },
|
||||
new() { StudentId = ada, Date = new DateOnly(2026, 8, 21), Title = "Gelöscht", IsDeleted = true },
|
||||
};
|
||||
|
||||
var result = ClassTeacherDetailsViewModel.FilterOwnDocumentation(docs, rosterMatches,
|
||||
new DateOnly(2026, 8, 1), new DateOnly(2026, 8, 31), "");
|
||||
|
||||
Assert.Equal(["Im Zeitraum"], result.Select(d => d.Title));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FilterOwnDocumentation_SortiertEntwuerfeVorNeuestenZuerst()
|
||||
{
|
||||
var id = Guid.NewGuid();
|
||||
var rosterMatches = new List<(Guid StudentId, string DisplayName)> { (id, "Ada Müller") };
|
||||
var docs = new List<Documentation>
|
||||
{
|
||||
new() { StudentId = id, Date = new DateOnly(2026, 8, 25), Title = "Neu, fertig" },
|
||||
new() { StudentId = id, Date = new DateOnly(2026, 8, 10), Title = "Alt, Entwurf", IsDraft = true },
|
||||
new() { StudentId = id, Date = new DateOnly(2026, 8, 20), Title = "Mittel, fertig" },
|
||||
};
|
||||
|
||||
var result = ClassTeacherDetailsViewModel.FilterOwnDocumentation(docs, rosterMatches,
|
||||
new DateOnly(2026, 8, 1), new DateOnly(2026, 8, 31), "");
|
||||
|
||||
Assert.Equal(["Alt, Entwurf", "Neu, fertig", "Mittel, fertig"], result.Select(d => d.Title));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FilterOwnDocumentation_SchuelerfilterErkenntVertauschteReihenfolge()
|
||||
{
|
||||
// Derselbe Namensabgleich wie bei den WebUntis-Klassenbuchzeilen (siehe
|
||||
// RosterBuild_ErkenntNamenAuchInVertauschterReihenfolge) - der Filter kommt aus der
|
||||
// Übersicht als "Nachname Vorname" oder "Vorname Nachname" je nach Bericht.
|
||||
var ada = Guid.NewGuid();
|
||||
var ben = Guid.NewGuid();
|
||||
var rosterMatches = new List<(Guid StudentId, string DisplayName)>
|
||||
{
|
||||
(ada, "Ada Müller"), (ben, "Ben Schmidt"),
|
||||
};
|
||||
var docs = new List<Documentation>
|
||||
{
|
||||
new() { StudentId = ada, Date = new DateOnly(2026, 8, 20), Title = "Ada" },
|
||||
new() { StudentId = ben, Date = new DateOnly(2026, 8, 20), Title = "Ben" },
|
||||
};
|
||||
|
||||
var result = ClassTeacherDetailsViewModel.FilterOwnDocumentation(docs, rosterMatches,
|
||||
new DateOnly(2026, 8, 1), new DateOnly(2026, 8, 31), "Müller Ada");
|
||||
|
||||
Assert.Equal(["Ada"], result.Select(d => d.Title));
|
||||
}
|
||||
|
||||
// ── Vorgang: Fallmappe für Klassenbuch- und Dokumentationseinträge ───────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void BuildClassRegisterSnapshot_KopiertAlleFelderDerZeile()
|
||||
{
|
||||
var row = new ClassTeacherClassRegisterRow(new DateOnly(2026, 8, 20), "Deu", "Schmidt Ben",
|
||||
"mueller", "Fehlende HA", "Negativ", "Buch vergessen");
|
||||
|
||||
var snapshot = ClassTeacherDetailsViewModel.BuildClassRegisterSnapshot(row);
|
||||
|
||||
Assert.Equal(new DateOnly(2026, 8, 20), snapshot.Date);
|
||||
Assert.Equal("Schmidt Ben", snapshot.StudentName);
|
||||
Assert.Equal("Deu", snapshot.Subject);
|
||||
Assert.Equal("mueller", snapshot.TeacherUsername);
|
||||
Assert.Equal("Fehlende HA", snapshot.CategoryName);
|
||||
Assert.Equal("Negativ", snapshot.CategoryGroup);
|
||||
Assert.Equal("Buch vergessen", snapshot.Text);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FilterAvailableDocumentation_ZeigtNurUnverknuepfteDokumentationDerVorgangsSchueler()
|
||||
{
|
||||
var ada = Guid.NewGuid();
|
||||
var ben = Guid.NewGuid();
|
||||
var fremd = Guid.NewGuid();
|
||||
var linked = new Documentation { StudentId = ada, Title = "Schon verknüpft" };
|
||||
var unlinked = new Documentation { StudentId = ada, Title = "Noch offen" };
|
||||
var otherStudentInCase = new Documentation { StudentId = ben, Title = "Ben, offen" };
|
||||
var unrelated = new Documentation { StudentId = fremd, Title = "Anderer Schüler" };
|
||||
var vorgang = new Vorgang { StudentIds = [ada, ben], DocumentationIds = [linked.Id] };
|
||||
var allDocs = new List<Documentation> { linked, unlinked, otherStudentInCase, unrelated };
|
||||
|
||||
var result = ClassTeacherCasesViewModel.FilterAvailableDocumentation(allDocs, vorgang);
|
||||
|
||||
Assert.Equal(["Noch offen", "Ben, offen"], result.Select(d => d.Title));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FilterAvailableDocumentation_LeerOhneUnverknuepfteEintraege()
|
||||
{
|
||||
var ada = Guid.NewGuid();
|
||||
var doc = new Documentation { StudentId = ada, Title = "Verknüpft" };
|
||||
var vorgang = new Vorgang { StudentIds = [ada], DocumentationIds = [doc.Id] };
|
||||
|
||||
var result = ClassTeacherCasesViewModel.FilterAvailableDocumentation([doc], vorgang);
|
||||
|
||||
Assert.Empty(result);
|
||||
}
|
||||
|
||||
// ── Konfigurierbare Gewichtung + Dämpfung/Rückfall-Warnung nach geschlossenem Vorgang ─────────
|
||||
|
||||
[Fact]
|
||||
public void MatchDomains_ErkenntBereicheAnSchluesselwoertern()
|
||||
{
|
||||
Assert.Equal([VorgangScoreDomain.Attendance], ClassTeacherRosterRow.MatchDomains(["Absentismus"]));
|
||||
Assert.Equal([VorgangScoreDomain.Lateness], ClassTeacherRosterRow.MatchDomains(["Verspätungen"]));
|
||||
Assert.Equal([VorgangScoreDomain.Classbook], ClassTeacherRosterRow.MatchDomains(["Konflikte"]));
|
||||
Assert.Equal([VorgangScoreDomain.Classbook], ClassTeacherRosterRow.MatchDomains(["Mitarbeit"]));
|
||||
Assert.Equal([VorgangScoreDomain.Classbook], ClassTeacherRosterRow.MatchDomains(["Hausaufgaben"]));
|
||||
Assert.Equal([VorgangScoreDomain.Classbook], ClassTeacherRosterRow.MatchDomains(["Eskalation"]));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MatchDomains_OhneErkanntesSchlagwortDaemptAlleBereiche()
|
||||
{
|
||||
// Nutzer-Entscheidung: ein Vorgang darf beim Schließen nie folgenlos bleiben, auch wenn sein
|
||||
// Schlagwort (z.B. "Elternkontakt") keinem bekannten Score-Bereich zugeordnet werden kann.
|
||||
var domains = ClassTeacherRosterRow.MatchDomains(["Elternkontakt"]);
|
||||
|
||||
Assert.Equal(3, domains.Count);
|
||||
Assert.Contains(VorgangScoreDomain.Attendance, domains);
|
||||
Assert.Contains(VorgangScoreDomain.Lateness, domains);
|
||||
Assert.Contains(VorgangScoreDomain.Classbook, domains);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComputeDampeningCutoffs_NimmtJeBereichDasJuengsteSchliessDatum()
|
||||
{
|
||||
var closed = new List<Vorgang>
|
||||
{
|
||||
new() { Tags = ["Absentismus"], Status = VorgangStatus.Closed, ClosedAt = new DateTime(2026, 8, 10) },
|
||||
new() { Tags = ["Absentismus"], Status = VorgangStatus.Closed, ClosedAt = new DateTime(2026, 8, 20) },
|
||||
new() { Tags = ["Verspätungen"], Status = VorgangStatus.Closed, ClosedAt = new DateTime(2026, 8, 5) },
|
||||
};
|
||||
|
||||
var cutoffs = ClassTeacherRosterRow.ComputeDampeningCutoffs(closed);
|
||||
|
||||
Assert.Equal(new DateOnly(2026, 8, 20), cutoffs[VorgangScoreDomain.Attendance]);
|
||||
Assert.Equal(new DateOnly(2026, 8, 5), cutoffs[VorgangScoreDomain.Lateness]);
|
||||
Assert.False(cutoffs.ContainsKey(VorgangScoreDomain.Classbook));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComputeDampeningCutoffs_IgnoriertVorgaengeOhneSchliessDatum()
|
||||
{
|
||||
var closed = new List<Vorgang> { new() { Tags = ["Absentismus"], Status = VorgangStatus.Open } };
|
||||
|
||||
var cutoffs = ClassTeacherRosterRow.ComputeDampeningCutoffs(closed);
|
||||
|
||||
Assert.Empty(cutoffs);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PatternScore_BenutztUebergebeneGewichteStattFesterKonstanten()
|
||||
{
|
||||
var student = Student(1001, "Ada Müller");
|
||||
var yearAbsences = new[]
|
||||
{
|
||||
new ClassAbsenceDaySummaryRow(new DateOnly(2026, 8, 10), "Müller Ada", 1001, 6, 270,
|
||||
["Deu"], [1, 2, 3, 4, 5, 6], ["nicht entsch."], ["Absent"], null, null, true),
|
||||
};
|
||||
var customWeights = new PatternScoreWeights { UnexcusedDayWeight = 10.0 };
|
||||
|
||||
var row = Assert.Single(ClassTeacherRosterRow.Build([student], [], [],
|
||||
new DateOnly(2026, 8, 26), yearAbsences, schoolDaysElapsed: 20, termStart: new DateOnly(2026, 8, 1),
|
||||
weights: customWeights));
|
||||
|
||||
Assert.Equal(10.0, row.PatternScore);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PatternScore_DaempftNurDenZumVorgangPassendenBereichNachAbschluss()
|
||||
{
|
||||
var student = Student(1001, "Ada Müller");
|
||||
var yearAbsences = new[]
|
||||
{
|
||||
// Vor dem Abschluss: darf für den Score nicht mehr zählen.
|
||||
new ClassAbsenceDaySummaryRow(new DateOnly(2026, 8, 5), "Müller Ada", 1001, 6, 270,
|
||||
["Deu"], [1, 2, 3, 4, 5, 6], ["nicht entsch."], ["Absent"], null, null, true),
|
||||
// Nach dem Abschluss: zählt weiter.
|
||||
new ClassAbsenceDaySummaryRow(new DateOnly(2026, 8, 20), "Müller Ada", 1001, 6, 270,
|
||||
["Deu"], [1, 2, 3, 4, 5, 6], ["nicht entsch."], ["Absent"], null, null, true),
|
||||
// Verspätung, anderer Bereich: bleibt von der auf "Absentismus" beschränkten Dämpfung unberührt.
|
||||
new ClassAbsenceDaySummaryRow(new DateOnly(2026, 8, 1), "Müller Ada", 1001, 0, 5,
|
||||
["Deu"], [1], ["entsch."], ["Verspätung"], null, null, false),
|
||||
};
|
||||
var closedVorgaengeByNameKey = new Dictionary<string, IReadOnlyList<Vorgang>>
|
||||
{
|
||||
[UntisNameMatching.NameKey("Ada Müller")] = [new Vorgang
|
||||
{
|
||||
Title = "Schuleschwänzen", Tags = ["Absentismus"], Status = VorgangStatus.Closed,
|
||||
ClosedAt = new DateTime(2026, 8, 12),
|
||||
}],
|
||||
};
|
||||
|
||||
var row = Assert.Single(ClassTeacherRosterRow.Build([student], [], [],
|
||||
new DateOnly(2026, 8, 26), yearAbsences, schoolDaysElapsed: 20, termStart: new DateOnly(2026, 8, 1),
|
||||
closedVorgaengeByNameKey: closedVorgaengeByNameKey));
|
||||
|
||||
// 1 unentschuldigter Tag nach Abschluss (3,0) + 1 unbeeinflusster Verspätungstag (0,5) = 3,5.
|
||||
Assert.Equal(3.5, row.PatternScore);
|
||||
// Die Quote bleibt von der Dämpfung unberührt: beide unentschuldigten Tage zählen weiter.
|
||||
Assert.Equal(2, row.YearUnexcusedDayCount);
|
||||
Assert.Equal(2, row.YearAbsenceDayCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PatternScore_VorgangOhneErkennbaresSchlagwortDaemptAlleBereiche()
|
||||
{
|
||||
var student = Student(1001, "Ada Müller");
|
||||
var yearAbsences = new[]
|
||||
{
|
||||
new ClassAbsenceDaySummaryRow(new DateOnly(2026, 8, 5), "Müller Ada", 1001, 0, 5,
|
||||
["Deu"], [1], ["entsch."], ["Verspätung"], null, null, false),
|
||||
};
|
||||
var closedVorgaengeByNameKey = new Dictionary<string, IReadOnlyList<Vorgang>>
|
||||
{
|
||||
[UntisNameMatching.NameKey("Ada Müller")] = [new Vorgang
|
||||
{
|
||||
Title = "Elterngespräch", Tags = ["Elternkontakt"], Status = VorgangStatus.Closed,
|
||||
ClosedAt = new DateTime(2026, 8, 12),
|
||||
}],
|
||||
};
|
||||
|
||||
var row = Assert.Single(ClassTeacherRosterRow.Build([student], [], [],
|
||||
new DateOnly(2026, 8, 26), yearAbsences, schoolDaysElapsed: 20, termStart: new DateOnly(2026, 8, 1),
|
||||
closedVorgaengeByNameKey: closedVorgaengeByNameKey));
|
||||
|
||||
Assert.Equal(0.0, row.PatternScore);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RegressionScore_IstNullOhneNeueEreignisseNachAbschluss()
|
||||
{
|
||||
var student = Student(1001, "Ada Müller");
|
||||
var yearAbsences = new[]
|
||||
{
|
||||
new ClassAbsenceDaySummaryRow(new DateOnly(2026, 8, 5), "Müller Ada", 1001, 6, 270,
|
||||
["Deu"], [1, 2, 3, 4, 5, 6], ["nicht entsch."], ["Absent"], null, null, true),
|
||||
};
|
||||
var closedVorgaengeByNameKey = new Dictionary<string, IReadOnlyList<Vorgang>>
|
||||
{
|
||||
[UntisNameMatching.NameKey("Ada Müller")] = [new Vorgang
|
||||
{
|
||||
Title = "Schuleschwänzen", Tags = ["Absentismus"], Status = VorgangStatus.Closed,
|
||||
ClosedAt = new DateTime(2026, 8, 12),
|
||||
}],
|
||||
};
|
||||
|
||||
var row = Assert.Single(ClassTeacherRosterRow.Build([student], [], [],
|
||||
new DateOnly(2026, 8, 26), yearAbsences, schoolDaysElapsed: 20, termStart: new DateOnly(2026, 8, 1),
|
||||
closedVorgaengeByNameKey: closedVorgaengeByNameKey));
|
||||
|
||||
Assert.Equal(0.0, row.RegressionScore);
|
||||
Assert.Equal("Schuleschwänzen", row.RegressionVorgangTitle);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RegressionScore_ErkenntNeueUnentschuldigteTageNachAbschluss()
|
||||
{
|
||||
var student = Student(1001, "Ada Müller");
|
||||
var yearAbsences = new[]
|
||||
{
|
||||
new ClassAbsenceDaySummaryRow(new DateOnly(2026, 8, 5), "Müller Ada", 1001, 6, 270,
|
||||
["Deu"], [1, 2, 3, 4, 5, 6], ["nicht entsch."], ["Absent"], null, null, true),
|
||||
// Nach Abschluss erneut unentschuldigt gefehlt.
|
||||
new ClassAbsenceDaySummaryRow(new DateOnly(2026, 8, 20), "Müller Ada", 1001, 6, 270,
|
||||
["Deu"], [1, 2, 3, 4, 5, 6], ["nicht entsch."], ["Absent"], null, null, true),
|
||||
};
|
||||
var closedVorgaengeByNameKey = new Dictionary<string, IReadOnlyList<Vorgang>>
|
||||
{
|
||||
[UntisNameMatching.NameKey("Ada Müller")] = [new Vorgang
|
||||
{
|
||||
Title = "Schuleschwänzen", Tags = ["Absentismus"], Status = VorgangStatus.Closed,
|
||||
ClosedAt = new DateTime(2026, 8, 12),
|
||||
}],
|
||||
};
|
||||
|
||||
var row = Assert.Single(ClassTeacherRosterRow.Build([student], [], [],
|
||||
new DateOnly(2026, 8, 26), yearAbsences, schoolDaysElapsed: 20, termStart: new DateOnly(2026, 8, 1),
|
||||
closedVorgaengeByNameKey: closedVorgaengeByNameKey));
|
||||
|
||||
Assert.Equal(3.0, row.RegressionScore);
|
||||
Assert.Equal("Schuleschwänzen", row.RegressionVorgangTitle);
|
||||
// Quote weiterhin unangetastet.
|
||||
Assert.Equal(2, row.YearUnexcusedDayCount);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
using System.IO.Compression;
|
||||
using LehrerApp.Core.Models;
|
||||
using LehrerApp.Core.Services;
|
||||
using LehrerApp.Desktop.ViewModels.Students;
|
||||
using LehrerApp.Templating;
|
||||
using Xunit;
|
||||
|
||||
namespace LehrerApp.Desktop.Tests;
|
||||
@@ -9,16 +8,12 @@ namespace LehrerApp.Desktop.Tests;
|
||||
public sealed class CreateLetterDialogViewModelTests : IDisposable
|
||||
{
|
||||
private readonly string _directory = Path.Combine(Path.GetTempPath(), $"lehrerapp-letter-vm-tests-{Guid.NewGuid():N}");
|
||||
|
||||
public CreateLetterDialogViewModelTests() => Directory.CreateDirectory(_directory);
|
||||
|
||||
[Fact]
|
||||
public void OhneVorlage_KannKeinenBriefErzeugen()
|
||||
{
|
||||
var student = StudentWithContact("Sehr geehrte Frau Muster,");
|
||||
|
||||
var vm = Build(student, new LetterTemplateService(_directory));
|
||||
|
||||
var vm = Build(StudentWithContact("Sehr geehrte Frau Muster,"), new TemplateStore(_directory));
|
||||
Assert.False(vm.CanGenerate);
|
||||
Assert.Contains(vm.Issues, i => i.Message.Contains("Vorlage", StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
@@ -26,83 +21,63 @@ public sealed class CreateLetterDialogViewModelTests : IDisposable
|
||||
[Fact]
|
||||
public void VerwendeteBriefanredeFehlt_BlockiertErzeugung()
|
||||
{
|
||||
var service = ServiceWithTemplate("Letter.Salutation");
|
||||
var vm = Build(StudentWithContact(null), service);
|
||||
|
||||
var vm = Build(StudentWithContact(null), StoreWithTemplate(
|
||||
new PlaceholderDefinition("Anrede", PlaceholderType.Text, true)));
|
||||
Assert.False(vm.CanGenerate);
|
||||
Assert.Contains(vm.Issues, i => i.Message.Contains("Briefanrede"));
|
||||
Assert.Contains(vm.Issues, i => i.Message.Contains("Anrede", StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void VollstaendigeDaten_ErzeugenEditierbareDocxKopie()
|
||||
public void VollstaendigeDaten_ErzeugenFlachesPdf()
|
||||
{
|
||||
var service = ServiceWithTemplate("Letter.Salutation", "Student.FirstName", "CurrentDate");
|
||||
var vm = Build(StudentWithContact("Sehr geehrte Frau Muster,"), service);
|
||||
var output = Path.Combine(_directory, "Brief.docx");
|
||||
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("Sehr geehrte Frau Muster,"), store);
|
||||
vm.LetterText = "Dies ist der Inhalt.";
|
||||
var output = Path.Combine(_directory, "Brief.pdf");
|
||||
|
||||
var generated = vm.Generate(output);
|
||||
|
||||
Assert.True(generated);
|
||||
Assert.True(File.Exists(output));
|
||||
using var archive = ZipFile.OpenRead(output);
|
||||
using var reader = new StreamReader(archive.GetEntry("word/document.xml")!.Open());
|
||||
var xml = reader.ReadToEnd();
|
||||
Assert.Contains("Sehr geehrte Frau Muster,", xml);
|
||||
Assert.Contains("Lena", xml);
|
||||
Assert.True(vm.Generate(output));
|
||||
Assert.Equal("%PDF", System.Text.Encoding.ASCII.GetString(File.ReadAllBytes(output), 0, 4));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void KontaktBearbeiten_SpeichertExpliziteBriefanrede()
|
||||
public void KonstanterPflichttext_BenoetigtKeineExterneEingabe()
|
||||
{
|
||||
var vm = new ContactEditDialogViewModel(new Contact { Name = "Frau Muster" });
|
||||
vm.LetterSalutation = "Sehr geehrte Frau Muster,";
|
||||
var store = StoreWithTemplate(new PlaceholderDefinition("Brieftext", PlaceholderType.Multiline,
|
||||
Required: true, IsConstant: true, ConstantValue: "Fest im Vorlagenpaket"));
|
||||
var vm = Build(StudentWithContact("Sehr geehrte Frau Muster,"), store);
|
||||
var output = Path.Combine(_directory, "Konstant.pdf");
|
||||
|
||||
vm.SaveCommand.Execute(null);
|
||||
|
||||
Assert.Equal("Sehr geehrte Frau Muster,", vm.Result!.LetterSalutation);
|
||||
Assert.True(vm.CanGenerate);
|
||||
Assert.True(vm.Generate(output));
|
||||
Assert.Equal("%PDF", System.Text.Encoding.ASCII.GetString(File.ReadAllBytes(output), 0, 4));
|
||||
}
|
||||
|
||||
private CreateLetterDialogViewModel Build(Student student, LetterTemplateService service) =>
|
||||
new(student, service, new FakeMemberships([]), new FakeGroups([]));
|
||||
private CreateLetterDialogViewModel Build(Student student, TemplateStore store) =>
|
||||
new(student, store, new QuestTemplateRenderer(), new FakeMemberships([]), new FakeGroups([]));
|
||||
|
||||
private LetterTemplateService ServiceWithTemplate(params string[] tags)
|
||||
private TemplateStore StoreWithTemplate(params PlaceholderDefinition[] definitions)
|
||||
{
|
||||
var source = Path.Combine(_directory, $"{Guid.NewGuid():N}.docx");
|
||||
using (var archive = ZipFile.Open(source, ZipArchiveMode.Create))
|
||||
var source = Path.Combine(_directory, $"{Guid.NewGuid():N}.lavorlage");
|
||||
var manifest = new TemplateManifest { Id = $"brief-{Guid.NewGuid():N}", Name = "Elternbrief", Placeholders = [.. definitions] };
|
||||
var lines = new List<string> { "PAGE 210 297 mm" };
|
||||
var y = 20;
|
||||
foreach (var definition in definitions)
|
||||
{
|
||||
var entry = archive.CreateEntry("word/document.xml");
|
||||
using var writer = new StreamWriter(entry.Open());
|
||||
var controls = string.Join("", tags.Select(tag =>
|
||||
$"<w:sdt><w:sdtPr><w:tag w:val=\"{tag}\"/></w:sdtPr><w:sdtContent>" +
|
||||
"<w:p><w:r><w:t>Platzhalter</w:t></w:r></w:p></w:sdtContent></w:sdt>"));
|
||||
writer.Write("<w:document xmlns:w=\"http://schemas.openxmlformats.org/wordprocessingml/2006/main\">" +
|
||||
$"<w:body>{controls}</w:body></w:document>");
|
||||
var element = definition.Type == PlaceholderType.Multiline ? "TEXTBOX" : "TEXT";
|
||||
lines.Add(element == "TEXTBOX" ? $"TEXTBOX 20 {y} 170 80 ${definition.Name}" : $"TEXT 20 {y} ${definition.Name}");
|
||||
y += 20;
|
||||
}
|
||||
var service = new LetterTemplateService(Path.Combine(_directory, $"service-{Guid.NewGuid():N}"));
|
||||
service.Import(source, "Elternbrief");
|
||||
return service;
|
||||
TemplatePackage.Create(source, manifest, string.Join('\n', lines), new Dictionary<string, byte[]>());
|
||||
var store = new TemplateStore(Path.Combine(_directory, $"store-{Guid.NewGuid():N}")); store.Import(source); return store;
|
||||
}
|
||||
|
||||
private static Student StudentWithContact(string? salutation) => new()
|
||||
{
|
||||
FirstName = "Lena",
|
||||
LastName = "Beispiel",
|
||||
Contacts =
|
||||
[
|
||||
new Contact
|
||||
{
|
||||
Name = "Frau Muster",
|
||||
Relation = "Mutter",
|
||||
LetterSalutation = salutation,
|
||||
Street = "Hauptstraße 1",
|
||||
PostalCode = "12345",
|
||||
City = "Musterstadt",
|
||||
},
|
||||
],
|
||||
FirstName = "Lena", LastName = "Beispiel",
|
||||
Contacts = [new Contact { Name = "Frau Muster", Relation = "Mutter", LetterSalutation = salutation,
|
||||
Street = "Hauptstraße 1", PostalCode = "12345", City = "Musterstadt" }],
|
||||
};
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (Directory.Exists(_directory)) Directory.Delete(_directory, recursive: true);
|
||||
}
|
||||
public void Dispose() { if (Directory.Exists(_directory)) Directory.Delete(_directory, true); }
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using LehrerApp.Core.Models;
|
||||
using LehrerApp.Core.Services;
|
||||
using LehrerApp.Desktop.ViewModels;
|
||||
using System.Globalization;
|
||||
using Xunit;
|
||||
|
||||
namespace LehrerApp.Desktop.Tests;
|
||||
@@ -59,12 +60,13 @@ public sealed class DashboardViewModelTests
|
||||
DashboardSettingsService? dashboardSettings = null, FakeSchoolHolidays? schoolHolidays = null,
|
||||
FakeLessons? lessons = null, FakeSubstitutionEntries? substitutions = null,
|
||||
FakeSessions? sessions = null, FakeEntries? entries = null,
|
||||
FakeAnnualPlanEvents? annualPlanEvents = null)
|
||||
FakeAnnualPlanEvents? annualPlanEvents = null, List<LearningGroup>? allGroups = null,
|
||||
FakeTimeEntries? timeEntries = null)
|
||||
{
|
||||
lessons ??= new FakeLessons();
|
||||
lessons.Add(lesson);
|
||||
return new DashboardViewModel(
|
||||
new FakeGroups([group]), new FakeSubjects([]), lessons,
|
||||
new FakeGroups(allGroups ?? [group]), new FakeSubjects([]), lessons,
|
||||
exams ?? new FakeExams([]), results ?? new FakeResults(), grades ?? new FakeGrades(),
|
||||
reportGrades ?? new FakeReportGrades(), memberships ?? new FakeMemberships([]),
|
||||
tasks ?? new FakeWorkTasks(), sessions ?? new FakeSessions([]), entries ?? new FakeEntries(),
|
||||
@@ -72,7 +74,182 @@ public sealed class DashboardViewModelTests
|
||||
slots ?? new FakeTimetableSlots(), periodSchedule ?? NewPeriodSchedule(),
|
||||
new AttendanceBalanceService(), new SchoolYearService(), dashboardSettings ?? NewDashboardSettings(),
|
||||
schoolHolidays ?? new FakeSchoolHolidays(), new PublicHolidayService(), NewCalendarSettings(),
|
||||
substitutions ?? new FakeSubstitutionEntries(), annualPlanEvents);
|
||||
substitutions ?? new FakeSubstitutionEntries(), timeEntries ?? new FakeTimeEntries(), annualPlanEvents);
|
||||
}
|
||||
|
||||
/// Montag einer Woche, die garantiert in der Zukunft liegt und innerhalb der
|
||||
/// Klausurwochen-Vorausschau — unabhängig davon, welcher Wochentag "heute" gerade ist.
|
||||
private static DateOnly NextIsoWeekMonday()
|
||||
{
|
||||
var reference = DateTime.Today.AddDays(7);
|
||||
return DateOnly.FromDateTime(
|
||||
ISOWeek.ToDateTime(ISOWeek.GetYear(reference), ISOWeek.GetWeekOfYear(reference), DayOfWeek.Monday));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ExamWeekLoads_DreiGeplanteKlausurenInDerselbenWoche_ErzeugtEintrag()
|
||||
{
|
||||
var group = new LearningGroup { Name = "9c" };
|
||||
var today = DateOnly.FromDateTime(DateTime.Today);
|
||||
var monday = NextIsoWeekMonday();
|
||||
var exams = new FakeExams([
|
||||
new Exam { GroupId = group.Id, Title = "a", Date = monday, Status = ExamStatus.Planned },
|
||||
new Exam { GroupId = group.Id, Title = "b", Date = monday.AddDays(1), Status = ExamStatus.Planned },
|
||||
new Exam { GroupId = group.Id, Title = "c", Date = monday.AddDays(2), Status = ExamStatus.Planned },
|
||||
]);
|
||||
|
||||
var vm = BuildVm(group, new Lesson { GroupId = group.Id, Date = today }, exams: exams);
|
||||
|
||||
var entry = Assert.Single(vm.ExamWeekLoads);
|
||||
Assert.Equal(3, entry.ExamCount);
|
||||
Assert.True(vm.ExamLoadCard.EffectiveIsVisible);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ExamWeekLoads_WenigeKlausurenProWoche_BleibtLeer()
|
||||
{
|
||||
var group = new LearningGroup { Name = "9c" };
|
||||
var today = DateOnly.FromDateTime(DateTime.Today);
|
||||
var monday = NextIsoWeekMonday();
|
||||
var exams = new FakeExams([
|
||||
new Exam { GroupId = group.Id, Title = "a", Date = monday, Status = ExamStatus.Planned },
|
||||
new Exam { GroupId = group.Id, Title = "b", Date = monday.AddDays(1), Status = ExamStatus.Planned },
|
||||
]);
|
||||
|
||||
var vm = BuildVm(group, new Lesson { GroupId = group.Id, Date = today }, exams: exams);
|
||||
|
||||
Assert.Empty(vm.ExamWeekLoads);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ExamWeekLoads_BereitsDurchgefuehrteKlausurenZaehlenNicht()
|
||||
{
|
||||
var group = new LearningGroup { Name = "9c" };
|
||||
var today = DateOnly.FromDateTime(DateTime.Today);
|
||||
var monday = NextIsoWeekMonday();
|
||||
var exams = new FakeExams([
|
||||
new Exam { GroupId = group.Id, Title = "a", Date = monday, Status = ExamStatus.Conducted },
|
||||
new Exam { GroupId = group.Id, Title = "b", Date = monday.AddDays(1), Status = ExamStatus.Graded },
|
||||
new Exam { GroupId = group.Id, Title = "c", Date = monday.AddDays(2), Status = ExamStatus.Planned },
|
||||
]);
|
||||
|
||||
var vm = BuildVm(group, new Lesson { GroupId = group.Id, Date = today }, exams: exams);
|
||||
|
||||
Assert.Empty(vm.ExamWeekLoads);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MissingTeachingTime_VergangenerUnterrichtstagOhneErfassung_WirdGemeldet()
|
||||
{
|
||||
var group = new LearningGroup { Name = "9c" };
|
||||
var today = DateOnly.FromDateTime(DateTime.Today);
|
||||
var pastDay = today.AddDays(-1);
|
||||
var slots = new FakeTimetableSlots();
|
||||
slots.Add(new TimetableSlot { GroupId = group.Id, Weekday = pastDay.DayOfWeek, PeriodNumber = 1 });
|
||||
var periodSchedule = NewPeriodSchedule();
|
||||
periodSchedule.SetPeriods([new PeriodTimeEntry
|
||||
{ PeriodNumber = 1, Start = new TimeOnly(8, 0), End = new TimeOnly(8, 45) }]);
|
||||
|
||||
var vm = BuildVm(group, new Lesson { GroupId = group.Id, Date = today }, slots: slots, periodSchedule: periodSchedule);
|
||||
|
||||
Assert.Contains(vm.MissingTeachingTimeEntries, i => i.Date == pastDay);
|
||||
Assert.True(vm.MissingTeachingTimeCard.EffectiveIsVisible);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MissingTeachingTime_BereitsErfassterTag_WirdNichtGemeldet()
|
||||
{
|
||||
var group = new LearningGroup { Name = "9c" };
|
||||
var today = DateOnly.FromDateTime(DateTime.Today);
|
||||
var pastDay = today.AddDays(-1);
|
||||
var slots = new FakeTimetableSlots();
|
||||
slots.Add(new TimetableSlot { GroupId = group.Id, Weekday = pastDay.DayOfWeek, PeriodNumber = 1 });
|
||||
var periodSchedule = NewPeriodSchedule();
|
||||
periodSchedule.SetPeriods([new PeriodTimeEntry
|
||||
{ PeriodNumber = 1, Start = new TimeOnly(8, 0), End = new TimeOnly(8, 45) }]);
|
||||
var timeEntries = new FakeTimeEntries();
|
||||
timeEntries.Add(new TimeEntry { Date = pastDay, Category = "Unterricht", DurationMinutes = 45 });
|
||||
|
||||
var vm = BuildVm(group, new Lesson { GroupId = group.Id, Date = today },
|
||||
slots: slots, periodSchedule: periodSchedule, timeEntries: timeEntries);
|
||||
|
||||
Assert.DoesNotContain(vm.MissingTeachingTimeEntries, i => i.Date == pastDay);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MissingTeachingTime_KomplettAusgefallenerTag_WirdNichtGemeldet()
|
||||
{
|
||||
var group = new LearningGroup { Name = "9c" };
|
||||
var today = DateOnly.FromDateTime(DateTime.Today);
|
||||
var pastDay = today.AddDays(-1);
|
||||
var slots = new FakeTimetableSlots();
|
||||
slots.Add(new TimetableSlot { GroupId = group.Id, Weekday = pastDay.DayOfWeek, PeriodNumber = 1 });
|
||||
var periodSchedule = NewPeriodSchedule();
|
||||
periodSchedule.SetPeriods([new PeriodTimeEntry
|
||||
{ PeriodNumber = 1, Start = new TimeOnly(8, 0), End = new TimeOnly(8, 45) }]);
|
||||
var substitutions = new FakeSubstitutionEntries();
|
||||
substitutions.Add(new SubstitutionEntry { Date = pastDay, Kind = SubstitutionKind.Cancelled, PeriodNumber = 1 });
|
||||
|
||||
var vm = BuildVm(group, new Lesson { GroupId = group.Id, Date = today },
|
||||
slots: slots, periodSchedule: periodSchedule, substitutions: substitutions);
|
||||
|
||||
Assert.DoesNotContain(vm.MissingTeachingTimeEntries, i => i.Date == pastDay);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MissingTeachingTime_SchulferienTagWirdNichtGemeldet()
|
||||
{
|
||||
var group = new LearningGroup { Name = "9c" };
|
||||
var today = DateOnly.FromDateTime(DateTime.Today);
|
||||
var pastDay = today.AddDays(-1);
|
||||
var slots = new FakeTimetableSlots();
|
||||
slots.Add(new TimetableSlot { GroupId = group.Id, Weekday = pastDay.DayOfWeek, PeriodNumber = 1 });
|
||||
var periodSchedule = NewPeriodSchedule();
|
||||
periodSchedule.SetPeriods([new PeriodTimeEntry
|
||||
{ PeriodNumber = 1, Start = new TimeOnly(8, 0), End = new TimeOnly(8, 45) }]);
|
||||
var schoolHolidays = new FakeSchoolHolidays();
|
||||
schoolHolidays.Add(new SchoolHoliday { Name = "Ferien", StartDate = pastDay, EndDate = pastDay });
|
||||
|
||||
var vm = BuildVm(group, new Lesson { GroupId = group.Id, Date = today },
|
||||
slots: slots, periodSchedule: periodSchedule, schoolHolidays: schoolHolidays);
|
||||
|
||||
Assert.DoesNotContain(vm.MissingTeachingTimeEntries, i => i.Date == pastDay);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MissingTeachingTime_HeuteVorAblaufDerWartezeit_WirdNichtGemeldet()
|
||||
{
|
||||
var group = new LearningGroup { Name = "9c" };
|
||||
var today = DateOnly.FromDateTime(DateTime.Today);
|
||||
var slots = new FakeTimetableSlots();
|
||||
slots.Add(new TimetableSlot { GroupId = group.Id, Weekday = today.DayOfWeek, PeriodNumber = 1 });
|
||||
var periodSchedule = NewPeriodSchedule();
|
||||
// Unterrichtsende liegt garantiert noch keine 30 Minuten zurück.
|
||||
var futureEnd = TimeOnly.FromDateTime(DateTime.Now).AddHours(2);
|
||||
periodSchedule.SetPeriods([new PeriodTimeEntry
|
||||
{ PeriodNumber = 1, Start = futureEnd.AddHours(-1), End = futureEnd }]);
|
||||
|
||||
var vm = BuildVm(group, new Lesson { GroupId = group.Id, Date = today }, slots: slots, periodSchedule: periodSchedule);
|
||||
|
||||
Assert.DoesNotContain(vm.MissingTeachingTimeEntries, i => i.Date == today);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MissingTeachingTime_HeuteNachAblaufDerWartezeit_WirdGemeldet()
|
||||
{
|
||||
var group = new LearningGroup { Name = "9c" };
|
||||
var today = DateOnly.FromDateTime(DateTime.Today);
|
||||
var slots = new FakeTimetableSlots();
|
||||
slots.Add(new TimetableSlot { GroupId = group.Id, Weekday = today.DayOfWeek, PeriodNumber = 1 });
|
||||
var periodSchedule = NewPeriodSchedule();
|
||||
// Unterrichtsende liegt garantiert mehr als 30 Minuten zurück.
|
||||
var pastEnd = TimeOnly.FromDateTime(DateTime.Now).AddHours(-2);
|
||||
periodSchedule.SetPeriods([new PeriodTimeEntry
|
||||
{ PeriodNumber = 1, Start = pastEnd.AddHours(-1), End = pastEnd }]);
|
||||
|
||||
var vm = BuildVm(group, new Lesson { GroupId = group.Id, Date = today }, slots: slots, periodSchedule: periodSchedule);
|
||||
|
||||
Assert.Contains(vm.MissingTeachingTimeEntries, i => i.Date == today);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -242,6 +419,26 @@ public sealed class DashboardViewModelTests
|
||||
Assert.Contains(vm.SelectedDayEvents, e => e.Kind == CalendarEventKind.Exam && e.Title == "Test");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Kalenderauswahl_SortiertKurseDesTagesVorDenAlphabetischenRest()
|
||||
{
|
||||
var today = DateOnly.FromDateTime(DateTime.Today);
|
||||
var selectedDate = today.AddDays(1);
|
||||
var alphabeticallyFirst = new LearningGroup { Name = "10a" };
|
||||
var selectedDayGroup = new LearningGroup { Name = "WAT 10c" };
|
||||
var lessons = new FakeLessons();
|
||||
lessons.Add(new Lesson { GroupId = selectedDayGroup.Id, Date = selectedDate });
|
||||
var vm = BuildVm(alphabeticallyFirst,
|
||||
new Lesson { GroupId = alphabeticallyFirst.Id, Date = today }, lessons: lessons,
|
||||
allGroups: [alphabeticallyFirst, selectedDayGroup]);
|
||||
|
||||
vm.SelectCalendarDayCommand.Execute(vm.CalendarDays.Single(d => d.Date == selectedDate));
|
||||
|
||||
Assert.Equal(selectedDayGroup.Id, vm.CurrentGroups[0].GroupId);
|
||||
Assert.True(vm.CurrentGroups[0].IsOnSelectedDay);
|
||||
Assert.Equal(alphabeticallyFirst.Id, vm.CurrentGroups[1].GroupId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Kalender_ZeigtMitarbeitssitzungenAlsEigenenTermintyp()
|
||||
{
|
||||
|
||||
@@ -247,6 +247,25 @@ public sealed class DocumentationDialogViewModelTests
|
||||
Assert.Equal(groupId, vm.Result.GroupId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GesamteLerngruppe_SaveSpeichertOhneSchuelerbezug()
|
||||
{
|
||||
var groupId = Guid.NewGuid();
|
||||
var wholeGroup = new StudentOption(Guid.Empty, "Gesamte Lerngruppe");
|
||||
var vm = new DocumentationDialogViewModel(Guid.Empty, null, new FakeAttachmentStorage(),
|
||||
[wholeGroup], groupId)
|
||||
{
|
||||
SelectedStudent = wholeGroup, Title = "Klausur planen", TypeName = "Planung / Erinnerung",
|
||||
};
|
||||
|
||||
vm.SaveCommand.Execute(null);
|
||||
|
||||
Assert.NotNull(vm.Result);
|
||||
Assert.Equal(Guid.Empty, vm.Result!.StudentId);
|
||||
Assert.Equal(groupId, vm.Result.GroupId);
|
||||
Assert.Equal(DocumentationType.Planning, vm.Result.Type);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MitStudentOptions_Bearbeiten_SchuelerIstVorausgewaehlt()
|
||||
{
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
using LehrerApp.Core.Models;
|
||||
using LehrerApp.Core.Services;
|
||||
using LehrerApp.Desktop.ViewModels.Groups;
|
||||
using Xunit;
|
||||
|
||||
namespace LehrerApp.Desktop.Tests;
|
||||
|
||||
public sealed class ExamDialogViewModelTests
|
||||
{
|
||||
private static ExamDialogViewModel BuildViewModel(List<Exam> existingExams, string dateText,
|
||||
Exam? editingExam = null)
|
||||
{
|
||||
var vm = new ExamDialogViewModel(new FakeExams(existingExams), new FakeCompetencyDomains(),
|
||||
new FakeGradingKeyTemplates(), new GradingService(), Guid.NewGuid(), null, 9,
|
||||
GradingSystem.Grades1To6, "Chemie", isDifferentiated: false, editingExam, null);
|
||||
vm.DateText = dateText;
|
||||
return vm;
|
||||
}
|
||||
|
||||
private static Exam MakeExam(DateOnly date) => new() { GroupId = Guid.NewGuid(), Title = "x", Date = date };
|
||||
|
||||
[Fact]
|
||||
public void WeekLoadHint_WenigeKlausurenInDerWoche_BleibtLeer()
|
||||
{
|
||||
var monday = new DateOnly(2026, 3, 16);
|
||||
var existing = new List<Exam> { MakeExam(monday) };
|
||||
|
||||
var vm = BuildViewModel(existing, monday.AddDays(1).ToString("dd.MM.yyyy"));
|
||||
|
||||
Assert.Equal("", vm.WeekLoadHint);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WeekLoadHint_DritteKlausurInDerWoche_ZeigtHinweis()
|
||||
{
|
||||
var monday = new DateOnly(2026, 3, 16);
|
||||
var existing = new List<Exam> { MakeExam(monday), MakeExam(monday.AddDays(1)) };
|
||||
|
||||
var vm = BuildViewModel(existing, monday.AddDays(2).ToString("dd.MM.yyyy"));
|
||||
|
||||
Assert.Contains("3 Klausuren", vm.WeekLoadHint);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WeekLoadHint_BearbeiteteKlausurZaehltNichtDoppelt()
|
||||
{
|
||||
var monday = new DateOnly(2026, 3, 16);
|
||||
var editing = MakeExam(monday);
|
||||
var existing = new List<Exam> { editing, MakeExam(monday.AddDays(1)) };
|
||||
|
||||
// Nur die eigene Klausur (editing) + eine weitere -> 2 insgesamt, kein Hinweis.
|
||||
var vm = BuildViewModel(existing, monday.ToString("dd.MM.yyyy"), editing);
|
||||
|
||||
Assert.Equal("", vm.WeekLoadHint);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WeekLoadHint_UngueltigesDatum_BleibtLeer()
|
||||
{
|
||||
var vm = BuildViewModel([], "keindatum");
|
||||
|
||||
Assert.Equal("", vm.WeekLoadHint);
|
||||
}
|
||||
}
|
||||
@@ -29,6 +29,20 @@ public sealed class ExamsOverviewViewModelTests
|
||||
Assert.Equal("Neue Klausur", vm.Rows[1].Title);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LeereListe_BietetAbsprungZuLerngruppen()
|
||||
{
|
||||
var vm = BuildVm([], []);
|
||||
var navigated = false;
|
||||
vm.OnNavigateToGroups = () => navigated = true;
|
||||
|
||||
vm.LoadCommand.Execute(null);
|
||||
vm.GoToGroupsCommand.Execute(null);
|
||||
|
||||
Assert.True(vm.HasNoExams);
|
||||
Assert.True(navigated);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Load_ParallelkurseWerdenAlsGeschwisterErkannt()
|
||||
{
|
||||
|
||||
@@ -170,6 +170,7 @@ public class FakeSessions(List<ParticipationSession> all) : IParticipationSessio
|
||||
{
|
||||
public List<ParticipationSession> GetByGroup(Guid groupId) => all.Where(s => s.GroupId == groupId).ToList();
|
||||
public ParticipationSession? GetById(Guid id) => all.FirstOrDefault(s => s.Id == id);
|
||||
public List<ParticipationSession> GetAll() => all.ToList();
|
||||
public void Save(ParticipationSession session)
|
||||
{
|
||||
all.RemoveAll(s => s.Id == session.Id);
|
||||
|
||||
@@ -14,7 +14,8 @@ public sealed class GlobalSearchViewModelTests
|
||||
Assert.Collection(vm.Results,
|
||||
item => Assert.Equal(GlobalSearchAction.NewTask, item.Action),
|
||||
item => Assert.Equal(GlobalSearchAction.NewReminder, item.Action),
|
||||
item => Assert.Equal(GlobalSearchAction.NewStudent, item.Action));
|
||||
item => Assert.Equal(GlobalSearchAction.NewStudent, item.Action),
|
||||
item => Assert.Equal(GlobalSearchAction.NewGroupDocumentation, item.Action));
|
||||
Assert.Same(vm.Results[0], vm.SelectedResult);
|
||||
}
|
||||
|
||||
@@ -67,8 +68,36 @@ public sealed class GlobalSearchViewModelTests
|
||||
Assert.True(reminder);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Suche_FindetAktiveGruppeUeberFachUndZeigtFachImUntertitel()
|
||||
{
|
||||
var subject = new Subject { Name = "Wirtschaft-Arbeit-Technik", ShortName = "WAT" };
|
||||
var group = new LearningGroup { Name = "10c", SubjectId = subject.Id, SchoolYear = "2026/27", GradeLevel = 10 };
|
||||
var vm = BuildVm(groups: [group], subjects: [subject]);
|
||||
|
||||
vm.Query = "wat";
|
||||
|
||||
var result = Assert.Single(vm.Results, x => x.Kind == GlobalSearchResultKind.Group);
|
||||
Assert.Contains("WAT", result.Subtitle);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Suche_UeberspringtArchivierteSchuelerUndGruppenSamtDerenKlausuren()
|
||||
{
|
||||
var archivedGroup = new LearningGroup { Name = "Testkurs Vorjahr", IsActive = false };
|
||||
var archivedStudent = new Student { FirstName = "Test", LastName = "Archiv", IsActive = false };
|
||||
var exam = new Exam { GroupId = archivedGroup.Id, Title = "Testklausur" };
|
||||
var vm = BuildVm([archivedStudent], [archivedGroup], [exam]);
|
||||
|
||||
vm.Query = "Test";
|
||||
|
||||
Assert.DoesNotContain(vm.Results, x => x.Kind is GlobalSearchResultKind.Student
|
||||
or GlobalSearchResultKind.Group or GlobalSearchResultKind.Exam);
|
||||
}
|
||||
|
||||
private static GlobalSearchViewModel BuildVm(List<Student>? students = null,
|
||||
List<LearningGroup>? groups = null, List<Exam>? exams = null, FakeWorkTasks? tasks = null) =>
|
||||
List<LearningGroup>? groups = null, List<Exam>? exams = null, FakeWorkTasks? tasks = null,
|
||||
List<Subject>? subjects = null) =>
|
||||
new(new FakeStudents(students ?? []), new FakeGroups(groups ?? []),
|
||||
new FakeExams(exams ?? []), tasks ?? new FakeWorkTasks());
|
||||
new FakeSubjects(subjects ?? []), new FakeExams(exams ?? []), tasks ?? new FakeWorkTasks());
|
||||
}
|
||||
|
||||
@@ -18,8 +18,8 @@ public sealed class GroupDetailViewModelTests
|
||||
var grades = new FakeGrades();
|
||||
|
||||
var vm = new GroupDetailViewModel(groups, students, memberships, subjects, exams, grades, tasks,
|
||||
new GroupOverviewViewModel(new FakeLessons(), exams, new FakeSessions([]), new FakeEntries(), students,
|
||||
new FakeDocumentation(), tasks, new AttendanceBalanceService(), new SchoolYearService()),
|
||||
new GroupOverviewViewModel(new FakeLessons(), exams, new FakeResults(), new FakeSessions([]), new FakeEntries(), students,
|
||||
new FakeDocumentation(), tasks, groups, new AttendanceBalanceService(), new GradingService(), new SchoolYearService()),
|
||||
new ParticipationTabViewModel(new FakeSessions([]), new FakeEntries(), new FakeAspects(),
|
||||
students, memberships, groups, new FakeCompetencyDomains()),
|
||||
new GradeOverviewTabViewModel(grades, exams, new FakeResults(), students, memberships, new GradingService()),
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
using LehrerApp.Core.Models;
|
||||
using LehrerApp.Desktop.ViewModels.Students;
|
||||
using Xunit;
|
||||
|
||||
namespace LehrerApp.Desktop.Tests;
|
||||
|
||||
public sealed class GroupDocumentationQuickViewModelTests
|
||||
{
|
||||
[Fact]
|
||||
public void Save_ErstelltPlanungFuerGesamteLerngruppe()
|
||||
{
|
||||
var group = new GroupDocumentationOption(Guid.NewGuid(), "10c · WAT");
|
||||
var vm = new GroupDocumentationQuickViewModel([group])
|
||||
{
|
||||
SelectedGroup = group, Title = "Klausur ankündigen", Content = "Termin abstimmen",
|
||||
};
|
||||
|
||||
vm.SaveCommand.Execute(null);
|
||||
|
||||
Assert.NotNull(vm.Result);
|
||||
Assert.Equal(Guid.Empty, vm.Result!.StudentId);
|
||||
Assert.Equal(group.Id, vm.Result.GroupId);
|
||||
Assert.Equal(DocumentationType.Planning, vm.Result.Type);
|
||||
Assert.True(vm.Result.ExcludeFromWebUntisSync);
|
||||
}
|
||||
}
|
||||
@@ -36,6 +36,25 @@ public sealed class GroupDocumentationTabViewModelTests
|
||||
Assert.Equal("Alt", vm.Entries[1].Model.Title);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Initialize_ZeigtGruppenweitenEintragAuchOhneSchueler()
|
||||
{
|
||||
var group = new LearningGroup { Name = "10c" };
|
||||
var docs = new FakeDocumentation();
|
||||
docs.Add(new Documentation
|
||||
{
|
||||
StudentId = Guid.Empty, GroupId = group.Id, Type = DocumentationType.Planning,
|
||||
Title = "Klausur planen", Date = new DateOnly(2026, 9, 1),
|
||||
});
|
||||
|
||||
var vm = BuildVm([], [group], docs);
|
||||
vm.Initialize(group.Id);
|
||||
|
||||
var entry = Assert.Single(vm.Entries);
|
||||
Assert.Equal("Gesamte Lerngruppe", entry.StudentName);
|
||||
Assert.Equal("Planung / Erinnerung", entry.TypeLabel);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Initialize_EintragAusAndererGruppe_WirdMitangezeigtAberAlsFremdMarkiert()
|
||||
{
|
||||
|
||||
@@ -12,10 +12,13 @@ public sealed class GroupOverviewViewModelTests
|
||||
{
|
||||
private static GroupOverviewViewModel NewVm(FakeLessons? lessons = null, FakeExams? exams = null,
|
||||
FakeSessions? sessions = null, FakeEntries? entries = null, FakeStudents? students = null,
|
||||
FakeDocumentation? documentation = null, FakeWorkTasks? tasks = null) =>
|
||||
new(lessons ?? new FakeLessons(), exams ?? new FakeExams([]), sessions ?? new FakeSessions([]),
|
||||
FakeDocumentation? documentation = null, FakeWorkTasks? tasks = null,
|
||||
FakeResults? results = null, FakeGroups? groups = null) =>
|
||||
new(lessons ?? new FakeLessons(), exams ?? new FakeExams([]), results ?? new FakeResults(),
|
||||
sessions ?? new FakeSessions([]),
|
||||
entries ?? new FakeEntries(), students ?? new FakeStudents([]), documentation ?? new FakeDocumentation(),
|
||||
tasks ?? new FakeWorkTasks(), new AttendanceBalanceService(), new SchoolYearService());
|
||||
tasks ?? new FakeWorkTasks(), groups ?? new FakeGroups([]), new AttendanceBalanceService(),
|
||||
new GradingService(), new SchoolYearService());
|
||||
|
||||
private static (GroupOverviewViewModel Vm, FakeLessons Lessons, FakeExams Exams,
|
||||
FakeSessions Sessions, FakeEntries Entries, FakeStudents Students, Guid GroupId) BuildScenario(
|
||||
@@ -76,6 +79,86 @@ public sealed class GroupOverviewViewModelTests
|
||||
Assert.Contains("Klassenarbeit 2", vm.NextExamLabel);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Vorjahresvergleich_OhneVerknuepfteVorgaengergruppe_BleibtVerborgen()
|
||||
{
|
||||
var groupId = Guid.NewGuid();
|
||||
var groups = new FakeGroups([new LearningGroup { Id = groupId, GradingSystem = GradingSystem.Grades1To6 }]);
|
||||
var vm = NewVm(groups: groups);
|
||||
|
||||
vm.Initialize(groupId, "Testkurs");
|
||||
|
||||
Assert.False(vm.HasYearComparison);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Vorjahresvergleich_VorgaengerOhneKlausurergebnisse_BleibtVerborgen()
|
||||
{
|
||||
var previousId = Guid.NewGuid();
|
||||
var groupId = Guid.NewGuid();
|
||||
var groups = new FakeGroups([
|
||||
new LearningGroup { Id = groupId, GradingSystem = GradingSystem.Grades1To6, PreviousGroupId = previousId },
|
||||
new LearningGroup { Id = previousId, GradingSystem = GradingSystem.Grades1To6 },
|
||||
]);
|
||||
var vm = NewVm(groups: groups);
|
||||
|
||||
vm.Initialize(groupId, "Testkurs");
|
||||
|
||||
Assert.False(vm.HasYearComparison);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Vorjahresvergleich_ZeigtDurchschnitteBeiderJahreAn()
|
||||
{
|
||||
var previousId = Guid.NewGuid();
|
||||
var groupId = Guid.NewGuid();
|
||||
var groups = new FakeGroups([
|
||||
new LearningGroup { Id = groupId, GradingSystem = GradingSystem.Grades1To6, PreviousGroupId = previousId },
|
||||
new LearningGroup { Id = previousId, GradingSystem = GradingSystem.Grades1To6 },
|
||||
]);
|
||||
|
||||
var previousExam = new Exam { Id = Guid.NewGuid(), GroupId = previousId };
|
||||
var currentExam = new Exam { Id = Guid.NewGuid(), GroupId = groupId };
|
||||
var exams = new FakeExams([previousExam, currentExam]);
|
||||
|
||||
var results = new FakeResults();
|
||||
results.Add(new ExamResult { ExamId = previousExam.Id, StudentId = Guid.NewGuid(), Grade = "2" });
|
||||
results.Add(new ExamResult { ExamId = previousExam.Id, StudentId = Guid.NewGuid(), Grade = "4" });
|
||||
results.Add(new ExamResult { ExamId = currentExam.Id, StudentId = Guid.NewGuid(), Grade = "1" });
|
||||
// Abwesende und leere Ergebnisse fließen bewusst nicht in den Schnitt ein.
|
||||
results.Add(new ExamResult { ExamId = currentExam.Id, StudentId = Guid.NewGuid(), Absent = true, Grade = "6" });
|
||||
|
||||
var vm = NewVm(exams: exams, results: results, groups: groups);
|
||||
|
||||
vm.Initialize(groupId, "Testkurs");
|
||||
|
||||
Assert.True(vm.HasYearComparison);
|
||||
Assert.Contains("Dieses Jahr: Ø 1,0 (1 Klausuren)", vm.YearComparisonLabel);
|
||||
Assert.Contains("Vorjahr: Ø 3,0 (2 Klausuren)", vm.YearComparisonLabel);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Vorjahresvergleich_OhneEigeneKlausurenZeigtNurVorjahr()
|
||||
{
|
||||
var previousId = Guid.NewGuid();
|
||||
var groupId = Guid.NewGuid();
|
||||
var groups = new FakeGroups([
|
||||
new LearningGroup { Id = groupId, GradingSystem = GradingSystem.Grades1To6, PreviousGroupId = previousId },
|
||||
new LearningGroup { Id = previousId, GradingSystem = GradingSystem.Grades1To6 },
|
||||
]);
|
||||
var previousExam = new Exam { Id = Guid.NewGuid(), GroupId = previousId };
|
||||
var exams = new FakeExams([previousExam]);
|
||||
var results = new FakeResults();
|
||||
results.Add(new ExamResult { ExamId = previousExam.Id, StudentId = Guid.NewGuid(), Grade = "3" });
|
||||
|
||||
var vm = NewVm(exams: exams, results: results, groups: groups);
|
||||
|
||||
vm.Initialize(groupId, "Testkurs");
|
||||
|
||||
Assert.True(vm.HasYearComparison);
|
||||
Assert.Contains("noch keine Klausur", vm.YearComparisonLabel);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Mitarbeit_OhneSitzungenZeigtHinweisOhneWarnung()
|
||||
{
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
using LehrerApp.Core.Models;
|
||||
using LehrerApp.Core.Services;
|
||||
using LehrerApp.Desktop.ViewModels.Planning;
|
||||
using Xunit;
|
||||
|
||||
namespace LehrerApp.Desktop.Tests;
|
||||
|
||||
public sealed class LessonSchedulingServiceTests
|
||||
{
|
||||
[Fact]
|
||||
public void UnitPicker_SchlaegtLaufendeEinheitVor_UndSpeichertNeuanlageNochNicht()
|
||||
{
|
||||
var groupId = Guid.NewGuid();
|
||||
var planned = new Unit { GroupId = groupId, Title = "Später", Status = UnitStatus.Planned };
|
||||
var active = new Unit { GroupId = groupId, Title = "Aktuell", Status = UnitStatus.Active };
|
||||
var repo = new FakeUnits();
|
||||
repo.Add(planned); repo.Add(active);
|
||||
var vm = new TimetableUnitPickerViewModel(repo, groupId, "10c", new(2026, 9, 2), 3);
|
||||
|
||||
Assert.Equal(active.Id, vm.SelectedUnit?.Model.Id);
|
||||
vm.NewUnitTitle = "Neue Reihe";
|
||||
vm.SaveCommand.Execute(null);
|
||||
|
||||
Assert.True(vm.ResultIsNew);
|
||||
Assert.Equal("Neue Reihe", vm.Result?.Title);
|
||||
Assert.Null(repo.GetById(vm.Result!.Id));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Move_AendertDatumUndStunde_UndRuecktNurNichtDurchgefuehrteFolgestundenNach()
|
||||
{
|
||||
var unitId = Guid.NewGuid();
|
||||
var groupId = Guid.NewGuid();
|
||||
var moved = new Lesson { UnitId = unitId, GroupId = groupId, Date = new(2026, 9, 1), LessonNumber = 2 };
|
||||
var draftFollowing = new Lesson { UnitId = unitId, GroupId = groupId, Date = new(2026, 9, 3), Status = LessonStatus.Draft };
|
||||
var conducted = new Lesson { UnitId = unitId, GroupId = groupId, Date = new(2026, 9, 4), Status = LessonStatus.Conducted };
|
||||
var repo = new FakeLessons();
|
||||
repo.Add(moved); repo.Add(draftFollowing); repo.Add(conducted);
|
||||
|
||||
new LessonSchedulingService(repo).Move(moved, new(2026, 9, 8), 5, shiftFollowing: true);
|
||||
|
||||
Assert.Equal(new DateOnly(2026, 9, 8), moved.Date);
|
||||
Assert.Equal(5, moved.LessonNumber);
|
||||
Assert.Equal(new DateOnly(2026, 9, 10), draftFollowing.Date);
|
||||
Assert.Equal(new DateOnly(2026, 9, 4), conducted.Date);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SplitAndMoveSecondPart_TeiltAuchEineUeberDieGrenzeLaufendePhase()
|
||||
{
|
||||
var source = new Lesson
|
||||
{
|
||||
UnitId = Guid.NewGuid(), GroupId = Guid.NewGuid(), Date = new(2026, 9, 1),
|
||||
LessonNumber = 3, Topic = "Fotosynthese", Homework = "Aufgabe 2",
|
||||
Phases =
|
||||
[
|
||||
new LessonPhaseStep { Name = "Einstieg", DurationMinutes = 30 },
|
||||
new LessonPhaseStep { Name = "Experiment", DurationMinutes = 30 },
|
||||
new LessonPhaseStep { Name = "Sicherung", DurationMinutes = 20 },
|
||||
],
|
||||
};
|
||||
var repo = new FakeLessons();
|
||||
repo.Add(source);
|
||||
|
||||
var continuation = new LessonSchedulingService(repo).SplitAndMoveSecondPart(source, 45,
|
||||
new(2026, 9, 3), 6, new TimeOnly(12, 15));
|
||||
|
||||
Assert.Equal([30, 15], source.Phases.Select(p => p.DurationMinutes));
|
||||
Assert.Equal([15, 20], continuation.Phases.Select(p => p.DurationMinutes));
|
||||
Assert.Equal("Fotosynthese – Fortsetzung", continuation.Topic);
|
||||
Assert.Null(source.Homework);
|
||||
Assert.Equal("Aufgabe 2", continuation.Homework);
|
||||
Assert.Equal(6, continuation.LessonNumber);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Move_LehntBelegtenZielterminAb()
|
||||
{
|
||||
var groupId = Guid.NewGuid();
|
||||
var source = new Lesson { GroupId = groupId, Date = new(2026, 9, 1), LessonNumber = 1 };
|
||||
var occupied = new Lesson { GroupId = groupId, Date = new(2026, 9, 2), LessonNumber = 4 };
|
||||
var repo = new FakeLessons();
|
||||
repo.Add(source); repo.Add(occupied);
|
||||
|
||||
var error = Assert.Throws<InvalidOperationException>(() =>
|
||||
new LessonSchedulingService(repo).Move(source, occupied.Date, 4, false));
|
||||
|
||||
Assert.Contains("bereits eine Planung", error.Message);
|
||||
}
|
||||
}
|
||||
@@ -294,4 +294,44 @@ public class PlanningTabViewModelTests
|
||||
Assert.Single(sessions.GetByGroup(groupId));
|
||||
Assert.Equal("Für diese Stunde existiert bereits eine Sitzung.", notified);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateParticipationSession_AndereStundeAmSelbenTagBereitsVerknuepft_LegtKeineZweiteSitzungAn()
|
||||
{
|
||||
// Nutzer-Feedback: eine dritte Stunde am selben Tag (z.B. Vertretung) soll die bereits
|
||||
// bestehende Sitzung des Tages weiterverwenden statt eine zweite anzulegen.
|
||||
var groupId = Guid.NewGuid();
|
||||
var group = new LearningGroup { Id = groupId, Name = "Testgruppe" };
|
||||
var groups = new FakeGroups([group]);
|
||||
var units = new FakeUnits();
|
||||
var lessons = new FakeLessons();
|
||||
var sessions = new FakeSessions([]);
|
||||
var vm = new PlanningTabViewModel(units, lessons, groups, new FakeSubjects([]),
|
||||
new FakeCompetencyDomains(), TestSupport.BuildAiSettingsService(), sessions);
|
||||
vm.Initialize(groupId);
|
||||
|
||||
var unit = new Unit { GroupId = groupId, Title = "Optik" };
|
||||
units.Add(unit);
|
||||
var date = new DateOnly(2025, 9, 1);
|
||||
var doubleLesson = new Lesson
|
||||
{ UnitId = unit.Id, GroupId = groupId, Date = date, Topic = "Brechung" };
|
||||
var thirdLesson = new Lesson
|
||||
{ UnitId = unit.Id, GroupId = groupId, Date = date, Topic = "Vertretung" };
|
||||
lessons.Add(doubleLesson);
|
||||
lessons.Add(thirdLesson);
|
||||
|
||||
vm.RefreshPlanning(unit.Id, doubleLesson.Id);
|
||||
vm.SelectedLesson = vm.Lessons.Single(l => l.Id == doubleLesson.Id);
|
||||
vm.CreateParticipationSessionCommand.Execute(null);
|
||||
|
||||
string? notified = null;
|
||||
vm.OnNotify = m => notified = m;
|
||||
vm.RefreshPlanning(unit.Id, thirdLesson.Id);
|
||||
vm.SelectedLesson = vm.Lessons.Single(l => l.Id == thirdLesson.Id);
|
||||
vm.CreateParticipationSessionCommand.Execute(null);
|
||||
|
||||
var created = Assert.Single(sessions.GetByGroup(groupId));
|
||||
Assert.Equal(doubleLesson.Id, created.LessonId);
|
||||
Assert.Equal("Für diesen Tag existiert bereits eine Sitzung.", notified);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -83,6 +83,67 @@ public sealed class QuickInputViewModelTests
|
||||
Assert.Equal(0.4, vm.CurrentStudentContentOpacity);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ClearRating_SetztVersehentlichGesetzteBewertungAufNichtBewertetZurueck()
|
||||
{
|
||||
var aspects = new List<AspectColumnDef>
|
||||
{
|
||||
new(new ParticipationAspect { Key = "quality", Label = "Qualität" }),
|
||||
};
|
||||
var rows = new List<ParticipationStudentRow>
|
||||
{
|
||||
new(Guid.NewGuid(), "Anna", new ParticipationEntry(), aspects, []),
|
||||
};
|
||||
var vm = new QuickInputViewModel(rows, aspects);
|
||||
vm.SetRatingByNumber(4); // aus Versehen eine Note für Qualität vergeben
|
||||
|
||||
vm.ClearRating();
|
||||
|
||||
Assert.Null(vm.AspectRows[0].Value);
|
||||
Assert.Null(rows[0].GetRating("quality"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SetDayHighlight_SpeichertAufDerZeileUndAktualisiertDieAnzeige()
|
||||
{
|
||||
var aspects = new List<AspectColumnDef>
|
||||
{
|
||||
new(new ParticipationAspect { Key = "quality", Label = "Qualität" }),
|
||||
};
|
||||
var rows = new List<ParticipationStudentRow>
|
||||
{
|
||||
new(Guid.NewGuid(), "Anna", new ParticipationEntry(), aspects, []),
|
||||
};
|
||||
var vm = new QuickInputViewModel(rows, aspects);
|
||||
|
||||
vm.SetDayHighlight(DayHighlightKind.Standout);
|
||||
|
||||
Assert.Equal(DayHighlightKind.Standout, rows[0].DayHighlight);
|
||||
Assert.Equal("👑", vm.CurrentStudentDayHighlightSymbol);
|
||||
Assert.Equal("Spitzentag", vm.CurrentStudentDayHighlightLabel);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ZurueckZuVorherigemSchueler_ZeigtDessenTagesflaggeWiederAn()
|
||||
{
|
||||
var aspects = new List<AspectColumnDef>
|
||||
{
|
||||
new(new ParticipationAspect { Key = "quality", Label = "Qualität" }),
|
||||
};
|
||||
var rows = new List<ParticipationStudentRow>
|
||||
{
|
||||
new(Guid.NewGuid(), "Anna", new ParticipationEntry(), aspects, []),
|
||||
new(Guid.NewGuid(), "Ben", new ParticipationEntry(), aspects, []),
|
||||
};
|
||||
var vm = new QuickInputViewModel(rows, aspects);
|
||||
|
||||
vm.SetDayHighlight(DayHighlightKind.Sleepy); // Anna
|
||||
vm.NextStudent(); // zu Ben, keine Flagge
|
||||
vm.PreviousStudent(); // zurück zu Anna
|
||||
|
||||
Assert.Equal(DayHighlightKind.Sleepy, vm.CurrentStudentDayHighlight);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void VorherigerAspekt_SpringtRueckwaertsMitUmlauf()
|
||||
{
|
||||
|
||||
@@ -256,6 +256,30 @@ public sealed class SeatingPlanViewModelTests
|
||||
Assert.Single(sessions.GetByGroup(groupId));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SelectOrCreateSessionForLesson_AndereStundeAmSelbenTagBereitsVerknuepft_LegtKeineZweiteSitzungAn()
|
||||
{
|
||||
// Nutzer-Feedback: eine dritte Stunde am selben Tag (z.B. durch Vertretung, eigene Lesson-
|
||||
// Id) soll die bereits bestehende Sitzung der Doppelstunde weiterverwenden statt eine
|
||||
// zweite, unabhängige Sitzung für denselben Tag anzulegen.
|
||||
var groupId = Guid.NewGuid();
|
||||
var today = DateOnly.FromDateTime(DateTime.Today);
|
||||
var doubleLesson = new Lesson { GroupId = groupId, Date = today, Topic = "Redox" };
|
||||
var thirdLesson = new Lesson { GroupId = groupId, Date = today, Topic = "Vertretung" };
|
||||
var existingSession = new ParticipationSession
|
||||
{ GroupId = groupId, Date = today, LessonId = doubleLesson.Id, Comment = "Redox" };
|
||||
var sessions = new FakeSessions([existingSession]);
|
||||
var plan = new SeatingPlan { GroupId = groupId, Name = "Standard", Rows = 1, Columns = 1 };
|
||||
var vm = new SeatingPlanTabViewModel(new FakeSeatingPlans([plan]), new FakeStudents([]),
|
||||
new FakeMemberships([]), sessions, new FakeEntries(), new FakeAspects());
|
||||
vm.Initialize(groupId, isReadOnly: false);
|
||||
|
||||
vm.SelectOrCreateSessionForLesson(thirdLesson);
|
||||
|
||||
Assert.Equal(existingSession.Id, vm.SelectedSession?.Id);
|
||||
Assert.Single(sessions.GetByGroup(groupId));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SelectOrCreateSessionForLesson_KeineSitzungVorhanden_LegtVerknuepfteAnUndWaehltSieAus()
|
||||
{
|
||||
@@ -472,6 +496,7 @@ public sealed class SeatingPlanViewModelTests
|
||||
vm.SetRatingByNumber(5);
|
||||
vm.ApplyAttendanceShortcut(1, clear: false);
|
||||
vm.ApplyHomeworkShortcut(7, clear: false);
|
||||
vm.ApplyDayHighlightShortcut(1, clear: false);
|
||||
|
||||
var session = Assert.Single(sessions.GetByGroup(groupId));
|
||||
Assert.Equal(DateOnly.FromDateTime(DateTime.Today), session.Date);
|
||||
@@ -481,6 +506,56 @@ public sealed class SeatingPlanViewModelTests
|
||||
Assert.Equal(AttendanceStatus.Present, entry.Attendance);
|
||||
Assert.Equal(HomeworkStatus.MissingOpen, entry.Homework);
|
||||
Assert.True(entry.HomeworkMissing);
|
||||
Assert.Equal(DayHighlightKind.Standout, entry.DayHighlight);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SitzplatzBewertung_TagesflaggeKannWiederGeloeschtWerden()
|
||||
{
|
||||
var groupId = Guid.NewGuid();
|
||||
var studentId = Guid.NewGuid();
|
||||
var sessions = new FakeSessions([]);
|
||||
var entries = new FakeEntries();
|
||||
var vm = new SeatAssessmentViewModel(sessions, entries, new FakeAspects(),
|
||||
groupId, studentId, "Beispiel, Anna", canEdit: true);
|
||||
|
||||
vm.ApplyDayHighlightShortcut(3, clear: false); // aus Versehen "Schlechter Tag" gesetzt
|
||||
vm.ApplyDayHighlightShortcut(null, clear: true);
|
||||
|
||||
var session = Assert.Single(sessions.GetByGroup(groupId));
|
||||
var entry = entries.GetBySessionAndStudent(session.Id, studentId)!;
|
||||
Assert.Null(entry.DayHighlight);
|
||||
Assert.Equal("Keine Markierung", vm.DayHighlightLabel);
|
||||
Assert.All(vm.DayHighlightChoices, c => Assert.Equal(c.Kind is null, c.IsSelected));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SitzplatzBewertung_SchlaegtQuantitaetAusStrichlisteVor()
|
||||
{
|
||||
var groupId = Guid.NewGuid();
|
||||
var studentId = Guid.NewGuid();
|
||||
var today = DateOnly.FromDateTime(DateTime.Today);
|
||||
var session = new ParticipationSession { GroupId = groupId, Date = today };
|
||||
var sessions = new FakeSessions([session]);
|
||||
var entries = new FakeEntries();
|
||||
entries.Add(new ParticipationEntry { SessionId = session.Id, StudentId = studentId, RaisedHandCount = 3 });
|
||||
|
||||
var vm = new SeatAssessmentViewModel(sessions, entries, new FakeAspects(),
|
||||
groupId, studentId, "Beispiel, Anna", canEdit: true);
|
||||
|
||||
var quantityRow = vm.AspectRows.Single(r => r.Key == "quantity");
|
||||
Assert.True(quantityRow.HasSuggestion);
|
||||
Assert.Equal(0, quantityRow.SuggestedValue); // 3 gemeldet -> "∼" laut ParticipationCountSuggestion
|
||||
Assert.Contains("3× gemeldet", quantityRow.SuggestionLabel);
|
||||
|
||||
var qualityRow = vm.AspectRows.Single(r => r.Key == "quality");
|
||||
Assert.False(qualityRow.HasSuggestion);
|
||||
|
||||
quantityRow.ApplySuggestionCommand.Execute(null);
|
||||
|
||||
Assert.Equal(0, quantityRow.Value);
|
||||
var updatedEntry = entries.GetBySessionAndStudent(session.Id, studentId)!;
|
||||
Assert.Equal(0, updatedEntry.Ratings.Single(r => r.Key == "quantity").Value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -517,6 +592,60 @@ public sealed class SeatingPlanViewModelTests
|
||||
Assert.Null(vm.SelectedSession);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TallyRaisedHand_ErhoehtNurGemeldetZaehler()
|
||||
{
|
||||
var groupId = Guid.NewGuid();
|
||||
var student = new Student { FirstName = "Anna", LastName = "A" };
|
||||
var plan = new SeatingPlan
|
||||
{
|
||||
GroupId = groupId, Name = "Standard", Rows = 1, Columns = 1,
|
||||
Assignments = [new SeatAssignment { StudentId = student.Id }],
|
||||
};
|
||||
var sessions = new FakeSessions([]);
|
||||
var entries = new FakeEntries();
|
||||
var vm = new SeatingPlanTabViewModel(new FakeSeatingPlans([plan]), new FakeStudents([student]),
|
||||
new FakeMemberships([new GroupMembership { GroupId = groupId, StudentId = student.Id }]),
|
||||
sessions, entries, new FakeAspects());
|
||||
vm.Initialize(groupId, isReadOnly: false);
|
||||
var seat = vm.Seats.Single();
|
||||
|
||||
seat.TallyRaisedHandCommand.Execute(null);
|
||||
seat.TallyRaisedHandCommand.Execute(null);
|
||||
|
||||
Assert.Equal(2, seat.RaisedHandCount);
|
||||
Assert.Equal(0, seat.CalledOnCount);
|
||||
var session = Assert.Single(sessions.GetByGroup(groupId));
|
||||
var entry = entries.GetBySessionAndStudent(session.Id, student.Id)!;
|
||||
Assert.Equal(2, entry.RaisedHandCount);
|
||||
Assert.Equal(0, entry.CalledOnCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TallyCalledOn_ErhoehtBeideZaehler()
|
||||
{
|
||||
var groupId = Guid.NewGuid();
|
||||
var student = new Student { FirstName = "Anna", LastName = "A" };
|
||||
var plan = new SeatingPlan
|
||||
{
|
||||
GroupId = groupId, Name = "Standard", Rows = 1, Columns = 1,
|
||||
Assignments = [new SeatAssignment { StudentId = student.Id }],
|
||||
};
|
||||
var sessions = new FakeSessions([]);
|
||||
var entries = new FakeEntries();
|
||||
var vm = new SeatingPlanTabViewModel(new FakeSeatingPlans([plan]), new FakeStudents([student]),
|
||||
new FakeMemberships([new GroupMembership { GroupId = groupId, StudentId = student.Id }]),
|
||||
sessions, entries, new FakeAspects());
|
||||
vm.Initialize(groupId, isReadOnly: false);
|
||||
var seat = vm.Seats.Single();
|
||||
|
||||
seat.TallyRaisedHandCommand.Execute(null); // erste Meldung, nicht drangekommen
|
||||
seat.TallyCalledOnCommand.Execute(null); // zweite Meldung, drangekommen
|
||||
|
||||
Assert.Equal(2, seat.RaisedHandCount);
|
||||
Assert.Equal(1, seat.CalledOnCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AssessStudent_LegtErstBeiTatsaechlicherBewertungEineSitzungAn()
|
||||
{
|
||||
|
||||
@@ -7,6 +7,7 @@ using LehrerApp.Sync;
|
||||
using LehrerApp.Sync.Models;
|
||||
using System.Linq;
|
||||
using Xunit;
|
||||
using LehrerApp.Templating;
|
||||
|
||||
namespace LehrerApp.Desktop.Tests;
|
||||
|
||||
@@ -28,12 +29,13 @@ public sealed class SettingsViewModelTests
|
||||
return new SettingsViewModel(
|
||||
subjects ?? new FakeSubjects([]), competencyDomains ?? new FakeCompetencyDomains(), new FakeGradingKeyTemplates(),
|
||||
new FakeSchemes(), new GradingService(), new BackupService(tempPath),
|
||||
new BackupSettingsService(tempPath),
|
||||
new DatabaseEncryptionService(), new AppLockService(tempPath),
|
||||
new LiteDbContext(new MemoryStream()), new PrivacySettingsService(tempPath),
|
||||
new FakeDocumentation(), new FakeStudents([]), new FakeShorthandCodes([]),
|
||||
holidays ?? new FakeSchoolHolidays(), new SchoolCalendarSettingsService(tempPath),
|
||||
new PeriodScheduleService(tempPath), supervisionDuties ?? new FakeSupervisionDuties(),
|
||||
new LetterTemplateService(tempPath), TestSupport.BuildAiSettingsService(), TestSupport.BuildAiPlanningService(),
|
||||
new TemplateStore(tempPath), TestSupport.BuildAiSettingsService(), TestSupport.BuildAiPlanningService(),
|
||||
TestSupport.BuildWebUntisSettingsService(),
|
||||
TestSupport.BuildAnnualPlanSettingsService(),
|
||||
TestSupport.BuildSyncSettingsService(), TestSupport.BuildSyncAuthService(),
|
||||
@@ -314,11 +316,12 @@ public sealed class SettingsViewModelTests
|
||||
var vm = new SettingsViewModel(
|
||||
new FakeSubjects([]), new FakeCompetencyDomains(), new FakeGradingKeyTemplates(),
|
||||
new FakeSchemes(), new GradingService(), new BackupService(tempPath),
|
||||
new BackupSettingsService(tempPath),
|
||||
new DatabaseEncryptionService(), new AppLockService(tempPath),
|
||||
new LiteDbContext(new MemoryStream()), new PrivacySettingsService(tempPath),
|
||||
new FakeDocumentation(), new FakeStudents([]), new FakeShorthandCodes([]),
|
||||
new FakeSchoolHolidays(), calendarSettings, new PeriodScheduleService(tempPath),
|
||||
new FakeSupervisionDuties(), new LetterTemplateService(tempPath), TestSupport.BuildAiSettingsService(), TestSupport.BuildAiPlanningService(),
|
||||
new FakeSupervisionDuties(), new TemplateStore(tempPath), TestSupport.BuildAiSettingsService(), TestSupport.BuildAiPlanningService(),
|
||||
TestSupport.BuildWebUntisSettingsService(),
|
||||
TestSupport.BuildAnnualPlanSettingsService(),
|
||||
TestSupport.BuildSyncSettingsService(), TestSupport.BuildSyncAuthService(), TestSupport.BuildEventQueue(),
|
||||
@@ -341,11 +344,12 @@ public sealed class SettingsViewModelTests
|
||||
var vm = new SettingsViewModel(
|
||||
new FakeSubjects([]), new FakeCompetencyDomains(), new FakeGradingKeyTemplates(),
|
||||
new FakeSchemes(), new GradingService(), new BackupService(tempPath),
|
||||
new BackupSettingsService(tempPath),
|
||||
new DatabaseEncryptionService(), new AppLockService(tempPath),
|
||||
new LiteDbContext(new MemoryStream()), new PrivacySettingsService(tempPath),
|
||||
new FakeDocumentation(), new FakeStudents([]), new FakeShorthandCodes([]),
|
||||
new FakeSchoolHolidays(), new SchoolCalendarSettingsService(tempPath), periodSchedule,
|
||||
new FakeSupervisionDuties(), new LetterTemplateService(tempPath), TestSupport.BuildAiSettingsService(), TestSupport.BuildAiPlanningService(),
|
||||
new FakeSupervisionDuties(), new TemplateStore(tempPath), TestSupport.BuildAiSettingsService(), TestSupport.BuildAiPlanningService(),
|
||||
TestSupport.BuildWebUntisSettingsService(),
|
||||
TestSupport.BuildAnnualPlanSettingsService(),
|
||||
TestSupport.BuildSyncSettingsService(), TestSupport.BuildSyncAuthService(), TestSupport.BuildEventQueue(),
|
||||
@@ -372,11 +376,12 @@ public sealed class SettingsViewModelTests
|
||||
var vm = new SettingsViewModel(
|
||||
new FakeSubjects([]), new FakeCompetencyDomains(), new FakeGradingKeyTemplates(),
|
||||
new FakeSchemes(), new GradingService(), new BackupService(tempPath),
|
||||
new BackupSettingsService(tempPath),
|
||||
new DatabaseEncryptionService(), new AppLockService(tempPath),
|
||||
new LiteDbContext(new MemoryStream()), new PrivacySettingsService(tempPath),
|
||||
new FakeDocumentation(), new FakeStudents([]), new FakeShorthandCodes([]),
|
||||
new FakeSchoolHolidays(), new SchoolCalendarSettingsService(tempPath), periodSchedule,
|
||||
new FakeSupervisionDuties(), new LetterTemplateService(tempPath), TestSupport.BuildAiSettingsService(), TestSupport.BuildAiPlanningService(),
|
||||
new FakeSupervisionDuties(), new TemplateStore(tempPath), TestSupport.BuildAiSettingsService(), TestSupport.BuildAiPlanningService(),
|
||||
TestSupport.BuildWebUntisSettingsService(),
|
||||
TestSupport.BuildAnnualPlanSettingsService(),
|
||||
TestSupport.BuildSyncSettingsService(), TestSupport.BuildSyncAuthService(), TestSupport.BuildEventQueue(),
|
||||
|
||||
@@ -51,6 +51,18 @@ public class StudentListViewModelTests
|
||||
Assert.Equal("M", vm.Students.Single(s => s.Id == _ben.Id).AvatarLabel);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SucheOhneTreffer_LiefertHilfreichenLeerzustand()
|
||||
{
|
||||
var vm = BuildViewModel();
|
||||
|
||||
vm.SearchText = "nicht vorhanden";
|
||||
|
||||
Assert.True(vm.HasNoStudents);
|
||||
Assert.False(vm.HasStudents);
|
||||
Assert.Contains("Suche", vm.EmptyListMessage);
|
||||
}
|
||||
|
||||
private StudentListViewModel BuildViewModel() => new(
|
||||
new FakeStudents([_anna, _ben]),
|
||||
new FakeGroups([_group]),
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
using LehrerApp.Core.Models;
|
||||
using LehrerApp.Core.Services;
|
||||
using LehrerApp.Desktop.ViewModels.Groups;
|
||||
using Xunit;
|
||||
|
||||
namespace LehrerApp.Desktop.Tests;
|
||||
|
||||
/// Tests für den Schüler-Leistungsüberblick (Nutzer-Feedback): Klausuren/Mitarbeit/Sonstige
|
||||
/// im Überblick, berechnete Zeugnisnote, Zielnoten- und Was-wäre-wenn-Rechner sowie der
|
||||
/// Bewerter-/Schülermodus-Unterschied (Kursdurchschnitt, Bearbeitbarkeit).
|
||||
public sealed class StudentPerformanceOverviewViewModelTests
|
||||
{
|
||||
private static readonly Guid GroupId = Guid.NewGuid();
|
||||
private static readonly Guid StudentId = Guid.NewGuid();
|
||||
private static readonly Guid OtherStudentId = Guid.NewGuid();
|
||||
|
||||
private static StudentPerformanceOverviewViewModel BuildViewModel(
|
||||
FakeExams? exams = null, FakeResults? results = null, FakeGrades? grades = null,
|
||||
FakeSchemes? schemes = null, GradingSystem system = GradingSystem.Grades1To6)
|
||||
{
|
||||
var scheme = new GradingScheme { ExamsPercent = 50, ParticipationPercent = 40, OtherPercent = 10 };
|
||||
var schemesRepo = schemes ?? new FakeSchemes();
|
||||
if (schemes is null) schemesRepo.SetForGroup(GroupId, scheme);
|
||||
|
||||
return new StudentPerformanceOverviewViewModel(
|
||||
grades ?? new FakeGrades(), exams ?? new FakeExams([]), results ?? new FakeResults(),
|
||||
new FakeMemberships([]), schemesRepo, new GradingService(),
|
||||
GroupId, StudentId, GroupType.Class, system, "Beispiel, Anna", "Testkurs", "2025/26");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ExamRows_ZeigtEigeneNoteUndKursdurchschnitt()
|
||||
{
|
||||
var exam = new Exam { Id = Guid.NewGuid(), GroupId = GroupId, Title = "K1", Date = new DateOnly(2025, 9, 10) };
|
||||
var exams = new FakeExams([exam]);
|
||||
var results = new FakeResults();
|
||||
results.Add(new ExamResult { ExamId = exam.Id, StudentId = StudentId, Grade = "2" });
|
||||
results.Add(new ExamResult { ExamId = exam.Id, StudentId = OtherStudentId, Grade = "4" });
|
||||
|
||||
var vm = BuildViewModel(exams, results);
|
||||
|
||||
var row = Assert.Single(vm.ExamRows);
|
||||
Assert.Equal("2", row.OwnGradeDisplay);
|
||||
Assert.Equal("3.0", row.ClassAverageDisplay);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ExamHistory_ZeigtEigenenVerlaufUndMarkiertNotenabfall()
|
||||
{
|
||||
var exam1 = new Exam { Id = Guid.NewGuid(), GroupId = GroupId, Title = "K1", Date = new DateOnly(2025, 9, 10) };
|
||||
var exam2 = new Exam { Id = Guid.NewGuid(), GroupId = GroupId, Title = "K2", Date = new DateOnly(2025, 9, 24) };
|
||||
var exams = new FakeExams([exam1, exam2]);
|
||||
var results = new FakeResults();
|
||||
results.Add(new ExamResult { ExamId = exam1.Id, StudentId = StudentId, Grade = "2" });
|
||||
results.Add(new ExamResult { ExamId = exam2.Id, StudentId = StudentId, Grade = "5" }); // Abfall + mangelhaft
|
||||
|
||||
var vm = BuildViewModel(exams, results);
|
||||
|
||||
Assert.Equal(2, vm.ExamHistory.Points.Count);
|
||||
Assert.False(vm.ExamHistory.Points[0].IsWarning);
|
||||
Assert.True(vm.ExamHistory.Points[1].IsWarning);
|
||||
Assert.Contains("Abfall", vm.ExamHistory.Points[1].WarningText);
|
||||
Assert.Contains("Versetzungsgefährdung", vm.ExamHistory.Points[1].WarningText);
|
||||
Assert.True(vm.ExamHistory.Points[1].BarHeight < vm.ExamHistory.Points[0].BarHeight);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AktuelleZeugnisnote_KombiniertAlleDreiBereiche()
|
||||
{
|
||||
var exam = new Exam { Id = Guid.NewGuid(), GroupId = GroupId, Title = "K1", Date = new DateOnly(2025, 9, 10) };
|
||||
var exams = new FakeExams([exam]);
|
||||
var results = new FakeResults();
|
||||
results.Add(new ExamResult { ExamId = exam.Id, StudentId = StudentId, Grade = "2" });
|
||||
var grades = new FakeGrades();
|
||||
grades.Add(new Grade { StudentId = StudentId, GroupId = GroupId, Category = GradeCategory.Participation,
|
||||
Date = new DateOnly(2025, 9, 5), Value = "3" });
|
||||
grades.Add(new Grade { StudentId = StudentId, GroupId = GroupId, Category = GradeCategory.Other,
|
||||
Date = new DateOnly(2025, 9, 5), Value = "1" });
|
||||
|
||||
var vm = BuildViewModel(exams, results, grades);
|
||||
|
||||
// (2*50 + 3*40 + 1*10) / 100 = 2.3 -> kaufmännisch 2
|
||||
Assert.Equal("Note 2", vm.CurrentReportGradeDisplay);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Zielnote_BerechnetNoetigenKlausurschnitt()
|
||||
{
|
||||
var grades = new FakeGrades();
|
||||
grades.Add(new Grade { StudentId = StudentId, GroupId = GroupId, Category = GradeCategory.Participation,
|
||||
Date = new DateOnly(2025, 9, 5), Value = "3" });
|
||||
grades.Add(new Grade { StudentId = StudentId, GroupId = GroupId, Category = GradeCategory.Other,
|
||||
Date = new DateOnly(2025, 9, 5), Value = "2" });
|
||||
var vm = BuildViewModel(grades: grades);
|
||||
|
||||
vm.TargetGradeText = "2";
|
||||
vm.TargetBucketName = "Klausuren";
|
||||
|
||||
// Ziel 2,0 = (x*50 + 3*40 + 2*10)/100 => 200 = 50x+120+20 => x = 1,2
|
||||
Assert.Contains("1.2", vm.TargetResultDisplay);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WasWaereWenn_ZeigtZeugnisnoteMitZusaetzlicherKlausurAn()
|
||||
{
|
||||
var exam = new Exam { Id = Guid.NewGuid(), GroupId = GroupId, Title = "K1", Date = new DateOnly(2025, 9, 10) };
|
||||
var exams = new FakeExams([exam]);
|
||||
var results = new FakeResults();
|
||||
results.Add(new ExamResult { ExamId = exam.Id, StudentId = StudentId, Grade = "2" });
|
||||
var grades = new FakeGrades();
|
||||
grades.Add(new Grade { StudentId = StudentId, GroupId = GroupId, Category = GradeCategory.Participation,
|
||||
Date = new DateOnly(2025, 9, 5), Value = "2" });
|
||||
|
||||
var vm = BuildViewModel(exams, results, grades);
|
||||
|
||||
vm.WhatIfExamGradeText = "6";
|
||||
|
||||
// Klausuren neu: (2+6)/2=4 (50%), Mitarbeit 2 (40%, Sonstige fehlt) -> (4*50+2*40)/90 = 3,11 -> kaufm. 3
|
||||
Assert.Contains("3", vm.WhatIfResultDisplay);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SaveCommand_AufMitarbeitszeile_SpeichertUndAktualisiertDurchschnitt()
|
||||
{
|
||||
var grades = new FakeGrades();
|
||||
var grade = new Grade { StudentId = StudentId, GroupId = GroupId, Category = GradeCategory.Participation,
|
||||
Date = new DateOnly(2025, 9, 5), Value = "3" };
|
||||
grades.Add(grade);
|
||||
var vm = BuildViewModel(grades: grades);
|
||||
|
||||
var row = Assert.Single(vm.ParticipationRows);
|
||||
row.Value = "1";
|
||||
row.SaveCommand.Execute(null);
|
||||
|
||||
Assert.Equal("1.0", vm.ParticipationAverageDisplay);
|
||||
Assert.Equal("1", grades.GetByStudentAndGroup(StudentId, GroupId).Single().Value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OhneWerteImZeitraum_ZeigtHinweisStattZeugnisnote()
|
||||
{
|
||||
var vm = BuildViewModel();
|
||||
|
||||
Assert.Contains("Noch nicht berechenbar", vm.CurrentReportGradeDisplay);
|
||||
}
|
||||
}
|
||||
@@ -277,7 +277,23 @@ public sealed class TimetableViewModelTests
|
||||
|
||||
Assert.Equal(group.Id, navigatedTo);
|
||||
Assert.False(viewerOpened);
|
||||
Assert.Equal("Zur Lerngruppe", vm.TodayItems[0].OpenButtonLabel);
|
||||
Assert.Equal("Stunde anlegen", vm.TodayItems[0].OpenButtonLabel);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task OpenTodayLesson_OhneLesson_StartetDirektanlageMitExaktemTermin()
|
||||
{
|
||||
var today = DateOnly.FromDateTime(DateTime.Today);
|
||||
var group = new LearningGroup { Name = "Q1 Chemie" };
|
||||
var slots = new FakeTimetableSlots();
|
||||
slots.Add(new TimetableSlot { GroupId = group.Id, Weekday = today.DayOfWeek, PeriodNumber = 4 });
|
||||
var vm = BuildViewModel(slots, new FakeGroups([group]));
|
||||
TimetableLessonRequest? requested = null;
|
||||
vm.OnCreateLesson = request => { requested = request; return Task.CompletedTask; };
|
||||
|
||||
await vm.OpenTodayLessonCommand.ExecuteAsync(vm.TodayItems[0]);
|
||||
|
||||
Assert.Equal(new TimetableLessonRequest(group.Id, today, 4), requested);
|
||||
}
|
||||
|
||||
// ── Unterrichtsmodus (14.x) ────────────────────────────────────────────────
|
||||
@@ -696,6 +712,31 @@ public sealed class TimetableViewModelTests
|
||||
Assert.Equal("1", period2.BadgeText);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Load_Doppelstunde_ZeigtLessonThemaInBeidenKacheln_AuchOhnePhasenUndStundenraster()
|
||||
{
|
||||
var date = DateInCurrentWeek(DayOfWeek.Monday);
|
||||
var group = new LearningGroup { Name = "10c" };
|
||||
var slots = new FakeTimetableSlots();
|
||||
slots.Add(new TimetableSlot { GroupId = group.Id, Weekday = date.DayOfWeek, PeriodNumber = 3 });
|
||||
slots.Add(new TimetableSlot { GroupId = group.Id, Weekday = date.DayOfWeek, PeriodNumber = 4 });
|
||||
var lesson = new Lesson
|
||||
{
|
||||
GroupId = group.Id, Date = date, LessonNumber = 3, Topic = "Werkstoffprüfung",
|
||||
Phases = [],
|
||||
};
|
||||
var lessons = new FakeLessons();
|
||||
lessons.Add(lesson);
|
||||
|
||||
var vm = BuildViewModel(slots, new FakeGroups([group]), lessons: lessons);
|
||||
|
||||
var first = vm.WeekItems.Single(c => c.IsSlotCell && c.Weekday == date.DayOfWeek && c.PeriodNumber == 3);
|
||||
var second = vm.WeekItems.Single(c => c.IsSlotCell && c.Weekday == date.DayOfWeek && c.PeriodNumber == 4);
|
||||
Assert.Equal("Werkstoffprüfung", first.Topic);
|
||||
Assert.Equal("Werkstoffprüfung", second.Topic);
|
||||
Assert.Equal(lesson.Id, second.Lesson?.Id);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Load_KeineAnstehendenFerien_KeinBadge()
|
||||
{
|
||||
|
||||
@@ -7,6 +7,17 @@
|
||||
<ResourceDictionary.MergedDictionaries>
|
||||
<ResourceInclude Source="avares://LehrerApp.Desktop/Styles/SemanticBrushes.axaml"/>
|
||||
</ResourceDictionary.MergedDictionaries>
|
||||
<!-- Plattformunabhängige Vektoricons für die Hauptnavigation (14.10). -->
|
||||
<StreamGeometry x:Key="IconSearch">M10,2 A8,8 0 1 0 10,18 A8,8 0 1 0 10,2 M10,5 A5,5 0 1 1 10,15 A5,5 0 1 1 10,5 M15,14 L22,21 L20,23 L13,16 Z</StreamGeometry>
|
||||
<StreamGeometry x:Key="IconDashboard">M3,3 H10 V10 H3 Z M14,3 H21 V7 H14 Z M14,11 H21 V21 H14 Z M3,14 H10 V21 H3 Z</StreamGeometry>
|
||||
<StreamGeometry x:Key="IconGroups">M3,8 L12,3 L21,8 V10 H3 Z M5,11 H8 V18 H5 Z M10,11 H14 V18 H10 Z M16,11 H19 V18 H16 Z M3,20 H21 V22 H3 Z</StreamGeometry>
|
||||
<StreamGeometry x:Key="IconStudents">M12,3 A4,4 0 1 0 12,11 A4,4 0 1 0 12,3 M4,21 C4,16 7,13 12,13 C17,13 20,16 20,21 Z</StreamGeometry>
|
||||
<StreamGeometry x:Key="IconExams">M5,2 H15 L20,7 V22 H5 Z M7,5 V19 H18 V9 H13 V4 H7 Z M9,11 H16 V13 H9 Z M9,15 H16 V17 H9 Z</StreamGeometry>
|
||||
<StreamGeometry x:Key="IconCalendar">M3,4 H21 V22 H3 Z M5,10 V20 H19 V10 Z M7,2 H9 V7 H7 Z M15,2 H17 V7 H15 Z M7,12 H10 V15 H7 Z M12,12 H15 V15 H12 Z M7,17 H10 V19 H7 Z M12,17 H15 V19 H12 Z</StreamGeometry>
|
||||
<StreamGeometry x:Key="IconClock">M12,2 A10,10 0 1 0 12,22 A10,10 0 1 0 12,2 M12,5 A7,7 0 1 1 12,19 A7,7 0 1 1 12,5 M11,7 H13 V12 L17,14 L16,16 L11,13 Z</StreamGeometry>
|
||||
<StreamGeometry x:Key="IconClassTeacher">M2,8 L12,3 L22,8 L12,13 Z M6,11 V16 C9,19 15,19 18,16 V11 M22,8 V15</StreamGeometry>
|
||||
<StreamGeometry x:Key="IconSettings">M3,5 H21 V7 H3 Z M8,3 A3,3 0 1 0 8,9 A3,3 0 1 0 8,3 M3,11 H21 V13 H3 Z M16,9 A3,3 0 1 0 16,15 A3,3 0 1 0 16,9 M3,17 H21 V19 H3 Z M10,15 A3,3 0 1 0 10,21 A3,3 0 1 0 10,15</StreamGeometry>
|
||||
<StreamGeometry x:Key="IconRefresh">M12,3 A9,9 0 0 1 21,12 H18 A6,6 0 0 0 8,7 L11,10 H3 V2 L6,5 A9,9 0 0 1 12,3 M12,21 A9,9 0 0 1 3,12 H6 A6,6 0 0 0 16,17 L13,14 H21 V22 L18,19 A9,9 0 0 1 12,21</StreamGeometry>
|
||||
</ResourceDictionary>
|
||||
</Application.Resources>
|
||||
|
||||
@@ -24,5 +35,17 @@
|
||||
<Setter Property="Opacity" Value="0.4"/>
|
||||
<Setter Property="FontSize" Value="13"/>
|
||||
</Style>
|
||||
<Style Selector="Button.touchTarget">
|
||||
<Setter Property="MinWidth" Value="40"/>
|
||||
<Setter Property="MinHeight" Value="40"/>
|
||||
</Style>
|
||||
<Style Selector="Button:focus-visible">
|
||||
<Setter Property="BorderBrush" Value="{DynamicResource SystemAccentColor}"/>
|
||||
<Setter Property="BorderThickness" Value="2"/>
|
||||
</Style>
|
||||
<Style Selector="TextBox:focus-visible, ComboBox:focus-visible">
|
||||
<Setter Property="BorderBrush" Value="{DynamicResource SystemAccentColor}"/>
|
||||
<Setter Property="BorderThickness" Value="2"/>
|
||||
</Style>
|
||||
</Application.Styles>
|
||||
</Application>
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Controls.ApplicationLifetimes;
|
||||
using Avalonia.Markup.Xaml;
|
||||
using Avalonia.Styling;
|
||||
using Avalonia.Threading;
|
||||
using LehrerApp.Core.Interfaces;
|
||||
using LehrerApp.Core.Models;
|
||||
using LehrerApp.Core.Services;
|
||||
using LehrerApp.Data;
|
||||
using LehrerApp.Desktop.Services;
|
||||
@@ -36,6 +39,21 @@ public class App : Application
|
||||
ApplyTheme(new AppearanceSettingsService(AppBootstrapper.ResolveAppDataPath()).Load());
|
||||
|
||||
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
|
||||
{
|
||||
// Erst den Splashscreen anzeigen und die synchrone Initialisierung in den ersten
|
||||
// Dispatcher-Durchlauf verschieben, damit das Ladebild vorher gezeichnet wird.
|
||||
var splash = new SplashWindow();
|
||||
desktop.MainWindow = splash;
|
||||
Dispatcher.UIThread.Post(
|
||||
() => ContinueStartup(desktop, splash),
|
||||
DispatcherPriority.Background);
|
||||
}
|
||||
|
||||
base.OnFrameworkInitializationCompleted();
|
||||
}
|
||||
|
||||
private static void ContinueStartup(
|
||||
IClassicDesktopStyleApplicationLifetime desktop, SplashWindow splash)
|
||||
{
|
||||
// Verschlüsselte Datenbank (13.3.4): Passwort abfragen, bevor die Datenbank
|
||||
// (und damit BuildServices, das sie öffnet) angefasst wird.
|
||||
@@ -47,22 +65,20 @@ public class App : Application
|
||||
promptVm.OnUnlocked = password =>
|
||||
{
|
||||
AppBootstrapper.DbPassword = password;
|
||||
StartMainApp(desktop, showImmediately: true);
|
||||
StartMainApp(desktop, splash);
|
||||
promptWindow.Close();
|
||||
};
|
||||
desktop.MainWindow = promptWindow;
|
||||
}
|
||||
else
|
||||
{
|
||||
StartMainApp(desktop, showImmediately: false);
|
||||
}
|
||||
promptWindow.Show();
|
||||
splash.Close();
|
||||
return;
|
||||
}
|
||||
|
||||
base.OnFrameworkInitializationCompleted();
|
||||
StartMainApp(desktop, splash);
|
||||
}
|
||||
|
||||
private static void StartMainApp(
|
||||
IClassicDesktopStyleApplicationLifetime desktop, bool showImmediately)
|
||||
IClassicDesktopStyleApplicationLifetime desktop, Window? windowToClose = null)
|
||||
{
|
||||
_serviceProvider = AppBootstrapper.BuildServices();
|
||||
Services = _serviceProvider;
|
||||
@@ -95,7 +111,8 @@ public class App : Application
|
||||
if (Services.GetService<SyncEngine>() is { } syncEngine)
|
||||
main.EnableFinalSync(syncEngine);
|
||||
desktop.MainWindow = main;
|
||||
if (showImmediately) main.Show();
|
||||
main.Show();
|
||||
windowToClose?.Close();
|
||||
}
|
||||
|
||||
/// <summary>Wechselt die Darstellung sofort, ohne Neustart (12.4) — Avalonia stylt den
|
||||
@@ -153,6 +170,10 @@ public class App : Application
|
||||
dash.OnNavigateToLesson = id => main.NavigateToGroupDetail(id, 3); // Tab "Mitarbeit"
|
||||
dash.OnNavigateToExam = id => main.NavigateToGroupDetail(id, 4); // Tab "Klausuren"
|
||||
dash.OnNavigateToUnplannedLesson = id => main.NavigateToGroupDetail(id, 6); // Tab "Planung"
|
||||
dash.OnAddMissingTeachingTime = item => ShowMissingTeachingTimeDialog(item, dash);
|
||||
|
||||
var examsOverview = Services.GetRequiredService<ViewModels.Exams.ExamsOverviewViewModel>();
|
||||
examsOverview.OnNavigateToGroups = () => main.NavigateToCommand.Execute(NavItem.Groups);
|
||||
|
||||
// Globale Suche/Schnellerfassung (14.2): Navigation bleibt im MainWindow-VM, die
|
||||
// vorhandenen Dialog-Helfer übernehmen Eingabe und Validierung.
|
||||
@@ -170,6 +191,7 @@ public class App : Application
|
||||
};
|
||||
search.OnQuickAddTask = startAsReminder => ShowQuickTaskDialog(startAsReminder, dash);
|
||||
search.OnQuickAddStudent = ShowAddStudentDialog;
|
||||
search.OnQuickAddGroupDocumentation = () => ShowQuickGroupDocumentationDialog(dash);
|
||||
|
||||
// StudentList → StudentDetail + Anlegen
|
||||
var sl = Services.GetRequiredService<StudentListViewModel>();
|
||||
@@ -203,4 +225,49 @@ public class App : Application
|
||||
dashboard.RefreshCommand.Execute(null);
|
||||
Services.GetRequiredService<WorkTaskListViewModel>().Load();
|
||||
}
|
||||
|
||||
private static async Task ShowMissingTeachingTimeDialog(MissingTeachingTimeItem item, DashboardViewModel dashboard)
|
||||
{
|
||||
if (Application.Current?.ApplicationLifetime is not IClassicDesktopStyleApplicationLifetime
|
||||
{ MainWindow: { } owner }) return;
|
||||
|
||||
var tasks = Services.GetRequiredService<IWorkTaskRepository>()
|
||||
.GetAll().Where(t => t.Status != WorkTaskStatus.Done).ToList();
|
||||
var vm = new AddTimeEntryDialogViewModel(tasks)
|
||||
{
|
||||
DateText = item.Date.ToString("dd.MM.yyyy"),
|
||||
SelectedCategory = "Unterricht",
|
||||
StartTimeText = item.WindowStart.ToString("HH:mm"),
|
||||
EndTimeText = item.WindowEnd.ToString("HH:mm"),
|
||||
};
|
||||
var dialog = new AddTimeEntryDialog { DataContext = vm };
|
||||
await dialog.ShowDialog<bool>(owner);
|
||||
if (vm.Result is null) return;
|
||||
|
||||
Services.GetRequiredService<ITimeEntryRepository>().Save(vm.Result);
|
||||
dashboard.RefreshCommand.Execute(null);
|
||||
Services.GetRequiredService<TimeTrackingViewModel>().Load();
|
||||
}
|
||||
|
||||
private static async Task ShowQuickGroupDocumentationDialog(DashboardViewModel dashboard)
|
||||
{
|
||||
if (Application.Current?.ApplicationLifetime is not IClassicDesktopStyleApplicationLifetime
|
||||
{ MainWindow: { } owner }) return;
|
||||
|
||||
var subjects = Services.GetRequiredService<ISubjectRepository>().GetAll()
|
||||
.ToDictionary(s => s.Id);
|
||||
var options = Services.GetRequiredService<IGroupRepository>().GetAll()
|
||||
.Where(g => g.IsActive)
|
||||
.OrderBy(g => g.Name)
|
||||
.Select(g => new GroupDocumentationOption(g.Id,
|
||||
g.SubjectId is { } subjectId && subjects.TryGetValue(subjectId, out var subject)
|
||||
? $"{g.Name} · {(string.IsNullOrWhiteSpace(subject.ShortName) ? subject.Name : subject.ShortName)}"
|
||||
: g.Name));
|
||||
var vm = new GroupDocumentationQuickViewModel(options);
|
||||
var dialog = new Views.Students.GroupDocumentationQuickDialog { DataContext = vm };
|
||||
if (!await dialog.ShowDialog<bool>(owner) || vm.Result is null) return;
|
||||
|
||||
Services.GetRequiredService<IDocumentationRepository>().Save(vm.Result);
|
||||
dashboard.RefreshCommand.Execute(null);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ using LehrerApp.Sync;
|
||||
using LehrerApp.Sync.Crypto;
|
||||
using LehrerApp.Sync.Models;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using LehrerApp.Templating;
|
||||
|
||||
namespace LehrerApp.Desktop;
|
||||
|
||||
@@ -117,13 +118,22 @@ public static class AppBootstrapper
|
||||
services.AddSingleton<NotificationService>();
|
||||
services.AddSingleton<ExportService>();
|
||||
services.AddSingleton<PdfExportService>();
|
||||
services.AddSingleton<ITemplateLoader, TemplateLoader>();
|
||||
services.AddSingleton<ITemplateRenderer, QuestTemplateRenderer>();
|
||||
services.AddSingleton(_ => new TemplateStore(appData));
|
||||
|
||||
// ── Datensicherheit (13.3) ───────────────────────────────────────────
|
||||
// Backup läuft vor dem Öffnen der Datenbank, rein dateibasiert (unabhängig von
|
||||
// Verschlüsselung) — reine Datei-Kopie, kein offener LiteDB-Handle nötig.
|
||||
var backup = new BackupService(appData);
|
||||
backup.CreateBackup(DbPath);
|
||||
var backupSettings = new BackupSettingsService(appData);
|
||||
var backup = new BackupService(appData) { SecondaryBackupDirectory = backupSettings.LoadSecondaryDirectory() };
|
||||
var backupPath = backup.CreateBackup(DbPath);
|
||||
// Best-effort-Prüfung des automatischen Startbackups: nur geloggt, kein Blocker für den
|
||||
// Programmstart — ein beschädigtes Backup soll auffallen, nicht den Nutzer aufhalten.
|
||||
if (backupPath is not null && !new DatabaseEncryptionService().CanOpenAndRead(backupPath, DbPassword))
|
||||
Logger.Warn($"Automatisches Backup {backupPath} lässt sich nicht öffnen/lesen — möglicherweise beschädigt.");
|
||||
services.AddSingleton(backup);
|
||||
services.AddSingleton(backupSettings);
|
||||
services.AddSingleton(_ => new AppLockService(appData));
|
||||
services.AddSingleton<DatabaseEncryptionService>();
|
||||
services.AddSingleton(_ => new PrivacySettingsService(appData));
|
||||
@@ -152,6 +162,7 @@ public static class AppBootstrapper
|
||||
services.AddSingleton<IUnitRepository, UnitRepository>();
|
||||
services.AddSingleton<ILessonRepository, LessonRepository>();
|
||||
services.AddSingleton<IDocumentationRepository, DocumentationRepository>();
|
||||
services.AddSingleton<IVorgangRepository, VorgangRepository>();
|
||||
services.AddSingleton<IWorkTaskRepository, WorkTaskRepository>();
|
||||
services.AddSingleton<ITimeEntryRepository, TimeEntryRepository>();
|
||||
services.AddSingleton<IParticipationSessionRepository, ParticipationSessionRepository>();
|
||||
@@ -185,9 +196,9 @@ public static class AppBootstrapper
|
||||
services.AddSingleton(_ => new PeriodScheduleService(appData));
|
||||
services.AddSingleton(_ => new WorkloadSettingsService(appData));
|
||||
services.AddSingleton(_ => new DashboardSettingsService(appData));
|
||||
services.AddSingleton(_ => new PatternScoreSettingsService(appData));
|
||||
services.AddSingleton(_ => new WindowSettingsService(appData));
|
||||
services.AddSingleton(_ => new AppearanceSettingsService(appData));
|
||||
services.AddSingleton(_ => new LetterTemplateService(appData));
|
||||
services.AddSingleton<PlanningExchangeService>();
|
||||
|
||||
// ── KI-Unterstützung (4.5.9, optional – nur wenn in den Einstellungen aktiviert) ──────
|
||||
@@ -306,6 +317,7 @@ public static class AppBootstrapper
|
||||
services.AddSingleton<WorkloadEvaluationViewModel>();
|
||||
services.AddSingleton<WorkloadViewModel>();
|
||||
services.AddSingleton<ClassTeacherDetailsViewModel>();
|
||||
services.AddSingleton<ClassTeacherCasesViewModel>();
|
||||
services.AddSingleton<ClassTeacherOverviewViewModel>();
|
||||
services.AddSingleton<ExamsOverviewViewModel>();
|
||||
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 2.2 MiB |
@@ -0,0 +1,22 @@
|
||||
using System;
|
||||
using System.Globalization;
|
||||
using Avalonia.Data.Converters;
|
||||
|
||||
namespace LehrerApp.Desktop.Converters;
|
||||
|
||||
/// <summary>`CalendarDatePicker.SelectedDate` ist (anders als der eingebaute `DatePicker`) vom
|
||||
/// WPF-Toolkit übernommenes `DateTime?`, während Datumsfelder in dieser Codebasis durchgängig
|
||||
/// `DateTimeOffset`(?) sind. Avalonia bietet dafür keine automatische Konvertierung
|
||||
/// (`TypeUtilities.TryConvert`/`TryConvertImplicit` unterstützen `DateTime`↔`DateTimeOffset`
|
||||
/// nicht) — ohne diesen Converter bleibt die Bindung beim Anzeigen stumm leer und wirft beim
|
||||
/// Zurückschreiben eine `InvalidCastException`.</summary>
|
||||
public sealed class DateTimeOffsetToDateTimeConverter : IValueConverter
|
||||
{
|
||||
public static readonly DateTimeOffsetToDateTimeConverter Instance = new();
|
||||
|
||||
public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture) =>
|
||||
value is DateTimeOffset offset ? offset.DateTime : null;
|
||||
|
||||
public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) =>
|
||||
value is DateTime dateTime ? new DateTimeOffset(DateTime.SpecifyKind(dateTime, DateTimeKind.Local)) : null;
|
||||
}
|
||||
@@ -10,6 +10,7 @@
|
||||
<ProjectReference Include="..\LehrerApp.Data\LehrerApp.Data.csproj" />
|
||||
<ProjectReference Include="..\LehrerApp.Sync\LehrerApp.Sync.csproj" />
|
||||
<ProjectReference Include="..\LehrerApp.WebUntis\LehrerApp.WebUntis.csproj" />
|
||||
<ProjectReference Include="..\LehrerApp.Templating\LehrerApp.Templating.csproj" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Avalonia" />
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
using System.Text.Json;
|
||||
|
||||
namespace LehrerApp.Desktop.Services;
|
||||
|
||||
internal sealed class BackupSettingsConfig
|
||||
{
|
||||
public string? SecondaryDirectory { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>Merkt sich einen optionalen zweiten Sicherungsordner (13.3.1 Nachtrag) — z.B. ein
|
||||
/// USB-Stick oder Netzlaufwerk, damit ein Backup nicht ausschließlich neben der Live-Datenbank
|
||||
/// liegt und bei Plattenausfall oder gelöschtem Profilverzeichnis mit verloren geht. Gleiches
|
||||
/// Muster wie <see cref="AppearanceSettingsService"/>. Das eigentliche Kopieren übernimmt
|
||||
/// <see cref="LehrerApp.Core.Services.BackupService"/> bewusst best-effort.</summary>
|
||||
public sealed class BackupSettingsService
|
||||
{
|
||||
private readonly string _configPath;
|
||||
|
||||
public BackupSettingsService(string appDataPath) =>
|
||||
_configPath = Path.Combine(appDataPath, "backupsettings.json");
|
||||
|
||||
public string? LoadSecondaryDirectory()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (File.Exists(_configPath))
|
||||
return JsonSerializer.Deserialize<BackupSettingsConfig>(File.ReadAllText(_configPath))
|
||||
?.SecondaryDirectory;
|
||||
}
|
||||
catch { /* beschädigte Konfiguration -> kein zweites Ziel */ }
|
||||
return null;
|
||||
}
|
||||
|
||||
public void SaveSecondaryDirectory(string? path) =>
|
||||
File.WriteAllText(_configPath, JsonSerializer.Serialize(new BackupSettingsConfig { SecondaryDirectory = path }));
|
||||
}
|
||||
@@ -5,6 +5,7 @@ namespace LehrerApp.Desktop.Services;
|
||||
public sealed class WebUntisIntegrationException(string message) : Exception(message);
|
||||
|
||||
public sealed record UntisSchoolYearDto(int UntisId, string Name, int StartDate, int EndDate);
|
||||
public sealed record UntisHolidayDto(int UntisId, string Name, string? LongName, int StartDate, int EndDate);
|
||||
public sealed record UntisClassDto(int UntisId, string Name, string? LongName);
|
||||
public sealed record UntisTeacherDto(int UntisId, string Name, string? ForeName, string? LongName, string? Title,
|
||||
bool Active, IReadOnlyList<int> DepartmentUntisIds)
|
||||
@@ -81,6 +82,10 @@ public sealed class WebUntisIntegrationService(HttpClient http, WebUntisSettings
|
||||
async client => (IReadOnlyList<UntisSchoolYearDto>)(await client.GetSchoolYearsAsync(token))
|
||||
.Select(x => new UntisSchoolYearDto(x.UntisId, x.Name, x.StartDate, x.EndDate)).ToList(), token);
|
||||
|
||||
public Task<IReadOnlyList<UntisHolidayDto>> GetHolidaysAsync(CancellationToken token = default) => ExecuteAsync(
|
||||
async client => (IReadOnlyList<UntisHolidayDto>)(await client.GetHolidaysAsync(token))
|
||||
.Select(x => new UntisHolidayDto(x.UntisId, x.Name, x.LongName, x.StartDate, x.EndDate)).ToList(), token);
|
||||
|
||||
public Task<IReadOnlyList<UntisClassDto>> GetClassesAsync(int schoolYearId, CancellationToken token = default) => ExecuteAsync(
|
||||
async client => (IReadOnlyList<UntisClassDto>)(await client.GetClassesAsync(schoolYearId, token))
|
||||
.Select(x => new UntisClassDto(x.UntisId, x.Name, x.LongName)).ToList(), token);
|
||||
|
||||
@@ -13,10 +13,17 @@ internal class WebUntisSettingsConfig
|
||||
public int? TeacherUntisId { get; set; }
|
||||
public int? HomeroomClassUntisId { get; set; }
|
||||
public string? HomeroomClassName { get; set; }
|
||||
public List<CachedUntisHoliday>? CachedHolidays { get; set; }
|
||||
public DateTime? HolidaysFetchedAt { get; set; }
|
||||
}
|
||||
|
||||
public sealed record WebUntisCredentials(string School, string Host, string Username, string Password);
|
||||
|
||||
/// <summary>Ferienzeitraum aus WebUntis' <c>getHolidays</c>-Bericht, hier auf die für die
|
||||
/// Fehlquoten-Berechnung ("Klassenlehrer"-Feature) relevanten Felder reduziert. Kein Geheimnis
|
||||
/// (anders als iCal-URL/API-Zugangsdaten in dieser Datei), deshalb unverschlüsselt gecacht.</summary>
|
||||
public sealed record CachedUntisHoliday(string Name, DateOnly Start, DateOnly End);
|
||||
|
||||
/// <summary>
|
||||
/// Einstellungen für den WebUntis-iCal-Abgleich (Nutzer-Feedback, siehe TODO.md). Liegt wie
|
||||
/// AiSettingsService/SyncSettingsService in LehrerApp.Desktop statt LehrerApp.Core, da die
|
||||
@@ -115,6 +122,16 @@ public class WebUntisSettingsService
|
||||
Save();
|
||||
}
|
||||
|
||||
public DateTime? HolidaysFetchedAt => _config.HolidaysFetchedAt;
|
||||
public IReadOnlyList<CachedUntisHoliday>? GetCachedHolidays() => _config.CachedHolidays;
|
||||
|
||||
public void SetCachedHolidays(IReadOnlyList<CachedUntisHoliday> holidays, DateTime at)
|
||||
{
|
||||
_config.CachedHolidays = holidays.ToList();
|
||||
_config.HolidaysFetchedAt = at;
|
||||
Save();
|
||||
}
|
||||
|
||||
private byte[] GenerateAndSaveKey()
|
||||
{
|
||||
var key = SyncCrypto.GenerateKey();
|
||||
|
||||
@@ -0,0 +1,249 @@
|
||||
using System.Collections.ObjectModel;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using LehrerApp.Core.Interfaces;
|
||||
using LehrerApp.Core.Models;
|
||||
using LehrerApp.Desktop.Services;
|
||||
using LehrerApp.Desktop.ViewModels.Students;
|
||||
|
||||
namespace LehrerApp.Desktop.ViewModels.ClassTeacher;
|
||||
|
||||
/// <summary>
|
||||
/// "Vorgänge"-Tab der Klassenlehreransicht: Fallmappen für konkrete, laufende Probleme mit einer/
|
||||
/// einem oder mehreren Schüler*innen der Klasse, die Dokumentation und (eingefrorene) WebUntis-
|
||||
/// Klassenbucheinträge bündeln. Löst wie <see cref="ClassTeacherDetailsViewModel"/> die Untis-
|
||||
/// Roster-Namen der Klasse auf lokale <see cref="Student"/>-Datensätze auf (bewusst ein eigener,
|
||||
/// nicht geteilter Abgleich — dasselbe kleine Muster steckt schon zweimal im Code, siehe
|
||||
/// <see cref="ClassTeacherOverviewViewModel.MatchStudent"/>, eine gemeinsame Abstraktion dafür ist
|
||||
/// hier nicht Teil der Aufgabe).
|
||||
/// </summary>
|
||||
public partial class ClassTeacherCasesViewModel : ObservableObject
|
||||
{
|
||||
private readonly IVorgangRepository _vorgaenge;
|
||||
private readonly IDocumentationRepository _documentation;
|
||||
private readonly IStudentRepository _students;
|
||||
private readonly UntisReportCacheService _cache;
|
||||
|
||||
private string _className = "";
|
||||
private List<StudentOption> _rosterStudentOptions = [];
|
||||
|
||||
public ObservableCollection<VorgangItem> Cases { get; } = [];
|
||||
|
||||
[ObservableProperty] private bool _onlyOpen = true;
|
||||
[ObservableProperty] private bool _busy;
|
||||
[ObservableProperty] private string _status = "";
|
||||
|
||||
public bool HasCases => Cases.Count > 0;
|
||||
|
||||
public Func<List<StudentOption>, Vorgang?, Task<Vorgang?>>? OnEditVorgang { get; set; }
|
||||
public Func<VorgangItem, Task<bool>>? OnConfirmDeleteVorgang { get; set; }
|
||||
public Func<Guid, List<StudentOption>, Documentation?, Task<Documentation?>>? OnEditDocumentation { get; set; }
|
||||
|
||||
public ClassTeacherCasesViewModel(IVorgangRepository vorgaenge, IDocumentationRepository documentation,
|
||||
IStudentRepository students, UntisReportCacheService cache)
|
||||
{
|
||||
_vorgaenge = vorgaenge;
|
||||
_documentation = documentation;
|
||||
_students = students;
|
||||
_cache = cache;
|
||||
}
|
||||
|
||||
public void Initialize(string className)
|
||||
{
|
||||
_className = className;
|
||||
Cases.Clear();
|
||||
Status = "Wird geladen…";
|
||||
NotifyState();
|
||||
_ = LoadInternal();
|
||||
}
|
||||
|
||||
partial void OnOnlyOpenChanged(bool value) => _ = LoadInternal();
|
||||
|
||||
[RelayCommand]
|
||||
private Task Refresh() => LoadInternal();
|
||||
|
||||
private async Task LoadInternal()
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(_className)) return;
|
||||
|
||||
// IsRevealed übersteht den Neuaufbau der Liste nicht automatisch (neue VorgangItem-Instanzen
|
||||
// pro Load) - deshalb hier gemerkt und danach wiederhergestellt, sonst klappt eine gerade
|
||||
// aufgeklappte Karte bei jeder Verknüpfungs-/Status-Aktion (die intern neu lädt) wieder zu.
|
||||
var revealedIds = Cases.Where(c => c.IsRevealed).Select(c => c.Model.Id).ToHashSet();
|
||||
|
||||
Busy = true;
|
||||
Cases.Clear();
|
||||
NotifyState();
|
||||
try
|
||||
{
|
||||
var roster = await _cache.GetStudentRosterAsync(_className);
|
||||
var localStudents = _students.GetAll();
|
||||
var matches = roster
|
||||
.Select(r => (Roster: r, Student: ClassTeacherOverviewViewModel.MatchStudent(r.DisplayName, localStudents)))
|
||||
.Where(x => x.Student is not null)
|
||||
.Select(x => (StudentId: x.Student!.Id, DisplayName: x.Roster.DisplayName))
|
||||
.DistinctBy(x => x.StudentId)
|
||||
.ToList();
|
||||
_rosterStudentOptions = matches.Select(m => new StudentOption(m.StudentId, m.DisplayName)).ToList();
|
||||
var rosterIds = matches.Select(m => m.StudentId).ToHashSet();
|
||||
var nameById = matches.ToDictionary(m => m.StudentId, m => m.DisplayName);
|
||||
|
||||
var relevant = _vorgaenge.GetAll().Where(v => v.StudentIds.Any(rosterIds.Contains));
|
||||
if (OnlyOpen) relevant = relevant.Where(v => v.Status == VorgangStatus.Open);
|
||||
|
||||
var allDocs = _documentation.GetAll();
|
||||
foreach (var vorgang in relevant.OrderByDescending(v => v.UpdatedAt))
|
||||
{
|
||||
var names = vorgang.StudentIds.Select(id => nameById.GetValueOrDefault(id, ""))
|
||||
.Where(n => n != "");
|
||||
var item = new VorgangItem(vorgang, string.Join(", ", names), LinkDocumentation,
|
||||
UnlinkDocumentation, RemoveClassRegisterEntry, EditDocumentation)
|
||||
{ IsRevealed = revealedIds.Contains(vorgang.Id) };
|
||||
|
||||
var linkedIds = vorgang.DocumentationIds.ToHashSet();
|
||||
foreach (var doc in allDocs.Where(d => linkedIds.Contains(d.Id)))
|
||||
item.LinkedDocumentation.Add(new DocumentationItem(doc, nameById.GetValueOrDefault(doc.StudentId, "")));
|
||||
foreach (var doc in FilterAvailableDocumentation(allDocs, vorgang))
|
||||
item.AvailableDocumentation.Add(new DocumentationItem(doc, nameById.GetValueOrDefault(doc.StudentId, "")));
|
||||
foreach (var entry in vorgang.ClassRegisterEntries.OrderByDescending(e => e.Date))
|
||||
item.ClassRegisterRows.Add(new VorgangClassRegisterEntryRow(entry));
|
||||
|
||||
Cases.Add(item);
|
||||
}
|
||||
Status = OnlyOpen ? $"{Cases.Count} offene Vorgänge" : $"{Cases.Count} Vorgänge";
|
||||
}
|
||||
finally { Busy = false; NotifyState(); }
|
||||
}
|
||||
|
||||
private void NotifyState() => OnPropertyChanged(nameof(HasCases));
|
||||
|
||||
[RelayCommand]
|
||||
private async Task AddVorgang()
|
||||
{
|
||||
if (OnEditVorgang is null) return;
|
||||
var result = await OnEditVorgang(_rosterStudentOptions, null);
|
||||
if (result is null) return;
|
||||
_vorgaenge.Save(result);
|
||||
await LoadInternal();
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private async Task EditVorgang(VorgangItem? item)
|
||||
{
|
||||
if (item is null || OnEditVorgang is null) return;
|
||||
var result = await OnEditVorgang(_rosterStudentOptions, item.Model);
|
||||
if (result is null) return;
|
||||
_vorgaenge.Save(result);
|
||||
await LoadInternal();
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private async Task ToggleStatus(VorgangItem? item)
|
||||
{
|
||||
if (item is null) return;
|
||||
item.Model.Status = item.Model.Status == VorgangStatus.Open ? VorgangStatus.Closed : VorgangStatus.Open;
|
||||
item.Model.ClosedAt = item.Model.Status == VorgangStatus.Closed ? DateTime.UtcNow : null;
|
||||
_vorgaenge.Save(item.Model);
|
||||
await LoadInternal();
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private async Task DeleteVorgang(VorgangItem? item)
|
||||
{
|
||||
if (item is null) return;
|
||||
if (OnConfirmDeleteVorgang is not null && !await OnConfirmDeleteVorgang(item)) return;
|
||||
_vorgaenge.Delete(item.Model.Id);
|
||||
await LoadInternal();
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private async Task CreateAndLinkDocumentation(VorgangItem? item)
|
||||
{
|
||||
if (item is null || OnEditDocumentation is null) return;
|
||||
var studentOptions = _rosterStudentOptions.Where(s => item.Model.StudentIds.Contains(s.Id)).ToList();
|
||||
var defaultStudentId = item.Model.StudentIds.FirstOrDefault();
|
||||
var result = await OnEditDocumentation(defaultStudentId, studentOptions, null);
|
||||
if (result is null) return;
|
||||
_documentation.Save(result);
|
||||
item.Model.DocumentationIds.Add(result.Id);
|
||||
_vorgaenge.Save(item.Model);
|
||||
await LoadInternal();
|
||||
}
|
||||
|
||||
/// Öffnet eine verknüpfte oder noch verknüpfbare Dokumentation im vollen Bearbeiten-Dialog, ohne
|
||||
/// den Vorgänge-Tab zu verlassen (Nutzer-Feedback: bisher musste man dafür zum Nachlesen in den
|
||||
/// Schülerdatensatz wechseln). Reine Bearbeitung der Dokumentation selbst — welche Vorgänge sie
|
||||
/// verlinken, ändert sich dadurch nicht.
|
||||
private async Task EditDocumentation(DocumentationItem doc)
|
||||
{
|
||||
if (OnEditDocumentation is null) return;
|
||||
var result = await OnEditDocumentation(doc.Model.StudentId, _rosterStudentOptions, doc.Model);
|
||||
if (result is null) return;
|
||||
_documentation.Save(result);
|
||||
await LoadInternal();
|
||||
}
|
||||
|
||||
/// Dokumentation der Vorgangs-Schüler*innen, die noch nicht mit diesem Vorgang verknüpft ist —
|
||||
/// reine, ohne Repository-Zugriff testbare Filterlogik (gleiches Muster wie
|
||||
/// <see cref="ClassTeacherRosterRow.Build"/>).
|
||||
public static List<Documentation> FilterAvailableDocumentation(IReadOnlyList<Documentation> allDocs, Vorgang vorgang)
|
||||
{
|
||||
var linkedIds = vorgang.DocumentationIds.ToHashSet();
|
||||
return allDocs.Where(d => vorgang.StudentIds.Contains(d.StudentId) && !linkedIds.Contains(d.Id)).ToList();
|
||||
}
|
||||
|
||||
private async Task LinkDocumentation(VorgangItem item, DocumentationItem doc)
|
||||
{
|
||||
item.Model.DocumentationIds.Add(doc.Model.Id);
|
||||
_vorgaenge.Save(item.Model);
|
||||
await LoadInternal();
|
||||
}
|
||||
|
||||
private async Task UnlinkDocumentation(VorgangItem item, DocumentationItem doc)
|
||||
{
|
||||
item.Model.DocumentationIds.Remove(doc.Model.Id);
|
||||
_vorgaenge.Save(item.Model);
|
||||
await LoadInternal();
|
||||
}
|
||||
|
||||
private async Task RemoveClassRegisterEntry(VorgangItem item, VorgangClassRegisterEntryRow row)
|
||||
{
|
||||
item.Model.ClassRegisterEntries.Remove(row.Model);
|
||||
_vorgaenge.Save(item.Model);
|
||||
await LoadInternal();
|
||||
}
|
||||
|
||||
// ── Anheften eines Klassenbuch-Eintrags aus dem Klassenbuch-Tab (ClassTeacherDetailsViewModel,
|
||||
// per Konstruktor-Injection dieser Instanz — kein Func-Hook, da hier keine UI im Spiel ist,
|
||||
// nur Zugriff auf schon geladene Daten dieser Geschwister-ViewModel-Instanz) ────────────────
|
||||
|
||||
public IReadOnlyList<StudentOption> RosterStudentOptions => _rosterStudentOptions;
|
||||
|
||||
/// Offene Vorgänge, an die sich ein Klassenbuch-Eintrag für diese/n Schüler*in anheften lässt
|
||||
/// (Namensabgleich wie beim restlichen Klassenlehrer-Bereich, siehe UntisNameMatching).
|
||||
public List<VorgangItem> OpenCasesForStudent(string untisDisplayName)
|
||||
{
|
||||
var match = _rosterStudentOptions.FirstOrDefault(s =>
|
||||
UntisNameMatching.NamesMatch(s.Name, untisDisplayName));
|
||||
if (match is null) return [];
|
||||
return Cases.Where(c => c.IsOpen && c.Model.StudentIds.Contains(match.Id)).ToList();
|
||||
}
|
||||
|
||||
public async Task PinClassRegisterEntryAsync(Guid vorgangId, VorgangClassRegisterEntry entry)
|
||||
{
|
||||
var vorgang = _vorgaenge.GetById(vorgangId);
|
||||
if (vorgang is null) return;
|
||||
vorgang.ClassRegisterEntries.Add(entry);
|
||||
_vorgaenge.Save(vorgang);
|
||||
await LoadInternal();
|
||||
}
|
||||
|
||||
public async Task<Vorgang> CreateAndPinAsync(string title, Guid studentId, VorgangClassRegisterEntry entry)
|
||||
{
|
||||
var vorgang = new Vorgang { Title = title, StudentIds = [studentId] };
|
||||
vorgang.ClassRegisterEntries.Add(entry);
|
||||
_vorgaenge.Save(vorgang);
|
||||
await LoadInternal();
|
||||
return vorgang;
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,10 @@
|
||||
using System.Collections.ObjectModel;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using LehrerApp.Core.Interfaces;
|
||||
using LehrerApp.Core.Models;
|
||||
using LehrerApp.Desktop.Services;
|
||||
using LehrerApp.Desktop.ViewModels.Students;
|
||||
|
||||
namespace LehrerApp.Desktop.ViewModels.ClassTeacher;
|
||||
|
||||
@@ -130,29 +133,63 @@ public sealed record ClassAbsenceDaySummaryRow(DateOnly Date, string StudentName
|
||||
public partial class ClassTeacherDetailsViewModel : ObservableObject
|
||||
{
|
||||
private readonly UntisReportCacheService _cache;
|
||||
private readonly IDocumentationRepository _documentation;
|
||||
private readonly IStudentRepository _students;
|
||||
private readonly ClassTeacherCasesViewModel _cases;
|
||||
private string _className = "";
|
||||
private DateOnly _loadedStart;
|
||||
private DateOnly _loadedEnd;
|
||||
/// Schüler*innen der Klasse, per Namensabgleich (<see cref="UntisNameMatching"/>) den lokalen
|
||||
/// <see cref="Student"/>-Datensätzen zugeordnet — Grundlage für die "Eigene Dokumentation"-Ansicht,
|
||||
/// die es (anders als der WebUntis-Klassenbuchbericht) nur lokal gibt. Nach jedem <see cref="LoadInternal"/>
|
||||
/// neu aufgebaut.
|
||||
private List<(Guid StudentId, string DisplayName)> _rosterMatches = [];
|
||||
|
||||
public ObservableCollection<ClassTeacherClassRegisterRow> Entries { get; } = [];
|
||||
public ObservableCollection<ClassAbsenceDaySummaryRow> AbsenceEntries { get; } = [];
|
||||
public ObservableCollection<ClassTeacherCategoryAggregateRow> CategoryAggregates { get; } = [];
|
||||
public ObservableCollection<DocumentationItem> OwnDocumentationEntries { get; } = [];
|
||||
|
||||
[ObservableProperty] private DateTimeOffset _startDate = DateTimeOffset.Now.AddDays(-6);
|
||||
[ObservableProperty] private DateTimeOffset _endDate = DateTimeOffset.Now;
|
||||
/// Für das Kontextmenü "→ An Vorgang anheften" (DataGrid.SelectedItem, zweigleisig gebunden).
|
||||
[ObservableProperty] private ClassTeacherClassRegisterRow? _selectedEntry;
|
||||
|
||||
[ObservableProperty] private DateTimeOffset? _startDate = DateTimeOffset.Now.AddDays(-6);
|
||||
[ObservableProperty] private DateTimeOffset? _endDate = DateTimeOffset.Now;
|
||||
[ObservableProperty] private string _status = "Zeitraum wählen und laden.";
|
||||
[ObservableProperty] private bool _busy;
|
||||
/// Von der Übersicht gesetzt (Klick auf eine Roster-Zeile) - leer zeigt alle Schüler*innen.
|
||||
[ObservableProperty] private string _studentFilter = "";
|
||||
[ObservableProperty] private int _quickRangeIndex = 1;
|
||||
/// Umschalter Klassenbuch (WebUntis, andere Lehrkräfte) ↔ eigene Dokumentation.
|
||||
[ObservableProperty] private bool _showOwnDocumentation;
|
||||
[ObservableProperty] private int _ownDocumentationCount;
|
||||
[ObservableProperty] private int _ownDocumentationFollowUpCount;
|
||||
[ObservableProperty] private int _ownDocumentationCriticalCount;
|
||||
|
||||
public Func<List<StudentOption>, Documentation?, Task<Documentation?>>? OnEditOwnDocumentation { get; set; }
|
||||
public Func<DocumentationItem, Task<bool>>? OnConfirmDeleteOwnDocumentation { get; set; }
|
||||
/// Zeigt die "→ Vorgang"-Auswahl für eine Klassenbuchzeile: bestehenden offenen Vorgang wählen
|
||||
/// oder einen neuen Titel eingeben. Liefert null bei Abbruch.
|
||||
public Func<ClassTeacherClassRegisterRow, Task<PinToVorgangChoice?>>? OnPickVorgangForPin { get; set; }
|
||||
|
||||
public bool HasEntries => Entries.Count > 0;
|
||||
public bool HasAbsenceEntries => AbsenceEntries.Count > 0;
|
||||
public bool HasCategoryAggregates => CategoryAggregates.Count > 0;
|
||||
public bool HasOwnDocumentationEntries => OwnDocumentationEntries.Count > 0;
|
||||
/// Die Kategorien-Chipreihe fasst nur WebUntis-Kategorien zusammen (<see cref="CategoryAggregates"/>)
|
||||
/// — im Modus "Eigene Dokumentation" ausgeblendet, dort gibt es kein Äquivalent zu CategoryGroup.
|
||||
public bool ShowCategoryAggregates => HasCategoryAggregates && !ShowOwnDocumentation;
|
||||
public int UntisCriticalCount => Entries.Count(e => e.IsDangerStatus);
|
||||
public string ActiveFilterLabel => string.IsNullOrWhiteSpace(StudentFilter)
|
||||
? "Alle Schüler*innen" : StudentFilter;
|
||||
|
||||
public ClassTeacherDetailsViewModel(UntisReportCacheService cache)
|
||||
public ClassTeacherDetailsViewModel(UntisReportCacheService cache, IDocumentationRepository documentation,
|
||||
IStudentRepository students, ClassTeacherCasesViewModel cases)
|
||||
{
|
||||
_cache = cache;
|
||||
_documentation = documentation;
|
||||
_students = students;
|
||||
_cases = cases;
|
||||
}
|
||||
|
||||
public void Initialize(string className)
|
||||
@@ -162,12 +199,16 @@ public partial class ClassTeacherDetailsViewModel : ObservableObject
|
||||
Entries.Clear();
|
||||
AbsenceEntries.Clear();
|
||||
CategoryAggregates.Clear();
|
||||
OwnDocumentationEntries.Clear();
|
||||
_rosterMatches = [];
|
||||
Status = "Zeitraum wählen und laden.";
|
||||
NotifyListState();
|
||||
}
|
||||
|
||||
partial void OnStudentFilterChanged(string value) => OnPropertyChanged(nameof(ActiveFilterLabel));
|
||||
|
||||
partial void OnShowOwnDocumentationChanged(bool value) => OnPropertyChanged(nameof(ShowCategoryAggregates));
|
||||
|
||||
partial void OnQuickRangeIndexChanged(int value)
|
||||
{
|
||||
var days = value switch { 0 => 0, 1 => 6, 2 => 29, _ => 6 };
|
||||
@@ -178,6 +219,9 @@ public partial class ClassTeacherDetailsViewModel : ObservableObject
|
||||
[RelayCommand]
|
||||
private Task Load() => LoadInternal(forceRefresh: false);
|
||||
|
||||
[RelayCommand] private void ShowUntisRegister() => ShowOwnDocumentation = false;
|
||||
[RelayCommand] private void ShowOwnDocs() => ShowOwnDocumentation = true;
|
||||
|
||||
/// Umgeht bewusst die Stunden-Sperre von <see cref="UntisReportCacheService"/> — für den Fall,
|
||||
/// dass man sicher weiß, dass sich seit dem letzten automatischen Abruf etwas geändert hat.
|
||||
[RelayCommand]
|
||||
@@ -195,17 +239,20 @@ public partial class ClassTeacherDetailsViewModel : ObservableObject
|
||||
|
||||
private async Task LoadInternal(bool forceRefresh)
|
||||
{
|
||||
var start = DateOnly.FromDateTime(StartDate.LocalDateTime);
|
||||
var end = DateOnly.FromDateTime(EndDate.LocalDateTime);
|
||||
var start = DateOnly.FromDateTime((StartDate ?? DateTimeOffset.Now).LocalDateTime);
|
||||
var end = DateOnly.FromDateTime((EndDate ?? DateTimeOffset.Now).LocalDateTime);
|
||||
if (end < start) { Status = "Das Enddatum darf nicht vor dem Startdatum liegen."; return; }
|
||||
if (string.IsNullOrWhiteSpace(_className)) { Status = "Keine Klasse ausgewählt."; return; }
|
||||
|
||||
Busy = true; Entries.Clear(); AbsenceEntries.Clear(); CategoryAggregates.Clear(); NotifyListState();
|
||||
Busy = true;
|
||||
Entries.Clear(); AbsenceEntries.Clear(); CategoryAggregates.Clear(); OwnDocumentationEntries.Clear();
|
||||
NotifyListState();
|
||||
try
|
||||
{
|
||||
var classRegisterTask = _cache.GetClassRegisterEventsAsync(_className, start, end, forceRefresh);
|
||||
var absencesTask = _cache.GetAbsencesAsync(_className, start, end, forceRefresh);
|
||||
await Task.WhenAll(classRegisterTask, absencesTask);
|
||||
var rosterTask = _cache.GetStudentRosterAsync(_className);
|
||||
await Task.WhenAll(classRegisterTask, absencesTask, rosterTask);
|
||||
|
||||
var ordered = classRegisterTask.Result
|
||||
.Where(MatchesStudentFilter)
|
||||
@@ -223,14 +270,139 @@ public partial class ClassTeacherDetailsViewModel : ObservableObject
|
||||
foreach (var row in ClassAbsenceDaySummaryRow.GroupByStudentAndDay(absences))
|
||||
AbsenceEntries.Add(row);
|
||||
|
||||
var localStudents = _students.GetAll();
|
||||
_rosterMatches = rosterTask.Result
|
||||
.Select(r => (Roster: r, Student: ClassTeacherOverviewViewModel.MatchStudent(r.DisplayName, localStudents)))
|
||||
.Where(x => x.Student is not null)
|
||||
.Select(x => (StudentId: x.Student!.Id, DisplayName: x.Roster.DisplayName))
|
||||
.DistinctBy(x => x.StudentId)
|
||||
.ToList();
|
||||
_loadedStart = start;
|
||||
_loadedEnd = end;
|
||||
LoadOwnDocumentationEntries();
|
||||
|
||||
Status = $"{Entries.Count} Klassenbucheinträge anderer Lehrkräfte, " +
|
||||
$"{AbsenceEntries.Count} Fehlzeiten-Tage im Zeitraum.";
|
||||
$"{AbsenceEntries.Count} Fehlzeiten-Tage, {OwnDocumentationEntries.Count} eigene Dokumentation im Zeitraum.";
|
||||
NotifyListState();
|
||||
}
|
||||
catch (WebUntisIntegrationException ex) { Status = ex.Message; }
|
||||
finally { Busy = false; NotifyListState(); }
|
||||
}
|
||||
|
||||
/// Baut <see cref="OwnDocumentationEntries"/> aus <see cref="_rosterMatches"/> und dem zuletzt
|
||||
/// geladenen Zeitraum neu auf — separat von <see cref="LoadInternal"/>, damit Anlegen/Bearbeiten/
|
||||
/// Löschen eines eigenen Eintrags nicht auch die WebUntis-Berichte neu abruft. Die eigentliche
|
||||
/// Filter-/Sortierlogik steckt in der reinen, ohne Repository-Zugriff testbaren
|
||||
/// <see cref="FilterOwnDocumentation"/> — analog zu <see cref="ClassTeacherRosterRow.Build"/>.
|
||||
private void LoadOwnDocumentationEntries()
|
||||
{
|
||||
OwnDocumentationEntries.Clear();
|
||||
var entries = FilterOwnDocumentation(_documentation.GetAll(), _rosterMatches,
|
||||
_loadedStart, _loadedEnd, StudentFilter);
|
||||
foreach (var d in entries)
|
||||
{
|
||||
var name = _rosterMatches.First(m => m.StudentId == d.StudentId).DisplayName;
|
||||
OwnDocumentationEntries.Add(new DocumentationItem(d, name));
|
||||
}
|
||||
OwnDocumentationCount = OwnDocumentationEntries.Count;
|
||||
OwnDocumentationFollowUpCount = entries.Count(d => d.IsDraft);
|
||||
OwnDocumentationCriticalCount = entries.Count(d =>
|
||||
d.Tags.Any(t => string.Equals(t, "Kritisch", StringComparison.OrdinalIgnoreCase)));
|
||||
OnPropertyChanged(nameof(HasOwnDocumentationEntries));
|
||||
}
|
||||
|
||||
/// Eigene Dokumentation zu Schüler*innen der Klasse im gewählten Zeitraum: nur Einträge
|
||||
/// (nicht gelöschter) Schüler*innen, die sich per Namensabgleich der WebUntis-Klasse zuordnen
|
||||
/// ließen (<paramref name="rosterMatches"/>, siehe <see cref="ClassTeacherOverviewViewModel.MatchStudent"/>),
|
||||
/// mit demselben Schülerfilter wie die WebUntis-Klassenbuchzeilen. Entwürfe ("Nacharbeiten")
|
||||
/// zuerst, danach neueste zuerst.
|
||||
public static List<Documentation> FilterOwnDocumentation(IReadOnlyList<Documentation> all,
|
||||
IReadOnlyList<(Guid StudentId, string DisplayName)> rosterMatches,
|
||||
DateOnly start, DateOnly end, string studentFilter)
|
||||
{
|
||||
var matchedIds = rosterMatches.Select(m => m.StudentId).ToHashSet();
|
||||
return all
|
||||
.Where(d => !d.IsDeleted && matchedIds.Contains(d.StudentId) && d.Date >= start && d.Date <= end)
|
||||
.Where(d => MatchesOwnDocStudentFilter(d, rosterMatches, studentFilter))
|
||||
.OrderByDescending(d => d.IsDraft).ThenByDescending(d => d.Date)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
private static bool MatchesOwnDocStudentFilter(Documentation d,
|
||||
IReadOnlyList<(Guid StudentId, string DisplayName)> rosterMatches, string studentFilter)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(studentFilter)) return true;
|
||||
var name = rosterMatches.FirstOrDefault(m => m.StudentId == d.StudentId).DisplayName;
|
||||
return name is not null && UntisNameMatching.NamesMatch(name, studentFilter);
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private async Task AddOwnDocumentation()
|
||||
{
|
||||
if (OnEditOwnDocumentation is null) return;
|
||||
var options = _rosterMatches.Select(m => new StudentOption(m.StudentId, m.DisplayName)).ToList();
|
||||
var result = await OnEditOwnDocumentation(options, null);
|
||||
if (result is null) return;
|
||||
_documentation.Save(result);
|
||||
LoadOwnDocumentationEntries();
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private async Task EditOwnDocumentation(DocumentationItem? item)
|
||||
{
|
||||
if (item is null || OnEditOwnDocumentation is null) return;
|
||||
var options = _rosterMatches.Select(m => new StudentOption(m.StudentId, m.DisplayName)).ToList();
|
||||
var result = await OnEditOwnDocumentation(options, item.Model);
|
||||
if (result is null) return;
|
||||
_documentation.Save(result);
|
||||
LoadOwnDocumentationEntries();
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private async Task DeleteOwnDocumentation(DocumentationItem? item)
|
||||
{
|
||||
if (item is null) return;
|
||||
if (OnConfirmDeleteOwnDocumentation is not null && !await OnConfirmDeleteOwnDocumentation(item)) return;
|
||||
_documentation.Delete(item.Model.Id);
|
||||
LoadOwnDocumentationEntries();
|
||||
}
|
||||
|
||||
/// Für den "→ Vorgang"-Auswahldialog im Code-behind: offene Vorgänge, an die sich diese
|
||||
/// Klassenbuchzeile anheften lässt (Durchreiche zur Geschwister-ViewModel-Instanz, siehe _cases).
|
||||
public List<VorgangItem> OpenCasesFor(string studentName) => _cases.OpenCasesForStudent(studentName);
|
||||
|
||||
/// Heftet eine WebUntis-Klassenbuchzeile als eingefrorene Kopie an einen (ggf. neuen) Vorgang
|
||||
/// im "Vorgänge"-Tab (<see cref="ClassTeacherCasesViewModel"/>) an — die Zeile selbst hat keine
|
||||
/// stabile ID, deshalb Werte-Kopie statt Referenz (siehe VorgangClassRegisterEntry).
|
||||
[RelayCommand]
|
||||
private async Task PinToVorgang(ClassTeacherClassRegisterRow? row)
|
||||
{
|
||||
if (row is null || OnPickVorgangForPin is null) return;
|
||||
var choice = await OnPickVorgangForPin(row);
|
||||
if (choice is null) return;
|
||||
|
||||
var entry = BuildClassRegisterSnapshot(row);
|
||||
|
||||
if (choice.ExistingVorgangId is { } vorgangId)
|
||||
await _cases.PinClassRegisterEntryAsync(vorgangId, entry);
|
||||
else if (!string.IsNullOrWhiteSpace(choice.NewVorgangTitle))
|
||||
{
|
||||
var student = _cases.RosterStudentOptions
|
||||
.FirstOrDefault(s => UntisNameMatching.NamesMatch(s.Name, row.StudentName));
|
||||
if (student is null) return;
|
||||
await _cases.CreateAndPinAsync(choice.NewVorgangTitle, student.Id, entry);
|
||||
}
|
||||
}
|
||||
|
||||
/// Baut die eingefrorene Klassenbuch-Kopie aus einer Zeile — reine, ohne Repository-Zugriff
|
||||
/// testbare Umwandlung.
|
||||
public static VorgangClassRegisterEntry BuildClassRegisterSnapshot(ClassTeacherClassRegisterRow row) => new()
|
||||
{
|
||||
Date = row.Date, StudentName = row.StudentName, Subject = row.Subject,
|
||||
TeacherUsername = row.TeacherUsername, CategoryName = row.CategoryName,
|
||||
CategoryGroup = row.CategoryGroup, Text = row.Text,
|
||||
};
|
||||
|
||||
// WebUntis liefert Namen je nach Bericht in anderer Reihenfolge als der Schülerreport, aus dem
|
||||
// StudentFilter beim Klick in der Übersicht gesetzt wird (siehe UntisNameMatching) - ein
|
||||
// exakter String-Vergleich hier ließ die gefilterten Listen fälschlich leer erscheinen.
|
||||
@@ -248,5 +420,8 @@ public partial class ClassTeacherDetailsViewModel : ObservableObject
|
||||
OnPropertyChanged(nameof(HasEntries));
|
||||
OnPropertyChanged(nameof(HasAbsenceEntries));
|
||||
OnPropertyChanged(nameof(HasCategoryAggregates));
|
||||
OnPropertyChanged(nameof(ShowCategoryAggregates));
|
||||
OnPropertyChanged(nameof(HasOwnDocumentationEntries));
|
||||
OnPropertyChanged(nameof(UntisCriticalCount));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,6 +14,10 @@ namespace LehrerApp.Desktop.ViewModels.ClassTeacher;
|
||||
/// ein hart codierter Hex-String aus dem ViewModel bliebe beim Wechsel unverändert stehen.</summary>
|
||||
public enum ClassTeacherStatusKind { Ok, Info, Warning, Danger }
|
||||
|
||||
/// <summary>Die drei Auffälligkeits-Achsen, die ein geschlossener <see cref="Vorgang"/> per
|
||||
/// Schlagwort gezielt dämpfen kann, siehe <see cref="ClassTeacherRosterRow.MatchDomains"/>.</summary>
|
||||
public enum VorgangScoreDomain { Attendance, Lateness, Classbook }
|
||||
|
||||
public sealed record ClassTeacherRosterRow(string StudentName, int? ExternKey, bool HasAbsenceToday,
|
||||
string? AbsenceTooltip, bool HasRecentClassRegisterEntry)
|
||||
{
|
||||
@@ -71,31 +75,189 @@ public sealed record ClassTeacherRosterRow(string StudentName, int? ExternKey, b
|
||||
};
|
||||
|
||||
/// Kumulierte Fehlzeiten seit Schuljahresbeginn (Nutzer-Feedback: der Heute-Snapshot allein
|
||||
/// sagt für Zeugnis/Attestpflicht wenig aus). <see cref="SchoolDaysElapsed"/> zählt nur
|
||||
/// Werktage, ohne Ferienkalender — eine bewusste Vereinfachung, siehe TODO.md 12.4-Nachtrag.
|
||||
/// sagt für Zeugnis/Attestpflicht wenig aus). <see cref="SchoolDaysElapsed"/> zählt Werktage
|
||||
/// abzüglich der über WebUntis geladenen Ferien (<see cref="ClassTeacherOverviewViewModel.CountSchoolWeekdays"/>).
|
||||
/// Zählt bewusst NUR echte Fehltage (unentschuldigt oder entschuldigt abwesend), keine reinen
|
||||
/// Verspätungstage mehr — Nutzer-Feedback: die Quote behandelte "10× 5 Min. verspätet" bisher
|
||||
/// exakt wie "10× ganztägig unentschuldigt gefehlt", obwohl beides für die eigene Reaktion
|
||||
/// (Elterngespräch vs. Achselzucken) grundverschieden ist. Reine Verspätung landet stattdessen
|
||||
/// separat in <see cref="YearLateDayCount"/> (gleiche Kategorisierung wie schon länger im
|
||||
/// Wochentrend, siehe <see cref="ClassTeacherOverviewViewModel.BuildTrend"/>: Unentschuldigt vor
|
||||
/// Verspätet vor Entschuldigt, überschneidungsfrei).
|
||||
public int YearAbsenceDayCount { get; init; }
|
||||
public int YearUnexcusedDayCount { get; init; }
|
||||
/// Tage, die WebUntis als Verspätung führt (ohne Unentschuldigt-Status) — zählen bewusst nicht
|
||||
/// in <see cref="YearAbsenceDayCount"/>/<see cref="YearAbsenceRatePercent"/>, tragen aber mit
|
||||
/// reduziertem Gewicht zu <see cref="PatternScore"/> bei.
|
||||
public int YearLateDayCount { get; init; }
|
||||
/// Entschuldigt abwesende Tage (weder unentschuldigt noch nur verspätet) — Kehrwert von
|
||||
/// <see cref="YearUnexcusedDayCount"/> innerhalb von <see cref="YearAbsenceDayCount"/>.
|
||||
public int YearExcusedDayCount { get; init; }
|
||||
/// Klassenbucheinträge anderer Lehrkräfte über das ganze bisherige Schuljahr mit
|
||||
/// <c>CategoryGroup</c> "Negativ" (Nutzer-Feedback: sollen ebenfalls ins Auffälligkeitsbild
|
||||
/// einfließen — "wenn die negativen Einträge durch die Decke gehen"). Anders als
|
||||
/// <see cref="HasRecentClassRegisterEntry"/> (nur letzte 7 Tage, für die "Klassenbuch"-Badges)
|
||||
/// bewusst über das ganze Jahr, damit sich ein Muster über die Zeit zeigen kann.
|
||||
public int YearNegativeClassRegisterCount { get; init; }
|
||||
/// Mindestens ein Klassenbucheintrag mit dem Schlüsselwort "suspendier(t)" im Zeitraum — soll
|
||||
/// den Score unabhängig von allem anderen sofort deutlich nach oben treiben (Nutzer-Feedback:
|
||||
/// "Suspendiert sollte direkt hochschießen").
|
||||
public bool HasSuspensionEntry { get; init; }
|
||||
/// Gewichtete Summe aus <see cref="ClassRegisterEntryWeight"/> über alle Klassenbucheinträge
|
||||
/// des Schuljahres — fließt in <see cref="PatternScore"/> ein.
|
||||
public double ClassRegisterScoreComponent { get; init; }
|
||||
public int SchoolDaysElapsed { get; init; }
|
||||
public bool HasYearSummary => SchoolDaysElapsed > 0 && YearAbsenceDayCount > 0;
|
||||
/// Nutzer-Feedback: nachdem der Nenner zeitweise Ferientage mitzählte (siehe TODO.md), soll die
|
||||
/// Herkunft der Zahl nachvollziehbar bleiben, ohne dafür die WebUntis-Ferienliste separat
|
||||
/// nachschlagen zu müssen — deshalb hier sichtbar im Tooltip statt nur intern verrechnet.
|
||||
public int HolidayWeekdaysExcluded { get; init; }
|
||||
/// Angenommener erster Unterrichtstag (<see cref="ClassTeacherOverviewViewModel.EstimateTermStart"/>),
|
||||
/// aus demselben Nachvollziehbarkeits-Grund wie <see cref="HolidayWeekdaysExcluded"/> im Tooltip
|
||||
/// sichtbar — WebUntis' Ferienkalender deckt die Sommerferien selbst nicht ab (siehe TODO.md),
|
||||
/// die Korrektur passiert also am Startpunkt, nicht an abgezogenen Tagen mittendrin.
|
||||
public DateOnly TermStart { get; init; }
|
||||
public bool HasYearSummary => SchoolDaysElapsed > 0 &&
|
||||
(YearAbsenceDayCount > 0 || YearLateDayCount > 0 || YearNegativeClassRegisterCount > 0);
|
||||
public int YearAbsenceRatePercent =>
|
||||
SchoolDaysElapsed <= 0 ? 0 : (int)Math.Round(100d * YearAbsenceDayCount / SchoolDaysElapsed);
|
||||
public string YearSummaryLabel => HasYearSummary
|
||||
? $"{YearAbsenceRatePercent} % Fehlzeit seit Schuljahresbeginn" : "";
|
||||
public string YearSummaryLabel => !HasYearSummary ? "" :
|
||||
YearAbsenceDayCount > 0 ? $"{YearAbsenceRatePercent} % Fehlzeit seit Schuljahresbeginn"
|
||||
: YearLateDayCount > 0 ? $"{YearLateDayCount}× verspätet, keine Fehltage seit Schuljahresbeginn"
|
||||
: $"{YearNegativeClassRegisterCount} negative Klassenbucheinträge seit Schuljahresbeginn";
|
||||
public string? YearSummaryTooltip => !HasYearSummary ? null :
|
||||
$"{YearAbsenceDayCount} von {SchoolDaysElapsed} Schultagen mit Fehlzeit" +
|
||||
(YearUnexcusedDayCount > 0 ? $" · {YearUnexcusedDayCount} unentschuldigt" : "");
|
||||
(YearAbsenceDayCount > 0
|
||||
? $"{YearAbsenceDayCount} von {SchoolDaysElapsed} Schultagen seit {TermStart:dd.MM.} mit Fehlzeit" +
|
||||
(YearUnexcusedDayCount > 0 ? $" · {YearUnexcusedDayCount} unentschuldigt" : "")
|
||||
: $"Keine Fehltage seit {TermStart:dd.MM.}") +
|
||||
(YearLateDayCount > 0 ? $" · {YearLateDayCount} Tage verspätet (zählt nicht zur Quote)" : "") +
|
||||
(YearNegativeClassRegisterCount > 0 ? $" · {YearNegativeClassRegisterCount} negative Klassenbucheinträge" : "") +
|
||||
(HasSuspensionEntry ? " · Suspendierung vermerkt" : "") +
|
||||
(HolidayWeekdaysExcluded > 0 ? $" · {HolidayWeekdaysExcluded} Ferientage abgezogen" : "");
|
||||
|
||||
private static readonly string[] ConcerningKeywords = ["hausaufgabe", "mitarbeit"];
|
||||
private static readonly string[] SuspensionKeywords = ["suspendier"];
|
||||
|
||||
/// Auffälligkeits-Score über das ganze bisherige Schuljahr statt nur "heute" (Nutzer-Feedback:
|
||||
/// wer heute da ist, aber davor 5 Tage unentschuldigt fehlte, verschwand bisher komplett aus der
|
||||
/// Liste, während der Musterschüler beim ersten Fehltag des Jahres auf Rang eins landete, sobald
|
||||
/// er der/die einzige "heute Auffällige" war). Reine Summe ohne zeitliche Abklingung — für die
|
||||
/// vom Nutzer genannten Fälle (Muster über die letzten Tage/Wochen) reicht das, ohne die
|
||||
/// zusätzliche Komplexität einer Verfallskurve. Bezieht neben Fehlzeiten auch Klassenbucheinträge
|
||||
/// anderer Lehrkräfte mit ein (<see cref="ClassRegisterScoreComponent"/>) — beides sind
|
||||
/// unabhängige Auffälligkeits-Achsen (Anwesenheit vs. Verhalten/Leistung), die sich addieren
|
||||
/// statt sich gegenseitig zu verdrängen. Gewichte sind einstellbar (<see cref="PatternScoreWeights"/>,
|
||||
/// Nutzer-Entscheidung nach anfänglich festen Konstanten) und die Bereiche eines geschlossenen
|
||||
/// <see cref="Vorgang"/> sind ab dessen Schließ-Datum aus dem Score ausgeklammert (<see cref="Build"/>),
|
||||
/// deshalb jetzt ein zur Build-Zeit berechneter Wert statt einer reinen Formel über die
|
||||
/// (unveränderten, für die Quote weiter vollständigen) Year*-Zähler.
|
||||
public double PatternScore { get; init; }
|
||||
|
||||
/// Score-Anteil, der NACH dem Schließen des zuletzt geschlossenen Vorgangs dieser/dieses
|
||||
/// Schülers*in neu hinzugekommen ist, beschränkt auf dessen eigene Themenbereiche
|
||||
/// (<see cref="MatchDomains"/>) — Grundlage für die "erneut auffällig"-Warnung. 0 ohne
|
||||
/// geschlossenen Vorgang oder ohne neue Ereignisse seitdem.
|
||||
public double RegressionScore { get; init; }
|
||||
public string? RegressionVorgangTitle { get; init; }
|
||||
|
||||
private static bool IsLateReason(string reason) =>
|
||||
reason.Contains("verspät", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
private static bool IsNegativeClassRegisterEntry(UntisForeignClassRegisterEventDto e) =>
|
||||
e.CategoryGroup?.Contains("Negativ", StringComparison.OrdinalIgnoreCase) == true;
|
||||
|
||||
private static bool MatchesAnyKeyword(UntisForeignClassRegisterEventDto e, IReadOnlyList<string> keywords) =>
|
||||
keywords.Any(k =>
|
||||
e.CategoryName?.Contains(k, StringComparison.OrdinalIgnoreCase) == true ||
|
||||
e.Text?.Contains(k, StringComparison.OrdinalIgnoreCase) == true);
|
||||
|
||||
private static double ClassRegisterEntryWeight(UntisForeignClassRegisterEventDto e, PatternScoreWeights weights)
|
||||
{
|
||||
var weight = 0.0;
|
||||
if (IsNegativeClassRegisterEntry(e)) weight += weights.NegativeClassRegisterEntryWeight;
|
||||
if (MatchesAnyKeyword(e, ConcerningKeywords)) weight += weights.ConcerningKeywordBonus;
|
||||
if (MatchesAnyKeyword(e, SuspensionKeywords)) weight += weights.SuspensionKeywordWeight;
|
||||
return weight;
|
||||
}
|
||||
|
||||
// ── Dämpfung nach geschlossenem Vorgang (Nutzer-Entscheidung: bereichsbezogen statt komplett) ──
|
||||
|
||||
private static readonly string[] AttendanceDomainKeywords = ["absent", "fehlzeit", "schwänz", "unentschuldigt"];
|
||||
private static readonly string[] LatenessDomainKeywords = ["versp"];
|
||||
private static readonly string[] ClassbookDomainKeywords =
|
||||
["konflikt", "eskalat", "mitarbeit", "hausaufgabe", "klassenbuch"];
|
||||
|
||||
/// Ordnet die Freitext-Schlagwörter eines Vorgangs den Score-Bereichen zu, die er dämpfen soll
|
||||
/// (gleiche Contains-Konvention wie die "Negativ"/"verspät"-Erkennung andernorts). Kein Treffer
|
||||
/// auf irgendein Schlagwort -> alle drei Bereiche, damit ein geschlossener Vorgang nie
|
||||
/// folgenlos bleibt (z.B. bei einem reinen "Elternkontakt"-Schlagwort ohne klare Zuordnung).
|
||||
public static IReadOnlySet<VorgangScoreDomain> MatchDomains(IReadOnlyList<string> tags)
|
||||
{
|
||||
var domains = new HashSet<VorgangScoreDomain>();
|
||||
if (tags.Any(t => AttendanceDomainKeywords.Any(k => t.Contains(k, StringComparison.OrdinalIgnoreCase))))
|
||||
domains.Add(VorgangScoreDomain.Attendance);
|
||||
if (tags.Any(t => LatenessDomainKeywords.Any(k => t.Contains(k, StringComparison.OrdinalIgnoreCase))))
|
||||
domains.Add(VorgangScoreDomain.Lateness);
|
||||
if (tags.Any(t => ClassbookDomainKeywords.Any(k => t.Contains(k, StringComparison.OrdinalIgnoreCase))))
|
||||
domains.Add(VorgangScoreDomain.Classbook);
|
||||
if (domains.Count == 0)
|
||||
domains = [VorgangScoreDomain.Attendance, VorgangScoreDomain.Lateness, VorgangScoreDomain.Classbook];
|
||||
return domains;
|
||||
}
|
||||
|
||||
/// Pro Bereich das jüngste Schließ-Datum unter den passenden geschlossenen Vorgängen — Ereignisse
|
||||
/// vor diesem Datum zählen für diesen Bereich nicht mehr in <see cref="PatternScore"/>, die volle
|
||||
/// Historie (Year*-Zähler, Quote) bleibt davon unberührt.
|
||||
public static IReadOnlyDictionary<VorgangScoreDomain, DateOnly> ComputeDampeningCutoffs(
|
||||
IReadOnlyList<Vorgang> closedVorgaenge)
|
||||
{
|
||||
var cutoffs = new Dictionary<VorgangScoreDomain, DateOnly>();
|
||||
foreach (var v in closedVorgaenge)
|
||||
{
|
||||
if (v.ClosedAt is not { } closedAt) continue;
|
||||
var closedDate = DateOnly.FromDateTime(closedAt);
|
||||
foreach (var domain in MatchDomains(v.Tags))
|
||||
if (!cutoffs.TryGetValue(domain, out var existing) || closedDate > existing)
|
||||
cutoffs[domain] = closedDate;
|
||||
}
|
||||
return cutoffs;
|
||||
}
|
||||
|
||||
/// Gemeinsame Score-Formel für <see cref="PatternScore"/> und <see cref="RegressionScore"/>:
|
||||
/// <paramref name="cutoffFor"/> liefert je Bereich das früheste zählende Datum (Standard
|
||||
/// <see cref="DateOnly.MinValue"/> = alles zählt, <see cref="DateOnly.MaxValue"/> = Bereich
|
||||
/// bewusst ausgeklammert — so lässt sich derselbe Code für "gedämpfter Gesamt-Score" und
|
||||
/// "nur die Bereiche eines einzelnen Vorgangs, nur danach" wiederverwenden).
|
||||
private static double ComputeScore(IReadOnlyList<ClassAbsenceDaySummaryRow> yearEntries,
|
||||
IReadOnlyList<UntisForeignClassRegisterEventDto> yearRegisterEntries,
|
||||
Func<VorgangScoreDomain, DateOnly> cutoffFor, PatternScoreWeights weights)
|
||||
{
|
||||
var attendanceCutoff = cutoffFor(VorgangScoreDomain.Attendance);
|
||||
var latenessCutoff = cutoffFor(VorgangScoreDomain.Lateness);
|
||||
var classbookCutoff = cutoffFor(VorgangScoreDomain.Classbook);
|
||||
var unexcused = yearEntries.Count(r => r.IsUnexcused && r.Date >= attendanceCutoff);
|
||||
var excused = yearEntries.Count(r => !r.IsUnexcused && !r.IsLate && r.Date >= attendanceCutoff);
|
||||
var late = yearEntries.Count(r => !r.IsUnexcused && r.IsLate && r.Date >= latenessCutoff);
|
||||
var classRegister = yearRegisterEntries
|
||||
.Where(e => TryDate(e.Date, out var d) && d >= classbookCutoff)
|
||||
.Sum(e => ClassRegisterEntryWeight(e, weights));
|
||||
return unexcused * weights.UnexcusedDayWeight + excused * weights.ExcusedDayWeight +
|
||||
late * weights.LateDayWeight + classRegister;
|
||||
}
|
||||
|
||||
public static IReadOnlyList<ClassTeacherRosterRow> Build(
|
||||
IReadOnlyList<UntisStudentRosterCacheEntry> students,
|
||||
IReadOnlyList<ClassAbsenceDaySummaryRow> todayAbsences,
|
||||
IReadOnlyList<UntisForeignClassRegisterEventDto> recentClassRegisterEntries,
|
||||
DateOnly? today = null,
|
||||
IReadOnlyList<ClassAbsenceDaySummaryRow>? yearAbsences = null,
|
||||
int schoolDaysElapsed = 0)
|
||||
int schoolDaysElapsed = 0,
|
||||
int holidayWeekdaysExcluded = 0,
|
||||
DateOnly termStart = default,
|
||||
IReadOnlyList<UntisForeignClassRegisterEventDto>? yearClassRegisterEntries = null,
|
||||
PatternScoreWeights? weights = null,
|
||||
IReadOnlyDictionary<string, IReadOnlyList<Vorgang>>? closedVorgaengeByNameKey = null)
|
||||
{
|
||||
var effectiveWeights = weights ?? new PatternScoreWeights();
|
||||
var referenceDate = today ?? todayAbsences.FirstOrDefault()?.Date ?? DateOnly.FromDateTime(DateTime.Today);
|
||||
var absenceByKey = todayAbsences.Where(a => a.ExternKey is not null)
|
||||
.GroupBy(a => a.ExternKey!.Value).ToDictionary(g => g.Key, g => g.First());
|
||||
@@ -103,6 +265,12 @@ public sealed record ClassTeacherRosterRow(string StudentName, int? ExternKey, b
|
||||
.ToDictionary(g => g.Key, g => g.First());
|
||||
var recentByName = recentClassRegisterEntries.GroupBy(e => UntisNameMatching.NameKey(e.StudentName))
|
||||
.ToDictionary(g => g.Key, g => g.ToList());
|
||||
// Für den Score bewusst über das ganze Schuljahr statt nur die letzten 7 Tage (anders als
|
||||
// recentClassRegisterEntries oben, das für HasRecentClassRegisterEntry/-Badges weiterhin nur
|
||||
// die aktuelle Woche abbildet) — fällt auf recentClassRegisterEntries zurück, wenn der
|
||||
// Aufrufer keine eigene Jahresliste mitgibt (z.B. bestehende Tests).
|
||||
var yearRegisterByName = (yearClassRegisterEntries ?? recentClassRegisterEntries)
|
||||
.GroupBy(e => UntisNameMatching.NameKey(e.StudentName)).ToDictionary(g => g.Key, g => g.ToList());
|
||||
var yearRows = yearAbsences ?? [];
|
||||
var yearByKey = yearRows.Where(a => a.ExternKey is not null)
|
||||
.GroupBy(a => a.ExternKey!.Value).ToDictionary(g => g.Key, g => g.ToList());
|
||||
@@ -117,15 +285,47 @@ public sealed record ClassTeacherRosterRow(string StudentName, int? ExternKey, b
|
||||
var registerEntries = recentByName.GetValueOrDefault(nameKey) ?? [];
|
||||
var yearEntries = (student.ExternKey is { } yearKey ? yearByKey.GetValueOrDefault(yearKey) : null)
|
||||
?? yearByName.GetValueOrDefault(nameKey) ?? [];
|
||||
var yearRegisterEntries = yearRegisterByName.GetValueOrDefault(nameKey) ?? [];
|
||||
|
||||
// Dämpfung/Rückfall (Nutzer-Entscheidung: bereichsbezogen statt kompletter Reset).
|
||||
var closedVorgaenge = closedVorgaengeByNameKey?.GetValueOrDefault(nameKey) ?? [];
|
||||
var dampeningCutoffs = ComputeDampeningCutoffs(closedVorgaenge);
|
||||
var patternScore = ComputeScore(yearEntries, yearRegisterEntries,
|
||||
d => dampeningCutoffs.GetValueOrDefault(d, DateOnly.MinValue), effectiveWeights);
|
||||
|
||||
var mostRecentClosed = closedVorgaenge.Where(v => v.ClosedAt.HasValue)
|
||||
.OrderByDescending(v => v.ClosedAt).FirstOrDefault();
|
||||
var regressionScore = 0.0;
|
||||
if (mostRecentClosed is not null)
|
||||
{
|
||||
var relevantDomains = MatchDomains(mostRecentClosed.Tags);
|
||||
var closedDate = DateOnly.FromDateTime(mostRecentClosed.ClosedAt!.Value);
|
||||
regressionScore = ComputeScore(yearEntries, yearRegisterEntries,
|
||||
d => relevantDomains.Contains(d) ? closedDate : DateOnly.MaxValue, effectiveWeights);
|
||||
}
|
||||
|
||||
return new ClassTeacherRosterRow(student.DisplayName, student.ExternKey, absence is not null,
|
||||
absence is null ? null : $"{absence.TotalAbsentPeriods} Stunde(n) — {absence.StatusLabel}",
|
||||
registerEntries.Count > 0)
|
||||
{
|
||||
TodayAbsence = absence,
|
||||
HasClassRegisterToday = registerEntries.Any(e => TryDate(e.Date, out var date) && date == referenceDate),
|
||||
YearAbsenceDayCount = yearEntries.Count,
|
||||
// Unentschuldigt vor Verspätet vor Entschuldigt, überschneidungsfrei — dieselbe
|
||||
// Kategorisierung wie im Wochentrend (BuildTrend). Nur Verspätung (nicht auch
|
||||
// unentschuldigt) fällt bewusst aus YearAbsenceDayCount heraus, siehe dort.
|
||||
YearAbsenceDayCount = yearEntries.Count(r => r.IsUnexcused || !r.IsLate),
|
||||
YearUnexcusedDayCount = yearEntries.Count(r => r.IsUnexcused),
|
||||
YearLateDayCount = yearEntries.Count(r => !r.IsUnexcused && r.IsLate),
|
||||
YearExcusedDayCount = yearEntries.Count(r => !r.IsUnexcused && !r.IsLate),
|
||||
YearNegativeClassRegisterCount = yearRegisterEntries.Count(IsNegativeClassRegisterEntry),
|
||||
HasSuspensionEntry = yearRegisterEntries.Any(e => MatchesAnyKeyword(e, SuspensionKeywords)),
|
||||
ClassRegisterScoreComponent = yearRegisterEntries.Sum(e => ClassRegisterEntryWeight(e, effectiveWeights)),
|
||||
PatternScore = patternScore,
|
||||
RegressionScore = regressionScore,
|
||||
RegressionVorgangTitle = mostRecentClosed?.Title,
|
||||
SchoolDaysElapsed = schoolDaysElapsed,
|
||||
HolidayWeekdaysExcluded = holidayWeekdaysExcluded,
|
||||
TermStart = termStart,
|
||||
};
|
||||
})
|
||||
.OrderBy(r => r.AttentionRank).ThenBy(r => r.StudentName).ToList();
|
||||
@@ -157,7 +357,7 @@ public sealed record ClassTeacherTrendDay(string DayLabel, int AlertCount, int U
|
||||
$"{UnexcusedCount} unentschuldigt · {LateExcusedCount} verspätet · {ExcusedCount} entschuldigt";
|
||||
}
|
||||
public sealed record ClassTeacherPatternNotice(string StudentName, string Message,
|
||||
ClassTeacherStatusKind Kind)
|
||||
ClassTeacherStatusKind Kind, bool CanCreateReminder = false)
|
||||
{
|
||||
public bool IsDangerStatus => Kind == ClassTeacherStatusKind.Danger;
|
||||
public bool IsWarningStatus => Kind == ClassTeacherStatusKind.Warning;
|
||||
@@ -182,7 +382,10 @@ public sealed record ClassTeacherOpenExcuseRow(string StudentName, DateOnly Date
|
||||
|
||||
public static IReadOnlyList<ClassTeacherOpenExcuseRow> Build(
|
||||
IReadOnlyList<ClassAbsenceDaySummaryRow> absenceDays, DateOnly today) =>
|
||||
absenceDays.Where(a => a.IsUnexcused)
|
||||
// Verspätungen ausgeschlossen: dafür gibt es i.d.R. keine Entschuldigungspflicht, eine
|
||||
// "Entschuldigung fehlt"-Erinnerung wäre hier gegenstandslos. Wiederholte Verspätungen
|
||||
// laufen stattdessen über die Mustererkennung (siehe BuildPatternNotices).
|
||||
absenceDays.Where(a => a.IsUnexcused && !a.IsLate)
|
||||
.Select(a => new ClassTeacherOpenExcuseRow(a.StudentDisplayName, a.Date,
|
||||
today.DayNumber - a.Date.DayNumber))
|
||||
.OrderByDescending(r => r.DaysOpen).ThenBy(r => r.StudentName)
|
||||
@@ -192,14 +395,20 @@ public sealed record ClassTeacherOpenExcuseRow(string StudentName, DateOnly Date
|
||||
public partial class ClassTeacherOverviewViewModel : ObservableObject
|
||||
{
|
||||
private readonly WebUntisSettingsService _settings;
|
||||
private readonly WebUntisIntegrationService _untis;
|
||||
private readonly UntisReportCacheService _cache;
|
||||
private readonly SchoolYearService _schoolYear;
|
||||
private readonly IWorkTaskRepository _workTasks;
|
||||
private readonly IStudentRepository _students;
|
||||
private readonly IDocumentationRepository _documentation;
|
||||
private readonly IVorgangRepository _vorgaenge;
|
||||
private readonly PatternScoreSettingsService _patternScoreSettings;
|
||||
private readonly IParticipationRepository _participation;
|
||||
private readonly IParticipationSessionRepository _participationSessions;
|
||||
private readonly AppLogger? _logger;
|
||||
|
||||
public ClassTeacherDetailsViewModel DetailsTab { get; }
|
||||
public ClassTeacherCasesViewModel CasesTab { get; }
|
||||
public ObservableCollection<ClassTeacherRosterRow> Roster { get; } = [];
|
||||
public ObservableCollection<ClassTeacherRosterRow> PrimaryRoster { get; } = [];
|
||||
public ObservableCollection<ClassTeacherRosterRow> SecondaryRoster { get; } = [];
|
||||
@@ -212,7 +421,11 @@ public partial class ClassTeacherOverviewViewModel : ObservableObject
|
||||
[ObservableProperty] private string _status = "";
|
||||
[ObservableProperty] private bool _busy;
|
||||
[ObservableProperty] private string _searchText = "";
|
||||
[ObservableProperty] private int _selectedRosterFilter;
|
||||
/// Standardmäßig der Muster-Reiter (3) statt "Auffällig" (0) — Nutzer-Feedback: die reine
|
||||
/// Heute-Ansicht lässt Schüler*innen mit Vorgeschichte (z.B. 5 Tage unentschuldigt letzte
|
||||
/// Woche, heute aber da) komplett verschwinden, während ein erstmaliger Einzelfehltag ganz oben
|
||||
/// steht, sobald er der/die einzige "heute Auffällige" ist.
|
||||
[ObservableProperty] private int _selectedRosterFilter = 3;
|
||||
[ObservableProperty] private string _primarySectionTitle = "Heute auffällig";
|
||||
[ObservableProperty] private string _secondarySectionTitle = "Weitere Schüler*innen";
|
||||
[ObservableProperty] private int _studentCount;
|
||||
@@ -225,6 +438,19 @@ public partial class ClassTeacherOverviewViewModel : ObservableObject
|
||||
[ObservableProperty] private int _unexcusedAbsenceCount;
|
||||
[ObservableProperty] private string _lastUpdatedLabel = "Noch nicht aktualisiert";
|
||||
[ObservableProperty] private int _openExcuseOverflowCount;
|
||||
/// Eigene Dokumentation zu Schüler*innen der Klasse mit "Nacharbeiten"-Status bzw. dem
|
||||
/// "Kritisch"-Tag — Kurzform derselben Zählung wie im Klassenbuch-Tab (siehe
|
||||
/// <see cref="ClassTeacherDetailsViewModel.OwnDocumentationFollowUpCount"/>), hier direkt an
|
||||
/// den "Klassenbuch öffnen"-Button gehängt statt in einer eigenen Kennzahlkarte.
|
||||
[ObservableProperty] private int _ownDocumentationFollowUpCount;
|
||||
[ObservableProperty] private int _ownDocumentationCriticalCount;
|
||||
|
||||
// Zuletzt per Load() geholte WebUntis-Rohdaten, für RefreshFromLocalDataOnly() - damit ein
|
||||
// eingehendes Sync-Ereignis die Ansicht neu aufbauen kann, ohne selbst WebUntis anzufragen.
|
||||
private IReadOnlyList<UntisStudentRosterCacheEntry>? _lastStudents;
|
||||
private IReadOnlyList<UntisClassAbsenceEntryDto>? _lastAbsences;
|
||||
private IReadOnlyList<UntisForeignClassRegisterEventDto>? _lastClassRegisterEvents;
|
||||
private IReadOnlyList<CachedUntisHoliday>? _lastHolidays;
|
||||
|
||||
public bool HomeroomClassConfigured => !string.IsNullOrWhiteSpace(HomeroomClassName);
|
||||
public bool HasPrimaryRoster => PrimaryRoster.Count > 0;
|
||||
@@ -233,9 +459,11 @@ public partial class ClassTeacherOverviewViewModel : ObservableObject
|
||||
public bool HasPatternNotices => PatternNotices.Count > 0;
|
||||
public bool HasOpenExcuses => OpenExcuses.Count > 0;
|
||||
public bool HasOpenExcuseOverflow => OpenExcuseOverflowCount > 0;
|
||||
public bool HasOwnDocumentationAlerts => OwnDocumentationFollowUpCount > 0 || OwnDocumentationCriticalCount > 0;
|
||||
public bool AlertsFilterSelected => SelectedRosterFilter == 0;
|
||||
public bool ClassRegisterFilterSelected => SelectedRosterFilter == 1;
|
||||
public bool AllFilterSelected => SelectedRosterFilter == 2;
|
||||
public bool PatternScoreFilterSelected => SelectedRosterFilter == 3;
|
||||
public int TodayUnexcusedPercent => Percent(TodayUnexcusedCount);
|
||||
public int LatePercent => Percent(LateCount);
|
||||
public int PresentPercent => Percent(PresentCount);
|
||||
@@ -268,39 +496,78 @@ public partial class ClassTeacherOverviewViewModel : ObservableObject
|
||||
public Func<Task>? OnNavigateToSettings { get; set; }
|
||||
public Func<Task>? OnNavigateToWorkload { get; set; }
|
||||
|
||||
public ClassTeacherOverviewViewModel(WebUntisSettingsService settings,
|
||||
public ClassTeacherOverviewViewModel(WebUntisSettingsService settings, WebUntisIntegrationService untis,
|
||||
UntisReportCacheService cache, SchoolYearService schoolYear, IWorkTaskRepository workTasks,
|
||||
IStudentRepository students, IParticipationRepository participation,
|
||||
IParticipationSessionRepository participationSessions, ClassTeacherDetailsViewModel detailsTab)
|
||||
IStudentRepository students, IDocumentationRepository documentation, IVorgangRepository vorgaenge,
|
||||
PatternScoreSettingsService patternScoreSettings, IParticipationRepository participation,
|
||||
IParticipationSessionRepository participationSessions, ClassTeacherDetailsViewModel detailsTab,
|
||||
ClassTeacherCasesViewModel casesTab, AppLogger? logger = null)
|
||||
{
|
||||
_settings = settings;
|
||||
_untis = untis;
|
||||
_cache = cache;
|
||||
_schoolYear = schoolYear;
|
||||
_workTasks = workTasks;
|
||||
_students = students;
|
||||
_documentation = documentation;
|
||||
_vorgaenge = vorgaenge;
|
||||
_patternScoreSettings = patternScoreSettings;
|
||||
_participation = participation;
|
||||
_participationSessions = participationSessions;
|
||||
_logger = logger;
|
||||
DetailsTab = detailsTab;
|
||||
CasesTab = casesTab;
|
||||
}
|
||||
|
||||
partial void OnHomeroomClassNameChanged(string? value) => OnPropertyChanged(nameof(HomeroomClassConfigured));
|
||||
partial void OnOpenExcuseOverflowCountChanged(int value) => OnPropertyChanged(nameof(HasOpenExcuseOverflow));
|
||||
partial void OnOwnDocumentationFollowUpCountChanged(int value) => OnPropertyChanged(nameof(HasOwnDocumentationAlerts));
|
||||
partial void OnOwnDocumentationCriticalCountChanged(int value) => OnPropertyChanged(nameof(HasOwnDocumentationAlerts));
|
||||
partial void OnSearchTextChanged(string value) => ApplyRosterFilter();
|
||||
partial void OnSelectedRosterFilterChanged(int value)
|
||||
{
|
||||
OnPropertyChanged(nameof(AlertsFilterSelected));
|
||||
OnPropertyChanged(nameof(ClassRegisterFilterSelected));
|
||||
OnPropertyChanged(nameof(AllFilterSelected));
|
||||
OnPropertyChanged(nameof(PatternScoreFilterSelected));
|
||||
ApplyRosterFilter();
|
||||
}
|
||||
|
||||
private void ResetRosterState()
|
||||
{
|
||||
ActiveTabIndex = 0;
|
||||
Roster.Clear(); PrimaryRoster.Clear(); SecondaryRoster.Clear(); TrendDays.Clear(); PatternNotices.Clear();
|
||||
OpenExcuses.Clear(); OpenExcuseOverflowCount = 0;
|
||||
}
|
||||
|
||||
// Wird beim Navigieren auf diese Seite aufgerufen statt LoadCommand: baut nur den lokalen
|
||||
// Grundzustand auf (keine WebUntis-Anfrage), damit Öffnen der Ansicht nicht auf einen
|
||||
// WebUntis-Login+Report-Abruf wartet. Die eigentlichen Fehlzeiten/Klassenbuch-Daten holt sich
|
||||
// die Nutzerin bewusst über den "Aktualisieren"-Button (LoadCommand).
|
||||
public void PrepareForDisplay()
|
||||
{
|
||||
if (Busy) return;
|
||||
HomeroomClassName = _settings.HomeroomClassName;
|
||||
ResetRosterState();
|
||||
if (!HomeroomClassConfigured)
|
||||
{
|
||||
Status = "Noch keine Klasse ausgewählt.";
|
||||
}
|
||||
else
|
||||
{
|
||||
DetailsTab.Initialize(HomeroomClassName!);
|
||||
CasesTab.Initialize(HomeroomClassName!);
|
||||
if (StudentCount == 0)
|
||||
Status = "Noch nicht geladen – auf „Aktualisieren“ klicken, um aktuelle WebUntis-Daten zu holen.";
|
||||
}
|
||||
NotifyRosterState();
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private async Task Load()
|
||||
{
|
||||
HomeroomClassName = _settings.HomeroomClassName;
|
||||
ActiveTabIndex = 0;
|
||||
Roster.Clear(); PrimaryRoster.Clear(); SecondaryRoster.Clear(); TrendDays.Clear(); PatternNotices.Clear();
|
||||
OpenExcuses.Clear(); OpenExcuseOverflowCount = 0;
|
||||
ResetRosterState();
|
||||
if (!HomeroomClassConfigured)
|
||||
{
|
||||
Status = "Noch keine Klasse ausgewählt.";
|
||||
@@ -310,29 +577,95 @@ public partial class ClassTeacherOverviewViewModel : ObservableObject
|
||||
|
||||
var className = HomeroomClassName!;
|
||||
DetailsTab.Initialize(className);
|
||||
CasesTab.Initialize(className);
|
||||
Busy = true;
|
||||
NotifyRosterState();
|
||||
try
|
||||
{
|
||||
var today = DateOnly.FromDateTime(DateTime.Today);
|
||||
var sevenDayStart = today.AddDays(-6);
|
||||
var trendDays = LastSchoolDays(today, 7);
|
||||
// Fehlzeiten seit Schuljahresbeginn statt nur der letzten 7 Tage: liefert die
|
||||
// Kennzahl unten (YearSummaryLabel) und den Nenner für die Fehlquote in einem Abruf.
|
||||
// UntisReportCacheService cached diesen "kalten" Bereich dauerhaft (siehe dort) —
|
||||
// teuer ist nur der erste Abruf pro Schuljahr, nicht jedes Öffnen der Ansicht.
|
||||
var yearStart = _schoolYear.SchoolYearStart(_schoolYear.CurrentSchoolYear(today));
|
||||
var (today, yearStart, sevenDayStart, trendDays) = RosterDateWindow();
|
||||
var studentsTask = _cache.GetStudentRosterAsync(className);
|
||||
var absencesTask = _cache.GetAbsencesAsync(className, yearStart, today);
|
||||
var classRegisterTask = _cache.GetClassRegisterEventsAsync(className, sevenDayStart, today);
|
||||
await Task.WhenAll(studentsTask, absencesTask, classRegisterTask);
|
||||
// Seit Schuljahresbeginn statt nur die letzte Woche (Nutzer-Feedback: Klassenbucheinträge
|
||||
// anderer Lehrkräfte — z.B. fehlende Hausaufgaben, schlechte Mitarbeit, im Extremfall eine
|
||||
// Suspendierung — sollen wie die Fehlzeiten in den Auffälligkeits-Score einfließen, nicht
|
||||
// nur die letzten 7 Tage). Dieselbe "kalte Historie bleibt gecacht"-Logik wie bei den
|
||||
// Fehlzeiten oben, kein zusätzliches Abruf-Risiko. Die "letzte 7 Tage"-Badges
|
||||
// (RecentClassRegisterCount, HasRecentClassRegisterEntry) werden weiterhin unten aus
|
||||
// genau diesem einen Abruf herausgefiltert statt separat erneut abgerufen.
|
||||
var classRegisterTask = _cache.GetClassRegisterEventsAsync(className, yearStart, today);
|
||||
var holidaysTask = GetHolidaysAsync();
|
||||
await Task.WhenAll(studentsTask, absencesTask, classRegisterTask, holidaysTask);
|
||||
|
||||
var absenceDaysYear = ClassAbsenceDaySummaryRow.GroupByStudentAndDay(absencesTask.Result);
|
||||
_lastStudents = studentsTask.Result;
|
||||
_lastAbsences = absencesTask.Result;
|
||||
_lastClassRegisterEvents = classRegisterTask.Result;
|
||||
_lastHolidays = holidaysTask.Result;
|
||||
BuildRosterFromCachedData(studentsTask.Result, absencesTask.Result, classRegisterTask.Result,
|
||||
holidaysTask.Result, today, yearStart, sevenDayStart, trendDays);
|
||||
LastUpdatedLabel = $"Zuletzt aktualisiert: Heute, {DateTime.Now:HH:mm}";
|
||||
}
|
||||
catch (WebUntisIntegrationException ex) { Status = ex.Message; NotifyRosterState(); }
|
||||
finally { Busy = false; NotifyRosterState(); }
|
||||
}
|
||||
|
||||
// Baut die Ansicht ausschließlich aus den zuletzt per Load() geholten WebUntis-Daten plus
|
||||
// aktuellen lokalen Daten (Vorgänge/Dokumentation) neu auf - löst NIE einen WebUntis-Request
|
||||
// aus, auch nicht über UntisReportCacheService' stündliches Hot-Window-Refresh. Wird bei
|
||||
// eingehenden Sync-Ereignissen aufgerufen (siehe MainWindowViewModel.RefreshCurrentPage);
|
||||
// echte WebUntis-Daten holt bewusst ausschließlich der "Aktualisieren"-Button (LoadCommand),
|
||||
// wenn die Nutzerin sich dafür entscheidet.
|
||||
public void RefreshFromLocalDataOnly()
|
||||
{
|
||||
if (Busy || _lastStudents is null || _lastAbsences is null ||
|
||||
_lastClassRegisterEvents is null || _lastHolidays is null) return;
|
||||
var (today, yearStart, sevenDayStart, trendDays) = RosterDateWindow();
|
||||
ResetRosterState();
|
||||
BuildRosterFromCachedData(_lastStudents, _lastAbsences, _lastClassRegisterEvents, _lastHolidays,
|
||||
today, yearStart, sevenDayStart, trendDays);
|
||||
NotifyRosterState();
|
||||
}
|
||||
|
||||
private (DateOnly Today, DateOnly YearStart, DateOnly SevenDayStart, IReadOnlyList<DateOnly> TrendDays) RosterDateWindow()
|
||||
{
|
||||
var today = DateOnly.FromDateTime(DateTime.Today);
|
||||
var yearStart = _schoolYear.SchoolYearStart(_schoolYear.CurrentSchoolYear(today));
|
||||
return (today, yearStart, today.AddDays(-6), LastSchoolDays(today, 7));
|
||||
}
|
||||
|
||||
private void BuildRosterFromCachedData(
|
||||
IReadOnlyList<UntisStudentRosterCacheEntry> students,
|
||||
IReadOnlyList<UntisClassAbsenceEntryDto> absences,
|
||||
IReadOnlyList<UntisForeignClassRegisterEventDto> classRegisterEvents,
|
||||
IReadOnlyList<CachedUntisHoliday> holidays,
|
||||
DateOnly today, DateOnly yearStart, DateOnly sevenDayStart, IReadOnlyList<DateOnly> trendDays)
|
||||
{
|
||||
var absenceDaysYear = ClassAbsenceDaySummaryRow.GroupByStudentAndDay(absences);
|
||||
var todayAbsences = absenceDaysYear.Where(a => a.Date == today).ToList();
|
||||
var weekAbsenceDays = absenceDaysYear.Where(a => a.Date >= sevenDayStart).ToList();
|
||||
var schoolDaysElapsed = CountWeekdays(yearStart, today);
|
||||
foreach (var row in ClassTeacherRosterRow.Build(studentsTask.Result, todayAbsences,
|
||||
classRegisterTask.Result, today, absenceDaysYear, schoolDaysElapsed)) Roster.Add(row);
|
||||
var termStart = EstimateTermStart(absenceDaysYear, yearStart);
|
||||
var rawWeekdaysElapsed = CountSchoolWeekdays(termStart, today, []);
|
||||
var schoolDaysElapsed = CountSchoolWeekdays(termStart, today, holidays);
|
||||
var holidayWeekdaysExcluded = rawWeekdaysElapsed - schoolDaysElapsed;
|
||||
var recentRegisterEntries = classRegisterEvents
|
||||
.Where(e => TryDate(e.Date, out var d) && d >= sevenDayStart).ToList();
|
||||
// Geschlossene Vorgänge je Klassen-Roster-Namen (für die bereichsbezogene Score-Dämpfung/
|
||||
// Rückfall-Erkennung in Build) — gleicher Namensabgleich wie bei den übrigen Build*-Methoden
|
||||
// dieser Klasse (z.B. BuildAttendanceParticipationNotices).
|
||||
var localStudents = _students.GetAll();
|
||||
var closedVorgaengeByNameKey = students
|
||||
.Select(r => (Roster: r, Student: MatchStudent(r.DisplayName, localStudents)))
|
||||
.Where(x => x.Student is not null)
|
||||
.ToDictionary(
|
||||
x => UntisNameMatching.NameKey(x.Roster.DisplayName),
|
||||
x => (IReadOnlyList<Vorgang>)_vorgaenge.GetByStudent(x.Student!.Id)
|
||||
.Where(v => v.Status == VorgangStatus.Closed).ToList());
|
||||
foreach (var row in ClassTeacherRosterRow.Build(students, todayAbsences,
|
||||
recentRegisterEntries, today, absenceDaysYear, schoolDaysElapsed,
|
||||
holidayWeekdaysExcluded, termStart, classRegisterEvents,
|
||||
_patternScoreSettings.Load(), closedVorgaengeByNameKey)) Roster.Add(row);
|
||||
|
||||
StudentCount = Roster.Count;
|
||||
TodayAlertCount = Roster.Count(r => r.HasAbsenceToday);
|
||||
@@ -341,24 +674,23 @@ public partial class ClassTeacherOverviewViewModel : ObservableObject
|
||||
PresentCount = Roster.Count(r => !r.HasAbsenceToday);
|
||||
ExcusedAbsenceCount = Roster.Count(r => r.HasAbsenceToday && !r.IsLate && !r.IsUnexcused);
|
||||
UnexcusedAbsenceCount = Roster.Count(r => r.HasAbsenceToday && !r.IsLate && r.IsUnexcused);
|
||||
RecentClassRegisterCount = classRegisterTask.Result.Count;
|
||||
RecentClassRegisterCount = recentRegisterEntries.Count;
|
||||
BuildTrend(absenceDaysYear, trendDays);
|
||||
BuildPatternNotices(weekAbsenceDays);
|
||||
BuildPatternNotices(absenceDaysYear, sevenDayStart);
|
||||
BuildWeekdayPatternNotices(absenceDaysYear);
|
||||
BuildAttendanceParticipationNotices();
|
||||
BuildVorgangRegressionNotices();
|
||||
BuildOwnDocumentationCounts();
|
||||
BuildOpenExcuses(absenceDaysYear, today);
|
||||
LastUpdatedLabel = $"Zuletzt aktualisiert: Heute, {DateTime.Now:HH:mm}";
|
||||
Status = $"{StudentCount} Schüler*innen · {TodayAlertCount} heute auffällig";
|
||||
ApplyRosterFilter();
|
||||
NotifySummary();
|
||||
}
|
||||
catch (WebUntisIntegrationException ex) { Status = ex.Message; NotifyRosterState(); }
|
||||
finally { Busy = false; NotifyRosterState(); }
|
||||
}
|
||||
|
||||
[RelayCommand] private void ShowAlerts() => SelectedRosterFilter = 0;
|
||||
[RelayCommand] private void ShowClassRegister() => SelectedRosterFilter = 1;
|
||||
[RelayCommand] private void ShowAll() => SelectedRosterFilter = 2;
|
||||
[RelayCommand] private void ShowPatternScore() => SelectedRosterFilter = 3;
|
||||
|
||||
[RelayCommand]
|
||||
private void OpenClassRegister()
|
||||
@@ -406,6 +738,18 @@ public partial class ClassTeacherOverviewViewModel : ObservableObject
|
||||
PrimarySectionTitle = "Klassenbucheinträge der letzten 7 Tage";
|
||||
foreach (var row in query.Where(r => r.HasRecentClassRegisterEntry)) PrimaryRoster.Add(row);
|
||||
}
|
||||
else if (SelectedRosterFilter == 3)
|
||||
{
|
||||
// Nutzer-Feedback: "heute auffällig" allein lässt Schüler*innen mit echter Vorgeschichte
|
||||
// verschwinden, sobald sie an einem Tag zufällig anwesend sind, und lässt einen
|
||||
// einmaligen Erstfehltag ganz oben stehen, sobald er der/die einzige "heute Auffällige"
|
||||
// ist. Deshalb eigene Sortierung nach dem kumulierten Score übers Schuljahr statt nach
|
||||
// dem heutigen Status — bewusst unabhängig von HasAbsenceToday.
|
||||
PrimarySectionTitle = "Nach Muster seit Schuljahresbeginn";
|
||||
foreach (var row in query.Where(r => r.PatternScore > 0)
|
||||
.OrderByDescending(r => r.PatternScore).ThenBy(r => r.StudentName))
|
||||
PrimaryRoster.Add(row);
|
||||
}
|
||||
else
|
||||
{
|
||||
PrimarySectionTitle = "Heute auffällig";
|
||||
@@ -438,6 +782,9 @@ public partial class ClassTeacherOverviewViewModel : ObservableObject
|
||||
(double)day.Unexcused / max, (double)day.LateExcused / max, (double)day.Excused / max));
|
||||
}
|
||||
|
||||
private static bool TryDate(int value, out DateOnly date) =>
|
||||
DateOnly.TryParseExact(value.ToString(), "yyyyMMdd", out date);
|
||||
|
||||
/// Letzte <paramref name="count"/> Werktage bis einschließlich <paramref name="end"/>, ohne
|
||||
/// Ferienkalender (bewusste Vereinfachung, siehe TODO.md 12.4-Nachtrag) — die App kennt keine
|
||||
/// Schulferien, nur Wochenenden.
|
||||
@@ -451,34 +798,123 @@ public partial class ClassTeacherOverviewViewModel : ObservableObject
|
||||
return days;
|
||||
}
|
||||
|
||||
/// Nenner für <see cref="ClassTeacherRosterRow.YearAbsenceRatePercent"/> — Werktage zwischen
|
||||
/// Schuljahresbeginn und heute, ebenfalls ohne Ferienkalender.
|
||||
private static int CountWeekdays(DateOnly start, DateOnly end)
|
||||
/// Nutzer-Feedback: Schüler*innen, die seit Unterrichtsbeginn nachweislich an jedem Tag
|
||||
/// fehlten, zeigten trotzdem nur ~57 % Fehlquote statt der erwarteten ~100 %. Ursache: der
|
||||
/// Nenner zählte bislang jeden Werktag ab dem fest verdrahteten 1. August
|
||||
/// (<see cref="SchoolYearService.SchoolYearStart"/>) als "Schultag" mit — die Sommerferien
|
||||
/// enden je nach Bundesland/Jahr aber erst Wochen später, und genau zu Schuljahresbeginn macht
|
||||
/// diese Ferienzeit einen großen Teil des bis dahin "verstrichenen" Zeitraums aus. WebUntis
|
||||
/// kennt einen Teil des echten Ferienkalenders (<c>getHolidays</c>-Bericht, hier zwischen den
|
||||
/// Werktagen ausgeschlossen) und behebt damit die kleineren Verzerrungen durch Herbst-/
|
||||
/// Weihnachts-/Osterferien im weiteren Jahresverlauf. Die Sommerferien selbst liefert
|
||||
/// <c>getHolidays</c> nach Prüfung der echten Antwort für dieses Konto aber NIE (über 11 Jahre
|
||||
/// zurück kein einziger Sommerferien-Eintrag, siehe <see cref="EstimateTermStart"/>) — sie
|
||||
/// liegen WebUntis-intern vermutlich außerhalb jedes Schuljahres-Datensatzes (der bei 1.8./31.7.
|
||||
/// endet), nicht "in" einem davon. Für die Sommerferien bleibt deshalb weiterhin
|
||||
/// <see cref="EstimateTermStart"/> nötig, das den Startpunkt selbst korrigiert statt Tage
|
||||
/// innerhalb des Zeitraums abzuziehen.
|
||||
public static int CountSchoolWeekdays(DateOnly start, DateOnly end, IReadOnlyList<CachedUntisHoliday> holidays)
|
||||
{
|
||||
if (end < start) return 0;
|
||||
var count = 0;
|
||||
for (var d = start; d <= end; d = d.AddDays(1))
|
||||
if (d.DayOfWeek is not (DayOfWeek.Saturday or DayOfWeek.Sunday)) count++;
|
||||
if (d.DayOfWeek is not (DayOfWeek.Saturday or DayOfWeek.Sunday) &&
|
||||
!holidays.Any(h => d >= h.Start && d <= h.End))
|
||||
count++;
|
||||
return count;
|
||||
}
|
||||
|
||||
private void BuildPatternNotices(IReadOnlyList<ClassAbsenceDaySummaryRow> absenceDays)
|
||||
/// Ergänzt <see cref="CountSchoolWeekdays"/> um genau die Lücke, die WebUntis' echter
|
||||
/// Ferienkalender nicht schließt: die Sommerferien. Nimmt den frühesten Tag mit irgendeinem
|
||||
/// Fehlzeiten-Eintrag der ganzen Klasse als Näherung für den tatsächlichen ersten
|
||||
/// Unterrichtstag (ein solcher Eintrag kann nur an einem Tag mit tatsächlich stattfindendem
|
||||
/// Unterricht entstehen) — Rückfall auf <paramref name="fallback"/>, wenn noch keine
|
||||
/// Fehlzeiten vorliegen (dann bleibt <see cref="ClassTeacherRosterRow.HasYearSummary"/>
|
||||
/// ohnehin ausgeblendet).
|
||||
public static DateOnly EstimateTermStart(IReadOnlyList<ClassAbsenceDaySummaryRow> absenceDaysYear, DateOnly fallback) =>
|
||||
absenceDaysYear.Count > 0 ? absenceDaysYear.Min(a => a.Date) : fallback;
|
||||
|
||||
/// Ferien ändern sich innerhalb eines Schuljahrs praktisch nie (anders als die
|
||||
/// Fehlzeiten-/Klassenbuchberichte, deshalb hier keine der stündlichen "heißes Fenster"-Logik
|
||||
/// aus <see cref="UntisReportCacheService"/>, sondern ein einfacher tagesgenauer Cache über
|
||||
/// <see cref="WebUntisSettingsService"/> — kein zusätzliches LiteDB-Repository nötig). Schlägt
|
||||
/// der Live-Abruf fehl (z. B. kurzzeitig kein Netz), wird der zuletzt bekannte Stand
|
||||
/// weiterverwendet statt die ganze Übersicht mit einem Fehler zu blockieren; ist noch nie
|
||||
/// erfolgreich abgerufen worden, bleibt die Liste leer und <see cref="CountSchoolWeekdays"/>
|
||||
/// verhält sich wie vorher (reine Werktagszählung ohne Ferienabzug).
|
||||
private static readonly TimeSpan HolidaysRefreshInterval = TimeSpan.FromDays(1);
|
||||
|
||||
private async Task<IReadOnlyList<CachedUntisHoliday>> GetHolidaysAsync()
|
||||
{
|
||||
foreach (var group in absenceDays.GroupBy(row => UntisNameMatching.NameKey(row.StudentName)))
|
||||
var cached = _settings.GetCachedHolidays();
|
||||
if (cached is not null && _settings.HolidaysFetchedAt is { } fetchedAt &&
|
||||
DateTime.UtcNow - fetchedAt < HolidaysRefreshInterval)
|
||||
return cached;
|
||||
try
|
||||
{
|
||||
var fresh = (await _untis.GetHolidaysAsync())
|
||||
.Select(h => new CachedUntisHoliday(h.Name, ParseDate(h.StartDate), ParseDate(h.EndDate)))
|
||||
.ToList();
|
||||
_settings.SetCachedHolidays(fresh, DateTime.UtcNow);
|
||||
_logger?.Info("Klassenlehrer: WebUntis-Ferien geladen — " +
|
||||
string.Join("; ", fresh.Select(h => $"{h.Name} {h.Start:yyyy-MM-dd}..{h.End:yyyy-MM-dd}")));
|
||||
return fresh;
|
||||
}
|
||||
catch (WebUntisIntegrationException ex)
|
||||
{
|
||||
_logger?.Error("Klassenlehrer: WebUntis-Ferienabruf fehlgeschlagen", ex);
|
||||
return cached ?? [];
|
||||
}
|
||||
}
|
||||
|
||||
private static DateOnly ParseDate(int value) => DateOnly.ParseExact(value.ToString(), "yyyyMMdd");
|
||||
|
||||
private const int LateYearEscalationThreshold = 5;
|
||||
|
||||
private void BuildPatternNotices(IReadOnlyList<ClassAbsenceDaySummaryRow> absenceDaysYear, DateOnly sevenDayStart)
|
||||
{
|
||||
var displayNames = Roster.GroupBy(r => UntisNameMatching.NameKey(r.StudentName))
|
||||
.ToDictionary(g => g.Key, g => g.First().StudentName);
|
||||
foreach (var notice in DetectLatePatterns(absenceDaysYear, displayNames, sevenDayStart))
|
||||
PatternNotices.Add(notice);
|
||||
OnPropertyChanged(nameof(HasPatternNotices));
|
||||
}
|
||||
|
||||
/// Verspätungen sind i.d.R. nicht entschuldigungsfähig (siehe <see cref="ClassTeacherOpenExcuseRow.Build"/>)
|
||||
/// — eine Häufung ist trotzdem ein Signal, nur eines für ein Elterngespräch/einen Brief statt
|
||||
/// für eine fehlende Entschuldigung. Zwei Stufen, Nutzer-Feedback: ein kurzfristiges Cluster
|
||||
/// (<paramref name="sevenDayStart"/>..heute) bleibt ein sanfter Hinweis ohne Aktion, eine hohe
|
||||
/// Jahressumme eskaliert zu Danger mit Wiedervorlage-Option
|
||||
/// (<see cref="CreateReminderForPatternNoticeCommand"/>). Reine, ohne ViewModel-Zustand
|
||||
/// testbare Kernlogik (gleiches Muster wie <see cref="DetectWeekdayPatterns"/>).
|
||||
public static IReadOnlyList<ClassTeacherPatternNotice> DetectLatePatterns(
|
||||
IReadOnlyList<ClassAbsenceDaySummaryRow> absenceDaysYear,
|
||||
IReadOnlyDictionary<string, string> displayNamesByKey,
|
||||
DateOnly sevenDayStart,
|
||||
int lateYearEscalationThreshold = LateYearEscalationThreshold)
|
||||
{
|
||||
var notices = new List<ClassTeacherPatternNotice>();
|
||||
foreach (var group in absenceDaysYear.GroupBy(row => UntisNameMatching.NameKey(row.StudentName)))
|
||||
{
|
||||
var rows = group.ToList();
|
||||
var displayName = Roster.FirstOrDefault(row =>
|
||||
UntisNameMatching.NameKey(row.StudentName) == group.Key)?.StudentName ?? rows[0].StudentDisplayName;
|
||||
var lateDays = rows.Count(row => row.IsLate);
|
||||
var unexcusedDays = rows.Count(row => row.IsUnexcused && !row.IsLate);
|
||||
if (unexcusedDays >= 2)
|
||||
PatternNotices.Add(new ClassTeacherPatternNotice(displayName,
|
||||
$"{unexcusedDays} unentschuldigte Fehltage in 7 Tagen", ClassTeacherStatusKind.Danger));
|
||||
else if (lateDays >= 2)
|
||||
PatternNotices.Add(new ClassTeacherPatternNotice(displayName,
|
||||
$"{lateDays}-mal verspätet in 7 Tagen", ClassTeacherStatusKind.Warning));
|
||||
var displayName = displayNamesByKey.GetValueOrDefault(group.Key) ?? rows[0].StudentDisplayName;
|
||||
var weekRows = rows.Where(row => row.Date >= sevenDayStart).ToList();
|
||||
var lateDaysWeek = weekRows.Count(row => row.IsLate);
|
||||
var unexcusedDaysWeek = weekRows.Count(row => row.IsUnexcused && !row.IsLate);
|
||||
var lateDaysYear = rows.Count(row => row.IsLate);
|
||||
|
||||
if (unexcusedDaysWeek >= 2)
|
||||
notices.Add(new ClassTeacherPatternNotice(displayName,
|
||||
$"{unexcusedDaysWeek} unentschuldigte Fehltage in 7 Tagen", ClassTeacherStatusKind.Danger));
|
||||
else if (lateDaysYear >= lateYearEscalationThreshold)
|
||||
notices.Add(new ClassTeacherPatternNotice(displayName,
|
||||
$"{lateDaysYear} Verspätungen seit Schuljahresbeginn – Elterngespräch oder Brief erwägen",
|
||||
ClassTeacherStatusKind.Danger, CanCreateReminder: true));
|
||||
else if (lateDaysWeek >= 2)
|
||||
notices.Add(new ClassTeacherPatternNotice(displayName,
|
||||
$"{lateDaysWeek}-mal verspätet in 7 Tagen", ClassTeacherStatusKind.Warning));
|
||||
}
|
||||
OnPropertyChanged(nameof(HasPatternNotices));
|
||||
return notices;
|
||||
}
|
||||
|
||||
/// Erweiterte Mustererkennung (Nutzer-Feedback): die obigen Regeln schauen nur auf die letzten
|
||||
@@ -572,6 +1008,44 @@ public partial class ClassTeacherOverviewViewModel : ObservableObject
|
||||
OnPropertyChanged(nameof(HasPatternNotices));
|
||||
}
|
||||
|
||||
/// Schwelle für die "erneut auffällig"-Warnung nach einem geschlossenen Vorgang (Nutzer-
|
||||
/// Entscheidung): erst ab einem spürbaren Muster, nicht schon bei der ersten Kleinigkeit nach
|
||||
/// Abschluss — ≈ 1 unentschuldigter Tag oder vergleichbar (<see cref="PatternScoreWeights.UnexcusedDayWeight"/>
|
||||
/// Default 3,0).
|
||||
private const double RegressionThreshold = 3.0;
|
||||
|
||||
private void BuildVorgangRegressionNotices()
|
||||
{
|
||||
foreach (var row in Roster.Where(r => r.RegressionScore >= RegressionThreshold &&
|
||||
r.RegressionVorgangTitle is not null))
|
||||
{
|
||||
if (PatternNotices.Any(n => n.StudentName == row.StudentName)) continue;
|
||||
PatternNotices.Add(new ClassTeacherPatternNotice(row.StudentName,
|
||||
$"Nach Bearbeitung von \"{row.RegressionVorgangTitle}\" erneut auffällig",
|
||||
ClassTeacherStatusKind.Danger, CanCreateReminder: true));
|
||||
}
|
||||
OnPropertyChanged(nameof(HasPatternNotices));
|
||||
}
|
||||
|
||||
/// Nacharbeiten-/Kritisch-Zählung für die eigene Dokumentation der Klasse — bewusst nicht auf
|
||||
/// die letzten 7 Tage begrenzt wie <see cref="RecentClassRegisterCount"/>: ein seit Wochen
|
||||
/// offener Entwurf oder ein als "Kritisch" markierter Eintrag soll nicht aus der Kennzahl
|
||||
/// verschwinden, nur weil er nicht mehr taufrisch ist. Selbe Namensabgleich-Logik wie
|
||||
/// <see cref="BuildAttendanceParticipationNotices"/>.
|
||||
private void BuildOwnDocumentationCounts()
|
||||
{
|
||||
var students = _students.GetAll();
|
||||
var matchedIds = Roster
|
||||
.Select(r => MatchStudent(r.StudentName, students))
|
||||
.Where(s => s is not null)
|
||||
.Select(s => s!.Id)
|
||||
.ToHashSet();
|
||||
var docs = _documentation.GetAll().Where(d => !d.IsDeleted && matchedIds.Contains(d.StudentId)).ToList();
|
||||
OwnDocumentationFollowUpCount = docs.Count(d => d.IsDraft);
|
||||
OwnDocumentationCriticalCount = docs.Count(d =>
|
||||
d.Tags.Any(t => string.Equals(t, "Kritisch", StringComparison.OrdinalIgnoreCase)));
|
||||
}
|
||||
|
||||
/// Reine, ohne Repository-Zugriff testbare Zuordnungslogik. Nutzt <c>FirstName</c>/<c>LastName</c>
|
||||
/// statt <see cref="Student.FullName"/>, weil dessen "Nachname, Vorname"-Format mit Komma den
|
||||
/// leerzeichenbasierten Wortabgleich in <see cref="UntisNameMatching"/> verfälschen würde
|
||||
@@ -635,6 +1109,13 @@ public partial class ClassTeacherOverviewViewModel : ObservableObject
|
||||
urgent: row.IsOverdue);
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void CreateReminderForPatternNotice(ClassTeacherPatternNotice? notice)
|
||||
{
|
||||
if (notice is null || !notice.CanCreateReminder) return;
|
||||
SaveReminder(notice.StudentName, notice.Message, urgent: true);
|
||||
}
|
||||
|
||||
private void SaveReminder(string studentName, string notes, bool urgent)
|
||||
{
|
||||
var task = new WorkTask
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using LehrerApp.Core.Services;
|
||||
|
||||
namespace LehrerApp.Desktop.ViewModels.ClassTeacher;
|
||||
|
||||
/// <summary>Dialog zum Anpassen der <see cref="PatternScoreWeights"/> für den "Gesamtbild"-Reiter
|
||||
/// der Klassenlehreransicht — ursprünglich feste Konstanten, auf Nutzerwunsch jetzt einstellbar.</summary>
|
||||
public partial class PatternScoreWeightsDialogViewModel : ObservableObject
|
||||
{
|
||||
private readonly PatternScoreSettingsService _settings;
|
||||
|
||||
[ObservableProperty] private double _unexcusedDayWeight;
|
||||
[ObservableProperty] private double _excusedDayWeight;
|
||||
[ObservableProperty] private double _lateDayWeight;
|
||||
[ObservableProperty] private double _negativeClassRegisterEntryWeight;
|
||||
[ObservableProperty] private double _concerningKeywordBonus;
|
||||
[ObservableProperty] private double _suspensionKeywordWeight;
|
||||
|
||||
public PatternScoreWeightsDialogViewModel(PatternScoreSettingsService settings)
|
||||
{
|
||||
_settings = settings;
|
||||
var weights = settings.Load();
|
||||
UnexcusedDayWeight = weights.UnexcusedDayWeight;
|
||||
ExcusedDayWeight = weights.ExcusedDayWeight;
|
||||
LateDayWeight = weights.LateDayWeight;
|
||||
NegativeClassRegisterEntryWeight = weights.NegativeClassRegisterEntryWeight;
|
||||
ConcerningKeywordBonus = weights.ConcerningKeywordBonus;
|
||||
SuspensionKeywordWeight = weights.SuspensionKeywordWeight;
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void Save() => _settings.Save(new PatternScoreWeights
|
||||
{
|
||||
UnexcusedDayWeight = UnexcusedDayWeight,
|
||||
ExcusedDayWeight = ExcusedDayWeight,
|
||||
LateDayWeight = LateDayWeight,
|
||||
NegativeClassRegisterEntryWeight = NegativeClassRegisterEntryWeight,
|
||||
ConcerningKeywordBonus = ConcerningKeywordBonus,
|
||||
SuspensionKeywordWeight = SuspensionKeywordWeight,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
using System.Collections.ObjectModel;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using LehrerApp.Core.Models;
|
||||
using LehrerApp.Desktop.ViewModels.Students;
|
||||
|
||||
namespace LehrerApp.Desktop.ViewModels.ClassTeacher;
|
||||
|
||||
// ── Anzeige-Helfer ────────────────────────────────────────────────────────
|
||||
|
||||
public static class VorgangTagDisplay
|
||||
{
|
||||
// Vom Nutzer selbst genannte Schlagwörter — eigene Labels bleiben trotzdem frei möglich
|
||||
// (AutoCompleteBox, gleiches Muster wie DocumentationTagDisplay.Suggestions).
|
||||
public static string[] Suggestions { get; } =
|
||||
["Absentismus", "Hausaufgaben", "Verspätungen", "Konflikte", "Mitarbeit", "Elternkontakt", "Eskalation"];
|
||||
}
|
||||
|
||||
public static class VorgangStatusDisplay
|
||||
{
|
||||
public static string Label(VorgangStatus status) => status == VorgangStatus.Closed ? "Geschlossen" : "Offen";
|
||||
}
|
||||
|
||||
/// Ergebnis des "→ Vorgang"-Auswahldialogs beim Anheften einer Klassenbuchzeile: entweder ein
|
||||
/// bestehender, offener Vorgang oder der Titel für einen neu anzulegenden.
|
||||
public sealed record PinToVorgangChoice(Guid? ExistingVorgangId, string? NewVorgangTitle);
|
||||
|
||||
/// Eingefrorene WebUntis-Klassenbuchzeile mit deutscher Anzeige-Aufbereitung — das Core-Modell
|
||||
/// <see cref="VorgangClassRegisterEntry"/> bleibt bewusst framework-frei ohne Formatierungslogik.
|
||||
public sealed record VorgangClassRegisterEntryRow(VorgangClassRegisterEntry Model)
|
||||
{
|
||||
public string DateLabel => Model.Date.ToString("dd.MM.yyyy");
|
||||
public string StudentName => Model.StudentName;
|
||||
public string SummaryLabel => string.Join(" · ", new[] { Model.Subject, Model.CategoryName, Model.Text }
|
||||
.Where(v => !string.IsNullOrWhiteSpace(v)));
|
||||
}
|
||||
|
||||
// ── Mehrfachauswahl Schüler*innen (Anlegen/Bearbeiten-Dialog) ────────────────
|
||||
|
||||
public partial class VorgangStudentOption(Guid studentId, string name) : ObservableObject
|
||||
{
|
||||
public Guid StudentId { get; } = studentId;
|
||||
public string Name { get; } = name;
|
||||
[ObservableProperty] private bool _isSelected;
|
||||
}
|
||||
|
||||
// ── Listen-Eintrag mit aufklappbarer Detailansicht ───────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Ein Vorgang in der Liste des "Vorgänge"-Tabs. Verknüpfte Dokumentation/Klassenbucheinträge
|
||||
/// werden von <see cref="ClassTeacherCasesViewModel"/> beim Laden befüllt; Link/Unlink-Aktionen
|
||||
/// laufen über die hier übergebenen Callbacks zurück ins ViewModel (statt eigenem Repository-
|
||||
/// Zugriff hier), damit dieser Wrapper ein reiner Anzeige-Baustein bleibt — analog
|
||||
/// <see cref="DocumentationItem"/>.
|
||||
/// </summary>
|
||||
public partial class VorgangItem : ObservableObject
|
||||
{
|
||||
private readonly Func<VorgangItem, DocumentationItem, Task> _onLink;
|
||||
private readonly Func<VorgangItem, DocumentationItem, Task> _onUnlink;
|
||||
private readonly Func<VorgangItem, VorgangClassRegisterEntryRow, Task> _onRemoveClassRegisterEntry;
|
||||
private readonly Func<DocumentationItem, Task> _onEditDocumentation;
|
||||
|
||||
public Vorgang Model { get; }
|
||||
public string StudentNames { get; }
|
||||
public List<TagChip> TagChips { get; }
|
||||
public string StatusLabel => VorgangStatusDisplay.Label(Model.Status);
|
||||
public bool IsOpen => Model.Status == VorgangStatus.Open;
|
||||
public string CreatedLabel => $"Angelegt {Model.CreatedAt:dd.MM.yyyy}";
|
||||
|
||||
public ObservableCollection<DocumentationItem> LinkedDocumentation { get; } = [];
|
||||
public ObservableCollection<DocumentationItem> AvailableDocumentation { get; } = [];
|
||||
public ObservableCollection<VorgangClassRegisterEntryRow> ClassRegisterRows { get; } = [];
|
||||
|
||||
public bool HasLinkedDocumentation => LinkedDocumentation.Count > 0;
|
||||
public bool HasAvailableDocumentation => AvailableDocumentation.Count > 0;
|
||||
public bool HasClassRegisterRows => ClassRegisterRows.Count > 0;
|
||||
|
||||
[ObservableProperty] private bool _isRevealed;
|
||||
|
||||
public VorgangItem(Vorgang model, string studentNames,
|
||||
Func<VorgangItem, DocumentationItem, Task> onLink,
|
||||
Func<VorgangItem, DocumentationItem, Task> onUnlink,
|
||||
Func<VorgangItem, VorgangClassRegisterEntryRow, Task> onRemoveClassRegisterEntry,
|
||||
Func<DocumentationItem, Task> onEditDocumentation)
|
||||
{
|
||||
Model = model;
|
||||
StudentNames = studentNames;
|
||||
TagChips = model.Tags.Select(t => new TagChip(t)).ToList();
|
||||
_onLink = onLink;
|
||||
_onUnlink = onUnlink;
|
||||
_onRemoveClassRegisterEntry = onRemoveClassRegisterEntry;
|
||||
_onEditDocumentation = onEditDocumentation;
|
||||
}
|
||||
|
||||
[RelayCommand] private void Reveal() => IsRevealed = !IsRevealed;
|
||||
|
||||
/// Öffnet den vollen Dokumentations-Dialog zum Nachlesen/Bearbeiten, ohne den Vorgänge-Tab zu
|
||||
/// verlassen (Nutzer-Feedback: bisher musste man dafür in den Schülerdatensatz wechseln).
|
||||
[RelayCommand]
|
||||
private async Task EditDocumentation(DocumentationItem? doc)
|
||||
{
|
||||
if (doc is not null) await _onEditDocumentation(doc);
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private async Task Link(DocumentationItem? doc)
|
||||
{
|
||||
if (doc is not null) await _onLink(this, doc);
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private async Task Unlink(DocumentationItem? doc)
|
||||
{
|
||||
if (doc is not null) await _onUnlink(this, doc);
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private async Task RemoveClassRegisterEntry(VorgangClassRegisterEntryRow? row)
|
||||
{
|
||||
if (row is not null) await _onRemoveClassRegisterEntry(this, row);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Dialog: Vorgang anlegen/bearbeiten ────────────────────────────────────
|
||||
|
||||
public partial class VorgangDialogViewModel : ObservableObject
|
||||
{
|
||||
private readonly Vorgang? _editing;
|
||||
|
||||
public ObservableCollection<VorgangStudentOption> StudentOptions { get; }
|
||||
|
||||
[ObservableProperty] private string _title = "";
|
||||
[ObservableProperty] private string _description = "";
|
||||
[ObservableProperty] private string _newTag = "";
|
||||
public ObservableCollection<string> Tags { get; } = [];
|
||||
public string[] TagSuggestions => VorgangTagDisplay.Suggestions;
|
||||
|
||||
[ObservableProperty] private string _titleError = "";
|
||||
[ObservableProperty] private string _studentsError = "";
|
||||
|
||||
public string DialogTitle => _editing is null ? "Vorgang anlegen" : "Vorgang bearbeiten";
|
||||
public Vorgang? Result { get; private set; }
|
||||
|
||||
public VorgangDialogViewModel(List<StudentOption> rosterStudents, Vorgang? editing)
|
||||
{
|
||||
_editing = editing;
|
||||
StudentOptions = new ObservableCollection<VorgangStudentOption>(rosterStudents.Select(s =>
|
||||
new VorgangStudentOption(s.Id, s.Name) { IsSelected = editing?.StudentIds.Contains(s.Id) == true }));
|
||||
if (editing is null) return;
|
||||
|
||||
Title = editing.Title;
|
||||
Description = editing.Description;
|
||||
foreach (var tag in editing.Tags) Tags.Add(tag);
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void AddTag()
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(NewTag)) return;
|
||||
var tag = NewTag.Trim();
|
||||
if (!Tags.Contains(tag)) Tags.Add(tag);
|
||||
NewTag = "";
|
||||
}
|
||||
|
||||
[RelayCommand] private void RemoveTag(string? tag) { if (tag is not null) Tags.Remove(tag); }
|
||||
|
||||
[RelayCommand]
|
||||
private void Save()
|
||||
{
|
||||
TitleError = ""; StudentsError = "";
|
||||
var valid = true;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(Title)) { TitleError = "Titel erforderlich."; valid = false; }
|
||||
var selectedIds = StudentOptions.Where(s => s.IsSelected).Select(s => s.StudentId).ToList();
|
||||
if (selectedIds.Count == 0) { StudentsError = "Mindestens eine/n Schüler*in auswählen."; valid = false; }
|
||||
|
||||
if (!valid) return;
|
||||
|
||||
Result = _editing ?? new Vorgang();
|
||||
Result.Title = Title.Trim();
|
||||
Result.Description = (Description ?? "").Trim();
|
||||
Result.StudentIds = selectedIds;
|
||||
Result.Tags = Tags.ToList();
|
||||
}
|
||||
}
|
||||
|
||||
// ── Dialog: Klassenbuchzeile an (bestehenden oder neuen) Vorgang anheften ────
|
||||
|
||||
public partial class PinToVorgangDialogViewModel : ObservableObject
|
||||
{
|
||||
public string RowSummary { get; }
|
||||
public List<VorgangItem> MatchingOpenCases { get; }
|
||||
public bool HasMatchingOpenCases => MatchingOpenCases.Count > 0;
|
||||
|
||||
[ObservableProperty] private VorgangItem? _selectedCase;
|
||||
[ObservableProperty] private string _newTitle = "";
|
||||
[ObservableProperty] private string _error = "";
|
||||
|
||||
public PinToVorgangChoice? Result { get; private set; }
|
||||
|
||||
public PinToVorgangDialogViewModel(string rowSummary, List<VorgangItem> matchingOpenCases)
|
||||
{
|
||||
RowSummary = rowSummary;
|
||||
MatchingOpenCases = matchingOpenCases;
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void Confirm()
|
||||
{
|
||||
Error = "";
|
||||
if (SelectedCase is not null) { Result = new PinToVorgangChoice(SelectedCase.Model.Id, null); return; }
|
||||
if (!string.IsNullOrWhiteSpace(NewTitle)) { Result = new PinToVorgangChoice(null, NewTitle.Trim()); return; }
|
||||
Error = "Bestehenden Vorgang wählen oder Titel für einen neuen Vorgang eingeben.";
|
||||
}
|
||||
}
|
||||
@@ -35,6 +35,7 @@ public partial class DashboardViewModel : ObservableObject
|
||||
private readonly PublicHolidayService _publicHolidays;
|
||||
private readonly SchoolCalendarSettingsService _calendarSettings;
|
||||
private readonly ISubstitutionEntryRepository _substitutions;
|
||||
private readonly ITimeEntryRepository _timeEntries;
|
||||
private readonly IAnnualPlanEventRepository? _annualPlanEvents;
|
||||
private readonly SchoolWeatherService? _schoolWeather;
|
||||
|
||||
@@ -50,12 +51,29 @@ public partial class DashboardViewModel : ObservableObject
|
||||
/// hat rein rechnerisch schon 100 %, das ist noch kein auffälliges Muster, nur eine zu kleine
|
||||
/// Stichprobe. Dieselbe Konstante wie GroupOverviewViewModel.AttendanceMinSampleSize.
|
||||
private const int AttendanceMinSampleSize = 8;
|
||||
/// Nutzer-Feedback: "ich vergesse es, und fasse die App auch außerhalb der Schule manchmal
|
||||
/// nicht mehr an" — die Erinnerung an fehlende Unterrichtszeit-Erfassung schaut deshalb nicht
|
||||
/// nur auf heute zurück, sondern auf ein paar Tage, damit ein vergessener Tag nicht verloren
|
||||
/// geht, sobald der nächste Schultag anbricht.
|
||||
private const int MissingTeachingTimeLookbackDays = 14;
|
||||
/// Für den heutigen Tag soll die Erinnerung nicht schon mitten im Unterricht auftauchen —
|
||||
/// erst diese Zeitspanne nach dem laut Stundenplan letzten Unterrichtsende.
|
||||
private const int MissingTeachingTimeTodayDelayMinutes = 30;
|
||||
/// Gleiche Puffer-Idee wie TimeTrackingViewModel.ComputeTodaysTeachingWindow (bewusst hier
|
||||
/// noch einmal definiert statt geteilt — der Vorschlag ist nur zwei Zeilen Rechnung).
|
||||
private const int TeachingWindowBufferBeforeMinutes = 15;
|
||||
private const int TeachingWindowBufferAfterMinutes = 10;
|
||||
/// Vorausschau für die Klausurwochen-Karte (Nutzer-Feedback): weit genug, um eine sich
|
||||
/// anbahnende Häufung noch rechtzeitig vor dem Anlegen weiterer Klausuren zu zeigen, aber
|
||||
/// keine Vorschau auf das ganze Schuljahr.
|
||||
private const int ExamLoadLookaheadDays = 60;
|
||||
|
||||
[ObservableProperty] private string _greeting = "";
|
||||
[ObservableProperty] private string _currentDate = "";
|
||||
[ObservableProperty] private string _currentSchoolYear = "";
|
||||
[ObservableProperty] private DateOnly _calendarMonth = FirstOfMonth(DateTime.Today);
|
||||
[ObservableProperty] private string _selectedDayLabel = "";
|
||||
[ObservableProperty] private DateOnly _selectedCalendarDate = DateOnly.FromDateTime(DateTime.Today);
|
||||
[ObservableProperty] private bool _isDashboardSettingsOpen;
|
||||
[ObservableProperty] private bool _isWeatherPanelVisible;
|
||||
[ObservableProperty] private string _weatherSummary = "";
|
||||
@@ -71,6 +89,8 @@ public partial class DashboardViewModel : ObservableObject
|
||||
public ObservableCollection<CalendarDayCell> CalendarDays { get; } = [];
|
||||
public ObservableCollection<OpenExcuseItem> OpenExcuses { get; } = [];
|
||||
public ObservableCollection<AttendanceWarningItem> AttendanceWarnings { get; } = [];
|
||||
public ObservableCollection<ExamWeekLoadItem> ExamWeekLoads { get; } = [];
|
||||
public ObservableCollection<MissingTeachingTimeItem> MissingTeachingTimeEntries { get; } = [];
|
||||
public ObservableCollection<SupportPlanDueItem> SupportPlanReviews { get; } = [];
|
||||
public ObservableCollection<UpcomingDateItem> UpcomingDates { get; } = [];
|
||||
public ObservableCollection<CorrectionProgressItem> OpenCorrections { get; } = [];
|
||||
@@ -95,6 +115,10 @@ public partial class DashboardViewModel : ObservableObject
|
||||
// Sprungziel für eine ungeplante Stunde — führt direkt in den Verlaufsplan-Tab der Gruppe
|
||||
// (nicht den Standard-Tab von OnNavigateToGroup), damit das Thema gleich ergänzt werden kann.
|
||||
public Action<Guid>? OnNavigateToUnplannedLesson { get; set; }
|
||||
// Öffnet den Nacherfassen-Dialog für einen Tag mit fehlender Unterrichtszeit-Erfassung
|
||||
// (Nutzer-Feedback), vorbelegt mit Datum, Kategorie "Unterricht" und dem laut Stundenplan
|
||||
// berechneten Zeitfenster — echtes Speichern bleibt eine bewusste Bestätigung im Dialog.
|
||||
public Func<MissingTeachingTimeItem, Task>? OnAddMissingTeachingTime { get; set; }
|
||||
|
||||
public DashboardCardOption TodayCard => Card("today");
|
||||
public DashboardCardOption TasksCard => Card("tasks");
|
||||
@@ -105,6 +129,8 @@ public partial class DashboardViewModel : ObservableObject
|
||||
public DashboardCardOption UnplannedCard => Card("unplanned");
|
||||
public DashboardCardOption AlertsCard => Card("alerts");
|
||||
public DashboardCardOption AttendanceCard => Card("attendance");
|
||||
public DashboardCardOption ExamLoadCard => Card("examload");
|
||||
public DashboardCardOption MissingTeachingTimeCard => Card("missingteachingtime");
|
||||
public DashboardCardOption SupportCard => Card("support");
|
||||
public DashboardCardOption GroupsCard => Card("groups");
|
||||
public int TodayLessonCount => TodaysLessons.Count;
|
||||
@@ -126,7 +152,8 @@ public partial class DashboardViewModel : ObservableObject
|
||||
PeriodScheduleService periodSchedule, AttendanceBalanceService attendanceBalance, SchoolYearService sy,
|
||||
DashboardSettingsService dashboardSettings, ISchoolHolidayRepository schoolHolidays,
|
||||
PublicHolidayService publicHolidays, SchoolCalendarSettingsService calendarSettings,
|
||||
ISubstitutionEntryRepository substitutions, IAnnualPlanEventRepository? annualPlanEvents = null,
|
||||
ISubstitutionEntryRepository substitutions, ITimeEntryRepository timeEntries,
|
||||
IAnnualPlanEventRepository? annualPlanEvents = null,
|
||||
AnnualPlanSyncService? annualPlanSync = null, SchoolWeatherService? schoolWeather = null)
|
||||
{
|
||||
_groups = groups; _subjects = subjects; _lessons = lessons; _exams = exams; _tasks = tasks;
|
||||
@@ -136,6 +163,7 @@ public partial class DashboardViewModel : ObservableObject
|
||||
_timetableSlots = timetableSlots; _periodSchedule = periodSchedule;
|
||||
_attendanceBalance = attendanceBalance; _sy = sy; _dashboardSettings = dashboardSettings;
|
||||
_schoolHolidays = schoolHolidays; _publicHolidays = publicHolidays; _calendarSettings = calendarSettings;
|
||||
_timeEntries = timeEntries;
|
||||
_substitutions = substitutions;
|
||||
_annualPlanEvents = annualPlanEvents;
|
||||
_schoolWeather = schoolWeather;
|
||||
@@ -196,7 +224,7 @@ public partial class DashboardViewModel : ObservableObject
|
||||
IsHighPriority = t.Priority == TaskPriority.High });
|
||||
|
||||
CurrentGroups.Clear();
|
||||
foreach (var g in groups.Values.OrderBy(g => g.Name))
|
||||
foreach (var g in groups.Values)
|
||||
CurrentGroups.Add(new()
|
||||
{
|
||||
GroupId = g.Id,
|
||||
@@ -208,6 +236,8 @@ public partial class DashboardViewModel : ObservableObject
|
||||
LoadCalendar();
|
||||
LoadOpenExcuses(groups.Values.ToList(), today);
|
||||
LoadAttendanceWarnings(today);
|
||||
LoadExamWeekLoads(groups, today);
|
||||
LoadMissingTeachingTime(today);
|
||||
LoadSupportPlanReviews(today);
|
||||
LoadUpcomingDates(groups, today);
|
||||
LoadOpenCorrections(groups, today);
|
||||
@@ -227,6 +257,8 @@ public partial class DashboardViewModel : ObservableObject
|
||||
UnplannedCard.IsEmpty = UnplannedLessons.Count == 0;
|
||||
AlertsCard.IsEmpty = Alerts.Count == 0;
|
||||
AttendanceCard.IsEmpty = AttendanceWarnings.Count == 0;
|
||||
ExamLoadCard.IsEmpty = ExamWeekLoads.Count == 0;
|
||||
MissingTeachingTimeCard.IsEmpty = MissingTeachingTimeEntries.Count == 0;
|
||||
SupportCard.IsEmpty = SupportPlanReviews.Count == 0;
|
||||
GroupsCard.IsEmpty = CurrentGroups.Count == 0;
|
||||
|
||||
@@ -300,12 +332,16 @@ public partial class DashboardViewModel : ObservableObject
|
||||
var from = _sy.SchoolYearStart(schoolYear);
|
||||
var to = _sy.SchoolYearEnd(schoolYear);
|
||||
|
||||
// Ein Bulk-Laden aller Sitzungen vermeidet, für jeden Mitarbeits-Eintrag jedes Schülers
|
||||
// einzeln GetById aufzurufen (N+1 bei vielen Schülern/Einträgen).
|
||||
var sessionDates = _participationSessions.GetAll().ToDictionary(s => s.Id, s => s.Date);
|
||||
|
||||
var items = new List<AttendanceWarningItem>();
|
||||
foreach (var student in _students.GetAll())
|
||||
{
|
||||
var entries = _participationEntries.GetByStudent(student.Id)
|
||||
.Select(e => _participationSessions.GetById(e.SessionId) is { } session
|
||||
? ((DateOnly?)session.Date, e.Attendance) : (null, e.Attendance))
|
||||
.Select(e => sessionDates.TryGetValue(e.SessionId, out var date)
|
||||
? ((DateOnly?)date, e.Attendance) : (null, e.Attendance))
|
||||
.Where(t => t.Item1.HasValue)
|
||||
.Select(t => (t.Item1!.Value, t.Attendance));
|
||||
|
||||
@@ -317,6 +353,75 @@ public partial class DashboardViewModel : ObservableObject
|
||||
AttendanceWarnings.Add(item);
|
||||
}
|
||||
|
||||
// ── Klausurwochen (Nutzer-Feedback) ───────────────────────────────────────
|
||||
//
|
||||
// Persönliche Klausurlast über alle Kurse hinweg — anders als die klassenbezogene
|
||||
// Kollisionsprüfung, die bereits der schulische Klausurplaner übernimmt (siehe
|
||||
// ExamWeekLoadService). Nur geplante, noch bevorstehende Klausuren zählen: die
|
||||
// Vorausschau soll helfen, eine sich anbahnende Woche zu erkennen, bevor man eine weitere
|
||||
// Klausur genau dort einträgt — bereits durchgeführte/korrigierte Klausuren werden schon
|
||||
// von der Karte "Offene Korrekturen" abgedeckt.
|
||||
|
||||
private void LoadExamWeekLoads(IReadOnlyDictionary<Guid, LearningGroup> groups, DateOnly today)
|
||||
{
|
||||
ExamWeekLoads.Clear();
|
||||
var lastDay = today.AddDays(ExamLoadLookaheadDays);
|
||||
var upcoming = groups.Keys
|
||||
.SelectMany(gid => _exams.GetByGroup(gid))
|
||||
.Where(e => e.Status == ExamStatus.Planned && e.Date >= today && e.Date <= lastDay);
|
||||
|
||||
foreach (var week in ExamWeekLoadService.FindOverloadedWeeks(upcoming))
|
||||
ExamWeekLoads.Add(new ExamWeekLoadItem(week.IsoWeek, week.WeekStart, week.WeekEnd, week.ExamCount));
|
||||
}
|
||||
|
||||
// ── Unterrichtszeit-Erfassung nachholen (Nutzer-Feedback) ─────────────────
|
||||
//
|
||||
// "Ich vergesse es, und fasse die App außerhalb der Schule manchmal nicht mehr an" — schaut
|
||||
// deshalb bewusst ein paar Tage zurück statt nur auf heute. Ein Tag zählt als erfasst, sobald
|
||||
// irgendein TimeEntry der Kategorie "Unterricht" an diesem Datum existiert; die genaue
|
||||
// Zeitüberschneidung mit dem Stundenplan wird nicht geprüft (dieselbe grobe Betrachtung wie
|
||||
// beim bestehenden "Unterrichtszeit übernehmen"-Vorschlag für heute).
|
||||
|
||||
private void LoadMissingTeachingTime(DateOnly today)
|
||||
{
|
||||
MissingTeachingTimeEntries.Clear();
|
||||
var firstDay = today.AddDays(-MissingTeachingTimeLookbackDays);
|
||||
var publicHolidayDates = Enumerable.Range(firstDay.Year, today.Year - firstDay.Year + 1)
|
||||
.SelectMany(y => _publicHolidays.GetHolidays(y, _calendarSettings.State))
|
||||
.Select(h => h.Date).ToHashSet();
|
||||
var schoolHolidays = _schoolHolidays.GetAll();
|
||||
var nowTime = TimeOnly.FromDateTime(DateTime.Now);
|
||||
|
||||
var items = new List<MissingTeachingTimeItem>();
|
||||
for (var date = firstDay; date <= today; date = date.AddDays(1))
|
||||
{
|
||||
if (IsFreeDay(date, schoolHolidays, publicHolidayDates)) continue;
|
||||
|
||||
var daySlots = _timetableSlots.GetAll().Where(s => s.Weekday == date.DayOfWeek).ToList();
|
||||
if (daySlots.Count == 0) continue;
|
||||
|
||||
var cancelledPeriods = _substitutions.GetByDate(date)
|
||||
.Where(s => s.Kind == SubstitutionKind.Cancelled)
|
||||
.Select(s => s.PeriodNumber).ToHashSet();
|
||||
var periodTimes = daySlots.Where(s => !cancelledPeriods.Contains(s.PeriodNumber))
|
||||
.Select(s => _periodSchedule.GetTimes(s.PeriodNumber))
|
||||
.Where(t => t is not null).Select(t => t!.Value).ToList();
|
||||
if (periodTimes.Count == 0) continue; // alle Stunden entfallen oder kein Zeitraster hinterlegt
|
||||
|
||||
var lastPeriodEnd = periodTimes.Max(t => t.End);
|
||||
if (date == today && nowTime < lastPeriodEnd.AddMinutes(MissingTeachingTimeTodayDelayMinutes))
|
||||
continue; // heute: erst 30 Minuten nach Unterrichtsschluss erinnern
|
||||
|
||||
if (_timeEntries.GetByDate(date).Any(e => e.Category == "Unterricht")) continue;
|
||||
|
||||
var windowStart = periodTimes.Min(t => t.Start).AddMinutes(-TeachingWindowBufferBeforeMinutes);
|
||||
var windowEnd = lastPeriodEnd.AddMinutes(TeachingWindowBufferAfterMinutes);
|
||||
items.Add(new MissingTeachingTimeItem(date, windowStart, windowEnd));
|
||||
}
|
||||
foreach (var item in items.OrderBy(i => i.Date))
|
||||
MissingTeachingTimeEntries.Add(item);
|
||||
}
|
||||
|
||||
// ── Förderplan-Wiedervorlage (5.3.2) ──────────────────────────────────────
|
||||
|
||||
private void LoadSupportPlanReviews(DateOnly today)
|
||||
@@ -542,7 +647,8 @@ public partial class DashboardViewModel : ObservableObject
|
||||
"excuses" => "Offene Entschuldigungen", "upcoming" => "Anstehende Termine",
|
||||
"corrections" => "Offene Korrekturen", "unplanned" => "Ungeplante Stunden", "alerts" => "Auffälligkeiten",
|
||||
"attendance" => "Fehlzeiten-Warnung", "support" => "Förderplan-Wiedervorlage",
|
||||
"groups" => "Meine Lerngruppen", _ => key,
|
||||
"groups" => "Meine Lerngruppen", "examload" => "Klausurwochen",
|
||||
"missingteachingtime" => "Unterrichtszeit nacherfassen", _ => key,
|
||||
};
|
||||
|
||||
private void ApplyCardLayout()
|
||||
@@ -592,6 +698,15 @@ public partial class DashboardViewModel : ObservableObject
|
||||
[RelayCommand] private void OpenStudentSupportPlan(SupportPlanDueItem? item)
|
||||
{ if (item is not null) OnNavigateToStudent?.Invoke(item.StudentId); }
|
||||
|
||||
[RelayCommand]
|
||||
private async Task AddMissingTeachingTime(MissingTeachingTimeItem? item)
|
||||
{
|
||||
if (item is null || OnAddMissingTeachingTime is null) return;
|
||||
await OnAddMissingTeachingTime(item);
|
||||
LoadMissingTeachingTime(DateOnly.FromDateTime(DateTime.Today));
|
||||
UpdateDashboardSummary();
|
||||
}
|
||||
|
||||
private void LoadOpenExcuses(List<LearningGroup> groups, DateOnly today)
|
||||
{
|
||||
OpenExcuses.Clear();
|
||||
@@ -749,9 +864,35 @@ public partial class DashboardViewModel : ObservableObject
|
||||
{
|
||||
if (day is null) return;
|
||||
foreach (var cell in CalendarDays) cell.IsSelected = cell == day;
|
||||
SelectedCalendarDate = day.Date;
|
||||
SelectedDayLabel = day.Date.ToString("dddd, d. MMMM", De);
|
||||
SelectedDayEvents.Clear();
|
||||
foreach (var item in day.Events) SelectedDayEvents.Add(item);
|
||||
SortCurrentGroups(day.Date);
|
||||
}
|
||||
|
||||
private void SortCurrentGroups(DateOnly date)
|
||||
{
|
||||
var schoolHolidays = _schoolHolidays.GetAll();
|
||||
var publicHolidays = _publicHolidays.GetHolidays(date.Year, _calendarSettings.State)
|
||||
.Select(h => h.Date).ToHashSet();
|
||||
var isFreeDay = IsFreeDay(date, schoolHolidays, publicHolidays);
|
||||
var cancelledPeriods = _substitutions.GetByDate(date)
|
||||
.Where(s => s.Kind == SubstitutionKind.Cancelled)
|
||||
.Select(s => s.PeriodNumber).ToHashSet();
|
||||
|
||||
foreach (var chip in CurrentGroups)
|
||||
{
|
||||
var hasLesson = _lessons.GetByGroupAndDate(chip.GroupId, date).Count > 0;
|
||||
var hasActiveSlot = !isFreeDay && _timetableSlots.GetByGroup(chip.GroupId)
|
||||
.Any(s => s.Weekday == date.DayOfWeek && !cancelledPeriods.Contains(s.PeriodNumber));
|
||||
chip.IsOnSelectedDay = hasLesson || hasActiveSlot;
|
||||
}
|
||||
|
||||
var sorted = CurrentGroups.OrderByDescending(g => g.IsOnSelectedDay)
|
||||
.ThenBy(g => g.Name, StringComparer.CurrentCultureIgnoreCase).ToList();
|
||||
CurrentGroups.Clear();
|
||||
foreach (var chip in sorted) CurrentGroups.Add(chip);
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
@@ -891,7 +1032,13 @@ public class LessonItem
|
||||
public bool HasRoom => !string.IsNullOrWhiteSpace(Room);
|
||||
}
|
||||
public class TaskItem { public string Title { get; set; } = ""; public string DueDate { get; set; } = ""; public bool IsOverdue { get; set; } public bool IsReminder { get; set; } public bool IsHighPriority { get; set; } }
|
||||
public class GroupChip { public Guid GroupId { get; set; } public string Name { get; set; } = ""; public string Subject { get; set; } = ""; }
|
||||
public class GroupChip
|
||||
{
|
||||
public Guid GroupId { get; set; }
|
||||
public string Name { get; set; } = "";
|
||||
public string Subject { get; set; } = "";
|
||||
public bool IsOnSelectedDay { get; set; }
|
||||
}
|
||||
|
||||
// ── Offene Entschuldigungen (aus Mitarbeit-Fehltagen) ────────────────────────
|
||||
|
||||
@@ -929,6 +1076,23 @@ public class AttendanceWarningItem(Guid studentId, string studentName, double ab
|
||||
public double AbsenceRatePercent { get; } = absenceRatePercent;
|
||||
}
|
||||
|
||||
public class ExamWeekLoadItem(int isoWeek, DateOnly weekStart, DateOnly weekEnd, int examCount)
|
||||
{
|
||||
public int ExamCount { get; } = examCount;
|
||||
public string Label => $"KW {isoWeek} ({weekStart:dd.MM.}–{weekEnd:dd.MM.})";
|
||||
public string CountDisplay => ExamCount == 1 ? "1 Klausur" : $"{ExamCount} Klausuren";
|
||||
}
|
||||
|
||||
public class MissingTeachingTimeItem(DateOnly date, TimeOnly windowStart, TimeOnly windowEnd)
|
||||
{
|
||||
private static readonly CultureInfo De = new("de-DE");
|
||||
|
||||
public DateOnly Date { get; } = date;
|
||||
public TimeOnly WindowStart { get; } = windowStart;
|
||||
public TimeOnly WindowEnd { get; } = windowEnd;
|
||||
public string DateDisplay { get; } = date.ToString("dddd, dd.MM.", De);
|
||||
}
|
||||
|
||||
// ── Förderplan-Wiedervorlage (5.3.2) ──────────────────────────────────────────
|
||||
|
||||
public class SupportPlanDueItem
|
||||
|
||||
@@ -43,6 +43,8 @@ public partial class ExamsOverviewViewModel : ObservableObject
|
||||
public Func<Exam, Task>? OnGradeExam { get; set; }
|
||||
public Func<Exam, Task>? OnEvaluateExam { get; set; }
|
||||
public Action<Guid>? OnNavigateToGroup { get; set; }
|
||||
public Action? OnNavigateToGroups { get; set; }
|
||||
public bool HasNoExams => Rows.Count == 0;
|
||||
|
||||
public ExamsOverviewViewModel(IExamRepository exams, IExamResultRepository examResults,
|
||||
IGroupRepository groups, IGroupMembershipRepository memberships, GradingService grading,
|
||||
@@ -85,6 +87,7 @@ public partial class ExamsOverviewViewModel : ObservableObject
|
||||
Rows.Add(row);
|
||||
|
||||
EmptyHint = Rows.Count == 0 ? "Keine Klausuren angelegt." : "";
|
||||
OnPropertyChanged(nameof(HasNoExams));
|
||||
SelectedRow = selectedId is { } id
|
||||
? Rows.FirstOrDefault(r => r.Exam.Id == id) ?? Rows.FirstOrDefault()
|
||||
: Rows.FirstOrDefault();
|
||||
@@ -173,6 +176,9 @@ public partial class ExamsOverviewViewModel : ObservableObject
|
||||
OnNavigateToGroup?.Invoke(SelectedRow.GroupId);
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void GoToGroups() => OnNavigateToGroups?.Invoke();
|
||||
|
||||
[RelayCommand]
|
||||
private void ToggleApproval() => ToggleDate(SelectedRow?.Exam, e => e.ApprovalGrantedAt,
|
||||
(e, v) => e.ApprovalGrantedAt = v);
|
||||
|
||||
@@ -17,6 +17,7 @@ public partial class GlobalSearchViewModel : ObservableObject
|
||||
|
||||
private readonly IStudentRepository _students;
|
||||
private readonly IGroupRepository _groups;
|
||||
private readonly ISubjectRepository _subjects;
|
||||
private readonly IExamRepository _exams;
|
||||
private readonly IWorkTaskRepository _tasks;
|
||||
|
||||
@@ -30,13 +31,15 @@ public partial class GlobalSearchViewModel : ObservableObject
|
||||
public Action<GlobalSearchResult>? OnNavigate { get; set; }
|
||||
public Func<bool, Task>? OnQuickAddTask { get; set; }
|
||||
public Func<Task>? OnQuickAddStudent { get; set; }
|
||||
public Func<Task>? OnQuickAddGroupDocumentation { get; set; }
|
||||
public Action? OnClose { get; set; }
|
||||
|
||||
public GlobalSearchViewModel(IStudentRepository students, IGroupRepository groups,
|
||||
IExamRepository exams, IWorkTaskRepository tasks)
|
||||
ISubjectRepository subjects, IExamRepository exams, IWorkTaskRepository tasks)
|
||||
{
|
||||
_students = students;
|
||||
_groups = groups;
|
||||
_subjects = subjects;
|
||||
_exams = exams;
|
||||
_tasks = tasks;
|
||||
RefreshResults();
|
||||
@@ -59,23 +62,34 @@ public partial class GlobalSearchViewModel : ObservableObject
|
||||
|
||||
if (query.Length > 0)
|
||||
{
|
||||
var groups = _groups.GetAll(includeInactive: true);
|
||||
var groups = _groups.GetAll().Where(g => g.IsActive).ToList();
|
||||
var groupNames = groups.ToDictionary(g => g.Id, g => g.Name);
|
||||
var subjects = _subjects.GetAll().ToDictionary(s => s.Id);
|
||||
Subject? GroupSubject(LearningGroup group) => group.SubjectId is { } subjectId
|
||||
? subjects.GetValueOrDefault(subjectId)
|
||||
: null;
|
||||
static string SubjectLabel(Subject? subject) => subject is null ? ""
|
||||
: string.IsNullOrWhiteSpace(subject.ShortName)
|
||||
|| subject.Name.Equals(subject.ShortName, StringComparison.CurrentCultureIgnoreCase)
|
||||
? subject.Name
|
||||
: $"{subject.ShortName} – {subject.Name}";
|
||||
var candidates = new List<GlobalSearchResult>();
|
||||
|
||||
candidates.AddRange(_students.GetAll(includeInactive: true)
|
||||
candidates.AddRange(_students.GetAll().Where(s => s.IsActive)
|
||||
.Where(s => Matches(s.FullName, query))
|
||||
.Select(s => GlobalSearchResult.ForStudent(s)));
|
||||
|
||||
candidates.AddRange(groups
|
||||
.Where(g => Matches($"{g.Name} {g.SchoolYear} {g.GradeLevel}", query))
|
||||
.Select(GlobalSearchResult.ForGroup));
|
||||
.Where(g => Matches($"{g.Name} {GroupSubject(g)?.Name} {GroupSubject(g)?.ShortName} {g.SchoolYear} {g.GradeLevel}", query))
|
||||
.Select(g => GlobalSearchResult.ForGroup(g, SubjectLabel(GroupSubject(g)))));
|
||||
|
||||
candidates.AddRange(_exams.GetAll()
|
||||
.Where(e => groupNames.ContainsKey(e.GroupId))
|
||||
.Where(e => Matches($"{e.Title} {groupNames.GetValueOrDefault(e.GroupId)}", query))
|
||||
.Select(e => GlobalSearchResult.ForExam(e, groupNames.GetValueOrDefault(e.GroupId) ?? "")));
|
||||
|
||||
candidates.AddRange(_tasks.GetAll()
|
||||
.Where(t => t.GroupId is null || groupNames.ContainsKey(t.GroupId.Value))
|
||||
.Where(t => Matches($"{t.Title} {t.Notes} {groupNames.GetValueOrDefault(t.GroupId ?? Guid.Empty)}", query))
|
||||
.Select(t => GlobalSearchResult.ForTask(t, groupNames.GetValueOrDefault(t.GroupId ?? Guid.Empty) ?? "")));
|
||||
|
||||
@@ -99,6 +113,7 @@ public partial class GlobalSearchViewModel : ObservableObject
|
||||
GlobalSearchResult.ForAction(GlobalSearchAction.NewTask, "Aufgabe anlegen", "Mit Fälligkeit, Gruppe und Priorität", "+"),
|
||||
GlobalSearchResult.ForAction(GlobalSearchAction.NewReminder, "Erinnerung anlegen", "Kurze Notiz ohne Zeiterfassung", "◷"),
|
||||
GlobalSearchResult.ForAction(GlobalSearchAction.NewStudent, "Schüler anlegen", "Neue Stammdaten erfassen", "+"),
|
||||
GlobalSearchResult.ForAction(GlobalSearchAction.NewGroupDocumentation, "Lerngruppen-Eintrag", "Planung, Klausur oder Erinnerung dokumentieren", "N"),
|
||||
};
|
||||
|
||||
foreach (var action in actions.Where(a => query.Length == 0 || Matches(a.Title, query)))
|
||||
@@ -124,6 +139,9 @@ public partial class GlobalSearchViewModel : ObservableObject
|
||||
case GlobalSearchAction.NewStudent:
|
||||
if (OnQuickAddStudent is not null) await OnQuickAddStudent();
|
||||
break;
|
||||
case GlobalSearchAction.NewGroupDocumentation:
|
||||
if (OnQuickAddGroupDocumentation is not null) await OnQuickAddGroupDocumentation();
|
||||
break;
|
||||
default:
|
||||
OnNavigate?.Invoke(result);
|
||||
break;
|
||||
@@ -134,7 +152,7 @@ public partial class GlobalSearchViewModel : ObservableObject
|
||||
}
|
||||
|
||||
public enum GlobalSearchResultKind { Action, Student, Group, Exam, Task }
|
||||
public enum GlobalSearchAction { None, NewTask, NewReminder, NewStudent }
|
||||
public enum GlobalSearchAction { None, NewTask, NewReminder, NewStudent, NewGroupDocumentation }
|
||||
|
||||
public sealed class GlobalSearchResult
|
||||
{
|
||||
@@ -171,10 +189,13 @@ public sealed class GlobalSearchResult
|
||||
Title = student.FullName, Subtitle = student.IsActive ? "Aktiv" : "Inaktiv", Icon = "P",
|
||||
};
|
||||
|
||||
public static GlobalSearchResult ForGroup(LearningGroup group) => new()
|
||||
public static GlobalSearchResult ForGroup(LearningGroup group, string subjectName) => new()
|
||||
{
|
||||
Kind = GlobalSearchResultKind.Group, EntityId = group.Id, GroupId = group.Id,
|
||||
Title = group.Name, Subtitle = $"{group.SchoolYear} · Stufe {group.GradeLevel}", Icon = "G",
|
||||
Title = group.Name,
|
||||
Subtitle = string.Join(" · ", new[] { subjectName, group.SchoolYear, $"Stufe {group.GradeLevel}" }
|
||||
.Where(x => !string.IsNullOrWhiteSpace(x))),
|
||||
Icon = "G",
|
||||
};
|
||||
|
||||
public static GlobalSearchResult ForExam(Exam exam, string groupName) => new()
|
||||
|
||||
@@ -32,6 +32,7 @@ public partial class ExamDialogViewModel : ObservableObject
|
||||
[ObservableProperty] private string _titleError = "";
|
||||
[ObservableProperty] private string _dateTextError = "";
|
||||
[ObservableProperty] private string _returnedAtTextError = "";
|
||||
[ObservableProperty] private string _weekLoadHint = "";
|
||||
[ObservableProperty] private double _totalPoints;
|
||||
[ObservableProperty] private bool _hasCompetencyCatalog;
|
||||
[ObservableProperty] private bool _useWeighting;
|
||||
@@ -108,6 +109,7 @@ public partial class ExamDialogViewModel : ObservableObject
|
||||
}
|
||||
foreach (var t in Tasks) t.ShowWeight = UseWeighting;
|
||||
RecomputeTotals();
|
||||
UpdateWeekLoadHint();
|
||||
}
|
||||
|
||||
partial void OnUseWeightingChanged(bool value)
|
||||
@@ -115,6 +117,26 @@ public partial class ExamDialogViewModel : ObservableObject
|
||||
foreach (var t in Tasks) t.ShowWeight = value;
|
||||
}
|
||||
|
||||
partial void OnDateTextChanged(string value) => UpdateWeekLoadHint();
|
||||
|
||||
/// Nutzer-Feedback: persönliche Klausurlast pro Woche — die klassenbezogene
|
||||
/// Kollisionsprüfung übernimmt bereits der schulische Klausurplaner (siehe
|
||||
/// ExamWeekLoadService), hier geht es um die eigene Häufung über alle Kurse hinweg.
|
||||
private void UpdateWeekLoadHint()
|
||||
{
|
||||
if (!DateOnly.TryParseExact(DateText, "dd.MM.yyyy", null, DateTimeStyles.None, out var date))
|
||||
{
|
||||
WeekLoadHint = "";
|
||||
return;
|
||||
}
|
||||
|
||||
var existing = ExamWeekLoadService.CountInSameWeek(_exams.GetAll(), date, _editingExam?.Id);
|
||||
var totalIfSaved = existing + 1;
|
||||
WeekLoadHint = totalIfSaved >= ExamWeekLoadService.WarningThreshold
|
||||
? $"Damit hättest du {totalIfSaved} Klausuren in dieser Woche."
|
||||
: "";
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void AddTask()
|
||||
{
|
||||
|
||||
@@ -54,6 +54,7 @@ public partial class GradeOverviewTabViewModel : ObservableObject
|
||||
public Func<GradeOverviewRow, Task>? OnManageStudentGrades { get; set; }
|
||||
public Func<Task>? OnCollectiveGrade { get; set; }
|
||||
public Func<Task>? OnReportGrades { get; set; }
|
||||
public Func<GradeOverviewRow, Task>? OnShowPerformanceOverview { get; set; }
|
||||
|
||||
public GradeOverviewTabViewModel(IGradeRepository grades, IExamRepository exams,
|
||||
IExamResultRepository results, IStudentRepository students,
|
||||
@@ -107,6 +108,14 @@ public partial class GradeOverviewTabViewModel : ObservableObject
|
||||
Recompute();
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private async Task ShowPerformanceOverview()
|
||||
{
|
||||
if (SelectedRow is null || OnShowPerformanceOverview is null) return;
|
||||
await OnShowPerformanceOverview(SelectedRow);
|
||||
Recompute();
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private async Task CollectiveGrade()
|
||||
{
|
||||
|
||||
@@ -25,6 +25,7 @@ public partial class GroupDocumentationTabViewModel : ObservableObject
|
||||
private List<StudentOption> _groupStudents = [];
|
||||
|
||||
public static readonly StudentOption AllStudentsOption = new(Guid.Empty, "Alle Schüler");
|
||||
public static readonly StudentOption WholeGroupOption = new(Guid.Empty, "Gesamte Lerngruppe");
|
||||
|
||||
public ObservableCollection<DocumentationItem> Entries { get; } = [];
|
||||
public ObservableCollection<StudentOption> StudentFilterOptions { get; } = [AllStudentsOption];
|
||||
@@ -62,8 +63,6 @@ public partial class GroupDocumentationTabViewModel : ObservableObject
|
||||
private void Load()
|
||||
{
|
||||
Entries.Clear();
|
||||
if (_groupStudents.Count == 0) return;
|
||||
|
||||
var studentNameById = _groupStudents.ToDictionary(s => s.Id, s => s.Name);
|
||||
var groupNameCache = new Dictionary<Guid, string>();
|
||||
string GroupLabel(Guid id)
|
||||
@@ -80,6 +79,10 @@ public partial class GroupDocumentationTabViewModel : ObservableObject
|
||||
|
||||
var all = relevantStudentIds
|
||||
.SelectMany(id => _docs.GetByStudent(id))
|
||||
.Concat(SelectedStudentFilter.Id == Guid.Empty
|
||||
? _docs.GetAll().Where(d => d.StudentId == Guid.Empty && d.GroupId == _groupId)
|
||||
: [])
|
||||
.DistinctBy(d => d.Id)
|
||||
.Where(d => !OnlyThisGroup || d.GroupId == _groupId)
|
||||
.OrderByDescending(d => d.IsDraft)
|
||||
.ThenByDescending(d => d.Date)
|
||||
@@ -90,7 +93,10 @@ public partial class GroupDocumentationTabViewModel : ObservableObject
|
||||
{
|
||||
var isOwnGroup = d.GroupId is null || d.GroupId == _groupId;
|
||||
var otherGroupLabel = isOwnGroup ? "" : GroupLabel(d.GroupId!.Value);
|
||||
Entries.Add(new DocumentationItem(d, studentNameById.GetValueOrDefault(d.StudentId, ""),
|
||||
var studentName = d.StudentId == Guid.Empty
|
||||
? WholeGroupOption.Name
|
||||
: studentNameById.GetValueOrDefault(d.StudentId, "");
|
||||
Entries.Add(new DocumentationItem(d, studentName,
|
||||
isOwnGroup, otherGroupLabel));
|
||||
}
|
||||
}
|
||||
@@ -100,7 +106,7 @@ public partial class GroupDocumentationTabViewModel : ObservableObject
|
||||
[RelayCommand]
|
||||
private async Task AddDocumentation()
|
||||
{
|
||||
if (OnEditDocumentation is null || _groupStudents.Count == 0) return;
|
||||
if (OnEditDocumentation is null) return;
|
||||
var result = await OnEditDocumentation(_groupId, _groupStudents, null);
|
||||
if (result is null) return;
|
||||
_docs.Save(result);
|
||||
|
||||
@@ -33,12 +33,15 @@ public partial class GroupOverviewViewModel : ObservableObject
|
||||
|
||||
private readonly ILessonRepository _lessons;
|
||||
private readonly IExamRepository _exams;
|
||||
private readonly IExamResultRepository _examResults;
|
||||
private readonly IParticipationSessionRepository _sessions;
|
||||
private readonly IParticipationRepository _entries;
|
||||
private readonly IStudentRepository _students;
|
||||
private readonly IDocumentationRepository _documentation;
|
||||
private readonly IWorkTaskRepository _tasks;
|
||||
private readonly IGroupRepository _groups;
|
||||
private readonly AttendanceBalanceService _attendanceBalance;
|
||||
private readonly GradingService _grading;
|
||||
private readonly SchoolYearService _schoolYear;
|
||||
|
||||
private Guid _groupId;
|
||||
@@ -48,6 +51,8 @@ public partial class GroupOverviewViewModel : ObservableObject
|
||||
[ObservableProperty] private string _nextLessonLabel = "";
|
||||
[ObservableProperty] private bool _hasNextExam;
|
||||
[ObservableProperty] private string _nextExamLabel = "";
|
||||
[ObservableProperty] private bool _hasYearComparison;
|
||||
[ObservableProperty] private string _yearComparisonLabel = "";
|
||||
[ObservableProperty] private string _participationHintLabel = "";
|
||||
[ObservableProperty] private bool _participationHintIsStale;
|
||||
[ObservableProperty] private bool _hasOpenHomeworkCheck;
|
||||
@@ -105,13 +110,14 @@ public partial class GroupOverviewViewModel : ObservableObject
|
||||
}
|
||||
|
||||
public GroupOverviewViewModel(ILessonRepository lessons, IExamRepository exams,
|
||||
IParticipationSessionRepository sessions, IParticipationRepository entries,
|
||||
IStudentRepository students, IDocumentationRepository documentation, IWorkTaskRepository tasks,
|
||||
AttendanceBalanceService attendanceBalance, SchoolYearService schoolYear)
|
||||
IExamResultRepository examResults, IParticipationSessionRepository sessions,
|
||||
IParticipationRepository entries, IStudentRepository students,
|
||||
IDocumentationRepository documentation, IWorkTaskRepository tasks, IGroupRepository groups,
|
||||
AttendanceBalanceService attendanceBalance, GradingService grading, SchoolYearService schoolYear)
|
||||
{
|
||||
_lessons = lessons; _exams = exams; _sessions = sessions;
|
||||
_lessons = lessons; _exams = exams; _examResults = examResults; _sessions = sessions;
|
||||
_entries = entries; _students = students; _documentation = documentation; _tasks = tasks;
|
||||
_attendanceBalance = attendanceBalance; _schoolYear = schoolYear;
|
||||
_groups = groups; _attendanceBalance = attendanceBalance; _grading = grading; _schoolYear = schoolYear;
|
||||
}
|
||||
|
||||
public void Initialize(Guid groupId, string groupName)
|
||||
@@ -126,6 +132,7 @@ public partial class GroupOverviewViewModel : ObservableObject
|
||||
var today = DateOnly.FromDateTime(DateTime.Today);
|
||||
LoadNextLesson(today);
|
||||
LoadNextExam(today);
|
||||
LoadYearComparison();
|
||||
LoadParticipationHint(today);
|
||||
LoadOpenHomeworkCheck(today);
|
||||
LoadOpenExcuses(today);
|
||||
@@ -138,7 +145,7 @@ public partial class GroupOverviewViewModel : ObservableObject
|
||||
private void LoadNextLesson(DateOnly today)
|
||||
{
|
||||
var next = _lessons.GetByGroupAndRange(_groupId, today, today.AddDays(NextLessonLookaheadDays))
|
||||
.Where(l => l.Status == LessonStatus.Planned)
|
||||
.Where(l => l.Status != LessonStatus.Conducted)
|
||||
.OrderBy(l => l.Date).ThenBy(l => l.LessonNumber ?? 0)
|
||||
.FirstOrDefault();
|
||||
|
||||
@@ -156,6 +163,38 @@ public partial class GroupOverviewViewModel : ObservableObject
|
||||
NextExamLabel = next is null ? "" : $"{next.Date:dd.MM.yyyy} — {next.Title}";
|
||||
}
|
||||
|
||||
// ── Klausurschnitt-Vorjahresvergleich (Nutzer-Feedback) ──────────────────
|
||||
//
|
||||
// Setzt voraus, dass diese Gruppe über GroupRolloverService aus einer Vorgängergruppe
|
||||
// hochgestuft wurde (LearningGroup.PreviousGroupId) — vor Einführung dieses Felds
|
||||
// hochgestufte Gruppen bleiben unverknüpft und zeigen keinen Vergleich. Nur sichtbar, wenn
|
||||
// im Vorjahr tatsächlich Klausurergebnisse vorliegen; ohne die ist kein Vergleich möglich,
|
||||
// auch wenn eine Verknüpfung besteht.
|
||||
|
||||
private void LoadYearComparison()
|
||||
{
|
||||
var group = _groups.GetById(_groupId);
|
||||
var previous = group?.PreviousGroupId is Guid previousId ? ComputeExamAverage(previousId) : null;
|
||||
if (previous is null) { HasYearComparison = false; return; }
|
||||
|
||||
var current = ComputeExamAverage(_groupId);
|
||||
YearComparisonLabel = current is null
|
||||
? $"Vorjahr: Ø {previous.Value.Average:0.0} ({previous.Value.Count} Klausuren) — dieses Jahr noch keine Klausur."
|
||||
: $"Dieses Jahr: Ø {current.Value.Average:0.0} ({current.Value.Count} Klausuren) · " +
|
||||
$"Vorjahr: Ø {previous.Value.Average:0.0} ({previous.Value.Count} Klausuren)";
|
||||
HasYearComparison = true;
|
||||
}
|
||||
|
||||
private (double Average, int Count)? ComputeExamAverage(Guid groupId)
|
||||
{
|
||||
var grades = _exams.GetByGroup(groupId)
|
||||
.SelectMany(e => _examResults.GetByExam(e.Id))
|
||||
.Where(r => !r.Absent && !string.IsNullOrWhiteSpace(r.Grade))
|
||||
.Select(r => (Grade: r.Grade!, Weight: 1.0))
|
||||
.ToList();
|
||||
return grades.Count == 0 ? null : (_grading.WeightedAverage(grades), grades.Count);
|
||||
}
|
||||
|
||||
/// Erinnert nicht an eine Note, sondern schlicht daran, überhaupt wieder eine Sitzung
|
||||
/// anzulegen — genau das vergisst man in Kursen, die man seltener unterrichtet, zuerst.
|
||||
private void LoadParticipationHint(DateOnly today)
|
||||
|
||||
@@ -158,6 +158,7 @@ public partial class ParticipationTabViewModel : ObservableObject
|
||||
row.OnCompetencyRatingChanged = (sid, code, val) => SaveCompetencyRating(sessionId, sid, code, val);
|
||||
row.HomeworkChangedCallback = (sid, val) => SaveHomework(sessionId, sid, val);
|
||||
row.AttendanceChangedCallback = (sid, val) => SaveAttendance(sessionId, sid, val);
|
||||
row.DayHighlightChangedCallback = (sid, val) => SaveDayHighlight(sessionId, sid, val);
|
||||
StudentRows.Add(row);
|
||||
}
|
||||
QuickInputCommand.NotifyCanExecuteChanged();
|
||||
@@ -212,6 +213,15 @@ public partial class ParticipationTabViewModel : ObservableObject
|
||||
_entries.Save(entry);
|
||||
}
|
||||
|
||||
private void SaveDayHighlight(Guid sessionId, Guid studentId, DayHighlightKind? value)
|
||||
{
|
||||
if (IsReadOnly) return;
|
||||
var entry = _entries.GetBySessionAndStudent(sessionId, studentId)
|
||||
?? new ParticipationEntry { SessionId = sessionId, StudentId = studentId };
|
||||
entry.DayHighlight = value;
|
||||
_entries.Save(entry);
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void ToggleCompetencyTags() => CompetencyTagsVisible = !CompetencyTagsVisible;
|
||||
|
||||
@@ -389,6 +399,7 @@ public partial class ParticipationStudentRow : ObservableObject
|
||||
|
||||
[ObservableProperty] private HomeworkStatus? _homework;
|
||||
[ObservableProperty] private AttendanceStatus? _attendance;
|
||||
[ObservableProperty] private DayHighlightKind? _dayHighlight;
|
||||
|
||||
public string AttendanceLabel => AttendanceDisplay.ShortLabel(Attendance);
|
||||
public string AttendanceTooltip => AttendanceDisplay.Label(Attendance);
|
||||
@@ -405,6 +416,7 @@ public partial class ParticipationStudentRow : ObservableObject
|
||||
public Action<Guid, string, int?>? OnCompetencyRatingChanged { get; set; }
|
||||
public Action<Guid, HomeworkStatus?>? HomeworkChangedCallback { get; set; }
|
||||
public Action<Guid, AttendanceStatus?>? AttendanceChangedCallback { get; set; }
|
||||
public Action<Guid, DayHighlightKind?>? DayHighlightChangedCallback { get; set; }
|
||||
|
||||
public ParticipationStudentRow(Guid id, string name, ParticipationEntry entry,
|
||||
List<AspectColumnDef> aspects, List<string> competencyCodes)
|
||||
@@ -414,6 +426,7 @@ public partial class ParticipationStudentRow : ObservableObject
|
||||
_aspectDefs = aspects;
|
||||
_homework = HomeworkDisplay.Effective(entry);
|
||||
_attendance = entry.Attendance;
|
||||
_dayHighlight = entry.DayHighlight;
|
||||
|
||||
foreach (var a in aspects)
|
||||
{
|
||||
@@ -463,6 +476,12 @@ public partial class ParticipationStudentRow : ObservableObject
|
||||
HomeworkChangedCallback?.Invoke(StudentId, value);
|
||||
}
|
||||
|
||||
public void SetDayHighlight(DayHighlightKind? value)
|
||||
{
|
||||
DayHighlight = value;
|
||||
DayHighlightChangedCallback?.Invoke(StudentId, value);
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void CycleAttendance()
|
||||
{
|
||||
@@ -614,6 +633,27 @@ public static class AttendanceDisplay
|
||||
};
|
||||
}
|
||||
|
||||
// ── Tagesflagge (Nutzer-Feedback) ──────────────────────────────────────────────
|
||||
|
||||
public static class DayHighlightDisplay
|
||||
{
|
||||
public static string Symbol(DayHighlightKind? kind) => kind switch
|
||||
{
|
||||
DayHighlightKind.Standout => "👑",
|
||||
DayHighlightKind.Sleepy => "😴",
|
||||
DayHighlightKind.Rough => "⚡",
|
||||
_ => "",
|
||||
};
|
||||
|
||||
public static string Label(DayHighlightKind? kind) => kind switch
|
||||
{
|
||||
DayHighlightKind.Standout => "Spitzentag",
|
||||
DayHighlightKind.Sleepy => "Schlaftag",
|
||||
DayHighlightKind.Rough => "Schlechter Tag",
|
||||
_ => "Keine Markierung",
|
||||
};
|
||||
}
|
||||
|
||||
// ── Eine Bewertungszelle ──────────────────────────────────────────────────────
|
||||
|
||||
public partial class RatingCell : ObservableObject
|
||||
@@ -800,12 +840,31 @@ public partial class QuickInputViewModel : ObservableObject
|
||||
[ObservableProperty] private string _progressText = "";
|
||||
[ObservableProperty] private bool _currentStudentIsAbsent;
|
||||
[ObservableProperty] private string _currentStudentAttendanceLabel = "";
|
||||
[ObservableProperty] private DayHighlightKind? _currentStudentDayHighlight;
|
||||
|
||||
/// Dimmt Name/Aspektliste, wenn der aktuelle Schüler abwesend ist — kein Blockieren der
|
||||
/// Eingabe (manche Bewertungssysteme wollen trotzdem einen Eintrag, z.B. "0 Punkte"), nur ein
|
||||
/// visueller Hinweis, dass eine Bewertung hier normalerweise keinen Sinn ergibt.
|
||||
public double CurrentStudentContentOpacity => CurrentStudentIsAbsent ? 0.4 : 1.0;
|
||||
|
||||
/// Tagesflagge (Nutzer-Feedback): Fallback für Kurse ohne Sitzplan — im Sitzplatz-Dialog gibt
|
||||
/// es dieselbe Auswahl bereits über SeatAssessmentViewModel.DayHighlightChoices.
|
||||
public string CurrentStudentDayHighlightSymbol => DayHighlightDisplay.Symbol(CurrentStudentDayHighlight);
|
||||
public string CurrentStudentDayHighlightLabel => DayHighlightDisplay.Label(CurrentStudentDayHighlight);
|
||||
|
||||
public void SetDayHighlight(DayHighlightKind? value)
|
||||
{
|
||||
if (_rows.Count == 0) return;
|
||||
_rows[StudentIndex].SetDayHighlight(value);
|
||||
CurrentStudentDayHighlight = value;
|
||||
}
|
||||
|
||||
partial void OnCurrentStudentDayHighlightChanged(DayHighlightKind? value)
|
||||
{
|
||||
OnPropertyChanged(nameof(CurrentStudentDayHighlightSymbol));
|
||||
OnPropertyChanged(nameof(CurrentStudentDayHighlightLabel));
|
||||
}
|
||||
|
||||
public ObservableCollection<QuickAspectRow> AspectRows { get; } = [];
|
||||
|
||||
/// Wechselt je nach Typ des aktuell gewählten Aspekts (3.1.3) — Scale3/Binary haben andere
|
||||
@@ -824,7 +883,8 @@ public partial class QuickInputViewModel : ObservableObject
|
||||
_ => "1–5 bewerten",
|
||||
};
|
||||
return $"{ratingHint} · Q/W/E/R/T Aspekt wählen · Leertaste/↓ nächster Aspekt · ↑ vorheriger Aspekt · " +
|
||||
"Enter/→ nächster Schüler · Backspace/← vorheriger Schüler · +/− anpassen · Esc schließen";
|
||||
"Enter/→ nächster Schüler · Backspace/← vorheriger Schüler · +/− anpassen · Entf nicht bewertet · " +
|
||||
"⇧1/2/3 Tagesflagge, ⇧0 löschen · Esc schließen";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -852,6 +912,7 @@ public partial class QuickInputViewModel : ObservableObject
|
||||
ProgressText = $"{index + 1} / {_rows.Count}";
|
||||
CurrentStudentIsAbsent = row.IsAbsent;
|
||||
CurrentStudentAttendanceLabel = row.AttendanceTooltip;
|
||||
CurrentStudentDayHighlight = row.DayHighlight;
|
||||
|
||||
AspectRows.Clear();
|
||||
foreach (var (a, i) in _aspects.Select((a, i) => (a, i)))
|
||||
@@ -902,6 +963,13 @@ public partial class QuickInputViewModel : ObservableObject
|
||||
ApplyRating(steps[Math.Clamp(idx + 1, 0, steps.Count - 1)]);
|
||||
}
|
||||
|
||||
/// Nutzer-Feedback: eine aus Versehen gesetzte Bewertung (z.B. Qualität, obwohl der Schüler
|
||||
/// sich nie gemeldet hat) muss sich wieder auf "nicht bewertet" zurücksetzen lassen, ohne
|
||||
/// einen inhaltlich falschen Wert stehen lassen zu müssen. Bewusst eine eigene Aktion statt
|
||||
/// über 0/1 zu laufen — 0 ("--") ist bei Quantität ein echter, beobachteter Wert ("hat sich
|
||||
/// nicht gemeldet"), kein Platzhalter für "nicht ermittelt".
|
||||
public void ClearRating() => ApplyRating(null);
|
||||
|
||||
public void DecrementRating()
|
||||
{
|
||||
var cell = GetCurrentCell();
|
||||
@@ -918,7 +986,7 @@ public partial class QuickInputViewModel : ObservableObject
|
||||
ApplyRating(steps[Math.Clamp(idx - 1, 0, steps.Count - 1)]);
|
||||
}
|
||||
|
||||
private void ApplyRating(int val)
|
||||
private void ApplyRating(int? val)
|
||||
{
|
||||
if (_rows.Count == 0 || !_aspects.Any()) return;
|
||||
var key = _aspects[Math.Clamp(AspectIndex, 0, _aspects.Count - 1)].Key;
|
||||
|
||||
@@ -20,6 +20,7 @@ public partial class ParticipationWizardDialogViewModel : ObservableObject
|
||||
private readonly IExamRepository _exams;
|
||||
private readonly IExamResultRepository _results;
|
||||
private readonly IGradeRepository _grades;
|
||||
private readonly IUnitRepository _units;
|
||||
private readonly Dictionary<Guid, GroupMembership> _membershipsByStudent;
|
||||
private readonly GradingService _grading;
|
||||
private readonly Guid _groupId;
|
||||
@@ -75,11 +76,11 @@ public partial class ParticipationWizardDialogViewModel : ObservableObject
|
||||
IParticipationAspectRepository aspects, IParticipationSectionRepository sectionRepo,
|
||||
IStudentRepository students, IGroupMembershipRepository memberships,
|
||||
IExamRepository exams, IExamResultRepository results,
|
||||
IGradeRepository grades, GradingService grading,
|
||||
IGradeRepository grades, IUnitRepository units, GradingService grading,
|
||||
Guid groupId, string schoolYear, GradingSystem gradingSystem, string groupLabel)
|
||||
{
|
||||
_sessions = sessions; _entries = entries; _sectionRepo = sectionRepo;
|
||||
_exams = exams; _results = results; _grades = grades; _grading = grading;
|
||||
_exams = exams; _results = results; _grades = grades; _units = units; _grading = grading;
|
||||
_groupId = groupId; _schoolYear = schoolYear; _gradingSystem = gradingSystem;
|
||||
GroupLabel = groupLabel;
|
||||
_membershipsByStudent = memberships.GetByGroup(groupId).ToDictionary(m => m.StudentId);
|
||||
@@ -98,6 +99,7 @@ public partial class ParticipationWizardDialogViewModel : ObservableObject
|
||||
|
||||
_rollupPeriod = RollupPeriodOptions[0];
|
||||
NewSectionLabel = $"Abschnitt {_sectionList.Count + 1}";
|
||||
NewSectionEndDateText = SuggestSectionEndDate().ToString("dd.MM.yyyy");
|
||||
|
||||
if (_students.Count > 0) ShowStudent(0);
|
||||
}
|
||||
@@ -277,6 +279,23 @@ public partial class ParticipationWizardDialogViewModel : ObservableObject
|
||||
_sectionList.Count > 0 ? _sectionList.Max(s => s.EndDate).AddDays(1)
|
||||
: (_allSessions.Count > 0 ? _allSessions.Min(s => s.Date) : DateOnly.FromDateTime(DateTime.Today));
|
||||
|
||||
/// Schlägt als Abschnittsende bewusst nicht immer "heute" vor: Wenn seit Abschnittsbeginn
|
||||
/// eine Unterrichtseinheit (4.2) abgeschlossen wurde, ist deren Ende meist die fachlich
|
||||
/// sinnvollere Zäsur für eine Mitarbeit-Teilnote als das Bearbeitungsdatum im Wizard.
|
||||
private DateOnly SuggestSectionEndDate()
|
||||
{
|
||||
var openStart = ComputeOpenStart();
|
||||
var today = DateOnly.FromDateTime(DateTime.Today);
|
||||
var completedUnitEnd = _units.GetByGroup(_groupId)
|
||||
.Where(u => u.Status == UnitStatus.Completed && u.EndDate.HasValue)
|
||||
.Select(u => u.EndDate!.Value)
|
||||
.Where(d => d >= openStart && d <= today)
|
||||
.OrderByDescending(d => d)
|
||||
.Cast<DateOnly?>()
|
||||
.FirstOrDefault();
|
||||
return completedUnitEnd ?? today;
|
||||
}
|
||||
|
||||
private void BuildSections(Guid studentId)
|
||||
{
|
||||
Sections.Clear();
|
||||
@@ -378,6 +397,7 @@ public partial class ParticipationWizardDialogViewModel : ObservableObject
|
||||
}
|
||||
|
||||
NewSectionLabel = $"Abschnitt {_sectionList.Count + 1}";
|
||||
NewSectionEndDateText = SuggestSectionEndDate().ToString("dd.MM.yyyy");
|
||||
BuildTimeline(CurrentStudentId);
|
||||
BuildSections(CurrentStudentId);
|
||||
}
|
||||
|
||||
@@ -13,7 +13,8 @@ namespace LehrerApp.Desktop.ViewModels.Groups;
|
||||
|
||||
// ── Ergebnisse der Verschieben-/Kopieren-/Serienerzeugungs-Dialoge (4.2.4 / 4.1.4 / 4.2.5) ───
|
||||
|
||||
public record MoveLessonTarget(DateOnly NewDate, bool ShiftFollowing);
|
||||
public record MoveLessonTarget(DateOnly NewDate, bool ShiftFollowing, int? NewPeriod = null,
|
||||
bool SplitDoubleLesson = false);
|
||||
public record CopyUnitTarget(Guid TargetGroupId, DateOnly AnchorDate);
|
||||
|
||||
public record LessonSeriesResult(int Created, int SkippedHoliday, int SkippedExisting)
|
||||
@@ -78,6 +79,7 @@ public partial class PlanningTabViewModel : ObservableObject
|
||||
public Func<Unit, Task<LessonSeriesResult?>>? OnGenerateLessonSeries { get; set; }
|
||||
public Func<Unit, Task<bool>>? OnAiAssist { get; set; }
|
||||
public Action<string>? OnNotify { get; set; }
|
||||
public Action<string>? OnError { get; set; }
|
||||
|
||||
public PlanningTabViewModel(IUnitRepository units, ILessonRepository lessons,
|
||||
IGroupRepository groups, ISubjectRepository subjects,
|
||||
@@ -333,7 +335,8 @@ public partial class PlanningTabViewModel : ObservableObject
|
||||
var lesson = SelectedLesson.Model;
|
||||
var target = await OnPickMoveTarget(lesson);
|
||||
if (target is null) return;
|
||||
MoveLessonInternal(lesson, target.NewDate, target.ShiftFollowing);
|
||||
try { MoveLessonInternal(lesson, target.NewDate, target.ShiftFollowing, target.NewPeriod); }
|
||||
catch (InvalidOperationException ex) { OnError?.Invoke(ex.Message); return; }
|
||||
LoadUnits();
|
||||
}
|
||||
|
||||
@@ -341,33 +344,19 @@ public partial class PlanningTabViewModel : ObservableObject
|
||||
/// sich alle anderen noch geplanten Stunden derselben Einheit, die ursprünglich NACH der
|
||||
/// verschobenen Stunde lagen, um denselben Tages-Delta. Bereits durchgeführte Stunden werden
|
||||
/// nie angefasst — nur Date ändert sich, UnitId/GroupId bleiben unverändert.
|
||||
private void MoveLessonInternal(Lesson moved, DateOnly newDate, bool shiftFollowing)
|
||||
private void MoveLessonInternal(Lesson moved, DateOnly newDate, bool shiftFollowing, int? newPeriod = null)
|
||||
{
|
||||
var oldDate = moved.Date;
|
||||
var delta = newDate.DayNumber - oldDate.DayNumber;
|
||||
|
||||
if (shiftFollowing && delta != 0)
|
||||
{
|
||||
foreach (var other in _lessons.GetByUnit(moved.UnitId))
|
||||
{
|
||||
if (other.Id == moved.Id) continue;
|
||||
if (other.Status != LessonStatus.Planned) continue;
|
||||
if (other.Date <= oldDate) continue;
|
||||
other.Date = other.Date.AddDays(delta);
|
||||
_lessons.Save(other);
|
||||
}
|
||||
}
|
||||
|
||||
moved.Date = newDate;
|
||||
_lessons.Save(moved);
|
||||
new LessonSchedulingService(_lessons).Move(moved, newDate, newPeriod, shiftFollowing);
|
||||
}
|
||||
|
||||
[RelayCommand(CanExecute = nameof(HasSelectedLesson))]
|
||||
private void AdvanceLessonStatus()
|
||||
{
|
||||
if (SelectedLesson is null || SelectedLesson.Model.Status != LessonStatus.Planned) return;
|
||||
if (SelectedLesson is null || SelectedLesson.Model.Status == LessonStatus.Conducted) return;
|
||||
var lesson = SelectedLesson.Model;
|
||||
lesson.Status = LessonStatus.Conducted;
|
||||
lesson.Status = lesson.Status == LessonStatus.Ready
|
||||
? LessonStatus.Conducted
|
||||
: LessonStatus.Ready;
|
||||
_lessons.Save(lesson);
|
||||
LoadUnits();
|
||||
}
|
||||
@@ -375,16 +364,25 @@ public partial class PlanningTabViewModel : ObservableObject
|
||||
/// Übernimmt Datum + Thema der Stunde in eine neue Mitarbeitssitzung (3.3.1) — verknüpft über
|
||||
/// das bisher ungenutzte Lesson.LessonId-Feld auf ParticipationSession, damit ein zweiter Klick
|
||||
/// auf dieselbe Stunde keine doppelte Sitzung anlegt, sondern nur darauf hinweist.
|
||||
///
|
||||
/// Prüft dabei zusätzlich auf JEDE bereits an diesem Tag bestehende Sitzung, nicht nur eine
|
||||
/// exakt mit `lesson.Id` verknüpfte (Nutzer-Feedback, analog
|
||||
/// <see cref="SeatingPlanTabViewModel.SelectOrCreateSessionForLesson"/>): kommt neben einer
|
||||
/// Doppelstunde noch eine dritte Stunde desselben Tages hinzu (eigene `Lesson`, z.B. durch
|
||||
/// Vertretung), soll das nicht zu einer zweiten Mitarbeitssitzung für den Tag führen.
|
||||
[RelayCommand(CanExecute = nameof(HasSelectedLesson))]
|
||||
private void CreateParticipationSession()
|
||||
{
|
||||
if (SelectedLesson is null) return;
|
||||
var lesson = SelectedLesson.Model;
|
||||
var existing = _participationSessions.GetByGroup(lesson.GroupId)
|
||||
.FirstOrDefault(s => s.LessonId == lesson.Id);
|
||||
var sessionsForGroup = _participationSessions.GetByGroup(lesson.GroupId);
|
||||
var existing = sessionsForGroup.FirstOrDefault(s => s.LessonId == lesson.Id)
|
||||
?? sessionsForGroup.FirstOrDefault(s => s.Date == lesson.Date);
|
||||
if (existing is not null)
|
||||
{
|
||||
OnNotify?.Invoke("Für diese Stunde existiert bereits eine Sitzung.");
|
||||
OnNotify?.Invoke(existing.LessonId == lesson.Id
|
||||
? "Für diese Stunde existiert bereits eine Sitzung."
|
||||
: "Für diesen Tag existiert bereits eine Sitzung.");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -479,8 +477,14 @@ public class LessonSummary
|
||||
Topic = l.Topic;
|
||||
StartTimeDisplay = l.StartTime?.ToString("HH:mm") ?? "–";
|
||||
Status = l.Status;
|
||||
StatusLabel = l.Status == LessonStatus.Conducted ? "Durchgeführt" : "Geplant";
|
||||
StatusColorHex = l.Status == LessonStatus.Conducted ? "#43A047" : "#9E9E9E";
|
||||
StatusLabel = LessonStatusDisplay.ToName(l.Status);
|
||||
StatusColorHex = l.Status switch
|
||||
{
|
||||
LessonStatus.Draft => "#78909C",
|
||||
LessonStatus.Ready => "#1976D2",
|
||||
LessonStatus.Conducted => "#43A047",
|
||||
_ => "#9E9E9E",
|
||||
};
|
||||
PhaseCountLabel = l.Phases.Count == 0 ? "–" : $"{l.Phases.Count} Phasen";
|
||||
var totalMinutes = l.Phases.Sum(p => p.DurationMinutes);
|
||||
TotalDurationLabel = totalMinutes == 0 ? "–" : $"{totalMinutes} Min.";
|
||||
@@ -515,12 +519,23 @@ public static class UnitStatusDisplay
|
||||
|
||||
public static class LessonStatusDisplay
|
||||
{
|
||||
public static string[] Options { get; } = ["Geplant", "Durchgeführt"];
|
||||
public static string[] Options { get; } = ["Entwurf", "Geplant", "Bereit", "Durchgeführt"];
|
||||
|
||||
public static string ToName(LessonStatus s) => s == LessonStatus.Conducted ? "Durchgeführt" : "Geplant";
|
||||
public static string ToName(LessonStatus s) => s switch
|
||||
{
|
||||
LessonStatus.Draft => "Entwurf",
|
||||
LessonStatus.Ready => "Bereit",
|
||||
LessonStatus.Conducted => "Durchgeführt",
|
||||
_ => "Geplant",
|
||||
};
|
||||
|
||||
public static LessonStatus FromName(string? name) =>
|
||||
name == "Durchgeführt" ? LessonStatus.Conducted : LessonStatus.Planned;
|
||||
public static LessonStatus FromName(string? name) => name switch
|
||||
{
|
||||
"Entwurf" => LessonStatus.Draft,
|
||||
"Bereit" => LessonStatus.Ready,
|
||||
"Durchgeführt" => LessonStatus.Conducted,
|
||||
_ => LessonStatus.Planned,
|
||||
};
|
||||
}
|
||||
|
||||
// ── Dialog: Einheit anlegen / bearbeiten (4.1.2 / 4.1.3) ─────────────────────
|
||||
@@ -722,7 +737,8 @@ public partial class LessonDialogViewModel : ObservableObject
|
||||
IAlternativeLessonPathRepository alternativePaths, ITimetableSlotRepository timetableSlots,
|
||||
PeriodScheduleService periodSchedule, IAttachmentStorage attachmentStorage,
|
||||
Guid unitId, Guid groupId, string groupName, string subjectName,
|
||||
List<string> materialSuggestions, List<string> shorthandHistorySuggestions, Lesson? editingLesson)
|
||||
List<string> materialSuggestions, List<string> shorthandHistorySuggestions, Lesson? editingLesson,
|
||||
DateOnly? suggestedDate = null, int? suggestedPeriod = null)
|
||||
{
|
||||
_lessons = lessons; _alternativePaths = alternativePaths;
|
||||
_timetableSlots = timetableSlots; _periodSchedule = periodSchedule;
|
||||
@@ -760,9 +776,12 @@ public partial class LessonDialogViewModel : ObservableObject
|
||||
}
|
||||
else
|
||||
{
|
||||
var (date, lessonNumber) = SuggestNextLesson();
|
||||
var (date, lessonNumber) = suggestedDate.HasValue
|
||||
? (suggestedDate.Value, suggestedPeriod)
|
||||
: SuggestNextLesson();
|
||||
DateText = date.ToString("dd.MM.yyyy");
|
||||
LessonNumber = lessonNumber;
|
||||
StatusName = LessonStatusDisplay.ToName(LessonStatus.Draft);
|
||||
}
|
||||
RecomputeTimes();
|
||||
}
|
||||
@@ -1145,28 +1164,45 @@ public partial class AlternativePathDialogViewModel : ObservableObject
|
||||
public partial class MoveLessonDialogViewModel : ObservableObject
|
||||
{
|
||||
[ObservableProperty] private string _newDateText;
|
||||
[ObservableProperty] private int? _newPeriod;
|
||||
[ObservableProperty] private bool _shiftFollowingPlanned = true;
|
||||
[ObservableProperty] private bool _splitDoubleLesson;
|
||||
[ObservableProperty] private string _newDateTextError = "";
|
||||
[ObservableProperty] private string _newPeriodError = "";
|
||||
|
||||
public string CurrentDateDisplay { get; }
|
||||
public string CurrentPeriodDisplay { get; }
|
||||
public bool CanSplitDoubleLesson { get; }
|
||||
public MoveLessonTarget? Result { get; private set; }
|
||||
|
||||
public MoveLessonDialogViewModel(DateOnly currentDate)
|
||||
public MoveLessonDialogViewModel(DateOnly currentDate, int? currentPeriod = null,
|
||||
bool canSplitDoubleLesson = false, bool splitByDefault = false)
|
||||
{
|
||||
CurrentDateDisplay = currentDate.ToString("dd.MM.yyyy");
|
||||
CurrentPeriodDisplay = currentPeriod is null ? "nicht festgelegt" : $"{currentPeriod}. Stunde";
|
||||
_newDateText = currentDate.ToString("dd.MM.yyyy");
|
||||
_newPeriod = currentPeriod;
|
||||
CanSplitDoubleLesson = canSplitDoubleLesson;
|
||||
_splitDoubleLesson = canSplitDoubleLesson && splitByDefault;
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void Save()
|
||||
{
|
||||
NewDateTextError = "";
|
||||
NewPeriodError = "";
|
||||
if (!DateOnly.TryParseExact(NewDateText, "dd.MM.yyyy", null, DateTimeStyles.None, out var date))
|
||||
{
|
||||
NewDateTextError = "Format TT.MM.JJJJ.";
|
||||
return;
|
||||
}
|
||||
Result = new MoveLessonTarget(date, ShiftFollowingPlanned);
|
||||
if (NewPeriod is < 1 or > 20)
|
||||
{
|
||||
NewPeriodError = "Bitte eine Stundennummer zwischen 1 und 20 wählen.";
|
||||
return;
|
||||
}
|
||||
Result = new MoveLessonTarget(date, ShiftFollowingPlanned, NewPeriod,
|
||||
CanSplitDoubleLesson && SplitDoubleLesson);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -141,10 +141,18 @@ public partial class SeatingPlanTabViewModel : ObservableObject
|
||||
/// erlaubt: welche Stunde gemeint ist, steht durch die explizite Auswahl der Lehrkraft
|
||||
/// (Klick auf "Unterrichtsmodus starten" für genau diese Stunde) bereits unzweideutig fest -
|
||||
/// keine Geistersitzungs-Gefahr wie beim bloßen Öffnen eines Tabs.
|
||||
///
|
||||
/// Fällt bewusst auf JEDE an diesem Tag bereits bestehende Sitzung zurück, nicht nur auf eine
|
||||
/// exakt mit `lesson.Id` verknüpfte (Nutzer-Feedback): kommt neben einer Doppelstunde noch eine
|
||||
/// dritte Stunde am selben Tag hinzu (z.B. Vertretung, eigene `Lesson` mit eigener Id), soll
|
||||
/// keine zweite Mitarbeitssitzung für denselben Tag entstehen — die Lehrkraft passt stattdessen
|
||||
/// die Einschätzung der bereits bestehenden Sitzung an. Das entspricht dem Verhalten von
|
||||
/// <see cref="EnsureTodaySession"/>, das schon immer pro Tag statt pro Stunde arbeitet.
|
||||
/// </summary>
|
||||
public void SelectOrCreateSessionForLesson(Lesson lesson)
|
||||
{
|
||||
var existing = TodaySessions.FirstOrDefault(s => s.LessonId == lesson.Id);
|
||||
var existing = TodaySessions.FirstOrDefault(s => s.LessonId == lesson.Id)
|
||||
?? TodaySessions.FirstOrDefault(s => s.Date == lesson.Date);
|
||||
if (existing is not null) { SelectedSession = existing; return; }
|
||||
if (!IsEditable) return;
|
||||
|
||||
@@ -227,7 +235,7 @@ public partial class SeatingPlanTabViewModel : ObservableObject
|
||||
?? StudentSeatOption.Empty;
|
||||
var isHidden = hiddenSeats.Any(h => h.Row == row && h.Column == column);
|
||||
Seats.Add(new SeatCellViewModel(row, column, StudentOptions, option, OnSeatChanged,
|
||||
CanEditLayout, ToggleSituationTag, IsEditable, isHidden, ToggleSeatHidden));
|
||||
CanEditLayout, ToggleSituationTag, IsEditable, isHidden, ToggleSeatHidden, TallyParticipation));
|
||||
}
|
||||
UpdateAssignmentSummary();
|
||||
RefreshSeatLessonData();
|
||||
@@ -266,6 +274,22 @@ public partial class SeatingPlanTabViewModel : ObservableObject
|
||||
}
|
||||
}
|
||||
|
||||
/// Strichliste im Sitzplan (Nutzer-Feedback): "drangekommen" ist immer auch eine Meldung und
|
||||
/// erhöht daher beide Zähler — es gibt kein "drangekommen, ohne sich gemeldet zu haben".
|
||||
private void TallyParticipation(SeatCellViewModel seat, bool calledOn)
|
||||
{
|
||||
if (!IsEditable || seat.SelectedOption.StudentId is not Guid studentId) return;
|
||||
var session = EnsureTodaySession();
|
||||
if (session is null) return;
|
||||
|
||||
var entry = _participation.GetBySessionAndStudent(session.Id, studentId)
|
||||
?? new ParticipationEntry { SessionId = session.Id, StudentId = studentId };
|
||||
entry.RaisedHandCount++;
|
||||
if (calledOn) entry.CalledOnCount++;
|
||||
_participation.Save(entry);
|
||||
RefreshSeatLessonData();
|
||||
}
|
||||
|
||||
private void ToggleSituationTag(SeatCellViewModel seat, string tag)
|
||||
{
|
||||
if (!IsEditable || _documentation is null ||
|
||||
@@ -531,9 +555,13 @@ public partial class SeatCellViewModel : ObservableObject
|
||||
[ObservableProperty] private double _lessonOpacity = 1.0;
|
||||
[ObservableProperty] private string _attendanceBadge = "";
|
||||
[ObservableProperty] private string _homeworkBadge = "";
|
||||
[ObservableProperty] private string _dayHighlightBadge = "";
|
||||
[ObservableProperty] private int _raisedHandCount;
|
||||
[ObservableProperty] private int _calledOnCount;
|
||||
public ObservableCollection<SituationTagChoice> SituationTags { get; } = [];
|
||||
public bool HasAttendanceBadge => AttendanceBadge.Length > 0;
|
||||
public bool HasHomeworkBadge => HomeworkBadge.Length > 0;
|
||||
public bool HasDayHighlightBadge => DayHighlightBadge.Length > 0;
|
||||
public bool ShowLessonOverview => IsOccupied && !CanEdit;
|
||||
[ObservableProperty] private bool _canRecordLesson;
|
||||
public bool IsOccupied => SelectedOption.StudentId.HasValue;
|
||||
@@ -555,10 +583,13 @@ public partial class SeatCellViewModel : ObservableObject
|
||||
/// die Abblendung für ausgeblendete Plätze hier statt per CSS-Klasse erfolgen.</summary>
|
||||
public double DisplayOpacity => IsHidden ? 0.4 : LessonOpacity;
|
||||
|
||||
private readonly Action<SeatCellViewModel, bool> _tally;
|
||||
|
||||
public SeatCellViewModel(int row, int column, ObservableCollection<StudentSeatOption> options,
|
||||
StudentSeatOption selectedOption, Action<SeatCellViewModel> onChanged, bool canEdit,
|
||||
Action<SeatCellViewModel, string>? toggleSituationTag = null, bool canRecordLesson = false,
|
||||
bool isHidden = false, Action<SeatCellViewModel>? toggleHidden = null)
|
||||
bool isHidden = false, Action<SeatCellViewModel>? toggleHidden = null,
|
||||
Action<SeatCellViewModel, bool>? tally = null)
|
||||
{
|
||||
Row = row;
|
||||
Column = column;
|
||||
@@ -570,10 +601,18 @@ public partial class SeatCellViewModel : ObservableObject
|
||||
_canRecordLesson = canRecordLesson;
|
||||
_isHidden = isHidden;
|
||||
_toggleHidden = toggleHidden ?? (_ => { });
|
||||
_tally = tally ?? ((_, _) => { });
|
||||
foreach (var tag in SituationTagChoice.DefaultTags)
|
||||
SituationTags.Add(new SituationTagChoice(tag, false, value => _toggleSituationTag(this, value)));
|
||||
}
|
||||
|
||||
/// Strichliste im Sitzplan (Nutzer-Feedback): schnelles Mitzählen während des Unterrichts,
|
||||
/// ohne dafür den vollen Sitzplatz-Bewertungsdialog öffnen zu müssen — bewusst nur ein
|
||||
/// Hochzählen ohne Korrekturmöglichkeit hier direkt auf der Kachel; die Rohwerte bleiben über
|
||||
/// den Bewertungsdialog einsehbar und fließen dort als Quantitäts-Vorschlag ein.
|
||||
[RelayCommand] private void TallyRaisedHand() => _tally(this, false);
|
||||
[RelayCommand] private void TallyCalledOn() => _tally(this, true);
|
||||
|
||||
partial void OnSelectedOptionChanged(StudentSeatOption value)
|
||||
{
|
||||
OnPropertyChanged(nameof(IsOccupied));
|
||||
@@ -615,12 +654,16 @@ public partial class SeatCellViewModel : ObservableObject
|
||||
var attendance = entry?.Attendance;
|
||||
AttendanceBadge = attendance is null ? "" : AttendanceDisplay.ShortLabel(attendance);
|
||||
HomeworkBadge = entry is null ? "" : HomeworkDisplay.Symbol(HomeworkDisplay.Effective(entry));
|
||||
DayHighlightBadge = DayHighlightDisplay.Symbol(entry?.DayHighlight);
|
||||
RaisedHandCount = entry?.RaisedHandCount ?? 0;
|
||||
CalledOnCount = entry?.CalledOnCount ?? 0;
|
||||
LessonOpacity = attendance is not null and not AttendanceStatus.Present
|
||||
and not AttendanceStatus.Late and not AttendanceStatus.SignificantlyLate ? 0.42 : 1.0;
|
||||
var selected = tags.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var choice in SituationTags) choice.IsSelected = selected.Contains(choice.Text);
|
||||
OnPropertyChanged(nameof(HasAttendanceBadge));
|
||||
OnPropertyChanged(nameof(HasHomeworkBadge));
|
||||
OnPropertyChanged(nameof(HasDayHighlightBadge));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -646,6 +689,7 @@ public partial class SeatAssessmentViewModel : ObservableObject
|
||||
[ObservableProperty] private int _selectedAspectIndex;
|
||||
[ObservableProperty] private string _attendanceLabel = "Noch nicht kontrolliert";
|
||||
[ObservableProperty] private string _homeworkLabel = "Keine Hausaufgabe aufgegeben";
|
||||
[ObservableProperty] private string _dayHighlightLabel = "Keine Markierung";
|
||||
|
||||
public string StudentName { get; }
|
||||
public string SessionDisplay { get; }
|
||||
@@ -656,6 +700,7 @@ public partial class SeatAssessmentViewModel : ObservableObject
|
||||
public ObservableCollection<SeatAssessmentAspectRow> AspectRows { get; } = [];
|
||||
public ObservableCollection<SeatAttendanceChoice> AttendanceChoices { get; } = [];
|
||||
public ObservableCollection<SeatHomeworkChoice> HomeworkChoices { get; } = [];
|
||||
public ObservableCollection<SeatDayHighlightChoice> DayHighlightChoices { get; } = [];
|
||||
|
||||
public SeatAssessmentViewModel(IParticipationSessionRepository sessions,
|
||||
IParticipationRepository entries, IParticipationAspectRepository aspects,
|
||||
@@ -690,11 +735,15 @@ public partial class SeatAssessmentViewModel : ObservableObject
|
||||
foreach (var (aspect, index) in aspectDefinitions.Select((a, i) => (a, i)))
|
||||
{
|
||||
var value = _entry?.Ratings.FirstOrDefault(r => r.Key == aspect.Key)?.Value;
|
||||
AspectRows.Add(new SeatAssessmentAspectRow(index, aspect, value, ApplyRating));
|
||||
// Quantitäts-Vorschlag aus der Sitzplan-Strichliste (Nutzer-Feedback): nur für den
|
||||
// eingebauten "quantity"-Aspekt, siehe ParticipationCountSuggestion.
|
||||
var raisedHandCount = aspect.Key == "quantity" ? _entry?.RaisedHandCount : null;
|
||||
AspectRows.Add(new SeatAssessmentAspectRow(index, aspect, value, ApplyRating, raisedHandCount));
|
||||
}
|
||||
|
||||
BuildAttendanceChoices();
|
||||
BuildHomeworkChoices();
|
||||
BuildDayHighlightChoices();
|
||||
RefreshStatusChoices();
|
||||
SelectAspect(0);
|
||||
}
|
||||
@@ -727,6 +776,17 @@ public partial class SeatAssessmentViewModel : ObservableObject
|
||||
HomeworkChoices.Add(new("·", "Keine aufgegeben", "⌥X", null, SetHomework));
|
||||
}
|
||||
|
||||
private void BuildDayHighlightChoices()
|
||||
{
|
||||
DayHighlightChoices.Add(new(DayHighlightDisplay.Symbol(DayHighlightKind.Standout),
|
||||
DayHighlightDisplay.Label(DayHighlightKind.Standout), "⇧1", DayHighlightKind.Standout, SetDayHighlight));
|
||||
DayHighlightChoices.Add(new(DayHighlightDisplay.Symbol(DayHighlightKind.Sleepy),
|
||||
DayHighlightDisplay.Label(DayHighlightKind.Sleepy), "⇧2", DayHighlightKind.Sleepy, SetDayHighlight));
|
||||
DayHighlightChoices.Add(new(DayHighlightDisplay.Symbol(DayHighlightKind.Rough),
|
||||
DayHighlightDisplay.Label(DayHighlightKind.Rough), "⇧3", DayHighlightKind.Rough, SetDayHighlight));
|
||||
DayHighlightChoices.Add(new("·", "Keine Markierung", "⇧X", null, SetDayHighlight));
|
||||
}
|
||||
|
||||
public void SelectAspect(int index)
|
||||
{
|
||||
if (index < 0 || index >= AspectRows.Count) return;
|
||||
@@ -793,6 +853,17 @@ public partial class SeatAssessmentViewModel : ObservableObject
|
||||
if (clear || digit is 0 or 1 or 3 or 4 or 5 or 7 or 8) SetHomework(status);
|
||||
}
|
||||
|
||||
public void ApplyDayHighlightShortcut(int? digit, bool clear)
|
||||
{
|
||||
if (!CanEdit) return;
|
||||
var kind = clear ? null : digit switch
|
||||
{
|
||||
1 => DayHighlightKind.Standout, 2 => DayHighlightKind.Sleepy, 3 => DayHighlightKind.Rough,
|
||||
_ => (DayHighlightKind?)null,
|
||||
};
|
||||
if (clear || digit is 1 or 2 or 3) SetDayHighlight(kind);
|
||||
}
|
||||
|
||||
private void ApplyRating(string key, int? value)
|
||||
{
|
||||
if (!CanEdit || _entry is null) return;
|
||||
@@ -823,13 +894,23 @@ public partial class SeatAssessmentViewModel : ObservableObject
|
||||
RefreshStatusChoices();
|
||||
}
|
||||
|
||||
private void SetDayHighlight(DayHighlightKind? kind)
|
||||
{
|
||||
if (!CanEdit || _entry is null) return;
|
||||
_entry.DayHighlight = kind;
|
||||
_entries.Save(_entry);
|
||||
RefreshStatusChoices();
|
||||
}
|
||||
|
||||
private void RefreshStatusChoices()
|
||||
{
|
||||
AttendanceLabel = AttendanceDisplay.Label(_entry?.Attendance);
|
||||
HomeworkLabel = HomeworkDisplay.Label(_entry is null ? null : HomeworkDisplay.Effective(_entry));
|
||||
DayHighlightLabel = DayHighlightDisplay.Label(_entry?.DayHighlight);
|
||||
foreach (var choice in AttendanceChoices) choice.IsSelected = choice.Status == _entry?.Attendance;
|
||||
var homework = _entry is null ? null : HomeworkDisplay.Effective(_entry);
|
||||
foreach (var choice in HomeworkChoices) choice.IsSelected = choice.Status == homework;
|
||||
foreach (var choice in DayHighlightChoices) choice.IsSelected = choice.Kind == _entry?.DayHighlight;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -847,11 +928,22 @@ public partial class SeatAssessmentAspectRow : ObservableObject
|
||||
public string DisplayValue => ParticipationRatingScale.DisplayLabel(ValueType, Value);
|
||||
public ObservableCollection<SeatRatingChoice> Choices { get; } = [];
|
||||
|
||||
/// Quantitäts-Vorschlag aus der Sitzplan-Strichliste (Nutzer-Feedback), siehe
|
||||
/// ParticipationCountSuggestion — null, wenn keine Strichliste zu diesem Aspekt gehört.
|
||||
private readonly int? _raisedHandCount;
|
||||
public int? SuggestedValue { get; }
|
||||
public bool HasSuggestion => SuggestedValue.HasValue && SuggestedValue != Value;
|
||||
public string SuggestionLabel =>
|
||||
$"{_raisedHandCount}× gemeldet → Vorschlag: {ParticipationRatingScale.DisplayLabel(ValueType, SuggestedValue)}";
|
||||
|
||||
public SeatAssessmentAspectRow(int index, ParticipationAspect aspect, int? value,
|
||||
Action<string, int?> apply)
|
||||
Action<string, int?> apply, int? raisedHandCount = null)
|
||||
{
|
||||
Index = index; Key = aspect.Key; Label = aspect.Label; ValueType = aspect.ValueType;
|
||||
MaxPoints = aspect.MaxPoints; _value = value; _apply = apply;
|
||||
_raisedHandCount = raisedHandCount;
|
||||
SuggestedValue = raisedHandCount is int rhc
|
||||
? ParticipationCountSuggestion.SuggestQuantity(ValueType, rhc) : null;
|
||||
var steps = ValueType == AspectValueType.Points
|
||||
? Enumerable.Range(0, Math.Min(MaxPoints, 9) + 1).Select(v => (v, v.ToString())).ToList()
|
||||
: ParticipationRatingScale.Steps(ValueType).ToList();
|
||||
@@ -864,10 +956,13 @@ public partial class SeatAssessmentAspectRow : ObservableObject
|
||||
{
|
||||
Value = value;
|
||||
OnPropertyChanged(nameof(DisplayValue));
|
||||
OnPropertyChanged(nameof(HasSuggestion));
|
||||
foreach (var choice in Choices) choice.IsSelected = choice.Value == value;
|
||||
_apply(Key, value);
|
||||
}
|
||||
|
||||
[RelayCommand] private void ApplySuggestion() => ApplyValue(SuggestedValue);
|
||||
|
||||
public void Adjust(int delta)
|
||||
{
|
||||
if (ValueType == AspectValueType.Points)
|
||||
@@ -918,6 +1013,17 @@ public partial class SeatHomeworkChoice(string symbol, string label, string shor
|
||||
[RelayCommand] private void Apply() => apply(Status);
|
||||
}
|
||||
|
||||
public partial class SeatDayHighlightChoice(string symbol, string label, string shortcut,
|
||||
DayHighlightKind? kind, Action<DayHighlightKind?> apply) : ObservableObject
|
||||
{
|
||||
public string Symbol { get; } = symbol;
|
||||
public string Label { get; } = label;
|
||||
public string Shortcut { get; } = shortcut;
|
||||
public DayHighlightKind? Kind { get; } = kind;
|
||||
[ObservableProperty] private bool _isSelected;
|
||||
[RelayCommand] private void Apply() => apply(Kind);
|
||||
}
|
||||
|
||||
public partial class SeatingPlanDialogViewModel : ObservableObject
|
||||
{
|
||||
private readonly ISeatingPlanRepository _plans;
|
||||
|
||||
@@ -0,0 +1,310 @@
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using LehrerApp.Core.Interfaces;
|
||||
using LehrerApp.Core.Models;
|
||||
using LehrerApp.Core.Services;
|
||||
using LehrerApp.Desktop.ViewModels.Students;
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Globalization;
|
||||
|
||||
namespace LehrerApp.Desktop.ViewModels.Groups;
|
||||
|
||||
// ── Schüler-Leistungsüberblick + Zielnoten-Rechner (Nutzer-Feedback) ─────────────
|
||||
//
|
||||
// Zeigt die Leistungen eines einzelnen Schülers in einem Kurs im Überblick — Klausuren,
|
||||
// Mitarbeit- und sonstige Noten, die daraus berechnete Zeugnisnote sowie zwei Rechner
|
||||
// ("Zielnote": was brauche ich noch? / "Was-wäre-wenn": wie wirkt sich eine zusätzliche
|
||||
// Klausurnote aus?). Bewusst pro Kurs statt fächerübergreifend, da Gewichtungsschema und
|
||||
// Notensystem am Kurs hängen. Zwei Modi über IsTeacherMode: im Bewertermodus zusätzlich
|
||||
// Kursdurchschnitt je Klausur und Bearbeitbarkeit der Mitarbeit-/Sonstige-Noten; im
|
||||
// Schülermodus ausschließlich die eigenen Werte, rein lesend — gedacht, um den Bildschirm im
|
||||
// Gespräch umzudrehen. Exam-Noten bleiben bewusst nur lesbar: eine Korrektur läuft weiterhin
|
||||
// über den bestehenden ExamGradingDialog, der die Punkte-Struktur konsistent hält — direktes
|
||||
// Überschreiben von Exam.Grade hier könnte mit ExamResult.Points/TotalPoints auseinanderlaufen.
|
||||
public partial class StudentPerformanceOverviewViewModel : ObservableObject
|
||||
{
|
||||
private readonly IGradeRepository _grades;
|
||||
private readonly IExamRepository _exams;
|
||||
private readonly IExamResultRepository _results;
|
||||
private readonly IGroupMembershipRepository _memberships;
|
||||
private readonly IGradingSchemeRepository _schemes;
|
||||
private readonly GradingService _grading;
|
||||
|
||||
private readonly Guid _groupId;
|
||||
private readonly Guid _studentId;
|
||||
private readonly GroupType _groupType;
|
||||
private readonly GradingSystem _gradingSystem;
|
||||
private readonly string _schoolYear;
|
||||
|
||||
private GradingScheme _scheme = new();
|
||||
private double? _examsAverage;
|
||||
private double? _participationAverage;
|
||||
private double? _otherAverage;
|
||||
private List<(string Grade, double Weight)> _examGradesRaw = [];
|
||||
private List<(string Grade, double Weight)> _participationGradesRaw = [];
|
||||
private List<(string Grade, double Weight)> _otherGradesRaw = [];
|
||||
|
||||
public string StudentName { get; }
|
||||
public string GroupLabel { get; }
|
||||
|
||||
[ObservableProperty] private ParticipationPeriodOption _selectedPeriod;
|
||||
[ObservableProperty] private bool _isTeacherMode = true;
|
||||
[ObservableProperty] private string _schemeSummary = "";
|
||||
[ObservableProperty] private string? _examsAverageDisplay;
|
||||
[ObservableProperty] private string? _participationAverageDisplay;
|
||||
[ObservableProperty] private string? _otherAverageDisplay;
|
||||
[ObservableProperty] private string _currentReportGradeDisplay = "";
|
||||
|
||||
[ObservableProperty] private string _targetGradeText = "";
|
||||
[ObservableProperty] private GradeBucketKind _targetBucket = GradeBucketKind.Exams;
|
||||
[ObservableProperty] private string _targetResultDisplay = "";
|
||||
|
||||
[ObservableProperty] private string _whatIfExamGradeText = "";
|
||||
[ObservableProperty] private string _whatIfResultDisplay = "";
|
||||
|
||||
public List<ParticipationPeriodOption> PeriodOptions { get; } =
|
||||
[
|
||||
new(ParticipationPeriod.FullYear, "Gesamtes Schuljahr"),
|
||||
new(ParticipationPeriod.H1, "1. Halbjahr"),
|
||||
new(ParticipationPeriod.H2, "2. Halbjahr"),
|
||||
];
|
||||
|
||||
public string[] TargetBucketOptions { get; } = ["Klausuren", "Mitarbeit", "Sonstige"];
|
||||
|
||||
public string TargetBucketName
|
||||
{
|
||||
get => TargetBucket switch
|
||||
{
|
||||
GradeBucketKind.Participation => "Mitarbeit",
|
||||
GradeBucketKind.Other => "Sonstige",
|
||||
_ => "Klausuren",
|
||||
};
|
||||
set => TargetBucket = value switch
|
||||
{
|
||||
"Mitarbeit" => GradeBucketKind.Participation,
|
||||
"Sonstige" => GradeBucketKind.Other,
|
||||
_ => GradeBucketKind.Exams,
|
||||
};
|
||||
}
|
||||
|
||||
public ObservableCollection<StudentExamRow> ExamRows { get; } = [];
|
||||
/// Eigener Verlauf (Nutzer-Feedback): dieselbe Balken-Sparkline wie die bestehende
|
||||
/// Notenentwicklung im Schülerdetail (2.5), hier nur auf die Klausuren dieses Kurses
|
||||
/// beschränkt statt aller Lerngruppen — passt zum Kursdurchschnitt-Vergleich je Klausur.
|
||||
public StudentGradeHistoryGroup ExamHistory { get; } = new("Klausurverlauf");
|
||||
public ObservableCollection<StudentSimpleGradeRow> ParticipationRows { get; } = [];
|
||||
public ObservableCollection<StudentSimpleGradeRow> OtherRows { get; } = [];
|
||||
|
||||
public StudentPerformanceOverviewViewModel(IGradeRepository grades, IExamRepository exams,
|
||||
IExamResultRepository results, IGroupMembershipRepository memberships,
|
||||
IGradingSchemeRepository schemes, GradingService grading,
|
||||
Guid groupId, Guid studentId, GroupType groupType, GradingSystem gradingSystem,
|
||||
string studentName, string groupLabel, string schoolYear)
|
||||
{
|
||||
_grades = grades; _exams = exams; _results = results; _memberships = memberships;
|
||||
_schemes = schemes; _grading = grading;
|
||||
_groupId = groupId; _studentId = studentId; _groupType = groupType;
|
||||
_gradingSystem = gradingSystem; _schoolYear = schoolYear;
|
||||
StudentName = studentName; GroupLabel = groupLabel;
|
||||
|
||||
_selectedPeriod = PeriodOptions[0];
|
||||
Recompute();
|
||||
}
|
||||
|
||||
partial void OnSelectedPeriodChanged(ParticipationPeriodOption value) => Recompute();
|
||||
|
||||
public string ModeButtonLabel => IsTeacherMode ? "Modus: Bewerter" : "Modus: Schüler";
|
||||
|
||||
partial void OnIsTeacherModeChanged(bool value) => OnPropertyChanged(nameof(ModeButtonLabel));
|
||||
|
||||
[RelayCommand]
|
||||
private void ToggleMode() => IsTeacherMode = !IsTeacherMode;
|
||||
|
||||
private GradingScheme ResolveScheme() =>
|
||||
_schemes.GetByGroup(_groupId)
|
||||
?? _schemes.GetDefaultForType(_groupType)
|
||||
?? new GradingScheme { ExamsPercent = 50, ParticipationPercent = 40, OtherPercent = 10 };
|
||||
|
||||
private void Recompute()
|
||||
{
|
||||
_scheme = ResolveScheme();
|
||||
SchemeSummary = $"Klausuren {_scheme.ExamsPercent:0.#} % · Mitarbeit {_scheme.ParticipationPercent:0.#} % · " +
|
||||
$"Sonstige {_scheme.OtherPercent:0.#} %";
|
||||
|
||||
var (periodFrom, periodTo) = GroupMembershipService.SchoolYearPeriod(_schoolYear, SelectedPeriod.Period switch
|
||||
{
|
||||
ParticipationPeriod.H1 => SchoolYearPeriodKind.H1,
|
||||
ParticipationPeriod.H2 => SchoolYearPeriodKind.H2,
|
||||
_ => SchoolYearPeriodKind.FullYear,
|
||||
});
|
||||
var membership = _memberships.GetByGroup(_groupId).FirstOrDefault(m => m.StudentId == _studentId);
|
||||
|
||||
// ── Klausuren ─────────────────────────────────────────────────────
|
||||
ExamRows.Clear();
|
||||
ExamHistory.Points.Clear();
|
||||
_examGradesRaw = [];
|
||||
int? previousNoteEquivalent = null;
|
||||
foreach (var exam in _exams.GetByGroup(_groupId)
|
||||
.Where(e => e.Date >= periodFrom && e.Date <= periodTo)
|
||||
.Where(e => membership is null || GroupMembershipService.IsActiveOn(membership, e.Date))
|
||||
.Where(e => !e.Niveau.HasValue || membership?.Niveau == e.Niveau)
|
||||
.OrderBy(e => e.Date))
|
||||
{
|
||||
var allResults = _results.GetByExam(exam.Id);
|
||||
var own = allResults.FirstOrDefault(r => r.StudentId == _studentId);
|
||||
string? ownGrade = own is { Absent: false, Grade: not null } ? own.Grade : null;
|
||||
if (ownGrade is not null) _examGradesRaw.Add((ownGrade, 1.0));
|
||||
|
||||
var classGrades = allResults.Where(r => !r.Absent && r.Grade is not null)
|
||||
.Select(r => (r.Grade!, 1.0)).ToList();
|
||||
var classAverage = classGrades.Count > 0 ? _grading.WeightedAverage(classGrades) : (double?)null;
|
||||
|
||||
ExamRows.Add(new StudentExamRow(exam.Title, exam.Date, ownGrade,
|
||||
classAverage.HasValue ? FormatGrade(classAverage.Value) : "–"));
|
||||
|
||||
// Gleiche Balken-Sparkline wie die bestehende Notenentwicklung (2.5): Rohwert auf die
|
||||
// 1..6-Notenäquivalent-Achse abbilden, damit Grades1To6 und Points0To15 dieselbe
|
||||
// Balkenhöhen-Formel nutzen können.
|
||||
int? noteEquivalent = ownGrade is not null && int.TryParse(ownGrade, out var raw)
|
||||
? (_gradingSystem == GradingSystem.Grades1To6 ? raw : int.Parse(PointsNoteMapping.PointsToNote(raw)))
|
||||
: null;
|
||||
var isFailing = noteEquivalent is >= 5;
|
||||
var isDrop = noteEquivalent.HasValue && previousNoteEquivalent.HasValue
|
||||
&& noteEquivalent.Value - previousNoteEquivalent.Value >= 1;
|
||||
var warnings = new List<string>();
|
||||
if (isDrop) warnings.Add("Abfall um ≥ 1 Note");
|
||||
if (isFailing) warnings.Add("Versetzungsgefährdung");
|
||||
ExamHistory.Points.Add(new GradeHistoryPoint(exam.Date, exam.Title, ownGrade ?? "–",
|
||||
noteEquivalent, warnings.Count > 0, string.Join(" · ", warnings)));
|
||||
if (noteEquivalent.HasValue) previousNoteEquivalent = noteEquivalent;
|
||||
}
|
||||
_examsAverage = _examGradesRaw.Count > 0 ? _grading.WeightedAverage(_examGradesRaw) : null;
|
||||
|
||||
// ── Mitarbeit & Sonstige ──────────────────────────────────────────
|
||||
var allGrades = _grades.GetByStudentAndGroup(_studentId, _groupId)
|
||||
.Where(g => g.Date >= periodFrom && g.Date <= periodTo)
|
||||
.Where(g => membership is null || GroupMembershipService.IsActiveOn(membership, g.Date))
|
||||
.OrderBy(g => g.Date).ToList();
|
||||
|
||||
ParticipationRows.Clear();
|
||||
_participationGradesRaw = [];
|
||||
foreach (var g in allGrades.Where(g => g.Category == GradeCategory.Participation))
|
||||
{
|
||||
_participationGradesRaw.Add((g.Value, g.Weight));
|
||||
ParticipationRows.Add(new StudentSimpleGradeRow(g, SaveGrade));
|
||||
}
|
||||
_participationAverage = _participationGradesRaw.Count > 0 ? _grading.WeightedAverage(_participationGradesRaw) : null;
|
||||
|
||||
OtherRows.Clear();
|
||||
_otherGradesRaw = [];
|
||||
foreach (var g in allGrades.Where(g => g.Category != GradeCategory.Participation))
|
||||
{
|
||||
_otherGradesRaw.Add((g.Value, g.Weight));
|
||||
OtherRows.Add(new StudentSimpleGradeRow(g, SaveGrade));
|
||||
}
|
||||
_otherAverage = _otherGradesRaw.Count > 0 ? _grading.WeightedAverage(_otherGradesRaw) : null;
|
||||
|
||||
ExamsAverageDisplay = _examsAverage.HasValue ? FormatGrade(_examsAverage.Value) : null;
|
||||
ParticipationAverageDisplay = _participationAverage.HasValue ? FormatGrade(_participationAverage.Value) : null;
|
||||
OtherAverageDisplay = _otherAverage.HasValue ? FormatGrade(_otherAverage.Value) : null;
|
||||
|
||||
var reportGrade = _grading.CalculateReportGrade(_examGradesRaw, _participationGradesRaw, _otherGradesRaw,
|
||||
_scheme, _gradingSystem, RoundingRule.Commercial);
|
||||
CurrentReportGradeDisplay = reportGrade is null
|
||||
? "Noch nicht berechenbar — keine Werte im gewählten Zeitraum."
|
||||
: $"Note {reportGrade}";
|
||||
|
||||
RecomputeTarget();
|
||||
RecomputeWhatIf();
|
||||
}
|
||||
|
||||
private void SaveGrade(Grade grade)
|
||||
{
|
||||
_grades.Save(grade);
|
||||
Recompute();
|
||||
}
|
||||
|
||||
partial void OnTargetGradeTextChanged(string value) => RecomputeTarget();
|
||||
partial void OnTargetBucketChanged(GradeBucketKind value)
|
||||
{
|
||||
OnPropertyChanged(nameof(TargetBucketName));
|
||||
RecomputeTarget();
|
||||
}
|
||||
|
||||
private void RecomputeTarget()
|
||||
{
|
||||
if (!TryParseGrade(TargetGradeText, out var target))
|
||||
{
|
||||
TargetResultDisplay = "";
|
||||
return;
|
||||
}
|
||||
|
||||
var result = ReportGradeTargetCalculator.SolveRequiredAverage(target, TargetBucket,
|
||||
_examsAverage, _participationAverage, _otherAverage, _scheme, _gradingSystem);
|
||||
|
||||
TargetResultDisplay = result switch
|
||||
{
|
||||
null => $"{TargetBucketName} fließt laut Gewichtungsschema nicht in die Zeugnisnote ein.",
|
||||
{ IsAchievable: true } r => $"Nötiger Schnitt in {TargetBucketName}: {FormatGrade(r.RequiredAverage)}",
|
||||
{ IsAchievable: false } r =>
|
||||
$"Mit den übrigen Bereichen rechnerisch nicht mehr erreichbar (bräuchte {FormatGrade(r.RequiredAverage)}).",
|
||||
};
|
||||
}
|
||||
|
||||
partial void OnWhatIfExamGradeTextChanged(string value) => RecomputeWhatIf();
|
||||
|
||||
private void RecomputeWhatIf()
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(WhatIfExamGradeText))
|
||||
{
|
||||
WhatIfResultDisplay = "";
|
||||
return;
|
||||
}
|
||||
|
||||
var hypothetical = new List<(string Grade, double Weight)>(_examGradesRaw) { (WhatIfExamGradeText.Trim(), 1.0) };
|
||||
var result = _grading.CalculateReportGrade(hypothetical, _participationGradesRaw, _otherGradesRaw,
|
||||
_scheme, _gradingSystem, RoundingRule.Commercial);
|
||||
WhatIfResultDisplay = result is null
|
||||
? "Ungültige Note."
|
||||
: $"Zeugnisnote mit dieser zusätzlichen Klausurnote: {result}";
|
||||
}
|
||||
|
||||
private static bool TryParseGrade(string text, out double value) =>
|
||||
double.TryParse(text.Trim().Replace(',', '.'), NumberStyles.Float, CultureInfo.InvariantCulture, out value);
|
||||
|
||||
private static string FormatGrade(double value) => value.ToString("0.0", CultureInfo.InvariantCulture);
|
||||
}
|
||||
|
||||
public class StudentExamRow(string title, DateOnly date, string? ownGrade, string classAverageDisplay)
|
||||
{
|
||||
public string Title { get; } = title;
|
||||
public string DateDisplay { get; } = date.ToString("dd.MM.yyyy");
|
||||
public string OwnGradeDisplay { get; } = ownGrade ?? "–";
|
||||
public string ClassAverageDisplay { get; } = classAverageDisplay;
|
||||
}
|
||||
|
||||
public partial class StudentSimpleGradeRow : ObservableObject
|
||||
{
|
||||
private readonly Grade _grade;
|
||||
private readonly Action<Grade> _save;
|
||||
|
||||
public string CategoryLabel { get; }
|
||||
public string DateDisplay { get; }
|
||||
[ObservableProperty] private string _value;
|
||||
|
||||
public StudentSimpleGradeRow(Grade grade, Action<Grade> save)
|
||||
{
|
||||
_grade = grade;
|
||||
_save = save;
|
||||
CategoryLabel = GradeCategoryDisplay.Label(grade.Category);
|
||||
DateDisplay = grade.Date.ToString("dd.MM.yyyy");
|
||||
_value = grade.Value;
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void Save()
|
||||
{
|
||||
_grade.Value = Value.Trim();
|
||||
_save(_grade);
|
||||
}
|
||||
}
|
||||
@@ -48,8 +48,8 @@ public partial class WebUntisLessonAbsenceComparisonViewModel : ObservableObject
|
||||
new Dictionary<DateOnly, ParticipationSession>();
|
||||
|
||||
public ObservableCollection<WebUntisLessonAbsenceRow> Rows { get; } = [];
|
||||
[ObservableProperty] private DateTimeOffset _startDate = DateTimeOffset.Now.AddMonths(-2);
|
||||
[ObservableProperty] private DateTimeOffset _endDate = DateTimeOffset.Now;
|
||||
[ObservableProperty] private DateTimeOffset? _startDate = DateTimeOffset.Now.AddMonths(-2);
|
||||
[ObservableProperty] private DateTimeOffset? _endDate = DateTimeOffset.Now;
|
||||
[ObservableProperty] private string _status = "Zeitraum wählen und Fehlzeiten laden.";
|
||||
[ObservableProperty] private bool _busy;
|
||||
[ObservableProperty] private bool _markUnknownAsPresent;
|
||||
@@ -73,8 +73,8 @@ public partial class WebUntisLessonAbsenceComparisonViewModel : ObservableObject
|
||||
return;
|
||||
}
|
||||
|
||||
var start = DateOnly.FromDateTime(StartDate.LocalDateTime);
|
||||
var end = DateOnly.FromDateTime(EndDate.LocalDateTime);
|
||||
var start = DateOnly.FromDateTime((StartDate ?? DateTimeOffset.Now).LocalDateTime);
|
||||
var end = DateOnly.FromDateTime((EndDate ?? DateTimeOffset.Now).LocalDateTime);
|
||||
if (end < start) { Status = "Das Enddatum darf nicht vor dem Startdatum liegen."; return; }
|
||||
Busy = true; Rows.Clear();
|
||||
try
|
||||
|
||||
@@ -90,7 +90,10 @@ public partial class MainWindowViewModel : ObservableObject
|
||||
case TimetableViewModel vm: vm.Load(); break;
|
||||
case WorkloadViewModel vm:
|
||||
vm.Tasks.Load(); vm.TimeTracking.Load(); vm.Evaluation.Load(); break;
|
||||
case ClassTeacherOverviewViewModel vm: vm.LoadCommand.Execute(null); break;
|
||||
// RefreshFromLocalDataOnly() statt LoadCommand: baut die Ansicht aus den zuletzt
|
||||
// geholten WebUntis-Daten + aktuellen lokalen Daten neu auf, löst aber selbst nie einen
|
||||
// WebUntis-Request aus - den holt bewusst ausschließlich der "Aktualisieren"-Button.
|
||||
case ClassTeacherOverviewViewModel vm: vm.RefreshFromLocalDataOnly(); break;
|
||||
case ExamsOverviewViewModel vm: vm.LoadCommand.Execute(null); break;
|
||||
case GroupDetailViewModel { Group: { } group } vm: vm.LoadGroup(group.Id); break;
|
||||
// Inline-Bearbeitung (IsEditing) nicht überschreiben - anders als die Gruppenansicht
|
||||
@@ -176,7 +179,11 @@ public partial class MainWindowViewModel : ObservableObject
|
||||
var vm = _services.GetRequiredService<ClassTeacherOverviewViewModel>();
|
||||
vm.OnNavigateToSettings = () => { NavigateToSettings(SettingsTab.WebUntis); return Task.CompletedTask; };
|
||||
vm.OnNavigateToWorkload = () => { NavigateToWorkload(); return Task.CompletedTask; };
|
||||
vm.LoadCommand.Execute(null);
|
||||
// Kein automatischer WebUntis-Abruf beim Navigieren: PrepareForDisplay() baut nur den
|
||||
// lokalen Grundzustand auf, die eigentlichen Report-Daten holt die Nutzerin bewusst über
|
||||
// den "Aktualisieren"-Button (LoadCommand) - vermeidet WebUntis-Login+Report-Latenz beim
|
||||
// bloßen Öffnen der Seite.
|
||||
vm.PrepareForDisplay();
|
||||
return vm;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using LehrerApp.Core.Interfaces;
|
||||
using LehrerApp.Core.Models;
|
||||
using System.Collections.ObjectModel;
|
||||
|
||||
namespace LehrerApp.Desktop.ViewModels.Planning;
|
||||
|
||||
public sealed class TimetableUnitOption(Unit unit)
|
||||
{
|
||||
public Unit Model { get; } = unit;
|
||||
public string Label { get; } = unit.Title;
|
||||
public string Detail { get; } = unit.Status switch
|
||||
{
|
||||
UnitStatus.Active => "Laufende Einheit",
|
||||
UnitStatus.Completed => "Abgeschlossene Einheit",
|
||||
_ => "Geplante Einheit",
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>Ordnet eine direkt aus dem Stundenplan angelegte Stunde einer Einheit zu.</summary>
|
||||
public partial class TimetableUnitPickerViewModel : ObservableObject
|
||||
{
|
||||
private readonly IUnitRepository _units;
|
||||
private readonly Guid _groupId;
|
||||
private readonly DateOnly _date;
|
||||
|
||||
public string ContextLabel { get; }
|
||||
public ObservableCollection<TimetableUnitOption> Units { get; } = [];
|
||||
[ObservableProperty] private TimetableUnitOption? _selectedUnit;
|
||||
[ObservableProperty] private string _newUnitTitle = "";
|
||||
[ObservableProperty] private string _error = "";
|
||||
public Unit? Result { get; private set; }
|
||||
public bool ResultIsNew { get; private set; }
|
||||
public bool HasUnits => Units.Count > 0;
|
||||
|
||||
public TimetableUnitPickerViewModel(IUnitRepository units, Guid groupId, string groupName,
|
||||
DateOnly date, int period)
|
||||
{
|
||||
_units = units;
|
||||
_groupId = groupId;
|
||||
_date = date;
|
||||
ContextLabel = $"{groupName} · {date:dd.MM.yyyy} · {period}. Stunde";
|
||||
|
||||
foreach (var unit in units.GetByGroup(groupId)
|
||||
.OrderBy(u => u.Status == UnitStatus.Active ? 0 : u.Status == UnitStatus.Planned ? 1 : 2)
|
||||
.ThenByDescending(u => u.StartDate)
|
||||
.ThenBy(u => u.Title, StringComparer.CurrentCultureIgnoreCase))
|
||||
Units.Add(new TimetableUnitOption(unit));
|
||||
SelectedUnit = Units.FirstOrDefault();
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void Save()
|
||||
{
|
||||
Error = "";
|
||||
if (!string.IsNullOrWhiteSpace(NewUnitTitle))
|
||||
{
|
||||
Result = new Unit
|
||||
{
|
||||
GroupId = _groupId,
|
||||
Title = NewUnitTitle.Trim(),
|
||||
StartDate = _date,
|
||||
Status = UnitStatus.Active,
|
||||
};
|
||||
// Erst speichern, wenn auch der anschließende Stunden-Dialog bestätigt wurde. So
|
||||
// hinterlässt ein Abbruch keine leere Einheit.
|
||||
ResultIsNew = true;
|
||||
return;
|
||||
}
|
||||
|
||||
if (SelectedUnit is null)
|
||||
{
|
||||
Error = "Bitte eine Einheit auswählen oder eine neue benennen.";
|
||||
return;
|
||||
}
|
||||
Result = SelectedUnit.Model;
|
||||
ResultIsNew = false;
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record TimetableLessonRequest(Guid GroupId, DateOnly Date, int PeriodNumber);
|
||||
public sealed record TimetableLessonMoveRequest(Lesson Lesson, int SelectedPeriod);
|
||||
@@ -5,6 +5,7 @@ using LehrerApp.Core.Models;
|
||||
using LehrerApp.Core.Services;
|
||||
using LehrerApp.Desktop.Services;
|
||||
using LehrerApp.Desktop.ViewModels.Settings;
|
||||
using LehrerApp.Desktop.ViewModels.Groups;
|
||||
using System.Collections.ObjectModel;
|
||||
|
||||
namespace LehrerApp.Desktop.ViewModels.Planning;
|
||||
@@ -108,6 +109,8 @@ public partial class TimetableViewModel : ObservableObject
|
||||
public Action<SettingsTab>? OnNavigateToSettings { get; set; }
|
||||
public Func<Lesson, Task>? OnOpenLessonViewer { get; set; }
|
||||
public Func<Lesson, Task>? OnOpenTeachingMode { get; set; }
|
||||
public Func<TimetableLessonRequest, Task>? OnCreateLesson { get; set; }
|
||||
public Func<TimetableLessonMoveRequest, Task>? OnMoveLesson { get; set; }
|
||||
|
||||
/// Öffentlich statt intern (kein InternalsVisibleTo in dieser Codebasis) - erlaubt Tests, die
|
||||
/// "heute"-abhängiges Verhalten (Wochenraster-Badges, Unterrichtszeit-Erkennung) prüfen, ohne
|
||||
@@ -301,9 +304,9 @@ public partial class TimetableViewModel : ObservableObject
|
||||
var cancelled = substitutionsToday.FirstOrDefault(s => s.Kind == SubstitutionKind.Cancelled && s.PeriodNumber == slot.PeriodNumber);
|
||||
if (cancelled is not null) { items.Add(TodayLessonItem.ForCancelled(slot.GroupId, slot.PeriodNumber, group.Name, cancelled)); continue; }
|
||||
|
||||
var lesson = _lessons.GetByGroupAndDate(slot.GroupId, today).FirstOrDefault();
|
||||
var lesson = FindLessonForSlot(slot.GroupId, today, slot.PeriodNumber);
|
||||
var exam = _exams.GetByGroup(slot.GroupId).FirstOrDefault(e => e.Date == today);
|
||||
items.Add(new TodayLessonItem(slot.GroupId, slot.PeriodNumber, group.Name,
|
||||
items.Add(new TodayLessonItem(slot.GroupId, today, slot.PeriodNumber, group.Name,
|
||||
slot.Room ?? "", ColorFor(group.Name), lesson?.Topic, exam?.Title,
|
||||
HasUnhandledHomework(slot.GroupId, today), lesson));
|
||||
}
|
||||
@@ -353,15 +356,15 @@ public partial class TimetableViewModel : ObservableObject
|
||||
}
|
||||
|
||||
/// Springt aus dem Stundenplan direkt in den Verlaufsplan-Viewer der zugehörigen Lesson (4.5.2)
|
||||
/// — sofern für den Slot schon eine Lesson existiert. Ohne Lesson (Slot laut Stundenplan belegt,
|
||||
/// aber noch keine konkrete Stunde geplant) bleibt es bei der bisherigen, gröberen Navigation
|
||||
/// zum Planung-Tab der Gruppe: eine neue Lesson direkt von hier aus anzulegen bräuchte eine
|
||||
/// Antwort auf "welcher Unit wird sie zugeordnet", die bewusst noch offen ist (siehe TODO 4.5.2).
|
||||
/// — sofern für den Slot schon eine Lesson existiert. Ohne Lesson startet die Direktanlage;
|
||||
/// der vorgeschaltete Einheiten-Dialog löst dabei die notwendige Unit-Zuordnung explizit.
|
||||
[RelayCommand]
|
||||
private async Task OpenTodayLesson(TodayLessonItem? item)
|
||||
{
|
||||
if (item is null || item.GroupId == Guid.Empty) return;
|
||||
if (item.Lesson is { } lesson && OnOpenLessonViewer is not null) await OnOpenLessonViewer(lesson);
|
||||
else if (OnCreateLesson is not null)
|
||||
await OnCreateLesson(new TimetableLessonRequest(item.GroupId, item.Date, item.PeriodNumber));
|
||||
else OnNavigateToGroup?.Invoke(item.GroupId);
|
||||
}
|
||||
|
||||
@@ -380,7 +383,7 @@ public partial class TimetableViewModel : ObservableObject
|
||||
/// nach der Stunde) Unterrichtszeit, geht es direkt in den Unterrichtsmodus — sonst wie
|
||||
/// bisher in den (schreibgeschützten) Planungsviewer bzw., ohne Lesson, zur Einheitenplanung
|
||||
/// der Gruppe. Das Popup-Menü (siehe TimetableView.axaml, MenuFlyout je Kachel) bietet
|
||||
/// daneben immer alle vier Ziele explizit an, unabhängig von dieser Automatik.
|
||||
/// daneben die weiteren Ziele (einschließlich Anlegen/Verschieben) explizit an.
|
||||
[RelayCommand]
|
||||
private async Task OpenWeekCell(WeekCellItem? item)
|
||||
{
|
||||
@@ -391,6 +394,8 @@ public partial class TimetableViewModel : ObservableObject
|
||||
await OnOpenTeachingMode(lesson);
|
||||
else if (OnOpenLessonViewer is not null) await OnOpenLessonViewer(lesson);
|
||||
}
|
||||
else if (item.Date is { } date && OnCreateLesson is not null)
|
||||
await OnCreateLesson(new TimetableLessonRequest(item.GroupId, date, item.PeriodNumber));
|
||||
else OnNavigateToGroup?.Invoke(item.GroupId);
|
||||
}
|
||||
|
||||
@@ -399,16 +404,20 @@ public partial class TimetableViewModel : ObservableObject
|
||||
/// Start-/Endzeit gebunden — man klickt auch kurz vor Stundenbeginn oder in einer kurzen
|
||||
/// Verzögerung danach noch typischerweise in Unterrichtsabsicht. Ohne konfiguriertes
|
||||
/// Stundenraster oder an einem anderen Tag als heute bleibt es beim Planungsviewer.
|
||||
/// Nimmt bewusst das Datum der Lesson selbst statt WeekCellItem.Date entgegen — Date ist dort
|
||||
/// nur bei Kopfzeilen (WeekdayHeader) gesetzt, nicht bei regulären Stunden-Kacheln (ForSlot).
|
||||
/// Nimmt das Datum der Lesson selbst entgegen; die Kachel trägt ihr Datum zusätzlich für die
|
||||
/// Direktanlage einer noch nicht existierenden Stunde.
|
||||
private static readonly TimeSpan TeachingTimeTolerance = TimeSpan.FromMinutes(10);
|
||||
private bool IsAroundTeachingTime(DateOnly lessonDate, int periodNumber)
|
||||
{
|
||||
var nowSnapshot = Clock();
|
||||
if (lessonDate != DateOnly.FromDateTime(nowSnapshot)) return false;
|
||||
if (_periodSchedule.GetTimes(periodNumber) is not { } times) return false;
|
||||
var now = TimeOnly.FromDateTime(nowSnapshot);
|
||||
return now >= times.Start.Add(-TeachingTimeTolerance) && now <= times.End.Add(TeachingTimeTolerance);
|
||||
// DateTime statt TimeOnly.Add: Letzteres springt nahe Mitternacht auf den anderen
|
||||
// Tagesrand und macht aus z.B. 00:05 ± 10 Minuten ein umgekehrtes Vergleichsfenster.
|
||||
var start = lessonDate.ToDateTime(times.Start).Subtract(TeachingTimeTolerance);
|
||||
var end = lessonDate.ToDateTime(times.End).Add(TeachingTimeTolerance);
|
||||
if (end < start) end = end.AddDays(1); // nur für ein ggf. über Mitternacht laufendes Raster
|
||||
return nowSnapshot >= start && nowSnapshot <= end;
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
@@ -487,14 +496,14 @@ public partial class TimetableViewModel : ObservableObject
|
||||
continue;
|
||||
}
|
||||
|
||||
var lesson = _lessons.GetByGroupAndDate(slot.GroupId, date).FirstOrDefault();
|
||||
var lesson = FindLessonForSlot(slot.GroupId, date, period);
|
||||
var hasExam = _exams.GetByGroup(slot.GroupId).Any(e => e.Date == date);
|
||||
var isHoliday = IsFreeDay(date, schoolHolidays, publicHolidayDates);
|
||||
var colorHex = isHoliday ? "#BDBDBD" : ColorFor(group?.Name ?? "");
|
||||
var holidayBadge = HolidayBadgeFor(date, weekday, schoolHolidays, publicHolidayDates);
|
||||
var isLastBeforeExam = IsLastBeforeExamFor(date, weekday, slot.GroupId, publicHolidayDates);
|
||||
|
||||
WeekItems.Add(WeekCellItem.ForSlot(weekday, period, date == today,
|
||||
WeekItems.Add(WeekCellItem.ForSlot(weekday, period, date == today, date,
|
||||
subject?.ShortName is { Length: > 0 } sn ? sn : subject?.Name ?? "",
|
||||
group?.Name ?? "?", slot.Room ?? "", lesson?.Topic ?? "",
|
||||
colorHex, holidayBadge, hasExam, isLastBeforeExam,
|
||||
@@ -560,6 +569,40 @@ public partial class TimetableViewModel : ObservableObject
|
||||
p.Activity?.Contains("Experiment", StringComparison.OrdinalIgnoreCase) == true ||
|
||||
p.Material?.Contains("Experiment", StringComparison.OrdinalIgnoreCase) == true)) == true;
|
||||
|
||||
/// Eine Doppelstunde wird als eine Lesson an der ersten Periode gespeichert. Für die zweite
|
||||
/// Rasterzelle liefern wir dieselbe Lesson, wenn der Stundenplan dort unmittelbar mit derselben
|
||||
/// Lerngruppe fortgesetzt wird. Das darf nicht von bereits eingetragenen Phasendauern abhängen:
|
||||
/// Gerade ein früher Entwurf hat oft erst Datum und Thema, gehört aber trotzdem zum kompletten
|
||||
/// Doppelstundenblock. Eine exakt an der Zielperiode verankerte Lesson hat immer Vorrang.
|
||||
private Lesson? FindLessonForSlot(Guid groupId, DateOnly date, int period)
|
||||
{
|
||||
var lessons = _lessons.GetByGroupAndDate(groupId, date);
|
||||
var exact = lessons.FirstOrDefault(l => l.LessonNumber == period);
|
||||
if (exact is not null) return exact;
|
||||
// Historische/manuell angelegte Einträge hatten häufig keine Stundennummer. Solange es
|
||||
// davon nur einen an diesem Tag gibt, bleibt das frühere Verhalten erhalten und er wird
|
||||
// dem vorhandenen Gruppen-Slot zugeordnet.
|
||||
var withoutPeriod = lessons.Where(l => l.LessonNumber is null).ToList();
|
||||
if (withoutPeriod.Count == 1) return withoutPeriod[0];
|
||||
|
||||
var periodsInBlock = _slots.GetByGroup(groupId)
|
||||
.Where(s => s.Weekday == date.DayOfWeek)
|
||||
.Select(s => s.PeriodNumber)
|
||||
.ToHashSet();
|
||||
|
||||
// Vom angefragten Slot innerhalb desselben lückenlosen Blocks rückwärts zur verankerten
|
||||
// Lesson laufen. Damit funktionieren neben Doppelstunden auch seltene Dreifachblöcke.
|
||||
for (var candidatePeriod = period - 1;
|
||||
periodsInBlock.Contains(candidatePeriod) && periodsInBlock.Contains(candidatePeriod + 1);
|
||||
candidatePeriod--)
|
||||
{
|
||||
var blockLesson = lessons.Where(l => l.LessonNumber == candidatePeriod)
|
||||
.OrderByDescending(l => l.UpdatedAt).FirstOrDefault();
|
||||
if (blockLesson is not null) return blockLesson;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>4.5.4: Hat die letzte vor <paramref name="date"/> liegende Lesson dieser Gruppe eine
|
||||
/// Hausaufgabe, die weder als kontrolliert noch als bewusst übersprungen markiert ist? Schaut
|
||||
/// bewusst nur auf die unmittelbar vorherige Lesson (nicht auf die gesamte Historie) — sobald
|
||||
@@ -824,7 +867,9 @@ public partial class WeekCellItem : ObservableObject
|
||||
/// Grundlage für den Direktsprung in den Verlaufsplan-Viewer (4.5.2).
|
||||
public Lesson? Lesson { get; private init; }
|
||||
public bool HasLesson => Lesson is not null;
|
||||
public string OpenButtonLabel => Lesson is not null ? "Verlaufsplan ansehen" : "Zur Lerngruppe";
|
||||
public bool HasNoLesson => Lesson is null;
|
||||
public string PlanningStatusLabel => Lesson is null ? "Nicht geplant" : LessonStatusDisplay.ToName(Lesson.Status);
|
||||
public string OpenButtonLabel => Lesson is not null ? "Verlaufsplan ansehen" : "Stunde anlegen";
|
||||
[ObservableProperty] private string _weatherSymbol = "";
|
||||
[ObservableProperty] private string _weatherTooltip = "";
|
||||
[ObservableProperty] private bool _hasWeatherWarning;
|
||||
@@ -879,18 +924,28 @@ public partial class WeekCellItem : ObservableObject
|
||||
IsSubstitutionSupervision = isSubstitution,
|
||||
};
|
||||
|
||||
public static WeekCellItem ForSlot(DayOfWeek day, int period, bool isToday, string subjectLabel,
|
||||
public static WeekCellItem ForSlot(DayOfWeek day, int period, bool isToday, DateOnly date, string subjectLabel,
|
||||
string groupName, string room, string topic, string colorHex, string holidayBadge,
|
||||
bool hasExam, bool isLastBeforeExam, bool hasExperiment, Guid groupId, bool isHoliday,
|
||||
bool hasUnhandledHomework = false, Lesson? lesson = null) => new()
|
||||
{
|
||||
Weekday = day, PeriodNumber = period, IsAssigned = true, IsToday = isToday,
|
||||
Weekday = day, PeriodNumber = period, IsAssigned = true, IsToday = isToday, Date = date,
|
||||
SubjectLabel = subjectLabel, GroupName = groupName, Room = room, Topic = topic,
|
||||
ColorHex = colorHex, HolidayBadge = holidayBadge, HasExam = hasExam,
|
||||
IsLastBeforeExam = isLastBeforeExam, HasExperiment = hasExperiment, GroupId = groupId,
|
||||
IsHoliday = isHoliday, HasUnhandledHomework = hasUnhandledHomework, Lesson = lesson,
|
||||
};
|
||||
|
||||
// Kompatible Überladung für isolierte ViewModel-Tests und ältere Aufrufer ohne konkreten
|
||||
// Wochenbezug. Produktiv wird die datierte Variante verwendet.
|
||||
public static WeekCellItem ForSlot(DayOfWeek day, int period, bool isToday, string subjectLabel,
|
||||
string groupName, string room, string topic, string colorHex, string holidayBadge,
|
||||
bool hasExam, bool isLastBeforeExam, bool hasExperiment, Guid groupId, bool isHoliday,
|
||||
bool hasUnhandledHomework = false, Lesson? lesson = null) => ForSlot(day, period, isToday,
|
||||
DateOnly.FromDateTime(DateTime.Today), subjectLabel, groupName, room, topic, colorHex,
|
||||
holidayBadge, hasExam, isLastBeforeExam, hasExperiment, groupId, isHoliday,
|
||||
hasUnhandledHomework, lesson);
|
||||
|
||||
public static WeekCellItem ForSubstitutionLesson(DayOfWeek day, int period, bool isToday, SubstitutionEntry entry) => new()
|
||||
{
|
||||
Weekday = day, PeriodNumber = period, IsAssigned = true, IsToday = isToday, IsSubstitutionLesson = true,
|
||||
@@ -937,12 +992,13 @@ public class UpcomingExamItem(DateOnly date, string groupName, string title)
|
||||
public string Title { get; } = title;
|
||||
}
|
||||
|
||||
public enum TimetableLessonDestination { TeachingMode, Viewer, SeatingPlan, Planning }
|
||||
public enum TimetableLessonDestination { TeachingMode, Viewer, Create, Move, SeatingPlan, Planning }
|
||||
public sealed record TimetableDestinationOption(TimetableLessonDestination Kind, string Label);
|
||||
|
||||
public class TodayLessonItem
|
||||
{
|
||||
public Guid GroupId { get; private init; }
|
||||
public DateOnly Date { get; private init; }
|
||||
public int PeriodNumber { get; private init; }
|
||||
public string GroupName { get; private init; } = "";
|
||||
public string Room { get; private init; } = "";
|
||||
@@ -960,23 +1016,24 @@ public class TodayLessonItem
|
||||
/// Direktsprung in den Verlaufsplan-Viewer (4.5.2) und den Unterrichtsmodus (14.x).
|
||||
public Lesson? Lesson { get; private init; }
|
||||
public bool HasLesson => Lesson is not null;
|
||||
public string OpenButtonLabel => Lesson is not null ? "Verlaufsplan ansehen" : "Zur Lerngruppe";
|
||||
public string PlanningStatusLabel => Lesson is null ? "Nicht geplant" : LessonStatusDisplay.ToName(Lesson.Status);
|
||||
public string OpenButtonLabel => Lesson is not null ? "Verlaufsplan ansehen" : "Stunde anlegen";
|
||||
|
||||
public TodayLessonItem(Guid groupId, int periodNumber, string groupName, string room,
|
||||
public TodayLessonItem(Guid groupId, DateOnly date, int periodNumber, string groupName, string room,
|
||||
string colorHex, string? lessonTopic, string? examTitle, bool hasUnhandledHomework = false,
|
||||
Lesson? lesson = null)
|
||||
{
|
||||
GroupId = groupId; PeriodNumber = periodNumber; GroupName = groupName; Room = room;
|
||||
GroupId = groupId; Date = date; PeriodNumber = periodNumber; GroupName = groupName; Room = room;
|
||||
ColorHex = colorHex; LessonTopic = lessonTopic; ExamTitle = examTitle;
|
||||
HasUnhandledHomework = hasUnhandledHomework; Lesson = lesson;
|
||||
}
|
||||
|
||||
public static TodayLessonItem ForSubstitution(int periodNumber, SubstitutionEntry entry) => new(
|
||||
entry.GroupId ?? Guid.Empty, periodNumber, entry.GroupLabel, "", "#8E24AA", entry.Description, null)
|
||||
entry.GroupId ?? Guid.Empty, entry.Date, periodNumber, entry.GroupLabel, "", "#8E24AA", entry.Description, null)
|
||||
{ IsSubstitution = true };
|
||||
|
||||
public static TodayLessonItem ForCancelled(Guid groupId, int periodNumber, string groupName, SubstitutionEntry entry) => new(
|
||||
groupId, periodNumber, groupName, "", "#757575",
|
||||
groupId, entry.Date, periodNumber, groupName, "", "#757575",
|
||||
string.IsNullOrWhiteSpace(entry.Description) ? null : entry.Description, null)
|
||||
{ IsCancelled = true };
|
||||
}
|
||||
|
||||
@@ -37,7 +37,7 @@ public partial class WebUntisTimetableImportViewModel : ObservableObject
|
||||
public ObservableCollection<UntisTeacherDto> Teachers { get; } = [];
|
||||
public ObservableCollection<WebUntisTimetableRow> Rows { get; } = [];
|
||||
[ObservableProperty] private UntisTeacherDto? _selectedTeacher;
|
||||
[ObservableProperty] private DateTimeOffset _weekDate = DateTimeOffset.Now;
|
||||
[ObservableProperty] private DateTimeOffset? _weekDate = DateTimeOffset.Now;
|
||||
[ObservableProperty] private string _status = "Lehrkraft auswählen und Untis-Woche laden.";
|
||||
[ObservableProperty] private bool _busy;
|
||||
public bool Saved { get; private set; }
|
||||
@@ -71,7 +71,7 @@ public partial class WebUntisTimetableImportViewModel : ObservableObject
|
||||
Busy = true; Rows.Clear();
|
||||
try
|
||||
{
|
||||
var selected = DateOnly.FromDateTime(WeekDate.LocalDateTime);
|
||||
var selected = DateOnly.FromDateTime((WeekDate ?? DateTimeOffset.Now).LocalDateTime);
|
||||
var monday = selected.AddDays(-(((int)selected.DayOfWeek + 6) % 7));
|
||||
var periods = await _untis.GetTimetableAsync(SelectedTeacher.UntisId, monday, monday.AddDays(6));
|
||||
var grid = await _untis.GetTimeGridAsync();
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using LehrerApp.Core.Interfaces;
|
||||
using LehrerApp.Core.Models;
|
||||
using LehrerApp.Core.Services;
|
||||
using LehrerApp.Data;
|
||||
using LehrerApp.Desktop.Services;
|
||||
using LehrerApp.Desktop.ViewModels.Planning;
|
||||
using LehrerApp.Sync;
|
||||
using LehrerApp.Sync.Crypto;
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Globalization;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace LehrerApp.Desktop.ViewModels.Settings;
|
||||
|
||||
public partial class SettingsViewModel
|
||||
{
|
||||
// ── KI-Unterstützung (4.5.9) ──────────────────────────────────────────────
|
||||
|
||||
[ObservableProperty] private bool _aiEnabled;
|
||||
[ObservableProperty] private string _aiUsername = "";
|
||||
[ObservableProperty] private string _aiPassword = "";
|
||||
[ObservableProperty] private string _aiLoginError = "";
|
||||
[ObservableProperty] private bool _aiIsLoggedIn;
|
||||
[ObservableProperty] private string _aiBalanceDisplay = "";
|
||||
|
||||
// ── KI-Unterstützung: Laden / Anmelden / Abmelden ────────────────────────
|
||||
|
||||
private void LoadAiSettings()
|
||||
{
|
||||
AiEnabled = _aiSettings.Enabled;
|
||||
AiUsername = _aiSettings.Username;
|
||||
AiIsLoggedIn = _aiSettings.IsLoggedIn;
|
||||
if (AiIsLoggedIn) _ = RefreshAiBalance();
|
||||
}
|
||||
|
||||
partial void OnAiEnabledChanged(bool value) => _aiSettings.SetEnabled(value);
|
||||
|
||||
private async Task RefreshAiBalance()
|
||||
{
|
||||
var token = _aiSettings.GetToken();
|
||||
if (token is null) return;
|
||||
try
|
||||
{
|
||||
var balance = await _aiPlanning.GetBalanceAsync(token);
|
||||
AiBalanceDisplay = $"Guthaben: {balance:0.00} €";
|
||||
}
|
||||
catch (AiBackendException ex) { AiBalanceDisplay = ex.Message; }
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private async Task AiLogin()
|
||||
{
|
||||
AiLoginError = "";
|
||||
var valid = true;
|
||||
if (string.IsNullOrWhiteSpace(AiUsername)) { AiLoginError = "Benutzername erforderlich."; valid = false; }
|
||||
if (string.IsNullOrWhiteSpace(AiPassword)) { AiLoginError = "Passwort erforderlich."; valid = false; }
|
||||
if (!valid) return;
|
||||
|
||||
try
|
||||
{
|
||||
var token = await _aiPlanning.LoginAsync(AiUsername, AiPassword);
|
||||
_aiSettings.SetCredentialsAndToken(AiUsername, token);
|
||||
AiPassword = "";
|
||||
AiIsLoggedIn = true;
|
||||
await RefreshAiBalance();
|
||||
}
|
||||
catch (AiBackendException ex) { AiLoginError = ex.Message; }
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void AiLogout()
|
||||
{
|
||||
_aiSettings.Logout();
|
||||
AiIsLoggedIn = false;
|
||||
AiBalanceDisplay = "";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using LehrerApp.Core.Interfaces;
|
||||
using LehrerApp.Core.Models;
|
||||
using LehrerApp.Core.Services;
|
||||
using LehrerApp.Data;
|
||||
using LehrerApp.Desktop.Services;
|
||||
using LehrerApp.Desktop.ViewModels.Planning;
|
||||
using LehrerApp.Sync;
|
||||
using LehrerApp.Sync.Crypto;
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Globalization;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace LehrerApp.Desktop.ViewModels.Settings;
|
||||
|
||||
public partial class SettingsViewModel
|
||||
{
|
||||
// ── Schulweiter Jahresplan (ClassyPlan-iCal) ─────────────────────────────
|
||||
|
||||
[ObservableProperty] private bool _annualPlanIsConfigured;
|
||||
[ObservableProperty] private string _annualPlanIcalUrlInput = "";
|
||||
[ObservableProperty] private string _annualPlanUrlError = "";
|
||||
[ObservableProperty] private string _annualPlanStatusDisplay = "";
|
||||
[ObservableProperty] private bool _annualPlanFetchBusy;
|
||||
|
||||
// ── Schulweiter Jahresplan: Laden / Speichern / Entfernen / Jetzt abrufen ─
|
||||
|
||||
private void LoadAnnualPlanSettings()
|
||||
{
|
||||
AnnualPlanIsConfigured = _annualPlanSettings.IsConfigured;
|
||||
AnnualPlanStatusDisplay = _annualPlanSettings.LastSyncAt is { } at
|
||||
? $"Letzter Abgleich: {at.ToLocalTime():dd.MM.yyyy HH:mm} — {_annualPlanSettings.LastSyncStatus}"
|
||||
: "Noch kein Abgleich durchgeführt.";
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void AnnualPlanSaveUrl()
|
||||
{
|
||||
AnnualPlanUrlError = "";
|
||||
if (string.IsNullOrWhiteSpace(AnnualPlanIcalUrlInput))
|
||||
{
|
||||
AnnualPlanUrlError = "iCal-URL erforderlich.";
|
||||
return;
|
||||
}
|
||||
if (!Uri.TryCreate(AnnualPlanIcalUrlInput, UriKind.Absolute, out var uri) ||
|
||||
uri.Scheme is not ("http" or "https"))
|
||||
{
|
||||
AnnualPlanUrlError = "Ungültige HTTP(S)-URL.";
|
||||
return;
|
||||
}
|
||||
|
||||
_annualPlanSettings.SetIcalUrl(AnnualPlanIcalUrlInput.Trim());
|
||||
_annualPlanSettings.SetEnabled(true);
|
||||
AnnualPlanIcalUrlInput = "";
|
||||
AppBootstrapper.RestartApplication();
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void AnnualPlanRemove()
|
||||
{
|
||||
_annualPlanSync?.Clear();
|
||||
_annualPlanSettings.ClearIcalUrl();
|
||||
LoadAnnualPlanSettings();
|
||||
AppBootstrapper.RestartApplication();
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private async Task AnnualPlanFetchNow()
|
||||
{
|
||||
if (_annualPlanSync is null)
|
||||
{
|
||||
AnnualPlanStatusDisplay = "Abgleich nicht aktiv — App neu starten.";
|
||||
return;
|
||||
}
|
||||
|
||||
AnnualPlanFetchBusy = true;
|
||||
try
|
||||
{
|
||||
await _annualPlanSync.PollAsync();
|
||||
LoadAnnualPlanSettings();
|
||||
}
|
||||
finally
|
||||
{
|
||||
AnnualPlanFetchBusy = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using LehrerApp.Core.Interfaces;
|
||||
using LehrerApp.Core.Models;
|
||||
using LehrerApp.Core.Services;
|
||||
using LehrerApp.Data;
|
||||
using LehrerApp.Desktop.Services;
|
||||
using LehrerApp.Desktop.ViewModels.Planning;
|
||||
using LehrerApp.Sync;
|
||||
using LehrerApp.Sync.Crypto;
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Globalization;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace LehrerApp.Desktop.ViewModels.Settings;
|
||||
|
||||
public partial class SettingsViewModel
|
||||
{
|
||||
// ── Sicherheit: App-Sperre nach Inaktivität (13.3.5) ─────────────────────
|
||||
|
||||
[ObservableProperty] private bool _appLockEnabled;
|
||||
[ObservableProperty] private int _appLockTimeoutMinutes = 10;
|
||||
[ObservableProperty] private string _appLockNewPassword = "";
|
||||
[ObservableProperty] private string _appLockNewPasswordConfirm = "";
|
||||
[ObservableProperty] private string _appLockPasswordError = "";
|
||||
[ObservableProperty] private string _appLockStatus = "";
|
||||
|
||||
public bool AppLockHasPassword => _appLock.HasPassword;
|
||||
|
||||
/// Vom Code-Behind gesetzt: aktualisiert den laufenden Inaktivitäts-Timer sofort,
|
||||
/// ohne dass die App neu gestartet werden muss.
|
||||
public Action? OnAppLockChanged { get; set; }
|
||||
|
||||
// ── App-Sperre: Speichern ─────────────────────────────────────────────────
|
||||
|
||||
[RelayCommand]
|
||||
private void SaveAppLockSettings()
|
||||
{
|
||||
AppLockPasswordError = "";
|
||||
var valid = true;
|
||||
var wantsNewPassword = !string.IsNullOrWhiteSpace(AppLockNewPassword) || !string.IsNullOrWhiteSpace(AppLockNewPasswordConfirm);
|
||||
|
||||
if (AppLockEnabled && !_appLock.HasPassword && !wantsNewPassword)
|
||||
{
|
||||
AppLockPasswordError = "Bitte ein Passwort für die App-Sperre festlegen.";
|
||||
valid = false;
|
||||
}
|
||||
else if (wantsNewPassword)
|
||||
{
|
||||
if (AppLockNewPassword != AppLockNewPasswordConfirm)
|
||||
{
|
||||
AppLockPasswordError = "Passwörter stimmen nicht überein.";
|
||||
valid = false;
|
||||
}
|
||||
else if (AppLockNewPassword.Length < 4)
|
||||
{
|
||||
AppLockPasswordError = "Mindestens 4 Zeichen.";
|
||||
valid = false;
|
||||
}
|
||||
}
|
||||
|
||||
if (!valid) return;
|
||||
|
||||
if (wantsNewPassword) _appLock.SetPassword(AppLockNewPassword.Trim());
|
||||
_appLock.Configure(AppLockEnabled, AppLockTimeoutMinutes);
|
||||
|
||||
AppLockNewPassword = ""; AppLockNewPasswordConfirm = "";
|
||||
OnPropertyChanged(nameof(AppLockHasPassword));
|
||||
AppLockStatus = "Gespeichert.";
|
||||
OnAppLockChanged?.Invoke();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using LehrerApp.Core.Interfaces;
|
||||
using LehrerApp.Core.Models;
|
||||
using LehrerApp.Core.Services;
|
||||
using LehrerApp.Data;
|
||||
using LehrerApp.Desktop.Services;
|
||||
using LehrerApp.Desktop.ViewModels.Planning;
|
||||
using LehrerApp.Sync;
|
||||
using LehrerApp.Sync.Crypto;
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Globalization;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace LehrerApp.Desktop.ViewModels.Settings;
|
||||
|
||||
public partial class SettingsViewModel
|
||||
{
|
||||
// ── Darstellung (12.4) ──────────────────────────────────────────────────────
|
||||
|
||||
[ObservableProperty] private AppTheme _selectedTheme;
|
||||
public string[] ThemeOptions { get; } = AppThemeDisplay.Options;
|
||||
public string SelectedThemeName
|
||||
{
|
||||
get => AppThemeDisplay.Label(SelectedTheme);
|
||||
set => SelectedTheme = AppThemeDisplay.FromLabel(value);
|
||||
}
|
||||
|
||||
/// Vom Code-Behind gesetzt: wendet die Theme-Variante sofort an (Application.Current, siehe
|
||||
/// App.ApplyTheme) — ViewModels fassen Avalonia-Framework-Typen nicht direkt an, gleiches
|
||||
/// Muster wie die übrigen Code-Behind-Hooks in dieser Klasse.
|
||||
public Action<AppTheme>? OnThemeChanged { get; set; }
|
||||
|
||||
partial void OnSelectedThemeChanged(AppTheme value)
|
||||
{
|
||||
_appearance.Save(value);
|
||||
OnThemeChanged?.Invoke(value);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using LehrerApp.Core.Interfaces;
|
||||
using LehrerApp.Core.Models;
|
||||
using LehrerApp.Core.Services;
|
||||
using LehrerApp.Data;
|
||||
using LehrerApp.Desktop.Services;
|
||||
using LehrerApp.Desktop.ViewModels.Planning;
|
||||
using LehrerApp.Sync;
|
||||
using LehrerApp.Sync.Crypto;
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Globalization;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace LehrerApp.Desktop.ViewModels.Settings;
|
||||
|
||||
public partial class SettingsViewModel
|
||||
{
|
||||
// ── Sicherheit: Datensicherung (13.3.1/13.3.2) ───────────────────────────
|
||||
|
||||
[ObservableProperty] private string _backupStatus = "";
|
||||
[ObservableProperty] private string _secondaryBackupDirectory = "";
|
||||
public ObservableCollection<BackupListItem> Backups { get; } = [];
|
||||
|
||||
/// Vom Code-Behind gesetzt: zeigt einen Bestätigungsdialog vor dem Wiederherstellen.
|
||||
public Func<BackupListItem, Task<bool>>? OnConfirmRestore { get; set; }
|
||||
|
||||
/// Vom Code-Behind gesetzt: öffnet einen Ordnerauswahl-Dialog für das zweite Sicherungsziel.
|
||||
public Func<Task<string?>>? OnPickBackupDirectory { get; set; }
|
||||
|
||||
// ── Datensicherung: Laden / Erstellen / Wiederherstellen ─────────────────
|
||||
|
||||
private void LoadBackups()
|
||||
{
|
||||
Backups.Clear();
|
||||
foreach (var b in _backups.ListBackups()) Backups.Add(new BackupListItem(b));
|
||||
SecondaryBackupDirectory = _backupSettings.LoadSecondaryDirectory() ?? "";
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void CreateBackupNow()
|
||||
{
|
||||
var path = _backups.CreateBackup(AppBootstrapper.DbPath);
|
||||
LoadBackups();
|
||||
if (path is null) { BackupStatus = "Kein Backup erstellt (Datenbankdatei fehlt)."; return; }
|
||||
|
||||
BackupStatus = _dbEncryption.CanOpenAndRead(path, AppBootstrapper.DbPassword)
|
||||
? "Backup erstellt und geprüft."
|
||||
: "Backup erstellt — Prüfung fehlgeschlagen, Datei könnte beschädigt sein!";
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private async Task PickBackupDirectory()
|
||||
{
|
||||
if (OnPickBackupDirectory is null) return;
|
||||
var path = await OnPickBackupDirectory();
|
||||
if (path is null) return;
|
||||
|
||||
_backupSettings.SaveSecondaryDirectory(path);
|
||||
_backups.SecondaryBackupDirectory = path;
|
||||
SecondaryBackupDirectory = path;
|
||||
BackupStatus = "Zweiter Sicherungsordner gespeichert.";
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void ClearBackupDirectory()
|
||||
{
|
||||
_backupSettings.SaveSecondaryDirectory(null);
|
||||
_backups.SecondaryBackupDirectory = null;
|
||||
SecondaryBackupDirectory = "";
|
||||
BackupStatus = "Zweiter Sicherungsordner entfernt.";
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private async Task RestoreBackup(BackupListItem? item)
|
||||
{
|
||||
if (item is null) return;
|
||||
if (OnConfirmRestore is not null && !await OnConfirmRestore(item)) return;
|
||||
|
||||
_dbContext.Dispose();
|
||||
_backups.RestoreBackup(item.Path, AppBootstrapper.DbPath);
|
||||
AppBootstrapper.RestartApplication();
|
||||
}
|
||||
}
|
||||
|
||||
public class BackupListItem(BackupInfo info)
|
||||
{
|
||||
public string Path { get; } = info.Path;
|
||||
public string Display { get; } = $"{info.CreatedAt:dd.MM.yyyy HH:mm} · {info.SizeBytes / 1024.0:0} KB";
|
||||
}
|
||||
@@ -0,0 +1,251 @@
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using LehrerApp.Core.Interfaces;
|
||||
using LehrerApp.Core.Models;
|
||||
using LehrerApp.Core.Services;
|
||||
using LehrerApp.Data;
|
||||
using LehrerApp.Desktop.Services;
|
||||
using LehrerApp.Desktop.ViewModels.Planning;
|
||||
using LehrerApp.Sync;
|
||||
using LehrerApp.Sync.Crypto;
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Globalization;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace LehrerApp.Desktop.ViewModels.Settings;
|
||||
|
||||
public partial class SettingsViewModel
|
||||
{
|
||||
// ── Kompetenzkatalog ──────────────────────────────────────────────────────
|
||||
|
||||
[ObservableProperty] private SubjectListItem? _catalogSubject;
|
||||
[ObservableProperty] private int _catalogGradeLevel = 10;
|
||||
[ObservableProperty] private string _newDomainName = "";
|
||||
[ObservableProperty] private string _newDomainCode = "";
|
||||
[ObservableProperty] private string _catalogValidation = "";
|
||||
|
||||
public ObservableCollection<DomainEditItem> Domains { get; } = [];
|
||||
|
||||
// ── Katalog: Laden ────────────────────────────────────────────────────────
|
||||
|
||||
partial void OnCatalogSubjectChanged(SubjectListItem? value) => LoadCatalog();
|
||||
partial void OnCatalogGradeLevelChanged(int value) => LoadCatalog();
|
||||
|
||||
private void LoadCatalog()
|
||||
{
|
||||
Domains.Clear();
|
||||
CatalogValidation = "";
|
||||
if (CatalogSubject is null) return;
|
||||
foreach (var d in _domainRepo.GetBySubjectAndGrade(CatalogSubject.Id, CatalogGradeLevel))
|
||||
Domains.Add(CreateDomainEditItem(d));
|
||||
RefreshDomainMoveState();
|
||||
}
|
||||
|
||||
// ── Katalog: Bereich hinzufügen / löschen ────────────────────────────────
|
||||
|
||||
[RelayCommand]
|
||||
private void AddDomain()
|
||||
{
|
||||
if (CatalogSubject is null) { CatalogValidation = "Bitte zuerst ein Fach auswählen."; return; }
|
||||
if (string.IsNullOrWhiteSpace(NewDomainName)) { CatalogValidation = "Bereichsname erforderlich."; return; }
|
||||
|
||||
var domain = new CompetencyDomain
|
||||
{
|
||||
SubjectId = CatalogSubject.Id,
|
||||
GradeLevel = CatalogGradeLevel,
|
||||
Name = NewDomainName.Trim(),
|
||||
Code = NewDomainCode.Trim(),
|
||||
SortOrder = Domains.Count,
|
||||
};
|
||||
_domainRepo.Save(domain);
|
||||
Domains.Add(CreateDomainEditItem(domain));
|
||||
RefreshDomainMoveState();
|
||||
NewDomainName = ""; NewDomainCode = ""; CatalogValidation = "";
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void DeleteDomain(DomainEditItem? item)
|
||||
{
|
||||
if (item is null) return;
|
||||
_domainRepo.Delete(item.Id);
|
||||
Domains.Remove(item);
|
||||
PersistDomainOrder();
|
||||
}
|
||||
|
||||
private DomainEditItem CreateDomainEditItem(CompetencyDomain domain) =>
|
||||
new(domain, _domainRepo, item => MoveDomain(item, -1), item => MoveDomain(item, 1));
|
||||
|
||||
private void MoveDomain(DomainEditItem item, int offset)
|
||||
{
|
||||
var oldIndex = Domains.IndexOf(item);
|
||||
var newIndex = oldIndex + offset;
|
||||
if (oldIndex < 0 || newIndex < 0 || newIndex >= Domains.Count) return;
|
||||
Domains.Move(oldIndex, newIndex);
|
||||
PersistDomainOrder();
|
||||
}
|
||||
|
||||
private void PersistDomainOrder()
|
||||
{
|
||||
for (var i = 0; i < Domains.Count; i++) Domains[i].SetSortOrder(i);
|
||||
RefreshDomainMoveState();
|
||||
}
|
||||
|
||||
private void RefreshDomainMoveState()
|
||||
{
|
||||
for (var i = 0; i < Domains.Count; i++)
|
||||
Domains[i].SetMoveState(i > 0, i < Domains.Count - 1);
|
||||
}
|
||||
}
|
||||
|
||||
// ── DomainEditItem ────────────────────────────────────────────────────────────
|
||||
|
||||
public partial class DomainEditItem : ObservableObject
|
||||
{
|
||||
private readonly CompetencyDomain _domain;
|
||||
private readonly ICompetencyDomainRepository _repo;
|
||||
|
||||
public Guid Id { get; }
|
||||
public string Name { get; }
|
||||
public string Code { get; }
|
||||
public string DisplayName { get; }
|
||||
|
||||
[ObservableProperty] private string _newItemCode = "";
|
||||
[ObservableProperty] private string _newItemDesc = "";
|
||||
|
||||
public ObservableCollection<CompetencyItemVm> Items { get; } = [];
|
||||
public IRelayCommand MoveUpCommand { get; }
|
||||
public IRelayCommand MoveDownCommand { get; }
|
||||
|
||||
public DomainEditItem(CompetencyDomain domain, ICompetencyDomainRepository repo,
|
||||
Action<DomainEditItem>? onMoveUp = null, Action<DomainEditItem>? onMoveDown = null)
|
||||
{
|
||||
_domain = domain;
|
||||
_repo = repo;
|
||||
Id = domain.Id;
|
||||
Name = domain.Name;
|
||||
Code = domain.Code;
|
||||
DisplayName = string.IsNullOrEmpty(domain.Code)
|
||||
? domain.Name
|
||||
: $"{domain.Name} ({domain.Code})";
|
||||
|
||||
MoveUpCommand = new RelayCommand(() => onMoveUp?.Invoke(this), () => _canMoveUp);
|
||||
MoveDownCommand = new RelayCommand(() => onMoveDown?.Invoke(this), () => _canMoveDown);
|
||||
|
||||
_domain.Items = domain.Items.OrderBy(i => i.SortOrder).ToList();
|
||||
foreach (var item in _domain.Items)
|
||||
Items.Add(CreateItemViewModel(item));
|
||||
RefreshItemMoveState();
|
||||
}
|
||||
|
||||
private bool _canMoveUp;
|
||||
private bool _canMoveDown;
|
||||
|
||||
internal void SetMoveState(bool canMoveUp, bool canMoveDown)
|
||||
{
|
||||
_canMoveUp = canMoveUp;
|
||||
_canMoveDown = canMoveDown;
|
||||
MoveUpCommand.NotifyCanExecuteChanged();
|
||||
MoveDownCommand.NotifyCanExecuteChanged();
|
||||
}
|
||||
|
||||
internal void SetSortOrder(int sortOrder)
|
||||
{
|
||||
if (_domain.SortOrder == sortOrder) return;
|
||||
_domain.SortOrder = sortOrder;
|
||||
_repo.Save(_domain);
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void AddItem()
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(NewItemDesc)) return;
|
||||
var item = new CompetencyItem
|
||||
{
|
||||
Code = NewItemCode.Trim(),
|
||||
Description = NewItemDesc.Trim(),
|
||||
SortOrder = _domain.Items.Count,
|
||||
};
|
||||
_domain.Items.Add(item);
|
||||
_repo.Save(_domain);
|
||||
Items.Add(CreateItemViewModel(item));
|
||||
RefreshItemMoveState();
|
||||
NewItemCode = ""; NewItemDesc = "";
|
||||
}
|
||||
|
||||
private void DeleteItem(CompetencyItemVm vm)
|
||||
{
|
||||
_domain.Items.RemoveAll(i => i.Id == vm.ItemId);
|
||||
Items.Remove(vm);
|
||||
PersistItemOrder();
|
||||
}
|
||||
|
||||
private CompetencyItemVm CreateItemViewModel(CompetencyItem item) =>
|
||||
new(item, DeleteItem, vm => MoveItem(vm, -1), vm => MoveItem(vm, 1));
|
||||
|
||||
private void MoveItem(CompetencyItemVm item, int offset)
|
||||
{
|
||||
var oldIndex = Items.IndexOf(item);
|
||||
var newIndex = oldIndex + offset;
|
||||
if (oldIndex < 0 || newIndex < 0 || newIndex >= Items.Count) return;
|
||||
Items.Move(oldIndex, newIndex);
|
||||
PersistItemOrder();
|
||||
}
|
||||
|
||||
private void PersistItemOrder()
|
||||
{
|
||||
_domain.Items = Items.Select(x => x.Model).ToList();
|
||||
for (var i = 0; i < _domain.Items.Count; i++) _domain.Items[i].SortOrder = i;
|
||||
_repo.Save(_domain);
|
||||
RefreshItemMoveState();
|
||||
}
|
||||
|
||||
private void RefreshItemMoveState()
|
||||
{
|
||||
for (var i = 0; i < Items.Count; i++)
|
||||
Items[i].SetMoveState(i > 0, i < Items.Count - 1);
|
||||
}
|
||||
}
|
||||
|
||||
// ── CompetencyItemVm ──────────────────────────────────────────────────────────
|
||||
|
||||
public class CompetencyItemVm
|
||||
{
|
||||
internal CompetencyItem Model { get; }
|
||||
public Guid ItemId { get; }
|
||||
public string Code { get; }
|
||||
public string Description { get; }
|
||||
public string Display { get; }
|
||||
public IRelayCommand DeleteCommand { get; }
|
||||
public IRelayCommand MoveUpCommand { get; }
|
||||
public IRelayCommand MoveDownCommand { get; }
|
||||
|
||||
public CompetencyItemVm(CompetencyItem item, Action<CompetencyItemVm> onDelete,
|
||||
Action<CompetencyItemVm>? onMoveUp = null, Action<CompetencyItemVm>? onMoveDown = null)
|
||||
{
|
||||
Model = item;
|
||||
ItemId = item.Id;
|
||||
Code = item.Code;
|
||||
Description = item.Description;
|
||||
Display = string.IsNullOrEmpty(item.Code)
|
||||
? item.Description
|
||||
: $"[{item.Code}] {item.Description}";
|
||||
DeleteCommand = new RelayCommand(() => onDelete(this));
|
||||
MoveUpCommand = new RelayCommand(() => onMoveUp?.Invoke(this), () => _canMoveUp);
|
||||
MoveDownCommand = new RelayCommand(() => onMoveDown?.Invoke(this), () => _canMoveDown);
|
||||
}
|
||||
|
||||
private bool _canMoveUp;
|
||||
private bool _canMoveDown;
|
||||
|
||||
internal void SetMoveState(bool canMoveUp, bool canMoveDown)
|
||||
{
|
||||
_canMoveUp = canMoveUp;
|
||||
_canMoveDown = canMoveDown;
|
||||
MoveUpCommand.NotifyCanExecuteChanged();
|
||||
MoveDownCommand.NotifyCanExecuteChanged();
|
||||
}
|
||||
}
|
||||
|
||||
// ── GradingKeyTemplateEditItem (1.3.2) ─────────────────────────────────────────
|
||||
@@ -0,0 +1,111 @@
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using LehrerApp.Core.Interfaces;
|
||||
using LehrerApp.Core.Models;
|
||||
using LehrerApp.Core.Services;
|
||||
using LehrerApp.Data;
|
||||
using LehrerApp.Desktop.Services;
|
||||
using LehrerApp.Desktop.ViewModels.Planning;
|
||||
using LehrerApp.Sync;
|
||||
using LehrerApp.Sync.Crypto;
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Globalization;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace LehrerApp.Desktop.ViewModels.Settings;
|
||||
|
||||
public partial class SettingsViewModel
|
||||
{
|
||||
// ── Geräte-Pairing (10.3.1: Schlüsselübertragung auf ein zweites Gerät) ───
|
||||
//
|
||||
// SnapshotService ist nur registriert, wenn bereits eine Server-URL konfiguriert ist (siehe
|
||||
// AppBootstrapper) — daher optional/nullable statt eines Pflicht-Konstruktorparameters.
|
||||
|
||||
[ObservableProperty] private string _pairingCode = "";
|
||||
[ObservableProperty] private string _pairingCodeInput = "";
|
||||
[ObservableProperty] private string _pairingStatus = "";
|
||||
[ObservableProperty] private bool _pairingBusy;
|
||||
|
||||
/// Vom Code-Behind gesetzt: zeigt einen Bestätigungsdialog, bevor die lokale Datenbank durch
|
||||
/// den Stand des anderen Geräts ersetzt wird.
|
||||
public Func<Task<bool>>? OnConfirmPairingRestore { get; set; }
|
||||
|
||||
/// Vom Code-Behind gesetzt: zeigt einen Fehlerdialog, den der Nutzer aktiv wegklicken muss —
|
||||
/// notwendig, weil RedeemPairingCode() nach einem Fehlschlag sofort neu startet (_dbContext ist
|
||||
/// zu dem Zeitpunkt bereits disposed, siehe dort) und PairingStatus dadurch sonst nie sichtbar
|
||||
/// gerendert würde.
|
||||
public Func<string, Task>? OnShowPairingError { get; set; }
|
||||
|
||||
// ── Geräte-Pairing: Code erzeugen / einlösen ─────────────────────────────
|
||||
//
|
||||
// CreateAndUploadAsync lädt einen verschlüsselten Snapshot der lokalen Datenbank samt
|
||||
// Sync-Schlüssel hoch, RestoreFromCodeAsync ersetzt auf dem ZWEITEN Gerät die dortige
|
||||
// Datenbank vollständig durch diesen Snapshot (vorheriger Stand wird automatisch als
|
||||
// .backup-Datei gesichert, siehe SnapshotService) — deshalb vor dem Einlösen ein
|
||||
// Bestätigungsdialog wie bei RestoreBackup.
|
||||
|
||||
[RelayCommand]
|
||||
private async Task CreatePairingCode()
|
||||
{
|
||||
if (_snapshotService is null) return;
|
||||
PairingBusy = true;
|
||||
PairingCode = "";
|
||||
PairingStatus = "";
|
||||
void OnProgress(SnapshotProgress p) => PairingStatus = p.Message;
|
||||
_snapshotService.ProgressChanged += OnProgress;
|
||||
try
|
||||
{
|
||||
var result = await _snapshotService.CreateAndUploadAsync();
|
||||
PairingCode = result.Code;
|
||||
PairingStatus = $"Gültig bis {result.ExpiresAt:dd.MM.yyyy HH:mm} — auf dem anderen Gerät eingeben.";
|
||||
}
|
||||
catch (Exception ex) when (ex is HttpRequestException or InvalidOperationException)
|
||||
{
|
||||
PairingStatus = $"Fehlgeschlagen: {ex.Message}";
|
||||
}
|
||||
finally
|
||||
{
|
||||
_snapshotService.ProgressChanged -= OnProgress;
|
||||
PairingBusy = false;
|
||||
}
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private async Task RedeemPairingCode()
|
||||
{
|
||||
if (_snapshotService is null) return;
|
||||
if (string.IsNullOrWhiteSpace(PairingCodeInput)) { PairingStatus = "Bitte Code eingeben."; return; }
|
||||
if (OnConfirmPairingRestore is not null && !await OnConfirmPairingRestore()) return;
|
||||
|
||||
PairingBusy = true;
|
||||
PairingStatus = "";
|
||||
void OnProgress(SnapshotProgress p) => PairingStatus = p.Message;
|
||||
_snapshotService.ProgressChanged += OnProgress;
|
||||
|
||||
// LiteDB hält beim Öffnen einen exklusiven Dateilock — muss vor dem Überschreiben
|
||||
// geschlossen sein. Läuft danach ein Fehler (falscher Code, Server nicht erreichbar), ist
|
||||
// _dbContext bereits disposed und die App nicht mehr sicher weiter benutzbar, ohne dass
|
||||
// die Datenbankdatei selbst angefasst wurde (RestoreFromCodeAsync schreibt sie erst ganz
|
||||
// am Ende) — deshalb in JEDEM Fall (Erfolg wie Fehlschlag) neu starten, nicht nur bei
|
||||
// Erfolg. Ein Neustart öffnet dann wieder dieselbe, unveränderte Datenbank.
|
||||
_dbContext.Dispose();
|
||||
try
|
||||
{
|
||||
await _snapshotService.RestoreFromCodeAsync(PairingCodeInput.Trim(), AppBootstrapper.DbPath);
|
||||
}
|
||||
catch (Exception ex) when (ex is SnapshotNotFoundException or InvalidOperationException or HttpRequestException)
|
||||
{
|
||||
// _dbContext ist bereits disposed, PairingStatus ist aber ein reines ViewModel-Feld
|
||||
// ohne DB-Zugriff - das Setzen ist unabhängig davon noch sicher.
|
||||
PairingStatus = $"Fehlgeschlagen: {ex.Message}";
|
||||
_logger.Error("Geräte-Pairing (Redeem) fehlgeschlagen", ex);
|
||||
// Ohne diesen Dialog würde PairingStatus nie gerendert: der direkt folgende Neustart
|
||||
// (unten, auch im Fehlerfall nötig, siehe Kommentar oben) beendet den Prozess, bevor
|
||||
// Avalonia den nächsten Frame zeichnen könnte.
|
||||
if (OnShowPairingError is not null) await OnShowPairingError(ex.Message);
|
||||
}
|
||||
AppBootstrapper.RestartApplication();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using LehrerApp.Core.Interfaces;
|
||||
using LehrerApp.Core.Models;
|
||||
using LehrerApp.Core.Services;
|
||||
using LehrerApp.Data;
|
||||
using LehrerApp.Desktop.Services;
|
||||
using LehrerApp.Desktop.ViewModels.Planning;
|
||||
using LehrerApp.Sync;
|
||||
using LehrerApp.Sync.Crypto;
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Globalization;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace LehrerApp.Desktop.ViewModels.Settings;
|
||||
|
||||
public partial class SettingsViewModel
|
||||
{
|
||||
// ── Sicherheit: Datenbank-Verschlüsselung (13.3.4) ───────────────────────
|
||||
|
||||
[ObservableProperty] private bool _isDbEncrypted;
|
||||
[ObservableProperty] private string _currentDbPassword = "";
|
||||
[ObservableProperty] private string _newDbPassword = "";
|
||||
[ObservableProperty] private string _newDbPasswordConfirm = "";
|
||||
[ObservableProperty] private string _dbPasswordError = "";
|
||||
|
||||
public string DbEncryptionStatusLabel => IsDbEncrypted ? "verschlüsselt" : "nicht verschlüsselt";
|
||||
|
||||
partial void OnIsDbEncryptedChanged(bool value) => OnPropertyChanged(nameof(DbEncryptionStatusLabel));
|
||||
|
||||
// ── Datenbank-Verschlüsselung: Setzen / Ändern / Entfernen ───────────────
|
||||
|
||||
[RelayCommand]
|
||||
private void SaveDbPassword()
|
||||
{
|
||||
DbPasswordError = "";
|
||||
var valid = true;
|
||||
var wantsPassword = !string.IsNullOrWhiteSpace(NewDbPassword) || !string.IsNullOrWhiteSpace(NewDbPasswordConfirm);
|
||||
|
||||
if (IsDbEncrypted && !_dbEncryption.VerifyPassword(AppBootstrapper.DbPath, CurrentDbPassword))
|
||||
{
|
||||
DbPasswordError = "Aktuelles Passwort ist falsch.";
|
||||
valid = false;
|
||||
}
|
||||
|
||||
if (!wantsPassword)
|
||||
{
|
||||
DbPasswordError = "Bitte ein neues Passwort eingeben.";
|
||||
valid = false;
|
||||
}
|
||||
else if (NewDbPassword != NewDbPasswordConfirm)
|
||||
{
|
||||
DbPasswordError = "Neue Passwörter stimmen nicht überein.";
|
||||
valid = false;
|
||||
}
|
||||
else if (NewDbPassword.Length < 4)
|
||||
{
|
||||
DbPasswordError = "Mindestens 4 Zeichen.";
|
||||
valid = false;
|
||||
}
|
||||
|
||||
if (!valid) return;
|
||||
|
||||
_dbContext.Dispose();
|
||||
_dbEncryption.SetPassword(AppBootstrapper.DbPath, IsDbEncrypted ? CurrentDbPassword : null, NewDbPassword.Trim());
|
||||
AppBootstrapper.RestartApplication();
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void RemoveDbPassword()
|
||||
{
|
||||
DbPasswordError = "";
|
||||
if (!_dbEncryption.VerifyPassword(AppBootstrapper.DbPath, CurrentDbPassword))
|
||||
{
|
||||
DbPasswordError = "Aktuelles Passwort ist falsch.";
|
||||
return;
|
||||
}
|
||||
|
||||
_dbContext.Dispose();
|
||||
_dbEncryption.SetPassword(AppBootstrapper.DbPath, CurrentDbPassword, null);
|
||||
AppBootstrapper.RestartApplication();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using LehrerApp.Core.Interfaces;
|
||||
using LehrerApp.Core.Models;
|
||||
using LehrerApp.Core.Services;
|
||||
using LehrerApp.Data;
|
||||
using LehrerApp.Desktop.Services;
|
||||
using LehrerApp.Desktop.ViewModels.Planning;
|
||||
using LehrerApp.Sync;
|
||||
using LehrerApp.Sync.Crypto;
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Globalization;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace LehrerApp.Desktop.ViewModels.Settings;
|
||||
|
||||
public partial class SettingsViewModel
|
||||
{
|
||||
// ── Notenschlüssel-Vorlagen (1.3.2) ──────────────────────────────────────
|
||||
|
||||
[ObservableProperty] private string _newTemplateName = "";
|
||||
[ObservableProperty] private string _newTemplateGradingSystemName = "Noten 1–6";
|
||||
[ObservableProperty] private string _newTemplateNameError = "";
|
||||
|
||||
public List<string> GradingSystemOptions { get; } = ["Noten 1–6", "Punkte 0–15"];
|
||||
public ObservableCollection<GradingKeyTemplateEditItem> GradingKeyTemplateList { get; } = [];
|
||||
|
||||
// ── Notenschlüssel-Vorlagen: Laden / Hinzufügen / Löschen ────────────────
|
||||
|
||||
private void LoadGradingKeyTemplates()
|
||||
{
|
||||
GradingKeyTemplateList.Clear();
|
||||
foreach (var t in _gradingKeyTemplates.GetAll())
|
||||
GradingKeyTemplateList.Add(new GradingKeyTemplateEditItem(t, _gradingKeyTemplates, _grading));
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void AddGradingKeyTemplate()
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(NewTemplateName)) { NewTemplateNameError = "Vorlagenname erforderlich."; return; }
|
||||
|
||||
var system = NewTemplateGradingSystemName == "Punkte 0–15"
|
||||
? GradingSystem.Points0To15 : GradingSystem.Grades1To6;
|
||||
var defaults = system == GradingSystem.Grades1To6
|
||||
? GradingService.DefaultKey1To6() : GradingService.DefaultKey0To15();
|
||||
|
||||
var template = new GradingKeyTemplate
|
||||
{
|
||||
Name = NewTemplateName.Trim(),
|
||||
GradingSystem = system,
|
||||
Entries = defaults.Select(e => new GradingKeyEntry { Grade = e.Grade, MinPercent = e.MinPercent }).ToList(),
|
||||
};
|
||||
_gradingKeyTemplates.Save(template);
|
||||
GradingKeyTemplateList.Add(new GradingKeyTemplateEditItem(template, _gradingKeyTemplates, _grading));
|
||||
NewTemplateName = "";
|
||||
NewTemplateNameError = "";
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void DeleteGradingKeyTemplate(GradingKeyTemplateEditItem? item)
|
||||
{
|
||||
if (item is null) return;
|
||||
_gradingKeyTemplates.Delete(item.Id);
|
||||
GradingKeyTemplateList.Remove(item);
|
||||
}
|
||||
}
|
||||
|
||||
public partial class GradingKeyTemplateEditItem : ObservableObject
|
||||
{
|
||||
private readonly GradingKeyTemplate _template;
|
||||
private readonly IGradingKeyTemplateRepository _repo;
|
||||
private readonly GradingService _grading;
|
||||
|
||||
public Guid Id { get; }
|
||||
public string Name { get; }
|
||||
public string GradingSystemLabel { get; }
|
||||
|
||||
[ObservableProperty] private string _newGrade = "";
|
||||
[ObservableProperty] private double _newMinPercent;
|
||||
[ObservableProperty] private string _newGradeError = "";
|
||||
[ObservableProperty] private string _newMinPercentError = "";
|
||||
[ObservableProperty] private string _completenessWarning = "";
|
||||
|
||||
public ObservableCollection<GradingKeyEntryVm> Entries { get; } = [];
|
||||
|
||||
public GradingKeyTemplateEditItem(GradingKeyTemplate template, IGradingKeyTemplateRepository repo,
|
||||
GradingService grading)
|
||||
{
|
||||
_template = template; _repo = repo; _grading = grading;
|
||||
Id = template.Id;
|
||||
Name = template.Name;
|
||||
GradingSystemLabel = template.GradingSystem == GradingSystem.Grades1To6
|
||||
? "Noten 1–6" : "Punkte 0–15";
|
||||
|
||||
foreach (var e in template.Entries.OrderByDescending(e => e.MinPercent))
|
||||
Entries.Add(new GradingKeyEntryVm(e, DeleteEntry));
|
||||
RecomputeCompleteness();
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void AddEntry()
|
||||
{
|
||||
NewGradeError = ""; NewMinPercentError = "";
|
||||
var valid = true;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(NewGrade)) { NewGradeError = "Bezeichnung erforderlich."; valid = false; }
|
||||
if (NewMinPercent is < 0 or > 100) { NewMinPercentError = "Muss zwischen 0 und 100 liegen."; valid = false; }
|
||||
else if (_template.Entries.Any(e => Math.Abs(e.MinPercent - NewMinPercent) < 0.0001))
|
||||
{ NewMinPercentError = "Diese Prozentgrenze existiert bereits."; valid = false; }
|
||||
|
||||
if (!valid) return;
|
||||
|
||||
var entry = new GradingKeyEntry { Grade = NewGrade.Trim(), MinPercent = NewMinPercent };
|
||||
_template.Entries.Add(entry);
|
||||
_template.Entries = _template.Entries.OrderByDescending(e => e.MinPercent).ToList();
|
||||
_repo.Save(_template);
|
||||
|
||||
Entries.Clear();
|
||||
foreach (var e in _template.Entries) Entries.Add(new GradingKeyEntryVm(e, DeleteEntry));
|
||||
NewGrade = ""; NewMinPercent = 0;
|
||||
RecomputeCompleteness();
|
||||
}
|
||||
|
||||
private void DeleteEntry(GradingKeyEntryVm vm)
|
||||
{
|
||||
_template.Entries.RemoveAll(e => e.Grade == vm.Grade && Math.Abs(e.MinPercent - vm.MinPercent) < 0.0001);
|
||||
_repo.Save(_template);
|
||||
Entries.Remove(vm);
|
||||
RecomputeCompleteness();
|
||||
}
|
||||
|
||||
private void RecomputeCompleteness() =>
|
||||
CompletenessWarning = _grading.ValidateGradingKey(_template.Entries) ?? "";
|
||||
}
|
||||
|
||||
// ── GradingKeyEntryVm ─────────────────────────────────────────────────────────
|
||||
|
||||
public class GradingKeyEntryVm
|
||||
{
|
||||
public string Grade { get; }
|
||||
public double MinPercent { get; }
|
||||
public string Display { get; }
|
||||
public IRelayCommand DeleteCommand { get; }
|
||||
|
||||
public GradingKeyEntryVm(GradingKeyEntry e, Action<GradingKeyEntryVm> onDelete)
|
||||
{
|
||||
Grade = e.Grade;
|
||||
MinPercent = e.MinPercent;
|
||||
Display = $"{Grade} — ab {MinPercent.ToString("0.##")} %";
|
||||
DeleteCommand = new RelayCommand(() => onDelete(this));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using LehrerApp.Core.Interfaces;
|
||||
using LehrerApp.Core.Models;
|
||||
using LehrerApp.Core.Services;
|
||||
using LehrerApp.Data;
|
||||
using LehrerApp.Desktop.Services;
|
||||
using LehrerApp.Desktop.ViewModels.Planning;
|
||||
using LehrerApp.Sync;
|
||||
using LehrerApp.Sync.Crypto;
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Globalization;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace LehrerApp.Desktop.ViewModels.Settings;
|
||||
|
||||
public partial class SettingsViewModel
|
||||
{
|
||||
// ── Ferien & Feiertage (4.3.5, aus dem Stundenplan hierher verschoben) ───
|
||||
|
||||
[ObservableProperty] private string _selectedStateName = "";
|
||||
[ObservableProperty] private string _newHolidayName = "";
|
||||
[ObservableProperty] private string _newHolidayStartText = "";
|
||||
[ObservableProperty] private string _newHolidayEndText = "";
|
||||
[ObservableProperty] private string _holidayNameError = "";
|
||||
[ObservableProperty] private string _holidayDateError = "";
|
||||
[ObservableProperty] private string _schoolName = "";
|
||||
[ObservableProperty] private string _schoolStreet = "";
|
||||
[ObservableProperty] private string _schoolPostalCode = "";
|
||||
[ObservableProperty] private string _schoolCity = "";
|
||||
[ObservableProperty] private string _schoolNameError = "";
|
||||
[ObservableProperty] private string _schoolStreetError = "";
|
||||
[ObservableProperty] private string _schoolPostalCodeError = "";
|
||||
[ObservableProperty] private string _schoolCityError = "";
|
||||
[ObservableProperty] private string _schoolLocationStatus = "";
|
||||
[ObservableProperty] private string _resolvedSchoolAddress = "";
|
||||
|
||||
public List<string> StateOptions { get; } = GermanStateDisplay.Options.ToList();
|
||||
public ObservableCollection<SchoolHolidayItem> SchoolHolidayEntries { get; } = [];
|
||||
|
||||
// ── Ferien & Feiertage: Bundesland / Schulferien pflegen ─────────────────
|
||||
|
||||
partial void OnSelectedStateNameChanged(string value) =>
|
||||
_calendarSettings.SetState(GermanStateDisplay.FromLabel(value));
|
||||
|
||||
[RelayCommand]
|
||||
private async Task LoadSchoolLocation()
|
||||
{
|
||||
if (_schoolWeather is null) return;
|
||||
try
|
||||
{
|
||||
var location = await _schoolWeather.GetLocationAsync();
|
||||
if (location is null) return;
|
||||
SchoolName = location.SchoolName;
|
||||
SchoolStreet = location.Street;
|
||||
SchoolPostalCode = location.PostalCode;
|
||||
SchoolCity = location.City;
|
||||
SelectedStateName = GermanStateDisplay.Label(location.State);
|
||||
ResolvedSchoolAddress = location.ResolvedAddress;
|
||||
}
|
||||
catch (SchoolWeatherException ex) { SchoolLocationStatus = ex.Message; }
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private async Task SaveSchoolLocation()
|
||||
{
|
||||
SchoolNameError = ""; SchoolStreetError = ""; SchoolPostalCodeError = "";
|
||||
SchoolCityError = ""; SchoolLocationStatus = "";
|
||||
var valid = true;
|
||||
if (string.IsNullOrWhiteSpace(SchoolName))
|
||||
{ SchoolNameError = "Name der Schule erforderlich."; valid = false; }
|
||||
if (string.IsNullOrWhiteSpace(SchoolStreet))
|
||||
{ SchoolStreetError = "Straße und Hausnummer erforderlich."; valid = false; }
|
||||
if (!System.Text.RegularExpressions.Regex.IsMatch(SchoolPostalCode.Trim(), @"^\d{5}$"))
|
||||
{ SchoolPostalCodeError = "Bitte eine fünfstellige PLZ angeben."; valid = false; }
|
||||
if (string.IsNullOrWhiteSpace(SchoolCity))
|
||||
{ SchoolCityError = "Ort erforderlich."; valid = false; }
|
||||
if (!valid) return;
|
||||
if (_schoolWeather is null)
|
||||
{
|
||||
SchoolLocationStatus = "Der Wetterdienst ist nicht verfügbar.";
|
||||
return;
|
||||
}
|
||||
|
||||
SchoolLocationStatus = "Adresse wird geprüft …";
|
||||
try
|
||||
{
|
||||
var profile = await _schoolWeather.SaveLocationAsync(new SchoolLocationRequest
|
||||
{
|
||||
SchoolName = SchoolName.Trim(), Street = SchoolStreet.Trim(),
|
||||
PostalCode = SchoolPostalCode.Trim(), City = SchoolCity.Trim(),
|
||||
State = GermanStateDisplay.FromLabel(SelectedStateName),
|
||||
});
|
||||
ResolvedSchoolAddress = profile.ResolvedAddress;
|
||||
SchoolLocationStatus = "Schulstandort gespeichert. Wetterdaten werden serverseitig abgerufen.";
|
||||
}
|
||||
catch (SchoolWeatherException ex) { SchoolLocationStatus = ex.Message; }
|
||||
}
|
||||
|
||||
private void LoadSchoolHolidays()
|
||||
{
|
||||
SchoolHolidayEntries.Clear();
|
||||
foreach (var h in _schoolHolidays.GetAll().OrderBy(h => h.StartDate))
|
||||
SchoolHolidayEntries.Add(new SchoolHolidayItem(h));
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void AddSchoolHoliday()
|
||||
{
|
||||
HolidayNameError = ""; HolidayDateError = "";
|
||||
var valid = true;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(NewHolidayName)) { HolidayNameError = "Name erforderlich."; valid = false; }
|
||||
|
||||
var hasStart = DateOnly.TryParseExact(NewHolidayStartText, "dd.MM.yyyy", CultureInfo.InvariantCulture,
|
||||
System.Globalization.DateTimeStyles.None, out var start);
|
||||
var hasEnd = DateOnly.TryParseExact(NewHolidayEndText, "dd.MM.yyyy", CultureInfo.InvariantCulture,
|
||||
System.Globalization.DateTimeStyles.None, out var end);
|
||||
|
||||
if (!hasStart || !hasEnd) { HolidayDateError = "Bitte Beginn und Ende im Format TT.MM.JJJJ angeben."; valid = false; }
|
||||
else if (end < start) { HolidayDateError = "Das Ende darf nicht vor dem Beginn liegen."; valid = false; }
|
||||
|
||||
if (!valid) return;
|
||||
|
||||
_schoolHolidays.Save(new SchoolHoliday { Name = NewHolidayName.Trim(), StartDate = start, EndDate = end });
|
||||
NewHolidayName = ""; NewHolidayStartText = ""; NewHolidayEndText = "";
|
||||
LoadSchoolHolidays();
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void RemoveSchoolHoliday(SchoolHolidayItem? item)
|
||||
{
|
||||
if (item is null) return;
|
||||
_schoolHolidays.Delete(item.Id);
|
||||
SchoolHolidayEntries.Remove(item);
|
||||
}
|
||||
}
|
||||
|
||||
public class SchoolHolidayItem(SchoolHoliday h)
|
||||
{
|
||||
public Guid Id { get; } = h.Id;
|
||||
public string Name { get; } = h.Name;
|
||||
public string RangeDisplay { get; } = $"{h.StartDate:dd.MM.yyyy} – {h.EndDate:dd.MM.yyyy}";
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using LehrerApp.Core.Interfaces;
|
||||
using LehrerApp.Core.Models;
|
||||
using LehrerApp.Core.Services;
|
||||
using LehrerApp.Data;
|
||||
using LehrerApp.Desktop.Services;
|
||||
using LehrerApp.Desktop.ViewModels.Planning;
|
||||
using LehrerApp.Sync;
|
||||
using LehrerApp.Sync.Crypto;
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Globalization;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace LehrerApp.Desktop.ViewModels.Settings;
|
||||
|
||||
public partial class SettingsViewModel
|
||||
{
|
||||
// ── JSON Import / Export ──────────────────────────────────────────────────
|
||||
|
||||
public CompetencyCatalogImportPreview? PrepareCatalogImport(string json)
|
||||
{
|
||||
if (CatalogSubject is null)
|
||||
{
|
||||
CatalogValidation = "Bitte zuerst ein Fach auswählen.";
|
||||
return null;
|
||||
}
|
||||
try
|
||||
{
|
||||
CatalogValidation = "";
|
||||
return _catalogImport.Analyze(
|
||||
json, CatalogSubject.Id, CatalogSubject.Name, CatalogGradeLevel);
|
||||
}
|
||||
catch (InvalidDataException ex)
|
||||
{
|
||||
CatalogValidation = ex.Message;
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public void ApplyCatalogImport(CompetencyCatalogImportPreview preview,
|
||||
CompetencyCatalogImportMode mode, IReadOnlySet<string> useImportedConflicts)
|
||||
{
|
||||
_catalogImport.Apply(preview, mode, useImportedConflicts);
|
||||
LoadCatalog();
|
||||
CatalogValidation = mode == CompetencyCatalogImportMode.Merge
|
||||
? "Kompetenzkatalog wurde sicher zusammengeführt."
|
||||
: "Kompetenzkatalog wurde vollständig ersetzt.";
|
||||
}
|
||||
|
||||
public string ExportCatalog()
|
||||
=> SerializeCatalog(CatalogGradeLevel);
|
||||
|
||||
public CompetencyCatalogImportPreview? PrepareCatalogCopy(int targetGradeLevel)
|
||||
{
|
||||
if (CatalogSubject is null)
|
||||
{
|
||||
CatalogValidation = "Bitte zuerst ein Fach auswählen.";
|
||||
return null;
|
||||
}
|
||||
if (Domains.Count == 0)
|
||||
{
|
||||
CatalogValidation = "Der ausgewählte Katalog enthält keine Bereiche.";
|
||||
return null;
|
||||
}
|
||||
if (targetGradeLevel is < 1 or > 13 || targetGradeLevel == CatalogGradeLevel)
|
||||
{
|
||||
CatalogValidation = "Bitte eine andere Zielklassenstufe zwischen 1 und 13 auswählen.";
|
||||
return null;
|
||||
}
|
||||
|
||||
CatalogValidation = "";
|
||||
return _catalogImport.Analyze(SerializeCatalog(targetGradeLevel), CatalogSubject.Id,
|
||||
CatalogSubject.Name, targetGradeLevel);
|
||||
}
|
||||
|
||||
public void SetCatalogCopyStatus(int targetGradeLevel) =>
|
||||
CatalogValidation = $"Kompetenzkatalog wurde in Klassenstufe {targetGradeLevel} kopiert.";
|
||||
|
||||
private string SerializeCatalog(int gradeLevel)
|
||||
{
|
||||
var dto = new CatalogDto
|
||||
{
|
||||
Subject = CatalogSubject?.Name ?? "",
|
||||
GradeLevel = gradeLevel,
|
||||
Domains = Domains.Select(d => new DomainDto
|
||||
{
|
||||
Name = d.Name,
|
||||
Code = d.Code,
|
||||
Competencies = d.Items.Select(i => new CompetencyDto
|
||||
{
|
||||
Code = i.Code,
|
||||
Description = i.Description,
|
||||
}).ToList(),
|
||||
}).ToList(),
|
||||
};
|
||||
return JsonSerializer.Serialize(dto, new JsonSerializerOptions { WriteIndented = true });
|
||||
}
|
||||
}
|
||||
|
||||
// ── JSON DTOs ─────────────────────────────────────────────────────────────────
|
||||
|
||||
internal class CatalogDto
|
||||
{
|
||||
[JsonPropertyName("subject")] public string? Subject { get; set; }
|
||||
[JsonPropertyName("gradeLevel")] public int GradeLevel { get; set; }
|
||||
[JsonPropertyName("domains")] public List<DomainDto>? Domains { get; set; }
|
||||
}
|
||||
|
||||
internal class DomainDto
|
||||
{
|
||||
[JsonPropertyName("name")] public string? Name { get; set; }
|
||||
[JsonPropertyName("code")] public string? Code { get; set; }
|
||||
[JsonPropertyName("competencies")] public List<CompetencyDto>? Competencies { get; set; }
|
||||
}
|
||||
|
||||
internal class CompetencyDto
|
||||
{
|
||||
[JsonPropertyName("code")] public string? Code { get; set; }
|
||||
[JsonPropertyName("description")] public string? Description { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
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 _letterTemplateStatus = "";
|
||||
public ObservableCollection<LetterTemplateListItem> LetterTemplateList { get; } = [];
|
||||
|
||||
private void LoadLetterTemplates()
|
||||
{
|
||||
LetterTemplateList.Clear();
|
||||
foreach (var template in _letterTemplates.GetTemplates()) LetterTemplateList.Add(CreateItem(template));
|
||||
}
|
||||
|
||||
public void ImportLetterTemplate(string path)
|
||||
{
|
||||
LetterTemplateStatus = "";
|
||||
try
|
||||
{
|
||||
var template = _letterTemplates.Import(path);
|
||||
LoadLetterTemplates();
|
||||
LetterTemplateStatus = $"„{template.Name}“ wurde geprüft und lokal importiert.";
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or InvalidDataException or UnauthorizedAccessException or TemplateValidationException)
|
||||
{ LetterTemplateStatus = $"Import fehlgeschlagen: {ex.Message}"; }
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void ValidateLetterTemplate(LetterTemplateListItem? item)
|
||||
{
|
||||
if (item is null) return;
|
||||
try
|
||||
{
|
||||
var refreshed = CreateItem(item.Model);
|
||||
var index = LetterTemplateList.IndexOf(item);
|
||||
if (index >= 0) LetterTemplateList[index] = refreshed;
|
||||
LetterTemplateStatus = $"„{item.Name}“ ist gültig (Schema {refreshed.SchemaVersion}).";
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or InvalidDataException or TemplateValidationException)
|
||||
{ LetterTemplateStatus = $"„{item.Name}“ ist ungültig: {ex.Message}"; }
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void DeleteLetterTemplate(LetterTemplateListItem? item)
|
||||
{
|
||||
if (item is null) return;
|
||||
_letterTemplates.Delete(item.Id); LetterTemplateList.Remove(item); LetterTemplateStatus = "Vorlage gelöscht.";
|
||||
}
|
||||
|
||||
private LetterTemplateListItem CreateItem(InstalledTemplate template)
|
||||
{
|
||||
var loaded = _letterTemplates.Load(template);
|
||||
return new(template, loaded.Manifest.SchemaVersion, loaded.Manifest.Placeholders.Count(x => !x.IsConstant),
|
||||
loaded.Manifest.Placeholders.Count(x => !x.IsConstant && x.Required));
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class LetterTemplateListItem(InstalledTemplate model, int schemaVersion, int fieldCount, int requiredCount)
|
||||
{
|
||||
public InstalledTemplate Model { get; } = model;
|
||||
public string Id => Model.Id;
|
||||
public string Name => Model.Name;
|
||||
public string PackageFileName => Path.GetFileName(Model.PackagePath);
|
||||
public int SchemaVersion { get; } = schemaVersion;
|
||||
public bool HasIssues => false;
|
||||
public bool HasNoIssues => true;
|
||||
public string ValidationSummary => $"Schema {SchemaVersion} · {fieldCount} Felder ({requiredCount} Pflicht)";
|
||||
public ObservableCollection<LetterTemplateIssueItem> Issues { get; } = [];
|
||||
}
|
||||
|
||||
public sealed class LetterTemplateIssueItem(ValidationIssue issue)
|
||||
{
|
||||
public string Icon => issue.Severity == ValidationSeverity.Error ? "⛔" : "ⓘ";
|
||||
public string Message => issue.Message;
|
||||
public string Color => issue.Severity == ValidationSeverity.Error ? "#DC2626" : "#6B7280";
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using LehrerApp.Core.Interfaces;
|
||||
using LehrerApp.Core.Models;
|
||||
using LehrerApp.Core.Services;
|
||||
using LehrerApp.Data;
|
||||
using LehrerApp.Desktop.Services;
|
||||
using LehrerApp.Desktop.ViewModels.Planning;
|
||||
using LehrerApp.Sync;
|
||||
using LehrerApp.Sync.Crypto;
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Globalization;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace LehrerApp.Desktop.ViewModels.Settings;
|
||||
|
||||
public partial class SettingsViewModel
|
||||
{
|
||||
// ── Stundenraster: Uhrzeiten je Einzelstunde (4.2.2 Nachtrag) ────────────
|
||||
|
||||
[ObservableProperty] private string _periodTimesError = "";
|
||||
[ObservableProperty] private string _periodTimesStatus = "";
|
||||
|
||||
public ObservableCollection<PeriodTimeEditItem> PeriodTimes { get; } = [];
|
||||
|
||||
// ── Stundenraster: Laden / Speichern ─────────────────────────────────────
|
||||
|
||||
private void LoadPeriodTimes()
|
||||
{
|
||||
PeriodTimes.Clear();
|
||||
for (var period = 1; period <= 10; period++)
|
||||
{
|
||||
var times = _periodSchedule.GetTimes(period);
|
||||
PeriodTimes.Add(new PeriodTimeEditItem(period, times?.Start, times?.End));
|
||||
}
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void SavePeriodTimes()
|
||||
{
|
||||
PeriodTimesError = ""; PeriodTimesStatus = "";
|
||||
var entries = new List<PeriodTimeEntry>();
|
||||
|
||||
foreach (var item in PeriodTimes)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(item.StartText) && string.IsNullOrWhiteSpace(item.EndText))
|
||||
continue; // Stunde bewusst nicht konfiguriert — ok, keine Pflicht für alle 10.
|
||||
|
||||
if (!TimeOnly.TryParseExact(item.StartText, "HH:mm", CultureInfo.InvariantCulture, DateTimeStyles.None, out var start) ||
|
||||
!TimeOnly.TryParseExact(item.EndText, "HH:mm", CultureInfo.InvariantCulture, DateTimeStyles.None, out var end))
|
||||
{ PeriodTimesError = $"{item.PeriodLabel}: Format HH:MM."; return; }
|
||||
|
||||
if (end <= start)
|
||||
{ PeriodTimesError = $"{item.PeriodLabel}: Ende muss nach dem Beginn liegen."; return; }
|
||||
|
||||
entries.Add(new PeriodTimeEntry { PeriodNumber = item.PeriodNumber, Start = start, End = end });
|
||||
}
|
||||
|
||||
_periodSchedule.SetPeriods(entries);
|
||||
PeriodTimesStatus = "Gespeichert.";
|
||||
}
|
||||
}
|
||||
|
||||
public partial class PeriodTimeEditItem : ObservableObject
|
||||
{
|
||||
public int PeriodNumber { get; }
|
||||
public string PeriodLabel { get; }
|
||||
|
||||
[ObservableProperty] private string _startText;
|
||||
[ObservableProperty] private string _endText;
|
||||
|
||||
public PeriodTimeEditItem(int periodNumber, TimeOnly? start, TimeOnly? end)
|
||||
{
|
||||
PeriodNumber = periodNumber;
|
||||
PeriodLabel = $"{periodNumber}. Stunde";
|
||||
_startText = start?.ToString("HH:mm") ?? "";
|
||||
_endText = end?.ToString("HH:mm") ?? "";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using LehrerApp.Core.Interfaces;
|
||||
using LehrerApp.Core.Models;
|
||||
using LehrerApp.Core.Services;
|
||||
using LehrerApp.Data;
|
||||
using LehrerApp.Desktop.Services;
|
||||
using LehrerApp.Desktop.ViewModels.Planning;
|
||||
using LehrerApp.Sync;
|
||||
using LehrerApp.Sync.Crypto;
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Globalization;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace LehrerApp.Desktop.ViewModels.Settings;
|
||||
|
||||
public partial class SettingsViewModel
|
||||
{
|
||||
// ── Datenschutz: Löschfristen (5.4.2) ────────────────────────────────────
|
||||
|
||||
[ObservableProperty] private int _retentionYears = 3;
|
||||
[ObservableProperty] private string _retentionStatus = "";
|
||||
|
||||
public ObservableCollection<ExpiredDocumentItem> ExpiredDocuments { get; } = [];
|
||||
|
||||
/// Vom Code-Behind gesetzt: zeigt einen Bestätigungsdialog vor dem endgültigen Löschen.
|
||||
public Func<ExpiredDocumentItem, Task<bool>>? OnConfirmHardDelete { get; set; }
|
||||
|
||||
// ── Datenschutz: Löschfristen ─────────────────────────────────────────────
|
||||
|
||||
private void LoadExpiredDocuments()
|
||||
{
|
||||
ExpiredDocuments.Clear();
|
||||
var cutoff = _privacy.RetentionCutoff();
|
||||
foreach (var d in _documentation.GetAll().Where(d => d.CreatedAt < cutoff))
|
||||
{
|
||||
var student = _students.GetById(d.StudentId);
|
||||
ExpiredDocuments.Add(new ExpiredDocumentItem(d, student?.FullName ?? "?"));
|
||||
}
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void SaveRetentionYears()
|
||||
{
|
||||
_privacy.SetRetentionYears(RetentionYears);
|
||||
LoadExpiredDocuments();
|
||||
RetentionStatus = "Gespeichert.";
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private async Task HardDeleteDocument(ExpiredDocumentItem? item)
|
||||
{
|
||||
if (item is null) return;
|
||||
if (OnConfirmHardDelete is not null && !await OnConfirmHardDelete(item)) return;
|
||||
_documentation.HardDelete(item.Id);
|
||||
ExpiredDocuments.Remove(item);
|
||||
}
|
||||
}
|
||||
|
||||
public class ExpiredDocumentItem(Documentation d, string studentName)
|
||||
{
|
||||
public Guid Id { get; } = d.Id;
|
||||
public string StudentName { get; } = studentName;
|
||||
public string Title { get; } = d.Title;
|
||||
public string CreatedAtDisplay { get; } = d.CreatedAt.ToLocalTime().ToString("dd.MM.yyyy");
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using LehrerApp.Core.Interfaces;
|
||||
using LehrerApp.Core.Models;
|
||||
using LehrerApp.Core.Services;
|
||||
using LehrerApp.Data;
|
||||
using LehrerApp.Desktop.Services;
|
||||
using LehrerApp.Desktop.ViewModels.Planning;
|
||||
using LehrerApp.Sync;
|
||||
using LehrerApp.Sync.Crypto;
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Globalization;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace LehrerApp.Desktop.ViewModels.Settings;
|
||||
|
||||
public partial class SettingsViewModel
|
||||
{
|
||||
// ── Kürzel-Katalog (Stundenverlaufsplan, 4.2.2) ──────────────────────────
|
||||
|
||||
[ObservableProperty] private string _newShorthandCode = "";
|
||||
[ObservableProperty] private string _newShorthandLabel = "";
|
||||
[ObservableProperty] private string _newShorthandCodeError = "";
|
||||
|
||||
public ObservableCollection<ShorthandCodeListItem> ShorthandCodes { get; } = [];
|
||||
|
||||
// ── Kürzel-Katalog: Laden / Hinzufügen / Löschen ─────────────────────────
|
||||
|
||||
public void LoadShorthandCodes()
|
||||
{
|
||||
ShorthandCodes.Clear();
|
||||
foreach (var c in _shorthandCodes.GetAll())
|
||||
ShorthandCodes.Add(new ShorthandCodeListItem(c));
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void AddShorthandCode()
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(NewShorthandCode)) { NewShorthandCodeError = "Kürzel erforderlich."; return; }
|
||||
try
|
||||
{
|
||||
_shorthandCodes.Save(new ShorthandCode { Code = NewShorthandCode.Trim(), Label = NewShorthandLabel.Trim() });
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
NewShorthandCodeError = ex.Message;
|
||||
return;
|
||||
}
|
||||
NewShorthandCode = ""; NewShorthandLabel = ""; NewShorthandCodeError = "";
|
||||
LoadShorthandCodes();
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void DeleteShorthandCode(ShorthandCodeListItem? item)
|
||||
{
|
||||
if (item is null) return;
|
||||
_shorthandCodes.Delete(item.Id);
|
||||
LoadShorthandCodes();
|
||||
}
|
||||
}
|
||||
|
||||
public class ShorthandCodeListItem(ShorthandCode c)
|
||||
{
|
||||
public Guid Id { get; } = c.Id;
|
||||
public string Code { get; } = c.Code;
|
||||
public string Label { get; } = c.Label;
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using LehrerApp.Core.Interfaces;
|
||||
using LehrerApp.Core.Models;
|
||||
using LehrerApp.Core.Services;
|
||||
using LehrerApp.Data;
|
||||
using LehrerApp.Desktop.Services;
|
||||
using LehrerApp.Desktop.ViewModels.Planning;
|
||||
using LehrerApp.Sync;
|
||||
using LehrerApp.Sync.Crypto;
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Globalization;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace LehrerApp.Desktop.ViewModels.Settings;
|
||||
|
||||
public partial class SettingsViewModel
|
||||
{
|
||||
// ── Fächer ────────────────────────────────────────────────────────────────
|
||||
|
||||
[ObservableProperty] private string _newName = "";
|
||||
[ObservableProperty] private string _newShort = "";
|
||||
[ObservableProperty] private string _newNameError = "";
|
||||
|
||||
public ObservableCollection<SubjectListItem> Subjects { get; } = [];
|
||||
|
||||
// ── Fächer: Laden / Hinzufügen / Löschen ─────────────────────────────────
|
||||
|
||||
public void LoadSubjects()
|
||||
{
|
||||
Subjects.Clear();
|
||||
foreach (var s in _subjects.GetAll())
|
||||
Subjects.Add(new SubjectListItem(s));
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void AddSubject()
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(NewName)) { NewNameError = "Name erforderlich."; return; }
|
||||
try
|
||||
{
|
||||
_subjects.Save(new Subject { Name = NewName.Trim(), ShortName = NewShort.Trim() });
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
NewNameError = ex.Message;
|
||||
return;
|
||||
}
|
||||
NewName = ""; NewShort = ""; NewNameError = "";
|
||||
LoadSubjects();
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void DeleteSubject(SubjectListItem? item)
|
||||
{
|
||||
if (item is null) return;
|
||||
try
|
||||
{
|
||||
_subjects.Delete(item.Id);
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
NewNameError = ex.Message;
|
||||
return;
|
||||
}
|
||||
NewNameError = "";
|
||||
if (CatalogSubject?.Id == item.Id) CatalogSubject = null;
|
||||
LoadSubjects();
|
||||
}
|
||||
}
|
||||
|
||||
public class SubjectListItem(Subject s)
|
||||
{
|
||||
public Guid Id { get; } = s.Id;
|
||||
public string Name { get; } = s.Name;
|
||||
public string ShortName { get; } = s.ShortName;
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using LehrerApp.Core.Interfaces;
|
||||
using LehrerApp.Core.Models;
|
||||
using LehrerApp.Core.Services;
|
||||
using LehrerApp.Data;
|
||||
using LehrerApp.Desktop.Services;
|
||||
using LehrerApp.Desktop.ViewModels.Planning;
|
||||
using LehrerApp.Sync;
|
||||
using LehrerApp.Sync.Crypto;
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Globalization;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace LehrerApp.Desktop.ViewModels.Settings;
|
||||
|
||||
public partial class SettingsViewModel
|
||||
{
|
||||
// ── Aufsichten: wiederkehrende Pausenaufsicht (4.3 Nachtrag) ─────────────
|
||||
|
||||
[ObservableProperty] private string _newDutyWeekdayName = WeekdayDisplay.Options[0];
|
||||
[ObservableProperty] private int _newDutyAfterPeriod;
|
||||
[ObservableProperty] private string _newDutyLocation = "";
|
||||
[ObservableProperty] private string _newDutyError = "";
|
||||
|
||||
public string[] WeekdayOptions { get; } = WeekdayDisplay.Options;
|
||||
public ObservableCollection<SupervisionDutyItem> SupervisionDuties { get; } = [];
|
||||
|
||||
// ── Aufsichten: Laden / Hinzufügen / Löschen ─────────────────────────────
|
||||
|
||||
private void LoadSupervisionDuties()
|
||||
{
|
||||
SupervisionDuties.Clear();
|
||||
foreach (var d in _supervisionDuties.GetAll())
|
||||
SupervisionDuties.Add(new SupervisionDutyItem(d));
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void AddSupervisionDuty()
|
||||
{
|
||||
NewDutyError = "";
|
||||
if (string.IsNullOrWhiteSpace(NewDutyLocation)) { NewDutyError = "Ort/Bezeichnung erforderlich."; return; }
|
||||
if (NewDutyAfterPeriod is < 0 or > 10) { NewDutyError = "Muss zwischen 0 und 10 liegen."; return; }
|
||||
|
||||
try
|
||||
{
|
||||
_supervisionDuties.Save(new SupervisionDuty
|
||||
{
|
||||
Weekday = WeekdayDisplay.FromLabel(NewDutyWeekdayName),
|
||||
AfterPeriod = NewDutyAfterPeriod,
|
||||
Location = NewDutyLocation.Trim(),
|
||||
});
|
||||
}
|
||||
catch (InvalidOperationException ex) { NewDutyError = ex.Message; return; }
|
||||
|
||||
NewDutyLocation = ""; NewDutyAfterPeriod = 0;
|
||||
LoadSupervisionDuties();
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void RemoveSupervisionDuty(SupervisionDutyItem? item)
|
||||
{
|
||||
if (item is null) return;
|
||||
_supervisionDuties.Delete(item.Id);
|
||||
SupervisionDuties.Remove(item);
|
||||
}
|
||||
}
|
||||
|
||||
public class SupervisionDutyItem(SupervisionDuty d)
|
||||
{
|
||||
public Guid Id { get; } = d.Id;
|
||||
public string WeekdayLabel { get; } = WeekdayDisplay.Label(d.Weekday);
|
||||
public int AfterPeriod { get; } = d.AfterPeriod;
|
||||
public string PeriodLabel { get; } = d.AfterPeriod == 0 ? "Vor der 1. Stunde" : $"Nach der {d.AfterPeriod}. Stunde";
|
||||
public string Location { get; } = d.Location;
|
||||
}
|
||||
|
||||
// ── Wochentag: deutsche Anzeige (Mo–Fr, für Aufsichten) ────────────────────────
|
||||
|
||||
public static class WeekdayDisplay
|
||||
{
|
||||
private static readonly (DayOfWeek Day, string Name)[] Entries =
|
||||
[
|
||||
(DayOfWeek.Monday, "Montag"), (DayOfWeek.Tuesday, "Dienstag"), (DayOfWeek.Wednesday, "Mittwoch"),
|
||||
(DayOfWeek.Thursday, "Donnerstag"), (DayOfWeek.Friday, "Freitag"),
|
||||
];
|
||||
|
||||
public static string[] Options { get; } = Entries.Select(e => e.Name).ToArray();
|
||||
public static string Label(DayOfWeek d) => Entries.First(e => e.Day == d).Name;
|
||||
public static DayOfWeek FromLabel(string label) => Entries.FirstOrDefault(e => e.Name == label).Day;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,245 @@
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using LehrerApp.Core.Interfaces;
|
||||
using LehrerApp.Core.Models;
|
||||
using LehrerApp.Core.Services;
|
||||
using LehrerApp.Data;
|
||||
using LehrerApp.Desktop.Services;
|
||||
using LehrerApp.Desktop.ViewModels.Planning;
|
||||
using LehrerApp.Sync;
|
||||
using LehrerApp.Sync.Crypto;
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Globalization;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace LehrerApp.Desktop.ViewModels.Settings;
|
||||
|
||||
public partial class SettingsViewModel
|
||||
{
|
||||
// ── Synchronisation (Kapitel 10) ──────────────────────────────────────────
|
||||
|
||||
[ObservableProperty] private string _syncServerUrl = "";
|
||||
[ObservableProperty] private string _syncUsername = "";
|
||||
[ObservableProperty] private string _syncPassword = "";
|
||||
[ObservableProperty] private string _syncLoginError = "";
|
||||
[ObservableProperty] private bool _syncIsLoggedIn;
|
||||
[ObservableProperty] private string _syncConnectionStatus = "";
|
||||
[ObservableProperty] private string _syncForceResyncStatus = "";
|
||||
[ObservableProperty] private bool _syncForceResyncBusy;
|
||||
[ObservableProperty] private bool _syncKeyWasRegenerated;
|
||||
[ObservableProperty] private string _recoveryCode = "";
|
||||
[ObservableProperty] private string _recoveryCodeInput = "";
|
||||
[ObservableProperty] private string _recoveryStatus = "";
|
||||
[ObservableProperty] private bool _recoveryBusy;
|
||||
|
||||
/// Vom Code-Behind gesetzt: lässt den Nutzer die Wiederherstellungsdatei selbst speichern
|
||||
/// (z.B. USB-Stick, eigene Cloud). Liefert false bei Abbruch.
|
||||
public Func<string, Task<bool>>? OnSaveRecoveryFile { get; set; }
|
||||
/// Vom Code-Behind gesetzt: lässt den Nutzer eine zuvor gesicherte Wiederherstellungsdatei
|
||||
/// auswählen. Liefert null bei Abbruch.
|
||||
public Func<Task<string?>>? OnPickRecoveryFile { get; set; }
|
||||
/// Vom Code-Behind gesetzt: bestätigt vor dem Überschreiben des aktuellen Sync-Schlüssels
|
||||
/// dieses Geräts (die App startet danach neu, siehe RedeemRecoveryCode).
|
||||
public Func<Task<bool>>? OnConfirmRecoveryRestore { get; set; }
|
||||
|
||||
public ObservableCollection<SyncConflictListItem> SyncConflicts { get; } = [];
|
||||
|
||||
// ── Synchronisation: Laden / Anmelden / Abmelden / Verbindungstest ───────
|
||||
//
|
||||
// Server-URL, Zugangsdaten und Token werden erst nach erfolgreichem Login zusammen
|
||||
// gespeichert (ein Restart deckt beides ab) — SyncEngine/SnapshotService werden nur einmalig
|
||||
// beim Start registriert (siehe AppBootstrapper), es gibt keinen Live-Re-Registrierungspfad.
|
||||
|
||||
private void LoadSyncSettings()
|
||||
{
|
||||
SyncServerUrl = _syncSettings.ServerUrl;
|
||||
SyncUsername = _syncSettings.Username;
|
||||
SyncIsLoggedIn = _syncSettings.IsLoggedIn;
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private async Task SyncTestConnection()
|
||||
{
|
||||
SyncConnectionStatus = "Teste Verbindung…";
|
||||
if (string.IsNullOrWhiteSpace(SyncServerUrl))
|
||||
{
|
||||
SyncConnectionStatus = "Bitte Server-Adresse eingeben.";
|
||||
return;
|
||||
}
|
||||
var result = await _syncAuth.TestConnectionAsync(SyncServerUrl, _syncSettings.GetToken());
|
||||
SyncConnectionStatus = result switch
|
||||
{
|
||||
SyncConnectionTestResult.Ok => "Verbindung erfolgreich.",
|
||||
SyncConnectionTestResult.Unauthorized => "Server erreichbar, aber nicht angemeldet oder Anmeldung abgelaufen.",
|
||||
SyncConnectionTestResult.IncompatibleVersion =>
|
||||
"Diese App-Version ist veraltet oder nicht mehr mit dem Sync-Server kompatibel. " +
|
||||
"Bitte aktualisiere die LehrerApp.",
|
||||
_ => "Server nicht erreichbar. Bitte Adresse und Internetverbindung prüfen.",
|
||||
};
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private async Task SyncLogin()
|
||||
{
|
||||
SyncLoginError = "";
|
||||
var valid = true;
|
||||
if (string.IsNullOrWhiteSpace(SyncServerUrl)) { SyncLoginError = "Server-Adresse erforderlich."; valid = false; }
|
||||
if (string.IsNullOrWhiteSpace(SyncUsername)) { SyncLoginError = "Benutzername erforderlich."; valid = false; }
|
||||
if (string.IsNullOrWhiteSpace(SyncPassword)) { SyncLoginError = "Passwort erforderlich."; valid = false; }
|
||||
if (!valid) return;
|
||||
|
||||
try
|
||||
{
|
||||
var (token, userId) = await _syncAuth.LoginAsync(SyncServerUrl, SyncUsername, SyncPassword);
|
||||
// Kontowechsel-Erkennung anhand der KANONISCHEN userId aus der Server-Antwort, nicht
|
||||
// anhand von SyncUsername: LiteDBs case-insensitive Standard-Collation lässt einen
|
||||
// Login mit abweichender Groß-/Kleinschreibung erfolgreich durch (TODO 10.2.5), meldet
|
||||
// serverseitig aber trotzdem eine ANDERE userId zurück - die lokale Versionsverfolgung
|
||||
// (BasedOnServerSeq je Entität, Pull-Cursor) bezieht sich dann auf ein fremdes Konto
|
||||
// und muss verworfen werden, sonst lehnt der Server jeden weiteren Push mit einem
|
||||
// dauerhaften 404 beim Nachladen ab (siehe TODO 10.3.5).
|
||||
var accountChanged = _syncSettings.LastUserId is not null && _syncSettings.LastUserId != userId;
|
||||
_syncSettings.SetServerUrl(SyncServerUrl);
|
||||
_syncSettings.SetCredentialsAndToken(SyncUsername, token, userId);
|
||||
if (accountChanged)
|
||||
{
|
||||
_eventQueue.SetLastServerSeq(0);
|
||||
_eventQueue.ResetKnownServerSeqs();
|
||||
}
|
||||
SyncPassword = "";
|
||||
AppBootstrapper.RestartApplication();
|
||||
}
|
||||
catch (SyncAuthException ex) { SyncLoginError = ex.Message; }
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void SyncLogout()
|
||||
{
|
||||
_syncSettings.Logout();
|
||||
AppBootstrapper.RestartApplication();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Setzt den lokalen Sync-Fortschritt (Pull-Cursor UND die per-Entität-Versionsverfolgung für
|
||||
/// Push) vollständig zurück und lädt danach alle Ereignisse dieses Nutzers erneut vom Server -
|
||||
/// manueller Reparaturweg, falls dieses Gerät wiederholt keine Änderungen eines anderen Geräts
|
||||
/// erhält (z.B. nach einem lokal bereits zu weit vorgerückten Cursor, TODO 10.1.10) oder eigene
|
||||
/// Pushes dauerhaft mit 404 abgelehnt werden (z.B. nach einem Kontowechsel, TODO 10.3.5).
|
||||
/// Sicher wiederholbar: EventApplier wendet jedes Ereignis über Upsert/Delete-by-Id idempotent
|
||||
/// an, und ein Push ohne bekannten Vorstand wird vom Server als Neuanlage behandelt.
|
||||
/// </summary>
|
||||
[RelayCommand]
|
||||
private async Task SyncForceFullResync()
|
||||
{
|
||||
if (_syncEngine is null) { SyncForceResyncStatus = "Sync nicht konfiguriert."; return; }
|
||||
SyncForceResyncBusy = true;
|
||||
SyncForceResyncStatus = "Lade alle Ereignisse erneut…";
|
||||
try
|
||||
{
|
||||
_eventQueue.SetLastServerSeq(0);
|
||||
_eventQueue.ResetKnownServerSeqs();
|
||||
var result = await _syncEngine.SyncNowAsync();
|
||||
SyncForceResyncStatus = result.Success
|
||||
? $"Abgeschlossen - {result.EventsPulled} Ereignis(se) erneut geladen."
|
||||
: $"Fehlgeschlagen: {result.Reason}";
|
||||
LoadSyncConflicts();
|
||||
}
|
||||
finally { SyncForceResyncBusy = false; }
|
||||
}
|
||||
|
||||
// ── Sync-Schlüssel: selbstverwalteter Wiederherstellungscode (10.3.2) ────────────────────
|
||||
//
|
||||
// Der Sync-Schlüssel verlässt nie den Server im Klartext (siehe SyncCrypto) - eine echte
|
||||
// Wiederherstellung nach Verlust des einzigen Geräts mit dem Schlüssel ist deshalb nur
|
||||
// möglich, wenn vorher proaktiv ein Rettungsanker angelegt wurde. "Code erzeugen"
|
||||
// verschlüsselt den aktuellen Schlüssel mit einem einmalig angezeigten, zufälligen Code
|
||||
// (SyncCrypto.EncryptKeyWithRecoveryCode) und lässt den Nutzer die verschlüsselte Datei
|
||||
// selbst sichern (USB-Stick, eigene Cloud o.ä.) - Datei und Code getrennt aufbewahren, erst
|
||||
// beides zusammen ergibt den Schlüssel. Läuft komplett offline, ohne Server-Beteiligung.
|
||||
|
||||
[RelayCommand]
|
||||
private async Task CreateRecoveryCode()
|
||||
{
|
||||
if (OnSaveRecoveryFile is null) return;
|
||||
RecoveryBusy = true;
|
||||
RecoveryStatus = "";
|
||||
RecoveryCode = "";
|
||||
try
|
||||
{
|
||||
var code = SyncCrypto.GenerateRecoveryCode();
|
||||
var fileContent = _syncKeyRecovery.CreateRecoveryFile(code);
|
||||
if (!await OnSaveRecoveryFile(fileContent)) { RecoveryStatus = "Abgebrochen."; return; }
|
||||
RecoveryCode = code;
|
||||
RecoveryStatus = "Datei gespeichert. Den Code getrennt von der Datei notieren und " +
|
||||
"sicher aufbewahren - er wird nirgends gespeichert und lässt sich " +
|
||||
"nicht erneut anzeigen.";
|
||||
}
|
||||
finally { RecoveryBusy = false; }
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private async Task RedeemRecoveryCode()
|
||||
{
|
||||
RecoveryStatus = "";
|
||||
if (string.IsNullOrWhiteSpace(RecoveryCodeInput)) { RecoveryStatus = "Bitte Code eingeben."; return; }
|
||||
if (OnPickRecoveryFile is null) return;
|
||||
var fileContent = await OnPickRecoveryFile();
|
||||
if (fileContent is null) return;
|
||||
if (OnConfirmRecoveryRestore is not null && !await OnConfirmRecoveryRestore()) return;
|
||||
|
||||
try
|
||||
{
|
||||
_syncKeyRecovery.RestoreFromFile(fileContent, RecoveryCodeInput.Trim());
|
||||
}
|
||||
catch (Exception ex) when (ex is CryptographicException or InvalidOperationException or JsonException)
|
||||
{
|
||||
RecoveryStatus = "Fehlgeschlagen: Code und Datei passen nicht zusammen.";
|
||||
_logger.Error("Sync-Schlüssel-Wiederherstellung fehlgeschlagen", ex);
|
||||
return;
|
||||
}
|
||||
// Der neue Schlüssel liegt jetzt auf der Platte, aber der aktuell im Speicher gehaltene
|
||||
// (falsche) Schlüssel ist ein DI-Singleton und kann zur Laufzeit nicht getauscht werden -
|
||||
// gleiches Muster wie RedeemPairingCode. Lokale Cursor/Versionsverfolgung zurücksetzen,
|
||||
// falls in der Zwischenzeit (mit dem gerade ersetzten, falschen Schlüssel) bereits etwas
|
||||
// synchronisiert wurde - sicher wiederholbar, siehe SyncForceFullResync.
|
||||
_eventQueue.SetLastServerSeq(0);
|
||||
_eventQueue.ResetKnownServerSeqs();
|
||||
AppBootstrapper.RestartApplication();
|
||||
}
|
||||
|
||||
// ── Synchronisation: Konflikte ────────────────────────────────────────────
|
||||
//
|
||||
// Zeigt, was ConflictResolver bereits entschieden hat (welche Seite gewonnen hat) — kein
|
||||
// Feld-Diff für v1, die Payloads sind clientseitig verschlüsselt und würden hier ohnehin nur
|
||||
// rohes JSON zeigen. Minimal: Entität, Zeitpunkt, Ergebnis, "gesehen"-Aktion.
|
||||
|
||||
private void LoadSyncConflicts()
|
||||
{
|
||||
SyncConflicts.Clear();
|
||||
foreach (var c in _eventQueue.GetUnreviewed().OrderByDescending(c => c.DetectedAt))
|
||||
SyncConflicts.Add(new SyncConflictListItem(c));
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void MarkConflictReviewed(SyncConflictListItem? item)
|
||||
{
|
||||
if (item is null) return;
|
||||
_eventQueue.MarkReviewed(item.Id);
|
||||
SyncConflicts.Remove(item);
|
||||
}
|
||||
}
|
||||
|
||||
public class SyncConflictListItem(ConflictEntry c)
|
||||
{
|
||||
public Guid Id { get; } = c.Id;
|
||||
public string EntityDisplay { get; } = $"{c.RemoteEvent.EntityType} ({c.RemoteEvent.EntityId[..Math.Min(8, c.RemoteEvent.EntityId.Length)]}…)";
|
||||
public string DetectedAtDisplay { get; } = c.DetectedAt.ToLocalTime().ToString("dd.MM.yyyy HH:mm");
|
||||
public string ResolutionDisplay { get; } = c.Resolution switch
|
||||
{
|
||||
"LocalWon" => "Lokale Änderung übernommen (dieses Gerät)",
|
||||
"RemoteWon" => "Änderung vom anderen Gerät übernommen",
|
||||
_ => c.Resolution,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using LehrerApp.Core.Interfaces;
|
||||
using LehrerApp.Core.Models;
|
||||
using LehrerApp.Core.Services;
|
||||
using LehrerApp.Data;
|
||||
using LehrerApp.Desktop.Services;
|
||||
using LehrerApp.Desktop.ViewModels.Planning;
|
||||
using LehrerApp.Sync;
|
||||
using LehrerApp.Sync.Crypto;
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Globalization;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace LehrerApp.Desktop.ViewModels.Settings;
|
||||
|
||||
public partial class SettingsViewModel
|
||||
{
|
||||
// ── WebUntis-iCal-Abgleich (Nutzer-Feedback) ──────────────────────────────
|
||||
|
||||
[ObservableProperty] private bool _untisEnabled;
|
||||
[ObservableProperty] private bool _untisIsConfigured;
|
||||
[ObservableProperty] private string _untisIcalUrlInput = "";
|
||||
[ObservableProperty] private string _untisUrlError = "";
|
||||
[ObservableProperty] private string _untisStatusDisplay = "";
|
||||
[ObservableProperty] private bool _untisFetchBusy;
|
||||
[ObservableProperty] private bool _untisApiIsConfigured;
|
||||
[ObservableProperty] private string _untisSchool = "";
|
||||
[ObservableProperty] private string _untisHost = "";
|
||||
[ObservableProperty] private string _untisUsername = "";
|
||||
[ObservableProperty] private string _untisPassword = "";
|
||||
[ObservableProperty] private string _untisApiStatus = "";
|
||||
[ObservableProperty] private bool _untisApiBusy;
|
||||
[ObservableProperty] private string? _untisHomeroomClassName;
|
||||
public string UntisHomeroomClassDisplay => UntisHomeroomClassName is { Length: > 0 } name
|
||||
? $"Ausgewählt: {name}"
|
||||
: "Keine Klasse ausgewählt.";
|
||||
partial void OnUntisHomeroomClassNameChanged(string? value) => OnPropertyChanged(nameof(UntisHomeroomClassDisplay));
|
||||
public Func<Task>? OnReviewUntisMapping { get; set; }
|
||||
/// Vom Code-Behind gesetzt: öffnet den WebUntis-Klassenauswahldialog, liefert null bei Abbruch.
|
||||
public Func<Task<(int UntisId, string Name)?>>? OnPickHomeroomClass { get; set; }
|
||||
|
||||
// ── WebUntis-iCal-Abgleich: Laden / Speichern / Entfernen / Jetzt abrufen ────
|
||||
//
|
||||
// Wie bei Sync deckt ein Neustart das Registrieren von UntisSyncService ab
|
||||
// (AppBootstrapper registriert es nur einmalig beim Start, wenn URL+Enabled vorliegen).
|
||||
|
||||
private void LoadUntisSettings()
|
||||
{
|
||||
UntisEnabled = _untisSettings.Enabled;
|
||||
UntisIsConfigured = _untisSettings.IsConfigured;
|
||||
UntisStatusDisplay = _untisSettings.LastSyncAt is { } at
|
||||
? $"Letzter Abgleich: {at.ToLocalTime():dd.MM.yyyy HH:mm} — {_untisSettings.LastSyncStatus}"
|
||||
: "Noch kein Abgleich durchgeführt.";
|
||||
UntisApiIsConfigured = _untisSettings.ApiIsConfigured;
|
||||
if (_untisSettings.GetApiCredentials() is { } credentials)
|
||||
{
|
||||
UntisSchool = credentials.School;
|
||||
UntisHost = credentials.Host;
|
||||
UntisUsername = credentials.Username;
|
||||
UntisApiStatus = $"API-Zugang für {credentials.Username} ist lokal verschlüsselt gespeichert.";
|
||||
}
|
||||
UntisHomeroomClassName = _untisSettings.HomeroomClassName;
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private async Task UntisSaveApi()
|
||||
{
|
||||
UntisApiStatus = "";
|
||||
if (_untisIntegration is null)
|
||||
{
|
||||
UntisApiStatus = "Die WebUntis-Integration ist nicht verfügbar.";
|
||||
return;
|
||||
}
|
||||
if (string.IsNullOrWhiteSpace(UntisSchool) || string.IsNullOrWhiteSpace(UntisUsername) ||
|
||||
string.IsNullOrWhiteSpace(UntisPassword))
|
||||
{
|
||||
UntisApiStatus = "Schule, Benutzername und Passwort sind erforderlich.";
|
||||
return;
|
||||
}
|
||||
UntisApiBusy = true;
|
||||
try
|
||||
{
|
||||
var credentials = new WebUntisCredentials(UntisSchool.Trim(), UntisHost.Trim(),
|
||||
UntisUsername.Trim(), UntisPassword);
|
||||
await _untisIntegration.ConnectAsync(credentials);
|
||||
_untisSettings.SetApiCredentials(credentials);
|
||||
UntisPassword = "";
|
||||
UntisApiIsConfigured = true;
|
||||
UntisApiStatus = "Anmeldung erfolgreich. Die WebUntis-Session bleibt bei Nutzung bis zu 10 Minuten offen.";
|
||||
}
|
||||
catch (WebUntisIntegrationException ex) { UntisApiStatus = ex.Message; }
|
||||
finally { UntisApiBusy = false; }
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private async Task UntisRemoveApi()
|
||||
{
|
||||
UntisApiBusy = true;
|
||||
try { if (_untisIntegration is not null) await _untisIntegration.DisconnectAsync(); }
|
||||
catch (WebUntisIntegrationException) { /* lokale Zugangsdaten trotzdem sicher entfernen */ }
|
||||
finally
|
||||
{
|
||||
_untisSettings.ClearApiCredentials();
|
||||
UntisPassword = "";
|
||||
UntisApiIsConfigured = false;
|
||||
UntisApiStatus = "WebUntis-API-Zugang entfernt.";
|
||||
UntisApiBusy = false;
|
||||
}
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void UntisSaveUrl()
|
||||
{
|
||||
UntisUrlError = "";
|
||||
if (string.IsNullOrWhiteSpace(UntisIcalUrlInput)) { UntisUrlError = "iCal-URL erforderlich."; return; }
|
||||
if (!Uri.TryCreate(UntisIcalUrlInput, UriKind.Absolute, out _)) { UntisUrlError = "Ungültige URL."; return; }
|
||||
|
||||
_untisSettings.SetIcalUrl(UntisIcalUrlInput.Trim());
|
||||
_untisSettings.SetEnabled(true);
|
||||
UntisIcalUrlInput = "";
|
||||
AppBootstrapper.RestartApplication();
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void UntisRemove()
|
||||
{
|
||||
_untisSettings.ClearIcalUrl();
|
||||
LoadUntisSettings();
|
||||
AppBootstrapper.RestartApplication();
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private async Task UntisFetchNow()
|
||||
{
|
||||
if (_untisSync is null) { UntisStatusDisplay = "Abgleich nicht aktiv — App neu starten."; return; }
|
||||
UntisFetchBusy = true;
|
||||
try
|
||||
{
|
||||
await _untisSync.PollAsync();
|
||||
LoadUntisSettings();
|
||||
}
|
||||
finally { UntisFetchBusy = false; }
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private async Task UntisReviewMapping()
|
||||
{
|
||||
if (OnReviewUntisMapping is not null) await OnReviewUntisMapping();
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private async Task UntisPickHomeroomClass()
|
||||
{
|
||||
if (OnPickHomeroomClass is null) return;
|
||||
var result = await OnPickHomeroomClass();
|
||||
if (result is null) return;
|
||||
_untisSettings.SetHomeroomClass(result.Value.UntisId, result.Value.Name);
|
||||
LoadUntisSettings();
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void UntisClearHomeroomClass()
|
||||
{
|
||||
_untisSettings.SetHomeroomClass(null, null);
|
||||
LoadUntisSettings();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using LehrerApp.Core.Interfaces;
|
||||
using LehrerApp.Core.Models;
|
||||
using LehrerApp.Core.Services;
|
||||
using LehrerApp.Data;
|
||||
using LehrerApp.Desktop.Services;
|
||||
using LehrerApp.Desktop.ViewModels.Planning;
|
||||
using LehrerApp.Sync;
|
||||
using LehrerApp.Sync.Crypto;
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Globalization;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace LehrerApp.Desktop.ViewModels.Settings;
|
||||
|
||||
public partial class SettingsViewModel
|
||||
{
|
||||
// ── Gewichtungsschema-Voreinstellungen (2.3.3) ───────────────────────────
|
||||
|
||||
[ObservableProperty] private GradingSchemeEditItem _classScheme = null!;
|
||||
[ObservableProperty] private GradingSchemeEditItem _courseScheme = null!;
|
||||
|
||||
// ── Gewichtungsschema-Voreinstellungen: Laden ────────────────────────────
|
||||
|
||||
private void LoadGradingSchemes()
|
||||
{
|
||||
ClassScheme = new GradingSchemeEditItem(
|
||||
_gradingSchemes.GetDefaultForType(GroupType.Class)
|
||||
?? new GradingScheme { GroupType = GroupType.Class, ExamsPercent = 50, ParticipationPercent = 40, OtherPercent = 10 },
|
||||
"Klassen", _gradingSchemes, _grading);
|
||||
CourseScheme = new GradingSchemeEditItem(
|
||||
_gradingSchemes.GetDefaultForType(GroupType.Course)
|
||||
?? new GradingScheme { GroupType = GroupType.Course, ExamsPercent = 60, ParticipationPercent = 30, OtherPercent = 10 },
|
||||
"Kurse", _gradingSchemes, _grading);
|
||||
}
|
||||
}
|
||||
|
||||
// ── GradingSchemeEditItem (2.3) ────────────────────────────────────────────────
|
||||
|
||||
public partial class GradingSchemeEditItem : ObservableObject
|
||||
{
|
||||
private readonly GradingScheme _scheme;
|
||||
private readonly IGradingSchemeRepository _repo;
|
||||
private readonly GradingService _grading;
|
||||
|
||||
public string Label { get; }
|
||||
|
||||
[ObservableProperty] private double _examsPercent;
|
||||
[ObservableProperty] private double _participationPercent;
|
||||
[ObservableProperty] private double _otherPercent;
|
||||
[ObservableProperty] private string _validationMessage = "";
|
||||
[ObservableProperty] private string _statusMessage = "";
|
||||
|
||||
public GradingSchemeEditItem(GradingScheme scheme, string label, IGradingSchemeRepository repo, GradingService grading)
|
||||
{
|
||||
_scheme = scheme; _repo = repo; _grading = grading;
|
||||
Label = label;
|
||||
_examsPercent = scheme.ExamsPercent;
|
||||
_participationPercent = scheme.ParticipationPercent;
|
||||
_otherPercent = scheme.OtherPercent;
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void Save()
|
||||
{
|
||||
_scheme.ExamsPercent = ExamsPercent;
|
||||
_scheme.ParticipationPercent = ParticipationPercent;
|
||||
_scheme.OtherPercent = OtherPercent;
|
||||
|
||||
var error = _grading.ValidateGradingScheme(_scheme);
|
||||
if (error is not null) { ValidationMessage = error; StatusMessage = ""; return; }
|
||||
|
||||
ValidationMessage = "";
|
||||
_repo.Save(_scheme);
|
||||
StatusMessage = "Gespeichert.";
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,21 +1,23 @@
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using LehrerApp.Core.Interfaces;
|
||||
using LehrerApp.Core.Models;
|
||||
using LehrerApp.Core.Services;
|
||||
using LehrerApp.Templating;
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Globalization;
|
||||
|
||||
namespace LehrerApp.Desktop.ViewModels.Students;
|
||||
|
||||
public partial class CreateLetterDialogViewModel : ObservableObject
|
||||
{
|
||||
private readonly Student _student;
|
||||
private readonly LetterTemplateService _templates;
|
||||
private readonly TemplateStore _templates;
|
||||
private readonly ITemplateRenderer _renderer;
|
||||
|
||||
[ObservableProperty] private LetterTemplateChoice? _selectedTemplate;
|
||||
[ObservableProperty] private LetterContactChoice? _selectedContact;
|
||||
[ObservableProperty] private LetterGroupChoice? _selectedGroup;
|
||||
[ObservableProperty] private DateTimeOffset? _letterDate = DateTimeOffset.Now;
|
||||
[ObservableProperty] private string _letterText = "";
|
||||
[ObservableProperty] private string _teacherName = "";
|
||||
[ObservableProperty] private string _generationError = "";
|
||||
[ObservableProperty] private bool _canGenerate;
|
||||
|
||||
@@ -28,155 +30,93 @@ public partial class CreateLetterDialogViewModel : ObservableObject
|
||||
public bool HasNoTemplates => Templates.Count == 0;
|
||||
public bool HasNoContacts => Contacts.Count == 0;
|
||||
public string SuggestedFileName => SanitizeFileName(
|
||||
$"{SelectedTemplate?.Name ?? "Elternbrief"}_{_student.LastName}_{_student.FirstName}.docx");
|
||||
$"{SelectedTemplate?.Name ?? "Elternbrief"}_{_student.LastName}_{_student.FirstName}.pdf");
|
||||
|
||||
public CreateLetterDialogViewModel(Student student, LetterTemplateService templates,
|
||||
public CreateLetterDialogViewModel(Student student, TemplateStore templates, ITemplateRenderer renderer,
|
||||
IGroupMembershipRepository memberships, IGroupRepository groups)
|
||||
{
|
||||
_student = student;
|
||||
_templates = templates;
|
||||
|
||||
foreach (var template in templates.GetTemplates())
|
||||
Templates.Add(new LetterTemplateChoice(template));
|
||||
foreach (var contact in student.Contacts.Where(c => !c.InvalidSince.HasValue).OrderBy(c => c.Name))
|
||||
Contacts.Add(new LetterContactChoice(contact));
|
||||
_student = student; _templates = templates; _renderer = renderer;
|
||||
foreach (var template in templates.GetTemplates()) Templates.Add(new(template));
|
||||
foreach (var contact in student.Contacts.Where(c => !c.InvalidSince.HasValue).OrderBy(c => c.Name)) Contacts.Add(new(contact));
|
||||
foreach (var membership in memberships.GetByStudent(student.Id))
|
||||
{
|
||||
var group = groups.GetById(membership.GroupId);
|
||||
if (group is not null) Groups.Add(new LetterGroupChoice(group));
|
||||
}
|
||||
|
||||
SelectedTemplate = Templates.FirstOrDefault();
|
||||
SelectedContact = Contacts.FirstOrDefault();
|
||||
SelectedGroup = Groups.FirstOrDefault();
|
||||
if (groups.GetById(membership.GroupId) is { } group) Groups.Add(new(group));
|
||||
SelectedTemplate = Templates.FirstOrDefault(); SelectedContact = Contacts.FirstOrDefault(); SelectedGroup = Groups.FirstOrDefault();
|
||||
RefreshValidation();
|
||||
}
|
||||
|
||||
partial void OnSelectedTemplateChanged(LetterTemplateChoice? value)
|
||||
{
|
||||
OnPropertyChanged(nameof(SuggestedFileName));
|
||||
RefreshValidation();
|
||||
}
|
||||
partial void OnSelectedTemplateChanged(LetterTemplateChoice? value) { OnPropertyChanged(nameof(SuggestedFileName)); RefreshValidation(); }
|
||||
partial void OnSelectedContactChanged(LetterContactChoice? value) => RefreshValidation();
|
||||
partial void OnSelectedGroupChanged(LetterGroupChoice? value) => RefreshValidation();
|
||||
partial void OnLetterDateChanged(DateTimeOffset? value) => RefreshValidation();
|
||||
partial void OnLetterTextChanged(string value) => RefreshValidation();
|
||||
partial void OnTeacherNameChanged(string value) => RefreshValidation();
|
||||
|
||||
public bool Generate(string outputPath)
|
||||
{
|
||||
RefreshValidation();
|
||||
if (!CanGenerate || SelectedTemplate is null) return false;
|
||||
GenerationError = "";
|
||||
RefreshValidation(); if (!CanGenerate || SelectedTemplate is null) return false;
|
||||
try
|
||||
{
|
||||
_templates.Generate(_templates.GetTemplatePath(SelectedTemplate.Model), outputPath, BuildValues());
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or InvalidDataException or UnauthorizedAccessException)
|
||||
{
|
||||
GenerationError = $"Der Brief konnte nicht erzeugt werden: {ex.Message}";
|
||||
return false;
|
||||
var pdf = _renderer.RenderToPdf(_templates.Load(SelectedTemplate.Model), new LetterDataProvider(BuildValues()));
|
||||
var directory = Path.GetDirectoryName(outputPath); if (!string.IsNullOrEmpty(directory)) Directory.CreateDirectory(directory);
|
||||
File.WriteAllBytes(outputPath, pdf); GenerationError = ""; return true;
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or InvalidDataException or UnauthorizedAccessException or TemplateValidationException)
|
||||
{ GenerationError = $"Der Brief konnte nicht erzeugt werden: {ex.Message}"; return false; }
|
||||
}
|
||||
|
||||
private void RefreshValidation()
|
||||
{
|
||||
Issues.Clear();
|
||||
GenerationError = "";
|
||||
|
||||
if (SelectedTemplate is null)
|
||||
Issues.Add(new("Bitte zuerst in den Einstellungen eine DOCX-Briefvorlage importieren.", true));
|
||||
if (SelectedContact is null)
|
||||
Issues.Add(new("Für den Schüler ist kein aktueller Kontakt ausgewählt.", true));
|
||||
if (LetterDate is null)
|
||||
Issues.Add(new("Bitte ein Briefdatum auswählen.", true));
|
||||
|
||||
Issues.Clear(); GenerationError = "";
|
||||
if (SelectedTemplate is null) Issues.Add(new("Bitte zuerst in den Einstellungen ein .lavorlage-Paket importieren.", true));
|
||||
if (SelectedContact is null) Issues.Add(new("Für den Schüler ist kein aktueller Kontakt ausgewählt.", true));
|
||||
if (LetterDate is null) Issues.Add(new("Bitte ein Briefdatum auswählen.", true));
|
||||
if (SelectedTemplate is not null)
|
||||
{
|
||||
var validation = _templates.Validate(SelectedTemplate.Model);
|
||||
foreach (var issue in validation.Issues)
|
||||
Issues.Add(new(issue.Message, issue.Severity is TemplateIssueSeverity.StrongWarning or TemplateIssueSeverity.Error));
|
||||
|
||||
var tags = validation.Tags.ToHashSet(StringComparer.Ordinal);
|
||||
var values = BuildValues();
|
||||
foreach (var tag in tags.Where(t => values.TryGetValue(t, out var value) && string.IsNullOrWhiteSpace(value)))
|
||||
try
|
||||
{
|
||||
var message = tag switch
|
||||
{
|
||||
"Letter.Salutation" => "Beim ausgewählten Kontakt fehlt die Briefanrede.",
|
||||
"Contact.Address" or "Contact.Street" or "Contact.PostalCode" or "Contact.City" =>
|
||||
$"Beim ausgewählten Kontakt fehlt der Wert für „{tag}“.",
|
||||
"Group.Name" or "SchoolYear" => "Die Vorlage verwendet Gruppendaten; bitte eine Lerngruppe auswählen.",
|
||||
_ => $"Für das Vorlagenfeld „{tag}“ ist kein Wert vorhanden.",
|
||||
};
|
||||
Issues.Add(new(message, true));
|
||||
var loaded = _templates.Load(SelectedTemplate.Model); var values = BuildValues();
|
||||
var validation = new TemplateLoader().Validate(loaded, values.ToDictionary(x => x.Key, x => x.Value.Type));
|
||||
foreach (var issue in validation.Issues) Issues.Add(new(issue.Message, issue.Severity == ValidationSeverity.Error));
|
||||
foreach (var required in loaded.Manifest.Placeholders.Where(x => !x.IsConstant && x.Required
|
||||
&& values.TryGetValue(x.Name, out var value) && IsEmpty(value)))
|
||||
Issues.Add(new($"Für das Pflichtfeld „{required.Name}“ ist kein Wert vorhanden.", true));
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or InvalidDataException or TemplateValidationException)
|
||||
{ Issues.Add(new($"Vorlage ist ungültig: {ex.Message}", true)); }
|
||||
}
|
||||
|
||||
CanGenerate = SelectedTemplate is not null && SelectedContact is not null && LetterDate is not null
|
||||
&& Issues.Count == 0;
|
||||
CanGenerate = SelectedTemplate is not null && SelectedContact is not null && LetterDate is not null && Issues.Count == 0;
|
||||
OnPropertyChanged(nameof(HasIssues));
|
||||
}
|
||||
|
||||
private IReadOnlyDictionary<string, string?> BuildValues()
|
||||
private IReadOnlyDictionary<string, PlaceholderValue> BuildValues()
|
||||
{
|
||||
var contact = SelectedContact?.Model;
|
||||
var group = SelectedGroup?.Model;
|
||||
var cityLine = string.Join(" ", new[] { contact?.PostalCode, contact?.City }
|
||||
.Where(v => !string.IsNullOrWhiteSpace(v)));
|
||||
var address = string.Join(Environment.NewLine, new[] { contact?.Street, cityLine }
|
||||
.Where(v => !string.IsNullOrWhiteSpace(v)));
|
||||
var date = LetterDate is null ? null : DateOnly.FromDateTime(LetterDate.Value.LocalDateTime)
|
||||
.ToString("dd.MM.yyyy", CultureInfo.GetCultureInfo("de-DE"));
|
||||
|
||||
return new Dictionary<string, string?>
|
||||
var contact = SelectedContact?.Model; var group = SelectedGroup?.Model;
|
||||
var cityLine = string.Join(" ", new[] { contact?.PostalCode, contact?.City }.Where(x => !string.IsNullOrWhiteSpace(x)));
|
||||
var address = string.Join(Environment.NewLine, new[] { contact?.Name, contact?.Street, cityLine }.Where(x => !string.IsNullOrWhiteSpace(x)));
|
||||
var date = DateOnly.FromDateTime((LetterDate ?? DateTimeOffset.Now).LocalDateTime);
|
||||
return new Dictionary<string, PlaceholderValue>(StringComparer.Ordinal)
|
||||
{
|
||||
["Student.FirstName"] = _student.FirstName,
|
||||
["Student.LastName"] = _student.LastName,
|
||||
["Contact.Name"] = contact?.Name,
|
||||
["Contact.Address"] = address,
|
||||
["Contact.Street"] = contact?.Street,
|
||||
["Contact.PostalCode"] = contact?.PostalCode,
|
||||
["Contact.City"] = contact?.City,
|
||||
["Letter.Salutation"] = contact?.LetterSalutation,
|
||||
["Group.Name"] = group?.Name,
|
||||
["SchoolYear"] = group?.SchoolYear,
|
||||
["CurrentDate"] = date,
|
||||
["Datum"] = new DateValue(date), ["CurrentDate"] = new DateValue(date),
|
||||
["Empfaenger"] = new TextValue(contact?.Name ?? ""), ["Anrede"] = new TextValue(contact?.LetterSalutation ?? ""),
|
||||
["Brieftext"] = new MultilineValue(LetterText), ["LehrerName"] = new TextValue(TeacherName),
|
||||
["Student.FirstName"] = new TextValue(_student.FirstName), ["Student.LastName"] = new TextValue(_student.LastName),
|
||||
["Contact.Name"] = new TextValue(contact?.Name ?? ""), ["Contact.Address"] = new MultilineValue(address),
|
||||
["Contact.Street"] = new TextValue(contact?.Street ?? ""), ["Contact.PostalCode"] = new TextValue(contact?.PostalCode ?? ""),
|
||||
["Contact.City"] = new TextValue(contact?.City ?? ""), ["Letter.Salutation"] = new TextValue(contact?.LetterSalutation ?? ""),
|
||||
["Group.Name"] = new TextValue(group?.Name ?? ""), ["SchoolYear"] = new TextValue(group?.SchoolYear ?? ""),
|
||||
};
|
||||
}
|
||||
|
||||
private static bool IsEmpty(PlaceholderValue value) => value switch
|
||||
{ TextValue x => string.IsNullOrWhiteSpace(x.Value), MultilineValue x => string.IsNullOrWhiteSpace(x.Value), _ => false };
|
||||
private static string SanitizeFileName(string value)
|
||||
{
|
||||
foreach (var character in Path.GetInvalidFileNameChars()) value = value.Replace(character, '_');
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class LetterTemplateChoice
|
||||
{
|
||||
public LetterTemplateInfo Model { get; }
|
||||
public string Name => Model.Name;
|
||||
public LetterTemplateChoice(LetterTemplateInfo model) => Model = model;
|
||||
}
|
||||
|
||||
public sealed class LetterContactChoice
|
||||
{
|
||||
public Contact Model { get; }
|
||||
public string Display => string.IsNullOrWhiteSpace(Model.Relation)
|
||||
? Model.Name
|
||||
: $"{Model.Name} · {Model.Relation}";
|
||||
public LetterContactChoice(Contact model) => Model = model;
|
||||
}
|
||||
|
||||
public sealed class LetterGroupChoice
|
||||
{
|
||||
public LearningGroup Model { get; }
|
||||
public string Display => $"{Model.Name} · {Model.SchoolYear}";
|
||||
public LetterGroupChoice(LearningGroup model) => Model = model;
|
||||
{ foreach (var character in Path.GetInvalidFileNameChars()) value = value.Replace(character, '_'); return value; }
|
||||
}
|
||||
|
||||
internal sealed class LetterDataProvider(IReadOnlyDictionary<string, PlaceholderValue> values) : ITemplateDataProvider
|
||||
{ public IReadOnlyDictionary<string, PlaceholderValue> GetValues() => values; }
|
||||
public sealed class LetterTemplateChoice(InstalledTemplate model) { public InstalledTemplate Model { get; } = model; public string Name => Model.Name; }
|
||||
public sealed class LetterContactChoice(Contact model) { public Contact Model { get; } = model; public string Display => string.IsNullOrWhiteSpace(Model.Relation) ? Model.Name : $"{Model.Name} · {Model.Relation}"; }
|
||||
public sealed class LetterGroupChoice(LearningGroup model) { public LearningGroup Model { get; } = model; public string Display => $"{Model.Name} · {Model.SchoolYear}"; }
|
||||
public sealed class LetterGenerationIssue(string message, bool isStrong)
|
||||
{
|
||||
public string Icon { get; } = isStrong ? "⚠" : "ⓘ";
|
||||
public string Message { get; } = message;
|
||||
public string Color { get; } = isStrong ? "#D97706" : "#6B7280";
|
||||
}
|
||||
{ public string Icon { get; } = isStrong ? "⚠" : "ⓘ"; public string Message { get; } = message; public string Color { get; } = isStrong ? "#D97706" : "#6B7280"; }
|
||||
|
||||
@@ -17,7 +17,7 @@ public record StudentOption(Guid Id, string Name);
|
||||
public static class DocumentationTypeDisplay
|
||||
{
|
||||
public static string[] Options { get; } =
|
||||
["Gespräch", "Vorkommnis", "Förderplan", "Fehlzeit", "Elternanruf", "Elternbrief"];
|
||||
["Gespräch", "Vorkommnis", "Förderplan", "Fehlzeit", "Elternanruf", "Elternbrief", "Planung / Erinnerung"];
|
||||
|
||||
public static string Label(DocumentationType t) => t switch
|
||||
{
|
||||
@@ -27,6 +27,7 @@ public static class DocumentationTypeDisplay
|
||||
DocumentationType.Absence => "Fehlzeit",
|
||||
DocumentationType.ParentCall => "Elternanruf",
|
||||
DocumentationType.ParentLetter => "Elternbrief",
|
||||
DocumentationType.Planning => "Planung / Erinnerung",
|
||||
_ => "",
|
||||
};
|
||||
|
||||
@@ -37,6 +38,7 @@ public static class DocumentationTypeDisplay
|
||||
"Fehlzeit" => DocumentationType.Absence,
|
||||
"Elternanruf" => DocumentationType.ParentCall,
|
||||
"Elternbrief" => DocumentationType.ParentLetter,
|
||||
"Planung / Erinnerung" => DocumentationType.Planning,
|
||||
_ => DocumentationType.Conversation,
|
||||
};
|
||||
}
|
||||
@@ -301,7 +303,7 @@ public partial class DocumentationDialogViewModel : ObservableObject
|
||||
LetterSentDateError = ""; LetterResponseDateError = "";
|
||||
var valid = true;
|
||||
|
||||
if (CanPickStudent && SelectedStudent is null) { StudentError = "Schüler auswählen."; valid = false; }
|
||||
if (CanPickStudent && SelectedStudent is null) { StudentError = "Bezug auswählen."; valid = false; }
|
||||
|
||||
if (string.IsNullOrWhiteSpace(Title)) { TitleError = "Titel erforderlich."; valid = false; }
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user