Author SHA1 Message Date
adminandClaude Sonnet 5 84c03962ef Klausurschnitt-Vorjahresvergleich beim Hochstufen
CI / build-and-test (push) Canceled after 0s
LearningGroup.PreviousGroupId wird von GroupRolloverService jetzt automatisch
gesetzt; neue Karte "Vorjahresvergleich" im Kurs-Tab Übersicht zeigt den
gepoolten Klausurdurchschnitt neben dem der Vorgängergruppe, sobald dort
Ergebnisse vorliegen.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-30 02:33:07 +02:00
admin b7c7d46ee4 Doppelstunden im Stundenplan korrekt anzeigen 2026-08-30 02:29:50 +02:00
adminandClaude Sonnet 5 da4b88c93d Persönliche Klausurlast pro Woche, Mitarbeit-Reset und Sitzplan-Strichliste
CI / build-and-test (push) Canceled after 0s
- Klausuren: neuer ExamWeekLoadService zählt eigene Klausuren je ISO-Woche
  über alle Kurse hinweg (Klassen-Kollisionen deckt bereits der externe
  Klausurplaner ab). Hinweis direkt im ExamDialog beim Datum, zusätzlich
  neue Dashboard-Karte "Klausurwochen" für die 60-Tage-Vorausschau.
- Mitarbeit: Schnelleingabe-Dialog kann eine Bewertung jetzt per Entf auf
  "nicht bewertet" zurücksetzen (bisher nur im Sitzplatz-Dialog möglich).
- Sitzplan: Strichliste "gemeldet"/"gemeldet und drangekommen" direkt auf
  der Sitzplatz-Kachel (ParticipationEntry.RaisedHandCount/CalledOnCount);
  schlägt im Sitzplatz-Dialog eine Quantitätsbewertung vor (ein Klick zum
  Übernehmen, nie automatisch).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-30 02:11:20 +02:00
adminandClaude Sonnet 5 538ad65a3a Dashboard-Kalender: Tageszahlen zentrieren, Karte nicht mehr strecken
Wochentag-Header waren zentriert, die Tageszahlen darunter aber
faktisch linksbündig (Button ohne HorizontalAlignment wurde in der
UniformGrid-Spalte gestretcht). Zusätzlich wurde die Kalenderkarte bei
breiten Fenstern über die ganze 2*-Spalte gezogen; MaxWidth+Left-Align
begrenzt sie auf ihre Inhaltsbreite.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-30 01:06:23 +02:00
admin dade41ee8d Stundenplan verbesserung
CI / build-and-test (push) Canceled after 0s
2026-08-30 00:53:32 +02:00
adminandClaude Sonnet 5 2b299fc940 Dashboard-Performance, CI-Workflow, Settings-Aufteilung, Backup-Härtung
- Dashboard: Fehlzeiten-Warnung lädt Mitarbeitssitzungen einmal vorab
  statt pro Schüler/Eintrag einzeln nachzuschlagen (N+1 vermieden);
  neues IParticipationSessionRepository.GetAll() dafür.
- CI: .gitea/workflows/ci.yml baut und testet bei jedem Push/PR.
  Dabei fehlende Release|Any CPU-Konfiguration für 6 Projekte in der
  .sln behoben (LehrerApp.Data.Tests wurde bei Release-Builds der
  Solution bislang stillschweigend übersprungen). TreatWarningsAsErrors
  jetzt aktiv.
- SettingsViewModel (1986 Zeilen) als partial class auf 20 Themen-
  dateien aufgeteilt, Verhalten unverändert.
- Backup: optionaler zweiter Sicherungsordner (USB-Stick/Netzlaufwerk,
  best-effort) und Integritätsprüfung nach jedem Backup
  (DatabaseEncryptionService.CanOpenAndRead).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-30 00:52:44 +02:00
