Compare commits
24
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7d3d021d09 | ||
|
|
7fbf035cbb | ||
|
|
9b04d7eb98 | ||
|
|
229ef79e75 | ||
|
|
cd08bbbbc4 | ||
|
|
442e3d5e3d | ||
|
|
3f484ce3c8 | ||
|
|
3f8dab4abd | ||
|
|
b3b86f0cf4 | ||
|
|
9d375caf14 | ||
|
|
cf8de08501 | ||
|
|
265ffe9a74 | ||
|
|
0c8bb58e1b | ||
|
|
69cc16bbd2 | ||
|
|
2b29dea824 | ||
|
|
056e864edd | ||
|
|
33c15ffe3e | ||
|
|
5e16ebf1bf | ||
|
|
7245196689 | ||
|
|
f9f5f75aa8 | ||
|
|
7fa8a93c82 | ||
|
|
87a7badb44 | ||
|
|
17475a781f | ||
|
|
1db16f8b69 |
@@ -1,4 +1,5 @@
|
||||
using LiteDB;
|
||||
using LehrerApp.Sync;
|
||||
using LehrerApp.Sync.Models;
|
||||
|
||||
namespace LehrerApp.Api;
|
||||
@@ -67,7 +68,7 @@ public class EventStore(string dataPath) : IDisposable
|
||||
{
|
||||
var col = GetCol(userId);
|
||||
var events = col.Find(e => e.ServerSeq > since && e.DeviceId != requestingDeviceId)
|
||||
.OrderBy(e => e.ServerSeq).Take(500)
|
||||
.OrderBy(e => e.ServerSeq).Take(SyncProtocol.PullBatchSize)
|
||||
.Select(e => new SyncEvent { EventId = e.EventId, DeviceId = e.DeviceId,
|
||||
DeviceType = e.DeviceType, Timestamp = e.Timestamp,
|
||||
SequenceNr = e.ServerSeq, EntityType = e.EntityType,
|
||||
|
||||
@@ -136,6 +136,40 @@ public interface IUntisSnapshotRepository
|
||||
void Save(UntisSnapshotEntry entry);
|
||||
void Delete(Guid id);
|
||||
}
|
||||
/// Lokaler Cache der Fehlzeiten-Berichtszeilen fürs Klassenlehrer-Feature (siehe TODO.md) —
|
||||
/// bewusst nicht synchronisiert (siehe Implementierung), jedes Gerät ruft WebUntis selbst ab.
|
||||
public interface IUntisAbsenceCacheRepository
|
||||
{
|
||||
List<UntisAbsenceCacheEntry> GetByClassAndRange(string className, int startDate, int endDate);
|
||||
/// Löscht vorhandene Zeilen im Bereich und fügt die übergebenen neu ein — fürs "heiße" Fenster,
|
||||
/// dessen Inhalt sich seit dem letzten Abruf geändert haben kann.
|
||||
void ReplaceRange(string className, int startDate, int endDate, IEnumerable<UntisAbsenceCacheEntry> entries);
|
||||
/// Fügt nur ein, ohne zu löschen — fürs Erweitern der als endgültig angenommenen "kalten" Historie.
|
||||
void InsertRange(IEnumerable<UntisAbsenceCacheEntry> entries);
|
||||
}
|
||||
/// Lokaler Cache der Klassenbuch-Berichtszeilen anderer Lehrkräfte fürs Klassenlehrer-Feature —
|
||||
/// gleiches Muster wie <see cref="IUntisAbsenceCacheRepository"/>.
|
||||
public interface IUntisClassRegisterCacheRepository
|
||||
{
|
||||
List<UntisClassRegisterCacheEntry> GetByClassAndRange(string className, int startDate, int endDate);
|
||||
void ReplaceRange(string className, int startDate, int endDate, IEnumerable<UntisClassRegisterCacheEntry> entries);
|
||||
void InsertRange(IEnumerable<UntisClassRegisterCacheEntry> entries);
|
||||
}
|
||||
/// Lokaler Cache der aktuellen Klassenliste (Roster) fürs Klassenlehrer-Feature — anders als
|
||||
/// Fehlzeiten/Klassenbuch keine Historie, deshalb kein ReplaceRange/InsertRange, sondern immer der
|
||||
/// komplette Ersatz des zuletzt bekannten Standes einer Klasse.
|
||||
public interface IUntisStudentRosterCacheRepository
|
||||
{
|
||||
List<UntisStudentRosterCacheEntry> GetByClass(string className);
|
||||
void ReplaceAll(string className, IEnumerable<UntisStudentRosterCacheEntry> entries);
|
||||
}
|
||||
/// Merkt sich je Klasse+Berichtsart, wann das heiße Fenster zuletzt aufgefrischt wurde und wie weit
|
||||
/// die kalte Historie bereits abgedeckt ist (siehe UntisReportCacheService).
|
||||
public interface IUntisCacheFetchStateRepository
|
||||
{
|
||||
UntisCacheFetchState? Get(string className, UntisCacheKind kind);
|
||||
void Save(UntisCacheFetchState state);
|
||||
}
|
||||
/// Vom Nutzer bestätigte Zuordnungen WebUntis-Wochenmuster → LearningGroup.
|
||||
public interface IUntisSlotMappingRepository
|
||||
{
|
||||
|
||||
@@ -13,6 +13,12 @@ public class Exam
|
||||
public DateOnly? ReturnedAt { get; set; }
|
||||
public Niveau? Niveau { get; set; }
|
||||
public string? Notes { get; set; }
|
||||
public DateOnly? ApprovalGrantedAt { get; set; }
|
||||
public DateOnly? AnnouncedAt { get; set; }
|
||||
/// Verknüpft Parallelklausuren (gleiche Arbeit, mehrere Kurse) für den Kurs-Umschalter
|
||||
/// auf der Klausuren-Hauptseite — Id der "Ursprungs"-Klausur, gesetzt beim Duplizieren
|
||||
/// (siehe ExamDialogViewModel.Save).
|
||||
public Guid? SharedExamGroupId { get; set; }
|
||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
@@ -20,6 +20,12 @@ public class LearningGroup
|
||||
/// deaktivierbar - blendet die Erinnerung "Ungeplante Stunden" im Dashboard für diese Gruppe aus.
|
||||
/// </summary>
|
||||
public bool RequiresLessonPlanning { get; set; } = true;
|
||||
/// <summary>
|
||||
/// WebUntis-interne Unterrichtsnummer (lsid) dieser Lerngruppe, Grundlage für den Abruf des
|
||||
/// "Fehlzeiten pro Unterricht"-Berichts. Wird von WebUntis pro Schuljahr neu vergeben und muss
|
||||
/// deshalb händisch je Lerngruppe/Schuljahr gepflegt werden - keine JSON-RPC-Methode liefert sie.
|
||||
/// </summary>
|
||||
public int? WebUntisLessonId { get; set; }
|
||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
namespace LehrerApp.Core.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Lokal zwischengespeicherte Zeile aus dem WebUntis-Bericht "Fehlzeiten pro Schüler*in"
|
||||
/// (Klassenlehrer-Feature, siehe TODO.md) — 1:1-Abbildung von `UntisClassAbsenceEntryDto`
|
||||
/// (LehrerApp.Desktop), damit Fehlzeiten nicht bei jedem Öffnen der Ansicht neu abgerufen werden
|
||||
/// müssen. <c>Date</c> bewusst als <c>int</c> (yyyyMMdd) wie die DTOs statt <c>DateOnly</c>/
|
||||
/// <c>DateTime</c> — vermeidet die LiteDB-<c>DateTime</c>-Kind-Tücke (siehe CLAUDE.md) und ist
|
||||
/// direkt für Bereichs-Queries nutzbar.
|
||||
/// </summary>
|
||||
public class UntisAbsenceCacheEntry
|
||||
{
|
||||
public Guid Id { get; set; } = Guid.NewGuid();
|
||||
public string ClassName { get; set; } = "";
|
||||
public string StudentName { get; set; } = "";
|
||||
public int? ExternKey { get; set; }
|
||||
public int Date { get; set; }
|
||||
public int AbsentPeriods { get; set; }
|
||||
public int AbsentMinutes { get; set; }
|
||||
public string? TeacherUsernames { get; set; }
|
||||
public string? Subject { get; set; }
|
||||
public string? AbsenceReason { get; set; }
|
||||
public string? Note { get; set; }
|
||||
public int? EntryId { get; set; }
|
||||
public string? HandledOn { get; set; }
|
||||
public bool Counts { get; set; }
|
||||
public string? ExcuseNote { get; set; }
|
||||
public int? PeriodNumber { get; set; }
|
||||
public string? Status { get; set; }
|
||||
public bool CountsAsFullDay { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>Lokal zwischengespeicherte Zeile aus dem "-alle-"-Klassenbuchbericht, gefiltert auf
|
||||
/// Einträge anderer Lehrkräfte (Klassenlehrer-Feature) — 1:1-Abbildung von
|
||||
/// `UntisForeignClassRegisterEventDto` (LehrerApp.Desktop).</summary>
|
||||
public class UntisClassRegisterCacheEntry
|
||||
{
|
||||
public Guid Id { get; set; } = Guid.NewGuid();
|
||||
public string ClassName { get; set; } = "";
|
||||
public int Date { get; set; }
|
||||
public string? Subject { get; set; }
|
||||
public string StudentName { get; set; } = "";
|
||||
public string? TeacherUsername { get; set; }
|
||||
public string? CategoryName { get; set; }
|
||||
public string? CategoryGroup { get; set; }
|
||||
public string? Text { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>Lokal zwischengespeicherte Zeile aus dem WebUntis-Schülerreport, eingeschränkt auf die
|
||||
/// für die Klassenlehrer-Übersicht nötigen Felder (Roster-Namen) — anders als Fehlzeiten/
|
||||
/// Klassenbuch keine Historie, sondern immer nur der zuletzt abgerufene Stand einer Klasse
|
||||
/// (siehe <see cref="UntisClassRegisterCacheEntry"/> für den Grund, warum es trotzdem ein eigenes
|
||||
/// Modell statt Wiederverwendung des vollen Schülerreport-DTOs ist: nur diese zwei Felder werden
|
||||
/// für den Roster-Abgleich tatsächlich gebraucht).</summary>
|
||||
public class UntisStudentRosterCacheEntry
|
||||
{
|
||||
public Guid Id { get; set; } = Guid.NewGuid();
|
||||
public string ClassName { get; set; } = "";
|
||||
public int? ExternKey { get; set; }
|
||||
public string DisplayName { get; set; } = "";
|
||||
}
|
||||
|
||||
public enum UntisCacheKind { Absences, ClassRegister, Roster }
|
||||
|
||||
/// <summary>
|
||||
/// Ein Datensatz pro (<see cref="ClassName"/>, <see cref="Kind"/>): wann das "heiße" Fenster
|
||||
/// (die letzten Tage, deren Status sich noch ändern kann) zuletzt aufgefrischt wurde, und wie weit
|
||||
/// die "kalte", als endgültig angenommene Historie bereits lückenlos zurückreicht. Getrennt von den
|
||||
/// Cache-Zeilen selbst, weil ein Fenster ohne Treffer (z.B. eine ereignislose Woche) sonst keine
|
||||
/// Zeile hinterließe, aus der sich "zuletzt abgerufen am…" ableiten ließe.
|
||||
/// </summary>
|
||||
public class UntisCacheFetchState
|
||||
{
|
||||
public Guid Id { get; set; } = Guid.NewGuid();
|
||||
public string ClassName { get; set; } = "";
|
||||
public UntisCacheKind Kind { get; set; }
|
||||
public DateTime? HotWindowFetchedAt { get; set; }
|
||||
public int? ColdCoverageStartDate { get; set; }
|
||||
}
|
||||
@@ -24,6 +24,10 @@ public class Documentation : IHasAttachments
|
||||
/// <summary>Im Unterricht schnell erfasst und noch inhaltlich nachzuarbeiten.</summary>
|
||||
public bool IsDraft { get; set; }
|
||||
public bool IsConfidential { get; set; }
|
||||
/// <summary>Für die pädagogische Arbeit/Leistungsbewertung gedacht, aber nicht für den
|
||||
/// WebUntis-Klassenbuch-Abgleich vorgesehen - blendet den Eintrag im Abgleichsdialog
|
||||
/// (WebUntisDocumentationComparisonViewModel) aus dem Zwischenablage-Vorschlag aus.</summary>
|
||||
public bool ExcludeFromWebUntisSync { get; set; }
|
||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
|
||||
// Löschen entfernt den Eintrag nicht hart, sondern markiert ihn nur (Nachvollziehbarkeit, 5.1.4).
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
using LehrerApp.Core.Models;
|
||||
|
||||
namespace LehrerApp.Core.Services;
|
||||
|
||||
/// Zählt den Korrekturfortschritt einer Klausur aus den vorhandenen Ergebnissen, statt einen
|
||||
/// eigenen "Korrektur läuft"-Status pflegen zu müssen — ein `ExamResult` gilt als erledigt, sobald
|
||||
/// entweder Punkte/Note eingetragen oder der Schüler als abwesend markiert wurde. Von
|
||||
/// DashboardViewModel (Karte "Offene Korrekturen") und ExamsOverviewViewModel (Klausuren-
|
||||
/// Hauptseite) gemeinsam genutzt, damit beide Stellen exakt dieselbe Zahl zeigen.
|
||||
public static class ExamCorrectionCounter
|
||||
{
|
||||
public static (int Expected, int Evaluated) Count(Exam exam, List<GroupMembership> groupMemberships,
|
||||
List<ExamResult> examResults)
|
||||
{
|
||||
var expected = groupMemberships.Count(m => GroupMembershipService.IsActiveOn(m, exam.Date));
|
||||
var evaluated = examResults.Count(r => r.Absent || !string.IsNullOrWhiteSpace(r.Grade) || r.Points.Count > 0);
|
||||
return (expected, Math.Min(evaluated, expected));
|
||||
}
|
||||
}
|
||||
|
||||
public enum ExamListStatus { Planned, AwaitingCorrection, CorrectionInProgress, CorrectionStuck, AwaitingReturn, Returned }
|
||||
|
||||
public readonly record struct ExamPriorityInfo(ExamListStatus Status, double Score);
|
||||
|
||||
/// Ordnet Klausuren für die Klausuren-Hauptseite nach einem unsichtbaren Prioritäts-Score statt
|
||||
/// nach Datum: unbearbeitete/überfällige Korrekturen oben, erledigte unten. "Korrektur läuft"
|
||||
/// wird bewusst nicht als eigener, manuell zu pflegender Status abgelegt (siehe
|
||||
/// `ExamCorrectionCounter`) — bleibt eine Klausur trotzdem lange in Bearbeitung hängen (typischer
|
||||
/// Grund: ein einzelner nie nachschreibender Schüler), fällt sie nach `StuckAfterDays` aus der
|
||||
/// dringenden Zone in eine ruhige "hängt fest"-Einstufung, statt dauerhaft oben zu kleben.
|
||||
public static class ExamPriorityService
|
||||
{
|
||||
public const int StuckAfterDays = 21;
|
||||
private const int CorrectionGraceDays = 3;
|
||||
|
||||
public static ExamPriorityInfo Evaluate(Exam exam, int expected, int evaluated, DateOnly today)
|
||||
{
|
||||
if (exam.Status == ExamStatus.Returned)
|
||||
return new ExamPriorityInfo(ExamListStatus.Returned, -100);
|
||||
|
||||
if (exam.Status == ExamStatus.Graded)
|
||||
return new ExamPriorityInfo(ExamListStatus.AwaitingReturn, 300);
|
||||
|
||||
if (exam.Status == ExamStatus.Planned)
|
||||
{
|
||||
var daysUntil = exam.Date.DayNumber - today.DayNumber;
|
||||
return new ExamPriorityInfo(ExamListStatus.Planned, Math.Clamp(150 - daysUntil, 20, 150));
|
||||
}
|
||||
|
||||
// Status == Conducted: Korrekturfortschritt entscheidet über die genaue Einstufung.
|
||||
var daysSince = today.DayNumber - exam.Date.DayNumber;
|
||||
|
||||
if (evaluated <= 0)
|
||||
{
|
||||
var score = daysSince <= CorrectionGraceDays ? 400 + daysSince * 10 : 600 + daysSince;
|
||||
return new ExamPriorityInfo(ExamListStatus.AwaitingCorrection, score);
|
||||
}
|
||||
|
||||
if (expected > 0 && evaluated >= expected)
|
||||
return new ExamPriorityInfo(ExamListStatus.AwaitingReturn, 300);
|
||||
|
||||
if (daysSince > StuckAfterDays)
|
||||
return new ExamPriorityInfo(ExamListStatus.CorrectionStuck, 90);
|
||||
|
||||
return new ExamPriorityInfo(ExamListStatus.CorrectionInProgress, 400 + daysSince * 3);
|
||||
}
|
||||
}
|
||||
@@ -60,6 +60,8 @@ public sealed class GroupRolloverService(
|
||||
IsActive = true,
|
||||
IsOwnClass = source.IsOwnClass,
|
||||
IsDifferentiated = source.IsDifferentiated,
|
||||
// WebUntisLessonId bewusst nicht übernommen: WebUntis vergibt sie pro Schuljahr neu,
|
||||
// eine übernommene alte lsid würde in der Folgegruppe stumm falsche Fehlzeiten liefern.
|
||||
};
|
||||
|
||||
var sourceWasActive = source.IsActive;
|
||||
|
||||
@@ -1053,6 +1053,101 @@ public sealed class RepositoryTests
|
||||
Assert.Equal("fahrt", repo.GetByExternalId("fahrt")!.ExternalId);
|
||||
}
|
||||
|
||||
// ── UntisAbsenceCacheRepository / UntisClassRegisterCacheRepository / UntisCacheFetchStateRepository ──
|
||||
|
||||
[Fact]
|
||||
public void UntisAbsenceCacheRepository_GetByClassAndRange_FiltertKlasseUndDatumsbereich()
|
||||
{
|
||||
using var db = NewInMemoryContext();
|
||||
var repo = new UntisAbsenceCacheRepository(db);
|
||||
repo.InsertRange(
|
||||
[
|
||||
new UntisAbsenceCacheEntry { ClassName = "10c", StudentName = "Max", Date = 20260824 },
|
||||
new UntisAbsenceCacheEntry { ClassName = "10c", StudentName = "Max", Date = 20260901 },
|
||||
new UntisAbsenceCacheEntry { ClassName = "10d", StudentName = "Ben", Date = 20260824 },
|
||||
]);
|
||||
|
||||
var result = repo.GetByClassAndRange("10c", 20260801, 20260831);
|
||||
|
||||
Assert.Single(result);
|
||||
Assert.Equal(20260824, result[0].Date);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UntisAbsenceCacheRepository_ReplaceRange_ErsetztNurDenAngegebenenBereich()
|
||||
{
|
||||
using var db = NewInMemoryContext();
|
||||
var repo = new UntisAbsenceCacheRepository(db);
|
||||
repo.InsertRange(
|
||||
[
|
||||
new UntisAbsenceCacheEntry { ClassName = "10c", StudentName = "Alt", Date = 20260810 },
|
||||
new UntisAbsenceCacheEntry { ClassName = "10c", StudentName = "Alt", Date = 20260901 },
|
||||
]);
|
||||
|
||||
repo.ReplaceRange("10c", 20260801, 20260831,
|
||||
[new UntisAbsenceCacheEntry { ClassName = "10c", StudentName = "Neu", Date = 20260815 }]);
|
||||
|
||||
var inRange = repo.GetByClassAndRange("10c", 20260801, 20260831);
|
||||
var untouched = repo.GetByClassAndRange("10c", 20260901, 20260901);
|
||||
Assert.Equal("Neu", Assert.Single(inRange).StudentName);
|
||||
Assert.Single(untouched);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UntisClassRegisterCacheRepository_InsertUndGetByClassAndRange_RoundTrip()
|
||||
{
|
||||
using var db = NewInMemoryContext();
|
||||
var repo = new UntisClassRegisterCacheRepository(db);
|
||||
repo.InsertRange([new UntisClassRegisterCacheEntry
|
||||
{
|
||||
ClassName = "10c", Date = 20260824, StudentName = "Max", Subject = "Deu",
|
||||
TeacherUsername = "mueller", CategoryName = "Positiv",
|
||||
}]);
|
||||
|
||||
var result = Assert.Single(repo.GetByClassAndRange("10c", 20260801, 20260831));
|
||||
Assert.Equal("mueller", result.TeacherUsername);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UntisStudentRosterCacheRepository_ReplaceAll_ErsetztNurDieAngegebeneKlasse()
|
||||
{
|
||||
using var db = NewInMemoryContext();
|
||||
var repo = new UntisStudentRosterCacheRepository(db);
|
||||
repo.ReplaceAll("10c", [new UntisStudentRosterCacheEntry { ClassName = "10c", DisplayName = "Alt" }]);
|
||||
repo.ReplaceAll("10d", [new UntisStudentRosterCacheEntry { ClassName = "10d", DisplayName = "Andere Klasse" }]);
|
||||
|
||||
repo.ReplaceAll("10c", [new UntisStudentRosterCacheEntry { ClassName = "10c", DisplayName = "Neu", ExternKey = 42 }]);
|
||||
|
||||
var tenC = repo.GetByClass("10c");
|
||||
var tenD = repo.GetByClass("10d");
|
||||
Assert.Equal("Neu", Assert.Single(tenC).DisplayName);
|
||||
Assert.Equal(42, tenC[0].ExternKey);
|
||||
Assert.Single(tenD); // andere Klasse bleibt unberührt
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UntisCacheFetchStateRepository_SaveUndGet_RoundTripJeKlasseUndArt()
|
||||
{
|
||||
using var db = NewInMemoryContext();
|
||||
var repo = new UntisCacheFetchStateRepository(db);
|
||||
var fetchedAt = new DateTime(2026, 8, 25, 10, 0, 0, DateTimeKind.Utc);
|
||||
repo.Save(new UntisCacheFetchState
|
||||
{
|
||||
ClassName = "10c", Kind = UntisCacheKind.Absences,
|
||||
HotWindowFetchedAt = fetchedAt, ColdCoverageStartDate = 20260101,
|
||||
});
|
||||
|
||||
var result = repo.Get("10c", UntisCacheKind.Absences);
|
||||
var missing = repo.Get("10c", UntisCacheKind.ClassRegister);
|
||||
|
||||
Assert.NotNull(result);
|
||||
// LiteDB rundet Kind Utc->Local, verschiebt dabei die Ticks um denselben Zeitpunkt zu
|
||||
// erhalten (siehe CLAUDE.md) - deshalb erst beide Seiten auf UTC normalisieren.
|
||||
Assert.Equal(fetchedAt.ToUniversalTime(), result!.HotWindowFetchedAt!.Value.ToUniversalTime());
|
||||
Assert.Equal(20260101, result.ColdCoverageStartDate);
|
||||
Assert.Null(missing);
|
||||
}
|
||||
|
||||
// ── WorkTaskRepository ────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -68,6 +68,10 @@ public class LiteDbContext : IDisposable
|
||||
public ILiteCollection<SubstitutionEntry> SubstitutionEntries => _db.GetCollection<SubstitutionEntry>("substitution_entries");
|
||||
public ILiteCollection<UntisSnapshotEntry> UntisSnapshotEntries => _db.GetCollection<UntisSnapshotEntry>("untis_snapshot_entries");
|
||||
public ILiteCollection<UntisSlotMapping> UntisSlotMappings => _db.GetCollection<UntisSlotMapping>("untis_slot_mappings");
|
||||
public ILiteCollection<UntisAbsenceCacheEntry> UntisAbsenceCache => _db.GetCollection<UntisAbsenceCacheEntry>("untis_absence_cache");
|
||||
public ILiteCollection<UntisClassRegisterCacheEntry> UntisClassRegisterCache => _db.GetCollection<UntisClassRegisterCacheEntry>("untis_classregister_cache");
|
||||
public ILiteCollection<UntisCacheFetchState> UntisCacheFetchStates => _db.GetCollection<UntisCacheFetchState>("untis_cache_fetch_state");
|
||||
public ILiteCollection<UntisStudentRosterCacheEntry> UntisStudentRosterCache => _db.GetCollection<UntisStudentRosterCacheEntry>("untis_student_roster_cache");
|
||||
public ILiteCollection<AnnualPlanEvent> AnnualPlanEvents => _db.GetCollection<AnnualPlanEvent>("annual_plan_events");
|
||||
public ILiteCollection<TrashedItem> TrashedItems => _db.GetCollection<TrashedItem>("trash");
|
||||
|
||||
@@ -535,6 +539,13 @@ public class LiteDbContext : IDisposable
|
||||
AnnualPlanEvents.EnsureIndex(x => x.ExternalId, unique: true);
|
||||
AnnualPlanEvents.EnsureIndex(x => x.StartDate);
|
||||
AnnualPlanEvents.EnsureIndex(x => x.EndDate);
|
||||
UntisAbsenceCache.EnsureIndex(x => x.ClassName);
|
||||
UntisAbsenceCache.EnsureIndex(x => x.Date);
|
||||
UntisClassRegisterCache.EnsureIndex(x => x.ClassName);
|
||||
UntisClassRegisterCache.EnsureIndex(x => x.Date);
|
||||
UntisStudentRosterCache.EnsureIndex(x => x.ClassName);
|
||||
UntisCacheFetchStates.EnsureIndex("ux_class_kind",
|
||||
BsonExpression.Create("STRING($.ClassName) + ':' + STRING($.Kind)"), unique: true);
|
||||
}
|
||||
|
||||
public void Dispose() => _db.Dispose();
|
||||
|
||||
@@ -794,6 +794,65 @@ public class AnnualPlanEventRepository(LiteDbContext db) : IAnnualPlanEventRepos
|
||||
public void Delete(Guid id) => db.AnnualPlanEvents.Delete(id);
|
||||
}
|
||||
|
||||
// Wie UntisSnapshotEntry/AnnualPlanEvent rein lokal, bewusst kein db.OnChange: Fehlzeiten- und
|
||||
// Klassenbuch-Cache fürs Klassenlehrer-Feature (siehe TODO.md) wachsen über ein Schuljahr auf viele
|
||||
// hundert Zeilen an - über Sync würde das nur Rauschen erzeugen, und jedes Gerät ruft WebUntis
|
||||
// ohnehin selbst ab (siehe UntisReportCacheService).
|
||||
public class UntisAbsenceCacheRepository(LiteDbContext db) : IUntisAbsenceCacheRepository
|
||||
{
|
||||
public List<UntisAbsenceCacheEntry> GetByClassAndRange(string className, int startDate, int endDate) =>
|
||||
db.UntisAbsenceCache
|
||||
.Find(e => e.ClassName == className && e.Date >= startDate && e.Date <= endDate)
|
||||
.ToList();
|
||||
|
||||
public void ReplaceRange(string className, int startDate, int endDate, IEnumerable<UntisAbsenceCacheEntry> entries)
|
||||
{
|
||||
db.UntisAbsenceCache.DeleteMany(e => e.ClassName == className && e.Date >= startDate && e.Date <= endDate);
|
||||
db.UntisAbsenceCache.InsertBulk(entries);
|
||||
}
|
||||
|
||||
public void InsertRange(IEnumerable<UntisAbsenceCacheEntry> entries) => db.UntisAbsenceCache.InsertBulk(entries);
|
||||
}
|
||||
|
||||
public class UntisClassRegisterCacheRepository(LiteDbContext db) : IUntisClassRegisterCacheRepository
|
||||
{
|
||||
public List<UntisClassRegisterCacheEntry> GetByClassAndRange(string className, int startDate, int endDate) =>
|
||||
db.UntisClassRegisterCache
|
||||
.Find(e => e.ClassName == className && e.Date >= startDate && e.Date <= endDate)
|
||||
.ToList();
|
||||
|
||||
public void ReplaceRange(string className, int startDate, int endDate, IEnumerable<UntisClassRegisterCacheEntry> entries)
|
||||
{
|
||||
db.UntisClassRegisterCache.DeleteMany(e => e.ClassName == className && e.Date >= startDate && e.Date <= endDate);
|
||||
db.UntisClassRegisterCache.InsertBulk(entries);
|
||||
}
|
||||
|
||||
public void InsertRange(IEnumerable<UntisClassRegisterCacheEntry> entries) => db.UntisClassRegisterCache.InsertBulk(entries);
|
||||
}
|
||||
|
||||
// Gleiches "bewusst kein db.OnChange"-Prinzip wie UntisAbsenceCacheRepository oben, auch wenn der
|
||||
// Roster selbst klein bleibt (aktuelle Klassenliste, keine Historie) - jedes Gerät ruft ihn ohnehin
|
||||
// selbst ab.
|
||||
public class UntisStudentRosterCacheRepository(LiteDbContext db) : IUntisStudentRosterCacheRepository
|
||||
{
|
||||
public List<UntisStudentRosterCacheEntry> GetByClass(string className) =>
|
||||
db.UntisStudentRosterCache.Find(e => e.ClassName == className).ToList();
|
||||
|
||||
public void ReplaceAll(string className, IEnumerable<UntisStudentRosterCacheEntry> entries)
|
||||
{
|
||||
db.UntisStudentRosterCache.DeleteMany(e => e.ClassName == className);
|
||||
db.UntisStudentRosterCache.InsertBulk(entries);
|
||||
}
|
||||
}
|
||||
|
||||
public class UntisCacheFetchStateRepository(LiteDbContext db) : IUntisCacheFetchStateRepository
|
||||
{
|
||||
public UntisCacheFetchState? Get(string className, UntisCacheKind kind) =>
|
||||
db.UntisCacheFetchStates.FindOne(s => s.ClassName == className && s.Kind == kind);
|
||||
|
||||
public void Save(UntisCacheFetchState state) => db.UntisCacheFetchStates.Upsert(state);
|
||||
}
|
||||
|
||||
public class CompetencyDomainRepository(LiteDbContext db) : ICompetencyDomainRepository
|
||||
{
|
||||
public List<CompetencyDomain> GetBySubjectAndGrade(Guid subjectId, int gradeLevel) =>
|
||||
|
||||
@@ -0,0 +1,412 @@
|
||||
using LehrerApp.Core.Models;
|
||||
using LehrerApp.Desktop.Services;
|
||||
using LehrerApp.Desktop.ViewModels.ClassTeacher;
|
||||
using Xunit;
|
||||
|
||||
namespace LehrerApp.Desktop.Tests;
|
||||
|
||||
public sealed class ClassTeacherViewModelsTests
|
||||
{
|
||||
[Fact]
|
||||
public void GroupByStudentAndDay_FasstFehlstundenProSchuelerUndTagZusammen()
|
||||
{
|
||||
var entries = new[]
|
||||
{
|
||||
new UntisClassAbsenceEntryDto("Muster Max", 1, "6a", 20260824, 1, 45, "HED", "Che", "Absent",
|
||||
"Krank", 1, "24.08.26", true, null, 3, "nicht entsch.", false),
|
||||
new UntisClassAbsenceEntryDto("Muster Max", 1, "6a", 20260824, 1, 45, "PAN", "Kunst", null,
|
||||
"Krank", 2, "24.08.26", true, null, 5, "nicht entsch.", true),
|
||||
new UntisClassAbsenceEntryDto("Andere Schuelerin", 2, "6a", 20260824, 1, 20, "BEN", "Deu",
|
||||
"Verspätung", null, 3, "24.08.26", false, "Zug verpasst", 1, "entsch.", false),
|
||||
};
|
||||
|
||||
var rows = ClassAbsenceDaySummaryRow.GroupByStudentAndDay(entries);
|
||||
|
||||
Assert.Equal(2, rows.Count);
|
||||
|
||||
var max = rows[1];
|
||||
Assert.Equal("Muster Max", max.StudentName);
|
||||
Assert.Equal(2, max.TotalAbsentPeriods);
|
||||
Assert.Equal(90, max.TotalAbsentMinutes);
|
||||
Assert.Equal(["Che", "Kunst"], max.Subjects);
|
||||
Assert.Equal([3, 5], max.PeriodNumbers.Order());
|
||||
Assert.Equal(["nicht entsch."], max.Statuses);
|
||||
Assert.Equal(["Absent"], max.AbsenceReasons);
|
||||
Assert.Equal("Krank", max.Note);
|
||||
Assert.Null(max.ExcuseNote);
|
||||
Assert.True(max.CountsAsFullDay);
|
||||
|
||||
var andere = rows[0];
|
||||
Assert.Equal("Andere Schuelerin", andere.StudentName);
|
||||
Assert.Equal(1, andere.TotalAbsentPeriods);
|
||||
Assert.Equal(20, andere.TotalAbsentMinutes);
|
||||
Assert.Equal(["Deu"], andere.Subjects);
|
||||
Assert.Null(andere.Note);
|
||||
Assert.Equal("Zug verpasst", andere.ExcuseNote);
|
||||
Assert.False(andere.CountsAsFullDay);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GroupByStudentAndDay_TrenntGleichenNamenAnUnterschiedlichenTagen()
|
||||
{
|
||||
var entries = new[]
|
||||
{
|
||||
new UntisClassAbsenceEntryDto("Muster Max", 1, "6a", 20260824, 1, 45, "HED", "Che", null,
|
||||
null, 1, "24.08.26", true, null, 3, "nicht entsch.", false),
|
||||
new UntisClassAbsenceEntryDto("Muster Max", 1, "6a", 20260825, 1, 45, "HED", "Che", null,
|
||||
null, 2, "25.08.26", true, null, 3, "nicht entsch.", false),
|
||||
};
|
||||
|
||||
var rows = ClassAbsenceDaySummaryRow.GroupByStudentAndDay(entries);
|
||||
|
||||
Assert.Equal(2, rows.Count);
|
||||
Assert.Equal(new DateOnly(2026, 8, 25), rows[0].Date);
|
||||
Assert.Equal(new DateOnly(2026, 8, 24), rows[1].Date);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RosterBuild_OrdnetFehlzeitPerExternKeyUndKlassenbuchPerNameZu()
|
||||
{
|
||||
var students = new[]
|
||||
{
|
||||
Student(1001, "Ada Müller"),
|
||||
Student(1002, "Ben Schmidt"),
|
||||
Student(1003, "Cem Yilmaz"),
|
||||
};
|
||||
var todayAbsences = new[]
|
||||
{
|
||||
new ClassAbsenceDaySummaryRow(new DateOnly(2026, 8, 25), "Ada Müller", 1001, 2, 90,
|
||||
["Che"], [3, 4], ["nicht entsch."], ["Absent"], null, null, false),
|
||||
};
|
||||
var recentClassRegister = new[]
|
||||
{
|
||||
new UntisForeignClassRegisterEventDto("6a", 20260824, "Deu", "Ben Schmidt", "mueller",
|
||||
"Vergessen", "Organisation", "Buch vergessen"),
|
||||
};
|
||||
|
||||
var roster = ClassTeacherRosterRow.Build(students, todayAbsences, recentClassRegister);
|
||||
|
||||
Assert.Equal(3, roster.Count);
|
||||
var ada = Assert.Single(roster, r => r.StudentName == "Ada Müller");
|
||||
Assert.True(ada.HasAbsenceToday);
|
||||
Assert.Contains("nicht entsch.", ada.AbsenceTooltip);
|
||||
Assert.False(ada.HasRecentClassRegisterEntry);
|
||||
|
||||
var ben = Assert.Single(roster, r => r.StudentName == "Ben Schmidt");
|
||||
Assert.False(ben.HasAbsenceToday);
|
||||
Assert.True(ben.HasRecentClassRegisterEntry);
|
||||
|
||||
var cem = Assert.Single(roster, r => r.StudentName == "Cem Yilmaz");
|
||||
Assert.False(cem.HasAbsenceToday);
|
||||
Assert.False(cem.HasRecentClassRegisterEntry);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RosterBuild_ErkenntNamenAuchInVertauschterReihenfolge()
|
||||
{
|
||||
// WebUntis liefert Namen im Schülerreport als "Vorname Nachname", in Fehlzeiten-/
|
||||
// Klassenbuchberichten aber typischerweise als "Nachname Vorname" - der Namensabgleich
|
||||
// muss das auch ohne ExternKey erkennen (Regressionstest für genau diesen Bug).
|
||||
var students = new[] { Student(externKey: null, displayName: "Ben Schmidt") };
|
||||
var todayAbsences = new[]
|
||||
{
|
||||
new ClassAbsenceDaySummaryRow(new DateOnly(2026, 8, 25), "Schmidt Ben", null, 1, 45,
|
||||
["Che"], [3], ["nicht entsch."], ["Absent"], null, null, false),
|
||||
};
|
||||
var recentClassRegister = new[]
|
||||
{
|
||||
new UntisForeignClassRegisterEventDto("6a", 20260824, "Deu", "Schmidt Ben", "mueller",
|
||||
"Vergessen", "Organisation", "Buch vergessen"),
|
||||
};
|
||||
|
||||
var roster = ClassTeacherRosterRow.Build(students, todayAbsences, recentClassRegister);
|
||||
|
||||
var ben = Assert.Single(roster);
|
||||
Assert.True(ben.HasAbsenceToday);
|
||||
Assert.True(ben.HasRecentClassRegisterEntry);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RosterBuild_BeschreibtVerspaetungNichtAlsNullFehlstunden()
|
||||
{
|
||||
var students = new[] { Student(1001, "Ada Müller") };
|
||||
var todayAbsences = new[]
|
||||
{
|
||||
new ClassAbsenceDaySummaryRow(new DateOnly(2026, 8, 26), "Müller Ada", 1001, 0, 20,
|
||||
["Deu"], [1], ["nicht entsch."], ["Verspätung"], null, null, false),
|
||||
};
|
||||
|
||||
var row = Assert.Single(ClassTeacherRosterRow.Build(students, todayAbsences, [],
|
||||
new DateOnly(2026, 8, 26)));
|
||||
|
||||
Assert.True(row.IsLate);
|
||||
Assert.True(row.IsUnexcused);
|
||||
Assert.Equal("20 Min. verspätet · Unentschuldigt", row.StatusText);
|
||||
Assert.Equal(ClassTeacherStatusKind.Danger, row.StatusKind);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RosterBuild_PriorisiertHandlungsbedarfVorUnauffaelligenSchuelern()
|
||||
{
|
||||
var students = new[]
|
||||
{
|
||||
Student(1001, "Zora Unauffällig"),
|
||||
Student(1002, "Ada Fehlzeit"),
|
||||
Student(1003, "Ben Klassenbuch"),
|
||||
};
|
||||
var absences = new[]
|
||||
{
|
||||
new ClassAbsenceDaySummaryRow(new DateOnly(2026, 8, 26), "Fehlzeit Ada", 1002, 2, 90,
|
||||
["Deu"], [1, 2], ["nicht entsch."], ["Absent"], null, null, false),
|
||||
};
|
||||
var register = new[]
|
||||
{
|
||||
new UntisForeignClassRegisterEventDto("10c", 20260825, "Deu", "Klassenbuch Ben", "test",
|
||||
"Fehlende HA", "Negativ", "Hausaufgaben fehlen"),
|
||||
};
|
||||
|
||||
var rows = ClassTeacherRosterRow.Build(students, absences, register, new DateOnly(2026, 8, 26));
|
||||
|
||||
Assert.Equal(["Ada Fehlzeit", "Ben Klassenbuch", "Zora Unauffällig"],
|
||||
rows.Select(r => r.StudentName));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RosterBuild_BerechnetFehlquoteSeitSchuljahresbeginn()
|
||||
{
|
||||
var students = new[] { Student(1001, "Ada Müller") };
|
||||
var yearAbsences = new[]
|
||||
{
|
||||
new ClassAbsenceDaySummaryRow(new DateOnly(2026, 8, 10), "Müller Ada", 1001, 2, 90,
|
||||
["Che"], [1, 2], ["entsch."], ["Krank"], null, null, false),
|
||||
new ClassAbsenceDaySummaryRow(new DateOnly(2026, 8, 17), "Müller Ada", 1001, 6, 270,
|
||||
["Che"], [1, 2, 3, 4, 5, 6], ["nicht entsch."], ["Absent"], null, null, true),
|
||||
};
|
||||
|
||||
var row = Assert.Single(ClassTeacherRosterRow.Build(students, [], [],
|
||||
new DateOnly(2026, 8, 26), yearAbsences, schoolDaysElapsed: 20));
|
||||
|
||||
Assert.True(row.HasYearSummary);
|
||||
Assert.Equal(2, row.YearAbsenceDayCount);
|
||||
Assert.Equal(1, row.YearUnexcusedDayCount);
|
||||
Assert.Equal(10, row.YearAbsenceRatePercent);
|
||||
Assert.Contains("10 %", row.YearSummaryLabel);
|
||||
Assert.Contains("2 von 20", row.YearSummaryTooltip);
|
||||
Assert.Contains("1 unentschuldigt", row.YearSummaryTooltip);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RosterBuild_OhneJahresdatenZeigtKeineFehlquote()
|
||||
{
|
||||
var students = new[] { Student(1001, "Ada Müller") };
|
||||
|
||||
var row = Assert.Single(ClassTeacherRosterRow.Build(students, [], [], new DateOnly(2026, 8, 26)));
|
||||
|
||||
Assert.False(row.HasYearSummary);
|
||||
Assert.Equal("", row.YearSummaryLabel);
|
||||
Assert.Null(row.YearSummaryTooltip);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RosterBuild_OrdnetJahresfehlzeitenAuchOhneExternKeyUeberNamenZu()
|
||||
{
|
||||
var students = new[] { Student(externKey: null, displayName: "Ben Schmidt") };
|
||||
var yearAbsences = new[]
|
||||
{
|
||||
new ClassAbsenceDaySummaryRow(new DateOnly(2026, 8, 10), "Schmidt Ben", null, 2, 90,
|
||||
["Che"], [1, 2], ["entsch."], ["Krank"], null, null, false),
|
||||
};
|
||||
|
||||
var row = Assert.Single(ClassTeacherRosterRow.Build(students, [], [], new DateOnly(2026, 8, 26),
|
||||
yearAbsences, schoolDaysElapsed: 10));
|
||||
|
||||
Assert.Equal(1, row.YearAbsenceDayCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TrendDay_AnteileSummierenSichZurGesamtquote()
|
||||
{
|
||||
var day = new ClassTeacherTrendDay("Mo", 5, 2, 1, 2, 0.4d, 0.2d, 0.4d);
|
||||
|
||||
Assert.Equal(1.0d, day.TotalFraction, precision: 10);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RosterBuild_StelltGefahrUndWarnungMitSymbolVoran()
|
||||
{
|
||||
var students = new[] { Student(1001, "Ada Müller"), Student(1002, "Ben Schmidt") };
|
||||
var todayAbsences = new[]
|
||||
{
|
||||
new ClassAbsenceDaySummaryRow(new DateOnly(2026, 8, 26), "Müller Ada", 1001, 2, 90,
|
||||
["Che"], [1, 2], ["nicht entsch."], ["Absent"], null, null, false),
|
||||
new ClassAbsenceDaySummaryRow(new DateOnly(2026, 8, 26), "Schmidt Ben", 1002, 2, 90,
|
||||
["Che"], [1, 2], ["entsch."], ["Absent"], null, null, false),
|
||||
};
|
||||
|
||||
var roster = ClassTeacherRosterRow.Build(students, todayAbsences, [], new DateOnly(2026, 8, 26));
|
||||
|
||||
var ada = Assert.Single(roster, r => r.StudentName == "Ada Müller");
|
||||
Assert.Equal(ClassTeacherStatusKind.Danger, ada.StatusKind);
|
||||
Assert.StartsWith("✕ ", ada.StatusTextWithGlyph);
|
||||
|
||||
var ben = Assert.Single(roster, r => r.StudentName == "Ben Schmidt");
|
||||
Assert.Equal(ClassTeacherStatusKind.Warning, ben.StatusKind);
|
||||
Assert.StartsWith("△ ", ben.StatusTextWithGlyph);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RosterBuild_LaesstUnauffaelligenStatusOhneSymbol()
|
||||
{
|
||||
var students = new[] { Student(1001, "Zora Unauffällig") };
|
||||
|
||||
var row = Assert.Single(ClassTeacherRosterRow.Build(students, [], [], new DateOnly(2026, 8, 26)));
|
||||
|
||||
Assert.Equal(row.StatusText, row.StatusTextWithGlyph);
|
||||
}
|
||||
|
||||
// ── Offene Entschuldigungen (Feature-Idee 2) ─────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void OpenExcuseBuild_SortiertNachTagenOffenAbsteigendUndIgnoriertEntschuldigte()
|
||||
{
|
||||
var absences = new[]
|
||||
{
|
||||
new ClassAbsenceDaySummaryRow(new DateOnly(2026, 8, 20), "Alt Ada", 1001, 2, 90,
|
||||
["Che"], [1, 2], ["nicht entsch."], ["Absent"], null, null, false),
|
||||
new ClassAbsenceDaySummaryRow(new DateOnly(2026, 8, 25), "Neu Ben", 1002, 1, 45,
|
||||
["Deu"], [1], ["nicht entsch."], ["Absent"], null, null, false),
|
||||
new ClassAbsenceDaySummaryRow(new DateOnly(2026, 8, 24), "Entschuldigt Cem", 1003, 1, 45,
|
||||
["Deu"], [1], ["entsch."], ["Absent"], null, null, false),
|
||||
};
|
||||
|
||||
var rows = ClassTeacherOpenExcuseRow.Build(absences, new DateOnly(2026, 8, 26));
|
||||
|
||||
// StudentName kommt aus StudentDisplayName (WebUntis liefert "Nachname Vorname",
|
||||
// hier für die Anzeige auf "Vorname Nachname" gedreht - siehe ClassAbsenceDaySummaryRow).
|
||||
Assert.Equal(["Ada Alt", "Ben Neu"], rows.Select(r => r.StudentName));
|
||||
Assert.Equal(6, rows[0].DaysOpen);
|
||||
Assert.True(rows[0].IsOverdue);
|
||||
Assert.Equal(1, rows[1].DaysOpen);
|
||||
Assert.False(rows[1].IsOverdue);
|
||||
}
|
||||
|
||||
// ── Aggregierte Klassenbuch-Kategorien (Feature-Idee 5) ──────────────────
|
||||
|
||||
[Fact]
|
||||
public void CategoryAggregateBuild_ZaehltProKategorieUndNenntTopSchueler()
|
||||
{
|
||||
var entries = new[]
|
||||
{
|
||||
new ClassTeacherClassRegisterRow(new DateOnly(2026, 8, 20), "Deu", "Schmidt Ben", "test",
|
||||
"Hausaufgaben fehlen", "Negativ", "HA fehlt"),
|
||||
new ClassTeacherClassRegisterRow(new DateOnly(2026, 8, 21), "Deu", "Schmidt Ben", "test",
|
||||
"Hausaufgaben fehlen", "Negativ", "HA fehlt"),
|
||||
new ClassTeacherClassRegisterRow(new DateOnly(2026, 8, 22), "Che", "Müller Ada", "test",
|
||||
"Hausaufgaben fehlen", "Negativ", "HA fehlt"),
|
||||
new ClassTeacherClassRegisterRow(new DateOnly(2026, 8, 23), "Che", "Müller Ada", "test",
|
||||
"Lob", "Positiv", "Gute Mitarbeit"),
|
||||
};
|
||||
|
||||
var rows = ClassTeacherCategoryAggregateRow.Build(entries);
|
||||
|
||||
Assert.Equal(["Hausaufgaben fehlen", "Lob"], rows.Select(r => r.CategoryName));
|
||||
var homework = rows[0];
|
||||
Assert.Equal(3, homework.Count);
|
||||
Assert.Contains("2× Ben Schmidt", homework.TopStudentsLabel);
|
||||
Assert.Contains("1× Ada Müller", homework.TopStudentsLabel);
|
||||
}
|
||||
|
||||
// ── Erweiterte Mustererkennung: Wochentags-Häufung (Feature-Idee 4) ──────
|
||||
|
||||
[Fact]
|
||||
public void DetectWeekdayPatterns_ErkenntDeutlicheHaeufungAnEinemWochentag()
|
||||
{
|
||||
// 24.08./31.08.2026 und 07.09.2026 sind allesamt Montage.
|
||||
var absences = new[]
|
||||
{
|
||||
new ClassAbsenceDaySummaryRow(new DateOnly(2026, 8, 24), "Muster Mona", 1001, 1, 45,
|
||||
["Deu"], [1], ["entsch."], ["Absent"], null, null, false),
|
||||
new ClassAbsenceDaySummaryRow(new DateOnly(2026, 8, 31), "Muster Mona", 1001, 1, 45,
|
||||
["Deu"], [1], ["entsch."], ["Absent"], null, null, false),
|
||||
new ClassAbsenceDaySummaryRow(new DateOnly(2026, 9, 7), "Muster Mona", 1001, 1, 45,
|
||||
["Deu"], [1], ["entsch."], ["Absent"], null, null, false),
|
||||
};
|
||||
var names = new Dictionary<string, string> { [UntisNameMatching.NameKey("Muster Mona")] = "Mona Muster" };
|
||||
|
||||
var notices = ClassTeacherOverviewViewModel.DetectWeekdayPatterns(
|
||||
absences, names, new HashSet<string>());
|
||||
|
||||
var notice = Assert.Single(notices);
|
||||
Assert.Equal("Mona Muster", notice.StudentName);
|
||||
Assert.Contains("Montag", notice.Message);
|
||||
Assert.Equal(ClassTeacherStatusKind.Info, notice.Kind);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DetectWeekdayPatterns_IgnoriertZuKleineStichprobe()
|
||||
{
|
||||
var absences = new[]
|
||||
{
|
||||
new ClassAbsenceDaySummaryRow(new DateOnly(2026, 8, 24), "Muster Mona", 1001, 1, 45,
|
||||
["Deu"], [1], ["entsch."], ["Absent"], null, null, false),
|
||||
new ClassAbsenceDaySummaryRow(new DateOnly(2026, 8, 31), "Muster Mona", 1001, 1, 45,
|
||||
["Deu"], [1], ["entsch."], ["Absent"], null, null, false),
|
||||
};
|
||||
|
||||
var notices = ClassTeacherOverviewViewModel.DetectWeekdayPatterns(
|
||||
absences, new Dictionary<string, string>(), new HashSet<string>());
|
||||
|
||||
Assert.Empty(notices);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DetectWeekdayPatterns_LaesstBereitsGemeldeteSchuelerAus()
|
||||
{
|
||||
var absences = new[]
|
||||
{
|
||||
new ClassAbsenceDaySummaryRow(new DateOnly(2026, 8, 24), "Muster Mona", 1001, 1, 45,
|
||||
["Deu"], [1], ["entsch."], ["Absent"], null, null, false),
|
||||
new ClassAbsenceDaySummaryRow(new DateOnly(2026, 8, 31), "Muster Mona", 1001, 1, 45,
|
||||
["Deu"], [1], ["entsch."], ["Absent"], null, null, false),
|
||||
new ClassAbsenceDaySummaryRow(new DateOnly(2026, 9, 7), "Muster Mona", 1001, 1, 45,
|
||||
["Deu"], [1], ["entsch."], ["Absent"], null, null, false),
|
||||
};
|
||||
var names = new Dictionary<string, string> { [UntisNameMatching.NameKey("Muster Mona")] = "Mona Muster" };
|
||||
|
||||
var notices = ClassTeacherOverviewViewModel.DetectWeekdayPatterns(
|
||||
absences, names, new HashSet<string> { "Mona Muster" });
|
||||
|
||||
Assert.Empty(notices);
|
||||
}
|
||||
|
||||
// ── Noten-/Mitarbeit-Brücke: Namensabgleich (Feature-Idee 7) ─────────────
|
||||
|
||||
[Fact]
|
||||
public void MatchStudent_FindetUeberVorUndNachnameTrotzKommaInFullName()
|
||||
{
|
||||
// Student.FullName liefert "Nachname, Vorname" (mit Komma) - ein Abgleich darüber würde
|
||||
// UntisNameMatching.NameKey verfälschen ("Müller," bliebe eigenes Wort). MatchStudent
|
||||
// muss deshalb FirstName/LastName getrennt verwenden, nicht FullName.
|
||||
var students = new List<Student>
|
||||
{
|
||||
new() { FirstName = "Ada", LastName = "Müller" },
|
||||
new() { FirstName = "Ben", LastName = "Schmidt" },
|
||||
};
|
||||
|
||||
var match = ClassTeacherOverviewViewModel.MatchStudent("Müller Ada", students);
|
||||
|
||||
Assert.NotNull(match);
|
||||
Assert.Equal("Ada", match!.FirstName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MatchStudent_LiefertNullOhneTreffer()
|
||||
{
|
||||
var students = new List<Student> { new() { FirstName = "Ada", LastName = "Müller" } };
|
||||
|
||||
Assert.Null(ClassTeacherOverviewViewModel.MatchStudent("Unbekannt Xyz", students));
|
||||
}
|
||||
|
||||
private static UntisStudentRosterCacheEntry Student(int? externKey, string displayName) =>
|
||||
new() { ClassName = "6a", ExternKey = externKey, DisplayName = displayName };
|
||||
}
|
||||
@@ -11,6 +11,22 @@ namespace LehrerApp.Desktop.Tests;
|
||||
/// (offene Aufgaben, ...).
|
||||
public sealed class DashboardViewModelTests
|
||||
{
|
||||
[Fact]
|
||||
public void LeereHinweisbereiche_WerdenAusgeblendetUndZusammenfassungBleibtKompakt()
|
||||
{
|
||||
var group = new LearningGroup { Name = "9c" };
|
||||
var today = DateOnly.FromDateTime(DateTime.Today);
|
||||
|
||||
var vm = BuildVm(group, new Lesson { GroupId = group.Id, Date = today });
|
||||
|
||||
Assert.False(vm.ExcusesCard.EffectiveIsVisible);
|
||||
Assert.False(vm.CorrectionsCard.EffectiveIsVisible);
|
||||
Assert.False(vm.AlertsCard.EffectiveIsVisible);
|
||||
Assert.Equal("0 offene Punkte", vm.AttentionSummary);
|
||||
Assert.True(vm.TodayCard.EffectiveIsVisible);
|
||||
Assert.True(vm.CalendarCard.EffectiveIsVisible);
|
||||
}
|
||||
|
||||
private static PeriodScheduleService NewPeriodSchedule()
|
||||
{
|
||||
var tempPath = System.IO.Path.Combine(
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
using LehrerApp.Core.Models;
|
||||
using LehrerApp.Core.Services;
|
||||
using LehrerApp.Desktop.ViewModels.Groups;
|
||||
using Xunit;
|
||||
|
||||
namespace LehrerApp.Desktop.Tests;
|
||||
|
||||
/// Deckt 1.6.3 (Bulk-Aktion "Rest als abwesend markieren") ab — löst das Nutzer-Feedback, dass
|
||||
/// Schüler, die nie nachschreiben, sonst dauerhaft eine leere, unberührte Zeile hinterlassen.
|
||||
public sealed class ExamGradingDialogViewModelTests
|
||||
{
|
||||
[Fact]
|
||||
public void MarkRemainingAbsent_MarktNurUnberührteZeilenAbwesend()
|
||||
{
|
||||
var group = new LearningGroup { Name = "Q1 Chemie" };
|
||||
var studentWithPoints = new Student { FirstName = "Anna", LastName = "Bauer" };
|
||||
var untouchedStudent1 = new Student { FirstName = "Ben", LastName = "Cordes" };
|
||||
var untouchedStudent2 = new Student { FirstName = "Cara", LastName = "Diehl" };
|
||||
var students = new FakeStudents([studentWithPoints, untouchedStudent1, untouchedStudent2]);
|
||||
var memberships = new FakeMemberships(
|
||||
[
|
||||
new GroupMembership { GroupId = group.Id, StudentId = studentWithPoints.Id },
|
||||
new GroupMembership { GroupId = group.Id, StudentId = untouchedStudent1.Id },
|
||||
new GroupMembership { GroupId = group.Id, StudentId = untouchedStudent2.Id },
|
||||
]);
|
||||
var exam = new Exam
|
||||
{
|
||||
GroupId = group.Id,
|
||||
Title = "Klausur 1",
|
||||
Tasks = [new ExamTask { Nr = 1, MaxPoints = 10 }],
|
||||
GradingKey = GradingService.DefaultKey1To6(),
|
||||
};
|
||||
var results = new FakeResults();
|
||||
|
||||
var vm = new ExamGradingDialogViewModel(results, students, memberships,
|
||||
new GradingService(), exam, group.Id);
|
||||
|
||||
var rowWithPoints = vm.Rows.Single(r => r.StudentId == studentWithPoints.Id);
|
||||
rowWithPoints.Cells[0].TrySetValue(7);
|
||||
|
||||
vm.MarkRemainingAbsentCommand.Execute(null);
|
||||
|
||||
Assert.False(rowWithPoints.Absent);
|
||||
Assert.True(vm.Rows.Single(r => r.StudentId == untouchedStudent1.Id).Absent);
|
||||
Assert.True(vm.Rows.Single(r => r.StudentId == untouchedStudent2.Id).Absent);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MarkRemainingAbsent_LässtBereitsAbwesendMarkierteUnverändert()
|
||||
{
|
||||
var group = new LearningGroup { Name = "Q1 Chemie" };
|
||||
var student = new Student { FirstName = "Ben", LastName = "Cordes" };
|
||||
var students = new FakeStudents([student]);
|
||||
var memberships = new FakeMemberships(
|
||||
[new GroupMembership { GroupId = group.Id, StudentId = student.Id }]);
|
||||
var exam = new Exam { GroupId = group.Id, Title = "Klausur 1", Tasks = [new ExamTask { Nr = 1, MaxPoints = 10 }], GradingKey = GradingService.DefaultKey1To6() };
|
||||
var vm = new ExamGradingDialogViewModel(new FakeResults(), students, memberships,
|
||||
new GradingService(), exam, group.Id);
|
||||
|
||||
vm.Rows[0].Absent = true;
|
||||
vm.MarkRemainingAbsentCommand.Execute(null);
|
||||
|
||||
Assert.True(vm.Rows[0].Absent);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
using LehrerApp.Core.Models;
|
||||
using LehrerApp.Core.Services;
|
||||
using LehrerApp.Desktop.ViewModels.Exams;
|
||||
using Xunit;
|
||||
|
||||
namespace LehrerApp.Desktop.Tests;
|
||||
|
||||
/// Deckt 1.6 (Klausuren-Hauptseite) ab: Priorisierung end-to-end über die ViewModel-Schicht,
|
||||
/// Parallelkurs-Umschalter (1.6.4) und Genehmigung/Ankündigung (1.6.5).
|
||||
public sealed class ExamsOverviewViewModelTests
|
||||
{
|
||||
private static ExamsOverviewViewModel BuildVm(List<LearningGroup> groups, List<Exam> exams,
|
||||
FakeMemberships? memberships = null, FakeResults? results = null) =>
|
||||
new(new FakeExams(exams), results ?? new FakeResults(), new FakeGroups(groups),
|
||||
memberships ?? new FakeMemberships([]), new GradingService(), new SchoolYearService());
|
||||
|
||||
[Fact]
|
||||
public void Load_UnbearbeiteteKorrekturStehtVorGeplanterKlausur()
|
||||
{
|
||||
var group = new LearningGroup { Name = "Q1 Chemie", SchoolYear = new SchoolYearService().CurrentSchoolYear() };
|
||||
var today = DateOnly.FromDateTime(DateTime.Today);
|
||||
var overdue = new Exam { GroupId = group.Id, Title = "Alte Klausur", Status = ExamStatus.Conducted, Date = today.AddDays(-10) };
|
||||
var planned = new Exam { GroupId = group.Id, Title = "Neue Klausur", Status = ExamStatus.Planned, Date = today.AddDays(3) };
|
||||
|
||||
var vm = BuildVm([group], [overdue, planned]);
|
||||
vm.LoadCommand.Execute(null);
|
||||
|
||||
Assert.Equal("Alte Klausur", vm.Rows[0].Title);
|
||||
Assert.Equal("Neue Klausur", vm.Rows[1].Title);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Load_ParallelkurseWerdenAlsGeschwisterErkannt()
|
||||
{
|
||||
var groupA = new LearningGroup { Name = "Chemie GK Q1", SchoolYear = new SchoolYearService().CurrentSchoolYear() };
|
||||
var groupB = new LearningGroup { Name = "Chemie GK Q1b", SchoolYear = groupA.SchoolYear };
|
||||
var original = new Exam { GroupId = groupA.Id, Title = "Redox", Status = ExamStatus.Planned, Date = DateOnly.FromDateTime(DateTime.Today).AddDays(5) };
|
||||
var duplicate = new Exam
|
||||
{
|
||||
GroupId = groupB.Id, Title = "Redox", Status = ExamStatus.Planned,
|
||||
Date = DateOnly.FromDateTime(DateTime.Today).AddDays(6), SharedExamGroupId = original.Id,
|
||||
};
|
||||
|
||||
var vm = BuildVm([groupA, groupB], [original, duplicate]);
|
||||
vm.LoadCommand.Execute(null);
|
||||
|
||||
vm.SelectedRow = vm.Rows.Single(r => r.Exam.Id == original.Id);
|
||||
Assert.True(vm.SelectedRow.HasSibling);
|
||||
Assert.Single(vm.Siblings);
|
||||
Assert.Equal(duplicate.Id, vm.Siblings[0].Exam.Id);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToggleApproval_SetztUndLöschtDatum()
|
||||
{
|
||||
var group = new LearningGroup { Name = "Q1 Chemie", SchoolYear = new SchoolYearService().CurrentSchoolYear() };
|
||||
var exam = new Exam { GroupId = group.Id, Title = "Klausur", Status = ExamStatus.Planned, Date = DateOnly.FromDateTime(DateTime.Today).AddDays(2) };
|
||||
var vm = BuildVm([group], [exam]);
|
||||
vm.LoadCommand.Execute(null);
|
||||
vm.SelectedRow = vm.Rows[0];
|
||||
|
||||
vm.ToggleApprovalCommand.Execute(null);
|
||||
Assert.NotNull(exam.ApprovalGrantedAt);
|
||||
Assert.StartsWith("Genehmigt am", vm.ApprovalLabel);
|
||||
|
||||
vm.ToggleApprovalCommand.Execute(null);
|
||||
Assert.Null(exam.ApprovalGrantedAt);
|
||||
Assert.Equal("Genehmigung noch ausstehend", vm.ApprovalLabel);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Selection_ZeigtKorrekturfortschrittOderNotenspiegelJeNachStatus()
|
||||
{
|
||||
var group = new LearningGroup { Name = "Q1 Chemie", SchoolYear = new SchoolYearService().CurrentSchoolYear() };
|
||||
var today = DateOnly.FromDateTime(DateTime.Today);
|
||||
var conducted = new Exam { GroupId = group.Id, Title = "Läuft", Status = ExamStatus.Conducted, Date = today.AddDays(-2) };
|
||||
var returned = new Exam { GroupId = group.Id, Title = "Fertig", Status = ExamStatus.Returned, Date = today.AddDays(-20) };
|
||||
var vm = BuildVm([group], [conducted, returned]);
|
||||
vm.LoadCommand.Execute(null);
|
||||
|
||||
vm.SelectedRow = vm.Rows.Single(r => r.Exam.Id == conducted.Id);
|
||||
Assert.True(vm.ShowCorrectionProgress);
|
||||
Assert.False(vm.ShowGradeSummary);
|
||||
|
||||
vm.SelectedRow = vm.Rows.Single(r => r.Exam.Id == returned.Id);
|
||||
Assert.False(vm.ShowCorrectionProgress);
|
||||
Assert.True(vm.ShowGradeSummary);
|
||||
}
|
||||
}
|
||||
@@ -474,6 +474,56 @@ public class FakeAnnualPlanEvents : IAnnualPlanEventRepository
|
||||
public void Delete(Guid id) => _all.RemoveAll(e => e.Id == id);
|
||||
}
|
||||
|
||||
public class FakeUntisAbsenceCache : IUntisAbsenceCacheRepository
|
||||
{
|
||||
private readonly List<UntisAbsenceCacheEntry> _all = [];
|
||||
public List<UntisAbsenceCacheEntry> GetByClassAndRange(string className, int startDate, int endDate) =>
|
||||
_all.Where(e => e.ClassName == className && e.Date >= startDate && e.Date <= endDate).ToList();
|
||||
public void ReplaceRange(string className, int startDate, int endDate, IEnumerable<UntisAbsenceCacheEntry> entries)
|
||||
{
|
||||
_all.RemoveAll(e => e.ClassName == className && e.Date >= startDate && e.Date <= endDate);
|
||||
_all.AddRange(entries);
|
||||
}
|
||||
public void InsertRange(IEnumerable<UntisAbsenceCacheEntry> entries) => _all.AddRange(entries);
|
||||
}
|
||||
|
||||
public class FakeUntisClassRegisterCache : IUntisClassRegisterCacheRepository
|
||||
{
|
||||
private readonly List<UntisClassRegisterCacheEntry> _all = [];
|
||||
public List<UntisClassRegisterCacheEntry> GetByClassAndRange(string className, int startDate, int endDate) =>
|
||||
_all.Where(e => e.ClassName == className && e.Date >= startDate && e.Date <= endDate).ToList();
|
||||
public void ReplaceRange(string className, int startDate, int endDate, IEnumerable<UntisClassRegisterCacheEntry> entries)
|
||||
{
|
||||
_all.RemoveAll(e => e.ClassName == className && e.Date >= startDate && e.Date <= endDate);
|
||||
_all.AddRange(entries);
|
||||
}
|
||||
public void InsertRange(IEnumerable<UntisClassRegisterCacheEntry> entries) => _all.AddRange(entries);
|
||||
}
|
||||
|
||||
public class FakeUntisStudentRosterCache : IUntisStudentRosterCacheRepository
|
||||
{
|
||||
private readonly List<UntisStudentRosterCacheEntry> _all = [];
|
||||
public List<UntisStudentRosterCacheEntry> GetByClass(string className) =>
|
||||
_all.Where(e => e.ClassName == className).ToList();
|
||||
public void ReplaceAll(string className, IEnumerable<UntisStudentRosterCacheEntry> entries)
|
||||
{
|
||||
_all.RemoveAll(e => e.ClassName == className);
|
||||
_all.AddRange(entries);
|
||||
}
|
||||
}
|
||||
|
||||
public class FakeUntisCacheFetchStates : IUntisCacheFetchStateRepository
|
||||
{
|
||||
private readonly List<UntisCacheFetchState> _all = [];
|
||||
public UntisCacheFetchState? Get(string className, UntisCacheKind kind) =>
|
||||
_all.FirstOrDefault(s => s.ClassName == className && s.Kind == kind);
|
||||
public void Save(UntisCacheFetchState state)
|
||||
{
|
||||
_all.RemoveAll(s => s.ClassName == state.ClassName && s.Kind == state.Kind);
|
||||
_all.Add(state);
|
||||
}
|
||||
}
|
||||
|
||||
public class FakeWorkTasks : IWorkTaskRepository
|
||||
{
|
||||
private readonly List<WorkTask> _all = [];
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
using System.Globalization;
|
||||
using LehrerApp.Desktop.Converters;
|
||||
using Xunit;
|
||||
|
||||
namespace LehrerApp.Desktop.Tests;
|
||||
|
||||
public sealed class FractionWidthConverterTests
|
||||
{
|
||||
[Fact]
|
||||
public void Convert_MultipliziertTrackbreiteMitAnteil()
|
||||
{
|
||||
var result = FractionWidthConverter.Instance.Convert(
|
||||
[200d, 0.25d], typeof(double), null, CultureInfo.InvariantCulture);
|
||||
|
||||
Assert.Equal(50d, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Convert_LiefertNullBreiteBeiUngueltigenWerten()
|
||||
{
|
||||
// Bounds.Width kann vor dem ersten Layout-Pass NaN sein (Element noch nicht gemessen) —
|
||||
// darf nicht zu einer negativen oder undefinierten Breite führen.
|
||||
Assert.Equal(0d, FractionWidthConverter.Instance.Convert(
|
||||
[double.NaN, 0.5d], typeof(double), null, CultureInfo.InvariantCulture));
|
||||
Assert.Equal(0d, FractionWidthConverter.Instance.Convert(
|
||||
["not a double", 0.5d], typeof(double), null, CultureInfo.InvariantCulture));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Convert_ClamptNegativeErgebnisseAufNull()
|
||||
{
|
||||
var result = FractionWidthConverter.Instance.Convert(
|
||||
[100d, -0.2d], typeof(double), null, CultureInfo.InvariantCulture);
|
||||
|
||||
Assert.Equal(0d, result);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
using LehrerApp.Core.Models;
|
||||
using LehrerApp.Desktop.ViewModels;
|
||||
using Xunit;
|
||||
|
||||
namespace LehrerApp.Desktop.Tests;
|
||||
|
||||
public sealed class GlobalSearchViewModelTests
|
||||
{
|
||||
[Fact]
|
||||
public void LeereSuche_ZeigtSchnellaktionen()
|
||||
{
|
||||
var vm = BuildVm();
|
||||
|
||||
Assert.Collection(vm.Results,
|
||||
item => Assert.Equal(GlobalSearchAction.NewTask, item.Action),
|
||||
item => Assert.Equal(GlobalSearchAction.NewReminder, item.Action),
|
||||
item => Assert.Equal(GlobalSearchAction.NewStudent, item.Action));
|
||||
Assert.Same(vm.Results[0], vm.SelectedResult);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Suche_FindetSchuelerGruppeKlausurUndAufgabe()
|
||||
{
|
||||
var group = new LearningGroup { Name = "9 Chemie", SchoolYear = "2026/27", GradeLevel = 9 };
|
||||
var student = new Student { FirstName = "Mia", LastName = "Chemie" };
|
||||
var exam = new Exam { GroupId = group.Id, Title = "Chemie-Test", Date = new DateOnly(2026, 9, 1) };
|
||||
var tasks = new FakeWorkTasks();
|
||||
tasks.Add(new WorkTask { GroupId = group.Id, Title = "Chemie-Klausur korrigieren" });
|
||||
var vm = BuildVm([student], [group], [exam], tasks);
|
||||
|
||||
vm.Query = "Chemie";
|
||||
|
||||
Assert.Contains(vm.Results, x => x.Kind == GlobalSearchResultKind.Student && x.EntityId == student.Id);
|
||||
Assert.Contains(vm.Results, x => x.Kind == GlobalSearchResultKind.Group && x.EntityId == group.Id);
|
||||
Assert.Contains(vm.Results, x => x.Kind == GlobalSearchResultKind.Exam && x.EntityId == exam.Id);
|
||||
Assert.Contains(vm.Results, x => x.Kind == GlobalSearchResultKind.Task && x.Title.Contains("korrigieren"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TrefferAusfuehren_NavigiertUndSchliesstPalette()
|
||||
{
|
||||
var student = new Student { FirstName = "Mia", LastName = "Muster" };
|
||||
var vm = BuildVm([student]);
|
||||
vm.Query = "Muster";
|
||||
var result = Assert.Single(vm.Results, x => x.Kind == GlobalSearchResultKind.Student);
|
||||
GlobalSearchResult? navigated = null;
|
||||
var closed = false;
|
||||
vm.OnNavigate = x => navigated = x;
|
||||
vm.OnClose = () => closed = true;
|
||||
|
||||
await vm.ExecuteCommand.ExecuteAsync(result);
|
||||
|
||||
Assert.Same(result, navigated);
|
||||
Assert.True(closed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ErinnerungSchnellErfassen_StartetDialogAlsErinnerung()
|
||||
{
|
||||
var vm = BuildVm();
|
||||
bool? reminder = null;
|
||||
vm.OnQuickAddTask = value => { reminder = value; return Task.CompletedTask; };
|
||||
var action = vm.Results.Single(x => x.Action == GlobalSearchAction.NewReminder);
|
||||
|
||||
await vm.ExecuteCommand.ExecuteAsync(action);
|
||||
|
||||
Assert.True(reminder);
|
||||
}
|
||||
|
||||
private static GlobalSearchViewModel BuildVm(List<Student>? students = null,
|
||||
List<LearningGroup>? groups = null, List<Exam>? exams = null, FakeWorkTasks? tasks = null) =>
|
||||
new(new FakeStudents(students ?? []), new FakeGroups(groups ?? []),
|
||||
new FakeExams(exams ?? []), tasks ?? new FakeWorkTasks());
|
||||
}
|
||||
@@ -5,76 +5,69 @@ using Xunit;
|
||||
|
||||
namespace LehrerApp.Desktop.Tests;
|
||||
|
||||
/// Tests für den Schnellüberblick im Auswahl-Panel der Gruppenliste (Nutzer-Feedback:
|
||||
/// "oberhalb der Buttonliste ein paar Daten auswerfen. Nächste Stunde, nächste Arbeit,
|
||||
/// wichtige Todos").
|
||||
/// <summary>Tests für die direkte, einlagige Lerngruppen-Navigation.</summary>
|
||||
public sealed class GroupListViewModelTests
|
||||
{
|
||||
private static GroupListViewModel BuildVm(LearningGroup group, FakeLessons? lessons = null,
|
||||
FakeExams? exams = null, FakeWorkTasks? tasks = null) =>
|
||||
new(new FakeGroups([group]), new FakeSubjects([]), new SchoolYearService(),
|
||||
lessons ?? new FakeLessons(), exams ?? new FakeExams([]), tasks ?? new FakeWorkTasks());
|
||||
private static GroupListViewModel BuildVm(params LearningGroup[] groups) =>
|
||||
new(new FakeGroups([.. groups]), new FakeSubjects([]), new SchoolYearService());
|
||||
|
||||
[Fact]
|
||||
public void SelectedGroup_OhneDatenZeigtKeinenSchnellueberblick()
|
||||
public void Gruppenkarte_OeffnetDirektDieUebersicht()
|
||||
{
|
||||
var group = new LearningGroup { Name = "9c" };
|
||||
var group = CurrentGroup("9c");
|
||||
var vm = BuildVm(group);
|
||||
Guid? openedId = null;
|
||||
int? openedTab = null;
|
||||
vm.OnNavigateToDetail = (id, tab) => { openedId = id; openedTab = tab; };
|
||||
|
||||
vm.SelectedGroup = vm.Groups.Count > 0 ? vm.Groups[0] : new GroupListItem(group, "");
|
||||
Assert.Single(vm.Groups).OpenCommand.Execute(null);
|
||||
|
||||
Assert.False(vm.QuickHasAnything);
|
||||
Assert.Equal(group.Id, openedId);
|
||||
Assert.Equal(0, openedTab);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SelectedGroup_ZeigtNaechsteGeplanteStundeUndKlausur()
|
||||
public async Task Verwaltungsmenue_BearbeitetDieGewaehlteKarteOhneZwischenauswahl()
|
||||
{
|
||||
var group = new LearningGroup { Name = "9c" };
|
||||
var today = DateOnly.FromDateTime(DateTime.Today);
|
||||
var lessons = new FakeLessons();
|
||||
lessons.Add(new Lesson { GroupId = group.Id, Date = today.AddDays(3), Topic = "Redox", Status = LessonStatus.Planned });
|
||||
var exams = new FakeExams([new Exam { GroupId = group.Id, Date = today.AddDays(10), Title = "Klausur 1" }]);
|
||||
var vm = BuildVm(group, lessons: lessons, exams: exams);
|
||||
var first = CurrentGroup("9a");
|
||||
var second = CurrentGroup("9b");
|
||||
var vm = BuildVm(first, second);
|
||||
Guid? editedId = null;
|
||||
vm.OnEditGroup = id => { editedId = id; return Task.CompletedTask; };
|
||||
var secondItem = vm.Groups.Single(x => x.Id == second.Id);
|
||||
|
||||
vm.SelectedGroup = new GroupListItem(group, "");
|
||||
await secondItem.EditCommand.ExecuteAsync(null);
|
||||
|
||||
Assert.True(vm.QuickHasNextLesson);
|
||||
Assert.Contains("Redox", vm.QuickNextLessonLabel);
|
||||
Assert.True(vm.QuickHasNextExam);
|
||||
Assert.Contains("Klausur 1", vm.QuickNextExamLabel);
|
||||
Assert.True(vm.QuickHasAnything);
|
||||
Assert.Equal(second.Id, editedId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SelectedGroup_ZeigtOffeneAufgabenDieserGruppeSortiertNachFaelligkeit()
|
||||
public void Suche_FiltertWeiterhinNachGruppenname()
|
||||
{
|
||||
var group = new LearningGroup { Name = "9c" };
|
||||
var today = DateOnly.FromDateTime(DateTime.Today);
|
||||
var tasks = new FakeWorkTasks();
|
||||
tasks.Add(new WorkTask { GroupId = group.Id, Title = "Bald fällig", DueDate = today.AddDays(1), Status = WorkTaskStatus.Open });
|
||||
tasks.Add(new WorkTask { GroupId = group.Id, Title = "Erledigt", DueDate = today, Status = WorkTaskStatus.Done });
|
||||
tasks.Add(new WorkTask { GroupId = Guid.NewGuid(), Title = "Andere Gruppe", DueDate = today, Status = WorkTaskStatus.Open });
|
||||
var vm = BuildVm(group, tasks: tasks);
|
||||
var vm = BuildVm(CurrentGroup("9 Chemie"), CurrentGroup("10 Mathematik"));
|
||||
|
||||
vm.SelectedGroup = new GroupListItem(group, "");
|
||||
vm.SearchText = "Chemie";
|
||||
|
||||
Assert.True(vm.QuickHasTasks);
|
||||
Assert.Equal("Bald fällig", Assert.Single(vm.QuickTasks).Title);
|
||||
Assert.Equal("9 Chemie", Assert.Single(vm.Groups).Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SelectedGroup_Zuruecksetzen_LeertSchnellueberblick()
|
||||
public void Archivieren_WirktAufDieKarteUndEntferntSieAusDerAktivenListe()
|
||||
{
|
||||
var group = new LearningGroup { Name = "9c" };
|
||||
var today = DateOnly.FromDateTime(DateTime.Today);
|
||||
var exams = new FakeExams([new Exam { GroupId = group.Id, Date = today.AddDays(10), Title = "Klausur 1" }]);
|
||||
var vm = BuildVm(group, exams: exams);
|
||||
vm.SelectedGroup = new GroupListItem(group, "");
|
||||
Assert.True(vm.QuickHasAnything);
|
||||
var group = CurrentGroup("9c");
|
||||
var vm = BuildVm(group);
|
||||
var item = Assert.Single(vm.Groups);
|
||||
|
||||
vm.SelectedGroup = null;
|
||||
item.ToggleArchiveCommand.Execute(null);
|
||||
|
||||
Assert.False(vm.QuickHasAnything);
|
||||
Assert.Empty(vm.QuickTasks);
|
||||
Assert.False(group.IsActive);
|
||||
Assert.Empty(vm.Groups);
|
||||
}
|
||||
|
||||
private static LearningGroup CurrentGroup(string name) => new()
|
||||
{
|
||||
Name = name,
|
||||
SchoolYear = new SchoolYearService().CurrentSchoolYear(),
|
||||
IsActive = true,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -13,6 +13,17 @@ public sealed class TeachingModeViewModelTests
|
||||
new FakeStudents(students ?? []), new FakeMemberships(memberships ?? []),
|
||||
sessions ?? new FakeSessions([]), new FakeEntries(), new FakeAspects());
|
||||
|
||||
/// Dieselben Fakes wie <see cref="BuildSeatingPlan"/>, aber für ParticipationTabViewModel —
|
||||
/// beide brauchen eigene IParticipationSessionRepository/IParticipationRepository-Instanzen
|
||||
/// im Produktivcode (siehe TeachingModeWindow-Kommentar zu ReloadSeatBadgesFromRepository),
|
||||
/// hier reicht für die Tests aber eine gemeinsame FakeSessions-Instanz, damit beide
|
||||
/// ViewModels dieselbe verknüpfte Sitzung sehen.
|
||||
private static ParticipationTabViewModel BuildParticipation(LearningGroup group,
|
||||
FakeSessions? sessions = null) =>
|
||||
new(sessions ?? new FakeSessions([]), new FakeEntries(), new FakeAspects(),
|
||||
new FakeStudents([]), new FakeMemberships([]), new FakeGroups([group]),
|
||||
new FakeCompetencyDomains());
|
||||
|
||||
[Fact]
|
||||
public void Konstruktor_LaedtVerlaufsplanUndInitialisiertSitzplanFuerDieGruppe()
|
||||
{
|
||||
@@ -24,7 +35,8 @@ public sealed class TeachingModeViewModelTests
|
||||
};
|
||||
var seatingPlan = BuildSeatingPlan(group.Id);
|
||||
|
||||
var vm = new TeachingModeViewModel(lesson, group, new FakeAlternativeLessonPaths([]), seatingPlan);
|
||||
var vm = new TeachingModeViewModel(lesson, group, new FakeAlternativeLessonPaths([]),
|
||||
new FakeLessons(), seatingPlan, BuildParticipation(group));
|
||||
|
||||
Assert.Equal("Q1 Chemie", vm.GroupName);
|
||||
Assert.Equal("Redox", vm.LessonInfo.Topic);
|
||||
@@ -41,13 +53,37 @@ public sealed class TeachingModeViewModelTests
|
||||
var sessions = new FakeSessions([]);
|
||||
var seatingPlan = BuildSeatingPlan(group.Id, sessions);
|
||||
|
||||
var vm = new TeachingModeViewModel(lesson, group, new FakeAlternativeLessonPaths([]), seatingPlan);
|
||||
var vm = new TeachingModeViewModel(lesson, group, new FakeAlternativeLessonPaths([]),
|
||||
new FakeLessons(), seatingPlan, BuildParticipation(group, sessions));
|
||||
|
||||
var created = Assert.Single(sessions.GetByGroup(group.Id));
|
||||
Assert.Equal(lesson.Id, created.LessonId);
|
||||
Assert.Equal(vm.SeatingPlan.SelectedSession?.Id, created.Id);
|
||||
}
|
||||
|
||||
/// Nutzer-Feedback: Schnellbewertungs-Dialoge (Mitarbeit, Anwesenheit/Hausaufgabe) sollen sich
|
||||
/// im Unterrichtsmodus direkt öffnen lassen — das setzt voraus, dass die dafür verwendete
|
||||
/// ParticipationTabViewModel-Instanz von Anfang an auf der zu dieser Stunde gehörenden
|
||||
/// Sitzung steht, nicht auf einer beliebigen anderen Sitzung der Gruppe.
|
||||
[Fact]
|
||||
public void Konstruktor_SelektiertDieselbeSitzungAuchInDerParticipationViewModel()
|
||||
{
|
||||
var group = new LearningGroup { Name = "Q1 Chemie" };
|
||||
var lesson = new Lesson
|
||||
{ GroupId = group.Id, Date = DateOnly.FromDateTime(DateTime.Today), Topic = "Redox" };
|
||||
var otherSession = new ParticipationSession
|
||||
{ GroupId = group.Id, Date = lesson.Date.AddDays(-1), Comment = "Andere Stunde" };
|
||||
var sessions = new FakeSessions([otherSession]);
|
||||
var seatingPlan = BuildSeatingPlan(group.Id, sessions);
|
||||
|
||||
var vm = new TeachingModeViewModel(lesson, group, new FakeAlternativeLessonPaths([]),
|
||||
new FakeLessons(), seatingPlan, BuildParticipation(group, sessions));
|
||||
|
||||
Assert.NotNull(vm.SeatingPlan.SelectedSession);
|
||||
Assert.Equal(vm.SeatingPlan.SelectedSession!.Id, vm.Participation.SelectedSession?.Id);
|
||||
Assert.NotEqual(otherSession.Id, vm.Participation.SelectedSession?.Id);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Konstruktor_BereitsExistierendeVerknuepfteSitzung_WirdWiederverwendet()
|
||||
{
|
||||
@@ -59,7 +95,8 @@ public sealed class TeachingModeViewModelTests
|
||||
var sessions = new FakeSessions([existing]);
|
||||
var seatingPlan = BuildSeatingPlan(group.Id, sessions);
|
||||
|
||||
var vm = new TeachingModeViewModel(lesson, group, new FakeAlternativeLessonPaths([]), seatingPlan);
|
||||
var vm = new TeachingModeViewModel(lesson, group, new FakeAlternativeLessonPaths([]),
|
||||
new FakeLessons(), seatingPlan, BuildParticipation(group, sessions));
|
||||
|
||||
Assert.Single(sessions.GetByGroup(group.Id));
|
||||
Assert.Equal(existing.Id, vm.SeatingPlan.SelectedSession?.Id);
|
||||
@@ -75,8 +112,92 @@ public sealed class TeachingModeViewModelTests
|
||||
{ GroupId = group.Id, Date = DateOnly.FromDateTime(DateTime.Today), Topic = "Redox" };
|
||||
var seatingPlan = BuildSeatingPlan(group.Id);
|
||||
|
||||
var vm = new TeachingModeViewModel(lesson, group, new FakeAlternativeLessonPaths([]), seatingPlan);
|
||||
var vm = new TeachingModeViewModel(lesson, group, new FakeAlternativeLessonPaths([]),
|
||||
new FakeLessons(), seatingPlan, BuildParticipation(group));
|
||||
|
||||
Assert.False(vm.SeatingPlan.IsEditable);
|
||||
}
|
||||
|
||||
// ── Hausaufgabe: letzte Stunde ansehen/abhaken, aktuelle einsehen/ändern ─────
|
||||
|
||||
[Fact]
|
||||
public void Konstruktor_FindetHausaufgabeDerVorherigenStunde()
|
||||
{
|
||||
var group = new LearningGroup { Name = "Q1 Chemie" };
|
||||
var today = DateOnly.FromDateTime(DateTime.Today);
|
||||
var previous = new Lesson
|
||||
{
|
||||
GroupId = group.Id, Date = today.AddDays(-7), Topic = "Säuren",
|
||||
Homework = "S. 42, Aufgabe 3", HomeworkChecked = false,
|
||||
};
|
||||
var lesson = new Lesson { GroupId = group.Id, Date = today, Topic = "Redox" };
|
||||
var lessons = new FakeLessons();
|
||||
lessons.Add(previous);
|
||||
var seatingPlan = BuildSeatingPlan(group.Id);
|
||||
|
||||
var vm = new TeachingModeViewModel(lesson, group, new FakeAlternativeLessonPaths([]),
|
||||
lessons, seatingPlan, BuildParticipation(group));
|
||||
|
||||
Assert.True(vm.Homework.HasPreviousHomework);
|
||||
Assert.Equal("S. 42, Aufgabe 3", vm.Homework.PreviousHomeworkText);
|
||||
Assert.Contains("Säuren", vm.Homework.PreviousLessonLabel);
|
||||
Assert.False(vm.Homework.PreviousHomeworkChecked);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Konstruktor_IgnoriertVorherigeStundeOhneHausaufgabe()
|
||||
{
|
||||
var group = new LearningGroup { Name = "Q1 Chemie" };
|
||||
var today = DateOnly.FromDateTime(DateTime.Today);
|
||||
var previous = new Lesson { GroupId = group.Id, Date = today.AddDays(-7), Topic = "Säuren" };
|
||||
var lesson = new Lesson { GroupId = group.Id, Date = today, Topic = "Redox" };
|
||||
var lessons = new FakeLessons();
|
||||
lessons.Add(previous);
|
||||
var seatingPlan = BuildSeatingPlan(group.Id);
|
||||
|
||||
var vm = new TeachingModeViewModel(lesson, group, new FakeAlternativeLessonPaths([]),
|
||||
lessons, seatingPlan, BuildParticipation(group));
|
||||
|
||||
Assert.False(vm.Homework.HasPreviousHomework);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PreviousHomeworkChecked_SpeichertSofortAnDerVorherigenStunde()
|
||||
{
|
||||
var group = new LearningGroup { Name = "Q1 Chemie" };
|
||||
var today = DateOnly.FromDateTime(DateTime.Today);
|
||||
var previous = new Lesson
|
||||
{ GroupId = group.Id, Date = today.AddDays(-7), Topic = "Säuren", Homework = "S. 42" };
|
||||
var lesson = new Lesson { GroupId = group.Id, Date = today, Topic = "Redox" };
|
||||
var lessons = new FakeLessons();
|
||||
lessons.Add(previous);
|
||||
var seatingPlan = BuildSeatingPlan(group.Id);
|
||||
var vm = new TeachingModeViewModel(lesson, group, new FakeAlternativeLessonPaths([]),
|
||||
lessons, seatingPlan, BuildParticipation(group));
|
||||
|
||||
vm.Homework.PreviousHomeworkChecked = true;
|
||||
|
||||
var saved = Assert.Single(lessons.GetByGroupAndRange(group.Id, today.AddDays(-120), today.AddDays(-1)));
|
||||
Assert.True(saved.HomeworkChecked);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SaveCurrentHomework_SchreibtAufDieAktuelleStunde()
|
||||
{
|
||||
var group = new LearningGroup { Name = "Q1 Chemie" };
|
||||
var lesson = new Lesson
|
||||
{ GroupId = group.Id, Date = DateOnly.FromDateTime(DateTime.Today), Topic = "Redox" };
|
||||
var lessons = new FakeLessons();
|
||||
lessons.Add(lesson);
|
||||
var seatingPlan = BuildSeatingPlan(group.Id);
|
||||
var vm = new TeachingModeViewModel(lesson, group, new FakeAlternativeLessonPaths([]),
|
||||
lessons, seatingPlan, BuildParticipation(group));
|
||||
|
||||
vm.Homework.CurrentHomework = "AB 7 fertigstellen";
|
||||
vm.Homework.SaveCurrentHomeworkCommand.Execute(null);
|
||||
|
||||
var saved = Assert.Single(lessons.GetByGroupAndRange(group.Id, lesson.Date, lesson.Date));
|
||||
Assert.Equal("AB 7 fertigstellen", saved.Homework);
|
||||
Assert.Equal("Gespeichert.", vm.Homework.SaveStatus);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,10 +14,11 @@ public sealed class TimetableViewModelTests
|
||||
FakeSubjects? subjects = null, FakeLessons? lessons = null, FakeExams? exams = null,
|
||||
SchoolCalendarSettingsService? calendarSettings = null,
|
||||
FakeSupervisionDuties? supervisionDuties = null, FakeSubstitutionEntries? substitutions = null,
|
||||
FakeUntisSlotMappings? untisMappings = null, WebUntisSettingsService? untisSettings = null)
|
||||
FakeUntisSlotMappings? untisMappings = null, WebUntisSettingsService? untisSettings = null,
|
||||
PeriodScheduleService? periodSchedule = null)
|
||||
{
|
||||
// Bewusst kein "using": SchoolCalendarSettingsService liest den Pfad nur bei Bedarf
|
||||
// (SetState), das Verzeichnis muss über die Lebensdauer des ViewModels bestehen bleiben.
|
||||
// Bewusst kein "using": SchoolCalendarSettingsService/PeriodScheduleService lesen den Pfad
|
||||
// nur bei Bedarf, das Verzeichnis muss über die Lebensdauer des ViewModels bestehen bleiben.
|
||||
var tempPath = System.IO.Path.Combine(
|
||||
System.IO.Path.GetTempPath(), $"lehrerapp-timetablevm-tests-{Guid.NewGuid():N}");
|
||||
Directory.CreateDirectory(tempPath);
|
||||
@@ -28,7 +29,8 @@ public sealed class TimetableViewModelTests
|
||||
calendarSettings ?? new SchoolCalendarSettingsService(tempPath),
|
||||
new PublicHolidayService(), new SchoolYearService(),
|
||||
supervisionDuties ?? new FakeSupervisionDuties(), substitutions ?? new FakeSubstitutionEntries(),
|
||||
untisMappings ?? new FakeUntisSlotMappings(), untisSettings ?? TestSupport.BuildWebUntisSettingsService());
|
||||
untisMappings ?? new FakeUntisSlotMappings(), untisSettings ?? TestSupport.BuildWebUntisSettingsService(),
|
||||
periodSchedule ?? new PeriodScheduleService(tempPath));
|
||||
}
|
||||
|
||||
/// Nächstes Datum ab (inkl.) <paramref name="from"/>, das auf einen Wochentag Mo-Fr fällt —
|
||||
@@ -336,6 +338,88 @@ public sealed class TimetableViewModelTests
|
||||
Assert.Equal(lesson.Id, openedLesson?.Id);
|
||||
}
|
||||
|
||||
/// Nutzer-Feedback (zweite Runde): der Direktklick auf eine Wochenraster-Kachel soll
|
||||
/// "einheitlicher" springen — bei einer Lesson HEUTE, während gerade Unterrichtszeit ist,
|
||||
/// direkt in den Unterrichtsmodus statt in den (schreibgeschützten) Planungsviewer.
|
||||
[Fact]
|
||||
public async Task OpenWeekCell_LessonHeuteWaehrendUnterrichtszeit_OeffnetUnterrichtsmodus()
|
||||
{
|
||||
var group = new LearningGroup { Name = "Q1 Chemie" };
|
||||
// Bewusst nicht der reale DateTime.Today-Wochentag: an einem Sa/So gäbe es dafür keine
|
||||
// Kachel im Mo-Fr-Raster. Stattdessen ein beliebiger Wochentag der laufenden Kalenderwoche
|
||||
// (Kachel existiert immer), dessen Datum per VM.Clock als "heute" simuliert wird (siehe
|
||||
// TimetableViewModel.Clock - genau für solche Tests eingeführt).
|
||||
var weekday = DayOfWeek.Monday;
|
||||
var lessonDate = DateInCurrentWeek(weekday);
|
||||
var slots = new FakeTimetableSlots();
|
||||
slots.Add(new TimetableSlot { GroupId = group.Id, Weekday = weekday, PeriodNumber = 1 });
|
||||
var lesson = new Lesson { GroupId = group.Id, Date = lessonDate, LessonNumber = 1, Topic = "Redox" };
|
||||
var lessons = new FakeLessons();
|
||||
lessons.Add(lesson);
|
||||
var periodSchedule = BuildPeriodSchedule(1);
|
||||
var vm = BuildViewModel(slots, new FakeGroups([group]), lessons: lessons, periodSchedule: periodSchedule);
|
||||
// Gleiche Uhrzeit wie BuildPeriodSchedule (reales DateTime.Now), aber am simulierten
|
||||
// "heutigen" Datum - die Stundenzeit-Prüfung (Uhrzeit) bleibt so unabhängig vom Datum.
|
||||
vm.Clock = () => lessonDate.ToDateTime(TimeOnly.FromDateTime(DateTime.Now));
|
||||
Lesson? teachingModeLesson = null;
|
||||
Lesson? viewerLesson = null;
|
||||
vm.OnOpenTeachingMode = l => { teachingModeLesson = l; return Task.CompletedTask; };
|
||||
vm.OnOpenLessonViewer = l => { viewerLesson = l; return Task.CompletedTask; };
|
||||
|
||||
var cell = vm.WeekItems.Single(c => c.IsSlotCell && c.Weekday == weekday && c.PeriodNumber == 1);
|
||||
await vm.OpenWeekCellCommand.ExecuteAsync(cell);
|
||||
|
||||
Assert.Equal(lesson.Id, teachingModeLesson?.Id);
|
||||
Assert.Null(viewerLesson);
|
||||
}
|
||||
|
||||
/// Dieselbe Stundenzeit passt, die Lesson liegt aber nicht heute — bleibt beim Viewer.
|
||||
[Fact]
|
||||
public async Task OpenWeekCell_LessonNichtHeuteTrotzPassenderUhrzeit_OeffnetViewer()
|
||||
{
|
||||
var group = new LearningGroup { Name = "Q1 Chemie" };
|
||||
var today = DateOnly.FromDateTime(DateTime.Today);
|
||||
var otherWeekday = today.DayOfWeek == DayOfWeek.Monday ? DayOfWeek.Tuesday : DayOfWeek.Monday;
|
||||
var otherDate = DateInCurrentWeek(otherWeekday);
|
||||
var slots = new FakeTimetableSlots();
|
||||
slots.Add(new TimetableSlot { GroupId = group.Id, Weekday = otherWeekday, PeriodNumber = 1 });
|
||||
var lesson = new Lesson { GroupId = group.Id, Date = otherDate, LessonNumber = 1, Topic = "Redox" };
|
||||
var lessons = new FakeLessons();
|
||||
lessons.Add(lesson);
|
||||
var periodSchedule = BuildPeriodSchedule(1);
|
||||
var vm = BuildViewModel(slots, new FakeGroups([group]), lessons: lessons, periodSchedule: periodSchedule);
|
||||
Lesson? teachingModeLesson = null;
|
||||
Lesson? viewerLesson = null;
|
||||
vm.OnOpenTeachingMode = l => { teachingModeLesson = l; return Task.CompletedTask; };
|
||||
vm.OnOpenLessonViewer = l => { viewerLesson = l; return Task.CompletedTask; };
|
||||
|
||||
var cell = vm.WeekItems.Single(c => c.IsSlotCell && c.Weekday == otherWeekday && c.PeriodNumber == 1);
|
||||
await vm.OpenWeekCellCommand.ExecuteAsync(cell);
|
||||
|
||||
Assert.Equal(lesson.Id, viewerLesson?.Id);
|
||||
Assert.Null(teachingModeLesson);
|
||||
}
|
||||
|
||||
/// Weites Zeitfenster um <paramref name="around"/> herum (±2 Stunden), damit der Test nicht an
|
||||
/// die exakte Toleranz von TimetableViewModel.TeachingTimeTolerance gebunden ist.
|
||||
/// Start UND Ende exakt auf "jetzt" statt eines künstlich breiteren Fensters (z.B. ganzer Tag
|
||||
/// via TimeOnly.MinValue/MaxValue): TimetableViewModel.IsAroundTeachingTime addiert selbst
|
||||
/// noch ±10 Minuten Toleranz (TeachingTimeTolerance) auf Start/Ende — ein zusätzliches, vom
|
||||
/// Test vorgegebenes Fenster würde nahe Mitternacht durch die TimeOnly-Arithmetik (wrappt bei
|
||||
/// 24 Uhr) selbst kippen. "Jetzt" als Start/Ende bleibt nur dann riskant, wenn der Test
|
||||
/// zufällig innerhalb der letzten/ersten 10 Minuten des Tages läuft — dasselbe inhärente
|
||||
/// Randproblem hätte dann auch die Produktion, kein Testartefakt.
|
||||
private static PeriodScheduleService BuildPeriodSchedule(int periodNumber)
|
||||
{
|
||||
var tempPath = System.IO.Path.Combine(
|
||||
System.IO.Path.GetTempPath(), $"lehrerapp-periodschedule-tests-{Guid.NewGuid():N}");
|
||||
Directory.CreateDirectory(tempPath);
|
||||
var service = new PeriodScheduleService(tempPath);
|
||||
var now = TimeOnly.FromDateTime(DateTime.Now);
|
||||
service.SetPeriods([new PeriodTimeEntry { PeriodNumber = periodNumber, Start = now, End = now }]);
|
||||
return service;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OpenSettings_RuftOnNavigateToSettingsAuf()
|
||||
{
|
||||
@@ -1177,3 +1261,43 @@ public sealed class TimetableViewModelTests
|
||||
Assert.False(vm.HasUntisMismatch);
|
||||
}
|
||||
}
|
||||
|
||||
/// Nutzer-Feedback (zweite Runde): das Popup-Menü je Wochenraster-Kachel (TimetableView.axaml,
|
||||
/// MenuFlyout) blendet "Unterrichtsansicht"/"Planungsviewer" per IsVisible="{Binding HasLesson}"
|
||||
/// aus, wenn für den Slot noch keine Lesson existiert, und den ganzen Menü-Trigger per
|
||||
/// IsVisible="{Binding HasGroupId}", wenn die Kachel gar keiner Gruppe zugeordnet ist (z.B. eine
|
||||
/// GroupId-lose Vertretung). Diese beiden Properties sind die Grundlage dafür.
|
||||
public sealed class WeekCellItemMenuVisibilityTests
|
||||
{
|
||||
[Fact]
|
||||
public void ForSlot_MitLesson_HatHasLessonUndHasGroupId()
|
||||
{
|
||||
var lesson = new Lesson { Topic = "Redox" };
|
||||
var cell = WeekCellItem.ForSlot(DayOfWeek.Monday, 3, isToday: true, "Che", "Q1 Chemie",
|
||||
"R204", "Redox", "#4C8DFF", "", hasExam: false, isLastBeforeExam: false,
|
||||
hasExperiment: false, Guid.NewGuid(), isHoliday: false, lesson: lesson);
|
||||
|
||||
Assert.True(cell.HasLesson);
|
||||
Assert.True(cell.HasGroupId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ForSlot_OhneLesson_HatKeinHasLesson()
|
||||
{
|
||||
var cell = WeekCellItem.ForSlot(DayOfWeek.Monday, 3, isToday: true, "Che", "Q1 Chemie",
|
||||
"R204", "", "#4C8DFF", "", hasExam: false, isLastBeforeExam: false,
|
||||
hasExperiment: false, Guid.NewGuid(), isHoliday: false);
|
||||
|
||||
Assert.False(cell.HasLesson);
|
||||
Assert.True(cell.HasGroupId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ForSubstitutionLesson_OhneGroupId_HatKeinHasGroupId()
|
||||
{
|
||||
var cell = WeekCellItem.ForSubstitutionLesson(DayOfWeek.Monday, 3, isToday: true,
|
||||
new SubstitutionEntry { GroupId = null, GroupLabel = "Fremde Klasse", Description = "Vertretung" });
|
||||
|
||||
Assert.False(cell.HasGroupId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
using LehrerApp.Desktop.ViewModels.ClassTeacher;
|
||||
using Xunit;
|
||||
|
||||
namespace LehrerApp.Desktop.Tests;
|
||||
|
||||
public sealed class UntisNameMatchingTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData("Ben Schmidt", "Schmidt Ben")]
|
||||
[InlineData("Ada Müller", "MÜLLER ADA")]
|
||||
[InlineData("Max Mustermann", "mustermann max")]
|
||||
public void NamesMatch_IstReihenfolgeUndGrossKleinschreibungUnabhaengig(string a, string b) =>
|
||||
Assert.True(UntisNameMatching.NamesMatch(a, b));
|
||||
|
||||
[Theory]
|
||||
[InlineData("Ben Schmidt", "Ben Müller")]
|
||||
[InlineData(null, "Ben Schmidt")]
|
||||
[InlineData("Ben Schmidt", "")]
|
||||
public void NamesMatch_LehntUnterschiedlicheOderFehlendeNamenAb(string? a, string b) =>
|
||||
Assert.False(UntisNameMatching.NamesMatch(a, b));
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
using System.Net;
|
||||
using System.Text;
|
||||
using LehrerApp.Core.Models;
|
||||
using LehrerApp.Desktop.Services;
|
||||
using Xunit;
|
||||
|
||||
namespace LehrerApp.Desktop.Tests;
|
||||
|
||||
public sealed class UntisReportCacheServiceTests
|
||||
{
|
||||
private static readonly DateOnly Today = new(2026, 8, 25);
|
||||
private static readonly DateOnly HotStart = Today.AddDays(-14);
|
||||
|
||||
// ── Plan(): reine Entscheidungslogik, keine Repositories/HTTP nötig ──────────
|
||||
|
||||
[Fact]
|
||||
public void Plan_ErsterAufruf_LaedtHeissesFensterUndDeckungReichtBisFensteranfang()
|
||||
{
|
||||
var plan = UntisReportCacheService.Plan(null, Today, Today, Today, forceRefresh: false, DateTime.UtcNow);
|
||||
|
||||
Assert.True(plan.RefreshHotWindow);
|
||||
Assert.Equal(HotStart, plan.HotWindowStart);
|
||||
Assert.Equal(Today, plan.HotWindowEnd);
|
||||
Assert.False(plan.FetchColdRange);
|
||||
Assert.Equal(Int(HotStart), plan.ResultingColdCoverageStartDate);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Plan_InnerhalbEinerStunde_KeinErneutesHeissesFenster()
|
||||
{
|
||||
var state = new UntisCacheFetchState
|
||||
{
|
||||
ClassName = "10c", Kind = UntisCacheKind.Absences,
|
||||
HotWindowFetchedAt = DateTime.UtcNow.AddMinutes(-10), ColdCoverageStartDate = Int(HotStart),
|
||||
};
|
||||
|
||||
var plan = UntisReportCacheService.Plan(state, Today, Today, Today, forceRefresh: false, DateTime.UtcNow);
|
||||
|
||||
Assert.False(plan.RefreshHotWindow);
|
||||
Assert.False(plan.FetchColdRange);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Plan_ForceRefresh_LoestTrotzKuerzlichemAbrufAus()
|
||||
{
|
||||
var state = new UntisCacheFetchState
|
||||
{
|
||||
ClassName = "10c", Kind = UntisCacheKind.Absences,
|
||||
HotWindowFetchedAt = DateTime.UtcNow.AddMinutes(-10), ColdCoverageStartDate = Int(HotStart),
|
||||
};
|
||||
|
||||
var plan = UntisReportCacheService.Plan(state, Today, Today, Today, forceRefresh: true, DateTime.UtcNow);
|
||||
|
||||
Assert.True(plan.RefreshHotWindow);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Plan_MehrAlsEineStundeVergangen_FrischtHeissesFensterErneutAuf()
|
||||
{
|
||||
var state = new UntisCacheFetchState
|
||||
{
|
||||
ClassName = "10c", Kind = UntisCacheKind.Absences,
|
||||
HotWindowFetchedAt = DateTime.UtcNow.AddHours(-2), ColdCoverageStartDate = Int(HotStart),
|
||||
};
|
||||
|
||||
var plan = UntisReportCacheService.Plan(state, Today, Today, Today, forceRefresh: false, DateTime.UtcNow);
|
||||
|
||||
Assert.True(plan.RefreshHotWindow);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Plan_AnfrageVorDemHeissenFenster_KeinHeissesFensterAberKalterErstabruf()
|
||||
{
|
||||
var requestStart = HotStart.AddDays(-30);
|
||||
var requestEnd = HotStart.AddDays(-5);
|
||||
|
||||
var plan = UntisReportCacheService.Plan(null, requestStart, requestEnd, Today, forceRefresh: false, DateTime.UtcNow);
|
||||
|
||||
Assert.False(plan.RefreshHotWindow);
|
||||
Assert.True(plan.FetchColdRange);
|
||||
Assert.Equal(requestStart, plan.ColdRangeStart);
|
||||
Assert.Equal(HotStart.AddDays(-1), plan.ColdRangeEnd);
|
||||
Assert.Equal(Int(requestStart), plan.ResultingColdCoverageStartDate);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Plan_BereitsKaltAbgedeckterBereich_KeinAbrufNoetig()
|
||||
{
|
||||
var coveredSince = HotStart.AddDays(-60);
|
||||
var state = new UntisCacheFetchState
|
||||
{
|
||||
ClassName = "10c", Kind = UntisCacheKind.Absences,
|
||||
HotWindowFetchedAt = DateTime.UtcNow, ColdCoverageStartDate = Int(coveredSince),
|
||||
};
|
||||
|
||||
var plan = UntisReportCacheService.Plan(state, coveredSince.AddDays(10), HotStart.AddDays(-20),
|
||||
Today, forceRefresh: false, DateTime.UtcNow);
|
||||
|
||||
Assert.False(plan.RefreshHotWindow);
|
||||
Assert.False(plan.FetchColdRange);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Plan_ErweitertKalteAbdeckungNurBisZumBenoetigtenStart()
|
||||
{
|
||||
var coveredSince = HotStart.AddDays(-30);
|
||||
var earlierStart = HotStart.AddDays(-90);
|
||||
var state = new UntisCacheFetchState
|
||||
{
|
||||
ClassName = "10c", Kind = UntisCacheKind.Absences,
|
||||
HotWindowFetchedAt = DateTime.UtcNow, ColdCoverageStartDate = Int(coveredSince),
|
||||
};
|
||||
|
||||
var plan = UntisReportCacheService.Plan(state, earlierStart, HotStart.AddDays(-40),
|
||||
Today, forceRefresh: false, DateTime.UtcNow);
|
||||
|
||||
Assert.False(plan.RefreshHotWindow);
|
||||
Assert.True(plan.FetchColdRange);
|
||||
Assert.Equal(earlierStart, plan.ColdRangeStart);
|
||||
Assert.Equal(coveredSince.AddDays(-1), plan.ColdRangeEnd);
|
||||
Assert.Equal(Int(earlierStart), plan.ResultingColdCoverageStartDate);
|
||||
}
|
||||
|
||||
private static int Int(DateOnly date) => date.Year * 10000 + date.Month * 100 + date.Day;
|
||||
|
||||
// ── Ende-zu-Ende: erster Aufruf ruft WebUntis ab, zweiter Aufruf binnen einer Stunde nicht ──
|
||||
|
||||
[Fact]
|
||||
public async Task GetClassRegisterEventsAsync_ZweiterAufrufBinnenEinerStunde_RuftWebUntisNichtErneutAb()
|
||||
{
|
||||
var todayLabel = DateTime.Today.ToString("dd.MM.yy");
|
||||
var csv = Encoding.UTF8.GetBytes(
|
||||
"Klasse\tDatum\tFach\tName\tBenutzer\tEintragskategorie\tKategoriegruppe\tText\r\n" +
|
||||
$"10c\t{todayLabel}\tNAT\tMuster Max\tmueller\tMitarb. über Erwart.\tPositiv\tArbeitet gut mit.\r\n");
|
||||
var handler = new QueueHandler(
|
||||
Json("{\"result\":{\"sessionId\":\"s\"}}"),
|
||||
Json("{\"result\":[{\"id\":1,\"name\":\"2026/27\",\"startDate\":20260801,\"endDate\":20270731}]}"),
|
||||
Json("{\"data\":{\"finished\":true,\"error\":false," +
|
||||
"\"reportParams\":\"get=rpt.tmp&name=ClassregEventPerStudent&format=csv\"}}"),
|
||||
new HttpResponseMessage(HttpStatusCode.OK) { Content = new ByteArrayContent(csv) });
|
||||
var settings = TestSupport.BuildWebUntisSettingsService();
|
||||
await using var untis = new WebUntisIntegrationService(new HttpClient(handler), settings);
|
||||
await untis.ConnectAsync(new WebUntisCredentials("bk-ostvest", "arche.webuntis.com", "lehrkraft", "geheim"));
|
||||
|
||||
var absenceCache = new FakeUntisAbsenceCache();
|
||||
var classRegisterCache = new FakeUntisClassRegisterCache();
|
||||
var rosterCache = new FakeUntisStudentRosterCache();
|
||||
var fetchStates = new FakeUntisCacheFetchStates();
|
||||
var cache = new UntisReportCacheService(untis, absenceCache, classRegisterCache, rosterCache, fetchStates);
|
||||
var today = DateOnly.FromDateTime(DateTime.Today);
|
||||
|
||||
var first = await cache.GetClassRegisterEventsAsync("10c", today, today);
|
||||
Assert.Single(first);
|
||||
Assert.Equal(4, handler.Requests.Count);
|
||||
|
||||
var second = await cache.GetClassRegisterEventsAsync("10c", today, today);
|
||||
Assert.Single(second);
|
||||
Assert.Equal(4, handler.Requests.Count); // keine weiteren Anfragen - aus dem Cache bedient
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetStudentRosterAsync_ZweiterAufrufBinnenEinerStunde_RuftWebUntisNichtErneutAb()
|
||||
{
|
||||
var csv = Encoding.UTF8.GetBytes(
|
||||
"id\texternKey\tklasse.name\tname\tlongName\tforeName\r\n" +
|
||||
"1\t9001\t10c\tMUST\tMustermann\tMax\r\n");
|
||||
var handler = new QueueHandler(
|
||||
Json("{\"result\":{\"sessionId\":\"s\"}}"),
|
||||
Json("{\"result\":[{\"id\":1,\"name\":\"2026/27\",\"startDate\":20260801,\"endDate\":20270731}]}"),
|
||||
Json("{\"data\":{\"finished\":true,\"error\":false," +
|
||||
"\"reportParams\":\"get=rpt.tmp&name=Student&format=csv\"}}"),
|
||||
new HttpResponseMessage(HttpStatusCode.OK) { Content = new ByteArrayContent(csv) });
|
||||
var settings = TestSupport.BuildWebUntisSettingsService();
|
||||
await using var untis = new WebUntisIntegrationService(new HttpClient(handler), settings);
|
||||
await untis.ConnectAsync(new WebUntisCredentials("bk-ostvest", "arche.webuntis.com", "lehrkraft", "geheim"));
|
||||
|
||||
var cache = new UntisReportCacheService(untis, new FakeUntisAbsenceCache(), new FakeUntisClassRegisterCache(),
|
||||
new FakeUntisStudentRosterCache(), new FakeUntisCacheFetchStates());
|
||||
|
||||
var first = await cache.GetStudentRosterAsync("10c");
|
||||
Assert.Single(first);
|
||||
Assert.Equal("Max Mustermann", first[0].DisplayName);
|
||||
Assert.Equal(4, handler.Requests.Count);
|
||||
|
||||
var second = await cache.GetStudentRosterAsync("10c");
|
||||
Assert.Single(second);
|
||||
Assert.Equal(4, handler.Requests.Count); // keine weiteren Anfragen - aus dem Cache bedient
|
||||
}
|
||||
|
||||
private static HttpResponseMessage Json(string json) => new(HttpStatusCode.OK)
|
||||
{
|
||||
Content = new StringContent(json, Encoding.UTF8, "application/json"),
|
||||
};
|
||||
|
||||
private sealed class QueueHandler(params HttpResponseMessage[] responses) : HttpMessageHandler
|
||||
{
|
||||
private readonly Queue<HttpResponseMessage> _responses = new(responses);
|
||||
public List<string> Requests { get; } = [];
|
||||
|
||||
protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
Requests.Add(request.RequestUri?.ToString() ?? "");
|
||||
await Task.Yield();
|
||||
return _responses.Dequeue();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -30,6 +30,41 @@ public sealed class WebUntisIntegrationServiceTests
|
||||
Assert.Single(handler.Requests, request => request.Body.Contains("\"method\":\"authenticate\""));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetClassAbsencesAsync_LoestKlasseNamensbasiertAufUndAdressiertSieAlsKLPlusId()
|
||||
{
|
||||
var csv = Encoding.UTF8.GetBytes(
|
||||
"Schüler*innen\tExterne Id\tText\tKlasse\tDatum\tWochentag\tFehlstd.\tFehlmin.\tLehrkraft\tFach\t" +
|
||||
"Abwesenheitsgrund\tText\tENr\tErledigt\tAbwesenheit zählt\tEntschuldigungstext\tStundennr.\tStatus\tFehltage\r\n" +
|
||||
"Muster Max\t12345\t\t10c\t24.08.26\tMo.\t1\t45\tHED\tChe\tAbsent\tKrank gemeldet\t555\t24.08.26\ttrue\t\t3\tnicht entsch.\t1\r\n");
|
||||
var handler = new QueueHandler(
|
||||
Json("{\"result\":{\"sessionId\":\"desktop-session\"}}"), // authenticate (ConnectAsync)
|
||||
Json("{\"result\":[{\"id\":7,\"name\":\"2026/27\",\"startDate\":20260801,\"endDate\":20270731}]}"), // getSchoolyears (ConnectAsync-Validierung)
|
||||
Json("{\"result\":[{\"id\":7,\"name\":\"2026/27\",\"startDate\":20260801,\"endDate\":20270731}]}"), // getSchoolyears (ResolveClassIdAsync)
|
||||
Json("{\"result\":[{\"id\":874,\"name\":\"10c\"}]}"), // getKlassen
|
||||
Json("{\"data\":{\"finished\":true,\"error\":false," +
|
||||
"\"reportParams\":\"get=rpt.tmp&name=AbsencePerStudent&format=csv\"}}"),
|
||||
new HttpResponseMessage(HttpStatusCode.OK) { Content = new ByteArrayContent(csv) },
|
||||
Json("{\"result\":{}}")); // logout (Dispose)
|
||||
var settings = TestSupport.BuildWebUntisSettingsService();
|
||||
await using var service = new WebUntisIntegrationService(new HttpClient(handler), settings);
|
||||
|
||||
await service.ConnectAsync(new WebUntisCredentials(
|
||||
"bk-ostvest", "arche.webuntis.com", "lehrkraft", "geheim"));
|
||||
var entries = await service.GetClassAbsencesAsync("10c",
|
||||
new DateOnly(2026, 8, 24), new DateOnly(2026, 8, 28));
|
||||
|
||||
var entry = Assert.Single(entries);
|
||||
Assert.Equal("Muster Max", entry.StudentName);
|
||||
Assert.Equal("nicht entsch.", entry.Status);
|
||||
// Zweimal getSchoolyears: einmal als ConnectAsync-Validierung, einmal in ResolveClassIdAsync.
|
||||
Assert.Equal(2, handler.Requests.Count(request => request.Body.Contains("\"method\":\"getSchoolyears\"")));
|
||||
Assert.Single(handler.Requests, request => request.Body.Contains("\"method\":\"getKlassen\""));
|
||||
Assert.Single(handler.Requests, request =>
|
||||
request.Uri.Contains("reports.do?name=AbsencePerStudent") &&
|
||||
request.Uri.Contains("klasseOrStudentgroupId=KL874"));
|
||||
}
|
||||
|
||||
private static HttpResponseMessage Json(string json) => new(HttpStatusCode.OK)
|
||||
{
|
||||
Content = new StringContent(json, Encoding.UTF8, "application/json"),
|
||||
|
||||
@@ -2,6 +2,14 @@
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
x:Class="LehrerApp.Desktop.App"
|
||||
RequestedThemeVariant="Default">
|
||||
<Application.Resources>
|
||||
<ResourceDictionary>
|
||||
<ResourceDictionary.MergedDictionaries>
|
||||
<ResourceInclude Source="avares://LehrerApp.Desktop/Styles/SemanticBrushes.axaml"/>
|
||||
</ResourceDictionary.MergedDictionaries>
|
||||
</ResourceDictionary>
|
||||
</Application.Resources>
|
||||
|
||||
<Application.Styles>
|
||||
<FluentTheme />
|
||||
<StyleInclude Source="avares://Avalonia.Controls.DataGrid/Themes/Fluent.xaml"/>
|
||||
|
||||
@@ -10,7 +10,9 @@ using LehrerApp.Desktop.ViewModels;
|
||||
using LehrerApp.Desktop.ViewModels.Groups;
|
||||
using LehrerApp.Desktop.ViewModels.Planning;
|
||||
using LehrerApp.Desktop.ViewModels.Students;
|
||||
using LehrerApp.Desktop.ViewModels.Workload;
|
||||
using LehrerApp.Desktop.Views;
|
||||
using LehrerApp.Desktop.Views.Workload;
|
||||
using LehrerApp.Sync;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
@@ -79,6 +81,15 @@ public class App : Application
|
||||
|
||||
var mainVm = Services.GetRequiredService<MainWindowViewModel>();
|
||||
WireCallbacks(mainVm);
|
||||
|
||||
// DI-Singletons werden erst beim ersten Auflösen erzeugt. Ohne dieses explizite
|
||||
// Auflösen entstand der Timer des optionalen WebUntis-Abgleichs erst, wenn die
|
||||
// Einstellungen geöffnet oder ein manueller Abruf gestartet wurde. Den Dienst beim
|
||||
// Anwendungsstart aktivieren und wie den Jahresplan sofort einmal abgleichen; danach
|
||||
// übernimmt sein stündlicher Timer.
|
||||
if (Services.GetService<UntisSyncService>() is { } untisSync)
|
||||
_ = untisSync.PollAsync();
|
||||
|
||||
var main = new MainWindow { DataContext = mainVm };
|
||||
main.EnableWindowSizePersistence(Services.GetRequiredService<WindowSettingsService>());
|
||||
if (Services.GetService<SyncEngine>() is { } syncEngine)
|
||||
@@ -100,11 +111,12 @@ public class App : Application
|
||||
|
||||
private static void DisposeServices()
|
||||
{
|
||||
if (_serviceProvider is null) return;
|
||||
var serviceProvider = Interlocked.Exchange(ref _serviceProvider, null);
|
||||
if (serviceProvider is null) return;
|
||||
|
||||
try
|
||||
{
|
||||
_serviceProvider.GetService<LiteDbContext>()?.Checkpoint();
|
||||
serviceProvider.GetService<LiteDbContext>()?.Checkpoint();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -112,8 +124,19 @@ public class App : Application
|
||||
}
|
||||
finally
|
||||
{
|
||||
_serviceProvider.Dispose();
|
||||
_serviceProvider = null;
|
||||
// Der Exit-Handler läuft synchron auf dem Avalonia-UI-Thread. Die asynchrone
|
||||
// Entsorgung darf dort nicht mit GetResult() gestartet werden: Fortsetzungen aus
|
||||
// WebUntis/HttpClient könnten sonst auf den blockierten UI-Kontext zurückwarten.
|
||||
try
|
||||
{
|
||||
Task.Run(async () =>
|
||||
await serviceProvider.DisposeAsync().ConfigureAwait(false))
|
||||
.GetAwaiter().GetResult();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AppBootstrapper.Logger.Error("Dienste konnten beim Beenden nicht vollständig freigegeben werden.", ex);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -131,6 +154,23 @@ public class App : Application
|
||||
dash.OnNavigateToExam = id => main.NavigateToGroupDetail(id, 4); // Tab "Klausuren"
|
||||
dash.OnNavigateToUnplannedLesson = id => main.NavigateToGroupDetail(id, 6); // Tab "Planung"
|
||||
|
||||
// Globale Suche/Schnellerfassung (14.2): Navigation bleibt im MainWindow-VM, die
|
||||
// vorhandenen Dialog-Helfer übernehmen Eingabe und Validierung.
|
||||
var search = Services.GetRequiredService<GlobalSearchViewModel>();
|
||||
search.OnNavigate = result =>
|
||||
{
|
||||
if (result.Kind == GlobalSearchResultKind.Student && result.EntityId is { } studentId)
|
||||
main.NavigateToStudent(studentId);
|
||||
else if (result.Kind == GlobalSearchResultKind.Group && result.GroupId is { } groupId)
|
||||
main.NavigateToGroupDetail(groupId);
|
||||
else if (result.Kind == GlobalSearchResultKind.Exam && result.GroupId is { } examGroupId)
|
||||
main.NavigateToGroupDetail(examGroupId, 4);
|
||||
else if (result.Kind == GlobalSearchResultKind.Task)
|
||||
main.NavigateToWorkload();
|
||||
};
|
||||
search.OnQuickAddTask = startAsReminder => ShowQuickTaskDialog(startAsReminder, dash);
|
||||
search.OnQuickAddStudent = ShowAddStudentDialog;
|
||||
|
||||
// StudentList → StudentDetail + Anlegen
|
||||
var sl = Services.GetRequiredService<StudentListViewModel>();
|
||||
sl.OnNavigateToDetail = id => main.NavigateToStudent(id);
|
||||
@@ -150,4 +190,17 @@ public class App : Application
|
||||
if (Application.Current?.ApplicationLifetime is IClassicDesktopStyleApplicationLifetime { MainWindow: { } owner })
|
||||
await dialog.ShowDialog<bool>(owner);
|
||||
}
|
||||
|
||||
private static async Task ShowQuickTaskDialog(bool startAsReminder, DashboardViewModel dashboard)
|
||||
{
|
||||
if (Application.Current?.ApplicationLifetime is not IClassicDesktopStyleApplicationLifetime
|
||||
{ MainWindow: { } owner }) return;
|
||||
|
||||
var result = await WorkTaskDialogHelper.ShowDialog(owner, startAsReminder: startAsReminder);
|
||||
if (result is null) return;
|
||||
|
||||
Services.GetRequiredService<IWorkTaskRepository>().Save(result);
|
||||
dashboard.RefreshCommand.Execute(null);
|
||||
Services.GetRequiredService<WorkTaskListViewModel>().Load();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,8 @@ using LehrerApp.Data;
|
||||
using LehrerApp.Data.Repositories;
|
||||
using LehrerApp.Desktop.Services;
|
||||
using LehrerApp.Desktop.ViewModels;
|
||||
using LehrerApp.Desktop.ViewModels.ClassTeacher;
|
||||
using LehrerApp.Desktop.ViewModels.Exams;
|
||||
using LehrerApp.Desktop.ViewModels.Groups;
|
||||
using LehrerApp.Desktop.ViewModels.Planning;
|
||||
using LehrerApp.Desktop.ViewModels.Settings;
|
||||
@@ -168,6 +170,10 @@ public static class AppBootstrapper
|
||||
services.AddSingleton<IUntisSnapshotRepository, UntisSnapshotRepository>();
|
||||
services.AddSingleton<IUntisSlotMappingRepository, UntisSlotMappingRepository>();
|
||||
services.AddSingleton<IAnnualPlanEventRepository, AnnualPlanEventRepository>();
|
||||
services.AddSingleton<IUntisAbsenceCacheRepository, UntisAbsenceCacheRepository>();
|
||||
services.AddSingleton<IUntisClassRegisterCacheRepository, UntisClassRegisterCacheRepository>();
|
||||
services.AddSingleton<IUntisCacheFetchStateRepository, UntisCacheFetchStateRepository>();
|
||||
services.AddSingleton<IUntisStudentRosterCacheRepository, UntisStudentRosterCacheRepository>();
|
||||
|
||||
// ── Services ──────────────────────────────────────────────────────────
|
||||
services.AddSingleton<GradingService>();
|
||||
@@ -224,6 +230,7 @@ public static class AppBootstrapper
|
||||
services.AddSingleton(_ => new SyncAuthService(new HttpClient()));
|
||||
services.AddSingleton(sp => new SchoolWeatherService(new HttpClient(), syncSettings));
|
||||
services.AddSingleton(sp => new WebUntisIntegrationService(new HttpClient(), untisSettings));
|
||||
services.AddSingleton<UntisReportCacheService>();
|
||||
// War dieses Gerät schon eingeloggt, aber sync.key fehlt(e), wurde gerade eben (unten)
|
||||
// stillschweigend ein neuer, unabhängiger Schlüssel erzeugt - bisher unter dem ALTEN
|
||||
// Schlüssel synchronisierte Server-Daten sind für dieses Gerät dann nicht mehr lesbar.
|
||||
@@ -285,6 +292,7 @@ public static class AppBootstrapper
|
||||
// Singleton: einmal erstellt, überall dieselbe Instanz
|
||||
services.AddSingleton<AppLockViewModel>();
|
||||
services.AddSingleton<MainWindowViewModel>();
|
||||
services.AddSingleton<GlobalSearchViewModel>();
|
||||
services.AddSingleton<DashboardViewModel>();
|
||||
services.AddSingleton(sp =>
|
||||
new SyncStatusViewModel(
|
||||
@@ -297,6 +305,9 @@ public static class AppBootstrapper
|
||||
services.AddSingleton<TimeTrackingViewModel>();
|
||||
services.AddSingleton<WorkloadEvaluationViewModel>();
|
||||
services.AddSingleton<WorkloadViewModel>();
|
||||
services.AddSingleton<ClassTeacherDetailsViewModel>();
|
||||
services.AddSingleton<ClassTeacherOverviewViewModel>();
|
||||
services.AddSingleton<ExamsOverviewViewModel>();
|
||||
|
||||
// Transient: neue Instanz pro Navigation (für Detailseiten)
|
||||
services.AddTransient<GroupDetailViewModel>();
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using Avalonia.Data.Converters;
|
||||
|
||||
namespace LehrerApp.Desktop.Converters;
|
||||
|
||||
/// <summary>Multipliziert die gerenderte Breite eines Referenzelements (typischerweise die neutrale
|
||||
/// Track-Leiste eines gestapelten Balkens) mit einem Anteil 0…1. Ersetzt eine im ViewModel hart
|
||||
/// codierte Pixelkonstante (Quick-Win, Nutzer-Feedback) — die Segmentbreite bleibt damit auch
|
||||
/// korrekt, wenn sich die Breite der Seitenspalte in XAML mal ändert, weil sie sich aus der
|
||||
/// tatsächlichen <c>Bounds.Width</c> des Track-Elements ergibt statt aus einer angenommenen
|
||||
/// festen Breite. `Grid.ColumnDefinitions` ließ sich dafür nicht binden (Avalonia bietet dort bei
|
||||
/// kompilierten Bindings keinen Setter — <c>AVLN3000</c>), deshalb MultiBinding auf Pixelbreite
|
||||
/// statt Grid-Sternspalten.</summary>
|
||||
public sealed class FractionWidthConverter : IMultiValueConverter
|
||||
{
|
||||
public static readonly FractionWidthConverter Instance = new();
|
||||
|
||||
public object? Convert(IList<object?> values, Type targetType, object? parameter, CultureInfo culture)
|
||||
{
|
||||
if (values is not [double trackWidth, double fraction, ..] || double.IsNaN(trackWidth)) return 0d;
|
||||
return Math.Max(0d, trackWidth * fraction);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
using LehrerApp.Core.Interfaces;
|
||||
using LehrerApp.Core.Models;
|
||||
|
||||
namespace LehrerApp.Desktop.Services;
|
||||
|
||||
/// <summary>Ergebnis von <see cref="UntisReportCacheService.Plan"/>: was für eine Anfrage
|
||||
/// [<c>RequestStart</c>..<c>RequestEnd</c>, nicht Teil dieses Records, siehe Aufrufer] tatsächlich
|
||||
/// live nachgeladen werden muss, und welcher Deckungsstand danach gilt. Bewusst als reine, ohne
|
||||
/// Repository/HTTP-Zugriff testbare Funktion ausgelagert (gleiches Muster wie
|
||||
/// <see cref="ClassTeacher.ClassAbsenceDaySummaryRow.GroupByStudentAndDay"/>).</summary>
|
||||
public readonly record struct UntisCacheRefreshPlan(
|
||||
bool RefreshHotWindow, DateOnly HotWindowStart, DateOnly HotWindowEnd,
|
||||
bool FetchColdRange, DateOnly ColdRangeStart, DateOnly ColdRangeEnd,
|
||||
int? ResultingColdCoverageStartDate);
|
||||
|
||||
/// <summary>
|
||||
/// Lokaler Cache für die beiden Klassenlehrer-Berichte (Fehlzeiten, Klassenbucheinträge anderer
|
||||
/// Lehrkräfte, siehe TODO.md) - Nutzer-Feedback: Warnungen sollen sofort da sein, ohne bei jedem
|
||||
/// Öffnen der Ansicht neu abzurufen, und der Bericht darf nicht mehrfach pro Stunde abgerufen
|
||||
/// werden (Sorge, bei WebUntis aufzufallen, wenn dieselbe Ansicht mehrfach geöffnet wird). Ein
|
||||
/// festes "heißes" Fenster der letzten <see cref="HotWindowDays"/> Tage wird höchstens stündlich
|
||||
/// aufgefrischt (dort kann sich der Status noch ändern, z. B. "ausstehend" → "entschuldigt");
|
||||
/// alles Ältere gilt als endgültig und wird, einmal abgerufen, dauerhaft aus dem Cache bedient.
|
||||
/// Die Cache-Tabellen selbst sind bewusst nicht synchronisiert (siehe
|
||||
/// <see cref="IUntisAbsenceCacheRepository"/>-Implementierung) - jedes Gerät füllt seinen Cache
|
||||
/// über diesen Service selbst.
|
||||
/// </summary>
|
||||
public sealed class UntisReportCacheService(
|
||||
WebUntisIntegrationService untis,
|
||||
IUntisAbsenceCacheRepository absenceCache,
|
||||
IUntisClassRegisterCacheRepository classRegisterCache,
|
||||
IUntisStudentRosterCacheRepository rosterCache,
|
||||
IUntisCacheFetchStateRepository fetchState)
|
||||
{
|
||||
private static readonly TimeSpan HotWindowRefreshInterval = TimeSpan.FromHours(1);
|
||||
private const int HotWindowDays = 14;
|
||||
|
||||
/// Roster hat anders als Fehlzeiten/Klassenbuch keine Historie (nur "aktueller Stand einer
|
||||
/// Klasse") - deshalb ohne heißes/kaltes Fenster, nur "gilt der letzte Abruf noch als frisch".
|
||||
/// Behebt, dass die Klassenlehrer-Übersicht bei jedem Öffnen spürbar verzögert wirkte: der
|
||||
/// Roster-Abruf lief bislang komplett am Cache vorbei live gegen WebUntis (Nutzer-Feedback).
|
||||
public async Task<IReadOnlyList<UntisStudentRosterCacheEntry>> GetStudentRosterAsync(string className,
|
||||
bool forceRefresh = false, CancellationToken token = default)
|
||||
{
|
||||
var state = fetchState.Get(className, UntisCacheKind.Roster);
|
||||
if (NeedsRefresh(state, forceRefresh, DateTime.UtcNow))
|
||||
{
|
||||
var fresh = await untis.GetStudentsAsync(className, token);
|
||||
rosterCache.ReplaceAll(className, fresh.Students.Select(s => new UntisStudentRosterCacheEntry
|
||||
{
|
||||
ClassName = className, ExternKey = s.ExternKey, DisplayName = s.DisplayName,
|
||||
}));
|
||||
fetchState.Save(new UntisCacheFetchState
|
||||
{
|
||||
Id = state?.Id ?? Guid.NewGuid(), ClassName = className, Kind = UntisCacheKind.Roster,
|
||||
HotWindowFetchedAt = DateTime.UtcNow,
|
||||
});
|
||||
}
|
||||
return rosterCache.GetByClass(className);
|
||||
}
|
||||
|
||||
private static bool NeedsRefresh(UntisCacheFetchState? state, bool forceRefresh, DateTime utcNow) =>
|
||||
forceRefresh || state?.HotWindowFetchedAt is null || utcNow - state.HotWindowFetchedAt >= HotWindowRefreshInterval;
|
||||
|
||||
public async Task<IReadOnlyList<UntisClassAbsenceEntryDto>> GetAbsencesAsync(string className,
|
||||
DateOnly start, DateOnly end, bool forceRefresh = false, CancellationToken token = default)
|
||||
{
|
||||
var state = fetchState.Get(className, UntisCacheKind.Absences);
|
||||
var plan = Plan(state, start, end, Today(), forceRefresh, DateTime.UtcNow);
|
||||
|
||||
if (plan.RefreshHotWindow)
|
||||
{
|
||||
var fresh = await untis.GetClassAbsencesAsync(className, plan.HotWindowStart, plan.HotWindowEnd, token);
|
||||
absenceCache.ReplaceRange(className, Int(plan.HotWindowStart), Int(plan.HotWindowEnd), fresh.Select(ToEntry));
|
||||
}
|
||||
if (plan.FetchColdRange)
|
||||
{
|
||||
var older = await untis.GetClassAbsencesAsync(className, plan.ColdRangeStart, plan.ColdRangeEnd, token);
|
||||
absenceCache.InsertRange(older.Select(ToEntry));
|
||||
}
|
||||
if (plan.RefreshHotWindow || plan.FetchColdRange)
|
||||
fetchState.Save(NextState(state, className, UntisCacheKind.Absences, plan));
|
||||
|
||||
return absenceCache.GetByClassAndRange(className, Int(start), Int(end)).Select(ToDto).ToList();
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<UntisForeignClassRegisterEventDto>> GetClassRegisterEventsAsync(
|
||||
string className, DateOnly start, DateOnly end, bool forceRefresh = false, CancellationToken token = default)
|
||||
{
|
||||
var state = fetchState.Get(className, UntisCacheKind.ClassRegister);
|
||||
var plan = Plan(state, start, end, Today(), forceRefresh, DateTime.UtcNow);
|
||||
|
||||
if (plan.RefreshHotWindow)
|
||||
{
|
||||
var fresh = await untis.GetForeignClassRegisterEventsAsync(className, plan.HotWindowStart, plan.HotWindowEnd, token);
|
||||
classRegisterCache.ReplaceRange(className, Int(plan.HotWindowStart), Int(plan.HotWindowEnd), fresh.Select(ToEntry));
|
||||
}
|
||||
if (plan.FetchColdRange)
|
||||
{
|
||||
var older = await untis.GetForeignClassRegisterEventsAsync(className, plan.ColdRangeStart, plan.ColdRangeEnd, token);
|
||||
classRegisterCache.InsertRange(older.Select(ToEntry));
|
||||
}
|
||||
if (plan.RefreshHotWindow || plan.FetchColdRange)
|
||||
fetchState.Save(NextState(state, className, UntisCacheKind.ClassRegister, plan));
|
||||
|
||||
return classRegisterCache.GetByClassAndRange(className, Int(start), Int(end)).Select(ToDto).ToList();
|
||||
}
|
||||
|
||||
/// Reine Entscheidungslogik ohne Repository-/HTTP-Zugriff: was muss für eine Anfrage
|
||||
/// [<paramref name="requestStart"/>..<paramref name="requestEnd"/>] live nachgeladen werden,
|
||||
/// gegeben den zuletzt gespeicherten Zustand. Das heiße Fenster ist immer fest
|
||||
/// [<paramref name="today"/> − <see cref="HotWindowDays"/>, <paramref name="today"/>], unabhängig
|
||||
/// von der angefragten Spanne. Nach einem heißen Refresh reicht die bekannte Abdeckung mindestens
|
||||
/// bis zum Fensteranfang zurück; ein zusätzlicher kalter Abruf erweitert sie nur so weit wie
|
||||
/// für <paramref name="requestStart"/> nötig, nie weiter.
|
||||
public static UntisCacheRefreshPlan Plan(UntisCacheFetchState? state, DateOnly requestStart, DateOnly requestEnd,
|
||||
DateOnly today, bool forceRefresh, DateTime utcNow)
|
||||
{
|
||||
var hotStart = today.AddDays(-HotWindowDays);
|
||||
var refreshHot = requestEnd >= hotStart && (forceRefresh || state?.HotWindowFetchedAt is null
|
||||
|| utcNow - state.HotWindowFetchedAt >= HotWindowRefreshInterval);
|
||||
|
||||
int? coverageStart = state?.ColdCoverageStartDate;
|
||||
if (refreshHot)
|
||||
coverageStart = coverageStart is { } existing ? Math.Min(existing, Int(hotStart)) : Int(hotStart);
|
||||
|
||||
var coldEnd = coverageStart is { } covered ? FromInt(covered).AddDays(-1) : hotStart.AddDays(-1);
|
||||
var fetchCold = (coverageStart is null || requestStart < FromInt(coverageStart.Value)) && requestStart <= coldEnd;
|
||||
if (fetchCold) coverageStart = Int(requestStart);
|
||||
|
||||
return new UntisCacheRefreshPlan(refreshHot, hotStart, today, fetchCold, requestStart, coldEnd, coverageStart);
|
||||
}
|
||||
|
||||
private static UntisCacheFetchState NextState(UntisCacheFetchState? previous, string className,
|
||||
UntisCacheKind kind, UntisCacheRefreshPlan plan) => new()
|
||||
{
|
||||
Id = previous?.Id ?? Guid.NewGuid(),
|
||||
ClassName = className,
|
||||
Kind = kind,
|
||||
HotWindowFetchedAt = plan.RefreshHotWindow ? DateTime.UtcNow : previous?.HotWindowFetchedAt,
|
||||
ColdCoverageStartDate = plan.ResultingColdCoverageStartDate,
|
||||
};
|
||||
|
||||
private static DateOnly Today() => DateOnly.FromDateTime(DateTime.Today);
|
||||
private static int Int(DateOnly date) => date.Year * 10000 + date.Month * 100 + date.Day;
|
||||
private static DateOnly FromInt(int value) => new(value / 10000, value / 100 % 100, value % 100);
|
||||
|
||||
private static UntisAbsenceCacheEntry ToEntry(UntisClassAbsenceEntryDto x) => new()
|
||||
{
|
||||
ClassName = x.ClassName, StudentName = x.StudentName, ExternKey = x.ExternKey, Date = x.Date,
|
||||
AbsentPeriods = x.AbsentPeriods, AbsentMinutes = x.AbsentMinutes, TeacherUsernames = x.TeacherUsernames,
|
||||
Subject = x.Subject, AbsenceReason = x.AbsenceReason, Note = x.Note, EntryId = x.EntryId,
|
||||
HandledOn = x.HandledOn, Counts = x.Counts, ExcuseNote = x.ExcuseNote, PeriodNumber = x.PeriodNumber,
|
||||
Status = x.Status, CountsAsFullDay = x.CountsAsFullDay,
|
||||
};
|
||||
|
||||
private static UntisClassAbsenceEntryDto ToDto(UntisAbsenceCacheEntry e) => new(
|
||||
e.StudentName, e.ExternKey, e.ClassName, e.Date, e.AbsentPeriods, e.AbsentMinutes, e.TeacherUsernames,
|
||||
e.Subject, e.AbsenceReason, e.Note, e.EntryId, e.HandledOn, e.Counts, e.ExcuseNote, e.PeriodNumber,
|
||||
e.Status, e.CountsAsFullDay);
|
||||
|
||||
private static UntisClassRegisterCacheEntry ToEntry(UntisForeignClassRegisterEventDto x) => new()
|
||||
{
|
||||
ClassName = x.ClassName, Date = x.Date, Subject = x.Subject, StudentName = x.StudentName,
|
||||
TeacherUsername = x.TeacherUsername, CategoryName = x.CategoryName, CategoryGroup = x.CategoryGroup,
|
||||
Text = x.Text,
|
||||
};
|
||||
|
||||
private static UntisForeignClassRegisterEventDto ToDto(UntisClassRegisterCacheEntry e) => new(
|
||||
e.ClassName, e.Date, e.Subject, e.StudentName, e.TeacherUsername, e.CategoryName, e.CategoryGroup, e.Text);
|
||||
}
|
||||
@@ -22,16 +22,22 @@ public sealed record UntisTimetablePeriodDto(int Id, int Date, int StartTime, in
|
||||
IReadOnlyList<UntisEntityDto> Subjects, IReadOnlyList<UntisEntityDto> Rooms);
|
||||
public sealed record UntisStudentAddressDto(string? Email, string? Mobile, string? Phone, string? City,
|
||||
string? PostCode, string? Street);
|
||||
public sealed record UntisStudentDto(int UntisId, int ExternKey, string ClassName, string? Name, string? LongName,
|
||||
public sealed record UntisStudentDto(int UntisId, int? ExternKey, string ClassName, string? Name, string? LongName,
|
||||
string? ForeName, string DisplayName, string? Gender, int? BirthDate, string? BirthDateRaw, int? EntryDate,
|
||||
string? EntryDateRaw, int? ExitDate, string? ExitDateRaw, string? Text, string? MedicalReportDuty,
|
||||
string? Schulpflicht, string? Majority, UntisStudentAddressDto Address, string? AttributeIL);
|
||||
public sealed record UntisStudentReportDto(int Count, string? ClassNameFilter, IReadOnlyList<UntisStudentDto> Students);
|
||||
public sealed record UntisStudentAbsenceDto(int StudentKey, int Date, int StartTime, int EndTime, int AbsentMinutes,
|
||||
bool Checked, string? AbsenceReason, string? ExcuseStatus, int? SubjectId, IReadOnlyList<int> TeacherIds,
|
||||
string? StudentGroup);
|
||||
public sealed record UntisStudentAbsenceReportDto(int StudentKey, int StartDate, int EndDate, int EntryCount,
|
||||
int AbsentMinutes, IReadOnlyList<UntisStudentAbsenceDto> Absences);
|
||||
public sealed record UntisLessonAbsenceDto(string StudentName, int Date, int AbsentPeriods,
|
||||
int UnexcusedAbsentPeriods, int AbsentMinutes, int UnexcusedAbsentMinutes, int? StartTime, int? EndTime,
|
||||
string? Reason, int? ExternKey, bool ExternKeyInParentheses, string? HandledOn, bool Counts);
|
||||
public sealed record UntisClassRegisterEventDto(string ClassName, int Date, string? Subject,
|
||||
string StudentName, string? CategoryName, string? CategoryGroup, string? Text);
|
||||
public sealed record UntisForeignClassRegisterEventDto(string ClassName, int Date, string? Subject,
|
||||
string StudentName, string? TeacherUsername, string? CategoryName, string? CategoryGroup, string? Text);
|
||||
public sealed record UntisClassAbsenceEntryDto(string StudentName, int? ExternKey, string ClassName, int Date,
|
||||
int AbsentPeriods, int AbsentMinutes, string? TeacherUsernames, string? Subject, string? AbsenceReason,
|
||||
string? Note, int? EntryId, string? HandledOn, bool Counts, string? ExcuseNote, int? PeriodNumber,
|
||||
string? Status, bool CountsAsFullDay);
|
||||
|
||||
/// <summary>Direkter WebUntis-Client des Desktops. Personenbezogene Antworten und der
|
||||
/// unverschlüsselte CSV-Report passieren zu keinem Zeitpunkt den LehrerApp-Server.</summary>
|
||||
@@ -56,19 +62,19 @@ public sealed class WebUntisIntegrationService(HttpClient http, WebUntisSettings
|
||||
}
|
||||
|
||||
WebUntisClient? previous;
|
||||
await _clientGate.WaitAsync(token);
|
||||
await _clientGate.WaitAsync(token).ConfigureAwait(false);
|
||||
try { previous = _client; _client = candidate; }
|
||||
finally { _clientGate.Release(); }
|
||||
if (previous is not null) await previous.DisposeAsync();
|
||||
if (previous is not null) await previous.DisposeAsync().ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task DisconnectAsync(CancellationToken token = default)
|
||||
{
|
||||
WebUntisClient? previous;
|
||||
await _clientGate.WaitAsync(token);
|
||||
await _clientGate.WaitAsync(token).ConfigureAwait(false);
|
||||
try { previous = _client; _client = null; }
|
||||
finally { _clientGate.Release(); }
|
||||
if (previous is not null) await previous.DisposeAsync();
|
||||
if (previous is not null) await previous.DisposeAsync().ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<UntisSchoolYearDto>> GetSchoolYearsAsync(CancellationToken token = default) => ExecuteAsync(
|
||||
@@ -109,16 +115,91 @@ public sealed class WebUntisIntegrationService(HttpClient http, WebUntisSettings
|
||||
x.Address.PostCode, x.Address.Street), x.AttributeIL)).ToList());
|
||||
}, token);
|
||||
|
||||
public Task<UntisStudentAbsenceReportDto> GetAbsencesAsync(int studentKey, DateOnly start, DateOnly end,
|
||||
CancellationToken token = default) => ExecuteAsync(async client =>
|
||||
public Task<IReadOnlyList<UntisLessonAbsenceDto>> GetLessonAbsencesAsync(int lessonId,
|
||||
DateOnly start, DateOnly end, CancellationToken token = default) => ExecuteAsync(async client =>
|
||||
{
|
||||
var report = await client.GetStudentAbsencesAsync(studentKey, Date(start), Date(end), token);
|
||||
return new UntisStudentAbsenceReportDto(report.StudentKey, report.StartDate, report.EndDate,
|
||||
report.EntryCount, report.AbsentMinutes, report.Absences.Select(x => new UntisStudentAbsenceDto(
|
||||
x.StudentKey, x.Date, x.StartTime, x.EndTime, x.AbsentMinutes, x.Checked, x.AbsenceReason,
|
||||
x.ExcuseStatus, x.SubjectId, x.TeacherIds, x.StudentGroup)).ToList());
|
||||
var absences = await client.GetLessonAbsencesAsync(lessonId, Date(start), Date(end), token);
|
||||
return (IReadOnlyList<UntisLessonAbsenceDto>)absences.Select(x => new UntisLessonAbsenceDto(
|
||||
x.StudentName, x.Date, x.AbsentPeriods, x.UnexcusedAbsentPeriods, x.AbsentMinutes,
|
||||
x.UnexcusedAbsentMinutes, x.StartTime, x.EndTime, x.Reason, x.ExternKey, x.ExternKeyInParentheses,
|
||||
x.HandledOn, x.Counts)).ToList();
|
||||
}, token);
|
||||
|
||||
// "-alle-"-Bericht, hier auf eigene Einträge gefiltert (Benutzer == eigener WebUntis-Login) - Einträge
|
||||
// anderer Lehrkräfte zu Schülern der eigenen Klasse gehören zu einem eigenständigen, noch nicht
|
||||
// gebauten "Klassenlehrer"-Feature (siehe TODO.md), nicht zum reinen Dokumentations-Abgleich.
|
||||
public Task<IReadOnlyList<UntisClassRegisterEventDto>> GetOwnClassRegisterEventsAsync(DateOnly start,
|
||||
DateOnly end, CancellationToken token = default) => ExecuteAsync(async client =>
|
||||
{
|
||||
var ownUsername = settings.GetApiCredentials()?.Username;
|
||||
var entries = await client.GetClassRegisterEventsReportAsync(Date(start), Date(end), token);
|
||||
return (IReadOnlyList<UntisClassRegisterEventDto>)entries
|
||||
.Where(x => ownUsername is not null
|
||||
&& string.Equals(x.TeacherUsername, ownUsername, StringComparison.OrdinalIgnoreCase))
|
||||
.Select(x => new UntisClassRegisterEventDto(x.ClassName, x.Date, x.Subject, x.StudentName,
|
||||
x.CategoryName, x.CategoryGroup, x.Text))
|
||||
.ToList();
|
||||
}, token);
|
||||
|
||||
// Spiegelbild von GetOwnClassRegisterEventsAsync für das "Klassenlehrer"-Feature (siehe TODO.md):
|
||||
// dieselben "-alle-"-Rohdaten, aber Einträge anderer Lehrkräfte statt der eigenen, zusätzlich auf
|
||||
// die übergebene Klasse eingeschränkt (der "-alle-"-Bericht liefert sonst auch andere Klassen mit,
|
||||
// sobald eine Lehrkraft mehrere Klassen unterrichtet).
|
||||
public Task<IReadOnlyList<UntisForeignClassRegisterEventDto>> GetForeignClassRegisterEventsAsync(
|
||||
string className, DateOnly start, DateOnly end, CancellationToken token = default) => ExecuteAsync(async client =>
|
||||
{
|
||||
var ownUsername = settings.GetApiCredentials()?.Username;
|
||||
var entries = await client.GetClassRegisterEventsReportAsync(Date(start), Date(end), token);
|
||||
return (IReadOnlyList<UntisForeignClassRegisterEventDto>)entries
|
||||
.Where(x => string.Equals(x.ClassName, className, StringComparison.OrdinalIgnoreCase)
|
||||
// Nicht einfach "!=" gegen ownUsername: wäre ownUsername unbekannt (null), würde
|
||||
// ein ebenfalls fehlender TeacherUsername sonst fälschlich als "eigen" erkannt
|
||||
// (Equals(null,null)==true) und rausgefiltert statt als fremd angezeigt zu werden.
|
||||
&& !(ownUsername is not null
|
||||
&& string.Equals(x.TeacherUsername, ownUsername, StringComparison.OrdinalIgnoreCase)))
|
||||
.Select(x => new UntisForeignClassRegisterEventDto(x.ClassName, x.Date, x.Subject, x.StudentName,
|
||||
x.TeacherUsername, x.CategoryName, x.CategoryGroup, x.Text))
|
||||
.ToList();
|
||||
}, token);
|
||||
|
||||
// Fehlzeiten der ganzen Klasse über alle Fächer, Ergänzung zu GetForeignClassRegisterEventsAsync
|
||||
// für das "Klassenlehrer"-Feature. Der Bericht adressiert die Klasse über "KL"+getKlassen-ID statt
|
||||
// eines Namens, siehe ResolveClassIdAsync.
|
||||
public Task<IReadOnlyList<UntisClassAbsenceEntryDto>> GetClassAbsencesAsync(string className,
|
||||
DateOnly start, DateOnly end, CancellationToken token = default) => ExecuteAsync(async client =>
|
||||
{
|
||||
var classId = await ResolveClassIdAsync(client, className, token);
|
||||
var entries = await client.GetClassAbsencesAsync(classId, Date(start), Date(end), token);
|
||||
return (IReadOnlyList<UntisClassAbsenceEntryDto>)entries.Select(x => new UntisClassAbsenceEntryDto(
|
||||
x.StudentName, x.ExternKey, x.ClassName, x.Date, x.AbsentPeriods, x.AbsentMinutes,
|
||||
x.TeacherUsernames, x.Subject, x.AbsenceReason, x.Note, x.EntryId, x.HandledOn, x.Counts,
|
||||
x.ExcuseNote, x.PeriodNumber, x.Status, x.CountsAsFullDay))
|
||||
.ToList();
|
||||
}, token);
|
||||
|
||||
// "KL" + getKlassen-ID, dasselbe Element-Kürzel-Schema wie kl/te/su/ro bei anderen Berichten
|
||||
// (siehe Entities(...) unten). Unbestätigt (kein offizieller Report-Parameter dokumentiert), aber
|
||||
// aus einem echten Browser-Request rückgeschlossen - schlägt die Zuordnung fehl, liefert der
|
||||
// Bericht erkennbar leere/falsche Daten statt eines stillen Fehlers.
|
||||
private static async Task<string> ResolveClassIdAsync(WebUntisClient client, string className,
|
||||
CancellationToken token)
|
||||
{
|
||||
var years = await client.GetSchoolYearsAsync(token);
|
||||
var today = Date(DateOnly.FromDateTime(DateTime.Today));
|
||||
var year = years.FirstOrDefault(y => y.StartDate <= today && y.EndDate >= today)
|
||||
?? years.OrderByDescending(y => y.StartDate).FirstOrDefault()
|
||||
?? throw new WebUntisIntegrationException("WebUntis hat kein Schuljahr geliefert.");
|
||||
var classes = await client.GetClassesAsync(year.UntisId, token);
|
||||
var matches = classes.Where(c => string.Equals(c.Name, className, StringComparison.OrdinalIgnoreCase)).ToList();
|
||||
return matches.Count switch
|
||||
{
|
||||
1 => $"KL{matches[0].UntisId}",
|
||||
0 => throw new WebUntisIntegrationException($"WebUntis kennt keine Klasse namens \"{className}\"."),
|
||||
_ => throw new WebUntisIntegrationException(
|
||||
$"Mehrere WebUntis-Klassen heißen \"{className}\" - bitte manuell prüfen."),
|
||||
};
|
||||
}
|
||||
|
||||
private async Task<T> ExecuteAsync<T>(Func<WebUntisClient, Task<T>> operation, CancellationToken token)
|
||||
{
|
||||
try { return await operation(await GetClientAsync(token)); }
|
||||
@@ -153,5 +234,5 @@ public sealed class WebUntisIntegrationService(HttpClient http, WebUntisSettings
|
||||
private static WebUntisIntegrationException Translate(Exception exception) =>
|
||||
new(exception.Message);
|
||||
|
||||
public async ValueTask DisposeAsync() => await DisconnectAsync();
|
||||
public async ValueTask DisposeAsync() => await DisconnectAsync().ConfigureAwait(false);
|
||||
}
|
||||
|
||||
@@ -11,6 +11,8 @@ internal class WebUntisSettingsConfig
|
||||
public string LastSyncStatus { get; set; } = "";
|
||||
public string? EncryptedApiCredentials { get; set; }
|
||||
public int? TeacherUntisId { get; set; }
|
||||
public int? HomeroomClassUntisId { get; set; }
|
||||
public string? HomeroomClassName { get; set; }
|
||||
}
|
||||
|
||||
public sealed record WebUntisCredentials(string School, string Host, string Username, string Password);
|
||||
@@ -36,6 +38,8 @@ public class WebUntisSettingsService
|
||||
public bool IsConfigured => _config.EncryptedIcalUrl is not null;
|
||||
public bool ApiIsConfigured => _config.EncryptedApiCredentials is not null;
|
||||
public int? TeacherUntisId => _config.TeacherUntisId;
|
||||
public int? HomeroomClassUntisId => _config.HomeroomClassUntisId;
|
||||
public string? HomeroomClassName => _config.HomeroomClassName;
|
||||
public DateTime? LastSyncAt => _config.LastSyncAt;
|
||||
public string LastSyncStatus => _config.LastSyncStatus;
|
||||
|
||||
@@ -83,6 +87,8 @@ public class WebUntisSettingsService
|
||||
{
|
||||
_config.EncryptedApiCredentials = null;
|
||||
_config.TeacherUntisId = null;
|
||||
_config.HomeroomClassUntisId = null;
|
||||
_config.HomeroomClassName = null;
|
||||
Save();
|
||||
}
|
||||
|
||||
@@ -92,6 +98,16 @@ public class WebUntisSettingsService
|
||||
Save();
|
||||
}
|
||||
|
||||
// Die Klasse, deren Klassenlehrer/-in man ist - unabhängig von jeder LearningGroup (siehe
|
||||
// TODO.md "Klassenlehrer"-Feature): man ist es für die ganze Klasse, nicht für einen einzelnen
|
||||
// Unterricht/Kurs. HomeroomClassName ist wie TeacherUntisId unverschlüsselt, da kein Geheimnis.
|
||||
public void SetHomeroomClass(int? untisId, string? name)
|
||||
{
|
||||
_config.HomeroomClassUntisId = untisId;
|
||||
_config.HomeroomClassName = name;
|
||||
Save();
|
||||
}
|
||||
|
||||
public void SetLastSync(DateTime at, string status)
|
||||
{
|
||||
_config.LastSyncAt = at;
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
<ResourceDictionary xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
|
||||
<!--
|
||||
Semantische Pinsel für Flächen und Statusfarben (Klassenlehrer-Bereich, perspektivisch auch
|
||||
die übrigen Ansichten). Hart codierte Hex-Werte in den Views waren auf das dunkle Theme
|
||||
zugeschnitten und blieben beim Umschalten auf "Hell" stehen — dunkle Kartenflächen auf hellem
|
||||
Fenstergrund, Statustexte ohne Kontrast. Über ThemeDictionaries tauscht Avalonia die Werte beim
|
||||
Theme-Wechsel live aus, sofern die Views sie per DynamicResource (nicht StaticResource) holen.
|
||||
-->
|
||||
<ResourceDictionary.ThemeDictionaries>
|
||||
|
||||
<ResourceDictionary x:Key="Dark">
|
||||
<!-- Flächen -->
|
||||
<SolidColorBrush x:Key="AppCardBackgroundBrush" Color="#11151B"/>
|
||||
<SolidColorBrush x:Key="AppCardBorderBrush" Color="#2A3038"/>
|
||||
<SolidColorBrush x:Key="AppListRowBackgroundBrush" Color="#0E1217"/>
|
||||
<SolidColorBrush x:Key="AppListRowHoverBrush" Color="#171D25"/>
|
||||
<SolidColorBrush x:Key="AppListRowBorderBrush" Color="#262C34"/>
|
||||
<SolidColorBrush x:Key="AppTrackBackgroundBrush" Color="#252B33"/>
|
||||
<SolidColorBrush x:Key="AppChipBackgroundBrush" Color="#1C222A"/>
|
||||
<SolidColorBrush x:Key="AppAvatarBorderBrush" Color="#4A515C"/>
|
||||
<!-- Akzent -->
|
||||
<SolidColorBrush x:Key="AppAccentTextBrush" Color="#5795F5"/>
|
||||
<SolidColorBrush x:Key="AppAccentSoftBackgroundBrush" Color="#173765"/>
|
||||
<SolidColorBrush x:Key="AppAccentOnSoftBrush" Color="#66A3FF"/>
|
||||
<SolidColorBrush x:Key="AppFilterBorderBrush" Color="#343B45"/>
|
||||
<SolidColorBrush x:Key="AppFilterActiveBackgroundBrush" Color="#173765"/>
|
||||
<SolidColorBrush x:Key="AppFilterActiveBorderBrush" Color="#3C86F7"/>
|
||||
<SolidColorBrush x:Key="AppFilterActiveForegroundBrush" Color="#FFFFFF"/>
|
||||
<!-- Status: Nutzer-Feedback (August 2026) — Warnung (Amber) und Gefahr (Korallrot) waren
|
||||
auf dem Bildschirm kaum auseinanderzuhalten. Per scripts/validate_palette.js der
|
||||
dataviz-Skill gegen AppTrackBackgroundBrush/AppCardBackgroundBrush geprüft (adjacent
|
||||
pairs, wie sie im gestapelten Balken tatsächlich aneinanderstoßen): die alte Kombi
|
||||
lag bei ΔE nur 4.6–10.8 (protan/deutan), jetzt 10.2–24.4 — deutlich über der
|
||||
Zielschwelle von 8. Ok/Warnung mussten zusätzlich im Tagesüberblick-Balken die
|
||||
Reihenfolge tauschen (Info dazwischen), weil Grün und das validierte Dunkel-Gelb sonst
|
||||
direkt aneinanderstießen (ΔE nur 3.0 unter Protanopie) — siehe
|
||||
ClassTeacherOverviewView.axaml.
|
||||
good=#008300, warning(dunkel)=#c98500, warning(hell)=#eda100, critical=#d03b3b
|
||||
sind dieselben Stufen wie die validierte Kategorial-/Statuspalette der dataviz-Skill;
|
||||
bewusst nicht wieder frei gewählt, um nicht erneut ins selbe Problem zu laufen. -->
|
||||
<SolidColorBrush x:Key="AppStatusOkBrush" Color="#008300"/>
|
||||
<SolidColorBrush x:Key="AppStatusInfoBrush" Color="#3987E5"/>
|
||||
<SolidColorBrush x:Key="AppStatusWarningBrush" Color="#C98500"/>
|
||||
<SolidColorBrush x:Key="AppStatusDangerBrush" Color="#D03B3B"/>
|
||||
</ResourceDictionary>
|
||||
|
||||
<ResourceDictionary x:Key="Light">
|
||||
<!-- Flächen: Karten heben sich hier heller vom grauen Fenstergrund ab, nicht dunkler. -->
|
||||
<SolidColorBrush x:Key="AppCardBackgroundBrush" Color="#FFFFFF"/>
|
||||
<SolidColorBrush x:Key="AppCardBorderBrush" Color="#DCE1E8"/>
|
||||
<SolidColorBrush x:Key="AppListRowBackgroundBrush" Color="#FFFFFF"/>
|
||||
<SolidColorBrush x:Key="AppListRowHoverBrush" Color="#EDF2F9"/>
|
||||
<SolidColorBrush x:Key="AppListRowBorderBrush" Color="#E3E7ED"/>
|
||||
<SolidColorBrush x:Key="AppTrackBackgroundBrush" Color="#E3E7ED"/>
|
||||
<SolidColorBrush x:Key="AppChipBackgroundBrush" Color="#EDF0F5"/>
|
||||
<SolidColorBrush x:Key="AppAvatarBorderBrush" Color="#B7BEC9"/>
|
||||
<!-- Akzent: dunklere Töne, die Akzentfarben aus dem Dark-Theme wären auf Weiß zu blass. -->
|
||||
<SolidColorBrush x:Key="AppAccentTextBrush" Color="#1B5FC7"/>
|
||||
<SolidColorBrush x:Key="AppAccentSoftBackgroundBrush" Color="#DCE9FC"/>
|
||||
<SolidColorBrush x:Key="AppAccentOnSoftBrush" Color="#14509F"/>
|
||||
<SolidColorBrush x:Key="AppFilterBorderBrush" Color="#C7CDD6"/>
|
||||
<SolidColorBrush x:Key="AppFilterActiveBackgroundBrush" Color="#D7E6FC"/>
|
||||
<SolidColorBrush x:Key="AppFilterActiveBorderBrush" Color="#2C6FD8"/>
|
||||
<SolidColorBrush x:Key="AppFilterActiveForegroundBrush" Color="#10243F"/>
|
||||
<!-- Status: siehe Dark-Kommentar oben — dieselbe Validierung, helle Stufen. -->
|
||||
<SolidColorBrush x:Key="AppStatusOkBrush" Color="#008300"/>
|
||||
<SolidColorBrush x:Key="AppStatusInfoBrush" Color="#2A78D6"/>
|
||||
<SolidColorBrush x:Key="AppStatusWarningBrush" Color="#EDA100"/>
|
||||
<SolidColorBrush x:Key="AppStatusDangerBrush" Color="#D03B3B"/>
|
||||
</ResourceDictionary>
|
||||
|
||||
</ResourceDictionary.ThemeDictionaries>
|
||||
</ResourceDictionary>
|
||||
@@ -0,0 +1,252 @@
|
||||
using System.Collections.ObjectModel;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using LehrerApp.Desktop.Services;
|
||||
|
||||
namespace LehrerApp.Desktop.ViewModels.ClassTeacher;
|
||||
|
||||
/// <summary>Eine Zeile aus dem "-alle-"-Klassenbuchbericht, die eine andere Lehrkraft angelegt hat.
|
||||
/// Rein zur Ansicht - anders als beim Dokumentations-Abgleich (eigene Einträge) gibt es hier kein
|
||||
/// "Übernehmen", da es keine eigenen Einträge zum lokalen Synchronisieren sind.</summary>
|
||||
public sealed record ClassTeacherClassRegisterRow(DateOnly Date, string? Subject, string StudentName,
|
||||
string? TeacherUsername, string? CategoryName, string? CategoryGroup, string? Text)
|
||||
{
|
||||
public string DateLabel => Date.ToString("dd.MM.yyyy");
|
||||
public string StudentDisplayName => DisplayName(StudentName);
|
||||
public ClassTeacherStatusKind CategoryKind =>
|
||||
CategoryGroup?.Contains("Negativ", StringComparison.OrdinalIgnoreCase) == true
|
||||
? ClassTeacherStatusKind.Danger : ClassTeacherStatusKind.Info;
|
||||
public bool IsDangerStatus => CategoryKind == ClassTeacherStatusKind.Danger;
|
||||
public bool IsInfoStatus => CategoryKind == ClassTeacherStatusKind.Info;
|
||||
|
||||
private static string DisplayName(string value)
|
||||
{
|
||||
var parts = value.Split(' ', StringSplitOptions.RemoveEmptyEntries);
|
||||
return parts.Length < 2 ? value : string.Join(" ", parts.Skip(1).Append(parts[0]));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Zusammenfassung "wie oft welche Klassenbuch-Kategorie im Zeitraum, und bei wem am
|
||||
/// häufigsten" — Nutzer-Feedback: die reine Gesamtzahl ("12 Klassenbucheinträge") sagt wenig,
|
||||
/// eine Aufschlüsselung nach Kategorie ist die eigentlich interessante Information ("Hausaufgaben
|
||||
/// fehlen: 12×, davon 5× Ben"). Rein clientseitig aus den bereits geladenen <see cref="Entries"/>
|
||||
/// berechnet, kein zusätzlicher WebUntis-Abruf.</summary>
|
||||
public sealed record ClassTeacherCategoryAggregateRow(string CategoryName, int Count, string TopStudentsLabel)
|
||||
{
|
||||
public string SummaryLabel => string.IsNullOrEmpty(TopStudentsLabel)
|
||||
? $"{Count}×" : $"{Count}× — {TopStudentsLabel}";
|
||||
|
||||
public static IReadOnlyList<ClassTeacherCategoryAggregateRow> Build(
|
||||
IReadOnlyList<ClassTeacherClassRegisterRow> entries, int topStudentsPerCategory = 2) =>
|
||||
entries.Where(e => !string.IsNullOrWhiteSpace(e.CategoryName))
|
||||
.GroupBy(e => e.CategoryName!)
|
||||
.Select(g =>
|
||||
{
|
||||
var topStudents = g.GroupBy(e => e.StudentDisplayName)
|
||||
.Select(sg => (Name: sg.Key, Count: sg.Count()))
|
||||
.OrderByDescending(sg => sg.Count).ThenBy(sg => sg.Name)
|
||||
.Take(topStudentsPerCategory)
|
||||
.Select(sg => $"{sg.Count}× {sg.Name}");
|
||||
return new ClassTeacherCategoryAggregateRow(g.Key, g.Count(), string.Join(", ", topStudents));
|
||||
})
|
||||
.OrderByDescending(r => r.Count).ThenBy(r => r.CategoryName)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
/// <summary>Fehlzeiten eines/einer Schüler*in an einem Tag, über alle Fächer zusammengefasst -
|
||||
/// der WebUntis-Bericht liefert eine Zeile pro Fehlstunde, was bei einer ganzen Klasse über mehrere
|
||||
/// Wochen zu unübersichtlich wäre (Nutzer-Feedback: kompakte "auf einen Blick"-Übersicht statt
|
||||
/// Rohdaten). Die Rohzeilen (<see cref="UntisClassAbsenceEntryDto"/>) bleiben trotzdem erreichbar,
|
||||
/// falls später ein Detail-Drill-down pro Schüler*in dazukommt (siehe TODO.md).</summary>
|
||||
public sealed record ClassAbsenceDaySummaryRow(DateOnly Date, string StudentName, int? ExternKey,
|
||||
int TotalAbsentPeriods, int TotalAbsentMinutes, IReadOnlyList<string> Subjects,
|
||||
IReadOnlyList<int> PeriodNumbers, IReadOnlyList<string> Statuses, IReadOnlyList<string> AbsenceReasons,
|
||||
string? Note, string? ExcuseNote, bool CountsAsFullDay)
|
||||
{
|
||||
public string DateLabel => Date.ToString("dd.MM.yyyy");
|
||||
public string StudentDisplayName
|
||||
{
|
||||
get
|
||||
{
|
||||
var parts = StudentName.Split(' ', StringSplitOptions.RemoveEmptyEntries);
|
||||
return parts.Length < 2 ? StudentName : string.Join(" ", parts.Skip(1).Append(parts[0]));
|
||||
}
|
||||
}
|
||||
public string SubjectsLabel => string.Join(", ", Subjects);
|
||||
public string PeriodsLabel => string.Join(", ", PeriodNumbers.Order());
|
||||
public string StatusLabel => string.Join(", ", Statuses);
|
||||
public string ReasonLabel => string.Join("; ", AbsenceReasons);
|
||||
public bool IsLate => TotalAbsentPeriods == 0 || AbsenceReasons.Any(r =>
|
||||
r.Contains("verspät", StringComparison.OrdinalIgnoreCase));
|
||||
public bool IsUnexcused => Statuses.Any(s =>
|
||||
s.Contains("nicht entsch", StringComparison.OrdinalIgnoreCase));
|
||||
public string KindLabel => IsLate ? $"{TotalAbsentMinutes} Min. verspätet" :
|
||||
CountsAsFullDay ? "Ganzer Fehltag" : $"{TotalAbsentPeriods} Fehlstunde{(TotalAbsentPeriods == 1 ? "" : "n")}";
|
||||
public string FriendlyStatusLabel => IsUnexcused ? "Unentschuldigt" :
|
||||
Statuses.Any(s => s.Contains("entsch", StringComparison.OrdinalIgnoreCase)) ? "Entschuldigt" : StatusLabel;
|
||||
public ClassTeacherStatusKind StatusKind => IsUnexcused ? ClassTeacherStatusKind.Danger :
|
||||
IsLate ? ClassTeacherStatusKind.Warning : ClassTeacherStatusKind.Ok;
|
||||
public bool IsDangerStatus => StatusKind == ClassTeacherStatusKind.Danger;
|
||||
public bool IsWarningStatus => StatusKind == ClassTeacherStatusKind.Warning;
|
||||
public string DetailLabel => string.Join(" · ", new[] { ReasonLabel, Note, ExcuseNote }
|
||||
.Where(value => !string.IsNullOrWhiteSpace(value)));
|
||||
|
||||
public static IReadOnlyList<ClassAbsenceDaySummaryRow> GroupByStudentAndDay(
|
||||
IEnumerable<UntisClassAbsenceEntryDto> entries) => entries
|
||||
.Select(e => (Entry: e, Date: TryDate(e.Date, out var d) ? d : (DateOnly?)null))
|
||||
.Where(x => x.Date is not null)
|
||||
.GroupBy(x => (Date: x.Date!.Value, x.Entry.StudentName, x.Entry.ExternKey))
|
||||
.Select(g => new ClassAbsenceDaySummaryRow(
|
||||
g.Key.Date, g.Key.StudentName, g.Key.ExternKey,
|
||||
g.Sum(x => x.Entry.AbsentPeriods), g.Sum(x => x.Entry.AbsentMinutes),
|
||||
Distinct(g.Select(x => x.Entry.Subject)),
|
||||
g.Select(x => x.Entry.PeriodNumber).Where(p => p is not null).Select(p => p!.Value).Distinct().ToList(),
|
||||
Distinct(g.Select(x => x.Entry.Status)),
|
||||
Distinct(g.Select(x => x.Entry.AbsenceReason)),
|
||||
g.Select(x => x.Entry.Note).FirstOrDefault(n => !string.IsNullOrWhiteSpace(n)),
|
||||
g.Select(x => x.Entry.ExcuseNote).FirstOrDefault(n => !string.IsNullOrWhiteSpace(n)),
|
||||
g.Any(x => x.Entry.CountsAsFullDay)))
|
||||
.OrderByDescending(r => r.Date).ThenBy(r => r.StudentName)
|
||||
.ToList();
|
||||
|
||||
private static List<string> Distinct(IEnumerable<string?> values) =>
|
||||
values.Where(v => !string.IsNullOrWhiteSpace(v)).Select(v => v!).Distinct().ToList();
|
||||
|
||||
private static bool TryDate(int value, out DateOnly date) =>
|
||||
DateOnly.TryParseExact(value.ToString(), "yyyyMMdd", out date);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Details-Ansicht des Klassenlehrer-Bereichs: Klassenbucheinträge, die andere Lehrkräfte zu
|
||||
/// Schülern der Klasse angelegt haben (WebUntis "-alle-"-Bericht, gefiltert auf fremde statt der
|
||||
/// eigenen Benutzerkennung wie beim bestehenden Dokumentations-Abgleich), sowie die Fehlzeiten der
|
||||
/// Klasse über alle Fächer — beide über <see cref="UntisReportCacheService"/> (lokal gecacht statt
|
||||
/// bei jedem Öffnen neu abgerufen, siehe TODO.md). Beides rein zur Ansicht, kein Übernehmen in
|
||||
/// lokale Daten. Bewusst unabhängig von jeder <see cref="Core.Models.LearningGroup"/> —
|
||||
/// Klassenlehrer ist man für die ganze Klasse, nicht für einen einzelnen Unterricht; die Klasse
|
||||
/// kommt aus <see cref="ClassTeacherOverviewViewModel"/> (letztlich aus den WebUntis-Einstellungen),
|
||||
/// nicht aus einer Gruppen-ID.
|
||||
/// </summary>
|
||||
public partial class ClassTeacherDetailsViewModel : ObservableObject
|
||||
{
|
||||
private readonly UntisReportCacheService _cache;
|
||||
private string _className = "";
|
||||
|
||||
public ObservableCollection<ClassTeacherClassRegisterRow> Entries { get; } = [];
|
||||
public ObservableCollection<ClassAbsenceDaySummaryRow> AbsenceEntries { get; } = [];
|
||||
public ObservableCollection<ClassTeacherCategoryAggregateRow> CategoryAggregates { get; } = [];
|
||||
|
||||
[ObservableProperty] private DateTimeOffset _startDate = DateTimeOffset.Now.AddDays(-6);
|
||||
[ObservableProperty] private DateTimeOffset _endDate = DateTimeOffset.Now;
|
||||
[ObservableProperty] private string _status = "Zeitraum wählen und laden.";
|
||||
[ObservableProperty] private bool _busy;
|
||||
/// Von der Übersicht gesetzt (Klick auf eine Roster-Zeile) - leer zeigt alle Schüler*innen.
|
||||
[ObservableProperty] private string _studentFilter = "";
|
||||
[ObservableProperty] private int _quickRangeIndex = 1;
|
||||
|
||||
public bool HasEntries => Entries.Count > 0;
|
||||
public bool HasAbsenceEntries => AbsenceEntries.Count > 0;
|
||||
public bool HasCategoryAggregates => CategoryAggregates.Count > 0;
|
||||
public string ActiveFilterLabel => string.IsNullOrWhiteSpace(StudentFilter)
|
||||
? "Alle Schüler*innen" : StudentFilter;
|
||||
|
||||
public ClassTeacherDetailsViewModel(UntisReportCacheService cache)
|
||||
{
|
||||
_cache = cache;
|
||||
}
|
||||
|
||||
public void Initialize(string className)
|
||||
{
|
||||
_className = className;
|
||||
StudentFilter = "";
|
||||
Entries.Clear();
|
||||
AbsenceEntries.Clear();
|
||||
CategoryAggregates.Clear();
|
||||
Status = "Zeitraum wählen und laden.";
|
||||
NotifyListState();
|
||||
}
|
||||
|
||||
partial void OnStudentFilterChanged(string value) => OnPropertyChanged(nameof(ActiveFilterLabel));
|
||||
|
||||
partial void OnQuickRangeIndexChanged(int value)
|
||||
{
|
||||
var days = value switch { 0 => 0, 1 => 6, 2 => 29, _ => 6 };
|
||||
EndDate = DateTimeOffset.Now;
|
||||
StartDate = DateTimeOffset.Now.AddDays(-days);
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private Task Load() => LoadInternal(forceRefresh: false);
|
||||
|
||||
/// Umgeht bewusst die Stunden-Sperre von <see cref="UntisReportCacheService"/> — für den Fall,
|
||||
/// dass man sicher weiß, dass sich seit dem letzten automatischen Abruf etwas geändert hat.
|
||||
[RelayCommand]
|
||||
private Task Refresh() => LoadInternal(forceRefresh: true);
|
||||
|
||||
[RelayCommand]
|
||||
private Task ApplyFilter() => LoadInternal(forceRefresh: false);
|
||||
|
||||
[RelayCommand]
|
||||
private async Task ClearStudentFilter()
|
||||
{
|
||||
StudentFilter = "";
|
||||
await LoadInternal(forceRefresh: false);
|
||||
}
|
||||
|
||||
private async Task LoadInternal(bool forceRefresh)
|
||||
{
|
||||
var start = DateOnly.FromDateTime(StartDate.LocalDateTime);
|
||||
var end = DateOnly.FromDateTime(EndDate.LocalDateTime);
|
||||
if (end < start) { Status = "Das Enddatum darf nicht vor dem Startdatum liegen."; return; }
|
||||
if (string.IsNullOrWhiteSpace(_className)) { Status = "Keine Klasse ausgewählt."; return; }
|
||||
|
||||
Busy = true; Entries.Clear(); AbsenceEntries.Clear(); CategoryAggregates.Clear(); NotifyListState();
|
||||
try
|
||||
{
|
||||
var classRegisterTask = _cache.GetClassRegisterEventsAsync(_className, start, end, forceRefresh);
|
||||
var absencesTask = _cache.GetAbsencesAsync(_className, start, end, forceRefresh);
|
||||
await Task.WhenAll(classRegisterTask, absencesTask);
|
||||
|
||||
var ordered = classRegisterTask.Result
|
||||
.Where(MatchesStudentFilter)
|
||||
.Select(e => (Entry: e, Date: TryDate(e.Date, out var d) ? d : (DateOnly?)null))
|
||||
.Where(x => x.Date is not null)
|
||||
.OrderByDescending(x => x.Date).ThenBy(x => x.Entry.StudentName);
|
||||
foreach (var (entry, date) in ordered)
|
||||
{
|
||||
Entries.Add(new ClassTeacherClassRegisterRow(date!.Value, entry.Subject, entry.StudentName,
|
||||
entry.TeacherUsername, entry.CategoryName, entry.CategoryGroup, entry.Text));
|
||||
}
|
||||
foreach (var row in ClassTeacherCategoryAggregateRow.Build(Entries)) CategoryAggregates.Add(row);
|
||||
|
||||
var absences = absencesTask.Result.Where(MatchesStudentFilter);
|
||||
foreach (var row in ClassAbsenceDaySummaryRow.GroupByStudentAndDay(absences))
|
||||
AbsenceEntries.Add(row);
|
||||
|
||||
Status = $"{Entries.Count} Klassenbucheinträge anderer Lehrkräfte, " +
|
||||
$"{AbsenceEntries.Count} Fehlzeiten-Tage im Zeitraum.";
|
||||
NotifyListState();
|
||||
}
|
||||
catch (WebUntisIntegrationException ex) { Status = ex.Message; }
|
||||
finally { Busy = false; NotifyListState(); }
|
||||
}
|
||||
|
||||
// WebUntis liefert Namen je nach Bericht in anderer Reihenfolge als der Schülerreport, aus dem
|
||||
// StudentFilter beim Klick in der Übersicht gesetzt wird (siehe UntisNameMatching) - ein
|
||||
// exakter String-Vergleich hier ließ die gefilterten Listen fälschlich leer erscheinen.
|
||||
private bool MatchesStudentFilter(UntisForeignClassRegisterEventDto entry) =>
|
||||
string.IsNullOrWhiteSpace(StudentFilter) || UntisNameMatching.NamesMatch(entry.StudentName, StudentFilter);
|
||||
|
||||
private bool MatchesStudentFilter(UntisClassAbsenceEntryDto entry) =>
|
||||
string.IsNullOrWhiteSpace(StudentFilter) || UntisNameMatching.NamesMatch(entry.StudentName, StudentFilter);
|
||||
|
||||
private static bool TryDate(int value, out DateOnly date) =>
|
||||
DateOnly.TryParseExact(value.ToString(), "yyyyMMdd", out date);
|
||||
|
||||
private void NotifyListState()
|
||||
{
|
||||
OnPropertyChanged(nameof(HasEntries));
|
||||
OnPropertyChanged(nameof(HasAbsenceEntries));
|
||||
OnPropertyChanged(nameof(HasCategoryAggregates));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,670 @@
|
||||
using System.Collections.ObjectModel;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using LehrerApp.Core.Interfaces;
|
||||
using LehrerApp.Core.Models;
|
||||
using LehrerApp.Core.Services;
|
||||
using LehrerApp.Desktop.Services;
|
||||
|
||||
namespace LehrerApp.Desktop.ViewModels.ClassTeacher;
|
||||
|
||||
/// <summary>Semantische Statusstufe einer Zeile bzw. eines Hinweises. Bewusst ohne Farbwert im
|
||||
/// ViewModel: Die Zuordnung zu einem Pinsel passiert über Style-Klassen in XAML
|
||||
/// (<c>Styles/SemanticBrushes.axaml</c>), damit ein Theme-Wechsel hell/dunkel sie live mitnimmt —
|
||||
/// ein hart codierter Hex-String aus dem ViewModel bliebe beim Wechsel unverändert stehen.</summary>
|
||||
public enum ClassTeacherStatusKind { Ok, Info, Warning, Danger }
|
||||
|
||||
public sealed record ClassTeacherRosterRow(string StudentName, int? ExternKey, bool HasAbsenceToday,
|
||||
string? AbsenceTooltip, bool HasRecentClassRegisterEntry)
|
||||
{
|
||||
public ClassAbsenceDaySummaryRow? TodayAbsence { get; init; }
|
||||
public bool HasClassRegisterToday { get; init; }
|
||||
public bool IsLate => TodayAbsence is { } a &&
|
||||
(a.TotalAbsentPeriods == 0 || a.AbsenceReasons.Any(IsLateReason));
|
||||
public bool IsUnexcused => TodayAbsence?.Statuses.Any(s =>
|
||||
s.Contains("nicht entsch", StringComparison.OrdinalIgnoreCase)) == true;
|
||||
public bool NeedsAttention => HasAbsenceToday || HasRecentClassRegisterEntry;
|
||||
public int AttentionRank => IsUnexcused ? 0 : IsLate ? 1 : HasAbsenceToday ? 2 :
|
||||
HasRecentClassRegisterEntry ? 3 : 4;
|
||||
|
||||
public string Initials
|
||||
{
|
||||
get
|
||||
{
|
||||
var parts = StudentName.Split(' ', StringSplitOptions.RemoveEmptyEntries);
|
||||
return string.Concat(parts.Take(2).Select(p => char.ToUpperInvariant(p[0])));
|
||||
}
|
||||
}
|
||||
|
||||
public string StatusText
|
||||
{
|
||||
get
|
||||
{
|
||||
if (TodayAbsence is null)
|
||||
return HasRecentClassRegisterEntry ? "Neuer Klassenbucheintrag" : "Keine gemeldete Fehlzeit";
|
||||
var core = IsLate
|
||||
? $"{TodayAbsence.TotalAbsentMinutes} Min. verspätet"
|
||||
: TodayAbsence.CountsAsFullDay
|
||||
? "Ganzer Fehltag"
|
||||
: $"{TodayAbsence.TotalAbsentPeriods} Fehlstunde{(TodayAbsence.TotalAbsentPeriods == 1 ? "" : "n")}";
|
||||
return IsUnexcused ? $"{core} · Unentschuldigt" : core;
|
||||
}
|
||||
}
|
||||
|
||||
public ClassTeacherStatusKind StatusKind => IsUnexcused ? ClassTeacherStatusKind.Danger :
|
||||
HasAbsenceToday ? ClassTeacherStatusKind.Warning :
|
||||
HasRecentClassRegisterEntry ? ClassTeacherStatusKind.Info : ClassTeacherStatusKind.Ok;
|
||||
public bool IsDangerStatus => StatusKind == ClassTeacherStatusKind.Danger;
|
||||
public bool IsWarningStatus => StatusKind == ClassTeacherStatusKind.Warning;
|
||||
public bool IsInfoStatus => StatusKind == ClassTeacherStatusKind.Info;
|
||||
public string ClassRegisterLabel => HasClassRegisterToday ? "Eintrag heute" :
|
||||
HasRecentClassRegisterEntry ? "Eintrag diese Woche" : "";
|
||||
|
||||
/// Quick-Win Barrierefreiheit: der Status wurde bislang ausschließlich über die Farbe des
|
||||
/// linken Balkens/Texts transportiert. Ein kleines vorangestelltes Symbol macht Warnung/Gefahr
|
||||
/// auch ohne verlässliche Farbunterscheidung erkennbar (und beim schnellen Scannen der Liste).
|
||||
public string StatusTextWithGlyph => StatusKind switch
|
||||
{
|
||||
ClassTeacherStatusKind.Danger => $"✕ {StatusText}",
|
||||
ClassTeacherStatusKind.Warning => $"△ {StatusText}",
|
||||
_ => StatusText,
|
||||
};
|
||||
|
||||
/// Kumulierte Fehlzeiten seit Schuljahresbeginn (Nutzer-Feedback: der Heute-Snapshot allein
|
||||
/// sagt für Zeugnis/Attestpflicht wenig aus). <see cref="SchoolDaysElapsed"/> zählt nur
|
||||
/// Werktage, ohne Ferienkalender — eine bewusste Vereinfachung, siehe TODO.md 12.4-Nachtrag.
|
||||
public int YearAbsenceDayCount { get; init; }
|
||||
public int YearUnexcusedDayCount { get; init; }
|
||||
public int SchoolDaysElapsed { get; init; }
|
||||
public bool HasYearSummary => SchoolDaysElapsed > 0 && YearAbsenceDayCount > 0;
|
||||
public int YearAbsenceRatePercent =>
|
||||
SchoolDaysElapsed <= 0 ? 0 : (int)Math.Round(100d * YearAbsenceDayCount / SchoolDaysElapsed);
|
||||
public string YearSummaryLabel => HasYearSummary
|
||||
? $"{YearAbsenceRatePercent} % Fehlzeit seit Schuljahresbeginn" : "";
|
||||
public string? YearSummaryTooltip => !HasYearSummary ? null :
|
||||
$"{YearAbsenceDayCount} von {SchoolDaysElapsed} Schultagen mit Fehlzeit" +
|
||||
(YearUnexcusedDayCount > 0 ? $" · {YearUnexcusedDayCount} unentschuldigt" : "");
|
||||
|
||||
private static bool IsLateReason(string reason) =>
|
||||
reason.Contains("verspät", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
public static IReadOnlyList<ClassTeacherRosterRow> Build(
|
||||
IReadOnlyList<UntisStudentRosterCacheEntry> students,
|
||||
IReadOnlyList<ClassAbsenceDaySummaryRow> todayAbsences,
|
||||
IReadOnlyList<UntisForeignClassRegisterEventDto> recentClassRegisterEntries,
|
||||
DateOnly? today = null,
|
||||
IReadOnlyList<ClassAbsenceDaySummaryRow>? yearAbsences = null,
|
||||
int schoolDaysElapsed = 0)
|
||||
{
|
||||
var referenceDate = today ?? todayAbsences.FirstOrDefault()?.Date ?? DateOnly.FromDateTime(DateTime.Today);
|
||||
var absenceByKey = todayAbsences.Where(a => a.ExternKey is not null)
|
||||
.GroupBy(a => a.ExternKey!.Value).ToDictionary(g => g.Key, g => g.First());
|
||||
var absenceByName = todayAbsences.GroupBy(a => UntisNameMatching.NameKey(a.StudentName))
|
||||
.ToDictionary(g => g.Key, g => g.First());
|
||||
var recentByName = recentClassRegisterEntries.GroupBy(e => UntisNameMatching.NameKey(e.StudentName))
|
||||
.ToDictionary(g => g.Key, g => g.ToList());
|
||||
var yearRows = yearAbsences ?? [];
|
||||
var yearByKey = yearRows.Where(a => a.ExternKey is not null)
|
||||
.GroupBy(a => a.ExternKey!.Value).ToDictionary(g => g.Key, g => g.ToList());
|
||||
var yearByName = yearRows.GroupBy(a => UntisNameMatching.NameKey(a.StudentName))
|
||||
.ToDictionary(g => g.Key, g => g.ToList());
|
||||
|
||||
return students.Select(student =>
|
||||
{
|
||||
var nameKey = UntisNameMatching.NameKey(student.DisplayName);
|
||||
var absence = (student.ExternKey is { } key ? absenceByKey.GetValueOrDefault(key) : null)
|
||||
?? absenceByName.GetValueOrDefault(nameKey);
|
||||
var registerEntries = recentByName.GetValueOrDefault(nameKey) ?? [];
|
||||
var yearEntries = (student.ExternKey is { } yearKey ? yearByKey.GetValueOrDefault(yearKey) : null)
|
||||
?? yearByName.GetValueOrDefault(nameKey) ?? [];
|
||||
return new ClassTeacherRosterRow(student.DisplayName, student.ExternKey, absence is not null,
|
||||
absence is null ? null : $"{absence.TotalAbsentPeriods} Stunde(n) — {absence.StatusLabel}",
|
||||
registerEntries.Count > 0)
|
||||
{
|
||||
TodayAbsence = absence,
|
||||
HasClassRegisterToday = registerEntries.Any(e => TryDate(e.Date, out var date) && date == referenceDate),
|
||||
YearAbsenceDayCount = yearEntries.Count,
|
||||
YearUnexcusedDayCount = yearEntries.Count(r => r.IsUnexcused),
|
||||
SchoolDaysElapsed = schoolDaysElapsed,
|
||||
};
|
||||
})
|
||||
.OrderBy(r => r.AttentionRank).ThenBy(r => r.StudentName).ToList();
|
||||
}
|
||||
|
||||
private static bool TryDate(int value, out DateOnly date) =>
|
||||
DateOnly.TryParseExact(value.ToString(), "yyyyMMdd", out date);
|
||||
}
|
||||
|
||||
/// Ein gestapelter Tages-Balken statt drei unabhängig skalierter (Nutzer-Feedback: die vorherige
|
||||
/// Version zeigte Alerts/Unentschuldigt/Verspätet als drei separate, gleich hohe Balken, obwohl
|
||||
/// Unentschuldigt und Verspätet Teilmengen von Alerts sind — das suggerierte fälschlich drei
|
||||
/// unabhängige Größen). Die drei Segmente sind überschneidungsfrei (Unentschuldigt hat Vorrang
|
||||
/// vor Verspätet, wie bei <see cref="ClassTeacherRosterRow.AttentionRank"/>).
|
||||
public sealed record ClassTeacherTrendDay(string DayLabel, int AlertCount, int UnexcusedCount,
|
||||
int LateExcusedCount, int ExcusedCount, double UnexcusedFraction, double LateExcusedFraction,
|
||||
double ExcusedFraction)
|
||||
{
|
||||
/// Anteile 0…1 des höchsten Tages der letzten 7 Schultage statt fester Pixelbreiten
|
||||
/// (Quick-Win, Nutzer-Feedback: die Balkenbreite hing bisher an einer im ViewModel hart
|
||||
/// codierten Pixelkonstante, die mit der tatsächlichen Breite der Seitenspalte in XAML
|
||||
/// synchron gehalten werden musste). Die View multipliziert diese Anteile per MultiBinding
|
||||
/// (<see cref="LehrerApp.Desktop.Converters.FractionWidthConverter"/>) mit der tatsächlich gerenderten Breite
|
||||
/// der Track-Leiste — bleibt damit auch dann korrekt, wenn die Seitenspalte mal eine andere
|
||||
/// Breite bekommt. <c>Grid.ColumnDefinitions</c> ließ sich dafür nicht binden (kein Setter bei
|
||||
/// kompilierten Bindings, AVLN3000), deshalb Pixelbreite statt Grid-Sternspalten.
|
||||
public double TotalFraction => UnexcusedFraction + LateExcusedFraction + ExcusedFraction;
|
||||
public string DetailTooltip =>
|
||||
$"{UnexcusedCount} unentschuldigt · {LateExcusedCount} verspätet · {ExcusedCount} entschuldigt";
|
||||
}
|
||||
public sealed record ClassTeacherPatternNotice(string StudentName, string Message,
|
||||
ClassTeacherStatusKind Kind)
|
||||
{
|
||||
public bool IsDangerStatus => Kind == ClassTeacherStatusKind.Danger;
|
||||
public bool IsWarningStatus => Kind == ClassTeacherStatusKind.Warning;
|
||||
public bool IsInfoStatus => Kind == ClassTeacherStatusKind.Info;
|
||||
}
|
||||
|
||||
/// <summary>Ein Fehltag, der aktuell (Stand letztem Abruf) als unentschuldigt geführt wird —
|
||||
/// Nutzer-Feedback: die Übersicht zeigte bislang nur den Heute-Snapshot und einen 7-Tage-Trend,
|
||||
/// aber keine Liste konkreter offener Fälle, die man abarbeiten könnte. Läuft über die ohnehin
|
||||
/// für die Jahresfehlquote geladenen Daten (<see cref="ClassTeacherOverviewViewModel.Load"/>),
|
||||
/// kein zusätzlicher Abruf.</summary>
|
||||
public sealed record ClassTeacherOpenExcuseRow(string StudentName, DateOnly Date, int DaysOpen)
|
||||
{
|
||||
/// Verbreitete Regelung in vielen Bundesländern: Entschuldigung "innerhalb von drei Tagen"
|
||||
/// nach der Fehlzeit (z.B. § 43 Abs. 2 SchulG NRW). Nur ein Richtwert für die optische
|
||||
/// Hervorhebung hier — WebUntis liefert keine schulspezifische Frist, und die tatsächliche
|
||||
/// Regelung kann je nach Bundesland/Schulordnung abweichen.
|
||||
public const int DeadlineDays = 3;
|
||||
public string DateLabel => Date.ToString("dd.MM.");
|
||||
public bool IsOverdue => DaysOpen > DeadlineDays;
|
||||
public string DaysOpenLabel => $"seit {DaysOpen} Tag{(DaysOpen == 1 ? "" : "en")} unentschuldigt";
|
||||
|
||||
public static IReadOnlyList<ClassTeacherOpenExcuseRow> Build(
|
||||
IReadOnlyList<ClassAbsenceDaySummaryRow> absenceDays, DateOnly today) =>
|
||||
absenceDays.Where(a => a.IsUnexcused)
|
||||
.Select(a => new ClassTeacherOpenExcuseRow(a.StudentDisplayName, a.Date,
|
||||
today.DayNumber - a.Date.DayNumber))
|
||||
.OrderByDescending(r => r.DaysOpen).ThenBy(r => r.StudentName)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
public partial class ClassTeacherOverviewViewModel : ObservableObject
|
||||
{
|
||||
private readonly WebUntisSettingsService _settings;
|
||||
private readonly UntisReportCacheService _cache;
|
||||
private readonly SchoolYearService _schoolYear;
|
||||
private readonly IWorkTaskRepository _workTasks;
|
||||
private readonly IStudentRepository _students;
|
||||
private readonly IParticipationRepository _participation;
|
||||
private readonly IParticipationSessionRepository _participationSessions;
|
||||
|
||||
public ClassTeacherDetailsViewModel DetailsTab { get; }
|
||||
public ObservableCollection<ClassTeacherRosterRow> Roster { get; } = [];
|
||||
public ObservableCollection<ClassTeacherRosterRow> PrimaryRoster { get; } = [];
|
||||
public ObservableCollection<ClassTeacherRosterRow> SecondaryRoster { get; } = [];
|
||||
public ObservableCollection<ClassTeacherTrendDay> TrendDays { get; } = [];
|
||||
public ObservableCollection<ClassTeacherPatternNotice> PatternNotices { get; } = [];
|
||||
public ObservableCollection<ClassTeacherOpenExcuseRow> OpenExcuses { get; } = [];
|
||||
|
||||
[ObservableProperty] private string? _homeroomClassName;
|
||||
[ObservableProperty] private int _activeTabIndex;
|
||||
[ObservableProperty] private string _status = "";
|
||||
[ObservableProperty] private bool _busy;
|
||||
[ObservableProperty] private string _searchText = "";
|
||||
[ObservableProperty] private int _selectedRosterFilter;
|
||||
[ObservableProperty] private string _primarySectionTitle = "Heute auffällig";
|
||||
[ObservableProperty] private string _secondarySectionTitle = "Weitere Schüler*innen";
|
||||
[ObservableProperty] private int _studentCount;
|
||||
[ObservableProperty] private int _todayAlertCount;
|
||||
[ObservableProperty] private int _todayUnexcusedCount;
|
||||
[ObservableProperty] private int _recentClassRegisterCount;
|
||||
[ObservableProperty] private int _presentCount;
|
||||
[ObservableProperty] private int _lateCount;
|
||||
[ObservableProperty] private int _excusedAbsenceCount;
|
||||
[ObservableProperty] private int _unexcusedAbsenceCount;
|
||||
[ObservableProperty] private string _lastUpdatedLabel = "Noch nicht aktualisiert";
|
||||
[ObservableProperty] private int _openExcuseOverflowCount;
|
||||
|
||||
public bool HomeroomClassConfigured => !string.IsNullOrWhiteSpace(HomeroomClassName);
|
||||
public bool HasPrimaryRoster => PrimaryRoster.Count > 0;
|
||||
public bool HasSecondaryRoster => SecondaryRoster.Count > 0;
|
||||
public bool HasNoFilterResults => !Busy && PrimaryRoster.Count == 0 && SecondaryRoster.Count == 0;
|
||||
public bool HasPatternNotices => PatternNotices.Count > 0;
|
||||
public bool HasOpenExcuses => OpenExcuses.Count > 0;
|
||||
public bool HasOpenExcuseOverflow => OpenExcuseOverflowCount > 0;
|
||||
public bool AlertsFilterSelected => SelectedRosterFilter == 0;
|
||||
public bool ClassRegisterFilterSelected => SelectedRosterFilter == 1;
|
||||
public bool AllFilterSelected => SelectedRosterFilter == 2;
|
||||
public int TodayUnexcusedPercent => Percent(TodayUnexcusedCount);
|
||||
public int LatePercent => Percent(LateCount);
|
||||
public int PresentPercent => Percent(PresentCount);
|
||||
public int ExcusedAbsencePercent => Percent(ExcusedAbsenceCount);
|
||||
public int UnexcusedAbsencePercent => Percent(UnexcusedAbsenceCount);
|
||||
|
||||
/// Quick-Win: <see cref="TodayUnexcusedPercent"/> wurde berechnet, aber nirgends gebunden —
|
||||
/// jetzt als zweite Zeile in der "unentschuldigt"-Kennzahlkarte sichtbar.
|
||||
public string TodayUnexcusedSummaryLabel => $"unentschuldigt · {TodayUnexcusedPercent} %";
|
||||
|
||||
/// Beschriftungen für den kompakten Tagesüberblick (Quick-Win: ein gestapelter 100-%-Balken
|
||||
/// statt vier Einzelzeilen mit separater Prozentspalte — Zahl und Anteil jetzt in einer
|
||||
/// Zeile). Die vier Kategorien sind exklusiv und summieren sich exakt zu StudentCount, siehe
|
||||
/// die Herleitung von Present-/Late-/Excused-/UnexcusedAbsenceCount in <see cref="Load"/>.
|
||||
public string PresentSummaryLabel => $"{PresentCount} Anwesend · {PresentPercent} %";
|
||||
public string LateSummaryLabel => $"{LateCount} Verspätet · {LatePercent} %";
|
||||
public string ExcusedAbsenceSummaryLabel => $"{ExcusedAbsenceCount} Entschuldigt · {ExcusedAbsencePercent} %";
|
||||
public string UnexcusedAbsenceSummaryLabel => $"{UnexcusedAbsenceCount} Unentschuldigt · {UnexcusedAbsencePercent} %";
|
||||
/// Anteile 0…1 für den gestapelten Tagesüberblick-Balken (View multipliziert per MultiBinding
|
||||
/// mit der gerenderten Track-Breite, siehe <see cref="ClassTeacherTrendDay.TotalFraction"/>
|
||||
/// für dasselbe Muster beim Trend-Chart). Die vier Kategorien sind exklusiv und summieren sich
|
||||
/// zu 1, da StudentCount == Present + Late + ExcusedAbsence + UnexcusedAbsence.
|
||||
public double PresentFraction => StudentCount == 0 ? 0 : (double)PresentCount / StudentCount;
|
||||
public double LateFraction => StudentCount == 0 ? 0 : (double)LateCount / StudentCount;
|
||||
public double ExcusedAbsenceFraction => StudentCount == 0 ? 0 : (double)ExcusedAbsenceCount / StudentCount;
|
||||
public double UnexcusedAbsenceFraction => StudentCount == 0 ? 0 : (double)UnexcusedAbsenceCount / StudentCount;
|
||||
public string DayOverviewTooltip => $"{PresentCount} anwesend · {LateCount} verspätet · " +
|
||||
$"{ExcusedAbsenceCount} entschuldigt · {UnexcusedAbsenceCount} unentschuldigt";
|
||||
|
||||
public Func<Task>? OnNavigateToSettings { get; set; }
|
||||
public Func<Task>? OnNavigateToWorkload { get; set; }
|
||||
|
||||
public ClassTeacherOverviewViewModel(WebUntisSettingsService settings,
|
||||
UntisReportCacheService cache, SchoolYearService schoolYear, IWorkTaskRepository workTasks,
|
||||
IStudentRepository students, IParticipationRepository participation,
|
||||
IParticipationSessionRepository participationSessions, ClassTeacherDetailsViewModel detailsTab)
|
||||
{
|
||||
_settings = settings;
|
||||
_cache = cache;
|
||||
_schoolYear = schoolYear;
|
||||
_workTasks = workTasks;
|
||||
_students = students;
|
||||
_participation = participation;
|
||||
_participationSessions = participationSessions;
|
||||
DetailsTab = detailsTab;
|
||||
}
|
||||
|
||||
partial void OnHomeroomClassNameChanged(string? value) => OnPropertyChanged(nameof(HomeroomClassConfigured));
|
||||
partial void OnOpenExcuseOverflowCountChanged(int value) => OnPropertyChanged(nameof(HasOpenExcuseOverflow));
|
||||
partial void OnSearchTextChanged(string value) => ApplyRosterFilter();
|
||||
partial void OnSelectedRosterFilterChanged(int value)
|
||||
{
|
||||
OnPropertyChanged(nameof(AlertsFilterSelected));
|
||||
OnPropertyChanged(nameof(ClassRegisterFilterSelected));
|
||||
OnPropertyChanged(nameof(AllFilterSelected));
|
||||
ApplyRosterFilter();
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private async Task Load()
|
||||
{
|
||||
HomeroomClassName = _settings.HomeroomClassName;
|
||||
ActiveTabIndex = 0;
|
||||
Roster.Clear(); PrimaryRoster.Clear(); SecondaryRoster.Clear(); TrendDays.Clear(); PatternNotices.Clear();
|
||||
OpenExcuses.Clear(); OpenExcuseOverflowCount = 0;
|
||||
if (!HomeroomClassConfigured)
|
||||
{
|
||||
Status = "Noch keine Klasse ausgewählt.";
|
||||
NotifyRosterState();
|
||||
return;
|
||||
}
|
||||
|
||||
var className = HomeroomClassName!;
|
||||
DetailsTab.Initialize(className);
|
||||
Busy = true;
|
||||
NotifyRosterState();
|
||||
try
|
||||
{
|
||||
var today = DateOnly.FromDateTime(DateTime.Today);
|
||||
var sevenDayStart = today.AddDays(-6);
|
||||
var trendDays = LastSchoolDays(today, 7);
|
||||
// Fehlzeiten seit Schuljahresbeginn statt nur der letzten 7 Tage: liefert die
|
||||
// Kennzahl unten (YearSummaryLabel) und den Nenner für die Fehlquote in einem Abruf.
|
||||
// UntisReportCacheService cached diesen "kalten" Bereich dauerhaft (siehe dort) —
|
||||
// teuer ist nur der erste Abruf pro Schuljahr, nicht jedes Öffnen der Ansicht.
|
||||
var yearStart = _schoolYear.SchoolYearStart(_schoolYear.CurrentSchoolYear(today));
|
||||
var studentsTask = _cache.GetStudentRosterAsync(className);
|
||||
var absencesTask = _cache.GetAbsencesAsync(className, yearStart, today);
|
||||
var classRegisterTask = _cache.GetClassRegisterEventsAsync(className, sevenDayStart, today);
|
||||
await Task.WhenAll(studentsTask, absencesTask, classRegisterTask);
|
||||
|
||||
var absenceDaysYear = ClassAbsenceDaySummaryRow.GroupByStudentAndDay(absencesTask.Result);
|
||||
var todayAbsences = absenceDaysYear.Where(a => a.Date == today).ToList();
|
||||
var weekAbsenceDays = absenceDaysYear.Where(a => a.Date >= sevenDayStart).ToList();
|
||||
var schoolDaysElapsed = CountWeekdays(yearStart, today);
|
||||
foreach (var row in ClassTeacherRosterRow.Build(studentsTask.Result, todayAbsences,
|
||||
classRegisterTask.Result, today, absenceDaysYear, schoolDaysElapsed)) Roster.Add(row);
|
||||
|
||||
StudentCount = Roster.Count;
|
||||
TodayAlertCount = Roster.Count(r => r.HasAbsenceToday);
|
||||
TodayUnexcusedCount = Roster.Count(r => r.IsUnexcused);
|
||||
LateCount = Roster.Count(r => r.IsLate);
|
||||
PresentCount = Roster.Count(r => !r.HasAbsenceToday);
|
||||
ExcusedAbsenceCount = Roster.Count(r => r.HasAbsenceToday && !r.IsLate && !r.IsUnexcused);
|
||||
UnexcusedAbsenceCount = Roster.Count(r => r.HasAbsenceToday && !r.IsLate && r.IsUnexcused);
|
||||
RecentClassRegisterCount = classRegisterTask.Result.Count;
|
||||
BuildTrend(absenceDaysYear, trendDays);
|
||||
BuildPatternNotices(weekAbsenceDays);
|
||||
BuildWeekdayPatternNotices(absenceDaysYear);
|
||||
BuildAttendanceParticipationNotices();
|
||||
BuildOpenExcuses(absenceDaysYear, today);
|
||||
LastUpdatedLabel = $"Zuletzt aktualisiert: Heute, {DateTime.Now:HH:mm}";
|
||||
Status = $"{StudentCount} Schüler*innen · {TodayAlertCount} heute auffällig";
|
||||
ApplyRosterFilter();
|
||||
NotifySummary();
|
||||
}
|
||||
catch (WebUntisIntegrationException ex) { Status = ex.Message; NotifyRosterState(); }
|
||||
finally { Busy = false; NotifyRosterState(); }
|
||||
}
|
||||
|
||||
[RelayCommand] private void ShowAlerts() => SelectedRosterFilter = 0;
|
||||
[RelayCommand] private void ShowClassRegister() => SelectedRosterFilter = 1;
|
||||
[RelayCommand] private void ShowAll() => SelectedRosterFilter = 2;
|
||||
|
||||
[RelayCommand]
|
||||
private void OpenClassRegister()
|
||||
{
|
||||
DetailsTab.StudentFilter = "";
|
||||
ActiveTabIndex = 1;
|
||||
DetailsTab.LoadCommand.Execute(null);
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void OpenAbsences()
|
||||
{
|
||||
DetailsTab.StudentFilter = "";
|
||||
ActiveTabIndex = 2;
|
||||
DetailsTab.LoadCommand.Execute(null);
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void ShowDetailsForStudent(ClassTeacherRosterRow? row)
|
||||
{
|
||||
if (row is null) return;
|
||||
DetailsTab.StudentFilter = row.StudentName;
|
||||
ActiveTabIndex = row.HasAbsenceToday ? 2 : 1;
|
||||
DetailsTab.LoadCommand.Execute(null);
|
||||
}
|
||||
|
||||
[RelayCommand] private async Task GoToSettings()
|
||||
{ if (OnNavigateToSettings is not null) await OnNavigateToSettings(); }
|
||||
|
||||
[RelayCommand] private async Task GoToWorkload()
|
||||
{ if (OnNavigateToWorkload is not null) await OnNavigateToWorkload(); }
|
||||
|
||||
private void ApplyRosterFilter()
|
||||
{
|
||||
PrimaryRoster.Clear(); SecondaryRoster.Clear();
|
||||
var query = Roster.Where(r => string.IsNullOrWhiteSpace(SearchText) ||
|
||||
r.StudentName.Contains(SearchText.Trim(), StringComparison.CurrentCultureIgnoreCase));
|
||||
if (SelectedRosterFilter == 0)
|
||||
{
|
||||
PrimarySectionTitle = "Heute auffällig";
|
||||
foreach (var row in query.Where(r => r.HasAbsenceToday)) PrimaryRoster.Add(row);
|
||||
}
|
||||
else if (SelectedRosterFilter == 1)
|
||||
{
|
||||
PrimarySectionTitle = "Klassenbucheinträge der letzten 7 Tage";
|
||||
foreach (var row in query.Where(r => r.HasRecentClassRegisterEntry)) PrimaryRoster.Add(row);
|
||||
}
|
||||
else
|
||||
{
|
||||
PrimarySectionTitle = "Heute auffällig";
|
||||
SecondarySectionTitle = "Weitere Schüler*innen";
|
||||
foreach (var row in query.Where(r => r.NeedsAttention)) PrimaryRoster.Add(row);
|
||||
foreach (var row in query.Where(r => !r.NeedsAttention)) SecondaryRoster.Add(row);
|
||||
}
|
||||
NotifyRosterState();
|
||||
}
|
||||
|
||||
/// Letzte <paramref name="days"/> statt letzte 7 Kalendertage (Nutzer-Feedback: an zwei von
|
||||
/// sieben Tagen war der Trend bislang systematisch leer, weil Wochenenden mitzählten). Ohne
|
||||
/// Ferienkalender — siehe <see cref="LastSchoolDays"/>.
|
||||
private void BuildTrend(IReadOnlyList<ClassAbsenceDaySummaryRow> absenceDays, IReadOnlyList<DateOnly> days)
|
||||
{
|
||||
var today = days[^1];
|
||||
var counts = days.Select(date =>
|
||||
{
|
||||
var rows = absenceDays.Where(r => r.Date == date).ToList();
|
||||
var unexcused = rows.Count(r => r.IsUnexcused);
|
||||
var lateExcused = rows.Count(r => !r.IsUnexcused && r.IsLate);
|
||||
var excused = rows.Count(r => !r.IsUnexcused && !r.IsLate);
|
||||
return (Date: date, Unexcused: unexcused, LateExcused: lateExcused, Excused: excused,
|
||||
Total: unexcused + lateExcused + excused);
|
||||
}).ToList();
|
||||
var max = Math.Max(1, counts.Max(c => c.Total));
|
||||
foreach (var day in counts)
|
||||
TrendDays.Add(new ClassTeacherTrendDay(day.Date == today ? "Heute" : day.Date.ToString("ddd"),
|
||||
day.Total, day.Unexcused, day.LateExcused, day.Excused,
|
||||
(double)day.Unexcused / max, (double)day.LateExcused / max, (double)day.Excused / max));
|
||||
}
|
||||
|
||||
/// Letzte <paramref name="count"/> Werktage bis einschließlich <paramref name="end"/>, ohne
|
||||
/// Ferienkalender (bewusste Vereinfachung, siehe TODO.md 12.4-Nachtrag) — die App kennt keine
|
||||
/// Schulferien, nur Wochenenden.
|
||||
private static IReadOnlyList<DateOnly> LastSchoolDays(DateOnly end, int count)
|
||||
{
|
||||
var days = new List<DateOnly>();
|
||||
for (var cursor = end; days.Count < count; cursor = cursor.AddDays(-1))
|
||||
if (cursor.DayOfWeek is not (DayOfWeek.Saturday or DayOfWeek.Sunday))
|
||||
days.Add(cursor);
|
||||
days.Reverse();
|
||||
return days;
|
||||
}
|
||||
|
||||
/// Nenner für <see cref="ClassTeacherRosterRow.YearAbsenceRatePercent"/> — Werktage zwischen
|
||||
/// Schuljahresbeginn und heute, ebenfalls ohne Ferienkalender.
|
||||
private static int CountWeekdays(DateOnly start, DateOnly end)
|
||||
{
|
||||
if (end < start) return 0;
|
||||
var count = 0;
|
||||
for (var d = start; d <= end; d = d.AddDays(1))
|
||||
if (d.DayOfWeek is not (DayOfWeek.Saturday or DayOfWeek.Sunday)) count++;
|
||||
return count;
|
||||
}
|
||||
|
||||
private void BuildPatternNotices(IReadOnlyList<ClassAbsenceDaySummaryRow> absenceDays)
|
||||
{
|
||||
foreach (var group in absenceDays.GroupBy(row => UntisNameMatching.NameKey(row.StudentName)))
|
||||
{
|
||||
var rows = group.ToList();
|
||||
var displayName = Roster.FirstOrDefault(row =>
|
||||
UntisNameMatching.NameKey(row.StudentName) == group.Key)?.StudentName ?? rows[0].StudentDisplayName;
|
||||
var lateDays = rows.Count(row => row.IsLate);
|
||||
var unexcusedDays = rows.Count(row => row.IsUnexcused && !row.IsLate);
|
||||
if (unexcusedDays >= 2)
|
||||
PatternNotices.Add(new ClassTeacherPatternNotice(displayName,
|
||||
$"{unexcusedDays} unentschuldigte Fehltage in 7 Tagen", ClassTeacherStatusKind.Danger));
|
||||
else if (lateDays >= 2)
|
||||
PatternNotices.Add(new ClassTeacherPatternNotice(displayName,
|
||||
$"{lateDays}-mal verspätet in 7 Tagen", ClassTeacherStatusKind.Warning));
|
||||
}
|
||||
OnPropertyChanged(nameof(HasPatternNotices));
|
||||
}
|
||||
|
||||
/// Erweiterte Mustererkennung (Nutzer-Feedback): die obigen Regeln schauen nur auf die letzten
|
||||
/// 7 Tage und nur auf unentschuldigt/verspätet. Ein Wochentags-Schwerpunkt über einen längeren
|
||||
/// Zeitraum (z.B. "immer montags") ist ein eigenes, oft erst über mehrere Wochen sichtbares
|
||||
/// Signal — deshalb mit den seit Schuljahresbeginn geladenen Daten, nicht nur den letzten 7
|
||||
/// Tagen. Wer schon eine Notiz aus <see cref="BuildPatternNotices"/> hat, wird hier
|
||||
/// ausgelassen, damit die Liste nicht zwei Hinweise für dieselbe Person zeigt.
|
||||
private void BuildWeekdayPatternNotices(IReadOnlyList<ClassAbsenceDaySummaryRow> absenceDays)
|
||||
{
|
||||
var displayNames = Roster.GroupBy(r => UntisNameMatching.NameKey(r.StudentName))
|
||||
.ToDictionary(g => g.Key, g => g.First().StudentName);
|
||||
var alreadyNoted = PatternNotices.Select(n => n.StudentName).ToHashSet();
|
||||
foreach (var notice in DetectWeekdayPatterns(absenceDays, displayNames, alreadyNoted))
|
||||
PatternNotices.Add(notice);
|
||||
OnPropertyChanged(nameof(HasPatternNotices));
|
||||
}
|
||||
|
||||
/// Reine, ohne ViewModel-Zustand testbare Kernlogik (gleiches Muster wie
|
||||
/// <see cref="ClassTeacherRosterRow.Build"/>). <paramref name="minSampleSize"/> verhindert,
|
||||
/// dass zwei zufällig auf denselben Wochentag fallende Fehltage schon als "Muster" gelten;
|
||||
/// <paramref name="clusterThreshold"/> verlangt eine deutliche Häufung, nicht nur eine leichte
|
||||
/// Mehrheit.
|
||||
public static IReadOnlyList<ClassTeacherPatternNotice> DetectWeekdayPatterns(
|
||||
IReadOnlyList<ClassAbsenceDaySummaryRow> absenceDays,
|
||||
IReadOnlyDictionary<string, string> displayNamesByKey,
|
||||
ISet<string> excludeDisplayNames,
|
||||
int minSampleSize = 3, double clusterThreshold = 0.6)
|
||||
{
|
||||
var notices = new List<ClassTeacherPatternNotice>();
|
||||
foreach (var group in absenceDays.GroupBy(row => UntisNameMatching.NameKey(row.StudentName)))
|
||||
{
|
||||
var rows = group.ToList();
|
||||
if (rows.Count < minSampleSize) continue;
|
||||
var displayName = displayNamesByKey.GetValueOrDefault(group.Key) ?? rows[0].StudentDisplayName;
|
||||
if (excludeDisplayNames.Contains(displayName)) continue;
|
||||
|
||||
var top = rows.GroupBy(r => r.Date.DayOfWeek)
|
||||
.Select(g => (Day: g.Key, Count: g.Count()))
|
||||
.OrderByDescending(g => g.Count).First();
|
||||
if ((double)top.Count / rows.Count < clusterThreshold) continue;
|
||||
|
||||
notices.Add(new ClassTeacherPatternNotice(displayName,
|
||||
$"{top.Count} von {rows.Count} Fehltagen an einem {WeekdayLabel(top.Day)}",
|
||||
ClassTeacherStatusKind.Info));
|
||||
}
|
||||
return notices;
|
||||
}
|
||||
|
||||
/// Nutzer-Feedback: "Hohe Fehlquote bei gleichzeitig fallender Mitarbeit ist ein Signal, das
|
||||
/// WebUntis allein nicht liefert." Verknüpft die ohnehin geladene Jahresfehlquote
|
||||
/// (<see cref="ClassTeacherRosterRow.YearAbsenceRatePercent"/>) mit dem Mitarbeit-Trend aus
|
||||
/// dem Dokumentations-/Mitarbeitsmodul (<see cref="IParticipationRepository"/>, gruppen-
|
||||
/// übergreifend über <c>GetByStudent</c>, gleiches Muster wie die Fehlzeitenbilanz in
|
||||
/// StudentDetailViewModel). Bewusst zurückhaltend: nur bei bereits spürbar erhöhter Fehlquote
|
||||
/// UND eindeutig fallendem Trend (erste vs. zweite Hälfte der letzten Bewertungen, gleiche
|
||||
/// simple Heuristik wie <c>ParticipationGradeRow.ComputeTrend</c>), sonst würde die Liste bei
|
||||
/// jeder kleinen Schwankung anschlagen. Reine Beobachtung, keine Kausalitätsaussage — deshalb
|
||||
/// als neutraler Hinweis (Info), nicht als Warnung/Gefahr. Die WebUntis-Rohbewertungen werden
|
||||
/// hier bewusst NICHT nach Aspekt-Gewichtung normiert (anders als in ParticipationGradeRow):
|
||||
/// die Aspekt-Definitionen sind je Lerngruppe konfigurierbar, der Klassenlehrer-Bereich kennt
|
||||
/// aber keine einzelne Gruppe — ein einfacher Rohwert-Durchschnitt über alle Bewertungen ist
|
||||
/// hier die pragmatischere Näherung als ein Konfigurations-Mismatch zu riskieren.
|
||||
private const int CorrelationAbsenceRateThreshold = 15;
|
||||
private void BuildAttendanceParticipationNotices()
|
||||
{
|
||||
var students = _students.GetAll();
|
||||
foreach (var row in Roster.Where(r => r.HasYearSummary &&
|
||||
r.YearAbsenceRatePercent >= CorrelationAbsenceRateThreshold))
|
||||
{
|
||||
if (PatternNotices.Any(n => n.StudentName == row.StudentName)) continue;
|
||||
var student = MatchStudent(row.StudentName, students);
|
||||
if (student is null) continue;
|
||||
|
||||
var points = _participation.GetByStudent(student.Id)
|
||||
.Select(e => (Session: _participationSessions.GetById(e.SessionId), Entry: e))
|
||||
.Where(x => x.Session is not null && x.Entry.Ratings.Count > 0)
|
||||
.OrderBy(x => x.Session!.Date)
|
||||
.TakeLast(8)
|
||||
.Select(x => x.Entry.Ratings.Average(r => r.Value))
|
||||
.ToList();
|
||||
if (points.Count < 4) continue;
|
||||
|
||||
var mid = points.Count / 2;
|
||||
var diff = points.Skip(mid).Average() - points.Take(mid).Average();
|
||||
if (diff <= -0.4)
|
||||
PatternNotices.Add(new ClassTeacherPatternNotice(row.StudentName,
|
||||
$"{row.YearAbsenceRatePercent} % Fehlzeit seit Schuljahresbeginn, dabei zuletzt sinkende Mitarbeit",
|
||||
ClassTeacherStatusKind.Info));
|
||||
}
|
||||
OnPropertyChanged(nameof(HasPatternNotices));
|
||||
}
|
||||
|
||||
/// Reine, ohne Repository-Zugriff testbare Zuordnungslogik. Nutzt <c>FirstName</c>/<c>LastName</c>
|
||||
/// statt <see cref="Student.FullName"/>, weil dessen "Nachname, Vorname"-Format mit Komma den
|
||||
/// leerzeichenbasierten Wortabgleich in <see cref="UntisNameMatching"/> verfälschen würde
|
||||
/// ("Müller," bliebe ein eigenes Wort statt mit "Müller" aus dem WebUntis-Namen zu matchen).
|
||||
public static Student? MatchStudent(string webUntisDisplayName, IReadOnlyList<Student> students)
|
||||
{
|
||||
var key = UntisNameMatching.NameKey(webUntisDisplayName);
|
||||
return students.FirstOrDefault(s => UntisNameMatching.NameKey($"{s.FirstName} {s.LastName}") == key);
|
||||
}
|
||||
|
||||
private static string WeekdayLabel(DayOfWeek day) => day switch
|
||||
{
|
||||
DayOfWeek.Monday => "Montag",
|
||||
DayOfWeek.Tuesday => "Dienstag",
|
||||
DayOfWeek.Wednesday => "Mittwoch",
|
||||
DayOfWeek.Thursday => "Donnerstag",
|
||||
DayOfWeek.Friday => "Freitag",
|
||||
DayOfWeek.Saturday => "Samstag",
|
||||
_ => "Sonntag",
|
||||
};
|
||||
|
||||
/// Zeigt höchstens <see cref="OpenExcuseDisplayLimit"/> Zeilen — Nutzer-Feedback zu den
|
||||
/// anderen Seitenpanels: eine lange Liste soll nicht die ganze Spalte einnehmen. Der Rest
|
||||
/// zählt in <see cref="OpenExcuseOverflowCount"/> und wird als "+N weitere" angezeigt.
|
||||
private const int OpenExcuseDisplayLimit = 6;
|
||||
|
||||
private void BuildOpenExcuses(IReadOnlyList<ClassAbsenceDaySummaryRow> absenceDays, DateOnly today)
|
||||
{
|
||||
var all = ClassTeacherOpenExcuseRow.Build(absenceDays, today);
|
||||
foreach (var row in all.Take(OpenExcuseDisplayLimit)) OpenExcuses.Add(row);
|
||||
OpenExcuseOverflowCount = Math.Max(0, all.Count - OpenExcuseDisplayLimit);
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void ShowDetailsForOpenExcuse(ClassTeacherOpenExcuseRow? row)
|
||||
{
|
||||
if (row is null) return;
|
||||
DetailsTab.StudentFilter = row.StudentName;
|
||||
ActiveTabIndex = 2;
|
||||
DetailsTab.LoadCommand.Execute(null);
|
||||
}
|
||||
|
||||
/// Nutzer-Feedback: "Aufgaben & Wiedervorlagen öffnen" führt nur ins Modul, das eigentliche
|
||||
/// Erstellen ("Eltern anrufen – Ada Müller") musste man dort noch einmal von Hand eintippen.
|
||||
/// Legt direkt eine Wiedervorlage an (Kind=Reminder, wie 6.1.1) statt einen Dialog zu öffnen —
|
||||
/// der bearbeitet sie bei Bedarf im Aufgaben-Modul weiter, wo `OnEditTask` (siehe
|
||||
/// WorkTaskListView) ohnehin nur dort verdrahtet ist. Bewusst ohne GroupId: Klassenlehrer ist
|
||||
/// man für die ganze Klasse, nicht für einen einzelnen Kurs (siehe Klassendoku oben).
|
||||
[RelayCommand]
|
||||
private void CreateReminderForStudent(ClassTeacherRosterRow? row)
|
||||
{
|
||||
if (row is null) return;
|
||||
SaveReminder(row.StudentName, row.StatusText, urgent: row.IsUnexcused);
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void CreateReminderForOpenExcuse(ClassTeacherOpenExcuseRow? row)
|
||||
{
|
||||
if (row is null) return;
|
||||
SaveReminder(row.StudentName, $"{row.DaysOpenLabel} (Fehltag vom {row.DateLabel}).",
|
||||
urgent: row.IsOverdue);
|
||||
}
|
||||
|
||||
private void SaveReminder(string studentName, string notes, bool urgent)
|
||||
{
|
||||
var task = new WorkTask
|
||||
{
|
||||
Title = $"Eltern kontaktieren – {studentName}",
|
||||
Notes = notes,
|
||||
Kind = TaskKind.Reminder,
|
||||
Priority = urgent ? TaskPriority.High : TaskPriority.Normal,
|
||||
DueDate = DateOnly.FromDateTime(DateTime.Today).AddDays(1),
|
||||
};
|
||||
_workTasks.Save(task);
|
||||
Status = $"Wiedervorlage „{task.Title}\" angelegt — zu finden unter Aufgaben & Wiedervorlagen.";
|
||||
}
|
||||
|
||||
private int Percent(int value) => StudentCount == 0 ? 0 : (int)Math.Round(100d * value / StudentCount);
|
||||
private void NotifySummary()
|
||||
{
|
||||
OnPropertyChanged(nameof(TodayUnexcusedPercent)); OnPropertyChanged(nameof(LatePercent));
|
||||
OnPropertyChanged(nameof(PresentPercent)); OnPropertyChanged(nameof(ExcusedAbsencePercent));
|
||||
OnPropertyChanged(nameof(UnexcusedAbsencePercent));
|
||||
OnPropertyChanged(nameof(TodayUnexcusedSummaryLabel));
|
||||
OnPropertyChanged(nameof(PresentSummaryLabel)); OnPropertyChanged(nameof(LateSummaryLabel));
|
||||
OnPropertyChanged(nameof(ExcusedAbsenceSummaryLabel)); OnPropertyChanged(nameof(UnexcusedAbsenceSummaryLabel));
|
||||
OnPropertyChanged(nameof(PresentFraction)); OnPropertyChanged(nameof(LateFraction));
|
||||
OnPropertyChanged(nameof(ExcusedAbsenceFraction)); OnPropertyChanged(nameof(UnexcusedAbsenceFraction));
|
||||
OnPropertyChanged(nameof(DayOverviewTooltip));
|
||||
}
|
||||
private void NotifyRosterState()
|
||||
{
|
||||
OnPropertyChanged(nameof(HasPrimaryRoster)); OnPropertyChanged(nameof(HasSecondaryRoster));
|
||||
OnPropertyChanged(nameof(HasNoFilterResults)); OnPropertyChanged(nameof(HasOpenExcuses));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
namespace LehrerApp.Desktop.ViewModels.ClassTeacher;
|
||||
|
||||
/// <summary>
|
||||
/// WebUntis liefert Schülernamen je nach Bericht in unterschiedlicher Reihenfolge — der
|
||||
/// Schülerreport baut "Vorname Nachname" (<see cref="Services.UntisStudentDto.DisplayName"/>),
|
||||
/// die Fehlzeiten-/Klassenbuchberichte liefern typischerweise "Nachname Vorname". Ein exakter
|
||||
/// String-Vergleich zwischen diesen Quellen schlägt deshalb praktisch immer fehl (Bug: gefilterte
|
||||
/// Listen im Klassenlehrer-Bereich blieben leer, Roster-Symbole waren durchgängig falsch). Der
|
||||
/// Vergleich hier ist deshalb reihenfolge-unabhängig: beide Namen werden in Wörter zerlegt und als
|
||||
/// sortierte Menge verglichen (kein Abgleich über eine externe Schülerkennung möglich, da der
|
||||
/// Klassenbuch-Bericht keine liefert, siehe UntisForeignClassRegisterEventDto).
|
||||
/// </summary>
|
||||
public static class UntisNameMatching
|
||||
{
|
||||
public static bool NamesMatch(string? a, string? b) =>
|
||||
!string.IsNullOrWhiteSpace(a) && !string.IsNullOrWhiteSpace(b) && NameKey(a) == NameKey(b);
|
||||
|
||||
public static string NameKey(string value) => string.Join(' ',
|
||||
value.Split(' ', StringSplitOptions.RemoveEmptyEntries)
|
||||
.Select(w => w.Trim().ToLowerInvariant())
|
||||
.OrderBy(w => w, StringComparer.Ordinal));
|
||||
}
|
||||
@@ -107,6 +107,15 @@ public partial class DashboardViewModel : ObservableObject
|
||||
public DashboardCardOption AttendanceCard => Card("attendance");
|
||||
public DashboardCardOption SupportCard => Card("support");
|
||||
public DashboardCardOption GroupsCard => Card("groups");
|
||||
public int TodayLessonCount => TodaysLessons.Count;
|
||||
public int OpenTaskCount => OpenTasks.Count;
|
||||
public int UpcomingCount => UpcomingDates.Count;
|
||||
public int AttentionCount => OpenExcuses.Count + AttendanceWarnings.Count + SupportPlanReviews.Count
|
||||
+ OpenCorrections.Count + UnplannedLessons.Count + Alerts.Count;
|
||||
public string TodayLessonSummary => TodayLessonCount == 1 ? "1 Stunde" : $"{TodayLessonCount} Stunden";
|
||||
public string OpenTaskSummary => OpenTaskCount == 1 ? "1 Aufgabe" : $"{OpenTaskCount} Aufgaben";
|
||||
public string AttentionSummary => AttentionCount == 1 ? "1 offener Punkt" : $"{AttentionCount} offene Punkte";
|
||||
public string UpcomingSummary => UpcomingCount == 1 ? "1 Termin" : $"{UpcomingCount} Termine";
|
||||
|
||||
public DashboardViewModel(IGroupRepository groups, ISubjectRepository subjects, ILessonRepository lessons,
|
||||
IExamRepository exams, IExamResultRepository examResults, IGradeRepository grades,
|
||||
@@ -204,6 +213,31 @@ public partial class DashboardViewModel : ObservableObject
|
||||
LoadOpenCorrections(groups, today);
|
||||
LoadUnplannedLessons(groups, today);
|
||||
LoadAlerts(groups, today);
|
||||
UpdateDashboardSummary();
|
||||
}
|
||||
|
||||
private void UpdateDashboardSummary()
|
||||
{
|
||||
TodayCard.IsEmpty = TodaysLessons.Count == 0;
|
||||
TasksCard.IsEmpty = OpenTasks.Count == 0;
|
||||
CalendarCard.IsEmpty = false;
|
||||
ExcusesCard.IsEmpty = OpenExcuses.Count == 0;
|
||||
UpcomingCard.IsEmpty = UpcomingDates.Count == 0;
|
||||
CorrectionsCard.IsEmpty = OpenCorrections.Count == 0;
|
||||
UnplannedCard.IsEmpty = UnplannedLessons.Count == 0;
|
||||
AlertsCard.IsEmpty = Alerts.Count == 0;
|
||||
AttendanceCard.IsEmpty = AttendanceWarnings.Count == 0;
|
||||
SupportCard.IsEmpty = SupportPlanReviews.Count == 0;
|
||||
GroupsCard.IsEmpty = CurrentGroups.Count == 0;
|
||||
|
||||
OnPropertyChanged(nameof(TodayLessonCount));
|
||||
OnPropertyChanged(nameof(OpenTaskCount));
|
||||
OnPropertyChanged(nameof(UpcomingCount));
|
||||
OnPropertyChanged(nameof(AttentionCount));
|
||||
OnPropertyChanged(nameof(TodayLessonSummary));
|
||||
OnPropertyChanged(nameof(OpenTaskSummary));
|
||||
OnPropertyChanged(nameof(AttentionSummary));
|
||||
OnPropertyChanged(nameof(UpcomingSummary));
|
||||
}
|
||||
|
||||
private async Task LoadWeatherAsync()
|
||||
@@ -352,12 +386,10 @@ public partial class DashboardViewModel : ObservableObject
|
||||
.Where(e => e.Status is ExamStatus.Conducted or ExamStatus.Graded)
|
||||
.OrderBy(e => e.Date))
|
||||
{
|
||||
var expected = _memberships.GetByGroup(group.Id)
|
||||
.Count(m => GroupMembershipService.IsActiveOn(m, exam.Date));
|
||||
var evaluated = _examResults.GetByExam(exam.Id)
|
||||
.Count(r => r.Absent || !string.IsNullOrWhiteSpace(r.Grade) || r.Points.Count > 0);
|
||||
var (expected, evaluated) = ExamCorrectionCounter.Count(exam,
|
||||
_memberships.GetByGroup(group.Id), _examResults.GetByExam(exam.Id));
|
||||
OpenCorrections.Add(new CorrectionProgressItem(exam.Id, group.Id, exam.Title,
|
||||
group.Name, exam.Date, Math.Min(evaluated, expected), expected, today));
|
||||
group.Name, exam.Date, evaluated, expected, today));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -592,6 +624,7 @@ public partial class DashboardViewModel : ObservableObject
|
||||
entry.Attendance = status;
|
||||
_participationEntries.Save(entry);
|
||||
OpenExcuses.Remove(item);
|
||||
UpdateDashboardSummary();
|
||||
}
|
||||
|
||||
private void LoadCalendar()
|
||||
@@ -1034,18 +1067,29 @@ public sealed class DashboardAlertItem(Guid studentId, Guid? groupId, string stu
|
||||
public partial class DashboardCardOption : ObservableObject
|
||||
{
|
||||
[ObservableProperty] private bool _isVisible;
|
||||
[ObservableProperty] private bool _isEmpty;
|
||||
[ObservableProperty] private int _row;
|
||||
[ObservableProperty] private int _column;
|
||||
public string Key { get; }
|
||||
public string Title { get; }
|
||||
public bool HideWhenEmpty { get; }
|
||||
public bool EffectiveIsVisible => IsVisible && (!HideWhenEmpty || !IsEmpty);
|
||||
public Action? OnVisibilityChanged { get; set; }
|
||||
|
||||
public DashboardCardOption(string key, string title, bool isVisible)
|
||||
{
|
||||
Key = key;
|
||||
Title = title;
|
||||
HideWhenEmpty = key is "excuses" or "upcoming" or "corrections" or "unplanned"
|
||||
or "alerts" or "attendance" or "support";
|
||||
_isVisible = isVisible;
|
||||
}
|
||||
|
||||
partial void OnIsVisibleChanged(bool value) => OnVisibilityChanged?.Invoke();
|
||||
partial void OnIsVisibleChanged(bool value)
|
||||
{
|
||||
OnPropertyChanged(nameof(EffectiveIsVisible));
|
||||
OnVisibilityChanged?.Invoke();
|
||||
}
|
||||
|
||||
partial void OnIsEmptyChanged(bool value) => OnPropertyChanged(nameof(EffectiveIsVisible));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,253 @@
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using LehrerApp.Core.Interfaces;
|
||||
using LehrerApp.Core.Models;
|
||||
using LehrerApp.Core.Services;
|
||||
using LehrerApp.Desktop.ViewModels.Groups;
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Globalization;
|
||||
|
||||
namespace LehrerApp.Desktop.ViewModels.Exams;
|
||||
|
||||
/// Klausuren-Hauptseite (Sidebar "Klausuren"): gruppenübergreifende Liste aller Klausuren des
|
||||
/// aktuellen Schuljahres, sortiert nach einem unsichtbaren Prioritäts-Score
|
||||
/// (`ExamPriorityService`) statt nach Datum — unbearbeitete/überfällige Korrekturen oben,
|
||||
/// erledigte unten. Oben ein Detailbereich zur ausgewählten Klausur, der sich je nach Status
|
||||
/// unterscheidet (Korrekturfortschritt vs. Notenspiegel), inklusive Umschalter für Parallelkurse
|
||||
/// (gleiche Arbeit, mehrere Kurse — siehe `Exam.SharedExamGroupId`).
|
||||
public partial class ExamsOverviewViewModel : ObservableObject
|
||||
{
|
||||
private readonly IExamRepository _exams;
|
||||
private readonly IExamResultRepository _examResults;
|
||||
private readonly IGroupRepository _groups;
|
||||
private readonly IGroupMembershipRepository _memberships;
|
||||
private readonly GradingService _grading;
|
||||
private readonly SchoolYearService _schoolYear;
|
||||
|
||||
public ObservableCollection<ExamListRowViewModel> Rows { get; } = [];
|
||||
public ObservableCollection<ExamListRowViewModel> Siblings { get; } = [];
|
||||
public ObservableCollection<GradeBarItem> DetailGradeDistribution { get; } = [];
|
||||
|
||||
[ObservableProperty] private ExamListRowViewModel? _selectedRow;
|
||||
[ObservableProperty] private bool _hasSelection;
|
||||
[ObservableProperty] private string _emptyHint = "";
|
||||
|
||||
[ObservableProperty] private bool _showCorrectionProgress;
|
||||
[ObservableProperty] private string _progressLabel = "";
|
||||
[ObservableProperty] private double _progressBarWidth;
|
||||
[ObservableProperty] private bool _showGradeSummary;
|
||||
[ObservableProperty] private string _averageLabel = "";
|
||||
[ObservableProperty] private string _approvalLabel = "";
|
||||
[ObservableProperty] private string _announcementLabel = "";
|
||||
|
||||
public Func<Exam, Task>? OnGradeExam { get; set; }
|
||||
public Func<Exam, Task>? OnEvaluateExam { get; set; }
|
||||
public Action<Guid>? OnNavigateToGroup { get; set; }
|
||||
|
||||
public ExamsOverviewViewModel(IExamRepository exams, IExamResultRepository examResults,
|
||||
IGroupRepository groups, IGroupMembershipRepository memberships, GradingService grading,
|
||||
SchoolYearService schoolYear)
|
||||
{
|
||||
_exams = exams; _examResults = examResults; _groups = groups;
|
||||
_memberships = memberships; _grading = grading; _schoolYear = schoolYear;
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
public void Load()
|
||||
{
|
||||
var selectedId = SelectedRow?.Exam.Id;
|
||||
Rows.Clear();
|
||||
|
||||
var groups = _groups.GetBySchoolYear(_schoolYear.CurrentSchoolYear());
|
||||
var today = DateOnly.FromDateTime(DateTime.Today);
|
||||
|
||||
var rows = new List<ExamListRowViewModel>();
|
||||
foreach (var group in groups)
|
||||
{
|
||||
var groupMemberships = _memberships.GetByGroup(group.Id);
|
||||
foreach (var exam in _exams.GetByGroup(group.Id))
|
||||
{
|
||||
var (expected, evaluated) = ExamCorrectionCounter.Count(exam, groupMemberships,
|
||||
_examResults.GetByExam(exam.Id));
|
||||
var info = ExamPriorityService.Evaluate(exam, expected, evaluated, today);
|
||||
rows.Add(new ExamListRowViewModel(exam, group, expected, evaluated, info, today));
|
||||
}
|
||||
}
|
||||
|
||||
// Parallelkurse markieren, damit die Liste einen Verknüpfungs-Hinweis zeigen kann.
|
||||
foreach (var group in rows.GroupBy(r => r.Exam.SharedExamGroupId ?? r.Exam.Id))
|
||||
{
|
||||
if (group.Count() <= 1) continue;
|
||||
foreach (var row in group) row.HasSibling = true;
|
||||
}
|
||||
|
||||
foreach (var row in rows.OrderByDescending(r => r.Score))
|
||||
Rows.Add(row);
|
||||
|
||||
EmptyHint = Rows.Count == 0 ? "Keine Klausuren angelegt." : "";
|
||||
SelectedRow = selectedId is { } id
|
||||
? Rows.FirstOrDefault(r => r.Exam.Id == id) ?? Rows.FirstOrDefault()
|
||||
: Rows.FirstOrDefault();
|
||||
}
|
||||
|
||||
partial void OnSelectedRowChanged(ExamListRowViewModel? value) => UpdateDetail(value);
|
||||
|
||||
private void UpdateDetail(ExamListRowViewModel? row)
|
||||
{
|
||||
HasSelection = row is not null;
|
||||
Siblings.Clear();
|
||||
DetailGradeDistribution.Clear();
|
||||
ShowCorrectionProgress = false;
|
||||
ShowGradeSummary = false;
|
||||
if (row is null) return;
|
||||
|
||||
ApprovalLabel = row.Exam.ApprovalGrantedAt is { } a
|
||||
? $"Genehmigt am {a.ToString("dd.MM.yyyy", CultureInfo.InvariantCulture)}"
|
||||
: "Genehmigung noch ausstehend";
|
||||
AnnouncementLabel = row.Exam.AnnouncedAt is { } n
|
||||
? $"Angekündigt am {n.ToString("dd.MM.yyyy", CultureInfo.InvariantCulture)}"
|
||||
: "Noch nicht angekündigt";
|
||||
|
||||
if (row.HasSibling)
|
||||
{
|
||||
var anchor = row.Exam.SharedExamGroupId ?? row.Exam.Id;
|
||||
foreach (var sibling in Rows.Where(r => r != row && (r.Exam.SharedExamGroupId ?? r.Exam.Id) == anchor))
|
||||
Siblings.Add(sibling);
|
||||
}
|
||||
|
||||
ShowCorrectionProgress = row.Status is ExamListStatus.AwaitingCorrection
|
||||
or ExamListStatus.CorrectionInProgress or ExamListStatus.CorrectionStuck;
|
||||
if (ShowCorrectionProgress)
|
||||
{
|
||||
ProgressLabel = $"{row.Evaluated} / {row.Expected} korrigiert";
|
||||
var fraction = row.Expected <= 0 ? 0 : Math.Clamp((double)row.Evaluated / row.Expected, 0, 1);
|
||||
ProgressBarWidth = fraction * 240.0;
|
||||
}
|
||||
|
||||
ShowGradeSummary = row.Status is ExamListStatus.AwaitingReturn or ExamListStatus.Returned;
|
||||
if (ShowGradeSummary) LoadGradeSummary(row);
|
||||
}
|
||||
|
||||
private void LoadGradeSummary(ExamListRowViewModel row)
|
||||
{
|
||||
var grades = _examResults.GetByExam(row.Exam.Id)
|
||||
.Where(r => !r.Absent && !string.IsNullOrWhiteSpace(r.Grade))
|
||||
.Select(r => r.Grade!).ToList();
|
||||
if (grades.Count == 0) { AverageLabel = "Noch keine Noten erfasst."; return; }
|
||||
|
||||
AverageLabel = "Ø " + _grading.WeightedAverage(grades.Select(g => (Grade: g, Weight: 1.0)).ToList())
|
||||
.ToString("0.00", CultureInfo.InvariantCulture);
|
||||
|
||||
var counts = grades.GroupBy(g => g).ToDictionary(g => g.Key, g => g.Count());
|
||||
var maxCount = counts.Count == 0 ? 0 : counts.Values.Max();
|
||||
foreach (var entry in row.Exam.GradingKey.OrderByDescending(e => e.MinPercent))
|
||||
{
|
||||
counts.TryGetValue(entry.Grade, out var count);
|
||||
DetailGradeDistribution.Add(new GradeBarItem(entry.Grade, count, grades.Count, maxCount));
|
||||
}
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void SelectExam(ExamListRowViewModel row) => SelectedRow = row;
|
||||
|
||||
[RelayCommand]
|
||||
private async Task GradeSelected()
|
||||
{
|
||||
if (SelectedRow is null || OnGradeExam is null) return;
|
||||
await OnGradeExam(SelectedRow.Exam);
|
||||
Load();
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private async Task EvaluateSelected()
|
||||
{
|
||||
if (SelectedRow is null || OnEvaluateExam is null) return;
|
||||
await OnEvaluateExam(SelectedRow.Exam);
|
||||
Load();
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void GoToGroup()
|
||||
{
|
||||
if (SelectedRow is null) return;
|
||||
OnNavigateToGroup?.Invoke(SelectedRow.GroupId);
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void ToggleApproval() => ToggleDate(SelectedRow?.Exam, e => e.ApprovalGrantedAt,
|
||||
(e, v) => e.ApprovalGrantedAt = v);
|
||||
|
||||
[RelayCommand]
|
||||
private void ToggleAnnouncement() => ToggleDate(SelectedRow?.Exam, e => e.AnnouncedAt,
|
||||
(e, v) => e.AnnouncedAt = v);
|
||||
|
||||
private void ToggleDate(Exam? exam, Func<Exam, DateOnly?> get, Action<Exam, DateOnly?> set)
|
||||
{
|
||||
if (exam is null) return;
|
||||
set(exam, get(exam) is null ? DateOnly.FromDateTime(DateTime.Today) : null);
|
||||
exam.UpdatedAt = DateTime.UtcNow;
|
||||
_exams.Save(exam);
|
||||
UpdateDetail(SelectedRow);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Zeile in der Klausurliste ────────────────────────────────────────────────
|
||||
|
||||
public class ExamListRowViewModel
|
||||
{
|
||||
public Exam Exam { get; }
|
||||
public Guid GroupId { get; }
|
||||
public string Title { get; }
|
||||
public string GroupLabel { get; }
|
||||
public int Expected { get; }
|
||||
public int Evaluated { get; }
|
||||
public ExamListStatus Status { get; }
|
||||
public double Score { get; }
|
||||
public string StatusLabel { get; }
|
||||
public string SubLabel { get; }
|
||||
public bool HasSibling { get; set; }
|
||||
|
||||
public bool IsOk { get; }
|
||||
public bool IsInfo { get; }
|
||||
public bool IsWarning { get; }
|
||||
public bool IsDanger { get; }
|
||||
|
||||
public ExamListRowViewModel(Exam exam, LearningGroup group, int expected, int evaluated,
|
||||
ExamPriorityInfo info, DateOnly today)
|
||||
{
|
||||
Exam = exam; GroupId = group.Id; Title = exam.Title; GroupLabel = group.Name;
|
||||
Expected = expected; Evaluated = evaluated; Status = info.Status; Score = info.Score;
|
||||
|
||||
(StatusLabel, SubLabel, IsOk, IsInfo, IsWarning, IsDanger) = Status switch
|
||||
{
|
||||
ExamListStatus.Planned => ("Geplant", $"geplant für {RelativeDay(exam.Date, today)}",
|
||||
false, false, false, false),
|
||||
ExamListStatus.AwaitingCorrection when today.DayNumber - exam.Date.DayNumber <= 3 =>
|
||||
("Korrektur ausstehend", $"geschrieben {RelativeDay(exam.Date, today)}",
|
||||
false, false, true, false),
|
||||
ExamListStatus.AwaitingCorrection =>
|
||||
("überfällig", $"geschrieben {RelativeDay(exam.Date, today)}", false, false, false, true),
|
||||
ExamListStatus.CorrectionInProgress =>
|
||||
("Korrektur läuft", $"{evaluated}/{expected} korrigiert", false, false, true, false),
|
||||
ExamListStatus.CorrectionStuck =>
|
||||
("hängt fest", $"{evaluated}/{expected} korrigiert", false, false, false, false),
|
||||
ExamListStatus.AwaitingReturn => ("Korrigiert", "Rückgabe aussteht", false, true, false, false),
|
||||
ExamListStatus.Returned => ("Abgeschlossen",
|
||||
exam.ReturnedAt is { } r ? $"zurückgegeben {r:dd.MM.yyyy}" : "zurückgegeben", true, false, false, false),
|
||||
_ => ("", "", false, false, false, false),
|
||||
};
|
||||
}
|
||||
|
||||
private static string RelativeDay(DateOnly date, DateOnly today)
|
||||
{
|
||||
var diff = date.DayNumber - today.DayNumber;
|
||||
return diff switch
|
||||
{
|
||||
0 => "heute",
|
||||
1 => "morgen",
|
||||
-1 => "gestern",
|
||||
> 1 => $"in {diff} Tagen",
|
||||
_ => $"vor {-diff} Tagen",
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using LehrerApp.Core.Interfaces;
|
||||
using LehrerApp.Core.Models;
|
||||
using System.Collections.ObjectModel;
|
||||
|
||||
namespace LehrerApp.Desktop.ViewModels;
|
||||
|
||||
/// <summary>
|
||||
/// Globale Suche und Schnellerfassung (14.2). Die Projektion bleibt bewusst klein und lokal:
|
||||
/// durchsucht werden die wichtigsten täglichen Ziele, ohne dafür einen zusätzlichen Suchindex
|
||||
/// oder eine Netzwerkabhängigkeit einzuführen.
|
||||
/// </summary>
|
||||
public partial class GlobalSearchViewModel : ObservableObject
|
||||
{
|
||||
private const int MaxDataResults = 12;
|
||||
|
||||
private readonly IStudentRepository _students;
|
||||
private readonly IGroupRepository _groups;
|
||||
private readonly IExamRepository _exams;
|
||||
private readonly IWorkTaskRepository _tasks;
|
||||
|
||||
[ObservableProperty] private string _query = "";
|
||||
[ObservableProperty] private GlobalSearchResult? _selectedResult;
|
||||
|
||||
public ObservableCollection<GlobalSearchResult> Results { get; } = [];
|
||||
public bool HasResults => Results.Count > 0;
|
||||
public bool ShowNoResults => !string.IsNullOrWhiteSpace(Query) && Results.Count == 0;
|
||||
|
||||
public Action<GlobalSearchResult>? OnNavigate { get; set; }
|
||||
public Func<bool, Task>? OnQuickAddTask { get; set; }
|
||||
public Func<Task>? OnQuickAddStudent { get; set; }
|
||||
public Action? OnClose { get; set; }
|
||||
|
||||
public GlobalSearchViewModel(IStudentRepository students, IGroupRepository groups,
|
||||
IExamRepository exams, IWorkTaskRepository tasks)
|
||||
{
|
||||
_students = students;
|
||||
_groups = groups;
|
||||
_exams = exams;
|
||||
_tasks = tasks;
|
||||
RefreshResults();
|
||||
}
|
||||
|
||||
partial void OnQueryChanged(string value) => RefreshResults();
|
||||
|
||||
public void Reset()
|
||||
{
|
||||
Query = "";
|
||||
RefreshResults();
|
||||
}
|
||||
|
||||
private void RefreshResults()
|
||||
{
|
||||
Results.Clear();
|
||||
var query = Query.Trim();
|
||||
|
||||
AddQuickActions(query);
|
||||
|
||||
if (query.Length > 0)
|
||||
{
|
||||
var groups = _groups.GetAll(includeInactive: true);
|
||||
var groupNames = groups.ToDictionary(g => g.Id, g => g.Name);
|
||||
var candidates = new List<GlobalSearchResult>();
|
||||
|
||||
candidates.AddRange(_students.GetAll(includeInactive: true)
|
||||
.Where(s => Matches(s.FullName, query))
|
||||
.Select(s => GlobalSearchResult.ForStudent(s)));
|
||||
|
||||
candidates.AddRange(groups
|
||||
.Where(g => Matches($"{g.Name} {g.SchoolYear} {g.GradeLevel}", query))
|
||||
.Select(GlobalSearchResult.ForGroup));
|
||||
|
||||
candidates.AddRange(_exams.GetAll()
|
||||
.Where(e => Matches($"{e.Title} {groupNames.GetValueOrDefault(e.GroupId)}", query))
|
||||
.Select(e => GlobalSearchResult.ForExam(e, groupNames.GetValueOrDefault(e.GroupId) ?? "")));
|
||||
|
||||
candidates.AddRange(_tasks.GetAll()
|
||||
.Where(t => Matches($"{t.Title} {t.Notes} {groupNames.GetValueOrDefault(t.GroupId ?? Guid.Empty)}", query))
|
||||
.Select(t => GlobalSearchResult.ForTask(t, groupNames.GetValueOrDefault(t.GroupId ?? Guid.Empty) ?? "")));
|
||||
|
||||
foreach (var item in candidates
|
||||
.OrderByDescending(x => x.Title.StartsWith(query, StringComparison.CurrentCultureIgnoreCase))
|
||||
.ThenBy(x => x.KindSortOrder)
|
||||
.ThenBy(x => x.Title)
|
||||
.Take(MaxDataResults))
|
||||
Results.Add(item);
|
||||
}
|
||||
|
||||
SelectedResult = Results.FirstOrDefault();
|
||||
OnPropertyChanged(nameof(HasResults));
|
||||
OnPropertyChanged(nameof(ShowNoResults));
|
||||
}
|
||||
|
||||
private void AddQuickActions(string query)
|
||||
{
|
||||
var actions = new[]
|
||||
{
|
||||
GlobalSearchResult.ForAction(GlobalSearchAction.NewTask, "Aufgabe anlegen", "Mit Fälligkeit, Gruppe und Priorität", "+"),
|
||||
GlobalSearchResult.ForAction(GlobalSearchAction.NewReminder, "Erinnerung anlegen", "Kurze Notiz ohne Zeiterfassung", "◷"),
|
||||
GlobalSearchResult.ForAction(GlobalSearchAction.NewStudent, "Schüler anlegen", "Neue Stammdaten erfassen", "+"),
|
||||
};
|
||||
|
||||
foreach (var action in actions.Where(a => query.Length == 0 || Matches(a.Title, query)))
|
||||
Results.Add(action);
|
||||
}
|
||||
|
||||
private static bool Matches(string? value, string query) =>
|
||||
value?.Contains(query, StringComparison.CurrentCultureIgnoreCase) == true;
|
||||
|
||||
[RelayCommand]
|
||||
private async Task Execute(GlobalSearchResult? result)
|
||||
{
|
||||
if (result is null) return;
|
||||
|
||||
switch (result.Action)
|
||||
{
|
||||
case GlobalSearchAction.NewTask:
|
||||
if (OnQuickAddTask is not null) await OnQuickAddTask(false);
|
||||
break;
|
||||
case GlobalSearchAction.NewReminder:
|
||||
if (OnQuickAddTask is not null) await OnQuickAddTask(true);
|
||||
break;
|
||||
case GlobalSearchAction.NewStudent:
|
||||
if (OnQuickAddStudent is not null) await OnQuickAddStudent();
|
||||
break;
|
||||
default:
|
||||
OnNavigate?.Invoke(result);
|
||||
break;
|
||||
}
|
||||
|
||||
OnClose?.Invoke();
|
||||
}
|
||||
}
|
||||
|
||||
public enum GlobalSearchResultKind { Action, Student, Group, Exam, Task }
|
||||
public enum GlobalSearchAction { None, NewTask, NewReminder, NewStudent }
|
||||
|
||||
public sealed class GlobalSearchResult
|
||||
{
|
||||
public GlobalSearchResultKind Kind { get; private init; }
|
||||
public GlobalSearchAction Action { get; private init; }
|
||||
public Guid? EntityId { get; private init; }
|
||||
public Guid? GroupId { get; private init; }
|
||||
public string Title { get; private init; } = "";
|
||||
public string Subtitle { get; private init; } = "";
|
||||
public string Icon { get; private init; } = "";
|
||||
public int KindSortOrder => Kind switch
|
||||
{
|
||||
GlobalSearchResultKind.Student => 0,
|
||||
GlobalSearchResultKind.Group => 1,
|
||||
GlobalSearchResultKind.Exam => 2,
|
||||
GlobalSearchResultKind.Task => 3,
|
||||
_ => -1,
|
||||
};
|
||||
public string KindLabel => Kind switch
|
||||
{
|
||||
GlobalSearchResultKind.Student => "Schüler",
|
||||
GlobalSearchResultKind.Group => "Lerngruppe",
|
||||
GlobalSearchResultKind.Exam => "Klausur",
|
||||
GlobalSearchResultKind.Task => "Aufgabe",
|
||||
_ => "Schnellaktion",
|
||||
};
|
||||
|
||||
public static GlobalSearchResult ForAction(GlobalSearchAction action, string title, string subtitle, string icon) =>
|
||||
new() { Kind = GlobalSearchResultKind.Action, Action = action, Title = title, Subtitle = subtitle, Icon = icon };
|
||||
|
||||
public static GlobalSearchResult ForStudent(Student student) => new()
|
||||
{
|
||||
Kind = GlobalSearchResultKind.Student, EntityId = student.Id,
|
||||
Title = student.FullName, Subtitle = student.IsActive ? "Aktiv" : "Inaktiv", Icon = "P",
|
||||
};
|
||||
|
||||
public static GlobalSearchResult ForGroup(LearningGroup group) => new()
|
||||
{
|
||||
Kind = GlobalSearchResultKind.Group, EntityId = group.Id, GroupId = group.Id,
|
||||
Title = group.Name, Subtitle = $"{group.SchoolYear} · Stufe {group.GradeLevel}", Icon = "G",
|
||||
};
|
||||
|
||||
public static GlobalSearchResult ForExam(Exam exam, string groupName) => new()
|
||||
{
|
||||
Kind = GlobalSearchResultKind.Exam, EntityId = exam.Id, GroupId = exam.GroupId,
|
||||
Title = exam.Title, Subtitle = $"{groupName} · {exam.Date:dd.MM.yyyy}", Icon = "K",
|
||||
};
|
||||
|
||||
public static GlobalSearchResult ForTask(WorkTask task, string groupName) => new()
|
||||
{
|
||||
Kind = GlobalSearchResultKind.Task, EntityId = task.Id, GroupId = task.GroupId,
|
||||
Title = task.Title,
|
||||
Subtitle = string.Join(" · ", new[] { groupName, task.DueDate?.ToString("dd.MM.yyyy") ?? "" }
|
||||
.Where(x => !string.IsNullOrWhiteSpace(x))),
|
||||
Icon = task.Kind == TaskKind.Reminder ? "E" : "A",
|
||||
};
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using LehrerApp.Core.Interfaces;
|
||||
using LehrerApp.Core.Models;
|
||||
using LehrerApp.Core.Services;
|
||||
@@ -52,6 +53,16 @@ public partial class ExamGradingDialogViewModel : ObservableObject
|
||||
|
||||
private void SaveRow(ExamResultRow row) => _results.Save(row.ToModel(_exam.Id));
|
||||
|
||||
/// Rest als abwesend markieren (Nutzerwunsch): Schüler, die nie nachschreiben, blockieren
|
||||
/// sonst dauerhaft den live abgeleiteten Korrekturstatus (ExamPriorityService), weil niemand
|
||||
/// eine leere Zeile anfasst, für die es nichts einzutragen gibt. Rührt bewusst nur unberührte
|
||||
/// Zeilen an — bereits eingetragene Punkte/Kommentare bleiben unangetastet.
|
||||
[RelayCommand]
|
||||
private void MarkRemainingAbsent()
|
||||
{
|
||||
foreach (var row in Rows.Where(r => !r.Absent && r.Cells.All(c => c.Value is null)))
|
||||
row.Absent = true;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Zeile im Punkteraster ──────────────────────────────────────────────────
|
||||
|
||||
@@ -21,6 +21,7 @@ public partial class ExamDialogViewModel : ObservableObject
|
||||
private readonly int _gradeLevel;
|
||||
private readonly GradingSystem _gradingSystem;
|
||||
private readonly Exam? _editingExam;
|
||||
private readonly Exam? _duplicateSource;
|
||||
private readonly string _subjectName;
|
||||
|
||||
[ObservableProperty] private string _title = "";
|
||||
@@ -68,6 +69,7 @@ public partial class ExamDialogViewModel : ObservableObject
|
||||
_groupId = groupId; _subjectId = subjectId; _gradeLevel = gradeLevel;
|
||||
_gradingSystem = gradingSystem;
|
||||
_editingExam = editingExam;
|
||||
_duplicateSource = duplicateSource;
|
||||
_subjectName = defaultSubjectName;
|
||||
IsDifferentiated = isDifferentiated;
|
||||
|
||||
@@ -304,6 +306,11 @@ public partial class ExamDialogViewModel : ObservableObject
|
||||
Result.Niveau = NiveauDisplay.FromName(SelectedNiveauName);
|
||||
Result.Tasks = Tasks.Select(t => t.ToModel()).ToList();
|
||||
Result.GradingKey = gradingKey;
|
||||
// Parallelkurse (gleiche Arbeit, mehrere Kurse): beim Duplizieren verknüpfen, damit die
|
||||
// Klausuren-Hauptseite zwischen ihnen umschalten kann. Der Anker ist die Id der zuerst
|
||||
// angelegten Klausur der Kette — schon verknüpfte Quellen geben ihren Anker weiter.
|
||||
if (_duplicateSource is not null)
|
||||
Result.SharedExamGroupId = _duplicateSource.SharedExamGroupId ?? _duplicateSource.Id;
|
||||
_exams.Save(Result);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,13 +13,8 @@ namespace LehrerApp.Desktop.ViewModels.Groups;
|
||||
|
||||
public partial class GroupListViewModel : ObservableObject
|
||||
{
|
||||
private const int QuickTasksMaxCount = 3;
|
||||
|
||||
private readonly IGroupRepository _groups;
|
||||
private readonly ISubjectRepository _subjects;
|
||||
private readonly ILessonRepository _lessons;
|
||||
private readonly IExamRepository _exams;
|
||||
private readonly IWorkTaskRepository _tasks;
|
||||
|
||||
public Action<Guid, int>? OnNavigateToDetail { get; set; }
|
||||
public Func<Task>? OnAddGroup { get; set; }
|
||||
@@ -29,11 +24,8 @@ public partial class GroupListViewModel : ObservableObject
|
||||
|
||||
[ObservableProperty] private string _selectedSchoolYear = "";
|
||||
[ObservableProperty] private string _searchText = "";
|
||||
[ObservableProperty] private GroupListItem? _selectedGroup;
|
||||
[ObservableProperty] private bool _showArchived;
|
||||
|
||||
public string SelectedGroupDisplayName => SelectedGroup?.DisplayName ?? "";
|
||||
public string SelectedGroupSubtitle => SelectedGroup?.Subtitle ?? "";
|
||||
public string ListSummary => ShowArchived
|
||||
? $"{Groups.Count} archivierte Gruppen · {SelectedSchoolYear}"
|
||||
: $"{Groups.Count} aktive Gruppen · {SelectedSchoolYear}";
|
||||
@@ -45,22 +37,10 @@ public partial class GroupListViewModel : ObservableObject
|
||||
public ObservableCollection<string> SchoolYears { get; } = [];
|
||||
public ObservableCollection<GroupListItem> Groups { get; } = [];
|
||||
|
||||
// ── Schnellüberblick im Auswahl-Panel (Nutzer-Feedback: "oberhalb der Buttonliste ein paar
|
||||
// Daten auswerfen. Nächste Stunde, nächste Arbeit, wichtige Todos") — bewusst dieselben
|
||||
// kompakten Kennzahlen wie die obersten Karten des Kurs-Dashboards (GroupOverviewViewModel),
|
||||
// hier nur ohne eigenen Tab-Wechsel, da man ohnehin schon auf der Gruppenliste steht.
|
||||
[ObservableProperty] private bool _quickHasNextLesson;
|
||||
[ObservableProperty] private string _quickNextLessonLabel = "";
|
||||
[ObservableProperty] private bool _quickHasNextExam;
|
||||
[ObservableProperty] private string _quickNextExamLabel = "";
|
||||
public ObservableCollection<GroupTaskItem> QuickTasks { get; } = [];
|
||||
public bool QuickHasTasks => QuickTasks.Count > 0;
|
||||
public bool QuickHasAnything => QuickHasNextLesson || QuickHasNextExam || QuickHasTasks;
|
||||
|
||||
public GroupListViewModel(IGroupRepository groups, ISubjectRepository subjects, SchoolYearService sy,
|
||||
ILessonRepository lessons, IExamRepository exams, IWorkTaskRepository tasks)
|
||||
public GroupListViewModel(IGroupRepository groups, ISubjectRepository subjects, SchoolYearService sy)
|
||||
{
|
||||
_groups = groups; _subjects = subjects; _lessons = lessons; _exams = exams; _tasks = tasks;
|
||||
_groups = groups;
|
||||
_subjects = subjects;
|
||||
foreach (var y in sy.RecentSchoolYears()) SchoolYears.Add(y);
|
||||
SelectedSchoolYear = sy.CurrentSchoolYear();
|
||||
}
|
||||
@@ -68,58 +48,8 @@ public partial class GroupListViewModel : ObservableObject
|
||||
partial void OnSelectedSchoolYearChanged(string value) => LoadGroups();
|
||||
partial void OnSearchTextChanged(string value) => LoadGroups();
|
||||
partial void OnShowArchivedChanged(bool value) => LoadGroups();
|
||||
partial void OnSelectedGroupChanged(GroupListItem? value)
|
||||
{
|
||||
OnPropertyChanged(nameof(SelectedGroupDisplayName));
|
||||
OnPropertyChanged(nameof(SelectedGroupSubtitle));
|
||||
NavigateToSectionCommand.NotifyCanExecuteChanged();
|
||||
EditGroupCommand.NotifyCanExecuteChanged();
|
||||
RollOverGroupCommand.NotifyCanExecuteChanged();
|
||||
ToggleArchiveCommand.NotifyCanExecuteChanged();
|
||||
DeleteGroupCommand.NotifyCanExecuteChanged();
|
||||
LoadQuickInfo();
|
||||
}
|
||||
|
||||
private void LoadQuickInfo()
|
||||
{
|
||||
QuickTasks.Clear();
|
||||
if (SelectedGroup is null)
|
||||
{
|
||||
QuickHasNextLesson = false; QuickNextLessonLabel = "";
|
||||
QuickHasNextExam = false; QuickNextExamLabel = "";
|
||||
OnPropertyChanged(nameof(QuickHasTasks));
|
||||
OnPropertyChanged(nameof(QuickHasAnything));
|
||||
return;
|
||||
}
|
||||
|
||||
var groupId = SelectedGroup.Id;
|
||||
var today = DateOnly.FromDateTime(DateTime.Today);
|
||||
|
||||
var nextLesson = _lessons.GetByGroupAndRange(groupId, today, today.AddDays(90))
|
||||
.Where(l => l.Status == LessonStatus.Planned)
|
||||
.OrderBy(l => l.Date).ThenBy(l => l.LessonNumber ?? 0).FirstOrDefault();
|
||||
QuickHasNextLesson = nextLesson is not null;
|
||||
QuickNextLessonLabel = nextLesson is null ? ""
|
||||
: string.IsNullOrWhiteSpace(nextLesson.Topic)
|
||||
? nextLesson.Date.ToString("dd.MM.yyyy")
|
||||
: $"{nextLesson.Date:dd.MM.yyyy} — {nextLesson.Topic}";
|
||||
|
||||
var nextExam = _exams.GetByGroup(groupId).Where(e => e.Date >= today).MinBy(e => e.Date);
|
||||
QuickHasNextExam = nextExam is not null;
|
||||
QuickNextExamLabel = nextExam is null ? "" : $"{nextExam.Date:dd.MM.yyyy} — {nextExam.Title}";
|
||||
|
||||
foreach (var t in _tasks.GetByGroup(groupId).Where(t => t.Status != WorkTaskStatus.Done)
|
||||
.OrderBy(t => t.DueDate ?? DateOnly.MaxValue).Take(QuickTasksMaxCount))
|
||||
QuickTasks.Add(new GroupTaskItem(t.Title, t.Kind == TaskKind.Reminder,
|
||||
t.DueDate?.ToString("dd.MM.yyyy") ?? "", t.DueDate is { } d && d < today,
|
||||
TaskPriorityDisplay.ColorHex(t.Priority), t.Priority == TaskPriority.High));
|
||||
OnPropertyChanged(nameof(QuickHasTasks));
|
||||
OnPropertyChanged(nameof(QuickHasAnything));
|
||||
}
|
||||
|
||||
public void LoadGroups()
|
||||
{
|
||||
var selectedId = SelectedGroup?.Id;
|
||||
Groups.Clear();
|
||||
var all = _groups.GetBySchoolYear(SelectedSchoolYear, includeInactive: ShowArchived)
|
||||
.Where(g => g.IsActive != ShowArchived);
|
||||
@@ -129,8 +59,18 @@ public partial class GroupListViewModel : ObservableObject
|
||||
|| (g.SubjectId is Guid id && subjectNames.GetValueOrDefault(id, "")
|
||||
.Contains(SearchText, StringComparison.OrdinalIgnoreCase)));
|
||||
foreach (var g in filtered.OrderBy(g => g.Name))
|
||||
Groups.Add(new GroupListItem(g, g.SubjectId is Guid id ? subjectNames.GetValueOrDefault(id, "") : ""));
|
||||
SelectedGroup = Groups.FirstOrDefault(g => g.Id == selectedId);
|
||||
{
|
||||
var item = new GroupListItem(g,
|
||||
g.SubjectId is Guid id ? subjectNames.GetValueOrDefault(id, "") : "")
|
||||
{
|
||||
OnOpen = OpenGroup,
|
||||
OnEdit = EditGroup,
|
||||
OnRollOver = RollOverGroup,
|
||||
OnToggleArchive = ToggleArchive,
|
||||
OnDelete = DeleteGroup,
|
||||
};
|
||||
Groups.Add(item);
|
||||
}
|
||||
OnPropertyChanged(nameof(ListSummary));
|
||||
OnPropertyChanged(nameof(HasNoGroups));
|
||||
OnPropertyChanged(nameof(EmptyListMessage));
|
||||
@@ -145,63 +85,57 @@ public partial class GroupListViewModel : ObservableObject
|
||||
}
|
||||
[RelayCommand] private void Refresh() => LoadGroups();
|
||||
|
||||
[RelayCommand(CanExecute = nameof(CanEditSelectedGroup))]
|
||||
private async Task EditGroup()
|
||||
[RelayCommand]
|
||||
private void OpenGroup(GroupListItem? group)
|
||||
{
|
||||
if (SelectedGroup is null || OnEditGroup is null) return;
|
||||
var id = SelectedGroup.Id;
|
||||
await OnEditGroup(id);
|
||||
LoadGroups();
|
||||
SelectedGroup = Groups.FirstOrDefault(g => g.Id == id);
|
||||
if (group is not null) OnNavigateToDetail?.Invoke(group.Id, 0);
|
||||
}
|
||||
|
||||
[RelayCommand(CanExecute = nameof(HasSelectedGroup))]
|
||||
private async Task RollOverGroup()
|
||||
[RelayCommand]
|
||||
private async Task EditGroup(GroupListItem? group)
|
||||
{
|
||||
if (SelectedGroup is null || OnRollOverGroup is null) return;
|
||||
var targetId = await OnRollOverGroup(SelectedGroup.Id);
|
||||
if (group?.IsActive != true || OnEditGroup is null) return;
|
||||
var id = group.Id;
|
||||
await OnEditGroup(id);
|
||||
LoadGroups();
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private async Task RollOverGroup(GroupListItem? group)
|
||||
{
|
||||
if (group is null || OnRollOverGroup is null) return;
|
||||
var targetId = await OnRollOverGroup(group.Id);
|
||||
if (targetId is null) return;
|
||||
var target = _groups.GetById(targetId.Value);
|
||||
if (target is null) return;
|
||||
if (!SchoolYears.Contains(target.SchoolYear)) SchoolYears.Insert(0, target.SchoolYear);
|
||||
SelectedSchoolYear = target.SchoolYear;
|
||||
LoadGroups();
|
||||
SelectedGroup = Groups.FirstOrDefault(g => g.Id == target.Id);
|
||||
OnNavigateToDetail?.Invoke(target.Id, 0);
|
||||
}
|
||||
|
||||
[RelayCommand(CanExecute = nameof(HasSelectedGroup))]
|
||||
private void ToggleArchive()
|
||||
[RelayCommand]
|
||||
private void ToggleArchive(GroupListItem? selected)
|
||||
{
|
||||
if (SelectedGroup is null) return;
|
||||
var group = _groups.GetById(SelectedGroup.Id);
|
||||
if (selected is null) return;
|
||||
var group = _groups.GetById(selected.Id);
|
||||
if (group is null) return;
|
||||
group.IsActive = !group.IsActive;
|
||||
_groups.Save(group);
|
||||
LoadGroups();
|
||||
}
|
||||
|
||||
[RelayCommand(CanExecute = nameof(CanEditSelectedGroup))]
|
||||
private async Task DeleteGroup()
|
||||
[RelayCommand]
|
||||
private async Task DeleteGroup(GroupListItem? group)
|
||||
{
|
||||
if (SelectedGroup is null || OnConfirmDelete is null) return;
|
||||
var selected = SelectedGroup;
|
||||
if (!await OnConfirmDelete(selected)) return;
|
||||
_groups.Delete(selected.Id);
|
||||
if (group?.IsActive != true || OnConfirmDelete is null) return;
|
||||
if (!await OnConfirmDelete(group)) return;
|
||||
_groups.Delete(group.Id);
|
||||
LoadGroups();
|
||||
}
|
||||
|
||||
[RelayCommand(CanExecute = nameof(HasSelectedGroup))]
|
||||
private void NavigateToSection(string? tabIndex)
|
||||
{
|
||||
if (SelectedGroup is null || !int.TryParse(tabIndex, out var tab)) return;
|
||||
OnNavigateToDetail?.Invoke(SelectedGroup.Id, tab);
|
||||
}
|
||||
|
||||
private bool HasSelectedGroup() => SelectedGroup is not null;
|
||||
private bool CanEditSelectedGroup() => SelectedGroup?.IsActive == true;
|
||||
}
|
||||
|
||||
public class GroupListItem
|
||||
public partial class GroupListItem : ObservableObject
|
||||
{
|
||||
public Guid Id { get; }
|
||||
public string Name { get; }
|
||||
@@ -212,6 +146,13 @@ public class GroupListItem
|
||||
public string Subtitle { get; }
|
||||
public bool IsActive { get; }
|
||||
public string ArchiveActionLabel => IsActive ? "Archivieren" : "Wieder aktivieren";
|
||||
public string OpenAutomationName => $"Lerngruppe {DisplayName} öffnen";
|
||||
public string ManageAutomationName => $"Lerngruppe {DisplayName} verwalten";
|
||||
public Action<GroupListItem>? OnOpen { get; init; }
|
||||
public Func<GroupListItem, Task>? OnEdit { get; init; }
|
||||
public Func<GroupListItem, Task>? OnRollOver { get; init; }
|
||||
public Action<GroupListItem>? OnToggleArchive { get; init; }
|
||||
public Func<GroupListItem, Task>? OnDelete { get; init; }
|
||||
|
||||
public GroupListItem(LearningGroup g, string subjectName)
|
||||
{
|
||||
@@ -224,6 +165,12 @@ public class GroupListItem
|
||||
DisplayName = string.IsNullOrEmpty(subjectName) ? g.Name : $"{g.Name} · {subjectName}";
|
||||
Subtitle = $"{TypeLabel} · Stufe {g.GradeLevel} · Noten {GradingLabel} · {g.SchoolYear}";
|
||||
}
|
||||
|
||||
[RelayCommand] private void Open() => OnOpen?.Invoke(this);
|
||||
[RelayCommand] private Task Edit() => OnEdit?.Invoke(this) ?? Task.CompletedTask;
|
||||
[RelayCommand] private Task RollOver() => OnRollOver?.Invoke(this) ?? Task.CompletedTask;
|
||||
[RelayCommand] private void ToggleArchive() => OnToggleArchive?.Invoke(this);
|
||||
[RelayCommand] private Task Delete() => OnDelete?.Invoke(this) ?? Task.CompletedTask;
|
||||
}
|
||||
|
||||
// ── Gruppendetail ─────────────────────────────────────────────────────────────
|
||||
@@ -878,6 +825,7 @@ public partial class AddGroupDialogViewModel : ObservableObject
|
||||
[ObservableProperty] private bool _isOwnClass;
|
||||
[ObservableProperty] private bool _isDifferentiated;
|
||||
[ObservableProperty] private bool _requiresLessonPlanning = true;
|
||||
[ObservableProperty] private int? _webUntisLessonId;
|
||||
[ObservableProperty] private string _nameError = "";
|
||||
[ObservableProperty] private string _gradeLevelError = "";
|
||||
|
||||
@@ -917,6 +865,7 @@ public partial class AddGroupDialogViewModel : ObservableObject
|
||||
IsOwnClass = group.IsOwnClass;
|
||||
IsDifferentiated = group.IsDifferentiated;
|
||||
RequiresLessonPlanning = group.RequiresLessonPlanning;
|
||||
WebUntisLessonId = group.WebUntisLessonId;
|
||||
OnPropertyChanged(nameof(DialogTitle));
|
||||
OnPropertyChanged(nameof(SaveButtonText));
|
||||
}
|
||||
@@ -963,6 +912,7 @@ public partial class AddGroupDialogViewModel : ObservableObject
|
||||
Result.IsOwnClass = IsOwnClass;
|
||||
Result.IsDifferentiated = IsDifferentiated;
|
||||
Result.RequiresLessonPlanning = RequiresLessonPlanning;
|
||||
Result.WebUntisLessonId = WebUntisLessonId;
|
||||
_groups.Save(Result);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -236,6 +236,13 @@ public partial class SeatingPlanTabViewModel : ObservableObject
|
||||
NotifyCommands();
|
||||
}
|
||||
|
||||
/// Öffentlich statt intern (kein InternalsVisibleTo in dieser Codebasis, siehe
|
||||
/// UntisSyncService-Kommentar): der Unterrichtsmodus bewertet über eine zweite, unabhängige
|
||||
/// ParticipationTabViewModel-Instanz (Schnellbewertungs-Dialoge, siehe TeachingModeWindow) —
|
||||
/// diese Instanz hier bekommt davon nichts automatisch mit und muss nach jedem Dialog explizit
|
||||
/// neu aus dem Repository laden, damit die Sitzplatz-Badges nicht veraltet bleiben.
|
||||
public void ReloadSeatBadgesFromRepository() => RefreshSeatLessonData();
|
||||
|
||||
private void RefreshSeatLessonData()
|
||||
{
|
||||
if (SelectedSession is null)
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using LehrerApp.Core.Interfaces;
|
||||
using LehrerApp.Core.Models;
|
||||
|
||||
@@ -10,24 +12,104 @@ namespace LehrerApp.Desktop.ViewModels.Groups;
|
||||
/// der "Heute"-Tagesliste heraus gestartet.
|
||||
///
|
||||
/// Bewusst keine eigene DI-Registrierung als Singleton/Transient: wie LessonViewerViewModel wird
|
||||
/// diese ViewModel direkt vom Code-Behind konstruiert, das dafür nötige SeatingPlanTabViewModel
|
||||
/// aber weiterhin über DI aufgelöst (transient, siehe AppBootstrapper) und hier per Konstruktor
|
||||
/// entgegengenommen statt selbst aus App.Services zu ziehen - ViewModels greifen in diesem Code
|
||||
/// nicht selbst auf den Service-Container zu, das bleibt Aufgabe des Code-Behind.
|
||||
/// diese ViewModel direkt vom Code-Behind konstruiert, das dafür nötige SeatingPlanTabViewModel/
|
||||
/// ParticipationTabViewModel aber weiterhin über DI aufgelöst (transient, siehe AppBootstrapper)
|
||||
/// und hier per Konstruktor entgegengenommen statt selbst aus App.Services zu ziehen -
|
||||
/// ViewModels greifen in diesem Code nicht selbst auf den Service-Container zu, das bleibt
|
||||
/// Aufgabe des Code-Behind.
|
||||
/// </summary>
|
||||
public class TeachingModeViewModel
|
||||
{
|
||||
public string GroupName { get; }
|
||||
public LessonViewerViewModel LessonInfo { get; }
|
||||
public SeatingPlanTabViewModel SeatingPlan { get; }
|
||||
/// <summary>Nutzer-Feedback: die Schnellbewertungs-Dialoge für Mitarbeit/Anwesenheit-Hausaufgabe
|
||||
/// gab es bislang nur über den Mitarbeit-Tab der Gruppe — im Unterrichtsmodus musste man dafür
|
||||
/// erst wieder raus. Hier dieselbe <see cref="ParticipationTabViewModel"/> wie im Mitarbeit-Tab,
|
||||
/// aber auf die zu dieser Stunde gehörende Sitzung vorselektiert (siehe Konstruktor), damit die
|
||||
/// über <c>OnQuickInput</c>/<c>OnStatusQuickInput</c> geöffneten Dialoge (Wiring in
|
||||
/// TeachingModeWindow.axaml.cs, gleiches Muster wie ParticipationTabView.axaml.cs) direkt auf
|
||||
/// der richtigen Sitzung starten.</summary>
|
||||
public ParticipationTabViewModel Participation { get; }
|
||||
public TeachingModeHomeworkViewModel Homework { get; }
|
||||
|
||||
public TeachingModeViewModel(Lesson lesson, LearningGroup group,
|
||||
IAlternativeLessonPathRepository alternativePaths, SeatingPlanTabViewModel seatingPlan)
|
||||
IAlternativeLessonPathRepository alternativePaths, ILessonRepository lessons,
|
||||
SeatingPlanTabViewModel seatingPlan, ParticipationTabViewModel participation)
|
||||
{
|
||||
GroupName = group.Name;
|
||||
LessonInfo = new LessonViewerViewModel(lesson, alternativePaths);
|
||||
|
||||
SeatingPlan = seatingPlan;
|
||||
SeatingPlan.Initialize(group.Id, !group.IsActive);
|
||||
SeatingPlan.SelectOrCreateSessionForLesson(lesson);
|
||||
|
||||
Participation = participation;
|
||||
Participation.Initialize(group.Id, group.SchoolYear, !group.IsActive);
|
||||
if (SeatingPlan.SelectedSession is { } linkedSession)
|
||||
Participation.SelectedSession =
|
||||
Participation.Sessions.FirstOrDefault(s => s.Id == linkedSession.Id);
|
||||
|
||||
// 4.5.4: dieselbe "letzte Stunde vor dieser"-Suche wie das Stundenplan-Badge
|
||||
// "Hausaufgabe kontrollieren" (TimetableViewModel.HasUnhandledHomework) - 120 Tage
|
||||
// Lookback deckt auch längere Ferienpausen ab, ohne unbegrenzt weit zurückzuscannen.
|
||||
var previousLesson = lessons.GetByGroupAndRange(group.Id, lesson.Date.AddDays(-120), lesson.Date.AddDays(-1))
|
||||
.OrderByDescending(l => l.Date).ThenByDescending(l => l.LessonNumber ?? 0)
|
||||
.FirstOrDefault();
|
||||
Homework = new TeachingModeHomeworkViewModel(lesson, previousLesson, lessons);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Nutzer-Feedback: im Unterrichtsmodus sollte man die Hausaufgabe aus der letzten Stunde als
|
||||
/// kontrolliert abhaken und die neue Hausaufgabe eintragen/ändern können, ohne dafür den vollen
|
||||
/// <see cref="LehrerApp.Desktop.Views.Groups.LessonDialog"/>-Editor zu öffnen (der zusätzlich Phasen/Reflexion usw. zeigt)
|
||||
/// - manchmal ergibt sich die Hausaufgabe erst während der Stunde. Bewusst eine eigene, kleine
|
||||
/// <see cref="ObservableObject"/> statt Felder direkt auf <see cref="TeachingModeViewModel"/>, da
|
||||
/// diese Klasse (wie <see cref="LessonViewerViewModel"/>) selbst keine Bindable-Basisklasse hat.
|
||||
/// </summary>
|
||||
public partial class TeachingModeHomeworkViewModel : ObservableObject
|
||||
{
|
||||
private readonly ILessonRepository _lessons;
|
||||
private readonly Lesson _lesson;
|
||||
private readonly Lesson? _previousLesson;
|
||||
|
||||
public bool HasPreviousHomework =>
|
||||
_previousLesson is not null && !string.IsNullOrWhiteSpace(_previousLesson.Homework);
|
||||
public string PreviousHomeworkText => _previousLesson?.Homework ?? "";
|
||||
public string PreviousLessonLabel => _previousLesson is null ? "" :
|
||||
$"{_previousLesson.Date:dd.MM.yyyy}" + (string.IsNullOrWhiteSpace(_previousLesson.Topic)
|
||||
? "" : $" · {_previousLesson.Topic}");
|
||||
|
||||
[ObservableProperty] private bool _previousHomeworkChecked;
|
||||
[ObservableProperty] private string _currentHomework;
|
||||
[ObservableProperty] private string _saveStatus = "";
|
||||
|
||||
public TeachingModeHomeworkViewModel(Lesson lesson, Lesson? previousLesson, ILessonRepository lessons)
|
||||
{
|
||||
_lesson = lesson;
|
||||
_previousLesson = previousLesson;
|
||||
_lessons = lessons;
|
||||
_previousHomeworkChecked = previousLesson?.HomeworkChecked ?? false;
|
||||
_currentHomework = lesson.Homework ?? "";
|
||||
}
|
||||
|
||||
/// Sofort beim Umschalten gespeichert (kein separater Speichern-Klick nötig) - dieselbe
|
||||
/// Checkbox-Semantik wie im LessonDialog (4.5.4): "kontrolliert" und "nicht kontrollieren"
|
||||
/// schließen sich aus.
|
||||
partial void OnPreviousHomeworkCheckedChanged(bool value)
|
||||
{
|
||||
if (_previousLesson is null) return;
|
||||
_previousLesson.HomeworkChecked = value;
|
||||
if (value) _previousLesson.HomeworkCheckDismissed = false;
|
||||
_lessons.Save(_previousLesson);
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void SaveCurrentHomework()
|
||||
{
|
||||
_lesson.Homework = CurrentHomework;
|
||||
_lessons.Save(_lesson);
|
||||
SaveStatus = "Gespeichert.";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,131 +0,0 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.ObjectModel;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using LehrerApp.Core.Importing;
|
||||
using LehrerApp.Core.Interfaces;
|
||||
using LehrerApp.Core.Models;
|
||||
using LehrerApp.Desktop.Services;
|
||||
|
||||
namespace LehrerApp.Desktop.ViewModels.Groups;
|
||||
|
||||
public partial class WebUntisAbsenceRow : ObservableObject
|
||||
{
|
||||
public required string StudentName { get; init; }
|
||||
public required DateOnly Date { get; init; }
|
||||
public required string TimeLabel { get; init; }
|
||||
public required string UntisStatus { get; init; }
|
||||
public required string LocalStatus { get; init; }
|
||||
public required AttendanceStatus TargetStatus { get; init; }
|
||||
public required Guid StudentId { get; init; }
|
||||
public required Guid? SessionId { get; init; }
|
||||
public string? Reason { get; init; }
|
||||
public string DateLabel => Date.ToString("dd.MM.yyyy");
|
||||
public bool CanApply => SessionId is not null;
|
||||
[ObservableProperty] private bool _selected;
|
||||
}
|
||||
|
||||
public partial class WebUntisAbsenceComparisonViewModel : ObservableObject
|
||||
{
|
||||
private readonly LearningGroup _group;
|
||||
private readonly WebUntisIntegrationService _untis;
|
||||
private readonly IStudentRepository _students;
|
||||
private readonly IParticipationSessionRepository _sessions;
|
||||
private readonly IParticipationRepository _participation;
|
||||
|
||||
public ObservableCollection<WebUntisAbsenceRow> Rows { get; } = [];
|
||||
[ObservableProperty] private DateTimeOffset _startDate = DateTimeOffset.Now.AddMonths(-2);
|
||||
[ObservableProperty] private DateTimeOffset _endDate = DateTimeOffset.Now;
|
||||
[ObservableProperty] private string _status = "Zeitraum wählen und Fehlzeiten laden.";
|
||||
[ObservableProperty] private bool _busy;
|
||||
|
||||
public WebUntisAbsenceComparisonViewModel(LearningGroup group, WebUntisIntegrationService untis,
|
||||
IStudentRepository students, IParticipationSessionRepository sessions,
|
||||
IParticipationRepository participation)
|
||||
{
|
||||
_group = group; _untis = untis; _students = students; _sessions = sessions;
|
||||
_participation = participation;
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private async Task Load()
|
||||
{
|
||||
var start = DateOnly.FromDateTime(StartDate.LocalDateTime);
|
||||
var end = DateOnly.FromDateTime(EndDate.LocalDateTime);
|
||||
if (end < start) { Status = "Das Enddatum darf nicht vor dem Startdatum liegen."; return; }
|
||||
Busy = true; Rows.Clear();
|
||||
try
|
||||
{
|
||||
var courseStudents = _students.GetByGroup(_group.Id);
|
||||
var localSessions = _sessions.GetByGroup(_group.Id)
|
||||
.Where(x => x.Date >= start && x.Date <= end).GroupBy(x => x.Date)
|
||||
.ToDictionary(x => x.Key, x => x.First());
|
||||
var loaded = new ConcurrentBag<(Student Student, UntisStudentAbsenceDto Absence)>();
|
||||
var linked = courseStudents.Select(student => (Student: student, Key: StudentKey(student)))
|
||||
.Where(x => x.Key is not null).ToList();
|
||||
await Parallel.ForEachAsync(linked, new ParallelOptions { MaxDegreeOfParallelism = 4 }, async (item, token) =>
|
||||
{
|
||||
var report = await _untis.GetAbsencesAsync(item.Key!.Value, start, end, token);
|
||||
foreach (var absence in report.Absences) loaded.Add((item.Student, absence));
|
||||
});
|
||||
|
||||
foreach (var item in loaded.OrderBy(x => x.Absence.Date).ThenBy(x => x.Student.FullName))
|
||||
{
|
||||
if (!TryDate(item.Absence.Date, out var date)) continue;
|
||||
localSessions.TryGetValue(date, out var session);
|
||||
var entry = session is null ? null : _participation.GetBySessionAndStudent(session.Id, item.Student.Id);
|
||||
var target = MapStatus(item.Absence.ExcuseStatus);
|
||||
Rows.Add(new WebUntisAbsenceRow
|
||||
{
|
||||
StudentName = item.Student.FullName, StudentId = item.Student.Id, Date = date,
|
||||
TimeLabel = $"{Time(item.Absence.StartTime)}–{Time(item.Absence.EndTime)}",
|
||||
UntisStatus = DisplayUntisStatus(item.Absence),
|
||||
LocalStatus = entry?.Attendance?.ToString() ?? (session is null ? "keine lokale Stunde" : "nicht erfasst"),
|
||||
TargetStatus = target, SessionId = session?.Id, Reason = item.Absence.AbsenceReason,
|
||||
Selected = session is not null && entry?.Attendance != target,
|
||||
});
|
||||
}
|
||||
var withoutKey = courseStudents.Count - linked.Count;
|
||||
Status = $"{Rows.Count} Untis-Fehlzeiten gefunden; {Rows.Count(x => x.CanApply)} sind einer lokalen Kursstunde zuordenbar."
|
||||
+ (withoutKey > 0 ? $" {withoutKey} Schüler haben noch keine WebUntis-Kennung." : "");
|
||||
}
|
||||
catch (WebUntisIntegrationException ex) { Status = ex.Message; }
|
||||
finally { Busy = false; }
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void Apply()
|
||||
{
|
||||
var selected = Rows.Where(x => x.Selected && x.SessionId is not null).ToList();
|
||||
foreach (var row in selected)
|
||||
{
|
||||
var entry = _participation.GetBySessionAndStudent(row.SessionId!.Value, row.StudentId)
|
||||
?? new ParticipationEntry { SessionId = row.SessionId.Value, StudentId = row.StudentId };
|
||||
entry.Attendance = row.TargetStatus;
|
||||
entry.UpdatedAt = DateTime.UtcNow;
|
||||
_participation.Save(entry);
|
||||
}
|
||||
Status = $"{selected.Count} Anwesenheitsstatus übernommen.";
|
||||
foreach (var row in selected) row.Selected = false;
|
||||
}
|
||||
|
||||
private static int? StudentKey(Student student)
|
||||
{
|
||||
student.ExternalIds ??= [];
|
||||
return student.ExternalIds.TryGetValue(StudentImportFormats.MasterDataCsv.Value, out var value)
|
||||
&& int.TryParse(value, out var key) ? key : null;
|
||||
}
|
||||
private static AttendanceStatus MapStatus(string? value)
|
||||
{
|
||||
var text = value?.Trim().ToLowerInvariant() ?? "";
|
||||
if (text.Contains("unexcused") || text.Contains("unentschuldigt") || text.Contains("nicht entschuldigt"))
|
||||
return AttendanceStatus.Unexcused;
|
||||
if (text.Contains("excused") || text.Contains("entschuldigt")) return AttendanceStatus.Excused;
|
||||
return AttendanceStatus.ExcusePending;
|
||||
}
|
||||
private static string DisplayUntisStatus(UntisStudentAbsenceDto absence) =>
|
||||
string.Join(" · ", new[] { absence.ExcuseStatus, absence.AbsenceReason }.Where(x => !string.IsNullOrWhiteSpace(x)))
|
||||
is { Length: > 0 } text ? text : "offen";
|
||||
private static bool TryDate(int value, out DateOnly date) => DateOnly.TryParseExact(value.ToString(), "yyyyMMdd", out date);
|
||||
private static string Time(int value) => $"{value / 100:00}:{value % 100:00}";
|
||||
}
|
||||
@@ -0,0 +1,238 @@
|
||||
using System.Collections.ObjectModel;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using LehrerApp.Core.Importing;
|
||||
using LehrerApp.Core.Interfaces;
|
||||
using LehrerApp.Core.Models;
|
||||
using LehrerApp.Desktop.Services;
|
||||
|
||||
namespace LehrerApp.Desktop.ViewModels.Groups;
|
||||
|
||||
/// <summary>Eine Zeile bleibt auch ohne automatische Zuordnung sichtbar (statt stillschweigend
|
||||
/// weggefiltert zu werden) - <see cref="AssignedStudent"/> kann manuell per Auswahlliste gesetzt
|
||||
/// werden, wenn weder `ENr` noch der Name eindeutig auf ein Kursmitglied passen.</summary>
|
||||
public partial class WebUntisLessonAbsenceRow : ObservableObject
|
||||
{
|
||||
public required string UntisStudentName { get; init; }
|
||||
public required DateOnly Date { get; init; }
|
||||
public required string TimeLabel { get; init; }
|
||||
public required string UntisStatus { get; init; }
|
||||
public required AttendanceStatus TargetStatus { get; init; }
|
||||
public required IReadOnlyList<Student> Candidates { get; init; }
|
||||
internal Action<WebUntisLessonAbsenceRow>? OnAssignmentChanged { get; init; }
|
||||
public string? Reason { get; init; }
|
||||
public string DateLabel => Date.ToString("dd.MM.yyyy");
|
||||
public bool CanApply => SessionId is not null;
|
||||
|
||||
[ObservableProperty] private Student? _assignedStudent;
|
||||
[ObservableProperty] private string _localStatus = "ohne Zuordnung";
|
||||
[ObservableProperty] private Guid? _sessionId;
|
||||
[ObservableProperty] private bool _selected;
|
||||
|
||||
partial void OnAssignedStudentChanged(Student? value) => OnAssignmentChanged?.Invoke(this);
|
||||
}
|
||||
|
||||
/// <summary>Fehlzeitenabgleich über den "Fehlzeiten pro Unterricht"-Bericht (<see cref="LearningGroup.WebUntisLessonId"/>).
|
||||
/// Der früher genutzte, pro Schüler abgerufene Fehlzeiten-Report (getTimetableWithAbsences) brauchte
|
||||
/// weitergehende WebUntis-Rechte als dieser Lerngruppen-Bericht und wurde deshalb entfernt.</summary>
|
||||
public partial class WebUntisLessonAbsenceComparisonViewModel : ObservableObject
|
||||
{
|
||||
private readonly LearningGroup _group;
|
||||
private readonly WebUntisIntegrationService _untis;
|
||||
private readonly IStudentRepository _students;
|
||||
private readonly IParticipationSessionRepository _sessions;
|
||||
private readonly IParticipationRepository _participation;
|
||||
|
||||
private IReadOnlyList<Student> _loadedStudents = [];
|
||||
private IReadOnlyDictionary<DateOnly, ParticipationSession> _loadedSessions =
|
||||
new Dictionary<DateOnly, ParticipationSession>();
|
||||
|
||||
public ObservableCollection<WebUntisLessonAbsenceRow> Rows { get; } = [];
|
||||
[ObservableProperty] private DateTimeOffset _startDate = DateTimeOffset.Now.AddMonths(-2);
|
||||
[ObservableProperty] private DateTimeOffset _endDate = DateTimeOffset.Now;
|
||||
[ObservableProperty] private string _status = "Zeitraum wählen und Fehlzeiten laden.";
|
||||
[ObservableProperty] private bool _busy;
|
||||
[ObservableProperty] private bool _markUnknownAsPresent;
|
||||
|
||||
public WebUntisLessonAbsenceComparisonViewModel(LearningGroup group, WebUntisIntegrationService untis,
|
||||
IStudentRepository students, IParticipationSessionRepository sessions,
|
||||
IParticipationRepository participation)
|
||||
{
|
||||
_group = group; _untis = untis; _students = students; _sessions = sessions;
|
||||
_participation = participation;
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private async Task Load()
|
||||
{
|
||||
var lessonId = _group.WebUntisLessonId;
|
||||
if (lessonId is null)
|
||||
{
|
||||
Status = "Für diese Lerngruppe ist noch keine WebUntis-Unterrichtsnummer hinterlegt " +
|
||||
"(Gruppe bearbeiten).";
|
||||
return;
|
||||
}
|
||||
|
||||
var start = DateOnly.FromDateTime(StartDate.LocalDateTime);
|
||||
var end = DateOnly.FromDateTime(EndDate.LocalDateTime);
|
||||
if (end < start) { Status = "Das Enddatum darf nicht vor dem Startdatum liegen."; return; }
|
||||
Busy = true; Rows.Clear();
|
||||
try
|
||||
{
|
||||
var courseStudents = _students.GetByGroup(_group.Id);
|
||||
var localSessions = _sessions.GetByGroup(_group.Id)
|
||||
.Where(x => x.Date >= start && x.Date <= end).GroupBy(x => x.Date)
|
||||
.ToDictionary(x => x.Key, x => x.First());
|
||||
_loadedStudents = courseStudents;
|
||||
_loadedSessions = localSessions;
|
||||
// Erste Wahl: WebUntis-Kennung (ENr). Nicht jeder Schüler hat eine (z.B. manuell statt
|
||||
// per WebUntis-Import angelegt) - Fallback über den Namen, aber nur wenn er innerhalb
|
||||
// der Kursmitglieder eindeutig ist, sonst lieber unzugeordnet lassen als raten.
|
||||
var byKey = courseStudents.Select(student => (Student: student, Key: StudentKey(student)))
|
||||
.Where(x => x.Key is not null).ToDictionary(x => x.Key!.Value, x => x.Student);
|
||||
var byName = courseStudents
|
||||
.SelectMany(student => new[]
|
||||
{
|
||||
NameKey($"{student.LastName} {student.FirstName}"),
|
||||
NameKey($"{student.FirstName} {student.LastName}"),
|
||||
}.Select(key => (Key: key, Student: student)))
|
||||
.GroupBy(x => x.Key)
|
||||
.Where(group => group.Select(x => x.Student).Distinct().Count() == 1)
|
||||
.ToDictionary(group => group.Key, group => group.First().Student);
|
||||
|
||||
var absences = await _untis.GetLessonAbsencesAsync(lessonId.Value, start, end);
|
||||
var ordered = absences
|
||||
.Select(absence => (Absence: absence, Date: TryDate(absence.Date, out var date) ? date : (DateOnly?)null))
|
||||
.Where(x => x.Date is not null)
|
||||
.OrderBy(x => x.Date).ThenBy(x => x.Absence.StudentName);
|
||||
|
||||
void ResolveLocalMatch(WebUntisLessonAbsenceRow row)
|
||||
{
|
||||
if (row.AssignedStudent is not { } student)
|
||||
{
|
||||
row.SessionId = null; row.LocalStatus = "ohne Zuordnung"; row.Selected = false;
|
||||
return;
|
||||
}
|
||||
localSessions.TryGetValue(row.Date, out var session);
|
||||
var entry = session is null ? null : _participation.GetBySessionAndStudent(session.Id, student.Id);
|
||||
row.SessionId = session?.Id;
|
||||
row.LocalStatus = session is null ? "keine lokale Stunde" : AttendanceDisplay.Label(entry?.Attendance);
|
||||
row.Selected = session is not null && entry?.Attendance != row.TargetStatus;
|
||||
}
|
||||
|
||||
foreach (var (absence, date) in ordered)
|
||||
{
|
||||
var match = absence.ExternKey is { } key && byKey.TryGetValue(key, out var byKeyStudent)
|
||||
? byKeyStudent
|
||||
: byName.GetValueOrDefault(NameKey(absence.StudentName));
|
||||
var row = new WebUntisLessonAbsenceRow
|
||||
{
|
||||
UntisStudentName = absence.StudentName, Date = date!.Value,
|
||||
TimeLabel = TimeLabel(absence.StartTime, absence.EndTime),
|
||||
UntisStatus = DisplayUntisStatus(absence),
|
||||
TargetStatus = MapStatus(absence), Reason = absence.Reason,
|
||||
Candidates = courseStudents, OnAssignmentChanged = ResolveLocalMatch,
|
||||
};
|
||||
Rows.Add(row);
|
||||
row.AssignedStudent = match; // löst OnAssignedStudentChanged aus und setzt SessionId/LocalStatus/Selected
|
||||
}
|
||||
|
||||
var unresolved = Rows.Count(x => x.AssignedStudent is null);
|
||||
Status = $"{absences.Count} Fehlzeiten von WebUntis erhalten, {Rows.Count - unresolved} automatisch zugeordnet" +
|
||||
(unresolved > 0 ? $", {unresolved} bitte manuell zuordnen" : "") +
|
||||
$". {Rows.Count(x => x.CanApply)} sind einer lokalen Kursstunde zuordenbar.";
|
||||
}
|
||||
catch (WebUntisIntegrationException ex) { Status = ex.Message; }
|
||||
finally { Busy = false; }
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void Apply()
|
||||
{
|
||||
var selected = Rows.Where(x => x.Selected && x.SessionId is not null && x.AssignedStudent is not null).ToList();
|
||||
foreach (var row in selected)
|
||||
{
|
||||
var studentId = row.AssignedStudent!.Id;
|
||||
var entry = _participation.GetBySessionAndStudent(row.SessionId!.Value, studentId)
|
||||
?? new ParticipationEntry { SessionId = row.SessionId.Value, StudentId = studentId };
|
||||
entry.Attendance = row.TargetStatus;
|
||||
entry.UpdatedAt = DateTime.UtcNow;
|
||||
_participation.Save(entry);
|
||||
}
|
||||
|
||||
var presentCount = MarkUnknownAsPresent ? FillUnknownAsPresent() : 0;
|
||||
Status = $"{selected.Count} Anwesenheitsstatus übernommen." +
|
||||
(MarkUnknownAsPresent ? $" {presentCount} unbekannte Status auf anwesend gesetzt." : "");
|
||||
foreach (var row in selected) row.Selected = false;
|
||||
}
|
||||
|
||||
// "Identifiziert" heißt hier: WebUntis hat für diesen Schüler an diesem Tag überhaupt eine Zeile
|
||||
// gemeldet - unabhängig davon, ob die Zeile markiert/übernommen wurde. Nur wer für den geladenen
|
||||
// Zeitraum weder von WebUntis gemeldet noch lokal schon kontrolliert wurde, gilt als "unbekannt"
|
||||
// und wird auf anwesend gesetzt; bereits erfasste Einträge (auch ohne Anwesenheitsstatus, z.B. nur
|
||||
// mit Notiz) werden nicht überschrieben, wenn ihr Anwesenheitsstatus schon gesetzt ist.
|
||||
private int FillUnknownAsPresent()
|
||||
{
|
||||
var identified = Rows.Where(x => x.AssignedStudent is not null)
|
||||
.Select(x => (x.Date, StudentId: x.AssignedStudent!.Id))
|
||||
.ToHashSet();
|
||||
var filled = 0;
|
||||
foreach (var session in _loadedSessions.Values)
|
||||
foreach (var student in _loadedStudents)
|
||||
{
|
||||
if (identified.Contains((session.Date, student.Id))) continue;
|
||||
var entry = _participation.GetBySessionAndStudent(session.Id, student.Id);
|
||||
if (entry?.Attendance is not null) continue;
|
||||
entry ??= new ParticipationEntry { SessionId = session.Id, StudentId = student.Id };
|
||||
entry.Attendance = AttendanceStatus.Present;
|
||||
entry.UpdatedAt = DateTime.UtcNow;
|
||||
_participation.Save(entry);
|
||||
filled++;
|
||||
}
|
||||
return filled;
|
||||
}
|
||||
|
||||
private static int? StudentKey(Student student)
|
||||
{
|
||||
student.ExternalIds ??= [];
|
||||
return student.ExternalIds.TryGetValue(StudentImportFormats.MasterDataCsv.Value, out var value)
|
||||
&& int.TryParse(value, out var key) ? key : null;
|
||||
}
|
||||
|
||||
// Groß-/Kleinschreibung, Leerraum und - da die tatsächliche WebUntis-Reihenfolge nicht
|
||||
// dokumentiert und schulabhängig unterschiedlich beobachtet wurde - beide Namensreihenfolgen
|
||||
// werden beim Aufbau von `byName` registriert; hier wird nur normalisiert.
|
||||
private static string NameKey(string value) => value.Trim().ToLowerInvariant();
|
||||
|
||||
private const int FullLessonMinutes = 45;
|
||||
|
||||
// Der Bericht liefert keinen Entschuldigungstext, nur Minutenwerte, ein Bearbeitet-Datum und die
|
||||
// (laut Schule) über Klammerung der ENr codierte Entscheidung des Klassenlehrers - ENr in
|
||||
// Klammern bedeutet unentschuldigt, ohne Klammern abgeschlossen/entschuldigt. Reihenfolge ist
|
||||
// wichtig: "nach Hause entlassen" zählt immer als vorzeitige Entlassung, unabhängig von der
|
||||
// Dauer; darunter zählt jede Fehlzeit unter einer vollen Stunde (45 Min.) immer als Verspätung
|
||||
// oder sonstiger Teilverlust, nie als komplette Abwesenheit - der Text "Verspätung" allein ist
|
||||
// laut Schule nicht zuverlässig genug, deshalb primär über die Minutenschwelle erkannt.
|
||||
private static AttendanceStatus MapStatus(UntisLessonAbsenceDto absence)
|
||||
{
|
||||
if (IsEarlyRelease(absence)) return AttendanceStatus.LeftDuringClass;
|
||||
if (absence.AbsentMinutes < FullLessonMinutes) return AttendanceStatus.Late;
|
||||
if (string.IsNullOrWhiteSpace(absence.HandledOn)) return AttendanceStatus.ExcusePending;
|
||||
if (absence.ExternKey is null) return AttendanceStatus.ExcusePending;
|
||||
return absence.ExternKeyInParentheses ? AttendanceStatus.Unexcused : AttendanceStatus.Excused;
|
||||
}
|
||||
|
||||
private static bool IsEarlyRelease(UntisLessonAbsenceDto absence) =>
|
||||
absence.Reason?.Contains("entlassen", StringComparison.OrdinalIgnoreCase) == true;
|
||||
|
||||
// Nutzt dieselben deutschen Bezeichnungen wie die reguläre Mitarbeitserfassung
|
||||
// (AttendanceDisplay.Label), statt eigene Statustexte zu erfinden.
|
||||
private static string DisplayUntisStatus(UntisLessonAbsenceDto absence) =>
|
||||
string.Join(" · ", new[] { AttendanceDisplay.Label(MapStatus(absence)), absence.Reason }
|
||||
.Where(x => !string.IsNullOrWhiteSpace(x)));
|
||||
|
||||
private static bool TryDate(int value, out DateOnly date) => DateOnly.TryParseExact(value.ToString(), "yyyyMMdd", out date);
|
||||
private static string TimeLabel(int? start, int? end) =>
|
||||
start is null || end is null ? "" : $"{Time(start.Value)}–{Time(end.Value)}";
|
||||
private static string Time(int value) => $"{value / 100:00}:{value % 100:00}";
|
||||
}
|
||||
@@ -3,6 +3,8 @@ using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using LehrerApp.Core.Services;
|
||||
using LehrerApp.Desktop.Services;
|
||||
using LehrerApp.Desktop.ViewModels.ClassTeacher;
|
||||
using LehrerApp.Desktop.ViewModels.Exams;
|
||||
using LehrerApp.Desktop.ViewModels.Groups;
|
||||
using LehrerApp.Desktop.ViewModels.Planning;
|
||||
using LehrerApp.Desktop.ViewModels.Settings;
|
||||
@@ -20,10 +22,12 @@ public partial class MainWindowViewModel : ObservableObject
|
||||
[ObservableProperty] private ObservableObject? _currentPage;
|
||||
[ObservableProperty] private NavItem _activeNavItem = NavItem.Dashboard;
|
||||
[ObservableProperty] private string _currentSchoolYear = "";
|
||||
[ObservableProperty] private bool _isCommandPaletteOpen;
|
||||
|
||||
public SyncStatusViewModel SyncStatus { get; }
|
||||
public ObservableCollection<ToastItem> Toasts { get; }
|
||||
public AppLockViewModel AppLock { get; }
|
||||
public GlobalSearchViewModel CommandPalette { get; }
|
||||
|
||||
public bool IsDashboardActive => ActiveNavItem == NavItem.Dashboard;
|
||||
public bool IsGroupsActive => ActiveNavItem == NavItem.Groups;
|
||||
@@ -31,22 +35,37 @@ public partial class MainWindowViewModel : ObservableObject
|
||||
public bool IsExamsActive => ActiveNavItem == NavItem.Exams;
|
||||
public bool IsPlannerActive => ActiveNavItem == NavItem.Planner;
|
||||
public bool IsWorkloadActive => ActiveNavItem == NavItem.Workload;
|
||||
public bool IsClassTeacherActive => ActiveNavItem == NavItem.ClassTeacher;
|
||||
public bool IsSettingsActive => ActiveNavItem == NavItem.Settings;
|
||||
|
||||
public MainWindowViewModel(IServiceProvider services,
|
||||
DashboardViewModel dashboard, SchoolYearService sy,
|
||||
SyncStatusViewModel syncStatus, NotificationService notifications, AppLockViewModel appLock)
|
||||
SyncStatusViewModel syncStatus, NotificationService notifications, AppLockViewModel appLock,
|
||||
GlobalSearchViewModel commandPalette)
|
||||
{
|
||||
_services = services;
|
||||
SyncStatus = syncStatus;
|
||||
Toasts = notifications.Toasts;
|
||||
AppLock = appLock;
|
||||
CommandPalette = commandPalette;
|
||||
CommandPalette.OnClose = CloseCommandPalette;
|
||||
CurrentSchoolYear = sy.CurrentSchoolYear();
|
||||
CurrentPage = dashboard;
|
||||
AppLock.ApplyConfig();
|
||||
SyncStatus.DataChanged += OnSyncDataChanged;
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void OpenCommandPalette()
|
||||
{
|
||||
if (AppLock.IsLocked) return;
|
||||
CommandPalette.Reset();
|
||||
IsCommandPaletteOpen = true;
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void CloseCommandPalette() => IsCommandPaletteOpen = false;
|
||||
|
||||
// EventApplier schreibt bei eingehenden Sync-Ereignissen absichtlich direkt auf die rohe
|
||||
// LiteDB-Collection, an jedem ViewModel vorbei (Ping-Pong-Vermeidung, siehe EventApplier-
|
||||
// Klassenkommentar) - ohne diesen Hook blieb die gerade sichtbare Seite bis zum nächsten
|
||||
@@ -71,6 +90,8 @@ public partial class MainWindowViewModel : ObservableObject
|
||||
case TimetableViewModel vm: vm.Load(); break;
|
||||
case WorkloadViewModel vm:
|
||||
vm.Tasks.Load(); vm.TimeTracking.Load(); vm.Evaluation.Load(); break;
|
||||
case ClassTeacherOverviewViewModel vm: vm.LoadCommand.Execute(null); break;
|
||||
case ExamsOverviewViewModel vm: vm.LoadCommand.Execute(null); break;
|
||||
case GroupDetailViewModel { Group: { } group } vm: vm.LoadGroup(group.Id); break;
|
||||
// Inline-Bearbeitung (IsEditing) nicht überschreiben - anders als die Gruppenansicht
|
||||
// laufen Namens-/Geschlechtsänderungen hier nicht über einen Dialog.
|
||||
@@ -87,6 +108,7 @@ public partial class MainWindowViewModel : ObservableObject
|
||||
OnPropertyChanged(nameof(IsExamsActive));
|
||||
OnPropertyChanged(nameof(IsPlannerActive));
|
||||
OnPropertyChanged(nameof(IsWorkloadActive));
|
||||
OnPropertyChanged(nameof(IsClassTeacherActive));
|
||||
OnPropertyChanged(nameof(IsSettingsActive));
|
||||
}
|
||||
|
||||
@@ -99,9 +121,10 @@ public partial class MainWindowViewModel : ObservableObject
|
||||
NavItem.Dashboard => GetDashboard(),
|
||||
NavItem.Groups => _services.GetRequiredService<GroupListViewModel>(),
|
||||
NavItem.Students => GetStudents(),
|
||||
NavItem.Exams => new PlaceholderViewModel { Title = "Klausuren", Icon = "📝" },
|
||||
NavItem.Exams => GetExams(),
|
||||
NavItem.Planner => GetTimetable(),
|
||||
NavItem.Workload => GetWorkload(),
|
||||
NavItem.ClassTeacher => GetClassTeacherOverview(),
|
||||
NavItem.Settings => _services.GetRequiredService<SettingsViewModel>(),
|
||||
_ => CurrentPage,
|
||||
};
|
||||
@@ -141,6 +164,22 @@ public partial class MainWindowViewModel : ObservableObject
|
||||
return workload;
|
||||
}
|
||||
|
||||
private ExamsOverviewViewModel GetExams()
|
||||
{
|
||||
var exams = _services.GetRequiredService<ExamsOverviewViewModel>();
|
||||
exams.LoadCommand.Execute(null);
|
||||
return exams;
|
||||
}
|
||||
|
||||
private ClassTeacherOverviewViewModel GetClassTeacherOverview()
|
||||
{
|
||||
var vm = _services.GetRequiredService<ClassTeacherOverviewViewModel>();
|
||||
vm.OnNavigateToSettings = () => { NavigateToSettings(SettingsTab.WebUntis); return Task.CompletedTask; };
|
||||
vm.OnNavigateToWorkload = () => { NavigateToWorkload(); return Task.CompletedTask; };
|
||||
vm.LoadCommand.Execute(null);
|
||||
return vm;
|
||||
}
|
||||
|
||||
public void NavigateToGroupDetail(Guid groupId, int initialTab = 0)
|
||||
{
|
||||
ActiveNavItem = NavItem.Groups;
|
||||
@@ -175,7 +214,7 @@ public partial class MainWindowViewModel : ObservableObject
|
||||
public void NavigateToWorkload() => NavigateTo(NavItem.Workload);
|
||||
}
|
||||
|
||||
public enum NavItem { Dashboard, Groups, Students, Exams, Planner, Workload, Settings }
|
||||
public enum NavItem { Dashboard, Groups, Students, Exams, Planner, Workload, ClassTeacher, Settings }
|
||||
|
||||
public partial class PlaceholderViewModel : ObservableObject
|
||||
{
|
||||
|
||||
@@ -65,6 +65,7 @@ public partial class TimetableViewModel : ObservableObject
|
||||
private readonly ISubstitutionEntryRepository _substitutions;
|
||||
private readonly IUntisSlotMappingRepository _untisMappings;
|
||||
private readonly WebUntisSettingsService _untisSettings;
|
||||
private readonly PeriodScheduleService _periodSchedule;
|
||||
private readonly SchoolWeatherService? _schoolWeather;
|
||||
private readonly SemaphoreSlim _weatherGate = new(1, 1);
|
||||
private WeatherSnapshot? _weatherSnapshot;
|
||||
@@ -108,19 +109,25 @@ public partial class TimetableViewModel : ObservableObject
|
||||
public Func<Lesson, Task>? OnOpenLessonViewer { get; set; }
|
||||
public Func<Lesson, Task>? OnOpenTeachingMode { get; set; }
|
||||
|
||||
/// Öffentlich statt intern (kein InternalsVisibleTo in dieser Codebasis) - erlaubt Tests, die
|
||||
/// "heute"-abhängiges Verhalten (Wochenraster-Badges, Unterrichtszeit-Erkennung) prüfen, ohne
|
||||
/// vom tatsächlichen Wochentag/der Uhrzeit im Testlauf abzuhängen.
|
||||
public Func<DateTime> Clock { get; set; } = () => DateTime.Now;
|
||||
|
||||
public TimetableViewModel(ITimetableSlotRepository slots, IGroupRepository groups,
|
||||
ISubjectRepository subjects, ILessonRepository lessons, IExamRepository exams,
|
||||
ISchoolHolidayRepository schoolHolidays, SchoolCalendarSettingsService calendarSettings,
|
||||
PublicHolidayService publicHolidays, SchoolYearService schoolYear,
|
||||
ISupervisionDutyRepository supervisionDuties, ISubstitutionEntryRepository substitutions,
|
||||
IUntisSlotMappingRepository untisMappings, WebUntisSettingsService untisSettings,
|
||||
SchoolWeatherService? schoolWeather = null)
|
||||
PeriodScheduleService periodSchedule, SchoolWeatherService? schoolWeather = null)
|
||||
{
|
||||
_slots = slots; _groups = groups; _subjects = subjects; _lessons = lessons; _exams = exams;
|
||||
_schoolHolidays = schoolHolidays; _calendarSettings = calendarSettings;
|
||||
_publicHolidays = publicHolidays; _schoolYear = schoolYear;
|
||||
_supervisionDuties = supervisionDuties; _substitutions = substitutions;
|
||||
_untisMappings = untisMappings; _untisSettings = untisSettings;
|
||||
_periodSchedule = periodSchedule;
|
||||
_schoolWeather = schoolWeather;
|
||||
Load();
|
||||
}
|
||||
@@ -154,7 +161,7 @@ public partial class TimetableViewModel : ObservableObject
|
||||
|
||||
public void Load()
|
||||
{
|
||||
var today = DateOnly.FromDateTime(DateTime.Today);
|
||||
var today = DateOnly.FromDateTime(Clock());
|
||||
TodayLabel = today.ToString("dddd, dd.MM.yyyy", System.Globalization.CultureInfo.GetCultureInfo("de-DE"));
|
||||
|
||||
var publicHolidayDates = new HashSet<DateOnly>();
|
||||
@@ -368,14 +375,42 @@ public partial class TimetableViewModel : ObservableObject
|
||||
await OnOpenTeachingMode(lesson);
|
||||
}
|
||||
|
||||
/// Nutzer-Feedback (zweite Runde): der Direktklick auf eine Wochenraster-Kachel soll
|
||||
/// "einheitlicher" springen. Existiert eine Lesson UND ist gerade (heute, mit Toleranz vor/
|
||||
/// nach der Stunde) Unterrichtszeit, geht es direkt in den Unterrichtsmodus — sonst wie
|
||||
/// bisher in den (schreibgeschützten) Planungsviewer bzw., ohne Lesson, zur Einheitenplanung
|
||||
/// der Gruppe. Das Popup-Menü (siehe TimetableView.axaml, MenuFlyout je Kachel) bietet
|
||||
/// daneben immer alle vier Ziele explizit an, unabhängig von dieser Automatik.
|
||||
[RelayCommand]
|
||||
private async Task OpenWeekCell(WeekCellItem? item)
|
||||
{
|
||||
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)
|
||||
{
|
||||
if (IsAroundTeachingTime(lesson.Date, item.PeriodNumber) && OnOpenTeachingMode is not null)
|
||||
await OnOpenTeachingMode(lesson);
|
||||
else if (OnOpenLessonViewer is not null) await OnOpenLessonViewer(lesson);
|
||||
}
|
||||
else OnNavigateToGroup?.Invoke(item.GroupId);
|
||||
}
|
||||
|
||||
/// Bewusst mit Toleranz vor/nach der eingetragenen Stundenzeit (siehe
|
||||
/// <see cref="PeriodScheduleService"/>, "Stundenraster" in den Einstellungen) statt exakt an
|
||||
/// Start-/Endzeit gebunden — man klickt auch kurz vor Stundenbeginn oder in einer kurzen
|
||||
/// Verzögerung danach noch typischerweise in Unterrichtsabsicht. Ohne konfiguriertes
|
||||
/// Stundenraster oder an einem anderen Tag als heute bleibt es beim Planungsviewer.
|
||||
/// Nimmt bewusst das Datum der Lesson selbst statt WeekCellItem.Date entgegen — Date ist dort
|
||||
/// nur bei Kopfzeilen (WeekdayHeader) gesetzt, nicht bei regulären Stunden-Kacheln (ForSlot).
|
||||
private static readonly TimeSpan TeachingTimeTolerance = TimeSpan.FromMinutes(10);
|
||||
private bool IsAroundTeachingTime(DateOnly lessonDate, int periodNumber)
|
||||
{
|
||||
var nowSnapshot = Clock();
|
||||
if (lessonDate != DateOnly.FromDateTime(nowSnapshot)) return false;
|
||||
if (_periodSchedule.GetTimes(periodNumber) is not { } times) return false;
|
||||
var now = TimeOnly.FromDateTime(nowSnapshot);
|
||||
return now >= times.Start.Add(-TeachingTimeTolerance) && now <= times.End.Add(TeachingTimeTolerance);
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void OpenSettings() => OnNavigateToSettings?.Invoke(SettingsTab.Holidays);
|
||||
|
||||
@@ -784,9 +819,11 @@ public partial class WeekCellItem : ObservableObject
|
||||
public bool HasSupervision => SupervisionLocation.Length > 0;
|
||||
public bool HasRoom => !string.IsNullOrWhiteSpace(Room);
|
||||
public bool HasTopic => !string.IsNullOrWhiteSpace(Topic);
|
||||
public bool HasGroupId => GroupId != Guid.Empty;
|
||||
/// Nur bei einer regulären, zugewiesenen Zelle mit bereits existierender Lesson gesetzt —
|
||||
/// Grundlage für den Direktsprung in den Verlaufsplan-Viewer (4.5.2).
|
||||
public Lesson? Lesson { get; private init; }
|
||||
public bool HasLesson => Lesson is not null;
|
||||
public string OpenButtonLabel => Lesson is not null ? "Verlaufsplan ansehen" : "Zur Lerngruppe";
|
||||
[ObservableProperty] private string _weatherSymbol = "";
|
||||
[ObservableProperty] private string _weatherTooltip = "";
|
||||
@@ -900,6 +937,9 @@ public class UpcomingExamItem(DateOnly date, string groupName, string title)
|
||||
public string Title { get; } = title;
|
||||
}
|
||||
|
||||
public enum TimetableLessonDestination { TeachingMode, Viewer, SeatingPlan, Planning }
|
||||
public sealed record TimetableDestinationOption(TimetableLessonDestination Kind, string Label);
|
||||
|
||||
public class TodayLessonItem
|
||||
{
|
||||
public Guid GroupId { get; private init; }
|
||||
|
||||
@@ -217,7 +217,14 @@ public partial class SettingsViewModel : ObservableObject
|
||||
[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; }
|
||||
|
||||
// ── Schulweiter Jahresplan (ClassyPlan-iCal) ─────────────────────────────
|
||||
|
||||
@@ -559,6 +566,7 @@ public partial class SettingsViewModel : ObservableObject
|
||||
UntisUsername = credentials.Username;
|
||||
UntisApiStatus = $"API-Zugang für {credentials.Username} ist lokal verschlüsselt gespeichert.";
|
||||
}
|
||||
UntisHomeroomClassName = _untisSettings.HomeroomClassName;
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
@@ -647,6 +655,23 @@ public partial class SettingsViewModel : ObservableObject
|
||||
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();
|
||||
}
|
||||
|
||||
// ── Schulweiter Jahresplan: Laden / Speichern / Entfernen / Jetzt abrufen ─
|
||||
|
||||
private void LoadAnnualPlanSettings()
|
||||
|
||||
@@ -107,6 +107,7 @@ public partial class DocumentationDialogViewModel : ObservableObject
|
||||
[ObservableProperty] private string _title = "";
|
||||
[ObservableProperty] private string _content = "";
|
||||
[ObservableProperty] private bool _isConfidential;
|
||||
[ObservableProperty] private bool _excludeFromWebUntisSync;
|
||||
|
||||
[ObservableProperty] private string _newParticipant = "";
|
||||
public ObservableCollection<string> Participants { get; } = [];
|
||||
@@ -176,6 +177,7 @@ public partial class DocumentationDialogViewModel : ObservableObject
|
||||
Title = editing.Title;
|
||||
Content = editing.Content;
|
||||
IsConfidential = editing.IsConfidential;
|
||||
ExcludeFromWebUntisSync = editing.ExcludeFromWebUntisSync;
|
||||
foreach (var p in editing.Participants) Participants.Add(p);
|
||||
if (editing.AbsenceData is { } a)
|
||||
{
|
||||
@@ -343,6 +345,7 @@ public partial class DocumentationDialogViewModel : ObservableObject
|
||||
// Schnellentwurf ab. Stunden- und Lesson-Bezug bleiben am bestehenden Objekt erhalten.
|
||||
Result.IsDraft = false;
|
||||
Result.IsConfidential = IsConfidential;
|
||||
Result.ExcludeFromWebUntisSync = ExcludeFromWebUntisSync;
|
||||
Result.Participants = type == DocumentationType.Conversation ? Participants.ToList() : [];
|
||||
Result.AbsenceData = type == DocumentationType.Absence
|
||||
? new AbsenceData
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
using System.Collections.ObjectModel;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using LehrerApp.Core.Interfaces;
|
||||
using LehrerApp.Core.Models;
|
||||
using LehrerApp.Core.Services;
|
||||
using LehrerApp.Desktop.Services;
|
||||
|
||||
namespace LehrerApp.Desktop.ViewModels.Students;
|
||||
|
||||
/// <summary>Eine Zeile bleibt auch ohne automatische Zuordnung sichtbar - <see cref="AssignedStudent"/>
|
||||
/// kann manuell per Auswahlliste gesetzt werden, wenn der Name nicht eindeutig auf ein/e Schüler*in
|
||||
/// passt (siehe WebUntisLessonAbsenceComparisonViewModel für dasselbe Muster).</summary>
|
||||
public partial class WebUntisDocumentationRow : ObservableObject
|
||||
{
|
||||
public required string ClassName { get; init; }
|
||||
public required DateOnly Date { get; init; }
|
||||
public required string UntisStudentName { get; init; }
|
||||
public string? Subject { get; init; }
|
||||
public string? CategoryName { get; init; }
|
||||
public string? CategoryGroup { get; init; }
|
||||
public string? Text { get; init; }
|
||||
public required IReadOnlyList<Student> Candidates { get; init; }
|
||||
public string DateLabel => Date.ToString("dd.MM.yyyy");
|
||||
public bool CanApply => AssignedStudent is not null;
|
||||
|
||||
[ObservableProperty] private Student? _assignedStudent;
|
||||
/// <summary>Titel/Text eines lokalen Eintrags, der am selben Tag für diese/n Schüler*in schon
|
||||
/// existiert - nur nach Datum+Schüler*in erkannt, nicht nach Wortlaut (der unterscheidet sich oft
|
||||
/// von der WebUntis-Kategorie). Deshalb Anzeige zum Vergleichen statt automatischem Ausblenden.</summary>
|
||||
[ObservableProperty] private string? _existingLocalEntry;
|
||||
[ObservableProperty] private bool _selected;
|
||||
}
|
||||
|
||||
/// <summary>Lokaler Dokumentationseintrag im geladenen Zeitraum ohne passenden WebUntis-Eintrag - kann
|
||||
/// nicht automatisch nach WebUntis geschrieben werden (bewusst keine schreibenden Aufrufe gegen die
|
||||
/// undokumentierte API), deshalb nur als Kopiervorlage für die manuelle Nacherfassung dort.</summary>
|
||||
public sealed record LocalOnlyDocumentationRow(
|
||||
string StudentName, DateOnly Date, string? GroupName, string Title, string Content)
|
||||
{
|
||||
public string DateLabel => Date.ToString("dd.MM.yyyy");
|
||||
public string ClipboardText =>
|
||||
$"{DateLabel} – {StudentName}" + (GroupName is null ? "" : $" ({GroupName})") +
|
||||
$"\n{Title}\n{Content}";
|
||||
}
|
||||
|
||||
/// <summary>Abgleich der WebUntis-Klassenbucheinträge (eigene, siehe
|
||||
/// <see cref="WebUntisIntegrationService.GetOwnClassRegisterEventsAsync"/>) gegen die lokale
|
||||
/// <see cref="Documentation"/> - Dashboard-weit statt pro Lerngruppe, weil der WebUntis-"-alle-"-
|
||||
/// Bericht ebenfalls klassenübergreifend ist (siehe TODO.md, Nachtrag zu 4.3).</summary>
|
||||
public partial class WebUntisDocumentationComparisonViewModel : ObservableObject
|
||||
{
|
||||
private readonly WebUntisIntegrationService _untis;
|
||||
private readonly IStudentRepository _students;
|
||||
private readonly IGroupRepository _groups;
|
||||
private readonly IDocumentationRepository _documentation;
|
||||
private readonly SchoolYearService _schoolYears;
|
||||
|
||||
public ObservableCollection<WebUntisDocumentationRow> Rows { get; } = [];
|
||||
public ObservableCollection<LocalOnlyDocumentationRow> LocalOnlyRows { get; } = [];
|
||||
[ObservableProperty] private DateTimeOffset _startDate = DateTimeOffset.Now.AddDays(-7);
|
||||
[ObservableProperty] private DateTimeOffset _endDate = DateTimeOffset.Now;
|
||||
[ObservableProperty] private string _status = "Zeitraum wählen und Klassenbucheinträge laden.";
|
||||
[ObservableProperty] private bool _busy;
|
||||
|
||||
public WebUntisDocumentationComparisonViewModel(WebUntisIntegrationService untis,
|
||||
IStudentRepository students, IGroupRepository groups, IDocumentationRepository documentation,
|
||||
SchoolYearService schoolYears)
|
||||
{
|
||||
_untis = untis; _students = students; _groups = groups; _documentation = documentation;
|
||||
_schoolYears = schoolYears;
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private async Task Load()
|
||||
{
|
||||
var start = DateOnly.FromDateTime(StartDate.LocalDateTime);
|
||||
var end = DateOnly.FromDateTime(EndDate.LocalDateTime);
|
||||
if (end < start) { Status = "Das Enddatum darf nicht vor dem Startdatum liegen."; return; }
|
||||
Busy = true; Rows.Clear(); LocalOnlyRows.Clear();
|
||||
try
|
||||
{
|
||||
var ownStudents = _students.GetAll();
|
||||
var globalIndex = BuildNameIndex(ownStudents);
|
||||
// Zusätzlich pro Klasse (aktuelles Schuljahr) indiziert: löst den Fall "gleicher Name in
|
||||
// verschiedenen Klassen" auf, den ein rein globaler Namensabgleich nicht unterscheiden könnte.
|
||||
var currentSchoolYear = _schoolYears.CurrentSchoolYear();
|
||||
var classIndexes = _groups.GetAll()
|
||||
.Where(g => g.Type == GroupType.Class && g.SchoolYear == currentSchoolYear)
|
||||
.GroupBy(g => g.Name, StringComparer.OrdinalIgnoreCase)
|
||||
.ToDictionary(
|
||||
g => g.Key,
|
||||
g => BuildNameIndex(g.SelectMany(x => _students.GetByGroup(x.Id)).Distinct().ToList()),
|
||||
StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
var entries = await _untis.GetOwnClassRegisterEventsAsync(start, end);
|
||||
var localDocs = _documentation.GetAll().Where(d => d.Date >= start && d.Date <= end).ToList();
|
||||
|
||||
var ordered = entries
|
||||
.Select(e => (Entry: e, Date: TryDate(e.Date, out var d) ? d : (DateOnly?)null))
|
||||
.Where(x => x.Date is not null)
|
||||
.OrderBy(x => x.Date).ThenBy(x => x.Entry.StudentName);
|
||||
|
||||
// Nur nach Schüler*in+Datum erkannt, nicht nach Wortlaut: ein lokaler Eintrag von vor dieser
|
||||
// Funktion (oder frei formuliert) hat selten denselben Titel wie die WebUntis-Kategorie.
|
||||
List<Documentation> LocalDocsFor(Guid studentId, DateOnly date) =>
|
||||
localDocs.Where(d => d.StudentId == studentId && d.Date == date).ToList();
|
||||
|
||||
foreach (var (entry, date) in ordered)
|
||||
{
|
||||
var nameKey = NameKey(entry.StudentName);
|
||||
var match = (classIndexes.TryGetValue(entry.ClassName, out var classIndex)
|
||||
? classIndex.GetValueOrDefault(nameKey)
|
||||
: null)
|
||||
?? globalIndex.GetValueOrDefault(nameKey);
|
||||
var existing = match is null ? [] : LocalDocsFor(match.Id, date!.Value);
|
||||
|
||||
Rows.Add(new WebUntisDocumentationRow
|
||||
{
|
||||
ClassName = entry.ClassName, Date = date!.Value, UntisStudentName = entry.StudentName,
|
||||
Subject = entry.Subject, CategoryName = entry.CategoryName, CategoryGroup = entry.CategoryGroup,
|
||||
Text = entry.Text, Candidates = ownStudents, AssignedStudent = match,
|
||||
ExistingLocalEntry = existing.Count == 0 ? null
|
||||
: string.Join(" | ", existing.Select(d => $"{d.Title}: {d.Content}")),
|
||||
Selected = match is not null && existing.Count == 0,
|
||||
});
|
||||
}
|
||||
|
||||
foreach (var doc in localDocs)
|
||||
{
|
||||
if (doc.ExcludeFromWebUntisSync) continue;
|
||||
var coveredByReport = Rows.Any(r => r.AssignedStudent?.Id == doc.StudentId && r.Date == doc.Date);
|
||||
if (coveredByReport) continue;
|
||||
var student = ownStudents.FirstOrDefault(s => s.Id == doc.StudentId);
|
||||
if (student is null) continue;
|
||||
LocalOnlyRows.Add(new LocalOnlyDocumentationRow(student.FullName, doc.Date,
|
||||
doc.GroupId is { } groupId ? _groups.GetById(groupId)?.Name : null, doc.Title, doc.Content));
|
||||
}
|
||||
|
||||
var unresolved = Rows.Count(x => x.AssignedStudent is null);
|
||||
var possibleDuplicates = Rows.Count(x => x.ExistingLocalEntry is not null);
|
||||
Status = $"{Rows.Count} WebUntis-Einträge erhalten, {possibleDuplicates} mit lokalem Eintrag am " +
|
||||
"selben Tag (bitte vergleichen)" +
|
||||
(unresolved > 0 ? $", {unresolved} bitte manuell zuordnen" : "") +
|
||||
$". {LocalOnlyRows.Count} lokale Einträge ohne WebUntis-Gegenstück.";
|
||||
}
|
||||
catch (WebUntisIntegrationException ex) { Status = ex.Message; }
|
||||
finally { Busy = false; }
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void Apply()
|
||||
{
|
||||
var selected = Rows.Where(x => x.Selected && x.CanApply).ToList();
|
||||
foreach (var row in selected)
|
||||
{
|
||||
_documentation.Save(new Documentation
|
||||
{
|
||||
StudentId = row.AssignedStudent!.Id,
|
||||
Date = row.Date,
|
||||
Type = DocumentationType.Incident,
|
||||
Title = row.CategoryName ?? "WebUntis-Klassenbucheintrag",
|
||||
Content = row.Text ?? "",
|
||||
Tags = row.CategoryGroup is { Length: > 0 } group ? [group] : [],
|
||||
});
|
||||
}
|
||||
foreach (var row in selected)
|
||||
{
|
||||
row.ExistingLocalEntry = $"{row.CategoryName}: {row.Text}";
|
||||
row.Selected = false;
|
||||
}
|
||||
Status = $"{selected.Count} Einträge aus WebUntis übernommen.";
|
||||
}
|
||||
|
||||
private static string NameKey(string value) => value.Trim().ToLowerInvariant();
|
||||
|
||||
// Wie beim Fehlzeiten-Abgleich: WebUntis liefert Namen nicht einheitlich in einer Reihenfolge,
|
||||
// deshalb werden beide Reihenfolgen registriert, aber nur falls innerhalb der Kandidaten eindeutig.
|
||||
private static Dictionary<string, Student> BuildNameIndex(IReadOnlyList<Student> candidates) =>
|
||||
candidates
|
||||
.SelectMany(student => new[]
|
||||
{
|
||||
NameKey($"{student.LastName} {student.FirstName}"),
|
||||
NameKey($"{student.FirstName} {student.LastName}"),
|
||||
}.Select(key => (Key: key, Student: student)))
|
||||
.GroupBy(x => x.Key)
|
||||
.Where(group => group.Select(x => x.Student).Distinct().Count() == 1)
|
||||
.ToDictionary(group => group.Key, group => group.First().Student);
|
||||
|
||||
private static bool TryDate(int value, out DateOnly date) =>
|
||||
DateOnly.TryParseExact(value.ToString(), "yyyyMMdd", out date);
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
<UserControl xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:vm="clr-namespace:LehrerApp.Desktop.ViewModels.ClassTeacher"
|
||||
x:Class="LehrerApp.Desktop.Views.ClassTeacher.ClassTeacherAbsencesView"
|
||||
x:DataType="vm:ClassTeacherDetailsViewModel">
|
||||
|
||||
<UserControl.Styles>
|
||||
<!-- Siehe Styles/SemanticBrushes.axaml: Farben per DynamicResource, damit der Theme-Wechsel
|
||||
sie live austauscht; die Statusfarbe je Zeile kommt als Style-Klasse aus dem ViewModel. -->
|
||||
<Style Selector="Border.filterCard">
|
||||
<Setter Property="Background" Value="{DynamicResource AppCardBackgroundBrush}"/>
|
||||
<Setter Property="BorderBrush" Value="{DynamicResource AppCardBorderBrush}"/>
|
||||
<Setter Property="BorderThickness" Value="1"/>
|
||||
<Setter Property="CornerRadius" Value="8"/>
|
||||
<Setter Property="Padding" Value="10"/>
|
||||
</Style>
|
||||
<Style Selector="TextBlock.status">
|
||||
<Setter Property="Foreground" Value="{DynamicResource AppStatusOkBrush}"/>
|
||||
</Style>
|
||||
<Style Selector="TextBlock.status.info">
|
||||
<Setter Property="Foreground" Value="{DynamicResource AppStatusInfoBrush}"/>
|
||||
</Style>
|
||||
<Style Selector="TextBlock.status.warning">
|
||||
<Setter Property="Foreground" Value="{DynamicResource AppStatusWarningBrush}"/>
|
||||
</Style>
|
||||
<Style Selector="TextBlock.status.danger">
|
||||
<Setter Property="Foreground" Value="{DynamicResource AppStatusDangerBrush}"/>
|
||||
</Style>
|
||||
</UserControl.Styles>
|
||||
|
||||
<Grid RowDefinitions="Auto,Auto,*,Auto" Margin="16" RowSpacing="10">
|
||||
<Grid Grid.Row="0" ColumnDefinitions="*,Auto">
|
||||
<StackPanel>
|
||||
<TextBlock Text="Fehlzeiten" FontSize="22" FontWeight="SemiBold"/>
|
||||
<TextBlock Text="Pro Schüler*in und Tag über alle Fächer zusammengefasst" FontSize="12" Opacity="0.55"/>
|
||||
</StackPanel>
|
||||
<Button Grid.Column="1" Content="↻ Aus WebUntis aktualisieren" Command="{Binding RefreshCommand}"
|
||||
IsEnabled="{Binding !Busy}" Background="Transparent" Foreground="{DynamicResource AppAccentTextBrush}" VerticalAlignment="Center"/>
|
||||
</Grid>
|
||||
|
||||
<Border Grid.Row="1" Classes="filterCard">
|
||||
<Grid RowDefinitions="Auto,Auto" RowSpacing="8">
|
||||
<Grid Grid.Row="0" ColumnDefinitions="155,Auto,*,Auto,*" ColumnSpacing="8">
|
||||
<ComboBox Grid.Column="0" SelectedIndex="{Binding QuickRangeIndex, Mode=TwoWay}">
|
||||
<ComboBoxItem Content="Heute"/><ComboBoxItem Content="Letzte 7 Tage"/><ComboBoxItem Content="Letzte 30 Tage"/>
|
||||
</ComboBox>
|
||||
<TextBlock Grid.Column="1" Text="von" VerticalAlignment="Center" Opacity="0.6"/>
|
||||
<DatePicker Grid.Column="2" SelectedDate="{Binding StartDate}" HorizontalAlignment="Stretch"/>
|
||||
<TextBlock Grid.Column="3" Text="bis" VerticalAlignment="Center" Opacity="0.6"/>
|
||||
<DatePicker Grid.Column="4" SelectedDate="{Binding EndDate}" HorizontalAlignment="Stretch"/>
|
||||
</Grid>
|
||||
<Grid Grid.Row="1" ColumnDefinitions="*,Auto,Auto" ColumnSpacing="8">
|
||||
<TextBox Grid.Column="0" Text="{Binding StudentFilter, Mode=TwoWay}" PlaceholderText="Schüler*in filtern…"/>
|
||||
<Button Grid.Column="1" Content="Anzeigen" Command="{Binding ApplyFilterCommand}" IsEnabled="{Binding !Busy}"/>
|
||||
<Button Grid.Column="2" Content="Filter löschen" Command="{Binding ClearStudentFilterCommand}"
|
||||
IsVisible="{Binding StudentFilter, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<Grid Grid.Row="2">
|
||||
<DataGrid ItemsSource="{Binding AbsenceEntries}" AutoGenerateColumns="False" IsReadOnly="True"
|
||||
GridLinesVisibility="Horizontal" BorderBrush="{DynamicResource AppCardBorderBrush}" BorderThickness="1"
|
||||
CanUserResizeColumns="True" CanUserSortColumns="True" RowHeight="48">
|
||||
<DataGrid.Columns>
|
||||
<DataGridTextColumn Header="Datum" Binding="{Binding DateLabel}" Width="105"/>
|
||||
<DataGridTextColumn Header="Schüler*in" Binding="{Binding StudentDisplayName}" Width="1.3*"/>
|
||||
<DataGridTemplateColumn Header="Art" Width="1.15*">
|
||||
<DataGridTemplateColumn.CellTemplate>
|
||||
<DataTemplate x:DataType="vm:ClassAbsenceDaySummaryRow">
|
||||
<TextBlock Text="{Binding KindLabel}" Classes="status"
|
||||
Classes.warning="{Binding IsWarningStatus}"
|
||||
Classes.danger="{Binding IsDangerStatus}" VerticalAlignment="Center"/>
|
||||
</DataTemplate>
|
||||
</DataGridTemplateColumn.CellTemplate>
|
||||
</DataGridTemplateColumn>
|
||||
<DataGridTemplateColumn Header="Status" Width="1*">
|
||||
<DataGridTemplateColumn.CellTemplate>
|
||||
<DataTemplate x:DataType="vm:ClassAbsenceDaySummaryRow">
|
||||
<Border Background="{DynamicResource AppChipBackgroundBrush}" CornerRadius="5"
|
||||
Padding="7,3" HorizontalAlignment="Left">
|
||||
<TextBlock Text="{Binding FriendlyStatusLabel}" Classes="status"
|
||||
Classes.warning="{Binding IsWarningStatus}"
|
||||
Classes.danger="{Binding IsDangerStatus}" FontSize="11"/>
|
||||
</Border>
|
||||
</DataTemplate>
|
||||
</DataGridTemplateColumn.CellTemplate>
|
||||
</DataGridTemplateColumn>
|
||||
<DataGridTextColumn Header="Fächer" Binding="{Binding SubjectsLabel}" Width="1.2*"/>
|
||||
<DataGridTextColumn Header="Stunden" Binding="{Binding PeriodsLabel}" Width="0.75*"/>
|
||||
<DataGridTextColumn Header="Grund / Notiz" Binding="{Binding DetailLabel}" Width="2*"/>
|
||||
</DataGrid.Columns>
|
||||
</DataGrid>
|
||||
<StackPanel IsVisible="{Binding !HasAbsenceEntries}" HorizontalAlignment="Center" VerticalAlignment="Center" Spacing="5">
|
||||
<TextBlock Text="Keine Fehlzeiten im gewählten Zeitraum" FontWeight="SemiBold"/>
|
||||
<TextBlock Text="Passe Zeitraum oder Schülerfilter an." FontSize="12" Opacity="0.55"/>
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
<TextBlock Grid.Row="3" Text="{Binding Status}" FontSize="11" Opacity="0.6"/>
|
||||
</Grid>
|
||||
</UserControl>
|
||||
@@ -0,0 +1,8 @@
|
||||
using Avalonia.Controls;
|
||||
|
||||
namespace LehrerApp.Desktop.Views.ClassTeacher;
|
||||
|
||||
public partial class ClassTeacherAbsencesView : UserControl
|
||||
{
|
||||
public ClassTeacherAbsencesView() => InitializeComponent();
|
||||
}
|
||||
@@ -0,0 +1,482 @@
|
||||
<UserControl xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:vm="clr-namespace:LehrerApp.Desktop.ViewModels.ClassTeacher"
|
||||
xmlns:views="clr-namespace:LehrerApp.Desktop.Views.ClassTeacher"
|
||||
xmlns:conv="clr-namespace:LehrerApp.Desktop.Converters"
|
||||
x:Class="LehrerApp.Desktop.Views.ClassTeacher.ClassTeacherOverviewView"
|
||||
x:DataType="vm:ClassTeacherOverviewViewModel">
|
||||
|
||||
<!-- Flächen- und Statusfarben kommen aus Styles/SemanticBrushes.axaml und werden bewusst per
|
||||
DynamicResource geholt, damit der Theme-Wechsel (hell/dunkel) sie live austauscht. Die
|
||||
Statusfarbe einer Zeile setzt deshalb ein Style-Selektor über Klassen und nicht ein
|
||||
Hex-String aus dem ViewModel — ein lokal gesetztes Attribut würde den Selektor überstimmen. -->
|
||||
<UserControl.Styles>
|
||||
<Style Selector="Border.metric">
|
||||
<Setter Property="Background" Value="{DynamicResource AppCardBackgroundBrush}"/>
|
||||
<Setter Property="BorderBrush" Value="{DynamicResource AppCardBorderBrush}"/>
|
||||
<Setter Property="BorderThickness" Value="1"/>
|
||||
<Setter Property="CornerRadius" Value="8"/>
|
||||
<Setter Property="Padding" Value="14,12"/>
|
||||
<Setter Property="Margin" Value="0,0,10,0"/>
|
||||
</Style>
|
||||
<Style Selector="Button.filter">
|
||||
<Setter Property="Background" Value="Transparent"/>
|
||||
<Setter Property="BorderBrush" Value="{DynamicResource AppFilterBorderBrush}"/>
|
||||
<Setter Property="BorderThickness" Value="1"/>
|
||||
<Setter Property="CornerRadius" Value="6"/>
|
||||
<Setter Property="Padding" Value="16,6"/>
|
||||
<Setter Property="FontSize" Value="12"/>
|
||||
</Style>
|
||||
<Style Selector="Button.filter.active">
|
||||
<Setter Property="Background" Value="{DynamicResource AppFilterActiveBackgroundBrush}"/>
|
||||
<Setter Property="BorderBrush" Value="{DynamicResource AppFilterActiveBorderBrush}"/>
|
||||
<Setter Property="Foreground" Value="{DynamicResource AppFilterActiveForegroundBrush}"/>
|
||||
</Style>
|
||||
<Style Selector="Button.rosterRow">
|
||||
<Setter Property="HorizontalAlignment" Value="Stretch"/>
|
||||
<Setter Property="HorizontalContentAlignment" Value="Stretch"/>
|
||||
<Setter Property="Background" Value="{DynamicResource AppListRowBackgroundBrush}"/>
|
||||
<Setter Property="BorderBrush" Value="{DynamicResource AppListRowBorderBrush}"/>
|
||||
<Setter Property="BorderThickness" Value="1,0,1,1"/>
|
||||
<Setter Property="Padding" Value="0"/>
|
||||
<Setter Property="MinHeight" Value="48"/>
|
||||
</Style>
|
||||
<Style Selector="Button.rosterRow:pointerover /template/ ContentPresenter">
|
||||
<Setter Property="Background" Value="{DynamicResource AppListRowHoverBrush}"/>
|
||||
</Style>
|
||||
<Style Selector="Button.rosterRowAction">
|
||||
<Setter Property="Width" Value="30"/>
|
||||
<Setter Property="Height" Value="48"/>
|
||||
<Setter Property="Background" Value="{DynamicResource AppListRowBackgroundBrush}"/>
|
||||
<Setter Property="BorderBrush" Value="{DynamicResource AppListRowBorderBrush}"/>
|
||||
<Setter Property="BorderThickness" Value="0,0,1,1"/>
|
||||
<Setter Property="Foreground" Value="{DynamicResource AppAccentTextBrush}"/>
|
||||
<Setter Property="FontSize" Value="16"/>
|
||||
<Setter Property="Padding" Value="0"/>
|
||||
<Setter Property="HorizontalContentAlignment" Value="Center"/>
|
||||
<Setter Property="VerticalContentAlignment" Value="Center"/>
|
||||
</Style>
|
||||
<Style Selector="Button.rosterRowAction:pointerover /template/ ContentPresenter">
|
||||
<Setter Property="Background" Value="{DynamicResource AppListRowHoverBrush}"/>
|
||||
</Style>
|
||||
<Style Selector="Border.sidePanel">
|
||||
<Setter Property="Background" Value="{DynamicResource AppCardBackgroundBrush}"/>
|
||||
<Setter Property="BorderBrush" Value="{DynamicResource AppCardBorderBrush}"/>
|
||||
<Setter Property="BorderThickness" Value="1"/>
|
||||
<Setter Property="CornerRadius" Value="8"/>
|
||||
<Setter Property="Padding" Value="14"/>
|
||||
<Setter Property="Margin" Value="0,0,0,10"/>
|
||||
</Style>
|
||||
<Style Selector="TextBlock.sectionLabel">
|
||||
<Setter Property="FontSize" Value="13"/>
|
||||
<Setter Property="FontWeight" Value="SemiBold"/>
|
||||
<Setter Property="Margin" Value="4,10,0,6"/>
|
||||
</Style>
|
||||
|
||||
<!-- Statusstufe: Basis ist "unauffällig", die Klassen aus dem ViewModel schalten hoch. -->
|
||||
<Style Selector="TextBlock.status">
|
||||
<Setter Property="Foreground" Value="{DynamicResource AppStatusOkBrush}"/>
|
||||
</Style>
|
||||
<Style Selector="TextBlock.status.info">
|
||||
<Setter Property="Foreground" Value="{DynamicResource AppStatusInfoBrush}"/>
|
||||
</Style>
|
||||
<Style Selector="TextBlock.status.warning">
|
||||
<Setter Property="Foreground" Value="{DynamicResource AppStatusWarningBrush}"/>
|
||||
</Style>
|
||||
<Style Selector="TextBlock.status.danger">
|
||||
<Setter Property="Foreground" Value="{DynamicResource AppStatusDangerBrush}"/>
|
||||
</Style>
|
||||
<Style Selector="Border.statusFill">
|
||||
<Setter Property="Background" Value="{DynamicResource AppStatusOkBrush}"/>
|
||||
</Style>
|
||||
<Style Selector="Border.statusFill.info">
|
||||
<Setter Property="Background" Value="{DynamicResource AppStatusInfoBrush}"/>
|
||||
</Style>
|
||||
<Style Selector="Border.statusFill.warning">
|
||||
<Setter Property="Background" Value="{DynamicResource AppStatusWarningBrush}"/>
|
||||
</Style>
|
||||
<Style Selector="Border.statusFill.danger">
|
||||
<Setter Property="Background" Value="{DynamicResource AppStatusDangerBrush}"/>
|
||||
</Style>
|
||||
</UserControl.Styles>
|
||||
|
||||
<UserControl.Resources>
|
||||
<!-- Eine Vorlage für beide Roster-Listen (auffällig / übrige): Sie waren Zeichen für Zeichen
|
||||
identisch und mussten bisher doppelt gepflegt werden. -->
|
||||
<DataTemplate x:Key="RosterRowTemplate" x:DataType="vm:ClassTeacherRosterRow">
|
||||
<!-- Zwei Geschwister-Controls statt eines ContextMenu auf dem Zeilen-Button: Avalonia löst
|
||||
$parent-Vorfahrenpfade in einem ContextMenu nicht zuverlässig auf, weil dessen Popup
|
||||
nicht im normalen visuellen Baum unter dem ItemsControl hängt. Ein separater kleiner
|
||||
Button daneben bleibt im normalen Baum und kann so ganz regulär auf das ViewModel des
|
||||
ItemsControl zugreifen. -->
|
||||
<Grid ColumnDefinitions="*,Auto">
|
||||
<Button Grid.Column="0" Classes="rosterRow"
|
||||
Command="{Binding $parent[ItemsControl].((vm:ClassTeacherOverviewViewModel)DataContext).ShowDetailsForStudentCommand}"
|
||||
CommandParameter="{Binding}">
|
||||
<Grid ColumnDefinitions="4,42,220,*,150,18">
|
||||
<Border Grid.Column="0" Classes="statusFill"
|
||||
Classes.info="{Binding IsInfoStatus}"
|
||||
Classes.warning="{Binding IsWarningStatus}"
|
||||
Classes.danger="{Binding IsDangerStatus}"/>
|
||||
<Border Grid.Column="1" Width="28" Height="28" CornerRadius="14"
|
||||
BorderBrush="{DynamicResource AppAvatarBorderBrush}" BorderThickness="1"
|
||||
HorizontalAlignment="Center" VerticalAlignment="Center"
|
||||
ToolTip.Tip="{Binding AbsenceTooltip}">
|
||||
<TextBlock Text="{Binding Initials}" FontSize="10"
|
||||
HorizontalAlignment="Center" VerticalAlignment="Center"/>
|
||||
</Border>
|
||||
<TextBlock Grid.Column="2" Text="{Binding StudentName}" VerticalAlignment="Center"
|
||||
TextTrimming="CharacterEllipsis"/>
|
||||
<StackPanel Grid.Column="3" VerticalAlignment="Center" Spacing="1"
|
||||
ToolTip.Tip="{Binding YearSummaryTooltip}">
|
||||
<!-- Quick-Win Barrierefreiheit: Status nicht ausschließlich über Farbe, sondern auch
|
||||
über ein vorangestelltes Symbol bei Warnung/Gefahr (StatusTextWithGlyph). -->
|
||||
<TextBlock Text="{Binding StatusTextWithGlyph}" Classes="status"
|
||||
Classes.info="{Binding IsInfoStatus}"
|
||||
Classes.warning="{Binding IsWarningStatus}"
|
||||
Classes.danger="{Binding IsDangerStatus}"
|
||||
FontSize="12" TextTrimming="CharacterEllipsis"/>
|
||||
<!-- Fehlquote seit Schuljahresbeginn (Nutzer-Feedback: der Heute-Snapshot allein sagt
|
||||
für Zeugnis/Attestpflicht wenig aus) — als dezente zweite Zeile statt eigener
|
||||
Spalte, damit das bestehende Layout nicht neu vermessen werden muss. -->
|
||||
<TextBlock Text="{Binding YearSummaryLabel}" IsVisible="{Binding HasYearSummary}"
|
||||
FontSize="10" Opacity="0.5" TextTrimming="CharacterEllipsis"/>
|
||||
</StackPanel>
|
||||
<TextBlock Grid.Column="4" Text="{Binding ClassRegisterLabel}"
|
||||
Foreground="{DynamicResource AppAccentTextBrush}"
|
||||
VerticalAlignment="Center" FontSize="11"/>
|
||||
<TextBlock Grid.Column="5" Text="›" FontSize="18" Opacity="0.7" VerticalAlignment="Center"/>
|
||||
</Grid>
|
||||
</Button>
|
||||
<!-- Quick-Win: "Eltern kontaktieren" als vorbefüllte Wiedervorlage direkt aus der Zeile
|
||||
anlegen, statt erst ins Aufgaben-Modul zu wechseln und von Hand einzutragen. -->
|
||||
<Button Grid.Column="1" Classes="rosterRowAction" Content="+"
|
||||
ToolTip.Tip="Wiedervorlage „Eltern kontaktieren“ anlegen"
|
||||
Command="{Binding $parent[ItemsControl].((vm:ClassTeacherOverviewViewModel)DataContext).CreateReminderForStudentCommand}"
|
||||
CommandParameter="{Binding}"/>
|
||||
</Grid>
|
||||
</DataTemplate>
|
||||
</UserControl.Resources>
|
||||
|
||||
<Grid>
|
||||
<StackPanel IsVisible="{Binding !HomeroomClassConfigured}" HorizontalAlignment="Center"
|
||||
VerticalAlignment="Center" Spacing="12" MaxWidth="380">
|
||||
<Border Width="56" Height="56" CornerRadius="28" HorizontalAlignment="Center"
|
||||
Background="{DynamicResource AppAccentSoftBackgroundBrush}">
|
||||
<TextBlock Text="KL" FontSize="18" FontWeight="Bold"
|
||||
Foreground="{DynamicResource AppAccentOnSoftBrush}"
|
||||
HorizontalAlignment="Center" VerticalAlignment="Center"/>
|
||||
</Border>
|
||||
<TextBlock Text="Noch keine Klasse ausgewählt" FontSize="18" FontWeight="SemiBold" HorizontalAlignment="Center"/>
|
||||
<TextBlock TextWrapping="Wrap" TextAlignment="Center" FontSize="13" Opacity="0.65"
|
||||
Text="Wähle in den WebUntis-Einstellungen die Klasse aus, deren Klassenlehrer/-in du bist."/>
|
||||
<Button Content="Zu den Einstellungen" Command="{Binding GoToSettingsCommand}" HorizontalAlignment="Center"/>
|
||||
</StackPanel>
|
||||
|
||||
<TabbedPage IsVisible="{Binding HomeroomClassConfigured}" TabPlacement="Top"
|
||||
SelectedIndex="{Binding ActiveTabIndex, Mode=TwoWay}">
|
||||
<ContentPage Header="Übersicht">
|
||||
<Grid RowDefinitions="Auto,Auto,*" Margin="16,12,16,16" RowSpacing="10">
|
||||
<Grid Grid.Row="0" ColumnDefinitions="*,Auto">
|
||||
<StackPanel Grid.Column="0" Spacing="2">
|
||||
<TextBlock Text="{Binding HomeroomClassName, StringFormat='Klasse {0}'}"
|
||||
FontSize="24" FontWeight="SemiBold"/>
|
||||
<TextBlock Text="Klassenlehrer-Übersicht" FontSize="13" Opacity="0.58"/>
|
||||
</StackPanel>
|
||||
<StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="10" VerticalAlignment="Center">
|
||||
<TextBlock Text="{Binding LastUpdatedLabel}" FontSize="11" Opacity="0.55" VerticalAlignment="Center"/>
|
||||
<Button Content="↻ Aktualisieren" Command="{Binding LoadCommand}" IsEnabled="{Binding !Busy}"
|
||||
Background="Transparent" BorderThickness="0"
|
||||
Foreground="{DynamicResource AppAccentTextBrush}"/>
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
|
||||
<Grid Grid.Row="1" ColumnDefinitions="*,*,*,*">
|
||||
<Border Grid.Column="0" Classes="metric">
|
||||
<Grid ColumnDefinitions="36,*">
|
||||
<TextBlock Text="◉" FontSize="22" VerticalAlignment="Center"
|
||||
Foreground="{DynamicResource AppStatusInfoBrush}"/>
|
||||
<StackPanel Grid.Column="1"><TextBlock Text="{Binding StudentCount}" FontSize="22" FontWeight="SemiBold"/><TextBlock Text="Schüler*innen" FontSize="11" Opacity="0.6"/></StackPanel>
|
||||
</Grid>
|
||||
</Border>
|
||||
<Border Grid.Column="1" Classes="metric">
|
||||
<Grid ColumnDefinitions="36,*">
|
||||
<TextBlock Text="△" FontSize="24" VerticalAlignment="Center"
|
||||
Foreground="{DynamicResource AppStatusWarningBrush}"/>
|
||||
<StackPanel Grid.Column="1"><TextBlock Text="{Binding TodayAlertCount}" FontSize="22" FontWeight="SemiBold"/><TextBlock Text="heute auffällig" FontSize="11" Opacity="0.6"/></StackPanel>
|
||||
</Grid>
|
||||
</Border>
|
||||
<Border Grid.Column="2" Classes="metric">
|
||||
<Grid ColumnDefinitions="36,*">
|
||||
<TextBlock Text="!" FontSize="22" VerticalAlignment="Center"
|
||||
Foreground="{DynamicResource AppStatusDangerBrush}"/>
|
||||
<StackPanel Grid.Column="1"><TextBlock Text="{Binding TodayUnexcusedCount}" FontSize="22" FontWeight="SemiBold"/><TextBlock Text="{Binding TodayUnexcusedSummaryLabel}" FontSize="11" Opacity="0.6"/></StackPanel>
|
||||
</Grid>
|
||||
</Border>
|
||||
<Border Grid.Column="3" Classes="metric" Margin="0">
|
||||
<Grid ColumnDefinitions="36,*">
|
||||
<TextBlock Text="▤" FontSize="21" VerticalAlignment="Center"
|
||||
Foreground="{DynamicResource AppStatusInfoBrush}"/>
|
||||
<StackPanel Grid.Column="1"><TextBlock Text="{Binding RecentClassRegisterCount}" FontSize="22" FontWeight="SemiBold"/><TextBlock Text="Klassenbucheinträge · 7 Tage" FontSize="11" Opacity="0.6"/></StackPanel>
|
||||
</Grid>
|
||||
</Border>
|
||||
</Grid>
|
||||
|
||||
<Grid Grid.Row="2" ColumnDefinitions="*,286" ColumnSpacing="12">
|
||||
<Grid Grid.Column="0" RowDefinitions="Auto,*">
|
||||
<Border Grid.Row="0" Background="{DynamicResource AppCardBackgroundBrush}"
|
||||
BorderBrush="{DynamicResource AppCardBorderBrush}" BorderThickness="1"
|
||||
CornerRadius="8" Padding="8" Margin="0,0,0,4">
|
||||
<Grid ColumnDefinitions="Auto,Auto,Auto,*,250" ColumnSpacing="8">
|
||||
<Button Grid.Column="0" Classes="filter" Classes.active="{Binding AlertsFilterSelected}"
|
||||
Content="Auffällig" Command="{Binding ShowAlertsCommand}"/>
|
||||
<Button Grid.Column="1" Classes="filter" Classes.active="{Binding ClassRegisterFilterSelected}"
|
||||
Content="Klassenbuch" Command="{Binding ShowClassRegisterCommand}"/>
|
||||
<Button Grid.Column="2" Classes="filter" Classes.active="{Binding AllFilterSelected}"
|
||||
Content="Alle" Command="{Binding ShowAllCommand}"/>
|
||||
<TextBox Grid.Column="4" Text="{Binding SearchText, Mode=TwoWay}" PlaceholderText="Schüler*in suchen…"
|
||||
FontSize="12" MinHeight="32"/>
|
||||
</Grid>
|
||||
</Border>
|
||||
<ScrollViewer Grid.Row="1">
|
||||
<StackPanel>
|
||||
<TextBlock Classes="sectionLabel" Text="{Binding PrimarySectionTitle}" IsVisible="{Binding HasPrimaryRoster}"/>
|
||||
<ItemsControl ItemsSource="{Binding PrimaryRoster}" ItemTemplate="{StaticResource RosterRowTemplate}"/>
|
||||
|
||||
<TextBlock Classes="sectionLabel" Text="{Binding SecondarySectionTitle}" IsVisible="{Binding HasSecondaryRoster}"/>
|
||||
<ItemsControl ItemsSource="{Binding SecondaryRoster}" ItemTemplate="{StaticResource RosterRowTemplate}"/>
|
||||
|
||||
<StackPanel IsVisible="{Binding HasNoFilterResults}" HorizontalAlignment="Center" Margin="0,54,0,0" Spacing="6">
|
||||
<TextBlock Text="Keine passenden Schüler*innen" FontWeight="SemiBold" HorizontalAlignment="Center"/>
|
||||
<TextBlock Text="Passe Filter oder Suche an." FontSize="12" Opacity="0.55"/>
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
</Grid>
|
||||
|
||||
<ScrollViewer Grid.Column="1">
|
||||
<StackPanel>
|
||||
<Border Classes="sidePanel">
|
||||
<StackPanel Spacing="9">
|
||||
<TextBlock Text="Tagesüberblick" FontSize="14" FontWeight="SemiBold"/>
|
||||
<!-- Quick-Win: ein gestapelter 100-%-Balken statt vier Einzelzeilen mit
|
||||
separater Prozentspalte — Verteilung auf einen Blick statt aus vier
|
||||
Prozentzahlen erschlossen. Jedes Segment multipliziert per MultiBinding
|
||||
die gerenderte Breite der Track-Leiste (x:Name="DayOverviewTrack") mit
|
||||
seinem Anteil 0…1 (FractionWidthConverter) — bleibt dadurch korrekt, egal
|
||||
wie breit die Seitenspalte in XAML gerade ist.
|
||||
Reihenfolge Ok→Info→Warnung→Gefahr (nicht Ok→Warnung→Info→Gefahr):
|
||||
Grün und das validierte Dunkel-Gelb liegen unter Protanopie zu nah
|
||||
beieinander (ΔE 3.0), direkt benachbart wäre das dieselbe Verwechslung
|
||||
wie beim ursprünglichen Nutzer-Feedback zu Warnung/Gefahr. Mit Info
|
||||
dazwischen entspricht die Reihenfolge zudem der echten Dringlichkeit
|
||||
(siehe ClassTeacherRosterRow.AttentionRank: Gefahr > Warnung > Info > Ok). -->
|
||||
<Grid Height="10" ToolTip.Tip="{Binding DayOverviewTooltip}">
|
||||
<Border x:Name="DayOverviewTrack" Background="{DynamicResource AppTrackBackgroundBrush}"
|
||||
CornerRadius="5"/>
|
||||
<Border CornerRadius="5" ClipToBounds="True" HorizontalAlignment="Left">
|
||||
<StackPanel Orientation="Horizontal">
|
||||
<Border Classes="statusFill">
|
||||
<Border.Width>
|
||||
<MultiBinding Converter="{x:Static conv:FractionWidthConverter.Instance}">
|
||||
<Binding ElementName="DayOverviewTrack" Path="Bounds.Width"/>
|
||||
<Binding Path="PresentFraction"/>
|
||||
</MultiBinding>
|
||||
</Border.Width>
|
||||
</Border>
|
||||
<Border Classes="statusFill info">
|
||||
<Border.Width>
|
||||
<MultiBinding Converter="{x:Static conv:FractionWidthConverter.Instance}">
|
||||
<Binding ElementName="DayOverviewTrack" Path="Bounds.Width"/>
|
||||
<Binding Path="ExcusedAbsenceFraction"/>
|
||||
</MultiBinding>
|
||||
</Border.Width>
|
||||
</Border>
|
||||
<Border Classes="statusFill warning">
|
||||
<Border.Width>
|
||||
<MultiBinding Converter="{x:Static conv:FractionWidthConverter.Instance}">
|
||||
<Binding ElementName="DayOverviewTrack" Path="Bounds.Width"/>
|
||||
<Binding Path="LateFraction"/>
|
||||
</MultiBinding>
|
||||
</Border.Width>
|
||||
</Border>
|
||||
<Border Classes="statusFill danger">
|
||||
<Border.Width>
|
||||
<MultiBinding Converter="{x:Static conv:FractionWidthConverter.Instance}">
|
||||
<Binding ElementName="DayOverviewTrack" Path="Bounds.Width"/>
|
||||
<Binding Path="UnexcusedAbsenceFraction"/>
|
||||
</MultiBinding>
|
||||
</Border.Width>
|
||||
</Border>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</Grid>
|
||||
<StackPanel Spacing="5">
|
||||
<StackPanel Orientation="Horizontal" Spacing="6"><Border Width="7" Height="7" CornerRadius="4" Classes="statusFill"/><TextBlock Text="{Binding PresentSummaryLabel}" FontSize="12"/></StackPanel>
|
||||
<StackPanel Orientation="Horizontal" Spacing="6"><Border Width="7" Height="7" CornerRadius="4" Classes="statusFill info"/><TextBlock Text="{Binding ExcusedAbsenceSummaryLabel}" FontSize="12"/></StackPanel>
|
||||
<StackPanel Orientation="Horizontal" Spacing="6"><Border Width="7" Height="7" CornerRadius="4" Classes="statusFill warning"/><TextBlock Text="{Binding LateSummaryLabel}" FontSize="12"/></StackPanel>
|
||||
<StackPanel Orientation="Horizontal" Spacing="6"><Border Width="7" Height="7" CornerRadius="4" Classes="statusFill danger"/><TextBlock Text="{Binding UnexcusedAbsenceSummaryLabel}" FontSize="12"/></StackPanel>
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<Border Classes="sidePanel">
|
||||
<StackPanel Spacing="8">
|
||||
<TextBlock Text="Trend · letzte 7 Tage" FontSize="14" FontWeight="SemiBold"/>
|
||||
<ItemsControl ItemsSource="{Binding TrendDays}">
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:ClassTeacherTrendDay">
|
||||
<Grid ColumnDefinitions="42,*,24" Margin="0,2">
|
||||
<TextBlock Text="{Binding DayLabel}" FontSize="11" Opacity="0.6" VerticalAlignment="Center"/>
|
||||
<Grid Grid.Column="1" Height="8" VerticalAlignment="Center"
|
||||
ToolTip.Tip="{Binding DetailTooltip}">
|
||||
<Border x:Name="TrendTrack" Background="{DynamicResource AppTrackBackgroundBrush}"
|
||||
CornerRadius="4"/>
|
||||
<!-- Gestapelter Balken: drei sich nicht überschneidende Segmente
|
||||
(siehe ClassTeacherTrendDay). Jedes Segment multipliziert per
|
||||
MultiBinding die gerenderte Breite von "TrendTrack" mit seinem
|
||||
Anteil 0…1 (FractionWidthConverter) — bleibt damit auch dann
|
||||
korrekt proportional, wenn die Seitenspalte in XAML mal eine
|
||||
andere Breite bekommt. Der äußere Wrapper ist auf CornerRadius
|
||||
zugeschnitten, damit nur die äußeren Kanten rund sind, nicht
|
||||
jedes Segment einzeln. -->
|
||||
<Border CornerRadius="4" ClipToBounds="True" HorizontalAlignment="Left">
|
||||
<Border.Width>
|
||||
<MultiBinding Converter="{x:Static conv:FractionWidthConverter.Instance}">
|
||||
<Binding ElementName="TrendTrack" Path="Bounds.Width"/>
|
||||
<Binding Path="TotalFraction"/>
|
||||
</MultiBinding>
|
||||
</Border.Width>
|
||||
<StackPanel Orientation="Horizontal">
|
||||
<Border Classes="statusFill danger">
|
||||
<Border.Width>
|
||||
<MultiBinding Converter="{x:Static conv:FractionWidthConverter.Instance}">
|
||||
<Binding ElementName="TrendTrack" Path="Bounds.Width"/>
|
||||
<Binding Path="UnexcusedFraction"/>
|
||||
</MultiBinding>
|
||||
</Border.Width>
|
||||
</Border>
|
||||
<Border Classes="statusFill warning">
|
||||
<Border.Width>
|
||||
<MultiBinding Converter="{x:Static conv:FractionWidthConverter.Instance}">
|
||||
<Binding ElementName="TrendTrack" Path="Bounds.Width"/>
|
||||
<Binding Path="LateExcusedFraction"/>
|
||||
</MultiBinding>
|
||||
</Border.Width>
|
||||
</Border>
|
||||
<Border Classes="statusFill info">
|
||||
<Border.Width>
|
||||
<MultiBinding Converter="{x:Static conv:FractionWidthConverter.Instance}">
|
||||
<Binding ElementName="TrendTrack" Path="Bounds.Width"/>
|
||||
<Binding Path="ExcusedFraction"/>
|
||||
</MultiBinding>
|
||||
</Border.Width>
|
||||
</Border>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</Grid>
|
||||
<TextBlock Grid.Column="2" Text="{Binding AlertCount}" FontSize="11" HorizontalAlignment="Right"/>
|
||||
</Grid>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
<StackPanel Orientation="Horizontal" Spacing="10">
|
||||
<StackPanel Orientation="Horizontal" Spacing="4">
|
||||
<Border Width="7" Height="7" CornerRadius="4" Classes="statusFill danger"/>
|
||||
<TextBlock Text="Unentsch." FontSize="10" Opacity="0.55"/>
|
||||
</StackPanel>
|
||||
<StackPanel Orientation="Horizontal" Spacing="4">
|
||||
<Border Width="7" Height="7" CornerRadius="4" Classes="statusFill warning"/>
|
||||
<TextBlock Text="Verspätet" FontSize="10" Opacity="0.55"/>
|
||||
</StackPanel>
|
||||
<StackPanel Orientation="Horizontal" Spacing="4">
|
||||
<Border Width="7" Height="7" CornerRadius="4" Classes="statusFill info"/>
|
||||
<TextBlock Text="Entschuldigt" FontSize="10" Opacity="0.55"/>
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<Border Classes="sidePanel" IsVisible="{Binding HasOpenExcuses}">
|
||||
<StackPanel Spacing="8">
|
||||
<TextBlock Text="Offene Entschuldigungen" FontSize="14" FontWeight="SemiBold"/>
|
||||
<ItemsControl ItemsSource="{Binding OpenExcuses}">
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:ClassTeacherOpenExcuseRow">
|
||||
<Grid ColumnDefinitions="*,Auto">
|
||||
<Button Grid.Column="0" Classes="rosterRow" Padding="6,5"
|
||||
Command="{Binding $parent[ItemsControl].((vm:ClassTeacherOverviewViewModel)DataContext).ShowDetailsForOpenExcuseCommand}"
|
||||
CommandParameter="{Binding}">
|
||||
<Grid ColumnDefinitions="4,*,Auto" Margin="0,2">
|
||||
<Border Grid.Column="0" CornerRadius="2" Margin="0,1,8,1"
|
||||
Classes="statusFill" Classes.warning="{Binding !IsOverdue}"
|
||||
Classes.danger="{Binding IsOverdue}"/>
|
||||
<StackPanel Grid.Column="1">
|
||||
<TextBlock Text="{Binding StudentName}" FontSize="12" FontWeight="SemiBold"/>
|
||||
<TextBlock Text="{Binding DaysOpenLabel}" FontSize="11" Opacity="0.62"/>
|
||||
</StackPanel>
|
||||
<TextBlock Grid.Column="2" Text="{Binding DateLabel}" FontSize="11"
|
||||
Opacity="0.5" VerticalAlignment="Center"/>
|
||||
</Grid>
|
||||
</Button>
|
||||
<Button Grid.Column="1" Classes="rosterRowAction" Content="+"
|
||||
ToolTip.Tip="Wiedervorlage „Eltern kontaktieren“ anlegen"
|
||||
Command="{Binding $parent[ItemsControl].((vm:ClassTeacherOverviewViewModel)DataContext).CreateReminderForOpenExcuseCommand}"
|
||||
CommandParameter="{Binding}"/>
|
||||
</Grid>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
<TextBlock FontSize="11" Opacity="0.5" IsVisible="{Binding HasOpenExcuseOverflow}">
|
||||
<Run Text="+"/><Run Text="{Binding OpenExcuseOverflowCount}"/><Run Text=" weitere"/>
|
||||
</TextBlock>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<Border Classes="sidePanel" IsVisible="{Binding HasPatternNotices}">
|
||||
<StackPanel Spacing="8">
|
||||
<TextBlock Text="Muster erkannt" FontSize="14" FontWeight="SemiBold"/>
|
||||
<ItemsControl ItemsSource="{Binding PatternNotices}">
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:ClassTeacherPatternNotice">
|
||||
<Grid ColumnDefinitions="4,*" Margin="0,3">
|
||||
<Border CornerRadius="2" Margin="0,1,8,1" Classes="statusFill"
|
||||
Classes.info="{Binding IsInfoStatus}"
|
||||
Classes.warning="{Binding IsWarningStatus}"
|
||||
Classes.danger="{Binding IsDangerStatus}"/>
|
||||
<StackPanel Grid.Column="1">
|
||||
<TextBlock Text="{Binding StudentName}" FontSize="12" FontWeight="SemiBold"/>
|
||||
<TextBlock Text="{Binding Message}" FontSize="11" Opacity="0.62" TextWrapping="Wrap"/>
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<Border Classes="sidePanel">
|
||||
<StackPanel Spacing="8">
|
||||
<TextBlock Text="Nächste Schritte" FontSize="14" FontWeight="SemiBold"/>
|
||||
<Button Content="Klassenbuch öffnen" Command="{Binding OpenClassRegisterCommand}" HorizontalAlignment="Stretch"/>
|
||||
<Button Content="Fehlzeiten öffnen" Command="{Binding OpenAbsencesCommand}" HorizontalAlignment="Stretch"/>
|
||||
<Button Content="Aufgaben & Wiedervorlagen" Command="{Binding GoToWorkloadCommand}"
|
||||
HorizontalAlignment="Stretch" Background="Transparent"/>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
<TextBlock Text="{Binding Status}" FontSize="11" TextWrapping="Wrap" Opacity="0.6"/>
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</ContentPage>
|
||||
|
||||
<ContentPage Header="Klassenbuch">
|
||||
<views:ClassTeacherRegisterView DataContext="{Binding DetailsTab}"/>
|
||||
</ContentPage>
|
||||
<ContentPage Header="Fehlzeiten">
|
||||
<views:ClassTeacherAbsencesView DataContext="{Binding DetailsTab}"/>
|
||||
</ContentPage>
|
||||
</TabbedPage>
|
||||
</Grid>
|
||||
</UserControl>
|
||||
@@ -0,0 +1,8 @@
|
||||
using Avalonia.Controls;
|
||||
|
||||
namespace LehrerApp.Desktop.Views.ClassTeacher;
|
||||
|
||||
public partial class ClassTeacherOverviewView : UserControl
|
||||
{
|
||||
public ClassTeacherOverviewView() => InitializeComponent();
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
<UserControl xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:vm="clr-namespace:LehrerApp.Desktop.ViewModels.ClassTeacher"
|
||||
x:Class="LehrerApp.Desktop.Views.ClassTeacher.ClassTeacherRegisterView"
|
||||
x:DataType="vm:ClassTeacherDetailsViewModel">
|
||||
|
||||
<UserControl.Styles>
|
||||
<!-- Siehe Styles/SemanticBrushes.axaml: Farben per DynamicResource, damit der Theme-Wechsel
|
||||
sie live austauscht; die Statusfarbe je Zeile kommt als Style-Klasse aus dem ViewModel. -->
|
||||
<Style Selector="Border.filterCard">
|
||||
<Setter Property="Background" Value="{DynamicResource AppCardBackgroundBrush}"/>
|
||||
<Setter Property="BorderBrush" Value="{DynamicResource AppCardBorderBrush}"/>
|
||||
<Setter Property="BorderThickness" Value="1"/>
|
||||
<Setter Property="CornerRadius" Value="8"/>
|
||||
<Setter Property="Padding" Value="10"/>
|
||||
</Style>
|
||||
<Style Selector="TextBlock.status">
|
||||
<Setter Property="Foreground" Value="{DynamicResource AppStatusOkBrush}"/>
|
||||
</Style>
|
||||
<Style Selector="TextBlock.status.info">
|
||||
<Setter Property="Foreground" Value="{DynamicResource AppStatusInfoBrush}"/>
|
||||
</Style>
|
||||
<Style Selector="TextBlock.status.warning">
|
||||
<Setter Property="Foreground" Value="{DynamicResource AppStatusWarningBrush}"/>
|
||||
</Style>
|
||||
<Style Selector="TextBlock.status.danger">
|
||||
<Setter Property="Foreground" Value="{DynamicResource AppStatusDangerBrush}"/>
|
||||
</Style>
|
||||
</UserControl.Styles>
|
||||
|
||||
<Grid RowDefinitions="Auto,Auto,Auto,*,Auto" Margin="16" RowSpacing="10">
|
||||
<Grid Grid.Row="0" ColumnDefinitions="*,Auto">
|
||||
<StackPanel>
|
||||
<TextBlock Text="Klassenbucheinträge" FontSize="22" FontWeight="SemiBold"/>
|
||||
<TextBlock Text="Einträge anderer Lehrkräfte – nur zur Ansicht" FontSize="12" Opacity="0.55"/>
|
||||
</StackPanel>
|
||||
<Button Grid.Column="1" Content="↻ Aus WebUntis aktualisieren" Command="{Binding RefreshCommand}"
|
||||
IsEnabled="{Binding !Busy}" Background="Transparent" Foreground="{DynamicResource AppAccentTextBrush}" VerticalAlignment="Center"/>
|
||||
</Grid>
|
||||
|
||||
<Border Grid.Row="1" Classes="filterCard">
|
||||
<Grid RowDefinitions="Auto,Auto" RowSpacing="8">
|
||||
<Grid Grid.Row="0" ColumnDefinitions="155,Auto,*,Auto,*" ColumnSpacing="8">
|
||||
<ComboBox Grid.Column="0" SelectedIndex="{Binding QuickRangeIndex, Mode=TwoWay}">
|
||||
<ComboBoxItem Content="Heute"/><ComboBoxItem Content="Letzte 7 Tage"/><ComboBoxItem Content="Letzte 30 Tage"/>
|
||||
</ComboBox>
|
||||
<TextBlock Grid.Column="1" Text="von" VerticalAlignment="Center" Opacity="0.6"/>
|
||||
<DatePicker Grid.Column="2" SelectedDate="{Binding StartDate}" HorizontalAlignment="Stretch"/>
|
||||
<TextBlock Grid.Column="3" Text="bis" VerticalAlignment="Center" Opacity="0.6"/>
|
||||
<DatePicker Grid.Column="4" SelectedDate="{Binding EndDate}" HorizontalAlignment="Stretch"/>
|
||||
</Grid>
|
||||
<Grid Grid.Row="1" ColumnDefinitions="*,Auto,Auto" ColumnSpacing="8">
|
||||
<TextBox Grid.Column="0" Text="{Binding StudentFilter, Mode=TwoWay}" PlaceholderText="Schüler*in filtern…"/>
|
||||
<Button Grid.Column="1" Content="Anzeigen" Command="{Binding ApplyFilterCommand}" IsEnabled="{Binding !Busy}"/>
|
||||
<Button Grid.Column="2" Content="Filter löschen" Command="{Binding ClearStudentFilterCommand}"
|
||||
IsVisible="{Binding StudentFilter, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<!-- Nutzer-Feedback: die reine Gesamtzahl der Einträge sagt wenig, eine Aufschlüsselung nach
|
||||
Kategorie ("Hausaufgaben fehlen: 12× — 5× Ben Schmidt, 3× Ada Müller") ist die eigentlich
|
||||
interessante Information. Als Chip-Reihe statt eigener Spalte in der Tabelle, damit die
|
||||
bestehenden Spalten unangetastet bleiben. -->
|
||||
<Border Grid.Row="2" Classes="filterCard" IsVisible="{Binding HasCategoryAggregates}">
|
||||
<StackPanel Spacing="6">
|
||||
<TextBlock Text="Kategorien im Zeitraum" FontSize="12" FontWeight="SemiBold" Opacity="0.7"/>
|
||||
<ItemsControl ItemsSource="{Binding CategoryAggregates}">
|
||||
<ItemsControl.ItemsPanel>
|
||||
<ItemsPanelTemplate>
|
||||
<WrapPanel/>
|
||||
</ItemsPanelTemplate>
|
||||
</ItemsControl.ItemsPanel>
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:ClassTeacherCategoryAggregateRow">
|
||||
<Border Background="{DynamicResource AppChipBackgroundBrush}" CornerRadius="5"
|
||||
Padding="8,4" Margin="0,0,6,6">
|
||||
<StackPanel Orientation="Horizontal" Spacing="4">
|
||||
<TextBlock Text="{Binding CategoryName}" FontSize="11" FontWeight="SemiBold"/>
|
||||
<TextBlock Text="{Binding SummaryLabel}" FontSize="11" Opacity="0.6"/>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<Grid Grid.Row="3">
|
||||
<DataGrid ItemsSource="{Binding Entries}" AutoGenerateColumns="False" IsReadOnly="True"
|
||||
GridLinesVisibility="Horizontal" BorderBrush="{DynamicResource AppCardBorderBrush}" BorderThickness="1"
|
||||
CanUserResizeColumns="True" CanUserSortColumns="True" RowHeight="46">
|
||||
<DataGrid.Columns>
|
||||
<DataGridTextColumn Header="Datum" Binding="{Binding DateLabel}" Width="105"/>
|
||||
<DataGridTextColumn Header="Schüler*in" Binding="{Binding StudentDisplayName}" Width="1.25*"/>
|
||||
<DataGridTextColumn Header="Fach" Binding="{Binding Subject}" Width="0.7*"/>
|
||||
<DataGridTextColumn Header="Lehrkraft" Binding="{Binding TeacherUsername}" Width="0.8*"/>
|
||||
<DataGridTextColumn Header="Kategorie" Binding="{Binding CategoryName}" Width="1*"/>
|
||||
<DataGridTextColumn Header="Gruppe" Binding="{Binding CategoryGroup}" Width="0.8*"/>
|
||||
<DataGridTextColumn Header="Eintrag" Binding="{Binding Text}" Width="2*"/>
|
||||
</DataGrid.Columns>
|
||||
</DataGrid>
|
||||
<StackPanel IsVisible="{Binding !HasEntries}" HorizontalAlignment="Center" VerticalAlignment="Center" Spacing="5">
|
||||
<TextBlock Text="Keine Klassenbucheinträge im gewählten Zeitraum" FontWeight="SemiBold"/>
|
||||
<TextBlock Text="Passe Zeitraum oder Schülerfilter an." FontSize="12" Opacity="0.55"/>
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
<TextBlock Grid.Row="4" Text="{Binding Status}" FontSize="11" Opacity="0.6"/>
|
||||
</Grid>
|
||||
</UserControl>
|
||||
@@ -0,0 +1,8 @@
|
||||
using Avalonia.Controls;
|
||||
|
||||
namespace LehrerApp.Desktop.Views.ClassTeacher;
|
||||
|
||||
public partial class ClassTeacherRegisterView : UserControl
|
||||
{
|
||||
public ClassTeacherRegisterView() => InitializeComponent();
|
||||
}
|
||||
@@ -25,10 +25,40 @@
|
||||
<TextBlock Text="{Binding Greeting}" FontSize="14" Opacity="0.6"/>
|
||||
<TextBlock Text="{Binding CurrentDate}" FontSize="24" FontWeight="SemiBold"/>
|
||||
</StackPanel>
|
||||
<Button Grid.Column="1" Content="Dashboard anpassen" Command="{Binding ToggleDashboardSettingsCommand}"
|
||||
<StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="8">
|
||||
<Button Content="Klassenbuch abgleichen…" Click="OnCompareWebUntisDocumentationClick"
|
||||
VerticalAlignment="Center"/>
|
||||
<Button Content="Bereiche anpassen" Command="{Binding ToggleDashboardSettingsCommand}"
|
||||
VerticalAlignment="Center"/>
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
|
||||
<!-- Der Tagesfokus beantwortet zuerst die vier Fragen, die beim Öffnen der App zählen:
|
||||
Was unterrichte ich, was ist zu tun, wo muss ich reagieren und was steht an? -->
|
||||
<Border Background="{DynamicResource AppCardBackgroundBrush}"
|
||||
BorderBrush="{DynamicResource AppCardBorderBrush}" BorderThickness="1"
|
||||
CornerRadius="10" Padding="16,14">
|
||||
<Grid ColumnDefinitions="*,*,*,*">
|
||||
<StackPanel Grid.Column="0" Spacing="3">
|
||||
<TextBlock Text="UNTERRICHT HEUTE" FontSize="10" FontWeight="Bold" Opacity="0.5"/>
|
||||
<TextBlock Text="{Binding TodayLessonSummary}" FontSize="18" FontWeight="SemiBold"/>
|
||||
</StackPanel>
|
||||
<StackPanel Grid.Column="1" Spacing="3" Margin="18,0,0,0">
|
||||
<TextBlock Text="AUFGABEN" FontSize="10" FontWeight="Bold" Opacity="0.5"/>
|
||||
<TextBlock Text="{Binding OpenTaskSummary}" FontSize="18" FontWeight="SemiBold"/>
|
||||
</StackPanel>
|
||||
<StackPanel Grid.Column="2" Spacing="3" Margin="18,0,0,0">
|
||||
<TextBlock Text="HANDLUNGSBEDARF" FontSize="10" FontWeight="Bold" Opacity="0.5"/>
|
||||
<TextBlock Text="{Binding AttentionSummary}" FontSize="18" FontWeight="SemiBold"
|
||||
Foreground="{DynamicResource AppStatusWarningBrush}"/>
|
||||
</StackPanel>
|
||||
<StackPanel Grid.Column="3" Spacing="3" Margin="18,0,0,0">
|
||||
<TextBlock Text="NÄCHSTE 30 TAGE" FontSize="10" FontWeight="Bold" Opacity="0.5"/>
|
||||
<TextBlock Text="{Binding UpcomingSummary}" FontSize="18" FontWeight="SemiBold"/>
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<Border Background="{DynamicResource SystemControlBackgroundAltHighBrush}" CornerRadius="8"
|
||||
Padding="12" IsVisible="{Binding IsDashboardSettingsOpen}">
|
||||
<ItemsControl ItemsSource="{Binding DashboardCards}">
|
||||
@@ -55,14 +85,15 @@
|
||||
</Border>
|
||||
|
||||
<!-- Serverseitig gecachte DWD-Daten für den in den Einstellungen hinterlegten Schulstandort. -->
|
||||
<Border Background="{DynamicResource SystemControlBackgroundAltHighBrush}" CornerRadius="8"
|
||||
Padding="16" IsVisible="{Binding IsWeatherPanelVisible}">
|
||||
<StackPanel Spacing="10">
|
||||
<Expander Header="{Binding WeatherSummary}" IsExpanded="{Binding HasWeatherWarnings}"
|
||||
IsVisible="{Binding IsWeatherPanelVisible}"
|
||||
Background="{DynamicResource AppCardBackgroundBrush}"
|
||||
BorderBrush="{DynamicResource AppCardBorderBrush}" BorderThickness="1"
|
||||
CornerRadius="8" Padding="12,6">
|
||||
<StackPanel Spacing="10" Margin="6,8,6,6">
|
||||
<Grid ColumnDefinitions="*,Auto">
|
||||
<StackPanel>
|
||||
<TextBlock Text="WETTER AM SCHULSTANDORT" FontSize="11" FontWeight="Bold" Opacity="0.5"/>
|
||||
<TextBlock Text="{Binding WeatherSummary}" FontSize="20" FontWeight="SemiBold" Margin="0,5,0,0"
|
||||
IsVisible="{Binding WeatherSummary, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
|
||||
<TextBlock Text="{Binding WeatherDetails}" FontSize="11" Opacity="0.65" TextWrapping="Wrap"
|
||||
IsVisible="{Binding WeatherDetails, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
|
||||
</StackPanel>
|
||||
@@ -92,17 +123,20 @@
|
||||
</ItemsControl>
|
||||
<TextBlock Text="Wetterdaten © Deutscher Wetterdienst" FontSize="10" Opacity="0.5"/>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</Expander>
|
||||
|
||||
<Grid ColumnDefinitions="*,*" RowDefinitions="Auto,Auto,Auto,Auto,Auto,Auto">
|
||||
<TextBlock Text="HEUTE UND HANDLUNGSBEDARF" FontSize="11" FontWeight="Bold" Opacity="0.5"
|
||||
Margin="2,2,0,-8"/>
|
||||
|
||||
<Grid ColumnDefinitions="3*,2*" RowDefinitions="Auto,Auto,Auto,Auto,Auto,Auto">
|
||||
|
||||
<!-- Heutige Stunden -->
|
||||
<Border Grid.Column="{Binding TodayCard.Column}" Grid.Row="{Binding TodayCard.Row}"
|
||||
IsVisible="{Binding TodayCard.IsVisible}" Margin="0,0,8,8"
|
||||
IsVisible="{Binding TodayCard.EffectiveIsVisible}" Margin="0,0,8,8"
|
||||
Background="{DynamicResource SystemControlBackgroundAltHighBrush}"
|
||||
CornerRadius="8" Padding="16">
|
||||
<StackPanel>
|
||||
<TextBlock Text="HEUTE" FontSize="11" FontWeight="Bold"
|
||||
<TextBlock Text="Heute" FontSize="14" FontWeight="SemiBold"
|
||||
Opacity="0.5" Margin="0,0,0,10"/>
|
||||
<ItemsControl ItemsSource="{Binding TodaysLessons}">
|
||||
<ItemsControl.ItemTemplate>
|
||||
@@ -139,12 +173,12 @@
|
||||
|
||||
<!-- Offene Aufgaben -->
|
||||
<Border Grid.Column="{Binding TasksCard.Column}" Grid.Row="{Binding TasksCard.Row}"
|
||||
IsVisible="{Binding TasksCard.IsVisible}" Margin="8,0,0,8"
|
||||
IsVisible="{Binding TasksCard.EffectiveIsVisible}" Margin="8,0,0,8"
|
||||
Background="{DynamicResource SystemControlBackgroundAltHighBrush}"
|
||||
CornerRadius="8" Padding="16">
|
||||
<StackPanel>
|
||||
<Grid ColumnDefinitions="*,Auto,Auto" Margin="0,0,0,10">
|
||||
<TextBlock Grid.Column="0" Text="OFFENE AUFGABEN" FontSize="11" FontWeight="Bold"
|
||||
<TextBlock Grid.Column="0" Text="Offene Aufgaben" FontSize="14" FontWeight="SemiBold"
|
||||
Opacity="0.5" VerticalAlignment="Center"/>
|
||||
<Button Grid.Column="1" Content="🔔+" FontSize="12" Padding="7,2" Margin="0,0,4,0"
|
||||
ToolTip.Tip="Erinnerung anlegen" Command="{Binding AddReminderCommand}"/>
|
||||
@@ -176,7 +210,7 @@
|
||||
<!-- Kalender: feste Position direkt unter Heute/Aufgaben, damit die wachsende
|
||||
Lerngruppen-Liste darunter ihn nicht nach unten verdrängt. -->
|
||||
<Border Grid.Column="{Binding CalendarCard.Column}" Grid.Row="{Binding CalendarCard.Row}"
|
||||
IsVisible="{Binding CalendarCard.IsVisible}" Margin="0,0,8,8"
|
||||
IsVisible="{Binding CalendarCard.EffectiveIsVisible}" Margin="0,0,8,8"
|
||||
Background="{DynamicResource SystemControlBackgroundAltHighBrush}"
|
||||
CornerRadius="8" Padding="16">
|
||||
<StackPanel Spacing="8">
|
||||
@@ -317,7 +351,7 @@
|
||||
|
||||
<!-- Offene Entschuldigungen: neben dem Kalender, ebenfalls feste Position -->
|
||||
<Border Grid.Column="{Binding ExcusesCard.Column}" Grid.Row="{Binding ExcusesCard.Row}"
|
||||
IsVisible="{Binding ExcusesCard.IsVisible}" Margin="8,0,0,8" VerticalAlignment="Top"
|
||||
IsVisible="{Binding ExcusesCard.EffectiveIsVisible}" Margin="8,0,0,8" VerticalAlignment="Top"
|
||||
Background="{DynamicResource SystemControlBackgroundAltHighBrush}"
|
||||
CornerRadius="8" Padding="16">
|
||||
<StackPanel>
|
||||
@@ -351,7 +385,7 @@
|
||||
|
||||
<!-- Fehlzeiten-Warnung (5.2.3) -->
|
||||
<Border Grid.Column="{Binding AttendanceCard.Column}" Grid.Row="{Binding AttendanceCard.Row}"
|
||||
IsVisible="{Binding AttendanceCard.IsVisible}" Margin="0,0,8,8" VerticalAlignment="Top"
|
||||
IsVisible="{Binding AttendanceCard.EffectiveIsVisible}" Margin="0,0,8,8" VerticalAlignment="Top"
|
||||
Background="{DynamicResource SystemControlBackgroundAltHighBrush}"
|
||||
CornerRadius="8" Padding="16">
|
||||
<StackPanel>
|
||||
@@ -381,7 +415,7 @@
|
||||
|
||||
<!-- Förderplan-Wiedervorlage (5.3.2) -->
|
||||
<Border Grid.Column="{Binding SupportCard.Column}" Grid.Row="{Binding SupportCard.Row}"
|
||||
IsVisible="{Binding SupportCard.IsVisible}" Margin="8,0,0,8" VerticalAlignment="Top"
|
||||
IsVisible="{Binding SupportCard.EffectiveIsVisible}" Margin="8,0,0,8" VerticalAlignment="Top"
|
||||
Background="{DynamicResource SystemControlBackgroundAltHighBrush}"
|
||||
CornerRadius="8" Padding="16">
|
||||
<StackPanel>
|
||||
@@ -412,7 +446,7 @@
|
||||
|
||||
<!-- Anstehende Termine (9.3) -->
|
||||
<Border Grid.Column="{Binding UpcomingCard.Column}" Grid.Row="{Binding UpcomingCard.Row}"
|
||||
IsVisible="{Binding UpcomingCard.IsVisible}" Margin="0,0,8,8" VerticalAlignment="Top"
|
||||
IsVisible="{Binding UpcomingCard.EffectiveIsVisible}" Margin="0,0,8,8" VerticalAlignment="Top"
|
||||
Background="{DynamicResource SystemControlBackgroundAltHighBrush}"
|
||||
CornerRadius="8" Padding="16">
|
||||
<StackPanel>
|
||||
@@ -449,7 +483,7 @@
|
||||
|
||||
<!-- Offene Korrekturen (9.4) -->
|
||||
<Border Grid.Column="{Binding CorrectionsCard.Column}" Grid.Row="{Binding CorrectionsCard.Row}"
|
||||
IsVisible="{Binding CorrectionsCard.IsVisible}" Margin="8,0,0,8" VerticalAlignment="Top"
|
||||
IsVisible="{Binding CorrectionsCard.EffectiveIsVisible}" Margin="8,0,0,8" VerticalAlignment="Top"
|
||||
Background="{DynamicResource SystemControlBackgroundAltHighBrush}"
|
||||
CornerRadius="8" Padding="16">
|
||||
<StackPanel>
|
||||
@@ -482,7 +516,7 @@
|
||||
|
||||
<!-- Ungeplante Stunden -->
|
||||
<Border Grid.Column="{Binding UnplannedCard.Column}" Grid.Row="{Binding UnplannedCard.Row}"
|
||||
IsVisible="{Binding UnplannedCard.IsVisible}" Margin="0,0,8,8" VerticalAlignment="Top"
|
||||
IsVisible="{Binding UnplannedCard.EffectiveIsVisible}" Margin="0,0,8,8" VerticalAlignment="Top"
|
||||
Background="{DynamicResource SystemControlBackgroundAltHighBrush}"
|
||||
CornerRadius="8" Padding="16">
|
||||
<StackPanel>
|
||||
@@ -512,7 +546,7 @@
|
||||
|
||||
<!-- Auffälligkeiten (9.5) -->
|
||||
<Border Grid.Column="{Binding AlertsCard.Column}" Grid.Row="{Binding AlertsCard.Row}"
|
||||
IsVisible="{Binding AlertsCard.IsVisible}" Margin="0,0,8,8" VerticalAlignment="Top"
|
||||
IsVisible="{Binding AlertsCard.EffectiveIsVisible}" Margin="0,0,8,8" VerticalAlignment="Top"
|
||||
Background="{DynamicResource SystemControlBackgroundAltHighBrush}"
|
||||
CornerRadius="8" Padding="16">
|
||||
<StackPanel>
|
||||
@@ -546,7 +580,7 @@
|
||||
|
||||
<!-- Meine Lerngruppen -->
|
||||
<Border Grid.Column="{Binding GroupsCard.Column}" Grid.Row="{Binding GroupsCard.Row}"
|
||||
IsVisible="{Binding GroupsCard.IsVisible}"
|
||||
IsVisible="{Binding GroupsCard.EffectiveIsVisible}"
|
||||
Background="{DynamicResource SystemControlBackgroundAltHighBrush}"
|
||||
CornerRadius="8" Padding="16">
|
||||
<StackPanel>
|
||||
|
||||
@@ -1,7 +1,14 @@
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Interactivity;
|
||||
using LehrerApp.Core.Interfaces;
|
||||
using LehrerApp.Core.Models;
|
||||
using LehrerApp.Core.Services;
|
||||
using LehrerApp.Desktop.Services;
|
||||
using LehrerApp.Desktop.ViewModels;
|
||||
using LehrerApp.Desktop.ViewModels.Students;
|
||||
using LehrerApp.Desktop.Views.Students;
|
||||
using LehrerApp.Desktop.Views.Workload;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace LehrerApp.Desktop.Views.Dashboard;
|
||||
|
||||
@@ -22,4 +29,17 @@ public partial class DashboardView : UserControl
|
||||
if (owner is null) return null;
|
||||
return await WorkTaskDialogHelper.ShowDialog(owner, startAsReminder: startAsReminder);
|
||||
}
|
||||
|
||||
private async void OnCompareWebUntisDocumentationClick(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
var owner = TopLevel.GetTopLevel(this) as Window;
|
||||
if (owner is null) return;
|
||||
var dialogVm = new WebUntisDocumentationComparisonViewModel(
|
||||
App.Services.GetRequiredService<WebUntisIntegrationService>(),
|
||||
App.Services.GetRequiredService<IStudentRepository>(),
|
||||
App.Services.GetRequiredService<IGroupRepository>(),
|
||||
App.Services.GetRequiredService<IDocumentationRepository>(),
|
||||
App.Services.GetRequiredService<SchoolYearService>());
|
||||
await new WebUntisDocumentationComparisonDialog { DataContext = dialogVm }.ShowDialog(owner);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,222 @@
|
||||
<UserControl xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:vm="clr-namespace:LehrerApp.Desktop.ViewModels.Exams"
|
||||
xmlns:vmg="clr-namespace:LehrerApp.Desktop.ViewModels.Groups"
|
||||
xmlns:shared="clr-namespace:LehrerApp.Desktop.Views.Shared"
|
||||
x:Class="LehrerApp.Desktop.Views.Exams.ExamsOverviewView"
|
||||
x:DataType="vm:ExamsOverviewViewModel">
|
||||
|
||||
<!-- Statusfarben kommen wie im Klassenlehrer-Bereich aus Styles/SemanticBrushes.axaml (siehe
|
||||
ClassTeacherOverviewView) — Basis ist hier aber "neutral" statt "ok", weil geplante bzw.
|
||||
hängengebliebene Klausuren (ExamPriorityService.CorrectionStuck) hier den Normalfall
|
||||
bilden, nicht ein positives Ergebnis. -->
|
||||
<UserControl.Styles>
|
||||
<Style Selector="Border.examStatusBar">
|
||||
<Setter Property="Background" Value="{DynamicResource AppListRowBorderBrush}"/>
|
||||
</Style>
|
||||
<Style Selector="Border.examStatusBar.ok">
|
||||
<Setter Property="Background" Value="{DynamicResource AppStatusOkBrush}"/>
|
||||
</Style>
|
||||
<Style Selector="Border.examStatusBar.info">
|
||||
<Setter Property="Background" Value="{DynamicResource AppStatusInfoBrush}"/>
|
||||
</Style>
|
||||
<Style Selector="Border.examStatusBar.warning">
|
||||
<Setter Property="Background" Value="{DynamicResource AppStatusWarningBrush}"/>
|
||||
</Style>
|
||||
<Style Selector="Border.examStatusBar.danger">
|
||||
<Setter Property="Background" Value="{DynamicResource AppStatusDangerBrush}"/>
|
||||
</Style>
|
||||
<Style Selector="Border.examStatusPill">
|
||||
<Setter Property="Background" Value="{DynamicResource AppChipBackgroundBrush}"/>
|
||||
<Setter Property="CornerRadius" Value="4"/>
|
||||
<Setter Property="Padding" Value="8,2"/>
|
||||
</Style>
|
||||
<Style Selector="Border.examStatusPill TextBlock">
|
||||
<Setter Property="FontSize" Value="12"/>
|
||||
</Style>
|
||||
<Style Selector="Border.examStatusPill.ok">
|
||||
<Setter Property="Background" Value="{DynamicResource AppStatusOkBrush}"/>
|
||||
</Style>
|
||||
<Style Selector="Border.examStatusPill.ok TextBlock">
|
||||
<Setter Property="Foreground" Value="White"/>
|
||||
</Style>
|
||||
<Style Selector="Border.examStatusPill.info">
|
||||
<Setter Property="Background" Value="{DynamicResource AppStatusInfoBrush}"/>
|
||||
</Style>
|
||||
<Style Selector="Border.examStatusPill.info TextBlock">
|
||||
<Setter Property="Foreground" Value="White"/>
|
||||
</Style>
|
||||
<Style Selector="Border.examStatusPill.warning">
|
||||
<Setter Property="Background" Value="{DynamicResource AppStatusWarningBrush}"/>
|
||||
</Style>
|
||||
<Style Selector="Border.examStatusPill.warning TextBlock">
|
||||
<Setter Property="Foreground" Value="White"/>
|
||||
</Style>
|
||||
<Style Selector="Border.examStatusPill.danger">
|
||||
<Setter Property="Background" Value="{DynamicResource AppStatusDangerBrush}"/>
|
||||
</Style>
|
||||
<Style Selector="Border.examStatusPill.danger TextBlock">
|
||||
<Setter Property="Foreground" Value="White"/>
|
||||
</Style>
|
||||
<Style Selector="Button.examRow">
|
||||
<Setter Property="HorizontalAlignment" Value="Stretch"/>
|
||||
<Setter Property="HorizontalContentAlignment" Value="Stretch"/>
|
||||
<Setter Property="Background" Value="{DynamicResource AppListRowBackgroundBrush}"/>
|
||||
<Setter Property="BorderBrush" Value="{DynamicResource AppListRowBorderBrush}"/>
|
||||
<Setter Property="BorderThickness" Value="1,0,1,1"/>
|
||||
<Setter Property="Padding" Value="0"/>
|
||||
<Setter Property="CornerRadius" Value="0"/>
|
||||
</Style>
|
||||
<Style Selector="Button.examRow:pointerover /template/ ContentPresenter">
|
||||
<Setter Property="Background" Value="{DynamicResource AppListRowHoverBrush}"/>
|
||||
</Style>
|
||||
<Style Selector="Button.siblingPill">
|
||||
<Setter Property="Padding" Value="10,4"/>
|
||||
<Setter Property="FontSize" Value="12"/>
|
||||
<Setter Property="CornerRadius" Value="12"/>
|
||||
</Style>
|
||||
</UserControl.Styles>
|
||||
|
||||
<Grid RowDefinitions="Auto,Auto,*">
|
||||
<Border Grid.Row="0" Padding="20,16"
|
||||
BorderBrush="{DynamicResource SystemControlForegroundBaseLowBrush}"
|
||||
BorderThickness="0,0,0,1">
|
||||
<Grid ColumnDefinitions="*,Auto">
|
||||
<shared:PageHeader Grid.Column="0" Title="Klausuren"
|
||||
Subtitle="Alle Klausuren des Schuljahres, nach Dringlichkeit sortiert"/>
|
||||
<Button Grid.Column="1" Content="↻ Aktualisieren" Command="{Binding LoadCommand}"
|
||||
VerticalAlignment="Center"/>
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<!-- Detailbereich zur ausgewählten Klausur ─────────────────────────────────── -->
|
||||
<Border Grid.Row="1" Margin="20,16,20,10" Padding="18"
|
||||
Background="{DynamicResource AppCardBackgroundBrush}"
|
||||
BorderBrush="{DynamicResource AppCardBorderBrush}" BorderThickness="1" CornerRadius="10"
|
||||
IsVisible="{Binding HasSelection}">
|
||||
<StackPanel Spacing="12">
|
||||
<Grid ColumnDefinitions="*,Auto">
|
||||
<StackPanel Grid.Column="0" Spacing="2">
|
||||
<TextBlock Text="{Binding SelectedRow.Title}" FontSize="16" FontWeight="SemiBold"/>
|
||||
<TextBlock FontSize="13" Opacity="0.6">
|
||||
<Run Text="{Binding SelectedRow.GroupLabel}"/>
|
||||
<Run Text=" · "/>
|
||||
<Run Text="{Binding SelectedRow.SubLabel}"/>
|
||||
</TextBlock>
|
||||
</StackPanel>
|
||||
<Border Grid.Column="1" Classes="examStatusPill" VerticalAlignment="Top"
|
||||
Classes.ok="{Binding SelectedRow.IsOk}" Classes.info="{Binding SelectedRow.IsInfo}"
|
||||
Classes.warning="{Binding SelectedRow.IsWarning}" Classes.danger="{Binding SelectedRow.IsDanger}">
|
||||
<TextBlock Text="{Binding SelectedRow.StatusLabel}"/>
|
||||
</Border>
|
||||
</Grid>
|
||||
|
||||
<!-- Parallelkurs-Umschalter -->
|
||||
<ItemsControl ItemsSource="{Binding Siblings}" IsVisible="{Binding SelectedRow.HasSibling}">
|
||||
<ItemsControl.ItemsPanel>
|
||||
<ItemsPanelTemplate>
|
||||
<StackPanel Orientation="Horizontal" Spacing="8"/>
|
||||
</ItemsPanelTemplate>
|
||||
</ItemsControl.ItemsPanel>
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:ExamListRowViewModel">
|
||||
<Button Classes="siblingPill"
|
||||
Command="{Binding $parent[ItemsControl].((vm:ExamsOverviewViewModel)DataContext).SelectExamCommand}"
|
||||
CommandParameter="{Binding}">
|
||||
<TextBlock>
|
||||
<Run Text="{Binding GroupLabel}"/>
|
||||
<Run Text=" · "/>
|
||||
<Run Text="{Binding Evaluated}"/>
|
||||
<Run Text="/"/>
|
||||
<Run Text="{Binding Expected}"/>
|
||||
</TextBlock>
|
||||
</Button>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
|
||||
<!-- Korrekturfortschritt -->
|
||||
<StackPanel Spacing="6" IsVisible="{Binding ShowCorrectionProgress}">
|
||||
<TextBlock Text="{Binding ProgressLabel}" FontSize="13"/>
|
||||
<Border Width="240" Height="6" CornerRadius="3" HorizontalAlignment="Left"
|
||||
Background="{DynamicResource AppTrackBackgroundBrush}">
|
||||
<Border Width="{Binding ProgressBarWidth}" Height="6" CornerRadius="3"
|
||||
HorizontalAlignment="Left" Background="{DynamicResource AppStatusWarningBrush}"/>
|
||||
</Border>
|
||||
</StackPanel>
|
||||
|
||||
<!-- Notenspiegel -->
|
||||
<StackPanel Spacing="6" IsVisible="{Binding ShowGradeSummary}">
|
||||
<TextBlock Text="{Binding AverageLabel}" FontSize="14" FontWeight="SemiBold"/>
|
||||
<ItemsControl ItemsSource="{Binding DetailGradeDistribution}">
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate x:DataType="vmg:GradeBarItem">
|
||||
<Grid ColumnDefinitions="30,*,60" Margin="0,1">
|
||||
<TextBlock Grid.Column="0" Text="{Binding Grade}" FontSize="12" VerticalAlignment="Center"/>
|
||||
<Border Grid.Column="1" Height="14" Width="{Binding BarWidth}" HorizontalAlignment="Left"
|
||||
Background="{DynamicResource AppAccentTextBrush}" CornerRadius="3"/>
|
||||
<TextBlock Grid.Column="2" Text="{Binding CountDisplay}" FontSize="11" Opacity="0.6"
|
||||
VerticalAlignment="Center"/>
|
||||
</Grid>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
</StackPanel>
|
||||
|
||||
<!-- Genehmigung / Ankündigung -->
|
||||
<StackPanel Orientation="Horizontal" Spacing="10">
|
||||
<Button Content="{Binding ApprovalLabel}" Command="{Binding ToggleApprovalCommand}" FontSize="12"/>
|
||||
<Button Content="{Binding AnnouncementLabel}" Command="{Binding ToggleAnnouncementCommand}" FontSize="12"/>
|
||||
</StackPanel>
|
||||
|
||||
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||
<Button Content="Punkte eingeben" Command="{Binding GradeSelectedCommand}"
|
||||
IsVisible="{Binding ShowCorrectionProgress}"/>
|
||||
<Button Content="Auswertung" Command="{Binding EvaluateSelectedCommand}"
|
||||
IsVisible="{Binding ShowGradeSummary}"/>
|
||||
<Button Content="Zum Kurs →" Command="{Binding GoToGroupCommand}" HorizontalAlignment="Right"/>
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<!-- Liste aller Klausuren, nach Priorität sortiert (nicht nach Datum) ─────────── -->
|
||||
<ScrollViewer Grid.Row="2" Margin="20,0,20,16">
|
||||
<StackPanel>
|
||||
<TextBlock Text="{Binding EmptyHint}" Classes="emptyhint" Margin="0,20,0,0"
|
||||
IsVisible="{Binding EmptyHint, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
|
||||
<ItemsControl ItemsSource="{Binding Rows}">
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:ExamListRowViewModel">
|
||||
<Button Classes="examRow"
|
||||
Command="{Binding $parent[ItemsControl].((vm:ExamsOverviewViewModel)DataContext).SelectExamCommand}"
|
||||
CommandParameter="{Binding}">
|
||||
<Grid ColumnDefinitions="4,*,Auto,Auto" MinHeight="52">
|
||||
<Border Grid.Column="0" Classes="examStatusBar" Classes.ok="{Binding IsOk}"
|
||||
Classes.info="{Binding IsInfo}" Classes.warning="{Binding IsWarning}"
|
||||
Classes.danger="{Binding IsDanger}"/>
|
||||
<StackPanel Grid.Column="1" VerticalAlignment="Center" Margin="12,6" Spacing="2">
|
||||
<StackPanel Orientation="Horizontal" Spacing="6">
|
||||
<TextBlock Text="{Binding Title}" FontSize="14" FontWeight="Medium"/>
|
||||
<TextBlock Text="🔗" FontSize="11" Opacity="0.5" IsVisible="{Binding HasSibling}"
|
||||
ToolTip.Tip="Parallelkurs vorhanden"/>
|
||||
</StackPanel>
|
||||
<TextBlock FontSize="12" Opacity="0.6">
|
||||
<Run Text="{Binding GroupLabel}"/>
|
||||
<Run Text=" · "/>
|
||||
<Run Text="{Binding SubLabel}"/>
|
||||
</TextBlock>
|
||||
</StackPanel>
|
||||
<Border Grid.Column="3" Classes="examStatusPill" VerticalAlignment="Center" Margin="0,0,12,0"
|
||||
Classes.ok="{Binding IsOk}" Classes.info="{Binding IsInfo}"
|
||||
Classes.warning="{Binding IsWarning}" Classes.danger="{Binding IsDanger}">
|
||||
<TextBlock Text="{Binding StatusLabel}"/>
|
||||
</Border>
|
||||
</Grid>
|
||||
</Button>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
</Grid>
|
||||
</UserControl>
|
||||
@@ -0,0 +1,63 @@
|
||||
using Avalonia.Controls;
|
||||
using LehrerApp.Core.Interfaces;
|
||||
using LehrerApp.Core.Models;
|
||||
using LehrerApp.Core.Services;
|
||||
using LehrerApp.Desktop.ViewModels;
|
||||
using LehrerApp.Desktop.ViewModels.Exams;
|
||||
using LehrerApp.Desktop.ViewModels.Groups;
|
||||
using LehrerApp.Desktop.Views.Groups;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace LehrerApp.Desktop.Views.Exams;
|
||||
|
||||
public partial class ExamsOverviewView : UserControl
|
||||
{
|
||||
private const int GroupDetailKlausurenTabIndex = 4;
|
||||
|
||||
public ExamsOverviewView() => InitializeComponent();
|
||||
|
||||
protected override void OnDataContextChanged(EventArgs e)
|
||||
{
|
||||
base.OnDataContextChanged(e);
|
||||
if (DataContext is ExamsOverviewViewModel vm)
|
||||
{
|
||||
vm.OnGradeExam = ShowGradeExamDialog;
|
||||
vm.OnEvaluateExam = ShowEvaluateExamDialog;
|
||||
vm.OnNavigateToGroup = groupId => App.Services.GetRequiredService<MainWindowViewModel>()
|
||||
.NavigateToGroupDetail(groupId, GroupDetailKlausurenTabIndex);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ShowGradeExamDialog(Exam exam)
|
||||
{
|
||||
var group = App.Services.GetRequiredService<IGroupRepository>().GetById(exam.GroupId);
|
||||
if (group is null) return;
|
||||
|
||||
var dialogVm = new ExamGradingDialogViewModel(
|
||||
App.Services.GetRequiredService<IExamResultRepository>(),
|
||||
App.Services.GetRequiredService<IStudentRepository>(),
|
||||
App.Services.GetRequiredService<IGroupMembershipRepository>(),
|
||||
App.Services.GetRequiredService<GradingService>(),
|
||||
exam, group.Id);
|
||||
|
||||
var dialog = new ExamGradingDialog { DataContext = dialogVm };
|
||||
var owner = TopLevel.GetTopLevel(this) as Window;
|
||||
if (owner is not null) await dialog.ShowDialog(owner);
|
||||
}
|
||||
|
||||
private async Task ShowEvaluateExamDialog(Exam exam)
|
||||
{
|
||||
var group = App.Services.GetRequiredService<IGroupRepository>().GetById(exam.GroupId);
|
||||
if (group is null) return;
|
||||
|
||||
var dialogVm = new ExamEvaluationDialogViewModel(
|
||||
App.Services.GetRequiredService<IExamRepository>(),
|
||||
App.Services.GetRequiredService<IExamResultRepository>(),
|
||||
App.Services.GetRequiredService<GradingService>(),
|
||||
exam, group.GradingSystem);
|
||||
|
||||
var dialog = new ExamEvaluationDialog { DataContext = dialogVm };
|
||||
var owner = TopLevel.GetTopLevel(this) as Window;
|
||||
if (owner is not null) await dialog.ShowDialog(owner);
|
||||
}
|
||||
}
|
||||
@@ -77,6 +77,13 @@
|
||||
<CheckBox Content="Benötigt Unterrichtsplanung" IsChecked="{Binding RequiresLessonPlanning}"
|
||||
ToolTip.Tip="Deaktivieren für Gruppen ohne inhaltlichen Verlaufsplan (z.B. Klassenrat, Willkommenskreis) - blendet für diese Gruppe die Dashboard-Erinnerung "Ungeplante Stunden" aus."/>
|
||||
|
||||
<StackPanel Spacing="4">
|
||||
<TextBlock Text="WebUntis-Unterrichtsnummer (optional)" FontSize="12" Opacity="0.7"/>
|
||||
<NumericUpDown Value="{Binding WebUntisLessonId}" Minimum="1" FormatString="0"
|
||||
HorizontalAlignment="Stretch"
|
||||
ToolTip.Tip="lsid aus WebUntis (Unterricht -> Mein Unterricht -> Berichte-Symbol der Zeile). Wird von WebUntis pro Schuljahr neu vergeben und muss deshalb jedes Schuljahr aktualisiert werden."/>
|
||||
</StackPanel>
|
||||
|
||||
</StackPanel>
|
||||
|
||||
<Grid Grid.Row="1" ColumnDefinitions="*,8,*" Margin="0,20,0,0">
|
||||
|
||||
@@ -15,6 +15,9 @@
|
||||
IsVisible="{Binding NiveauLabel, Converter={x:Static StringConverters.IsNotNullOrEmpty}}">
|
||||
<TextBlock Text="{Binding NiveauLabel}" FontSize="12" Foreground="White"/>
|
||||
</Border>
|
||||
<Button Content="Rest als abwesend markieren" Command="{Binding MarkRemainingAbsentCommand}"
|
||||
Margin="20,0,0,0" VerticalAlignment="Center"
|
||||
ToolTip.Tip="Setzt bei allen noch leeren Zeilen 'Abwesend' — für Schüler, die nicht nachschreiben."/>
|
||||
</StackPanel>
|
||||
|
||||
<DataGrid Grid.Row="1" Name="ResultGrid"
|
||||
|
||||
@@ -27,7 +27,7 @@
|
||||
IsEnabled="{Binding IsEditable}"/>
|
||||
<Button Content="↻ Aus WebUntis…" Click="OnImportParticipantsFromWebUntisClick"
|
||||
IsEnabled="{Binding IsEditable}"/>
|
||||
<Button Content="Fehlzeiten abgleichen…" Click="OnCompareWebUntisAbsencesClick"/>
|
||||
<Button Content="Fehlzeiten je Unterricht…" Click="OnCompareWebUntisLessonAbsencesClick"/>
|
||||
<Button Content="{Binding SelectedStudent.WithdrawActionLabel}"
|
||||
Command="{Binding WithdrawStudentCommand}"
|
||||
IsVisible="{Binding SelectedStudent, Converter={x:Static ObjectConverters.IsNotNull}}"/>
|
||||
|
||||
@@ -13,6 +13,7 @@ using LehrerApp.Desktop.Views.Shared;
|
||||
using LehrerApp.Desktop.Views.Students;
|
||||
using LehrerApp.Desktop.Views.Workload;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using System.Globalization;
|
||||
using System.Text;
|
||||
|
||||
namespace LehrerApp.Desktop.Views.Groups;
|
||||
@@ -152,16 +153,16 @@ public partial class GroupDetailView : UserControl
|
||||
}
|
||||
}
|
||||
|
||||
private async void OnCompareWebUntisAbsencesClick(object? sender, RoutedEventArgs e)
|
||||
private async void OnCompareWebUntisLessonAbsencesClick(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
var owner = TopLevel.GetTopLevel(this) as Window;
|
||||
if (owner is null || DataContext is not GroupDetailViewModel vm || vm.Group is null) return;
|
||||
var dialogVm = new WebUntisAbsenceComparisonViewModel(vm.Group,
|
||||
var dialogVm = new WebUntisLessonAbsenceComparisonViewModel(vm.Group,
|
||||
App.Services.GetRequiredService<WebUntisIntegrationService>(),
|
||||
App.Services.GetRequiredService<IStudentRepository>(),
|
||||
App.Services.GetRequiredService<IParticipationSessionRepository>(),
|
||||
App.Services.GetRequiredService<IParticipationRepository>());
|
||||
await new WebUntisAbsenceComparisonDialog { DataContext = dialogVm }.ShowDialog(owner);
|
||||
await new WebUntisLessonAbsenceComparisonDialog { DataContext = dialogVm }.ShowDialog(owner);
|
||||
vm.ParticipationTab.RefreshCurrentGrid();
|
||||
}
|
||||
|
||||
@@ -174,7 +175,7 @@ public partial class GroupDetailView : UserControl
|
||||
var values = new[]
|
||||
{
|
||||
student.LongName ?? student.Name, student.ForeName, student.Gender,
|
||||
student.BirthDate?.ToString() ?? student.BirthDateRaw, student.ClassName,
|
||||
FormatBirthDate(student), student.ClassName,
|
||||
student.ExternKey.ToString(), student.Address.Email, student.Address.Mobile,
|
||||
student.Address.Phone, student.Address.City, student.Address.PostCode, student.Address.Street,
|
||||
};
|
||||
@@ -183,6 +184,17 @@ public partial class GroupDetailView : UserControl
|
||||
return new ImportFile("webuntis-students.csv", Encoding.UTF8.GetBytes(builder.ToString()));
|
||||
}
|
||||
|
||||
private static string? FormatBirthDate(UntisStudentDto student)
|
||||
{
|
||||
if (student.BirthDate is not { } normalizedDate) return student.BirthDateRaw;
|
||||
|
||||
var value = normalizedDate.ToString("D8", CultureInfo.InvariantCulture);
|
||||
return DateOnly.TryParseExact(value, "yyyyMMdd", CultureInfo.InvariantCulture,
|
||||
DateTimeStyles.None, out var date)
|
||||
? date.ToString("dd.MM.yyyy", CultureInfo.InvariantCulture)
|
||||
: student.BirthDateRaw;
|
||||
}
|
||||
|
||||
private static string SafeTsv(string? value) => (value ?? "").Replace('\t', ' ')
|
||||
.Replace('\r', ' ').Replace('\n', ' ');
|
||||
|
||||
|
||||
@@ -5,13 +5,6 @@
|
||||
x:Class="LehrerApp.Desktop.Views.Groups.GroupListView"
|
||||
x:DataType="vm:GroupListViewModel">
|
||||
|
||||
<UserControl.Styles>
|
||||
<Style Selector="TextBlock.overdue">
|
||||
<Setter Property="Foreground" Value="Red"/>
|
||||
<Setter Property="FontWeight" Value="SemiBold"/>
|
||||
</Style>
|
||||
</UserControl.Styles>
|
||||
|
||||
<Grid RowDefinitions="Auto,*">
|
||||
|
||||
<!-- Kopfzeile mit Schuljahr-Wähler und Neu-Button -->
|
||||
@@ -28,180 +21,85 @@
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<!-- Master-Detail: Liste links, Übersicht rechts -->
|
||||
<Grid Grid.Row="1" ColumnDefinitions="260,*">
|
||||
|
||||
<!-- Linke Spalte: Sucheingabe + Listenansicht -->
|
||||
<Border Grid.Column="0"
|
||||
BorderBrush="{DynamicResource SystemControlForegroundBaseLowBrush}"
|
||||
BorderThickness="0,0,1,0">
|
||||
<DockPanel>
|
||||
<StackPanel DockPanel.Dock="Top" Margin="12,8" Spacing="8">
|
||||
<TextBox Text="{Binding SearchText}" PlaceholderText="Suchen…"/>
|
||||
<ToggleSwitch Content="Archiv anzeigen" IsChecked="{Binding ShowArchived}"/>
|
||||
</StackPanel>
|
||||
<TextBlock Text="{Binding EmptyListMessage}" TextWrapping="Wrap"
|
||||
Margin="16,12" FontSize="12" Opacity="0.45"
|
||||
IsVisible="{Binding HasNoGroups}"/>
|
||||
<ListBox ItemsSource="{Binding Groups}"
|
||||
SelectedItem="{Binding SelectedGroup}">
|
||||
<ListBox.ItemTemplate>
|
||||
<DataTemplate DataType="vm:GroupListItem">
|
||||
<Grid ColumnDefinitions="4,*" Margin="2,4">
|
||||
<Border Grid.Column="0" Width="4" CornerRadius="2"
|
||||
Background="{DynamicResource SystemAccentColor}"
|
||||
Margin="0,0,10,0"/>
|
||||
<StackPanel Grid.Column="1">
|
||||
<Grid ColumnDefinitions="*,Auto">
|
||||
<TextBlock Grid.Column="0" Text="{Binding Name}"
|
||||
FontWeight="SemiBold" FontSize="13"/>
|
||||
<TextBlock Grid.Column="1" Text="{Binding TypeLabel}"
|
||||
FontSize="11" Opacity="0.5"/>
|
||||
<!-- Eine Navigationsebene: Karten öffnen direkt das Gruppendetail. Die Verwaltung sitzt
|
||||
am jeweiligen Eintrag und benötigt keine vorgeschaltete Bereichsauswahl mehr. -->
|
||||
<Grid Grid.Row="1" RowDefinitions="Auto,*">
|
||||
<Grid Grid.Row="0" ColumnDefinitions="*,Auto" Margin="24,18,24,8">
|
||||
<TextBox Grid.Column="0" Text="{Binding SearchText}"
|
||||
PlaceholderText="Name oder Fach suchen …" MaxWidth="560"
|
||||
HorizontalAlignment="Stretch"/>
|
||||
<ToggleSwitch Grid.Column="1" Content="Archiv anzeigen" IsChecked="{Binding ShowArchived}"
|
||||
Margin="20,0,0,0" VerticalAlignment="Center"/>
|
||||
</Grid>
|
||||
<TextBlock Text="{Binding Subject}" FontSize="12" Opacity="0.65"
|
||||
|
||||
<StackPanel Grid.Row="1" HorizontalAlignment="Center" VerticalAlignment="Center" Spacing="10"
|
||||
IsVisible="{Binding HasNoGroups}">
|
||||
<TextBlock Text="{Binding EmptyListMessage}" FontSize="15" Opacity="0.55"
|
||||
TextWrapping="Wrap" TextAlignment="Center"/>
|
||||
<Button Content="+ Erste Lerngruppe anlegen" Command="{Binding AddGroupCommand}"
|
||||
IsVisible="{Binding !ShowArchived}" HorizontalAlignment="Center"/>
|
||||
</StackPanel>
|
||||
|
||||
<ScrollViewer Grid.Row="1" IsVisible="{Binding !HasNoGroups}"
|
||||
HorizontalScrollBarVisibility="Disabled">
|
||||
<ItemsControl ItemsSource="{Binding Groups}" Margin="20,12,20,24">
|
||||
<ItemsControl.ItemsPanel>
|
||||
<ItemsPanelTemplate>
|
||||
<WrapPanel Orientation="Horizontal"/>
|
||||
</ItemsPanelTemplate>
|
||||
</ItemsControl.ItemsPanel>
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:GroupListItem">
|
||||
<Border Width="330" MinHeight="116" Margin="6" CornerRadius="9"
|
||||
Background="{DynamicResource AppCardBackgroundBrush}"
|
||||
BorderBrush="{DynamicResource AppCardBorderBrush}" BorderThickness="1">
|
||||
<Grid ColumnDefinitions="4,*,Auto">
|
||||
<Border Grid.Column="0" Background="{DynamicResource SystemAccentColor}"
|
||||
CornerRadius="9,0,0,9"/>
|
||||
<Button Grid.Column="1" Background="Transparent" BorderThickness="0"
|
||||
Padding="16,14" HorizontalAlignment="Stretch"
|
||||
HorizontalContentAlignment="Stretch" VerticalContentAlignment="Stretch"
|
||||
Command="{Binding OpenCommand}"
|
||||
AutomationProperties.Name="{Binding OpenAutomationName}">
|
||||
<StackPanel Spacing="6">
|
||||
<TextBlock Text="{Binding Name}" FontSize="17" FontWeight="SemiBold"
|
||||
TextTrimming="CharacterEllipsis"/>
|
||||
<TextBlock Text="{Binding Subject}" FontSize="13" Opacity="0.7"
|
||||
TextTrimming="CharacterEllipsis"
|
||||
IsVisible="{Binding Subject, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
|
||||
<TextBlock Text="{Binding GradingLabel}" FontSize="11" Opacity="0.4"/>
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</DataTemplate>
|
||||
</ListBox.ItemTemplate>
|
||||
</ListBox>
|
||||
</DockPanel>
|
||||
<StackPanel Orientation="Horizontal" Spacing="7">
|
||||
<Border Background="{DynamicResource AppChipBackgroundBrush}" CornerRadius="8" Padding="7,2">
|
||||
<TextBlock Text="{Binding TypeLabel}" FontSize="10" Opacity="0.75"/>
|
||||
</Border>
|
||||
|
||||
<!-- Rechte Spalte: Platzhalter wenn keine Auswahl -->
|
||||
<StackPanel Grid.Column="1" HorizontalAlignment="Center"
|
||||
VerticalAlignment="Center" Spacing="8"
|
||||
IsVisible="{Binding SelectedGroup, Converter={x:Static ObjectConverters.IsNull}}">
|
||||
<TextBlock Text="Gruppe auswählen" FontSize="16" Opacity="0.4"
|
||||
HorizontalAlignment="Center"/>
|
||||
<TextBlock Text="oder + Neue Gruppe anlegen" FontSize="12" Opacity="0.3"
|
||||
HorizontalAlignment="Center"/>
|
||||
<TextBlock Text="{Binding GradingLabel}" FontSize="11" Opacity="0.5"
|
||||
VerticalAlignment="Center"/>
|
||||
</StackPanel>
|
||||
|
||||
<!-- Rechte Spalte: Gruppen-Übersicht wenn ausgewählt -->
|
||||
<ScrollViewer Grid.Column="1"
|
||||
IsVisible="{Binding SelectedGroup, Converter={x:Static ObjectConverters.IsNotNull}}">
|
||||
<StackPanel Margin="28,24" Spacing="20">
|
||||
|
||||
<!-- Gruppenname und Kurzinfos -->
|
||||
<Grid ColumnDefinitions="*,Auto">
|
||||
<StackPanel Grid.Column="0" Spacing="4">
|
||||
<TextBlock Text="{Binding SelectedGroupDisplayName}"
|
||||
FontSize="24" FontWeight="SemiBold" TextWrapping="Wrap"/>
|
||||
<TextBlock Text="{Binding SelectedGroupSubtitle}"
|
||||
FontSize="12" Opacity="0.55"/>
|
||||
</StackPanel>
|
||||
<Button Grid.Column="1" Content="⋯ Verwalten" VerticalAlignment="Top"
|
||||
Margin="16,0,0,0">
|
||||
</Button>
|
||||
<Button Grid.Column="2" Content="⋯" Width="36" Height="32" Margin="0,10,10,0"
|
||||
Padding="0" VerticalAlignment="Top"
|
||||
ToolTip.Tip="Lerngruppe verwalten"
|
||||
AutomationProperties.Name="{Binding ManageAutomationName}">
|
||||
<Button.Flyout>
|
||||
<MenuFlyout>
|
||||
<MenuItem Header="Details bearbeiten"
|
||||
Command="{Binding EditGroupCommand}"/>
|
||||
<MenuItem Header="Details bearbeiten" IsEnabled="{Binding IsActive}"
|
||||
Command="{Binding EditCommand}"/>
|
||||
<MenuItem Header="Ins nächste Schuljahr übernehmen …"
|
||||
Command="{Binding RollOverGroupCommand}"/>
|
||||
<MenuItem Header="{Binding SelectedGroup.ArchiveActionLabel}"
|
||||
Command="{Binding RollOverCommand}"/>
|
||||
<MenuItem Header="{Binding ArchiveActionLabel}"
|
||||
Command="{Binding ToggleArchiveCommand}"/>
|
||||
<Separator/>
|
||||
<MenuItem Header="Teilnehmer importieren (bald)" IsEnabled="False"
|
||||
ToolTip.Tip="Import aus dem Teilnehmerexport der Lernplattform folgt."/>
|
||||
<Separator/>
|
||||
<MenuItem Header="Lerngruppe löschen"
|
||||
Command="{Binding DeleteGroupCommand}"/>
|
||||
<MenuItem Header="Lerngruppe löschen" IsEnabled="{Binding IsActive}"
|
||||
Command="{Binding DeleteCommand}"/>
|
||||
</MenuFlyout>
|
||||
</Button.Flyout>
|
||||
</Button>
|
||||
</Grid>
|
||||
|
||||
<Separator/>
|
||||
|
||||
<!-- Schnellüberblick (Nutzer-Feedback) -->
|
||||
<StackPanel Spacing="8" IsVisible="{Binding QuickHasAnything}">
|
||||
<TextBlock Text="SCHNELLÜBERBLICK" FontSize="10" FontWeight="Bold" Opacity="0.4"/>
|
||||
<StackPanel Spacing="1" IsVisible="{Binding QuickHasNextLesson}">
|
||||
<TextBlock Text="Nächste Stunde" FontSize="11" Opacity="0.55"/>
|
||||
<TextBlock Text="{Binding QuickNextLessonLabel}" FontSize="13" TextWrapping="Wrap"/>
|
||||
</StackPanel>
|
||||
<StackPanel Spacing="1" IsVisible="{Binding QuickHasNextExam}">
|
||||
<TextBlock Text="Nächste Klausur" FontSize="11" Opacity="0.55"/>
|
||||
<TextBlock Text="{Binding QuickNextExamLabel}" FontSize="13" TextWrapping="Wrap"/>
|
||||
</StackPanel>
|
||||
<StackPanel Spacing="4" IsVisible="{Binding QuickHasTasks}">
|
||||
<TextBlock Text="Wichtige Aufgaben" FontSize="11" Opacity="0.55"/>
|
||||
<ItemsControl ItemsSource="{Binding QuickTasks}">
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:GroupTaskItem">
|
||||
<Grid ColumnDefinitions="4,Auto,*,Auto" Margin="0,2">
|
||||
<Border Grid.Column="0" Background="{Binding PriorityColorHex}" CornerRadius="2"
|
||||
Margin="0,0,6,0" IsVisible="{Binding IsHighPriority}"/>
|
||||
<TextBlock Grid.Column="1" Text="🔔" FontSize="11" Margin="0,0,4,0"
|
||||
IsVisible="{Binding IsReminder}" VerticalAlignment="Center"/>
|
||||
<TextBlock Grid.Column="2" Text="{Binding Title}" FontSize="12"
|
||||
TextTrimming="CharacterEllipsis" VerticalAlignment="Center"/>
|
||||
<TextBlock Grid.Column="3" Text="{Binding DueDateDisplay}" FontSize="11"
|
||||
VerticalAlignment="Center" Classes.overdue="{Binding IsOverdue}"/>
|
||||
</Grid>
|
||||
</Border>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
<Separator IsVisible="{Binding QuickHasAnything}"/>
|
||||
|
||||
<!-- Bereichs-Navigation -->
|
||||
<TextBlock Text="BEREICHE" FontSize="10" FontWeight="Bold"
|
||||
Opacity="0.4" Margin="0,0,0,2"/>
|
||||
<StackPanel Spacing="6">
|
||||
<Button Content="📊 Übersicht"
|
||||
HorizontalAlignment="Stretch" HorizontalContentAlignment="Left"
|
||||
Padding="12,9"
|
||||
Command="{Binding NavigateToSectionCommand}"
|
||||
CommandParameter="0"/>
|
||||
<Button Content="👤 Schülerliste"
|
||||
HorizontalAlignment="Stretch" HorizontalContentAlignment="Left"
|
||||
Padding="12,9"
|
||||
Command="{Binding NavigateToSectionCommand}"
|
||||
CommandParameter="1"/>
|
||||
<Button Content="🪑 Sitzpläne"
|
||||
HorizontalAlignment="Stretch" HorizontalContentAlignment="Left"
|
||||
Padding="12,9"
|
||||
Command="{Binding NavigateToSectionCommand}"
|
||||
CommandParameter="2"/>
|
||||
<Button Content="✋ Mitarbeit"
|
||||
HorizontalAlignment="Stretch" HorizontalContentAlignment="Left"
|
||||
Padding="12,9"
|
||||
Command="{Binding NavigateToSectionCommand}"
|
||||
CommandParameter="3"/>
|
||||
<Button Content="📝 Klausuren"
|
||||
HorizontalAlignment="Stretch" HorizontalContentAlignment="Left"
|
||||
Padding="12,9"
|
||||
Command="{Binding NavigateToSectionCommand}"
|
||||
CommandParameter="4"/>
|
||||
<Button Content="🔢 Notenübersicht"
|
||||
HorizontalAlignment="Stretch" HorizontalContentAlignment="Left"
|
||||
Padding="12,9"
|
||||
Command="{Binding NavigateToSectionCommand}"
|
||||
CommandParameter="5"/>
|
||||
<Button Content="📅 Unterrichtsplanung"
|
||||
HorizontalAlignment="Stretch" HorizontalContentAlignment="Left"
|
||||
Padding="12,9"
|
||||
Command="{Binding NavigateToSectionCommand}"
|
||||
CommandParameter="6"/>
|
||||
<Button Content="🎯 Kompetenzübersicht"
|
||||
HorizontalAlignment="Stretch" HorizontalContentAlignment="Left"
|
||||
Padding="12,9"
|
||||
Command="{Binding NavigateToSectionCommand}"
|
||||
CommandParameter="7"/>
|
||||
<Button Content="📋 Dokumentation"
|
||||
HorizontalAlignment="Stretch" HorizontalContentAlignment="Left"
|
||||
Padding="12,9"
|
||||
Command="{Binding NavigateToSectionCommand}"
|
||||
CommandParameter="8"/>
|
||||
</StackPanel>
|
||||
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
|
||||
</Grid>
|
||||
</Grid>
|
||||
</UserControl>
|
||||
|
||||
@@ -27,6 +27,13 @@
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
<StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="8" VerticalAlignment="Center">
|
||||
<!-- Nutzer-Feedback: die Schnellbewertungs-Dialoge gab es bisher nur über den
|
||||
Mitarbeit-Tab der Gruppe — hier direkt auf die Sitzung dieser Stunde vorselektiert
|
||||
(siehe TeachingModeViewModel), kein Umweg mehr über "Zur Mitarbeit". -->
|
||||
<Button Content="⚡ Mitarbeit" Command="{Binding Participation.QuickInputCommand}"
|
||||
ToolTip.Tip="Mitarbeit dieser Stunde schnell bewerten."/>
|
||||
<Button Content="⚡ Anwesenheit/Hausaufgabe" Command="{Binding Participation.StatusQuickInputCommand}"
|
||||
ToolTip.Tip="Anwesenheit und Hausaufgabenstatus dieser Stunde schnell erfassen."/>
|
||||
<Button Content="Zur Mitarbeit" Command="{Binding LessonInfo.NavigateToParticipationCommand}"
|
||||
ToolTip.Tip="Schließt den Unterrichtsmodus und springt zum Tab 'Mitarbeit' der Lerngruppe."/>
|
||||
<Button Content="Zu den Noten" Command="{Binding LessonInfo.NavigateToGradesCommand}"/>
|
||||
@@ -80,9 +87,32 @@
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
|
||||
<StackPanel Spacing="4" IsVisible="{Binding LessonInfo.Homework, Converter={x:Static StringConverters.IsNotNullOrEmpty}}">
|
||||
<TextBlock Text="Hausaufgabe" FontSize="12" FontWeight="SemiBold" Opacity="0.6"/>
|
||||
<TextBlock Text="{Binding LessonInfo.Homework}" FontSize="13" TextWrapping="Wrap"/>
|
||||
<!-- Nutzer-Feedback: Hausaufgabe der letzten Stunde ansehen/als kontrolliert abhaken
|
||||
und die Hausaufgabe DIESER Stunde einsehen/ändern, ohne den vollen
|
||||
Verlaufsplan-Editor öffnen zu müssen — manchmal ergibt sich die Hausaufgabe erst
|
||||
während des Unterrichts. Ersetzt die bisherige rein lesbare Anzeige
|
||||
(LessonInfo.Homework), die dasselbe Feld doppelt und nur lesend gezeigt hätte. -->
|
||||
<Border Background="{DynamicResource SystemControlBackgroundAltHighBrush}"
|
||||
CornerRadius="6" Padding="10,8" IsVisible="{Binding Homework.HasPreviousHomework}">
|
||||
<StackPanel Spacing="4">
|
||||
<TextBlock FontSize="12" FontWeight="SemiBold" Opacity="0.6">
|
||||
<Run Text="Hausaufgabe letzte Stunde"/><Run Text=" · "/>
|
||||
<Run Text="{Binding Homework.PreviousLessonLabel}"/>
|
||||
</TextBlock>
|
||||
<TextBlock Text="{Binding Homework.PreviousHomeworkText}" FontSize="13" TextWrapping="Wrap"/>
|
||||
<CheckBox Content="Kontrolliert" IsChecked="{Binding Homework.PreviousHomeworkChecked}"/>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<StackPanel Spacing="4">
|
||||
<TextBlock Text="Hausaufgabe (diese Stunde)" FontSize="12" FontWeight="SemiBold" Opacity="0.6"/>
|
||||
<TextBox Text="{Binding Homework.CurrentHomework, Mode=TwoWay}" AcceptsReturn="True"
|
||||
TextWrapping="Wrap" MinHeight="52" PlaceholderText="Noch keine Hausaufgabe eingetragen…"/>
|
||||
<Grid ColumnDefinitions="Auto,*">
|
||||
<Button Grid.Column="0" Content="Speichern" Command="{Binding Homework.SaveCurrentHomeworkCommand}"/>
|
||||
<TextBlock Grid.Column="1" Text="{Binding Homework.SaveStatus}" FontSize="11" Opacity="0.55"
|
||||
VerticalAlignment="Center" Margin="8,0,0,0"/>
|
||||
</Grid>
|
||||
</StackPanel>
|
||||
|
||||
<StackPanel Spacing="4" IsVisible="{Binding LessonInfo.Reflection, Converter={x:Static StringConverters.IsNotNullOrEmpty}}">
|
||||
|
||||
@@ -16,12 +16,38 @@ public partial class TeachingModeWindow : Window
|
||||
// Gleiches Muster wie LessonViewerDialog (4.5.3): Fenster schließt zuerst, dann Sprung
|
||||
// über die MainWindowViewModel-Singleton-Navigation in den Ziel-Tab der Gruppe.
|
||||
if (DataContext is TeachingModeViewModel vm)
|
||||
{
|
||||
vm.LessonInfo.OnNavigateToTab = tabIndex =>
|
||||
{
|
||||
Close();
|
||||
App.Services.GetRequiredService<MainWindowViewModel>()
|
||||
.NavigateToGroupDetail(vm.LessonInfo.GroupId, tabIndex);
|
||||
};
|
||||
// Nutzer-Feedback: Schnellbewertung (Mitarbeit) und Schnellanwesenheit/Hausaufgabe
|
||||
// direkt aus dem Unterrichtsmodus statt erst zum Mitarbeit-Tab wechseln zu müssen —
|
||||
// dieselben Dialoge wie in ParticipationTabView.axaml.cs, hier auf die zu dieser
|
||||
// Stunde gehörende Sitzung vorselektiert (siehe TeachingModeViewModel-Konstruktor).
|
||||
vm.Participation.OnQuickInput = ShowQuickInputDialog;
|
||||
vm.Participation.OnStatusQuickInput = ShowStatusQuickInputDialog;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ShowQuickInputDialog(ParticipationTabViewModel tabVm)
|
||||
{
|
||||
if (tabVm.StudentRows.Count == 0) return;
|
||||
var quickVm = new QuickInputViewModel(tabVm.StudentRows.ToList(), tabVm.Aspects.ToList());
|
||||
var dialog = new ParticipationQuickInputDialog { DataContext = quickVm };
|
||||
await dialog.ShowDialog(this);
|
||||
(DataContext as TeachingModeViewModel)?.SeatingPlan.ReloadSeatBadgesFromRepository();
|
||||
}
|
||||
|
||||
private async Task ShowStatusQuickInputDialog(ParticipationTabViewModel tabVm)
|
||||
{
|
||||
if (tabVm.StudentRows.Count == 0 || tabVm.SelectedSession is null) return;
|
||||
var quickVm = new AttendanceHomeworkQuickInputViewModel(tabVm.StudentRows, tabVm.SelectedSessionDisplay);
|
||||
var dialog = new AttendanceHomeworkQuickInputDialog { DataContext = quickVm };
|
||||
await dialog.ShowDialog(this);
|
||||
(DataContext as TeachingModeViewModel)?.SeatingPlan.ReloadSeatBadgesFromRepository();
|
||||
}
|
||||
|
||||
private void OnClose(object? sender, RoutedEventArgs e) => Close();
|
||||
|
||||
@@ -1,42 +0,0 @@
|
||||
<Window xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:vm="clr-namespace:LehrerApp.Desktop.ViewModels.Groups"
|
||||
x:Class="LehrerApp.Desktop.Views.Groups.WebUntisAbsenceComparisonDialog"
|
||||
x:DataType="vm:WebUntisAbsenceComparisonViewModel"
|
||||
Title="Fehlzeiten mit WebUntis abgleichen" Width="850" Height="620"
|
||||
MinWidth="700" MinHeight="450" WindowStartupLocation="CenterOwner">
|
||||
<Grid RowDefinitions="Auto,Auto,*,Auto" Margin="24" RowSpacing="12">
|
||||
<StackPanel Grid.Row="0" Spacing="4">
|
||||
<TextBlock Text="Fehlzeiten mit WebUntis abgleichen" Classes="dialogtitle"/>
|
||||
<TextBlock Text="Nur markierte Zeilen mit einer vorhandenen lokalen Kursstunde werden übernommen."
|
||||
FontSize="12" Opacity="0.65"/>
|
||||
</StackPanel>
|
||||
<StackPanel Grid.Row="1" Orientation="Horizontal" Spacing="8">
|
||||
<DatePicker SelectedDate="{Binding StartDate}"/>
|
||||
<TextBlock Text="bis" VerticalAlignment="Center"/>
|
||||
<DatePicker SelectedDate="{Binding EndDate}"/>
|
||||
<Button Content="Fehlzeiten laden" Command="{Binding LoadCommand}" IsEnabled="{Binding !Busy}"/>
|
||||
</StackPanel>
|
||||
<ScrollViewer Grid.Row="2">
|
||||
<ItemsControl ItemsSource="{Binding Rows}">
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:WebUntisAbsenceRow">
|
||||
<Grid ColumnDefinitions="Auto,1.5*,90,90,1.5*,1.5*" ColumnSpacing="8" Margin="0,3">
|
||||
<CheckBox Grid.Column="0" IsChecked="{Binding Selected}" IsEnabled="{Binding CanApply}"/>
|
||||
<TextBlock Grid.Column="1" Text="{Binding StudentName}" VerticalAlignment="Center"/>
|
||||
<TextBlock Grid.Column="2" Text="{Binding DateLabel}" VerticalAlignment="Center"/>
|
||||
<TextBlock Grid.Column="3" Text="{Binding TimeLabel}" VerticalAlignment="Center"/>
|
||||
<TextBlock Grid.Column="4" Text="{Binding UntisStatus}" TextWrapping="Wrap" VerticalAlignment="Center"/>
|
||||
<TextBlock Grid.Column="5" Text="{Binding LocalStatus}" TextWrapping="Wrap" VerticalAlignment="Center"/>
|
||||
</Grid>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
</ScrollViewer>
|
||||
<Grid Grid.Row="3" ColumnDefinitions="*,Auto,Auto" ColumnSpacing="8">
|
||||
<TextBlock Grid.Column="0" Text="{Binding Status}" FontSize="12" TextWrapping="Wrap" VerticalAlignment="Center"/>
|
||||
<Button Grid.Column="1" Content="Schließen" Click="OnClose"/>
|
||||
<Button Grid.Column="2" Content="Markierte übernehmen" Command="{Binding ApplyCommand}" IsEnabled="{Binding !Busy}"/>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Window>
|
||||
@@ -0,0 +1,56 @@
|
||||
<Window xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:vm="clr-namespace:LehrerApp.Desktop.ViewModels.Groups"
|
||||
x:Class="LehrerApp.Desktop.Views.Groups.WebUntisLessonAbsenceComparisonDialog"
|
||||
x:DataType="vm:WebUntisLessonAbsenceComparisonViewModel"
|
||||
Title="Fehlzeiten je Unterricht mit WebUntis abgleichen" Width="1000" Height="640"
|
||||
MinWidth="800" MinHeight="450" WindowStartupLocation="CenterOwner">
|
||||
<Grid RowDefinitions="Auto,Auto,Auto,*,Auto,Auto" Margin="24" RowSpacing="8">
|
||||
<StackPanel Grid.Row="0" Spacing="4">
|
||||
<TextBlock Text="Fehlzeiten je Unterricht mit WebUntis abgleichen" Classes="dialogtitle"/>
|
||||
<TextBlock Text="Nur markierte Zeilen mit einer vorhandenen lokalen Kursstunde werden übernommen. Zeilen ohne automatische Zuordnung bitte manuell einem Kursmitglied zuweisen."
|
||||
FontSize="12" Opacity="0.65" TextWrapping="Wrap"/>
|
||||
</StackPanel>
|
||||
<StackPanel Grid.Row="1" Orientation="Horizontal" Spacing="8">
|
||||
<DatePicker SelectedDate="{Binding StartDate}"/>
|
||||
<TextBlock Text="bis" VerticalAlignment="Center"/>
|
||||
<DatePicker SelectedDate="{Binding EndDate}"/>
|
||||
<Button Content="Fehlzeiten laden" Command="{Binding LoadCommand}" IsEnabled="{Binding !Busy}"/>
|
||||
</StackPanel>
|
||||
<Grid Grid.Row="2" ColumnDefinitions="Auto,1.2*,1.2*,80,80,1.1*,1.1*" ColumnSpacing="8" Margin="4,0">
|
||||
<TextBlock Grid.Column="1" Text="Name (WebUntis)" FontSize="11" Opacity="0.6"/>
|
||||
<TextBlock Grid.Column="2" Text="Zuordnung" FontSize="11" Opacity="0.6"/>
|
||||
<TextBlock Grid.Column="3" Text="Datum" FontSize="11" Opacity="0.6"/>
|
||||
<TextBlock Grid.Column="4" Text="Zeit" FontSize="11" Opacity="0.6"/>
|
||||
<TextBlock Grid.Column="5" Text="WebUntis-Status" FontSize="11" Opacity="0.6"/>
|
||||
<TextBlock Grid.Column="6" Text="Lokaler Status" FontSize="11" Opacity="0.6"/>
|
||||
</Grid>
|
||||
<ScrollViewer Grid.Row="3">
|
||||
<ItemsControl ItemsSource="{Binding Rows}">
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:WebUntisLessonAbsenceRow">
|
||||
<Grid ColumnDefinitions="Auto,1.2*,1.2*,80,80,1.1*,1.1*" ColumnSpacing="8" Margin="0,3">
|
||||
<CheckBox Grid.Column="0" IsChecked="{Binding Selected}" IsEnabled="{Binding CanApply}"/>
|
||||
<TextBlock Grid.Column="1" Text="{Binding UntisStudentName}" VerticalAlignment="Center" TextWrapping="Wrap"/>
|
||||
<ComboBox Grid.Column="2" ItemsSource="{Binding Candidates}" SelectedItem="{Binding AssignedStudent}"
|
||||
DisplayMemberBinding="{Binding FullName}" PlaceholderText="Schüler wählen…"
|
||||
HorizontalAlignment="Stretch"/>
|
||||
<TextBlock Grid.Column="3" Text="{Binding DateLabel}" VerticalAlignment="Center"/>
|
||||
<TextBlock Grid.Column="4" Text="{Binding TimeLabel}" VerticalAlignment="Center"/>
|
||||
<TextBlock Grid.Column="5" Text="{Binding UntisStatus}" TextWrapping="Wrap" VerticalAlignment="Center"/>
|
||||
<TextBlock Grid.Column="6" Text="{Binding LocalStatus}" TextWrapping="Wrap" VerticalAlignment="Center"/>
|
||||
</Grid>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
</ScrollViewer>
|
||||
<CheckBox Grid.Row="4" Content="Unbekannte im geladenen Zeitraum auf „Anwesend“ setzen"
|
||||
IsChecked="{Binding MarkUnknownAsPresent}"
|
||||
ToolTip.Tip="Gilt nur für Schüler*innen, die WebUntis für den jeweiligen Tag nicht gemeldet hat und die lokal noch keinen Anwesenheitsstatus haben."/>
|
||||
<Grid Grid.Row="5" ColumnDefinitions="*,Auto,Auto" ColumnSpacing="8">
|
||||
<TextBlock Grid.Column="0" Text="{Binding Status}" FontSize="12" TextWrapping="Wrap" VerticalAlignment="Center"/>
|
||||
<Button Grid.Column="1" Content="Schließen" Click="OnClose"/>
|
||||
<Button Grid.Column="2" Content="Markierte übernehmen" Command="{Binding ApplyCommand}" IsEnabled="{Binding !Busy}"/>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Window>
|
||||
+2
-2
@@ -3,8 +3,8 @@ using Avalonia.Interactivity;
|
||||
|
||||
namespace LehrerApp.Desktop.Views.Groups;
|
||||
|
||||
public partial class WebUntisAbsenceComparisonDialog : Window
|
||||
public partial class WebUntisLessonAbsenceComparisonDialog : Window
|
||||
{
|
||||
public WebUntisAbsenceComparisonDialog() => InitializeComponent();
|
||||
public WebUntisLessonAbsenceComparisonDialog() => InitializeComponent();
|
||||
private void OnClose(object? sender, RoutedEventArgs e) => Close();
|
||||
}
|
||||
@@ -13,6 +13,10 @@
|
||||
xmlns:vmp="clr-namespace:LehrerApp.Desktop.ViewModels.Planning"
|
||||
xmlns:vw="clr-namespace:LehrerApp.Desktop.Views.Workload"
|
||||
xmlns:vmw="clr-namespace:LehrerApp.Desktop.ViewModels.Workload"
|
||||
xmlns:vct="clr-namespace:LehrerApp.Desktop.Views.ClassTeacher"
|
||||
xmlns:vmct="clr-namespace:LehrerApp.Desktop.ViewModels.ClassTeacher"
|
||||
xmlns:vex="clr-namespace:LehrerApp.Desktop.Views.Exams"
|
||||
xmlns:vmex="clr-namespace:LehrerApp.Desktop.ViewModels.Exams"
|
||||
xmlns:svc="clr-namespace:LehrerApp.Desktop.Services"
|
||||
x:Class="LehrerApp.Desktop.Views.MainWindow"
|
||||
x:DataType="vm:MainWindowViewModel"
|
||||
@@ -58,6 +62,12 @@
|
||||
<DataTemplate DataType="vmw:WorkloadViewModel">
|
||||
<vw:WorkloadView/>
|
||||
</DataTemplate>
|
||||
<DataTemplate DataType="vmct:ClassTeacherOverviewViewModel">
|
||||
<vct:ClassTeacherOverviewView/>
|
||||
</DataTemplate>
|
||||
<DataTemplate DataType="vmex:ExamsOverviewViewModel">
|
||||
<vex:ExamsOverviewView/>
|
||||
</DataTemplate>
|
||||
<DataTemplate DataType="vm:PlaceholderViewModel">
|
||||
<views:PlaceholderView/>
|
||||
</DataTemplate>
|
||||
@@ -141,6 +151,20 @@
|
||||
|
||||
<ScrollViewer>
|
||||
<StackPanel Classes="navitems" Margin="8,12,8,0" Spacing="2">
|
||||
<Button Classes="navitem" HorizontalAlignment="Stretch"
|
||||
HorizontalContentAlignment="Left" CornerRadius="6"
|
||||
Click="OnOpenCommandPaletteClick"
|
||||
ToolTip.Tip="Suchen und schnell erfassen (Strg/⌘+K)"
|
||||
AutomationProperties.Name="Suchen und schnell erfassen">
|
||||
<Grid ColumnDefinitions="Auto,*,Auto">
|
||||
<TextBlock Classes="navicon" Text="⌕" TextAlignment="Center"/>
|
||||
<TextBlock Grid.Column="1" Classes="navlabel" Text="Suchen / Erfassen"/>
|
||||
<TextBlock Grid.Column="2" Classes="navlabel" Text="⌘K" FontSize="10" Opacity="0.45"
|
||||
VerticalAlignment="Center"/>
|
||||
</Grid>
|
||||
</Button>
|
||||
<Separator Margin="4,6"/>
|
||||
|
||||
<Button Classes="navitem" Classes.active="{Binding IsDashboardActive}" HorizontalAlignment="Stretch"
|
||||
HorizontalContentAlignment="Left"
|
||||
CornerRadius="6"
|
||||
@@ -209,6 +233,16 @@
|
||||
<TextBlock Classes="navlabel" Text="Arbeitszeit"/>
|
||||
</StackPanel>
|
||||
</Button>
|
||||
<Button Classes="navitem" Classes.active="{Binding IsClassTeacherActive}" HorizontalAlignment="Stretch"
|
||||
HorizontalContentAlignment="Left"
|
||||
CornerRadius="6"
|
||||
Command="{Binding NavigateToCommand}"
|
||||
CommandParameter="{x:Static vm:NavItem.ClassTeacher}">
|
||||
<StackPanel Classes="navcontent" Orientation="Horizontal" Spacing="10">
|
||||
<TextBlock Classes="navicon" Text="🎓" TextAlignment="Center"/>
|
||||
<TextBlock Classes="navlabel" Text="Klassenlehrer"/>
|
||||
</StackPanel>
|
||||
</Button>
|
||||
<Button Classes="navitem" Classes.active="{Binding IsSettingsActive}" HorizontalAlignment="Stretch"
|
||||
HorizontalContentAlignment="Left"
|
||||
CornerRadius="6"
|
||||
@@ -226,6 +260,77 @@
|
||||
|
||||
</DrawerPage>
|
||||
|
||||
<!-- Globale Suche und Schnellerfassung (14.2). Bewusst als Overlay auf der aktuellen Seite:
|
||||
Der Nutzer behält den Kontext und kann mit Escape ohne Navigation zurückkehren. -->
|
||||
<Border Background="#A0000000" IsVisible="{Binding IsCommandPaletteOpen}"
|
||||
AutomationProperties.Name="Globale Suche und Schnellerfassung">
|
||||
<Grid>
|
||||
<Button Background="Transparent" BorderThickness="0"
|
||||
Command="{Binding CloseCommandPaletteCommand}"
|
||||
AutomationProperties.Name="Suche schließen"/>
|
||||
<Border Width="680" MaxHeight="570" Margin="24" Padding="0"
|
||||
HorizontalAlignment="Center" VerticalAlignment="Top"
|
||||
Background="{DynamicResource AppCardBackgroundBrush}"
|
||||
BorderBrush="{DynamicResource AppCardBorderBrush}" BorderThickness="1"
|
||||
CornerRadius="12">
|
||||
<Grid RowDefinitions="Auto,Auto,*,Auto">
|
||||
<Grid Grid.Row="0" ColumnDefinitions="Auto,*,Auto" Margin="18,16,18,10">
|
||||
<TextBlock Text="⌕" FontSize="24" VerticalAlignment="Center" Margin="0,0,10,0"/>
|
||||
<TextBox x:Name="CommandPaletteSearchBox" Grid.Column="1"
|
||||
Text="{Binding CommandPalette.Query, UpdateSourceTrigger=PropertyChanged}"
|
||||
PlaceholderText="Schüler, Lerngruppe, Klausur oder Aufgabe suchen …"
|
||||
FontSize="16" BorderThickness="0" Background="Transparent"
|
||||
AutomationProperties.Name="Suchbegriff"/>
|
||||
<Button Grid.Column="2" Content="Esc" FontSize="10" Padding="8,3"
|
||||
Command="{Binding CloseCommandPaletteCommand}"
|
||||
AutomationProperties.Name="Suche schließen"/>
|
||||
</Grid>
|
||||
<Separator Grid.Row="1"/>
|
||||
<ListBox Grid.Row="2" ItemsSource="{Binding CommandPalette.Results}"
|
||||
SelectedItem="{Binding CommandPalette.SelectedResult}"
|
||||
Background="Transparent" BorderThickness="0" Margin="8"
|
||||
MaxHeight="420">
|
||||
<ListBox.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:GlobalSearchResult">
|
||||
<Button Background="Transparent" BorderThickness="0" Padding="10,8"
|
||||
HorizontalAlignment="Stretch" HorizontalContentAlignment="Stretch"
|
||||
Command="{Binding $parent[Window].((vm:MainWindowViewModel)DataContext).CommandPalette.ExecuteCommand}"
|
||||
CommandParameter="{Binding}">
|
||||
<Grid ColumnDefinitions="38,*,Auto">
|
||||
<Border Width="30" Height="30" CornerRadius="7"
|
||||
Background="{DynamicResource AppAccentSoftBackgroundBrush}"
|
||||
VerticalAlignment="Center">
|
||||
<TextBlock Text="{Binding Icon}" FontWeight="SemiBold"
|
||||
Foreground="{DynamicResource AppAccentOnSoftBrush}"
|
||||
HorizontalAlignment="Center" VerticalAlignment="Center"/>
|
||||
</Border>
|
||||
<StackPanel Grid.Column="1" Margin="10,0">
|
||||
<TextBlock Text="{Binding Title}" FontSize="14" FontWeight="SemiBold"/>
|
||||
<TextBlock Text="{Binding Subtitle}" FontSize="11" Opacity="0.6"
|
||||
TextTrimming="CharacterEllipsis"/>
|
||||
</StackPanel>
|
||||
<Border Grid.Column="2" Padding="7,3" CornerRadius="8"
|
||||
Background="{DynamicResource AppChipBackgroundBrush}"
|
||||
VerticalAlignment="Center">
|
||||
<TextBlock Text="{Binding KindLabel}" FontSize="10" Opacity="0.7"/>
|
||||
</Border>
|
||||
</Grid>
|
||||
</Button>
|
||||
</DataTemplate>
|
||||
</ListBox.ItemTemplate>
|
||||
</ListBox>
|
||||
<TextBlock Grid.Row="2" Text="Keine passenden Ergebnisse."
|
||||
Classes="emptyhint" HorizontalAlignment="Center" Margin="20"
|
||||
IsVisible="{Binding CommandPalette.ShowNoResults}"/>
|
||||
<Grid Grid.Row="3" ColumnDefinitions="*,Auto" Margin="18,8,18,14">
|
||||
<TextBlock Text="↑↓ auswählen · Enter öffnen · Esc schließen" FontSize="10" Opacity="0.5"/>
|
||||
<TextBlock Grid.Column="1" Text="Strg/⌘ + K" FontSize="10" Opacity="0.5"/>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Border>
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<!-- Dauerhafte Meldung bei einer echten Protokoll-Inkompatibilität. Als Overlay außerhalb
|
||||
von DrawerPage.Content bleibt der DataContext das MainWindowViewModel; innerhalb des
|
||||
ContentPresenters würde eine fehlgeschlagene Bindung IsVisible auf true stehen lassen. -->
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Input;
|
||||
using Avalonia.Threading;
|
||||
using LehrerApp.Desktop.Services;
|
||||
using LehrerApp.Desktop.ViewModels;
|
||||
using LehrerApp.Sync;
|
||||
@@ -19,6 +20,62 @@ public partial class MainWindow : Window
|
||||
PointerMoved += (_, _) => NotifyActivity();
|
||||
PointerPressed += (_, _) => NotifyActivity();
|
||||
KeyDown += (_, _) => NotifyActivity();
|
||||
KeyDown += OnWindowKeyDown;
|
||||
}
|
||||
|
||||
private void OnWindowKeyDown(object? sender, KeyEventArgs e)
|
||||
{
|
||||
if (DataContext is not MainWindowViewModel vm) return;
|
||||
|
||||
var commandModifier = (e.KeyModifiers & (KeyModifiers.Control | KeyModifiers.Meta)) != 0;
|
||||
if (commandModifier && e.Key == Key.K)
|
||||
{
|
||||
vm.OpenCommandPaletteCommand.Execute(null);
|
||||
FocusCommandPalette();
|
||||
e.Handled = true;
|
||||
return;
|
||||
}
|
||||
|
||||
if (!vm.IsCommandPaletteOpen) return;
|
||||
if (e.Key == Key.Escape)
|
||||
{
|
||||
vm.CloseCommandPaletteCommand.Execute(null);
|
||||
e.Handled = true;
|
||||
}
|
||||
else if (e.Key == Key.Enter)
|
||||
{
|
||||
vm.CommandPalette.ExecuteCommand.Execute(vm.CommandPalette.SelectedResult);
|
||||
e.Handled = true;
|
||||
}
|
||||
else if (e.Key is Key.Down or Key.Up)
|
||||
{
|
||||
MoveCommandPaletteSelection(vm, e.Key == Key.Down ? 1 : -1);
|
||||
e.Handled = true;
|
||||
}
|
||||
}
|
||||
|
||||
private void OnOpenCommandPaletteClick(object? sender, Avalonia.Interactivity.RoutedEventArgs e)
|
||||
{
|
||||
if (DataContext is MainWindowViewModel vm)
|
||||
vm.OpenCommandPaletteCommand.Execute(null);
|
||||
FocusCommandPalette();
|
||||
}
|
||||
|
||||
private void FocusCommandPalette() => Dispatcher.UIThread.Post(() =>
|
||||
{
|
||||
if (this.FindControl<TextBox>("CommandPaletteSearchBox") is { } search)
|
||||
{
|
||||
search.Focus();
|
||||
search.SelectAll();
|
||||
}
|
||||
});
|
||||
|
||||
private static void MoveCommandPaletteSelection(MainWindowViewModel vm, int delta)
|
||||
{
|
||||
var results = vm.CommandPalette.Results;
|
||||
if (results.Count == 0) return;
|
||||
var current = vm.CommandPalette.SelectedResult is { } selected ? results.IndexOf(selected) : -1;
|
||||
vm.CommandPalette.SelectedResult = results[Math.Clamp(current + delta, 0, results.Count - 1)];
|
||||
}
|
||||
|
||||
public void EnableFinalSync(SyncEngine syncEngine)
|
||||
|
||||
@@ -21,6 +21,21 @@
|
||||
<Style Selector="Border.supervisioncell.substitution">
|
||||
<Setter Property="Background" Value="#8E24AA"/>
|
||||
</Style>
|
||||
<Style Selector="Button.weekCellMenuTrigger">
|
||||
<Setter Property="Width" Value="18"/>
|
||||
<Setter Property="Height" Value="16"/>
|
||||
<Setter Property="Padding" Value="0"/>
|
||||
<Setter Property="Margin" Value="2"/>
|
||||
<Setter Property="Background" Value="#33000000"/>
|
||||
<Setter Property="Foreground" Value="White"/>
|
||||
<Setter Property="FontSize" Value="11"/>
|
||||
<Setter Property="CornerRadius" Value="4"/>
|
||||
<Setter Property="HorizontalContentAlignment" Value="Center"/>
|
||||
<Setter Property="VerticalContentAlignment" Value="Center"/>
|
||||
</Style>
|
||||
<Style Selector="Button.weekCellMenuTrigger:pointerover /template/ ContentPresenter">
|
||||
<Setter Property="Background" Value="#55000000"/>
|
||||
</Style>
|
||||
</UserControl.Styles>
|
||||
|
||||
<Grid RowDefinitions="Auto,Auto,*">
|
||||
@@ -226,7 +241,8 @@
|
||||
</Border>
|
||||
<Border Classes="timetablecell" Classes.assigned="{Binding IsAssigned}"
|
||||
CornerRadius="6" IsVisible="{Binding IsSlotCell}">
|
||||
<Button Background="{Binding ColorHex}" IsVisible="{Binding IsAssigned}"
|
||||
<Panel IsVisible="{Binding IsAssigned}">
|
||||
<Button Background="{Binding ColorHex}"
|
||||
HorizontalAlignment="Stretch" VerticalAlignment="Stretch"
|
||||
HorizontalContentAlignment="Stretch" CornerRadius="6" Padding="6"
|
||||
Command="{Binding $parent[ItemsControl;1].((vm:TimetableViewModel)DataContext).OpenWeekCellCommand}"
|
||||
@@ -256,6 +272,34 @@
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
</Button>
|
||||
<!-- Nutzer-Feedback (zweite Runde): Popup-Menü statt Dropdown, hier
|
||||
im Wochenraster statt in der Tagesliste (dort waren die
|
||||
bisherigen zwei Buttons schon in Ordnung). Sibling-Button statt
|
||||
verschachteltem Button/ContextMenu — Routing über
|
||||
MenuItem.Click in TimetableView.axaml.cs, siehe Kommentar dort
|
||||
zu DataContext-Vererbung vs. $parent-Vorfahrensuche im Flyout. -->
|
||||
<Button Classes="weekCellMenuTrigger" Content="⋮"
|
||||
HorizontalAlignment="Right" VerticalAlignment="Top"
|
||||
IsVisible="{Binding HasGroupId}"
|
||||
ToolTip.Tip="Weitere Ziele…">
|
||||
<Button.Flyout>
|
||||
<MenuFlyout Placement="BottomEdgeAlignedRight">
|
||||
<MenuItem Header="▶ Unterrichtsansicht" IsVisible="{Binding HasLesson}"
|
||||
Tag="{x:Static vm:TimetableLessonDestination.TeachingMode}"
|
||||
Click="OnWeekCellMenuItemClick"/>
|
||||
<MenuItem Header="📋 Planungsviewer" IsVisible="{Binding HasLesson}"
|
||||
Tag="{x:Static vm:TimetableLessonDestination.Viewer}"
|
||||
Click="OnWeekCellMenuItemClick"/>
|
||||
<MenuItem Header="🪑 Sitzplan"
|
||||
Tag="{x:Static vm:TimetableLessonDestination.SeatingPlan}"
|
||||
Click="OnWeekCellMenuItemClick"/>
|
||||
<MenuItem Header="✏️ Planung"
|
||||
Tag="{x:Static vm:TimetableLessonDestination.Planning}"
|
||||
Click="OnWeekCellMenuItemClick"/>
|
||||
</MenuFlyout>
|
||||
</Button.Flyout>
|
||||
</Button>
|
||||
</Panel>
|
||||
</Border>
|
||||
</Grid>
|
||||
</DataTemplate>
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Interactivity;
|
||||
using LehrerApp.Core.Interfaces;
|
||||
using LehrerApp.Core.Models;
|
||||
using LehrerApp.Core.Services;
|
||||
using LehrerApp.Desktop.ViewModels;
|
||||
using LehrerApp.Desktop.ViewModels.Planning;
|
||||
using LehrerApp.Desktop.ViewModels.Groups;
|
||||
using LehrerApp.Desktop.Views.Groups;
|
||||
@@ -27,6 +29,46 @@ public partial class TimetableView : UserControl
|
||||
}
|
||||
}
|
||||
|
||||
/// Nutzer-Feedback (zweite Runde — die erste Fassung hatte das Problem an der falschen
|
||||
/// 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
|
||||
/// 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
|
||||
/// Alternative zum Direktklick, siehe <see cref="TimetableViewModel.OpenWeekCellCommand"/>
|
||||
/// für den "einheitlicheren" Standard-Klick (Unterrichtsansicht bei laufender Stunde, sonst
|
||||
/// Planungsviewer, sonst Einheitenplanung). MenuItem.Click statt Command-Binding: ein
|
||||
/// $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
|
||||
/// TODO.md-Nachtrag zum Klassenlehrer-Bereich) — DataContext-Vererbung (kein Pfad-Suchen,
|
||||
/// nur der normale Eltern-Wert) funktioniert dort aber sehr wohl, deshalb liest der Handler
|
||||
/// die Zelle über <c>((MenuItem)sender).DataContext</c> statt über eine Binding.
|
||||
private async void OnWeekCellMenuItemClick(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
if (sender is not MenuItem { Tag: TimetableLessonDestination destination } menuItem) return;
|
||||
if (menuItem.DataContext is not WeekCellItem cell) return;
|
||||
if (DataContext is not TimetableViewModel vm) return;
|
||||
|
||||
switch (destination)
|
||||
{
|
||||
case TimetableLessonDestination.TeachingMode:
|
||||
if (cell.Lesson is { } teachLesson && vm.OnOpenTeachingMode is not null)
|
||||
await vm.OnOpenTeachingMode(teachLesson);
|
||||
break;
|
||||
case TimetableLessonDestination.Viewer:
|
||||
if (cell.Lesson is { } viewLesson && vm.OnOpenLessonViewer is not null)
|
||||
await vm.OnOpenLessonViewer(viewLesson);
|
||||
break;
|
||||
case TimetableLessonDestination.Planning:
|
||||
if (cell.GroupId != Guid.Empty)
|
||||
App.Services.GetRequiredService<MainWindowViewModel>().NavigateToGroupDetail(cell.GroupId, 6);
|
||||
break;
|
||||
case TimetableLessonDestination.SeatingPlan:
|
||||
if (cell.GroupId != Guid.Empty)
|
||||
App.Services.GetRequiredService<MainWindowViewModel>().NavigateToGroupDetail(cell.GroupId, 2);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ShowWebUntisTimetableDialog()
|
||||
{
|
||||
var owner = TopLevel.GetTopLevel(this) as Window;
|
||||
@@ -50,7 +92,9 @@ public partial class TimetableView : UserControl
|
||||
|
||||
var teachingModeVm = new TeachingModeViewModel(lesson, group,
|
||||
App.Services.GetRequiredService<IAlternativeLessonPathRepository>(),
|
||||
App.Services.GetRequiredService<SeatingPlanTabViewModel>());
|
||||
App.Services.GetRequiredService<ILessonRepository>(),
|
||||
App.Services.GetRequiredService<SeatingPlanTabViewModel>(),
|
||||
App.Services.GetRequiredService<ParticipationTabViewModel>());
|
||||
var window = new TeachingModeWindow { DataContext = teachingModeVm };
|
||||
await window.ShowDialog(owner);
|
||||
}
|
||||
|
||||
@@ -1000,6 +1000,19 @@
|
||||
IsVisible="{Binding UntisApiIsConfigured}" IsEnabled="{Binding !UntisApiBusy}"/>
|
||||
</StackPanel>
|
||||
|
||||
<StackPanel Spacing="8" IsVisible="{Binding UntisApiIsConfigured}">
|
||||
<Separator Margin="0,8"/>
|
||||
<TextBlock Text="Klassenlehrer" FontSize="16" FontWeight="SemiBold"/>
|
||||
<TextBlock FontSize="12" Opacity="0.6" TextWrapping="Wrap"
|
||||
Text="Die Klasse, deren Klassenlehrer/-in du bist — unabhängig von deinem eigenen Unterricht. Schaltet den Klassenlehrer-Bereich in der Seitenleiste frei."/>
|
||||
<TextBlock FontSize="13" Text="{Binding UntisHomeroomClassDisplay}"/>
|
||||
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||
<Button Content="Klasse auswählen…" Command="{Binding UntisPickHomeroomClassCommand}"/>
|
||||
<Button Content="Entfernen" Command="{Binding UntisClearHomeroomClassCommand}"
|
||||
IsVisible="{Binding UntisHomeroomClassName, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
|
||||
<Separator Margin="0,8"/>
|
||||
|
||||
<TextBlock Text="WebUntis-Stundenplan-Abgleich" FontSize="16" FontWeight="SemiBold"/>
|
||||
|
||||
@@ -4,8 +4,10 @@ using Avalonia.Platform.Storage;
|
||||
using LehrerApp.Core.Interfaces;
|
||||
using LehrerApp.Desktop.Services;
|
||||
using LehrerApp.Desktop.ViewModels;
|
||||
using LehrerApp.Desktop.ViewModels.Groups;
|
||||
using LehrerApp.Desktop.ViewModels.Planning;
|
||||
using LehrerApp.Desktop.ViewModels.Settings;
|
||||
using LehrerApp.Desktop.Views.Groups;
|
||||
using LehrerApp.Desktop.Views.Planning;
|
||||
using LehrerApp.Desktop.Views.Shared;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
@@ -31,6 +33,7 @@ public partial class SettingsView : UserControl
|
||||
vm.OnConfirmRecoveryRestore = ShowRecoveryRestoreConfirmDialog;
|
||||
vm.OnThemeChanged = App.ApplyTheme;
|
||||
vm.OnReviewUntisMapping = ShowUntisMappingReviewDialog;
|
||||
vm.OnPickHomeroomClass = PickHomeroomClassAsync;
|
||||
_ = vm.LoadSchoolLocationCommand.ExecuteAsync(null);
|
||||
}
|
||||
}
|
||||
@@ -51,6 +54,20 @@ public partial class SettingsView : UserControl
|
||||
await dialog.ShowDialog<bool>(owner);
|
||||
}
|
||||
|
||||
private async Task<(int UntisId, string Name)?> PickHomeroomClassAsync()
|
||||
{
|
||||
var owner = TopLevel.GetTopLevel(this) as Window;
|
||||
if (owner is null) return null;
|
||||
|
||||
var untis = App.Services.GetRequiredService<WebUntisIntegrationService>();
|
||||
var selectionVm = new WebUntisClassSelectionViewModel(untis);
|
||||
var selection = new WebUntisClassSelectionDialog { DataContext = selectionVm };
|
||||
await selectionVm.InitializeAsync();
|
||||
if (!await selection.ShowDialog<bool>(owner) || selectionVm.SelectedClass is null) return null;
|
||||
|
||||
return (selectionVm.SelectedClass.UntisId, selectionVm.SelectedClass.Name);
|
||||
}
|
||||
|
||||
private async Task<bool> SaveRecoveryFile(string content)
|
||||
{
|
||||
var topLevel = TopLevel.GetTopLevel(this);
|
||||
|
||||
@@ -233,6 +233,9 @@
|
||||
|
||||
<CheckBox Content="Vertraulich" IsChecked="{Binding IsConfidential}"
|
||||
ToolTip.Tip="Blendet den Inhalt in der Übersicht standardmäßig aus; erst nach Klick auf 'Anzeigen' sichtbar."/>
|
||||
|
||||
<CheckBox Content="Nicht mit WebUntis abgleichen" IsChecked="{Binding ExcludeFromWebUntisSync}"
|
||||
ToolTip.Tip="Erscheint dann nicht als Vorschlag zum Nacherfassen im WebUntis-Klassenbuch-Abgleich (Dashboard)."/>
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
<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.WebUntisDocumentationComparisonDialog"
|
||||
x:DataType="vm:WebUntisDocumentationComparisonViewModel"
|
||||
Title="Klassenbucheinträge mit WebUntis abgleichen" Width="1150" Height="720"
|
||||
MinWidth="900" MinHeight="500" WindowStartupLocation="CenterOwner">
|
||||
<Grid RowDefinitions="Auto,Auto,Auto,2*,Auto,Auto,1*,Auto" Margin="24" RowSpacing="8">
|
||||
<StackPanel Grid.Row="0" Spacing="4">
|
||||
<TextBlock Text="Klassenbucheinträge mit WebUntis abgleichen" Classes="dialogtitle"/>
|
||||
<TextBlock TextWrapping="Wrap" FontSize="12" Opacity="0.65"
|
||||
Text="Nur eigene WebUntis-Einträge (Benutzer = eigener Login). Zeilen ohne automatische Zuordnung bitte manuell einem/einer Schüler*in zuweisen. Bereits lokal vorhandene Einträge sind gesperrt."/>
|
||||
</StackPanel>
|
||||
<StackPanel Grid.Row="1" Orientation="Horizontal" Spacing="8">
|
||||
<DatePicker SelectedDate="{Binding StartDate}"/>
|
||||
<TextBlock Text="bis" VerticalAlignment="Center"/>
|
||||
<DatePicker SelectedDate="{Binding EndDate}"/>
|
||||
<Button Content="Klassenbucheinträge laden" Command="{Binding LoadCommand}" IsEnabled="{Binding !Busy}"/>
|
||||
</StackPanel>
|
||||
|
||||
<Grid Grid.Row="2" ColumnDefinitions="Auto,70,55,65,1.1*,1.1*,1*,1.3*,1.3*" ColumnSpacing="8" Margin="4,0">
|
||||
<TextBlock Grid.Column="1" Text="Datum" FontSize="11" Opacity="0.6"/>
|
||||
<TextBlock Grid.Column="2" Text="Klasse" FontSize="11" Opacity="0.6"/>
|
||||
<TextBlock Grid.Column="3" Text="Fach" FontSize="11" Opacity="0.6"/>
|
||||
<TextBlock Grid.Column="4" Text="Name (WebUntis)" FontSize="11" Opacity="0.6"/>
|
||||
<TextBlock Grid.Column="5" Text="Zuordnung" FontSize="11" Opacity="0.6"/>
|
||||
<TextBlock Grid.Column="6" Text="Kategorie" FontSize="11" Opacity="0.6"/>
|
||||
<TextBlock Grid.Column="7" Text="Text" FontSize="11" Opacity="0.6"/>
|
||||
<TextBlock Grid.Column="8" Text="Lokal am selben Tag" FontSize="11" Opacity="0.6"/>
|
||||
</Grid>
|
||||
<ScrollViewer Grid.Row="3">
|
||||
<ItemsControl ItemsSource="{Binding Rows}">
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:WebUntisDocumentationRow">
|
||||
<Grid ColumnDefinitions="Auto,70,55,65,1.1*,1.1*,1*,1.3*,1.3*" ColumnSpacing="8" Margin="0,3">
|
||||
<CheckBox Grid.Column="0" IsChecked="{Binding Selected}" IsEnabled="{Binding CanApply}"/>
|
||||
<TextBlock Grid.Column="1" Text="{Binding DateLabel}" VerticalAlignment="Center"/>
|
||||
<TextBlock Grid.Column="2" Text="{Binding ClassName}" VerticalAlignment="Center"/>
|
||||
<TextBlock Grid.Column="3" Text="{Binding Subject}" VerticalAlignment="Center"/>
|
||||
<TextBlock Grid.Column="4" Text="{Binding UntisStudentName}" VerticalAlignment="Center" TextWrapping="Wrap"/>
|
||||
<ComboBox Grid.Column="5" ItemsSource="{Binding Candidates}" SelectedItem="{Binding AssignedStudent}"
|
||||
DisplayMemberBinding="{Binding FullName}" PlaceholderText="Schüler*in wählen…"
|
||||
HorizontalAlignment="Stretch"/>
|
||||
<StackPanel Grid.Column="6" Spacing="0">
|
||||
<TextBlock Text="{Binding CategoryName}" TextWrapping="Wrap"/>
|
||||
<TextBlock Text="{Binding CategoryGroup}" FontSize="11" Opacity="0.6"/>
|
||||
</StackPanel>
|
||||
<TextBlock Grid.Column="7" Text="{Binding Text}" TextWrapping="Wrap" VerticalAlignment="Center"/>
|
||||
<TextBlock Grid.Column="8" Text="{Binding ExistingLocalEntry}" TextWrapping="Wrap"
|
||||
Foreground="DarkOrange" VerticalAlignment="Center"
|
||||
IsVisible="{Binding ExistingLocalEntry, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
|
||||
</Grid>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
</ScrollViewer>
|
||||
<Grid Grid.Row="4" ColumnDefinitions="*,Auto" ColumnSpacing="8">
|
||||
<TextBlock Grid.Column="0" Text="{Binding Status}" FontSize="12" TextWrapping="Wrap" VerticalAlignment="Center"/>
|
||||
<Button Grid.Column="1" Content="Markierte übernehmen" Command="{Binding ApplyCommand}" IsEnabled="{Binding !Busy}"/>
|
||||
</Grid>
|
||||
|
||||
<StackPanel Grid.Row="5" Spacing="2">
|
||||
<TextBlock Text="Lokale Einträge im Zeitraum ohne WebUntis-Gegenstück" FontWeight="SemiBold" FontSize="13"/>
|
||||
<TextBlock FontSize="12" Opacity="0.65" TextWrapping="Wrap"
|
||||
Text="Kein Schreibzugriff auf WebUntis — Text zum manuellen Nacherfassen in die Zwischenablage kopieren."/>
|
||||
</StackPanel>
|
||||
<ScrollViewer Grid.Row="6">
|
||||
<ItemsControl ItemsSource="{Binding LocalOnlyRows}">
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:LocalOnlyDocumentationRow">
|
||||
<Grid ColumnDefinitions="80,1.2*,1.5*,2.5*,Auto" ColumnSpacing="8" Margin="0,3">
|
||||
<TextBlock Grid.Column="0" Text="{Binding DateLabel}" VerticalAlignment="Center"/>
|
||||
<TextBlock Grid.Column="1" Text="{Binding StudentName}" VerticalAlignment="Center" TextWrapping="Wrap"/>
|
||||
<TextBlock Grid.Column="2" Text="{Binding GroupName}" VerticalAlignment="Center" TextWrapping="Wrap"/>
|
||||
<StackPanel Grid.Column="3">
|
||||
<TextBlock Text="{Binding Title}" FontWeight="SemiBold"/>
|
||||
<TextBlock Text="{Binding Content}" TextWrapping="Wrap" FontSize="12" Opacity="0.8"/>
|
||||
</StackPanel>
|
||||
<Button Grid.Column="4" Content="In Zwischenablage" Click="OnCopyToClipboardClick"/>
|
||||
</Grid>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
</ScrollViewer>
|
||||
|
||||
<Button Grid.Row="7" Content="Schließen" Click="OnClose" HorizontalAlignment="Right"/>
|
||||
</Grid>
|
||||
</Window>
|
||||
@@ -0,0 +1,24 @@
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Input.Platform;
|
||||
using Avalonia.Interactivity;
|
||||
using LehrerApp.Desktop.Services;
|
||||
using LehrerApp.Desktop.ViewModels.Students;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace LehrerApp.Desktop.Views.Students;
|
||||
|
||||
public partial class WebUntisDocumentationComparisonDialog : Window
|
||||
{
|
||||
public WebUntisDocumentationComparisonDialog() => InitializeComponent();
|
||||
|
||||
private void OnClose(object? sender, RoutedEventArgs e) => Close();
|
||||
|
||||
private async void OnCopyToClipboardClick(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
if (sender is not Button { DataContext: LocalOnlyDocumentationRow row }) return;
|
||||
var clipboard = TopLevel.GetTopLevel(this)?.Clipboard;
|
||||
if (clipboard is null) return;
|
||||
await clipboard.SetTextAsync(row.ClipboardText);
|
||||
App.Services.GetRequiredService<NotificationService>().ShowSuccess("In die Zwischenablage kopiert.");
|
||||
}
|
||||
}
|
||||
@@ -229,6 +229,95 @@ public sealed class SyncEngineTests
|
||||
Assert.Equal(0, temp.Queue.PendingCount());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SyncNowAsync_MehrAlsEinPushBatch_LeertQueueInEinemSync()
|
||||
{
|
||||
using var temp = new TempEventQueue();
|
||||
for (var i = 0; i < SyncProtocol.PushBatchSize + 5; i++)
|
||||
temp.Queue.Enqueue("this-device", DeviceType.Desktop,
|
||||
"Lesson", Guid.NewGuid().ToString(), "Save", $"payload-{i}");
|
||||
|
||||
var pushedBatchSizes = new List<int>();
|
||||
long serverSeq = 0;
|
||||
var handler = new FakeHttpMessageHandler(req =>
|
||||
{
|
||||
if (req.RequestUri!.AbsolutePath == "/api/sync/push")
|
||||
{
|
||||
var events = req.Content!.ReadFromJsonAsync<List<SyncEvent>>().GetAwaiter().GetResult()!;
|
||||
pushedBatchSizes.Add(events.Count);
|
||||
var assigned = events.ToDictionary(e => e.EventId, _ => ++serverSeq);
|
||||
return new HttpResponseMessage(HttpStatusCode.OK)
|
||||
{
|
||||
Content = JsonContent.Create(new PushResponse
|
||||
{ ServerSequenceNr = serverSeq, AssignedServerSeqs = assigned }),
|
||||
};
|
||||
}
|
||||
return new HttpResponseMessage(HttpStatusCode.OK)
|
||||
{ Content = JsonContent.Create(new PullResponse()) };
|
||||
});
|
||||
var engine = MakeEngine(temp, handler);
|
||||
|
||||
var result = await engine.SyncNowAsync();
|
||||
|
||||
Assert.True(result.Success);
|
||||
Assert.Equal(SyncProtocol.PushBatchSize + 5, result.EventsPushed);
|
||||
Assert.Equal([SyncProtocol.PushBatchSize, 5], pushedBatchSizes);
|
||||
Assert.Equal(0, temp.Queue.PendingCount());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SyncNowAsync_VollerPullBatch_LaedtFolgebatchImSelbenSync()
|
||||
{
|
||||
using var temp = new TempEventQueue();
|
||||
using var db = NewInMemoryContext();
|
||||
var allEvents = Enumerable.Range(1, SyncProtocol.PullBatchSize + 3)
|
||||
.Select(sequence =>
|
||||
{
|
||||
var student = new Student { FirstName = $"Vorname-{sequence}", LastName = "Beispiel" };
|
||||
return new SyncEvent
|
||||
{
|
||||
DeviceId = "other-device", DeviceType = DeviceType.Desktop,
|
||||
EntityType = nameof(Student), EntityId = student.Id.ToString(),
|
||||
Operation = "Save", Payload = SyncCrypto.EncryptObject(student, Key),
|
||||
SequenceNr = sequence,
|
||||
};
|
||||
}).ToList();
|
||||
var pullRequests = 0;
|
||||
var handler = new FakeHttpMessageHandler(req =>
|
||||
{
|
||||
if (req.RequestUri!.AbsolutePath == "/api/sync/pull")
|
||||
{
|
||||
pullRequests++;
|
||||
var since = long.Parse(req.RequestUri.Query.TrimStart('?').Split('&')
|
||||
.Select(part => part.Split('=')).Single(part => part[0] == "since")[1]);
|
||||
var events = allEvents.Where(e => e.SequenceNr > since)
|
||||
.Take(SyncProtocol.PullBatchSize).ToList();
|
||||
return new HttpResponseMessage(HttpStatusCode.OK)
|
||||
{
|
||||
Content = JsonContent.Create(new PullResponse
|
||||
{
|
||||
Events = events,
|
||||
ServerSequenceNr = events.Count == 0 ? since : events[^1].SequenceNr,
|
||||
}),
|
||||
};
|
||||
}
|
||||
return new HttpResponseMessage(HttpStatusCode.OK)
|
||||
{ Content = JsonContent.Create(new PushResponse()) };
|
||||
});
|
||||
var engine = MakeEngine(temp, handler, new EventApplier(db, Key, versions: temp.Queue));
|
||||
var dataChangedCount = 0;
|
||||
engine.DataChanged += () => dataChangedCount++;
|
||||
|
||||
var result = await engine.SyncNowAsync();
|
||||
|
||||
Assert.True(result.Success);
|
||||
Assert.Equal(SyncProtocol.PullBatchSize + 3, result.EventsPulled);
|
||||
Assert.Equal(2, pullRequests);
|
||||
Assert.Equal(SyncProtocol.PullBatchSize + 3, db.Students.Count());
|
||||
Assert.Equal(SyncProtocol.PullBatchSize + 3, temp.Queue.GetLastServerSeq());
|
||||
Assert.Equal(1, dataChangedCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PushAsync_SetztBasedOnServerSeqAusLokalerVersionsverfolgung()
|
||||
{
|
||||
|
||||
@@ -49,8 +49,14 @@ public class EventQueue : IDisposable
|
||||
return evt;
|
||||
}
|
||||
|
||||
public List<SyncEvent> GetPending(int max = 200) =>
|
||||
_queue.Find(Query.All(nameof(SyncEvent.SequenceNr))).Take(max).ToList();
|
||||
public List<SyncEvent> GetPending(int max = SyncProtocol.PushBatchSize,
|
||||
IReadOnlySet<Guid>? excludedEventIds = null)
|
||||
{
|
||||
var pending = _queue.Find(Query.All(nameof(SyncEvent.SequenceNr))).AsEnumerable();
|
||||
if (excludedEventIds is not null)
|
||||
pending = pending.Where(e => !excludedEventIds.Contains(e.EventId));
|
||||
return pending.Take(max).ToList();
|
||||
}
|
||||
public int PendingCount() => _queue.Count();
|
||||
public void Acknowledge(IEnumerable<Guid> ids) { foreach (var id in ids) _queue.Delete(id); }
|
||||
public long GetLastServerSeq() => _meta.FindById("serverSeq")?.Value ?? 0;
|
||||
|
||||
@@ -86,14 +86,37 @@ public class SyncEngine : IDisposable
|
||||
|
||||
private async Task<SyncResult> RunSyncAsync(bool isAutomatic)
|
||||
{
|
||||
var pulledDataChanged = false;
|
||||
SetState(SyncState.Syncing);
|
||||
_logger?.Info($"Sync: Start ({(isAutomatic ? "automatisch" : "manuell")}), Gerät={_config.DeviceId}, " +
|
||||
$"{_queue.PendingCount()} lokal ausstehend");
|
||||
try
|
||||
{
|
||||
var (pushed, pushConflicts) = await PushAsync();
|
||||
var pushed = 0;
|
||||
var pushConflicts = 0;
|
||||
var deferredPushEvents = new HashSet<Guid>();
|
||||
PushBatchResult pushBatch;
|
||||
do
|
||||
{
|
||||
pushBatch = await PushAsync(deferredPushEvents);
|
||||
pushed += pushBatch.Pushed;
|
||||
pushConflicts += pushBatch.Conflicts;
|
||||
deferredPushEvents.UnionWith(pushBatch.DeferredEventIds);
|
||||
} while (pushBatch.SourceEventCount == SyncProtocol.PushBatchSize);
|
||||
|
||||
await _attachments.UploadPendingAsync(_queue);
|
||||
var (pulled, conflicts) = await PullAsync();
|
||||
|
||||
var pulled = 0;
|
||||
var conflicts = 0;
|
||||
PullBatchResult pullBatch;
|
||||
do
|
||||
{
|
||||
pullBatch = await PullAsync();
|
||||
pulled += pullBatch.Pulled;
|
||||
conflicts += pullBatch.Conflicts;
|
||||
pulledDataChanged |= pullBatch.Pulled > 0;
|
||||
} while (pullBatch.Pulled == SyncProtocol.PullBatchSize);
|
||||
|
||||
_queue.SetLastSyncAt(DateTime.UtcNow);
|
||||
SetState(SyncState.Idle);
|
||||
_logger?.Info($"Sync: Fertig - {pushed} gepusht ({pushConflicts} Push-Konflikte), " +
|
||||
@@ -121,12 +144,19 @@ public class SyncEngine : IDisposable
|
||||
SetState(SyncState.Error, ex.Message);
|
||||
return new() { Reason = ex.Message };
|
||||
}
|
||||
finally
|
||||
{
|
||||
// Auch wenn ein späterer Batch fehlschlägt, wurden frühere Batches bereits dauerhaft
|
||||
// angewendet. Die Oberfläche muss diesen erfolgreich übernommenen Zwischenstand dann
|
||||
// trotzdem neu laden; bei einem erfolgreichen Drain feuert der Hook genau einmal.
|
||||
if (pulledDataChanged) DataChanged?.Invoke();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<(int Pushed, int Conflicts)> PushAsync()
|
||||
private async Task<PushBatchResult> PushAsync(IReadOnlySet<Guid> excludedEventIds)
|
||||
{
|
||||
var pending = DeduplicatePending();
|
||||
if (pending.Count == 0) return (0, 0);
|
||||
var pending = DeduplicatePending(excludedEventIds, out var sourceEventCount);
|
||||
if (pending.Count == 0) return new(0, 0, sourceEventCount, []);
|
||||
// BasedOnServerSeq erst unmittelbar vor dem Senden setzen (nicht beim Enqueue) - zwischen
|
||||
// Enqueue und Push kann ein Pull den lokal bekannten Stand dieser Entität aktualisiert
|
||||
// haben (siehe EventApplier.ApplyAsync).
|
||||
@@ -139,7 +169,11 @@ public class SyncEngine : IDisposable
|
||||
using var resp = await _http.SendAsync(request);
|
||||
await SyncProtocol.EnsureCompatibleSuccessAsync(resp);
|
||||
var result = await resp.Content.ReadFromJsonAsync<PushResponse>();
|
||||
if (result is null) { _logger?.Warn("Sync: Push - leere Server-Antwort."); return (0, 0); }
|
||||
if (result is null)
|
||||
{
|
||||
_logger?.Warn("Sync: Push - leere Server-Antwort.");
|
||||
return new(0, 0, sourceEventCount, pending.Select(e => e.EventId).ToList());
|
||||
}
|
||||
_queue.Acknowledge(pending
|
||||
.Where(e => !result.ConflictingEventIds.Contains(e.EventId))
|
||||
.Select(e => e.EventId));
|
||||
@@ -161,8 +195,8 @@ public class SyncEngine : IDisposable
|
||||
$"{result.ConflictingEventIds.Count} abgelehnt (Konflikt).");
|
||||
if (result.ConflictingEventIds.Count > 0)
|
||||
await HandleRejectedAsync(pending.Where(e => result.ConflictingEventIds.Contains(e.EventId)));
|
||||
return (pending.Count - result.ConflictingEventIds.Count,
|
||||
result.ConflictingEventIds.Count);
|
||||
return new(pending.Count - result.ConflictingEventIds.Count,
|
||||
result.ConflictingEventIds.Count, sourceEventCount, result.ConflictingEventIds);
|
||||
}
|
||||
|
||||
// Payload ist immer ein vollständiges Entitäts-Snapshot (nie ein Delta, siehe
|
||||
@@ -171,9 +205,11 @@ public class SyncEngine : IDisposable
|
||||
// exakte BasedOnServerSeq-Prüfung: ohne Dedup könnten zwei Ereignisse derselben Entität im
|
||||
// selben Batch mit demselben (veralteten) BasedOnServerSeq ankommen und sich gegenseitig ins
|
||||
// Aus laufen.
|
||||
private List<SyncEvent> DeduplicatePending()
|
||||
private List<SyncEvent> DeduplicatePending(IReadOnlySet<Guid> excludedEventIds,
|
||||
out int sourceEventCount)
|
||||
{
|
||||
var pending = _queue.GetPending();
|
||||
var pending = _queue.GetPending(SyncProtocol.PushBatchSize, excludedEventIds);
|
||||
sourceEventCount = pending.Count;
|
||||
if (pending.Count == 0) return pending;
|
||||
var latest = pending
|
||||
.GroupBy(e => (e.EntityType, e.EntityId))
|
||||
@@ -259,7 +295,7 @@ public class SyncEngine : IDisposable
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<(int Pulled, int Conflicts)> PullAsync()
|
||||
private async Task<PullBatchResult> PullAsync()
|
||||
{
|
||||
var since = _queue.GetLastServerSeq();
|
||||
_logger?.Info($"Sync: Pull - frage Server nach Ereignissen seit ServerSequenceNr={since}.");
|
||||
@@ -271,7 +307,7 @@ public class SyncEngine : IDisposable
|
||||
if (resp is null || resp.Events.Count == 0)
|
||||
{
|
||||
_logger?.Info("Sync: Pull - keine neuen Ereignisse vom Server.");
|
||||
return (0, 0);
|
||||
return new(0, 0);
|
||||
}
|
||||
_logger?.Info($"Sync: Pull - {resp.Events.Count} Ereignis(se) vom Server erhalten: " +
|
||||
string.Join(", ", resp.Events.Select(e => $"{e.EntityType}/{e.Operation}")));
|
||||
@@ -287,10 +323,13 @@ public class SyncEngine : IDisposable
|
||||
if (c.Resolution == "RemoteWon") await _applier.ApplyAsync(evt);
|
||||
}
|
||||
_queue.SetLastServerSeq(resp.ServerSequenceNr);
|
||||
DataChanged?.Invoke();
|
||||
return (resp.Events.Count, conflicts);
|
||||
return new(resp.Events.Count, conflicts);
|
||||
}
|
||||
|
||||
private readonly record struct PushBatchResult(int Pushed, int Conflicts,
|
||||
int SourceEventCount, IReadOnlyCollection<Guid> DeferredEventIds);
|
||||
private readonly record struct PullBatchResult(int Pulled, int Conflicts);
|
||||
|
||||
private void SetState(SyncState state, string? error = null)
|
||||
{
|
||||
Status = new SyncStatus
|
||||
|
||||
@@ -14,6 +14,8 @@ public static class SyncProtocol
|
||||
{
|
||||
public const string CurrentVersion = "1";
|
||||
public const string VersionHeaderName = "X-LehrerApp-Sync-Version";
|
||||
public const int PushBatchSize = 200;
|
||||
public const int PullBatchSize = 500;
|
||||
|
||||
public static HttpRequestMessage CreateRequest(HttpMethod method, string requestUri)
|
||||
{
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
using LehrerApp.Core.Models;
|
||||
using LehrerApp.Core.Services;
|
||||
using Xunit;
|
||||
|
||||
namespace LehrerApp.Tests;
|
||||
|
||||
public class ExamPriorityServiceTests
|
||||
{
|
||||
private static Exam MakeExam(ExamStatus status, DateOnly date) => new()
|
||||
{
|
||||
GroupId = Guid.NewGuid(), Title = "Klausur", Status = status, Date = date,
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public void Evaluate_UnbearbeiteteKorrekturSteigtMitAlter()
|
||||
{
|
||||
var today = new DateOnly(2026, 8, 29);
|
||||
var exam = MakeExam(ExamStatus.Conducted, today.AddDays(-10));
|
||||
|
||||
var result = ExamPriorityService.Evaluate(exam, expected: 20, evaluated: 0, today);
|
||||
|
||||
Assert.Equal(ExamListStatus.AwaitingCorrection, result.Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Evaluate_UnbearbeiteteKorrekturIstDringenderAlsLaufendeKorrektur()
|
||||
{
|
||||
var today = new DateOnly(2026, 8, 29);
|
||||
var untouched = MakeExam(ExamStatus.Conducted, today.AddDays(-10));
|
||||
var inProgress = MakeExam(ExamStatus.Conducted, today.AddDays(-10));
|
||||
|
||||
var untouchedResult = ExamPriorityService.Evaluate(untouched, expected: 20, evaluated: 0, today);
|
||||
var progressResult = ExamPriorityService.Evaluate(inProgress, expected: 20, evaluated: 10, today);
|
||||
|
||||
Assert.True(untouchedResult.Score > progressResult.Score);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Evaluate_LaufendeKorrekturIstDringenderAlsGeplanteKlausur()
|
||||
{
|
||||
var today = new DateOnly(2026, 8, 29);
|
||||
var inProgress = MakeExam(ExamStatus.Conducted, today.AddDays(-2));
|
||||
var planned = MakeExam(ExamStatus.Planned, today.AddDays(1));
|
||||
|
||||
var progressResult = ExamPriorityService.Evaluate(inProgress, expected: 20, evaluated: 10, today);
|
||||
var plannedResult = ExamPriorityService.Evaluate(planned, expected: 20, evaluated: 0, today);
|
||||
|
||||
Assert.True(progressResult.Score > plannedResult.Score);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Evaluate_HängengebliebeneKorrekturFälltAusDerDringlichenZone()
|
||||
{
|
||||
var today = new DateOnly(2026, 8, 29);
|
||||
var stuck = MakeExam(ExamStatus.Conducted, today.AddDays(-(ExamPriorityService.StuckAfterDays + 5)));
|
||||
var fresh = MakeExam(ExamStatus.Conducted, today.AddDays(-2));
|
||||
|
||||
// Ein einzelner nie nachschreibender Schüler: 19 von 20 erledigt, seit Wochen unverändert.
|
||||
var stuckResult = ExamPriorityService.Evaluate(stuck, expected: 20, evaluated: 19, today);
|
||||
var freshResult = ExamPriorityService.Evaluate(fresh, expected: 20, evaluated: 10, today);
|
||||
|
||||
Assert.Equal(ExamListStatus.CorrectionStuck, stuckResult.Status);
|
||||
Assert.True(stuckResult.Score < freshResult.Score);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Evaluate_VollständigBewertetGiltAlsBereitZurRückgabe()
|
||||
{
|
||||
var today = new DateOnly(2026, 8, 29);
|
||||
var exam = MakeExam(ExamStatus.Conducted, today.AddDays(-5));
|
||||
|
||||
var result = ExamPriorityService.Evaluate(exam, expected: 20, evaluated: 20, today);
|
||||
|
||||
Assert.Equal(ExamListStatus.AwaitingReturn, result.Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Evaluate_ZurückgegebenIstAmWenigstenDringlich()
|
||||
{
|
||||
var today = new DateOnly(2026, 8, 29);
|
||||
var returned = MakeExam(ExamStatus.Returned, today.AddDays(-30));
|
||||
var planned = MakeExam(ExamStatus.Planned, today.AddDays(60));
|
||||
|
||||
var returnedResult = ExamPriorityService.Evaluate(returned, expected: 20, evaluated: 20, today);
|
||||
var plannedResult = ExamPriorityService.Evaluate(planned, expected: 20, evaluated: 0, today);
|
||||
|
||||
Assert.True(returnedResult.Score < plannedResult.Score);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Count_AbwesenderSchülerGiltAlsErledigt()
|
||||
{
|
||||
var exam = MakeExam(ExamStatus.Conducted, new DateOnly(2026, 8, 20));
|
||||
var membership = new GroupMembership { GroupId = exam.GroupId, StudentId = Guid.NewGuid() };
|
||||
var results = new List<ExamResult> { new() { ExamId = exam.Id, StudentId = membership.StudentId, Absent = true } };
|
||||
|
||||
var (expected, evaluated) = ExamCorrectionCounter.Count(exam, [membership], results);
|
||||
|
||||
Assert.Equal(1, expected);
|
||||
Assert.Equal(1, evaluated);
|
||||
}
|
||||
}
|
||||
@@ -14,7 +14,7 @@ public sealed class GroupRolloverServiceTests
|
||||
{
|
||||
Name = "8a", SchoolYear = "2025/26", GradeLevel = 8,
|
||||
Type = GroupType.Class, SubjectId = Guid.NewGuid(), GradingSystem = GradingSystem.Grades1To6,
|
||||
HoursPerWeek = 4, IsOwnClass = true, IsDifferentiated = true,
|
||||
HoursPerWeek = 4, IsOwnClass = true, IsDifferentiated = true, WebUntisLessonId = 38262,
|
||||
};
|
||||
var oldMembership = new GroupMembership
|
||||
{
|
||||
@@ -38,6 +38,7 @@ public sealed class GroupRolloverServiceTests
|
||||
Assert.Equal(source.HoursPerWeek, target.HoursPerWeek);
|
||||
Assert.True(target.IsOwnClass);
|
||||
Assert.True(target.IsDifferentiated);
|
||||
Assert.Null(target.WebUntisLessonId);
|
||||
Assert.False(source.IsActive);
|
||||
|
||||
var copied = Assert.Single(memberships.GetByGroup(target.Id));
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
using Xunit;
|
||||
using LehrerApp.WebUntis;
|
||||
|
||||
namespace LehrerApp.WebUntis.Tests;
|
||||
|
||||
public sealed class WebUntisClassRegisterEventReportParserTests
|
||||
{
|
||||
[Fact]
|
||||
public void Parse_UebernimmtAlleFelderUndZweistelligesJahr()
|
||||
{
|
||||
const string report = "\uFEFFKlasse\tDatum\tFach\tName\tBenutzer\tEintragskategorie\tKategoriegruppe\tText\r\n" +
|
||||
"10c\t24.08.26\tEng_G\tMuster Erika\ttownsend\tFehlende HA\tNegativ\tKeine Hausaufgaben gemacht.\r\n";
|
||||
|
||||
var entry = Assert.Single(WebUntisClassRegisterEventReportParser.Parse(report));
|
||||
|
||||
Assert.Equal("10c", entry.ClassName);
|
||||
Assert.Equal(20260824, entry.Date);
|
||||
Assert.Equal("Eng_G", entry.Subject);
|
||||
Assert.Equal("Muster Erika", entry.StudentName);
|
||||
Assert.Equal("townsend", entry.TeacherUsername);
|
||||
Assert.Equal("Fehlende HA", entry.CategoryName);
|
||||
Assert.Equal("Negativ", entry.CategoryGroup);
|
||||
Assert.Equal("Keine Hausaufgaben gemacht.", entry.Text);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_UeberspringtLeereZeilen()
|
||||
{
|
||||
const string report = "Klasse\tDatum\tFach\tName\tBenutzer\tEintragskategorie\tKategoriegruppe\tText\r\n" +
|
||||
"6a\t24.08.26\tNAT\tMuster Max\thedtrich\tMitarb. über Erwart.\tPositiv\tGut.\r\n" +
|
||||
"\t\t\t\t\t\t\t\r\n";
|
||||
|
||||
var entry = Assert.Single(WebUntisClassRegisterEventReportParser.Parse(report));
|
||||
Assert.Equal("Muster Max", entry.StudentName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_LehntUngueltigesDatumAb()
|
||||
{
|
||||
const string report = "Klasse\tDatum\tFach\tName\tBenutzer\tEintragskategorie\tKategoriegruppe\tText\r\n" +
|
||||
"6a\tkein-datum\tNAT\tMuster Max\thedtrich\tMitarb. über Erwart.\tPositiv\tGut.\r\n";
|
||||
|
||||
Assert.Throws<InvalidDataException>(() => WebUntisClassRegisterEventReportParser.Parse(report));
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
using System.Net;
|
||||
using System.Diagnostics;
|
||||
using System.Text;
|
||||
using LehrerApp.WebUntis;
|
||||
using Xunit;
|
||||
@@ -16,7 +17,7 @@ public sealed class WebUntisClientTests
|
||||
"2\t20\t8b\tMeier\tBerta\r\n");
|
||||
var handler = new QueueHandler(
|
||||
Json("{\"jsonrpc\":\"2.0\",\"result\":{\"sessionId\":\"session-1\"}}"),
|
||||
Json("{\"data\":{\"finished\":true,\"reportParams\":\"foo=bar\"}}"),
|
||||
Json("{\"data\":{\"finished\":true,\"error\":false,\"reportParams\":\"foo=bar\"}}"),
|
||||
new HttpResponseMessage(HttpStatusCode.OK)
|
||||
{
|
||||
Content = new ByteArrayContent(latin1),
|
||||
@@ -84,6 +85,20 @@ public sealed class WebUntisClientTests
|
||||
await client.DisposeAsync();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Dispose_BegrenztEinenNichtAntwortendenLogout()
|
||||
{
|
||||
var handler = new HangingLogoutHandler();
|
||||
var client = CreateClient(handler);
|
||||
await client.GetSchoolYearsAsync(CancellationToken.None);
|
||||
|
||||
var elapsed = Stopwatch.StartNew();
|
||||
await client.DisposeAsync();
|
||||
|
||||
Assert.True(elapsed.Elapsed < TimeSpan.FromSeconds(5));
|
||||
Assert.True(handler.LogoutWasCancelled);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task VollstaendigeLoginUrl_TrenntRegionalenServerUndSchulkennung()
|
||||
{
|
||||
@@ -128,16 +143,10 @@ public sealed class WebUntisClientTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FehlzeitenUndKlassenbuch_WerdenNachSchuelerAbgerufenUndTypisiert()
|
||||
public async Task Klassenbuch_WirdTypisiertAbgerufen()
|
||||
{
|
||||
var handler = new QueueHandler(
|
||||
Json("{\"result\":{\"sessionId\":\"s\"}}"),
|
||||
Json("{\"result\":{\"periodsWithAbsences\":[" +
|
||||
"{\"studentId\":9001,\"date\":20260901,\"startTime\":800,\"endTime\":845," +
|
||||
"\"absentTime\":45,\"checked\":true,\"absenceReason\":\"Krank\"," +
|
||||
"\"excuseStatus\":\"entschuldigt\",\"subjectId\":12,\"teacherIds\":[\"7\"]}," +
|
||||
"{\"studentId\":9999,\"date\":20260901,\"startTime\":800,\"endTime\":845," +
|
||||
"\"absentTime\":45,\"checked\":false}]}}"),
|
||||
Json("{\"result\":[{\"studentid\":9001,\"surname\":\"Müller\",\"forname\":\"Ada\"," +
|
||||
"\"date\":20260902,\"subject\":\"MA\",\"categoryId\":3,\"reason\":\"Material\"," +
|
||||
"\"text\":\"Buch vergessen\"}]}"),
|
||||
@@ -147,25 +156,18 @@ public sealed class WebUntisClientTests
|
||||
Json("{\"result\":{}}"));
|
||||
var client = CreateClient(handler);
|
||||
|
||||
var report = await client.GetStudentAbsencesAsync(9001, 20260801, 20270731,
|
||||
CancellationToken.None);
|
||||
var entries = await client.GetClassRegisterEntriesAsync(17, 20260801, 20270731,
|
||||
CancellationToken.None);
|
||||
var categories = await client.GetClassRegisterCategoriesAsync(CancellationToken.None);
|
||||
var groups = await client.GetClassRegisterCategoryGroupsAsync(CancellationToken.None);
|
||||
|
||||
var absence = Assert.Single(report.Absences);
|
||||
Assert.Equal(45, report.AbsentMinutes);
|
||||
Assert.Equal("entschuldigt", absence.ExcuseStatus);
|
||||
Assert.Equal(7, Assert.Single(absence.TeacherIds));
|
||||
var entry = Assert.Single(entries);
|
||||
Assert.Equal("Ada Müller", entry.DisplayName);
|
||||
Assert.Equal("Buch vergessen", entry.Text);
|
||||
Assert.Equal(3, Assert.Single(categories).Id);
|
||||
Assert.Equal("Organisation", Assert.Single(groups).Name);
|
||||
Assert.Contains("\"method\":\"getTimetableWithAbsences\"", handler.Requests[1].Body);
|
||||
Assert.Contains("\"method\":\"getClassregEvents\"", handler.Requests[2].Body);
|
||||
Assert.Contains("\"id\":17,\"type\":5", handler.Requests[2].Body);
|
||||
Assert.Contains("\"method\":\"getClassregEvents\"", handler.Requests[1].Body);
|
||||
Assert.Contains("\"id\":17,\"type\":5", handler.Requests[1].Body);
|
||||
Assert.Single(handler.Requests,
|
||||
request => request.Body.Contains("\"method\":\"authenticate\""));
|
||||
|
||||
@@ -173,6 +175,122 @@ public sealed class WebUntisClientTests
|
||||
Assert.Contains("\"method\":\"logout\"", handler.Requests[^1].Body);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetLessonAbsencesAsync_LoestTeacherIdAusDerEigenenSessionAufUndTypisiertDasErgebnis()
|
||||
{
|
||||
var csv = Encoding.UTF8.GetBytes(
|
||||
"Schüler*innen\tDatum\tFehlstd.\tUnentsch. Fehlstd.\tFehlmin.\tUnentsch. Fehlmin.\tZeit\t" +
|
||||
"Abwesenheitsgrund\tENr\tErledigt\tAbwesenheit zählt\r\n" +
|
||||
"Erika Mustermann\t09.12.25\t1\t1\t45\t45\t09:40-10:25\tKrank\t(185120)\t09.12.25\ttrue\r\n");
|
||||
var handler = new QueueHandler(
|
||||
Json("{\"result\":{\"sessionId\":\"s\",\"personId\":89}}"),
|
||||
Json("{\"data\":{\"finished\":true,\"error\":false," +
|
||||
"\"reportParams\":\"get=rpt1.tmp&name=AbsencePerLesson&format=csv\"}}"),
|
||||
new HttpResponseMessage(HttpStatusCode.OK) { Content = new ByteArrayContent(csv) },
|
||||
Json("{\"result\":{}}"));
|
||||
var client = CreateClient(handler);
|
||||
|
||||
var absences = await client.GetLessonAbsencesAsync(38262, 20250811, 20260710, CancellationToken.None);
|
||||
|
||||
var absence = Assert.Single(absences);
|
||||
Assert.Equal("Erika Mustermann", absence.StudentName);
|
||||
Assert.Equal(20251209, absence.Date);
|
||||
Assert.Equal(185120, absence.ExternKey);
|
||||
Assert.True(absence.ExternKeyInParentheses);
|
||||
Assert.Equal(940, absence.StartTime);
|
||||
Assert.True(absence.Counts);
|
||||
Assert.Contains("reports.do?name=AbsencePerLesson", handler.Requests[1].Uri);
|
||||
Assert.Contains("lsid=38262", handler.Requests[1].Uri);
|
||||
Assert.Contains("teacherId=89", handler.Requests[1].Uri);
|
||||
Assert.Contains("rpt_sd=20250811", handler.Requests[1].Uri);
|
||||
Assert.Contains("rpt_ed=20260710", handler.Requests[1].Uri);
|
||||
Assert.EndsWith("reports.do?get=rpt1.tmp&name=AbsencePerLesson&format=csv", handler.Requests[2].Uri);
|
||||
|
||||
await client.DisposeAsync();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetClassRegisterEventsReportAsync_BautDenBerichtsAbrufAufUndTypisiertDasErgebnis()
|
||||
{
|
||||
var csv = Encoding.UTF8.GetBytes(
|
||||
"Klasse\tDatum\tFach\tName\tBenutzer\tEintragskategorie\tKategoriegruppe\tText\r\n" +
|
||||
"6a\t24.08.26\tNAT\tMuster Max\thedtrich\tMitarb. über Erwart.\tPositiv\tArbeitet gut mit.\r\n");
|
||||
var handler = new QueueHandler(
|
||||
Json("{\"result\":{\"sessionId\":\"s\",\"personId\":89}}"),
|
||||
Json("{\"data\":{\"finished\":true,\"error\":false," +
|
||||
"\"reportParams\":\"get=rpt2.tmp&name=ClassregEventPerStudent&format=csv\"}}"),
|
||||
new HttpResponseMessage(HttpStatusCode.OK) { Content = new ByteArrayContent(csv) },
|
||||
Json("{\"result\":{}}"));
|
||||
var client = CreateClient(handler);
|
||||
|
||||
var report = await client.GetClassRegisterEventsReportAsync(20260824, 20260828, CancellationToken.None);
|
||||
|
||||
var entry = Assert.Single(report);
|
||||
Assert.Equal("6a", entry.ClassName);
|
||||
Assert.Equal(20260824, entry.Date);
|
||||
Assert.Equal("NAT", entry.Subject);
|
||||
Assert.Equal("Muster Max", entry.StudentName);
|
||||
Assert.Equal("hedtrich", entry.TeacherUsername);
|
||||
Assert.Equal("Mitarb. über Erwart.", entry.CategoryName);
|
||||
Assert.Equal("Positiv", entry.CategoryGroup);
|
||||
Assert.Equal("Arbeitet gut mit.", entry.Text);
|
||||
Assert.Contains("reports.do?name=ClassregEventPerStudent", handler.Requests[1].Uri);
|
||||
Assert.Contains("klasseOrStudentgroupId=-1", handler.Requests[1].Uri);
|
||||
Assert.Contains("studentId=-1", handler.Requests[1].Uri);
|
||||
Assert.Contains("rpt_sd=20260824", handler.Requests[1].Uri);
|
||||
Assert.Contains("rpt_ed=20260828", handler.Requests[1].Uri);
|
||||
Assert.EndsWith("reports.do?get=rpt2.tmp&name=ClassregEventPerStudent&format=csv", handler.Requests[2].Uri);
|
||||
|
||||
await client.DisposeAsync();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetClassAbsencesAsync_BautDenBerichtsAbrufAufUndTypisiertDasErgebnis()
|
||||
{
|
||||
// Kopfzeile "Text" kommt zweimal vor (Spalte 3 leer, Spalte 12 die eigentliche Tagesnotiz) -
|
||||
// deckt ab, dass die spätere Spalte beim Parsen die frühere überschreibt (siehe Parser-Kommentar).
|
||||
var csv = Encoding.UTF8.GetBytes(
|
||||
"Schüler*innen\tExterne Id\tText\tKlasse\tDatum\tWochentag\tFehlstd.\tFehlmin.\tLehrkraft\tFach\t" +
|
||||
"Abwesenheitsgrund\tText\tENr\tErledigt\tAbwesenheit zählt\tEntschuldigungstext\tStundennr.\tStatus\tFehltage\r\n" +
|
||||
"Muster Max\t12345\t\t6a\t24.08.26\tMo.\t1\t45\tHED\tChe\tAbsent\tKrank gemeldet\t555\t24.08.26\ttrue\t\t3\tnicht entsch.\t1\r\n");
|
||||
var handler = new QueueHandler(
|
||||
Json("{\"result\":{\"sessionId\":\"s\",\"personId\":89}}"),
|
||||
Json("{\"data\":{\"finished\":true,\"error\":false," +
|
||||
"\"reportParams\":\"get=rpt3.tmp&name=AbsencePerStudent&format=csv\"}}"),
|
||||
new HttpResponseMessage(HttpStatusCode.OK) { Content = new ByteArrayContent(csv) },
|
||||
Json("{\"result\":{}}"));
|
||||
var client = CreateClient(handler);
|
||||
|
||||
var entries = await client.GetClassAbsencesAsync("KL874", 20260824, 20260828, CancellationToken.None);
|
||||
|
||||
var entry = Assert.Single(entries);
|
||||
Assert.Equal("Muster Max", entry.StudentName);
|
||||
Assert.Equal(12345, entry.ExternKey);
|
||||
Assert.Equal("6a", entry.ClassName);
|
||||
Assert.Equal(20260824, entry.Date);
|
||||
Assert.Equal(1, entry.AbsentPeriods);
|
||||
Assert.Equal(45, entry.AbsentMinutes);
|
||||
Assert.Equal("HED", entry.TeacherUsernames);
|
||||
Assert.Equal("Che", entry.Subject);
|
||||
Assert.Equal("Absent", entry.AbsenceReason);
|
||||
Assert.Equal("Krank gemeldet", entry.Note);
|
||||
Assert.Equal(555, entry.EntryId);
|
||||
Assert.Equal("24.08.26", entry.HandledOn);
|
||||
Assert.True(entry.Counts);
|
||||
Assert.Null(entry.ExcuseNote);
|
||||
Assert.Equal(3, entry.PeriodNumber);
|
||||
Assert.Equal("nicht entsch.", entry.Status);
|
||||
Assert.True(entry.CountsAsFullDay);
|
||||
Assert.Contains("reports.do?name=AbsencePerStudent", handler.Requests[1].Uri);
|
||||
Assert.Contains("klasseOrStudentgroupId=KL874", handler.Requests[1].Uri);
|
||||
Assert.Contains("studentId=-1", handler.Requests[1].Uri);
|
||||
Assert.Contains("rpt_sd=20260824", handler.Requests[1].Uri);
|
||||
Assert.Contains("rpt_ed=20260828", handler.Requests[1].Uri);
|
||||
Assert.EndsWith("reports.do?get=rpt3.tmp&name=AbsencePerStudent&format=csv", handler.Requests[2].Uri);
|
||||
|
||||
await client.DisposeAsync();
|
||||
}
|
||||
|
||||
private static WebUntisClient CreateClient(HttpMessageHandler handler) => new(
|
||||
new HttpClient(handler),
|
||||
new WebUntisOptions
|
||||
@@ -207,5 +325,33 @@ public sealed class WebUntisClientTests
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class HangingLogoutHandler : HttpMessageHandler
|
||||
{
|
||||
public bool LogoutWasCancelled { get; private set; }
|
||||
|
||||
protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var body = request.Content is null
|
||||
? ""
|
||||
: await request.Content.ReadAsStringAsync(cancellationToken);
|
||||
if (body.Contains("\"method\":\"authenticate\""))
|
||||
return Json("{\"result\":{\"sessionId\":\"s\"}}");
|
||||
if (body.Contains("\"method\":\"getSchoolyears\""))
|
||||
return Json("{\"result\":[]}");
|
||||
|
||||
try
|
||||
{
|
||||
await Task.Delay(Timeout.InfiniteTimeSpan, cancellationToken);
|
||||
throw new InvalidOperationException("Der simulierte Logout darf nicht regulär enden.");
|
||||
}
|
||||
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
LogoutWasCancelled = true;
|
||||
throw;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private sealed record CapturedRequest(string Uri, string Body, string Cookie);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
using Xunit;
|
||||
using LehrerApp.WebUntis;
|
||||
|
||||
namespace LehrerApp.WebUntis.Tests;
|
||||
|
||||
public sealed class WebUntisLessonAbsenceParserTests
|
||||
{
|
||||
[Fact]
|
||||
public void Parse_UebernimmtAlleFelderUndZweistelligesJahr()
|
||||
{
|
||||
const string report = "\uFEFFSchüler*innen\tDatum\tFehlstd.\tUnentsch. Fehlstd.\tFehlmin.\tUnentsch. Fehlmin.\tZeit\tAbwesenheitsgrund\tENr\tErledigt\tAbwesenheit zählt\r\n" +
|
||||
"Erika Mustermann\t09.12.25\t1\t1\t45\t45\t09:40-10:25\tKrank\t(185120)\t09.12.25\ttrue\r\n";
|
||||
|
||||
var absence = Assert.Single(WebUntisLessonAbsenceParser.Parse(report));
|
||||
|
||||
Assert.Equal("Erika Mustermann", absence.StudentName);
|
||||
Assert.Equal(20251209, absence.Date);
|
||||
Assert.Equal(1, absence.AbsentPeriods);
|
||||
Assert.Equal(1, absence.UnexcusedAbsentPeriods);
|
||||
Assert.Equal(45, absence.AbsentMinutes);
|
||||
Assert.Equal(45, absence.UnexcusedAbsentMinutes);
|
||||
Assert.Equal(940, absence.StartTime);
|
||||
Assert.Equal(1025, absence.EndTime);
|
||||
Assert.Equal("Krank", absence.Reason);
|
||||
Assert.Equal(185120, absence.ExternKey);
|
||||
Assert.True(absence.ExternKeyInParentheses);
|
||||
Assert.Equal("09.12.25", absence.HandledOn);
|
||||
Assert.True(absence.Counts);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_AkzeptiertExterneSchuelerkennungOhneKlammern()
|
||||
{
|
||||
const string report = "Schüler*innen\tDatum\tFehlstd.\tUnentsch. Fehlstd.\tFehlmin.\tUnentsch. Fehlmin.\tZeit\tAbwesenheitsgrund\tENr\tErledigt\tAbwesenheit zählt\r\n" +
|
||||
"Max Muster\t27.01.26\t1\t0\t45\t0\t10:25-11:10\t\t178156\t\tfalse\r\n";
|
||||
|
||||
var absence = Assert.Single(WebUntisLessonAbsenceParser.Parse(report));
|
||||
|
||||
Assert.Equal(178156, absence.ExternKey);
|
||||
Assert.False(absence.ExternKeyInParentheses);
|
||||
Assert.Null(absence.Reason);
|
||||
Assert.Null(absence.HandledOn);
|
||||
Assert.False(absence.Counts);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_LehntUngueltigesDatumAb()
|
||||
{
|
||||
const string report = "Schüler*innen\tDatum\tFehlstd.\tUnentsch. Fehlstd.\tFehlmin.\tUnentsch. Fehlmin.\tZeit\tAbwesenheitsgrund\tENr\tErledigt\tAbwesenheit zählt\r\n" +
|
||||
"Max Muster\tkein-datum\t1\t0\t45\t0\t10:25-11:10\t\t178156\t\tfalse\r\n";
|
||||
|
||||
Assert.Throws<InvalidDataException>(() => WebUntisLessonAbsenceParser.Parse(report));
|
||||
}
|
||||
}
|
||||
@@ -37,6 +37,33 @@ public sealed class WebUntisStudentReportParserTests
|
||||
Assert.Equal("MUS\"T", student.DisplayName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_AkzeptiertIsoDatumswerteOhneTrennzeichen()
|
||||
{
|
||||
const string report = "id\texternKey\tklasse.name\tbirthDate\tentryDate\texitDate\r\n" +
|
||||
"17\t9001\t10a\t20100203\t20210801\t20270731\r\n";
|
||||
|
||||
var student = Assert.Single(WebUntisStudentReportParser.Parse(report));
|
||||
|
||||
Assert.Equal(20100203, student.BirthDate);
|
||||
Assert.Equal("20100203", student.BirthDateRaw);
|
||||
Assert.Equal(20210801, student.EntryDate);
|
||||
Assert.Equal(20270731, student.ExitDate);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_ErlaubtFehlendenExternKey()
|
||||
{
|
||||
// Reale Schuldaten enthalten Schüler*innen ohne gepflegten externen Schlüssel (z.B. frisch
|
||||
// angelegt) - das darf nicht den Abruf der gesamten Klassenliste zum Absturz bringen.
|
||||
const string report = "id\texternKey\tklasse.name\r\n17\t\t10a\r\n";
|
||||
|
||||
var student = Assert.Single(WebUntisStudentReportParser.Parse(report));
|
||||
|
||||
Assert.Equal(17, student.UntisId);
|
||||
Assert.Null(student.ExternKey);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_LehntUngueltigePflichtIdAb()
|
||||
{
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
using System.Text;
|
||||
|
||||
namespace LehrerApp.WebUntis;
|
||||
|
||||
internal static class TabSeparatedTextReader
|
||||
{
|
||||
public static List<List<string>> ParseRows(string content, char separator)
|
||||
{
|
||||
var rows = new List<List<string>>();
|
||||
var row = new List<string>();
|
||||
var field = new StringBuilder();
|
||||
var inQuotes = false;
|
||||
|
||||
for (var index = 0; index < content.Length; index++)
|
||||
{
|
||||
var character = content[index];
|
||||
if (character == '"')
|
||||
{
|
||||
if (inQuotes && index + 1 < content.Length && content[index + 1] == '"')
|
||||
{
|
||||
field.Append('"');
|
||||
index++;
|
||||
}
|
||||
else
|
||||
{
|
||||
inQuotes = !inQuotes;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!inQuotes && character == separator)
|
||||
{
|
||||
row.Add(field.ToString());
|
||||
field.Clear();
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!inQuotes && character == '\n')
|
||||
{
|
||||
row.Add(field.ToString());
|
||||
rows.Add(row);
|
||||
row = [];
|
||||
field.Clear();
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!inQuotes && character == '\r') continue;
|
||||
field.Append(character);
|
||||
}
|
||||
|
||||
row.Add(field.ToString());
|
||||
if (row.Count > 1 || !string.IsNullOrWhiteSpace(row[0])) rows.Add(row);
|
||||
return rows;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
using System.Globalization;
|
||||
|
||||
namespace LehrerApp.WebUntis;
|
||||
|
||||
public static class WebUntisClassAbsenceParser
|
||||
{
|
||||
public static IReadOnlyList<UntisClassAbsenceEntry> Parse(string content)
|
||||
{
|
||||
var rows = TabSeparatedTextReader.ParseRows(content, '\t');
|
||||
if (rows.Count == 0) return [];
|
||||
|
||||
var headers = rows[0]
|
||||
.Select((header, index) => (index == 0 ? header.TrimStart('\uFEFF') : header).Trim())
|
||||
.ToArray();
|
||||
var result = new List<UntisClassAbsenceEntry>();
|
||||
|
||||
foreach (var row in rows.Skip(1))
|
||||
{
|
||||
if (row.All(string.IsNullOrWhiteSpace)) continue;
|
||||
|
||||
// Der Header "Text" kommt zweimal vor (Spalte 3, in echten Daten immer leer; die
|
||||
// eigentliche freitextliche Tagesnotiz steht in der späteren Spalte). Der spätere Index
|
||||
// überschreibt beim Befüllen den früheren, was hier zufällig den gewünschten Wert übrig
|
||||
// lässt - bewusst kein Verlass darauf, dass Spalte 3 auch künftig immer leer bleibt.
|
||||
var values = new Dictionary<string, string?>(StringComparer.Ordinal);
|
||||
for (var index = 0; index < headers.Length; index++)
|
||||
values[headers[index]] = index < row.Count ? row[index].Trim() : null;
|
||||
|
||||
result.Add(new UntisClassAbsenceEntry(
|
||||
Get(values, "Schüler*innen")?.Trim() ?? "",
|
||||
OptionalInt(Get(values, "Externe Id")),
|
||||
Get(values, "Klasse")?.Trim() ?? "",
|
||||
GermanShortDate(Get(values, "Datum")) ?? throw new InvalidDataException(
|
||||
"Ungültiges Datum in Spalte \"Datum\"."),
|
||||
RequiredInt(Get(values, "Fehlstd."), "Fehlstd."),
|
||||
RequiredInt(Get(values, "Fehlmin."), "Fehlmin."),
|
||||
Optional(Get(values, "Lehrkraft")),
|
||||
Optional(Get(values, "Fach")),
|
||||
Optional(Get(values, "Abwesenheitsgrund")),
|
||||
Optional(Get(values, "Text")),
|
||||
OptionalInt(Get(values, "ENr")),
|
||||
Optional(Get(values, "Erledigt")),
|
||||
ParseBoolean(Get(values, "Abwesenheit zählt")),
|
||||
Optional(Get(values, "Entschuldigungstext")),
|
||||
OptionalInt(Get(values, "Stundennr.")),
|
||||
Optional(Get(values, "Status")),
|
||||
Get(values, "Fehltage")?.Trim() == "1"));
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private static bool ParseBoolean(string? value) =>
|
||||
string.Equals(value?.Trim(), "true", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
private static string? Get(IReadOnlyDictionary<string, string?> values, string key) =>
|
||||
values.TryGetValue(key, out var value) ? value : null;
|
||||
|
||||
private static string? Optional(string? value) =>
|
||||
string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
||||
|
||||
private static int? OptionalInt(string? value) =>
|
||||
int.TryParse(value?.Trim(), NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsed)
|
||||
? parsed
|
||||
: null;
|
||||
|
||||
private static int RequiredInt(string? value, string field) =>
|
||||
int.TryParse(value?.Trim(), NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsed)
|
||||
? parsed
|
||||
: throw new InvalidDataException($"Ungültige Zahl in Spalte \"{field}\".");
|
||||
|
||||
private static int? GermanShortDate(string? value)
|
||||
{
|
||||
if (!DateOnly.TryParseExact(value?.Trim(), "dd.MM.yy", CultureInfo.InvariantCulture,
|
||||
DateTimeStyles.None, out var date))
|
||||
return null;
|
||||
return date.Year * 10_000 + date.Month * 100 + date.Day;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
using System.Globalization;
|
||||
|
||||
namespace LehrerApp.WebUntis;
|
||||
|
||||
public static class WebUntisClassRegisterEventReportParser
|
||||
{
|
||||
public static IReadOnlyList<UntisClassRegisterEventReportEntry> Parse(string content)
|
||||
{
|
||||
var rows = TabSeparatedTextReader.ParseRows(content, '\t');
|
||||
if (rows.Count == 0) return [];
|
||||
|
||||
var headers = rows[0]
|
||||
.Select((header, index) => (index == 0 ? header.TrimStart('\uFEFF') : header).Trim())
|
||||
.ToArray();
|
||||
var result = new List<UntisClassRegisterEventReportEntry>();
|
||||
|
||||
foreach (var row in rows.Skip(1))
|
||||
{
|
||||
if (row.All(string.IsNullOrWhiteSpace)) continue;
|
||||
|
||||
var values = new Dictionary<string, string?>(StringComparer.Ordinal);
|
||||
for (var index = 0; index < headers.Length; index++)
|
||||
values[headers[index]] = index < row.Count ? row[index].Trim() : null;
|
||||
|
||||
result.Add(new UntisClassRegisterEventReportEntry(
|
||||
Get(values, "Klasse")?.Trim() ?? "",
|
||||
GermanShortDate(Get(values, "Datum")) ?? throw new InvalidDataException(
|
||||
"Ungültiges Datum in Spalte \"Datum\"."),
|
||||
Optional(Get(values, "Fach")),
|
||||
Get(values, "Name")?.Trim() ?? "",
|
||||
Optional(Get(values, "Benutzer")),
|
||||
Optional(Get(values, "Eintragskategorie")),
|
||||
Optional(Get(values, "Kategoriegruppe")),
|
||||
Optional(Get(values, "Text"))));
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private static string? Get(IReadOnlyDictionary<string, string?> values, string key) =>
|
||||
values.TryGetValue(key, out var value) ? value : null;
|
||||
|
||||
private static string? Optional(string? value) =>
|
||||
string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
||||
|
||||
private static int? GermanShortDate(string? value)
|
||||
{
|
||||
if (!DateOnly.TryParseExact(value?.Trim(), "dd.MM.yy", CultureInfo.InvariantCulture,
|
||||
DateTimeStyles.None, out var date))
|
||||
return null;
|
||||
return date.Year * 10_000 + date.Month * 100 + date.Day;
|
||||
}
|
||||
}
|
||||
@@ -9,11 +9,13 @@ public sealed class WebUntisClient : IAsyncDisposable
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
|
||||
private static readonly UTF8Encoding StrictUtf8 = new(false, true);
|
||||
private static readonly TimeSpan LogoutTimeout = TimeSpan.FromSeconds(2);
|
||||
private readonly HttpClient _http;
|
||||
private readonly WebUntisOptions _options;
|
||||
private readonly SemaphoreSlim _sessionGate = new(1, 1);
|
||||
private readonly Timer _sessionExpiryTimer;
|
||||
private string? _sessionId;
|
||||
private int? _myTeacherUntisId;
|
||||
private DateTimeOffset _sessionExpiresAt;
|
||||
private int _activeRequests;
|
||||
private bool _disposed;
|
||||
@@ -113,7 +115,8 @@ public sealed class WebUntisClient : IAsyncDisposable
|
||||
public Task<UntisStudentReport> GetStudentReportAsync(string? classNameFilter,
|
||||
CancellationToken cancellationToken) => WithSessionAsync(async sessionId =>
|
||||
{
|
||||
var reportData = await RequestReportAsync(sessionId, cancellationToken)
|
||||
var reportData = await RequestReportAsync(sessionId,
|
||||
"name=Student&format=csv&klasseId=-1&studentsForDate=true&context=klasseId", cancellationToken)
|
||||
?? await PollReportAsync(sessionId, cancellationToken);
|
||||
var reportText = await FetchReportTextAsync(sessionId, reportData, cancellationToken);
|
||||
var allStudents = WebUntisStudentReportParser.Parse(reportText);
|
||||
@@ -125,6 +128,82 @@ public sealed class WebUntisClient : IAsyncDisposable
|
||||
return new UntisStudentReport(students.Count, normalizedFilter, students);
|
||||
}, cancellationToken);
|
||||
|
||||
// Undokumentierter interner Bericht der WebUntis-WebApp (Unterricht -> Berichte zum Unterricht ->
|
||||
// "Fehlzeiten pro Unterricht pro Schüler*in"). lessonId entspricht der lsid, die pro Schuljahr neu
|
||||
// vergeben wird (WebUntis-interne Unterrichtsnummer) und deshalb pro Lerngruppe gepflegt werden muss;
|
||||
// eine JSON-RPC-Methode dafür existiert nicht. teacherId ist dagegen immer die eigene Lehrkraft-Kennung
|
||||
// aus der Session — der Bericht zeigt ohnehin nur eigenen Unterricht, auch bei Doppelbesetzung.
|
||||
public Task<IReadOnlyList<UntisLessonAbsence>> GetLessonAbsencesAsync(int lessonId,
|
||||
int startDate, int endDate, CancellationToken cancellationToken) => WithSessionAsync(async sessionId =>
|
||||
{
|
||||
var teacherId = _myTeacherUntisId
|
||||
?? throw new WebUntisException("WebUntis hat keine Lehrer-Kennung (personId) für diese Sitzung geliefert.");
|
||||
var query = "name=AbsencePerLesson&format=csv" +
|
||||
$"&lsid={lessonId}&lessonId={lessonId}&teacherId={teacherId}&reportElementType=2" +
|
||||
"&_withoutPageBreaks=on&_empty=on&_usingMarkNames=on" +
|
||||
$"&rpt_sd={startDate}&rpt_ed={endDate}&rpt_syid=-1&rpt_drdtype=CUSTOM";
|
||||
var reportData = await RequestReportAsync(sessionId, query, cancellationToken)
|
||||
?? await PollReportAsync(sessionId, cancellationToken);
|
||||
var reportText = await FetchReportTextAsync(sessionId, reportData, cancellationToken);
|
||||
return WebUntisLessonAbsenceParser.Parse(reportText);
|
||||
}, cancellationToken);
|
||||
|
||||
// Undokumentierter interner Bericht der WebUntis-WebApp für Klassenbucheinträge ("-alle-"-Bereich,
|
||||
// damit auch Einträge zu fremden Klassen aus dem eigenen Unterricht mit dabei sind - die
|
||||
// Oberfläche bietet sonst nur "eigene Klasse" oder "-alle-" an). klasseOrStudentgroupId/
|
||||
// studentId=-1 entspricht "-alle-" und wird lokal auf eigene Lerngruppen gefiltert.
|
||||
// Query-Parameter 1:1 aus einem echten Browser-Request übernommen (bis auf das nicht benötigte
|
||||
// selectedDateRange/_csrf, siehe GetLessonAbsencesAsync). Anders als AbsencePerLesson liefert
|
||||
// dieser Bericht keine externe Schülerkennung, nur den Namen (siehe UntisClassRegisterEventReportEntry).
|
||||
public Task<IReadOnlyList<UntisClassRegisterEventReportEntry>> GetClassRegisterEventsReportAsync(
|
||||
int startDate, int endDate, CancellationToken cancellationToken) => WithSessionAsync(async sessionId =>
|
||||
{
|
||||
var query = "name=ClassregEventPerStudent&format=csv&klasseOrStudentgroupId=-1&studentId=-1" +
|
||||
"&calendarChange=0&withAbsences=true&_withAbsences=on&withLateness=true&_withLateness=on" +
|
||||
"&excludeNotCountingAbsences=true&_excludeNotCountingAbsences=on&_allStudents=on" +
|
||||
"&absStudGroup=1&studExcuseStatusId=-1&_withoutPageBreaksAbsPerStudent=on&absClassGroup=1" +
|
||||
"&classExcuseStatusId=-1&_absClassSorted=on&_separateAbsentDays=on&_filterAbsencesKlasse=on" +
|
||||
"&absSubjectGroup=7&absSubjectId=-1&absSubjectGroupGroup=7&subjectGroupId=-1&_totalDays=on" +
|
||||
"&absentPeriodsLimit=16&reportWeeks=4&dayLimit=3&excuseGroup=1&_groupPerWeek=on" +
|
||||
"&_excuseStatusAll=on&studEventReasonId=-1&classEventReasonId=-1&_evntClassSorted=on" +
|
||||
"&_withoutPageBreaks=on&withStudentAbsences=true&_withStudentAbsences=on" +
|
||||
"&examinationTypeId=-1&teachingMethodId=-1&examTypeId=-1&_usingMarkNames=on" +
|
||||
$"&rpt_sd={startDate}&rpt_ed={endDate}&rpt_syid=-1&rpt_drdtype=CUSTOM";
|
||||
var reportData = await RequestReportAsync(sessionId, query, cancellationToken)
|
||||
?? await PollReportAsync(sessionId, cancellationToken);
|
||||
var reportText = await FetchReportTextAsync(sessionId, reportData, cancellationToken);
|
||||
return WebUntisClassRegisterEventReportParser.Parse(reportText);
|
||||
}, cancellationToken);
|
||||
|
||||
// Undokumentierter Bericht "AbsencePerStudent" (Unterricht -> Berichte -> Fehlzeiten der ganzen
|
||||
// Klasse), Gegenstück zu GetLessonAbsencesAsync für das "Klassenlehrer"-Feature: deckt alle
|
||||
// Fächer der Klasse ab, nicht nur den eigenen Unterricht. Adressiert die Klasse über
|
||||
// klasseOrStudentgroupId = "KL" + getKlassen-ID (Auflösung siehe
|
||||
// WebUntisIntegrationService.ResolveClassIdAsync) statt einer lsid. Query-Parameter 1:1 aus
|
||||
// einem echten Browser-Request übernommen (identisch mit GetClassRegisterEventsReportAsync bis
|
||||
// auf name/klasseOrStudentgroupId/studentId; _csrf/selectedDateRange dort schon als nicht nötig
|
||||
// bekannt).
|
||||
public Task<IReadOnlyList<UntisClassAbsenceEntry>> GetClassAbsencesAsync(string klasseOrStudentgroupId,
|
||||
int startDate, int endDate, CancellationToken cancellationToken) => WithSessionAsync(async sessionId =>
|
||||
{
|
||||
var query = "name=AbsencePerStudent&format=csv" +
|
||||
$"&klasseOrStudentgroupId={Uri.EscapeDataString(klasseOrStudentgroupId)}&studentId=-1" +
|
||||
"&calendarChange=0&withAbsences=true&_withAbsences=on&withLateness=true&_withLateness=on" +
|
||||
"&excludeNotCountingAbsences=true&_excludeNotCountingAbsences=on&_allStudents=on" +
|
||||
"&absStudGroup=1&studExcuseStatusId=-1&_withoutPageBreaksAbsPerStudent=on&absClassGroup=1" +
|
||||
"&classExcuseStatusId=-1&_absClassSorted=on&_separateAbsentDays=on&_filterAbsencesKlasse=on" +
|
||||
"&absSubjectGroup=7&absSubjectId=-1&absSubjectGroupGroup=7&subjectGroupId=-1&_totalDays=on" +
|
||||
"&absentPeriodsLimit=16&reportWeeks=4&dayLimit=3&excuseGroup=1&_groupPerWeek=on" +
|
||||
"&_excuseStatusAll=on&studEventReasonId=-1&classEventReasonId=-1&_evntClassSorted=on" +
|
||||
"&_withoutPageBreaks=on&withStudentAbsences=true&_withStudentAbsences=on" +
|
||||
"&examinationTypeId=-1&teachingMethodId=-1&examTypeId=-1&_usingMarkNames=on" +
|
||||
$"&rpt_sd={startDate}&rpt_ed={endDate}&rpt_syid=-1&rpt_drdtype=CUSTOM";
|
||||
var reportData = await RequestReportAsync(sessionId, query, cancellationToken)
|
||||
?? await PollReportAsync(sessionId, cancellationToken);
|
||||
var reportText = await FetchReportTextAsync(sessionId, reportData, cancellationToken);
|
||||
return WebUntisClassAbsenceParser.Parse(reportText);
|
||||
}, cancellationToken);
|
||||
|
||||
public Task<IReadOnlyList<UntisSubstitution>> GetSubstitutionsAsync(int startDate, int endDate,
|
||||
int? departmentId, CancellationToken cancellationToken) => WithSessionAsync(async sessionId =>
|
||||
{
|
||||
@@ -177,39 +256,6 @@ public sealed class WebUntisClient : IAsyncDisposable
|
||||
Entities(entry, "ro"), entry.Clone())).ToList();
|
||||
}, cancellationToken);
|
||||
|
||||
public Task<UntisStudentAbsenceReport> GetStudentAbsencesAsync(int studentKey, int startDate, int endDate,
|
||||
CancellationToken cancellationToken) => WithSessionAsync(async sessionId =>
|
||||
{
|
||||
var result = await RpcAsync("getTimetableWithAbsences", new
|
||||
{
|
||||
options = new { startDate, endDate },
|
||||
}, sessionId, cancellationToken);
|
||||
if (!TryProperty(result, "periodsWithAbsences", out var periods) || periods.ValueKind != JsonValueKind.Array)
|
||||
throw new WebUntisException("WebUntis hat keine gültigen Fehlzeiten-Daten geliefert.");
|
||||
|
||||
var absences = periods.EnumerateArray()
|
||||
.Where(entry => OptionalInt(entry, "studentId") == studentKey)
|
||||
.Select(entry => new UntisStudentAbsence(
|
||||
RequiredInt(entry, "studentId"),
|
||||
RequiredInt(entry, "date"),
|
||||
RequiredInt(entry, "startTime"),
|
||||
RequiredInt(entry, "endTime"),
|
||||
OptionalInt(entry, "absentTime") ?? 0,
|
||||
OptionalBoolean(entry, "checked"),
|
||||
OptionalString(entry, "absenceReason"),
|
||||
OptionalString(entry, "excuseStatus"),
|
||||
OptionalInt(entry, "subjectId"),
|
||||
IntArray(entry, "teacherIds"),
|
||||
OptionalString(entry, "studentGroup"),
|
||||
entry.Clone()))
|
||||
.OrderBy(entry => entry.Date)
|
||||
.ThenBy(entry => entry.StartTime)
|
||||
.ToList();
|
||||
|
||||
return new UntisStudentAbsenceReport(studentKey, startDate, endDate, absences.Count,
|
||||
absences.Sum(entry => entry.AbsentMinutes), absences);
|
||||
}, cancellationToken);
|
||||
|
||||
public Task<IReadOnlyList<UntisClassRegisterEntry>> GetClassRegisterEntriesAsync(int studentId,
|
||||
int startDate, int endDate, CancellationToken cancellationToken) => WithSessionAsync(async sessionId =>
|
||||
{
|
||||
@@ -290,6 +336,7 @@ public sealed class WebUntisClient : IAsyncDisposable
|
||||
$"WebUntis-Login fehlgeschlagen: {configuration.Host} hat für die Schulkennung " +
|
||||
$"„{configuration.School}“ keine Sitzung geliefert. Bitte insbesondere Server und " +
|
||||
"Schulkennung mit der WebUntis-Anmeldeseite vergleichen.");
|
||||
_myTeacherUntisId = OptionalInt(result, "personId");
|
||||
}
|
||||
|
||||
_activeRequests++;
|
||||
@@ -346,14 +393,15 @@ public sealed class WebUntisClient : IAsyncDisposable
|
||||
|
||||
private async Task TryLogoutAsync(string sessionId)
|
||||
{
|
||||
try { await RpcAsync("logout", new { }, sessionId, CancellationToken.None); }
|
||||
using var timeout = new CancellationTokenSource(LogoutTimeout);
|
||||
try { await RpcAsync("logout", new { }, sessionId, timeout.Token).ConfigureAwait(false); }
|
||||
catch { /* Ein fehlgeschlagener Logout darf Abrufe und Shutdown nicht fehlschlagen lassen. */ }
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
string? sessionId;
|
||||
await _sessionGate.WaitAsync();
|
||||
await _sessionGate.WaitAsync().ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
if (_disposed) return;
|
||||
@@ -368,8 +416,8 @@ public sealed class WebUntisClient : IAsyncDisposable
|
||||
|
||||
// Außerhalb des Gates warten: ein bereits laufender Timer-Callback könnte selbst gerade
|
||||
// auf dieses Gate warten und würde sonst den Shutdown blockieren.
|
||||
await _sessionExpiryTimer.DisposeAsync();
|
||||
if (sessionId is not null) await TryLogoutAsync(sessionId);
|
||||
await _sessionExpiryTimer.DisposeAsync().ConfigureAwait(false);
|
||||
if (sessionId is not null) await TryLogoutAsync(sessionId).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private TimeSpan SessionIdleTimeout =>
|
||||
@@ -400,10 +448,10 @@ public sealed class WebUntisClient : IAsyncDisposable
|
||||
return result.Clone();
|
||||
}
|
||||
|
||||
private async Task<ReportData?> RequestReportAsync(string sessionId, CancellationToken cancellationToken)
|
||||
private async Task<ReportData?> RequestReportAsync(string sessionId, string query,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var uri = $"https://{GetConfiguration().Host}/WebUntis/reports.do" +
|
||||
"?name=Student&format=csv&klasseId=-1&studentsForDate=true&context=klasseId";
|
||||
var uri = $"https://{GetConfiguration().Host}/WebUntis/reports.do?{query}";
|
||||
using var request = ReportRequest(uri, sessionId, acceptJson: true);
|
||||
using var response = await SendAsync(request, TimeSpan.FromSeconds(20), cancellationToken);
|
||||
var payload = await ReadJsonAsync(response, cancellationToken);
|
||||
@@ -417,7 +465,7 @@ public sealed class WebUntisClient : IAsyncDisposable
|
||||
}
|
||||
|
||||
if (!TryProperty(payload, "data", out var data) || data.ValueKind != JsonValueKind.Object ||
|
||||
(TryProperty(data, "error", out var reportError) && reportError.ValueKind is not JsonValueKind.Null and not JsonValueKind.Undefined))
|
||||
(TryProperty(data, "error", out var reportError) && HasErrorValue(reportError)))
|
||||
throw new WebUntisException($"Report-Anfrage fehlgeschlagen: {ErrorMessage(payload, response)}");
|
||||
if (!OptionalBoolean(data, "finished")) return null;
|
||||
return ReportData.From(data);
|
||||
@@ -646,6 +694,19 @@ public sealed class WebUntisClient : IAsyncDisposable
|
||||
(string.Equals(value.GetString(), "true", StringComparison.OrdinalIgnoreCase) || value.GetString() == "1");
|
||||
}
|
||||
|
||||
private static bool HasErrorValue(JsonElement value) => value.ValueKind switch
|
||||
{
|
||||
JsonValueKind.Null or JsonValueKind.Undefined or JsonValueKind.False => false,
|
||||
JsonValueKind.True => true,
|
||||
JsonValueKind.String => !string.IsNullOrWhiteSpace(value.GetString()) &&
|
||||
!string.Equals(value.GetString(), "false", StringComparison.OrdinalIgnoreCase) &&
|
||||
value.GetString() != "0",
|
||||
JsonValueKind.Number => !value.TryGetInt32(out var number) || number != 0,
|
||||
JsonValueKind.Array => value.GetArrayLength() > 0,
|
||||
JsonValueKind.Object => value.EnumerateObject().Any(),
|
||||
_ => true,
|
||||
};
|
||||
|
||||
private static string ErrorMessage(JsonElement payload, HttpResponseMessage response)
|
||||
{
|
||||
if (payload.ValueKind == JsonValueKind.String) return payload.GetString() ?? "Unerwartete Antwort.";
|
||||
@@ -654,8 +715,25 @@ public sealed class WebUntisClient : IAsyncDisposable
|
||||
if (TryProperty(payload, "error", out var error) && OptionalString(error, "message") is { } errorMessage)
|
||||
return errorMessage;
|
||||
if (OptionalString(payload, "message") is { } message) return message;
|
||||
if (TryProperty(payload, "data", out var data) && OptionalString(data, "message") is { } dataMessage)
|
||||
return dataMessage;
|
||||
if (TryProperty(payload, "data", out var data))
|
||||
{
|
||||
if (OptionalString(data, "message") is { } dataMessage) return dataMessage;
|
||||
if (data.ValueKind == JsonValueKind.Object)
|
||||
{
|
||||
var dataFields = data.EnumerateObject().Select(property => property.Name).Take(8).ToArray();
|
||||
if (dataFields.Length > 0)
|
||||
return $"HTTP {(int)response.StatusCode}; unerwartete data-Felder: " +
|
||||
$"{string.Join(", ", dataFields)}.";
|
||||
}
|
||||
else
|
||||
{
|
||||
return $"HTTP {(int)response.StatusCode}; data hat den Typ {data.ValueKind}.";
|
||||
}
|
||||
}
|
||||
var fields = payload.EnumerateObject().Select(property => property.Name).Take(8).ToArray();
|
||||
return fields.Length == 0
|
||||
? $"HTTP {(int)response.StatusCode}; leeres JSON-Objekt."
|
||||
: $"HTTP {(int)response.StatusCode}; unerwartete JSON-Felder: {string.Join(", ", fields)}.";
|
||||
}
|
||||
return response.IsSuccessStatusCode ? "Unerwartete JSON-Antwort." : $"HTTP {(int)response.StatusCode}";
|
||||
}
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
using System.Globalization;
|
||||
|
||||
namespace LehrerApp.WebUntis;
|
||||
|
||||
public static class WebUntisLessonAbsenceParser
|
||||
{
|
||||
public static IReadOnlyList<UntisLessonAbsence> Parse(string content)
|
||||
{
|
||||
var rows = TabSeparatedTextReader.ParseRows(content, '\t');
|
||||
if (rows.Count == 0) return [];
|
||||
|
||||
var headers = rows[0]
|
||||
.Select((header, index) => (index == 0 ? header.TrimStart('\uFEFF') : header).Trim())
|
||||
.ToArray();
|
||||
var result = new List<UntisLessonAbsence>();
|
||||
|
||||
foreach (var row in rows.Skip(1))
|
||||
{
|
||||
if (row.All(string.IsNullOrWhiteSpace)) continue;
|
||||
|
||||
var values = new Dictionary<string, string?>(StringComparer.Ordinal);
|
||||
for (var index = 0; index < headers.Length; index++)
|
||||
values[headers[index]] = index < row.Count ? row[index].Trim() : null;
|
||||
|
||||
var (start, end) = ParseTimeRange(Get(values, "Zeit"));
|
||||
var (externKey, inParentheses) = ParseExternKey(Get(values, "ENr"));
|
||||
result.Add(new UntisLessonAbsence(
|
||||
Get(values, "Schüler*innen")?.Trim() ?? "",
|
||||
GermanShortDate(Get(values, "Datum")) ?? throw new InvalidDataException(
|
||||
"Ungültiges Datum in Spalte \"Datum\"."),
|
||||
RequiredInt(Get(values, "Fehlstd."), "Fehlstd."),
|
||||
RequiredInt(Get(values, "Unentsch. Fehlstd."), "Unentsch. Fehlstd."),
|
||||
RequiredInt(Get(values, "Fehlmin."), "Fehlmin."),
|
||||
RequiredInt(Get(values, "Unentsch. Fehlmin."), "Unentsch. Fehlmin."),
|
||||
start,
|
||||
end,
|
||||
Optional(Get(values, "Abwesenheitsgrund")),
|
||||
externKey,
|
||||
inParentheses,
|
||||
Optional(Get(values, "Erledigt")),
|
||||
ParseBoolean(Get(values, "Abwesenheit zählt"))));
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private static (int? Start, int? End) ParseTimeRange(string? value)
|
||||
{
|
||||
var parts = value?.Split('-', 2);
|
||||
return parts is { Length: 2 } ? (ParseClock(parts[0]), ParseClock(parts[1])) : (null, null);
|
||||
}
|
||||
|
||||
private static int? ParseClock(string value) =>
|
||||
TimeOnly.TryParseExact(value.Trim(), "HH:mm", CultureInfo.InvariantCulture, DateTimeStyles.None,
|
||||
out var time)
|
||||
? time.Hour * 100 + time.Minute
|
||||
: null;
|
||||
|
||||
// WebUntis klammert die externe Schülerkennung in dieser Spalte teils ein, teils nicht - die
|
||||
// Klammer selbst ist das Signal (siehe Statuszuordnung im Desktop-Client), nicht nur Formatierung,
|
||||
// und wird deshalb hier separat zurückgegeben statt beim Parsen verworfen zu werden.
|
||||
private static (int? Key, bool InParentheses) ParseExternKey(string? value)
|
||||
{
|
||||
var trimmed = value?.Trim();
|
||||
if (string.IsNullOrEmpty(trimmed)) return (null, false);
|
||||
var inParentheses = trimmed.StartsWith('(') && trimmed.EndsWith(')');
|
||||
var digits = trimmed.Trim('(', ')');
|
||||
return int.TryParse(digits, NumberStyles.Integer, CultureInfo.InvariantCulture, out var key)
|
||||
? (key, inParentheses)
|
||||
: (null, false);
|
||||
}
|
||||
|
||||
private static bool ParseBoolean(string? value) =>
|
||||
string.Equals(value?.Trim(), "true", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
private static string? Get(IReadOnlyDictionary<string, string?> values, string key) =>
|
||||
values.TryGetValue(key, out var value) ? value : null;
|
||||
|
||||
private static string? Optional(string? value) =>
|
||||
string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
||||
|
||||
private static int RequiredInt(string? value, string field) =>
|
||||
int.TryParse(value?.Trim(), NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsed)
|
||||
? parsed
|
||||
: throw new InvalidDataException($"Ungültige Zahl in Spalte \"{field}\".");
|
||||
|
||||
private static int? GermanShortDate(string? value)
|
||||
{
|
||||
if (!DateOnly.TryParseExact(value?.Trim(), "dd.MM.yy", CultureInfo.InvariantCulture,
|
||||
DateTimeStyles.None, out var date))
|
||||
return null;
|
||||
return date.Year * 10_000 + date.Month * 100 + date.Day;
|
||||
}
|
||||
}
|
||||
@@ -41,9 +41,12 @@ public sealed record UntisStudentAddress(
|
||||
string? PostCode,
|
||||
string? Street);
|
||||
|
||||
// ExternKey optional: reale Schuldaten enthalten Schüler*innen (z.B. frisch angelegt/übernommen)
|
||||
// ohne gepflegten externen Schlüssel - ein fehlender Wert bei einer/einem soll nicht den Abruf der
|
||||
// gesamten Klassenliste zum Absturz bringen (siehe WebUntisStudentReportParser).
|
||||
public sealed record UntisStudent(
|
||||
int UntisId,
|
||||
int ExternKey,
|
||||
int? ExternKey,
|
||||
string ClassName,
|
||||
string? Name,
|
||||
string? LongName,
|
||||
@@ -132,27 +135,59 @@ public sealed record UntisTimetablePeriod(
|
||||
IReadOnlyList<UntisEntity> Rooms,
|
||||
JsonElement Raw);
|
||||
|
||||
public sealed record UntisStudentAbsence(
|
||||
int StudentKey,
|
||||
public sealed record UntisLessonAbsence(
|
||||
string StudentName,
|
||||
int Date,
|
||||
int StartTime,
|
||||
int EndTime,
|
||||
int AbsentPeriods,
|
||||
int UnexcusedAbsentPeriods,
|
||||
int AbsentMinutes,
|
||||
bool Checked,
|
||||
string? AbsenceReason,
|
||||
string? ExcuseStatus,
|
||||
int? SubjectId,
|
||||
IReadOnlyList<int> TeacherIds,
|
||||
string? StudentGroup,
|
||||
JsonElement Raw);
|
||||
int UnexcusedAbsentMinutes,
|
||||
int? StartTime,
|
||||
int? EndTime,
|
||||
string? Reason,
|
||||
int? ExternKey,
|
||||
bool ExternKeyInParentheses,
|
||||
string? HandledOn,
|
||||
bool Counts);
|
||||
|
||||
public sealed record UntisStudentAbsenceReport(
|
||||
int StudentKey,
|
||||
int StartDate,
|
||||
int EndDate,
|
||||
int EntryCount,
|
||||
// Undokumentierter Bericht "AbsencePerStudent" (Fehlzeiten der ganzen Klasse über alle Fächer,
|
||||
// nicht nur den eigenen Unterricht wie AbsencePerLesson) - eigenes Spaltenschema, eine Zeile pro
|
||||
// Fehlstunde. Anders als bei AbsencePerLesson liefert "Status" hier einen Klartext-Wert direkt
|
||||
// (im Sample nur "nicht entsch." beobachtet, andere Werte nicht bestätigt - Rohwert durchreichen).
|
||||
// "Fehltage" ist der auf eine ganze Zahl abgerundete Fehlzeit-Tagesanteil (Nutzer-Angabe, nicht
|
||||
// durch WebUntis-Dokumentation bestätigt): bleibt 0, bis auch die letzte Unterrichtsstunde des
|
||||
// Tages als versäumt gilt - erst dann springt er auf 1. Pro Schüler*in/Tag trägt deshalb genau die
|
||||
// Zeile der letzten Stunde des Tages den Wert 1, alle vorherigen Stunden dieses Tages bleiben 0.
|
||||
public sealed record UntisClassAbsenceEntry(
|
||||
string StudentName,
|
||||
int? ExternKey,
|
||||
string ClassName,
|
||||
int Date,
|
||||
int AbsentPeriods,
|
||||
int AbsentMinutes,
|
||||
IReadOnlyList<UntisStudentAbsence> Absences);
|
||||
string? TeacherUsernames,
|
||||
string? Subject,
|
||||
string? AbsenceReason,
|
||||
string? Note,
|
||||
int? EntryId,
|
||||
string? HandledOn,
|
||||
bool Counts,
|
||||
string? ExcuseNote,
|
||||
int? PeriodNumber,
|
||||
string? Status,
|
||||
bool CountsAsFullDay);
|
||||
|
||||
// "-alle-"-Bericht ohne externe Schülerkennung - anders als bei AbsencePerLesson bleibt für den
|
||||
// Abgleich mit lokalen Schülern nur der Name (siehe Namens-Fallback im Desktop-Abgleich).
|
||||
public sealed record UntisClassRegisterEventReportEntry(
|
||||
string ClassName,
|
||||
int Date,
|
||||
string? Subject,
|
||||
string StudentName,
|
||||
string? TeacherUsername,
|
||||
string? CategoryName,
|
||||
string? CategoryGroup,
|
||||
string? Text);
|
||||
|
||||
public sealed record UntisClassRegisterEntry(
|
||||
int? StudentKey,
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
using System.Globalization;
|
||||
using System.Text;
|
||||
|
||||
namespace LehrerApp.WebUntis;
|
||||
|
||||
@@ -7,7 +6,7 @@ public static class WebUntisStudentReportParser
|
||||
{
|
||||
public static IReadOnlyList<UntisStudent> Parse(string content)
|
||||
{
|
||||
var rows = ParseSeparatedRows(content, '\t');
|
||||
var rows = TabSeparatedTextReader.ParseRows(content, '\t');
|
||||
if (rows.Count == 0) return [];
|
||||
|
||||
var headers = rows[0]
|
||||
@@ -24,7 +23,7 @@ public static class WebUntisStudentReportParser
|
||||
values[headers[index]] = index < row.Count ? row[index].Trim() : null;
|
||||
|
||||
var untisId = RequiredInt(Get(values, "id"), "id");
|
||||
var externalKey = RequiredInt(Get(values, "externKey"), "externKey");
|
||||
var externalKey = OptionalInt(Get(values, "externKey"));
|
||||
var name = Optional(Get(values, "name"));
|
||||
var lastName = Optional(Get(values, "longName"));
|
||||
var firstName = Optional(Get(values, "foreName"));
|
||||
@@ -63,55 +62,6 @@ public static class WebUntisStudentReportParser
|
||||
return result;
|
||||
}
|
||||
|
||||
private static List<List<string>> ParseSeparatedRows(string content, char separator)
|
||||
{
|
||||
var rows = new List<List<string>>();
|
||||
var row = new List<string>();
|
||||
var field = new StringBuilder();
|
||||
var inQuotes = false;
|
||||
|
||||
for (var index = 0; index < content.Length; index++)
|
||||
{
|
||||
var character = content[index];
|
||||
if (character == '"')
|
||||
{
|
||||
if (inQuotes && index + 1 < content.Length && content[index + 1] == '"')
|
||||
{
|
||||
field.Append('"');
|
||||
index++;
|
||||
}
|
||||
else
|
||||
{
|
||||
inQuotes = !inQuotes;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!inQuotes && character == separator)
|
||||
{
|
||||
row.Add(field.ToString());
|
||||
field.Clear();
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!inQuotes && character == '\n')
|
||||
{
|
||||
row.Add(field.ToString());
|
||||
rows.Add(row);
|
||||
row = [];
|
||||
field.Clear();
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!inQuotes && character == '\r') continue;
|
||||
field.Append(character);
|
||||
}
|
||||
|
||||
row.Add(field.ToString());
|
||||
if (row.Count > 1 || !string.IsNullOrWhiteSpace(row[0])) rows.Add(row);
|
||||
return rows;
|
||||
}
|
||||
|
||||
private static string? Get(IReadOnlyDictionary<string, string?> values, string key) =>
|
||||
values.TryGetValue(key, out var value) ? value : null;
|
||||
|
||||
@@ -123,9 +73,14 @@ public static class WebUntisStudentReportParser
|
||||
? parsed
|
||||
: throw new InvalidDataException($"Ungültige Zahl in Spalte \"{field}\".");
|
||||
|
||||
private static int? OptionalInt(string? value) =>
|
||||
int.TryParse(value?.Trim(), NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsed)
|
||||
? parsed
|
||||
: null;
|
||||
|
||||
private static int? GermanDate(string? value)
|
||||
{
|
||||
if (!DateOnly.TryParseExact(value?.Trim(), "dd.MM.yyyy", CultureInfo.InvariantCulture,
|
||||
if (!DateOnly.TryParseExact(value?.Trim(), ["dd.MM.yyyy", "yyyyMMdd"], CultureInfo.InvariantCulture,
|
||||
DateTimeStyles.None, out var date))
|
||||
return null;
|
||||
return date.Year * 10_000 + date.Month * 100 + date.Day;
|
||||
|
||||
@@ -96,6 +96,48 @@ pragmatisch über "Abwesend" bei den übrigen Schülern statt über eine eigene
|
||||
- [x] **1.5.4** Export der Auswertung — CSV-Export direkt im Dialog; inzwischen auf die gemeinsame
|
||||
Export-Infrastruktur aus 11.1 und den zentralen `CsvBuilder` umgestellt.
|
||||
|
||||
### 1.6 Klausuren-Hauptseite (gruppenübergreifend)
|
||||
|
||||
Der Sidebar-Punkt "Klausuren" (`NavItem.Exams`) war bisher ein `PlaceholderViewModel` ohne
|
||||
Funktion — die vollständige Verwaltung existierte nur innerhalb einer Gruppe (1.1–1.5). Nutzer
|
||||
unterrichten oft mehrere Kurse gleichzeitig und brauchen einen gruppenübergreifenden Überblick.
|
||||
|
||||
- [x] **1.6.1** Neue Seite `ExamsOverviewViewModel`/`ExamsOverviewView` ersetzt den Placeholder.
|
||||
Detailbereich zur ausgewählten Klausur oben (ca. 55–60 % Höhe), darunter eine scrollbare
|
||||
Liste aller Klausuren des aktuellen Schuljahres — bewusst kein separates Menü, sondern
|
||||
Bestandteil derselben Seite (Nutzerwunsch: "keine eigene Hauptseite in dem Ausmaß").
|
||||
- [x] **1.6.2** Sortierung nach unsichtbarem Prioritäts-Score statt nach Datum — neuer Service
|
||||
`ExamPriorityService` (`LehrerApp.Core/Services/ExamPriorityService.cs`) leitet Status
|
||||
(`ExamListStatus`: Planned/AwaitingCorrection/CorrectionInProgress/CorrectionStuck/
|
||||
AwaitingReturn/Returned) und Score aus dem vorhandenen `Exam.Status` plus dem
|
||||
Korrekturfortschritt ab — kein neuer, manuell zu pflegender "Korrektur läuft"-Status.
|
||||
Der Korrekturfortschritt selbst kommt aus `ExamCorrectionCounter` (ebenda), das auch
|
||||
`DashboardViewModel.LoadOpenCorrections` jetzt nutzt (vorher zwei Kopien derselben Zählung).
|
||||
Farbcodierung wiederverwendet die validierten `AppStatus{Ok,Info,Warning,Danger}Brush`
|
||||
aus `Styles/SemanticBrushes.axaml` (Klassenlehrer-Bereich) statt neuer Hex-Werte.
|
||||
- [x] **1.6.3** Nutzer-Feedback zum "hängt fest"-Fall: ein einzelner Schüler, der nie
|
||||
nachschreibt, blockierte sonst dauerhaft die live abgeleitete Korrektur-Erkennung, weil
|
||||
niemand eine leere Zeile anfasst, für die es nichts einzutragen gibt. Zwei Bausteine statt
|
||||
eines manuellen Override: (a) neuer Button "Rest als abwesend markieren" im
|
||||
`ExamGradingDialog` setzt bei allen noch unberührten Zeilen "Abwesend" in einem Klick;
|
||||
(b) bleibt eine Klausur trotzdem länger als `ExamPriorityService.StuckAfterDays` (21 Tage)
|
||||
in Bearbeitung hängen, fällt sie aus der dringenden Zone in eine ruhige
|
||||
`CorrectionStuck`-Einstufung statt dauerhaft oben zu kleben.
|
||||
- [x] **1.6.4** Parallelkurse (gleiche Arbeit, mehrere Kurse — z.B. zwei Chemie-Kurse im selben
|
||||
Jahrgang): neues optionales Feld `Exam.SharedExamGroupId`, beim Duplizieren (1.1.4) auf die
|
||||
Id der Ursprungsklausur gesetzt. Die Klausuren-Hauptseite zeigt dafür einen Kurs-Umschalter
|
||||
im Detailbereich; Zeilen mit Verknüpfung tragen ein 🔗-Symbol in der Liste.
|
||||
- [x] **1.6.5** Zwei neue nullable Datumsfelder `Exam.ApprovalGrantedAt`/`AnnouncedAt`
|
||||
("Genehmigung erteilt"/"Ankündigung bei Schülern gemacht", Label verhandelbar) — bewusst
|
||||
keine weiteren Workflow-Statusstufen, weil sie zeitlich oft parallel statt sequenziell zum
|
||||
Hauptstatus laufen. Als anklickbare Zeilen im Detailbereich, die das Datum auf heute setzen
|
||||
bzw. wieder löschen.
|
||||
- [x] **1.6.6** Detailbereich zeigt je nach Status Korrekturfortschritt (Balken + Zahl) oder
|
||||
Notenspiegel (Balken je Note + Durchschnitt, gleiche Berechnung wie 1.5.1 über
|
||||
`GradingService`/`GradeBarItem`) — kein Kompetenz-Breakdown in dieser Iteration. Button
|
||||
"Zum Kurs" springt in den Klausuren-Tab der jeweiligen Gruppe (dort wie gehabt Zugriff auf
|
||||
Kompetenzen-Tab, 8.2/8.3).
|
||||
|
||||
---
|
||||
|
||||
## 2. Noten & Zeugnisnoten
|
||||
@@ -1242,6 +1284,294 @@ Zugangsdaten; Schülerdaten, Fehlzeiten und der unverschlüsselte CSV-Report pas
|
||||
eine lokale `ParticipationSession` dieses Kurses existiert, können als offen, entschuldigt oder
|
||||
unentschuldigt übernommen werden; fremde/ganztägige Abwesenheiten erzeugen keine lokale Stunde.
|
||||
|
||||
**Nachtrag zu 4.3, Fehlzeiten je Unterricht (August 2026):** Der ursprüngliche Fehlzeitenabgleich
|
||||
rief `getTimetableWithAbsences` ohne Element auf und bekam damit den kompletten Lehrer-Stundenplan
|
||||
zurück (einmal pro Kursmitglied, siehe damalige Ineffizienz-Korrektur) — das erfordert mehr
|
||||
WebUntis-Rechte, als ein Lehrkraft-Konto standardmäßig hat, und schlug deshalb in der Praxis fehl.
|
||||
Ersetzt durch den undokumentierten internen Bericht "Fehlzeiten pro Unterricht pro Schüler\*in"
|
||||
(`reports.do?name=AbsencePerLesson`, per Netzwerk-Mitschnitt aus der WebUntis-WebApp
|
||||
reverse-engineered, da die offizielle Reports-API eine Partner-Freigabe braucht), der nur den
|
||||
eigenen Unterricht abfragt und deshalb mit den regulären Lehrkraft-Rechten funktioniert.
|
||||
- `LearningGroup.WebUntisLessonId` (die WebUntis-interne Unterrichtsnummer/lsid) muss dafür pro
|
||||
Lerngruppe von Hand hinterlegt werden — sie wird von WebUntis pro Schuljahr neu vergeben (kein
|
||||
Auslesen über JSON-RPC möglich) und wird deshalb beim Schuljahreswechsel (`GroupRolloverService`)
|
||||
bewusst nicht in die Folgegruppe übernommen.
|
||||
- Die Lehrkraft-Kennung (`teacherId`) kommt aus `personId` der `authenticate`-Antwort und muss nicht
|
||||
gepflegt werden — der Bericht zeigt ohnehin nur eigenen Unterricht, auch bei Doppelbesetzung.
|
||||
- Der Bericht liefert keinen Klartext-Entschuldigungsstatus, nur Minutenwerte, ein
|
||||
Bearbeitet-Datum und die externe Schülerkennung. Statuszuordnung (Heuristik, an echten Daten der
|
||||
Schule korrigiert, nicht durch WebUntis-Dokumentation bestätigt): Abwesenheitsgrund enthält
|
||||
"entlassen" → vorzeitig entlassen, unabhängig von der Dauer; sonst Fehlminuten unter 45 (eine volle
|
||||
Stunde) → Verspätung/Teilverlust; sonst ohne Bearbeitet-Datum → ausstehend; sonst externe
|
||||
Schülerkennung in Klammern → unentschuldigt, ohne Klammern → entschuldigt (das Vorzeichen der
|
||||
Kennung war die ursprüngliche, falsche Annahme — es gibt kein Minuszeichen in den echten Daten).
|
||||
- Der alte, jetzt entfernte Fehlzeitenabgleich (`WebUntisAbsenceComparisonViewModel`) sowie der
|
||||
zugehörige Client-Aufruf `getTimetableWithAbsences` wurden ersatzlos gestrichen statt behoben,
|
||||
da der neue Bericht denselben Zweck ohne die Rechteproblematik erfüllt.
|
||||
- Namensabgleich als Fallback, wenn ein Schüler keine externe WebUntis-Kennung hat: WebUntis liefert
|
||||
Namen teils als "Nachname Vorname", teils uneinheitlich — deshalb werden lokal beide Reihenfolgen
|
||||
registriert, aber nur falls eindeutig innerhalb der Kandidaten; sonst manuelle Zuordnung per Auswahl
|
||||
in der Vergleichszeile, statt zu raten.
|
||||
- Zweiter, undokumentierter Bericht `reports.do?name=ClassregEventPerStudent` liefert Klassenbuch-
|
||||
einträge (Klasse, Datum, Fach, Name, Benutzer, Eintragskategorie, Kategoriegruppe, Text) im
|
||||
"-alle-"-Bereich, da die Oberfläche sonst nur "eigene Klasse" oder "-alle-" anbietet
|
||||
(`WebUntisClient.GetClassRegisterEventsReportAsync`, `WebUntisClassRegisterEventReportParser`).
|
||||
Auch hier keine externe Schülerkennung, nur der Name — Namensabgleich soll deshalb zusätzlich auf
|
||||
Klasse+Fach aus der Zeile scopen (→ genau eine lokale Lerngruppe), nicht global über alle Schüler.
|
||||
Abgleichsdialog (Diff gegen `Documentation`, Übernehmen in beide Richtungen) noch nicht gebaut.
|
||||
- [x] **"Klassenlehrer"-Feature — Datenschicht (August 2026):** Zwei WebUntis-Berichte liefern, was
|
||||
für einen Klassenlehrer-Überblick fehlte. `WebUntisIntegrationService.GetForeignClassRegisterEventsAsync`
|
||||
ist das Spiegelbild von `GetOwnClassRegisterEventsAsync`: derselbe "-alle-"-Rohbericht
|
||||
(`ClassregEventPerStudent`), aber Einträge anderer Lehrkräfte (`Benutzer` ≠ eigener Login) statt
|
||||
der eigenen, auf eine Klasse eingeschränkt — kein neuer Bericht nötig.
|
||||
`GetClassAbsencesAsync`/`reports.do?name=AbsencePerStudent&klasseOrStudentgroupId=KL<id>&studentId=-1`
|
||||
(per Netzwerk-Mitschnitt gefunden) liefert Fehlzeiten der ganzen Klasse über alle Fächer, nicht nur
|
||||
eigenen Unterricht. `klasseOrStudentgroupId` ist `"KL"` + die `getKlassen`-interne Klassen-ID
|
||||
(dasselbe Element-Kürzel-Schema wie `kl`/`te`/`su`/`ro` bei anderen Berichten) —
|
||||
`WebUntisIntegrationService.ResolveClassIdAsync` löst sie pro Aufruf über
|
||||
`getSchoolyears`+`getKlassen` und den Klassennamen auf. Das CSV-Format ist **nicht** identisch mit
|
||||
`AbsencePerLesson` (ursprüngliche Vermutung anhand echter Daten widerlegt) — eigenes Spaltenschema,
|
||||
eine Zeile pro Fehlstunde, eigener Parser `WebUntisClassAbsenceParser`/Modell
|
||||
`UntisClassAbsenceEntry`. Anders als `AbsencePerLesson` liefert `Status` hier einen Klartext-Wert
|
||||
direkt (im Sample nur `"nicht entsch."` beobachtet, andere Werte nicht bestätigt), es braucht also
|
||||
keine Status-Heuristik. Der Header `Text` kommt zweimal vor (eine immer-leere Spalte, dann die
|
||||
eigentliche Tagesnotiz) — der Dictionary-per-Header-Parse-Stil überschreibt das zufällig richtig,
|
||||
im Parser kommentiert statt stillschweigend vorausgesetzt. `Fehltage` (0/1, `CountsAsFullDay`) ist
|
||||
laut Nutzer-Angabe der auf eine ganze Zahl abgerundete Fehlzeit-Tagesanteil: bleibt 0, bis auch die
|
||||
letzte Unterrichtsstunde des Tages als versäumt gilt, erst deren Zeile trägt dann den Wert 1.
|
||||
`ClassAbsenceDaySummaryRow.GroupByStudentAndDay` fasst die Fehlstunden pro Schüler*in/Tag zusammen
|
||||
(Fächer/Status/Gründe als distinkte Listen) statt eine Zeile pro Fehlstunde zu zeigen.
|
||||
- [x] **"Klassenlehrer"-Feature — Neuausrichtung (August 2026):** Die erste Umsetzung hing das
|
||||
Feature an ein `LearningGroup.IsClassTeacher`-Flag mit Tab in der Gruppendetailansicht.
|
||||
Nutzer-Feedback: konzeptionell falsch — Klassenlehrer ist man für eine ganze Klasse, unabhängig
|
||||
vom eigenen Unterricht (die Checkbox saß z. B. auch an "SOL"/"Klassenrat"), und die Übersicht wird
|
||||
gerade an Tagen gebraucht, an denen man selbst keinen Unterricht mit der Klasse hat.
|
||||
`LearningGroup.IsClassTeacher` wieder entfernt. Stattdessen:
|
||||
- **Klassenauswahl in den Einstellungen** statt an einer Gruppe:
|
||||
`WebUntisSettingsService.HomeroomClassUntisId`/`HomeroomClassName` (unverschlüsselt, gleiches
|
||||
Muster wie `TeacherUntisId`), Auswahl im "Untis-Einbettung"-Tab über den bestehenden
|
||||
`WebUntisClassSelectionViewModel`/`WebUntisClassSelectionDialog` (bisher nur für den
|
||||
WebUntis-Klassenimport in der Kursansicht genutzt).
|
||||
- **Eigener Top-Level-Bereich** "Klassenlehrer" in der Seitenleiste (`NavItem.ClassTeacher`,
|
||||
`ClassTeacherOverviewViewModel`) statt Tab in einer `LearningGroup`-Detailansicht — sichtbar und
|
||||
ladbar unabhängig davon, ob heute eigener Unterricht mit der Klasse stattfindet. Zeigt bei
|
||||
fehlender Klassenauswahl einen Hinweis mit Sprung in die Einstellungen.
|
||||
- **Zwei Ebenen** (Nutzer-Feedback: "auf die Schnelle" statt Rohdaten-Listen als Erstes):
|
||||
"Übersicht" — kompaktes Roster (`ClassTeacherRosterRow.Build`, Zuordnung primär über `ExternKey`
|
||||
für die Fehlzeiten, Namens-Fallback für den Klassenbuch-Bericht ohne `ExternKey`) mit
|
||||
Ampel-Symbol "keine gemeldete Fehlzeit heute laut WebUntis" (unabhängig von eigener
|
||||
Anwesenheitserfassung) und Badge bei Klassenbucheinträgen anderer Lehrkräfte in den letzten 7
|
||||
Tagen; "Details" — die bisherigen Rohdaten-Listen (jetzt `ClassTeacherDetailsViewModel`/
|
||||
`ClassTeacherDetailsView`, aus `ViewModels/Groups`/`Views/Groups` nach
|
||||
`ViewModels/ClassTeacher`/`Views/ClassTeacher` verschoben, `Initialize(string className)` statt
|
||||
`Initialize(Guid groupId)`), aus der Übersicht per Klick auf eine Zeile gefiltert auf
|
||||
eine/n Schüler*in anspringbar (`ClassTeacherDetailsViewModel.StudentFilter`).
|
||||
**Zurückgestellte Ideen:** Verknüpfung Roster-Zeile ↔ lokaler `Student`/Navigation zum
|
||||
Schülerdetail; ein Detail-Drill-down pro Schüler*in (Heatmap gefehlter Einzelstunden,
|
||||
Verspätung/vorzeitiges Verlassen je Woche); eine KI-gestützte Zusammenfassung/Bewertung der
|
||||
Einträge (pro Schüler*in oder gesamt) mit Namens-Anonymisierung/Synonymen vor dem Versand.
|
||||
**Echter Fehler gefunden und behoben:** Die Übersicht rief für das Roster
|
||||
`WebUntisIntegrationService.GetStudentsAsync` auf (den bestehenden, schon länger genutzten
|
||||
Schülerlisten-Bericht) — `WebUntisStudentReportParser` behandelte die Spalte `externKey` bisher als
|
||||
Pflichtfeld (`RequiredInt`) und ließ den kompletten Abruf mit "Ungültige Zahl in Spalte
|
||||
\"externKey\"" scheitern, sobald auch nur eine/einer der Schüler*innen der Klasse keinen
|
||||
gepflegten externen Schlüssel hat (in der Praxis vorkommend, z. B. frisch angelegt). `externKey`
|
||||
ist jetzt optional (`UntisStudent.ExternKey`/`UntisStudentDto.ExternKey` → `int?`) — betraf auch
|
||||
den bestehenden WebUntis-Klassenimport in der Kursansicht, der das aber schon immer über
|
||||
`FirstNotEmpty` beim Weiterimport toleriert hatte und deshalb nicht auffiel.
|
||||
- [x] **"Klassenlehrer"-Feature — lokaler Cache für Fehlzeiten & Klassenbucheinträge (August 2026):**
|
||||
Nutzer-Feedback: die Berichtszeilen sind starr genug für ein eigenes Datenmodell, Warnungen
|
||||
sollen sofort da sein statt bei jedem Öffnen neu abgerufen zu werden, und — wichtigstes Motiv —
|
||||
derselbe Bericht darf nicht mehrfach pro Stunde abgerufen werden, nur weil die Ansicht mehrfach
|
||||
geöffnet wird (Sorge, bei WebUntis aufzufallen). Neue Modelle `UntisAbsenceCacheEntry`/
|
||||
`UntisClassRegisterCacheEntry` (1:1-Abbildung der bestehenden DTOs, `Date` als `int`/yyyyMMdd wie
|
||||
dort, nicht `DateOnly`, um die LiteDB-`DateTime`-Kind-Tücke zu vermeiden) plus
|
||||
`UntisCacheFetchState` (ein Datensatz je Klasse+Berichtsart: wann das "heiße" Fenster zuletzt
|
||||
aufgefrischt wurde, wie weit die "kalte" Historie lückenlos zurückreicht).
|
||||
**Bewusst nicht synchronisiert** — `UntisAbsenceCacheRepository`/`UntisClassRegisterCacheRepository`/
|
||||
`UntisCacheFetchStateRepository` rufen `db.OnChange` nie auf (Sync ist opt-in pro Repository,
|
||||
siehe `UntisSnapshotRepository`/`AnnualPlanEventRepository` für dasselbe Muster) — jedes Gerät
|
||||
ruft WebUntis ohnehin selbst ab, und die Zeilenzahl wächst übers Schuljahr absichtlich an
|
||||
(gewollte Historie, kein Pruning).
|
||||
Neuer Service `UntisReportCacheService`: festes heißes Fenster `[heute−14, heute]`
|
||||
(unabhängig vom angefragten Bereich), höchstens stündlich automatisch aufgefrischt (dort kann
|
||||
sich der Status noch ändern, z. B. "ausstehend" → "entschuldigt"); alles davor gilt als endgültig
|
||||
und wird, einmal abgerufen, dauerhaft aus dem Cache bedient. Die eigentliche Entscheidungslogik
|
||||
(`UntisReportCacheService.Plan`) ist bewusst als reine, ohne Repositories/HTTP testbare Funktion
|
||||
ausgelagert. `ClassTeacherOverviewViewModel`/`ClassTeacherDetailsViewModel` nutzen den Cache-Service
|
||||
statt direkt `WebUntisIntegrationService`; das "Laden" in den Details respektiert die
|
||||
Stunden-Sperre, ein zusätzlicher Button "Jetzt wirklich neu abrufen" umgeht sie bewusst
|
||||
(`forceRefresh: true`) für den Fall, dass man sicher weiß, dass sich etwas geändert hat.
|
||||
**Zurückgestellt:** Muster-Erkennung/Heuristiken über die gecachten Daten (vom Nutzer als Motiv
|
||||
für den Cache genannt) — erst sinnvoll, wenn genug Historie im Cache liegt.
|
||||
- [x] **Zwei echte Fehler gefunden und behoben (August 2026):**
|
||||
- Gefilterte Listen im Klassenlehrer-Bereich blieben nach Klick auf eine Roster-Zeile leer,
|
||||
obwohl Einträge vorhanden sein sollten. Ursache: WebUntis liefert Schülernamen je nach Bericht
|
||||
in unterschiedlicher Reihenfolge — der Schülerreport baut "Vorname Nachname"
|
||||
(`UntisStudentDto.DisplayName`), Fehlzeiten-/Klassenbuchberichte liefern typischerweise
|
||||
"Nachname Vorname". Der exakte String-Vergleich beim Filtern (`ClassTeacherDetailsViewModel`)
|
||||
schlug deshalb praktisch immer fehl — derselbe Fehler steckte unbemerkt auch schon im
|
||||
Roster-Namensabgleich der Übersicht (`ClassTeacherRosterRow.Build`, Fallback wenn kein
|
||||
`ExternKey` vorliegt), dort blieben Ampel-Symbol und Klassenbuch-Badge also vermutlich fast
|
||||
immer falsch. Neuer reihenfolge-unabhängiger Namensvergleich
|
||||
(`ClassTeacher/UntisNameMatching.NamesMatch`: Namen in Wörter zerlegt, als sortierte Menge
|
||||
verglichen), an beiden Stellen eingesetzt.
|
||||
- Nach dem ersten Klick auf eine Roster-Zeile (Wechsel in die Details) blieb ein zweiter Klick
|
||||
wirkungslos, sobald man zwischendurch manuell zur Übersicht zurückgeklickt hatte — man blieb
|
||||
auf der Übersicht hängen. Ursache: `TabbedPage.SelectedIndex="{Binding ActiveTabIndex}"` ohne
|
||||
`Mode=TwoWay` ist effektiv nur VM→View gebunden; ein manueller Tab-Klick des Nutzers schreibt
|
||||
`ActiveTabIndex` im ViewModel nicht zurück auf 0, wodurch ein erneutes `ActiveTabIndex = 1` aus
|
||||
`ShowDetailsForStudent` denselben Wert setzt und mangels Änderung kein `PropertyChanged`
|
||||
auslöst. Fix: `Mode=TwoWay` explizit ergänzt (`ClassTeacherOverviewView.axaml`). Dasselbe
|
||||
Bindungsmuster ohne `Mode=TwoWay` existiert auch in `GroupDetailView.axaml`/`WorkloadView.axaml`
|
||||
(dortige `OnActiveTabIndexChanged`-Refreshs beim Zurückkehren zu einem Tab greifen bei einem
|
||||
reinen Nutzerklick möglicherweise ebenfalls nicht) — als Hintergrundaufgabe zur Prüfung
|
||||
ausgelagert, nicht Teil dieser Änderung.
|
||||
- Die Klassenlehrer-Übersicht wirkte beim Öffnen spürbar zeitverzögert, obwohl Fehlzeiten und
|
||||
Klassenbuch schon gecacht waren (Nutzer-Feedback: "fühlt sich an wie ein Live-Pull mit
|
||||
CSV-Parsing"). Ursache: genau das passierte tatsächlich — der Schülerreport
|
||||
(`GetStudentsAsync`, die Namen fürs Roster) lief komplett am Cache vorbei live gegen WebUntis,
|
||||
bei jedem einzelnen Öffnen. Neues `UntisStudentRosterCacheEntry`/
|
||||
`UntisStudentRosterCacheRepository` (gleiches "kein db.OnChange"-Prinzip wie die anderen beiden
|
||||
Caches) plus `UntisReportCacheService.GetStudentRosterAsync` — anders als Fehlzeiten/Klassenbuch
|
||||
ohne heißes/kaltes Fenster, da eine Klassenliste keine Historie hat, sondern nur "gilt der
|
||||
letzte Abruf noch als frisch" (dieselbe Stundenschwelle). `ClassTeacherRosterRow.Build` nimmt
|
||||
jetzt `UntisStudentRosterCacheEntry` statt des vollen `UntisStudentDto` entgegen (nur
|
||||
`ExternKey`/`DisplayName` wurden je gebraucht).
|
||||
|
||||
- [x] **"Klassenlehrer"-Feature — Trend & Fehlquote (August 2026):** Zwei Nutzer-Feedback-Punkte
|
||||
auf einmal umgesetzt, weil sie sich denselben Datenabruf teilen.
|
||||
- **Trend als gestapelter Balken statt drei unabhängig skalierten:** `ClassTeacherTrendDay`
|
||||
lieferte bisher `AlertBarWidth`/`UnexcusedBarWidth`/`LateBarWidth`, jede für sich auf dasselbe
|
||||
Maximum normiert — das suggerierte drei unabhängige Größen, obwohl Unentschuldigt und
|
||||
Verspätet Teilmengen von Alerts sind. Jetzt drei überschneidungsfreie Segmente (Unentschuldigt
|
||||
hat Vorrang vor Verspätet, gleiche Priorisierung wie `ClassTeacherRosterRow.AttentionRank`);
|
||||
in XAML als `Border ClipToBounds="True"` mit `CornerRadius` um eine horizontale `StackPanel`
|
||||
aus drei Segmenten gerendert, damit nur die äußeren Kanten abgerundet sind. Wie die
|
||||
Segmentbreiten selbst berechnet werden, hat sich im Nachtrag unten noch einmal geändert —
|
||||
siehe dort.
|
||||
- **Trend über die letzten 7 Schultage statt Kalendertage:** an zwei von sieben Tagen war der
|
||||
Balken bislang systematisch leer, weil `start.AddDays(offset)` Wochenenden mitzählte. Neues
|
||||
`ClassTeacherOverviewViewModel.LastSchoolDays` überspringt Samstag/Sonntag — bewusst ohne
|
||||
Ferienkalender, die App kennt keine Schulferien (gilt auch für die Fehlquote unten).
|
||||
- **Kumulierte Fehlquote seit Schuljahresbeginn je Schüler*in:** der bisherige Heute-Snapshot
|
||||
sagt für Zeugnis/Attestpflicht wenig aus. `ClassTeacherRosterRow` bekommt
|
||||
`YearAbsenceDayCount`/`YearUnexcusedDayCount`/`YearAbsenceRatePercent`
|
||||
(`SchoolDaysElapsed` als Nenner, wieder nur Werktage ohne Ferienkalender) und zeigt sie als
|
||||
dezente zweite Zeile unter dem Tagesstatus, Details im Tooltip. Dafür holt `Load()` die
|
||||
Fehlzeiten jetzt für `[Schuljahresbeginn, heute]` statt nur der letzten 7 Tage — teuer ist das
|
||||
nur beim ersten Öffnen pro Schuljahr, `UntisReportCacheService` cached den "kalten" Bereich
|
||||
danach dauerhaft (siehe Cache-Eintrag oben). Klassenbuch-Abruf bleibt bewusst bei 7 Tagen
|
||||
(für "Eintrag diese Woche"/`RecentClassRegisterCount` reicht das, ein Jahr an fremden
|
||||
Klassenbucheinträgen zu cachen wäre unnötiges Datenvolumen).
|
||||
Neue `SchoolYearService`-Abhängigkeit in `ClassTeacherOverviewViewModel` (Konstruktor-Injection,
|
||||
Service war bereits als Singleton registriert).
|
||||
- [x] **"Klassenlehrer"-Feature — restliche Quick-Wins (August 2026):** Die vier verbliebenen
|
||||
Punkte aus derselben Vorschlagsliste wie oben.
|
||||
- **Balkenbreiten responsiv statt fester Pixelkonstante:** `BuildTrend` rechnete bisher mit
|
||||
einer im ViewModel hart codierten Pixelbreite (`112d`), die mit der tatsächlichen Breite der
|
||||
Seitenspalte in XAML synchron gehalten werden musste. Erster Versuch — `Grid.ColumnDefinitions`
|
||||
per `{Binding}` auf einen "N*,N*,…"-String setzen, wie es die eingebaute XAML-Stringsyntax
|
||||
erlaubt — scheiterte an `AVLN3000` ("Unable to find suitable setter"): `ColumnDefinitions` hat
|
||||
bei kompilierten Bindings (`x:DataType`) keinen normalen Property-Setter, auch nicht über einen
|
||||
`IValueConverter`. Stattdessen liefert `ClassTeacherTrendDay`/`ClassTeacherOverviewViewModel`
|
||||
jetzt Anteile 0…1 (`UnexcusedFraction`/`LateExcusedFraction`/`ExcusedFraction` bzw.
|
||||
`Present-/Late-/ExcusedAbsence-/UnexcusedAbsenceFraction`), und die View multipliziert sie per
|
||||
`MultiBinding` mit der tatsächlich gerenderten Breite einer benannten, unsichtbaren
|
||||
Track-`Border` (`ElementName=…, Path=Bounds.Width`) — neuer
|
||||
[FractionWidthConverter.cs](LehrerApp.Desktop/Converters/FractionWidthConverter.cs)
|
||||
(`IMultiValueConverter`). Bleibt dadurch korrekt, unabhängig davon, wie breit die Seitenspalte
|
||||
gerade ist, ohne dass View und ViewModel einen Pixelwert teilen müssten.
|
||||
- **Tagesüberblick als gestapelter 100-%-Balken statt vier Einzelzeilen:** neue `Grid` mit
|
||||
demselben Track-`Border`/`MultiBinding`/`FractionWidthConverter`-Muster wie beim Trend, direkt
|
||||
über der (weiterhin vorhandenen, jetzt kompakteren) Legende. Die vier Kategorien sind exklusiv
|
||||
und summieren sich exakt zu `StudentCount`, brauchen also anders als beim Trend keine
|
||||
Rest-Kategorie für den Sonderfall "unter dem Maximum".
|
||||
- **`AbsenceTooltip` und `TodayUnexcusedPercent` gebunden:** beide wurden berechnet, aber nie
|
||||
angezeigt. `AbsenceTooltip` hängt jetzt als `ToolTip.Tip` am Avatar-Kreis jeder Roster-Zeile;
|
||||
`TodayUnexcusedPercent` steckt in einem neuen `TodayUnexcusedSummaryLabel` unter der
|
||||
"unentschuldigt"-Kennzahlkarte oben. Die vier vormals ungenutzten Prozent-Properties
|
||||
(`Present-/Late-/ExcusedAbsence-/UnexcusedAbsencePercent`) sind jetzt ebenfalls in
|
||||
Summary-Labels der neuen Tagesüberblick-Legende eingebettet, statt einer eigenen
|
||||
rechtsbündigen Spalte wie zuvor.
|
||||
- **Symbol zusätzlich zur Farbe (Barrierefreiheit):** der Status einer Roster-Zeile hing zuvor
|
||||
an der Farbe der linken 4-px-Leiste plus reinem Text ohne visuellen Vorrang. Neues
|
||||
`ClassTeacherRosterRow.StatusTextWithGlyph` stellt "✕ " (Gefahr) bzw. "△ " (Warnung) voran —
|
||||
dieselben Symbole wie in den Kennzahlkarten oben, damit sich die Bedeutung nicht neu erlernen
|
||||
lässt. `StatusText` selbst bleibt unverändert (bestehende Tests/Bindings), das Symbol ist eine
|
||||
zusätzliche, separat getestete Property.
|
||||
- [x] **"Klassenlehrer"-Feature — Warnung/Gefahr farblich kaum unterscheidbar (August 2026,
|
||||
Nutzer-Feedback):** Amber (`#F59E0B`) und Korallrot (`#FF5A67`) lagen zu nah beieinander,
|
||||
besonders bei Rot-Grün-Sehschwäche. Statt erneut freihändig zu wählen, per
|
||||
`scripts/validate_palette.js` der dataviz-Skill gegen die tatsächlichen Flächen
|
||||
(`AppTrackBackgroundBrush`/`AppCardBackgroundBrush`) und in der Reihenfolge geprüft, in der die
|
||||
Segmente in den gestapelten Balken wirklich aneinanderstoßen (nicht `--pairs all`, das prüft
|
||||
auch Paare, die im Chart nie benachbart sind). Befund bestätigte das Nutzer-Feedback:
|
||||
Warnung/Gefahr lagen im Hellmodus bei ΔE 2,5 (deutan) und sogar nur 11,4 bei normaler
|
||||
Farbwahrnehmung — unter der Zielschwelle von 15, also unabhängig von einer Sehschwäche schwer
|
||||
zu unterscheiden. Neue Werte in
|
||||
[SemanticBrushes.axaml](LehrerApp.Desktop/Styles/SemanticBrushes.axaml) —
|
||||
`good=#008300`, `info=#2A78D6`/`#3987E5`, `warning=#EDA100`/`#C98500`,
|
||||
`critical=#D03B3B` (hell/dunkel) — dieselben Stufen wie die validierte Kategorial-/Statuspalette
|
||||
der dataviz-Skill, bewusst nicht wieder frei gewählt. Jetzt ΔE 10,2–24,4 in allen geprüften
|
||||
Paarungen/Themes. Der Tagesüberblick-Balken musste dafür zusätzlich die Segmentreihenfolge von
|
||||
Ok→Warnung→Info→Gefahr auf **Ok→Info→Warnung→Gefahr** tauschen (Grün und das validierte
|
||||
Dunkel-Gelb liegen unter Protanopie mit ΔE 3,0 selbst zu nah beieinander) — passt nebenbei
|
||||
besser zur tatsächlichen Dringlichkeitsreihenfolge (`ClassTeacherRosterRow.AttentionRank`:
|
||||
Gefahr > Warnung > Info > Ok). Da die Farben über die geteilten `AppStatus*Brush`-Ressourcen
|
||||
laufen, wirkt die Korrektur automatisch überall im Klassenlehrer-Bereich (Kennzahlkarten,
|
||||
Roster-Status, Chips in Fehlzeiten-/Klassenbuchansicht), nicht nur in den beiden Charts.
|
||||
- [x] **"Klassenlehrer"-Feature — vier Feature-Ideen umgesetzt (August 2026):** Aus derselben
|
||||
Vorschlagsliste wie die Quick-Wins oben, auf Nutzerwunsch alle außer dem Elterngesprächs-PDF
|
||||
(dafür fehlt noch ein geschärftes Konzept).
|
||||
- **Offene Entschuldigungen mit Frist:** neue Karte in der Seitenspalte, listet aktuell
|
||||
unentschuldigte Fehltage über den gesamten geladenen Zeitraum (nicht nur heute), sortiert nach
|
||||
Tagen offen absteigend, auf 6 Zeilen gedeckelt ("+N weitere"). `ClassTeacherOpenExcuseRow`
|
||||
markiert Einträge über `DeadlineDays = 3` als überfällig (Richtwert für die häufige
|
||||
"innerhalb von drei Tagen"-Regelung, z.B. § 43 Abs. 2 SchulG NRW — keine rechtlich geprüfte,
|
||||
schulspezifische Frist, bei Bedarf anpassen). Läuft über die ohnehin für die Jahresfehlquote
|
||||
geladenen Daten, kein zusätzlicher Abruf.
|
||||
- **Aggregierte Klassenbuch-Kategorien:** neue Chip-Reihe über der Tabelle im Klassenbuch-Tab
|
||||
("Hausaufgaben fehlen: 12× — 5× Ben Schmidt, 3× Ada Müller" statt nur einer Gesamtzahl).
|
||||
`ClassTeacherCategoryAggregateRow.Build` gruppiert die bereits geladenen `Entries` nach
|
||||
Kategorie, rein clientseitig.
|
||||
- **Erweiterte Mustererkennung — Wochentags-Häufung:** dritte Regel neben den bestehenden
|
||||
(≥2 unentschuldigt/verspätet in 7 Tagen): wenn ≥60 % der Fehltage eines/einer Schülers/-in
|
||||
seit Schuljahresbeginn (Mindeststichprobe 3) auf denselben Wochentag fallen, ein Hinweis
|
||||
("3 von 4 Fehltagen an einem Montag"). Wer schon eine Notiz aus der bestehenden Regel hat,
|
||||
wird ausgelassen, damit niemand doppelt auftaucht. Kernlogik als
|
||||
`ClassTeacherOverviewViewModel.DetectWeekdayPatterns` bewusst statisch/ohne ViewModel-Zustand
|
||||
(gleiches Muster wie `ClassTeacherRosterRow.Build`) und dadurch direkt testbar.
|
||||
- **Aufgabe/Wiedervorlage direkt aus der Zeile:** neuer kleiner "+"-Button neben jeder
|
||||
Roster-Zeile und jeder Zeile in "Offene Entschuldigungen" legt sofort eine `WorkTask`
|
||||
(`Kind = Reminder`, wie 6.1.1, bewusst ohne `GroupId` — Klassenlehrer ist man für die ganze
|
||||
Klasse) mit vorbefülltem Titel "Eltern kontaktieren – {Name}" an, ohne Dialog. **Wichtiger
|
||||
XAML-Fallstrick dabei:** ein `ContextMenu` auf dem Zeilen-`Button` war der naheliegendere
|
||||
erste Ansatz, scheitert aber an `$parent`-Vorfahren-Bindings — das `ContextMenu`-Popup hängt
|
||||
nicht im normalen visuellen Baum unter dem `ItemsControl`, `$parent[ItemsControl]` findet das
|
||||
ViewModel dort nicht. Stattdessen zwei Geschwister-`Button` in einem `Grid` (der bestehende
|
||||
Zeilen-Button bleibt unverändert, der neue "+"-Button daneben hat ganz regulären
|
||||
Vorfahren-Zugriff).
|
||||
- **Verknüpfung mit Noten/Mitarbeit:** bewusst als dritte Regel im "Muster erkannt"-Panel statt
|
||||
als Dauerzusatz in jeder Zeile (Overload vermeiden) — nur bei bereits spürbar erhöhter
|
||||
Jahresfehlquote (≥15 %) UND eindeutig fallendem Mitarbeit-Trend über die letzten bis zu 8
|
||||
Bewertungen (gleiche einfache Erste-Hälfte/Zweite-Hälfte-Heuristik wie
|
||||
`ParticipationGradeRow.ComputeTrend`). Namensabgleich WebUntis→internem `Student` über
|
||||
`ClassTeacherOverviewViewModel.MatchStudent` (öffentlich statt `internal`, wie der Rest der
|
||||
Codebasis es hält, siehe `UntisSyncService`-Kommentar — kein `InternalsVisibleTo`
|
||||
konfiguriert). **Wichtiger Fallstrick:** `Student.FullName` liefert "Nachname, Vorname" mit
|
||||
Komma — ein Abgleich darüber hätte den leerzeichenbasierten Wortvergleich in
|
||||
`UntisNameMatching` verfälscht ("Müller," bliebe eigenes Wort). `MatchStudent` baut den
|
||||
Vergleichsnamen deshalb explizit aus `FirstName`/`LastName` zusammen, nicht aus `FullName`.
|
||||
Mitarbeit-Rohwerte werden bewusst NICHT nach Aspekt-Gewichtung normiert (anders als in
|
||||
`ParticipationGradeViewModels`) — die Aspekt-Konfiguration ist je Lerngruppe, der
|
||||
Klassenlehrer-Bereich kennt aber keine einzelne Gruppe; ein einfacher Rohwert-Durchschnitt ist
|
||||
hier die pragmatischere Näherung als ein Konfigurations-Mismatch zu riskieren.
|
||||
Neue Abhängigkeiten in `ClassTeacherOverviewViewModel`: `IWorkTaskRepository`,
|
||||
`IStudentRepository`, `IParticipationRepository`, `IParticipationSessionRepository` (alle schon
|
||||
als Singleton registriert, nur Konstruktor-Injection ergänzt).
|
||||
**Bewusst zurückgestellt:** Elterngesprächs-Blatt als PDF — Konzept noch nicht geschärft genug.
|
||||
|
||||
### 4.4 Wochen-/Tagesansicht
|
||||
- [x] **4.4.1** Kalenderansicht über alle Gruppen: Woche und Tag — siehe Nachtrag zu 4.3
|
||||
("Heute"-Tab: Tagesliste unten angedockt, gruppenübergreifendes Wochenraster darüber, inkl.
|
||||
@@ -1604,6 +1934,62 @@ folgenden Punkte gehören direkt in `LehrerApp.Desktop`:
|
||||
stattdessen ein maximiertes, normal schließbares Fenster, um kein neues, ungetestetes
|
||||
UI-Verhalten einzuführen.
|
||||
|
||||
**Nachtrag (August 2026, Nutzer-Feedback) — Schnellbewertung und Hausaufgabe direkt im
|
||||
Unterrichtsmodus:** zwei neue Buttons oben ("⚡ Mitarbeit", "⚡ Anwesenheit/Hausaufgabe")
|
||||
öffnen dieselben Schnellbewertungs-Dialoge wie der Mitarbeit-Tab der Gruppe
|
||||
(`ParticipationQuickInputDialog`/`AttendanceHomeworkQuickInputDialog`), ohne den
|
||||
Unterrichtsmodus verlassen zu müssen. Dafür bekommt `TeachingModeViewModel` jetzt eine
|
||||
eigene, per DI aufgelöste `ParticipationTabViewModel`-Instanz (gleiches Muster wie
|
||||
`SeatingPlanTabViewModel`), initialisiert auf die Gruppe und explizit auf die zu dieser
|
||||
Stunde gehörende Sitzung vorselektiert (`SeatingPlan.SelectedSession.Id` nach
|
||||
`SelectOrCreateSessionForLesson`). **Wichtige Falle dabei:** die beiden ViewModels laufen
|
||||
als unabhängige Instanzen mit jeweils eigenem, lokal geladenem Zustand — eine über die
|
||||
Schnellbewertung geänderte Anwesenheit/Hausaufgabe spiegelt sich nicht automatisch in den
|
||||
Sitzplan-Badges (`SeatCellViewModel.AttendanceBadge`/`HomeworkBadge`), da
|
||||
`SeatingPlanTabViewModel.RefreshSeatLessonData()` nur auf eigene Änderungen reagiert. Neue
|
||||
öffentliche Methode `SeatingPlanTabViewModel.ReloadSeatBadgesFromRepository()` (öffentlich
|
||||
statt intern, wie der Rest der Codebasis es hält — kein `InternalsVisibleTo`), von
|
||||
`TeachingModeWindow.axaml.cs` nach jedem geschlossenen Schnellbewertungs-Dialog aufgerufen.
|
||||
|
||||
Zusätzlich: die linke Spalte zeigt jetzt statt der rein lesbaren Hausaufgaben-Anzeige einen
|
||||
interaktiven Block — Hausaufgabe der **letzten** Stunde (120-Tage-Rückschau wie beim
|
||||
bestehenden Stundenplan-Badge, `TimetableViewModel.HasUnhandledHomework`) mit
|
||||
"Kontrolliert"-Checkbox (speichert sofort, dieselbe Semantik wie im `LessonDialog`, 4.5.4),
|
||||
und die Hausaufgabe **dieser** Stunde als Textfeld mit Speichern-Button — Nutzer-Feedback:
|
||||
die Hausaufgabe ergibt sich manchmal erst während der Stunde, dafür sollte man nicht extra
|
||||
den vollen Verlaufsplan-Editor öffnen müssen. Neue kleine
|
||||
`TeachingModeHomeworkViewModel : ObservableObject`, da `TeachingModeViewModel` selbst (wie
|
||||
`LessonViewerViewModel`) keine Bindable-Basisklasse hat.
|
||||
- [x] **4.5.24** Popup-Menü im Wochenraster für Unterrichtsansicht/Sitzplan/Planung/Planungsviewer
|
||||
(August 2026, Nutzer-Feedback, zweite Runde). Die erste Fassung hatte das Problem am
|
||||
falschen Ort gelöst — ein Dropdown in der "Heute"-**Tagesliste** (unten angedockt), obwohl
|
||||
die dortigen zwei Buttons laut Nutzer schon in Ordnung waren. Zurückgesetzt auf den
|
||||
ursprünglichen Stand (zwei Buttons, `TodayLessonItem` ohne `DestinationOptions`). Das
|
||||
eigentliche Problem lag im **Wochenraster darüber**: ein Klick auf eine Stunden-Kachel führt
|
||||
dort in den Planungsviewer oder zur Einheitenplanung, je nachdem ob schon eine `Lesson`
|
||||
existiert — von außen nicht erkennbar, welches Ziel man bekommt.
|
||||
- **Popup-Menü:** kleiner "⋮"-Button pro Kachel (`Button.weekCellMenuTrigger`, oben rechts in
|
||||
der Zelle überlagert) öffnet ein `MenuFlyout` mit vier ausdrücklich benannten Zielen.
|
||||
Unterrichtsansicht/Planungsviewer nur wenn schon eine `Lesson` existiert, Sitzplan/Planung
|
||||
immer (Planung ist ohnehin der Ort, an dem man eine Lesson für den Slot erst anlegt).
|
||||
**Wichtiger XAML-Fallstrick, diesmal genauer verstanden:** ein `$parent[ItemsControl]`-
|
||||
Vorfahrenpfad (wie beim normalen Zeilen-Button) funktioniert in einem `MenuFlyout` nicht,
|
||||
weil dessen Popup nicht im normalen visuellen Baum hängt — **aber** eine normale
|
||||
`{Binding}` ohne Vorfahrensuche funktioniert dort sehr wohl, weil Avalonia die
|
||||
DataContext-**Vererbung** (kein Baum-Durchsuchen, nur der geerbte Wert) auch in Flyouts
|
||||
korrekt weiterreicht. Trotzdem bewusst `MenuItem.Click` statt `Command`-Binding gewählt
|
||||
(kein `WeekCellItem`-eigenes Command nötig): Handler in `TimetableView.axaml.cs` liest die
|
||||
Zelle über `((MenuItem)sender).DataContext`, das Ziel über `Tag="{x:Static
|
||||
vm:TimetableLessonDestination.…}"` — beides ohne jede Vorfahrensuche.
|
||||
- **Einheitlicherer Direktklick:** `OpenWeekCellCommand` prüft jetzt zusätzlich, ob gerade
|
||||
(heute, ±10 Minuten Toleranz) Unterrichtszeit der Stunde ist (neues
|
||||
`TimetableViewModel.IsAroundTeachingTime`, `PeriodScheduleService`/"Stundenraster" als
|
||||
Grundlage) — dann direkt in den Unterrichtsmodus statt in den Planungsviewer. Ohne Lesson
|
||||
bleibt es beim Sprung zur Einheitenplanung. Das Popup-Menü bietet immer alle vier Ziele
|
||||
explizit an, unabhängig von dieser Automatik. Nutzt bewusst `lesson.Date` statt
|
||||
`WeekCellItem.Date` — letzteres ist nur bei Kopfzeilen gesetzt, nicht bei
|
||||
Stunden-Kacheln (führte im ersten Testlauf zu einem Bug: die Automatik griff nie).
|
||||
|
||||
**Wichtige Abweichung von der ursprünglichen Planung (5.2):** Vor der Umsetzung zeigte sich,
|
||||
dass 5.2 wie ursprünglich beschrieben eine zweite, parallele Fehlzeiten-Erfassung neben dem
|
||||
bereits bestehenden Anwesenheits-Tracking aus Kapitel 3 (`ParticipationEntry.Attendance`,
|
||||
@@ -1897,6 +2283,13 @@ erreichbar. Ergänzt: ein "SCHNELLÜBERBLICK"-Block oberhalb der Buttonliste
|
||||
obersten Karten des Kurs-Dashboards, hier nur ohne eigenen Tab-Wechsel. Sowie die beiden
|
||||
fehlenden Buttons "📊 Übersicht" (Tab 0) und "✋ Mitarbeit" (Tab 3).
|
||||
|
||||
**Spätere UX-Vereinfachung (August 2026):** Dieses Auswahl-Panel wurde wieder entfernt, nachdem
|
||||
die Gruppenansicht selbst bereits denselben Schnellüberblick als Übersicht-Tab anbot und dadurch
|
||||
zwei nahezu identische Bereichsnavigationen nacheinander entstanden. Die Gruppenliste zeigt nun
|
||||
responsive Karten; ein Klick öffnet unmittelbar den Übersicht-Tab der Gruppe. Bearbeiten,
|
||||
Schuljahresübernahme, Archivieren/Reaktivieren und Löschen bleiben über ein Drei-Punkte-Menü an
|
||||
jeder Karte erreichbar. Suche, Schuljahresfilter und Archivansicht bleiben erhalten.
|
||||
|
||||
### 6.2 Zeiterfassung
|
||||
- [x] **6.2.1** Timer starten/stoppen mit Zuordnung zu Aufgabe oder Kategorie —
|
||||
`TimeTrackingViewModel`. Bewusst ohne live mitlaufende Sekundenanzeige (keine
|
||||
@@ -2073,6 +2466,12 @@ Hinweis in Kapitel 1 — betrifft auch Kurse, nicht nur Klassen.
|
||||
Gruppeneigenschaften können erst in einem getrennten Schritt nach der Reaktivierung geändert
|
||||
oder gelöscht werden. Reine Ansichten und das Kopieren einer Unterrichtseinheit als Vorlage
|
||||
in eine aktive Gruppe bleiben möglich.
|
||||
- [x] **7.2.6** Einlagige Lerngruppen-Navigation: Gruppen werden als responsive Karten dargestellt
|
||||
und öffnen mit einem Klick direkt den Übersicht-Tab. Die zuvor vorgeschaltete Detailspalte
|
||||
mit einer zweiten Liste aller Bereiche wurde entfernt; die Tabs im Gruppendetail sind damit
|
||||
die einzige Bereichsnavigation. Das Verwaltungsmenü sitzt direkt an jeder Karte und ist per
|
||||
Automation-Namen zugänglich. `GroupListViewModelTests` prüfen Direktnavigation, kontextuelle
|
||||
Bearbeitung, Suche und Archivierung.
|
||||
|
||||
### 7.3 Import
|
||||
|
||||
@@ -2184,7 +2583,7 @@ Hervorhebung "eigene Klasse" über `LearningGroup.IsOwnClass`, feste Kartenbreit
|
||||
Notenstufe bzw. drei Punkte); Versetzungsgefährdung basiert auf dem jüngsten gespeicherten
|
||||
Zeugnisnotenstand (Note 5/6 bzw. höchstens 4 Punkte). Klick öffnet den betroffenen Schüler.
|
||||
- [x] **9.6** Dashboard-Kacheln ein-/ausblendbar und in der Reihenfolge konfigurierbar.
|
||||
„Dashboard anpassen“ bietet für jede Kachel Sichtbarkeit sowie Hoch-/Runter-Sortierung;
|
||||
„Bereiche anpassen“ bietet für jede Kachel Sichtbarkeit sowie Hoch-/Runter-Sortierung;
|
||||
die Konfiguration wird lokal in `dashboardsettings.json` gespeichert und das Raster ohne
|
||||
Lücken neu angeordnet.
|
||||
- [x] **9.7** Automatische Aktualisierung beim Zurücknavigieren.
|
||||
@@ -2237,6 +2636,14 @@ gleiche Abfrage (Datum + Stundennummer, bewusst ohne Gruppenbezug, siehe `Substi
|
||||
PeriodNumber`-Doku) wie in `TimetableViewModel.BuildToday` für die "Heute"-Ansicht des
|
||||
Stundenplans.
|
||||
|
||||
**Nachtrag Dashboard-Fokus (August 2026):** Oberhalb der konfigurierbaren Karten fasst ein neuer
|
||||
Tagesfokus Unterricht, offene Aufgaben, Handlungsbedarf und Termine der nächsten 30 Tage in vier
|
||||
Kennzahlen zusammen. Wetterdetails sind standardmäßig eingeklappt (amtliche Warnungen öffnen den
|
||||
Bereich automatisch), die linke Inhaltsspalte erhält mehr Breite und leere reine Hinweis-Karten
|
||||
(u.a. Fehlzeiten-Warnung, Förderplan-Wiedervorlage, Korrekturen und Auffälligkeiten) werden trotz
|
||||
aktivierter Dashboard-Konfiguration automatisch ausgeblendet. Inhalte und Direktaktionen bleiben
|
||||
unverändert erhalten; die Seite wird bei ruhiger Datenlage lediglich deutlich kürzer.
|
||||
|
||||
---
|
||||
|
||||
## 10. Sync & Server
|
||||
@@ -2897,6 +3304,26 @@ von 4.2.2) der Tab "Kürzel-Katalog" für die Von/Nach-Kürzel des Stundenverlau
|
||||
ca. 23 XAML-Dateien mit fest codierten Hex-Farben statt Theme-Ressourcen — die
|
||||
Umschaltung selbst funktioniert, aber einzelne Stellen dürften im Dunkelmodus optisch
|
||||
nicht passen. Eigene, größere Aufgabe. Schriftgröße nicht umgesetzt.
|
||||
|
||||
**Nachtrag (August 2026) — Theme-Ressourcen, erster Schritt:** Neue
|
||||
[SemanticBrushes.axaml](LehrerApp.Desktop/Styles/SemanticBrushes.axaml) definiert über
|
||||
`ResourceDictionary.ThemeDictionaries` (Light/Dark) semantische Pinsel für Flächen
|
||||
(`AppCardBackgroundBrush`, `AppListRowBackgroundBrush`, `AppChipBackgroundBrush`, …),
|
||||
Akzent (`AppAccentTextBrush`, `AppFilterActive*Brush`, …) und Status
|
||||
(`AppStatusOk/Info/Warning/DangerBrush`); eingebunden in `Application.Resources` von
|
||||
`App.axaml`. Der Klassenlehrer-Bereich ist die erste vollständig umgestellte Ansicht — er
|
||||
war mit ~36 Hex-Werten der stärkste Fall und im hellen Theme unbrauchbar (dunkle
|
||||
Kartenflächen auf hellem Grund). Zwei Punkte, die beim Umstellen weiterer Ansichten
|
||||
genauso gelten:
|
||||
- Farben müssen per **`DynamicResource`** geholt werden — `StaticResource` löst einmalig
|
||||
auf und bliebe beim Laufzeit-Wechsel stehen.
|
||||
- Statusfarben dürfen **nicht als Hex-String aus dem ViewModel** kommen (bisheriges Muster
|
||||
`StatusColor => "#FF5A67"`), aus demselben Grund. Stattdessen liefert das ViewModel eine
|
||||
semantische Stufe (`ClassTeacherStatusKind`) plus `Is{Kind}Status`-Flags, und ein
|
||||
Style-Selektor (`TextBlock.status.danger`, `Border.statusFill.warning`) setzt den Pinsel
|
||||
— die Basisfarbe muss dabei ebenfalls aus einem Selektor kommen, sonst überstimmt sie das
|
||||
lokale Attribut (siehe CLAUDE.md, Avalonia-XAML-Fallstricke).
|
||||
Offen bleiben die übrigen Ansichten (Dashboard, Planung, Mitarbeit, Gruppen …).
|
||||
- [ ] **12.5** Speicherort der Datenbank anzeigen und ändern.
|
||||
- [ ] **12.6** Backup-Verwaltung (siehe 13.3) in den Einstellungen zugänglich machen.
|
||||
|
||||
@@ -3075,7 +3502,16 @@ von 4.2.2) der Tab "Kürzel-Katalog" für die Von/Nach-Kürzel des Stundenverlau
|
||||
|
||||
- [ ] **14.1** Tastaturbedienung durchgängig: alle Hauptfunktionen ohne Maus erreichbar
|
||||
(Vorbild: Mitarbeit-Schnelleingabe).
|
||||
- [ ] **14.2** Globale Suche (Schüler, Gruppe, Klausur) über Tastenkürzel.
|
||||
- [x] **14.2** Globale Suche (Schüler, Gruppe, Klausur) über Tastenkürzel.
|
||||
**Umsetzung:** `Strg+K` (Windows/Linux) bzw. `⌘K` (macOS) öffnet aus jeder Hauptansicht eine
|
||||
modale Befehlspalette. `GlobalSearchViewModel` durchsucht lokal und ohne zusätzlichen Index
|
||||
aktive wie inaktive Schüler/Lerngruppen sowie Klausuren und Aufgaben; Treffer springen direkt
|
||||
ins Schülerdetail, Gruppendetail, den Klausuren-Tab oder die Aufgabenverwaltung. Pfeiltasten,
|
||||
Enter und Escape bedienen die Palette vollständig ohne Maus. Bei leerer Suche stehen die
|
||||
Schnellaktionen „Aufgabe anlegen“, „Erinnerung anlegen“ und „Schüler anlegen“ bereit und
|
||||
verwenden die bereits vorhandenen Dialoge samt Validierung. Zusätzlich ist der Einstieg als
|
||||
zugänglich benannte Schaltfläche im Navigationsbereich sichtbar. Tests in
|
||||
`GlobalSearchViewModelTests` decken Ergebnisarten, Navigation und Schnellerfassung ab.
|
||||
- [~] **14.3** Rückgängig-Funktion für Löschvorgänge (mindestens Bestätigungsdialog überall).
|
||||
|
||||
**Umsetzung:** generischer, nicht-invasiver "Papierkorb light" statt einer
|
||||
|
||||
Reference in New Issue
Block a user