Compare commits
27
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 | ||
|
|
34a9fdf73b | ||
|
|
0f10f754d0 | ||
|
|
5841d96c5b |
@@ -60,12 +60,16 @@ The API can also run via `docker/docker-compose.yml` (reads `JWT_SECRET` from th
|
|||||||
orchestration, timer-driven), `SnapshotService`, `Crypto/SyncCrypto` (AES-256-GCM payload
|
orchestration, timer-driven), `SnapshotService`, `Crypto/SyncCrypto` (AES-256-GCM payload
|
||||||
encryption — desktop events are encrypted at rest and in transit; Companion/WebApp events are
|
encryption — desktop events are encrypted at rest and in transit; Companion/WebApp events are
|
||||||
plaintext, see `PlainSyncEvent` vs `SyncEvent`).
|
plaintext, see `PlainSyncEvent` vs `SyncEvent`).
|
||||||
|
- **LehrerApp.WebUntis** — direkter, serverunabhängiger WebUntis-Client für den Desktop. Hält die
|
||||||
|
persönliche JSON-RPC-Sitzung lokal und parst den Schülerreport lokal; WebUntis-Zugangsdaten und
|
||||||
|
personenbezogene Antworten dürfen nicht über `LehrerApp.Api` geleitet werden.
|
||||||
- **LehrerApp.Api** — minimal ASP.NET Core server: JWT auth, an append-only `EventStore` plus
|
- **LehrerApp.Api** — minimal ASP.NET Core server: JWT auth, an append-only `EventStore` plus
|
||||||
`SnapshotStore`/`ReadableSnapshotStore` per device, mapped in `Endpoints/Endpoints.cs`. Sync is
|
`SnapshotStore`/`ReadableSnapshotStore` per device, mapped in `Endpoints/Endpoints.cs`. Sync is
|
||||||
optional — Desktop only registers `SyncEngine`/`SnapshotService` in DI when a server URL is
|
optional — Desktop only registers `SyncEngine`/`SnapshotService` in DI when a server URL is
|
||||||
configured (`AppBootstrapper.LoadServerUrl`).
|
configured (`AppBootstrapper.LoadServerUrl`).
|
||||||
- Each library has a matching `*.Tests` project (`LehrerApp.Tests` → Core, `LehrerApp.Data.Tests` →
|
- Each library has a matching `*.Tests` project (`LehrerApp.Tests` → Core, `LehrerApp.Data.Tests` →
|
||||||
Data, `LehrerApp.Desktop.Tests` → Desktop, `LehrerApp.Sync.Tests` → Sync), all xUnit.
|
Data, `LehrerApp.Desktop.Tests` → Desktop, `LehrerApp.Sync.Tests` → Sync), all xUnit.
|
||||||
|
`LehrerApp.WebUntis.Tests` covers the direct WebUntis client and report parser.
|
||||||
|
|
||||||
### MVVM conventions (Desktop)
|
### MVVM conventions (Desktop)
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
using System.IdentityModel.Tokens.Jwt;
|
using System.IdentityModel.Tokens.Jwt;
|
||||||
using System.Security.Claims;
|
using System.Security.Claims;
|
||||||
using System.Text;
|
using System.Text;
|
||||||
|
using System.Globalization;
|
||||||
using LehrerApp.Core.Models;
|
using LehrerApp.Core.Models;
|
||||||
using LehrerApp.Sync.Models;
|
using LehrerApp.Sync.Models;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using LiteDB;
|
using LiteDB;
|
||||||
|
using LehrerApp.Sync;
|
||||||
using LehrerApp.Sync.Models;
|
using LehrerApp.Sync.Models;
|
||||||
|
|
||||||
namespace LehrerApp.Api;
|
namespace LehrerApp.Api;
|
||||||
@@ -67,7 +68,7 @@ public class EventStore(string dataPath) : IDisposable
|
|||||||
{
|
{
|
||||||
var col = GetCol(userId);
|
var col = GetCol(userId);
|
||||||
var events = col.Find(e => e.ServerSeq > since && e.DeviceId != requestingDeviceId)
|
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,
|
.Select(e => new SyncEvent { EventId = e.EventId, DeviceId = e.DeviceId,
|
||||||
DeviceType = e.DeviceType, Timestamp = e.Timestamp,
|
DeviceType = e.DeviceType, Timestamp = e.Timestamp,
|
||||||
SequenceNr = e.ServerSeq, EntityType = e.EntityType,
|
SequenceNr = e.ServerSeq, EntityType = e.EntityType,
|
||||||
|
|||||||
@@ -136,6 +136,40 @@ public interface IUntisSnapshotRepository
|
|||||||
void Save(UntisSnapshotEntry entry);
|
void Save(UntisSnapshotEntry entry);
|
||||||
void Delete(Guid id);
|
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.
|
/// Vom Nutzer bestätigte Zuordnungen WebUntis-Wochenmuster → LearningGroup.
|
||||||
public interface IUntisSlotMappingRepository
|
public interface IUntisSlotMappingRepository
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -13,6 +13,12 @@ public class Exam
|
|||||||
public DateOnly? ReturnedAt { get; set; }
|
public DateOnly? ReturnedAt { get; set; }
|
||||||
public Niveau? Niveau { get; set; }
|
public Niveau? Niveau { get; set; }
|
||||||
public string? Notes { 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 CreatedAt { get; set; } = DateTime.UtcNow;
|
||||||
public DateTime UpdatedAt { 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.
|
/// deaktivierbar - blendet die Erinnerung "Ungeplante Stunden" im Dashboard für diese Gruppe aus.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public bool RequiresLessonPlanning { get; set; } = true;
|
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 CreatedAt { get; set; } = DateTime.UtcNow;
|
||||||
public DateTime UpdatedAt { 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>
|
/// <summary>Im Unterricht schnell erfasst und noch inhaltlich nachzuarbeiten.</summary>
|
||||||
public bool IsDraft { get; set; }
|
public bool IsDraft { get; set; }
|
||||||
public bool IsConfidential { 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 CreatedAt { get; set; } = DateTime.UtcNow;
|
||||||
public DateTime UpdatedAt { 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).
|
// 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,
|
IsActive = true,
|
||||||
IsOwnClass = source.IsOwnClass,
|
IsOwnClass = source.IsOwnClass,
|
||||||
IsDifferentiated = source.IsDifferentiated,
|
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;
|
var sourceWasActive = source.IsActive;
|
||||||
|
|||||||
@@ -1053,6 +1053,101 @@ public sealed class RepositoryTests
|
|||||||
Assert.Equal("fahrt", repo.GetByExternalId("fahrt")!.ExternalId);
|
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 ────────────────────────────────────────────────────
|
// ── WorkTaskRepository ────────────────────────────────────────────────────
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|||||||
@@ -68,6 +68,10 @@ public class LiteDbContext : IDisposable
|
|||||||
public ILiteCollection<SubstitutionEntry> SubstitutionEntries => _db.GetCollection<SubstitutionEntry>("substitution_entries");
|
public ILiteCollection<SubstitutionEntry> SubstitutionEntries => _db.GetCollection<SubstitutionEntry>("substitution_entries");
|
||||||
public ILiteCollection<UntisSnapshotEntry> UntisSnapshotEntries => _db.GetCollection<UntisSnapshotEntry>("untis_snapshot_entries");
|
public ILiteCollection<UntisSnapshotEntry> UntisSnapshotEntries => _db.GetCollection<UntisSnapshotEntry>("untis_snapshot_entries");
|
||||||
public ILiteCollection<UntisSlotMapping> UntisSlotMappings => _db.GetCollection<UntisSlotMapping>("untis_slot_mappings");
|
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<AnnualPlanEvent> AnnualPlanEvents => _db.GetCollection<AnnualPlanEvent>("annual_plan_events");
|
||||||
public ILiteCollection<TrashedItem> TrashedItems => _db.GetCollection<TrashedItem>("trash");
|
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.ExternalId, unique: true);
|
||||||
AnnualPlanEvents.EnsureIndex(x => x.StartDate);
|
AnnualPlanEvents.EnsureIndex(x => x.StartDate);
|
||||||
AnnualPlanEvents.EnsureIndex(x => x.EndDate);
|
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();
|
public void Dispose() => _db.Dispose();
|
||||||
|
|||||||
@@ -794,6 +794,65 @@ public class AnnualPlanEventRepository(LiteDbContext db) : IAnnualPlanEventRepos
|
|||||||
public void Delete(Guid id) => db.AnnualPlanEvents.Delete(id);
|
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 class CompetencyDomainRepository(LiteDbContext db) : ICompetencyDomainRepository
|
||||||
{
|
{
|
||||||
public List<CompetencyDomain> GetBySubjectAndGrade(Guid subjectId, int gradeLevel) =>
|
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, ...).
|
/// (offene Aufgaben, ...).
|
||||||
public sealed class DashboardViewModelTests
|
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()
|
private static PeriodScheduleService NewPeriodSchedule()
|
||||||
{
|
{
|
||||||
var tempPath = System.IO.Path.Combine(
|
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 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
|
public class FakeWorkTasks : IWorkTaskRepository
|
||||||
{
|
{
|
||||||
private readonly List<WorkTask> _all = [];
|
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;
|
namespace LehrerApp.Desktop.Tests;
|
||||||
|
|
||||||
/// Tests für den Schnellüberblick im Auswahl-Panel der Gruppenliste (Nutzer-Feedback:
|
/// <summary>Tests für die direkte, einlagige Lerngruppen-Navigation.</summary>
|
||||||
/// "oberhalb der Buttonliste ein paar Daten auswerfen. Nächste Stunde, nächste Arbeit,
|
|
||||||
/// wichtige Todos").
|
|
||||||
public sealed class GroupListViewModelTests
|
public sealed class GroupListViewModelTests
|
||||||
{
|
{
|
||||||
private static GroupListViewModel BuildVm(LearningGroup group, FakeLessons? lessons = null,
|
private static GroupListViewModel BuildVm(params LearningGroup[] groups) =>
|
||||||
FakeExams? exams = null, FakeWorkTasks? tasks = null) =>
|
new(new FakeGroups([.. groups]), new FakeSubjects([]), new SchoolYearService());
|
||||||
new(new FakeGroups([group]), new FakeSubjects([]), new SchoolYearService(),
|
|
||||||
lessons ?? new FakeLessons(), exams ?? new FakeExams([]), tasks ?? new FakeWorkTasks());
|
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void SelectedGroup_OhneDatenZeigtKeinenSchnellueberblick()
|
public void Gruppenkarte_OeffnetDirektDieUebersicht()
|
||||||
{
|
{
|
||||||
var group = new LearningGroup { Name = "9c" };
|
var group = CurrentGroup("9c");
|
||||||
var vm = BuildVm(group);
|
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]
|
[Fact]
|
||||||
public void SelectedGroup_ZeigtNaechsteGeplanteStundeUndKlausur()
|
public async Task Verwaltungsmenue_BearbeitetDieGewaehlteKarteOhneZwischenauswahl()
|
||||||
{
|
{
|
||||||
var group = new LearningGroup { Name = "9c" };
|
var first = CurrentGroup("9a");
|
||||||
var today = DateOnly.FromDateTime(DateTime.Today);
|
var second = CurrentGroup("9b");
|
||||||
var lessons = new FakeLessons();
|
var vm = BuildVm(first, second);
|
||||||
lessons.Add(new Lesson { GroupId = group.Id, Date = today.AddDays(3), Topic = "Redox", Status = LessonStatus.Planned });
|
Guid? editedId = null;
|
||||||
var exams = new FakeExams([new Exam { GroupId = group.Id, Date = today.AddDays(10), Title = "Klausur 1" }]);
|
vm.OnEditGroup = id => { editedId = id; return Task.CompletedTask; };
|
||||||
var vm = BuildVm(group, lessons: lessons, exams: exams);
|
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.Equal(second.Id, editedId);
|
||||||
Assert.Contains("Redox", vm.QuickNextLessonLabel);
|
|
||||||
Assert.True(vm.QuickHasNextExam);
|
|
||||||
Assert.Contains("Klausur 1", vm.QuickNextExamLabel);
|
|
||||||
Assert.True(vm.QuickHasAnything);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void SelectedGroup_ZeigtOffeneAufgabenDieserGruppeSortiertNachFaelligkeit()
|
public void Suche_FiltertWeiterhinNachGruppenname()
|
||||||
{
|
{
|
||||||
var group = new LearningGroup { Name = "9c" };
|
var vm = BuildVm(CurrentGroup("9 Chemie"), CurrentGroup("10 Mathematik"));
|
||||||
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);
|
|
||||||
|
|
||||||
vm.SelectedGroup = new GroupListItem(group, "");
|
vm.SearchText = "Chemie";
|
||||||
|
|
||||||
Assert.True(vm.QuickHasTasks);
|
Assert.Equal("9 Chemie", Assert.Single(vm.Groups).Name);
|
||||||
Assert.Equal("Bald fällig", Assert.Single(vm.QuickTasks).Title);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void SelectedGroup_Zuruecksetzen_LeertSchnellueberblick()
|
public void Archivieren_WirktAufDieKarteUndEntferntSieAusDerAktivenListe()
|
||||||
{
|
{
|
||||||
var group = new LearningGroup { Name = "9c" };
|
var group = CurrentGroup("9c");
|
||||||
var today = DateOnly.FromDateTime(DateTime.Today);
|
var vm = BuildVm(group);
|
||||||
var exams = new FakeExams([new Exam { GroupId = group.Id, Date = today.AddDays(10), Title = "Klausur 1" }]);
|
var item = Assert.Single(vm.Groups);
|
||||||
var vm = BuildVm(group, exams: exams);
|
|
||||||
vm.SelectedGroup = new GroupListItem(group, "");
|
|
||||||
Assert.True(vm.QuickHasAnything);
|
|
||||||
|
|
||||||
vm.SelectedGroup = null;
|
item.ToggleArchiveCommand.Execute(null);
|
||||||
|
|
||||||
Assert.False(vm.QuickHasAnything);
|
Assert.False(group.IsActive);
|
||||||
Assert.Empty(vm.QuickTasks);
|
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 ?? []),
|
new FakeStudents(students ?? []), new FakeMemberships(memberships ?? []),
|
||||||
sessions ?? new FakeSessions([]), new FakeEntries(), new FakeAspects());
|
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]
|
[Fact]
|
||||||
public void Konstruktor_LaedtVerlaufsplanUndInitialisiertSitzplanFuerDieGruppe()
|
public void Konstruktor_LaedtVerlaufsplanUndInitialisiertSitzplanFuerDieGruppe()
|
||||||
{
|
{
|
||||||
@@ -24,7 +35,8 @@ public sealed class TeachingModeViewModelTests
|
|||||||
};
|
};
|
||||||
var seatingPlan = BuildSeatingPlan(group.Id);
|
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("Q1 Chemie", vm.GroupName);
|
||||||
Assert.Equal("Redox", vm.LessonInfo.Topic);
|
Assert.Equal("Redox", vm.LessonInfo.Topic);
|
||||||
@@ -41,13 +53,37 @@ public sealed class TeachingModeViewModelTests
|
|||||||
var sessions = new FakeSessions([]);
|
var sessions = new FakeSessions([]);
|
||||||
var seatingPlan = BuildSeatingPlan(group.Id, sessions);
|
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));
|
var created = Assert.Single(sessions.GetByGroup(group.Id));
|
||||||
Assert.Equal(lesson.Id, created.LessonId);
|
Assert.Equal(lesson.Id, created.LessonId);
|
||||||
Assert.Equal(vm.SeatingPlan.SelectedSession?.Id, created.Id);
|
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]
|
[Fact]
|
||||||
public void Konstruktor_BereitsExistierendeVerknuepfteSitzung_WirdWiederverwendet()
|
public void Konstruktor_BereitsExistierendeVerknuepfteSitzung_WirdWiederverwendet()
|
||||||
{
|
{
|
||||||
@@ -59,7 +95,8 @@ public sealed class TeachingModeViewModelTests
|
|||||||
var sessions = new FakeSessions([existing]);
|
var sessions = new FakeSessions([existing]);
|
||||||
var seatingPlan = BuildSeatingPlan(group.Id, sessions);
|
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.Single(sessions.GetByGroup(group.Id));
|
||||||
Assert.Equal(existing.Id, vm.SeatingPlan.SelectedSession?.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" };
|
{ GroupId = group.Id, Date = DateOnly.FromDateTime(DateTime.Today), Topic = "Redox" };
|
||||||
var seatingPlan = BuildSeatingPlan(group.Id);
|
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);
|
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,
|
FakeSubjects? subjects = null, FakeLessons? lessons = null, FakeExams? exams = null,
|
||||||
SchoolCalendarSettingsService? calendarSettings = null,
|
SchoolCalendarSettingsService? calendarSettings = null,
|
||||||
FakeSupervisionDuties? supervisionDuties = null, FakeSubstitutionEntries? substitutions = 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
|
// Bewusst kein "using": SchoolCalendarSettingsService/PeriodScheduleService lesen den Pfad
|
||||||
// (SetState), das Verzeichnis muss über die Lebensdauer des ViewModels bestehen bleiben.
|
// nur bei Bedarf, das Verzeichnis muss über die Lebensdauer des ViewModels bestehen bleiben.
|
||||||
var tempPath = System.IO.Path.Combine(
|
var tempPath = System.IO.Path.Combine(
|
||||||
System.IO.Path.GetTempPath(), $"lehrerapp-timetablevm-tests-{Guid.NewGuid():N}");
|
System.IO.Path.GetTempPath(), $"lehrerapp-timetablevm-tests-{Guid.NewGuid():N}");
|
||||||
Directory.CreateDirectory(tempPath);
|
Directory.CreateDirectory(tempPath);
|
||||||
@@ -28,7 +29,8 @@ public sealed class TimetableViewModelTests
|
|||||||
calendarSettings ?? new SchoolCalendarSettingsService(tempPath),
|
calendarSettings ?? new SchoolCalendarSettingsService(tempPath),
|
||||||
new PublicHolidayService(), new SchoolYearService(),
|
new PublicHolidayService(), new SchoolYearService(),
|
||||||
supervisionDuties ?? new FakeSupervisionDuties(), substitutions ?? new FakeSubstitutionEntries(),
|
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 —
|
/// 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);
|
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]
|
[Fact]
|
||||||
public void OpenSettings_RuftOnNavigateToSettingsAuf()
|
public void OpenSettings_RuftOnNavigateToSettingsAuf()
|
||||||
{
|
{
|
||||||
@@ -1177,3 +1261,43 @@ public sealed class TimetableViewModelTests
|
|||||||
Assert.False(vm.HasUntisMismatch);
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
using System.Net;
|
||||||
|
using System.Text;
|
||||||
|
using LehrerApp.Desktop.Services;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace LehrerApp.Desktop.Tests;
|
||||||
|
|
||||||
|
public sealed class WebUntisIntegrationServiceTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public async Task ConnectUndAbruf_GehenDirektAnWebUntisUndVerwendenDieselbeSession()
|
||||||
|
{
|
||||||
|
var handler = new QueueHandler(
|
||||||
|
Json("{\"result\":{\"sessionId\":\"desktop-session\"}}"),
|
||||||
|
Json("{\"result\":[{\"id\":1,\"name\":\"2026/27\",\"startDate\":20260801,\"endDate\":20270731}]}"),
|
||||||
|
Json("{\"result\":[{\"id\":42,\"name\":\"KUE\",\"longName\":\"Kürbis\",\"active\":true}]}"),
|
||||||
|
Json("{\"result\":{}}"));
|
||||||
|
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 teachers = await service.GetTeachersAsync();
|
||||||
|
|
||||||
|
Assert.Equal("KUE", Assert.Single(teachers).Name);
|
||||||
|
Assert.Equal(3, handler.Requests.Count);
|
||||||
|
Assert.All(handler.Requests, request =>
|
||||||
|
Assert.StartsWith("https://arche.webuntis.com/WebUntis/", request.Uri));
|
||||||
|
Assert.DoesNotContain(handler.Requests, request => request.Uri.Contains("/api/webuntis"));
|
||||||
|
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"),
|
||||||
|
};
|
||||||
|
|
||||||
|
private sealed class QueueHandler(params HttpResponseMessage[] responses) : HttpMessageHandler
|
||||||
|
{
|
||||||
|
private readonly Queue<HttpResponseMessage> _responses = new(responses);
|
||||||
|
public List<CapturedRequest> Requests { get; } = [];
|
||||||
|
|
||||||
|
protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
Requests.Add(new(request.RequestUri?.ToString() ?? "",
|
||||||
|
request.Content is null ? "" : await request.Content.ReadAsStringAsync(cancellationToken)));
|
||||||
|
return _responses.Dequeue();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed record CapturedRequest(string Uri, string Body);
|
||||||
|
}
|
||||||
@@ -86,4 +86,39 @@ public sealed class WebUntisSettingsServiceTests
|
|||||||
Assert.Equal(at, reloaded.LastSyncAt);
|
Assert.Equal(at, reloaded.LastSyncAt);
|
||||||
Assert.Equal("3 Vertretungen erkannt", reloaded.LastSyncStatus);
|
Assert.Equal("3 Vertretungen erkannt", reloaded.LastSyncStatus);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ApiCredentials_SindVerschluesseltUndPersistieren()
|
||||||
|
{
|
||||||
|
var path = BuildTempPath();
|
||||||
|
var credentials = new WebUntisCredentials("schule", "mese.webuntis.com", "lehrkraft", "sehr geheim");
|
||||||
|
var service = new WebUntisSettingsService(path);
|
||||||
|
|
||||||
|
service.SetApiCredentials(credentials);
|
||||||
|
service.SetTeacherUntisId(4711);
|
||||||
|
|
||||||
|
var raw = File.ReadAllText(Path.Combine(path, "webuntis-settings.json"));
|
||||||
|
var reloaded = new WebUntisSettingsService(path);
|
||||||
|
Assert.DoesNotContain("sehr geheim", raw);
|
||||||
|
Assert.True(reloaded.ApiIsConfigured);
|
||||||
|
Assert.Equal(credentials, reloaded.GetApiCredentials());
|
||||||
|
Assert.Equal(4711, reloaded.TeacherUntisId);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ClearApiCredentials_LaesstIcalKonfigurationUnveraendert()
|
||||||
|
{
|
||||||
|
var service = new WebUntisSettingsService(BuildTempPath());
|
||||||
|
service.SetIcalUrl(SampleUrl);
|
||||||
|
service.SetApiCredentials(new("schule", "", "user", "password"));
|
||||||
|
service.SetTeacherUntisId(12);
|
||||||
|
|
||||||
|
service.ClearApiCredentials();
|
||||||
|
|
||||||
|
Assert.False(service.ApiIsConfigured);
|
||||||
|
Assert.Null(service.GetApiCredentials());
|
||||||
|
Assert.Null(service.TeacherUntisId);
|
||||||
|
Assert.True(service.IsConfigured);
|
||||||
|
Assert.Equal(SampleUrl, service.GetIcalUrl());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,14 @@
|
|||||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
x:Class="LehrerApp.Desktop.App"
|
x:Class="LehrerApp.Desktop.App"
|
||||||
RequestedThemeVariant="Default">
|
RequestedThemeVariant="Default">
|
||||||
|
<Application.Resources>
|
||||||
|
<ResourceDictionary>
|
||||||
|
<ResourceDictionary.MergedDictionaries>
|
||||||
|
<ResourceInclude Source="avares://LehrerApp.Desktop/Styles/SemanticBrushes.axaml"/>
|
||||||
|
</ResourceDictionary.MergedDictionaries>
|
||||||
|
</ResourceDictionary>
|
||||||
|
</Application.Resources>
|
||||||
|
|
||||||
<Application.Styles>
|
<Application.Styles>
|
||||||
<FluentTheme />
|
<FluentTheme />
|
||||||
<StyleInclude Source="avares://Avalonia.Controls.DataGrid/Themes/Fluent.xaml"/>
|
<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.Groups;
|
||||||
using LehrerApp.Desktop.ViewModels.Planning;
|
using LehrerApp.Desktop.ViewModels.Planning;
|
||||||
using LehrerApp.Desktop.ViewModels.Students;
|
using LehrerApp.Desktop.ViewModels.Students;
|
||||||
|
using LehrerApp.Desktop.ViewModels.Workload;
|
||||||
using LehrerApp.Desktop.Views;
|
using LehrerApp.Desktop.Views;
|
||||||
|
using LehrerApp.Desktop.Views.Workload;
|
||||||
using LehrerApp.Sync;
|
using LehrerApp.Sync;
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
|
||||||
@@ -79,6 +81,15 @@ public class App : Application
|
|||||||
|
|
||||||
var mainVm = Services.GetRequiredService<MainWindowViewModel>();
|
var mainVm = Services.GetRequiredService<MainWindowViewModel>();
|
||||||
WireCallbacks(mainVm);
|
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 };
|
var main = new MainWindow { DataContext = mainVm };
|
||||||
main.EnableWindowSizePersistence(Services.GetRequiredService<WindowSettingsService>());
|
main.EnableWindowSizePersistence(Services.GetRequiredService<WindowSettingsService>());
|
||||||
if (Services.GetService<SyncEngine>() is { } syncEngine)
|
if (Services.GetService<SyncEngine>() is { } syncEngine)
|
||||||
@@ -100,11 +111,12 @@ public class App : Application
|
|||||||
|
|
||||||
private static void DisposeServices()
|
private static void DisposeServices()
|
||||||
{
|
{
|
||||||
if (_serviceProvider is null) return;
|
var serviceProvider = Interlocked.Exchange(ref _serviceProvider, null);
|
||||||
|
if (serviceProvider is null) return;
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
_serviceProvider.GetService<LiteDbContext>()?.Checkpoint();
|
serviceProvider.GetService<LiteDbContext>()?.Checkpoint();
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
@@ -112,8 +124,19 @@ public class App : Application
|
|||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
_serviceProvider.Dispose();
|
// Der Exit-Handler läuft synchron auf dem Avalonia-UI-Thread. Die asynchrone
|
||||||
_serviceProvider = null;
|
// 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.OnNavigateToExam = id => main.NavigateToGroupDetail(id, 4); // Tab "Klausuren"
|
||||||
dash.OnNavigateToUnplannedLesson = id => main.NavigateToGroupDetail(id, 6); // Tab "Planung"
|
dash.OnNavigateToUnplannedLesson = id => main.NavigateToGroupDetail(id, 6); // Tab "Planung"
|
||||||
|
|
||||||
|
// 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
|
// StudentList → StudentDetail + Anlegen
|
||||||
var sl = Services.GetRequiredService<StudentListViewModel>();
|
var sl = Services.GetRequiredService<StudentListViewModel>();
|
||||||
sl.OnNavigateToDetail = id => main.NavigateToStudent(id);
|
sl.OnNavigateToDetail = id => main.NavigateToStudent(id);
|
||||||
@@ -150,4 +190,17 @@ public class App : Application
|
|||||||
if (Application.Current?.ApplicationLifetime is IClassicDesktopStyleApplicationLifetime { MainWindow: { } owner })
|
if (Application.Current?.ApplicationLifetime is IClassicDesktopStyleApplicationLifetime { MainWindow: { } owner })
|
||||||
await dialog.ShowDialog<bool>(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.Data.Repositories;
|
||||||
using LehrerApp.Desktop.Services;
|
using LehrerApp.Desktop.Services;
|
||||||
using LehrerApp.Desktop.ViewModels;
|
using LehrerApp.Desktop.ViewModels;
|
||||||
|
using LehrerApp.Desktop.ViewModels.ClassTeacher;
|
||||||
|
using LehrerApp.Desktop.ViewModels.Exams;
|
||||||
using LehrerApp.Desktop.ViewModels.Groups;
|
using LehrerApp.Desktop.ViewModels.Groups;
|
||||||
using LehrerApp.Desktop.ViewModels.Planning;
|
using LehrerApp.Desktop.ViewModels.Planning;
|
||||||
using LehrerApp.Desktop.ViewModels.Settings;
|
using LehrerApp.Desktop.ViewModels.Settings;
|
||||||
@@ -168,6 +170,10 @@ public static class AppBootstrapper
|
|||||||
services.AddSingleton<IUntisSnapshotRepository, UntisSnapshotRepository>();
|
services.AddSingleton<IUntisSnapshotRepository, UntisSnapshotRepository>();
|
||||||
services.AddSingleton<IUntisSlotMappingRepository, UntisSlotMappingRepository>();
|
services.AddSingleton<IUntisSlotMappingRepository, UntisSlotMappingRepository>();
|
||||||
services.AddSingleton<IAnnualPlanEventRepository, AnnualPlanEventRepository>();
|
services.AddSingleton<IAnnualPlanEventRepository, AnnualPlanEventRepository>();
|
||||||
|
services.AddSingleton<IUntisAbsenceCacheRepository, UntisAbsenceCacheRepository>();
|
||||||
|
services.AddSingleton<IUntisClassRegisterCacheRepository, UntisClassRegisterCacheRepository>();
|
||||||
|
services.AddSingleton<IUntisCacheFetchStateRepository, UntisCacheFetchStateRepository>();
|
||||||
|
services.AddSingleton<IUntisStudentRosterCacheRepository, UntisStudentRosterCacheRepository>();
|
||||||
|
|
||||||
// ── Services ──────────────────────────────────────────────────────────
|
// ── Services ──────────────────────────────────────────────────────────
|
||||||
services.AddSingleton<GradingService>();
|
services.AddSingleton<GradingService>();
|
||||||
@@ -223,6 +229,8 @@ public static class AppBootstrapper
|
|||||||
services.AddSingleton(syncSettings);
|
services.AddSingleton(syncSettings);
|
||||||
services.AddSingleton(_ => new SyncAuthService(new HttpClient()));
|
services.AddSingleton(_ => new SyncAuthService(new HttpClient()));
|
||||||
services.AddSingleton(sp => new SchoolWeatherService(new HttpClient(), syncSettings));
|
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)
|
// 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
|
// 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.
|
// Schlüssel synchronisierte Server-Daten sind für dieses Gerät dann nicht mehr lesbar.
|
||||||
@@ -284,6 +292,7 @@ public static class AppBootstrapper
|
|||||||
// Singleton: einmal erstellt, überall dieselbe Instanz
|
// Singleton: einmal erstellt, überall dieselbe Instanz
|
||||||
services.AddSingleton<AppLockViewModel>();
|
services.AddSingleton<AppLockViewModel>();
|
||||||
services.AddSingleton<MainWindowViewModel>();
|
services.AddSingleton<MainWindowViewModel>();
|
||||||
|
services.AddSingleton<GlobalSearchViewModel>();
|
||||||
services.AddSingleton<DashboardViewModel>();
|
services.AddSingleton<DashboardViewModel>();
|
||||||
services.AddSingleton(sp =>
|
services.AddSingleton(sp =>
|
||||||
new SyncStatusViewModel(
|
new SyncStatusViewModel(
|
||||||
@@ -296,6 +305,9 @@ public static class AppBootstrapper
|
|||||||
services.AddSingleton<TimeTrackingViewModel>();
|
services.AddSingleton<TimeTrackingViewModel>();
|
||||||
services.AddSingleton<WorkloadEvaluationViewModel>();
|
services.AddSingleton<WorkloadEvaluationViewModel>();
|
||||||
services.AddSingleton<WorkloadViewModel>();
|
services.AddSingleton<WorkloadViewModel>();
|
||||||
|
services.AddSingleton<ClassTeacherDetailsViewModel>();
|
||||||
|
services.AddSingleton<ClassTeacherOverviewViewModel>();
|
||||||
|
services.AddSingleton<ExamsOverviewViewModel>();
|
||||||
|
|
||||||
// Transient: neue Instanz pro Navigation (für Detailseiten)
|
// Transient: neue Instanz pro Navigation (für Detailseiten)
|
||||||
services.AddTransient<GroupDetailViewModel>();
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@
|
|||||||
<ProjectReference Include="..\LehrerApp.Core\LehrerApp.Core.csproj" />
|
<ProjectReference Include="..\LehrerApp.Core\LehrerApp.Core.csproj" />
|
||||||
<ProjectReference Include="..\LehrerApp.Data\LehrerApp.Data.csproj" />
|
<ProjectReference Include="..\LehrerApp.Data\LehrerApp.Data.csproj" />
|
||||||
<ProjectReference Include="..\LehrerApp.Sync\LehrerApp.Sync.csproj" />
|
<ProjectReference Include="..\LehrerApp.Sync\LehrerApp.Sync.csproj" />
|
||||||
|
<ProjectReference Include="..\LehrerApp.WebUntis\LehrerApp.WebUntis.csproj" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="Avalonia" />
|
<PackageReference Include="Avalonia" />
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -0,0 +1,238 @@
|
|||||||
|
using LehrerApp.WebUntis;
|
||||||
|
|
||||||
|
namespace LehrerApp.Desktop.Services;
|
||||||
|
|
||||||
|
public sealed class WebUntisIntegrationException(string message) : Exception(message);
|
||||||
|
|
||||||
|
public sealed record UntisSchoolYearDto(int UntisId, string Name, int StartDate, int EndDate);
|
||||||
|
public sealed record UntisClassDto(int UntisId, string Name, string? LongName);
|
||||||
|
public sealed record UntisTeacherDto(int UntisId, string Name, string? ForeName, string? LongName, string? Title,
|
||||||
|
bool Active, IReadOnlyList<int> DepartmentUntisIds)
|
||||||
|
{
|
||||||
|
public string DisplayName => string.IsNullOrWhiteSpace(LongName)
|
||||||
|
? Name
|
||||||
|
: $"{ForeName} {LongName} ({Name})".Trim();
|
||||||
|
}
|
||||||
|
public sealed record UntisEntityDto(int Id, string Name, int? OriginalId, string? OriginalName, string? ExternalKey);
|
||||||
|
public sealed record UntisTimeUnitDto(string Name, int StartTime, int EndTime);
|
||||||
|
public sealed record UntisTimeGridDayDto(int Day, IReadOnlyList<UntisTimeUnitDto> TimeUnits);
|
||||||
|
public sealed record UntisTimetablePeriodDto(int Id, int Date, int StartTime, int EndTime, string? Code,
|
||||||
|
string? ActivityType, string? Info, string? LessonText, string? SubstitutionText, string? StudentGroup,
|
||||||
|
IReadOnlyList<UntisEntityDto> Classes, IReadOnlyList<UntisEntityDto> Teachers,
|
||||||
|
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,
|
||||||
|
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 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>
|
||||||
|
public sealed class WebUntisIntegrationService(HttpClient http, WebUntisSettingsService settings) : IAsyncDisposable
|
||||||
|
{
|
||||||
|
private readonly SemaphoreSlim _clientGate = new(1, 1);
|
||||||
|
private WebUntisClient? _client;
|
||||||
|
|
||||||
|
public bool IsAvailable => settings.ApiIsConfigured;
|
||||||
|
|
||||||
|
public async Task ConnectAsync(WebUntisCredentials credentials, CancellationToken token = default)
|
||||||
|
{
|
||||||
|
var candidate = CreateClient(credentials);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await candidate.GetSchoolYearsAsync(token);
|
||||||
|
}
|
||||||
|
catch (Exception exception) when (IsUntisError(exception))
|
||||||
|
{
|
||||||
|
await candidate.DisposeAsync();
|
||||||
|
throw Translate(exception);
|
||||||
|
}
|
||||||
|
|
||||||
|
WebUntisClient? previous;
|
||||||
|
await _clientGate.WaitAsync(token).ConfigureAwait(false);
|
||||||
|
try { previous = _client; _client = candidate; }
|
||||||
|
finally { _clientGate.Release(); }
|
||||||
|
if (previous is not null) await previous.DisposeAsync().ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task DisconnectAsync(CancellationToken token = default)
|
||||||
|
{
|
||||||
|
WebUntisClient? previous;
|
||||||
|
await _clientGate.WaitAsync(token).ConfigureAwait(false);
|
||||||
|
try { previous = _client; _client = null; }
|
||||||
|
finally { _clientGate.Release(); }
|
||||||
|
if (previous is not null) await previous.DisposeAsync().ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<IReadOnlyList<UntisSchoolYearDto>> GetSchoolYearsAsync(CancellationToken token = default) => ExecuteAsync(
|
||||||
|
async client => (IReadOnlyList<UntisSchoolYearDto>)(await client.GetSchoolYearsAsync(token))
|
||||||
|
.Select(x => new UntisSchoolYearDto(x.UntisId, x.Name, x.StartDate, x.EndDate)).ToList(), token);
|
||||||
|
|
||||||
|
public Task<IReadOnlyList<UntisClassDto>> GetClassesAsync(int schoolYearId, CancellationToken token = default) => ExecuteAsync(
|
||||||
|
async client => (IReadOnlyList<UntisClassDto>)(await client.GetClassesAsync(schoolYearId, token))
|
||||||
|
.Select(x => new UntisClassDto(x.UntisId, x.Name, x.LongName)).ToList(), token);
|
||||||
|
|
||||||
|
public Task<IReadOnlyList<UntisTeacherDto>> GetTeachersAsync(CancellationToken token = default) => ExecuteAsync(
|
||||||
|
async client => (IReadOnlyList<UntisTeacherDto>)(await client.GetTeachersAsync(token))
|
||||||
|
.Select(x => new UntisTeacherDto(x.UntisId, x.Name, x.ForeName, x.LongName, x.Title, x.Active,
|
||||||
|
x.DepartmentUntisIds)).ToList(), token);
|
||||||
|
|
||||||
|
public Task<IReadOnlyList<UntisTimeGridDayDto>> GetTimeGridAsync(CancellationToken token = default) => ExecuteAsync(
|
||||||
|
async client => (IReadOnlyList<UntisTimeGridDayDto>)(await client.GetTimeGridAsync(token))
|
||||||
|
.Select(x => new UntisTimeGridDayDto(x.Day,
|
||||||
|
x.TimeUnits.Select(t => new UntisTimeUnitDto(t.Name, t.StartTime, t.EndTime)).ToList())).ToList(), token);
|
||||||
|
|
||||||
|
public Task<IReadOnlyList<UntisTimetablePeriodDto>> GetTimetableAsync(int teacherId, DateOnly start,
|
||||||
|
DateOnly end, CancellationToken token = default) => ExecuteAsync(async client =>
|
||||||
|
(IReadOnlyList<UntisTimetablePeriodDto>)(await client.GetTimetableAsync(
|
||||||
|
UntisTimetableElementType.Teacher, teacherId, Date(start), Date(end), token))
|
||||||
|
.Select(x => new UntisTimetablePeriodDto(x.Id, x.Date, x.StartTime, x.EndTime, x.Code,
|
||||||
|
x.ActivityType, x.Info, x.LessonText, x.SubstitutionText, x.StudentGroup,
|
||||||
|
Entities(x.Classes), Entities(x.Teachers), Entities(x.Subjects), Entities(x.Rooms))).ToList(), token);
|
||||||
|
|
||||||
|
public Task<UntisStudentReportDto> GetStudentsAsync(string className, CancellationToken token = default) => ExecuteAsync(
|
||||||
|
async client =>
|
||||||
|
{
|
||||||
|
var report = await client.GetStudentReportAsync(className, token);
|
||||||
|
return new UntisStudentReportDto(report.Count, report.ClassNameFilter, report.Students.Select(x =>
|
||||||
|
new UntisStudentDto(x.UntisId, x.ExternKey, x.ClassName, x.Name, x.LongName, x.ForeName,
|
||||||
|
x.DisplayName, x.Gender, x.BirthDate, x.BirthDateRaw, x.EntryDate, x.EntryDateRaw,
|
||||||
|
x.ExitDate, x.ExitDateRaw, x.Text, x.MedicalReportDuty, x.Schulpflicht, x.Majority,
|
||||||
|
new UntisStudentAddressDto(x.Address.Email, x.Address.Mobile, x.Address.Phone, x.Address.City,
|
||||||
|
x.Address.PostCode, x.Address.Street), x.AttributeIL)).ToList());
|
||||||
|
}, token);
|
||||||
|
|
||||||
|
public Task<IReadOnlyList<UntisLessonAbsenceDto>> GetLessonAbsencesAsync(int lessonId,
|
||||||
|
DateOnly start, DateOnly end, CancellationToken token = default) => ExecuteAsync(async client =>
|
||||||
|
{
|
||||||
|
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)); }
|
||||||
|
catch (Exception exception) when (IsUntisError(exception)) { throw Translate(exception); }
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<WebUntisClient> GetClientAsync(CancellationToken token)
|
||||||
|
{
|
||||||
|
if (_client is not null) return _client;
|
||||||
|
await _clientGate.WaitAsync(token);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (_client is not null) return _client;
|
||||||
|
var credentials = settings.GetApiCredentials()
|
||||||
|
?? throw new WebUntisIntegrationException("Bitte zuerst die WebUntis-Anmeldedaten einrichten.");
|
||||||
|
return _client = CreateClient(credentials);
|
||||||
|
}
|
||||||
|
finally { _clientGate.Release(); }
|
||||||
|
}
|
||||||
|
|
||||||
|
private WebUntisClient CreateClient(WebUntisCredentials credentials) => new(http, new WebUntisOptions
|
||||||
|
{
|
||||||
|
School = credentials.School, Host = credentials.Host, Username = credentials.Username,
|
||||||
|
Password = credentials.Password, Client = "LehrerApp-Desktop", SessionIdleTimeoutMinutes = 10,
|
||||||
|
});
|
||||||
|
|
||||||
|
private static int Date(DateOnly date) => date.Year * 10000 + date.Month * 100 + date.Day;
|
||||||
|
private static IReadOnlyList<UntisEntityDto> Entities(IReadOnlyList<UntisEntity> values) => values
|
||||||
|
.Select(x => new UntisEntityDto(x.Id, x.Name, x.OriginalId, x.OriginalName, x.ExternalKey)).ToList();
|
||||||
|
private static bool IsUntisError(Exception exception) => exception is WebUntisException
|
||||||
|
or WebUntisConfigurationException or InvalidDataException;
|
||||||
|
private static WebUntisIntegrationException Translate(Exception exception) =>
|
||||||
|
new(exception.Message);
|
||||||
|
|
||||||
|
public async ValueTask DisposeAsync() => await DisconnectAsync().ConfigureAwait(false);
|
||||||
|
}
|
||||||
@@ -9,8 +9,14 @@ internal class WebUntisSettingsConfig
|
|||||||
public string? EncryptedIcalUrl { get; set; }
|
public string? EncryptedIcalUrl { get; set; }
|
||||||
public DateTime? LastSyncAt { get; set; }
|
public DateTime? LastSyncAt { get; set; }
|
||||||
public string LastSyncStatus { get; set; } = "";
|
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);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Einstellungen für den WebUntis-iCal-Abgleich (Nutzer-Feedback, siehe TODO.md). Liegt wie
|
/// Einstellungen für den WebUntis-iCal-Abgleich (Nutzer-Feedback, siehe TODO.md). Liegt wie
|
||||||
/// AiSettingsService/SyncSettingsService in LehrerApp.Desktop statt LehrerApp.Core, da die
|
/// AiSettingsService/SyncSettingsService in LehrerApp.Desktop statt LehrerApp.Core, da die
|
||||||
@@ -30,6 +36,10 @@ public class WebUntisSettingsService
|
|||||||
|
|
||||||
public bool Enabled => _config.Enabled;
|
public bool Enabled => _config.Enabled;
|
||||||
public bool IsConfigured => _config.EncryptedIcalUrl is not null;
|
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 DateTime? LastSyncAt => _config.LastSyncAt;
|
||||||
public string LastSyncStatus => _config.LastSyncStatus;
|
public string LastSyncStatus => _config.LastSyncStatus;
|
||||||
|
|
||||||
@@ -63,6 +73,41 @@ public class WebUntisSettingsService
|
|||||||
Save();
|
Save();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public void SetApiCredentials(WebUntisCredentials credentials)
|
||||||
|
{
|
||||||
|
_config.EncryptedApiCredentials = SyncCrypto.EncryptObject(credentials, _urlKey);
|
||||||
|
Save();
|
||||||
|
}
|
||||||
|
|
||||||
|
public WebUntisCredentials? GetApiCredentials() => _config.EncryptedApiCredentials is null
|
||||||
|
? null
|
||||||
|
: SyncCrypto.DecryptObject<WebUntisCredentials>(_config.EncryptedApiCredentials, _urlKey);
|
||||||
|
|
||||||
|
public void ClearApiCredentials()
|
||||||
|
{
|
||||||
|
_config.EncryptedApiCredentials = null;
|
||||||
|
_config.TeacherUntisId = null;
|
||||||
|
_config.HomeroomClassUntisId = null;
|
||||||
|
_config.HomeroomClassName = null;
|
||||||
|
Save();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void SetTeacherUntisId(int? teacherUntisId)
|
||||||
|
{
|
||||||
|
_config.TeacherUntisId = teacherUntisId;
|
||||||
|
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)
|
public void SetLastSync(DateTime at, string status)
|
||||||
{
|
{
|
||||||
_config.LastSyncAt = at;
|
_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 AttendanceCard => Card("attendance");
|
||||||
public DashboardCardOption SupportCard => Card("support");
|
public DashboardCardOption SupportCard => Card("support");
|
||||||
public DashboardCardOption GroupsCard => Card("groups");
|
public DashboardCardOption GroupsCard => Card("groups");
|
||||||
|
public int TodayLessonCount => TodaysLessons.Count;
|
||||||
|
public int 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,
|
public DashboardViewModel(IGroupRepository groups, ISubjectRepository subjects, ILessonRepository lessons,
|
||||||
IExamRepository exams, IExamResultRepository examResults, IGradeRepository grades,
|
IExamRepository exams, IExamResultRepository examResults, IGradeRepository grades,
|
||||||
@@ -204,6 +213,31 @@ public partial class DashboardViewModel : ObservableObject
|
|||||||
LoadOpenCorrections(groups, today);
|
LoadOpenCorrections(groups, today);
|
||||||
LoadUnplannedLessons(groups, today);
|
LoadUnplannedLessons(groups, today);
|
||||||
LoadAlerts(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()
|
private async Task LoadWeatherAsync()
|
||||||
@@ -352,12 +386,10 @@ public partial class DashboardViewModel : ObservableObject
|
|||||||
.Where(e => e.Status is ExamStatus.Conducted or ExamStatus.Graded)
|
.Where(e => e.Status is ExamStatus.Conducted or ExamStatus.Graded)
|
||||||
.OrderBy(e => e.Date))
|
.OrderBy(e => e.Date))
|
||||||
{
|
{
|
||||||
var expected = _memberships.GetByGroup(group.Id)
|
var (expected, evaluated) = ExamCorrectionCounter.Count(exam,
|
||||||
.Count(m => GroupMembershipService.IsActiveOn(m, exam.Date));
|
_memberships.GetByGroup(group.Id), _examResults.GetByExam(exam.Id));
|
||||||
var evaluated = _examResults.GetByExam(exam.Id)
|
|
||||||
.Count(r => r.Absent || !string.IsNullOrWhiteSpace(r.Grade) || r.Points.Count > 0);
|
|
||||||
OpenCorrections.Add(new CorrectionProgressItem(exam.Id, group.Id, exam.Title,
|
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;
|
entry.Attendance = status;
|
||||||
_participationEntries.Save(entry);
|
_participationEntries.Save(entry);
|
||||||
OpenExcuses.Remove(item);
|
OpenExcuses.Remove(item);
|
||||||
|
UpdateDashboardSummary();
|
||||||
}
|
}
|
||||||
|
|
||||||
private void LoadCalendar()
|
private void LoadCalendar()
|
||||||
@@ -1034,18 +1067,29 @@ public sealed class DashboardAlertItem(Guid studentId, Guid? groupId, string stu
|
|||||||
public partial class DashboardCardOption : ObservableObject
|
public partial class DashboardCardOption : ObservableObject
|
||||||
{
|
{
|
||||||
[ObservableProperty] private bool _isVisible;
|
[ObservableProperty] private bool _isVisible;
|
||||||
|
[ObservableProperty] private bool _isEmpty;
|
||||||
[ObservableProperty] private int _row;
|
[ObservableProperty] private int _row;
|
||||||
[ObservableProperty] private int _column;
|
[ObservableProperty] private int _column;
|
||||||
public string Key { get; }
|
public string Key { get; }
|
||||||
public string Title { get; }
|
public string Title { get; }
|
||||||
|
public bool HideWhenEmpty { get; }
|
||||||
|
public bool EffectiveIsVisible => IsVisible && (!HideWhenEmpty || !IsEmpty);
|
||||||
public Action? OnVisibilityChanged { get; set; }
|
public Action? OnVisibilityChanged { get; set; }
|
||||||
|
|
||||||
public DashboardCardOption(string key, string title, bool isVisible)
|
public DashboardCardOption(string key, string title, bool isVisible)
|
||||||
{
|
{
|
||||||
Key = key;
|
Key = key;
|
||||||
Title = title;
|
Title = title;
|
||||||
|
HideWhenEmpty = key is "excuses" or "upcoming" or "corrections" or "unplanned"
|
||||||
|
or "alerts" or "attendance" or "support";
|
||||||
_isVisible = isVisible;
|
_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.ComponentModel;
|
||||||
|
using CommunityToolkit.Mvvm.Input;
|
||||||
using LehrerApp.Core.Interfaces;
|
using LehrerApp.Core.Interfaces;
|
||||||
using LehrerApp.Core.Models;
|
using LehrerApp.Core.Models;
|
||||||
using LehrerApp.Core.Services;
|
using LehrerApp.Core.Services;
|
||||||
@@ -52,6 +53,16 @@ public partial class ExamGradingDialogViewModel : ObservableObject
|
|||||||
|
|
||||||
private void SaveRow(ExamResultRow row) => _results.Save(row.ToModel(_exam.Id));
|
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 ──────────────────────────────────────────────────
|
// ── Zeile im Punkteraster ──────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ public partial class ExamDialogViewModel : ObservableObject
|
|||||||
private readonly int _gradeLevel;
|
private readonly int _gradeLevel;
|
||||||
private readonly GradingSystem _gradingSystem;
|
private readonly GradingSystem _gradingSystem;
|
||||||
private readonly Exam? _editingExam;
|
private readonly Exam? _editingExam;
|
||||||
|
private readonly Exam? _duplicateSource;
|
||||||
private readonly string _subjectName;
|
private readonly string _subjectName;
|
||||||
|
|
||||||
[ObservableProperty] private string _title = "";
|
[ObservableProperty] private string _title = "";
|
||||||
@@ -68,6 +69,7 @@ public partial class ExamDialogViewModel : ObservableObject
|
|||||||
_groupId = groupId; _subjectId = subjectId; _gradeLevel = gradeLevel;
|
_groupId = groupId; _subjectId = subjectId; _gradeLevel = gradeLevel;
|
||||||
_gradingSystem = gradingSystem;
|
_gradingSystem = gradingSystem;
|
||||||
_editingExam = editingExam;
|
_editingExam = editingExam;
|
||||||
|
_duplicateSource = duplicateSource;
|
||||||
_subjectName = defaultSubjectName;
|
_subjectName = defaultSubjectName;
|
||||||
IsDifferentiated = isDifferentiated;
|
IsDifferentiated = isDifferentiated;
|
||||||
|
|
||||||
@@ -304,6 +306,11 @@ public partial class ExamDialogViewModel : ObservableObject
|
|||||||
Result.Niveau = NiveauDisplay.FromName(SelectedNiveauName);
|
Result.Niveau = NiveauDisplay.FromName(SelectedNiveauName);
|
||||||
Result.Tasks = Tasks.Select(t => t.ToModel()).ToList();
|
Result.Tasks = Tasks.Select(t => t.ToModel()).ToList();
|
||||||
Result.GradingKey = gradingKey;
|
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);
|
_exams.Save(Result);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,13 +13,8 @@ namespace LehrerApp.Desktop.ViewModels.Groups;
|
|||||||
|
|
||||||
public partial class GroupListViewModel : ObservableObject
|
public partial class GroupListViewModel : ObservableObject
|
||||||
{
|
{
|
||||||
private const int QuickTasksMaxCount = 3;
|
|
||||||
|
|
||||||
private readonly IGroupRepository _groups;
|
private readonly IGroupRepository _groups;
|
||||||
private readonly ISubjectRepository _subjects;
|
private readonly ISubjectRepository _subjects;
|
||||||
private readonly ILessonRepository _lessons;
|
|
||||||
private readonly IExamRepository _exams;
|
|
||||||
private readonly IWorkTaskRepository _tasks;
|
|
||||||
|
|
||||||
public Action<Guid, int>? OnNavigateToDetail { get; set; }
|
public Action<Guid, int>? OnNavigateToDetail { get; set; }
|
||||||
public Func<Task>? OnAddGroup { get; set; }
|
public Func<Task>? OnAddGroup { get; set; }
|
||||||
@@ -29,11 +24,8 @@ public partial class GroupListViewModel : ObservableObject
|
|||||||
|
|
||||||
[ObservableProperty] private string _selectedSchoolYear = "";
|
[ObservableProperty] private string _selectedSchoolYear = "";
|
||||||
[ObservableProperty] private string _searchText = "";
|
[ObservableProperty] private string _searchText = "";
|
||||||
[ObservableProperty] private GroupListItem? _selectedGroup;
|
|
||||||
[ObservableProperty] private bool _showArchived;
|
[ObservableProperty] private bool _showArchived;
|
||||||
|
|
||||||
public string SelectedGroupDisplayName => SelectedGroup?.DisplayName ?? "";
|
|
||||||
public string SelectedGroupSubtitle => SelectedGroup?.Subtitle ?? "";
|
|
||||||
public string ListSummary => ShowArchived
|
public string ListSummary => ShowArchived
|
||||||
? $"{Groups.Count} archivierte Gruppen · {SelectedSchoolYear}"
|
? $"{Groups.Count} archivierte Gruppen · {SelectedSchoolYear}"
|
||||||
: $"{Groups.Count} aktive Gruppen · {SelectedSchoolYear}";
|
: $"{Groups.Count} aktive Gruppen · {SelectedSchoolYear}";
|
||||||
@@ -45,22 +37,10 @@ public partial class GroupListViewModel : ObservableObject
|
|||||||
public ObservableCollection<string> SchoolYears { get; } = [];
|
public ObservableCollection<string> SchoolYears { get; } = [];
|
||||||
public ObservableCollection<GroupListItem> Groups { get; } = [];
|
public ObservableCollection<GroupListItem> Groups { get; } = [];
|
||||||
|
|
||||||
// ── Schnellüberblick im Auswahl-Panel (Nutzer-Feedback: "oberhalb der Buttonliste ein paar
|
public GroupListViewModel(IGroupRepository groups, ISubjectRepository subjects, SchoolYearService sy)
|
||||||
// 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)
|
|
||||||
{
|
{
|
||||||
_groups = groups; _subjects = subjects; _lessons = lessons; _exams = exams; _tasks = tasks;
|
_groups = groups;
|
||||||
|
_subjects = subjects;
|
||||||
foreach (var y in sy.RecentSchoolYears()) SchoolYears.Add(y);
|
foreach (var y in sy.RecentSchoolYears()) SchoolYears.Add(y);
|
||||||
SelectedSchoolYear = sy.CurrentSchoolYear();
|
SelectedSchoolYear = sy.CurrentSchoolYear();
|
||||||
}
|
}
|
||||||
@@ -68,58 +48,8 @@ public partial class GroupListViewModel : ObservableObject
|
|||||||
partial void OnSelectedSchoolYearChanged(string value) => LoadGroups();
|
partial void OnSelectedSchoolYearChanged(string value) => LoadGroups();
|
||||||
partial void OnSearchTextChanged(string value) => LoadGroups();
|
partial void OnSearchTextChanged(string value) => LoadGroups();
|
||||||
partial void OnShowArchivedChanged(bool 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()
|
public void LoadGroups()
|
||||||
{
|
{
|
||||||
var selectedId = SelectedGroup?.Id;
|
|
||||||
Groups.Clear();
|
Groups.Clear();
|
||||||
var all = _groups.GetBySchoolYear(SelectedSchoolYear, includeInactive: ShowArchived)
|
var all = _groups.GetBySchoolYear(SelectedSchoolYear, includeInactive: ShowArchived)
|
||||||
.Where(g => g.IsActive != ShowArchived);
|
.Where(g => g.IsActive != ShowArchived);
|
||||||
@@ -129,8 +59,18 @@ public partial class GroupListViewModel : ObservableObject
|
|||||||
|| (g.SubjectId is Guid id && subjectNames.GetValueOrDefault(id, "")
|
|| (g.SubjectId is Guid id && subjectNames.GetValueOrDefault(id, "")
|
||||||
.Contains(SearchText, StringComparison.OrdinalIgnoreCase)));
|
.Contains(SearchText, StringComparison.OrdinalIgnoreCase)));
|
||||||
foreach (var g in filtered.OrderBy(g => g.Name))
|
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(ListSummary));
|
||||||
OnPropertyChanged(nameof(HasNoGroups));
|
OnPropertyChanged(nameof(HasNoGroups));
|
||||||
OnPropertyChanged(nameof(EmptyListMessage));
|
OnPropertyChanged(nameof(EmptyListMessage));
|
||||||
@@ -145,63 +85,57 @@ public partial class GroupListViewModel : ObservableObject
|
|||||||
}
|
}
|
||||||
[RelayCommand] private void Refresh() => LoadGroups();
|
[RelayCommand] private void Refresh() => LoadGroups();
|
||||||
|
|
||||||
[RelayCommand(CanExecute = nameof(CanEditSelectedGroup))]
|
[RelayCommand]
|
||||||
private async Task EditGroup()
|
private void OpenGroup(GroupListItem? group)
|
||||||
{
|
{
|
||||||
if (SelectedGroup is null || OnEditGroup is null) return;
|
if (group is not null) OnNavigateToDetail?.Invoke(group.Id, 0);
|
||||||
var id = SelectedGroup.Id;
|
|
||||||
await OnEditGroup(id);
|
|
||||||
LoadGroups();
|
|
||||||
SelectedGroup = Groups.FirstOrDefault(g => g.Id == id);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[RelayCommand(CanExecute = nameof(HasSelectedGroup))]
|
[RelayCommand]
|
||||||
private async Task RollOverGroup()
|
private async Task EditGroup(GroupListItem? group)
|
||||||
{
|
{
|
||||||
if (SelectedGroup is null || OnRollOverGroup is null) return;
|
if (group?.IsActive != true || OnEditGroup is null) return;
|
||||||
var targetId = await OnRollOverGroup(SelectedGroup.Id);
|
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;
|
if (targetId is null) return;
|
||||||
var target = _groups.GetById(targetId.Value);
|
var target = _groups.GetById(targetId.Value);
|
||||||
if (target is null) return;
|
if (target is null) return;
|
||||||
if (!SchoolYears.Contains(target.SchoolYear)) SchoolYears.Insert(0, target.SchoolYear);
|
if (!SchoolYears.Contains(target.SchoolYear)) SchoolYears.Insert(0, target.SchoolYear);
|
||||||
SelectedSchoolYear = target.SchoolYear;
|
SelectedSchoolYear = target.SchoolYear;
|
||||||
LoadGroups();
|
LoadGroups();
|
||||||
SelectedGroup = Groups.FirstOrDefault(g => g.Id == target.Id);
|
OnNavigateToDetail?.Invoke(target.Id, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
[RelayCommand(CanExecute = nameof(HasSelectedGroup))]
|
[RelayCommand]
|
||||||
private void ToggleArchive()
|
private void ToggleArchive(GroupListItem? selected)
|
||||||
{
|
{
|
||||||
if (SelectedGroup is null) return;
|
if (selected is null) return;
|
||||||
var group = _groups.GetById(SelectedGroup.Id);
|
var group = _groups.GetById(selected.Id);
|
||||||
if (group is null) return;
|
if (group is null) return;
|
||||||
group.IsActive = !group.IsActive;
|
group.IsActive = !group.IsActive;
|
||||||
_groups.Save(group);
|
_groups.Save(group);
|
||||||
LoadGroups();
|
LoadGroups();
|
||||||
}
|
}
|
||||||
|
|
||||||
[RelayCommand(CanExecute = nameof(CanEditSelectedGroup))]
|
[RelayCommand]
|
||||||
private async Task DeleteGroup()
|
private async Task DeleteGroup(GroupListItem? group)
|
||||||
{
|
{
|
||||||
if (SelectedGroup is null || OnConfirmDelete is null) return;
|
if (group?.IsActive != true || OnConfirmDelete is null) return;
|
||||||
var selected = SelectedGroup;
|
if (!await OnConfirmDelete(group)) return;
|
||||||
if (!await OnConfirmDelete(selected)) return;
|
_groups.Delete(group.Id);
|
||||||
_groups.Delete(selected.Id);
|
|
||||||
LoadGroups();
|
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 Guid Id { get; }
|
||||||
public string Name { get; }
|
public string Name { get; }
|
||||||
@@ -212,6 +146,13 @@ public class GroupListItem
|
|||||||
public string Subtitle { get; }
|
public string Subtitle { get; }
|
||||||
public bool IsActive { get; }
|
public bool IsActive { get; }
|
||||||
public string ArchiveActionLabel => IsActive ? "Archivieren" : "Wieder aktivieren";
|
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)
|
public GroupListItem(LearningGroup g, string subjectName)
|
||||||
{
|
{
|
||||||
@@ -224,6 +165,12 @@ public class GroupListItem
|
|||||||
DisplayName = string.IsNullOrEmpty(subjectName) ? g.Name : $"{g.Name} · {subjectName}";
|
DisplayName = string.IsNullOrEmpty(subjectName) ? g.Name : $"{g.Name} · {subjectName}";
|
||||||
Subtitle = $"{TypeLabel} · Stufe {g.GradeLevel} · Noten {GradingLabel} · {g.SchoolYear}";
|
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 ─────────────────────────────────────────────────────────────
|
// ── Gruppendetail ─────────────────────────────────────────────────────────────
|
||||||
@@ -878,6 +825,7 @@ public partial class AddGroupDialogViewModel : ObservableObject
|
|||||||
[ObservableProperty] private bool _isOwnClass;
|
[ObservableProperty] private bool _isOwnClass;
|
||||||
[ObservableProperty] private bool _isDifferentiated;
|
[ObservableProperty] private bool _isDifferentiated;
|
||||||
[ObservableProperty] private bool _requiresLessonPlanning = true;
|
[ObservableProperty] private bool _requiresLessonPlanning = true;
|
||||||
|
[ObservableProperty] private int? _webUntisLessonId;
|
||||||
[ObservableProperty] private string _nameError = "";
|
[ObservableProperty] private string _nameError = "";
|
||||||
[ObservableProperty] private string _gradeLevelError = "";
|
[ObservableProperty] private string _gradeLevelError = "";
|
||||||
|
|
||||||
@@ -917,6 +865,7 @@ public partial class AddGroupDialogViewModel : ObservableObject
|
|||||||
IsOwnClass = group.IsOwnClass;
|
IsOwnClass = group.IsOwnClass;
|
||||||
IsDifferentiated = group.IsDifferentiated;
|
IsDifferentiated = group.IsDifferentiated;
|
||||||
RequiresLessonPlanning = group.RequiresLessonPlanning;
|
RequiresLessonPlanning = group.RequiresLessonPlanning;
|
||||||
|
WebUntisLessonId = group.WebUntisLessonId;
|
||||||
OnPropertyChanged(nameof(DialogTitle));
|
OnPropertyChanged(nameof(DialogTitle));
|
||||||
OnPropertyChanged(nameof(SaveButtonText));
|
OnPropertyChanged(nameof(SaveButtonText));
|
||||||
}
|
}
|
||||||
@@ -963,6 +912,7 @@ public partial class AddGroupDialogViewModel : ObservableObject
|
|||||||
Result.IsOwnClass = IsOwnClass;
|
Result.IsOwnClass = IsOwnClass;
|
||||||
Result.IsDifferentiated = IsDifferentiated;
|
Result.IsDifferentiated = IsDifferentiated;
|
||||||
Result.RequiresLessonPlanning = RequiresLessonPlanning;
|
Result.RequiresLessonPlanning = RequiresLessonPlanning;
|
||||||
|
Result.WebUntisLessonId = WebUntisLessonId;
|
||||||
_groups.Save(Result);
|
_groups.Save(Result);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -236,6 +236,13 @@ public partial class SeatingPlanTabViewModel : ObservableObject
|
|||||||
NotifyCommands();
|
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()
|
private void RefreshSeatLessonData()
|
||||||
{
|
{
|
||||||
if (SelectedSession is null)
|
if (SelectedSession is null)
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
using CommunityToolkit.Mvvm.ComponentModel;
|
||||||
|
using CommunityToolkit.Mvvm.Input;
|
||||||
using LehrerApp.Core.Interfaces;
|
using LehrerApp.Core.Interfaces;
|
||||||
using LehrerApp.Core.Models;
|
using LehrerApp.Core.Models;
|
||||||
|
|
||||||
@@ -10,24 +12,104 @@ namespace LehrerApp.Desktop.ViewModels.Groups;
|
|||||||
/// der "Heute"-Tagesliste heraus gestartet.
|
/// der "Heute"-Tagesliste heraus gestartet.
|
||||||
///
|
///
|
||||||
/// Bewusst keine eigene DI-Registrierung als Singleton/Transient: wie LessonViewerViewModel wird
|
/// Bewusst keine eigene DI-Registrierung als Singleton/Transient: wie LessonViewerViewModel wird
|
||||||
/// diese ViewModel direkt vom Code-Behind konstruiert, das dafür nötige SeatingPlanTabViewModel
|
/// 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
|
/// ParticipationTabViewModel aber weiterhin über DI aufgelöst (transient, siehe AppBootstrapper)
|
||||||
/// entgegengenommen statt selbst aus App.Services zu ziehen - ViewModels greifen in diesem Code
|
/// und hier per Konstruktor entgegengenommen statt selbst aus App.Services zu ziehen -
|
||||||
/// nicht selbst auf den Service-Container zu, das bleibt Aufgabe des Code-Behind.
|
/// ViewModels greifen in diesem Code nicht selbst auf den Service-Container zu, das bleibt
|
||||||
|
/// Aufgabe des Code-Behind.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class TeachingModeViewModel
|
public class TeachingModeViewModel
|
||||||
{
|
{
|
||||||
public string GroupName { get; }
|
public string GroupName { get; }
|
||||||
public LessonViewerViewModel LessonInfo { get; }
|
public LessonViewerViewModel LessonInfo { get; }
|
||||||
public SeatingPlanTabViewModel SeatingPlan { 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,
|
public TeachingModeViewModel(Lesson lesson, LearningGroup group,
|
||||||
IAlternativeLessonPathRepository alternativePaths, SeatingPlanTabViewModel seatingPlan)
|
IAlternativeLessonPathRepository alternativePaths, ILessonRepository lessons,
|
||||||
|
SeatingPlanTabViewModel seatingPlan, ParticipationTabViewModel participation)
|
||||||
{
|
{
|
||||||
GroupName = group.Name;
|
GroupName = group.Name;
|
||||||
LessonInfo = new LessonViewerViewModel(lesson, alternativePaths);
|
LessonInfo = new LessonViewerViewModel(lesson, alternativePaths);
|
||||||
|
|
||||||
SeatingPlan = seatingPlan;
|
SeatingPlan = seatingPlan;
|
||||||
SeatingPlan.Initialize(group.Id, !group.IsActive);
|
SeatingPlan.Initialize(group.Id, !group.IsActive);
|
||||||
SeatingPlan.SelectOrCreateSessionForLesson(lesson);
|
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.";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
using System.Collections.ObjectModel;
|
||||||
|
using CommunityToolkit.Mvvm.ComponentModel;
|
||||||
|
using CommunityToolkit.Mvvm.Input;
|
||||||
|
using LehrerApp.Desktop.Services;
|
||||||
|
|
||||||
|
namespace LehrerApp.Desktop.ViewModels.Groups;
|
||||||
|
|
||||||
|
public partial class WebUntisClassSelectionViewModel(WebUntisIntegrationService untis) : ObservableObject
|
||||||
|
{
|
||||||
|
public ObservableCollection<UntisSchoolYearDto> SchoolYears { get; } = [];
|
||||||
|
public ObservableCollection<UntisClassDto> Classes { get; } = [];
|
||||||
|
[ObservableProperty] private UntisSchoolYearDto? _selectedSchoolYear;
|
||||||
|
[ObservableProperty] private UntisClassDto? _selectedClass;
|
||||||
|
[ObservableProperty] private string _status = "";
|
||||||
|
[ObservableProperty] private bool _busy;
|
||||||
|
public bool CanConfirm => SelectedClass is not null && !Busy;
|
||||||
|
|
||||||
|
partial void OnSelectedClassChanged(UntisClassDto? value) => OnPropertyChanged(nameof(CanConfirm));
|
||||||
|
partial void OnBusyChanged(bool value) => OnPropertyChanged(nameof(CanConfirm));
|
||||||
|
|
||||||
|
public async Task InitializeAsync()
|
||||||
|
{
|
||||||
|
Busy = true;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
foreach (var year in (await untis.GetSchoolYearsAsync()).OrderByDescending(x => x.StartDate))
|
||||||
|
SchoolYears.Add(year);
|
||||||
|
SelectedSchoolYear = SchoolYears.FirstOrDefault(x => x.StartDate <= Today() && x.EndDate >= Today())
|
||||||
|
?? SchoolYears.FirstOrDefault();
|
||||||
|
await LoadClasses();
|
||||||
|
}
|
||||||
|
catch (WebUntisIntegrationException ex) { Status = ex.Message; }
|
||||||
|
finally { Busy = false; }
|
||||||
|
}
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
private async Task LoadClasses()
|
||||||
|
{
|
||||||
|
if (SelectedSchoolYear is null) return;
|
||||||
|
Busy = true; Classes.Clear(); SelectedClass = null;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
foreach (var entry in (await untis.GetClassesAsync(SelectedSchoolYear.UntisId)).OrderBy(x => x.Name))
|
||||||
|
Classes.Add(entry);
|
||||||
|
Status = Classes.Count == 0 ? "Keine Klassen gefunden." : $"{Classes.Count} Klassen gefunden.";
|
||||||
|
}
|
||||||
|
catch (WebUntisIntegrationException ex) { Status = ex.Message; }
|
||||||
|
finally { Busy = false; }
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int Today()
|
||||||
|
{
|
||||||
|
var date = DateOnly.FromDateTime(DateTime.Today);
|
||||||
|
return date.Year * 10000 + date.Month * 100 + date.Day;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 CommunityToolkit.Mvvm.Input;
|
||||||
using LehrerApp.Core.Services;
|
using LehrerApp.Core.Services;
|
||||||
using LehrerApp.Desktop.Services;
|
using LehrerApp.Desktop.Services;
|
||||||
|
using LehrerApp.Desktop.ViewModels.ClassTeacher;
|
||||||
|
using LehrerApp.Desktop.ViewModels.Exams;
|
||||||
using LehrerApp.Desktop.ViewModels.Groups;
|
using LehrerApp.Desktop.ViewModels.Groups;
|
||||||
using LehrerApp.Desktop.ViewModels.Planning;
|
using LehrerApp.Desktop.ViewModels.Planning;
|
||||||
using LehrerApp.Desktop.ViewModels.Settings;
|
using LehrerApp.Desktop.ViewModels.Settings;
|
||||||
@@ -20,10 +22,12 @@ public partial class MainWindowViewModel : ObservableObject
|
|||||||
[ObservableProperty] private ObservableObject? _currentPage;
|
[ObservableProperty] private ObservableObject? _currentPage;
|
||||||
[ObservableProperty] private NavItem _activeNavItem = NavItem.Dashboard;
|
[ObservableProperty] private NavItem _activeNavItem = NavItem.Dashboard;
|
||||||
[ObservableProperty] private string _currentSchoolYear = "";
|
[ObservableProperty] private string _currentSchoolYear = "";
|
||||||
|
[ObservableProperty] private bool _isCommandPaletteOpen;
|
||||||
|
|
||||||
public SyncStatusViewModel SyncStatus { get; }
|
public SyncStatusViewModel SyncStatus { get; }
|
||||||
public ObservableCollection<ToastItem> Toasts { get; }
|
public ObservableCollection<ToastItem> Toasts { get; }
|
||||||
public AppLockViewModel AppLock { get; }
|
public AppLockViewModel AppLock { get; }
|
||||||
|
public GlobalSearchViewModel CommandPalette { get; }
|
||||||
|
|
||||||
public bool IsDashboardActive => ActiveNavItem == NavItem.Dashboard;
|
public bool IsDashboardActive => ActiveNavItem == NavItem.Dashboard;
|
||||||
public bool IsGroupsActive => ActiveNavItem == NavItem.Groups;
|
public bool IsGroupsActive => ActiveNavItem == NavItem.Groups;
|
||||||
@@ -31,22 +35,37 @@ public partial class MainWindowViewModel : ObservableObject
|
|||||||
public bool IsExamsActive => ActiveNavItem == NavItem.Exams;
|
public bool IsExamsActive => ActiveNavItem == NavItem.Exams;
|
||||||
public bool IsPlannerActive => ActiveNavItem == NavItem.Planner;
|
public bool IsPlannerActive => ActiveNavItem == NavItem.Planner;
|
||||||
public bool IsWorkloadActive => ActiveNavItem == NavItem.Workload;
|
public bool IsWorkloadActive => ActiveNavItem == NavItem.Workload;
|
||||||
|
public bool IsClassTeacherActive => ActiveNavItem == NavItem.ClassTeacher;
|
||||||
public bool IsSettingsActive => ActiveNavItem == NavItem.Settings;
|
public bool IsSettingsActive => ActiveNavItem == NavItem.Settings;
|
||||||
|
|
||||||
public MainWindowViewModel(IServiceProvider services,
|
public MainWindowViewModel(IServiceProvider services,
|
||||||
DashboardViewModel dashboard, SchoolYearService sy,
|
DashboardViewModel dashboard, SchoolYearService sy,
|
||||||
SyncStatusViewModel syncStatus, NotificationService notifications, AppLockViewModel appLock)
|
SyncStatusViewModel syncStatus, NotificationService notifications, AppLockViewModel appLock,
|
||||||
|
GlobalSearchViewModel commandPalette)
|
||||||
{
|
{
|
||||||
_services = services;
|
_services = services;
|
||||||
SyncStatus = syncStatus;
|
SyncStatus = syncStatus;
|
||||||
Toasts = notifications.Toasts;
|
Toasts = notifications.Toasts;
|
||||||
AppLock = appLock;
|
AppLock = appLock;
|
||||||
|
CommandPalette = commandPalette;
|
||||||
|
CommandPalette.OnClose = CloseCommandPalette;
|
||||||
CurrentSchoolYear = sy.CurrentSchoolYear();
|
CurrentSchoolYear = sy.CurrentSchoolYear();
|
||||||
CurrentPage = dashboard;
|
CurrentPage = dashboard;
|
||||||
AppLock.ApplyConfig();
|
AppLock.ApplyConfig();
|
||||||
SyncStatus.DataChanged += OnSyncDataChanged;
|
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
|
// EventApplier schreibt bei eingehenden Sync-Ereignissen absichtlich direkt auf die rohe
|
||||||
// LiteDB-Collection, an jedem ViewModel vorbei (Ping-Pong-Vermeidung, siehe EventApplier-
|
// LiteDB-Collection, an jedem ViewModel vorbei (Ping-Pong-Vermeidung, siehe EventApplier-
|
||||||
// Klassenkommentar) - ohne diesen Hook blieb die gerade sichtbare Seite bis zum nächsten
|
// 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 TimetableViewModel vm: vm.Load(); break;
|
||||||
case WorkloadViewModel vm:
|
case WorkloadViewModel vm:
|
||||||
vm.Tasks.Load(); vm.TimeTracking.Load(); vm.Evaluation.Load(); break;
|
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;
|
case GroupDetailViewModel { Group: { } group } vm: vm.LoadGroup(group.Id); break;
|
||||||
// Inline-Bearbeitung (IsEditing) nicht überschreiben - anders als die Gruppenansicht
|
// Inline-Bearbeitung (IsEditing) nicht überschreiben - anders als die Gruppenansicht
|
||||||
// laufen Namens-/Geschlechtsänderungen hier nicht über einen Dialog.
|
// laufen Namens-/Geschlechtsänderungen hier nicht über einen Dialog.
|
||||||
@@ -87,6 +108,7 @@ public partial class MainWindowViewModel : ObservableObject
|
|||||||
OnPropertyChanged(nameof(IsExamsActive));
|
OnPropertyChanged(nameof(IsExamsActive));
|
||||||
OnPropertyChanged(nameof(IsPlannerActive));
|
OnPropertyChanged(nameof(IsPlannerActive));
|
||||||
OnPropertyChanged(nameof(IsWorkloadActive));
|
OnPropertyChanged(nameof(IsWorkloadActive));
|
||||||
|
OnPropertyChanged(nameof(IsClassTeacherActive));
|
||||||
OnPropertyChanged(nameof(IsSettingsActive));
|
OnPropertyChanged(nameof(IsSettingsActive));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -99,9 +121,10 @@ public partial class MainWindowViewModel : ObservableObject
|
|||||||
NavItem.Dashboard => GetDashboard(),
|
NavItem.Dashboard => GetDashboard(),
|
||||||
NavItem.Groups => _services.GetRequiredService<GroupListViewModel>(),
|
NavItem.Groups => _services.GetRequiredService<GroupListViewModel>(),
|
||||||
NavItem.Students => GetStudents(),
|
NavItem.Students => GetStudents(),
|
||||||
NavItem.Exams => new PlaceholderViewModel { Title = "Klausuren", Icon = "📝" },
|
NavItem.Exams => GetExams(),
|
||||||
NavItem.Planner => GetTimetable(),
|
NavItem.Planner => GetTimetable(),
|
||||||
NavItem.Workload => GetWorkload(),
|
NavItem.Workload => GetWorkload(),
|
||||||
|
NavItem.ClassTeacher => GetClassTeacherOverview(),
|
||||||
NavItem.Settings => _services.GetRequiredService<SettingsViewModel>(),
|
NavItem.Settings => _services.GetRequiredService<SettingsViewModel>(),
|
||||||
_ => CurrentPage,
|
_ => CurrentPage,
|
||||||
};
|
};
|
||||||
@@ -141,6 +164,22 @@ public partial class MainWindowViewModel : ObservableObject
|
|||||||
return workload;
|
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)
|
public void NavigateToGroupDetail(Guid groupId, int initialTab = 0)
|
||||||
{
|
{
|
||||||
ActiveNavItem = NavItem.Groups;
|
ActiveNavItem = NavItem.Groups;
|
||||||
@@ -175,7 +214,7 @@ public partial class MainWindowViewModel : ObservableObject
|
|||||||
public void NavigateToWorkload() => NavigateTo(NavItem.Workload);
|
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
|
public partial class PlaceholderViewModel : ObservableObject
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -65,6 +65,7 @@ public partial class TimetableViewModel : ObservableObject
|
|||||||
private readonly ISubstitutionEntryRepository _substitutions;
|
private readonly ISubstitutionEntryRepository _substitutions;
|
||||||
private readonly IUntisSlotMappingRepository _untisMappings;
|
private readonly IUntisSlotMappingRepository _untisMappings;
|
||||||
private readonly WebUntisSettingsService _untisSettings;
|
private readonly WebUntisSettingsService _untisSettings;
|
||||||
|
private readonly PeriodScheduleService _periodSchedule;
|
||||||
private readonly SchoolWeatherService? _schoolWeather;
|
private readonly SchoolWeatherService? _schoolWeather;
|
||||||
private readonly SemaphoreSlim _weatherGate = new(1, 1);
|
private readonly SemaphoreSlim _weatherGate = new(1, 1);
|
||||||
private WeatherSnapshot? _weatherSnapshot;
|
private WeatherSnapshot? _weatherSnapshot;
|
||||||
@@ -103,23 +104,30 @@ public partial class TimetableViewModel : ObservableObject
|
|||||||
public Func<TimetableCellItem, Task>? OnEditSlot { get; set; }
|
public Func<TimetableCellItem, Task>? OnEditSlot { get; set; }
|
||||||
public Action<Guid>? OnNavigateToGroup { get; set; }
|
public Action<Guid>? OnNavigateToGroup { get; set; }
|
||||||
public Func<Task>? OnAddSubstitution { get; set; }
|
public Func<Task>? OnAddSubstitution { get; set; }
|
||||||
|
public Func<Task>? OnImportWebUntisTimetable { get; set; }
|
||||||
public Action<SettingsTab>? OnNavigateToSettings { get; set; }
|
public Action<SettingsTab>? OnNavigateToSettings { get; set; }
|
||||||
public Func<Lesson, Task>? OnOpenLessonViewer { get; set; }
|
public Func<Lesson, Task>? OnOpenLessonViewer { get; set; }
|
||||||
public Func<Lesson, Task>? OnOpenTeachingMode { get; set; }
|
public Func<Lesson, Task>? OnOpenTeachingMode { get; set; }
|
||||||
|
|
||||||
|
/// Ö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,
|
public TimetableViewModel(ITimetableSlotRepository slots, IGroupRepository groups,
|
||||||
ISubjectRepository subjects, ILessonRepository lessons, IExamRepository exams,
|
ISubjectRepository subjects, ILessonRepository lessons, IExamRepository exams,
|
||||||
ISchoolHolidayRepository schoolHolidays, SchoolCalendarSettingsService calendarSettings,
|
ISchoolHolidayRepository schoolHolidays, SchoolCalendarSettingsService calendarSettings,
|
||||||
PublicHolidayService publicHolidays, SchoolYearService schoolYear,
|
PublicHolidayService publicHolidays, SchoolYearService schoolYear,
|
||||||
ISupervisionDutyRepository supervisionDuties, ISubstitutionEntryRepository substitutions,
|
ISupervisionDutyRepository supervisionDuties, ISubstitutionEntryRepository substitutions,
|
||||||
IUntisSlotMappingRepository untisMappings, WebUntisSettingsService untisSettings,
|
IUntisSlotMappingRepository untisMappings, WebUntisSettingsService untisSettings,
|
||||||
SchoolWeatherService? schoolWeather = null)
|
PeriodScheduleService periodSchedule, SchoolWeatherService? schoolWeather = null)
|
||||||
{
|
{
|
||||||
_slots = slots; _groups = groups; _subjects = subjects; _lessons = lessons; _exams = exams;
|
_slots = slots; _groups = groups; _subjects = subjects; _lessons = lessons; _exams = exams;
|
||||||
_schoolHolidays = schoolHolidays; _calendarSettings = calendarSettings;
|
_schoolHolidays = schoolHolidays; _calendarSettings = calendarSettings;
|
||||||
_publicHolidays = publicHolidays; _schoolYear = schoolYear;
|
_publicHolidays = publicHolidays; _schoolYear = schoolYear;
|
||||||
_supervisionDuties = supervisionDuties; _substitutions = substitutions;
|
_supervisionDuties = supervisionDuties; _substitutions = substitutions;
|
||||||
_untisMappings = untisMappings; _untisSettings = untisSettings;
|
_untisMappings = untisMappings; _untisSettings = untisSettings;
|
||||||
|
_periodSchedule = periodSchedule;
|
||||||
_schoolWeather = schoolWeather;
|
_schoolWeather = schoolWeather;
|
||||||
Load();
|
Load();
|
||||||
}
|
}
|
||||||
@@ -143,9 +151,17 @@ public partial class TimetableViewModel : ObservableObject
|
|||||||
Load();
|
Load();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
private async Task ImportWebUntisTimetable()
|
||||||
|
{
|
||||||
|
if (OnImportWebUntisTimetable is null) return;
|
||||||
|
await OnImportWebUntisTimetable();
|
||||||
|
Load();
|
||||||
|
}
|
||||||
|
|
||||||
public void Load()
|
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"));
|
TodayLabel = today.ToString("dddd, dd.MM.yyyy", System.Globalization.CultureInfo.GetCultureInfo("de-DE"));
|
||||||
|
|
||||||
var publicHolidayDates = new HashSet<DateOnly>();
|
var publicHolidayDates = new HashSet<DateOnly>();
|
||||||
@@ -359,14 +375,42 @@ public partial class TimetableViewModel : ObservableObject
|
|||||||
await OnOpenTeachingMode(lesson);
|
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]
|
[RelayCommand]
|
||||||
private async Task OpenWeekCell(WeekCellItem? item)
|
private async Task OpenWeekCell(WeekCellItem? item)
|
||||||
{
|
{
|
||||||
if (item is null || item.GroupId == Guid.Empty) return;
|
if (item is null || item.GroupId == Guid.Empty) return;
|
||||||
if (item.Lesson is { } lesson && OnOpenLessonViewer is not null) await OnOpenLessonViewer(lesson);
|
if (item.Lesson is { } lesson)
|
||||||
|
{
|
||||||
|
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);
|
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]
|
[RelayCommand]
|
||||||
private void OpenSettings() => OnNavigateToSettings?.Invoke(SettingsTab.Holidays);
|
private void OpenSettings() => OnNavigateToSettings?.Invoke(SettingsTab.Holidays);
|
||||||
|
|
||||||
@@ -775,9 +819,11 @@ public partial class WeekCellItem : ObservableObject
|
|||||||
public bool HasSupervision => SupervisionLocation.Length > 0;
|
public bool HasSupervision => SupervisionLocation.Length > 0;
|
||||||
public bool HasRoom => !string.IsNullOrWhiteSpace(Room);
|
public bool HasRoom => !string.IsNullOrWhiteSpace(Room);
|
||||||
public bool HasTopic => !string.IsNullOrWhiteSpace(Topic);
|
public bool HasTopic => !string.IsNullOrWhiteSpace(Topic);
|
||||||
|
public bool HasGroupId => GroupId != Guid.Empty;
|
||||||
/// Nur bei einer regulären, zugewiesenen Zelle mit bereits existierender Lesson gesetzt —
|
/// Nur bei einer regulären, zugewiesenen Zelle mit bereits existierender Lesson gesetzt —
|
||||||
/// Grundlage für den Direktsprung in den Verlaufsplan-Viewer (4.5.2).
|
/// Grundlage für den Direktsprung in den Verlaufsplan-Viewer (4.5.2).
|
||||||
public Lesson? Lesson { get; private init; }
|
public Lesson? Lesson { get; private init; }
|
||||||
|
public bool HasLesson => Lesson is not null;
|
||||||
public string OpenButtonLabel => Lesson is not null ? "Verlaufsplan ansehen" : "Zur Lerngruppe";
|
public string OpenButtonLabel => Lesson is not null ? "Verlaufsplan ansehen" : "Zur Lerngruppe";
|
||||||
[ObservableProperty] private string _weatherSymbol = "";
|
[ObservableProperty] private string _weatherSymbol = "";
|
||||||
[ObservableProperty] private string _weatherTooltip = "";
|
[ObservableProperty] private string _weatherTooltip = "";
|
||||||
@@ -891,6 +937,9 @@ public class UpcomingExamItem(DateOnly date, string groupName, string title)
|
|||||||
public string Title { get; } = title;
|
public string Title { get; } = title;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public enum TimetableLessonDestination { TeachingMode, Viewer, SeatingPlan, Planning }
|
||||||
|
public sealed record TimetableDestinationOption(TimetableLessonDestination Kind, string Label);
|
||||||
|
|
||||||
public class TodayLessonItem
|
public class TodayLessonItem
|
||||||
{
|
{
|
||||||
public Guid GroupId { get; private init; }
|
public Guid GroupId { get; private init; }
|
||||||
|
|||||||
@@ -0,0 +1,159 @@
|
|||||||
|
using System.Collections.ObjectModel;
|
||||||
|
using CommunityToolkit.Mvvm.ComponentModel;
|
||||||
|
using CommunityToolkit.Mvvm.Input;
|
||||||
|
using LehrerApp.Core.Interfaces;
|
||||||
|
using LehrerApp.Core.Models;
|
||||||
|
using LehrerApp.Desktop.Services;
|
||||||
|
|
||||||
|
namespace LehrerApp.Desktop.ViewModels.Planning;
|
||||||
|
|
||||||
|
public sealed record WebUntisGroupOption(Guid Id, string DisplayName);
|
||||||
|
|
||||||
|
public partial class WebUntisTimetableRow : ObservableObject
|
||||||
|
{
|
||||||
|
public required DayOfWeek Weekday { get; init; }
|
||||||
|
public required int PeriodNumber { get; init; }
|
||||||
|
public required string TimeLabel { get; init; }
|
||||||
|
public required string UntisLabel { get; init; }
|
||||||
|
public required string SuggestedGroupName { get; init; }
|
||||||
|
public string? SubjectName { get; init; }
|
||||||
|
public string? Room { get; init; }
|
||||||
|
public ObservableCollection<WebUntisGroupOption> GroupOptions { get; } = [];
|
||||||
|
[ObservableProperty] private WebUntisGroupOption? _selectedGroup;
|
||||||
|
public string WeekdayLabel => Weekday switch
|
||||||
|
{
|
||||||
|
DayOfWeek.Monday => "Mo", DayOfWeek.Tuesday => "Di", DayOfWeek.Wednesday => "Mi",
|
||||||
|
DayOfWeek.Thursday => "Do", DayOfWeek.Friday => "Fr", _ => Weekday.ToString()[..2],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public partial class WebUntisTimetableImportViewModel : ObservableObject
|
||||||
|
{
|
||||||
|
private readonly WebUntisIntegrationService _untis;
|
||||||
|
private readonly WebUntisSettingsService _settings;
|
||||||
|
private readonly ITimetableSlotRepository _slots;
|
||||||
|
private readonly IGroupRepository _groups;
|
||||||
|
|
||||||
|
public ObservableCollection<UntisTeacherDto> Teachers { get; } = [];
|
||||||
|
public ObservableCollection<WebUntisTimetableRow> Rows { get; } = [];
|
||||||
|
[ObservableProperty] private UntisTeacherDto? _selectedTeacher;
|
||||||
|
[ObservableProperty] private DateTimeOffset _weekDate = DateTimeOffset.Now;
|
||||||
|
[ObservableProperty] private string _status = "Lehrkraft auswählen und Untis-Woche laden.";
|
||||||
|
[ObservableProperty] private bool _busy;
|
||||||
|
public bool Saved { get; private set; }
|
||||||
|
public Func<WebUntisTimetableRow, Task<LearningGroup?>>? OnCreateGroup { get; set; }
|
||||||
|
|
||||||
|
public WebUntisTimetableImportViewModel(WebUntisIntegrationService untis, WebUntisSettingsService settings,
|
||||||
|
ITimetableSlotRepository slots, IGroupRepository groups)
|
||||||
|
{
|
||||||
|
_untis = untis; _settings = settings; _slots = slots; _groups = groups;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task InitializeAsync()
|
||||||
|
{
|
||||||
|
Busy = true;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
foreach (var teacher in (await _untis.GetTeachersAsync()).Where(x => x.Active).OrderBy(x => x.DisplayName))
|
||||||
|
Teachers.Add(teacher);
|
||||||
|
SelectedTeacher = Teachers.FirstOrDefault(x => x.UntisId == _settings.TeacherUntisId)
|
||||||
|
?? Teachers.FirstOrDefault();
|
||||||
|
Status = Teachers.Count == 0 ? "WebUntis hat keine Lehrkräfte geliefert." : "Bereit zum Laden.";
|
||||||
|
}
|
||||||
|
catch (WebUntisIntegrationException ex) { Status = ex.Message; }
|
||||||
|
finally { Busy = false; }
|
||||||
|
}
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
private async Task Load()
|
||||||
|
{
|
||||||
|
if (SelectedTeacher is null) { Status = "Bitte eine Lehrkraft auswählen."; return; }
|
||||||
|
Busy = true; Rows.Clear();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var selected = DateOnly.FromDateTime(WeekDate.LocalDateTime);
|
||||||
|
var monday = selected.AddDays(-(((int)selected.DayOfWeek + 6) % 7));
|
||||||
|
var periods = await _untis.GetTimetableAsync(SelectedTeacher.UntisId, monday, monday.AddDays(6));
|
||||||
|
var grid = await _untis.GetTimeGridAsync();
|
||||||
|
var groupOptions = BuildGroupOptions();
|
||||||
|
var localSlots = _slots.GetAll().ToDictionary(x => (x.Weekday, x.PeriodNumber));
|
||||||
|
|
||||||
|
foreach (var period in periods.Where(x => string.IsNullOrWhiteSpace(x.Code) || x.Code != "cancelled")
|
||||||
|
.GroupBy(x => (x.Date, x.StartTime, x.EndTime, x.StudentGroup,
|
||||||
|
Class: string.Join("/", x.Classes.Select(c => c.Name)),
|
||||||
|
Subject: string.Join("/", x.Subjects.Select(s => s.Name)),
|
||||||
|
Room: string.Join("/", x.Rooms.Select(r => r.Name))))
|
||||||
|
.Select(x => x.First()).OrderBy(x => x.Date).ThenBy(x => x.StartTime))
|
||||||
|
{
|
||||||
|
if (!TryDate(period.Date, out var date) || date.DayOfWeek is DayOfWeek.Saturday or DayOfWeek.Sunday)
|
||||||
|
continue;
|
||||||
|
var dayGrid = grid.FirstOrDefault(x => x.Day == UntisDay(date.DayOfWeek));
|
||||||
|
var number = dayGrid?.TimeUnits.ToList().FindIndex(x => x.StartTime == period.StartTime) + 1 ?? 0;
|
||||||
|
if (number <= 0) continue;
|
||||||
|
var className = period.Classes.FirstOrDefault()?.Name ?? "";
|
||||||
|
var subject = period.Subjects.FirstOrDefault()?.Name;
|
||||||
|
var suggested = !string.IsNullOrWhiteSpace(period.StudentGroup) ? period.StudentGroup! : className;
|
||||||
|
var row = new WebUntisTimetableRow
|
||||||
|
{
|
||||||
|
Weekday = date.DayOfWeek, PeriodNumber = number,
|
||||||
|
TimeLabel = $"{Time(period.StartTime)}–{Time(period.EndTime)}",
|
||||||
|
UntisLabel = string.Join(" · ", new[] { subject, suggested, period.Rooms.FirstOrDefault()?.Name }
|
||||||
|
.Where(x => !string.IsNullOrWhiteSpace(x))),
|
||||||
|
SuggestedGroupName = suggested, SubjectName = subject,
|
||||||
|
Room = period.Rooms.FirstOrDefault()?.Name,
|
||||||
|
};
|
||||||
|
foreach (var option in groupOptions) row.GroupOptions.Add(option);
|
||||||
|
if (localSlots.TryGetValue((row.Weekday, row.PeriodNumber), out var existing))
|
||||||
|
row.SelectedGroup = groupOptions.FirstOrDefault(x => x.Id == existing.GroupId);
|
||||||
|
row.SelectedGroup ??= BestMatch(groupOptions, suggested, className, subject);
|
||||||
|
Rows.Add(row);
|
||||||
|
}
|
||||||
|
_settings.SetTeacherUntisId(SelectedTeacher.UntisId);
|
||||||
|
Status = Rows.Count == 0 ? "In dieser Woche wurde kein Unterricht gefunden."
|
||||||
|
: $"{Rows.Count} regelmäßige Termine gefunden. Zuordnung prüfen und übernehmen.";
|
||||||
|
}
|
||||||
|
catch (WebUntisIntegrationException ex) { Status = ex.Message; }
|
||||||
|
finally { Busy = false; }
|
||||||
|
}
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
private async Task CreateGroup(WebUntisTimetableRow row)
|
||||||
|
{
|
||||||
|
if (OnCreateGroup is null) return;
|
||||||
|
var group = await OnCreateGroup(row);
|
||||||
|
if (group is null) return;
|
||||||
|
var option = new WebUntisGroupOption(group.Id, group.Name);
|
||||||
|
foreach (var item in Rows.Where(x => x.SuggestedGroupName == row.SuggestedGroupName))
|
||||||
|
{
|
||||||
|
item.GroupOptions.Add(option);
|
||||||
|
item.SelectedGroup = option;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
private void Save()
|
||||||
|
{
|
||||||
|
var selected = Rows.Where(x => x.SelectedGroup is not null).ToList();
|
||||||
|
foreach (var row in selected)
|
||||||
|
{
|
||||||
|
var existing = _slots.GetAll().FirstOrDefault(x => x.Weekday == row.Weekday && x.PeriodNumber == row.PeriodNumber);
|
||||||
|
var slot = existing ?? new TimetableSlot { Weekday = row.Weekday, PeriodNumber = row.PeriodNumber };
|
||||||
|
slot.GroupId = row.SelectedGroup!.Id;
|
||||||
|
slot.Room = string.IsNullOrWhiteSpace(row.Room) ? null : row.Room;
|
||||||
|
_slots.Save(slot);
|
||||||
|
}
|
||||||
|
Saved = true;
|
||||||
|
Status = $"{selected.Count} Stundenplan-Einträge übernommen.";
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<WebUntisGroupOption> BuildGroupOptions() => _groups.GetAll().OrderBy(x => x.Name)
|
||||||
|
.Select(x => new WebUntisGroupOption(x.Id, x.Name)).ToList();
|
||||||
|
|
||||||
|
private static WebUntisGroupOption? BestMatch(IEnumerable<WebUntisGroupOption> options, params string?[] terms) =>
|
||||||
|
options.FirstOrDefault(x => terms.Any(term => !string.IsNullOrWhiteSpace(term) &&
|
||||||
|
(x.DisplayName.Equals(term, StringComparison.OrdinalIgnoreCase) ||
|
||||||
|
x.DisplayName.Contains(term, StringComparison.OrdinalIgnoreCase))));
|
||||||
|
private static int UntisDay(DayOfWeek day) => day == DayOfWeek.Sunday ? 7 : (int)day;
|
||||||
|
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}";
|
||||||
|
}
|
||||||
@@ -210,7 +210,21 @@ public partial class SettingsViewModel : ObservableObject
|
|||||||
[ObservableProperty] private string _untisUrlError = "";
|
[ObservableProperty] private string _untisUrlError = "";
|
||||||
[ObservableProperty] private string _untisStatusDisplay = "";
|
[ObservableProperty] private string _untisStatusDisplay = "";
|
||||||
[ObservableProperty] private bool _untisFetchBusy;
|
[ObservableProperty] private bool _untisFetchBusy;
|
||||||
|
[ObservableProperty] private bool _untisApiIsConfigured;
|
||||||
|
[ObservableProperty] private string _untisSchool = "";
|
||||||
|
[ObservableProperty] private string _untisHost = "";
|
||||||
|
[ObservableProperty] private string _untisUsername = "";
|
||||||
|
[ObservableProperty] private string _untisPassword = "";
|
||||||
|
[ObservableProperty] private string _untisApiStatus = "";
|
||||||
|
[ObservableProperty] private bool _untisApiBusy;
|
||||||
|
[ObservableProperty] private string? _untisHomeroomClassName;
|
||||||
|
public string UntisHomeroomClassDisplay => UntisHomeroomClassName is { Length: > 0 } name
|
||||||
|
? $"Ausgewählt: {name}"
|
||||||
|
: "Keine Klasse ausgewählt.";
|
||||||
|
partial void OnUntisHomeroomClassNameChanged(string? value) => OnPropertyChanged(nameof(UntisHomeroomClassDisplay));
|
||||||
public Func<Task>? OnReviewUntisMapping { get; set; }
|
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) ─────────────────────────────
|
// ── Schulweiter Jahresplan (ClassyPlan-iCal) ─────────────────────────────
|
||||||
|
|
||||||
@@ -299,6 +313,7 @@ public partial class SettingsViewModel : ObservableObject
|
|||||||
private readonly AiSettingsService _aiSettings;
|
private readonly AiSettingsService _aiSettings;
|
||||||
private readonly AiPlanningService _aiPlanning;
|
private readonly AiPlanningService _aiPlanning;
|
||||||
private readonly WebUntisSettingsService _untisSettings;
|
private readonly WebUntisSettingsService _untisSettings;
|
||||||
|
private readonly WebUntisIntegrationService? _untisIntegration;
|
||||||
private readonly UntisSyncService? _untisSync;
|
private readonly UntisSyncService? _untisSync;
|
||||||
private readonly AnnualPlanSettingsService _annualPlanSettings;
|
private readonly AnnualPlanSettingsService _annualPlanSettings;
|
||||||
private readonly AnnualPlanSyncService? _annualPlanSync;
|
private readonly AnnualPlanSyncService? _annualPlanSync;
|
||||||
@@ -330,7 +345,7 @@ public partial class SettingsViewModel : ObservableObject
|
|||||||
AppearanceSettingsService appearance, TrashViewModel trashTab,
|
AppearanceSettingsService appearance, TrashViewModel trashTab,
|
||||||
SnapshotService? snapshotService = null, SyncEngine? syncEngine = null,
|
SnapshotService? snapshotService = null, SyncEngine? syncEngine = null,
|
||||||
UntisSyncService? untisSync = null, AnnualPlanSyncService? annualPlanSync = null,
|
UntisSyncService? untisSync = null, AnnualPlanSyncService? annualPlanSync = null,
|
||||||
SchoolWeatherService? schoolWeather = null)
|
SchoolWeatherService? schoolWeather = null, WebUntisIntegrationService? untisIntegration = null)
|
||||||
{
|
{
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
_syncKeyRecovery = syncKeyRecovery;
|
_syncKeyRecovery = syncKeyRecovery;
|
||||||
@@ -360,6 +375,7 @@ public partial class SettingsViewModel : ObservableObject
|
|||||||
_aiSettings = aiSettings;
|
_aiSettings = aiSettings;
|
||||||
_aiPlanning = aiPlanning;
|
_aiPlanning = aiPlanning;
|
||||||
_untisSettings = untisSettings;
|
_untisSettings = untisSettings;
|
||||||
|
_untisIntegration = untisIntegration;
|
||||||
_untisSync = untisSync;
|
_untisSync = untisSync;
|
||||||
_annualPlanSettings = annualPlanSettings;
|
_annualPlanSettings = annualPlanSettings;
|
||||||
_annualPlanSync = annualPlanSync;
|
_annualPlanSync = annualPlanSync;
|
||||||
@@ -542,6 +558,61 @@ public partial class SettingsViewModel : ObservableObject
|
|||||||
UntisStatusDisplay = _untisSettings.LastSyncAt is { } at
|
UntisStatusDisplay = _untisSettings.LastSyncAt is { } at
|
||||||
? $"Letzter Abgleich: {at.ToLocalTime():dd.MM.yyyy HH:mm} — {_untisSettings.LastSyncStatus}"
|
? $"Letzter Abgleich: {at.ToLocalTime():dd.MM.yyyy HH:mm} — {_untisSettings.LastSyncStatus}"
|
||||||
: "Noch kein Abgleich durchgeführt.";
|
: "Noch kein Abgleich durchgeführt.";
|
||||||
|
UntisApiIsConfigured = _untisSettings.ApiIsConfigured;
|
||||||
|
if (_untisSettings.GetApiCredentials() is { } credentials)
|
||||||
|
{
|
||||||
|
UntisSchool = credentials.School;
|
||||||
|
UntisHost = credentials.Host;
|
||||||
|
UntisUsername = credentials.Username;
|
||||||
|
UntisApiStatus = $"API-Zugang für {credentials.Username} ist lokal verschlüsselt gespeichert.";
|
||||||
|
}
|
||||||
|
UntisHomeroomClassName = _untisSettings.HomeroomClassName;
|
||||||
|
}
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
private async Task UntisSaveApi()
|
||||||
|
{
|
||||||
|
UntisApiStatus = "";
|
||||||
|
if (_untisIntegration is null)
|
||||||
|
{
|
||||||
|
UntisApiStatus = "Die WebUntis-Integration ist nicht verfügbar.";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (string.IsNullOrWhiteSpace(UntisSchool) || string.IsNullOrWhiteSpace(UntisUsername) ||
|
||||||
|
string.IsNullOrWhiteSpace(UntisPassword))
|
||||||
|
{
|
||||||
|
UntisApiStatus = "Schule, Benutzername und Passwort sind erforderlich.";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
UntisApiBusy = true;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var credentials = new WebUntisCredentials(UntisSchool.Trim(), UntisHost.Trim(),
|
||||||
|
UntisUsername.Trim(), UntisPassword);
|
||||||
|
await _untisIntegration.ConnectAsync(credentials);
|
||||||
|
_untisSettings.SetApiCredentials(credentials);
|
||||||
|
UntisPassword = "";
|
||||||
|
UntisApiIsConfigured = true;
|
||||||
|
UntisApiStatus = "Anmeldung erfolgreich. Die WebUntis-Session bleibt bei Nutzung bis zu 10 Minuten offen.";
|
||||||
|
}
|
||||||
|
catch (WebUntisIntegrationException ex) { UntisApiStatus = ex.Message; }
|
||||||
|
finally { UntisApiBusy = false; }
|
||||||
|
}
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
private async Task UntisRemoveApi()
|
||||||
|
{
|
||||||
|
UntisApiBusy = true;
|
||||||
|
try { if (_untisIntegration is not null) await _untisIntegration.DisconnectAsync(); }
|
||||||
|
catch (WebUntisIntegrationException) { /* lokale Zugangsdaten trotzdem sicher entfernen */ }
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_untisSettings.ClearApiCredentials();
|
||||||
|
UntisPassword = "";
|
||||||
|
UntisApiIsConfigured = false;
|
||||||
|
UntisApiStatus = "WebUntis-API-Zugang entfernt.";
|
||||||
|
UntisApiBusy = false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
[RelayCommand]
|
[RelayCommand]
|
||||||
@@ -584,6 +655,23 @@ public partial class SettingsViewModel : ObservableObject
|
|||||||
if (OnReviewUntisMapping is not null) await OnReviewUntisMapping();
|
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 ─
|
// ── Schulweiter Jahresplan: Laden / Speichern / Entfernen / Jetzt abrufen ─
|
||||||
|
|
||||||
private void LoadAnnualPlanSettings()
|
private void LoadAnnualPlanSettings()
|
||||||
|
|||||||
@@ -107,6 +107,7 @@ public partial class DocumentationDialogViewModel : ObservableObject
|
|||||||
[ObservableProperty] private string _title = "";
|
[ObservableProperty] private string _title = "";
|
||||||
[ObservableProperty] private string _content = "";
|
[ObservableProperty] private string _content = "";
|
||||||
[ObservableProperty] private bool _isConfidential;
|
[ObservableProperty] private bool _isConfidential;
|
||||||
|
[ObservableProperty] private bool _excludeFromWebUntisSync;
|
||||||
|
|
||||||
[ObservableProperty] private string _newParticipant = "";
|
[ObservableProperty] private string _newParticipant = "";
|
||||||
public ObservableCollection<string> Participants { get; } = [];
|
public ObservableCollection<string> Participants { get; } = [];
|
||||||
@@ -176,6 +177,7 @@ public partial class DocumentationDialogViewModel : ObservableObject
|
|||||||
Title = editing.Title;
|
Title = editing.Title;
|
||||||
Content = editing.Content;
|
Content = editing.Content;
|
||||||
IsConfidential = editing.IsConfidential;
|
IsConfidential = editing.IsConfidential;
|
||||||
|
ExcludeFromWebUntisSync = editing.ExcludeFromWebUntisSync;
|
||||||
foreach (var p in editing.Participants) Participants.Add(p);
|
foreach (var p in editing.Participants) Participants.Add(p);
|
||||||
if (editing.AbsenceData is { } a)
|
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.
|
// Schnellentwurf ab. Stunden- und Lesson-Bezug bleiben am bestehenden Objekt erhalten.
|
||||||
Result.IsDraft = false;
|
Result.IsDraft = false;
|
||||||
Result.IsConfidential = IsConfidential;
|
Result.IsConfidential = IsConfidential;
|
||||||
|
Result.ExcludeFromWebUntisSync = ExcludeFromWebUntisSync;
|
||||||
Result.Participants = type == DocumentationType.Conversation ? Participants.ToList() : [];
|
Result.Participants = type == DocumentationType.Conversation ? Participants.ToList() : [];
|
||||||
Result.AbsenceData = type == DocumentationType.Absence
|
Result.AbsenceData = type == DocumentationType.Absence
|
||||||
? new AbsenceData
|
? 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 Greeting}" FontSize="14" Opacity="0.6"/>
|
||||||
<TextBlock Text="{Binding CurrentDate}" FontSize="24" FontWeight="SemiBold"/>
|
<TextBlock Text="{Binding CurrentDate}" FontSize="24" FontWeight="SemiBold"/>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
<Button Grid.Column="1" Content="Dashboard anpassen" Command="{Binding ToggleDashboardSettingsCommand}"
|
<StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="8">
|
||||||
VerticalAlignment="Center"/>
|
<Button Content="Klassenbuch abgleichen…" Click="OnCompareWebUntisDocumentationClick"
|
||||||
|
VerticalAlignment="Center"/>
|
||||||
|
<Button Content="Bereiche anpassen" Command="{Binding ToggleDashboardSettingsCommand}"
|
||||||
|
VerticalAlignment="Center"/>
|
||||||
|
</StackPanel>
|
||||||
</Grid>
|
</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"
|
<Border Background="{DynamicResource SystemControlBackgroundAltHighBrush}" CornerRadius="8"
|
||||||
Padding="12" IsVisible="{Binding IsDashboardSettingsOpen}">
|
Padding="12" IsVisible="{Binding IsDashboardSettingsOpen}">
|
||||||
<ItemsControl ItemsSource="{Binding DashboardCards}">
|
<ItemsControl ItemsSource="{Binding DashboardCards}">
|
||||||
@@ -55,14 +85,15 @@
|
|||||||
</Border>
|
</Border>
|
||||||
|
|
||||||
<!-- Serverseitig gecachte DWD-Daten für den in den Einstellungen hinterlegten Schulstandort. -->
|
<!-- Serverseitig gecachte DWD-Daten für den in den Einstellungen hinterlegten Schulstandort. -->
|
||||||
<Border Background="{DynamicResource SystemControlBackgroundAltHighBrush}" CornerRadius="8"
|
<Expander Header="{Binding WeatherSummary}" IsExpanded="{Binding HasWeatherWarnings}"
|
||||||
Padding="16" IsVisible="{Binding IsWeatherPanelVisible}">
|
IsVisible="{Binding IsWeatherPanelVisible}"
|
||||||
<StackPanel Spacing="10">
|
Background="{DynamicResource AppCardBackgroundBrush}"
|
||||||
|
BorderBrush="{DynamicResource AppCardBorderBrush}" BorderThickness="1"
|
||||||
|
CornerRadius="8" Padding="12,6">
|
||||||
|
<StackPanel Spacing="10" Margin="6,8,6,6">
|
||||||
<Grid ColumnDefinitions="*,Auto">
|
<Grid ColumnDefinitions="*,Auto">
|
||||||
<StackPanel>
|
<StackPanel>
|
||||||
<TextBlock Text="WETTER AM SCHULSTANDORT" FontSize="11" FontWeight="Bold" Opacity="0.5"/>
|
<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"
|
<TextBlock Text="{Binding WeatherDetails}" FontSize="11" Opacity="0.65" TextWrapping="Wrap"
|
||||||
IsVisible="{Binding WeatherDetails, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
|
IsVisible="{Binding WeatherDetails, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
@@ -92,17 +123,20 @@
|
|||||||
</ItemsControl>
|
</ItemsControl>
|
||||||
<TextBlock Text="Wetterdaten © Deutscher Wetterdienst" FontSize="10" Opacity="0.5"/>
|
<TextBlock Text="Wetterdaten © Deutscher Wetterdienst" FontSize="10" Opacity="0.5"/>
|
||||||
</StackPanel>
|
</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 -->
|
<!-- Heutige Stunden -->
|
||||||
<Border Grid.Column="{Binding TodayCard.Column}" Grid.Row="{Binding TodayCard.Row}"
|
<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}"
|
Background="{DynamicResource SystemControlBackgroundAltHighBrush}"
|
||||||
CornerRadius="8" Padding="16">
|
CornerRadius="8" Padding="16">
|
||||||
<StackPanel>
|
<StackPanel>
|
||||||
<TextBlock Text="HEUTE" FontSize="11" FontWeight="Bold"
|
<TextBlock Text="Heute" FontSize="14" FontWeight="SemiBold"
|
||||||
Opacity="0.5" Margin="0,0,0,10"/>
|
Opacity="0.5" Margin="0,0,0,10"/>
|
||||||
<ItemsControl ItemsSource="{Binding TodaysLessons}">
|
<ItemsControl ItemsSource="{Binding TodaysLessons}">
|
||||||
<ItemsControl.ItemTemplate>
|
<ItemsControl.ItemTemplate>
|
||||||
@@ -139,12 +173,12 @@
|
|||||||
|
|
||||||
<!-- Offene Aufgaben -->
|
<!-- Offene Aufgaben -->
|
||||||
<Border Grid.Column="{Binding TasksCard.Column}" Grid.Row="{Binding TasksCard.Row}"
|
<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}"
|
Background="{DynamicResource SystemControlBackgroundAltHighBrush}"
|
||||||
CornerRadius="8" Padding="16">
|
CornerRadius="8" Padding="16">
|
||||||
<StackPanel>
|
<StackPanel>
|
||||||
<Grid ColumnDefinitions="*,Auto,Auto" Margin="0,0,0,10">
|
<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"/>
|
Opacity="0.5" VerticalAlignment="Center"/>
|
||||||
<Button Grid.Column="1" Content="🔔+" FontSize="12" Padding="7,2" Margin="0,0,4,0"
|
<Button Grid.Column="1" Content="🔔+" FontSize="12" Padding="7,2" Margin="0,0,4,0"
|
||||||
ToolTip.Tip="Erinnerung anlegen" Command="{Binding AddReminderCommand}"/>
|
ToolTip.Tip="Erinnerung anlegen" Command="{Binding AddReminderCommand}"/>
|
||||||
@@ -176,7 +210,7 @@
|
|||||||
<!-- Kalender: feste Position direkt unter Heute/Aufgaben, damit die wachsende
|
<!-- Kalender: feste Position direkt unter Heute/Aufgaben, damit die wachsende
|
||||||
Lerngruppen-Liste darunter ihn nicht nach unten verdrängt. -->
|
Lerngruppen-Liste darunter ihn nicht nach unten verdrängt. -->
|
||||||
<Border Grid.Column="{Binding CalendarCard.Column}" Grid.Row="{Binding CalendarCard.Row}"
|
<Border Grid.Column="{Binding CalendarCard.Column}" Grid.Row="{Binding CalendarCard.Row}"
|
||||||
IsVisible="{Binding CalendarCard.IsVisible}" Margin="0,0,8,8"
|
IsVisible="{Binding CalendarCard.EffectiveIsVisible}" Margin="0,0,8,8"
|
||||||
Background="{DynamicResource SystemControlBackgroundAltHighBrush}"
|
Background="{DynamicResource SystemControlBackgroundAltHighBrush}"
|
||||||
CornerRadius="8" Padding="16">
|
CornerRadius="8" Padding="16">
|
||||||
<StackPanel Spacing="8">
|
<StackPanel Spacing="8">
|
||||||
@@ -317,7 +351,7 @@
|
|||||||
|
|
||||||
<!-- Offene Entschuldigungen: neben dem Kalender, ebenfalls feste Position -->
|
<!-- Offene Entschuldigungen: neben dem Kalender, ebenfalls feste Position -->
|
||||||
<Border Grid.Column="{Binding ExcusesCard.Column}" Grid.Row="{Binding ExcusesCard.Row}"
|
<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}"
|
Background="{DynamicResource SystemControlBackgroundAltHighBrush}"
|
||||||
CornerRadius="8" Padding="16">
|
CornerRadius="8" Padding="16">
|
||||||
<StackPanel>
|
<StackPanel>
|
||||||
@@ -351,7 +385,7 @@
|
|||||||
|
|
||||||
<!-- Fehlzeiten-Warnung (5.2.3) -->
|
<!-- Fehlzeiten-Warnung (5.2.3) -->
|
||||||
<Border Grid.Column="{Binding AttendanceCard.Column}" Grid.Row="{Binding AttendanceCard.Row}"
|
<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}"
|
Background="{DynamicResource SystemControlBackgroundAltHighBrush}"
|
||||||
CornerRadius="8" Padding="16">
|
CornerRadius="8" Padding="16">
|
||||||
<StackPanel>
|
<StackPanel>
|
||||||
@@ -381,7 +415,7 @@
|
|||||||
|
|
||||||
<!-- Förderplan-Wiedervorlage (5.3.2) -->
|
<!-- Förderplan-Wiedervorlage (5.3.2) -->
|
||||||
<Border Grid.Column="{Binding SupportCard.Column}" Grid.Row="{Binding SupportCard.Row}"
|
<Border Grid.Column="{Binding SupportCard.Column}" Grid.Row="{Binding SupportCard.Row}"
|
||||||
IsVisible="{Binding SupportCard.IsVisible}" Margin="8,0,0,8" VerticalAlignment="Top"
|
IsVisible="{Binding SupportCard.EffectiveIsVisible}" Margin="8,0,0,8" VerticalAlignment="Top"
|
||||||
Background="{DynamicResource SystemControlBackgroundAltHighBrush}"
|
Background="{DynamicResource SystemControlBackgroundAltHighBrush}"
|
||||||
CornerRadius="8" Padding="16">
|
CornerRadius="8" Padding="16">
|
||||||
<StackPanel>
|
<StackPanel>
|
||||||
@@ -412,7 +446,7 @@
|
|||||||
|
|
||||||
<!-- Anstehende Termine (9.3) -->
|
<!-- Anstehende Termine (9.3) -->
|
||||||
<Border Grid.Column="{Binding UpcomingCard.Column}" Grid.Row="{Binding UpcomingCard.Row}"
|
<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}"
|
Background="{DynamicResource SystemControlBackgroundAltHighBrush}"
|
||||||
CornerRadius="8" Padding="16">
|
CornerRadius="8" Padding="16">
|
||||||
<StackPanel>
|
<StackPanel>
|
||||||
@@ -449,7 +483,7 @@
|
|||||||
|
|
||||||
<!-- Offene Korrekturen (9.4) -->
|
<!-- Offene Korrekturen (9.4) -->
|
||||||
<Border Grid.Column="{Binding CorrectionsCard.Column}" Grid.Row="{Binding CorrectionsCard.Row}"
|
<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}"
|
Background="{DynamicResource SystemControlBackgroundAltHighBrush}"
|
||||||
CornerRadius="8" Padding="16">
|
CornerRadius="8" Padding="16">
|
||||||
<StackPanel>
|
<StackPanel>
|
||||||
@@ -482,7 +516,7 @@
|
|||||||
|
|
||||||
<!-- Ungeplante Stunden -->
|
<!-- Ungeplante Stunden -->
|
||||||
<Border Grid.Column="{Binding UnplannedCard.Column}" Grid.Row="{Binding UnplannedCard.Row}"
|
<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}"
|
Background="{DynamicResource SystemControlBackgroundAltHighBrush}"
|
||||||
CornerRadius="8" Padding="16">
|
CornerRadius="8" Padding="16">
|
||||||
<StackPanel>
|
<StackPanel>
|
||||||
@@ -512,7 +546,7 @@
|
|||||||
|
|
||||||
<!-- Auffälligkeiten (9.5) -->
|
<!-- Auffälligkeiten (9.5) -->
|
||||||
<Border Grid.Column="{Binding AlertsCard.Column}" Grid.Row="{Binding AlertsCard.Row}"
|
<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}"
|
Background="{DynamicResource SystemControlBackgroundAltHighBrush}"
|
||||||
CornerRadius="8" Padding="16">
|
CornerRadius="8" Padding="16">
|
||||||
<StackPanel>
|
<StackPanel>
|
||||||
@@ -546,7 +580,7 @@
|
|||||||
|
|
||||||
<!-- Meine Lerngruppen -->
|
<!-- Meine Lerngruppen -->
|
||||||
<Border Grid.Column="{Binding GroupsCard.Column}" Grid.Row="{Binding GroupsCard.Row}"
|
<Border Grid.Column="{Binding GroupsCard.Column}" Grid.Row="{Binding GroupsCard.Row}"
|
||||||
IsVisible="{Binding GroupsCard.IsVisible}"
|
IsVisible="{Binding GroupsCard.EffectiveIsVisible}"
|
||||||
Background="{DynamicResource SystemControlBackgroundAltHighBrush}"
|
Background="{DynamicResource SystemControlBackgroundAltHighBrush}"
|
||||||
CornerRadius="8" Padding="16">
|
CornerRadius="8" Padding="16">
|
||||||
<StackPanel>
|
<StackPanel>
|
||||||
|
|||||||
@@ -1,7 +1,14 @@
|
|||||||
using Avalonia.Controls;
|
using Avalonia.Controls;
|
||||||
|
using Avalonia.Interactivity;
|
||||||
|
using LehrerApp.Core.Interfaces;
|
||||||
using LehrerApp.Core.Models;
|
using LehrerApp.Core.Models;
|
||||||
|
using LehrerApp.Core.Services;
|
||||||
|
using LehrerApp.Desktop.Services;
|
||||||
using LehrerApp.Desktop.ViewModels;
|
using LehrerApp.Desktop.ViewModels;
|
||||||
|
using LehrerApp.Desktop.ViewModels.Students;
|
||||||
|
using LehrerApp.Desktop.Views.Students;
|
||||||
using LehrerApp.Desktop.Views.Workload;
|
using LehrerApp.Desktop.Views.Workload;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
|
||||||
namespace LehrerApp.Desktop.Views.Dashboard;
|
namespace LehrerApp.Desktop.Views.Dashboard;
|
||||||
|
|
||||||
@@ -22,4 +29,17 @@ public partial class DashboardView : UserControl
|
|||||||
if (owner is null) return null;
|
if (owner is null) return null;
|
||||||
return await WorkTaskDialogHelper.ShowDialog(owner, startAsReminder: startAsReminder);
|
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}"
|
<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."/>
|
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>
|
</StackPanel>
|
||||||
|
|
||||||
<Grid Grid.Row="1" ColumnDefinitions="*,8,*" Margin="0,20,0,0">
|
<Grid Grid.Row="1" ColumnDefinitions="*,8,*" Margin="0,20,0,0">
|
||||||
|
|||||||
@@ -15,6 +15,9 @@
|
|||||||
IsVisible="{Binding NiveauLabel, Converter={x:Static StringConverters.IsNotNullOrEmpty}}">
|
IsVisible="{Binding NiveauLabel, Converter={x:Static StringConverters.IsNotNullOrEmpty}}">
|
||||||
<TextBlock Text="{Binding NiveauLabel}" FontSize="12" Foreground="White"/>
|
<TextBlock Text="{Binding NiveauLabel}" FontSize="12" Foreground="White"/>
|
||||||
</Border>
|
</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>
|
</StackPanel>
|
||||||
|
|
||||||
<DataGrid Grid.Row="1" Name="ResultGrid"
|
<DataGrid Grid.Row="1" Name="ResultGrid"
|
||||||
|
|||||||
@@ -25,6 +25,9 @@
|
|||||||
<Button Content="+ Schüler" Command="{Binding AddStudentCommand}" IsEnabled="{Binding IsEditable}"/>
|
<Button Content="+ Schüler" Command="{Binding AddStudentCommand}" IsEnabled="{Binding IsEditable}"/>
|
||||||
<Button Content="⇩ Teilnehmer importieren…" Click="OnImportParticipantsClick"
|
<Button Content="⇩ Teilnehmer importieren…" Click="OnImportParticipantsClick"
|
||||||
IsEnabled="{Binding IsEditable}"/>
|
IsEnabled="{Binding IsEditable}"/>
|
||||||
|
<Button Content="↻ Aus WebUntis…" Click="OnImportParticipantsFromWebUntisClick"
|
||||||
|
IsEnabled="{Binding IsEditable}"/>
|
||||||
|
<Button Content="Fehlzeiten je Unterricht…" Click="OnCompareWebUntisLessonAbsencesClick"/>
|
||||||
<Button Content="{Binding SelectedStudent.WithdrawActionLabel}"
|
<Button Content="{Binding SelectedStudent.WithdrawActionLabel}"
|
||||||
Command="{Binding WithdrawStudentCommand}"
|
Command="{Binding WithdrawStudentCommand}"
|
||||||
IsVisible="{Binding SelectedStudent, Converter={x:Static ObjectConverters.IsNotNull}}"/>
|
IsVisible="{Binding SelectedStudent, Converter={x:Static ObjectConverters.IsNotNull}}"/>
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ using LehrerApp.Desktop.Views.Shared;
|
|||||||
using LehrerApp.Desktop.Views.Students;
|
using LehrerApp.Desktop.Views.Students;
|
||||||
using LehrerApp.Desktop.Views.Workload;
|
using LehrerApp.Desktop.Views.Workload;
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using System.Globalization;
|
||||||
|
using System.Text;
|
||||||
|
|
||||||
namespace LehrerApp.Desktop.Views.Groups;
|
namespace LehrerApp.Desktop.Views.Groups;
|
||||||
|
|
||||||
@@ -113,18 +115,7 @@ public partial class GroupDetailView : UserControl
|
|||||||
throw new InvalidDataException("Die Importdatei ist größer als 20 MB.");
|
throw new InvalidDataException("Die Importdatei ist größer als 20 MB.");
|
||||||
|
|
||||||
var importFile = new ImportFile(files[0].Name, buffer.ToArray());
|
var importFile = new ImportFile(files[0].Name, buffer.ToArray());
|
||||||
var preview = await Task.Run(async () =>
|
await ShowStudentImportPreview(owner, vm, importFile, files[0].Name);
|
||||||
await service.AnalyzeAsync(importFile, vm.Group.Id).ConfigureAwait(false));
|
|
||||||
var dialogVm = new StudentImportDialogViewModel(service, preview, files[0].Name);
|
|
||||||
var dialog = new StudentImportDialog { DataContext = dialogVm };
|
|
||||||
if (!await dialog.ShowDialog<bool>(owner)) return;
|
|
||||||
|
|
||||||
vm.LoadStudents();
|
|
||||||
vm.ParticipationTab.RefreshCurrentGrid();
|
|
||||||
var result = dialogVm.Result!;
|
|
||||||
App.Services.GetRequiredService<NotificationService>().ShowSuccess(
|
|
||||||
$"Teilnehmerimport abgeschlossen: {result.CreatedStudents} neu, "
|
|
||||||
+ $"{result.UpdatedStudents} ergänzt, {result.CreatedMemberships} zugeordnet.");
|
|
||||||
}
|
}
|
||||||
catch (Exception ex) when (ex is ImportFormatException
|
catch (Exception ex) when (ex is ImportFormatException
|
||||||
or InvalidDataException
|
or InvalidDataException
|
||||||
@@ -135,6 +126,96 @@ public partial class GroupDetailView : UserControl
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async void OnImportParticipantsFromWebUntisClick(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;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
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;
|
||||||
|
|
||||||
|
var report = await untis.GetStudentsAsync(selectionVm.SelectedClass.Name);
|
||||||
|
var importFile = BuildWebUntisStudentImport(report);
|
||||||
|
await ShowStudentImportPreview(owner, vm, importFile,
|
||||||
|
$"WebUntis · {selectionVm.SelectedClass.Name}");
|
||||||
|
}
|
||||||
|
catch (WebUntisIntegrationException ex)
|
||||||
|
{
|
||||||
|
App.Services.GetRequiredService<NotificationService>().ShowError(ex.Message);
|
||||||
|
}
|
||||||
|
catch (Exception ex) when (ex is ImportFormatException or InvalidDataException)
|
||||||
|
{
|
||||||
|
App.Services.GetRequiredService<NotificationService>().ShowError(ex.Message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 WebUntisLessonAbsenceComparisonViewModel(vm.Group,
|
||||||
|
App.Services.GetRequiredService<WebUntisIntegrationService>(),
|
||||||
|
App.Services.GetRequiredService<IStudentRepository>(),
|
||||||
|
App.Services.GetRequiredService<IParticipationSessionRepository>(),
|
||||||
|
App.Services.GetRequiredService<IParticipationRepository>());
|
||||||
|
await new WebUntisLessonAbsenceComparisonDialog { DataContext = dialogVm }.ShowDialog(owner);
|
||||||
|
vm.ParticipationTab.RefreshCurrentGrid();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ImportFile BuildWebUntisStudentImport(UntisStudentReportDto report)
|
||||||
|
{
|
||||||
|
var builder = new StringBuilder();
|
||||||
|
builder.AppendLine("longName\tforeName\tgender\tbirthDate\tklasse.name\texternKey\taddress.email\taddress.mobile\taddress.phone\taddress.city\taddress.postCode\taddress.street");
|
||||||
|
foreach (var student in report.Students)
|
||||||
|
{
|
||||||
|
var values = new[]
|
||||||
|
{
|
||||||
|
student.LongName ?? student.Name, student.ForeName, student.Gender,
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
builder.AppendLine(string.Join('\t', values.Select(SafeTsv)));
|
||||||
|
}
|
||||||
|
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', ' ');
|
||||||
|
|
||||||
|
private static async Task ShowStudentImportPreview(Window owner, GroupDetailViewModel vm,
|
||||||
|
ImportFile importFile, string sourceName)
|
||||||
|
{
|
||||||
|
var service = App.Services.GetRequiredService<StudentImportService>();
|
||||||
|
var preview = await Task.Run(async () =>
|
||||||
|
await service.AnalyzeAsync(importFile, vm.Group!.Id).ConfigureAwait(false));
|
||||||
|
var dialogVm = new StudentImportDialogViewModel(service, preview, sourceName);
|
||||||
|
var dialog = new StudentImportDialog { DataContext = dialogVm };
|
||||||
|
if (!await dialog.ShowDialog<bool>(owner)) return;
|
||||||
|
|
||||||
|
vm.LoadStudents();
|
||||||
|
vm.ParticipationTab.RefreshCurrentGrid();
|
||||||
|
var result = dialogVm.Result!;
|
||||||
|
App.Services.GetRequiredService<NotificationService>().ShowSuccess(
|
||||||
|
$"Teilnehmerimport abgeschlossen: {result.CreatedStudents} neu, "
|
||||||
|
+ $"{result.UpdatedStudents} ergänzt, {result.CreatedMemberships} zugeordnet.");
|
||||||
|
}
|
||||||
|
|
||||||
private async Task<bool> ShowWithdrawStudentDialog(StudentSummary student)
|
private async Task<bool> ShowWithdrawStudentDialog(StudentSummary student)
|
||||||
{
|
{
|
||||||
if (DataContext is not GroupDetailViewModel vm || vm.Group is null) return false;
|
if (DataContext is not GroupDetailViewModel vm || vm.Group is null) return false;
|
||||||
|
|||||||
@@ -5,13 +5,6 @@
|
|||||||
x:Class="LehrerApp.Desktop.Views.Groups.GroupListView"
|
x:Class="LehrerApp.Desktop.Views.Groups.GroupListView"
|
||||||
x:DataType="vm:GroupListViewModel">
|
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,*">
|
<Grid RowDefinitions="Auto,*">
|
||||||
|
|
||||||
<!-- Kopfzeile mit Schuljahr-Wähler und Neu-Button -->
|
<!-- Kopfzeile mit Schuljahr-Wähler und Neu-Button -->
|
||||||
@@ -28,180 +21,85 @@
|
|||||||
</Grid>
|
</Grid>
|
||||||
</Border>
|
</Border>
|
||||||
|
|
||||||
<!-- Master-Detail: Liste links, Übersicht rechts -->
|
<!-- Eine Navigationsebene: Karten öffnen direkt das Gruppendetail. Die Verwaltung sitzt
|
||||||
<Grid Grid.Row="1" ColumnDefinitions="260,*">
|
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>
|
||||||
|
|
||||||
<!-- Linke Spalte: Sucheingabe + Listenansicht -->
|
<StackPanel Grid.Row="1" HorizontalAlignment="Center" VerticalAlignment="Center" Spacing="10"
|
||||||
<Border Grid.Column="0"
|
IsVisible="{Binding HasNoGroups}">
|
||||||
BorderBrush="{DynamicResource SystemControlForegroundBaseLowBrush}"
|
<TextBlock Text="{Binding EmptyListMessage}" FontSize="15" Opacity="0.55"
|
||||||
BorderThickness="0,0,1,0">
|
TextWrapping="Wrap" TextAlignment="Center"/>
|
||||||
<DockPanel>
|
<Button Content="+ Erste Lerngruppe anlegen" Command="{Binding AddGroupCommand}"
|
||||||
<StackPanel DockPanel.Dock="Top" Margin="12,8" Spacing="8">
|
IsVisible="{Binding !ShowArchived}" HorizontalAlignment="Center"/>
|
||||||
<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"/>
|
|
||||||
</Grid>
|
|
||||||
<TextBlock Text="{Binding Subject}" FontSize="12" Opacity="0.65"
|
|
||||||
IsVisible="{Binding Subject, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
|
|
||||||
<TextBlock Text="{Binding GradingLabel}" FontSize="11" Opacity="0.4"/>
|
|
||||||
</StackPanel>
|
|
||||||
</Grid>
|
|
||||||
</DataTemplate>
|
|
||||||
</ListBox.ItemTemplate>
|
|
||||||
</ListBox>
|
|
||||||
</DockPanel>
|
|
||||||
</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"/>
|
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
|
|
||||||
<!-- Rechte Spalte: Gruppen-Übersicht wenn ausgewählt -->
|
<ScrollViewer Grid.Row="1" IsVisible="{Binding !HasNoGroups}"
|
||||||
<ScrollViewer Grid.Column="1"
|
HorizontalScrollBarVisibility="Disabled">
|
||||||
IsVisible="{Binding SelectedGroup, Converter={x:Static ObjectConverters.IsNotNull}}">
|
<ItemsControl ItemsSource="{Binding Groups}" Margin="20,12,20,24">
|
||||||
<StackPanel Margin="28,24" Spacing="20">
|
<ItemsControl.ItemsPanel>
|
||||||
|
<ItemsPanelTemplate>
|
||||||
<!-- Gruppenname und Kurzinfos -->
|
<WrapPanel Orientation="Horizontal"/>
|
||||||
<Grid ColumnDefinitions="*,Auto">
|
</ItemsPanelTemplate>
|
||||||
<StackPanel Grid.Column="0" Spacing="4">
|
</ItemsControl.ItemsPanel>
|
||||||
<TextBlock Text="{Binding SelectedGroupDisplayName}"
|
<ItemsControl.ItemTemplate>
|
||||||
FontSize="24" FontWeight="SemiBold" TextWrapping="Wrap"/>
|
<DataTemplate x:DataType="vm:GroupListItem">
|
||||||
<TextBlock Text="{Binding SelectedGroupSubtitle}"
|
<Border Width="330" MinHeight="116" Margin="6" CornerRadius="9"
|
||||||
FontSize="12" Opacity="0.55"/>
|
Background="{DynamicResource AppCardBackgroundBrush}"
|
||||||
</StackPanel>
|
BorderBrush="{DynamicResource AppCardBorderBrush}" BorderThickness="1">
|
||||||
<Button Grid.Column="1" Content="⋯ Verwalten" VerticalAlignment="Top"
|
<Grid ColumnDefinitions="4,*,Auto">
|
||||||
Margin="16,0,0,0">
|
<Border Grid.Column="0" Background="{DynamicResource SystemAccentColor}"
|
||||||
<Button.Flyout>
|
CornerRadius="9,0,0,9"/>
|
||||||
<MenuFlyout>
|
<Button Grid.Column="1" Background="Transparent" BorderThickness="0"
|
||||||
<MenuItem Header="Details bearbeiten"
|
Padding="16,14" HorizontalAlignment="Stretch"
|
||||||
Command="{Binding EditGroupCommand}"/>
|
HorizontalContentAlignment="Stretch" VerticalContentAlignment="Stretch"
|
||||||
<MenuItem Header="Ins nächste Schuljahr übernehmen …"
|
Command="{Binding OpenCommand}"
|
||||||
Command="{Binding RollOverGroupCommand}"/>
|
AutomationProperties.Name="{Binding OpenAutomationName}">
|
||||||
<MenuItem Header="{Binding SelectedGroup.ArchiveActionLabel}"
|
<StackPanel Spacing="6">
|
||||||
Command="{Binding ToggleArchiveCommand}"/>
|
<TextBlock Text="{Binding Name}" FontSize="17" FontWeight="SemiBold"
|
||||||
<Separator/>
|
TextTrimming="CharacterEllipsis"/>
|
||||||
<MenuItem Header="Teilnehmer importieren (bald)" IsEnabled="False"
|
<TextBlock Text="{Binding Subject}" FontSize="13" Opacity="0.7"
|
||||||
ToolTip.Tip="Import aus dem Teilnehmerexport der Lernplattform folgt."/>
|
TextTrimming="CharacterEllipsis"
|
||||||
<Separator/>
|
IsVisible="{Binding Subject, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
|
||||||
<MenuItem Header="Lerngruppe löschen"
|
<StackPanel Orientation="Horizontal" Spacing="7">
|
||||||
Command="{Binding DeleteGroupCommand}"/>
|
<Border Background="{DynamicResource AppChipBackgroundBrush}" CornerRadius="8" Padding="7,2">
|
||||||
</MenuFlyout>
|
<TextBlock Text="{Binding TypeLabel}" FontSize="10" Opacity="0.75"/>
|
||||||
</Button.Flyout>
|
</Border>
|
||||||
</Button>
|
<TextBlock Text="{Binding GradingLabel}" FontSize="11" Opacity="0.5"
|
||||||
</Grid>
|
VerticalAlignment="Center"/>
|
||||||
|
</StackPanel>
|
||||||
<Separator/>
|
</StackPanel>
|
||||||
|
</Button>
|
||||||
<!-- Schnellüberblick (Nutzer-Feedback) -->
|
<Button Grid.Column="2" Content="⋯" Width="36" Height="32" Margin="0,10,10,0"
|
||||||
<StackPanel Spacing="8" IsVisible="{Binding QuickHasAnything}">
|
Padding="0" VerticalAlignment="Top"
|
||||||
<TextBlock Text="SCHNELLÜBERBLICK" FontSize="10" FontWeight="Bold" Opacity="0.4"/>
|
ToolTip.Tip="Lerngruppe verwalten"
|
||||||
<StackPanel Spacing="1" IsVisible="{Binding QuickHasNextLesson}">
|
AutomationProperties.Name="{Binding ManageAutomationName}">
|
||||||
<TextBlock Text="Nächste Stunde" FontSize="11" Opacity="0.55"/>
|
<Button.Flyout>
|
||||||
<TextBlock Text="{Binding QuickNextLessonLabel}" FontSize="13" TextWrapping="Wrap"/>
|
<MenuFlyout>
|
||||||
</StackPanel>
|
<MenuItem Header="Details bearbeiten" IsEnabled="{Binding IsActive}"
|
||||||
<StackPanel Spacing="1" IsVisible="{Binding QuickHasNextExam}">
|
Command="{Binding EditCommand}"/>
|
||||||
<TextBlock Text="Nächste Klausur" FontSize="11" Opacity="0.55"/>
|
<MenuItem Header="Ins nächste Schuljahr übernehmen …"
|
||||||
<TextBlock Text="{Binding QuickNextExamLabel}" FontSize="13" TextWrapping="Wrap"/>
|
Command="{Binding RollOverCommand}"/>
|
||||||
</StackPanel>
|
<MenuItem Header="{Binding ArchiveActionLabel}"
|
||||||
<StackPanel Spacing="4" IsVisible="{Binding QuickHasTasks}">
|
Command="{Binding ToggleArchiveCommand}"/>
|
||||||
<TextBlock Text="Wichtige Aufgaben" FontSize="11" Opacity="0.55"/>
|
<Separator/>
|
||||||
<ItemsControl ItemsSource="{Binding QuickTasks}">
|
<MenuItem Header="Lerngruppe löschen" IsEnabled="{Binding IsActive}"
|
||||||
<ItemsControl.ItemTemplate>
|
Command="{Binding DeleteCommand}"/>
|
||||||
<DataTemplate x:DataType="vm:GroupTaskItem">
|
</MenuFlyout>
|
||||||
<Grid ColumnDefinitions="4,Auto,*,Auto" Margin="0,2">
|
</Button.Flyout>
|
||||||
<Border Grid.Column="0" Background="{Binding PriorityColorHex}" CornerRadius="2"
|
</Button>
|
||||||
Margin="0,0,6,0" IsVisible="{Binding IsHighPriority}"/>
|
</Grid>
|
||||||
<TextBlock Grid.Column="1" Text="🔔" FontSize="11" Margin="0,0,4,0"
|
</Border>
|
||||||
IsVisible="{Binding IsReminder}" VerticalAlignment="Center"/>
|
</DataTemplate>
|
||||||
<TextBlock Grid.Column="2" Text="{Binding Title}" FontSize="12"
|
</ItemsControl.ItemTemplate>
|
||||||
TextTrimming="CharacterEllipsis" VerticalAlignment="Center"/>
|
</ItemsControl>
|
||||||
<TextBlock Grid.Column="3" Text="{Binding DueDateDisplay}" FontSize="11"
|
|
||||||
VerticalAlignment="Center" Classes.overdue="{Binding IsOverdue}"/>
|
|
||||||
</Grid>
|
|
||||||
</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>
|
</ScrollViewer>
|
||||||
|
|
||||||
</Grid>
|
</Grid>
|
||||||
</Grid>
|
</Grid>
|
||||||
</UserControl>
|
</UserControl>
|
||||||
|
|||||||
@@ -27,6 +27,13 @@
|
|||||||
</StackPanel>
|
</StackPanel>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
<StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="8" VerticalAlignment="Center">
|
<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}"
|
<Button Content="Zur Mitarbeit" Command="{Binding LessonInfo.NavigateToParticipationCommand}"
|
||||||
ToolTip.Tip="Schließt den Unterrichtsmodus und springt zum Tab 'Mitarbeit' der Lerngruppe."/>
|
ToolTip.Tip="Schließt den Unterrichtsmodus und springt zum Tab 'Mitarbeit' der Lerngruppe."/>
|
||||||
<Button Content="Zu den Noten" Command="{Binding LessonInfo.NavigateToGradesCommand}"/>
|
<Button Content="Zu den Noten" Command="{Binding LessonInfo.NavigateToGradesCommand}"/>
|
||||||
@@ -80,9 +87,32 @@
|
|||||||
</ItemsControl.ItemTemplate>
|
</ItemsControl.ItemTemplate>
|
||||||
</ItemsControl>
|
</ItemsControl>
|
||||||
|
|
||||||
<StackPanel Spacing="4" IsVisible="{Binding LessonInfo.Homework, Converter={x:Static StringConverters.IsNotNullOrEmpty}}">
|
<!-- Nutzer-Feedback: Hausaufgabe der letzten Stunde ansehen/als kontrolliert abhaken
|
||||||
<TextBlock Text="Hausaufgabe" FontSize="12" FontWeight="SemiBold" Opacity="0.6"/>
|
und die Hausaufgabe DIESER Stunde einsehen/ändern, ohne den vollen
|
||||||
<TextBlock Text="{Binding LessonInfo.Homework}" FontSize="13" TextWrapping="Wrap"/>
|
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>
|
||||||
|
|
||||||
<StackPanel Spacing="4" IsVisible="{Binding LessonInfo.Reflection, Converter={x:Static StringConverters.IsNotNullOrEmpty}}">
|
<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
|
// Gleiches Muster wie LessonViewerDialog (4.5.3): Fenster schließt zuerst, dann Sprung
|
||||||
// über die MainWindowViewModel-Singleton-Navigation in den Ziel-Tab der Gruppe.
|
// über die MainWindowViewModel-Singleton-Navigation in den Ziel-Tab der Gruppe.
|
||||||
if (DataContext is TeachingModeViewModel vm)
|
if (DataContext is TeachingModeViewModel vm)
|
||||||
|
{
|
||||||
vm.LessonInfo.OnNavigateToTab = tabIndex =>
|
vm.LessonInfo.OnNavigateToTab = tabIndex =>
|
||||||
{
|
{
|
||||||
Close();
|
Close();
|
||||||
App.Services.GetRequiredService<MainWindowViewModel>()
|
App.Services.GetRequiredService<MainWindowViewModel>()
|
||||||
.NavigateToGroupDetail(vm.LessonInfo.GroupId, tabIndex);
|
.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();
|
private void OnClose(object? sender, RoutedEventArgs e) => Close();
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
<Window xmlns="https://github.com/avaloniaui"
|
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
|
xmlns:vm="clr-namespace:LehrerApp.Desktop.ViewModels.Groups"
|
||||||
|
xmlns:svc="clr-namespace:LehrerApp.Desktop.Services"
|
||||||
|
x:Class="LehrerApp.Desktop.Views.Groups.WebUntisClassSelectionDialog"
|
||||||
|
x:DataType="vm:WebUntisClassSelectionViewModel"
|
||||||
|
Title="Schüler aus WebUntis" Width="480" SizeToContent="Height"
|
||||||
|
CanResize="False" WindowStartupLocation="CenterOwner">
|
||||||
|
<Grid RowDefinitions="*,Auto" Margin="24">
|
||||||
|
<StackPanel Grid.Row="0" Spacing="12">
|
||||||
|
<TextBlock Text="WebUntis-Klasse auswählen" Classes="dialogtitle"/>
|
||||||
|
<TextBlock Text="Die Schülerliste wird danach im gewohnten Importdialog geprüft."
|
||||||
|
FontSize="12" Opacity="0.65" TextWrapping="Wrap"/>
|
||||||
|
<Grid ColumnDefinitions="*,Auto" ColumnSpacing="8">
|
||||||
|
<ComboBox Grid.Column="0" ItemsSource="{Binding SchoolYears}" SelectedItem="{Binding SelectedSchoolYear}">
|
||||||
|
<ComboBox.ItemTemplate><DataTemplate x:DataType="svc:UntisSchoolYearDto"><TextBlock Text="{Binding Name}"/></DataTemplate></ComboBox.ItemTemplate>
|
||||||
|
</ComboBox>
|
||||||
|
<Button Grid.Column="1" Content="Klassen laden" Command="{Binding LoadClassesCommand}" IsEnabled="{Binding !Busy}"/>
|
||||||
|
</Grid>
|
||||||
|
<ComboBox ItemsSource="{Binding Classes}" SelectedItem="{Binding SelectedClass}" PlaceholderText="Klasse auswählen">
|
||||||
|
<ComboBox.ItemTemplate>
|
||||||
|
<DataTemplate x:DataType="svc:UntisClassDto">
|
||||||
|
<TextBlock><Run Text="{Binding Name}"/><Run Text=" — "/><Run Text="{Binding LongName}"/></TextBlock>
|
||||||
|
</DataTemplate>
|
||||||
|
</ComboBox.ItemTemplate>
|
||||||
|
</ComboBox>
|
||||||
|
<TextBlock Text="{Binding Status}" FontSize="12" TextWrapping="Wrap"/>
|
||||||
|
</StackPanel>
|
||||||
|
<Grid Grid.Row="1" ColumnDefinitions="*,8,*" Margin="0,20,0,0">
|
||||||
|
<Button Grid.Column="0" Content="Abbrechen" Click="OnCancel"/>
|
||||||
|
<Button Grid.Column="2" Content="Schülerliste laden" IsEnabled="{Binding CanConfirm}" Click="OnConfirm"/>
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
</Window>
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
using Avalonia.Controls;
|
||||||
|
using Avalonia.Interactivity;
|
||||||
|
using LehrerApp.Desktop.ViewModels.Groups;
|
||||||
|
|
||||||
|
namespace LehrerApp.Desktop.Views.Groups;
|
||||||
|
|
||||||
|
public partial class WebUntisClassSelectionDialog : Window
|
||||||
|
{
|
||||||
|
public WebUntisClassSelectionDialog() => InitializeComponent();
|
||||||
|
private void OnCancel(object? sender, RoutedEventArgs e) => Close(false);
|
||||||
|
private void OnConfirm(object? sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
if (DataContext is WebUntisClassSelectionViewModel { SelectedClass: not null }) Close(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
using Avalonia.Controls;
|
||||||
|
using Avalonia.Interactivity;
|
||||||
|
|
||||||
|
namespace LehrerApp.Desktop.Views.Groups;
|
||||||
|
|
||||||
|
public partial class WebUntisLessonAbsenceComparisonDialog : Window
|
||||||
|
{
|
||||||
|
public WebUntisLessonAbsenceComparisonDialog() => InitializeComponent();
|
||||||
|
private void OnClose(object? sender, RoutedEventArgs e) => Close();
|
||||||
|
}
|
||||||
@@ -13,6 +13,10 @@
|
|||||||
xmlns:vmp="clr-namespace:LehrerApp.Desktop.ViewModels.Planning"
|
xmlns:vmp="clr-namespace:LehrerApp.Desktop.ViewModels.Planning"
|
||||||
xmlns:vw="clr-namespace:LehrerApp.Desktop.Views.Workload"
|
xmlns:vw="clr-namespace:LehrerApp.Desktop.Views.Workload"
|
||||||
xmlns:vmw="clr-namespace:LehrerApp.Desktop.ViewModels.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"
|
xmlns:svc="clr-namespace:LehrerApp.Desktop.Services"
|
||||||
x:Class="LehrerApp.Desktop.Views.MainWindow"
|
x:Class="LehrerApp.Desktop.Views.MainWindow"
|
||||||
x:DataType="vm:MainWindowViewModel"
|
x:DataType="vm:MainWindowViewModel"
|
||||||
@@ -58,6 +62,12 @@
|
|||||||
<DataTemplate DataType="vmw:WorkloadViewModel">
|
<DataTemplate DataType="vmw:WorkloadViewModel">
|
||||||
<vw:WorkloadView/>
|
<vw:WorkloadView/>
|
||||||
</DataTemplate>
|
</DataTemplate>
|
||||||
|
<DataTemplate DataType="vmct:ClassTeacherOverviewViewModel">
|
||||||
|
<vct:ClassTeacherOverviewView/>
|
||||||
|
</DataTemplate>
|
||||||
|
<DataTemplate DataType="vmex:ExamsOverviewViewModel">
|
||||||
|
<vex:ExamsOverviewView/>
|
||||||
|
</DataTemplate>
|
||||||
<DataTemplate DataType="vm:PlaceholderViewModel">
|
<DataTemplate DataType="vm:PlaceholderViewModel">
|
||||||
<views:PlaceholderView/>
|
<views:PlaceholderView/>
|
||||||
</DataTemplate>
|
</DataTemplate>
|
||||||
@@ -141,6 +151,20 @@
|
|||||||
|
|
||||||
<ScrollViewer>
|
<ScrollViewer>
|
||||||
<StackPanel Classes="navitems" Margin="8,12,8,0" Spacing="2">
|
<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"
|
<Button Classes="navitem" Classes.active="{Binding IsDashboardActive}" HorizontalAlignment="Stretch"
|
||||||
HorizontalContentAlignment="Left"
|
HorizontalContentAlignment="Left"
|
||||||
CornerRadius="6"
|
CornerRadius="6"
|
||||||
@@ -209,6 +233,16 @@
|
|||||||
<TextBlock Classes="navlabel" Text="Arbeitszeit"/>
|
<TextBlock Classes="navlabel" Text="Arbeitszeit"/>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
</Button>
|
</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"
|
<Button Classes="navitem" Classes.active="{Binding IsSettingsActive}" HorizontalAlignment="Stretch"
|
||||||
HorizontalContentAlignment="Left"
|
HorizontalContentAlignment="Left"
|
||||||
CornerRadius="6"
|
CornerRadius="6"
|
||||||
@@ -226,6 +260,77 @@
|
|||||||
|
|
||||||
</DrawerPage>
|
</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
|
<!-- Dauerhafte Meldung bei einer echten Protokoll-Inkompatibilität. Als Overlay außerhalb
|
||||||
von DrawerPage.Content bleibt der DataContext das MainWindowViewModel; innerhalb des
|
von DrawerPage.Content bleibt der DataContext das MainWindowViewModel; innerhalb des
|
||||||
ContentPresenters würde eine fehlgeschlagene Bindung IsVisible auf true stehen lassen. -->
|
ContentPresenters würde eine fehlgeschlagene Bindung IsVisible auf true stehen lassen. -->
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
using Avalonia.Controls;
|
using Avalonia.Controls;
|
||||||
using Avalonia.Input;
|
using Avalonia.Input;
|
||||||
|
using Avalonia.Threading;
|
||||||
using LehrerApp.Desktop.Services;
|
using LehrerApp.Desktop.Services;
|
||||||
using LehrerApp.Desktop.ViewModels;
|
using LehrerApp.Desktop.ViewModels;
|
||||||
using LehrerApp.Sync;
|
using LehrerApp.Sync;
|
||||||
@@ -19,6 +20,62 @@ public partial class MainWindow : Window
|
|||||||
PointerMoved += (_, _) => NotifyActivity();
|
PointerMoved += (_, _) => NotifyActivity();
|
||||||
PointerPressed += (_, _) => NotifyActivity();
|
PointerPressed += (_, _) => NotifyActivity();
|
||||||
KeyDown += (_, _) => 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)
|
public void EnableFinalSync(SyncEngine syncEngine)
|
||||||
|
|||||||
@@ -21,6 +21,21 @@
|
|||||||
<Style Selector="Border.supervisioncell.substitution">
|
<Style Selector="Border.supervisioncell.substitution">
|
||||||
<Setter Property="Background" Value="#8E24AA"/>
|
<Setter Property="Background" Value="#8E24AA"/>
|
||||||
</Style>
|
</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>
|
</UserControl.Styles>
|
||||||
|
|
||||||
<Grid RowDefinitions="Auto,Auto,*">
|
<Grid RowDefinitions="Auto,Auto,*">
|
||||||
@@ -226,36 +241,65 @@
|
|||||||
</Border>
|
</Border>
|
||||||
<Border Classes="timetablecell" Classes.assigned="{Binding IsAssigned}"
|
<Border Classes="timetablecell" Classes.assigned="{Binding IsAssigned}"
|
||||||
CornerRadius="6" IsVisible="{Binding IsSlotCell}">
|
CornerRadius="6" IsVisible="{Binding IsSlotCell}">
|
||||||
<Button Background="{Binding ColorHex}" IsVisible="{Binding IsAssigned}"
|
<Panel IsVisible="{Binding IsAssigned}">
|
||||||
HorizontalAlignment="Stretch" VerticalAlignment="Stretch"
|
<Button Background="{Binding ColorHex}"
|
||||||
HorizontalContentAlignment="Stretch" CornerRadius="6" Padding="6"
|
HorizontalAlignment="Stretch" VerticalAlignment="Stretch"
|
||||||
Command="{Binding $parent[ItemsControl;1].((vm:TimetableViewModel)DataContext).OpenWeekCellCommand}"
|
HorizontalContentAlignment="Stretch" CornerRadius="6" Padding="6"
|
||||||
CommandParameter="{Binding}">
|
Command="{Binding $parent[ItemsControl;1].((vm:TimetableViewModel)DataContext).OpenWeekCellCommand}"
|
||||||
<StackPanel Spacing="1">
|
CommandParameter="{Binding}">
|
||||||
<TextBlock FontSize="11" FontWeight="Bold" Foreground="White" TextWrapping="Wrap">
|
<StackPanel Spacing="1">
|
||||||
<Run Text="{Binding SubjectLabel}"/><Run Text=" · "/><Run Text="{Binding GroupName}"/>
|
<TextBlock FontSize="11" FontWeight="Bold" Foreground="White" TextWrapping="Wrap">
|
||||||
</TextBlock>
|
<Run Text="{Binding SubjectLabel}"/><Run Text=" · "/><Run Text="{Binding GroupName}"/>
|
||||||
<TextBlock Text="{Binding Room}" FontSize="10" Foreground="White" Opacity="0.9"
|
</TextBlock>
|
||||||
IsVisible="{Binding HasRoom}"/>
|
<TextBlock Text="{Binding Room}" FontSize="10" Foreground="White" Opacity="0.9"
|
||||||
<TextBlock Text="{Binding Topic}" FontSize="10" Foreground="White" Opacity="0.8"
|
IsVisible="{Binding HasRoom}"/>
|
||||||
TextWrapping="Wrap" IsVisible="{Binding HasTopic}"/>
|
<TextBlock Text="{Binding Topic}" FontSize="10" Foreground="White" Opacity="0.8"
|
||||||
<TextBlock Text="Ferien" FontSize="10" Foreground="White" FontWeight="SemiBold"
|
TextWrapping="Wrap" IsVisible="{Binding HasTopic}"/>
|
||||||
Opacity="0.9" IsVisible="{Binding IsHoliday}" Margin="0,2,0,0"/>
|
<TextBlock Text="Ferien" FontSize="10" Foreground="White" FontWeight="SemiBold"
|
||||||
<TextBlock Text="Ausfall" FontSize="10" Foreground="White" FontWeight="SemiBold"
|
Opacity="0.9" IsVisible="{Binding IsHoliday}" Margin="0,2,0,0"/>
|
||||||
Opacity="0.9" IsVisible="{Binding IsCancelled}" Margin="0,2,0,0"/>
|
<TextBlock Text="Ausfall" FontSize="10" Foreground="White" FontWeight="SemiBold"
|
||||||
<StackPanel Orientation="Horizontal" Spacing="3" Margin="0,2,0,0"
|
Opacity="0.9" IsVisible="{Binding IsCancelled}" Margin="0,2,0,0"/>
|
||||||
IsVisible="{Binding !IsHoliday}">
|
<StackPanel Orientation="Horizontal" Spacing="3" Margin="0,2,0,0"
|
||||||
<Border Background="#B71C1C" CornerRadius="7" Padding="4,0" IsVisible="{Binding HasHolidayBadge}">
|
IsVisible="{Binding !IsHoliday}">
|
||||||
<TextBlock Text="{Binding HolidayBadge}" FontSize="9" FontWeight="Bold" Foreground="White"/>
|
<Border Background="#B71C1C" CornerRadius="7" Padding="4,0" IsVisible="{Binding HasHolidayBadge}">
|
||||||
</Border>
|
<TextBlock Text="{Binding HolidayBadge}" FontSize="9" FontWeight="Bold" Foreground="White"/>
|
||||||
<TextBlock Text="📝" FontSize="11" IsVisible="{Binding HasExam}" ToolTip.Tip="Klausur"/>
|
</Border>
|
||||||
<TextBlock Text="⏰" FontSize="11" IsVisible="{Binding IsLastBeforeExam}" ToolTip.Tip="Letzte Stunde vor der Klausur"/>
|
<TextBlock Text="📝" FontSize="11" IsVisible="{Binding HasExam}" ToolTip.Tip="Klausur"/>
|
||||||
<TextBlock Text="🧪" FontSize="11" IsVisible="{Binding HasExperiment}" ToolTip.Tip="Experiment geplant"/>
|
<TextBlock Text="⏰" FontSize="11" IsVisible="{Binding IsLastBeforeExam}" ToolTip.Tip="Letzte Stunde vor der Klausur"/>
|
||||||
<TextBlock Text="📓" FontSize="11" IsVisible="{Binding HasUnhandledHomework}"
|
<TextBlock Text="🧪" FontSize="11" IsVisible="{Binding HasExperiment}" ToolTip.Tip="Experiment geplant"/>
|
||||||
ToolTip.Tip="Hausaufgabe aus letzter Stunde noch nicht kontrolliert"/>
|
<TextBlock Text="📓" FontSize="11" IsVisible="{Binding HasUnhandledHomework}"
|
||||||
|
ToolTip.Tip="Hausaufgabe aus letzter Stunde noch nicht kontrolliert"/>
|
||||||
|
</StackPanel>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
</StackPanel>
|
</Button>
|
||||||
</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>
|
</Border>
|
||||||
</Grid>
|
</Grid>
|
||||||
</DataTemplate>
|
</DataTemplate>
|
||||||
@@ -308,6 +352,11 @@
|
|||||||
<ContentPage Header="Bearbeiten">
|
<ContentPage Header="Bearbeiten">
|
||||||
<ScrollViewer>
|
<ScrollViewer>
|
||||||
<StackPanel Margin="32,20,32,28" Spacing="10">
|
<StackPanel Margin="32,20,32,28" Spacing="10">
|
||||||
|
<StackPanel Orientation="Horizontal" Spacing="8" Margin="0,0,0,6">
|
||||||
|
<Button Content="Aus WebUntis laden…" Command="{Binding ImportWebUntisTimetableCommand}"/>
|
||||||
|
<TextBlock Text="Importiert oder vergleicht eine typische Unterrichtswoche; Vertretungen bleiben im iCal-Abgleich."
|
||||||
|
FontSize="11" Opacity="0.6" VerticalAlignment="Center"/>
|
||||||
|
</StackPanel>
|
||||||
<!-- Zeilenweise (GridRows) statt eines flachen UniformGrid, siehe Kommentar im
|
<!-- Zeilenweise (GridRows) statt eines flachen UniformGrid, siehe Kommentar im
|
||||||
Wochenraster oben (Heute-Tab) - gleicher Grund (Grid.RowDefinitions lässt sich
|
Wochenraster oben (Heute-Tab) - gleicher Grund (Grid.RowDefinitions lässt sich
|
||||||
nicht per {Binding} setzen). -->
|
nicht per {Binding} setzen). -->
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
using Avalonia.Controls;
|
using Avalonia.Controls;
|
||||||
|
using Avalonia.Interactivity;
|
||||||
using LehrerApp.Core.Interfaces;
|
using LehrerApp.Core.Interfaces;
|
||||||
using LehrerApp.Core.Models;
|
using LehrerApp.Core.Models;
|
||||||
using LehrerApp.Core.Services;
|
using LehrerApp.Core.Services;
|
||||||
|
using LehrerApp.Desktop.ViewModels;
|
||||||
using LehrerApp.Desktop.ViewModels.Planning;
|
using LehrerApp.Desktop.ViewModels.Planning;
|
||||||
using LehrerApp.Desktop.ViewModels.Groups;
|
using LehrerApp.Desktop.ViewModels.Groups;
|
||||||
using LehrerApp.Desktop.Views.Groups;
|
using LehrerApp.Desktop.Views.Groups;
|
||||||
|
using LehrerApp.Desktop.Services;
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
|
||||||
namespace LehrerApp.Desktop.Views.Planning;
|
namespace LehrerApp.Desktop.Views.Planning;
|
||||||
@@ -20,11 +23,67 @@ public partial class TimetableView : UserControl
|
|||||||
{
|
{
|
||||||
vm.OnEditSlot = ShowSlotDialog;
|
vm.OnEditSlot = ShowSlotDialog;
|
||||||
vm.OnAddSubstitution = ShowSubstitutionDialog;
|
vm.OnAddSubstitution = ShowSubstitutionDialog;
|
||||||
|
vm.OnImportWebUntisTimetable = ShowWebUntisTimetableDialog;
|
||||||
vm.OnOpenLessonViewer = ShowLessonViewerDialog;
|
vm.OnOpenLessonViewer = ShowLessonViewerDialog;
|
||||||
vm.OnOpenTeachingMode = ShowTeachingMode;
|
vm.OnOpenTeachingMode = ShowTeachingMode;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 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;
|
||||||
|
if (owner is null) return;
|
||||||
|
var vm = new WebUntisTimetableImportViewModel(
|
||||||
|
App.Services.GetRequiredService<WebUntisIntegrationService>(),
|
||||||
|
App.Services.GetRequiredService<WebUntisSettingsService>(),
|
||||||
|
App.Services.GetRequiredService<ITimetableSlotRepository>(),
|
||||||
|
App.Services.GetRequiredService<IGroupRepository>());
|
||||||
|
var dialog = new WebUntisTimetableImportDialog { DataContext = vm };
|
||||||
|
vm.OnCreateGroup = dialog.CreateGroupAsync;
|
||||||
|
await vm.InitializeAsync();
|
||||||
|
await dialog.ShowDialog<bool>(owner);
|
||||||
|
}
|
||||||
|
|
||||||
private async Task ShowTeachingMode(Lesson lesson)
|
private async Task ShowTeachingMode(Lesson lesson)
|
||||||
{
|
{
|
||||||
var owner = TopLevel.GetTopLevel(this) as Window;
|
var owner = TopLevel.GetTopLevel(this) as Window;
|
||||||
@@ -33,7 +92,9 @@ public partial class TimetableView : UserControl
|
|||||||
|
|
||||||
var teachingModeVm = new TeachingModeViewModel(lesson, group,
|
var teachingModeVm = new TeachingModeViewModel(lesson, group,
|
||||||
App.Services.GetRequiredService<IAlternativeLessonPathRepository>(),
|
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 };
|
var window = new TeachingModeWindow { DataContext = teachingModeVm };
|
||||||
await window.ShowDialog(owner);
|
await window.ShowDialog(owner);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.Planning"
|
||||||
|
xmlns:svc="clr-namespace:LehrerApp.Desktop.Services"
|
||||||
|
x:Class="LehrerApp.Desktop.Views.Planning.WebUntisTimetableImportDialog"
|
||||||
|
x:DataType="vm:WebUntisTimetableImportViewModel"
|
||||||
|
Title="Stundenplan aus WebUntis" Width="820" Height="650"
|
||||||
|
MinWidth="680" MinHeight="480" WindowStartupLocation="CenterOwner">
|
||||||
|
<Grid RowDefinitions="Auto,Auto,*,Auto" Margin="24" RowSpacing="14">
|
||||||
|
<StackPanel Grid.Row="0" Spacing="4">
|
||||||
|
<TextBlock Text="Stundenplan aus WebUntis" Classes="dialogtitle"/>
|
||||||
|
<TextBlock Text="Wähle eine typische Unterrichtswoche. Vorhandene Einträge werden vorgeschlagen und erst nach Bestätigung ersetzt."
|
||||||
|
FontSize="12" Opacity="0.65" TextWrapping="Wrap"/>
|
||||||
|
</StackPanel>
|
||||||
|
<Grid Grid.Row="1" ColumnDefinitions="2*,*,Auto" ColumnSpacing="10">
|
||||||
|
<ComboBox Grid.Column="0" ItemsSource="{Binding Teachers}" SelectedItem="{Binding SelectedTeacher}">
|
||||||
|
<ComboBox.ItemTemplate>
|
||||||
|
<DataTemplate x:DataType="svc:UntisTeacherDto"><TextBlock Text="{Binding DisplayName}"/></DataTemplate>
|
||||||
|
</ComboBox.ItemTemplate>
|
||||||
|
</ComboBox>
|
||||||
|
<DatePicker Grid.Column="1" SelectedDate="{Binding WeekDate}"/>
|
||||||
|
<Button Grid.Column="2" Content="Woche laden" Command="{Binding LoadCommand}" IsEnabled="{Binding !Busy}"/>
|
||||||
|
</Grid>
|
||||||
|
<ScrollViewer Grid.Row="2">
|
||||||
|
<ItemsControl ItemsSource="{Binding Rows}">
|
||||||
|
<ItemsControl.ItemTemplate>
|
||||||
|
<DataTemplate x:DataType="vm:WebUntisTimetableRow">
|
||||||
|
<Grid ColumnDefinitions="48,85,2*,2*,Auto" ColumnSpacing="8" Margin="0,3">
|
||||||
|
<TextBlock Grid.Column="0" Text="{Binding WeekdayLabel}" VerticalAlignment="Center" FontWeight="SemiBold"/>
|
||||||
|
<StackPanel Grid.Column="1">
|
||||||
|
<TextBlock Text="{Binding PeriodNumber, StringFormat={}{0}. Std.}" FontSize="12"/>
|
||||||
|
<TextBlock Text="{Binding TimeLabel}" FontSize="10" Opacity="0.6"/>
|
||||||
|
</StackPanel>
|
||||||
|
<TextBlock Grid.Column="2" Text="{Binding UntisLabel}" TextWrapping="Wrap" VerticalAlignment="Center"/>
|
||||||
|
<ComboBox Grid.Column="3" ItemsSource="{Binding GroupOptions}" SelectedItem="{Binding SelectedGroup}"
|
||||||
|
PlaceholderText="nicht übernehmen">
|
||||||
|
<ComboBox.ItemTemplate>
|
||||||
|
<DataTemplate x:DataType="vm:WebUntisGroupOption"><TextBlock Text="{Binding DisplayName}"/></DataTemplate>
|
||||||
|
</ComboBox.ItemTemplate>
|
||||||
|
</ComboBox>
|
||||||
|
<Button Grid.Column="4" Content="Neue Gruppe…" FontSize="11"
|
||||||
|
Command="{Binding $parent[ItemsControl].((vm:WebUntisTimetableImportViewModel)DataContext).CreateGroupCommand}"
|
||||||
|
CommandParameter="{Binding}"/>
|
||||||
|
</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="Abbrechen" Click="OnCancel"/>
|
||||||
|
<Button Grid.Column="2" Content="Zuordnung übernehmen" Command="{Binding SaveCommand}" Click="OnSave"
|
||||||
|
IsEnabled="{Binding !Busy}"/>
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
</Window>
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
using Avalonia.Controls;
|
||||||
|
using Avalonia.Interactivity;
|
||||||
|
using LehrerApp.Core.Models;
|
||||||
|
using LehrerApp.Desktop.ViewModels.Groups;
|
||||||
|
using LehrerApp.Desktop.ViewModels.Planning;
|
||||||
|
using LehrerApp.Desktop.Views.Groups;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
|
||||||
|
namespace LehrerApp.Desktop.Views.Planning;
|
||||||
|
|
||||||
|
public partial class WebUntisTimetableImportDialog : Window
|
||||||
|
{
|
||||||
|
public WebUntisTimetableImportDialog() => InitializeComponent();
|
||||||
|
|
||||||
|
private void OnCancel(object? sender, RoutedEventArgs e) => Close(false);
|
||||||
|
private void OnSave(object? sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
if (DataContext is WebUntisTimetableImportViewModel { Saved: true }) Close(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<LearningGroup?> CreateGroupAsync(WebUntisTimetableRow source)
|
||||||
|
{
|
||||||
|
var vm = App.Services.GetRequiredService<AddGroupDialogViewModel>();
|
||||||
|
vm.Name = source.SuggestedGroupName;
|
||||||
|
vm.Subject = source.SubjectName ?? "";
|
||||||
|
vm.GradeLevel = ParseGrade(source.SuggestedGroupName) ?? 10;
|
||||||
|
var dialog = new AddGroupDialog { DataContext = vm };
|
||||||
|
return await dialog.ShowDialog<bool>(this) ? vm.Result : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int? ParseGrade(string value)
|
||||||
|
{
|
||||||
|
var digits = new string(value.TakeWhile(char.IsDigit).ToArray());
|
||||||
|
return int.TryParse(digits, out var grade) && grade is >= 1 and <= 13 ? grade : null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -967,7 +967,53 @@
|
|||||||
|
|
||||||
<TextBlock Text="Untis-Einbettung" FontSize="18" FontWeight="SemiBold"/>
|
<TextBlock Text="Untis-Einbettung" FontSize="18" FontWeight="SemiBold"/>
|
||||||
<TextBlock FontSize="12" Opacity="0.6" TextWrapping="Wrap"
|
<TextBlock FontSize="12" Opacity="0.6" TextWrapping="Wrap"
|
||||||
Text="Bindet den persönlichen WebUntis-Stundenplan und den schulweiten Jahresplan als zwei unabhängige iCal-Quellen ein."/>
|
Text="Bindet WebUntis-Daten über deinen persönlichen Zugang ein. Der bestehende iCal-Abgleich für Vertretungen bleibt davon unabhängig."/>
|
||||||
|
|
||||||
|
<TextBlock Text="WebUntis-API" FontSize="16" FontWeight="SemiBold"/>
|
||||||
|
<TextBlock FontSize="12" Opacity="0.6" TextWrapping="Wrap"
|
||||||
|
Text="Ermöglicht Stundenplan-, Schüler- und Fehlzeitenabgleich direkt zwischen diesem Gerät und WebUntis. Zugangsdaten werden lokal verschlüsselt gespeichert; weder sie noch Schülerdaten oder CSV-Reports passieren den LehrerApp-Server."/>
|
||||||
|
<Grid ColumnDefinitions="*,*" RowDefinitions="Auto,Auto" ColumnSpacing="8" RowSpacing="8">
|
||||||
|
<StackPanel Grid.Row="0" Grid.Column="0" Spacing="4">
|
||||||
|
<TextBlock Text="Schule" FontSize="12" Opacity="0.7"/>
|
||||||
|
<TextBox Text="{Binding UntisSchool}" PlaceholderText="Schulkennung, nicht Anzeigename"/>
|
||||||
|
</StackPanel>
|
||||||
|
<StackPanel Grid.Row="0" Grid.Column="1" Spacing="4">
|
||||||
|
<TextBlock Text="Server (optional)" FontSize="12" Opacity="0.7"/>
|
||||||
|
<TextBox Text="{Binding UntisHost}" PlaceholderText="z. B. arche.webuntis.com"/>
|
||||||
|
</StackPanel>
|
||||||
|
<StackPanel Grid.Row="1" Grid.Column="0" Spacing="4">
|
||||||
|
<TextBlock Text="Benutzername" FontSize="12" Opacity="0.7"/>
|
||||||
|
<TextBox Text="{Binding UntisUsername}"/>
|
||||||
|
</StackPanel>
|
||||||
|
<StackPanel Grid.Row="1" Grid.Column="1" Spacing="4">
|
||||||
|
<TextBlock Text="Passwort" FontSize="12" Opacity="0.7"/>
|
||||||
|
<TextBox Text="{Binding UntisPassword}" PasswordChar="●"/>
|
||||||
|
</StackPanel>
|
||||||
|
</Grid>
|
||||||
|
<TextBlock FontSize="11" Opacity="0.6" TextWrapping="Wrap"
|
||||||
|
Text="Die Schulkennung steht in der WebUntis-Anmelde-URL hinter ?school=. Als Server kannst du auch die vollständige Anmelde-URL einfügen; Server und Schulkennung sind häufig verschieden."/>
|
||||||
|
<TextBlock Text="{Binding UntisApiStatus}" FontSize="12" TextWrapping="Wrap"/>
|
||||||
|
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||||
|
<Button Content="Anmeldung prüfen und speichern" Command="{Binding UntisSaveApiCommand}"
|
||||||
|
IsEnabled="{Binding !UntisApiBusy}"/>
|
||||||
|
<Button Content="API-Zugang entfernen" Command="{Binding UntisRemoveApiCommand}"
|
||||||
|
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"/>
|
<TextBlock Text="WebUntis-Stundenplan-Abgleich" FontSize="16" FontWeight="SemiBold"/>
|
||||||
<TextBlock FontSize="12" Opacity="0.6" TextWrapping="Wrap"
|
<TextBlock FontSize="12" Opacity="0.6" TextWrapping="Wrap"
|
||||||
|
|||||||
@@ -4,8 +4,10 @@ using Avalonia.Platform.Storage;
|
|||||||
using LehrerApp.Core.Interfaces;
|
using LehrerApp.Core.Interfaces;
|
||||||
using LehrerApp.Desktop.Services;
|
using LehrerApp.Desktop.Services;
|
||||||
using LehrerApp.Desktop.ViewModels;
|
using LehrerApp.Desktop.ViewModels;
|
||||||
|
using LehrerApp.Desktop.ViewModels.Groups;
|
||||||
using LehrerApp.Desktop.ViewModels.Planning;
|
using LehrerApp.Desktop.ViewModels.Planning;
|
||||||
using LehrerApp.Desktop.ViewModels.Settings;
|
using LehrerApp.Desktop.ViewModels.Settings;
|
||||||
|
using LehrerApp.Desktop.Views.Groups;
|
||||||
using LehrerApp.Desktop.Views.Planning;
|
using LehrerApp.Desktop.Views.Planning;
|
||||||
using LehrerApp.Desktop.Views.Shared;
|
using LehrerApp.Desktop.Views.Shared;
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
@@ -31,6 +33,7 @@ public partial class SettingsView : UserControl
|
|||||||
vm.OnConfirmRecoveryRestore = ShowRecoveryRestoreConfirmDialog;
|
vm.OnConfirmRecoveryRestore = ShowRecoveryRestoreConfirmDialog;
|
||||||
vm.OnThemeChanged = App.ApplyTheme;
|
vm.OnThemeChanged = App.ApplyTheme;
|
||||||
vm.OnReviewUntisMapping = ShowUntisMappingReviewDialog;
|
vm.OnReviewUntisMapping = ShowUntisMappingReviewDialog;
|
||||||
|
vm.OnPickHomeroomClass = PickHomeroomClassAsync;
|
||||||
_ = vm.LoadSchoolLocationCommand.ExecuteAsync(null);
|
_ = vm.LoadSchoolLocationCommand.ExecuteAsync(null);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -51,6 +54,20 @@ public partial class SettingsView : UserControl
|
|||||||
await dialog.ShowDialog<bool>(owner);
|
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)
|
private async Task<bool> SaveRecoveryFile(string content)
|
||||||
{
|
{
|
||||||
var topLevel = TopLevel.GetTopLevel(this);
|
var topLevel = TopLevel.GetTopLevel(this);
|
||||||
|
|||||||
@@ -233,6 +233,9 @@
|
|||||||
|
|
||||||
<CheckBox Content="Vertraulich" IsChecked="{Binding IsConfidential}"
|
<CheckBox Content="Vertraulich" IsChecked="{Binding IsConfidential}"
|
||||||
ToolTip.Tip="Blendet den Inhalt in der Übersicht standardmäßig aus; erst nach Klick auf 'Anzeigen' sichtbar."/>
|
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>
|
</StackPanel>
|
||||||
</ScrollViewer>
|
</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());
|
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]
|
[Fact]
|
||||||
public async Task PushAsync_SetztBasedOnServerSeqAusLokalerVersionsverfolgung()
|
public async Task PushAsync_SetztBasedOnServerSeqAusLokalerVersionsverfolgung()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -49,8 +49,14 @@ public class EventQueue : IDisposable
|
|||||||
return evt;
|
return evt;
|
||||||
}
|
}
|
||||||
|
|
||||||
public List<SyncEvent> GetPending(int max = 200) =>
|
public List<SyncEvent> GetPending(int max = SyncProtocol.PushBatchSize,
|
||||||
_queue.Find(Query.All(nameof(SyncEvent.SequenceNr))).Take(max).ToList();
|
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 int PendingCount() => _queue.Count();
|
||||||
public void Acknowledge(IEnumerable<Guid> ids) { foreach (var id in ids) _queue.Delete(id); }
|
public void Acknowledge(IEnumerable<Guid> ids) { foreach (var id in ids) _queue.Delete(id); }
|
||||||
public long GetLastServerSeq() => _meta.FindById("serverSeq")?.Value ?? 0;
|
public long GetLastServerSeq() => _meta.FindById("serverSeq")?.Value ?? 0;
|
||||||
|
|||||||
@@ -86,14 +86,37 @@ public class SyncEngine : IDisposable
|
|||||||
|
|
||||||
private async Task<SyncResult> RunSyncAsync(bool isAutomatic)
|
private async Task<SyncResult> RunSyncAsync(bool isAutomatic)
|
||||||
{
|
{
|
||||||
|
var pulledDataChanged = false;
|
||||||
SetState(SyncState.Syncing);
|
SetState(SyncState.Syncing);
|
||||||
_logger?.Info($"Sync: Start ({(isAutomatic ? "automatisch" : "manuell")}), Gerät={_config.DeviceId}, " +
|
_logger?.Info($"Sync: Start ({(isAutomatic ? "automatisch" : "manuell")}), Gerät={_config.DeviceId}, " +
|
||||||
$"{_queue.PendingCount()} lokal ausstehend");
|
$"{_queue.PendingCount()} lokal ausstehend");
|
||||||
try
|
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);
|
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);
|
_queue.SetLastSyncAt(DateTime.UtcNow);
|
||||||
SetState(SyncState.Idle);
|
SetState(SyncState.Idle);
|
||||||
_logger?.Info($"Sync: Fertig - {pushed} gepusht ({pushConflicts} Push-Konflikte), " +
|
_logger?.Info($"Sync: Fertig - {pushed} gepusht ({pushConflicts} Push-Konflikte), " +
|
||||||
@@ -121,12 +144,19 @@ public class SyncEngine : IDisposable
|
|||||||
SetState(SyncState.Error, ex.Message);
|
SetState(SyncState.Error, ex.Message);
|
||||||
return new() { Reason = 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();
|
var pending = DeduplicatePending(excludedEventIds, out var sourceEventCount);
|
||||||
if (pending.Count == 0) return (0, 0);
|
if (pending.Count == 0) return new(0, 0, sourceEventCount, []);
|
||||||
// BasedOnServerSeq erst unmittelbar vor dem Senden setzen (nicht beim Enqueue) - zwischen
|
// 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
|
// Enqueue und Push kann ein Pull den lokal bekannten Stand dieser Entität aktualisiert
|
||||||
// haben (siehe EventApplier.ApplyAsync).
|
// haben (siehe EventApplier.ApplyAsync).
|
||||||
@@ -139,7 +169,11 @@ public class SyncEngine : IDisposable
|
|||||||
using var resp = await _http.SendAsync(request);
|
using var resp = await _http.SendAsync(request);
|
||||||
await SyncProtocol.EnsureCompatibleSuccessAsync(resp);
|
await SyncProtocol.EnsureCompatibleSuccessAsync(resp);
|
||||||
var result = await resp.Content.ReadFromJsonAsync<PushResponse>();
|
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
|
_queue.Acknowledge(pending
|
||||||
.Where(e => !result.ConflictingEventIds.Contains(e.EventId))
|
.Where(e => !result.ConflictingEventIds.Contains(e.EventId))
|
||||||
.Select(e => e.EventId));
|
.Select(e => e.EventId));
|
||||||
@@ -161,8 +195,8 @@ public class SyncEngine : IDisposable
|
|||||||
$"{result.ConflictingEventIds.Count} abgelehnt (Konflikt).");
|
$"{result.ConflictingEventIds.Count} abgelehnt (Konflikt).");
|
||||||
if (result.ConflictingEventIds.Count > 0)
|
if (result.ConflictingEventIds.Count > 0)
|
||||||
await HandleRejectedAsync(pending.Where(e => result.ConflictingEventIds.Contains(e.EventId)));
|
await HandleRejectedAsync(pending.Where(e => result.ConflictingEventIds.Contains(e.EventId)));
|
||||||
return (pending.Count - result.ConflictingEventIds.Count,
|
return new(pending.Count - result.ConflictingEventIds.Count,
|
||||||
result.ConflictingEventIds.Count);
|
result.ConflictingEventIds.Count, sourceEventCount, result.ConflictingEventIds);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Payload ist immer ein vollständiges Entitäts-Snapshot (nie ein Delta, siehe
|
// 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
|
// 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
|
// selben Batch mit demselben (veralteten) BasedOnServerSeq ankommen und sich gegenseitig ins
|
||||||
// Aus laufen.
|
// 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;
|
if (pending.Count == 0) return pending;
|
||||||
var latest = pending
|
var latest = pending
|
||||||
.GroupBy(e => (e.EntityType, e.EntityId))
|
.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();
|
var since = _queue.GetLastServerSeq();
|
||||||
_logger?.Info($"Sync: Pull - frage Server nach Ereignissen seit ServerSequenceNr={since}.");
|
_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)
|
if (resp is null || resp.Events.Count == 0)
|
||||||
{
|
{
|
||||||
_logger?.Info("Sync: Pull - keine neuen Ereignisse vom Server.");
|
_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: " +
|
_logger?.Info($"Sync: Pull - {resp.Events.Count} Ereignis(se) vom Server erhalten: " +
|
||||||
string.Join(", ", resp.Events.Select(e => $"{e.EntityType}/{e.Operation}")));
|
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);
|
if (c.Resolution == "RemoteWon") await _applier.ApplyAsync(evt);
|
||||||
}
|
}
|
||||||
_queue.SetLastServerSeq(resp.ServerSequenceNr);
|
_queue.SetLastServerSeq(resp.ServerSequenceNr);
|
||||||
DataChanged?.Invoke();
|
return new(resp.Events.Count, conflicts);
|
||||||
return (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)
|
private void SetState(SyncState state, string? error = null)
|
||||||
{
|
{
|
||||||
Status = new SyncStatus
|
Status = new SyncStatus
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ public static class SyncProtocol
|
|||||||
{
|
{
|
||||||
public const string CurrentVersion = "1";
|
public const string CurrentVersion = "1";
|
||||||
public const string VersionHeaderName = "X-LehrerApp-Sync-Version";
|
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)
|
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,
|
Name = "8a", SchoolYear = "2025/26", GradeLevel = 8,
|
||||||
Type = GroupType.Class, SubjectId = Guid.NewGuid(), GradingSystem = GradingSystem.Grades1To6,
|
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
|
var oldMembership = new GroupMembership
|
||||||
{
|
{
|
||||||
@@ -38,6 +38,7 @@ public sealed class GroupRolloverServiceTests
|
|||||||
Assert.Equal(source.HoursPerWeek, target.HoursPerWeek);
|
Assert.Equal(source.HoursPerWeek, target.HoursPerWeek);
|
||||||
Assert.True(target.IsOwnClass);
|
Assert.True(target.IsOwnClass);
|
||||||
Assert.True(target.IsDifferentiated);
|
Assert.True(target.IsDifferentiated);
|
||||||
|
Assert.Null(target.WebUntisLessonId);
|
||||||
Assert.False(source.IsActive);
|
Assert.False(source.IsActive);
|
||||||
|
|
||||||
var copied = Assert.Single(memberships.GetByGroup(target.Id));
|
var copied = Assert.Single(memberships.GetByGroup(target.Id));
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<IsPackable>false</IsPackable>
|
||||||
|
<IsTestProject>true</IsTestProject>
|
||||||
|
</PropertyGroup>
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\LehrerApp.WebUntis\LehrerApp.WebUntis.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Microsoft.NET.Test.Sdk" />
|
||||||
|
<PackageReference Include="xunit" />
|
||||||
|
<PackageReference Include="xunit.runner.visualstudio">
|
||||||
|
<PrivateAssets>all</PrivateAssets>
|
||||||
|
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||||
|
</PackageReference>
|
||||||
|
</ItemGroup>
|
||||||
|
</Project>
|
||||||
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,357 @@
|
|||||||
|
using System.Net;
|
||||||
|
using System.Diagnostics;
|
||||||
|
using System.Text;
|
||||||
|
using LehrerApp.WebUntis;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace LehrerApp.WebUntis.Tests;
|
||||||
|
|
||||||
|
public sealed class WebUntisClientTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public async Task AufeinanderfolgendeAbrufe_VerwendenDieselbeSessionBisZumDispose()
|
||||||
|
{
|
||||||
|
var latin1 = Encoding.Latin1.GetBytes(
|
||||||
|
"id\texternKey\tklasse.name\tlongName\tforeName\r\n" +
|
||||||
|
"1\t10\t7a\tMüller\tAda\r\n" +
|
||||||
|
"2\t20\t8b\tMeier\tBerta\r\n");
|
||||||
|
var handler = new QueueHandler(
|
||||||
|
Json("{\"jsonrpc\":\"2.0\",\"result\":{\"sessionId\":\"session-1\"}}"),
|
||||||
|
Json("{\"data\":{\"finished\":true,\"error\":false,\"reportParams\":\"foo=bar\"}}"),
|
||||||
|
new HttpResponseMessage(HttpStatusCode.OK)
|
||||||
|
{
|
||||||
|
Content = new ByteArrayContent(latin1),
|
||||||
|
},
|
||||||
|
Json("{\"jsonrpc\":\"2.0\",\"result\":[{\"id\":7,\"name\":\"2026/27\",\"startDate\":20260801,\"endDate\":20270731}]}"),
|
||||||
|
Json("{\"jsonrpc\":\"2.0\",\"result\":{}}"));
|
||||||
|
var client = CreateClient(handler);
|
||||||
|
|
||||||
|
var result = await client.GetStudentReportAsync("7a", CancellationToken.None);
|
||||||
|
var schoolYears = await client.GetSchoolYearsAsync(CancellationToken.None);
|
||||||
|
|
||||||
|
var student = Assert.Single(result.Students);
|
||||||
|
Assert.Equal("Ada Müller", student.DisplayName);
|
||||||
|
Assert.Equal("7a", result.ClassNameFilter);
|
||||||
|
Assert.Equal("2026/27", Assert.Single(schoolYears).Name);
|
||||||
|
Assert.Equal(4, handler.Requests.Count);
|
||||||
|
Assert.Contains("jsonrpc.do?school=meine-schule", handler.Requests[0].Uri);
|
||||||
|
Assert.Contains("\"method\":\"authenticate\"", handler.Requests[0].Body);
|
||||||
|
Assert.Contains("reports.do?name=Student", handler.Requests[1].Uri);
|
||||||
|
Assert.Contains("schoolname=\"_", handler.Requests[1].Cookie);
|
||||||
|
Assert.EndsWith("reports.do?foo=bar", handler.Requests[2].Uri);
|
||||||
|
Assert.Contains("\"method\":\"getSchoolyears\"", handler.Requests[3].Body);
|
||||||
|
Assert.Single(handler.Requests,
|
||||||
|
request => request.Body.Contains("\"method\":\"authenticate\""));
|
||||||
|
|
||||||
|
await client.DisposeAsync();
|
||||||
|
|
||||||
|
Assert.Equal(5, handler.Requests.Count);
|
||||||
|
Assert.Contains("\"method\":\"logout\"", handler.Requests[4].Body);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetTimetableAsync_SendetElementUndZeitraumAlsSeparateAbfrage()
|
||||||
|
{
|
||||||
|
var handler = new QueueHandler(
|
||||||
|
Json("{\"result\":{\"sessionId\":\"s\"}}"),
|
||||||
|
Json("{\"result\":[{\"id\":99,\"date\":20260824,\"startTime\":800,\"endTime\":845," +
|
||||||
|
"\"kl\":[{\"id\":4,\"name\":\"7a\"}],\"te\":[],\"su\":[],\"ro\":[]}]}"),
|
||||||
|
Json("{\"result\":{}}"));
|
||||||
|
var client = CreateClient(handler);
|
||||||
|
|
||||||
|
var periods = await client.GetTimetableAsync(UntisTimetableElementType.Teacher, 42,
|
||||||
|
20260824, 20260828, CancellationToken.None);
|
||||||
|
|
||||||
|
var period = Assert.Single(periods);
|
||||||
|
Assert.Equal(99, period.Id);
|
||||||
|
Assert.Equal("7a", Assert.Single(period.Classes).Name);
|
||||||
|
Assert.Contains("\"method\":\"getTimetable\"", handler.Requests[1].Body);
|
||||||
|
Assert.Contains("\"element\":{\"id\":42,\"type\":2}", handler.Requests[1].Body);
|
||||||
|
Assert.Contains("\"startDate\":20260824", handler.Requests[1].Body);
|
||||||
|
|
||||||
|
await client.DisposeAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task FehlendeKonfiguration_BrichtVorHttpRequestAb()
|
||||||
|
{
|
||||||
|
var handler = new QueueHandler();
|
||||||
|
var client = new WebUntisClient(new HttpClient(handler), new WebUntisOptions());
|
||||||
|
|
||||||
|
await Assert.ThrowsAsync<WebUntisConfigurationException>(
|
||||||
|
() => client.GetSchoolYearsAsync(CancellationToken.None));
|
||||||
|
|
||||||
|
Assert.Empty(handler.Requests);
|
||||||
|
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()
|
||||||
|
{
|
||||||
|
var handler = new QueueHandler(
|
||||||
|
Json("{\"result\":{\"sessionId\":\"s\"}}"),
|
||||||
|
Json("{\"result\":[{\"id\":1,\"name\":\"2026/27\",\"startDate\":20260801,\"endDate\":20270731}]}"),
|
||||||
|
Json("{\"result\":{}}"));
|
||||||
|
var client = new WebUntisClient(new HttpClient(handler), new WebUntisOptions
|
||||||
|
{
|
||||||
|
School = "https://arche.webuntis.com/WebUntis/?school=bk-ostvest#/basic/login",
|
||||||
|
Username = "api-user",
|
||||||
|
Password = "secret",
|
||||||
|
});
|
||||||
|
|
||||||
|
await client.GetSchoolYearsAsync(CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.StartsWith("https://arche.webuntis.com/WebUntis/jsonrpc.do", handler.Requests[0].Uri);
|
||||||
|
Assert.Contains("school=bk-ostvest", handler.Requests[0].Uri);
|
||||||
|
await client.DisposeAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task VollstaendigeServerUrl_WirdOhnePfadVerwendet()
|
||||||
|
{
|
||||||
|
var handler = new QueueHandler(
|
||||||
|
Json("{\"result\":{\"sessionId\":\"s\"}}"),
|
||||||
|
Json("{\"result\":[{\"id\":1,\"name\":\"2026/27\",\"startDate\":20260801,\"endDate\":20270731}]}"),
|
||||||
|
Json("{\"result\":{}}"));
|
||||||
|
var client = new WebUntisClient(new HttpClient(handler), new WebUntisOptions
|
||||||
|
{
|
||||||
|
School = "bk-ostvest",
|
||||||
|
Host = "https://arche.webuntis.com/WebUntis/?school=bk-ostvest#/basic/login",
|
||||||
|
Username = "api-user",
|
||||||
|
Password = "secret",
|
||||||
|
});
|
||||||
|
|
||||||
|
await client.GetSchoolYearsAsync(CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.StartsWith("https://arche.webuntis.com/WebUntis/jsonrpc.do", handler.Requests[0].Uri);
|
||||||
|
Assert.Contains("school=bk-ostvest", handler.Requests[0].Uri);
|
||||||
|
await client.DisposeAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Klassenbuch_WirdTypisiertAbgerufen()
|
||||||
|
{
|
||||||
|
var handler = new QueueHandler(
|
||||||
|
Json("{\"result\":{\"sessionId\":\"s\"}}"),
|
||||||
|
Json("{\"result\":[{\"studentid\":9001,\"surname\":\"Müller\",\"forname\":\"Ada\"," +
|
||||||
|
"\"date\":20260902,\"subject\":\"MA\",\"categoryId\":3,\"reason\":\"Material\"," +
|
||||||
|
"\"text\":\"Buch vergessen\"}]}"),
|
||||||
|
Json("{\"result\":[{\"id\":3,\"name\":\"Material\",\"longName\":\"Material vergessen\"," +
|
||||||
|
"\"groupId\":2}]}"),
|
||||||
|
Json("{\"result\":[{\"id\":2,\"name\":\"Organisation\"}]}"),
|
||||||
|
Json("{\"result\":{}}"));
|
||||||
|
var client = CreateClient(handler);
|
||||||
|
|
||||||
|
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 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\":\"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\""));
|
||||||
|
|
||||||
|
await client.DisposeAsync();
|
||||||
|
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
|
||||||
|
{
|
||||||
|
School = "meine-schule",
|
||||||
|
Host = "meine-schule.webuntis.com",
|
||||||
|
Username = "api-user",
|
||||||
|
Password = "secret",
|
||||||
|
Client = "tests",
|
||||||
|
});
|
||||||
|
|
||||||
|
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<CapturedRequest> Requests { get; } = [];
|
||||||
|
|
||||||
|
protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
Requests.Add(new CapturedRequest(
|
||||||
|
request.RequestUri?.ToString() ?? "",
|
||||||
|
request.Content is null ? "" : await request.Content.ReadAsStringAsync(cancellationToken),
|
||||||
|
string.Join("; ", request.Headers.TryGetValues("Cookie", out var cookies) ? cookies : [])));
|
||||||
|
return _responses.Count > 0
|
||||||
|
? _responses.Dequeue()
|
||||||
|
: throw new InvalidOperationException("Keine Testantwort mehr vorhanden.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
using Xunit;
|
||||||
|
using LehrerApp.WebUntis;
|
||||||
|
|
||||||
|
namespace LehrerApp.WebUntis.Tests;
|
||||||
|
|
||||||
|
public sealed class WebUntisStudentReportParserTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void Parse_UebernimmtAlleFelderUndDeutscheDatumswerte()
|
||||||
|
{
|
||||||
|
const string report = "\uFEFFid\texternKey\tklasse.name\tname\tlongName\tforeName\tgender\tbirthDate\tentryDate\texitDate\ttext\tmedicalReportDuty\tschulpflicht\tmajority\tadress.email\tadress.mobile\tadress.phone\tadress.city\tadress.postCode\tadress.street\tattribute.iL\r\n" +
|
||||||
|
"17\t9001\t10a\tMUST\tMustermann\tErika\tw\t03.02.2010\t01.08.2021\t\t\"Zeile 1\nZeile 2\"\tja\tja\tnein\terika@example.org\t0151\t030\tBerlin\t10115\tTestweg 1\tIL-A\r\n";
|
||||||
|
|
||||||
|
var student = Assert.Single(WebUntisStudentReportParser.Parse(report));
|
||||||
|
|
||||||
|
Assert.Equal(17, student.UntisId);
|
||||||
|
Assert.Equal(9001, student.ExternKey);
|
||||||
|
Assert.Equal("10a", student.ClassName);
|
||||||
|
Assert.Equal("Erika Mustermann", student.DisplayName);
|
||||||
|
Assert.Equal(20100203, student.BirthDate);
|
||||||
|
Assert.Equal(20210801, student.EntryDate);
|
||||||
|
Assert.Null(student.ExitDate);
|
||||||
|
Assert.Equal("Zeile 1\nZeile 2", student.Text);
|
||||||
|
Assert.Equal("erika@example.org", student.Address.Email);
|
||||||
|
Assert.Equal("IL-A", student.AttributeIL);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Parse_BehandeltEscapteAnfuehrungszeichenUndLeereZeilen()
|
||||||
|
{
|
||||||
|
const string report = "id\texternKey\tklasse.name\tname\r\n" +
|
||||||
|
"1\t2\t5b\t\"MUS\"\"T\"\r\n\t\t\t\r\n";
|
||||||
|
|
||||||
|
var student = Assert.Single(WebUntisStudentReportParser.Parse(report));
|
||||||
|
|
||||||
|
Assert.Equal("MUS\"T", student.Name);
|
||||||
|
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()
|
||||||
|
{
|
||||||
|
const string report = "id\texternKey\tklasse.name\r\nkeine-zahl\t1\t5b\r\n";
|
||||||
|
|
||||||
|
var error = Assert.Throws<InvalidDataException>(() => WebUntisStudentReportParser.Parse(report));
|
||||||
|
|
||||||
|
Assert.Contains("id", error.Message);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
</PropertyGroup>
|
||||||
|
</Project>
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user