adminandClaude Sonnet 5 dbd8777e64 Punkteeingabe: Enter und Pfeiltasten unterstützen beide Korrektur-Workflows
Enter läuft jetzt zeilenweise (Arbeit für Arbeit), Pfeil-Hoch/-Runter spaltenweise
mit Umbruch zur Nachbaraufgabe (Aufgabe für Aufgabe) - beides ohne Moduswechsel,
da sich beide Reihenfolgen ohnehin auf dieselbe Tabelle abbilden.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-29 22:25:53 +02:00
admin 89a6376fb6 Improve desktop UX and accessibility 2026-08-29 20:56:55 +02:00
admin b7a105af73 Dashboard und Schnellnavigation fix 2026-08-29 19:35:05 +02:00
104 changed files with 4706 additions and 2026 deletions
+24
View File
@@ -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"
+1 -1
View File
@@ -4,7 +4,7 @@
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>
<LangVersion>latest</LangVersion> <LangVersion>latest</LangVersion>
<TreatWarningsAsErrors>false</TreatWarningsAsErrors> <TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<!-- CS8618: falsch-positiv durch [ObservableProperty] Source Generator --> <!-- CS8618: falsch-positiv durch [ObservableProperty] Source Generator -->
<NoWarn>CS8618</NoWarn> <NoWarn>CS8618</NoWarn>
</PropertyGroup> </PropertyGroup>
@@ -225,6 +225,9 @@ public interface IParticipationSessionRepository
{ {
List<ParticipationSession> GetByGroup(Guid groupId); List<ParticipationSession> GetByGroup(Guid groupId);
ParticipationSession? GetById(Guid id); 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 Save(ParticipationSession session);
void Delete(Guid id); void Delete(Guid id);
} }
+7
View File
@@ -26,6 +26,13 @@ public class LearningGroup
/// deshalb händisch je Lerngruppe/Schuljahr gepflegt werden - keine JSON-RPC-Methode liefert sie. /// deshalb händisch je Lerngruppe/Schuljahr gepflegt werden - keine JSON-RPC-Methode liefert sie.
/// </summary> /// </summary>
public int? WebUntisLessonId { get; set; } 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 CreatedAt { get; set; } = DateTime.UtcNow;
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow; public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
} }
+32
View File
@@ -24,6 +24,14 @@ public class ParticipationEntry
// Bleibt für bereits gespeicherte Daten erhalten. Neue Schreibvorgänge setzen beide Felder. // Bleibt für bereits gespeicherte Daten erhalten. Neue Schreibvorgänge setzen beide Felder.
public bool HomeworkMissing { get; set; } public bool HomeworkMissing { get; set; }
public AttendanceStatus? Attendance { 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; }
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow; public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
} }
@@ -160,3 +168,27 @@ public static class ParticipationRatingScale
_ => value, _ => 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,
};
}
}
+4 -1
View File
@@ -100,7 +100,10 @@ public class LessonPhaseStep
} }
public enum UnitStatus { Planned, Active, Completed } 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> /// <summary>
/// Katalogeintrag für einen wiederverwendbaren "alternativen Ablauf" (z.B. "Kurzversion" bei /// Katalogeintrag für einen wiederverwendbaren "alternativen Ablauf" (z.B. "Kurzversion" bei
+1 -1
View File
@@ -93,7 +93,7 @@ public interface IHasAttachments
List<DocumentAttachment> Attachments { get; } List<DocumentAttachment> Attachments { get; }
} }
// Hinten angefügt, damit die numerischen Werte bereits gespeicherter LiteDB-Einträge stabil bleiben. // 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 enum SupportStatus { Active, Completed, Paused }
public class WorkTask public class WorkTask
+34
View File
@@ -14,6 +14,13 @@ public class BackupService
public string BackupDirectory => _backupDirectory; 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) public BackupService(string appDataPath)
{ {
_backupDirectory = Path.Combine(appDataPath, "backups"); _backupDirectory = Path.Combine(appDataPath, "backups");
@@ -34,9 +41,36 @@ public class BackupService
File.Copy(databasePath, target, overwrite: true); File.Copy(databasePath, target, overwrite: true);
PruneOldBackups(keepCount); PruneOldBackups(keepCount);
if (!string.IsNullOrWhiteSpace(SecondaryBackupDirectory))
TryMirrorToSecondary(target, fileName, keepCount);
return target; 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() => public List<BackupInfo> ListBackups() =>
Directory.GetFiles(_backupDirectory, "lehrerapp-*.db") Directory.GetFiles(_backupDirectory, "lehrerapp-*.db")
.Select(p => new BackupInfo(p, File.GetLastWriteTime(p), new FileInfo(p).Length)) .Select(p => new BackupInfo(p, File.GetLastWriteTime(p), new FileInfo(p).Length))
@@ -15,7 +15,7 @@ public sealed class DashboardSettingsService
public static readonly string[] DefaultCardOrder = public static readonly string[] DefaultCardOrder =
[ [
"today", "tasks", "calendar", "excuses", "upcoming", "today", "tasks", "calendar", "excuses", "upcoming",
"corrections", "unplanned", "alerts", "attendance", "support", "groups", "corrections", "unplanned", "alerts", "attendance", "support", "groups", "examload",
]; ];
private readonly string _configPath; 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);
}
@@ -60,6 +60,7 @@ public sealed class GroupRolloverService(
IsActive = true, IsActive = true,
IsOwnClass = source.IsOwnClass, IsOwnClass = source.IsOwnClass,
IsDifferentiated = source.IsDifferentiated, IsDifferentiated = source.IsDifferentiated,
PreviousGroupId = source.Id,
// WebUntisLessonId bewusst nicht übernommen: WebUntis vergibt sie pro Schuljahr neu, // WebUntisLessonId bewusst nicht übernommen: WebUntis vergibt sie pro Schuljahr neu,
// eine übernommene alte lsid würde in der Folgegruppe stumm falsche Fehlzeiten liefern. // 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,
};
}
@@ -60,6 +60,65 @@ public sealed class DatabaseEncryptionServiceTests
Assert.Single(reopened.GetCollection<BsonDocument>("t").FindAll()); 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 sealed class TempDatabase : IDisposable
{ {
private readonly string _directory = System.IO.Path.Combine( private readonly string _directory = System.IO.Path.Combine(
@@ -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 /// Setzt (newPassword != null), ändert oder entfernt (newPassword == null) das
/// Passwort der Datenbank. Der Aufrufer muss sicherstellen, dass keine andere /// Passwort der Datenbank. Der Aufrufer muss sicherstellen, dass keine andere
/// Verbindung (z.B. der laufende <see cref="LiteDbContext"/>) die Datei offen hält. /// Verbindung (z.B. der laufende <see cref="LiteDbContext"/>) die Datei offen hält.
@@ -492,6 +492,7 @@ public class ParticipationSessionRepository(LiteDbContext db) : IParticipationSe
public List<ParticipationSession> GetByGroup(Guid groupId) => public List<ParticipationSession> GetByGroup(Guid groupId) =>
db.ParticipationSessions.Find(s => s.GroupId == groupId).OrderByDescending(s => s.Date).ToList(); db.ParticipationSessions.Find(s => s.GroupId == groupId).OrderByDescending(s => s.Date).ToList();
public ParticipationSession? GetById(Guid id) => db.ParticipationSessions.FindById(id); public ParticipationSession? GetById(Guid id) => db.ParticipationSessions.FindById(id);
public List<ParticipationSession> GetAll() => db.ParticipationSessions.FindAll().ToList();
public void Save(ParticipationSession s) public void Save(ParticipationSession s)
{ {
ArchivedGroupWriteGuard.EnsureActive(db, s.GroupId); ArchivedGroupWriteGuard.EnsureActive(db, s.GroupId);
@@ -1,6 +1,7 @@
using LehrerApp.Core.Models; using LehrerApp.Core.Models;
using LehrerApp.Core.Services; using LehrerApp.Core.Services;
using LehrerApp.Desktop.ViewModels; using LehrerApp.Desktop.ViewModels;
using System.Globalization;
using Xunit; using Xunit;
namespace LehrerApp.Desktop.Tests; namespace LehrerApp.Desktop.Tests;
@@ -59,12 +60,12 @@ public sealed class DashboardViewModelTests
DashboardSettingsService? dashboardSettings = null, FakeSchoolHolidays? schoolHolidays = null, DashboardSettingsService? dashboardSettings = null, FakeSchoolHolidays? schoolHolidays = null,
FakeLessons? lessons = null, FakeSubstitutionEntries? substitutions = null, FakeLessons? lessons = null, FakeSubstitutionEntries? substitutions = null,
FakeSessions? sessions = null, FakeEntries? entries = null, FakeSessions? sessions = null, FakeEntries? entries = null,
FakeAnnualPlanEvents? annualPlanEvents = null) FakeAnnualPlanEvents? annualPlanEvents = null, List<LearningGroup>? allGroups = null)
{ {
lessons ??= new FakeLessons(); lessons ??= new FakeLessons();
lessons.Add(lesson); lessons.Add(lesson);
return new DashboardViewModel( 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(), exams ?? new FakeExams([]), results ?? new FakeResults(), grades ?? new FakeGrades(),
reportGrades ?? new FakeReportGrades(), memberships ?? new FakeMemberships([]), reportGrades ?? new FakeReportGrades(), memberships ?? new FakeMemberships([]),
tasks ?? new FakeWorkTasks(), sessions ?? new FakeSessions([]), entries ?? new FakeEntries(), tasks ?? new FakeWorkTasks(), sessions ?? new FakeSessions([]), entries ?? new FakeEntries(),
@@ -75,6 +76,67 @@ public sealed class DashboardViewModelTests
substitutions ?? new FakeSubstitutionEntries(), annualPlanEvents); substitutions ?? new FakeSubstitutionEntries(), 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] [Fact]
public void TodaysLessons_LoestRaumUeberPassendenStundenplanSlotAuf() public void TodaysLessons_LoestRaumUeberPassendenStundenplanSlotAuf()
{ {
@@ -242,6 +304,26 @@ public sealed class DashboardViewModelTests
Assert.Contains(vm.SelectedDayEvents, e => e.Kind == CalendarEventKind.Exam && e.Title == "Test"); 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] [Fact]
public void Kalender_ZeigtMitarbeitssitzungenAlsEigenenTermintyp() public void Kalender_ZeigtMitarbeitssitzungenAlsEigenenTermintyp()
{ {
@@ -247,6 +247,25 @@ public sealed class DocumentationDialogViewModelTests
Assert.Equal(groupId, vm.Result.GroupId); 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] [Fact]
public void MitStudentOptions_Bearbeiten_SchuelerIstVorausgewaehlt() 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); 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] [Fact]
public void Load_ParallelkurseWerdenAlsGeschwisterErkannt() public void Load_ParallelkurseWerdenAlsGeschwisterErkannt()
{ {
+1
View File
@@ -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 List<ParticipationSession> GetByGroup(Guid groupId) => all.Where(s => s.GroupId == groupId).ToList();
public ParticipationSession? GetById(Guid id) => all.FirstOrDefault(s => s.Id == id); public ParticipationSession? GetById(Guid id) => all.FirstOrDefault(s => s.Id == id);
public List<ParticipationSession> GetAll() => all.ToList();
public void Save(ParticipationSession session) public void Save(ParticipationSession session)
{ {
all.RemoveAll(s => s.Id == session.Id); all.RemoveAll(s => s.Id == session.Id);
@@ -14,7 +14,8 @@ public sealed class GlobalSearchViewModelTests
Assert.Collection(vm.Results, Assert.Collection(vm.Results,
item => Assert.Equal(GlobalSearchAction.NewTask, item.Action), item => Assert.Equal(GlobalSearchAction.NewTask, item.Action),
item => Assert.Equal(GlobalSearchAction.NewReminder, 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); Assert.Same(vm.Results[0], vm.SelectedResult);
} }
@@ -67,8 +68,36 @@ public sealed class GlobalSearchViewModelTests
Assert.True(reminder); 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, 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(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 grades = new FakeGrades();
var vm = new GroupDetailViewModel(groups, students, memberships, subjects, exams, grades, tasks, var vm = new GroupDetailViewModel(groups, students, memberships, subjects, exams, grades, tasks,
new GroupOverviewViewModel(new FakeLessons(), exams, new FakeSessions([]), new FakeEntries(), students, new GroupOverviewViewModel(new FakeLessons(), exams, new FakeResults(), new FakeSessions([]), new FakeEntries(), students,
new FakeDocumentation(), tasks, new AttendanceBalanceService(), new SchoolYearService()), new FakeDocumentation(), tasks, groups, new AttendanceBalanceService(), new GradingService(), new SchoolYearService()),
new ParticipationTabViewModel(new FakeSessions([]), new FakeEntries(), new FakeAspects(), new ParticipationTabViewModel(new FakeSessions([]), new FakeEntries(), new FakeAspects(),
students, memberships, groups, new FakeCompetencyDomains()), students, memberships, groups, new FakeCompetencyDomains()),
new GradeOverviewTabViewModel(grades, exams, new FakeResults(), students, memberships, new GradingService()), 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); 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] [Fact]
public void Initialize_EintragAusAndererGruppe_WirdMitangezeigtAberAlsFremdMarkiert() public void Initialize_EintragAusAndererGruppe_WirdMitangezeigtAberAlsFremdMarkiert()
{ {
@@ -12,10 +12,13 @@ public sealed class GroupOverviewViewModelTests
{ {
private static GroupOverviewViewModel NewVm(FakeLessons? lessons = null, FakeExams? exams = null, private static GroupOverviewViewModel NewVm(FakeLessons? lessons = null, FakeExams? exams = null,
FakeSessions? sessions = null, FakeEntries? entries = null, FakeStudents? students = null, FakeSessions? sessions = null, FakeEntries? entries = null, FakeStudents? students = null,
FakeDocumentation? documentation = null, FakeWorkTasks? tasks = null) => FakeDocumentation? documentation = null, FakeWorkTasks? tasks = null,
new(lessons ?? new FakeLessons(), exams ?? new FakeExams([]), sessions ?? new FakeSessions([]), 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(), 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, private static (GroupOverviewViewModel Vm, FakeLessons Lessons, FakeExams Exams,
FakeSessions Sessions, FakeEntries Entries, FakeStudents Students, Guid GroupId) BuildScenario( FakeSessions Sessions, FakeEntries Entries, FakeStudents Students, Guid GroupId) BuildScenario(
@@ -76,6 +79,86 @@ public sealed class GroupOverviewViewModelTests
Assert.Contains("Klassenarbeit 2", vm.NextExamLabel); 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] [Fact]
public void Mitarbeit_OhneSitzungenZeigtHinweisOhneWarnung() 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);
}
}
@@ -83,6 +83,26 @@ public sealed class QuickInputViewModelTests
Assert.Equal(0.4, vm.CurrentStudentContentOpacity); 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] [Fact]
public void VorherigerAspekt_SpringtRueckwaertsMitUmlauf() public void VorherigerAspekt_SpringtRueckwaertsMitUmlauf()
{ {
@@ -483,6 +483,35 @@ public sealed class SeatingPlanViewModelTests
Assert.True(entry.HomeworkMissing); Assert.True(entry.HomeworkMissing);
} }
[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] [Fact]
public void SitzplatzBewertung_ArchiviertOhneSitzung_LegtKeineNeueSitzungAn() public void SitzplatzBewertung_ArchiviertOhneSitzung_LegtKeineNeueSitzungAn()
{ {
@@ -517,6 +546,60 @@ public sealed class SeatingPlanViewModelTests
Assert.Null(vm.SelectedSession); 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] [Fact]
public async Task AssessStudent_LegtErstBeiTatsaechlicherBewertungEineSitzungAn() public async Task AssessStudent_LegtErstBeiTatsaechlicherBewertungEineSitzungAn()
{ {
@@ -28,6 +28,7 @@ public sealed class SettingsViewModelTests
return new SettingsViewModel( return new SettingsViewModel(
subjects ?? new FakeSubjects([]), competencyDomains ?? new FakeCompetencyDomains(), new FakeGradingKeyTemplates(), subjects ?? new FakeSubjects([]), competencyDomains ?? new FakeCompetencyDomains(), new FakeGradingKeyTemplates(),
new FakeSchemes(), new GradingService(), new BackupService(tempPath), new FakeSchemes(), new GradingService(), new BackupService(tempPath),
new BackupSettingsService(tempPath),
new DatabaseEncryptionService(), new AppLockService(tempPath), new DatabaseEncryptionService(), new AppLockService(tempPath),
new LiteDbContext(new MemoryStream()), new PrivacySettingsService(tempPath), new LiteDbContext(new MemoryStream()), new PrivacySettingsService(tempPath),
new FakeDocumentation(), new FakeStudents([]), new FakeShorthandCodes([]), new FakeDocumentation(), new FakeStudents([]), new FakeShorthandCodes([]),
@@ -314,6 +315,7 @@ public sealed class SettingsViewModelTests
var vm = new SettingsViewModel( var vm = new SettingsViewModel(
new FakeSubjects([]), new FakeCompetencyDomains(), new FakeGradingKeyTemplates(), new FakeSubjects([]), new FakeCompetencyDomains(), new FakeGradingKeyTemplates(),
new FakeSchemes(), new GradingService(), new BackupService(tempPath), new FakeSchemes(), new GradingService(), new BackupService(tempPath),
new BackupSettingsService(tempPath),
new DatabaseEncryptionService(), new AppLockService(tempPath), new DatabaseEncryptionService(), new AppLockService(tempPath),
new LiteDbContext(new MemoryStream()), new PrivacySettingsService(tempPath), new LiteDbContext(new MemoryStream()), new PrivacySettingsService(tempPath),
new FakeDocumentation(), new FakeStudents([]), new FakeShorthandCodes([]), new FakeDocumentation(), new FakeStudents([]), new FakeShorthandCodes([]),
@@ -341,6 +343,7 @@ public sealed class SettingsViewModelTests
var vm = new SettingsViewModel( var vm = new SettingsViewModel(
new FakeSubjects([]), new FakeCompetencyDomains(), new FakeGradingKeyTemplates(), new FakeSubjects([]), new FakeCompetencyDomains(), new FakeGradingKeyTemplates(),
new FakeSchemes(), new GradingService(), new BackupService(tempPath), new FakeSchemes(), new GradingService(), new BackupService(tempPath),
new BackupSettingsService(tempPath),
new DatabaseEncryptionService(), new AppLockService(tempPath), new DatabaseEncryptionService(), new AppLockService(tempPath),
new LiteDbContext(new MemoryStream()), new PrivacySettingsService(tempPath), new LiteDbContext(new MemoryStream()), new PrivacySettingsService(tempPath),
new FakeDocumentation(), new FakeStudents([]), new FakeShorthandCodes([]), new FakeDocumentation(), new FakeStudents([]), new FakeShorthandCodes([]),
@@ -372,6 +375,7 @@ public sealed class SettingsViewModelTests
var vm = new SettingsViewModel( var vm = new SettingsViewModel(
new FakeSubjects([]), new FakeCompetencyDomains(), new FakeGradingKeyTemplates(), new FakeSubjects([]), new FakeCompetencyDomains(), new FakeGradingKeyTemplates(),
new FakeSchemes(), new GradingService(), new BackupService(tempPath), new FakeSchemes(), new GradingService(), new BackupService(tempPath),
new BackupSettingsService(tempPath),
new DatabaseEncryptionService(), new AppLockService(tempPath), new DatabaseEncryptionService(), new AppLockService(tempPath),
new LiteDbContext(new MemoryStream()), new PrivacySettingsService(tempPath), new LiteDbContext(new MemoryStream()), new PrivacySettingsService(tempPath),
new FakeDocumentation(), new FakeStudents([]), new FakeShorthandCodes([]), new FakeDocumentation(), new FakeStudents([]), new FakeShorthandCodes([]),
@@ -51,6 +51,18 @@ public class StudentListViewModelTests
Assert.Equal("M", vm.Students.Single(s => s.Id == _ben.Id).AvatarLabel); 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( private StudentListViewModel BuildViewModel() => new(
new FakeStudents([_anna, _ben]), new FakeStudents([_anna, _ben]),
new FakeGroups([_group]), new FakeGroups([_group]),
@@ -277,7 +277,23 @@ public sealed class TimetableViewModelTests
Assert.Equal(group.Id, navigatedTo); Assert.Equal(group.Id, navigatedTo);
Assert.False(viewerOpened); 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) ──────────────────────────────────────────────── // ── Unterrichtsmodus (14.x) ────────────────────────────────────────────────
@@ -696,6 +712,31 @@ public sealed class TimetableViewModelTests
Assert.Equal("1", period2.BadgeText); 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] [Fact]
public void Load_KeineAnstehendenFerien_KeinBadge() public void Load_KeineAnstehendenFerien_KeinBadge()
{ {
+23
View File
@@ -7,6 +7,17 @@
<ResourceDictionary.MergedDictionaries> <ResourceDictionary.MergedDictionaries>
<ResourceInclude Source="avares://LehrerApp.Desktop/Styles/SemanticBrushes.axaml"/> <ResourceInclude Source="avares://LehrerApp.Desktop/Styles/SemanticBrushes.axaml"/>
</ResourceDictionary.MergedDictionaries> </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> </ResourceDictionary>
</Application.Resources> </Application.Resources>
@@ -24,5 +35,17 @@
<Setter Property="Opacity" Value="0.4"/> <Setter Property="Opacity" Value="0.4"/>
<Setter Property="FontSize" Value="13"/> <Setter Property="FontSize" Value="13"/>
</Style> </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.Styles>
</Application> </Application>
+26
View File
@@ -154,6 +154,9 @@ public class App : Application
dash.OnNavigateToExam = id => main.NavigateToGroupDetail(id, 4); // Tab "Klausuren" dash.OnNavigateToExam = id => main.NavigateToGroupDetail(id, 4); // Tab "Klausuren"
dash.OnNavigateToUnplannedLesson = id => main.NavigateToGroupDetail(id, 6); // Tab "Planung" dash.OnNavigateToUnplannedLesson = id => main.NavigateToGroupDetail(id, 6); // Tab "Planung"
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 // Globale Suche/Schnellerfassung (14.2): Navigation bleibt im MainWindow-VM, die
// vorhandenen Dialog-Helfer übernehmen Eingabe und Validierung. // vorhandenen Dialog-Helfer übernehmen Eingabe und Validierung.
var search = Services.GetRequiredService<GlobalSearchViewModel>(); var search = Services.GetRequiredService<GlobalSearchViewModel>();
@@ -170,6 +173,7 @@ public class App : Application
}; };
search.OnQuickAddTask = startAsReminder => ShowQuickTaskDialog(startAsReminder, dash); search.OnQuickAddTask = startAsReminder => ShowQuickTaskDialog(startAsReminder, dash);
search.OnQuickAddStudent = ShowAddStudentDialog; search.OnQuickAddStudent = ShowAddStudentDialog;
search.OnQuickAddGroupDocumentation = () => ShowQuickGroupDocumentationDialog(dash);
// StudentList → StudentDetail + Anlegen // StudentList → StudentDetail + Anlegen
var sl = Services.GetRequiredService<StudentListViewModel>(); var sl = Services.GetRequiredService<StudentListViewModel>();
@@ -203,4 +207,26 @@ public class App : Application
dashboard.RefreshCommand.Execute(null); dashboard.RefreshCommand.Execute(null);
Services.GetRequiredService<WorkTaskListViewModel>().Load(); Services.GetRequiredService<WorkTaskListViewModel>().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);
}
} }
+8 -2
View File
@@ -121,9 +121,15 @@ public static class AppBootstrapper
// ── Datensicherheit (13.3) ─────────────────────────────────────────── // ── Datensicherheit (13.3) ───────────────────────────────────────────
// Backup läuft vor dem Öffnen der Datenbank, rein dateibasiert (unabhängig von // Backup läuft vor dem Öffnen der Datenbank, rein dateibasiert (unabhängig von
// Verschlüsselung) — reine Datei-Kopie, kein offener LiteDB-Handle nötig. // Verschlüsselung) — reine Datei-Kopie, kein offener LiteDB-Handle nötig.
var backup = new BackupService(appData); var backupSettings = new BackupSettingsService(appData);
backup.CreateBackup(DbPath); 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(backup);
services.AddSingleton(backupSettings);
services.AddSingleton(_ => new AppLockService(appData)); services.AddSingleton(_ => new AppLockService(appData));
services.AddSingleton<DatabaseEncryptionService>(); services.AddSingleton<DatabaseEncryptionService>();
services.AddSingleton(_ => new PrivacySettingsService(appData)); services.AddSingleton(_ => new PrivacySettingsService(appData));
@@ -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 }));
}
@@ -50,12 +50,17 @@ public partial class DashboardViewModel : ObservableObject
/// hat rein rechnerisch schon 100 %, das ist noch kein auffälliges Muster, nur eine zu kleine /// hat rein rechnerisch schon 100 %, das ist noch kein auffälliges Muster, nur eine zu kleine
/// Stichprobe. Dieselbe Konstante wie GroupOverviewViewModel.AttendanceMinSampleSize. /// Stichprobe. Dieselbe Konstante wie GroupOverviewViewModel.AttendanceMinSampleSize.
private const int AttendanceMinSampleSize = 8; private const int AttendanceMinSampleSize = 8;
/// 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 _greeting = "";
[ObservableProperty] private string _currentDate = ""; [ObservableProperty] private string _currentDate = "";
[ObservableProperty] private string _currentSchoolYear = ""; [ObservableProperty] private string _currentSchoolYear = "";
[ObservableProperty] private DateOnly _calendarMonth = FirstOfMonth(DateTime.Today); [ObservableProperty] private DateOnly _calendarMonth = FirstOfMonth(DateTime.Today);
[ObservableProperty] private string _selectedDayLabel = ""; [ObservableProperty] private string _selectedDayLabel = "";
[ObservableProperty] private DateOnly _selectedCalendarDate = DateOnly.FromDateTime(DateTime.Today);
[ObservableProperty] private bool _isDashboardSettingsOpen; [ObservableProperty] private bool _isDashboardSettingsOpen;
[ObservableProperty] private bool _isWeatherPanelVisible; [ObservableProperty] private bool _isWeatherPanelVisible;
[ObservableProperty] private string _weatherSummary = ""; [ObservableProperty] private string _weatherSummary = "";
@@ -71,6 +76,7 @@ public partial class DashboardViewModel : ObservableObject
public ObservableCollection<CalendarDayCell> CalendarDays { get; } = []; public ObservableCollection<CalendarDayCell> CalendarDays { get; } = [];
public ObservableCollection<OpenExcuseItem> OpenExcuses { get; } = []; public ObservableCollection<OpenExcuseItem> OpenExcuses { get; } = [];
public ObservableCollection<AttendanceWarningItem> AttendanceWarnings { get; } = []; public ObservableCollection<AttendanceWarningItem> AttendanceWarnings { get; } = [];
public ObservableCollection<ExamWeekLoadItem> ExamWeekLoads { get; } = [];
public ObservableCollection<SupportPlanDueItem> SupportPlanReviews { get; } = []; public ObservableCollection<SupportPlanDueItem> SupportPlanReviews { get; } = [];
public ObservableCollection<UpcomingDateItem> UpcomingDates { get; } = []; public ObservableCollection<UpcomingDateItem> UpcomingDates { get; } = [];
public ObservableCollection<CorrectionProgressItem> OpenCorrections { get; } = []; public ObservableCollection<CorrectionProgressItem> OpenCorrections { get; } = [];
@@ -105,6 +111,7 @@ public partial class DashboardViewModel : ObservableObject
public DashboardCardOption UnplannedCard => Card("unplanned"); public DashboardCardOption UnplannedCard => Card("unplanned");
public DashboardCardOption AlertsCard => Card("alerts"); public DashboardCardOption AlertsCard => Card("alerts");
public DashboardCardOption AttendanceCard => Card("attendance"); public DashboardCardOption AttendanceCard => Card("attendance");
public DashboardCardOption ExamLoadCard => Card("examload");
public DashboardCardOption SupportCard => Card("support"); public DashboardCardOption SupportCard => Card("support");
public DashboardCardOption GroupsCard => Card("groups"); public DashboardCardOption GroupsCard => Card("groups");
public int TodayLessonCount => TodaysLessons.Count; public int TodayLessonCount => TodaysLessons.Count;
@@ -196,7 +203,7 @@ public partial class DashboardViewModel : ObservableObject
IsHighPriority = t.Priority == TaskPriority.High }); IsHighPriority = t.Priority == TaskPriority.High });
CurrentGroups.Clear(); CurrentGroups.Clear();
foreach (var g in groups.Values.OrderBy(g => g.Name)) foreach (var g in groups.Values)
CurrentGroups.Add(new() CurrentGroups.Add(new()
{ {
GroupId = g.Id, GroupId = g.Id,
@@ -208,6 +215,7 @@ public partial class DashboardViewModel : ObservableObject
LoadCalendar(); LoadCalendar();
LoadOpenExcuses(groups.Values.ToList(), today); LoadOpenExcuses(groups.Values.ToList(), today);
LoadAttendanceWarnings(today); LoadAttendanceWarnings(today);
LoadExamWeekLoads(groups, today);
LoadSupportPlanReviews(today); LoadSupportPlanReviews(today);
LoadUpcomingDates(groups, today); LoadUpcomingDates(groups, today);
LoadOpenCorrections(groups, today); LoadOpenCorrections(groups, today);
@@ -227,6 +235,7 @@ public partial class DashboardViewModel : ObservableObject
UnplannedCard.IsEmpty = UnplannedLessons.Count == 0; UnplannedCard.IsEmpty = UnplannedLessons.Count == 0;
AlertsCard.IsEmpty = Alerts.Count == 0; AlertsCard.IsEmpty = Alerts.Count == 0;
AttendanceCard.IsEmpty = AttendanceWarnings.Count == 0; AttendanceCard.IsEmpty = AttendanceWarnings.Count == 0;
ExamLoadCard.IsEmpty = ExamWeekLoads.Count == 0;
SupportCard.IsEmpty = SupportPlanReviews.Count == 0; SupportCard.IsEmpty = SupportPlanReviews.Count == 0;
GroupsCard.IsEmpty = CurrentGroups.Count == 0; GroupsCard.IsEmpty = CurrentGroups.Count == 0;
@@ -300,12 +309,16 @@ public partial class DashboardViewModel : ObservableObject
var from = _sy.SchoolYearStart(schoolYear); var from = _sy.SchoolYearStart(schoolYear);
var to = _sy.SchoolYearEnd(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>(); var items = new List<AttendanceWarningItem>();
foreach (var student in _students.GetAll()) foreach (var student in _students.GetAll())
{ {
var entries = _participationEntries.GetByStudent(student.Id) var entries = _participationEntries.GetByStudent(student.Id)
.Select(e => _participationSessions.GetById(e.SessionId) is { } session .Select(e => sessionDates.TryGetValue(e.SessionId, out var date)
? ((DateOnly?)session.Date, e.Attendance) : (null, e.Attendance)) ? ((DateOnly?)date, e.Attendance) : (null, e.Attendance))
.Where(t => t.Item1.HasValue) .Where(t => t.Item1.HasValue)
.Select(t => (t.Item1!.Value, t.Attendance)); .Select(t => (t.Item1!.Value, t.Attendance));
@@ -317,6 +330,27 @@ public partial class DashboardViewModel : ObservableObject
AttendanceWarnings.Add(item); 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));
}
// ── Förderplan-Wiedervorlage (5.3.2) ────────────────────────────────────── // ── Förderplan-Wiedervorlage (5.3.2) ──────────────────────────────────────
private void LoadSupportPlanReviews(DateOnly today) private void LoadSupportPlanReviews(DateOnly today)
@@ -542,7 +576,7 @@ public partial class DashboardViewModel : ObservableObject
"excuses" => "Offene Entschuldigungen", "upcoming" => "Anstehende Termine", "excuses" => "Offene Entschuldigungen", "upcoming" => "Anstehende Termine",
"corrections" => "Offene Korrekturen", "unplanned" => "Ungeplante Stunden", "alerts" => "Auffälligkeiten", "corrections" => "Offene Korrekturen", "unplanned" => "Ungeplante Stunden", "alerts" => "Auffälligkeiten",
"attendance" => "Fehlzeiten-Warnung", "support" => "Förderplan-Wiedervorlage", "attendance" => "Fehlzeiten-Warnung", "support" => "Förderplan-Wiedervorlage",
"groups" => "Meine Lerngruppen", _ => key, "groups" => "Meine Lerngruppen", "examload" => "Klausurwochen", _ => key,
}; };
private void ApplyCardLayout() private void ApplyCardLayout()
@@ -749,9 +783,35 @@ public partial class DashboardViewModel : ObservableObject
{ {
if (day is null) return; if (day is null) return;
foreach (var cell in CalendarDays) cell.IsSelected = cell == day; foreach (var cell in CalendarDays) cell.IsSelected = cell == day;
SelectedCalendarDate = day.Date;
SelectedDayLabel = day.Date.ToString("dddd, d. MMMM", De); SelectedDayLabel = day.Date.ToString("dddd, d. MMMM", De);
SelectedDayEvents.Clear(); SelectedDayEvents.Clear();
foreach (var item in day.Events) SelectedDayEvents.Add(item); 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] [RelayCommand]
@@ -891,7 +951,13 @@ public class LessonItem
public bool HasRoom => !string.IsNullOrWhiteSpace(Room); 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 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) ──────────────────────── // ── Offene Entschuldigungen (aus Mitarbeit-Fehltagen) ────────────────────────
@@ -929,6 +995,13 @@ public class AttendanceWarningItem(Guid studentId, string studentName, double ab
public double AbsenceRatePercent { get; } = absenceRatePercent; 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";
}
// ── Förderplan-Wiedervorlage (5.3.2) ────────────────────────────────────────── // ── Förderplan-Wiedervorlage (5.3.2) ──────────────────────────────────────────
public class SupportPlanDueItem public class SupportPlanDueItem
@@ -43,6 +43,8 @@ public partial class ExamsOverviewViewModel : ObservableObject
public Func<Exam, Task>? OnGradeExam { get; set; } public Func<Exam, Task>? OnGradeExam { get; set; }
public Func<Exam, Task>? OnEvaluateExam { get; set; } public Func<Exam, Task>? OnEvaluateExam { get; set; }
public Action<Guid>? OnNavigateToGroup { 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, public ExamsOverviewViewModel(IExamRepository exams, IExamResultRepository examResults,
IGroupRepository groups, IGroupMembershipRepository memberships, GradingService grading, IGroupRepository groups, IGroupMembershipRepository memberships, GradingService grading,
@@ -85,6 +87,7 @@ public partial class ExamsOverviewViewModel : ObservableObject
Rows.Add(row); Rows.Add(row);
EmptyHint = Rows.Count == 0 ? "Keine Klausuren angelegt." : ""; EmptyHint = Rows.Count == 0 ? "Keine Klausuren angelegt." : "";
OnPropertyChanged(nameof(HasNoExams));
SelectedRow = selectedId is { } id SelectedRow = selectedId is { } id
? Rows.FirstOrDefault(r => r.Exam.Id == id) ?? Rows.FirstOrDefault() ? Rows.FirstOrDefault(r => r.Exam.Id == id) ?? Rows.FirstOrDefault()
: Rows.FirstOrDefault(); : Rows.FirstOrDefault();
@@ -173,6 +176,9 @@ public partial class ExamsOverviewViewModel : ObservableObject
OnNavigateToGroup?.Invoke(SelectedRow.GroupId); OnNavigateToGroup?.Invoke(SelectedRow.GroupId);
} }
[RelayCommand]
private void GoToGroups() => OnNavigateToGroups?.Invoke();
[RelayCommand] [RelayCommand]
private void ToggleApproval() => ToggleDate(SelectedRow?.Exam, e => e.ApprovalGrantedAt, private void ToggleApproval() => ToggleDate(SelectedRow?.Exam, e => e.ApprovalGrantedAt,
(e, v) => e.ApprovalGrantedAt = v); (e, v) => e.ApprovalGrantedAt = v);
@@ -17,6 +17,7 @@ public partial class GlobalSearchViewModel : ObservableObject
private readonly IStudentRepository _students; private readonly IStudentRepository _students;
private readonly IGroupRepository _groups; private readonly IGroupRepository _groups;
private readonly ISubjectRepository _subjects;
private readonly IExamRepository _exams; private readonly IExamRepository _exams;
private readonly IWorkTaskRepository _tasks; private readonly IWorkTaskRepository _tasks;
@@ -30,13 +31,15 @@ public partial class GlobalSearchViewModel : ObservableObject
public Action<GlobalSearchResult>? OnNavigate { get; set; } public Action<GlobalSearchResult>? OnNavigate { get; set; }
public Func<bool, Task>? OnQuickAddTask { get; set; } public Func<bool, Task>? OnQuickAddTask { get; set; }
public Func<Task>? OnQuickAddStudent { get; set; } public Func<Task>? OnQuickAddStudent { get; set; }
public Func<Task>? OnQuickAddGroupDocumentation { get; set; }
public Action? OnClose { get; set; } public Action? OnClose { get; set; }
public GlobalSearchViewModel(IStudentRepository students, IGroupRepository groups, public GlobalSearchViewModel(IStudentRepository students, IGroupRepository groups,
IExamRepository exams, IWorkTaskRepository tasks) ISubjectRepository subjects, IExamRepository exams, IWorkTaskRepository tasks)
{ {
_students = students; _students = students;
_groups = groups; _groups = groups;
_subjects = subjects;
_exams = exams; _exams = exams;
_tasks = tasks; _tasks = tasks;
RefreshResults(); RefreshResults();
@@ -59,23 +62,34 @@ public partial class GlobalSearchViewModel : ObservableObject
if (query.Length > 0) 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 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>(); 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)) .Where(s => Matches(s.FullName, query))
.Select(s => GlobalSearchResult.ForStudent(s))); .Select(s => GlobalSearchResult.ForStudent(s)));
candidates.AddRange(groups candidates.AddRange(groups
.Where(g => Matches($"{g.Name} {g.SchoolYear} {g.GradeLevel}", query)) .Where(g => Matches($"{g.Name} {GroupSubject(g)?.Name} {GroupSubject(g)?.ShortName} {g.SchoolYear} {g.GradeLevel}", query))
.Select(GlobalSearchResult.ForGroup)); .Select(g => GlobalSearchResult.ForGroup(g, SubjectLabel(GroupSubject(g)))));
candidates.AddRange(_exams.GetAll() candidates.AddRange(_exams.GetAll()
.Where(e => groupNames.ContainsKey(e.GroupId))
.Where(e => Matches($"{e.Title} {groupNames.GetValueOrDefault(e.GroupId)}", query)) .Where(e => Matches($"{e.Title} {groupNames.GetValueOrDefault(e.GroupId)}", query))
.Select(e => GlobalSearchResult.ForExam(e, groupNames.GetValueOrDefault(e.GroupId) ?? ""))); .Select(e => GlobalSearchResult.ForExam(e, groupNames.GetValueOrDefault(e.GroupId) ?? "")));
candidates.AddRange(_tasks.GetAll() 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)) .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) ?? ""))); .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.NewTask, "Aufgabe anlegen", "Mit Fälligkeit, Gruppe und Priorität", ""),
GlobalSearchResult.ForAction(GlobalSearchAction.NewReminder, "Erinnerung anlegen", "Kurze Notiz ohne Zeiterfassung", "◷"), GlobalSearchResult.ForAction(GlobalSearchAction.NewReminder, "Erinnerung anlegen", "Kurze Notiz ohne Zeiterfassung", "◷"),
GlobalSearchResult.ForAction(GlobalSearchAction.NewStudent, "Schüler anlegen", "Neue Stammdaten erfassen", ""), 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))) 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: case GlobalSearchAction.NewStudent:
if (OnQuickAddStudent is not null) await OnQuickAddStudent(); if (OnQuickAddStudent is not null) await OnQuickAddStudent();
break; break;
case GlobalSearchAction.NewGroupDocumentation:
if (OnQuickAddGroupDocumentation is not null) await OnQuickAddGroupDocumentation();
break;
default: default:
OnNavigate?.Invoke(result); OnNavigate?.Invoke(result);
break; break;
@@ -134,7 +152,7 @@ public partial class GlobalSearchViewModel : ObservableObject
} }
public enum GlobalSearchResultKind { Action, Student, Group, Exam, Task } 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 public sealed class GlobalSearchResult
{ {
@@ -171,10 +189,13 @@ public sealed class GlobalSearchResult
Title = student.FullName, Subtitle = student.IsActive ? "Aktiv" : "Inaktiv", Icon = "P", 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, 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() 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 _titleError = "";
[ObservableProperty] private string _dateTextError = ""; [ObservableProperty] private string _dateTextError = "";
[ObservableProperty] private string _returnedAtTextError = ""; [ObservableProperty] private string _returnedAtTextError = "";
[ObservableProperty] private string _weekLoadHint = "";
[ObservableProperty] private double _totalPoints; [ObservableProperty] private double _totalPoints;
[ObservableProperty] private bool _hasCompetencyCatalog; [ObservableProperty] private bool _hasCompetencyCatalog;
[ObservableProperty] private bool _useWeighting; [ObservableProperty] private bool _useWeighting;
@@ -108,6 +109,7 @@ public partial class ExamDialogViewModel : ObservableObject
} }
foreach (var t in Tasks) t.ShowWeight = UseWeighting; foreach (var t in Tasks) t.ShowWeight = UseWeighting;
RecomputeTotals(); RecomputeTotals();
UpdateWeekLoadHint();
} }
partial void OnUseWeightingChanged(bool value) partial void OnUseWeightingChanged(bool value)
@@ -115,6 +117,26 @@ public partial class ExamDialogViewModel : ObservableObject
foreach (var t in Tasks) t.ShowWeight = value; 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] [RelayCommand]
private void AddTask() private void AddTask()
{ {
@@ -25,6 +25,7 @@ public partial class GroupDocumentationTabViewModel : ObservableObject
private List<StudentOption> _groupStudents = []; private List<StudentOption> _groupStudents = [];
public static readonly StudentOption AllStudentsOption = new(Guid.Empty, "Alle Schüler"); 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<DocumentationItem> Entries { get; } = [];
public ObservableCollection<StudentOption> StudentFilterOptions { get; } = [AllStudentsOption]; public ObservableCollection<StudentOption> StudentFilterOptions { get; } = [AllStudentsOption];
@@ -62,8 +63,6 @@ public partial class GroupDocumentationTabViewModel : ObservableObject
private void Load() private void Load()
{ {
Entries.Clear(); Entries.Clear();
if (_groupStudents.Count == 0) return;
var studentNameById = _groupStudents.ToDictionary(s => s.Id, s => s.Name); var studentNameById = _groupStudents.ToDictionary(s => s.Id, s => s.Name);
var groupNameCache = new Dictionary<Guid, string>(); var groupNameCache = new Dictionary<Guid, string>();
string GroupLabel(Guid id) string GroupLabel(Guid id)
@@ -80,6 +79,10 @@ public partial class GroupDocumentationTabViewModel : ObservableObject
var all = relevantStudentIds var all = relevantStudentIds
.SelectMany(id => _docs.GetByStudent(id)) .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) .Where(d => !OnlyThisGroup || d.GroupId == _groupId)
.OrderByDescending(d => d.IsDraft) .OrderByDescending(d => d.IsDraft)
.ThenByDescending(d => d.Date) .ThenByDescending(d => d.Date)
@@ -90,7 +93,10 @@ public partial class GroupDocumentationTabViewModel : ObservableObject
{ {
var isOwnGroup = d.GroupId is null || d.GroupId == _groupId; var isOwnGroup = d.GroupId is null || d.GroupId == _groupId;
var otherGroupLabel = isOwnGroup ? "" : GroupLabel(d.GroupId!.Value); 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)); isOwnGroup, otherGroupLabel));
} }
} }
@@ -100,7 +106,7 @@ public partial class GroupDocumentationTabViewModel : ObservableObject
[RelayCommand] [RelayCommand]
private async Task AddDocumentation() private async Task AddDocumentation()
{ {
if (OnEditDocumentation is null || _groupStudents.Count == 0) return; if (OnEditDocumentation is null) return;
var result = await OnEditDocumentation(_groupId, _groupStudents, null); var result = await OnEditDocumentation(_groupId, _groupStudents, null);
if (result is null) return; if (result is null) return;
_docs.Save(result); _docs.Save(result);
@@ -33,12 +33,15 @@ public partial class GroupOverviewViewModel : ObservableObject
private readonly ILessonRepository _lessons; private readonly ILessonRepository _lessons;
private readonly IExamRepository _exams; private readonly IExamRepository _exams;
private readonly IExamResultRepository _examResults;
private readonly IParticipationSessionRepository _sessions; private readonly IParticipationSessionRepository _sessions;
private readonly IParticipationRepository _entries; private readonly IParticipationRepository _entries;
private readonly IStudentRepository _students; private readonly IStudentRepository _students;
private readonly IDocumentationRepository _documentation; private readonly IDocumentationRepository _documentation;
private readonly IWorkTaskRepository _tasks; private readonly IWorkTaskRepository _tasks;
private readonly IGroupRepository _groups;
private readonly AttendanceBalanceService _attendanceBalance; private readonly AttendanceBalanceService _attendanceBalance;
private readonly GradingService _grading;
private readonly SchoolYearService _schoolYear; private readonly SchoolYearService _schoolYear;
private Guid _groupId; private Guid _groupId;
@@ -48,6 +51,8 @@ public partial class GroupOverviewViewModel : ObservableObject
[ObservableProperty] private string _nextLessonLabel = ""; [ObservableProperty] private string _nextLessonLabel = "";
[ObservableProperty] private bool _hasNextExam; [ObservableProperty] private bool _hasNextExam;
[ObservableProperty] private string _nextExamLabel = ""; [ObservableProperty] private string _nextExamLabel = "";
[ObservableProperty] private bool _hasYearComparison;
[ObservableProperty] private string _yearComparisonLabel = "";
[ObservableProperty] private string _participationHintLabel = ""; [ObservableProperty] private string _participationHintLabel = "";
[ObservableProperty] private bool _participationHintIsStale; [ObservableProperty] private bool _participationHintIsStale;
[ObservableProperty] private bool _hasOpenHomeworkCheck; [ObservableProperty] private bool _hasOpenHomeworkCheck;
@@ -105,13 +110,14 @@ public partial class GroupOverviewViewModel : ObservableObject
} }
public GroupOverviewViewModel(ILessonRepository lessons, IExamRepository exams, public GroupOverviewViewModel(ILessonRepository lessons, IExamRepository exams,
IParticipationSessionRepository sessions, IParticipationRepository entries, IExamResultRepository examResults, IParticipationSessionRepository sessions,
IStudentRepository students, IDocumentationRepository documentation, IWorkTaskRepository tasks, IParticipationRepository entries, IStudentRepository students,
AttendanceBalanceService attendanceBalance, SchoolYearService schoolYear) 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; _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) public void Initialize(Guid groupId, string groupName)
@@ -126,6 +132,7 @@ public partial class GroupOverviewViewModel : ObservableObject
var today = DateOnly.FromDateTime(DateTime.Today); var today = DateOnly.FromDateTime(DateTime.Today);
LoadNextLesson(today); LoadNextLesson(today);
LoadNextExam(today); LoadNextExam(today);
LoadYearComparison();
LoadParticipationHint(today); LoadParticipationHint(today);
LoadOpenHomeworkCheck(today); LoadOpenHomeworkCheck(today);
LoadOpenExcuses(today); LoadOpenExcuses(today);
@@ -138,7 +145,7 @@ public partial class GroupOverviewViewModel : ObservableObject
private void LoadNextLesson(DateOnly today) private void LoadNextLesson(DateOnly today)
{ {
var next = _lessons.GetByGroupAndRange(_groupId, today, today.AddDays(NextLessonLookaheadDays)) var next = _lessons.GetByGroupAndRange(_groupId, today, today.AddDays(NextLessonLookaheadDays))
.Where(l => l.Status == LessonStatus.Planned) .Where(l => l.Status != LessonStatus.Conducted)
.OrderBy(l => l.Date).ThenBy(l => l.LessonNumber ?? 0) .OrderBy(l => l.Date).ThenBy(l => l.LessonNumber ?? 0)
.FirstOrDefault(); .FirstOrDefault();
@@ -156,6 +163,38 @@ public partial class GroupOverviewViewModel : ObservableObject
NextExamLabel = next is null ? "" : $"{next.Date:dd.MM.yyyy} — {next.Title}"; 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 /// Erinnert nicht an eine Note, sondern schlicht daran, überhaupt wieder eine Sitzung
/// anzulegen — genau das vergisst man in Kursen, die man seltener unterrichtet, zuerst. /// anzulegen — genau das vergisst man in Kursen, die man seltener unterrichtet, zuerst.
private void LoadParticipationHint(DateOnly today) private void LoadParticipationHint(DateOnly today)
@@ -824,7 +824,7 @@ public partial class QuickInputViewModel : ObservableObject
_ => "15 bewerten", _ => "15 bewerten",
}; };
return $"{ratingHint} · Q/W/E/R/T Aspekt wählen · Leertaste/↓ nächster Aspekt · ↑ vorheriger Aspekt · " + 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 · Esc schließen";
} }
} }
@@ -902,6 +902,13 @@ public partial class QuickInputViewModel : ObservableObject
ApplyRating(steps[Math.Clamp(idx + 1, 0, steps.Count - 1)]); 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() public void DecrementRating()
{ {
var cell = GetCurrentCell(); var cell = GetCurrentCell();
@@ -918,7 +925,7 @@ public partial class QuickInputViewModel : ObservableObject
ApplyRating(steps[Math.Clamp(idx - 1, 0, steps.Count - 1)]); 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; if (_rows.Count == 0 || !_aspects.Any()) return;
var key = _aspects[Math.Clamp(AspectIndex, 0, _aspects.Count - 1)].Key; var key = _aspects[Math.Clamp(AspectIndex, 0, _aspects.Count - 1)].Key;
@@ -13,7 +13,8 @@ namespace LehrerApp.Desktop.ViewModels.Groups;
// ── Ergebnisse der Verschieben-/Kopieren-/Serienerzeugungs-Dialoge (4.2.4 / 4.1.4 / 4.2.5) ─── // ── 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 CopyUnitTarget(Guid TargetGroupId, DateOnly AnchorDate);
public record LessonSeriesResult(int Created, int SkippedHoliday, int SkippedExisting) 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<LessonSeriesResult?>>? OnGenerateLessonSeries { get; set; }
public Func<Unit, Task<bool>>? OnAiAssist { get; set; } public Func<Unit, Task<bool>>? OnAiAssist { get; set; }
public Action<string>? OnNotify { get; set; } public Action<string>? OnNotify { get; set; }
public Action<string>? OnError { get; set; }
public PlanningTabViewModel(IUnitRepository units, ILessonRepository lessons, public PlanningTabViewModel(IUnitRepository units, ILessonRepository lessons,
IGroupRepository groups, ISubjectRepository subjects, IGroupRepository groups, ISubjectRepository subjects,
@@ -333,7 +335,8 @@ public partial class PlanningTabViewModel : ObservableObject
var lesson = SelectedLesson.Model; var lesson = SelectedLesson.Model;
var target = await OnPickMoveTarget(lesson); var target = await OnPickMoveTarget(lesson);
if (target is null) return; 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(); LoadUnits();
} }
@@ -341,33 +344,19 @@ public partial class PlanningTabViewModel : ObservableObject
/// sich alle anderen noch geplanten Stunden derselben Einheit, die ursprünglich NACH der /// 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 /// verschobenen Stunde lagen, um denselben Tages-Delta. Bereits durchgeführte Stunden werden
/// nie angefasst — nur Date ändert sich, UnitId/GroupId bleiben unverändert. /// 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; new LessonSchedulingService(_lessons).Move(moved, newDate, newPeriod, shiftFollowing);
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);
} }
[RelayCommand(CanExecute = nameof(HasSelectedLesson))] [RelayCommand(CanExecute = nameof(HasSelectedLesson))]
private void AdvanceLessonStatus() 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; var lesson = SelectedLesson.Model;
lesson.Status = LessonStatus.Conducted; lesson.Status = lesson.Status == LessonStatus.Ready
? LessonStatus.Conducted
: LessonStatus.Ready;
_lessons.Save(lesson); _lessons.Save(lesson);
LoadUnits(); LoadUnits();
} }
@@ -479,8 +468,14 @@ public class LessonSummary
Topic = l.Topic; Topic = l.Topic;
StartTimeDisplay = l.StartTime?.ToString("HH:mm") ?? ""; StartTimeDisplay = l.StartTime?.ToString("HH:mm") ?? "";
Status = l.Status; Status = l.Status;
StatusLabel = l.Status == LessonStatus.Conducted ? "Durchgeführt" : "Geplant"; StatusLabel = LessonStatusDisplay.ToName(l.Status);
StatusColorHex = l.Status == LessonStatus.Conducted ? "#43A047" : "#9E9E9E"; StatusColorHex = l.Status switch
{
LessonStatus.Draft => "#78909C",
LessonStatus.Ready => "#1976D2",
LessonStatus.Conducted => "#43A047",
_ => "#9E9E9E",
};
PhaseCountLabel = l.Phases.Count == 0 ? "" : $"{l.Phases.Count} Phasen"; PhaseCountLabel = l.Phases.Count == 0 ? "" : $"{l.Phases.Count} Phasen";
var totalMinutes = l.Phases.Sum(p => p.DurationMinutes); var totalMinutes = l.Phases.Sum(p => p.DurationMinutes);
TotalDurationLabel = totalMinutes == 0 ? "" : $"{totalMinutes} Min."; TotalDurationLabel = totalMinutes == 0 ? "" : $"{totalMinutes} Min.";
@@ -515,12 +510,23 @@ public static class UnitStatusDisplay
public static class LessonStatusDisplay 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) => public static LessonStatus FromName(string? name) => name switch
name == "Durchgeführt" ? LessonStatus.Conducted : LessonStatus.Planned; {
"Entwurf" => LessonStatus.Draft,
"Bereit" => LessonStatus.Ready,
"Durchgeführt" => LessonStatus.Conducted,
_ => LessonStatus.Planned,
};
} }
// ── Dialog: Einheit anlegen / bearbeiten (4.1.2 / 4.1.3) ───────────────────── // ── Dialog: Einheit anlegen / bearbeiten (4.1.2 / 4.1.3) ─────────────────────
@@ -722,7 +728,8 @@ public partial class LessonDialogViewModel : ObservableObject
IAlternativeLessonPathRepository alternativePaths, ITimetableSlotRepository timetableSlots, IAlternativeLessonPathRepository alternativePaths, ITimetableSlotRepository timetableSlots,
PeriodScheduleService periodSchedule, IAttachmentStorage attachmentStorage, PeriodScheduleService periodSchedule, IAttachmentStorage attachmentStorage,
Guid unitId, Guid groupId, string groupName, string subjectName, 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; _lessons = lessons; _alternativePaths = alternativePaths;
_timetableSlots = timetableSlots; _periodSchedule = periodSchedule; _timetableSlots = timetableSlots; _periodSchedule = periodSchedule;
@@ -760,9 +767,12 @@ public partial class LessonDialogViewModel : ObservableObject
} }
else else
{ {
var (date, lessonNumber) = SuggestNextLesson(); var (date, lessonNumber) = suggestedDate.HasValue
? (suggestedDate.Value, suggestedPeriod)
: SuggestNextLesson();
DateText = date.ToString("dd.MM.yyyy"); DateText = date.ToString("dd.MM.yyyy");
LessonNumber = lessonNumber; LessonNumber = lessonNumber;
StatusName = LessonStatusDisplay.ToName(LessonStatus.Draft);
} }
RecomputeTimes(); RecomputeTimes();
} }
@@ -1145,28 +1155,45 @@ public partial class AlternativePathDialogViewModel : ObservableObject
public partial class MoveLessonDialogViewModel : ObservableObject public partial class MoveLessonDialogViewModel : ObservableObject
{ {
[ObservableProperty] private string _newDateText; [ObservableProperty] private string _newDateText;
[ObservableProperty] private int? _newPeriod;
[ObservableProperty] private bool _shiftFollowingPlanned = true; [ObservableProperty] private bool _shiftFollowingPlanned = true;
[ObservableProperty] private bool _splitDoubleLesson;
[ObservableProperty] private string _newDateTextError = ""; [ObservableProperty] private string _newDateTextError = "";
[ObservableProperty] private string _newPeriodError = "";
public string CurrentDateDisplay { get; } public string CurrentDateDisplay { get; }
public string CurrentPeriodDisplay { get; }
public bool CanSplitDoubleLesson { get; }
public MoveLessonTarget? Result { get; private set; } 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"); CurrentDateDisplay = currentDate.ToString("dd.MM.yyyy");
CurrentPeriodDisplay = currentPeriod is null ? "nicht festgelegt" : $"{currentPeriod}. Stunde";
_newDateText = currentDate.ToString("dd.MM.yyyy"); _newDateText = currentDate.ToString("dd.MM.yyyy");
_newPeriod = currentPeriod;
CanSplitDoubleLesson = canSplitDoubleLesson;
_splitDoubleLesson = canSplitDoubleLesson && splitByDefault;
} }
[RelayCommand] [RelayCommand]
private void Save() private void Save()
{ {
NewDateTextError = ""; NewDateTextError = "";
NewPeriodError = "";
if (!DateOnly.TryParseExact(NewDateText, "dd.MM.yyyy", null, DateTimeStyles.None, out var date)) if (!DateOnly.TryParseExact(NewDateText, "dd.MM.yyyy", null, DateTimeStyles.None, out var date))
{ {
NewDateTextError = "Format TT.MM.JJJJ."; NewDateTextError = "Format TT.MM.JJJJ.";
return; 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);
} }
} }
@@ -227,7 +227,7 @@ public partial class SeatingPlanTabViewModel : ObservableObject
?? StudentSeatOption.Empty; ?? StudentSeatOption.Empty;
var isHidden = hiddenSeats.Any(h => h.Row == row && h.Column == column); var isHidden = hiddenSeats.Any(h => h.Row == row && h.Column == column);
Seats.Add(new SeatCellViewModel(row, column, StudentOptions, option, OnSeatChanged, Seats.Add(new SeatCellViewModel(row, column, StudentOptions, option, OnSeatChanged,
CanEditLayout, ToggleSituationTag, IsEditable, isHidden, ToggleSeatHidden)); CanEditLayout, ToggleSituationTag, IsEditable, isHidden, ToggleSeatHidden, TallyParticipation));
} }
UpdateAssignmentSummary(); UpdateAssignmentSummary();
RefreshSeatLessonData(); RefreshSeatLessonData();
@@ -266,6 +266,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) private void ToggleSituationTag(SeatCellViewModel seat, string tag)
{ {
if (!IsEditable || _documentation is null || if (!IsEditable || _documentation is null ||
@@ -531,6 +547,8 @@ public partial class SeatCellViewModel : ObservableObject
[ObservableProperty] private double _lessonOpacity = 1.0; [ObservableProperty] private double _lessonOpacity = 1.0;
[ObservableProperty] private string _attendanceBadge = ""; [ObservableProperty] private string _attendanceBadge = "";
[ObservableProperty] private string _homeworkBadge = ""; [ObservableProperty] private string _homeworkBadge = "";
[ObservableProperty] private int _raisedHandCount;
[ObservableProperty] private int _calledOnCount;
public ObservableCollection<SituationTagChoice> SituationTags { get; } = []; public ObservableCollection<SituationTagChoice> SituationTags { get; } = [];
public bool HasAttendanceBadge => AttendanceBadge.Length > 0; public bool HasAttendanceBadge => AttendanceBadge.Length > 0;
public bool HasHomeworkBadge => HomeworkBadge.Length > 0; public bool HasHomeworkBadge => HomeworkBadge.Length > 0;
@@ -555,10 +573,13 @@ public partial class SeatCellViewModel : ObservableObject
/// die Abblendung für ausgeblendete Plätze hier statt per CSS-Klasse erfolgen.</summary> /// die Abblendung für ausgeblendete Plätze hier statt per CSS-Klasse erfolgen.</summary>
public double DisplayOpacity => IsHidden ? 0.4 : LessonOpacity; public double DisplayOpacity => IsHidden ? 0.4 : LessonOpacity;
private readonly Action<SeatCellViewModel, bool> _tally;
public SeatCellViewModel(int row, int column, ObservableCollection<StudentSeatOption> options, public SeatCellViewModel(int row, int column, ObservableCollection<StudentSeatOption> options,
StudentSeatOption selectedOption, Action<SeatCellViewModel> onChanged, bool canEdit, StudentSeatOption selectedOption, Action<SeatCellViewModel> onChanged, bool canEdit,
Action<SeatCellViewModel, string>? toggleSituationTag = null, bool canRecordLesson = false, 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; Row = row;
Column = column; Column = column;
@@ -570,10 +591,18 @@ public partial class SeatCellViewModel : ObservableObject
_canRecordLesson = canRecordLesson; _canRecordLesson = canRecordLesson;
_isHidden = isHidden; _isHidden = isHidden;
_toggleHidden = toggleHidden ?? (_ => { }); _toggleHidden = toggleHidden ?? (_ => { });
_tally = tally ?? ((_, _) => { });
foreach (var tag in SituationTagChoice.DefaultTags) foreach (var tag in SituationTagChoice.DefaultTags)
SituationTags.Add(new SituationTagChoice(tag, false, value => _toggleSituationTag(this, value))); 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) partial void OnSelectedOptionChanged(StudentSeatOption value)
{ {
OnPropertyChanged(nameof(IsOccupied)); OnPropertyChanged(nameof(IsOccupied));
@@ -615,6 +644,8 @@ public partial class SeatCellViewModel : ObservableObject
var attendance = entry?.Attendance; var attendance = entry?.Attendance;
AttendanceBadge = attendance is null ? "" : AttendanceDisplay.ShortLabel(attendance); AttendanceBadge = attendance is null ? "" : AttendanceDisplay.ShortLabel(attendance);
HomeworkBadge = entry is null ? "" : HomeworkDisplay.Symbol(HomeworkDisplay.Effective(entry)); HomeworkBadge = entry is null ? "" : HomeworkDisplay.Symbol(HomeworkDisplay.Effective(entry));
RaisedHandCount = entry?.RaisedHandCount ?? 0;
CalledOnCount = entry?.CalledOnCount ?? 0;
LessonOpacity = attendance is not null and not AttendanceStatus.Present LessonOpacity = attendance is not null and not AttendanceStatus.Present
and not AttendanceStatus.Late and not AttendanceStatus.SignificantlyLate ? 0.42 : 1.0; and not AttendanceStatus.Late and not AttendanceStatus.SignificantlyLate ? 0.42 : 1.0;
var selected = tags.ToHashSet(StringComparer.OrdinalIgnoreCase); var selected = tags.ToHashSet(StringComparer.OrdinalIgnoreCase);
@@ -690,7 +721,10 @@ public partial class SeatAssessmentViewModel : ObservableObject
foreach (var (aspect, index) in aspectDefinitions.Select((a, i) => (a, i))) foreach (var (aspect, index) in aspectDefinitions.Select((a, i) => (a, i)))
{ {
var value = _entry?.Ratings.FirstOrDefault(r => r.Key == aspect.Key)?.Value; 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(); BuildAttendanceChoices();
@@ -847,11 +881,22 @@ public partial class SeatAssessmentAspectRow : ObservableObject
public string DisplayValue => ParticipationRatingScale.DisplayLabel(ValueType, Value); public string DisplayValue => ParticipationRatingScale.DisplayLabel(ValueType, Value);
public ObservableCollection<SeatRatingChoice> Choices { get; } = []; 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, 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; Index = index; Key = aspect.Key; Label = aspect.Label; ValueType = aspect.ValueType;
MaxPoints = aspect.MaxPoints; _value = value; _apply = apply; MaxPoints = aspect.MaxPoints; _value = value; _apply = apply;
_raisedHandCount = raisedHandCount;
SuggestedValue = raisedHandCount is int rhc
? ParticipationCountSuggestion.SuggestQuantity(ValueType, rhc) : null;
var steps = ValueType == AspectValueType.Points var steps = ValueType == AspectValueType.Points
? Enumerable.Range(0, Math.Min(MaxPoints, 9) + 1).Select(v => (v, v.ToString())).ToList() ? Enumerable.Range(0, Math.Min(MaxPoints, 9) + 1).Select(v => (v, v.ToString())).ToList()
: ParticipationRatingScale.Steps(ValueType).ToList(); : ParticipationRatingScale.Steps(ValueType).ToList();
@@ -864,10 +909,13 @@ public partial class SeatAssessmentAspectRow : ObservableObject
{ {
Value = value; Value = value;
OnPropertyChanged(nameof(DisplayValue)); OnPropertyChanged(nameof(DisplayValue));
OnPropertyChanged(nameof(HasSuggestion));
foreach (var choice in Choices) choice.IsSelected = choice.Value == value; foreach (var choice in Choices) choice.IsSelected = choice.Value == value;
_apply(Key, value); _apply(Key, value);
} }
[RelayCommand] private void ApplySuggestion() => ApplyValue(SuggestedValue);
public void Adjust(int delta) public void Adjust(int delta)
{ {
if (ValueType == AspectValueType.Points) if (ValueType == AspectValueType.Points)
@@ -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.Core.Services;
using LehrerApp.Desktop.Services; using LehrerApp.Desktop.Services;
using LehrerApp.Desktop.ViewModels.Settings; using LehrerApp.Desktop.ViewModels.Settings;
using LehrerApp.Desktop.ViewModels.Groups;
using System.Collections.ObjectModel; using System.Collections.ObjectModel;
namespace LehrerApp.Desktop.ViewModels.Planning; namespace LehrerApp.Desktop.ViewModels.Planning;
@@ -108,6 +109,8 @@ public partial class TimetableViewModel : ObservableObject
public Action<SettingsTab>? OnNavigateToSettings { get; set; } public Action<SettingsTab>? OnNavigateToSettings { get; set; }
public Func<Lesson, Task>? OnOpenLessonViewer { get; set; } public Func<Lesson, Task>? OnOpenLessonViewer { get; set; }
public Func<Lesson, Task>? OnOpenTeachingMode { 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 /// Öffentlich statt intern (kein InternalsVisibleTo in dieser Codebasis) - erlaubt Tests, die
/// "heute"-abhängiges Verhalten (Wochenraster-Badges, Unterrichtszeit-Erkennung) prüfen, ohne /// "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); 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; } 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); 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, slot.Room ?? "", ColorFor(group.Name), lesson?.Topic, exam?.Title,
HasUnhandledHomework(slot.GroupId, today), lesson)); 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) /// 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, /// — sofern für den Slot schon eine Lesson existiert. Ohne Lesson startet die Direktanlage;
/// aber noch keine konkrete Stunde geplant) bleibt es bei der bisherigen, gröberen Navigation /// der vorgeschaltete Einheiten-Dialog löst dabei die notwendige Unit-Zuordnung explizit.
/// 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).
[RelayCommand] [RelayCommand]
private async Task OpenTodayLesson(TodayLessonItem? item) private async Task OpenTodayLesson(TodayLessonItem? item)
{ {
if (item is null || item.GroupId == Guid.Empty) return; if (item is null || item.GroupId == Guid.Empty) return;
if (item.Lesson is { } lesson && OnOpenLessonViewer is not null) await OnOpenLessonViewer(lesson); 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); 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 /// nach der Stunde) Unterrichtszeit, geht es direkt in den Unterrichtsmodus — sonst wie
/// bisher in den (schreibgeschützten) Planungsviewer bzw., ohne Lesson, zur Einheitenplanung /// bisher in den (schreibgeschützten) Planungsviewer bzw., ohne Lesson, zur Einheitenplanung
/// der Gruppe. Das Popup-Menü (siehe TimetableView.axaml, MenuFlyout je Kachel) bietet /// 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] [RelayCommand]
private async Task OpenWeekCell(WeekCellItem? item) private async Task OpenWeekCell(WeekCellItem? item)
{ {
@@ -391,6 +394,8 @@ public partial class TimetableViewModel : ObservableObject
await OnOpenTeachingMode(lesson); await OnOpenTeachingMode(lesson);
else if (OnOpenLessonViewer is not null) await OnOpenLessonViewer(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); 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 /// Start-/Endzeit gebunden — man klickt auch kurz vor Stundenbeginn oder in einer kurzen
/// Verzögerung danach noch typischerweise in Unterrichtsabsicht. Ohne konfiguriertes /// Verzögerung danach noch typischerweise in Unterrichtsabsicht. Ohne konfiguriertes
/// Stundenraster oder an einem anderen Tag als heute bleibt es beim Planungsviewer. /// 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 /// Nimmt das Datum der Lesson selbst entgegen; die Kachel trägt ihr Datum zusätzlich für die
/// nur bei Kopfzeilen (WeekdayHeader) gesetzt, nicht bei regulären Stunden-Kacheln (ForSlot). /// Direktanlage einer noch nicht existierenden Stunde.
private static readonly TimeSpan TeachingTimeTolerance = TimeSpan.FromMinutes(10); private static readonly TimeSpan TeachingTimeTolerance = TimeSpan.FromMinutes(10);
private bool IsAroundTeachingTime(DateOnly lessonDate, int periodNumber) private bool IsAroundTeachingTime(DateOnly lessonDate, int periodNumber)
{ {
var nowSnapshot = Clock(); var nowSnapshot = Clock();
if (lessonDate != DateOnly.FromDateTime(nowSnapshot)) return false; if (lessonDate != DateOnly.FromDateTime(nowSnapshot)) return false;
if (_periodSchedule.GetTimes(periodNumber) is not { } times) return false; if (_periodSchedule.GetTimes(periodNumber) is not { } times) return false;
var now = TimeOnly.FromDateTime(nowSnapshot); // DateTime statt TimeOnly.Add: Letzteres springt nahe Mitternacht auf den anderen
return now >= times.Start.Add(-TeachingTimeTolerance) && now <= times.End.Add(TeachingTimeTolerance); // 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] [RelayCommand]
@@ -487,14 +496,14 @@ public partial class TimetableViewModel : ObservableObject
continue; 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 hasExam = _exams.GetByGroup(slot.GroupId).Any(e => e.Date == date);
var isHoliday = IsFreeDay(date, schoolHolidays, publicHolidayDates); var isHoliday = IsFreeDay(date, schoolHolidays, publicHolidayDates);
var colorHex = isHoliday ? "#BDBDBD" : ColorFor(group?.Name ?? ""); var colorHex = isHoliday ? "#BDBDBD" : ColorFor(group?.Name ?? "");
var holidayBadge = HolidayBadgeFor(date, weekday, schoolHolidays, publicHolidayDates); var holidayBadge = HolidayBadgeFor(date, weekday, schoolHolidays, publicHolidayDates);
var isLastBeforeExam = IsLastBeforeExamFor(date, weekday, slot.GroupId, 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 ?? "", subject?.ShortName is { Length: > 0 } sn ? sn : subject?.Name ?? "",
group?.Name ?? "?", slot.Room ?? "", lesson?.Topic ?? "", group?.Name ?? "?", slot.Room ?? "", lesson?.Topic ?? "",
colorHex, holidayBadge, hasExam, isLastBeforeExam, colorHex, holidayBadge, hasExam, isLastBeforeExam,
@@ -560,6 +569,40 @@ public partial class TimetableViewModel : ObservableObject
p.Activity?.Contains("Experiment", StringComparison.OrdinalIgnoreCase) == true || p.Activity?.Contains("Experiment", StringComparison.OrdinalIgnoreCase) == true ||
p.Material?.Contains("Experiment", StringComparison.OrdinalIgnoreCase) == true)) == 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 /// <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 /// 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 /// 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). /// Grundlage für den Direktsprung in den Verlaufsplan-Viewer (4.5.2).
public Lesson? Lesson { get; private init; } public Lesson? Lesson { get; private init; }
public bool HasLesson => Lesson is not null; 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 _weatherSymbol = "";
[ObservableProperty] private string _weatherTooltip = ""; [ObservableProperty] private string _weatherTooltip = "";
[ObservableProperty] private bool _hasWeatherWarning; [ObservableProperty] private bool _hasWeatherWarning;
@@ -879,18 +924,28 @@ public partial class WeekCellItem : ObservableObject
IsSubstitutionSupervision = isSubstitution, 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, string groupName, string room, string topic, string colorHex, string holidayBadge,
bool hasExam, bool isLastBeforeExam, bool hasExperiment, Guid groupId, bool isHoliday, bool hasExam, bool isLastBeforeExam, bool hasExperiment, Guid groupId, bool isHoliday,
bool hasUnhandledHomework = false, Lesson? lesson = null) => new() 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, SubjectLabel = subjectLabel, GroupName = groupName, Room = room, Topic = topic,
ColorHex = colorHex, HolidayBadge = holidayBadge, HasExam = hasExam, ColorHex = colorHex, HolidayBadge = holidayBadge, HasExam = hasExam,
IsLastBeforeExam = isLastBeforeExam, HasExperiment = hasExperiment, GroupId = groupId, IsLastBeforeExam = isLastBeforeExam, HasExperiment = hasExperiment, GroupId = groupId,
IsHoliday = isHoliday, HasUnhandledHomework = hasUnhandledHomework, Lesson = lesson, 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() public static WeekCellItem ForSubstitutionLesson(DayOfWeek day, int period, bool isToday, SubstitutionEntry entry) => new()
{ {
Weekday = day, PeriodNumber = period, IsAssigned = true, IsToday = isToday, IsSubstitutionLesson = true, 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 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 sealed record TimetableDestinationOption(TimetableLessonDestination Kind, string Label);
public class TodayLessonItem public class TodayLessonItem
{ {
public Guid GroupId { get; private init; } public Guid GroupId { get; private init; }
public DateOnly Date { get; private init; }
public int PeriodNumber { get; private init; } public int PeriodNumber { get; private init; }
public string GroupName { get; private init; } = ""; public string GroupName { get; private init; } = "";
public string Room { 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). /// Direktsprung in den Verlaufsplan-Viewer (4.5.2) und den Unterrichtsmodus (14.x).
public Lesson? Lesson { get; private init; } public Lesson? Lesson { get; private init; }
public bool HasLesson => Lesson is not null; 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, string colorHex, string? lessonTopic, string? examTitle, bool hasUnhandledHomework = false,
Lesson? lesson = null) 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; ColorHex = colorHex; LessonTopic = lessonTopic; ExamTitle = examTitle;
HasUnhandledHomework = hasUnhandledHomework; Lesson = lesson; HasUnhandledHomework = hasUnhandledHomework; Lesson = lesson;
} }
public static TodayLessonItem ForSubstitution(int periodNumber, SubstitutionEntry entry) => new( 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 }; { IsSubstitution = true };
public static TodayLessonItem ForCancelled(Guid groupId, int periodNumber, string groupName, SubstitutionEntry entry) => new( 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) string.IsNullOrWhiteSpace(entry.Description) ? null : entry.Description, null)
{ IsCancelled = true }; { IsCancelled = true };
} }
@@ -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 16";
[ObservableProperty] private string _newTemplateNameError = "";
public List<string> GradingSystemOptions { get; } = ["Noten 16", "Punkte 015"];
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 015"
? 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 16" : "Punkte 015";
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,114 @@
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
{
// ── Word-Briefvorlagen (7.1.4 / 11.5) ───────────────────────────────────
[ObservableProperty] private string _letterTemplateStatus = "";
public ObservableCollection<LetterTemplateListItem> LetterTemplateList { get; } = [];
public IReadOnlyList<LetterPlaceholder> SupportedLetterPlaceholders =>
LetterTemplateService.SupportedPlaceholders;
// ── Word-Briefvorlagen: Import und Validierung ──────────────────────────
private void LoadLetterTemplates()
{
LetterTemplateList.Clear();
foreach (var template in _letterTemplates.GetTemplates())
LetterTemplateList.Add(new LetterTemplateListItem(template, _letterTemplates.Validate(template)));
}
public void ImportLetterTemplate(string path)
{
LetterTemplateStatus = "";
try
{
var template = _letterTemplates.Import(path);
var validation = _letterTemplates.Validate(template);
LoadLetterTemplates();
LetterTemplateStatus = validation.Issues.Count == 0
? "Vorlage importiert und ohne Auffälligkeiten geprüft."
: $"Vorlage importiert. Die Prüfung meldet {validation.Issues.Count} Hinweis(e).";
}
catch (Exception ex) when (ex is IOException or InvalidDataException or UnauthorizedAccessException)
{
LetterTemplateStatus = $"Import fehlgeschlagen: {ex.Message}";
}
}
[RelayCommand]
private void ValidateLetterTemplate(LetterTemplateListItem? item)
{
if (item is null) return;
var index = LetterTemplateList.IndexOf(item);
var refreshed = new LetterTemplateListItem(item.Model, _letterTemplates.Validate(item.Model));
if (index >= 0) LetterTemplateList[index] = refreshed;
LetterTemplateStatus = refreshed.Validation.Issues.Count == 0
? $"„{item.Name}“ ist ohne Auffälligkeiten."
: $"„{item.Name}“: {refreshed.Validation.Issues.Count} Hinweis(e).";
}
[RelayCommand]
private void DeleteLetterTemplate(LetterTemplateListItem? item)
{
if (item is null) return;
_letterTemplates.Delete(item.Id);
LetterTemplateList.Remove(item);
LetterTemplateStatus = "Vorlage gelöscht.";
}
}
public sealed class LetterTemplateListItem
{
public LetterTemplateInfo Model { get; }
public TemplateValidationResult Validation { get; }
public Guid Id => Model.Id;
public string Name => Model.Name;
public string OriginalFileName => Model.OriginalFileName;
public bool HasIssues => Validation.Issues.Count > 0;
public bool HasNoIssues => !HasIssues;
public string ValidationSummary => HasNoIssues
? $"{Validation.Tags.Count} Feld(er) · keine Auffälligkeiten"
: $"{Validation.Tags.Count} Feld(er) · {Validation.Issues.Count} Hinweis(e)";
public ObservableCollection<LetterTemplateIssueItem> Issues { get; }
public LetterTemplateListItem(LetterTemplateInfo model, TemplateValidationResult validation)
{
Model = model;
Validation = validation;
Issues = new(validation.Issues.Select(i => new LetterTemplateIssueItem(i)));
}
}
public sealed class LetterTemplateIssueItem(TemplateValidationIssue issue)
{
public string Icon => issue.Severity switch
{
TemplateIssueSeverity.Error => "⛔",
TemplateIssueSeverity.StrongWarning => "⚠",
_ => "ⓘ",
};
public string Message => issue.Message;
public string Color => issue.Severity switch
{
TemplateIssueSeverity.Error => "#DC2626",
TemplateIssueSeverity.StrongWarning => "#D97706",
_ => "#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 (MoFr, 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
@@ -17,7 +17,7 @@ public record StudentOption(Guid Id, string Name);
public static class DocumentationTypeDisplay public static class DocumentationTypeDisplay
{ {
public static string[] Options { get; } = 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 public static string Label(DocumentationType t) => t switch
{ {
@@ -27,6 +27,7 @@ public static class DocumentationTypeDisplay
DocumentationType.Absence => "Fehlzeit", DocumentationType.Absence => "Fehlzeit",
DocumentationType.ParentCall => "Elternanruf", DocumentationType.ParentCall => "Elternanruf",
DocumentationType.ParentLetter => "Elternbrief", DocumentationType.ParentLetter => "Elternbrief",
DocumentationType.Planning => "Planung / Erinnerung",
_ => "", _ => "",
}; };
@@ -37,6 +38,7 @@ public static class DocumentationTypeDisplay
"Fehlzeit" => DocumentationType.Absence, "Fehlzeit" => DocumentationType.Absence,
"Elternanruf" => DocumentationType.ParentCall, "Elternanruf" => DocumentationType.ParentCall,
"Elternbrief" => DocumentationType.ParentLetter, "Elternbrief" => DocumentationType.ParentLetter,
"Planung / Erinnerung" => DocumentationType.Planning,
_ => DocumentationType.Conversation, _ => DocumentationType.Conversation,
}; };
} }
@@ -301,7 +303,7 @@ public partial class DocumentationDialogViewModel : ObservableObject
LetterSentDateError = ""; LetterResponseDateError = ""; LetterSentDateError = ""; LetterResponseDateError = "";
var valid = true; 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; } if (string.IsNullOrWhiteSpace(Title)) { TitleError = "Titel erforderlich."; valid = false; }
@@ -0,0 +1,46 @@
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using LehrerApp.Core.Models;
using System.Globalization;
namespace LehrerApp.Desktop.ViewModels.Students;
public sealed record GroupDocumentationOption(Guid Id, string DisplayName);
/// Kompakte Erfassung einer gruppenweiten Planungsnotiz aus der globalen Befehlspalette.
public partial class GroupDocumentationQuickViewModel(IEnumerable<GroupDocumentationOption> groups)
: ObservableObject
{
public List<GroupDocumentationOption> Groups { get; } = groups.ToList();
[ObservableProperty] private GroupDocumentationOption? _selectedGroup;
[ObservableProperty] private string _title = "";
[ObservableProperty] private string _content = "";
[ObservableProperty] private string _dateText = DateOnly.FromDateTime(DateTime.Today).ToString("dd.MM.yyyy");
[ObservableProperty] private string _groupError = "";
[ObservableProperty] private string _titleError = "";
[ObservableProperty] private string _dateError = "";
public Documentation? Result { get; private set; }
[RelayCommand]
private void Save()
{
GroupError = TitleError = DateError = "";
if (SelectedGroup is null) GroupError = "Lerngruppe auswählen.";
if (string.IsNullOrWhiteSpace(Title)) TitleError = "Titel erforderlich.";
if (!DateOnly.TryParseExact(DateText, "dd.MM.yyyy", null, DateTimeStyles.None, out var date))
DateError = "Format TT.MM.JJJJ.";
if (GroupError.Length > 0 || TitleError.Length > 0 || DateError.Length > 0) return;
Result = new Documentation
{
StudentId = Guid.Empty,
GroupId = SelectedGroup!.Id,
Type = DocumentationType.Planning,
Date = date,
Title = Title.Trim(),
Content = Content.Trim(),
ExcludeFromWebUntisSync = true,
};
}
}
@@ -64,9 +64,15 @@ public partial class StudentListViewModel : ObservableObject
[ObservableProperty] private string _searchText = ""; [ObservableProperty] private string _searchText = "";
[ObservableProperty] private bool _showInactive; [ObservableProperty] private bool _showInactive;
[ObservableProperty] private StudentListItem? _selectedStudent; [ObservableProperty] private StudentListItem? _selectedStudent;
[ObservableProperty] private bool _isImporting;
public ObservableCollection<StudentListItem> Students { get; } = []; public ObservableCollection<StudentListItem> Students { get; } = [];
public string CountSummary => $"{Students.Count} Schüler gesamt"; public string CountSummary => $"{Students.Count} Schüler gesamt";
public bool HasNoStudents => Students.Count == 0;
public bool HasStudents => !HasNoStudents;
public string EmptyListMessage => string.IsNullOrWhiteSpace(SearchText)
? ShowInactive ? "Noch keine Schüler vorhanden." : "Noch keine aktiven Schüler vorhanden."
: "Keine Schüler passen zur aktuellen Suche.";
public StudentListViewModel(IStudentRepository students, IGroupRepository groups, public StudentListViewModel(IStudentRepository students, IGroupRepository groups,
IGroupMembershipRepository memberships) IGroupMembershipRepository memberships)
@@ -105,6 +111,9 @@ public partial class StudentListViewModel : ObservableObject
} }
foreach (var s in f) Students.Add(new StudentListItem(s)); foreach (var s in f) Students.Add(new StudentListItem(s));
OnPropertyChanged(nameof(CountSummary)); OnPropertyChanged(nameof(CountSummary));
OnPropertyChanged(nameof(HasNoStudents));
OnPropertyChanged(nameof(HasStudents));
OnPropertyChanged(nameof(EmptyListMessage));
} }
public Func<Task>? OnAddStudent { get; set; } public Func<Task>? OnAddStudent { get; set; }
@@ -20,17 +20,17 @@
<StackPanel Spacing="20"> <StackPanel Spacing="20">
<!-- Begrüßung --> <!-- Begrüßung -->
<Grid ColumnDefinitions="*,Auto"> <Grid RowDefinitions="Auto,Auto">
<StackPanel> <StackPanel>
<TextBlock Text="{Binding Greeting}" FontSize="14" Opacity="0.6"/> <TextBlock Text="{Binding Greeting}" FontSize="14" Opacity="0.6"/>
<TextBlock Text="{Binding CurrentDate}" FontSize="24" FontWeight="SemiBold"/> <TextBlock Text="{Binding CurrentDate}" FontSize="24" FontWeight="SemiBold"/>
</StackPanel> </StackPanel>
<StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="8"> <WrapPanel Grid.Row="1" Orientation="Horizontal" ItemSpacing="8" LineSpacing="8" Margin="0,10,0,0">
<Button Content="Klassenbuch abgleichen…" Click="OnCompareWebUntisDocumentationClick" <Button Content="Klassenbuch abgleichen…" Click="OnCompareWebUntisDocumentationClick"
VerticalAlignment="Center"/> VerticalAlignment="Center"/>
<Button Content="Bereiche anpassen" Command="{Binding ToggleDashboardSettingsCommand}" <Button Content="Bereiche anpassen" Command="{Binding ToggleDashboardSettingsCommand}"
VerticalAlignment="Center"/> VerticalAlignment="Center"/>
</StackPanel> </WrapPanel>
</Grid> </Grid>
<!-- Der Tagesfokus beantwortet zuerst die vier Fragen, die beim Öffnen der App zählen: <!-- Der Tagesfokus beantwortet zuerst die vier Fragen, die beim Öffnen der App zählen:
@@ -72,9 +72,13 @@
<StackPanel Orientation="Horizontal" Spacing="6"> <StackPanel Orientation="Horizontal" Spacing="6">
<CheckBox Content="{Binding Title}" IsChecked="{Binding IsVisible}" VerticalAlignment="Center"/> <CheckBox Content="{Binding Title}" IsChecked="{Binding IsVisible}" VerticalAlignment="Center"/>
<Button Content="↑" Padding="6,2" <Button Content="↑" Padding="6,2"
ToolTip.Tip="Bereich nach oben verschieben"
AutomationProperties.Name="Dashboard-Bereich nach oben verschieben"
Command="{Binding $parent[ItemsControl].((vm:DashboardViewModel)DataContext).MoveCardUpCommand}" Command="{Binding $parent[ItemsControl].((vm:DashboardViewModel)DataContext).MoveCardUpCommand}"
CommandParameter="{Binding}"/> CommandParameter="{Binding}"/>
<Button Content="↓" Padding="6,2" <Button Content="↓" Padding="6,2"
ToolTip.Tip="Bereich nach unten verschieben"
AutomationProperties.Name="Dashboard-Bereich nach unten verschieben"
Command="{Binding $parent[ItemsControl].((vm:DashboardViewModel)DataContext).MoveCardDownCommand}" Command="{Binding $parent[ItemsControl].((vm:DashboardViewModel)DataContext).MoveCardDownCommand}"
CommandParameter="{Binding}"/> CommandParameter="{Binding}"/>
</StackPanel> </StackPanel>
@@ -212,7 +216,7 @@
<Border Grid.Column="{Binding CalendarCard.Column}" Grid.Row="{Binding CalendarCard.Row}" <Border Grid.Column="{Binding CalendarCard.Column}" Grid.Row="{Binding CalendarCard.Row}"
IsVisible="{Binding CalendarCard.EffectiveIsVisible}" Margin="0,0,8,8" IsVisible="{Binding CalendarCard.EffectiveIsVisible}" Margin="0,0,8,8"
Background="{DynamicResource SystemControlBackgroundAltHighBrush}" Background="{DynamicResource SystemControlBackgroundAltHighBrush}"
CornerRadius="8" Padding="16"> CornerRadius="8" Padding="16" HorizontalAlignment="Left" MaxWidth="320">
<StackPanel Spacing="8"> <StackPanel Spacing="8">
<StackPanel.Styles> <StackPanel.Styles>
<!-- Basiswerte als Style (nicht lokal), damit die spezifischeren Selektoren <!-- Basiswerte als Style (nicht lokal), damit die spezifischeren Selektoren
@@ -250,8 +254,10 @@
Width="130" Margin="12,0"/> Width="130" Margin="12,0"/>
<Button Grid.Column="2" Content="Heute" FontSize="11" Padding="8,2" <Button Grid.Column="2" Content="Heute" FontSize="11" Padding="8,2"
Command="{Binding CalendarTodayCommand}" Margin="0,0,4,0"/> Command="{Binding CalendarTodayCommand}" Margin="0,0,4,0"/>
<Button Grid.Column="3" Content="" Padding="8,2" Command="{Binding PrevMonthCommand}"/> <Button Grid.Column="3" Content="" Padding="8,2" Command="{Binding PrevMonthCommand}"
<Button Grid.Column="4" Content="" Padding="8,2" Command="{Binding NextMonthCommand}" Margin="4,0,0,0"/> ToolTip.Tip="Vorheriger Monat" AutomationProperties.Name="Vorheriger Monat"/>
<Button Grid.Column="4" Content="" Padding="8,2" Command="{Binding NextMonthCommand}" Margin="4,0,0,0"
ToolTip.Tip="Nächster Monat" AutomationProperties.Name="Nächster Monat"/>
</Grid> </Grid>
<ItemsControl ItemsSource="{Binding CalendarWeekdayHeaders}"> <ItemsControl ItemsSource="{Binding CalendarWeekdayHeaders}">
@@ -273,6 +279,7 @@
<ItemsControl.ItemTemplate> <ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:CalendarDayCell"> <DataTemplate x:DataType="vm:CalendarDayCell">
<Button Background="Transparent" BorderThickness="0" Padding="0" Margin="1" <Button Background="Transparent" BorderThickness="0" Padding="0" Margin="1"
HorizontalAlignment="Center"
Command="{Binding $parent[ItemsControl].((vm:DashboardViewModel)DataContext).SelectCalendarDayCommand}" Command="{Binding $parent[ItemsControl].((vm:DashboardViewModel)DataContext).SelectCalendarDayCommand}"
CommandParameter="{Binding}" ToolTip.Tip="{Binding Tooltip}"> CommandParameter="{Binding}" ToolTip.Tip="{Binding Tooltip}">
<Border Classes="daycell" Classes.ownclass="{Binding IsOwnClassDay}" <Border Classes="daycell" Classes.ownclass="{Binding IsOwnClassDay}"
@@ -413,6 +420,30 @@
</StackPanel> </StackPanel>
</Border> </Border>
<!-- Klausurwochen (Nutzer-Feedback): eigene Klausurlast über alle Kurse hinweg -->
<Border Grid.Column="{Binding ExamLoadCard.Column}" Grid.Row="{Binding ExamLoadCard.Row}"
IsVisible="{Binding ExamLoadCard.EffectiveIsVisible}" Margin="0,0,8,8" VerticalAlignment="Top"
Background="{DynamicResource SystemControlBackgroundAltHighBrush}"
CornerRadius="8" Padding="16">
<StackPanel>
<TextBlock Text="KLAUSURWOCHEN" FontSize="11" FontWeight="Bold"
Opacity="0.5" Margin="0,0,0,10"/>
<ItemsControl ItemsSource="{Binding ExamWeekLoads}">
<ItemsControl.ItemTemplate>
<DataTemplate DataType="vm:ExamWeekLoadItem">
<Grid ColumnDefinitions="*,Auto" Margin="0,4">
<TextBlock Grid.Column="0" Text="{Binding Label}" FontSize="13"/>
<TextBlock Grid.Column="1" Text="{Binding CountDisplay}" Foreground="#F59E0B"
FontSize="12" VerticalAlignment="Center"/>
</Grid>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<TextBlock Text="Keine Woche mit auffälliger Klausurhäufung." Classes="emptyhint"
IsVisible="{Binding !ExamWeekLoads.Count}"/>
</StackPanel>
</Border>
<!-- Förderplan-Wiedervorlage (5.3.2) --> <!-- Förderplan-Wiedervorlage (5.3.2) -->
<Border Grid.Column="{Binding SupportCard.Column}" Grid.Row="{Binding SupportCard.Row}" <Border Grid.Column="{Binding SupportCard.Column}" Grid.Row="{Binding SupportCard.Row}"
IsVisible="{Binding SupportCard.EffectiveIsVisible}" Margin="8,0,0,8" VerticalAlignment="Top" IsVisible="{Binding SupportCard.EffectiveIsVisible}" Margin="8,0,0,8" VerticalAlignment="Top"
@@ -586,10 +617,10 @@
<StackPanel> <StackPanel>
<TextBlock Text="MEINE LERNGRUPPEN" FontSize="11" FontWeight="Bold" <TextBlock Text="MEINE LERNGRUPPEN" FontSize="11" FontWeight="Bold"
Opacity="0.5" Margin="0,0,0,10"/> Opacity="0.5" Margin="0,0,0,10"/>
<ItemsControl ItemsSource="{Binding CurrentGroups}"> <ItemsControl ItemsSource="{Binding CurrentGroups}" HorizontalAlignment="Stretch">
<ItemsControl.ItemsPanel> <ItemsControl.ItemsPanel>
<ItemsPanelTemplate> <ItemsPanelTemplate>
<WrapPanel Orientation="Horizontal"/> <WrapPanel Orientation="Horizontal" ItemSpacing="8" LineSpacing="8"/>
</ItemsPanelTemplate> </ItemsPanelTemplate>
</ItemsControl.ItemsPanel> </ItemsControl.ItemsPanel>
<ItemsControl.ItemTemplate> <ItemsControl.ItemTemplate>
@@ -597,9 +628,15 @@
<Button Command="{Binding $parent[ItemsControl].((vm:DashboardViewModel)DataContext).OpenGroupCommand}" <Button Command="{Binding $parent[ItemsControl].((vm:DashboardViewModel)DataContext).OpenGroupCommand}"
CommandParameter="{Binding}" CommandParameter="{Binding}"
Background="{DynamicResource SystemAccentColorLight2}" Background="{DynamicResource SystemAccentColorLight2}"
CornerRadius="6" Padding="12,6" Margin="0,0,8,8"> CornerRadius="6" Padding="12,6" MinWidth="150"
ToolTip.Tip="Kurs öffnen">
<StackPanel> <StackPanel>
<StackPanel Orientation="Horizontal" Spacing="6">
<Border Width="6" Height="6" CornerRadius="3"
Background="{DynamicResource SystemAccentColor}"
IsVisible="{Binding IsOnSelectedDay}" VerticalAlignment="Center"/>
<TextBlock Text="{Binding Name}" FontWeight="SemiBold" FontSize="13"/> <TextBlock Text="{Binding Name}" FontWeight="SemiBold" FontSize="13"/>
</StackPanel>
<TextBlock Text="{Binding Subject}" FontSize="11" Opacity="0.7" <TextBlock Text="{Binding Subject}" FontSize="11" Opacity="0.7"
IsVisible="{Binding Subject, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/> IsVisible="{Binding Subject, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
</StackPanel> </StackPanel>
@@ -182,8 +182,14 @@
<!-- Liste aller Klausuren, nach Priorität sortiert (nicht nach Datum) ─────────── --> <!-- Liste aller Klausuren, nach Priorität sortiert (nicht nach Datum) ─────────── -->
<ScrollViewer Grid.Row="2" Margin="20,0,20,16"> <ScrollViewer Grid.Row="2" Margin="20,0,20,16">
<StackPanel> <StackPanel>
<TextBlock Text="{Binding EmptyHint}" Classes="emptyhint" Margin="0,20,0,0" <StackPanel Margin="0,28,0,0" Spacing="10" HorizontalAlignment="Center"
IsVisible="{Binding EmptyHint, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/> IsVisible="{Binding HasNoExams}">
<TextBlock Text="Noch keine Klausuren im aktuellen Schuljahr." Classes="emptyhint"
FontSize="15" TextAlignment="Center"/>
<TextBlock Text="Klausuren werden im jeweiligen Kurs angelegt." FontSize="12" Opacity="0.55"/>
<Button Content="Zu den Lerngruppen" Command="{Binding GoToGroupsCommand}"
HorizontalAlignment="Center"/>
</StackPanel>
<ItemsControl ItemsSource="{Binding Rows}"> <ItemsControl ItemsSource="{Binding Rows}">
<ItemsControl.ItemTemplate> <ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:ExamListRowViewModel"> <DataTemplate x:DataType="vm:ExamListRowViewModel">
@@ -36,6 +36,8 @@
<TextBox Text="{Binding DateText}" PlaceholderText="TT.MM.JJJJ"/> <TextBox Text="{Binding DateText}" PlaceholderText="TT.MM.JJJJ"/>
<TextBlock Text="{Binding DateTextError}" Foreground="Red" FontSize="11" <TextBlock Text="{Binding DateTextError}" Foreground="Red" FontSize="11"
IsVisible="{Binding DateTextError, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/> IsVisible="{Binding DateTextError, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
<TextBlock Text="{Binding WeekLoadHint}" Foreground="#F59E0B" FontSize="11"
IsVisible="{Binding WeekLoadHint, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
</StackPanel> </StackPanel>
<StackPanel Grid.Column="2" Spacing="4"> <StackPanel Grid.Column="2" Spacing="4">
<TextBlock Text="Klausurnummer" FontSize="12" Opacity="0.7"/> <TextBlock Text="Klausurnummer" FontSize="12" Opacity="0.7"/>
@@ -48,7 +48,7 @@ public partial class ExamGradingDialog : Window
{ {
Header = string.IsNullOrWhiteSpace(task.Title) ? $"Aufg. {task.Nr}" : $"{task.Nr}. {task.Title}", Header = string.IsNullOrWhiteSpace(task.Title) ? $"Aufg. {task.Nr}" : $"{task.Nr}. {task.Title}",
Width = new DataGridLength(1, DataGridLengthUnitType.Star), Width = new DataGridLength(1, DataGridLengthUnitType.Star),
CellTemplate = BuildPointsCellTemplate(i, grid), CellTemplate = BuildPointsCellTemplate(i, grid, vm.Tasks.Count),
}); });
} }
@@ -74,7 +74,7 @@ public partial class ExamGradingDialog : Window
}); });
} }
private IDataTemplate BuildPointsCellTemplate(int taskIndex, DataGrid grid) private IDataTemplate BuildPointsCellTemplate(int taskIndex, DataGrid grid, int taskCount)
{ {
var cellName = $"Pts_{taskIndex}"; var cellName = $"Pts_{taskIndex}";
return new FuncDataTemplate<ExamResultRow>((row, _) => return new FuncDataTemplate<ExamResultRow>((row, _) =>
@@ -102,7 +102,7 @@ public partial class ExamGradingDialog : Window
}; };
tb.LostFocus += (_, _) => CommitPointsCell(tb, cell); tb.LostFocus += (_, _) => CommitPointsCell(tb, cell);
tb.KeyDown += (_, e) => HandleCellKeyDown(e, grid, row, cellName); tb.KeyDown += (_, e) => HandlePointsCellKeyDown(e, grid, row, taskIndex, taskCount);
return tb; return tb;
}); });
@@ -150,14 +150,53 @@ public partial class ExamGradingDialog : Window
Padding = new Thickness(6, 4), Padding = new Thickness(6, 4),
}; };
tb.LostFocus += (_, _) => row.Comment = tb.Text ?? ""; tb.LostFocus += (_, _) => row.Comment = tb.Text ?? "";
tb.KeyDown += (_, e) => HandleCellKeyDown(e, grid, row, cellName); tb.KeyDown += (_, e) => HandleCommentCellKeyDown(e, grid, row, cellName);
return tb; return tb;
}); });
} }
/// Enter/Pfeil-Hoch/Pfeil-Runter springen zur gleichen Spalte in der Nachbarzeile /// Zwei Korrektur-Workflows (Nutzer-Feedback): "Arbeit für Arbeit" (ein Schüler komplett,
/// (Tab funktioniert bereits über die normale Fokus-Reihenfolge). /// dann der nächste) und "Aufgabe für Aufgabe" (eine Aufgabe für alle Schüler, dann die
private void HandleCellKeyDown(KeyEventArgs e, DataGrid grid, object rowItem, string cellName) /// nächste) — beide bewusst ohne Moduswechsel über zwei verschiedene Tasten, weil sich beide
/// Reihenfolgen ohnehin schon auf die Tabelle abbilden (Zeile vs. Spalte). Enter läuft
/// zeilenweise (am Zeilenende zurück zu Aufgabe 1 der nächsten Zeile), Pfeil-hoch/-runter
/// spaltenweise (am Spaltenende weiter zur Nachbaraufgabe) — beides bricht am Tabellenende
/// einfach ab, kein Rundlauf zurück zum Anfang.
private void HandlePointsCellKeyDown(KeyEventArgs e, DataGrid grid, ExamResultRow row, int taskIndex, int taskCount)
{
if (e.Key is not (Key.Down or Key.Up or Key.Enter)) return;
e.Handled = true;
if (grid.ItemsSource is not IList items) return;
var rowIdx = items.IndexOf(row);
if (rowIdx < 0) return;
int targetRowIdx;
int targetTaskIndex;
if (e.Key == Key.Enter)
{
targetTaskIndex = taskIndex + 1;
targetRowIdx = rowIdx;
if (targetTaskIndex >= taskCount) { targetTaskIndex = 0; targetRowIdx = rowIdx + 1; }
}
else
{
targetTaskIndex = taskIndex;
targetRowIdx = e.Key == Key.Up ? rowIdx - 1 : rowIdx + 1;
if (targetRowIdx < 0) { targetRowIdx = items.Count - 1; targetTaskIndex = taskIndex - 1; }
else if (targetRowIdx >= items.Count) { targetRowIdx = 0; targetTaskIndex = taskIndex + 1; }
}
if (targetTaskIndex < 0 || targetTaskIndex >= taskCount
|| targetRowIdx < 0 || targetRowIdx >= items.Count) return;
FocusPointsCell(grid, items[targetRowIdx]!, targetTaskIndex);
}
/// Enter/Pfeil-Hoch/Pfeil-Runter springen im Kommentarfeld zur gleichen Spalte in der
/// Nachbarzeile (Tab funktioniert bereits über die normale Fokus-Reihenfolge).
private void HandleCommentCellKeyDown(KeyEventArgs e, DataGrid grid, object rowItem, string cellName)
{ {
if (e.Key is not (Key.Down or Key.Up or Key.Enter)) return; if (e.Key is not (Key.Down or Key.Up or Key.Enter)) return;
e.Handled = true; e.Handled = true;
@@ -181,5 +220,25 @@ public partial class ExamGradingDialog : Window
}, DispatcherPriority.Loaded); }, DispatcherPriority.Loaded);
} }
/// Spalte 0 ist "Schüler", danach folgen die Aufgaben-Spalten in Reihenfolge — daher der
/// Offset von 1 beim Auflösen des Spaltenobjekts für ScrollIntoView (muss auch horizontal
/// zur Nachbaraufgabe scrollen, nicht nur vertikal zur Nachbarzeile).
private void FocusPointsCell(DataGrid grid, object targetItem, int targetTaskIndex)
{
var targetColumn = grid.Columns.ElementAtOrDefault(1 + targetTaskIndex);
if (targetColumn is null) return;
grid.ScrollIntoView(targetItem, targetColumn);
var cellName = $"Pts_{targetTaskIndex}";
Dispatcher.UIThread.Post(() =>
{
var targetTb = grid.GetVisualDescendants().OfType<TextBox>()
.FirstOrDefault(t => t.DataContext == targetItem && t.Name == cellName);
if (targetTb is null) return;
targetTb.Focus();
targetTb.SelectAll();
}, DispatcherPriority.Loaded);
}
private void OnClose(object? sender, RoutedEventArgs e) => Close(); private void OnClose(object? sender, RoutedEventArgs e) => Close();
} }
@@ -13,9 +13,9 @@
<Border Grid.Row="0" Padding="20,16" <Border Grid.Row="0" Padding="20,16"
BorderBrush="{DynamicResource SystemControlForegroundBaseLowBrush}" BorderBrush="{DynamicResource SystemControlForegroundBaseLowBrush}"
BorderThickness="0,0,0,1"> BorderThickness="0,0,0,1">
<Grid ColumnDefinitions="*,Auto"> <Grid RowDefinitions="Auto,Auto">
<shared:PageHeader Grid.Column="0" Title="{Binding GroupTitle}" Subtitle="{Binding GroupSubtitle}"/> <shared:PageHeader Title="{Binding GroupTitle}" Subtitle="{Binding GroupSubtitle}"/>
<StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="8"> <WrapPanel Grid.Row="1" Orientation="Horizontal" ItemSpacing="8" LineSpacing="8" Margin="0,10,0,0">
<TextBlock VerticalAlignment="Center" Opacity="0.6" FontSize="13"> <TextBlock VerticalAlignment="Center" Opacity="0.6" FontSize="13">
<Run Text="{Binding StudentCount}"/> <Run Text="{Binding StudentCount}"/>
<Run Text=" Schüler"/> <Run Text=" Schüler"/>
@@ -34,7 +34,7 @@
<Button Content="Austragung zurücknehmen" Command="{Binding ReinstateStudentCommand}" <Button Content="Austragung zurücknehmen" Command="{Binding ReinstateStudentCommand}"
IsVisible="{Binding SelectedStudent.HasExitDate}"/> IsVisible="{Binding SelectedStudent.HasExitDate}"/>
<Button Content=" Klausur" Command="{Binding AddExamCommand}" IsEnabled="{Binding IsEditable}"/> <Button Content=" Klausur" Command="{Binding AddExamCommand}" IsEnabled="{Binding IsEditable}"/>
</StackPanel> </WrapPanel>
</Grid> </Grid>
</Border> </Border>
@@ -28,8 +28,15 @@ public partial class GroupDocumentationTabView : UserControl
var owner = TopLevel.GetTopLevel(this) as Window; var owner = TopLevel.GetTopLevel(this) as Window;
if (owner is null) return null; if (owner is null) return null;
var options = new List<StudentOption> { GroupDocumentationTabViewModel.WholeGroupOption };
options.AddRange(studentOptions);
var vm = new DocumentationDialogViewModel(editing?.StudentId ?? Guid.Empty, editing, var vm = new DocumentationDialogViewModel(editing?.StudentId ?? Guid.Empty, editing,
App.Services.GetRequiredService<IAttachmentStorage>(), studentOptions, groupId); App.Services.GetRequiredService<IAttachmentStorage>(), options, groupId);
if (editing is null)
{
vm.SelectedStudent = GroupDocumentationTabViewModel.WholeGroupOption;
vm.TypeName = DocumentationTypeDisplay.Label(DocumentationType.Planning);
}
var dialog = new DocumentationDialog { DataContext = vm }; var dialog = new DocumentationDialog { DataContext = vm };
var saved = await dialog.ShowDialog<bool>(owner); var saved = await dialog.ShowDialog<bool>(owner);
if (!saved) vm.DiscardUnsavedAttachments(); if (!saved) vm.DiscardUnsavedAttachments();
@@ -11,13 +11,14 @@
<Border Grid.Row="0" Padding="20,16" <Border Grid.Row="0" Padding="20,16"
BorderBrush="{DynamicResource SystemControlForegroundBaseLowBrush}" BorderBrush="{DynamicResource SystemControlForegroundBaseLowBrush}"
BorderThickness="0,0,0,1"> BorderThickness="0,0,0,1">
<Grid ColumnDefinitions="*,Auto,Auto"> <Grid RowDefinitions="Auto,Auto" ColumnDefinitions="*,Auto,Auto">
<shared:PageHeader Grid.Column="0" Title="Lerngruppen" Subtitle="{Binding ListSummary}"/> <shared:PageHeader Grid.ColumnSpan="3" Title="Lerngruppen" Subtitle="{Binding ListSummary}"/>
<ComboBox Grid.Column="1" ItemsSource="{Binding SchoolYears}" <ComboBox Grid.Row="1" Grid.Column="1" ItemsSource="{Binding SchoolYears}"
SelectedItem="{Binding SelectedSchoolYear}" SelectedItem="{Binding SelectedSchoolYear}"
Width="100" Margin="0,0,8,0" VerticalAlignment="Center"/> Width="110" Margin="0,10,8,0" VerticalAlignment="Center"
<Button Grid.Column="2" Content=" Neue Gruppe" AutomationProperties.Name="Schuljahr auswählen"/>
Command="{Binding AddGroupCommand}" VerticalAlignment="Center"/> <Button Grid.Row="1" Grid.Column="2" Content=" Neue Gruppe"
Command="{Binding AddGroupCommand}" VerticalAlignment="Center" Margin="0,10,0,0"/>
</Grid> </Grid>
</Border> </Border>
@@ -76,7 +77,7 @@
</StackPanel> </StackPanel>
</StackPanel> </StackPanel>
</Button> </Button>
<Button Grid.Column="2" Content="⋯" Width="36" Height="32" Margin="0,10,10,0" <Button Grid.Column="2" Content="⋯" Width="40" Height="40" Margin="0,10,10,0"
Padding="0" VerticalAlignment="Top" Padding="0" VerticalAlignment="Top"
ToolTip.Tip="Lerngruppe verwalten" ToolTip.Tip="Lerngruppe verwalten"
AutomationProperties.Name="{Binding ManageAutomationName}"> AutomationProperties.Name="{Binding ManageAutomationName}">
@@ -66,6 +66,14 @@
</StackPanel> </StackPanel>
</Border> </Border>
<!-- Klausurschnitt-Vorjahresvergleich (Nutzer-Feedback) -->
<Border Classes="card" IsVisible="{Binding HasYearComparison}">
<StackPanel>
<TextBlock Text="VORJAHRESVERGLEICH" Classes="cardTitle"/>
<TextBlock Text="{Binding YearComparisonLabel}" TextWrapping="Wrap" Classes="cardBody"/>
</StackPanel>
</Border>
<!-- Mitarbeit --> <!-- Mitarbeit -->
<Border Classes="card"> <Border Classes="card">
<StackPanel> <StackPanel>
@@ -4,7 +4,7 @@
x:Class="LehrerApp.Desktop.Views.Groups.MoveLessonDialog" x:Class="LehrerApp.Desktop.Views.Groups.MoveLessonDialog"
x:DataType="vm:MoveLessonDialogViewModel" x:DataType="vm:MoveLessonDialogViewModel"
Title="Stunde verschieben" Title="Stunde verschieben"
Width="400" Height="260" MinWidth="360" MinHeight="240" Width="440" Height="430" MinWidth="400" MinHeight="400"
CanResize="False" WindowStartupLocation="CenterOwner"> CanResize="False" WindowStartupLocation="CenterOwner">
<Grid RowDefinitions="*,Auto" Margin="24"> <Grid RowDefinitions="*,Auto" Margin="24">
@@ -13,6 +13,8 @@
<TextBlock FontSize="12" Opacity="0.6"> <TextBlock FontSize="12" Opacity="0.6">
<Run Text="Bisheriges Datum: "/> <Run Text="Bisheriges Datum: "/>
<Run Text="{Binding CurrentDateDisplay}"/> <Run Text="{Binding CurrentDateDisplay}"/>
<Run Text=" · "/>
<Run Text="{Binding CurrentPeriodDisplay}"/>
</TextBlock> </TextBlock>
<StackPanel Spacing="4"> <StackPanel Spacing="4">
@@ -22,6 +24,24 @@
IsVisible="{Binding NewDateTextError, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/> IsVisible="{Binding NewDateTextError, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
</StackPanel> </StackPanel>
<StackPanel Spacing="4">
<TextBlock Text="Neue Stunde" FontSize="12" Opacity="0.7"/>
<NumericUpDown Value="{Binding NewPeriod}" Minimum="1" Maximum="20"
FormatString="0" PlaceholderText="Stundennummer"/>
<TextBlock Text="{Binding NewPeriodError}" Foreground="Red" FontSize="11"
IsVisible="{Binding NewPeriodError, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
</StackPanel>
<Border Background="{DynamicResource SystemControlBackgroundAltHighBrush}" CornerRadius="6"
Padding="10" IsVisible="{Binding CanSplitDoubleLesson}">
<StackPanel Spacing="5">
<CheckBox Content="Nur den zweiten Teil der Doppelstunde verschieben"
IsChecked="{Binding SplitDoubleLesson}"/>
<TextBlock Text="Der Verlauf wird an der Dauer der ersten Stunde geteilt. Der zweite Teil wird als eigene Fortsetzungsstunde angelegt."
FontSize="11" Opacity="0.65" TextWrapping="Wrap"/>
</StackPanel>
</Border>
<CheckBox Content="Folgestunden automatisch verschieben" <CheckBox Content="Folgestunden automatisch verschieben"
IsChecked="{Binding ShiftFollowingPlanned}" IsChecked="{Binding ShiftFollowingPlanned}"
ToolTip.Tip="Verschiebt alle noch geplanten Stunden derselben Einheit, die nach dieser Stunde liegen, um denselben Zeitraum. Bereits durchgeführte Stunden bleiben unverändert."/> ToolTip.Tip="Verschiebt alle noch geplanten Stunden derselben Einheit, die nach dieser Stunde liegen, um denselben Zeitraum. Bereits durchgeführte Stunden bleiben unverändert."/>
@@ -58,6 +58,7 @@ public partial class ParticipationQuickInputDialog : Window
case Key.OemPlus or Key.Add: vm.IncrementRating(); e.Handled = true; break; case Key.OemPlus or Key.Add: vm.IncrementRating(); e.Handled = true; break;
case Key.OemMinus or Key.Subtract: vm.DecrementRating(); e.Handled = true; break; case Key.OemMinus or Key.Subtract: vm.DecrementRating(); e.Handled = true; break;
case Key.Delete: vm.ClearRating(); e.Handled = true; break;
case Key.Escape: Close(); e.Handled = true; break; case Key.Escape: Close(); e.Handled = true; break;
@@ -91,7 +91,8 @@
ToolTip.Tip="Verlaufsplan schreibgeschützt und größer anzeigen — zum Mitnehmen in den Unterricht."/> ToolTip.Tip="Verlaufsplan schreibgeschützt und größer anzeigen — zum Mitnehmen in den Unterricht."/>
<Button Content="Bearbeiten" Command="{Binding EditLessonCommand}" IsEnabled="{Binding !IsReadOnly}"/> <Button Content="Bearbeiten" Command="{Binding EditLessonCommand}" IsEnabled="{Binding !IsReadOnly}"/>
<Button Content="Verschieben" Command="{Binding MoveLessonCommand}" IsEnabled="{Binding !IsReadOnly}"/> <Button Content="Verschieben" Command="{Binding MoveLessonCommand}" IsEnabled="{Binding !IsReadOnly}"/>
<Button Content="Status → Durchgeführt" Command="{Binding AdvanceLessonStatusCommand}" IsEnabled="{Binding !IsReadOnly}"/> <Button Content="Status weiter" Command="{Binding AdvanceLessonStatusCommand}" IsEnabled="{Binding !IsReadOnly}"
ToolTip.Tip="Entwurf/Geplant → Bereit → Durchgeführt"/>
<Button Content="Sitzung erzeugen" Command="{Binding CreateParticipationSessionCommand}" <Button Content="Sitzung erzeugen" Command="{Binding CreateParticipationSessionCommand}"
IsEnabled="{Binding !IsReadOnly}" IsEnabled="{Binding !IsReadOnly}"
ToolTip.Tip="Legt eine Mitarbeitssitzung mit Datum und Thema dieser Stunde an."/> ToolTip.Tip="Legt eine Mitarbeitssitzung mit Datum und Thema dieser Stunde an."/>
@@ -42,6 +42,7 @@ public partial class PlanningTabView : UserControl
vm.OnGenerateLessonSeries = ShowGenerateLessonSeriesDialog; vm.OnGenerateLessonSeries = ShowGenerateLessonSeriesDialog;
vm.OnAiAssist = ShowAiAssistDialog; vm.OnAiAssist = ShowAiAssistDialog;
vm.OnNotify = Notifications.ShowSuccess; vm.OnNotify = Notifications.ShowSuccess;
vm.OnError = Notifications.ShowError;
} }
} }
@@ -126,7 +127,7 @@ public partial class PlanningTabView : UserControl
private async Task<MoveLessonTarget?> ShowMoveLessonDialog(Lesson lesson) private async Task<MoveLessonTarget?> ShowMoveLessonDialog(Lesson lesson)
{ {
var dialogVm = new MoveLessonDialogViewModel(lesson.Date); var dialogVm = new MoveLessonDialogViewModel(lesson.Date, lesson.LessonNumber);
var dialog = new MoveLessonDialog { DataContext = dialogVm }; var dialog = new MoveLessonDialog { DataContext = dialogVm };
var owner = TopLevel.GetTopLevel(this) as Window; var owner = TopLevel.GetTopLevel(this) as Window;
if (owner is null) return null; if (owner is null) return null;
@@ -58,6 +58,10 @@
<Run Text="["/><Run Text="{Binding Shortcut}"/><Run Text="] "/><Run Text="{Binding Label}"/> <Run Text="["/><Run Text="{Binding Shortcut}"/><Run Text="] "/><Run Text="{Binding Label}"/>
</TextBlock> </TextBlock>
<TextBlock Text="{Binding DisplayValue}" FontSize="11" Opacity="0.6"/> <TextBlock Text="{Binding DisplayValue}" FontSize="11" Opacity="0.6"/>
<Button Content="{Binding SuggestionLabel}" Command="{Binding ApplySuggestionCommand}"
IsVisible="{Binding HasSuggestion}" FontSize="10" Padding="6,2" Margin="0,3,0,0"
HorizontalAlignment="Left"
ToolTip.Tip="Aus der Strichliste im Sitzplan übernehmen"/>
</StackPanel> </StackPanel>
<WrapPanel Grid.Column="1" HorizontalAlignment="Right"> <WrapPanel Grid.Column="1" HorizontalAlignment="Right">
<ItemsControl ItemsSource="{Binding Choices}"> <ItemsControl ItemsSource="{Binding Choices}">
@@ -155,6 +155,21 @@
ToolTip.Tip="Hausaufgabe"/> ToolTip.Tip="Hausaufgabe"/>
</Border> </Border>
</StackPanel> </StackPanel>
<!-- Strichliste Meldungen (Nutzer-Feedback): schnelles Mitzählen ohne den
vollen Bewertungsdialog zu öffnen -->
<StackPanel Orientation="Horizontal" HorizontalAlignment="Center" Spacing="5"
IsVisible="{Binding ShowLessonOverview}">
<Button Padding="6,2" FontSize="10"
Command="{Binding TallyRaisedHandCommand}"
ToolTip.Tip="Meldung zählen">
<TextBlock Text="{Binding RaisedHandCount, StringFormat='✋ {0}'}"/>
</Button>
<Button Padding="6,2" FontSize="10"
Command="{Binding TallyCalledOnCommand}"
ToolTip.Tip="Meldung + drangekommen zählen">
<TextBlock Text="{Binding CalledOnCount, StringFormat='✋✓ {0}'}"/>
</Button>
</StackPanel>
<Expander Header=" Situation" FontSize="10" <Expander Header=" Situation" FontSize="10"
IsVisible="{Binding CanRecordLesson}"> IsVisible="{Binding CanRecordLesson}">
<ItemsControl ItemsSource="{Binding SituationTags}" Margin="0,4,0,0"> <ItemsControl ItemsSource="{Binding SituationTags}" Margin="0,4,0,0">
+31 -23
View File
@@ -22,7 +22,7 @@
x:DataType="vm:MainWindowViewModel" x:DataType="vm:MainWindowViewModel"
Title="LehrerApp" Title="LehrerApp"
Width="1280" Height="800" Width="1280" Height="800"
MinWidth="900" MinHeight="600"> MinWidth="640" MinHeight="480">
<Panel> <Panel>
<!-- <!--
@@ -81,14 +81,14 @@
FontFamily explizit auf die farbige Emoji-Schrift gepinnt: Windows kann für FontFamily explizit auf die farbige Emoji-Schrift gepinnt: Windows kann für
Emoji-Codepoints je nach Fallback-Auflösung sonst auf "Segoe UI Symbol" Emoji-Codepoints je nach Fallback-Auflösung sonst auf "Segoe UI Symbol"
(einfarbig/schwarz) statt "Segoe UI Emoji" (farbig) ausweichen. --> (einfarbig/schwarz) statt "Segoe UI Emoji" (farbig) ausweichen. -->
<Style Selector="TextBlock.navicon"> <Style Selector="PathIcon.navicon">
<Setter Property="FontSize" Value="15"/>
<Setter Property="Width" Value="24"/> <Setter Property="Width" Value="24"/>
<Setter Property="FontFamily" Value="Segoe UI Emoji,Segoe UI Symbol,Segoe UI"/> <Setter Property="Height" Value="24"/>
</Style> </Style>
<Style Selector="Button.navitem"> <Style Selector="Button.navitem">
<Setter Property="Background" Value="Transparent"/> <Setter Property="Background" Value="Transparent"/>
<Setter Property="Padding" Value="10,8"/> <Setter Property="Padding" Value="10,8"/>
<Setter Property="MinHeight" Value="40"/>
</Style> </Style>
<Style Selector="Button.navitem.active"> <Style Selector="Button.navitem.active">
<Setter Property="Background" Value="{DynamicResource SystemControlBackgroundAccentBrush}"/> <Setter Property="Background" Value="{DynamicResource SystemControlBackgroundAccentBrush}"/>
@@ -98,9 +98,9 @@
<Style Selector="DockPanel.compact TextBlock.navlabel"> <Style Selector="DockPanel.compact TextBlock.navlabel">
<Setter Property="IsVisible" Value="False"/> <Setter Property="IsVisible" Value="False"/>
</Style> </Style>
<Style Selector="DockPanel.compact TextBlock.navicon"> <Style Selector="DockPanel.compact PathIcon.navicon">
<Setter Property="FontSize" Value="20"/>
<Setter Property="Width" Value="28"/> <Setter Property="Width" Value="28"/>
<Setter Property="Height" Value="28"/>
</Style> </Style>
<Style Selector="DockPanel.compact Button.navitem"> <Style Selector="DockPanel.compact Button.navitem">
<Setter Property="HorizontalContentAlignment" Value="Center"/> <Setter Property="HorizontalContentAlignment" Value="Center"/>
@@ -157,7 +157,7 @@
ToolTip.Tip="Suchen und schnell erfassen (Strg/⌘+K)" ToolTip.Tip="Suchen und schnell erfassen (Strg/⌘+K)"
AutomationProperties.Name="Suchen und schnell erfassen"> AutomationProperties.Name="Suchen und schnell erfassen">
<Grid ColumnDefinitions="Auto,*,Auto"> <Grid ColumnDefinitions="Auto,*,Auto">
<TextBlock Classes="navicon" Text="⌕" TextAlignment="Center"/> <PathIcon Classes="navicon" Data="{StaticResource IconSearch}"/>
<TextBlock Grid.Column="1" Classes="navlabel" Text="Suchen / Erfassen"/> <TextBlock Grid.Column="1" Classes="navlabel" Text="Suchen / Erfassen"/>
<TextBlock Grid.Column="2" Classes="navlabel" Text="⌘K" FontSize="10" Opacity="0.45" <TextBlock Grid.Column="2" Classes="navlabel" Text="⌘K" FontSize="10" Opacity="0.45"
VerticalAlignment="Center"/> VerticalAlignment="Center"/>
@@ -169,9 +169,10 @@
HorizontalContentAlignment="Left" HorizontalContentAlignment="Left"
CornerRadius="6" CornerRadius="6"
Command="{Binding NavigateToCommand}" Command="{Binding NavigateToCommand}"
CommandParameter="{x:Static vm:NavItem.Dashboard}"> CommandParameter="{x:Static vm:NavItem.Dashboard}"
ToolTip.Tip="Dashboard (Strg/⌘+1)" AutomationProperties.Name="Dashboard">
<StackPanel Classes="navcontent" Orientation="Horizontal" Spacing="10"> <StackPanel Classes="navcontent" Orientation="Horizontal" Spacing="10">
<TextBlock Classes="navicon" Text="📊" TextAlignment="Center"/> <PathIcon Classes="navicon" Data="{StaticResource IconDashboard}"/>
<TextBlock Classes="navlabel" Text="Dashboard"/> <TextBlock Classes="navlabel" Text="Dashboard"/>
</StackPanel> </StackPanel>
</Button> </Button>
@@ -183,9 +184,10 @@
HorizontalContentAlignment="Left" HorizontalContentAlignment="Left"
CornerRadius="6" CornerRadius="6"
Command="{Binding NavigateToCommand}" Command="{Binding NavigateToCommand}"
CommandParameter="{x:Static vm:NavItem.Groups}"> CommandParameter="{x:Static vm:NavItem.Groups}"
ToolTip.Tip="Lerngruppen (Strg/⌘+2)" AutomationProperties.Name="Lerngruppen">
<StackPanel Classes="navcontent" Orientation="Horizontal" Spacing="10"> <StackPanel Classes="navcontent" Orientation="Horizontal" Spacing="10">
<TextBlock Classes="navicon" Text="🏫" TextAlignment="Center"/> <PathIcon Classes="navicon" Data="{StaticResource IconGroups}"/>
<TextBlock Classes="navlabel" Text="Lerngruppen"/> <TextBlock Classes="navlabel" Text="Lerngruppen"/>
</StackPanel> </StackPanel>
</Button> </Button>
@@ -193,9 +195,10 @@
HorizontalContentAlignment="Left" HorizontalContentAlignment="Left"
CornerRadius="6" CornerRadius="6"
Command="{Binding NavigateToCommand}" Command="{Binding NavigateToCommand}"
CommandParameter="{x:Static vm:NavItem.Students}"> CommandParameter="{x:Static vm:NavItem.Students}"
ToolTip.Tip="Schüler (Strg/⌘+3)" AutomationProperties.Name="Schüler">
<StackPanel Classes="navcontent" Orientation="Horizontal" Spacing="10"> <StackPanel Classes="navcontent" Orientation="Horizontal" Spacing="10">
<TextBlock Classes="navicon" Text="👤" TextAlignment="Center"/> <PathIcon Classes="navicon" Data="{StaticResource IconStudents}"/>
<TextBlock Classes="navlabel" Text="Schüler"/> <TextBlock Classes="navlabel" Text="Schüler"/>
</StackPanel> </StackPanel>
</Button> </Button>
@@ -203,9 +206,10 @@
HorizontalContentAlignment="Left" HorizontalContentAlignment="Left"
CornerRadius="6" CornerRadius="6"
Command="{Binding NavigateToCommand}" Command="{Binding NavigateToCommand}"
CommandParameter="{x:Static vm:NavItem.Exams}"> CommandParameter="{x:Static vm:NavItem.Exams}"
ToolTip.Tip="Klausuren (Strg/⌘+4)" AutomationProperties.Name="Klausuren">
<StackPanel Classes="navcontent" Orientation="Horizontal" Spacing="10"> <StackPanel Classes="navcontent" Orientation="Horizontal" Spacing="10">
<TextBlock Classes="navicon" Text="📝" TextAlignment="Center"/> <PathIcon Classes="navicon" Data="{StaticResource IconExams}"/>
<TextBlock Classes="navlabel" Text="Klausuren"/> <TextBlock Classes="navlabel" Text="Klausuren"/>
</StackPanel> </StackPanel>
</Button> </Button>
@@ -213,9 +217,10 @@
HorizontalContentAlignment="Left" HorizontalContentAlignment="Left"
CornerRadius="6" CornerRadius="6"
Command="{Binding NavigateToCommand}" Command="{Binding NavigateToCommand}"
CommandParameter="{x:Static vm:NavItem.Planner}"> CommandParameter="{x:Static vm:NavItem.Planner}"
ToolTip.Tip="Planung (Strg/⌘+5)" AutomationProperties.Name="Planung">
<StackPanel Classes="navcontent" Orientation="Horizontal" Spacing="10"> <StackPanel Classes="navcontent" Orientation="Horizontal" Spacing="10">
<TextBlock Classes="navicon" Text="📅" TextAlignment="Center"/> <PathIcon Classes="navicon" Data="{StaticResource IconCalendar}"/>
<TextBlock Classes="navlabel" Text="Planung"/> <TextBlock Classes="navlabel" Text="Planung"/>
</StackPanel> </StackPanel>
</Button> </Button>
@@ -227,9 +232,10 @@
HorizontalContentAlignment="Left" HorizontalContentAlignment="Left"
CornerRadius="6" CornerRadius="6"
Command="{Binding NavigateToCommand}" Command="{Binding NavigateToCommand}"
CommandParameter="{x:Static vm:NavItem.Workload}"> CommandParameter="{x:Static vm:NavItem.Workload}"
ToolTip.Tip="Arbeitszeit (Strg/⌘+6)" AutomationProperties.Name="Arbeitszeit">
<StackPanel Classes="navcontent" Orientation="Horizontal" Spacing="10"> <StackPanel Classes="navcontent" Orientation="Horizontal" Spacing="10">
<TextBlock Classes="navicon" Text="⏱" TextAlignment="Center"/> <PathIcon Classes="navicon" Data="{StaticResource IconClock}"/>
<TextBlock Classes="navlabel" Text="Arbeitszeit"/> <TextBlock Classes="navlabel" Text="Arbeitszeit"/>
</StackPanel> </StackPanel>
</Button> </Button>
@@ -237,9 +243,10 @@
HorizontalContentAlignment="Left" HorizontalContentAlignment="Left"
CornerRadius="6" CornerRadius="6"
Command="{Binding NavigateToCommand}" Command="{Binding NavigateToCommand}"
CommandParameter="{x:Static vm:NavItem.ClassTeacher}"> CommandParameter="{x:Static vm:NavItem.ClassTeacher}"
ToolTip.Tip="Klassenlehrer (Strg/⌘+7)" AutomationProperties.Name="Klassenlehrer">
<StackPanel Classes="navcontent" Orientation="Horizontal" Spacing="10"> <StackPanel Classes="navcontent" Orientation="Horizontal" Spacing="10">
<TextBlock Classes="navicon" Text="🎓" TextAlignment="Center"/> <PathIcon Classes="navicon" Data="{StaticResource IconClassTeacher}"/>
<TextBlock Classes="navlabel" Text="Klassenlehrer"/> <TextBlock Classes="navlabel" Text="Klassenlehrer"/>
</StackPanel> </StackPanel>
</Button> </Button>
@@ -247,9 +254,10 @@
HorizontalContentAlignment="Left" HorizontalContentAlignment="Left"
CornerRadius="6" CornerRadius="6"
Command="{Binding NavigateToCommand}" Command="{Binding NavigateToCommand}"
CommandParameter="{x:Static vm:NavItem.Settings}"> CommandParameter="{x:Static vm:NavItem.Settings}"
ToolTip.Tip="Einstellungen (Strg/⌘+8)" AutomationProperties.Name="Einstellungen">
<StackPanel Classes="navcontent" Orientation="Horizontal" Spacing="10"> <StackPanel Classes="navcontent" Orientation="Horizontal" Spacing="10">
<TextBlock Classes="navicon" Text="⚙️" TextAlignment="Center"/> <PathIcon Classes="navicon" Data="{StaticResource IconSettings}"/>
<TextBlock Classes="navlabel" Text="Einstellungen"/> <TextBlock Classes="navlabel" Text="Einstellungen"/>
</StackPanel> </StackPanel>
</Button> </Button>
@@ -36,6 +36,13 @@ public partial class MainWindow : Window
return; return;
} }
if (commandModifier && TryGetNavigationShortcut(e.Key, out var destination))
{
vm.NavigateToCommand.Execute(destination);
e.Handled = true;
return;
}
if (!vm.IsCommandPaletteOpen) return; if (!vm.IsCommandPaletteOpen) return;
if (e.Key == Key.Escape) if (e.Key == Key.Escape)
{ {
@@ -54,6 +61,23 @@ public partial class MainWindow : Window
} }
} }
private static bool TryGetNavigationShortcut(Key key, out NavItem destination)
{
destination = key switch
{
Key.D1 or Key.NumPad1 => NavItem.Dashboard,
Key.D2 or Key.NumPad2 => NavItem.Groups,
Key.D3 or Key.NumPad3 => NavItem.Students,
Key.D4 or Key.NumPad4 => NavItem.Exams,
Key.D5 or Key.NumPad5 => NavItem.Planner,
Key.D6 or Key.NumPad6 => NavItem.Workload,
Key.D7 or Key.NumPad7 => NavItem.ClassTeacher,
Key.D8 or Key.NumPad8 => NavItem.Settings,
_ => default,
};
return key is >= Key.D1 and <= Key.D8 or >= Key.NumPad1 and <= Key.NumPad8;
}
private void OnOpenCommandPaletteClick(object? sender, Avalonia.Interactivity.RoutedEventArgs e) private void OnOpenCommandPaletteClick(object? sender, Avalonia.Interactivity.RoutedEventArgs e)
{ {
if (DataContext is MainWindowViewModel vm) if (DataContext is MainWindowViewModel vm)
@@ -0,0 +1,43 @@
<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="clr-namespace:LehrerApp.Desktop.ViewModels.Planning"
x:Class="LehrerApp.Desktop.Views.Planning.TimetableUnitPickerDialog"
x:DataType="vm:TimetableUnitPickerViewModel"
Title="Einheit für die Stunde wählen"
Width="470" Height="390" MinWidth="420" MinHeight="360"
CanResize="False" WindowStartupLocation="CenterOwner">
<Grid RowDefinitions="*,Auto" Margin="24">
<StackPanel Grid.Row="0" Spacing="16">
<TextBlock Text="Stunde anlegen" Classes="dialogtitle"/>
<TextBlock Text="{Binding ContextLabel}" FontSize="12" Opacity="0.65"/>
<TextBlock Text="Die Stunde braucht eine Unterrichtseinheit. Laufende Einheiten werden zuerst vorgeschlagen."
FontSize="12" TextWrapping="Wrap" Opacity="0.75"/>
<StackPanel Spacing="5" IsVisible="{Binding HasUnits}">
<TextBlock Text="Vorhandene Einheit" FontSize="12" Opacity="0.7"/>
<ComboBox ItemsSource="{Binding Units}" SelectedItem="{Binding SelectedUnit}"
HorizontalAlignment="Stretch">
<ComboBox.ItemTemplate>
<DataTemplate x:DataType="vm:TimetableUnitOption">
<StackPanel>
<TextBlock Text="{Binding Label}" FontWeight="SemiBold"/>
<TextBlock Text="{Binding Detail}" FontSize="11" Opacity="0.6"/>
</StackPanel>
</DataTemplate>
</ComboBox.ItemTemplate>
</ComboBox>
</StackPanel>
<StackPanel Spacing="5">
<TextBlock Text="Oder neue Einheit anlegen" FontSize="12" Opacity="0.7"/>
<TextBox Text="{Binding NewUnitTitle}" PlaceholderText="Titel der neuen Einheit"/>
</StackPanel>
<TextBlock Text="{Binding Error}" Foreground="Red" FontSize="11"
IsVisible="{Binding Error, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
</StackPanel>
<Grid Grid.Row="1" ColumnDefinitions="*,8,*" Margin="0,20,0,0">
<Button Grid.Column="0" Content="Abbrechen" HorizontalAlignment="Stretch" Click="OnCancel"/>
<Button Grid.Column="2" Content="Weiter zur Planung" HorizontalAlignment="Stretch" Click="OnSave"/>
</Grid>
</Grid>
</Window>
@@ -0,0 +1,19 @@
using Avalonia.Controls;
using Avalonia.Interactivity;
using LehrerApp.Desktop.ViewModels.Planning;
namespace LehrerApp.Desktop.Views.Planning;
public partial class TimetableUnitPickerDialog : Window
{
public TimetableUnitPickerDialog() => InitializeComponent();
private void OnSave(object? sender, RoutedEventArgs e)
{
if (DataContext is not TimetableUnitPickerViewModel vm) return;
vm.SaveCommand.Execute(null);
if (vm.Result is not null) Close(true);
}
private void OnCancel(object? sender, RoutedEventArgs e) => Close(false);
}
@@ -120,6 +120,7 @@
</TextBlock> </TextBlock>
<TextBlock Text="{Binding LessonTopic}" FontSize="11" Opacity="0.75" <TextBlock Text="{Binding LessonTopic}" FontSize="11" Opacity="0.75"
TextWrapping="Wrap" IsVisible="{Binding HasLessonTopic}"/> TextWrapping="Wrap" IsVisible="{Binding HasLessonTopic}"/>
<TextBlock Text="{Binding PlanningStatusLabel}" FontSize="10" Opacity="0.62"/>
<TextBlock Text="{Binding ExamTitle}" FontSize="11" Foreground="#D85A30" FontWeight="SemiBold" <TextBlock Text="{Binding ExamTitle}" FontSize="11" Foreground="#D85A30" FontWeight="SemiBold"
IsVisible="{Binding HasExam}"/> IsVisible="{Binding HasExam}"/>
<TextBlock Text="📓 Hausaufgabe aus letzter Stunde noch nicht kontrolliert" FontSize="11" <TextBlock Text="📓 Hausaufgabe aus letzter Stunde noch nicht kontrolliert" FontSize="11"
@@ -154,9 +155,11 @@
<Grid ColumnDefinitions="Auto,Auto,*,Auto,Auto,Auto"> <Grid ColumnDefinitions="Auto,Auto,*,Auto,Auto,Auto">
<Button Grid.Column="0" Content="" FontWeight="Bold" Padding="10,4" <Button Grid.Column="0" Content="" FontWeight="Bold" Padding="10,4"
Command="{Binding PreviousWeekCommand}" ToolTip.Tip="Vorherige Woche"/> Command="{Binding PreviousWeekCommand}" ToolTip.Tip="Vorherige Woche"
AutomationProperties.Name="Vorherige Woche"/>
<Button Grid.Column="1" Content="" FontWeight="Bold" Padding="10,4" Margin="4,0,0,0" <Button Grid.Column="1" Content="" FontWeight="Bold" Padding="10,4" Margin="4,0,0,0"
Command="{Binding NextWeekCommand}" ToolTip.Tip="Nächste Woche"/> Command="{Binding NextWeekCommand}" ToolTip.Tip="Nächste Woche"
AutomationProperties.Name="Nächste Woche"/>
<TextBlock Grid.Column="2" FontSize="16" FontWeight="SemiBold" <TextBlock Grid.Column="2" FontSize="16" FontWeight="SemiBold"
VerticalAlignment="Center" Margin="12,0,0,0"> VerticalAlignment="Center" Margin="12,0,0,0">
<Run Text="Woche "/><Run Text="{Binding WeekRangeLabel}"/> <Run Text="Woche "/><Run Text="{Binding WeekRangeLabel}"/>
@@ -166,7 +169,8 @@
<Button Grid.Column="4" Content="Ausnahme eintragen" Margin="0,0,8,0" <Button Grid.Column="4" Content="Ausnahme eintragen" Margin="0,0,8,0"
Command="{Binding AddSubstitutionCommand}"/> Command="{Binding AddSubstitutionCommand}"/>
<Button Grid.Column="5" Content="⚙️" Command="{Binding OpenSettingsCommand}" <Button Grid.Column="5" Content="⚙️" Command="{Binding OpenSettingsCommand}"
ToolTip.Tip="Einstellungen (Ferien, Aufsichten, Stundenraster)"/> ToolTip.Tip="Einstellungen (Ferien, Aufsichten, Stundenraster)"
AutomationProperties.Name="Stundenplan-Einstellungen"/>
</Grid> </Grid>
<Border Background="#33F59E0B" BorderBrush="#F59E0B" BorderThickness="1" <Border Background="#33F59E0B" BorderBrush="#F59E0B" BorderThickness="1"
@@ -255,6 +259,8 @@
IsVisible="{Binding HasRoom}"/> IsVisible="{Binding HasRoom}"/>
<TextBlock Text="{Binding Topic}" FontSize="10" Foreground="White" Opacity="0.8" <TextBlock Text="{Binding Topic}" FontSize="10" Foreground="White" Opacity="0.8"
TextWrapping="Wrap" IsVisible="{Binding HasTopic}"/> TextWrapping="Wrap" IsVisible="{Binding HasTopic}"/>
<TextBlock Text="{Binding PlanningStatusLabel}" FontSize="9" Foreground="White"
Opacity="0.78" Margin="0,1,0,0"/>
<TextBlock Text="Ferien" FontSize="10" Foreground="White" FontWeight="SemiBold" <TextBlock Text="Ferien" FontSize="10" Foreground="White" FontWeight="SemiBold"
Opacity="0.9" IsVisible="{Binding IsHoliday}" Margin="0,2,0,0"/> Opacity="0.9" IsVisible="{Binding IsHoliday}" Margin="0,2,0,0"/>
<TextBlock Text="Ausfall" FontSize="10" Foreground="White" FontWeight="SemiBold" <TextBlock Text="Ausfall" FontSize="10" Foreground="White" FontWeight="SemiBold"
@@ -281,7 +287,7 @@
<Button Classes="weekCellMenuTrigger" Content="⋮" <Button Classes="weekCellMenuTrigger" Content="⋮"
HorizontalAlignment="Right" VerticalAlignment="Top" HorizontalAlignment="Right" VerticalAlignment="Top"
IsVisible="{Binding HasGroupId}" IsVisible="{Binding HasGroupId}"
ToolTip.Tip="Weitere Ziele…"> ToolTip.Tip="Weitere Ziele…" AutomationProperties.Name="Weitere Ziele">
<Button.Flyout> <Button.Flyout>
<MenuFlyout Placement="BottomEdgeAlignedRight"> <MenuFlyout Placement="BottomEdgeAlignedRight">
<MenuItem Header="▶ Unterrichtsansicht" IsVisible="{Binding HasLesson}" <MenuItem Header="▶ Unterrichtsansicht" IsVisible="{Binding HasLesson}"
@@ -290,6 +296,12 @@
<MenuItem Header="📋 Planungsviewer" IsVisible="{Binding HasLesson}" <MenuItem Header="📋 Planungsviewer" IsVisible="{Binding HasLesson}"
Tag="{x:Static vm:TimetableLessonDestination.Viewer}" Tag="{x:Static vm:TimetableLessonDestination.Viewer}"
Click="OnWeekCellMenuItemClick"/> Click="OnWeekCellMenuItemClick"/>
<MenuItem Header=" Stunde anlegen" IsVisible="{Binding HasNoLesson}"
Tag="{x:Static vm:TimetableLessonDestination.Create}"
Click="OnWeekCellMenuItemClick"/>
<MenuItem Header="↪ Stunde verschieben" IsVisible="{Binding HasLesson}"
Tag="{x:Static vm:TimetableLessonDestination.Move}"
Click="OnWeekCellMenuItemClick"/>
<MenuItem Header="🪑 Sitzplan" <MenuItem Header="🪑 Sitzplan"
Tag="{x:Static vm:TimetableLessonDestination.SeatingPlan}" Tag="{x:Static vm:TimetableLessonDestination.SeatingPlan}"
Click="OnWeekCellMenuItemClick"/> Click="OnWeekCellMenuItemClick"/>
@@ -404,7 +416,8 @@
HorizontalAlignment="Stretch" VerticalAlignment="Stretch" HorizontalAlignment="Stretch" VerticalAlignment="Stretch"
HorizontalContentAlignment="Center" Background="Transparent" HorizontalContentAlignment="Center" Background="Transparent"
Command="{Binding $parent[ItemsControl;1].((vm:TimetableViewModel)DataContext).EditCellCommand}" Command="{Binding $parent[ItemsControl;1].((vm:TimetableViewModel)DataContext).EditCellCommand}"
CommandParameter="{Binding}"/> CommandParameter="{Binding}"
AutomationProperties.Name="Stundenplan-Eintrag hinzufügen"/>
<Border Background="#D85A30" CornerRadius="8" Padding="5,1" <Border Background="#D85A30" CornerRadius="8" Padding="5,1"
IsVisible="{Binding HasBadge}" IsVisible="{Binding HasBadge}"
HorizontalAlignment="Right" VerticalAlignment="Top" Margin="2"> HorizontalAlignment="Right" VerticalAlignment="Top" Margin="2">
@@ -26,6 +26,8 @@ public partial class TimetableView : UserControl
vm.OnImportWebUntisTimetable = ShowWebUntisTimetableDialog; vm.OnImportWebUntisTimetable = ShowWebUntisTimetableDialog;
vm.OnOpenLessonViewer = ShowLessonViewerDialog; vm.OnOpenLessonViewer = ShowLessonViewerDialog;
vm.OnOpenTeachingMode = ShowTeachingMode; vm.OnOpenTeachingMode = ShowTeachingMode;
vm.OnCreateLesson = ShowCreateLessonDialog;
vm.OnMoveLesson = ShowMoveLessonDialog;
} }
} }
@@ -33,10 +35,11 @@ public partial class TimetableView : UserControl
/// Stelle gelöst, in der "Heute"-Tagesliste statt im Wochenraster): im Wochenraster oben /// Stelle gelöst, in der "Heute"-Tagesliste statt im Wochenraster): im Wochenraster oben
/// führt ein Klick auf eine Stunden-Kachel bislang entweder in den Planungsviewer oder zur /// führt ein Klick auf eine Stunden-Kachel bislang entweder in den Planungsviewer oder zur
/// Einheitenplanung — je nachdem, ob schon eine Lesson existiert, ohne dass das von außen /// Einheitenplanung — je nachdem, ob schon eine Lesson existiert, ohne dass das von außen
/// erkennbar wäre. Popup-Menü (MenuFlyout, kein ComboBox mehr) mit allen vier Zielen als /// erkennbar wäre. Popup-Menü (MenuFlyout, kein ComboBox mehr) mit allen Zielen einschließlich
/// Direktanlage und Verschieben als
/// Alternative zum Direktklick, siehe <see cref="TimetableViewModel.OpenWeekCellCommand"/> /// Alternative zum Direktklick, siehe <see cref="TimetableViewModel.OpenWeekCellCommand"/>
/// für den "einheitlicheren" Standard-Klick (Unterrichtsansicht bei laufender Stunde, sonst /// für den "einheitlicheren" Standard-Klick (Unterrichtsansicht bei laufender Stunde, sonst
/// Planungsviewer, sonst Einheitenplanung). MenuItem.Click statt Command-Binding: ein /// Planungsviewer, sonst Direktanlage). MenuItem.Click statt Command-Binding: ein
/// $parent[ItemsControl]-Vorfahrenpfad (wie beim Zeilen-Button) funktioniert innerhalb eines /// $parent[ItemsControl]-Vorfahrenpfad (wie beim Zeilen-Button) funktioniert innerhalb eines
/// Flyouts nicht zuverlässig, weil dessen Popup nicht im normalen visuellen Baum hängt (siehe /// Flyouts nicht zuverlässig, weil dessen Popup nicht im normalen visuellen Baum hängt (siehe
/// TODO.md-Nachtrag zum Klassenlehrer-Bereich) — DataContext-Vererbung (kein Pfad-Suchen, /// TODO.md-Nachtrag zum Klassenlehrer-Bereich) — DataContext-Vererbung (kein Pfad-Suchen,
@@ -58,6 +61,14 @@ public partial class TimetableView : UserControl
if (cell.Lesson is { } viewLesson && vm.OnOpenLessonViewer is not null) if (cell.Lesson is { } viewLesson && vm.OnOpenLessonViewer is not null)
await vm.OnOpenLessonViewer(viewLesson); await vm.OnOpenLessonViewer(viewLesson);
break; break;
case TimetableLessonDestination.Create:
if (cell.Date is { } createDate && vm.OnCreateLesson is not null)
await vm.OnCreateLesson(new TimetableLessonRequest(cell.GroupId, createDate, cell.PeriodNumber));
break;
case TimetableLessonDestination.Move:
if (cell.Lesson is { } moveLesson && vm.OnMoveLesson is not null)
await vm.OnMoveLesson(new TimetableLessonMoveRequest(moveLesson, cell.PeriodNumber));
break;
case TimetableLessonDestination.Planning: case TimetableLessonDestination.Planning:
if (cell.GroupId != Guid.Empty) if (cell.GroupId != Guid.Empty)
App.Services.GetRequiredService<MainWindowViewModel>().NavigateToGroupDetail(cell.GroupId, 6); App.Services.GetRequiredService<MainWindowViewModel>().NavigateToGroupDetail(cell.GroupId, 6);
@@ -69,6 +80,96 @@ public partial class TimetableView : UserControl
} }
} }
private async Task ShowCreateLessonDialog(TimetableLessonRequest request)
{
var owner = TopLevel.GetTopLevel(this) as Window;
var groups = App.Services.GetRequiredService<IGroupRepository>();
var group = groups.GetById(request.GroupId);
if (owner is null || group is null || !group.IsActive) return;
var lessons = App.Services.GetRequiredService<ILessonRepository>();
if (lessons.GetByGroupAndDate(request.GroupId, request.Date)
.Any(l => l.LessonNumber == request.PeriodNumber))
{
App.Services.GetRequiredService<NotificationService>()
.ShowError("Für diesen Termin existiert bereits eine Stundenplanung.");
return;
}
var units = App.Services.GetRequiredService<IUnitRepository>();
var pickerVm = new TimetableUnitPickerViewModel(units, group.Id, group.Name,
request.Date, request.PeriodNumber);
var picker = new TimetableUnitPickerDialog { DataContext = pickerVm };
if (!await picker.ShowDialog<bool>(owner) || pickerVm.Result is not { } unit) return;
var groupLessons = units.GetByGroup(group.Id).SelectMany(u => lessons.GetByUnit(u.Id)).ToList();
var materials = groupLessons.SelectMany(l => l.Phases).Select(p => p.Material)
.Where(x => !string.IsNullOrWhiteSpace(x)).Distinct(StringComparer.OrdinalIgnoreCase)
.OrderBy(x => x, StringComparer.CurrentCultureIgnoreCase).ToList();
var shorthands = groupLessons.SelectMany(l => l.Phases).Select(p => p.Shorthand)
.Where(x => !string.IsNullOrWhiteSpace(x)).Distinct(StringComparer.OrdinalIgnoreCase)
.OrderBy(x => x, StringComparer.CurrentCultureIgnoreCase).ToList();
var subject = group.SubjectId is { } subjectId
? App.Services.GetRequiredService<ISubjectRepository>().GetById(subjectId) : null;
var lessonVm = new LessonDialogViewModel(
lessons,
App.Services.GetRequiredService<IShorthandCodeRepository>(),
App.Services.GetRequiredService<IAlternativeLessonPathRepository>(),
App.Services.GetRequiredService<ITimetableSlotRepository>(),
App.Services.GetRequiredService<PeriodScheduleService>(),
App.Services.GetRequiredService<IAttachmentStorage>(),
unit.Id, group.Id, group.Name, subject?.Name ?? "", materials, shorthands,
editingLesson: null, suggestedDate: request.Date, suggestedPeriod: request.PeriodNumber);
var lessonDialog = new LessonDialog { DataContext = lessonVm };
if (await lessonDialog.ShowDialog<bool>(owner) && lessonVm.Result is not null)
{
if (pickerVm.ResultIsNew) units.Save(unit);
(DataContext as TimetableViewModel)?.Load();
App.Services.GetRequiredService<NotificationService>().ShowSuccess("Stunde angelegt.");
}
}
private async Task ShowMoveLessonDialog(TimetableLessonMoveRequest request)
{
var owner = TopLevel.GetTopLevel(this) as Window;
if (owner is null) return;
var lesson = request.Lesson;
var slots = App.Services.GetRequiredService<ITimetableSlotRepository>();
var schedule = App.Services.GetRequiredService<PeriodScheduleService>();
var anchor = lesson.LessonNumber;
var firstPartMinutes = anchor is int p ? schedule.GetDurationMinutes(p) : 0;
var isDouble = anchor is int start && firstPartMinutes > 0 &&
slots.GetByGroup(lesson.GroupId).Any(s => s.Weekday == lesson.Date.DayOfWeek &&
s.PeriodNumber == start + 1) && lesson.Phases.Sum(x => x.DurationMinutes) > firstPartMinutes;
var dialogVm = new MoveLessonDialogViewModel(lesson.Date, lesson.LessonNumber, isDouble,
isDouble && anchor is int a && request.SelectedPeriod > a);
var dialog = new MoveLessonDialog { DataContext = dialogVm };
if (!await dialog.ShowDialog<bool>(owner) || dialogVm.Result is not { } target) return;
try
{
var service = new LessonSchedulingService(App.Services.GetRequiredService<ILessonRepository>());
if (target.SplitDoubleLesson)
{
if (target.NewPeriod is not int newPeriod)
throw new InvalidOperationException("Für den zweiten Teil ist eine Zielstunde erforderlich.");
service.SplitAndMoveSecondPart(lesson, firstPartMinutes, target.NewDate, newPeriod,
schedule.GetTimes(newPeriod)?.Start);
}
else service.Move(lesson, target.NewDate, target.NewPeriod, target.ShiftFollowing);
(DataContext as TimetableViewModel)?.Load();
App.Services.GetRequiredService<NotificationService>().ShowSuccess(
target.SplitDoubleLesson ? "Doppelstunde getrennt und Fortsetzung verschoben." : "Stunde verschoben.");
}
catch (InvalidOperationException ex)
{
App.Services.GetRequiredService<NotificationService>().ShowError(ex.Message);
}
}
private async Task ShowWebUntisTimetableDialog() private async Task ShowWebUntisTimetableDialog()
{ {
var owner = TopLevel.GetTopLevel(this) as Window; var owner = TopLevel.GetTopLevel(this) as Window;
@@ -619,6 +619,19 @@
<TextBlock Text="{Binding BackupStatus}" Foreground="Green" FontSize="12" <TextBlock Text="{Binding BackupStatus}" Foreground="Green" FontSize="12"
IsVisible="{Binding BackupStatus, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/> IsVisible="{Binding BackupStatus, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
<StackPanel Spacing="6" Margin="0,6,0,0">
<TextBlock Text="Zweiter Sicherungsordner (optional)" FontSize="13" FontWeight="SemiBold"/>
<TextBlock Text="Zusätzliches Ziel für jedes Backup, z. B. ein USB-Stick oder Netzlaufwerk — ist es beim Sichern nicht erreichbar, bleibt das Hauptbackup davon unberührt."
FontSize="12" Opacity="0.6" TextWrapping="Wrap"/>
<TextBlock Text="{Binding SecondaryBackupDirectory}" FontSize="12"
IsVisible="{Binding SecondaryBackupDirectory, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
<StackPanel Orientation="Horizontal" Spacing="8">
<Button Content="Ordner wählen…" Command="{Binding PickBackupDirectoryCommand}"/>
<Button Content="Entfernen" Command="{Binding ClearBackupDirectoryCommand}"
IsVisible="{Binding SecondaryBackupDirectory, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
</StackPanel>
</StackPanel>
<ItemsControl ItemsSource="{Binding Backups}"> <ItemsControl ItemsSource="{Binding Backups}">
<ItemsControl.ItemTemplate> <ItemsControl.ItemTemplate>
<DataTemplate DataType="vm:BackupListItem"> <DataTemplate DataType="vm:BackupListItem">
@@ -34,6 +34,7 @@ public partial class SettingsView : UserControl
vm.OnThemeChanged = App.ApplyTheme; vm.OnThemeChanged = App.ApplyTheme;
vm.OnReviewUntisMapping = ShowUntisMappingReviewDialog; vm.OnReviewUntisMapping = ShowUntisMappingReviewDialog;
vm.OnPickHomeroomClass = PickHomeroomClassAsync; vm.OnPickHomeroomClass = PickHomeroomClassAsync;
vm.OnPickBackupDirectory = PickBackupDirectoryAsync;
_ = vm.LoadSchoolLocationCommand.ExecuteAsync(null); _ = vm.LoadSchoolLocationCommand.ExecuteAsync(null);
} }
} }
@@ -84,6 +85,19 @@ public partial class SettingsView : UserControl
return true; return true;
} }
private async Task<string?> PickBackupDirectoryAsync()
{
var topLevel = TopLevel.GetTopLevel(this);
if (topLevel is null) return null;
var folders = await topLevel.StorageProvider.OpenFolderPickerAsync(new FolderPickerOpenOptions
{
Title = "Zweiten Sicherungsordner wählen",
AllowMultiple = false,
});
return folders.Count == 0 ? null : folders[0].Path.LocalPath;
}
private async Task<string?> PickRecoveryFile() private async Task<string?> PickRecoveryFile()
{ {
var topLevel = TopLevel.GetTopLevel(this); var topLevel = TopLevel.GetTopLevel(this);
@@ -13,10 +13,10 @@
<TextBlock Text="{Binding DialogTitle}" Classes="dialogtitle"/> <TextBlock Text="{Binding DialogTitle}" Classes="dialogtitle"/>
<StackPanel Spacing="4" IsVisible="{Binding CanPickStudent}"> <StackPanel Spacing="4" IsVisible="{Binding CanPickStudent}">
<TextBlock Text="Schüler *" FontSize="12" Opacity="0.7"/> <TextBlock Text="Bezug *" FontSize="12" Opacity="0.7"/>
<ComboBox ItemsSource="{Binding StudentOptions}" SelectedItem="{Binding SelectedStudent}" <ComboBox ItemsSource="{Binding StudentOptions}" SelectedItem="{Binding SelectedStudent}"
DisplayMemberBinding="{Binding Name}" HorizontalAlignment="Stretch" DisplayMemberBinding="{Binding Name}" HorizontalAlignment="Stretch"
PlaceholderText="Schüler wählen"/> PlaceholderText="Schüler oder gesamte Lerngruppe wählen"/>
<TextBlock Text="{Binding StudentError}" Foreground="Red" FontSize="11" <TextBlock Text="{Binding StudentError}" Foreground="Red" FontSize="11"
IsVisible="{Binding StudentError, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/> IsVisible="{Binding StudentError, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
</StackPanel> </StackPanel>
@@ -0,0 +1,44 @@
<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="clr-namespace:LehrerApp.Desktop.ViewModels.Students"
x:Class="LehrerApp.Desktop.Views.Students.GroupDocumentationQuickDialog"
x:DataType="vm:GroupDocumentationQuickViewModel"
Title="Lerngruppen-Eintrag" Width="480" SizeToContent="Height"
CanResize="False" WindowStartupLocation="CenterOwner">
<Grid RowDefinitions="*,Auto" Margin="24">
<StackPanel Spacing="12">
<TextBlock Text="Lerngruppen-Eintrag" Classes="dialogtitle"/>
<TextBlock Text="Planung, Klausur oder Erinnerung für eine ganze Lerngruppe festhalten."
TextWrapping="Wrap" Opacity="0.7"/>
<StackPanel Spacing="4">
<TextBlock Text="Lerngruppe *" FontSize="12" Opacity="0.7"/>
<ComboBox ItemsSource="{Binding Groups}" SelectedItem="{Binding SelectedGroup}"
DisplayMemberBinding="{Binding DisplayName}" PlaceholderText="Lerngruppe wählen"/>
<TextBlock Text="{Binding GroupError}" Foreground="Red" FontSize="11"
IsVisible="{Binding GroupError, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
</StackPanel>
<Grid ColumnDefinitions="*,12,140">
<StackPanel Spacing="4">
<TextBlock Text="Titel *" FontSize="12" Opacity="0.7"/>
<TextBox Text="{Binding Title}" PlaceholderText="z. B. Klausur ankündigen"/>
<TextBlock Text="{Binding TitleError}" Foreground="Red" FontSize="11"
IsVisible="{Binding TitleError, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
</StackPanel>
<StackPanel Grid.Column="2" Spacing="4">
<TextBlock Text="Datum *" FontSize="12" Opacity="0.7"/>
<TextBox Text="{Binding DateText}"/>
<TextBlock Text="{Binding DateError}" Foreground="Red" FontSize="11"
IsVisible="{Binding DateError, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
</StackPanel>
</Grid>
<StackPanel Spacing="4">
<TextBlock Text="Notiz" FontSize="12" Opacity="0.7"/>
<TextBox Text="{Binding Content}" AcceptsReturn="True" Height="90" TextWrapping="Wrap"/>
</StackPanel>
</StackPanel>
<StackPanel Grid.Row="1" Orientation="Horizontal" HorizontalAlignment="Right" Spacing="8" Margin="0,20,0,0">
<Button Content="Abbrechen" Click="OnCancel"/>
<Button Content="Speichern" Classes="accent" Click="OnSave"/>
</StackPanel>
</Grid>
</Window>
@@ -0,0 +1,19 @@
using Avalonia.Controls;
using Avalonia.Interactivity;
using LehrerApp.Desktop.ViewModels.Students;
namespace LehrerApp.Desktop.Views.Students;
public partial class GroupDocumentationQuickDialog : Window
{
public GroupDocumentationQuickDialog() => InitializeComponent();
private void OnSave(object? sender, RoutedEventArgs e)
{
if (DataContext is not GroupDocumentationQuickViewModel vm) return;
vm.SaveCommand.Execute(null);
if (vm.Result is not null) Close(true);
}
private void OnCancel(object? sender, RoutedEventArgs e) => Close(false);
}
@@ -8,18 +8,21 @@
<Border Grid.Row="0" Padding="20,16" <Border Grid.Row="0" Padding="20,16"
BorderBrush="{DynamicResource SystemControlForegroundBaseLowBrush}" BorderBrush="{DynamicResource SystemControlForegroundBaseLowBrush}"
BorderThickness="0,0,0,1"> BorderThickness="0,0,0,1">
<Grid ColumnDefinitions="*,Auto,Auto,Auto"> <Grid RowDefinitions="Auto,Auto" ColumnDefinitions="*,Auto,Auto,Auto">
<shared:PageHeader Grid.Column="0" Title="Schüler" Subtitle="{Binding CountSummary}"/> <shared:PageHeader Grid.ColumnSpan="4" Title="Schüler" Subtitle="{Binding CountSummary}"/>
<CheckBox Grid.Column="1" Content="Inaktive anzeigen" <CheckBox Grid.Row="1" Grid.Column="1" Content="Inaktive anzeigen"
IsChecked="{Binding ShowInactive}" IsChecked="{Binding ShowInactive}"
VerticalAlignment="Center" Margin="0,0,12,0"/> VerticalAlignment="Center" Margin="0,10,12,0"/>
<Button Grid.Column="2" Content="⇩ Importieren…" Click="OnImportClick" <Button Grid.Row="1" Grid.Column="2" Content="⇩ Importieren…" Click="OnImportClick"
VerticalAlignment="Center" Margin="0,0,8,0"/> IsEnabled="{Binding !IsImporting}"
<Button Grid.Column="3" Content=" Neuer Schüler" VerticalAlignment="Center" Margin="0,10,8,0"
Command="{Binding AddStudentCommand}" VerticalAlignment="Center"/> AutomationProperties.Name="Schüler importieren"/>
<Button Grid.Row="1" Grid.Column="3" Content=" Neuer Schüler"
Command="{Binding AddStudentCommand}" VerticalAlignment="Center" Margin="0,10,0,0"/>
</Grid> </Grid>
</Border> </Border>
<DockPanel Grid.Row="1"> <Grid Grid.Row="1">
<DockPanel IsVisible="{Binding HasStudents}">
<TextBox DockPanel.Dock="Top" Text="{Binding SearchText}" <TextBox DockPanel.Dock="Top" Text="{Binding SearchText}"
PlaceholderText="Name oder Lerngruppe suchen…" Margin="16,10,16,4"/> PlaceholderText="Name oder Lerngruppe suchen…" Margin="16,10,16,4"/>
<DataGrid ItemsSource="{Binding Students}" <DataGrid ItemsSource="{Binding Students}"
@@ -45,5 +48,22 @@
</DataGrid.Columns> </DataGrid.Columns>
</DataGrid> </DataGrid>
</DockPanel> </DockPanel>
<StackPanel HorizontalAlignment="Center" VerticalAlignment="Center" Spacing="10"
IsVisible="{Binding HasNoStudents}">
<TextBlock Text="{Binding EmptyListMessage}" Classes="emptyhint" FontSize="15"
TextAlignment="Center"/>
<StackPanel Orientation="Horizontal" HorizontalAlignment="Center" Spacing="8"
IsVisible="{Binding SearchText, Converter={x:Static StringConverters.IsNullOrEmpty}}">
<Button Content=" Ersten Schüler anlegen" Command="{Binding AddStudentCommand}"/>
<Button Content="⇩ Schülerliste importieren" Click="OnImportClick"/>
</StackPanel>
</StackPanel>
<Border Background="#70000000" IsVisible="{Binding IsImporting}">
<StackPanel HorizontalAlignment="Center" VerticalAlignment="Center" Spacing="10">
<ProgressBar IsIndeterminate="True" Width="220" Height="5"/>
<TextBlock Text="Schülerliste wird analysiert …" Foreground="White"/>
</StackPanel>
</Border>
</Grid>
</Grid> </Grid>
</UserControl> </UserControl>
@@ -36,6 +36,7 @@ public partial class StudentListView : UserControl
}); });
if (files.Count == 0) return; if (files.Count == 0) return;
list.IsImporting = true;
try try
{ {
await using var source = await files[0].OpenReadAsync(); await using var source = await files[0].OpenReadAsync();
@@ -67,5 +68,9 @@ public partial class StudentListView : UserControl
{ {
App.Services.GetRequiredService<NotificationService>().ShowError(ex.Message); App.Services.GetRequiredService<NotificationService>().ShowError(ex.Message);
} }
finally
{
list.IsImporting = false;
}
} }
} }
+10 -3
View File
@@ -14,10 +14,17 @@
Foreground="#D32F2F"/> Foreground="#D32F2F"/>
<TextBlock Text="Sync angehalten" FontSize="10" Foreground="#D32F2F" Opacity="0.8"/> <TextBlock Text="Sync angehalten" FontSize="10" Foreground="#D32F2F" Opacity="0.8"/>
</StackPanel> </StackPanel>
<Button Grid.Column="1" Content="↻" FontSize="14" <Button Grid.Column="1" Classes="touchTarget"
Command="{Binding SyncNowCommand}" Command="{Binding SyncNowCommand}"
IsVisible="{Binding CanAttemptSync}" IsVisible="{Binding CanAttemptSync}"
Background="Transparent" Padding="6,4" Background="Transparent" Padding="8"
ToolTip.Tip="Jetzt synchronisieren"/> ToolTip.Tip="Jetzt synchronisieren" AutomationProperties.Name="Jetzt synchronisieren">
<Grid>
<PathIcon Data="{StaticResource IconRefresh}" Width="18" Height="18"
IsVisible="{Binding !IsSyncing}"/>
<ProgressBar Width="22" Height="4" IsIndeterminate="True"
IsVisible="{Binding IsSyncing}"/>
</Grid>
</Button>
</Grid> </Grid>
</UserControl> </UserControl>
+56
View File
@@ -92,6 +92,62 @@ public sealed class BackupServiceTests
service.RestoreBackup(Path.Combine(temp.Path, "backups", "fehlt.db"), Path.Combine(temp.Path, "lehrerapp.db"))); service.RestoreBackup(Path.Combine(temp.Path, "backups", "fehlt.db"), Path.Combine(temp.Path, "lehrerapp.db")));
} }
[Fact]
public void CreateBackup_MitSecondaryDirectory_SpiegeltDasBackupDorthin()
{
using var temp = new TempAppData();
var dbPath = Path.Combine(temp.Path, "lehrerapp.db");
File.WriteAllText(dbPath, "Testinhalt");
var secondaryDir = Path.Combine(temp.Path, "secondary");
var service = new BackupService(temp.Path) { SecondaryBackupDirectory = secondaryDir };
var backupPath = service.CreateBackup(dbPath);
Assert.NotNull(backupPath);
var mirrored = Path.Combine(secondaryDir, Path.GetFileName(backupPath!));
Assert.True(File.Exists(mirrored));
Assert.Equal("Testinhalt", File.ReadAllText(mirrored));
}
[Fact]
public void CreateBackup_SecondaryDirectoryNichtErreichbar_HauptbackupBleibtErhalten()
{
using var temp = new TempAppData();
var dbPath = Path.Combine(temp.Path, "lehrerapp.db");
File.WriteAllText(dbPath, "Testinhalt");
// Eine gewöhnliche Datei blockiert den Pfad, sodass darunter kein Verzeichnis angelegt
// werden kann — simuliert plattformunabhängig ein nicht eingestecktes USB-Ziel bzw. eine
// nicht erreichbare Netzwerkfreigabe.
var blockingFile = Path.Combine(temp.Path, "blockiert");
File.WriteAllText(blockingFile, "x");
var unreachable = Path.Combine(blockingFile, "secondary");
var service = new BackupService(temp.Path) { SecondaryBackupDirectory = unreachable };
var backupPath = service.CreateBackup(dbPath);
Assert.NotNull(backupPath);
Assert.True(File.Exists(backupPath));
}
[Fact]
public void CreateBackup_MitSecondaryDirectory_BehaeltNurDieLetztenNAuchDort()
{
using var temp = new TempAppData();
var dbPath = Path.Combine(temp.Path, "lehrerapp.db");
var secondaryDir = Path.Combine(temp.Path, "secondary");
var service = new BackupService(temp.Path) { SecondaryBackupDirectory = secondaryDir };
for (var i = 0; i < 5; i++)
{
File.WriteAllText(dbPath, $"v{i}");
var backup = service.CreateBackup(dbPath, keepCount: 3)!;
var mirrored = Path.Combine(secondaryDir, Path.GetFileName(backup));
File.SetLastWriteTime(mirrored, DateTime.Now.AddMinutes(-5 + i));
}
Assert.Equal(3, Directory.GetFiles(secondaryDir, "lehrerapp-*.db").Length);
}
private sealed class TempAppData : IDisposable private sealed class TempAppData : IDisposable
{ {
public string Path { get; } = System.IO.Path.Combine( public string Path { get; } = System.IO.Path.Combine(
@@ -0,0 +1,86 @@
using LehrerApp.Core.Models;
using LehrerApp.Core.Services;
using Xunit;
namespace LehrerApp.Tests;
public class ExamWeekLoadServiceTests
{
private static Exam MakeExam(DateOnly date, ExamStatus status = ExamStatus.Planned) => new()
{
GroupId = Guid.NewGuid(), Title = "Klausur", Status = status, Date = date,
};
[Fact]
public void CountInSameWeek_ZaehltNurKlausurenInDerselbenIsoWoche()
{
// KW 12/2026: Montag 16.03. bis Sonntag 22.03.
var monday = new DateOnly(2026, 3, 16);
var exams = new List<Exam>
{
MakeExam(monday),
MakeExam(monday.AddDays(4)), // Freitag derselben Woche
MakeExam(monday.AddDays(-1)), // Sonntag der Vorwoche
MakeExam(monday.AddDays(7)), // Montag der Folgewoche
};
var count = ExamWeekLoadService.CountInSameWeek(exams, monday.AddDays(2));
Assert.Equal(2, count);
}
[Fact]
public void CountInSameWeek_SchliesstAngegebeneKlausurAus()
{
var monday = new DateOnly(2026, 3, 16);
var editing = MakeExam(monday);
var exams = new List<Exam> { editing, MakeExam(monday.AddDays(1)) };
var count = ExamWeekLoadService.CountInSameWeek(exams, monday, editing.Id);
Assert.Equal(1, count);
}
[Fact]
public void FindOverloadedWeeks_MeldetNurWochenAbDemSchwellenwert()
{
var monday = new DateOnly(2026, 3, 16);
var exams = new List<Exam>
{
MakeExam(monday), MakeExam(monday.AddDays(1)), // KW 12: 2 -> unauffällig
MakeExam(monday.AddDays(7)), MakeExam(monday.AddDays(8)), MakeExam(monday.AddDays(9)), // KW 13: 3 -> Meldung
};
var overloaded = ExamWeekLoadService.FindOverloadedWeeks(exams);
var week = Assert.Single(overloaded);
Assert.Equal(3, week.ExamCount);
Assert.Equal(monday.AddDays(7), week.WeekStart);
}
[Fact]
public void FindOverloadedWeeks_OhneHaeufung_GibtLeereListeZurueck()
{
var monday = new DateOnly(2026, 3, 16);
var exams = new List<Exam> { MakeExam(monday), MakeExam(monday.AddDays(7)) };
Assert.Empty(ExamWeekLoadService.FindOverloadedWeeks(exams));
}
[Fact]
public void FindOverloadedWeeks_SortiertChronologisch()
{
var laterWeek = new DateOnly(2026, 4, 20);
var earlierWeek = new DateOnly(2026, 3, 16);
var exams = new List<Exam>
{
MakeExam(laterWeek), MakeExam(laterWeek.AddDays(1)), MakeExam(laterWeek.AddDays(2)),
MakeExam(earlierWeek), MakeExam(earlierWeek.AddDays(1)), MakeExam(earlierWeek.AddDays(2)),
};
var overloaded = ExamWeekLoadService.FindOverloadedWeeks(exams);
Assert.Equal(2, overloaded.Count);
Assert.True(overloaded[0].WeekStart < overloaded[1].WeekStart);
}
}

Some files were not shown because too many files have changed in this diff Show More