Compare commits
61
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2ccc3201d2 | ||
|
|
ab1feba0f0 | ||
|
|
b2d0ccd30d | ||
|
|
c6efdab9ef | ||
|
|
11ef73ab1f | ||
|
|
4b4a746e17 | ||
|
|
e2e2bfb854 | ||
|
|
3e5f197bdb | ||
|
|
95e3f677e4 | ||
|
|
cdbed69d8b | ||
|
|
527c186090 | ||
|
|
28469bf547 | ||
|
|
9dce6576e2 | ||
|
|
9f28081931 | ||
|
|
6468596bf3 | ||
|
|
23abe7af63 | ||
|
|
3f71f7f371 | ||
|
|
2f2761caf8 | ||
|
|
a088823b30 | ||
|
|
1215d4ac22 | ||
|
|
84c03962ef | ||
|
|
b7c7d46ee4 | ||
|
|
da4b88c93d | ||
|
|
538ad65a3a | ||
|
|
dade41ee8d | ||
|
|
2b299fc940 | ||
|
|
dbd8777e64 | ||
|
|
89a6376fb6 | ||
|
|
b7a105af73 | ||
|
|
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 | ||
|
|
a30aab0fa5 | ||
|
|
07788dcfcb | ||
|
|
788ae6ad32 | ||
|
|
83a430f8ee | ||
|
|
e76884a58e |
@@ -0,0 +1,24 @@
|
|||||||
|
name: CI
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
pull_request:
|
||||||
|
branches: [main]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build-and-test:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
container:
|
||||||
|
image: mcr.microsoft.com/dotnet/sdk:10.0
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Restore
|
||||||
|
run: dotnet restore LehrerApp.sln
|
||||||
|
|
||||||
|
- name: Build
|
||||||
|
run: dotnet build LehrerApp.sln --no-restore --configuration Release
|
||||||
|
|
||||||
|
- name: Test
|
||||||
|
run: dotnet test LehrerApp.sln --no-build --configuration Release --logger "console;verbosity=normal"
|
||||||
Vendored
+15
-1
@@ -5,7 +5,7 @@
|
|||||||
"name": "Desktop App starten",
|
"name": "Desktop App starten",
|
||||||
"type": "coreclr",
|
"type": "coreclr",
|
||||||
"request": "launch",
|
"request": "launch",
|
||||||
"preLaunchTask": "Desktop bauen",
|
"preLaunchTask": "TemplateDesigner bauen",
|
||||||
"program": "${workspaceFolder}/LehrerApp.Desktop/bin/Debug/net10.0/LehrerApp.Desktop.dll",
|
"program": "${workspaceFolder}/LehrerApp.Desktop/bin/Debug/net10.0/LehrerApp.Desktop.dll",
|
||||||
"args": [],
|
"args": [],
|
||||||
"cwd": "${workspaceFolder}/LehrerApp.Desktop",
|
"cwd": "${workspaceFolder}/LehrerApp.Desktop",
|
||||||
@@ -15,6 +15,20 @@
|
|||||||
"DOTNET_ENVIRONMENT": "Development"
|
"DOTNET_ENVIRONMENT": "Development"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "TemplateDesigner App starten",
|
||||||
|
"type": "coreclr",
|
||||||
|
"request": "launch",
|
||||||
|
"preLaunchTask": "Desktop bauen",
|
||||||
|
"program": "${workspaceFolder}/LehrerApp.TemplateDesigner/bin/Debug/net10.0/LehrerApp.TemplateDesigner.dll",
|
||||||
|
"args": [],
|
||||||
|
"cwd": "${workspaceFolder}/LehrerApp.TemplateDesigner",
|
||||||
|
"stopAtEntry": false,
|
||||||
|
"console": "internalConsole",
|
||||||
|
"env": {
|
||||||
|
"DOTNET_ENVIRONMENT": "Development"
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "API Server starten",
|
"name": "API Server starten",
|
||||||
"type": "coreclr",
|
"type": "coreclr",
|
||||||
|
|||||||
Vendored
+13
@@ -27,6 +27,19 @@
|
|||||||
"problemMatcher": "$msCompile",
|
"problemMatcher": "$msCompile",
|
||||||
"group": "build"
|
"group": "build"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"label": "TemplateDesigner bauen",
|
||||||
|
"command": "dotnet",
|
||||||
|
"type": "process",
|
||||||
|
"args": [
|
||||||
|
"build",
|
||||||
|
"${workspaceFolder}/LehrerApp.TemplateDesigner/LehrerApp.TemplateDesigner.csproj",
|
||||||
|
"--configuration", "Debug",
|
||||||
|
"--verbosity", "minimal"
|
||||||
|
],
|
||||||
|
"problemMatcher": "$msCompile",
|
||||||
|
"group": "build"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"label": "Solution bauen",
|
"label": "Solution bauen",
|
||||||
"command": "dotnet",
|
"command": "dotnet",
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
<Nullable>enable</Nullable>
|
<Nullable>enable</Nullable>
|
||||||
<ImplicitUsings>enable</ImplicitUsings>
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
<LangVersion>latest</LangVersion>
|
<LangVersion>latest</LangVersion>
|
||||||
<TreatWarningsAsErrors>false</TreatWarningsAsErrors>
|
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||||
<!-- CS8618: falsch-positiv durch [ObservableProperty] Source Generator -->
|
<!-- CS8618: falsch-positiv durch [ObservableProperty] Source Generator -->
|
||||||
<NoWarn>CS8618</NoWarn>
|
<NoWarn>CS8618</NoWarn>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|||||||
@@ -17,9 +17,10 @@
|
|||||||
<!-- Datenbank -->
|
<!-- Datenbank -->
|
||||||
<PackageVersion Include="LiteDB" Version="5.0.21" />
|
<PackageVersion Include="LiteDB" Version="5.0.21" />
|
||||||
|
|
||||||
<!-- PDF-Erzeugung (11.3) - nur LehrerApp.Desktop: zieht SkiaSharp-Native-Binaries mit,
|
<!-- PDF-Erzeugung: Desktop-Exporte und die unabhängige Templating-Library. -->
|
||||||
die im Server-Image (LehrerApp.Api) nichts verloren haben. -->
|
|
||||||
<PackageVersion Include="QuestPDF" Version="2025.7.0" />
|
<PackageVersion Include="QuestPDF" Version="2025.7.0" />
|
||||||
|
<PackageVersion Include="PDFtoImage" Version="5.4.0" />
|
||||||
|
<PackageVersion Include="PdfPig" Version="0.1.13" />
|
||||||
|
|
||||||
<!-- API -->
|
<!-- API -->
|
||||||
<PackageVersion Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.0" />
|
<PackageVersion Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.0" />
|
||||||
|
|||||||
@@ -39,6 +39,8 @@ public sealed class DwdWeatherServiceTests
|
|||||||
<dwd:Forecast dwd:elementName="FF"><dwd:value>5</dwd:value></dwd:Forecast>
|
<dwd:Forecast dwd:elementName="FF"><dwd:value>5</dwd:value></dwd:Forecast>
|
||||||
<dwd:Forecast dwd:elementName="FX1"><dwd:value>-</dwd:value></dwd:Forecast>
|
<dwd:Forecast dwd:elementName="FX1"><dwd:value>-</dwd:value></dwd:Forecast>
|
||||||
<dwd:Forecast dwd:elementName="RR1c"><dwd:value>1.25</dwd:value></dwd:Forecast>
|
<dwd:Forecast dwd:elementName="RR1c"><dwd:value>1.25</dwd:value></dwd:Forecast>
|
||||||
|
<dwd:Forecast dwd:elementName="R101"><dwd:value>67.6</dwd:value></dwd:Forecast>
|
||||||
|
<dwd:Forecast dwd:elementName="SunD1"><dwd:value>1800</dwd:value></dwd:Forecast>
|
||||||
<dwd:Forecast dwd:elementName="Neff"><dwd:value>75</dwd:value></dwd:Forecast>
|
<dwd:Forecast dwd:elementName="Neff"><dwd:value>75</dwd:value></dwd:Forecast>
|
||||||
<dwd:Forecast dwd:elementName="ww"><dwd:value>61</dwd:value></dwd:Forecast>
|
<dwd:Forecast dwd:elementName="ww"><dwd:value>61</dwd:value></dwd:Forecast>
|
||||||
</kml:ExtendedData></kml:Placemark></kml:Document>
|
</kml:ExtendedData></kml:Placemark></kml:Document>
|
||||||
@@ -52,9 +54,34 @@ public sealed class DwdWeatherServiceTests
|
|||||||
Assert.Equal(18, hour.WindSpeedKmh);
|
Assert.Equal(18, hour.WindSpeedKmh);
|
||||||
Assert.Null(hour.WindGustKmh);
|
Assert.Null(hour.WindGustKmh);
|
||||||
Assert.Equal(1.3, hour.PrecipitationMm);
|
Assert.Equal(1.3, hour.PrecipitationMm);
|
||||||
|
Assert.Equal(68, hour.PrecipitationProbabilityPercent);
|
||||||
|
Assert.Equal(30, hour.SunshineDurationMinutes);
|
||||||
Assert.Equal(61, hour.WeatherCode);
|
Assert.Equal(61, hour.WeatherCode);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ParseMosmix_LiefertMehrAlsDreiTageFuerFolgewoche()
|
||||||
|
{
|
||||||
|
var start = DateTime.UtcNow.AddHours(1);
|
||||||
|
var timeSteps = string.Join("", Enumerable.Range(0, 100)
|
||||||
|
.Select(i => $"<dwd:TimeStep>{start.AddHours(i):yyyy-MM-dd'T'HH:mm:ss'Z'}</dwd:TimeStep>"));
|
||||||
|
var values = string.Join(' ', Enumerable.Repeat("293.15", 100));
|
||||||
|
var kml = $$"""
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<kml:kml xmlns:kml="http://www.opengis.net/kml/2.2" xmlns:dwd="https://opendata.dwd.de/weather/lib/pointforecast_dwd_extension_V1_0.xsd">
|
||||||
|
<kml:Document><kml:ExtendedData><dwd:ProductDefinition>
|
||||||
|
<dwd:ForecastTimeSteps>{{timeSteps}}</dwd:ForecastTimeSteps>
|
||||||
|
</dwd:ProductDefinition></kml:ExtendedData><kml:Placemark><kml:ExtendedData>
|
||||||
|
<dwd:Forecast dwd:elementName="TTT"><dwd:value>{{values}}</dwd:value></dwd:Forecast>
|
||||||
|
</kml:ExtendedData></kml:Placemark></kml:Document>
|
||||||
|
</kml:kml>
|
||||||
|
""";
|
||||||
|
|
||||||
|
var parsed = DwdWeatherService.ParseMosmix(Zip("forecast.kml", kml));
|
||||||
|
|
||||||
|
Assert.Equal(100, parsed.Hours.Count);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void ParseCapArchive_OrdnetWarnpolygonGeografischZu()
|
public void ParseCapArchive_OrdnetWarnpolygonGeografischZu()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -138,7 +138,9 @@ public sealed class DwdWeatherService(HttpClient http)
|
|||||||
? series[i] : null;
|
? series[i] : null;
|
||||||
var hours = new List<WeatherForecastHour>();
|
var hours = new List<WeatherForecastHour>();
|
||||||
var earliest = DateTime.UtcNow.AddHours(-1);
|
var earliest = DateTime.UtcNow.AddHours(-1);
|
||||||
for (var i = 0; i < times.Count && hours.Count < 72; i++)
|
// MOSMIX_L reicht bis +240 h. Die vollständige Spanne ist für die Unterrichtsplanung
|
||||||
|
// sinnvoll, weil das Wochenraster auch in die Folgewoche geblättert werden kann.
|
||||||
|
for (var i = 0; i < times.Count && hours.Count < 240; i++)
|
||||||
{
|
{
|
||||||
if (times[i] < earliest) continue;
|
if (times[i] < earliest) continue;
|
||||||
hours.Add(new WeatherForecastHour
|
hours.Add(new WeatherForecastHour
|
||||||
@@ -148,6 +150,10 @@ public sealed class DwdWeatherService(HttpClient http)
|
|||||||
WindSpeedKmh = Round(Value("FF", i) * 3.6, 1),
|
WindSpeedKmh = Round(Value("FF", i) * 3.6, 1),
|
||||||
WindGustKmh = Round(Value("FX1", i) * 3.6, 1),
|
WindGustKmh = Round(Value("FX1", i) * 3.6, 1),
|
||||||
PrecipitationMm = Round(Value("RR1c", i), 1),
|
PrecipitationMm = Round(Value("RR1c", i), 1),
|
||||||
|
// Beide Zusatzwerte stammen aus derselben MOSMIX-L-Datei. R101 bezeichnet die
|
||||||
|
// Wahrscheinlichkeit für >0,1 mm in der letzten Stunde, SunD1 liegt in Sekunden vor.
|
||||||
|
PrecipitationProbabilityPercent = Round(Value("R101", i), 0),
|
||||||
|
SunshineDurationMinutes = Round(Value("SunD1", i) / 60, 0),
|
||||||
CloudCoverPercent = Round(Value("Neff", i), 0),
|
CloudCoverPercent = Round(Value("Neff", i), 0),
|
||||||
WeatherCode = Value("ww", i) is { } code ? (int)Math.Round(code) : null,
|
WeatherCode = Value("ww", i) is { } code ? (int)Math.Round(code) : null,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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
|
||||||
{
|
{
|
||||||
@@ -191,6 +225,9 @@ public interface IParticipationSessionRepository
|
|||||||
{
|
{
|
||||||
List<ParticipationSession> GetByGroup(Guid groupId);
|
List<ParticipationSession> GetByGroup(Guid groupId);
|
||||||
ParticipationSession? GetById(Guid id);
|
ParticipationSession? GetById(Guid id);
|
||||||
|
// Für Aggregationen über alle Gruppen (z.B. Dashboard-Fehlzeiten-Warnung), die sonst pro
|
||||||
|
// Eintrag einzeln GetById aufrufen müssten — ein Bulk-Laden vermeidet dieses N+1-Muster.
|
||||||
|
List<ParticipationSession> GetAll();
|
||||||
void Save(ParticipationSession session);
|
void Save(ParticipationSession session);
|
||||||
void Delete(Guid id);
|
void Delete(Guid id);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,19 @@ 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; }
|
||||||
|
/// <summary>
|
||||||
|
/// Vorgängergruppe aus dem Hochstufen (<see cref="Services.GroupRolloverService"/>) —
|
||||||
|
/// ermöglicht einen Schuljahresvergleich (z.B. Klausurschnitt) über die Kette hinweg.
|
||||||
|
/// Wird ausschließlich beim Hochstufen automatisch gesetzt; vor Einführung dieses Felds
|
||||||
|
/// hochgestufte Gruppen bleiben unverknüpft (kein rückwirkendes Verknüpfen vorgesehen).
|
||||||
|
/// </summary>
|
||||||
|
public Guid? PreviousGroupId { get; set; }
|
||||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||||
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
|
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,9 +24,26 @@ public class ParticipationEntry
|
|||||||
// Bleibt für bereits gespeicherte Daten erhalten. Neue Schreibvorgänge setzen beide Felder.
|
// Bleibt für bereits gespeicherte Daten erhalten. Neue Schreibvorgänge setzen beide Felder.
|
||||||
public bool HomeworkMissing { get; set; }
|
public bool HomeworkMissing { get; set; }
|
||||||
public AttendanceStatus? Attendance { get; set; }
|
public AttendanceStatus? Attendance { get; set; }
|
||||||
|
// Strichliste im Sitzplan (Nutzer-Feedback): RaisedHandCount zählt jede beobachtete Meldung,
|
||||||
|
// CalledOnCount die Teilmenge davon, bei der der Schüler auch drangekommen ist —
|
||||||
|
// "drangekommen" ist immer auch eine Meldung und erhöht daher immer beide Zähler zugleich,
|
||||||
|
// nie CalledOnCount allein. Ergibt nebenbei eine Aufrufgerechtigkeits-Quote
|
||||||
|
// (CalledOnCount / RaisedHandCount) und schlägt die Quantitätsbewertung vor, siehe
|
||||||
|
// ParticipationCountSuggestion.
|
||||||
|
public int RaisedHandCount { get; set; }
|
||||||
|
public int CalledOnCount { get; set; }
|
||||||
|
/// <summary>
|
||||||
|
/// Tagesflagge (Nutzer-Feedback): markiert eine session-bezogene Bewertung als besonders
|
||||||
|
/// herausragend oder besonders schwach, unabhängig von der Richtung ("egal in welche
|
||||||
|
/// Richtung, herausragend festhalten"). Rein deskriptiv — fließt nirgends in eine Berechnung
|
||||||
|
/// (Mitarbeitsnote 3.2, Aufrufgerechtigkeit) ein, nur zur Erinnerung/Dokumentation.
|
||||||
|
/// </summary>
|
||||||
|
public DayHighlightKind? DayHighlight { get; set; }
|
||||||
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
|
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public enum DayHighlightKind { Standout, Sleepy, Rough }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Hausaufgabenstatus einer Sitzung; null bedeutet, dass für diesen Termin keine
|
/// Hausaufgabenstatus einer Sitzung; null bedeutet, dass für diesen Termin keine
|
||||||
/// Hausaufgabe erfasst wurde. MissingOpen kann später in MissingOverdue oder SubmittedLate
|
/// Hausaufgabe erfasst wurde. MissingOpen kann später in MissingOverdue oder SubmittedLate
|
||||||
@@ -160,3 +177,27 @@ public static class ParticipationRatingScale
|
|||||||
_ => value,
|
_ => value,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Nutzer-Feedback: die Strichliste im Sitzplan (<see cref="ParticipationEntry.RaisedHandCount"/>)
|
||||||
|
/// schlägt direkt eine Quantitätsbewertung vor — wie oft sich jemand meldet, ist ja bereits die
|
||||||
|
/// Quantität. Nur ein Richtwert, kein fester Automatismus: die Lehrkraft übernimmt den Vorschlag
|
||||||
|
/// per Klick oder ignoriert ihn. Bewusst nur für <see cref="AspectValueType.Scale5"/> (der
|
||||||
|
/// Standardtyp des "quantity"-Aspekts) — für individuell umkonfigurierte Aspekttypen gäbe es keine
|
||||||
|
/// sinnvolle, unmissverständliche Abbildung.
|
||||||
|
/// </summary>
|
||||||
|
public static class ParticipationCountSuggestion
|
||||||
|
{
|
||||||
|
public static int? SuggestQuantity(AspectValueType type, int raisedHandCount)
|
||||||
|
{
|
||||||
|
if (type != AspectValueType.Scale5) return null;
|
||||||
|
return raisedHandCount switch
|
||||||
|
{
|
||||||
|
0 => -2,
|
||||||
|
1 => -1,
|
||||||
|
2 or 3 => 0,
|
||||||
|
4 or 5 => 1,
|
||||||
|
_ => 2,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -100,7 +100,10 @@ public class LessonPhaseStep
|
|||||||
}
|
}
|
||||||
|
|
||||||
public enum UnitStatus { Planned, Active, Completed }
|
public enum UnitStatus { Planned, Active, Completed }
|
||||||
public enum LessonStatus { Planned, Conducted }
|
// Planned=0 und Conducted=1 bleiben absichtlich an ihren bisherigen numerischen Positionen:
|
||||||
|
// LiteDB hat diese Werte bereits gespeichert. Die neuen Zustände werden nur angehängt, damit
|
||||||
|
// vorhandene Daten ohne Migration weiterhin korrekt gelesen werden.
|
||||||
|
public enum LessonStatus { Planned = 0, Conducted = 1, Draft = 2, Ready = 3 }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Katalogeintrag für einen wiederverwendbaren "alternativen Ablauf" (z.B. "Kurzversion" bei
|
/// Katalogeintrag für einen wiederverwendbaren "alternativen Ablauf" (z.B. "Kurzversion" bei
|
||||||
|
|||||||
@@ -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; }
|
||||||
|
}
|
||||||
@@ -42,6 +42,10 @@ public sealed class WeatherForecastHour
|
|||||||
public double? WindSpeedKmh { get; set; }
|
public double? WindSpeedKmh { get; set; }
|
||||||
public double? WindGustKmh { get; set; }
|
public double? WindGustKmh { get; set; }
|
||||||
public double? PrecipitationMm { get; set; }
|
public double? PrecipitationMm { get; set; }
|
||||||
|
/// <summary>Stündliche MOSMIX-Wahrscheinlichkeit für mehr als 0,1 mm Niederschlag.</summary>
|
||||||
|
public double? PrecipitationProbabilityPercent { get; set; }
|
||||||
|
/// <summary>MOSMIX-Sonnenscheindauer der letzten Stunde, in Minuten.</summary>
|
||||||
|
public double? SunshineDurationMinutes { get; set; }
|
||||||
public double? CloudCoverPercent { get; set; }
|
public double? CloudCoverPercent { get; set; }
|
||||||
public int? WeatherCode { get; set; }
|
public int? WeatherCode { 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).
|
||||||
@@ -89,7 +93,7 @@ public interface IHasAttachments
|
|||||||
List<DocumentAttachment> Attachments { get; }
|
List<DocumentAttachment> Attachments { get; }
|
||||||
}
|
}
|
||||||
// Hinten angefügt, damit die numerischen Werte bereits gespeicherter LiteDB-Einträge stabil bleiben.
|
// Hinten angefügt, damit die numerischen Werte bereits gespeicherter LiteDB-Einträge stabil bleiben.
|
||||||
public enum DocumentationType { Conversation, Incident, SupportPlan, Absence, ParentCall, ParentLetter }
|
public enum DocumentationType { Conversation, Incident, SupportPlan, Absence, ParentCall, ParentLetter, Planning }
|
||||||
public enum SupportStatus { Active, Completed, Paused }
|
public enum SupportStatus { Active, Completed, Paused }
|
||||||
|
|
||||||
public class WorkTask
|
public class WorkTask
|
||||||
|
|||||||
@@ -14,6 +14,13 @@ public class BackupService
|
|||||||
|
|
||||||
public string BackupDirectory => _backupDirectory;
|
public string BackupDirectory => _backupDirectory;
|
||||||
|
|
||||||
|
/// Optionales zweites Sicherungsziel (z.B. USB-Stick, Netzlaufwerk) — Backups liegen sonst
|
||||||
|
/// ausschließlich neben der Live-Datenbank und sind bei einem Plattenausfall oder einem
|
||||||
|
/// versehentlich gelöschten Profilverzeichnis wertlos. Wird von außen gesetzt (Desktop-Settings,
|
||||||
|
/// siehe BackupSettingsService); bewusst best-effort — ist das Ziel beim Sichern nicht
|
||||||
|
/// erreichbar (Stick nicht eingesteckt, Freigabe offline), bleibt nur das Hauptbackup bestehen.
|
||||||
|
public string? SecondaryBackupDirectory { get; set; }
|
||||||
|
|
||||||
public BackupService(string appDataPath)
|
public BackupService(string appDataPath)
|
||||||
{
|
{
|
||||||
_backupDirectory = Path.Combine(appDataPath, "backups");
|
_backupDirectory = Path.Combine(appDataPath, "backups");
|
||||||
@@ -34,9 +41,36 @@ public class BackupService
|
|||||||
File.Copy(databasePath, target, overwrite: true);
|
File.Copy(databasePath, target, overwrite: true);
|
||||||
|
|
||||||
PruneOldBackups(keepCount);
|
PruneOldBackups(keepCount);
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(SecondaryBackupDirectory))
|
||||||
|
TryMirrorToSecondary(target, fileName, keepCount);
|
||||||
|
|
||||||
return target;
|
return target;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void TryMirrorToSecondary(string sourcePath, string fileName, int keepCount)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Directory.CreateDirectory(SecondaryBackupDirectory!);
|
||||||
|
File.Copy(sourcePath, Path.Combine(SecondaryBackupDirectory!, fileName), overwrite: true);
|
||||||
|
|
||||||
|
foreach (var stale in Directory.GetFiles(SecondaryBackupDirectory!, "lehrerapp-*.db")
|
||||||
|
.Select(p => new FileInfo(p))
|
||||||
|
.OrderByDescending(f => f.LastWriteTime)
|
||||||
|
.Skip(keepCount))
|
||||||
|
{
|
||||||
|
try { stale.Delete(); }
|
||||||
|
catch { /* nächster Lauf versucht es erneut */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// Sicherungsziel nicht erreichbar — das Hauptbackup (_backupDirectory) ist davon
|
||||||
|
// unberührt, ein manuelles/automatisches Backup soll daran nicht scheitern.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public List<BackupInfo> ListBackups() =>
|
public List<BackupInfo> ListBackups() =>
|
||||||
Directory.GetFiles(_backupDirectory, "lehrerapp-*.db")
|
Directory.GetFiles(_backupDirectory, "lehrerapp-*.db")
|
||||||
.Select(p => new BackupInfo(p, File.GetLastWriteTime(p), new FileInfo(p).Length))
|
.Select(p => new BackupInfo(p, File.GetLastWriteTime(p), new FileInfo(p).Length))
|
||||||
|
|||||||
@@ -15,7 +15,8 @@ public sealed class DashboardSettingsService
|
|||||||
public static readonly string[] DefaultCardOrder =
|
public static readonly string[] DefaultCardOrder =
|
||||||
[
|
[
|
||||||
"today", "tasks", "calendar", "excuses", "upcoming",
|
"today", "tasks", "calendar", "excuses", "upcoming",
|
||||||
"corrections", "unplanned", "alerts", "attendance", "support", "groups",
|
"corrections", "unplanned", "alerts", "attendance", "support", "groups", "examload",
|
||||||
|
"missingteachingtime",
|
||||||
];
|
];
|
||||||
|
|
||||||
private readonly string _configPath;
|
private readonly string _configPath;
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
using System.Globalization;
|
||||||
|
using LehrerApp.Core.Models;
|
||||||
|
|
||||||
|
namespace LehrerApp.Core.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Zählt eigene Klausuren pro ISO-Kalenderwoche über alle Lerngruppen hinweg. Nutzer-Feedback:
|
||||||
|
/// die klassenbezogene Kollisionsprüfung (mehrere Klausuren einer einzelnen Klasse in einer
|
||||||
|
/// Woche) übernimmt bereits der schulische Klausurplaner (externes Klassenbuch) — was dort
|
||||||
|
/// fehlt, ist die persönliche Belastung der Lehrkraft: mehr eigene Klausuren in derselben Woche
|
||||||
|
/// bedeuten unabhängig von der Klasse mehr Erstellungsaufwand vorher und mehr Korrekturaufwand
|
||||||
|
/// danach. Da eine LiteDB-Datei genau einem Nutzer gehört (siehe CLAUDE.md), sind "alle
|
||||||
|
/// Klausuren in der Datenbank" bereits gleichbedeutend mit "meine Klausuren".
|
||||||
|
/// </summary>
|
||||||
|
public static class ExamWeekLoadService
|
||||||
|
{
|
||||||
|
/// Ab dieser Anzahl eigener Klausuren in derselben Woche gilt die Woche als spürbar belastet.
|
||||||
|
/// Nutzer-Feedback: zwei Klausuren aus zwei unterrichteten Fächern in derselben Woche sind
|
||||||
|
/// normal, eine dritte ist die eigentliche Häufung.
|
||||||
|
public const int WarningThreshold = 3;
|
||||||
|
|
||||||
|
/// Anzahl Klausuren (ohne <paramref name="excludeExamId"/>, z.B. die gerade im Dialog
|
||||||
|
/// bearbeitete), die in dieselbe ISO-Kalenderwoche wie <paramref name="date"/> fallen.
|
||||||
|
public static int CountInSameWeek(IEnumerable<Exam> allExams, DateOnly date, Guid? excludeExamId = null)
|
||||||
|
{
|
||||||
|
var (year, week) = IsoWeek(date);
|
||||||
|
return allExams.Count(e => e.Id != excludeExamId && IsoWeek(e.Date) == (year, week));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Gruppiert die übergebenen Klausuren nach ISO-Kalenderwoche und liefert nur die Wochen mit
|
||||||
|
/// mindestens <see cref="WarningThreshold"/> Klausuren, chronologisch sortiert — für einen
|
||||||
|
/// vorausschauenden Überblick (z.B. Dashboard), nicht für die Einzelprüfung beim Anlegen.
|
||||||
|
public static List<ExamWeekLoad> FindOverloadedWeeks(IEnumerable<Exam> exams) =>
|
||||||
|
exams
|
||||||
|
.GroupBy(e => IsoWeek(e.Date))
|
||||||
|
.Where(g => g.Count() >= WarningThreshold)
|
||||||
|
.Select(g => new ExamWeekLoad(g.Key.Year, g.Key.Week, WeekStart(g.Key.Year, g.Key.Week), g.Count()))
|
||||||
|
.OrderBy(w => w.WeekStart)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
private static (int Year, int Week) IsoWeek(DateOnly date)
|
||||||
|
{
|
||||||
|
var dt = date.ToDateTime(TimeOnly.MinValue);
|
||||||
|
return (ISOWeek.GetYear(dt), ISOWeek.GetWeekOfYear(dt));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static DateOnly WeekStart(int isoYear, int isoWeek) =>
|
||||||
|
DateOnly.FromDateTime(ISOWeek.ToDateTime(isoYear, isoWeek, DayOfWeek.Monday));
|
||||||
|
}
|
||||||
|
|
||||||
|
public readonly record struct ExamWeekLoad(int IsoYear, int IsoWeek, DateOnly WeekStart, int ExamCount)
|
||||||
|
{
|
||||||
|
public DateOnly WeekEnd => WeekStart.AddDays(6);
|
||||||
|
}
|
||||||
@@ -77,13 +77,29 @@ public class GradingService
|
|||||||
public double WeightedAverage(List<(string Grade, double Weight)> grades)
|
public double WeightedAverage(List<(string Grade, double Weight)> grades)
|
||||||
{
|
{
|
||||||
var numeric = grades
|
var numeric = grades
|
||||||
.Select(g => (Value: int.TryParse(g.Grade, out var n) ? (int?)n : null, g.Weight))
|
.Select(g => (Value: ParseGradeValue(g.Grade), g.Weight))
|
||||||
.Where(g => g.Value.HasValue).ToList();
|
.Where(g => g.Value.HasValue).ToList();
|
||||||
if (numeric.Count == 0) return 0;
|
if (numeric.Count == 0) return 0;
|
||||||
var total = numeric.Sum(g => g.Weight);
|
var total = numeric.Sum(g => g.Weight);
|
||||||
return total == 0 ? 0 : numeric.Sum(g => g.Value!.Value * g.Weight) / total;
|
return total == 0 ? 0 : numeric.Sum(g => g.Value!.Value * g.Weight) / total;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Wandelt eine Notenangabe mit optionaler Tendenz ("3+", "2-") in einen rechnerischen
|
||||||
|
/// Notenwert um. Tendenzen verschieben um ein Drittel in Richtung der besseren Note
|
||||||
|
/// (z.B. "3+" → 2,67, "2-" → 2,33) — der in deutschen Notensystemen übliche Drittelabstand.
|
||||||
|
private static double? ParseGradeValue(string grade)
|
||||||
|
{
|
||||||
|
var trimmed = grade.Trim();
|
||||||
|
if (trimmed.Length == 0) return null;
|
||||||
|
|
||||||
|
var tendency = 0.0;
|
||||||
|
var core = trimmed;
|
||||||
|
if (core.EndsWith('+')) { tendency = -1.0 / 3.0; core = core[..^1].TrimEnd(); }
|
||||||
|
else if (core.EndsWith('-')) { tendency = 1.0 / 3.0; core = core[..^1].TrimEnd(); }
|
||||||
|
|
||||||
|
return int.TryParse(core, out var n) ? n + tendency : null;
|
||||||
|
}
|
||||||
|
|
||||||
/// Rundet einen rechnerischen Notenwert auf eine ganze Note/Punktzahl.
|
/// Rundet einen rechnerischen Notenwert auf eine ganze Note/Punktzahl.
|
||||||
/// "Kaufmännisch" rundet bei genau 0,5 immer vom Nullpunkt weg (Standard).
|
/// "Kaufmännisch" rundet bei genau 0,5 immer vom Nullpunkt weg (Standard).
|
||||||
/// "Pädagogisch" rundet bei genau 0,5 in Richtung der besseren Note
|
/// "Pädagogisch" rundet bei genau 0,5 in Richtung der besseren Note
|
||||||
|
|||||||
@@ -60,6 +60,9 @@ public sealed class GroupRolloverService(
|
|||||||
IsActive = true,
|
IsActive = true,
|
||||||
IsOwnClass = source.IsOwnClass,
|
IsOwnClass = source.IsOwnClass,
|
||||||
IsDifferentiated = source.IsDifferentiated,
|
IsDifferentiated = source.IsDifferentiated,
|
||||||
|
PreviousGroupId = source.Id,
|
||||||
|
// WebUntisLessonId bewusst nicht übernommen: WebUntis vergibt sie pro Schuljahr neu,
|
||||||
|
// eine übernommene alte lsid würde in der Folgegruppe stumm falsche Fehlzeiten liefern.
|
||||||
};
|
};
|
||||||
|
|
||||||
var sourceWasActive = source.IsActive;
|
var sourceWasActive = source.IsActive;
|
||||||
|
|||||||
@@ -0,0 +1,99 @@
|
|||||||
|
using LehrerApp.Core.Interfaces;
|
||||||
|
using LehrerApp.Core.Models;
|
||||||
|
|
||||||
|
namespace LehrerApp.Core.Services;
|
||||||
|
|
||||||
|
/// <summary>Gemeinsame Terminlogik für Verschieben und Trennen von Stunden.</summary>
|
||||||
|
public sealed class LessonSchedulingService(ILessonRepository lessons)
|
||||||
|
{
|
||||||
|
public void Move(Lesson lesson, DateOnly newDate, int? newPeriod, bool shiftFollowing)
|
||||||
|
{
|
||||||
|
EnsureTargetIsFree(lesson, newDate, newPeriod);
|
||||||
|
var oldDate = lesson.Date;
|
||||||
|
var delta = newDate.DayNumber - oldDate.DayNumber;
|
||||||
|
|
||||||
|
if (shiftFollowing && delta != 0)
|
||||||
|
{
|
||||||
|
foreach (var other in lessons.GetByUnit(lesson.UnitId))
|
||||||
|
{
|
||||||
|
if (other.Id == lesson.Id || other.Status == LessonStatus.Conducted || other.Date <= oldDate)
|
||||||
|
continue;
|
||||||
|
other.Date = other.Date.AddDays(delta);
|
||||||
|
lessons.Save(other);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
lesson.Date = newDate;
|
||||||
|
if (newPeriod.HasValue) lesson.LessonNumber = newPeriod;
|
||||||
|
lessons.Save(lesson);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Lesson SplitAndMoveSecondPart(Lesson source, int splitAfterMinutes, DateOnly newDate,
|
||||||
|
int newPeriod, TimeOnly? newStartTime)
|
||||||
|
{
|
||||||
|
if (splitAfterMinutes <= 0 || source.Phases.Sum(p => p.DurationMinutes) <= splitAfterMinutes)
|
||||||
|
throw new InvalidOperationException("Der Verlauf reicht nicht über die erste Stunde hinaus.");
|
||||||
|
EnsureTargetIsFree(source, newDate, newPeriod);
|
||||||
|
|
||||||
|
var first = new List<LessonPhaseStep>();
|
||||||
|
var second = new List<LessonPhaseStep>();
|
||||||
|
var elapsed = 0;
|
||||||
|
foreach (var phase in source.Phases)
|
||||||
|
{
|
||||||
|
var remainingInFirst = splitAfterMinutes - elapsed;
|
||||||
|
if (remainingInFirst <= 0)
|
||||||
|
second.Add(Clone(phase, phase.DurationMinutes));
|
||||||
|
else if (phase.DurationMinutes <= remainingInFirst)
|
||||||
|
first.Add(Clone(phase, phase.DurationMinutes));
|
||||||
|
else
|
||||||
|
{
|
||||||
|
first.Add(Clone(phase, remainingInFirst));
|
||||||
|
second.Add(Clone(phase, phase.DurationMinutes - remainingInFirst));
|
||||||
|
}
|
||||||
|
elapsed += phase.DurationMinutes;
|
||||||
|
}
|
||||||
|
|
||||||
|
source.Phases = first;
|
||||||
|
var continuation = new Lesson
|
||||||
|
{
|
||||||
|
UnitId = source.UnitId,
|
||||||
|
GroupId = source.GroupId,
|
||||||
|
Date = newDate,
|
||||||
|
LessonNumber = newPeriod,
|
||||||
|
StartTime = newStartTime,
|
||||||
|
Topic = string.IsNullOrWhiteSpace(source.Topic) ? "Fortsetzung" : $"{source.Topic} – Fortsetzung",
|
||||||
|
Phases = second,
|
||||||
|
Homework = source.Homework,
|
||||||
|
HomeworkChecked = source.HomeworkChecked,
|
||||||
|
HomeworkCheckDismissed = source.HomeworkCheckDismissed,
|
||||||
|
Reflection = source.Reflection,
|
||||||
|
Status = source.Status == LessonStatus.Conducted ? LessonStatus.Draft : source.Status,
|
||||||
|
};
|
||||||
|
source.Homework = null;
|
||||||
|
source.HomeworkChecked = false;
|
||||||
|
source.HomeworkCheckDismissed = false;
|
||||||
|
source.Reflection = null;
|
||||||
|
lessons.Save(source);
|
||||||
|
lessons.Save(continuation);
|
||||||
|
return continuation;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void EnsureTargetIsFree(Lesson source, DateOnly date, int? period)
|
||||||
|
{
|
||||||
|
if (period is null) return;
|
||||||
|
var occupied = lessons.GetByGroupAndDate(source.GroupId, date)
|
||||||
|
.Any(l => l.Id != source.Id && l.LessonNumber == period);
|
||||||
|
if (occupied)
|
||||||
|
throw new InvalidOperationException($"Für die Lerngruppe existiert am {date:dd.MM.yyyy} in der {period}. Stunde bereits eine Planung.");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static LessonPhaseStep Clone(LessonPhaseStep source, int duration) => new()
|
||||||
|
{
|
||||||
|
Name = source.Name,
|
||||||
|
DurationMinutes = duration,
|
||||||
|
Activity = source.Activity,
|
||||||
|
Material = source.Material,
|
||||||
|
Shorthand = source.Shorthand,
|
||||||
|
AlternativePathId = source.AlternativePathId,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,316 +0,0 @@
|
|||||||
using System.IO.Compression;
|
|
||||||
using System.Text.Json;
|
|
||||||
using System.Xml.Linq;
|
|
||||||
|
|
||||||
namespace LehrerApp.Core.Services;
|
|
||||||
|
|
||||||
public enum TemplateIssueSeverity
|
|
||||||
{
|
|
||||||
Warning,
|
|
||||||
StrongWarning,
|
|
||||||
Error,
|
|
||||||
}
|
|
||||||
|
|
||||||
public sealed record LetterPlaceholder(string Tag, string Description);
|
|
||||||
|
|
||||||
public sealed record TemplateValidationIssue(
|
|
||||||
TemplateIssueSeverity Severity,
|
|
||||||
string Message,
|
|
||||||
string? Tag = null,
|
|
||||||
string? SuggestedTag = null);
|
|
||||||
|
|
||||||
public sealed record TemplateValidationResult(
|
|
||||||
IReadOnlyList<string> Tags,
|
|
||||||
IReadOnlyList<TemplateValidationIssue> Issues)
|
|
||||||
{
|
|
||||||
public bool CanGenerate => Issues.All(i => i.Severity != TemplateIssueSeverity.Error);
|
|
||||||
public bool HasStrongWarnings => Issues.Any(i => i.Severity == TemplateIssueSeverity.StrongWarning);
|
|
||||||
}
|
|
||||||
|
|
||||||
public sealed class LetterTemplateInfo
|
|
||||||
{
|
|
||||||
public Guid Id { get; set; } = Guid.NewGuid();
|
|
||||||
public string Name { get; set; } = "";
|
|
||||||
public string StoredFileName { get; set; } = "";
|
|
||||||
public string OriginalFileName { get; set; } = "";
|
|
||||||
public DateTime ImportedAt { get; set; } = DateTime.UtcNow;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Verwaltet DOCX-Briefvorlagen und befüllt Word-Inhaltssteuerelemente anhand ihres Tags.
|
|
||||||
/// Die Originalvorlage wird nie verändert.
|
|
||||||
/// </summary>
|
|
||||||
public sealed class LetterTemplateService
|
|
||||||
{
|
|
||||||
private static readonly XNamespace W = "http://schemas.openxmlformats.org/wordprocessingml/2006/main";
|
|
||||||
private static readonly JsonSerializerOptions JsonOptions = new() { WriteIndented = true };
|
|
||||||
|
|
||||||
public static IReadOnlyList<LetterPlaceholder> SupportedPlaceholders { get; } =
|
|
||||||
[
|
|
||||||
new("Student.FirstName", "Vorname des Schülers"),
|
|
||||||
new("Student.LastName", "Nachname des Schülers"),
|
|
||||||
new("Contact.Name", "Name des ausgewählten Kontakts"),
|
|
||||||
new("Contact.Address", "Vollständige Anschrift (mehrzeilig)"),
|
|
||||||
new("Contact.Street", "Straße und Hausnummer"),
|
|
||||||
new("Contact.PostalCode", "Postleitzahl"),
|
|
||||||
new("Contact.City", "Ort"),
|
|
||||||
new("Letter.Salutation", "Gespeicherte Briefanrede des Kontakts"),
|
|
||||||
new("Group.Name", "Ausgewählte Lerngruppe"),
|
|
||||||
new("SchoolYear", "Schuljahr der ausgewählten Gruppe"),
|
|
||||||
new("CurrentDate", "Gewähltes Briefdatum"),
|
|
||||||
];
|
|
||||||
|
|
||||||
private readonly string _templateDirectory;
|
|
||||||
private readonly string _indexPath;
|
|
||||||
|
|
||||||
public LetterTemplateService(string appDataPath)
|
|
||||||
{
|
|
||||||
_templateDirectory = Path.Combine(appDataPath, "letter-templates");
|
|
||||||
_indexPath = Path.Combine(_templateDirectory, "templates.json");
|
|
||||||
Directory.CreateDirectory(_templateDirectory);
|
|
||||||
}
|
|
||||||
|
|
||||||
public IReadOnlyList<LetterTemplateInfo> GetTemplates() => LoadIndex()
|
|
||||||
.OrderBy(t => t.Name, StringComparer.CurrentCultureIgnoreCase)
|
|
||||||
.ToList();
|
|
||||||
|
|
||||||
public string GetTemplatePath(LetterTemplateInfo template) =>
|
|
||||||
Path.Combine(_templateDirectory, template.StoredFileName);
|
|
||||||
|
|
||||||
public LetterTemplateInfo Import(string sourcePath, string? displayName = null)
|
|
||||||
{
|
|
||||||
var validation = Validate(sourcePath);
|
|
||||||
if (!validation.CanGenerate)
|
|
||||||
throw new InvalidDataException(validation.Issues.First(i => i.Severity == TemplateIssueSeverity.Error).Message);
|
|
||||||
|
|
||||||
var templates = LoadIndex();
|
|
||||||
var info = new LetterTemplateInfo
|
|
||||||
{
|
|
||||||
Name = string.IsNullOrWhiteSpace(displayName)
|
|
||||||
? Path.GetFileNameWithoutExtension(sourcePath)
|
|
||||||
: displayName.Trim(),
|
|
||||||
OriginalFileName = Path.GetFileName(sourcePath),
|
|
||||||
};
|
|
||||||
info.StoredFileName = $"{info.Id:N}.docx";
|
|
||||||
File.Copy(sourcePath, GetTemplatePath(info), overwrite: false);
|
|
||||||
templates.Add(info);
|
|
||||||
SaveIndex(templates);
|
|
||||||
return info;
|
|
||||||
}
|
|
||||||
|
|
||||||
public void Delete(Guid id)
|
|
||||||
{
|
|
||||||
var templates = LoadIndex();
|
|
||||||
var template = templates.FirstOrDefault(t => t.Id == id);
|
|
||||||
if (template is null) return;
|
|
||||||
var path = GetTemplatePath(template);
|
|
||||||
if (File.Exists(path)) File.Delete(path);
|
|
||||||
templates.Remove(template);
|
|
||||||
SaveIndex(templates);
|
|
||||||
}
|
|
||||||
|
|
||||||
public TemplateValidationResult Validate(LetterTemplateInfo template) =>
|
|
||||||
Validate(GetTemplatePath(template));
|
|
||||||
|
|
||||||
public TemplateValidationResult Validate(string path)
|
|
||||||
{
|
|
||||||
var tags = new List<string>();
|
|
||||||
var issues = new List<TemplateValidationIssue>();
|
|
||||||
|
|
||||||
if (!File.Exists(path))
|
|
||||||
return Error("Die Vorlagendatei wurde nicht gefunden.");
|
|
||||||
if (!string.Equals(Path.GetExtension(path), ".docx", StringComparison.OrdinalIgnoreCase))
|
|
||||||
return Error("Die Vorlage muss eine DOCX-Datei sein.");
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
using var archive = ZipFile.OpenRead(path);
|
|
||||||
var xmlEntries = WordXmlEntries(archive).ToList();
|
|
||||||
if (xmlEntries.All(e => !string.Equals(e.FullName, "word/document.xml", StringComparison.OrdinalIgnoreCase)))
|
|
||||||
return Error("Die Datei ist kein gültiges Word-DOCX-Dokument.");
|
|
||||||
|
|
||||||
foreach (var entry in xmlEntries)
|
|
||||||
{
|
|
||||||
using var stream = entry.Open();
|
|
||||||
var document = XDocument.Load(stream, LoadOptions.PreserveWhitespace);
|
|
||||||
foreach (var control in document.Descendants(W + "sdt"))
|
|
||||||
{
|
|
||||||
var tag = control.Element(W + "sdtPr")?.Element(W + "tag")?.Attribute(W + "val")?.Value?.Trim();
|
|
||||||
if (string.IsNullOrWhiteSpace(tag))
|
|
||||||
{
|
|
||||||
issues.Add(new(TemplateIssueSeverity.StrongWarning,
|
|
||||||
$"Ein Inhaltssteuerelement in {PartLabel(entry.FullName)} besitzt keinen Tag und kann nicht befüllt werden."));
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
tags.Add(tag);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch (InvalidDataException)
|
|
||||||
{
|
|
||||||
return Error("Die Datei ist beschädigt oder kein gültiges DOCX-Dokument.");
|
|
||||||
}
|
|
||||||
catch (System.Xml.XmlException)
|
|
||||||
{
|
|
||||||
return Error("Die Word-Vorlage enthält ungültiges XML und kann nicht gelesen werden.");
|
|
||||||
}
|
|
||||||
catch (IOException ex)
|
|
||||||
{
|
|
||||||
return Error($"Die Vorlage konnte nicht geöffnet werden: {ex.Message}");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (tags.Count == 0 && issues.Count == 0)
|
|
||||||
issues.Add(new(TemplateIssueSeverity.Warning,
|
|
||||||
"Die Vorlage enthält keine Inhaltssteuerelemente. Es werden keine Felder befüllt."));
|
|
||||||
|
|
||||||
foreach (var tag in tags.Distinct(StringComparer.Ordinal))
|
|
||||||
{
|
|
||||||
if (SupportedPlaceholders.Any(p => p.Tag == tag)) continue;
|
|
||||||
var suggestion = FindSuggestion(tag);
|
|
||||||
issues.Add(suggestion is null
|
|
||||||
? new(TemplateIssueSeverity.Warning,
|
|
||||||
$"Der unbekannte Tag „{tag}“ wird nicht befüllt.", tag)
|
|
||||||
: new(TemplateIssueSeverity.StrongWarning,
|
|
||||||
$"Wahrscheinlicher Schreibfehler: „{tag}“. Meinten Sie „{suggestion}“?", tag, suggestion));
|
|
||||||
}
|
|
||||||
|
|
||||||
return new(tags.Distinct(StringComparer.Ordinal).OrderBy(t => t).ToList(), issues);
|
|
||||||
|
|
||||||
TemplateValidationResult Error(string message) =>
|
|
||||||
new([], [new(TemplateIssueSeverity.Error, message)]);
|
|
||||||
}
|
|
||||||
|
|
||||||
public void Generate(string templatePath, string outputPath,
|
|
||||||
IReadOnlyDictionary<string, string?> values)
|
|
||||||
{
|
|
||||||
var validation = Validate(templatePath);
|
|
||||||
if (!validation.CanGenerate)
|
|
||||||
throw new InvalidDataException(validation.Issues.First(i => i.Severity == TemplateIssueSeverity.Error).Message);
|
|
||||||
|
|
||||||
var outputDirectory = Path.GetDirectoryName(outputPath);
|
|
||||||
if (!string.IsNullOrEmpty(outputDirectory)) Directory.CreateDirectory(outputDirectory);
|
|
||||||
|
|
||||||
using var source = ZipFile.OpenRead(templatePath);
|
|
||||||
using var target = ZipFile.Open(outputPath, ZipArchiveMode.Create);
|
|
||||||
foreach (var entry in source.Entries)
|
|
||||||
{
|
|
||||||
var targetEntry = target.CreateEntry(entry.FullName, CompressionLevel.Optimal);
|
|
||||||
targetEntry.LastWriteTime = entry.LastWriteTime;
|
|
||||||
using var input = entry.Open();
|
|
||||||
using var output = targetEntry.Open();
|
|
||||||
|
|
||||||
if (!IsWordXmlEntry(entry))
|
|
||||||
{
|
|
||||||
input.CopyTo(output);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
var document = XDocument.Load(input, LoadOptions.PreserveWhitespace);
|
|
||||||
foreach (var control in document.Descendants(W + "sdt").ToList())
|
|
||||||
{
|
|
||||||
var tag = control.Element(W + "sdtPr")?.Element(W + "tag")?.Attribute(W + "val")?.Value?.Trim();
|
|
||||||
if (tag is null || !values.TryGetValue(tag, out var value)) continue;
|
|
||||||
ReplaceContent(control.Element(W + "sdtContent"), value ?? "");
|
|
||||||
}
|
|
||||||
document.Save(output, SaveOptions.DisableFormatting);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void ReplaceContent(XElement? content, string value)
|
|
||||||
{
|
|
||||||
if (content is null) return;
|
|
||||||
var paragraph = content.Elements(W + "p").FirstOrDefault();
|
|
||||||
XElement run;
|
|
||||||
|
|
||||||
if (paragraph is not null)
|
|
||||||
{
|
|
||||||
run = paragraph.Descendants(W + "r").FirstOrDefault() ?? new XElement(W + "r");
|
|
||||||
var paragraphProperties = paragraph.Element(W + "pPr");
|
|
||||||
var runProperties = run.Element(W + "rPr") is { } rp ? new XElement(rp) : null;
|
|
||||||
paragraph.RemoveNodes();
|
|
||||||
if (paragraphProperties is not null) paragraph.Add(paragraphProperties);
|
|
||||||
run = new XElement(W + "r");
|
|
||||||
if (runProperties is not null) run.Add(runProperties);
|
|
||||||
paragraph.Add(run);
|
|
||||||
foreach (var extra in content.Elements(W + "p").Skip(1).ToList()) extra.Remove();
|
|
||||||
foreach (var node in content.Nodes().Where(n => n is XElement e && e.Name != W + "p").ToList()) node.Remove();
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
var firstRun = content.Descendants(W + "r").FirstOrDefault();
|
|
||||||
var runProperties = firstRun?.Element(W + "rPr") is { } rp ? new XElement(rp) : null;
|
|
||||||
content.RemoveNodes();
|
|
||||||
run = new XElement(W + "r");
|
|
||||||
if (runProperties is not null) run.Add(runProperties);
|
|
||||||
content.Add(run);
|
|
||||||
}
|
|
||||||
|
|
||||||
var lines = value.Replace("\r\n", "\n", StringComparison.Ordinal).Replace('\r', '\n').Split('\n');
|
|
||||||
for (var i = 0; i < lines.Length; i++)
|
|
||||||
{
|
|
||||||
if (i > 0) run.Add(new XElement(W + "br"));
|
|
||||||
run.Add(new XElement(W + "t", new XAttribute(XNamespace.Xml + "space", "preserve"), lines[i]));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static IEnumerable<ZipArchiveEntry> WordXmlEntries(ZipArchive archive) =>
|
|
||||||
archive.Entries.Where(IsWordXmlEntry);
|
|
||||||
|
|
||||||
private static bool IsWordXmlEntry(ZipArchiveEntry entry) =>
|
|
||||||
entry.FullName.StartsWith("word/", StringComparison.OrdinalIgnoreCase)
|
|
||||||
&& entry.FullName.EndsWith(".xml", StringComparison.OrdinalIgnoreCase);
|
|
||||||
|
|
||||||
private static string PartLabel(string path) => path switch
|
|
||||||
{
|
|
||||||
"word/document.xml" => "Dokumenttext",
|
|
||||||
var p when p.StartsWith("word/header", StringComparison.OrdinalIgnoreCase) => "einer Kopfzeile",
|
|
||||||
var p when p.StartsWith("word/footer", StringComparison.OrdinalIgnoreCase) => "einer Fußzeile",
|
|
||||||
_ => $"dem Dokumentteil „{Path.GetFileName(path)}“",
|
|
||||||
};
|
|
||||||
|
|
||||||
private static string? FindSuggestion(string unknown)
|
|
||||||
{
|
|
||||||
var candidate = SupportedPlaceholders
|
|
||||||
.Select(p => (p.Tag, Distance: Levenshtein(unknown.ToLowerInvariant(), p.Tag.ToLowerInvariant())))
|
|
||||||
.OrderBy(x => x.Distance)
|
|
||||||
.ThenBy(x => x.Tag)
|
|
||||||
.First();
|
|
||||||
var threshold = candidate.Tag.Length >= 12 ? 3 : 2;
|
|
||||||
return candidate.Distance <= threshold ? candidate.Tag : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static int Levenshtein(string left, string right)
|
|
||||||
{
|
|
||||||
var previous = Enumerable.Range(0, right.Length + 1).ToArray();
|
|
||||||
for (var i = 1; i <= left.Length; i++)
|
|
||||||
{
|
|
||||||
var current = new int[right.Length + 1];
|
|
||||||
current[0] = i;
|
|
||||||
for (var j = 1; j <= right.Length; j++)
|
|
||||||
current[j] = Math.Min(Math.Min(current[j - 1] + 1, previous[j] + 1),
|
|
||||||
previous[j - 1] + (left[i - 1] == right[j - 1] ? 0 : 1));
|
|
||||||
previous = current;
|
|
||||||
}
|
|
||||||
return previous[right.Length];
|
|
||||||
}
|
|
||||||
|
|
||||||
private List<LetterTemplateInfo> LoadIndex()
|
|
||||||
{
|
|
||||||
if (!File.Exists(_indexPath)) return [];
|
|
||||||
try
|
|
||||||
{
|
|
||||||
return JsonSerializer.Deserialize<List<LetterTemplateInfo>>(File.ReadAllText(_indexPath), JsonOptions) ?? [];
|
|
||||||
}
|
|
||||||
catch (JsonException)
|
|
||||||
{
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void SaveIndex(List<LetterTemplateInfo> templates)
|
|
||||||
{
|
|
||||||
var temporaryPath = _indexPath + ".tmp";
|
|
||||||
File.WriteAllText(temporaryPath, JsonSerializer.Serialize(templates, JsonOptions));
|
|
||||||
File.Move(temporaryPath, _indexPath, overwrite: true);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
using LehrerApp.Core.Models;
|
||||||
|
|
||||||
|
namespace LehrerApp.Core.Services;
|
||||||
|
|
||||||
|
/// Welcher der drei Leistungsbereiche (siehe GradingScheme) bei der Zielnoten-Frage der
|
||||||
|
/// gesuchte/unbekannte ist.
|
||||||
|
public enum GradeBucketKind { Exams, Participation, Other }
|
||||||
|
|
||||||
|
public readonly record struct TargetGradeResult(double RequiredAverage, bool IsAchievable);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Beantwortet die Zielnoten-Frage "Was brauche ich noch, um Zeugnisnote X zu erreichen?"
|
||||||
|
/// (Nutzer-Feedback) — kehrt <see cref="GradingService.CalculateReportGrade"/> um: löst nach
|
||||||
|
/// dem Durchschnitt eines gewählten Bereichs auf, der zusammen mit den bekannten Durchschnitten
|
||||||
|
/// der übrigen Bereiche (gewichtet nach <see cref="GradingScheme"/>) genau die Zielnote ergibt.
|
||||||
|
/// Bereiche ohne bekannten Durchschnitt (z.B. noch keine Mitarbeitsnote im Halbjahr) fließen
|
||||||
|
/// nicht in die bekannte Seite der Gleichung ein — exakt dieselbe Normierung wie
|
||||||
|
/// <see cref="GradingService.CalculateReportGrade"/> bei fehlenden Bereichen.
|
||||||
|
/// </summary>
|
||||||
|
public static class ReportGradeTargetCalculator
|
||||||
|
{
|
||||||
|
public static TargetGradeResult? SolveRequiredAverage(double target, GradeBucketKind solveFor,
|
||||||
|
double? examsAverage, double? participationAverage, double? otherAverage,
|
||||||
|
GradingScheme scheme, GradingSystem system)
|
||||||
|
{
|
||||||
|
var solvePercent = solveFor switch
|
||||||
|
{
|
||||||
|
GradeBucketKind.Exams => scheme.ExamsPercent,
|
||||||
|
GradeBucketKind.Participation => scheme.ParticipationPercent,
|
||||||
|
_ => scheme.OtherPercent,
|
||||||
|
};
|
||||||
|
// Dieser Bereich fließt laut Gewichtungsschema gar nicht in die Zeugnisnote ein —
|
||||||
|
// keine Zielnoten-Frage für ihn beantwortbar.
|
||||||
|
if (solvePercent <= 0) return null;
|
||||||
|
|
||||||
|
var known = new List<(double Avg, double Percent)>();
|
||||||
|
if (solveFor != GradeBucketKind.Exams && examsAverage is { } e) known.Add((e, scheme.ExamsPercent));
|
||||||
|
if (solveFor != GradeBucketKind.Participation && participationAverage is { } p) known.Add((p, scheme.ParticipationPercent));
|
||||||
|
if (solveFor != GradeBucketKind.Other && otherAverage is { } o) known.Add((o, scheme.OtherPercent));
|
||||||
|
|
||||||
|
var totalPercent = known.Sum(k => k.Percent) + solvePercent;
|
||||||
|
var knownContribution = known.Sum(k => k.Avg * k.Percent);
|
||||||
|
var required = (target * totalPercent - knownContribution) / solvePercent;
|
||||||
|
|
||||||
|
var (min, max) = system == GradingSystem.Grades1To6 ? (1.0, 6.0) : (0.0, 15.0);
|
||||||
|
return new TargetGradeResult(required, required >= min && required <= max);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -60,6 +60,65 @@ public sealed class DatabaseEncryptionServiceTests
|
|||||||
Assert.Single(reopened.GetCollection<BsonDocument>("t").FindAll());
|
Assert.Single(reopened.GetCollection<BsonDocument>("t").FindAll());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void CanOpenAndRead_OhneVorhandeneDatei_GibtFalseZurueck()
|
||||||
|
{
|
||||||
|
using var temp = new TempDatabase();
|
||||||
|
var service = new DatabaseEncryptionService();
|
||||||
|
|
||||||
|
Assert.False(service.CanOpenAndRead(temp.Path, null));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void CanOpenAndRead_GueltigeUnverschluesselteDatenbank_GibtTrueZurueck()
|
||||||
|
{
|
||||||
|
using var temp = new TempDatabase();
|
||||||
|
using (var db = new LiteDatabase(temp.Path))
|
||||||
|
db.GetCollection<BsonDocument>("t").Insert(new BsonDocument { ["x"] = 1 });
|
||||||
|
|
||||||
|
var service = new DatabaseEncryptionService();
|
||||||
|
|
||||||
|
Assert.True(service.CanOpenAndRead(temp.Path, null));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void CanOpenAndRead_GueltigeVerschluesselteDatenbankMitRichtigemPasswort_GibtTrueZurueck()
|
||||||
|
{
|
||||||
|
using var temp = new TempDatabase();
|
||||||
|
using (var db = new LiteDatabase(new ConnectionString(temp.Path) { Password = "geheim123" }))
|
||||||
|
db.GetCollection<BsonDocument>("t").Insert(new BsonDocument { ["x"] = 1 });
|
||||||
|
|
||||||
|
var service = new DatabaseEncryptionService();
|
||||||
|
|
||||||
|
Assert.True(service.CanOpenAndRead(temp.Path, "geheim123"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void CanOpenAndRead_FalschesPasswort_GibtFalseZurueck()
|
||||||
|
{
|
||||||
|
using var temp = new TempDatabase();
|
||||||
|
using (var db = new LiteDatabase(new ConnectionString(temp.Path) { Password = "geheim123" }))
|
||||||
|
db.GetCollection<BsonDocument>("t").Insert(new BsonDocument { ["x"] = 1 });
|
||||||
|
|
||||||
|
var service = new DatabaseEncryptionService();
|
||||||
|
|
||||||
|
Assert.False(service.CanOpenAndRead(temp.Path, "falsch"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void CanOpenAndRead_BeschaedigteDatei_GibtFalseZurueck()
|
||||||
|
{
|
||||||
|
using var temp = new TempDatabase();
|
||||||
|
// Eine kurze Textdatei initialisiert LiteDB stillschweigend als neue, leere Datenbank
|
||||||
|
// (kleiner als eine Seite) — erst genug Datenmüll jenseits der Seitengröße lässt das
|
||||||
|
// Datei-Layout tatsächlich fehlschlagen und simuliert eine echte Beschädigung
|
||||||
|
// (z.B. durch einen vollen Datenträger während der Backup-Kopie).
|
||||||
|
File.WriteAllBytes(temp.Path, Enumerable.Repeat((byte)0x42, 32_768).ToArray());
|
||||||
|
var service = new DatabaseEncryptionService();
|
||||||
|
|
||||||
|
Assert.False(service.CanOpenAndRead(temp.Path, null));
|
||||||
|
}
|
||||||
|
|
||||||
private sealed class TempDatabase : IDisposable
|
private sealed class TempDatabase : IDisposable
|
||||||
{
|
{
|
||||||
private readonly string _directory = System.IO.Path.Combine(
|
private readonly string _directory = System.IO.Path.Combine(
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|||||||
@@ -37,6 +37,29 @@ public class DatabaseEncryptionService
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Öffnet die Datei probeweise und liest die Sammlungsliste, um eine strukturell gültige
|
||||||
|
/// (nicht nur vorhandene) LiteDB-Datei zu bestätigen — z.B. direkt nach dem Anlegen eines
|
||||||
|
/// Backups, das durch einen vollen Datenträger oder einen vorzeitig entfernten USB-Stick
|
||||||
|
/// beschädigt worden sein könnte. Bewusst breites Catch: jede Art von Fehlschlag beim Öffnen
|
||||||
|
/// oder Lesen bedeutet hier "Backup nicht verifizierbar", nicht nur ein falsches Passwort.
|
||||||
|
public bool CanOpenAndRead(string dbPath, string? password)
|
||||||
|
{
|
||||||
|
if (!File.Exists(dbPath)) return false;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var connection = password is null
|
||||||
|
? new ConnectionString(dbPath)
|
||||||
|
: new ConnectionString(dbPath) { Password = password };
|
||||||
|
using var db = new LiteDatabase(connection);
|
||||||
|
_ = db.GetCollectionNames().ToList();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Setzt (newPassword != null), ändert oder entfernt (newPassword == null) das
|
/// Setzt (newPassword != null), ändert oder entfernt (newPassword == null) das
|
||||||
/// Passwort der Datenbank. Der Aufrufer muss sicherstellen, dass keine andere
|
/// Passwort der Datenbank. Der Aufrufer muss sicherstellen, dass keine andere
|
||||||
/// Verbindung (z.B. der laufende <see cref="LiteDbContext"/>) die Datei offen hält.
|
/// Verbindung (z.B. der laufende <see cref="LiteDbContext"/>) die Datei offen hält.
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -492,6 +492,7 @@ public class ParticipationSessionRepository(LiteDbContext db) : IParticipationSe
|
|||||||
public List<ParticipationSession> GetByGroup(Guid groupId) =>
|
public List<ParticipationSession> GetByGroup(Guid groupId) =>
|
||||||
db.ParticipationSessions.Find(s => s.GroupId == groupId).OrderByDescending(s => s.Date).ToList();
|
db.ParticipationSessions.Find(s => s.GroupId == groupId).OrderByDescending(s => s.Date).ToList();
|
||||||
public ParticipationSession? GetById(Guid id) => db.ParticipationSessions.FindById(id);
|
public ParticipationSession? GetById(Guid id) => db.ParticipationSessions.FindById(id);
|
||||||
|
public List<ParticipationSession> GetAll() => db.ParticipationSessions.FindAll().ToList();
|
||||||
public void Save(ParticipationSession s)
|
public void Save(ParticipationSession s)
|
||||||
{
|
{
|
||||||
ArchivedGroupWriteGuard.EnsureActive(db, s.GroupId);
|
ArchivedGroupWriteGuard.EnsureActive(db, s.GroupId);
|
||||||
@@ -794,6 +795,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,587 @@
|
|||||||
|
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,
|
||||||
|
termStart: new DateOnly(2026, 8, 1)));
|
||||||
|
|
||||||
|
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("seit 01.08.", row.YearSummaryTooltip);
|
||||||
|
Assert.Contains("1 unentschuldigt", row.YearSummaryTooltip);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void RosterBuild_ZeigtAbgezogeneFerientageImTooltip()
|
||||||
|
{
|
||||||
|
// Nutzer-Nachfrage: die Herkunft des Nenners soll sich ohne Blick in die WebUntis-Ferienliste
|
||||||
|
// direkt in der App nachvollziehen lassen, nachdem er zeitweise Ferientage mitzählte.
|
||||||
|
var students = new[] { Student(1001, "Ada Müller") };
|
||||||
|
var yearAbsences = new[]
|
||||||
|
{
|
||||||
|
new ClassAbsenceDaySummaryRow(new DateOnly(2026, 8, 17), "Müller Ada", 1001, 2, 90,
|
||||||
|
["Che"], [1, 2], ["entsch."], ["Krank"], null, null, false),
|
||||||
|
};
|
||||||
|
|
||||||
|
var row = Assert.Single(ClassTeacherRosterRow.Build(students, [], [],
|
||||||
|
new DateOnly(2026, 8, 26), yearAbsences, schoolDaysElapsed: 14, holidayWeekdaysExcluded: 7,
|
||||||
|
termStart: new DateOnly(2026, 8, 1)));
|
||||||
|
|
||||||
|
Assert.Contains("7 Ferientage abgezogen", row.YearSummaryTooltip);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void RosterBuild_OhneJahresdatenZeigtKeineFehlquote()
|
||||||
|
{
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Nenner der Jahresfehlquote: echter WebUntis-Ferienkalender statt grober Werktagszählung ──
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void CountSchoolWeekdays_SchliesstFerientageAus()
|
||||||
|
{
|
||||||
|
// 1.-31.08.2026 hat 21 Werktage; die (fiktiven) Sommerferien bis 11.08. nehmen davon 7
|
||||||
|
// Werktage weg (Mo 03. - Fr 07., Mo 10. - Di 11.) - Nutzer-Feedback: Schüler, die seit
|
||||||
|
// Unterrichtsbeginn jeden Tag fehlten, kamen wegen dieser mitgezählten Ferienzeit nur auf
|
||||||
|
// ~57 % statt ~100 % Fehlquote.
|
||||||
|
var holidays = new[] { new CachedUntisHoliday("Sommerferien", new DateOnly(2026, 7, 1), new DateOnly(2026, 8, 11)) };
|
||||||
|
|
||||||
|
var schoolDays = ClassTeacherOverviewViewModel.CountSchoolWeekdays(
|
||||||
|
new DateOnly(2026, 8, 1), new DateOnly(2026, 8, 31), holidays);
|
||||||
|
|
||||||
|
Assert.Equal(14, schoolDays);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void CountSchoolWeekdays_OhneFerienVerhaeltSichWieReineWerktagszaehlung()
|
||||||
|
{
|
||||||
|
var schoolDays = ClassTeacherOverviewViewModel.CountSchoolWeekdays(
|
||||||
|
new DateOnly(2026, 8, 1), new DateOnly(2026, 8, 31), []);
|
||||||
|
|
||||||
|
Assert.Equal(21, schoolDays);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void EstimateTermStart_NutztFruehestenFehlzeitEintragDerGanzenKlasse()
|
||||||
|
{
|
||||||
|
// Realer Befund (Nutzer-Feedback): WebUntis' getHolidays liefert für Bremen nie einen
|
||||||
|
// Sommerferien-Eintrag (über 11 Jahre Kontohistorie geprüft, kein einziger Juli-/August-
|
||||||
|
// Zeitraum dabei) - vermutlich weil die Sommerferien WebUntis-intern zwischen zwei
|
||||||
|
// Schuljahres-Datensätzen liegen (1.8./31.7.-Grenze), nicht "in" einem davon. Für den
|
||||||
|
// Schuljahresbeginn bleibt deshalb weiterhin der früheste Fehlzeiten-Eintrag der Klasse
|
||||||
|
// nötig statt CountSchoolWeekdays allein.
|
||||||
|
var absences = new[]
|
||||||
|
{
|
||||||
|
new ClassAbsenceDaySummaryRow(new DateOnly(2026, 8, 13), "Fehlt Cem", 1001, 1, 45,
|
||||||
|
["Deu"], [1], ["nicht entsch."], ["Absent"], null, null, false),
|
||||||
|
new ClassAbsenceDaySummaryRow(new DateOnly(2026, 8, 19), "Andere Ada", 1002, 1, 45,
|
||||||
|
["Deu"], [1], ["entsch."], ["Absent"], null, null, false),
|
||||||
|
};
|
||||||
|
|
||||||
|
var termStart = ClassTeacherOverviewViewModel.EstimateTermStart(absences, new DateOnly(2026, 8, 1));
|
||||||
|
|
||||||
|
Assert.Equal(new DateOnly(2026, 8, 13), termStart);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void EstimateTermStart_FaelltOhneFehlzeitenAufSchuljahresbeginnZurueck()
|
||||||
|
{
|
||||||
|
var termStart = ClassTeacherOverviewViewModel.EstimateTermStart([], new DateOnly(2026, 8, 1));
|
||||||
|
|
||||||
|
Assert.Equal(new DateOnly(2026, 8, 1), termStart);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void CountSchoolWeekdays_AbEchtemTerminstartOhneSommerferienEintragStimmtMitBeobachtungUeberein()
|
||||||
|
{
|
||||||
|
// Regressionstest für den konkret gemeldeten Fall: 13 tatsächliche Schultage seit
|
||||||
|
// Unterrichtsbeginn (13.08.2026, ein Donnerstag) bis 31.08.2026, keine WebUntis-Ferien im
|
||||||
|
// Bereich (die nächste, "Herbstferien", liegt erst im Oktober) - Kombination aus
|
||||||
|
// EstimateTermStart (Sommerferien-Lücke) und CountSchoolWeekdays (übrige Ferien) muss auf
|
||||||
|
// die vom Nutzer nachgezählte Zahl kommen, nicht auf die volle Werktagszahl ab 1.8. (21).
|
||||||
|
var termStart = new DateOnly(2026, 8, 13);
|
||||||
|
var noHolidaysInRange = Array.Empty<CachedUntisHoliday>();
|
||||||
|
|
||||||
|
var schoolDays = ClassTeacherOverviewViewModel.CountSchoolWeekdays(
|
||||||
|
termStart, new DateOnly(2026, 8, 31), noHolidaysInRange);
|
||||||
|
|
||||||
|
Assert.Equal(13, schoolDays);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Offene Entschuldigungen (Feature-Idee 2) ─────────────────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void OpenExcuseBuild_IgnoriertUnentschuldigteVerspaetungen()
|
||||||
|
{
|
||||||
|
// Verspätungen sind i.d.R. nicht entschuldigungsfähig - "unentschuldigt" darf hier keine
|
||||||
|
// Erinnerung an eine fehlende Entschuldigung auslösen (Nutzer-Feedback).
|
||||||
|
var absences = new[]
|
||||||
|
{
|
||||||
|
new ClassAbsenceDaySummaryRow(new DateOnly(2026, 8, 25), "Neu Ben", 1002, 0, 15,
|
||||||
|
["Deu"], [1], ["nicht entsch."], ["Verspätung"], null, null, false),
|
||||||
|
new ClassAbsenceDaySummaryRow(new DateOnly(2026, 8, 24), "Fehlt Cem", 1003, 1, 45,
|
||||||
|
["Deu"], [1], ["nicht entsch."], ["Absent"], null, null, false),
|
||||||
|
};
|
||||||
|
|
||||||
|
var rows = ClassTeacherOpenExcuseRow.Build(absences, new DateOnly(2026, 8, 26));
|
||||||
|
|
||||||
|
Assert.Equal(["Cem Fehlt"], rows.Select(r => r.StudentName));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Verspätungsmuster: Elterngespräch-Eskalation (Nutzer-Feedback) ───────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void DetectLatePatterns_EskaliertBeiHoherJahressummeMitWiedervorlageOption()
|
||||||
|
{
|
||||||
|
var absences = Enumerable.Range(0, 5)
|
||||||
|
.Select(i => new ClassAbsenceDaySummaryRow(new DateOnly(2026, 8, 3 + i), "Spaet Timo", 1001, 0, 10,
|
||||||
|
["Deu"], [1], ["entsch."], ["Verspätung"], null, null, false))
|
||||||
|
.ToArray();
|
||||||
|
var names = new Dictionary<string, string> { [UntisNameMatching.NameKey("Spaet Timo")] = "Timo Spät" };
|
||||||
|
|
||||||
|
var notices = ClassTeacherOverviewViewModel.DetectLatePatterns(
|
||||||
|
absences, names, sevenDayStart: new DateOnly(2026, 8, 31));
|
||||||
|
|
||||||
|
var notice = Assert.Single(notices);
|
||||||
|
Assert.Equal("Timo Spät", notice.StudentName);
|
||||||
|
Assert.Contains("5 Verspätungen seit Schuljahresbeginn", notice.Message);
|
||||||
|
Assert.Equal(ClassTeacherStatusKind.Danger, notice.Kind);
|
||||||
|
Assert.True(notice.CanCreateReminder);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void DetectLatePatterns_KurzfristigesClusterBleibtHinweisOhneAktion()
|
||||||
|
{
|
||||||
|
var absences = new[]
|
||||||
|
{
|
||||||
|
new ClassAbsenceDaySummaryRow(new DateOnly(2026, 8, 24), "Spaet Timo", 1001, 0, 10,
|
||||||
|
["Deu"], [1], ["entsch."], ["Verspätung"], null, null, false),
|
||||||
|
new ClassAbsenceDaySummaryRow(new DateOnly(2026, 8, 25), "Spaet Timo", 1001, 0, 10,
|
||||||
|
["Deu"], [1], ["entsch."], ["Verspätung"], null, null, false),
|
||||||
|
};
|
||||||
|
var names = new Dictionary<string, string> { [UntisNameMatching.NameKey("Spaet Timo")] = "Timo Spät" };
|
||||||
|
|
||||||
|
var notices = ClassTeacherOverviewViewModel.DetectLatePatterns(
|
||||||
|
absences, names, sevenDayStart: new DateOnly(2026, 8, 20));
|
||||||
|
|
||||||
|
var notice = Assert.Single(notices);
|
||||||
|
Assert.Equal("2-mal verspätet in 7 Tagen", notice.Message);
|
||||||
|
Assert.Equal(ClassTeacherStatusKind.Warning, notice.Kind);
|
||||||
|
Assert.False(notice.CanCreateReminder);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void DetectLatePatterns_UnentschuldigteFehltageGehenVorVerspaetungseskalation()
|
||||||
|
{
|
||||||
|
var absences = new[]
|
||||||
|
{
|
||||||
|
new ClassAbsenceDaySummaryRow(new DateOnly(2026, 8, 24), "Fehlt Cem", 1001, 1, 45,
|
||||||
|
["Deu"], [1], ["nicht entsch."], ["Absent"], null, null, false),
|
||||||
|
new ClassAbsenceDaySummaryRow(new DateOnly(2026, 8, 25), "Fehlt Cem", 1001, 1, 45,
|
||||||
|
["Deu"], [1], ["nicht entsch."], ["Absent"], null, null, false),
|
||||||
|
};
|
||||||
|
var names = new Dictionary<string, string> { [UntisNameMatching.NameKey("Fehlt Cem")] = "Cem Fehlt" };
|
||||||
|
|
||||||
|
var notices = ClassTeacherOverviewViewModel.DetectLatePatterns(
|
||||||
|
absences, names, sevenDayStart: new DateOnly(2026, 8, 20));
|
||||||
|
|
||||||
|
var notice = Assert.Single(notices);
|
||||||
|
Assert.Contains("unentschuldigte Fehltage", notice.Message);
|
||||||
|
Assert.Equal(ClassTeacherStatusKind.Danger, notice.Kind);
|
||||||
|
Assert.False(notice.CanCreateReminder);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Aggregierte Klassenbuch-Kategorien (Feature-Idee 5) ──────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
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 };
|
||||||
|
}
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
using System.IO.Compression;
|
|
||||||
using LehrerApp.Core.Models;
|
using LehrerApp.Core.Models;
|
||||||
using LehrerApp.Core.Services;
|
|
||||||
using LehrerApp.Desktop.ViewModels.Students;
|
using LehrerApp.Desktop.ViewModels.Students;
|
||||||
|
using LehrerApp.Templating;
|
||||||
using Xunit;
|
using Xunit;
|
||||||
|
|
||||||
namespace LehrerApp.Desktop.Tests;
|
namespace LehrerApp.Desktop.Tests;
|
||||||
@@ -9,16 +8,12 @@ namespace LehrerApp.Desktop.Tests;
|
|||||||
public sealed class CreateLetterDialogViewModelTests : IDisposable
|
public sealed class CreateLetterDialogViewModelTests : IDisposable
|
||||||
{
|
{
|
||||||
private readonly string _directory = Path.Combine(Path.GetTempPath(), $"lehrerapp-letter-vm-tests-{Guid.NewGuid():N}");
|
private readonly string _directory = Path.Combine(Path.GetTempPath(), $"lehrerapp-letter-vm-tests-{Guid.NewGuid():N}");
|
||||||
|
|
||||||
public CreateLetterDialogViewModelTests() => Directory.CreateDirectory(_directory);
|
public CreateLetterDialogViewModelTests() => Directory.CreateDirectory(_directory);
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void OhneVorlage_KannKeinenBriefErzeugen()
|
public void OhneVorlage_KannKeinenBriefErzeugen()
|
||||||
{
|
{
|
||||||
var student = StudentWithContact("Sehr geehrte Frau Muster,");
|
var vm = Build(StudentWithContact("Sehr geehrte Frau Muster,"), new TemplateStore(_directory));
|
||||||
|
|
||||||
var vm = Build(student, new LetterTemplateService(_directory));
|
|
||||||
|
|
||||||
Assert.False(vm.CanGenerate);
|
Assert.False(vm.CanGenerate);
|
||||||
Assert.Contains(vm.Issues, i => i.Message.Contains("Vorlage", StringComparison.OrdinalIgnoreCase));
|
Assert.Contains(vm.Issues, i => i.Message.Contains("Vorlage", StringComparison.OrdinalIgnoreCase));
|
||||||
}
|
}
|
||||||
@@ -26,83 +21,63 @@ public sealed class CreateLetterDialogViewModelTests : IDisposable
|
|||||||
[Fact]
|
[Fact]
|
||||||
public void VerwendeteBriefanredeFehlt_BlockiertErzeugung()
|
public void VerwendeteBriefanredeFehlt_BlockiertErzeugung()
|
||||||
{
|
{
|
||||||
var service = ServiceWithTemplate("Letter.Salutation");
|
var vm = Build(StudentWithContact(null), StoreWithTemplate(
|
||||||
var vm = Build(StudentWithContact(null), service);
|
new PlaceholderDefinition("Anrede", PlaceholderType.Text, true)));
|
||||||
|
|
||||||
Assert.False(vm.CanGenerate);
|
Assert.False(vm.CanGenerate);
|
||||||
Assert.Contains(vm.Issues, i => i.Message.Contains("Briefanrede"));
|
Assert.Contains(vm.Issues, i => i.Message.Contains("Anrede", StringComparison.OrdinalIgnoreCase));
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void VollstaendigeDaten_ErzeugenEditierbareDocxKopie()
|
public void VollstaendigeDaten_ErzeugenFlachesPdf()
|
||||||
{
|
{
|
||||||
var service = ServiceWithTemplate("Letter.Salutation", "Student.FirstName", "CurrentDate");
|
var store = StoreWithTemplate(new PlaceholderDefinition("Anrede", PlaceholderType.Text, true),
|
||||||
var vm = Build(StudentWithContact("Sehr geehrte Frau Muster,"), service);
|
new PlaceholderDefinition("Datum", PlaceholderType.Date, true),
|
||||||
var output = Path.Combine(_directory, "Brief.docx");
|
new PlaceholderDefinition("Brieftext", PlaceholderType.Multiline, true));
|
||||||
|
var vm = Build(StudentWithContact("Sehr geehrte Frau Muster,"), store);
|
||||||
|
vm.LetterText = "Dies ist der Inhalt.";
|
||||||
|
var output = Path.Combine(_directory, "Brief.pdf");
|
||||||
|
|
||||||
var generated = vm.Generate(output);
|
Assert.True(vm.Generate(output));
|
||||||
|
Assert.Equal("%PDF", System.Text.Encoding.ASCII.GetString(File.ReadAllBytes(output), 0, 4));
|
||||||
Assert.True(generated);
|
|
||||||
Assert.True(File.Exists(output));
|
|
||||||
using var archive = ZipFile.OpenRead(output);
|
|
||||||
using var reader = new StreamReader(archive.GetEntry("word/document.xml")!.Open());
|
|
||||||
var xml = reader.ReadToEnd();
|
|
||||||
Assert.Contains("Sehr geehrte Frau Muster,", xml);
|
|
||||||
Assert.Contains("Lena", xml);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void KontaktBearbeiten_SpeichertExpliziteBriefanrede()
|
public void KonstanterPflichttext_BenoetigtKeineExterneEingabe()
|
||||||
{
|
{
|
||||||
var vm = new ContactEditDialogViewModel(new Contact { Name = "Frau Muster" });
|
var store = StoreWithTemplate(new PlaceholderDefinition("Brieftext", PlaceholderType.Multiline,
|
||||||
vm.LetterSalutation = "Sehr geehrte Frau Muster,";
|
Required: true, IsConstant: true, ConstantValue: "Fest im Vorlagenpaket"));
|
||||||
|
var vm = Build(StudentWithContact("Sehr geehrte Frau Muster,"), store);
|
||||||
|
var output = Path.Combine(_directory, "Konstant.pdf");
|
||||||
|
|
||||||
vm.SaveCommand.Execute(null);
|
Assert.True(vm.CanGenerate);
|
||||||
|
Assert.True(vm.Generate(output));
|
||||||
Assert.Equal("Sehr geehrte Frau Muster,", vm.Result!.LetterSalutation);
|
Assert.Equal("%PDF", System.Text.Encoding.ASCII.GetString(File.ReadAllBytes(output), 0, 4));
|
||||||
}
|
}
|
||||||
|
|
||||||
private CreateLetterDialogViewModel Build(Student student, LetterTemplateService service) =>
|
private CreateLetterDialogViewModel Build(Student student, TemplateStore store) =>
|
||||||
new(student, service, new FakeMemberships([]), new FakeGroups([]));
|
new(student, store, new QuestTemplateRenderer(), new FakeMemberships([]), new FakeGroups([]));
|
||||||
|
|
||||||
private LetterTemplateService ServiceWithTemplate(params string[] tags)
|
private TemplateStore StoreWithTemplate(params PlaceholderDefinition[] definitions)
|
||||||
{
|
{
|
||||||
var source = Path.Combine(_directory, $"{Guid.NewGuid():N}.docx");
|
var source = Path.Combine(_directory, $"{Guid.NewGuid():N}.lavorlage");
|
||||||
using (var archive = ZipFile.Open(source, ZipArchiveMode.Create))
|
var manifest = new TemplateManifest { Id = $"brief-{Guid.NewGuid():N}", Name = "Elternbrief", Placeholders = [.. definitions] };
|
||||||
|
var lines = new List<string> { "PAGE 210 297 mm" };
|
||||||
|
var y = 20;
|
||||||
|
foreach (var definition in definitions)
|
||||||
{
|
{
|
||||||
var entry = archive.CreateEntry("word/document.xml");
|
var element = definition.Type == PlaceholderType.Multiline ? "TEXTBOX" : "TEXT";
|
||||||
using var writer = new StreamWriter(entry.Open());
|
lines.Add(element == "TEXTBOX" ? $"TEXTBOX 20 {y} 170 80 ${definition.Name}" : $"TEXT 20 {y} ${definition.Name}");
|
||||||
var controls = string.Join("", tags.Select(tag =>
|
y += 20;
|
||||||
$"<w:sdt><w:sdtPr><w:tag w:val=\"{tag}\"/></w:sdtPr><w:sdtContent>" +
|
|
||||||
"<w:p><w:r><w:t>Platzhalter</w:t></w:r></w:p></w:sdtContent></w:sdt>"));
|
|
||||||
writer.Write("<w:document xmlns:w=\"http://schemas.openxmlformats.org/wordprocessingml/2006/main\">" +
|
|
||||||
$"<w:body>{controls}</w:body></w:document>");
|
|
||||||
}
|
}
|
||||||
var service = new LetterTemplateService(Path.Combine(_directory, $"service-{Guid.NewGuid():N}"));
|
TemplatePackage.Create(source, manifest, string.Join('\n', lines), new Dictionary<string, byte[]>());
|
||||||
service.Import(source, "Elternbrief");
|
var store = new TemplateStore(Path.Combine(_directory, $"store-{Guid.NewGuid():N}")); store.Import(source); return store;
|
||||||
return service;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static Student StudentWithContact(string? salutation) => new()
|
private static Student StudentWithContact(string? salutation) => new()
|
||||||
{
|
{
|
||||||
FirstName = "Lena",
|
FirstName = "Lena", LastName = "Beispiel",
|
||||||
LastName = "Beispiel",
|
Contacts = [new Contact { Name = "Frau Muster", Relation = "Mutter", LetterSalutation = salutation,
|
||||||
Contacts =
|
Street = "Hauptstraße 1", PostalCode = "12345", City = "Musterstadt" }],
|
||||||
[
|
|
||||||
new Contact
|
|
||||||
{
|
|
||||||
Name = "Frau Muster",
|
|
||||||
Relation = "Mutter",
|
|
||||||
LetterSalutation = salutation,
|
|
||||||
Street = "Hauptstraße 1",
|
|
||||||
PostalCode = "12345",
|
|
||||||
City = "Musterstadt",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
};
|
||||||
|
public void Dispose() { if (Directory.Exists(_directory)) Directory.Delete(_directory, true); }
|
||||||
public void Dispose()
|
|
||||||
{
|
|
||||||
if (Directory.Exists(_directory)) Directory.Delete(_directory, recursive: true);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
using LehrerApp.Core.Models;
|
using LehrerApp.Core.Models;
|
||||||
using LehrerApp.Core.Services;
|
using LehrerApp.Core.Services;
|
||||||
using LehrerApp.Desktop.ViewModels;
|
using LehrerApp.Desktop.ViewModels;
|
||||||
|
using System.Globalization;
|
||||||
using Xunit;
|
using Xunit;
|
||||||
|
|
||||||
namespace LehrerApp.Desktop.Tests;
|
namespace LehrerApp.Desktop.Tests;
|
||||||
@@ -11,6 +12,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(
|
||||||
@@ -43,12 +60,13 @@ public sealed class DashboardViewModelTests
|
|||||||
DashboardSettingsService? dashboardSettings = null, FakeSchoolHolidays? schoolHolidays = null,
|
DashboardSettingsService? dashboardSettings = null, FakeSchoolHolidays? schoolHolidays = null,
|
||||||
FakeLessons? lessons = null, FakeSubstitutionEntries? substitutions = null,
|
FakeLessons? lessons = null, FakeSubstitutionEntries? substitutions = null,
|
||||||
FakeSessions? sessions = null, FakeEntries? entries = null,
|
FakeSessions? sessions = null, FakeEntries? entries = null,
|
||||||
FakeAnnualPlanEvents? annualPlanEvents = null)
|
FakeAnnualPlanEvents? annualPlanEvents = null, List<LearningGroup>? allGroups = null,
|
||||||
|
FakeTimeEntries? timeEntries = null)
|
||||||
{
|
{
|
||||||
lessons ??= new FakeLessons();
|
lessons ??= new FakeLessons();
|
||||||
lessons.Add(lesson);
|
lessons.Add(lesson);
|
||||||
return new DashboardViewModel(
|
return new DashboardViewModel(
|
||||||
new FakeGroups([group]), new FakeSubjects([]), lessons,
|
new FakeGroups(allGroups ?? [group]), new FakeSubjects([]), lessons,
|
||||||
exams ?? new FakeExams([]), results ?? new FakeResults(), grades ?? new FakeGrades(),
|
exams ?? new FakeExams([]), results ?? new FakeResults(), grades ?? new FakeGrades(),
|
||||||
reportGrades ?? new FakeReportGrades(), memberships ?? new FakeMemberships([]),
|
reportGrades ?? new FakeReportGrades(), memberships ?? new FakeMemberships([]),
|
||||||
tasks ?? new FakeWorkTasks(), sessions ?? new FakeSessions([]), entries ?? new FakeEntries(),
|
tasks ?? new FakeWorkTasks(), sessions ?? new FakeSessions([]), entries ?? new FakeEntries(),
|
||||||
@@ -56,7 +74,182 @@ public sealed class DashboardViewModelTests
|
|||||||
slots ?? new FakeTimetableSlots(), periodSchedule ?? NewPeriodSchedule(),
|
slots ?? new FakeTimetableSlots(), periodSchedule ?? NewPeriodSchedule(),
|
||||||
new AttendanceBalanceService(), new SchoolYearService(), dashboardSettings ?? NewDashboardSettings(),
|
new AttendanceBalanceService(), new SchoolYearService(), dashboardSettings ?? NewDashboardSettings(),
|
||||||
schoolHolidays ?? new FakeSchoolHolidays(), new PublicHolidayService(), NewCalendarSettings(),
|
schoolHolidays ?? new FakeSchoolHolidays(), new PublicHolidayService(), NewCalendarSettings(),
|
||||||
substitutions ?? new FakeSubstitutionEntries(), annualPlanEvents);
|
substitutions ?? new FakeSubstitutionEntries(), timeEntries ?? new FakeTimeEntries(), annualPlanEvents);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Montag einer Woche, die garantiert in der Zukunft liegt und innerhalb der
|
||||||
|
/// Klausurwochen-Vorausschau — unabhängig davon, welcher Wochentag "heute" gerade ist.
|
||||||
|
private static DateOnly NextIsoWeekMonday()
|
||||||
|
{
|
||||||
|
var reference = DateTime.Today.AddDays(7);
|
||||||
|
return DateOnly.FromDateTime(
|
||||||
|
ISOWeek.ToDateTime(ISOWeek.GetYear(reference), ISOWeek.GetWeekOfYear(reference), DayOfWeek.Monday));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ExamWeekLoads_DreiGeplanteKlausurenInDerselbenWoche_ErzeugtEintrag()
|
||||||
|
{
|
||||||
|
var group = new LearningGroup { Name = "9c" };
|
||||||
|
var today = DateOnly.FromDateTime(DateTime.Today);
|
||||||
|
var monday = NextIsoWeekMonday();
|
||||||
|
var exams = new FakeExams([
|
||||||
|
new Exam { GroupId = group.Id, Title = "a", Date = monday, Status = ExamStatus.Planned },
|
||||||
|
new Exam { GroupId = group.Id, Title = "b", Date = monday.AddDays(1), Status = ExamStatus.Planned },
|
||||||
|
new Exam { GroupId = group.Id, Title = "c", Date = monday.AddDays(2), Status = ExamStatus.Planned },
|
||||||
|
]);
|
||||||
|
|
||||||
|
var vm = BuildVm(group, new Lesson { GroupId = group.Id, Date = today }, exams: exams);
|
||||||
|
|
||||||
|
var entry = Assert.Single(vm.ExamWeekLoads);
|
||||||
|
Assert.Equal(3, entry.ExamCount);
|
||||||
|
Assert.True(vm.ExamLoadCard.EffectiveIsVisible);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ExamWeekLoads_WenigeKlausurenProWoche_BleibtLeer()
|
||||||
|
{
|
||||||
|
var group = new LearningGroup { Name = "9c" };
|
||||||
|
var today = DateOnly.FromDateTime(DateTime.Today);
|
||||||
|
var monday = NextIsoWeekMonday();
|
||||||
|
var exams = new FakeExams([
|
||||||
|
new Exam { GroupId = group.Id, Title = "a", Date = monday, Status = ExamStatus.Planned },
|
||||||
|
new Exam { GroupId = group.Id, Title = "b", Date = monday.AddDays(1), Status = ExamStatus.Planned },
|
||||||
|
]);
|
||||||
|
|
||||||
|
var vm = BuildVm(group, new Lesson { GroupId = group.Id, Date = today }, exams: exams);
|
||||||
|
|
||||||
|
Assert.Empty(vm.ExamWeekLoads);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ExamWeekLoads_BereitsDurchgefuehrteKlausurenZaehlenNicht()
|
||||||
|
{
|
||||||
|
var group = new LearningGroup { Name = "9c" };
|
||||||
|
var today = DateOnly.FromDateTime(DateTime.Today);
|
||||||
|
var monday = NextIsoWeekMonday();
|
||||||
|
var exams = new FakeExams([
|
||||||
|
new Exam { GroupId = group.Id, Title = "a", Date = monday, Status = ExamStatus.Conducted },
|
||||||
|
new Exam { GroupId = group.Id, Title = "b", Date = monday.AddDays(1), Status = ExamStatus.Graded },
|
||||||
|
new Exam { GroupId = group.Id, Title = "c", Date = monday.AddDays(2), Status = ExamStatus.Planned },
|
||||||
|
]);
|
||||||
|
|
||||||
|
var vm = BuildVm(group, new Lesson { GroupId = group.Id, Date = today }, exams: exams);
|
||||||
|
|
||||||
|
Assert.Empty(vm.ExamWeekLoads);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void MissingTeachingTime_VergangenerUnterrichtstagOhneErfassung_WirdGemeldet()
|
||||||
|
{
|
||||||
|
var group = new LearningGroup { Name = "9c" };
|
||||||
|
var today = DateOnly.FromDateTime(DateTime.Today);
|
||||||
|
var pastDay = today.AddDays(-1);
|
||||||
|
var slots = new FakeTimetableSlots();
|
||||||
|
slots.Add(new TimetableSlot { GroupId = group.Id, Weekday = pastDay.DayOfWeek, PeriodNumber = 1 });
|
||||||
|
var periodSchedule = NewPeriodSchedule();
|
||||||
|
periodSchedule.SetPeriods([new PeriodTimeEntry
|
||||||
|
{ PeriodNumber = 1, Start = new TimeOnly(8, 0), End = new TimeOnly(8, 45) }]);
|
||||||
|
|
||||||
|
var vm = BuildVm(group, new Lesson { GroupId = group.Id, Date = today }, slots: slots, periodSchedule: periodSchedule);
|
||||||
|
|
||||||
|
Assert.Contains(vm.MissingTeachingTimeEntries, i => i.Date == pastDay);
|
||||||
|
Assert.True(vm.MissingTeachingTimeCard.EffectiveIsVisible);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void MissingTeachingTime_BereitsErfassterTag_WirdNichtGemeldet()
|
||||||
|
{
|
||||||
|
var group = new LearningGroup { Name = "9c" };
|
||||||
|
var today = DateOnly.FromDateTime(DateTime.Today);
|
||||||
|
var pastDay = today.AddDays(-1);
|
||||||
|
var slots = new FakeTimetableSlots();
|
||||||
|
slots.Add(new TimetableSlot { GroupId = group.Id, Weekday = pastDay.DayOfWeek, PeriodNumber = 1 });
|
||||||
|
var periodSchedule = NewPeriodSchedule();
|
||||||
|
periodSchedule.SetPeriods([new PeriodTimeEntry
|
||||||
|
{ PeriodNumber = 1, Start = new TimeOnly(8, 0), End = new TimeOnly(8, 45) }]);
|
||||||
|
var timeEntries = new FakeTimeEntries();
|
||||||
|
timeEntries.Add(new TimeEntry { Date = pastDay, Category = "Unterricht", DurationMinutes = 45 });
|
||||||
|
|
||||||
|
var vm = BuildVm(group, new Lesson { GroupId = group.Id, Date = today },
|
||||||
|
slots: slots, periodSchedule: periodSchedule, timeEntries: timeEntries);
|
||||||
|
|
||||||
|
Assert.DoesNotContain(vm.MissingTeachingTimeEntries, i => i.Date == pastDay);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void MissingTeachingTime_KomplettAusgefallenerTag_WirdNichtGemeldet()
|
||||||
|
{
|
||||||
|
var group = new LearningGroup { Name = "9c" };
|
||||||
|
var today = DateOnly.FromDateTime(DateTime.Today);
|
||||||
|
var pastDay = today.AddDays(-1);
|
||||||
|
var slots = new FakeTimetableSlots();
|
||||||
|
slots.Add(new TimetableSlot { GroupId = group.Id, Weekday = pastDay.DayOfWeek, PeriodNumber = 1 });
|
||||||
|
var periodSchedule = NewPeriodSchedule();
|
||||||
|
periodSchedule.SetPeriods([new PeriodTimeEntry
|
||||||
|
{ PeriodNumber = 1, Start = new TimeOnly(8, 0), End = new TimeOnly(8, 45) }]);
|
||||||
|
var substitutions = new FakeSubstitutionEntries();
|
||||||
|
substitutions.Add(new SubstitutionEntry { Date = pastDay, Kind = SubstitutionKind.Cancelled, PeriodNumber = 1 });
|
||||||
|
|
||||||
|
var vm = BuildVm(group, new Lesson { GroupId = group.Id, Date = today },
|
||||||
|
slots: slots, periodSchedule: periodSchedule, substitutions: substitutions);
|
||||||
|
|
||||||
|
Assert.DoesNotContain(vm.MissingTeachingTimeEntries, i => i.Date == pastDay);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void MissingTeachingTime_SchulferienTagWirdNichtGemeldet()
|
||||||
|
{
|
||||||
|
var group = new LearningGroup { Name = "9c" };
|
||||||
|
var today = DateOnly.FromDateTime(DateTime.Today);
|
||||||
|
var pastDay = today.AddDays(-1);
|
||||||
|
var slots = new FakeTimetableSlots();
|
||||||
|
slots.Add(new TimetableSlot { GroupId = group.Id, Weekday = pastDay.DayOfWeek, PeriodNumber = 1 });
|
||||||
|
var periodSchedule = NewPeriodSchedule();
|
||||||
|
periodSchedule.SetPeriods([new PeriodTimeEntry
|
||||||
|
{ PeriodNumber = 1, Start = new TimeOnly(8, 0), End = new TimeOnly(8, 45) }]);
|
||||||
|
var schoolHolidays = new FakeSchoolHolidays();
|
||||||
|
schoolHolidays.Add(new SchoolHoliday { Name = "Ferien", StartDate = pastDay, EndDate = pastDay });
|
||||||
|
|
||||||
|
var vm = BuildVm(group, new Lesson { GroupId = group.Id, Date = today },
|
||||||
|
slots: slots, periodSchedule: periodSchedule, schoolHolidays: schoolHolidays);
|
||||||
|
|
||||||
|
Assert.DoesNotContain(vm.MissingTeachingTimeEntries, i => i.Date == pastDay);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void MissingTeachingTime_HeuteVorAblaufDerWartezeit_WirdNichtGemeldet()
|
||||||
|
{
|
||||||
|
var group = new LearningGroup { Name = "9c" };
|
||||||
|
var today = DateOnly.FromDateTime(DateTime.Today);
|
||||||
|
var slots = new FakeTimetableSlots();
|
||||||
|
slots.Add(new TimetableSlot { GroupId = group.Id, Weekday = today.DayOfWeek, PeriodNumber = 1 });
|
||||||
|
var periodSchedule = NewPeriodSchedule();
|
||||||
|
// Unterrichtsende liegt garantiert noch keine 30 Minuten zurück.
|
||||||
|
var futureEnd = TimeOnly.FromDateTime(DateTime.Now).AddHours(2);
|
||||||
|
periodSchedule.SetPeriods([new PeriodTimeEntry
|
||||||
|
{ PeriodNumber = 1, Start = futureEnd.AddHours(-1), End = futureEnd }]);
|
||||||
|
|
||||||
|
var vm = BuildVm(group, new Lesson { GroupId = group.Id, Date = today }, slots: slots, periodSchedule: periodSchedule);
|
||||||
|
|
||||||
|
Assert.DoesNotContain(vm.MissingTeachingTimeEntries, i => i.Date == today);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void MissingTeachingTime_HeuteNachAblaufDerWartezeit_WirdGemeldet()
|
||||||
|
{
|
||||||
|
var group = new LearningGroup { Name = "9c" };
|
||||||
|
var today = DateOnly.FromDateTime(DateTime.Today);
|
||||||
|
var slots = new FakeTimetableSlots();
|
||||||
|
slots.Add(new TimetableSlot { GroupId = group.Id, Weekday = today.DayOfWeek, PeriodNumber = 1 });
|
||||||
|
var periodSchedule = NewPeriodSchedule();
|
||||||
|
// Unterrichtsende liegt garantiert mehr als 30 Minuten zurück.
|
||||||
|
var pastEnd = TimeOnly.FromDateTime(DateTime.Now).AddHours(-2);
|
||||||
|
periodSchedule.SetPeriods([new PeriodTimeEntry
|
||||||
|
{ PeriodNumber = 1, Start = pastEnd.AddHours(-1), End = pastEnd }]);
|
||||||
|
|
||||||
|
var vm = BuildVm(group, new Lesson { GroupId = group.Id, Date = today }, slots: slots, periodSchedule: periodSchedule);
|
||||||
|
|
||||||
|
Assert.Contains(vm.MissingTeachingTimeEntries, i => i.Date == today);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
@@ -226,6 +419,26 @@ public sealed class DashboardViewModelTests
|
|||||||
Assert.Contains(vm.SelectedDayEvents, e => e.Kind == CalendarEventKind.Exam && e.Title == "Test");
|
Assert.Contains(vm.SelectedDayEvents, e => e.Kind == CalendarEventKind.Exam && e.Title == "Test");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Kalenderauswahl_SortiertKurseDesTagesVorDenAlphabetischenRest()
|
||||||
|
{
|
||||||
|
var today = DateOnly.FromDateTime(DateTime.Today);
|
||||||
|
var selectedDate = today.AddDays(1);
|
||||||
|
var alphabeticallyFirst = new LearningGroup { Name = "10a" };
|
||||||
|
var selectedDayGroup = new LearningGroup { Name = "WAT 10c" };
|
||||||
|
var lessons = new FakeLessons();
|
||||||
|
lessons.Add(new Lesson { GroupId = selectedDayGroup.Id, Date = selectedDate });
|
||||||
|
var vm = BuildVm(alphabeticallyFirst,
|
||||||
|
new Lesson { GroupId = alphabeticallyFirst.Id, Date = today }, lessons: lessons,
|
||||||
|
allGroups: [alphabeticallyFirst, selectedDayGroup]);
|
||||||
|
|
||||||
|
vm.SelectCalendarDayCommand.Execute(vm.CalendarDays.Single(d => d.Date == selectedDate));
|
||||||
|
|
||||||
|
Assert.Equal(selectedDayGroup.Id, vm.CurrentGroups[0].GroupId);
|
||||||
|
Assert.True(vm.CurrentGroups[0].IsOnSelectedDay);
|
||||||
|
Assert.Equal(alphabeticallyFirst.Id, vm.CurrentGroups[1].GroupId);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Kalender_ZeigtMitarbeitssitzungenAlsEigenenTermintyp()
|
public void Kalender_ZeigtMitarbeitssitzungenAlsEigenenTermintyp()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -247,6 +247,25 @@ public sealed class DocumentationDialogViewModelTests
|
|||||||
Assert.Equal(groupId, vm.Result.GroupId);
|
Assert.Equal(groupId, vm.Result.GroupId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void GesamteLerngruppe_SaveSpeichertOhneSchuelerbezug()
|
||||||
|
{
|
||||||
|
var groupId = Guid.NewGuid();
|
||||||
|
var wholeGroup = new StudentOption(Guid.Empty, "Gesamte Lerngruppe");
|
||||||
|
var vm = new DocumentationDialogViewModel(Guid.Empty, null, new FakeAttachmentStorage(),
|
||||||
|
[wholeGroup], groupId)
|
||||||
|
{
|
||||||
|
SelectedStudent = wholeGroup, Title = "Klausur planen", TypeName = "Planung / Erinnerung",
|
||||||
|
};
|
||||||
|
|
||||||
|
vm.SaveCommand.Execute(null);
|
||||||
|
|
||||||
|
Assert.NotNull(vm.Result);
|
||||||
|
Assert.Equal(Guid.Empty, vm.Result!.StudentId);
|
||||||
|
Assert.Equal(groupId, vm.Result.GroupId);
|
||||||
|
Assert.Equal(DocumentationType.Planning, vm.Result.Type);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void MitStudentOptions_Bearbeiten_SchuelerIstVorausgewaehlt()
|
public void MitStudentOptions_Bearbeiten_SchuelerIstVorausgewaehlt()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
using LehrerApp.Core.Models;
|
||||||
|
using LehrerApp.Core.Services;
|
||||||
|
using LehrerApp.Desktop.ViewModels.Groups;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace LehrerApp.Desktop.Tests;
|
||||||
|
|
||||||
|
public sealed class ExamDialogViewModelTests
|
||||||
|
{
|
||||||
|
private static ExamDialogViewModel BuildViewModel(List<Exam> existingExams, string dateText,
|
||||||
|
Exam? editingExam = null)
|
||||||
|
{
|
||||||
|
var vm = new ExamDialogViewModel(new FakeExams(existingExams), new FakeCompetencyDomains(),
|
||||||
|
new FakeGradingKeyTemplates(), new GradingService(), Guid.NewGuid(), null, 9,
|
||||||
|
GradingSystem.Grades1To6, "Chemie", isDifferentiated: false, editingExam, null);
|
||||||
|
vm.DateText = dateText;
|
||||||
|
return vm;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Exam MakeExam(DateOnly date) => new() { GroupId = Guid.NewGuid(), Title = "x", Date = date };
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void WeekLoadHint_WenigeKlausurenInDerWoche_BleibtLeer()
|
||||||
|
{
|
||||||
|
var monday = new DateOnly(2026, 3, 16);
|
||||||
|
var existing = new List<Exam> { MakeExam(monday) };
|
||||||
|
|
||||||
|
var vm = BuildViewModel(existing, monday.AddDays(1).ToString("dd.MM.yyyy"));
|
||||||
|
|
||||||
|
Assert.Equal("", vm.WeekLoadHint);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void WeekLoadHint_DritteKlausurInDerWoche_ZeigtHinweis()
|
||||||
|
{
|
||||||
|
var monday = new DateOnly(2026, 3, 16);
|
||||||
|
var existing = new List<Exam> { MakeExam(monday), MakeExam(monday.AddDays(1)) };
|
||||||
|
|
||||||
|
var vm = BuildViewModel(existing, monday.AddDays(2).ToString("dd.MM.yyyy"));
|
||||||
|
|
||||||
|
Assert.Contains("3 Klausuren", vm.WeekLoadHint);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void WeekLoadHint_BearbeiteteKlausurZaehltNichtDoppelt()
|
||||||
|
{
|
||||||
|
var monday = new DateOnly(2026, 3, 16);
|
||||||
|
var editing = MakeExam(monday);
|
||||||
|
var existing = new List<Exam> { editing, MakeExam(monday.AddDays(1)) };
|
||||||
|
|
||||||
|
// Nur die eigene Klausur (editing) + eine weitere -> 2 insgesamt, kein Hinweis.
|
||||||
|
var vm = BuildViewModel(existing, monday.ToString("dd.MM.yyyy"), editing);
|
||||||
|
|
||||||
|
Assert.Equal("", vm.WeekLoadHint);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void WeekLoadHint_UngueltigesDatum_BleibtLeer()
|
||||||
|
{
|
||||||
|
var vm = BuildViewModel([], "keindatum");
|
||||||
|
|
||||||
|
Assert.Equal("", vm.WeekLoadHint);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,103 @@
|
|||||||
|
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 LeereListe_BietetAbsprungZuLerngruppen()
|
||||||
|
{
|
||||||
|
var vm = BuildVm([], []);
|
||||||
|
var navigated = false;
|
||||||
|
vm.OnNavigateToGroups = () => navigated = true;
|
||||||
|
|
||||||
|
vm.LoadCommand.Execute(null);
|
||||||
|
vm.GoToGroupsCommand.Execute(null);
|
||||||
|
|
||||||
|
Assert.True(vm.HasNoExams);
|
||||||
|
Assert.True(navigated);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Load_ParallelkurseWerdenAlsGeschwisterErkannt()
|
||||||
|
{
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -170,6 +170,7 @@ public class FakeSessions(List<ParticipationSession> all) : IParticipationSessio
|
|||||||
{
|
{
|
||||||
public List<ParticipationSession> GetByGroup(Guid groupId) => all.Where(s => s.GroupId == groupId).ToList();
|
public List<ParticipationSession> GetByGroup(Guid groupId) => all.Where(s => s.GroupId == groupId).ToList();
|
||||||
public ParticipationSession? GetById(Guid id) => all.FirstOrDefault(s => s.Id == id);
|
public ParticipationSession? GetById(Guid id) => all.FirstOrDefault(s => s.Id == id);
|
||||||
|
public List<ParticipationSession> GetAll() => all.ToList();
|
||||||
public void Save(ParticipationSession session)
|
public void Save(ParticipationSession session)
|
||||||
{
|
{
|
||||||
all.RemoveAll(s => s.Id == session.Id);
|
all.RemoveAll(s => s.Id == session.Id);
|
||||||
@@ -474,6 +475,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,103 @@
|
|||||||
|
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),
|
||||||
|
item => Assert.Equal(GlobalSearchAction.NewGroupDocumentation, 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);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Suche_FindetAktiveGruppeUeberFachUndZeigtFachImUntertitel()
|
||||||
|
{
|
||||||
|
var subject = new Subject { Name = "Wirtschaft-Arbeit-Technik", ShortName = "WAT" };
|
||||||
|
var group = new LearningGroup { Name = "10c", SubjectId = subject.Id, SchoolYear = "2026/27", GradeLevel = 10 };
|
||||||
|
var vm = BuildVm(groups: [group], subjects: [subject]);
|
||||||
|
|
||||||
|
vm.Query = "wat";
|
||||||
|
|
||||||
|
var result = Assert.Single(vm.Results, x => x.Kind == GlobalSearchResultKind.Group);
|
||||||
|
Assert.Contains("WAT", result.Subtitle);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Suche_UeberspringtArchivierteSchuelerUndGruppenSamtDerenKlausuren()
|
||||||
|
{
|
||||||
|
var archivedGroup = new LearningGroup { Name = "Testkurs Vorjahr", IsActive = false };
|
||||||
|
var archivedStudent = new Student { FirstName = "Test", LastName = "Archiv", IsActive = false };
|
||||||
|
var exam = new Exam { GroupId = archivedGroup.Id, Title = "Testklausur" };
|
||||||
|
var vm = BuildVm([archivedStudent], [archivedGroup], [exam]);
|
||||||
|
|
||||||
|
vm.Query = "Test";
|
||||||
|
|
||||||
|
Assert.DoesNotContain(vm.Results, x => x.Kind is GlobalSearchResultKind.Student
|
||||||
|
or GlobalSearchResultKind.Group or GlobalSearchResultKind.Exam);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static GlobalSearchViewModel BuildVm(List<Student>? students = null,
|
||||||
|
List<LearningGroup>? groups = null, List<Exam>? exams = null, FakeWorkTasks? tasks = null,
|
||||||
|
List<Subject>? subjects = null) =>
|
||||||
|
new(new FakeStudents(students ?? []), new FakeGroups(groups ?? []),
|
||||||
|
new FakeSubjects(subjects ?? []), new FakeExams(exams ?? []), tasks ?? new FakeWorkTasks());
|
||||||
|
}
|
||||||
@@ -18,8 +18,8 @@ public sealed class GroupDetailViewModelTests
|
|||||||
var grades = new FakeGrades();
|
var grades = new FakeGrades();
|
||||||
|
|
||||||
var vm = new GroupDetailViewModel(groups, students, memberships, subjects, exams, grades, tasks,
|
var vm = new GroupDetailViewModel(groups, students, memberships, subjects, exams, grades, tasks,
|
||||||
new GroupOverviewViewModel(new FakeLessons(), exams, new FakeSessions([]), new FakeEntries(), students,
|
new GroupOverviewViewModel(new FakeLessons(), exams, new FakeResults(), new FakeSessions([]), new FakeEntries(), students,
|
||||||
new FakeDocumentation(), tasks, new AttendanceBalanceService(), new SchoolYearService()),
|
new FakeDocumentation(), tasks, groups, new AttendanceBalanceService(), new GradingService(), new SchoolYearService()),
|
||||||
new ParticipationTabViewModel(new FakeSessions([]), new FakeEntries(), new FakeAspects(),
|
new ParticipationTabViewModel(new FakeSessions([]), new FakeEntries(), new FakeAspects(),
|
||||||
students, memberships, groups, new FakeCompetencyDomains()),
|
students, memberships, groups, new FakeCompetencyDomains()),
|
||||||
new GradeOverviewTabViewModel(grades, exams, new FakeResults(), students, memberships, new GradingService()),
|
new GradeOverviewTabViewModel(grades, exams, new FakeResults(), students, memberships, new GradingService()),
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
using LehrerApp.Core.Models;
|
||||||
|
using LehrerApp.Desktop.ViewModels.Students;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace LehrerApp.Desktop.Tests;
|
||||||
|
|
||||||
|
public sealed class GroupDocumentationQuickViewModelTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void Save_ErstelltPlanungFuerGesamteLerngruppe()
|
||||||
|
{
|
||||||
|
var group = new GroupDocumentationOption(Guid.NewGuid(), "10c · WAT");
|
||||||
|
var vm = new GroupDocumentationQuickViewModel([group])
|
||||||
|
{
|
||||||
|
SelectedGroup = group, Title = "Klausur ankündigen", Content = "Termin abstimmen",
|
||||||
|
};
|
||||||
|
|
||||||
|
vm.SaveCommand.Execute(null);
|
||||||
|
|
||||||
|
Assert.NotNull(vm.Result);
|
||||||
|
Assert.Equal(Guid.Empty, vm.Result!.StudentId);
|
||||||
|
Assert.Equal(group.Id, vm.Result.GroupId);
|
||||||
|
Assert.Equal(DocumentationType.Planning, vm.Result.Type);
|
||||||
|
Assert.True(vm.Result.ExcludeFromWebUntisSync);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -36,6 +36,25 @@ public sealed class GroupDocumentationTabViewModelTests
|
|||||||
Assert.Equal("Alt", vm.Entries[1].Model.Title);
|
Assert.Equal("Alt", vm.Entries[1].Model.Title);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Initialize_ZeigtGruppenweitenEintragAuchOhneSchueler()
|
||||||
|
{
|
||||||
|
var group = new LearningGroup { Name = "10c" };
|
||||||
|
var docs = new FakeDocumentation();
|
||||||
|
docs.Add(new Documentation
|
||||||
|
{
|
||||||
|
StudentId = Guid.Empty, GroupId = group.Id, Type = DocumentationType.Planning,
|
||||||
|
Title = "Klausur planen", Date = new DateOnly(2026, 9, 1),
|
||||||
|
});
|
||||||
|
|
||||||
|
var vm = BuildVm([], [group], docs);
|
||||||
|
vm.Initialize(group.Id);
|
||||||
|
|
||||||
|
var entry = Assert.Single(vm.Entries);
|
||||||
|
Assert.Equal("Gesamte Lerngruppe", entry.StudentName);
|
||||||
|
Assert.Equal("Planung / Erinnerung", entry.TypeLabel);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Initialize_EintragAusAndererGruppe_WirdMitangezeigtAberAlsFremdMarkiert()
|
public void Initialize_EintragAusAndererGruppe_WirdMitangezeigtAberAlsFremdMarkiert()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,10 +12,13 @@ public sealed class GroupOverviewViewModelTests
|
|||||||
{
|
{
|
||||||
private static GroupOverviewViewModel NewVm(FakeLessons? lessons = null, FakeExams? exams = null,
|
private static GroupOverviewViewModel NewVm(FakeLessons? lessons = null, FakeExams? exams = null,
|
||||||
FakeSessions? sessions = null, FakeEntries? entries = null, FakeStudents? students = null,
|
FakeSessions? sessions = null, FakeEntries? entries = null, FakeStudents? students = null,
|
||||||
FakeDocumentation? documentation = null, FakeWorkTasks? tasks = null) =>
|
FakeDocumentation? documentation = null, FakeWorkTasks? tasks = null,
|
||||||
new(lessons ?? new FakeLessons(), exams ?? new FakeExams([]), sessions ?? new FakeSessions([]),
|
FakeResults? results = null, FakeGroups? groups = null) =>
|
||||||
|
new(lessons ?? new FakeLessons(), exams ?? new FakeExams([]), results ?? new FakeResults(),
|
||||||
|
sessions ?? new FakeSessions([]),
|
||||||
entries ?? new FakeEntries(), students ?? new FakeStudents([]), documentation ?? new FakeDocumentation(),
|
entries ?? new FakeEntries(), students ?? new FakeStudents([]), documentation ?? new FakeDocumentation(),
|
||||||
tasks ?? new FakeWorkTasks(), new AttendanceBalanceService(), new SchoolYearService());
|
tasks ?? new FakeWorkTasks(), groups ?? new FakeGroups([]), new AttendanceBalanceService(),
|
||||||
|
new GradingService(), new SchoolYearService());
|
||||||
|
|
||||||
private static (GroupOverviewViewModel Vm, FakeLessons Lessons, FakeExams Exams,
|
private static (GroupOverviewViewModel Vm, FakeLessons Lessons, FakeExams Exams,
|
||||||
FakeSessions Sessions, FakeEntries Entries, FakeStudents Students, Guid GroupId) BuildScenario(
|
FakeSessions Sessions, FakeEntries Entries, FakeStudents Students, Guid GroupId) BuildScenario(
|
||||||
@@ -76,6 +79,86 @@ public sealed class GroupOverviewViewModelTests
|
|||||||
Assert.Contains("Klassenarbeit 2", vm.NextExamLabel);
|
Assert.Contains("Klassenarbeit 2", vm.NextExamLabel);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Vorjahresvergleich_OhneVerknuepfteVorgaengergruppe_BleibtVerborgen()
|
||||||
|
{
|
||||||
|
var groupId = Guid.NewGuid();
|
||||||
|
var groups = new FakeGroups([new LearningGroup { Id = groupId, GradingSystem = GradingSystem.Grades1To6 }]);
|
||||||
|
var vm = NewVm(groups: groups);
|
||||||
|
|
||||||
|
vm.Initialize(groupId, "Testkurs");
|
||||||
|
|
||||||
|
Assert.False(vm.HasYearComparison);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Vorjahresvergleich_VorgaengerOhneKlausurergebnisse_BleibtVerborgen()
|
||||||
|
{
|
||||||
|
var previousId = Guid.NewGuid();
|
||||||
|
var groupId = Guid.NewGuid();
|
||||||
|
var groups = new FakeGroups([
|
||||||
|
new LearningGroup { Id = groupId, GradingSystem = GradingSystem.Grades1To6, PreviousGroupId = previousId },
|
||||||
|
new LearningGroup { Id = previousId, GradingSystem = GradingSystem.Grades1To6 },
|
||||||
|
]);
|
||||||
|
var vm = NewVm(groups: groups);
|
||||||
|
|
||||||
|
vm.Initialize(groupId, "Testkurs");
|
||||||
|
|
||||||
|
Assert.False(vm.HasYearComparison);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Vorjahresvergleich_ZeigtDurchschnitteBeiderJahreAn()
|
||||||
|
{
|
||||||
|
var previousId = Guid.NewGuid();
|
||||||
|
var groupId = Guid.NewGuid();
|
||||||
|
var groups = new FakeGroups([
|
||||||
|
new LearningGroup { Id = groupId, GradingSystem = GradingSystem.Grades1To6, PreviousGroupId = previousId },
|
||||||
|
new LearningGroup { Id = previousId, GradingSystem = GradingSystem.Grades1To6 },
|
||||||
|
]);
|
||||||
|
|
||||||
|
var previousExam = new Exam { Id = Guid.NewGuid(), GroupId = previousId };
|
||||||
|
var currentExam = new Exam { Id = Guid.NewGuid(), GroupId = groupId };
|
||||||
|
var exams = new FakeExams([previousExam, currentExam]);
|
||||||
|
|
||||||
|
var results = new FakeResults();
|
||||||
|
results.Add(new ExamResult { ExamId = previousExam.Id, StudentId = Guid.NewGuid(), Grade = "2" });
|
||||||
|
results.Add(new ExamResult { ExamId = previousExam.Id, StudentId = Guid.NewGuid(), Grade = "4" });
|
||||||
|
results.Add(new ExamResult { ExamId = currentExam.Id, StudentId = Guid.NewGuid(), Grade = "1" });
|
||||||
|
// Abwesende und leere Ergebnisse fließen bewusst nicht in den Schnitt ein.
|
||||||
|
results.Add(new ExamResult { ExamId = currentExam.Id, StudentId = Guid.NewGuid(), Absent = true, Grade = "6" });
|
||||||
|
|
||||||
|
var vm = NewVm(exams: exams, results: results, groups: groups);
|
||||||
|
|
||||||
|
vm.Initialize(groupId, "Testkurs");
|
||||||
|
|
||||||
|
Assert.True(vm.HasYearComparison);
|
||||||
|
Assert.Contains("Dieses Jahr: Ø 1,0 (1 Klausuren)", vm.YearComparisonLabel);
|
||||||
|
Assert.Contains("Vorjahr: Ø 3,0 (2 Klausuren)", vm.YearComparisonLabel);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Vorjahresvergleich_OhneEigeneKlausurenZeigtNurVorjahr()
|
||||||
|
{
|
||||||
|
var previousId = Guid.NewGuid();
|
||||||
|
var groupId = Guid.NewGuid();
|
||||||
|
var groups = new FakeGroups([
|
||||||
|
new LearningGroup { Id = groupId, GradingSystem = GradingSystem.Grades1To6, PreviousGroupId = previousId },
|
||||||
|
new LearningGroup { Id = previousId, GradingSystem = GradingSystem.Grades1To6 },
|
||||||
|
]);
|
||||||
|
var previousExam = new Exam { Id = Guid.NewGuid(), GroupId = previousId };
|
||||||
|
var exams = new FakeExams([previousExam]);
|
||||||
|
var results = new FakeResults();
|
||||||
|
results.Add(new ExamResult { ExamId = previousExam.Id, StudentId = Guid.NewGuid(), Grade = "3" });
|
||||||
|
|
||||||
|
var vm = NewVm(exams: exams, results: results, groups: groups);
|
||||||
|
|
||||||
|
vm.Initialize(groupId, "Testkurs");
|
||||||
|
|
||||||
|
Assert.True(vm.HasYearComparison);
|
||||||
|
Assert.Contains("noch keine Klausur", vm.YearComparisonLabel);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Mitarbeit_OhneSitzungenZeigtHinweisOhneWarnung()
|
public void Mitarbeit_OhneSitzungenZeigtHinweisOhneWarnung()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -0,0 +1,90 @@
|
|||||||
|
using LehrerApp.Core.Models;
|
||||||
|
using LehrerApp.Core.Services;
|
||||||
|
using LehrerApp.Desktop.ViewModels.Planning;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace LehrerApp.Desktop.Tests;
|
||||||
|
|
||||||
|
public sealed class LessonSchedulingServiceTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void UnitPicker_SchlaegtLaufendeEinheitVor_UndSpeichertNeuanlageNochNicht()
|
||||||
|
{
|
||||||
|
var groupId = Guid.NewGuid();
|
||||||
|
var planned = new Unit { GroupId = groupId, Title = "Später", Status = UnitStatus.Planned };
|
||||||
|
var active = new Unit { GroupId = groupId, Title = "Aktuell", Status = UnitStatus.Active };
|
||||||
|
var repo = new FakeUnits();
|
||||||
|
repo.Add(planned); repo.Add(active);
|
||||||
|
var vm = new TimetableUnitPickerViewModel(repo, groupId, "10c", new(2026, 9, 2), 3);
|
||||||
|
|
||||||
|
Assert.Equal(active.Id, vm.SelectedUnit?.Model.Id);
|
||||||
|
vm.NewUnitTitle = "Neue Reihe";
|
||||||
|
vm.SaveCommand.Execute(null);
|
||||||
|
|
||||||
|
Assert.True(vm.ResultIsNew);
|
||||||
|
Assert.Equal("Neue Reihe", vm.Result?.Title);
|
||||||
|
Assert.Null(repo.GetById(vm.Result!.Id));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Move_AendertDatumUndStunde_UndRuecktNurNichtDurchgefuehrteFolgestundenNach()
|
||||||
|
{
|
||||||
|
var unitId = Guid.NewGuid();
|
||||||
|
var groupId = Guid.NewGuid();
|
||||||
|
var moved = new Lesson { UnitId = unitId, GroupId = groupId, Date = new(2026, 9, 1), LessonNumber = 2 };
|
||||||
|
var draftFollowing = new Lesson { UnitId = unitId, GroupId = groupId, Date = new(2026, 9, 3), Status = LessonStatus.Draft };
|
||||||
|
var conducted = new Lesson { UnitId = unitId, GroupId = groupId, Date = new(2026, 9, 4), Status = LessonStatus.Conducted };
|
||||||
|
var repo = new FakeLessons();
|
||||||
|
repo.Add(moved); repo.Add(draftFollowing); repo.Add(conducted);
|
||||||
|
|
||||||
|
new LessonSchedulingService(repo).Move(moved, new(2026, 9, 8), 5, shiftFollowing: true);
|
||||||
|
|
||||||
|
Assert.Equal(new DateOnly(2026, 9, 8), moved.Date);
|
||||||
|
Assert.Equal(5, moved.LessonNumber);
|
||||||
|
Assert.Equal(new DateOnly(2026, 9, 10), draftFollowing.Date);
|
||||||
|
Assert.Equal(new DateOnly(2026, 9, 4), conducted.Date);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void SplitAndMoveSecondPart_TeiltAuchEineUeberDieGrenzeLaufendePhase()
|
||||||
|
{
|
||||||
|
var source = new Lesson
|
||||||
|
{
|
||||||
|
UnitId = Guid.NewGuid(), GroupId = Guid.NewGuid(), Date = new(2026, 9, 1),
|
||||||
|
LessonNumber = 3, Topic = "Fotosynthese", Homework = "Aufgabe 2",
|
||||||
|
Phases =
|
||||||
|
[
|
||||||
|
new LessonPhaseStep { Name = "Einstieg", DurationMinutes = 30 },
|
||||||
|
new LessonPhaseStep { Name = "Experiment", DurationMinutes = 30 },
|
||||||
|
new LessonPhaseStep { Name = "Sicherung", DurationMinutes = 20 },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
var repo = new FakeLessons();
|
||||||
|
repo.Add(source);
|
||||||
|
|
||||||
|
var continuation = new LessonSchedulingService(repo).SplitAndMoveSecondPart(source, 45,
|
||||||
|
new(2026, 9, 3), 6, new TimeOnly(12, 15));
|
||||||
|
|
||||||
|
Assert.Equal([30, 15], source.Phases.Select(p => p.DurationMinutes));
|
||||||
|
Assert.Equal([15, 20], continuation.Phases.Select(p => p.DurationMinutes));
|
||||||
|
Assert.Equal("Fotosynthese – Fortsetzung", continuation.Topic);
|
||||||
|
Assert.Null(source.Homework);
|
||||||
|
Assert.Equal("Aufgabe 2", continuation.Homework);
|
||||||
|
Assert.Equal(6, continuation.LessonNumber);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Move_LehntBelegtenZielterminAb()
|
||||||
|
{
|
||||||
|
var groupId = Guid.NewGuid();
|
||||||
|
var source = new Lesson { GroupId = groupId, Date = new(2026, 9, 1), LessonNumber = 1 };
|
||||||
|
var occupied = new Lesson { GroupId = groupId, Date = new(2026, 9, 2), LessonNumber = 4 };
|
||||||
|
var repo = new FakeLessons();
|
||||||
|
repo.Add(source); repo.Add(occupied);
|
||||||
|
|
||||||
|
var error = Assert.Throws<InvalidOperationException>(() =>
|
||||||
|
new LessonSchedulingService(repo).Move(source, occupied.Date, 4, false));
|
||||||
|
|
||||||
|
Assert.Contains("bereits eine Planung", error.Message);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -83,6 +83,67 @@ public sealed class QuickInputViewModelTests
|
|||||||
Assert.Equal(0.4, vm.CurrentStudentContentOpacity);
|
Assert.Equal(0.4, vm.CurrentStudentContentOpacity);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ClearRating_SetztVersehentlichGesetzteBewertungAufNichtBewertetZurueck()
|
||||||
|
{
|
||||||
|
var aspects = new List<AspectColumnDef>
|
||||||
|
{
|
||||||
|
new(new ParticipationAspect { Key = "quality", Label = "Qualität" }),
|
||||||
|
};
|
||||||
|
var rows = new List<ParticipationStudentRow>
|
||||||
|
{
|
||||||
|
new(Guid.NewGuid(), "Anna", new ParticipationEntry(), aspects, []),
|
||||||
|
};
|
||||||
|
var vm = new QuickInputViewModel(rows, aspects);
|
||||||
|
vm.SetRatingByNumber(4); // aus Versehen eine Note für Qualität vergeben
|
||||||
|
|
||||||
|
vm.ClearRating();
|
||||||
|
|
||||||
|
Assert.Null(vm.AspectRows[0].Value);
|
||||||
|
Assert.Null(rows[0].GetRating("quality"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void SetDayHighlight_SpeichertAufDerZeileUndAktualisiertDieAnzeige()
|
||||||
|
{
|
||||||
|
var aspects = new List<AspectColumnDef>
|
||||||
|
{
|
||||||
|
new(new ParticipationAspect { Key = "quality", Label = "Qualität" }),
|
||||||
|
};
|
||||||
|
var rows = new List<ParticipationStudentRow>
|
||||||
|
{
|
||||||
|
new(Guid.NewGuid(), "Anna", new ParticipationEntry(), aspects, []),
|
||||||
|
};
|
||||||
|
var vm = new QuickInputViewModel(rows, aspects);
|
||||||
|
|
||||||
|
vm.SetDayHighlight(DayHighlightKind.Standout);
|
||||||
|
|
||||||
|
Assert.Equal(DayHighlightKind.Standout, rows[0].DayHighlight);
|
||||||
|
Assert.Equal("👑", vm.CurrentStudentDayHighlightSymbol);
|
||||||
|
Assert.Equal("Spitzentag", vm.CurrentStudentDayHighlightLabel);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ZurueckZuVorherigemSchueler_ZeigtDessenTagesflaggeWiederAn()
|
||||||
|
{
|
||||||
|
var aspects = new List<AspectColumnDef>
|
||||||
|
{
|
||||||
|
new(new ParticipationAspect { Key = "quality", Label = "Qualität" }),
|
||||||
|
};
|
||||||
|
var rows = new List<ParticipationStudentRow>
|
||||||
|
{
|
||||||
|
new(Guid.NewGuid(), "Anna", new ParticipationEntry(), aspects, []),
|
||||||
|
new(Guid.NewGuid(), "Ben", new ParticipationEntry(), aspects, []),
|
||||||
|
};
|
||||||
|
var vm = new QuickInputViewModel(rows, aspects);
|
||||||
|
|
||||||
|
vm.SetDayHighlight(DayHighlightKind.Sleepy); // Anna
|
||||||
|
vm.NextStudent(); // zu Ben, keine Flagge
|
||||||
|
vm.PreviousStudent(); // zurück zu Anna
|
||||||
|
|
||||||
|
Assert.Equal(DayHighlightKind.Sleepy, vm.CurrentStudentDayHighlight);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void VorherigerAspekt_SpringtRueckwaertsMitUmlauf()
|
public void VorherigerAspekt_SpringtRueckwaertsMitUmlauf()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -0,0 +1,139 @@
|
|||||||
|
using LehrerApp.Core.Models;
|
||||||
|
using LehrerApp.Desktop.ViewModels.Planning;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace LehrerApp.Desktop.Tests;
|
||||||
|
|
||||||
|
public sealed class SchoolDayWeatherSummaryTests
|
||||||
|
{
|
||||||
|
private static readonly DateOnly Day = new(2026, 8, 24);
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void From_GewitterHatVorrangVorSturmUndRegen()
|
||||||
|
{
|
||||||
|
var snapshot = Snapshot(
|
||||||
|
Hour(8, code: 61, rain: 2, gust: 70),
|
||||||
|
Hour(10, code: 95, rain: 1, gust: 80));
|
||||||
|
|
||||||
|
var summary = SchoolDayWeatherSummary.From(snapshot, Day);
|
||||||
|
|
||||||
|
Assert.Equal(SchoolDayWeatherKind.Thunderstorm, summary!.Kind);
|
||||||
|
Assert.Equal("⛈️", summary.Symbol);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void From_BoeAbBeaufortAcht_WirdAlsSturmAngezeigt()
|
||||||
|
{
|
||||||
|
var summary = SchoolDayWeatherSummary.From(Snapshot(Hour(10, gust: 62)), Day);
|
||||||
|
|
||||||
|
Assert.Equal(SchoolDayWeatherKind.Storm, summary!.Kind);
|
||||||
|
Assert.Equal("💨", summary.Symbol);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void From_NaechtlicheBoeBeeinflusstSchultagNicht()
|
||||||
|
{
|
||||||
|
var snapshot = Snapshot(Hour(10, cloud: 10), Hour(23, gust: 90));
|
||||||
|
|
||||||
|
var summary = SchoolDayWeatherSummary.From(snapshot, Day);
|
||||||
|
|
||||||
|
Assert.Equal(20, summary!.MaxWindGustKmh);
|
||||||
|
Assert.Equal(SchoolDayWeatherKind.Sunny, summary!.Kind);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void From_VerwendetFuerKennzahlenNurAchtBisSechzehnUhr()
|
||||||
|
{
|
||||||
|
var snapshot = Snapshot(
|
||||||
|
Hour(7, temperature: 5, rain: 3, probability: 95, sunshineMinutes: 0),
|
||||||
|
Hour(8, temperature: 12, probability: 20, sunshineMinutes: 30),
|
||||||
|
Hour(16, temperature: 17, probability: 65, sunshineMinutes: 45),
|
||||||
|
Hour(17, temperature: 25, rain: 4, probability: 99, sunshineMinutes: 60));
|
||||||
|
|
||||||
|
var summary = SchoolDayWeatherSummary.From(snapshot, Day);
|
||||||
|
|
||||||
|
Assert.Equal(12, summary!.MinTemperatureC);
|
||||||
|
Assert.Equal(17, summary.MaxTemperatureC);
|
||||||
|
Assert.Equal(0, summary.PrecipitationMm);
|
||||||
|
Assert.Equal(65, summary.MaxHourlyPrecipitationProbabilityPercent);
|
||||||
|
Assert.Equal(1.3, summary.SunshineDurationHours);
|
||||||
|
Assert.Contains("08–16 Uhr", summary.Tooltip);
|
||||||
|
Assert.Contains("Regenwahrscheinlichkeit: bis 65 % (stündlich)", summary.Tooltip);
|
||||||
|
Assert.Contains($"Sonnenschein: {summary.SunshineDurationHours:0.#} h", summary.Tooltip);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void From_AmtlicheGewitterwarnung_PraegtSymbolUndTooltip()
|
||||||
|
{
|
||||||
|
var snapshot = Snapshot(Hour(10, cloud: 20));
|
||||||
|
snapshot.Warnings.Add(new WeatherWarning
|
||||||
|
{
|
||||||
|
Identifier = "w1", Event = "GEWITTER", Headline = "Amtliche Warnung vor Gewitter",
|
||||||
|
Instruction = "Aufenthalt im Freien vermeiden.", Severity = "Severe",
|
||||||
|
Onset = Day.ToDateTime(new TimeOnly(12, 0)), Expires = Day.ToDateTime(new TimeOnly(16, 0)),
|
||||||
|
});
|
||||||
|
|
||||||
|
var summary = SchoolDayWeatherSummary.From(snapshot, Day);
|
||||||
|
|
||||||
|
Assert.Equal(SchoolDayWeatherKind.Thunderstorm, summary!.Kind);
|
||||||
|
Assert.Contains("Amtliche Warnung", summary.Tooltip);
|
||||||
|
Assert.Contains("Aufenthalt im Freien", summary.Tooltip);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void From_RegenSummiertStundenUndZeigtTemperaturspanne()
|
||||||
|
{
|
||||||
|
var snapshot = Snapshot(
|
||||||
|
Hour(8, temperature: 12, rain: 0.1),
|
||||||
|
Hour(12, temperature: 17, rain: 0.4));
|
||||||
|
|
||||||
|
var summary = SchoolDayWeatherSummary.From(snapshot, Day);
|
||||||
|
|
||||||
|
Assert.Equal(SchoolDayWeatherKind.Rain, summary!.Kind);
|
||||||
|
Assert.Equal(0.5, summary.PrecipitationMm);
|
||||||
|
Assert.Contains("12–17 °C", summary.Tooltip);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void WarningTouchesDate_MehrtaegigeVorwarnung_TrifftAlleUeberlapptenTage()
|
||||||
|
{
|
||||||
|
var warning = new WeatherWarning
|
||||||
|
{
|
||||||
|
Onset = Day.ToDateTime(new TimeOnly(18, 0)),
|
||||||
|
Expires = Day.AddDays(2).ToDateTime(new TimeOnly(8, 0)),
|
||||||
|
};
|
||||||
|
|
||||||
|
Assert.True(SchoolDayWeatherSummary.WarningTouchesDate(warning, Day.AddDays(1)));
|
||||||
|
Assert.False(SchoolDayWeatherSummary.WarningTouchesDate(warning, Day.AddDays(3)));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void WeekCellItem_WetterOderWarnung_AktiviertGemeinsameHoverFlaeche()
|
||||||
|
{
|
||||||
|
var cell = WeekCellItem.WeekdayHeader(DayOfWeek.Monday, Day, false);
|
||||||
|
Assert.False(cell.HasWeatherInformation);
|
||||||
|
|
||||||
|
cell.SetWeather(SchoolDayWeatherSummary.From(Snapshot(Hour(10)), Day), []);
|
||||||
|
Assert.True(cell.HasWeatherInformation);
|
||||||
|
|
||||||
|
cell.SetWeather(null, [new WeatherWarning { Headline = "Amtliche Warnung" }]);
|
||||||
|
Assert.True(cell.HasWeatherInformation);
|
||||||
|
Assert.Contains("Amtliche Warnung", cell.WeatherTooltip);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static WeatherSnapshot Snapshot(params WeatherForecastHour[] hours) => new()
|
||||||
|
{
|
||||||
|
RetrievedAt = DateTime.UtcNow,
|
||||||
|
Forecast = hours.ToList(),
|
||||||
|
};
|
||||||
|
|
||||||
|
private static WeatherForecastHour Hour(int hour, int? code = 0, double? temperature = 20,
|
||||||
|
double? rain = 0, double? gust = 20, double? cloud = 20,
|
||||||
|
double? probability = null, double? sunshineMinutes = null) => new()
|
||||||
|
{
|
||||||
|
ValidAt = Day.ToDateTime(new TimeOnly(hour, 0)), WeatherCode = code,
|
||||||
|
TemperatureC = temperature, PrecipitationMm = rain, WindGustKmh = gust,
|
||||||
|
CloudCoverPercent = cloud, PrecipitationProbabilityPercent = probability,
|
||||||
|
SunshineDurationMinutes = sunshineMinutes,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -472,6 +472,7 @@ public sealed class SeatingPlanViewModelTests
|
|||||||
vm.SetRatingByNumber(5);
|
vm.SetRatingByNumber(5);
|
||||||
vm.ApplyAttendanceShortcut(1, clear: false);
|
vm.ApplyAttendanceShortcut(1, clear: false);
|
||||||
vm.ApplyHomeworkShortcut(7, clear: false);
|
vm.ApplyHomeworkShortcut(7, clear: false);
|
||||||
|
vm.ApplyDayHighlightShortcut(1, clear: false);
|
||||||
|
|
||||||
var session = Assert.Single(sessions.GetByGroup(groupId));
|
var session = Assert.Single(sessions.GetByGroup(groupId));
|
||||||
Assert.Equal(DateOnly.FromDateTime(DateTime.Today), session.Date);
|
Assert.Equal(DateOnly.FromDateTime(DateTime.Today), session.Date);
|
||||||
@@ -481,6 +482,56 @@ public sealed class SeatingPlanViewModelTests
|
|||||||
Assert.Equal(AttendanceStatus.Present, entry.Attendance);
|
Assert.Equal(AttendanceStatus.Present, entry.Attendance);
|
||||||
Assert.Equal(HomeworkStatus.MissingOpen, entry.Homework);
|
Assert.Equal(HomeworkStatus.MissingOpen, entry.Homework);
|
||||||
Assert.True(entry.HomeworkMissing);
|
Assert.True(entry.HomeworkMissing);
|
||||||
|
Assert.Equal(DayHighlightKind.Standout, entry.DayHighlight);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void SitzplatzBewertung_TagesflaggeKannWiederGeloeschtWerden()
|
||||||
|
{
|
||||||
|
var groupId = Guid.NewGuid();
|
||||||
|
var studentId = Guid.NewGuid();
|
||||||
|
var sessions = new FakeSessions([]);
|
||||||
|
var entries = new FakeEntries();
|
||||||
|
var vm = new SeatAssessmentViewModel(sessions, entries, new FakeAspects(),
|
||||||
|
groupId, studentId, "Beispiel, Anna", canEdit: true);
|
||||||
|
|
||||||
|
vm.ApplyDayHighlightShortcut(3, clear: false); // aus Versehen "Schlechter Tag" gesetzt
|
||||||
|
vm.ApplyDayHighlightShortcut(null, clear: true);
|
||||||
|
|
||||||
|
var session = Assert.Single(sessions.GetByGroup(groupId));
|
||||||
|
var entry = entries.GetBySessionAndStudent(session.Id, studentId)!;
|
||||||
|
Assert.Null(entry.DayHighlight);
|
||||||
|
Assert.Equal("Keine Markierung", vm.DayHighlightLabel);
|
||||||
|
Assert.All(vm.DayHighlightChoices, c => Assert.Equal(c.Kind is null, c.IsSelected));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void SitzplatzBewertung_SchlaegtQuantitaetAusStrichlisteVor()
|
||||||
|
{
|
||||||
|
var groupId = Guid.NewGuid();
|
||||||
|
var studentId = Guid.NewGuid();
|
||||||
|
var today = DateOnly.FromDateTime(DateTime.Today);
|
||||||
|
var session = new ParticipationSession { GroupId = groupId, Date = today };
|
||||||
|
var sessions = new FakeSessions([session]);
|
||||||
|
var entries = new FakeEntries();
|
||||||
|
entries.Add(new ParticipationEntry { SessionId = session.Id, StudentId = studentId, RaisedHandCount = 3 });
|
||||||
|
|
||||||
|
var vm = new SeatAssessmentViewModel(sessions, entries, new FakeAspects(),
|
||||||
|
groupId, studentId, "Beispiel, Anna", canEdit: true);
|
||||||
|
|
||||||
|
var quantityRow = vm.AspectRows.Single(r => r.Key == "quantity");
|
||||||
|
Assert.True(quantityRow.HasSuggestion);
|
||||||
|
Assert.Equal(0, quantityRow.SuggestedValue); // 3 gemeldet -> "∼" laut ParticipationCountSuggestion
|
||||||
|
Assert.Contains("3× gemeldet", quantityRow.SuggestionLabel);
|
||||||
|
|
||||||
|
var qualityRow = vm.AspectRows.Single(r => r.Key == "quality");
|
||||||
|
Assert.False(qualityRow.HasSuggestion);
|
||||||
|
|
||||||
|
quantityRow.ApplySuggestionCommand.Execute(null);
|
||||||
|
|
||||||
|
Assert.Equal(0, quantityRow.Value);
|
||||||
|
var updatedEntry = entries.GetBySessionAndStudent(session.Id, studentId)!;
|
||||||
|
Assert.Equal(0, updatedEntry.Ratings.Single(r => r.Key == "quantity").Value);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
@@ -517,6 +568,60 @@ public sealed class SeatingPlanViewModelTests
|
|||||||
Assert.Null(vm.SelectedSession);
|
Assert.Null(vm.SelectedSession);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void TallyRaisedHand_ErhoehtNurGemeldetZaehler()
|
||||||
|
{
|
||||||
|
var groupId = Guid.NewGuid();
|
||||||
|
var student = new Student { FirstName = "Anna", LastName = "A" };
|
||||||
|
var plan = new SeatingPlan
|
||||||
|
{
|
||||||
|
GroupId = groupId, Name = "Standard", Rows = 1, Columns = 1,
|
||||||
|
Assignments = [new SeatAssignment { StudentId = student.Id }],
|
||||||
|
};
|
||||||
|
var sessions = new FakeSessions([]);
|
||||||
|
var entries = new FakeEntries();
|
||||||
|
var vm = new SeatingPlanTabViewModel(new FakeSeatingPlans([plan]), new FakeStudents([student]),
|
||||||
|
new FakeMemberships([new GroupMembership { GroupId = groupId, StudentId = student.Id }]),
|
||||||
|
sessions, entries, new FakeAspects());
|
||||||
|
vm.Initialize(groupId, isReadOnly: false);
|
||||||
|
var seat = vm.Seats.Single();
|
||||||
|
|
||||||
|
seat.TallyRaisedHandCommand.Execute(null);
|
||||||
|
seat.TallyRaisedHandCommand.Execute(null);
|
||||||
|
|
||||||
|
Assert.Equal(2, seat.RaisedHandCount);
|
||||||
|
Assert.Equal(0, seat.CalledOnCount);
|
||||||
|
var session = Assert.Single(sessions.GetByGroup(groupId));
|
||||||
|
var entry = entries.GetBySessionAndStudent(session.Id, student.Id)!;
|
||||||
|
Assert.Equal(2, entry.RaisedHandCount);
|
||||||
|
Assert.Equal(0, entry.CalledOnCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void TallyCalledOn_ErhoehtBeideZaehler()
|
||||||
|
{
|
||||||
|
var groupId = Guid.NewGuid();
|
||||||
|
var student = new Student { FirstName = "Anna", LastName = "A" };
|
||||||
|
var plan = new SeatingPlan
|
||||||
|
{
|
||||||
|
GroupId = groupId, Name = "Standard", Rows = 1, Columns = 1,
|
||||||
|
Assignments = [new SeatAssignment { StudentId = student.Id }],
|
||||||
|
};
|
||||||
|
var sessions = new FakeSessions([]);
|
||||||
|
var entries = new FakeEntries();
|
||||||
|
var vm = new SeatingPlanTabViewModel(new FakeSeatingPlans([plan]), new FakeStudents([student]),
|
||||||
|
new FakeMemberships([new GroupMembership { GroupId = groupId, StudentId = student.Id }]),
|
||||||
|
sessions, entries, new FakeAspects());
|
||||||
|
vm.Initialize(groupId, isReadOnly: false);
|
||||||
|
var seat = vm.Seats.Single();
|
||||||
|
|
||||||
|
seat.TallyRaisedHandCommand.Execute(null); // erste Meldung, nicht drangekommen
|
||||||
|
seat.TallyCalledOnCommand.Execute(null); // zweite Meldung, drangekommen
|
||||||
|
|
||||||
|
Assert.Equal(2, seat.RaisedHandCount);
|
||||||
|
Assert.Equal(1, seat.CalledOnCount);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task AssessStudent_LegtErstBeiTatsaechlicherBewertungEineSitzungAn()
|
public async Task AssessStudent_LegtErstBeiTatsaechlicherBewertungEineSitzungAn()
|
||||||
{
|
{
|
||||||
@@ -543,6 +648,50 @@ public sealed class SeatingPlanViewModelTests
|
|||||||
Assert.Equal(session.Id, vm.SelectedSession?.Id);
|
Assert.Equal(session.Id, vm.SelectedSession?.Id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task AssessStudent_SitzungBereitsUeberAndereInstanzVerknuepft_LegtKeineZweiteAn()
|
||||||
|
{
|
||||||
|
// Regressionstest: Der Unterrichtsmodus läuft auf einer eigenen, per DI frisch aufgelösten
|
||||||
|
// SeatingPlanTabViewModel-Instanz (TimetableView.ShowTeachingMode), getrennt von der
|
||||||
|
// Instanz im Sitzplan-Tab der Kursübersicht — beide teilen sich nur das Repository. Wurde
|
||||||
|
// (wie hier simuliert) die Sitzung bereits über den Unterrichtsmodus mit der Stunde
|
||||||
|
// verknüpft, bevor im echten Sitzplan-Tab bewertet wird, darf letzterer keine zweite,
|
||||||
|
// unverknüpfte "Sitzplan"-Sitzung für denselben Tag anlegen.
|
||||||
|
var groupId = Guid.NewGuid();
|
||||||
|
var student = new Student { FirstName = "Anna", LastName = "A" };
|
||||||
|
var plan = new SeatingPlan
|
||||||
|
{
|
||||||
|
GroupId = groupId, Name = "Standard", Rows = 1, Columns = 1,
|
||||||
|
Assignments = [new SeatAssignment { StudentId = student.Id }],
|
||||||
|
};
|
||||||
|
var sessions = new FakeSessions([]);
|
||||||
|
var memberships = new FakeMemberships([new GroupMembership { GroupId = groupId, StudentId = student.Id }]);
|
||||||
|
|
||||||
|
// Instanz A: Sitzplan-Tab der Kursübersicht, bereits initialisiert (kein SelectedSession).
|
||||||
|
var tabVm = new SeatingPlanTabViewModel(new FakeSeatingPlans([plan]), new FakeStudents([student]),
|
||||||
|
memberships, sessions, new FakeEntries(), new FakeAspects());
|
||||||
|
tabVm.Initialize(groupId, isReadOnly: false);
|
||||||
|
tabVm.OnAssessStudent = _ => Task.CompletedTask;
|
||||||
|
|
||||||
|
// Instanz B: eigene, frische Instanz wie im Unterrichtsmodus — verknüpft die Sitzung mit
|
||||||
|
// der Stunde, ohne dass Instanz A davon erfährt.
|
||||||
|
var lesson = new Lesson { GroupId = groupId, Date = DateOnly.FromDateTime(DateTime.Today), Topic = "Redox" };
|
||||||
|
var teachingModeVm = new SeatingPlanTabViewModel(new FakeSeatingPlans([plan]), new FakeStudents([student]),
|
||||||
|
memberships, sessions, new FakeEntries(), new FakeAspects());
|
||||||
|
teachingModeVm.Initialize(groupId, isReadOnly: false);
|
||||||
|
teachingModeVm.SelectOrCreateSessionForLesson(lesson);
|
||||||
|
|
||||||
|
var linked = Assert.Single(sessions.GetByGroup(groupId));
|
||||||
|
Assert.Equal(lesson.Id, linked.LessonId);
|
||||||
|
|
||||||
|
await tabVm.AssessStudent(tabVm.Seats.Single());
|
||||||
|
|
||||||
|
var session = Assert.Single(sessions.GetByGroup(groupId));
|
||||||
|
Assert.Equal(linked.Id, session.Id);
|
||||||
|
Assert.Equal(lesson.Id, session.LessonId);
|
||||||
|
Assert.Equal(session.Id, tabVm.SelectedSession?.Id);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void ShuffleSeats_ErzeugtNeuenPlanMitVertauschtenPlaetzenUndBehaeltDenUrsprungsplan()
|
public void ShuffleSeats_ErzeugtNeuenPlanMitVertauschtenPlaetzenUndBehaeltDenUrsprungsplan()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ using LehrerApp.Sync;
|
|||||||
using LehrerApp.Sync.Models;
|
using LehrerApp.Sync.Models;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
using Xunit;
|
using Xunit;
|
||||||
|
using LehrerApp.Templating;
|
||||||
|
|
||||||
namespace LehrerApp.Desktop.Tests;
|
namespace LehrerApp.Desktop.Tests;
|
||||||
|
|
||||||
@@ -28,12 +29,13 @@ public sealed class SettingsViewModelTests
|
|||||||
return new SettingsViewModel(
|
return new SettingsViewModel(
|
||||||
subjects ?? new FakeSubjects([]), competencyDomains ?? new FakeCompetencyDomains(), new FakeGradingKeyTemplates(),
|
subjects ?? new FakeSubjects([]), competencyDomains ?? new FakeCompetencyDomains(), new FakeGradingKeyTemplates(),
|
||||||
new FakeSchemes(), new GradingService(), new BackupService(tempPath),
|
new FakeSchemes(), new GradingService(), new BackupService(tempPath),
|
||||||
|
new BackupSettingsService(tempPath),
|
||||||
new DatabaseEncryptionService(), new AppLockService(tempPath),
|
new DatabaseEncryptionService(), new AppLockService(tempPath),
|
||||||
new LiteDbContext(new MemoryStream()), new PrivacySettingsService(tempPath),
|
new LiteDbContext(new MemoryStream()), new PrivacySettingsService(tempPath),
|
||||||
new FakeDocumentation(), new FakeStudents([]), new FakeShorthandCodes([]),
|
new FakeDocumentation(), new FakeStudents([]), new FakeShorthandCodes([]),
|
||||||
holidays ?? new FakeSchoolHolidays(), new SchoolCalendarSettingsService(tempPath),
|
holidays ?? new FakeSchoolHolidays(), new SchoolCalendarSettingsService(tempPath),
|
||||||
new PeriodScheduleService(tempPath), supervisionDuties ?? new FakeSupervisionDuties(),
|
new PeriodScheduleService(tempPath), supervisionDuties ?? new FakeSupervisionDuties(),
|
||||||
new LetterTemplateService(tempPath), TestSupport.BuildAiSettingsService(), TestSupport.BuildAiPlanningService(),
|
new TemplateStore(tempPath), TestSupport.BuildAiSettingsService(), TestSupport.BuildAiPlanningService(),
|
||||||
TestSupport.BuildWebUntisSettingsService(),
|
TestSupport.BuildWebUntisSettingsService(),
|
||||||
TestSupport.BuildAnnualPlanSettingsService(),
|
TestSupport.BuildAnnualPlanSettingsService(),
|
||||||
TestSupport.BuildSyncSettingsService(), TestSupport.BuildSyncAuthService(),
|
TestSupport.BuildSyncSettingsService(), TestSupport.BuildSyncAuthService(),
|
||||||
@@ -314,11 +316,12 @@ public sealed class SettingsViewModelTests
|
|||||||
var vm = new SettingsViewModel(
|
var vm = new SettingsViewModel(
|
||||||
new FakeSubjects([]), new FakeCompetencyDomains(), new FakeGradingKeyTemplates(),
|
new FakeSubjects([]), new FakeCompetencyDomains(), new FakeGradingKeyTemplates(),
|
||||||
new FakeSchemes(), new GradingService(), new BackupService(tempPath),
|
new FakeSchemes(), new GradingService(), new BackupService(tempPath),
|
||||||
|
new BackupSettingsService(tempPath),
|
||||||
new DatabaseEncryptionService(), new AppLockService(tempPath),
|
new DatabaseEncryptionService(), new AppLockService(tempPath),
|
||||||
new LiteDbContext(new MemoryStream()), new PrivacySettingsService(tempPath),
|
new LiteDbContext(new MemoryStream()), new PrivacySettingsService(tempPath),
|
||||||
new FakeDocumentation(), new FakeStudents([]), new FakeShorthandCodes([]),
|
new FakeDocumentation(), new FakeStudents([]), new FakeShorthandCodes([]),
|
||||||
new FakeSchoolHolidays(), calendarSettings, new PeriodScheduleService(tempPath),
|
new FakeSchoolHolidays(), calendarSettings, new PeriodScheduleService(tempPath),
|
||||||
new FakeSupervisionDuties(), new LetterTemplateService(tempPath), TestSupport.BuildAiSettingsService(), TestSupport.BuildAiPlanningService(),
|
new FakeSupervisionDuties(), new TemplateStore(tempPath), TestSupport.BuildAiSettingsService(), TestSupport.BuildAiPlanningService(),
|
||||||
TestSupport.BuildWebUntisSettingsService(),
|
TestSupport.BuildWebUntisSettingsService(),
|
||||||
TestSupport.BuildAnnualPlanSettingsService(),
|
TestSupport.BuildAnnualPlanSettingsService(),
|
||||||
TestSupport.BuildSyncSettingsService(), TestSupport.BuildSyncAuthService(), TestSupport.BuildEventQueue(),
|
TestSupport.BuildSyncSettingsService(), TestSupport.BuildSyncAuthService(), TestSupport.BuildEventQueue(),
|
||||||
@@ -341,11 +344,12 @@ public sealed class SettingsViewModelTests
|
|||||||
var vm = new SettingsViewModel(
|
var vm = new SettingsViewModel(
|
||||||
new FakeSubjects([]), new FakeCompetencyDomains(), new FakeGradingKeyTemplates(),
|
new FakeSubjects([]), new FakeCompetencyDomains(), new FakeGradingKeyTemplates(),
|
||||||
new FakeSchemes(), new GradingService(), new BackupService(tempPath),
|
new FakeSchemes(), new GradingService(), new BackupService(tempPath),
|
||||||
|
new BackupSettingsService(tempPath),
|
||||||
new DatabaseEncryptionService(), new AppLockService(tempPath),
|
new DatabaseEncryptionService(), new AppLockService(tempPath),
|
||||||
new LiteDbContext(new MemoryStream()), new PrivacySettingsService(tempPath),
|
new LiteDbContext(new MemoryStream()), new PrivacySettingsService(tempPath),
|
||||||
new FakeDocumentation(), new FakeStudents([]), new FakeShorthandCodes([]),
|
new FakeDocumentation(), new FakeStudents([]), new FakeShorthandCodes([]),
|
||||||
new FakeSchoolHolidays(), new SchoolCalendarSettingsService(tempPath), periodSchedule,
|
new FakeSchoolHolidays(), new SchoolCalendarSettingsService(tempPath), periodSchedule,
|
||||||
new FakeSupervisionDuties(), new LetterTemplateService(tempPath), TestSupport.BuildAiSettingsService(), TestSupport.BuildAiPlanningService(),
|
new FakeSupervisionDuties(), new TemplateStore(tempPath), TestSupport.BuildAiSettingsService(), TestSupport.BuildAiPlanningService(),
|
||||||
TestSupport.BuildWebUntisSettingsService(),
|
TestSupport.BuildWebUntisSettingsService(),
|
||||||
TestSupport.BuildAnnualPlanSettingsService(),
|
TestSupport.BuildAnnualPlanSettingsService(),
|
||||||
TestSupport.BuildSyncSettingsService(), TestSupport.BuildSyncAuthService(), TestSupport.BuildEventQueue(),
|
TestSupport.BuildSyncSettingsService(), TestSupport.BuildSyncAuthService(), TestSupport.BuildEventQueue(),
|
||||||
@@ -372,11 +376,12 @@ public sealed class SettingsViewModelTests
|
|||||||
var vm = new SettingsViewModel(
|
var vm = new SettingsViewModel(
|
||||||
new FakeSubjects([]), new FakeCompetencyDomains(), new FakeGradingKeyTemplates(),
|
new FakeSubjects([]), new FakeCompetencyDomains(), new FakeGradingKeyTemplates(),
|
||||||
new FakeSchemes(), new GradingService(), new BackupService(tempPath),
|
new FakeSchemes(), new GradingService(), new BackupService(tempPath),
|
||||||
|
new BackupSettingsService(tempPath),
|
||||||
new DatabaseEncryptionService(), new AppLockService(tempPath),
|
new DatabaseEncryptionService(), new AppLockService(tempPath),
|
||||||
new LiteDbContext(new MemoryStream()), new PrivacySettingsService(tempPath),
|
new LiteDbContext(new MemoryStream()), new PrivacySettingsService(tempPath),
|
||||||
new FakeDocumentation(), new FakeStudents([]), new FakeShorthandCodes([]),
|
new FakeDocumentation(), new FakeStudents([]), new FakeShorthandCodes([]),
|
||||||
new FakeSchoolHolidays(), new SchoolCalendarSettingsService(tempPath), periodSchedule,
|
new FakeSchoolHolidays(), new SchoolCalendarSettingsService(tempPath), periodSchedule,
|
||||||
new FakeSupervisionDuties(), new LetterTemplateService(tempPath), TestSupport.BuildAiSettingsService(), TestSupport.BuildAiPlanningService(),
|
new FakeSupervisionDuties(), new TemplateStore(tempPath), TestSupport.BuildAiSettingsService(), TestSupport.BuildAiPlanningService(),
|
||||||
TestSupport.BuildWebUntisSettingsService(),
|
TestSupport.BuildWebUntisSettingsService(),
|
||||||
TestSupport.BuildAnnualPlanSettingsService(),
|
TestSupport.BuildAnnualPlanSettingsService(),
|
||||||
TestSupport.BuildSyncSettingsService(), TestSupport.BuildSyncAuthService(), TestSupport.BuildEventQueue(),
|
TestSupport.BuildSyncSettingsService(), TestSupport.BuildSyncAuthService(), TestSupport.BuildEventQueue(),
|
||||||
|
|||||||
@@ -51,6 +51,18 @@ public class StudentListViewModelTests
|
|||||||
Assert.Equal("M", vm.Students.Single(s => s.Id == _ben.Id).AvatarLabel);
|
Assert.Equal("M", vm.Students.Single(s => s.Id == _ben.Id).AvatarLabel);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void SucheOhneTreffer_LiefertHilfreichenLeerzustand()
|
||||||
|
{
|
||||||
|
var vm = BuildViewModel();
|
||||||
|
|
||||||
|
vm.SearchText = "nicht vorhanden";
|
||||||
|
|
||||||
|
Assert.True(vm.HasNoStudents);
|
||||||
|
Assert.False(vm.HasStudents);
|
||||||
|
Assert.Contains("Suche", vm.EmptyListMessage);
|
||||||
|
}
|
||||||
|
|
||||||
private StudentListViewModel BuildViewModel() => new(
|
private StudentListViewModel BuildViewModel() => new(
|
||||||
new FakeStudents([_anna, _ben]),
|
new FakeStudents([_anna, _ben]),
|
||||||
new FakeGroups([_group]),
|
new FakeGroups([_group]),
|
||||||
|
|||||||
@@ -0,0 +1,146 @@
|
|||||||
|
using LehrerApp.Core.Models;
|
||||||
|
using LehrerApp.Core.Services;
|
||||||
|
using LehrerApp.Desktop.ViewModels.Groups;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace LehrerApp.Desktop.Tests;
|
||||||
|
|
||||||
|
/// Tests für den Schüler-Leistungsüberblick (Nutzer-Feedback): Klausuren/Mitarbeit/Sonstige
|
||||||
|
/// im Überblick, berechnete Zeugnisnote, Zielnoten- und Was-wäre-wenn-Rechner sowie der
|
||||||
|
/// Bewerter-/Schülermodus-Unterschied (Kursdurchschnitt, Bearbeitbarkeit).
|
||||||
|
public sealed class StudentPerformanceOverviewViewModelTests
|
||||||
|
{
|
||||||
|
private static readonly Guid GroupId = Guid.NewGuid();
|
||||||
|
private static readonly Guid StudentId = Guid.NewGuid();
|
||||||
|
private static readonly Guid OtherStudentId = Guid.NewGuid();
|
||||||
|
|
||||||
|
private static StudentPerformanceOverviewViewModel BuildViewModel(
|
||||||
|
FakeExams? exams = null, FakeResults? results = null, FakeGrades? grades = null,
|
||||||
|
FakeSchemes? schemes = null, GradingSystem system = GradingSystem.Grades1To6)
|
||||||
|
{
|
||||||
|
var scheme = new GradingScheme { ExamsPercent = 50, ParticipationPercent = 40, OtherPercent = 10 };
|
||||||
|
var schemesRepo = schemes ?? new FakeSchemes();
|
||||||
|
if (schemes is null) schemesRepo.SetForGroup(GroupId, scheme);
|
||||||
|
|
||||||
|
return new StudentPerformanceOverviewViewModel(
|
||||||
|
grades ?? new FakeGrades(), exams ?? new FakeExams([]), results ?? new FakeResults(),
|
||||||
|
new FakeMemberships([]), schemesRepo, new GradingService(),
|
||||||
|
GroupId, StudentId, GroupType.Class, system, "Beispiel, Anna", "Testkurs", "2025/26");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ExamRows_ZeigtEigeneNoteUndKursdurchschnitt()
|
||||||
|
{
|
||||||
|
var exam = new Exam { Id = Guid.NewGuid(), GroupId = GroupId, Title = "K1", Date = new DateOnly(2025, 9, 10) };
|
||||||
|
var exams = new FakeExams([exam]);
|
||||||
|
var results = new FakeResults();
|
||||||
|
results.Add(new ExamResult { ExamId = exam.Id, StudentId = StudentId, Grade = "2" });
|
||||||
|
results.Add(new ExamResult { ExamId = exam.Id, StudentId = OtherStudentId, Grade = "4" });
|
||||||
|
|
||||||
|
var vm = BuildViewModel(exams, results);
|
||||||
|
|
||||||
|
var row = Assert.Single(vm.ExamRows);
|
||||||
|
Assert.Equal("2", row.OwnGradeDisplay);
|
||||||
|
Assert.Equal("3.0", row.ClassAverageDisplay);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ExamHistory_ZeigtEigenenVerlaufUndMarkiertNotenabfall()
|
||||||
|
{
|
||||||
|
var exam1 = new Exam { Id = Guid.NewGuid(), GroupId = GroupId, Title = "K1", Date = new DateOnly(2025, 9, 10) };
|
||||||
|
var exam2 = new Exam { Id = Guid.NewGuid(), GroupId = GroupId, Title = "K2", Date = new DateOnly(2025, 9, 24) };
|
||||||
|
var exams = new FakeExams([exam1, exam2]);
|
||||||
|
var results = new FakeResults();
|
||||||
|
results.Add(new ExamResult { ExamId = exam1.Id, StudentId = StudentId, Grade = "2" });
|
||||||
|
results.Add(new ExamResult { ExamId = exam2.Id, StudentId = StudentId, Grade = "5" }); // Abfall + mangelhaft
|
||||||
|
|
||||||
|
var vm = BuildViewModel(exams, results);
|
||||||
|
|
||||||
|
Assert.Equal(2, vm.ExamHistory.Points.Count);
|
||||||
|
Assert.False(vm.ExamHistory.Points[0].IsWarning);
|
||||||
|
Assert.True(vm.ExamHistory.Points[1].IsWarning);
|
||||||
|
Assert.Contains("Abfall", vm.ExamHistory.Points[1].WarningText);
|
||||||
|
Assert.Contains("Versetzungsgefährdung", vm.ExamHistory.Points[1].WarningText);
|
||||||
|
Assert.True(vm.ExamHistory.Points[1].BarHeight < vm.ExamHistory.Points[0].BarHeight);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void AktuelleZeugnisnote_KombiniertAlleDreiBereiche()
|
||||||
|
{
|
||||||
|
var exam = new Exam { Id = Guid.NewGuid(), GroupId = GroupId, Title = "K1", Date = new DateOnly(2025, 9, 10) };
|
||||||
|
var exams = new FakeExams([exam]);
|
||||||
|
var results = new FakeResults();
|
||||||
|
results.Add(new ExamResult { ExamId = exam.Id, StudentId = StudentId, Grade = "2" });
|
||||||
|
var grades = new FakeGrades();
|
||||||
|
grades.Add(new Grade { StudentId = StudentId, GroupId = GroupId, Category = GradeCategory.Participation,
|
||||||
|
Date = new DateOnly(2025, 9, 5), Value = "3" });
|
||||||
|
grades.Add(new Grade { StudentId = StudentId, GroupId = GroupId, Category = GradeCategory.Other,
|
||||||
|
Date = new DateOnly(2025, 9, 5), Value = "1" });
|
||||||
|
|
||||||
|
var vm = BuildViewModel(exams, results, grades);
|
||||||
|
|
||||||
|
// (2*50 + 3*40 + 1*10) / 100 = 2.3 -> kaufmännisch 2
|
||||||
|
Assert.Equal("Note 2", vm.CurrentReportGradeDisplay);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Zielnote_BerechnetNoetigenKlausurschnitt()
|
||||||
|
{
|
||||||
|
var grades = new FakeGrades();
|
||||||
|
grades.Add(new Grade { StudentId = StudentId, GroupId = GroupId, Category = GradeCategory.Participation,
|
||||||
|
Date = new DateOnly(2025, 9, 5), Value = "3" });
|
||||||
|
grades.Add(new Grade { StudentId = StudentId, GroupId = GroupId, Category = GradeCategory.Other,
|
||||||
|
Date = new DateOnly(2025, 9, 5), Value = "2" });
|
||||||
|
var vm = BuildViewModel(grades: grades);
|
||||||
|
|
||||||
|
vm.TargetGradeText = "2";
|
||||||
|
vm.TargetBucketName = "Klausuren";
|
||||||
|
|
||||||
|
// Ziel 2,0 = (x*50 + 3*40 + 2*10)/100 => 200 = 50x+120+20 => x = 1,2
|
||||||
|
Assert.Contains("1.2", vm.TargetResultDisplay);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void WasWaereWenn_ZeigtZeugnisnoteMitZusaetzlicherKlausurAn()
|
||||||
|
{
|
||||||
|
var exam = new Exam { Id = Guid.NewGuid(), GroupId = GroupId, Title = "K1", Date = new DateOnly(2025, 9, 10) };
|
||||||
|
var exams = new FakeExams([exam]);
|
||||||
|
var results = new FakeResults();
|
||||||
|
results.Add(new ExamResult { ExamId = exam.Id, StudentId = StudentId, Grade = "2" });
|
||||||
|
var grades = new FakeGrades();
|
||||||
|
grades.Add(new Grade { StudentId = StudentId, GroupId = GroupId, Category = GradeCategory.Participation,
|
||||||
|
Date = new DateOnly(2025, 9, 5), Value = "2" });
|
||||||
|
|
||||||
|
var vm = BuildViewModel(exams, results, grades);
|
||||||
|
|
||||||
|
vm.WhatIfExamGradeText = "6";
|
||||||
|
|
||||||
|
// Klausuren neu: (2+6)/2=4 (50%), Mitarbeit 2 (40%, Sonstige fehlt) -> (4*50+2*40)/90 = 3,11 -> kaufm. 3
|
||||||
|
Assert.Contains("3", vm.WhatIfResultDisplay);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void SaveCommand_AufMitarbeitszeile_SpeichertUndAktualisiertDurchschnitt()
|
||||||
|
{
|
||||||
|
var grades = new FakeGrades();
|
||||||
|
var grade = new Grade { StudentId = StudentId, GroupId = GroupId, Category = GradeCategory.Participation,
|
||||||
|
Date = new DateOnly(2025, 9, 5), Value = "3" };
|
||||||
|
grades.Add(grade);
|
||||||
|
var vm = BuildViewModel(grades: grades);
|
||||||
|
|
||||||
|
var row = Assert.Single(vm.ParticipationRows);
|
||||||
|
row.Value = "1";
|
||||||
|
row.SaveCommand.Execute(null);
|
||||||
|
|
||||||
|
Assert.Equal("1.0", vm.ParticipationAverageDisplay);
|
||||||
|
Assert.Equal("1", grades.GetByStudentAndGroup(StudentId, GroupId).Single().Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void OhneWerteImZeitraum_ZeigtHinweisStattZeugnisnote()
|
||||||
|
{
|
||||||
|
var vm = BuildViewModel();
|
||||||
|
|
||||||
|
Assert.Contains("Noch nicht berechenbar", vm.CurrentReportGradeDisplay);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 —
|
||||||
@@ -275,7 +277,23 @@ public sealed class TimetableViewModelTests
|
|||||||
|
|
||||||
Assert.Equal(group.Id, navigatedTo);
|
Assert.Equal(group.Id, navigatedTo);
|
||||||
Assert.False(viewerOpened);
|
Assert.False(viewerOpened);
|
||||||
Assert.Equal("Zur Lerngruppe", vm.TodayItems[0].OpenButtonLabel);
|
Assert.Equal("Stunde anlegen", vm.TodayItems[0].OpenButtonLabel);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task OpenTodayLesson_OhneLesson_StartetDirektanlageMitExaktemTermin()
|
||||||
|
{
|
||||||
|
var today = DateOnly.FromDateTime(DateTime.Today);
|
||||||
|
var group = new LearningGroup { Name = "Q1 Chemie" };
|
||||||
|
var slots = new FakeTimetableSlots();
|
||||||
|
slots.Add(new TimetableSlot { GroupId = group.Id, Weekday = today.DayOfWeek, PeriodNumber = 4 });
|
||||||
|
var vm = BuildViewModel(slots, new FakeGroups([group]));
|
||||||
|
TimetableLessonRequest? requested = null;
|
||||||
|
vm.OnCreateLesson = request => { requested = request; return Task.CompletedTask; };
|
||||||
|
|
||||||
|
await vm.OpenTodayLessonCommand.ExecuteAsync(vm.TodayItems[0]);
|
||||||
|
|
||||||
|
Assert.Equal(new TimetableLessonRequest(group.Id, today, 4), requested);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Unterrichtsmodus (14.x) ────────────────────────────────────────────────
|
// ── Unterrichtsmodus (14.x) ────────────────────────────────────────────────
|
||||||
@@ -336,6 +354,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()
|
||||||
{
|
{
|
||||||
@@ -612,6 +712,31 @@ public sealed class TimetableViewModelTests
|
|||||||
Assert.Equal("1", period2.BadgeText);
|
Assert.Equal("1", period2.BadgeText);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Load_Doppelstunde_ZeigtLessonThemaInBeidenKacheln_AuchOhnePhasenUndStundenraster()
|
||||||
|
{
|
||||||
|
var date = DateInCurrentWeek(DayOfWeek.Monday);
|
||||||
|
var group = new LearningGroup { Name = "10c" };
|
||||||
|
var slots = new FakeTimetableSlots();
|
||||||
|
slots.Add(new TimetableSlot { GroupId = group.Id, Weekday = date.DayOfWeek, PeriodNumber = 3 });
|
||||||
|
slots.Add(new TimetableSlot { GroupId = group.Id, Weekday = date.DayOfWeek, PeriodNumber = 4 });
|
||||||
|
var lesson = new Lesson
|
||||||
|
{
|
||||||
|
GroupId = group.Id, Date = date, LessonNumber = 3, Topic = "Werkstoffprüfung",
|
||||||
|
Phases = [],
|
||||||
|
};
|
||||||
|
var lessons = new FakeLessons();
|
||||||
|
lessons.Add(lesson);
|
||||||
|
|
||||||
|
var vm = BuildViewModel(slots, new FakeGroups([group]), lessons: lessons);
|
||||||
|
|
||||||
|
var first = vm.WeekItems.Single(c => c.IsSlotCell && c.Weekday == date.DayOfWeek && c.PeriodNumber == 3);
|
||||||
|
var second = vm.WeekItems.Single(c => c.IsSlotCell && c.Weekday == date.DayOfWeek && c.PeriodNumber == 4);
|
||||||
|
Assert.Equal("Werkstoffprüfung", first.Topic);
|
||||||
|
Assert.Equal("Werkstoffprüfung", second.Topic);
|
||||||
|
Assert.Equal(lesson.Id, second.Lesson?.Id);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Load_KeineAnstehendenFerien_KeinBadge()
|
public void Load_KeineAnstehendenFerien_KeinBadge()
|
||||||
{
|
{
|
||||||
@@ -1177,3 +1302,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,25 @@
|
|||||||
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>
|
||||||
|
<!-- Plattformunabhängige Vektoricons für die Hauptnavigation (14.10). -->
|
||||||
|
<StreamGeometry x:Key="IconSearch">M10,2 A8,8 0 1 0 10,18 A8,8 0 1 0 10,2 M10,5 A5,5 0 1 1 10,15 A5,5 0 1 1 10,5 M15,14 L22,21 L20,23 L13,16 Z</StreamGeometry>
|
||||||
|
<StreamGeometry x:Key="IconDashboard">M3,3 H10 V10 H3 Z M14,3 H21 V7 H14 Z M14,11 H21 V21 H14 Z M3,14 H10 V21 H3 Z</StreamGeometry>
|
||||||
|
<StreamGeometry x:Key="IconGroups">M3,8 L12,3 L21,8 V10 H3 Z M5,11 H8 V18 H5 Z M10,11 H14 V18 H10 Z M16,11 H19 V18 H16 Z M3,20 H21 V22 H3 Z</StreamGeometry>
|
||||||
|
<StreamGeometry x:Key="IconStudents">M12,3 A4,4 0 1 0 12,11 A4,4 0 1 0 12,3 M4,21 C4,16 7,13 12,13 C17,13 20,16 20,21 Z</StreamGeometry>
|
||||||
|
<StreamGeometry x:Key="IconExams">M5,2 H15 L20,7 V22 H5 Z M7,5 V19 H18 V9 H13 V4 H7 Z M9,11 H16 V13 H9 Z M9,15 H16 V17 H9 Z</StreamGeometry>
|
||||||
|
<StreamGeometry x:Key="IconCalendar">M3,4 H21 V22 H3 Z M5,10 V20 H19 V10 Z M7,2 H9 V7 H7 Z M15,2 H17 V7 H15 Z M7,12 H10 V15 H7 Z M12,12 H15 V15 H12 Z M7,17 H10 V19 H7 Z M12,17 H15 V19 H12 Z</StreamGeometry>
|
||||||
|
<StreamGeometry x:Key="IconClock">M12,2 A10,10 0 1 0 12,22 A10,10 0 1 0 12,2 M12,5 A7,7 0 1 1 12,19 A7,7 0 1 1 12,5 M11,7 H13 V12 L17,14 L16,16 L11,13 Z</StreamGeometry>
|
||||||
|
<StreamGeometry x:Key="IconClassTeacher">M2,8 L12,3 L22,8 L12,13 Z M6,11 V16 C9,19 15,19 18,16 V11 M22,8 V15</StreamGeometry>
|
||||||
|
<StreamGeometry x:Key="IconSettings">M3,5 H21 V7 H3 Z M8,3 A3,3 0 1 0 8,9 A3,3 0 1 0 8,3 M3,11 H21 V13 H3 Z M16,9 A3,3 0 1 0 16,15 A3,3 0 1 0 16,9 M3,17 H21 V19 H3 Z M10,15 A3,3 0 1 0 10,21 A3,3 0 1 0 10,15</StreamGeometry>
|
||||||
|
<StreamGeometry x:Key="IconRefresh">M12,3 A9,9 0 0 1 21,12 H18 A6,6 0 0 0 8,7 L11,10 H3 V2 L6,5 A9,9 0 0 1 12,3 M12,21 A9,9 0 0 1 3,12 H6 A6,6 0 0 0 16,17 L13,14 H21 V22 L18,19 A9,9 0 0 1 12,21</StreamGeometry>
|
||||||
|
</ResourceDictionary>
|
||||||
|
</Application.Resources>
|
||||||
|
|
||||||
<Application.Styles>
|
<Application.Styles>
|
||||||
<FluentTheme />
|
<FluentTheme />
|
||||||
<StyleInclude Source="avares://Avalonia.Controls.DataGrid/Themes/Fluent.xaml"/>
|
<StyleInclude Source="avares://Avalonia.Controls.DataGrid/Themes/Fluent.xaml"/>
|
||||||
@@ -16,5 +35,17 @@
|
|||||||
<Setter Property="Opacity" Value="0.4"/>
|
<Setter Property="Opacity" Value="0.4"/>
|
||||||
<Setter Property="FontSize" Value="13"/>
|
<Setter Property="FontSize" Value="13"/>
|
||||||
</Style>
|
</Style>
|
||||||
|
<Style Selector="Button.touchTarget">
|
||||||
|
<Setter Property="MinWidth" Value="40"/>
|
||||||
|
<Setter Property="MinHeight" Value="40"/>
|
||||||
|
</Style>
|
||||||
|
<Style Selector="Button:focus-visible">
|
||||||
|
<Setter Property="BorderBrush" Value="{DynamicResource SystemAccentColor}"/>
|
||||||
|
<Setter Property="BorderThickness" Value="2"/>
|
||||||
|
</Style>
|
||||||
|
<Style Selector="TextBox:focus-visible, ComboBox:focus-visible">
|
||||||
|
<Setter Property="BorderBrush" Value="{DynamicResource SystemAccentColor}"/>
|
||||||
|
<Setter Property="BorderThickness" Value="2"/>
|
||||||
|
</Style>
|
||||||
</Application.Styles>
|
</Application.Styles>
|
||||||
</Application>
|
</Application>
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ using Avalonia.Controls.ApplicationLifetimes;
|
|||||||
using Avalonia.Markup.Xaml;
|
using Avalonia.Markup.Xaml;
|
||||||
using Avalonia.Styling;
|
using Avalonia.Styling;
|
||||||
using LehrerApp.Core.Interfaces;
|
using LehrerApp.Core.Interfaces;
|
||||||
|
using LehrerApp.Core.Models;
|
||||||
using LehrerApp.Core.Services;
|
using LehrerApp.Core.Services;
|
||||||
using LehrerApp.Data;
|
using LehrerApp.Data;
|
||||||
using LehrerApp.Desktop.Services;
|
using LehrerApp.Desktop.Services;
|
||||||
@@ -10,7 +11,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 +82,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 +112,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 +125,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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -130,6 +154,28 @@ public class App : Application
|
|||||||
dash.OnNavigateToLesson = id => main.NavigateToGroupDetail(id, 3); // Tab "Mitarbeit"
|
dash.OnNavigateToLesson = id => main.NavigateToGroupDetail(id, 3); // Tab "Mitarbeit"
|
||||||
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"
|
||||||
|
dash.OnAddMissingTeachingTime = item => ShowMissingTeachingTimeDialog(item, dash);
|
||||||
|
|
||||||
|
var examsOverview = Services.GetRequiredService<ViewModels.Exams.ExamsOverviewViewModel>();
|
||||||
|
examsOverview.OnNavigateToGroups = () => main.NavigateToCommand.Execute(NavItem.Groups);
|
||||||
|
|
||||||
|
// Globale Suche/Schnellerfassung (14.2): Navigation bleibt im MainWindow-VM, die
|
||||||
|
// vorhandenen Dialog-Helfer übernehmen Eingabe und Validierung.
|
||||||
|
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;
|
||||||
|
search.OnQuickAddGroupDocumentation = () => ShowQuickGroupDocumentationDialog(dash);
|
||||||
|
|
||||||
// StudentList → StudentDetail + Anlegen
|
// StudentList → StudentDetail + Anlegen
|
||||||
var sl = Services.GetRequiredService<StudentListViewModel>();
|
var sl = Services.GetRequiredService<StudentListViewModel>();
|
||||||
@@ -150,4 +196,62 @@ 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();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task ShowMissingTeachingTimeDialog(MissingTeachingTimeItem item, DashboardViewModel dashboard)
|
||||||
|
{
|
||||||
|
if (Application.Current?.ApplicationLifetime is not IClassicDesktopStyleApplicationLifetime
|
||||||
|
{ MainWindow: { } owner }) return;
|
||||||
|
|
||||||
|
var tasks = Services.GetRequiredService<IWorkTaskRepository>()
|
||||||
|
.GetAll().Where(t => t.Status != WorkTaskStatus.Done).ToList();
|
||||||
|
var vm = new AddTimeEntryDialogViewModel(tasks)
|
||||||
|
{
|
||||||
|
DateText = item.Date.ToString("dd.MM.yyyy"),
|
||||||
|
SelectedCategory = "Unterricht",
|
||||||
|
StartTimeText = item.WindowStart.ToString("HH:mm"),
|
||||||
|
EndTimeText = item.WindowEnd.ToString("HH:mm"),
|
||||||
|
};
|
||||||
|
var dialog = new AddTimeEntryDialog { DataContext = vm };
|
||||||
|
await dialog.ShowDialog<bool>(owner);
|
||||||
|
if (vm.Result is null) return;
|
||||||
|
|
||||||
|
Services.GetRequiredService<ITimeEntryRepository>().Save(vm.Result);
|
||||||
|
dashboard.RefreshCommand.Execute(null);
|
||||||
|
Services.GetRequiredService<TimeTrackingViewModel>().Load();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task ShowQuickGroupDocumentationDialog(DashboardViewModel dashboard)
|
||||||
|
{
|
||||||
|
if (Application.Current?.ApplicationLifetime is not IClassicDesktopStyleApplicationLifetime
|
||||||
|
{ MainWindow: { } owner }) return;
|
||||||
|
|
||||||
|
var subjects = Services.GetRequiredService<ISubjectRepository>().GetAll()
|
||||||
|
.ToDictionary(s => s.Id);
|
||||||
|
var options = Services.GetRequiredService<IGroupRepository>().GetAll()
|
||||||
|
.Where(g => g.IsActive)
|
||||||
|
.OrderBy(g => g.Name)
|
||||||
|
.Select(g => new GroupDocumentationOption(g.Id,
|
||||||
|
g.SubjectId is { } subjectId && subjects.TryGetValue(subjectId, out var subject)
|
||||||
|
? $"{g.Name} · {(string.IsNullOrWhiteSpace(subject.ShortName) ? subject.Name : subject.ShortName)}"
|
||||||
|
: g.Name));
|
||||||
|
var vm = new GroupDocumentationQuickViewModel(options);
|
||||||
|
var dialog = new Views.Students.GroupDocumentationQuickDialog { DataContext = vm };
|
||||||
|
if (!await dialog.ShowDialog<bool>(owner) || vm.Result is null) return;
|
||||||
|
|
||||||
|
Services.GetRequiredService<IDocumentationRepository>().Save(vm.Result);
|
||||||
|
dashboard.RefreshCommand.Execute(null);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -14,6 +16,7 @@ using LehrerApp.Sync;
|
|||||||
using LehrerApp.Sync.Crypto;
|
using LehrerApp.Sync.Crypto;
|
||||||
using LehrerApp.Sync.Models;
|
using LehrerApp.Sync.Models;
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using LehrerApp.Templating;
|
||||||
|
|
||||||
namespace LehrerApp.Desktop;
|
namespace LehrerApp.Desktop;
|
||||||
|
|
||||||
@@ -115,13 +118,22 @@ public static class AppBootstrapper
|
|||||||
services.AddSingleton<NotificationService>();
|
services.AddSingleton<NotificationService>();
|
||||||
services.AddSingleton<ExportService>();
|
services.AddSingleton<ExportService>();
|
||||||
services.AddSingleton<PdfExportService>();
|
services.AddSingleton<PdfExportService>();
|
||||||
|
services.AddSingleton<ITemplateLoader, TemplateLoader>();
|
||||||
|
services.AddSingleton<ITemplateRenderer, QuestTemplateRenderer>();
|
||||||
|
services.AddSingleton(_ => new TemplateStore(appData));
|
||||||
|
|
||||||
// ── Datensicherheit (13.3) ───────────────────────────────────────────
|
// ── Datensicherheit (13.3) ───────────────────────────────────────────
|
||||||
// Backup läuft vor dem Öffnen der Datenbank, rein dateibasiert (unabhängig von
|
// Backup läuft vor dem Öffnen der Datenbank, rein dateibasiert (unabhängig von
|
||||||
// Verschlüsselung) — reine Datei-Kopie, kein offener LiteDB-Handle nötig.
|
// Verschlüsselung) — reine Datei-Kopie, kein offener LiteDB-Handle nötig.
|
||||||
var backup = new BackupService(appData);
|
var backupSettings = new BackupSettingsService(appData);
|
||||||
backup.CreateBackup(DbPath);
|
var backup = new BackupService(appData) { SecondaryBackupDirectory = backupSettings.LoadSecondaryDirectory() };
|
||||||
|
var backupPath = backup.CreateBackup(DbPath);
|
||||||
|
// Best-effort-Prüfung des automatischen Startbackups: nur geloggt, kein Blocker für den
|
||||||
|
// Programmstart — ein beschädigtes Backup soll auffallen, nicht den Nutzer aufhalten.
|
||||||
|
if (backupPath is not null && !new DatabaseEncryptionService().CanOpenAndRead(backupPath, DbPassword))
|
||||||
|
Logger.Warn($"Automatisches Backup {backupPath} lässt sich nicht öffnen/lesen — möglicherweise beschädigt.");
|
||||||
services.AddSingleton(backup);
|
services.AddSingleton(backup);
|
||||||
|
services.AddSingleton(backupSettings);
|
||||||
services.AddSingleton(_ => new AppLockService(appData));
|
services.AddSingleton(_ => new AppLockService(appData));
|
||||||
services.AddSingleton<DatabaseEncryptionService>();
|
services.AddSingleton<DatabaseEncryptionService>();
|
||||||
services.AddSingleton(_ => new PrivacySettingsService(appData));
|
services.AddSingleton(_ => new PrivacySettingsService(appData));
|
||||||
@@ -168,6 +180,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>();
|
||||||
@@ -181,7 +197,6 @@ public static class AppBootstrapper
|
|||||||
services.AddSingleton(_ => new DashboardSettingsService(appData));
|
services.AddSingleton(_ => new DashboardSettingsService(appData));
|
||||||
services.AddSingleton(_ => new WindowSettingsService(appData));
|
services.AddSingleton(_ => new WindowSettingsService(appData));
|
||||||
services.AddSingleton(_ => new AppearanceSettingsService(appData));
|
services.AddSingleton(_ => new AppearanceSettingsService(appData));
|
||||||
services.AddSingleton(_ => new LetterTemplateService(appData));
|
|
||||||
services.AddSingleton<PlanningExchangeService>();
|
services.AddSingleton<PlanningExchangeService>();
|
||||||
|
|
||||||
// ── KI-Unterstützung (4.5.9, optional – nur wenn in den Einstellungen aktiviert) ──────
|
// ── KI-Unterstützung (4.5.9, optional – nur wenn in den Einstellungen aktiviert) ──────
|
||||||
@@ -223,6 +238,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 +301,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 +314,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,8 @@
|
|||||||
<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" />
|
||||||
|
<ProjectReference Include="..\LehrerApp.Templating\LehrerApp.Templating.csproj" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="Avalonia" />
|
<PackageReference Include="Avalonia" />
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
|
||||||
|
namespace LehrerApp.Desktop.Services;
|
||||||
|
|
||||||
|
internal sealed class BackupSettingsConfig
|
||||||
|
{
|
||||||
|
public string? SecondaryDirectory { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Merkt sich einen optionalen zweiten Sicherungsordner (13.3.1 Nachtrag) — z.B. ein
|
||||||
|
/// USB-Stick oder Netzlaufwerk, damit ein Backup nicht ausschließlich neben der Live-Datenbank
|
||||||
|
/// liegt und bei Plattenausfall oder gelöschtem Profilverzeichnis mit verloren geht. Gleiches
|
||||||
|
/// Muster wie <see cref="AppearanceSettingsService"/>. Das eigentliche Kopieren übernimmt
|
||||||
|
/// <see cref="LehrerApp.Core.Services.BackupService"/> bewusst best-effort.</summary>
|
||||||
|
public sealed class BackupSettingsService
|
||||||
|
{
|
||||||
|
private readonly string _configPath;
|
||||||
|
|
||||||
|
public BackupSettingsService(string appDataPath) =>
|
||||||
|
_configPath = Path.Combine(appDataPath, "backupsettings.json");
|
||||||
|
|
||||||
|
public string? LoadSecondaryDirectory()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (File.Exists(_configPath))
|
||||||
|
return JsonSerializer.Deserialize<BackupSettingsConfig>(File.ReadAllText(_configPath))
|
||||||
|
?.SecondaryDirectory;
|
||||||
|
}
|
||||||
|
catch { /* beschädigte Konfiguration -> kein zweites Ziel */ }
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void SaveSecondaryDirectory(string? path) =>
|
||||||
|
File.WriteAllText(_configPath, JsonSerializer.Serialize(new BackupSettingsConfig { SecondaryDirectory = path }));
|
||||||
|
}
|
||||||
@@ -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,243 @@
|
|||||||
|
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 UntisHolidayDto(int UntisId, string Name, string? LongName, int StartDate, int EndDate);
|
||||||
|
public sealed record UntisClassDto(int UntisId, string Name, string? LongName);
|
||||||
|
public sealed record UntisTeacherDto(int UntisId, string Name, string? ForeName, string? LongName, string? Title,
|
||||||
|
bool Active, IReadOnlyList<int> DepartmentUntisIds)
|
||||||
|
{
|
||||||
|
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<UntisHolidayDto>> GetHolidaysAsync(CancellationToken token = default) => ExecuteAsync(
|
||||||
|
async client => (IReadOnlyList<UntisHolidayDto>)(await client.GetHolidaysAsync(token))
|
||||||
|
.Select(x => new UntisHolidayDto(x.UntisId, x.Name, x.LongName, x.StartDate, x.EndDate)).ToList(), token);
|
||||||
|
|
||||||
|
public Task<IReadOnlyList<UntisClassDto>> GetClassesAsync(int schoolYearId, CancellationToken token = default) => ExecuteAsync(
|
||||||
|
async client => (IReadOnlyList<UntisClassDto>)(await client.GetClassesAsync(schoolYearId, token))
|
||||||
|
.Select(x => new UntisClassDto(x.UntisId, x.Name, x.LongName)).ToList(), token);
|
||||||
|
|
||||||
|
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,21 @@ 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 List<CachedUntisHoliday>? CachedHolidays { get; set; }
|
||||||
|
public DateTime? HolidaysFetchedAt { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public sealed record WebUntisCredentials(string School, string Host, string Username, string Password);
|
||||||
|
|
||||||
|
/// <summary>Ferienzeitraum aus WebUntis' <c>getHolidays</c>-Bericht, hier auf die für die
|
||||||
|
/// Fehlquoten-Berechnung ("Klassenlehrer"-Feature) relevanten Felder reduziert. Kein Geheimnis
|
||||||
|
/// (anders als iCal-URL/API-Zugangsdaten in dieser Datei), deshalb unverschlüsselt gecacht.</summary>
|
||||||
|
public sealed record CachedUntisHoliday(string Name, DateOnly Start, DateOnly End);
|
||||||
|
|
||||||
/// <summary>
|
/// <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 +43,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 +80,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;
|
||||||
@@ -70,6 +122,16 @@ public class WebUntisSettingsService
|
|||||||
Save();
|
Save();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public DateTime? HolidaysFetchedAt => _config.HolidaysFetchedAt;
|
||||||
|
public IReadOnlyList<CachedUntisHoliday>? GetCachedHolidays() => _config.CachedHolidays;
|
||||||
|
|
||||||
|
public void SetCachedHolidays(IReadOnlyList<CachedUntisHoliday> holidays, DateTime at)
|
||||||
|
{
|
||||||
|
_config.CachedHolidays = holidays.ToList();
|
||||||
|
_config.HolidaysFetchedAt = at;
|
||||||
|
Save();
|
||||||
|
}
|
||||||
|
|
||||||
private byte[] GenerateAndSaveKey()
|
private byte[] GenerateAndSaveKey()
|
||||||
{
|
{
|
||||||
var key = SyncCrypto.GenerateKey();
|
var key = SyncCrypto.GenerateKey();
|
||||||
|
|||||||
@@ -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,792 @@
|
|||||||
|
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 Werktage
|
||||||
|
/// abzüglich der über WebUntis geladenen Ferien (<see cref="ClassTeacherOverviewViewModel.CountSchoolWeekdays"/>).
|
||||||
|
public int YearAbsenceDayCount { get; init; }
|
||||||
|
public int YearUnexcusedDayCount { get; init; }
|
||||||
|
public int SchoolDaysElapsed { get; init; }
|
||||||
|
/// Nutzer-Feedback: nachdem der Nenner zeitweise Ferientage mitzählte (siehe TODO.md), soll die
|
||||||
|
/// Herkunft der Zahl nachvollziehbar bleiben, ohne dafür die WebUntis-Ferienliste separat
|
||||||
|
/// nachschlagen zu müssen — deshalb hier sichtbar im Tooltip statt nur intern verrechnet.
|
||||||
|
public int HolidayWeekdaysExcluded { get; init; }
|
||||||
|
/// Angenommener erster Unterrichtstag (<see cref="ClassTeacherOverviewViewModel.EstimateTermStart"/>),
|
||||||
|
/// aus demselben Nachvollziehbarkeits-Grund wie <see cref="HolidayWeekdaysExcluded"/> im Tooltip
|
||||||
|
/// sichtbar — WebUntis' Ferienkalender deckt die Sommerferien selbst nicht ab (siehe TODO.md),
|
||||||
|
/// die Korrektur passiert also am Startpunkt, nicht an abgezogenen Tagen mittendrin.
|
||||||
|
public DateOnly TermStart { get; init; }
|
||||||
|
public bool HasYearSummary => SchoolDaysElapsed > 0 && YearAbsenceDayCount > 0;
|
||||||
|
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 seit {TermStart:dd.MM.} mit Fehlzeit" +
|
||||||
|
(YearUnexcusedDayCount > 0 ? $" · {YearUnexcusedDayCount} unentschuldigt" : "") +
|
||||||
|
(HolidayWeekdaysExcluded > 0 ? $" · {HolidayWeekdaysExcluded} Ferientage abgezogen" : "");
|
||||||
|
|
||||||
|
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,
|
||||||
|
int holidayWeekdaysExcluded = 0,
|
||||||
|
DateOnly termStart = default)
|
||||||
|
{
|
||||||
|
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,
|
||||||
|
HolidayWeekdaysExcluded = holidayWeekdaysExcluded,
|
||||||
|
TermStart = termStart,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.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, bool CanCreateReminder = false)
|
||||||
|
{
|
||||||
|
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) =>
|
||||||
|
// Verspätungen ausgeschlossen: dafür gibt es i.d.R. keine Entschuldigungspflicht, eine
|
||||||
|
// "Entschuldigung fehlt"-Erinnerung wäre hier gegenstandslos. Wiederholte Verspätungen
|
||||||
|
// laufen stattdessen über die Mustererkennung (siehe BuildPatternNotices).
|
||||||
|
absenceDays.Where(a => a.IsUnexcused && !a.IsLate)
|
||||||
|
.Select(a => new ClassTeacherOpenExcuseRow(a.StudentDisplayName, a.Date,
|
||||||
|
today.DayNumber - a.Date.DayNumber))
|
||||||
|
.OrderByDescending(r => r.DaysOpen).ThenBy(r => r.StudentName)
|
||||||
|
.ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
public partial class ClassTeacherOverviewViewModel : ObservableObject
|
||||||
|
{
|
||||||
|
private readonly WebUntisSettingsService _settings;
|
||||||
|
private readonly WebUntisIntegrationService _untis;
|
||||||
|
private readonly UntisReportCacheService _cache;
|
||||||
|
private readonly SchoolYearService _schoolYear;
|
||||||
|
private readonly IWorkTaskRepository _workTasks;
|
||||||
|
private readonly IStudentRepository _students;
|
||||||
|
private readonly IParticipationRepository _participation;
|
||||||
|
private readonly IParticipationSessionRepository _participationSessions;
|
||||||
|
private readonly AppLogger? _logger;
|
||||||
|
|
||||||
|
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, WebUntisIntegrationService untis,
|
||||||
|
UntisReportCacheService cache, SchoolYearService schoolYear, IWorkTaskRepository workTasks,
|
||||||
|
IStudentRepository students, IParticipationRepository participation,
|
||||||
|
IParticipationSessionRepository participationSessions, ClassTeacherDetailsViewModel detailsTab,
|
||||||
|
AppLogger? logger = null)
|
||||||
|
{
|
||||||
|
_settings = settings;
|
||||||
|
_untis = untis;
|
||||||
|
_cache = cache;
|
||||||
|
_schoolYear = schoolYear;
|
||||||
|
_workTasks = workTasks;
|
||||||
|
_students = students;
|
||||||
|
_participation = participation;
|
||||||
|
_participationSessions = participationSessions;
|
||||||
|
_logger = logger;
|
||||||
|
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);
|
||||||
|
var holidaysTask = GetHolidaysAsync();
|
||||||
|
await Task.WhenAll(studentsTask, absencesTask, classRegisterTask, holidaysTask);
|
||||||
|
|
||||||
|
var absenceDaysYear = ClassAbsenceDaySummaryRow.GroupByStudentAndDay(absencesTask.Result);
|
||||||
|
var todayAbsences = absenceDaysYear.Where(a => a.Date == today).ToList();
|
||||||
|
var termStart = EstimateTermStart(absenceDaysYear, yearStart);
|
||||||
|
var rawWeekdaysElapsed = CountSchoolWeekdays(termStart, today, []);
|
||||||
|
var schoolDaysElapsed = CountSchoolWeekdays(termStart, today, holidaysTask.Result);
|
||||||
|
var holidayWeekdaysExcluded = rawWeekdaysElapsed - schoolDaysElapsed;
|
||||||
|
foreach (var row in ClassTeacherRosterRow.Build(studentsTask.Result, todayAbsences,
|
||||||
|
classRegisterTask.Result, today, absenceDaysYear, schoolDaysElapsed,
|
||||||
|
holidayWeekdaysExcluded, termStart)) 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(absenceDaysYear, sevenDayStart);
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Nutzer-Feedback: Schüler*innen, die seit Unterrichtsbeginn nachweislich an jedem Tag
|
||||||
|
/// fehlten, zeigten trotzdem nur ~57 % Fehlquote statt der erwarteten ~100 %. Ursache: der
|
||||||
|
/// Nenner zählte bislang jeden Werktag ab dem fest verdrahteten 1. August
|
||||||
|
/// (<see cref="SchoolYearService.SchoolYearStart"/>) als "Schultag" mit — die Sommerferien
|
||||||
|
/// enden je nach Bundesland/Jahr aber erst Wochen später, und genau zu Schuljahresbeginn macht
|
||||||
|
/// diese Ferienzeit einen großen Teil des bis dahin "verstrichenen" Zeitraums aus. WebUntis
|
||||||
|
/// kennt einen Teil des echten Ferienkalenders (<c>getHolidays</c>-Bericht, hier zwischen den
|
||||||
|
/// Werktagen ausgeschlossen) und behebt damit die kleineren Verzerrungen durch Herbst-/
|
||||||
|
/// Weihnachts-/Osterferien im weiteren Jahresverlauf. Die Sommerferien selbst liefert
|
||||||
|
/// <c>getHolidays</c> nach Prüfung der echten Antwort für dieses Konto aber NIE (über 11 Jahre
|
||||||
|
/// zurück kein einziger Sommerferien-Eintrag, siehe <see cref="EstimateTermStart"/>) — sie
|
||||||
|
/// liegen WebUntis-intern vermutlich außerhalb jedes Schuljahres-Datensatzes (der bei 1.8./31.7.
|
||||||
|
/// endet), nicht "in" einem davon. Für die Sommerferien bleibt deshalb weiterhin
|
||||||
|
/// <see cref="EstimateTermStart"/> nötig, das den Startpunkt selbst korrigiert statt Tage
|
||||||
|
/// innerhalb des Zeitraums abzuziehen.
|
||||||
|
public static int CountSchoolWeekdays(DateOnly start, DateOnly end, IReadOnlyList<CachedUntisHoliday> holidays)
|
||||||
|
{
|
||||||
|
if (end < start) return 0;
|
||||||
|
var count = 0;
|
||||||
|
for (var d = start; d <= end; d = d.AddDays(1))
|
||||||
|
if (d.DayOfWeek is not (DayOfWeek.Saturday or DayOfWeek.Sunday) &&
|
||||||
|
!holidays.Any(h => d >= h.Start && d <= h.End))
|
||||||
|
count++;
|
||||||
|
return count;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ergänzt <see cref="CountSchoolWeekdays"/> um genau die Lücke, die WebUntis' echter
|
||||||
|
/// Ferienkalender nicht schließt: die Sommerferien. Nimmt den frühesten Tag mit irgendeinem
|
||||||
|
/// Fehlzeiten-Eintrag der ganzen Klasse als Näherung für den tatsächlichen ersten
|
||||||
|
/// Unterrichtstag (ein solcher Eintrag kann nur an einem Tag mit tatsächlich stattfindendem
|
||||||
|
/// Unterricht entstehen) — Rückfall auf <paramref name="fallback"/>, wenn noch keine
|
||||||
|
/// Fehlzeiten vorliegen (dann bleibt <see cref="ClassTeacherRosterRow.HasYearSummary"/>
|
||||||
|
/// ohnehin ausgeblendet).
|
||||||
|
public static DateOnly EstimateTermStart(IReadOnlyList<ClassAbsenceDaySummaryRow> absenceDaysYear, DateOnly fallback) =>
|
||||||
|
absenceDaysYear.Count > 0 ? absenceDaysYear.Min(a => a.Date) : fallback;
|
||||||
|
|
||||||
|
/// Ferien ändern sich innerhalb eines Schuljahrs praktisch nie (anders als die
|
||||||
|
/// Fehlzeiten-/Klassenbuchberichte, deshalb hier keine der stündlichen "heißes Fenster"-Logik
|
||||||
|
/// aus <see cref="UntisReportCacheService"/>, sondern ein einfacher tagesgenauer Cache über
|
||||||
|
/// <see cref="WebUntisSettingsService"/> — kein zusätzliches LiteDB-Repository nötig). Schlägt
|
||||||
|
/// der Live-Abruf fehl (z. B. kurzzeitig kein Netz), wird der zuletzt bekannte Stand
|
||||||
|
/// weiterverwendet statt die ganze Übersicht mit einem Fehler zu blockieren; ist noch nie
|
||||||
|
/// erfolgreich abgerufen worden, bleibt die Liste leer und <see cref="CountSchoolWeekdays"/>
|
||||||
|
/// verhält sich wie vorher (reine Werktagszählung ohne Ferienabzug).
|
||||||
|
private static readonly TimeSpan HolidaysRefreshInterval = TimeSpan.FromDays(1);
|
||||||
|
|
||||||
|
private async Task<IReadOnlyList<CachedUntisHoliday>> GetHolidaysAsync()
|
||||||
|
{
|
||||||
|
var cached = _settings.GetCachedHolidays();
|
||||||
|
if (cached is not null && _settings.HolidaysFetchedAt is { } fetchedAt &&
|
||||||
|
DateTime.UtcNow - fetchedAt < HolidaysRefreshInterval)
|
||||||
|
return cached;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var fresh = (await _untis.GetHolidaysAsync())
|
||||||
|
.Select(h => new CachedUntisHoliday(h.Name, ParseDate(h.StartDate), ParseDate(h.EndDate)))
|
||||||
|
.ToList();
|
||||||
|
_settings.SetCachedHolidays(fresh, DateTime.UtcNow);
|
||||||
|
_logger?.Info("Klassenlehrer: WebUntis-Ferien geladen — " +
|
||||||
|
string.Join("; ", fresh.Select(h => $"{h.Name} {h.Start:yyyy-MM-dd}..{h.End:yyyy-MM-dd}")));
|
||||||
|
return fresh;
|
||||||
|
}
|
||||||
|
catch (WebUntisIntegrationException ex)
|
||||||
|
{
|
||||||
|
_logger?.Error("Klassenlehrer: WebUntis-Ferienabruf fehlgeschlagen", ex);
|
||||||
|
return cached ?? [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static DateOnly ParseDate(int value) => DateOnly.ParseExact(value.ToString(), "yyyyMMdd");
|
||||||
|
|
||||||
|
private const int LateYearEscalationThreshold = 5;
|
||||||
|
|
||||||
|
private void BuildPatternNotices(IReadOnlyList<ClassAbsenceDaySummaryRow> absenceDaysYear, DateOnly sevenDayStart)
|
||||||
|
{
|
||||||
|
var displayNames = Roster.GroupBy(r => UntisNameMatching.NameKey(r.StudentName))
|
||||||
|
.ToDictionary(g => g.Key, g => g.First().StudentName);
|
||||||
|
foreach (var notice in DetectLatePatterns(absenceDaysYear, displayNames, sevenDayStart))
|
||||||
|
PatternNotices.Add(notice);
|
||||||
|
OnPropertyChanged(nameof(HasPatternNotices));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Verspätungen sind i.d.R. nicht entschuldigungsfähig (siehe <see cref="ClassTeacherOpenExcuseRow.Build"/>)
|
||||||
|
/// — eine Häufung ist trotzdem ein Signal, nur eines für ein Elterngespräch/einen Brief statt
|
||||||
|
/// für eine fehlende Entschuldigung. Zwei Stufen, Nutzer-Feedback: ein kurzfristiges Cluster
|
||||||
|
/// (<paramref name="sevenDayStart"/>..heute) bleibt ein sanfter Hinweis ohne Aktion, eine hohe
|
||||||
|
/// Jahressumme eskaliert zu Danger mit Wiedervorlage-Option
|
||||||
|
/// (<see cref="CreateReminderForPatternNoticeCommand"/>). Reine, ohne ViewModel-Zustand
|
||||||
|
/// testbare Kernlogik (gleiches Muster wie <see cref="DetectWeekdayPatterns"/>).
|
||||||
|
public static IReadOnlyList<ClassTeacherPatternNotice> DetectLatePatterns(
|
||||||
|
IReadOnlyList<ClassAbsenceDaySummaryRow> absenceDaysYear,
|
||||||
|
IReadOnlyDictionary<string, string> displayNamesByKey,
|
||||||
|
DateOnly sevenDayStart,
|
||||||
|
int lateYearEscalationThreshold = LateYearEscalationThreshold)
|
||||||
|
{
|
||||||
|
var notices = new List<ClassTeacherPatternNotice>();
|
||||||
|
foreach (var group in absenceDaysYear.GroupBy(row => UntisNameMatching.NameKey(row.StudentName)))
|
||||||
|
{
|
||||||
|
var rows = group.ToList();
|
||||||
|
var displayName = displayNamesByKey.GetValueOrDefault(group.Key) ?? rows[0].StudentDisplayName;
|
||||||
|
var weekRows = rows.Where(row => row.Date >= sevenDayStart).ToList();
|
||||||
|
var lateDaysWeek = weekRows.Count(row => row.IsLate);
|
||||||
|
var unexcusedDaysWeek = weekRows.Count(row => row.IsUnexcused && !row.IsLate);
|
||||||
|
var lateDaysYear = rows.Count(row => row.IsLate);
|
||||||
|
|
||||||
|
if (unexcusedDaysWeek >= 2)
|
||||||
|
notices.Add(new ClassTeacherPatternNotice(displayName,
|
||||||
|
$"{unexcusedDaysWeek} unentschuldigte Fehltage in 7 Tagen", ClassTeacherStatusKind.Danger));
|
||||||
|
else if (lateDaysYear >= lateYearEscalationThreshold)
|
||||||
|
notices.Add(new ClassTeacherPatternNotice(displayName,
|
||||||
|
$"{lateDaysYear} Verspätungen seit Schuljahresbeginn – Elterngespräch oder Brief erwägen",
|
||||||
|
ClassTeacherStatusKind.Danger, CanCreateReminder: true));
|
||||||
|
else if (lateDaysWeek >= 2)
|
||||||
|
notices.Add(new ClassTeacherPatternNotice(displayName,
|
||||||
|
$"{lateDaysWeek}-mal verspätet in 7 Tagen", ClassTeacherStatusKind.Warning));
|
||||||
|
}
|
||||||
|
return notices;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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);
|
||||||
|
}
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
private void CreateReminderForPatternNotice(ClassTeacherPatternNotice? notice)
|
||||||
|
{
|
||||||
|
if (notice is null || !notice.CanCreateReminder) return;
|
||||||
|
SaveReminder(notice.StudentName, notice.Message, urgent: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void SaveReminder(string studentName, string notes, bool urgent)
|
||||||
|
{
|
||||||
|
var task = new WorkTask
|
||||||
|
{
|
||||||
|
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));
|
||||||
|
}
|
||||||
@@ -35,6 +35,7 @@ public partial class DashboardViewModel : ObservableObject
|
|||||||
private readonly PublicHolidayService _publicHolidays;
|
private readonly PublicHolidayService _publicHolidays;
|
||||||
private readonly SchoolCalendarSettingsService _calendarSettings;
|
private readonly SchoolCalendarSettingsService _calendarSettings;
|
||||||
private readonly ISubstitutionEntryRepository _substitutions;
|
private readonly ISubstitutionEntryRepository _substitutions;
|
||||||
|
private readonly ITimeEntryRepository _timeEntries;
|
||||||
private readonly IAnnualPlanEventRepository? _annualPlanEvents;
|
private readonly IAnnualPlanEventRepository? _annualPlanEvents;
|
||||||
private readonly SchoolWeatherService? _schoolWeather;
|
private readonly SchoolWeatherService? _schoolWeather;
|
||||||
|
|
||||||
@@ -50,12 +51,29 @@ public partial class DashboardViewModel : ObservableObject
|
|||||||
/// hat rein rechnerisch schon 100 %, das ist noch kein auffälliges Muster, nur eine zu kleine
|
/// hat rein rechnerisch schon 100 %, das ist noch kein auffälliges Muster, nur eine zu kleine
|
||||||
/// Stichprobe. Dieselbe Konstante wie GroupOverviewViewModel.AttendanceMinSampleSize.
|
/// Stichprobe. Dieselbe Konstante wie GroupOverviewViewModel.AttendanceMinSampleSize.
|
||||||
private const int AttendanceMinSampleSize = 8;
|
private const int AttendanceMinSampleSize = 8;
|
||||||
|
/// Nutzer-Feedback: "ich vergesse es, und fasse die App auch außerhalb der Schule manchmal
|
||||||
|
/// nicht mehr an" — die Erinnerung an fehlende Unterrichtszeit-Erfassung schaut deshalb nicht
|
||||||
|
/// nur auf heute zurück, sondern auf ein paar Tage, damit ein vergessener Tag nicht verloren
|
||||||
|
/// geht, sobald der nächste Schultag anbricht.
|
||||||
|
private const int MissingTeachingTimeLookbackDays = 14;
|
||||||
|
/// Für den heutigen Tag soll die Erinnerung nicht schon mitten im Unterricht auftauchen —
|
||||||
|
/// erst diese Zeitspanne nach dem laut Stundenplan letzten Unterrichtsende.
|
||||||
|
private const int MissingTeachingTimeTodayDelayMinutes = 30;
|
||||||
|
/// Gleiche Puffer-Idee wie TimeTrackingViewModel.ComputeTodaysTeachingWindow (bewusst hier
|
||||||
|
/// noch einmal definiert statt geteilt — der Vorschlag ist nur zwei Zeilen Rechnung).
|
||||||
|
private const int TeachingWindowBufferBeforeMinutes = 15;
|
||||||
|
private const int TeachingWindowBufferAfterMinutes = 10;
|
||||||
|
/// Vorausschau für die Klausurwochen-Karte (Nutzer-Feedback): weit genug, um eine sich
|
||||||
|
/// anbahnende Häufung noch rechtzeitig vor dem Anlegen weiterer Klausuren zu zeigen, aber
|
||||||
|
/// keine Vorschau auf das ganze Schuljahr.
|
||||||
|
private const int ExamLoadLookaheadDays = 60;
|
||||||
|
|
||||||
[ObservableProperty] private string _greeting = "";
|
[ObservableProperty] private string _greeting = "";
|
||||||
[ObservableProperty] private string _currentDate = "";
|
[ObservableProperty] private string _currentDate = "";
|
||||||
[ObservableProperty] private string _currentSchoolYear = "";
|
[ObservableProperty] private string _currentSchoolYear = "";
|
||||||
[ObservableProperty] private DateOnly _calendarMonth = FirstOfMonth(DateTime.Today);
|
[ObservableProperty] private DateOnly _calendarMonth = FirstOfMonth(DateTime.Today);
|
||||||
[ObservableProperty] private string _selectedDayLabel = "";
|
[ObservableProperty] private string _selectedDayLabel = "";
|
||||||
|
[ObservableProperty] private DateOnly _selectedCalendarDate = DateOnly.FromDateTime(DateTime.Today);
|
||||||
[ObservableProperty] private bool _isDashboardSettingsOpen;
|
[ObservableProperty] private bool _isDashboardSettingsOpen;
|
||||||
[ObservableProperty] private bool _isWeatherPanelVisible;
|
[ObservableProperty] private bool _isWeatherPanelVisible;
|
||||||
[ObservableProperty] private string _weatherSummary = "";
|
[ObservableProperty] private string _weatherSummary = "";
|
||||||
@@ -71,6 +89,8 @@ public partial class DashboardViewModel : ObservableObject
|
|||||||
public ObservableCollection<CalendarDayCell> CalendarDays { get; } = [];
|
public ObservableCollection<CalendarDayCell> CalendarDays { get; } = [];
|
||||||
public ObservableCollection<OpenExcuseItem> OpenExcuses { get; } = [];
|
public ObservableCollection<OpenExcuseItem> OpenExcuses { get; } = [];
|
||||||
public ObservableCollection<AttendanceWarningItem> AttendanceWarnings { get; } = [];
|
public ObservableCollection<AttendanceWarningItem> AttendanceWarnings { get; } = [];
|
||||||
|
public ObservableCollection<ExamWeekLoadItem> ExamWeekLoads { get; } = [];
|
||||||
|
public ObservableCollection<MissingTeachingTimeItem> MissingTeachingTimeEntries { get; } = [];
|
||||||
public ObservableCollection<SupportPlanDueItem> SupportPlanReviews { get; } = [];
|
public ObservableCollection<SupportPlanDueItem> SupportPlanReviews { get; } = [];
|
||||||
public ObservableCollection<UpcomingDateItem> UpcomingDates { get; } = [];
|
public ObservableCollection<UpcomingDateItem> UpcomingDates { get; } = [];
|
||||||
public ObservableCollection<CorrectionProgressItem> OpenCorrections { get; } = [];
|
public ObservableCollection<CorrectionProgressItem> OpenCorrections { get; } = [];
|
||||||
@@ -95,6 +115,10 @@ public partial class DashboardViewModel : ObservableObject
|
|||||||
// Sprungziel für eine ungeplante Stunde — führt direkt in den Verlaufsplan-Tab der Gruppe
|
// Sprungziel für eine ungeplante Stunde — führt direkt in den Verlaufsplan-Tab der Gruppe
|
||||||
// (nicht den Standard-Tab von OnNavigateToGroup), damit das Thema gleich ergänzt werden kann.
|
// (nicht den Standard-Tab von OnNavigateToGroup), damit das Thema gleich ergänzt werden kann.
|
||||||
public Action<Guid>? OnNavigateToUnplannedLesson { get; set; }
|
public Action<Guid>? OnNavigateToUnplannedLesson { get; set; }
|
||||||
|
// Öffnet den Nacherfassen-Dialog für einen Tag mit fehlender Unterrichtszeit-Erfassung
|
||||||
|
// (Nutzer-Feedback), vorbelegt mit Datum, Kategorie "Unterricht" und dem laut Stundenplan
|
||||||
|
// berechneten Zeitfenster — echtes Speichern bleibt eine bewusste Bestätigung im Dialog.
|
||||||
|
public Func<MissingTeachingTimeItem, Task>? OnAddMissingTeachingTime { get; set; }
|
||||||
|
|
||||||
public DashboardCardOption TodayCard => Card("today");
|
public DashboardCardOption TodayCard => Card("today");
|
||||||
public DashboardCardOption TasksCard => Card("tasks");
|
public DashboardCardOption TasksCard => Card("tasks");
|
||||||
@@ -105,8 +129,19 @@ public partial class DashboardViewModel : ObservableObject
|
|||||||
public DashboardCardOption UnplannedCard => Card("unplanned");
|
public DashboardCardOption UnplannedCard => Card("unplanned");
|
||||||
public DashboardCardOption AlertsCard => Card("alerts");
|
public DashboardCardOption AlertsCard => Card("alerts");
|
||||||
public DashboardCardOption AttendanceCard => Card("attendance");
|
public DashboardCardOption AttendanceCard => Card("attendance");
|
||||||
|
public DashboardCardOption ExamLoadCard => Card("examload");
|
||||||
|
public DashboardCardOption MissingTeachingTimeCard => Card("missingteachingtime");
|
||||||
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,
|
||||||
@@ -117,7 +152,8 @@ public partial class DashboardViewModel : ObservableObject
|
|||||||
PeriodScheduleService periodSchedule, AttendanceBalanceService attendanceBalance, SchoolYearService sy,
|
PeriodScheduleService periodSchedule, AttendanceBalanceService attendanceBalance, SchoolYearService sy,
|
||||||
DashboardSettingsService dashboardSettings, ISchoolHolidayRepository schoolHolidays,
|
DashboardSettingsService dashboardSettings, ISchoolHolidayRepository schoolHolidays,
|
||||||
PublicHolidayService publicHolidays, SchoolCalendarSettingsService calendarSettings,
|
PublicHolidayService publicHolidays, SchoolCalendarSettingsService calendarSettings,
|
||||||
ISubstitutionEntryRepository substitutions, IAnnualPlanEventRepository? annualPlanEvents = null,
|
ISubstitutionEntryRepository substitutions, ITimeEntryRepository timeEntries,
|
||||||
|
IAnnualPlanEventRepository? annualPlanEvents = null,
|
||||||
AnnualPlanSyncService? annualPlanSync = null, SchoolWeatherService? schoolWeather = null)
|
AnnualPlanSyncService? annualPlanSync = null, SchoolWeatherService? schoolWeather = null)
|
||||||
{
|
{
|
||||||
_groups = groups; _subjects = subjects; _lessons = lessons; _exams = exams; _tasks = tasks;
|
_groups = groups; _subjects = subjects; _lessons = lessons; _exams = exams; _tasks = tasks;
|
||||||
@@ -127,6 +163,7 @@ public partial class DashboardViewModel : ObservableObject
|
|||||||
_timetableSlots = timetableSlots; _periodSchedule = periodSchedule;
|
_timetableSlots = timetableSlots; _periodSchedule = periodSchedule;
|
||||||
_attendanceBalance = attendanceBalance; _sy = sy; _dashboardSettings = dashboardSettings;
|
_attendanceBalance = attendanceBalance; _sy = sy; _dashboardSettings = dashboardSettings;
|
||||||
_schoolHolidays = schoolHolidays; _publicHolidays = publicHolidays; _calendarSettings = calendarSettings;
|
_schoolHolidays = schoolHolidays; _publicHolidays = publicHolidays; _calendarSettings = calendarSettings;
|
||||||
|
_timeEntries = timeEntries;
|
||||||
_substitutions = substitutions;
|
_substitutions = substitutions;
|
||||||
_annualPlanEvents = annualPlanEvents;
|
_annualPlanEvents = annualPlanEvents;
|
||||||
_schoolWeather = schoolWeather;
|
_schoolWeather = schoolWeather;
|
||||||
@@ -187,7 +224,7 @@ public partial class DashboardViewModel : ObservableObject
|
|||||||
IsHighPriority = t.Priority == TaskPriority.High });
|
IsHighPriority = t.Priority == TaskPriority.High });
|
||||||
|
|
||||||
CurrentGroups.Clear();
|
CurrentGroups.Clear();
|
||||||
foreach (var g in groups.Values.OrderBy(g => g.Name))
|
foreach (var g in groups.Values)
|
||||||
CurrentGroups.Add(new()
|
CurrentGroups.Add(new()
|
||||||
{
|
{
|
||||||
GroupId = g.Id,
|
GroupId = g.Id,
|
||||||
@@ -199,11 +236,40 @@ public partial class DashboardViewModel : ObservableObject
|
|||||||
LoadCalendar();
|
LoadCalendar();
|
||||||
LoadOpenExcuses(groups.Values.ToList(), today);
|
LoadOpenExcuses(groups.Values.ToList(), today);
|
||||||
LoadAttendanceWarnings(today);
|
LoadAttendanceWarnings(today);
|
||||||
|
LoadExamWeekLoads(groups, today);
|
||||||
|
LoadMissingTeachingTime(today);
|
||||||
LoadSupportPlanReviews(today);
|
LoadSupportPlanReviews(today);
|
||||||
LoadUpcomingDates(groups, today);
|
LoadUpcomingDates(groups, today);
|
||||||
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;
|
||||||
|
ExamLoadCard.IsEmpty = ExamWeekLoads.Count == 0;
|
||||||
|
MissingTeachingTimeCard.IsEmpty = MissingTeachingTimeEntries.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()
|
||||||
@@ -266,12 +332,16 @@ public partial class DashboardViewModel : ObservableObject
|
|||||||
var from = _sy.SchoolYearStart(schoolYear);
|
var from = _sy.SchoolYearStart(schoolYear);
|
||||||
var to = _sy.SchoolYearEnd(schoolYear);
|
var to = _sy.SchoolYearEnd(schoolYear);
|
||||||
|
|
||||||
|
// Ein Bulk-Laden aller Sitzungen vermeidet, für jeden Mitarbeits-Eintrag jedes Schülers
|
||||||
|
// einzeln GetById aufzurufen (N+1 bei vielen Schülern/Einträgen).
|
||||||
|
var sessionDates = _participationSessions.GetAll().ToDictionary(s => s.Id, s => s.Date);
|
||||||
|
|
||||||
var items = new List<AttendanceWarningItem>();
|
var items = new List<AttendanceWarningItem>();
|
||||||
foreach (var student in _students.GetAll())
|
foreach (var student in _students.GetAll())
|
||||||
{
|
{
|
||||||
var entries = _participationEntries.GetByStudent(student.Id)
|
var entries = _participationEntries.GetByStudent(student.Id)
|
||||||
.Select(e => _participationSessions.GetById(e.SessionId) is { } session
|
.Select(e => sessionDates.TryGetValue(e.SessionId, out var date)
|
||||||
? ((DateOnly?)session.Date, e.Attendance) : (null, e.Attendance))
|
? ((DateOnly?)date, e.Attendance) : (null, e.Attendance))
|
||||||
.Where(t => t.Item1.HasValue)
|
.Where(t => t.Item1.HasValue)
|
||||||
.Select(t => (t.Item1!.Value, t.Attendance));
|
.Select(t => (t.Item1!.Value, t.Attendance));
|
||||||
|
|
||||||
@@ -283,6 +353,75 @@ public partial class DashboardViewModel : ObservableObject
|
|||||||
AttendanceWarnings.Add(item);
|
AttendanceWarnings.Add(item);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Klausurwochen (Nutzer-Feedback) ───────────────────────────────────────
|
||||||
|
//
|
||||||
|
// Persönliche Klausurlast über alle Kurse hinweg — anders als die klassenbezogene
|
||||||
|
// Kollisionsprüfung, die bereits der schulische Klausurplaner übernimmt (siehe
|
||||||
|
// ExamWeekLoadService). Nur geplante, noch bevorstehende Klausuren zählen: die
|
||||||
|
// Vorausschau soll helfen, eine sich anbahnende Woche zu erkennen, bevor man eine weitere
|
||||||
|
// Klausur genau dort einträgt — bereits durchgeführte/korrigierte Klausuren werden schon
|
||||||
|
// von der Karte "Offene Korrekturen" abgedeckt.
|
||||||
|
|
||||||
|
private void LoadExamWeekLoads(IReadOnlyDictionary<Guid, LearningGroup> groups, DateOnly today)
|
||||||
|
{
|
||||||
|
ExamWeekLoads.Clear();
|
||||||
|
var lastDay = today.AddDays(ExamLoadLookaheadDays);
|
||||||
|
var upcoming = groups.Keys
|
||||||
|
.SelectMany(gid => _exams.GetByGroup(gid))
|
||||||
|
.Where(e => e.Status == ExamStatus.Planned && e.Date >= today && e.Date <= lastDay);
|
||||||
|
|
||||||
|
foreach (var week in ExamWeekLoadService.FindOverloadedWeeks(upcoming))
|
||||||
|
ExamWeekLoads.Add(new ExamWeekLoadItem(week.IsoWeek, week.WeekStart, week.WeekEnd, week.ExamCount));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Unterrichtszeit-Erfassung nachholen (Nutzer-Feedback) ─────────────────
|
||||||
|
//
|
||||||
|
// "Ich vergesse es, und fasse die App außerhalb der Schule manchmal nicht mehr an" — schaut
|
||||||
|
// deshalb bewusst ein paar Tage zurück statt nur auf heute. Ein Tag zählt als erfasst, sobald
|
||||||
|
// irgendein TimeEntry der Kategorie "Unterricht" an diesem Datum existiert; die genaue
|
||||||
|
// Zeitüberschneidung mit dem Stundenplan wird nicht geprüft (dieselbe grobe Betrachtung wie
|
||||||
|
// beim bestehenden "Unterrichtszeit übernehmen"-Vorschlag für heute).
|
||||||
|
|
||||||
|
private void LoadMissingTeachingTime(DateOnly today)
|
||||||
|
{
|
||||||
|
MissingTeachingTimeEntries.Clear();
|
||||||
|
var firstDay = today.AddDays(-MissingTeachingTimeLookbackDays);
|
||||||
|
var publicHolidayDates = Enumerable.Range(firstDay.Year, today.Year - firstDay.Year + 1)
|
||||||
|
.SelectMany(y => _publicHolidays.GetHolidays(y, _calendarSettings.State))
|
||||||
|
.Select(h => h.Date).ToHashSet();
|
||||||
|
var schoolHolidays = _schoolHolidays.GetAll();
|
||||||
|
var nowTime = TimeOnly.FromDateTime(DateTime.Now);
|
||||||
|
|
||||||
|
var items = new List<MissingTeachingTimeItem>();
|
||||||
|
for (var date = firstDay; date <= today; date = date.AddDays(1))
|
||||||
|
{
|
||||||
|
if (IsFreeDay(date, schoolHolidays, publicHolidayDates)) continue;
|
||||||
|
|
||||||
|
var daySlots = _timetableSlots.GetAll().Where(s => s.Weekday == date.DayOfWeek).ToList();
|
||||||
|
if (daySlots.Count == 0) continue;
|
||||||
|
|
||||||
|
var cancelledPeriods = _substitutions.GetByDate(date)
|
||||||
|
.Where(s => s.Kind == SubstitutionKind.Cancelled)
|
||||||
|
.Select(s => s.PeriodNumber).ToHashSet();
|
||||||
|
var periodTimes = daySlots.Where(s => !cancelledPeriods.Contains(s.PeriodNumber))
|
||||||
|
.Select(s => _periodSchedule.GetTimes(s.PeriodNumber))
|
||||||
|
.Where(t => t is not null).Select(t => t!.Value).ToList();
|
||||||
|
if (periodTimes.Count == 0) continue; // alle Stunden entfallen oder kein Zeitraster hinterlegt
|
||||||
|
|
||||||
|
var lastPeriodEnd = periodTimes.Max(t => t.End);
|
||||||
|
if (date == today && nowTime < lastPeriodEnd.AddMinutes(MissingTeachingTimeTodayDelayMinutes))
|
||||||
|
continue; // heute: erst 30 Minuten nach Unterrichtsschluss erinnern
|
||||||
|
|
||||||
|
if (_timeEntries.GetByDate(date).Any(e => e.Category == "Unterricht")) continue;
|
||||||
|
|
||||||
|
var windowStart = periodTimes.Min(t => t.Start).AddMinutes(-TeachingWindowBufferBeforeMinutes);
|
||||||
|
var windowEnd = lastPeriodEnd.AddMinutes(TeachingWindowBufferAfterMinutes);
|
||||||
|
items.Add(new MissingTeachingTimeItem(date, windowStart, windowEnd));
|
||||||
|
}
|
||||||
|
foreach (var item in items.OrderBy(i => i.Date))
|
||||||
|
MissingTeachingTimeEntries.Add(item);
|
||||||
|
}
|
||||||
|
|
||||||
// ── Förderplan-Wiedervorlage (5.3.2) ──────────────────────────────────────
|
// ── Förderplan-Wiedervorlage (5.3.2) ──────────────────────────────────────
|
||||||
|
|
||||||
private void LoadSupportPlanReviews(DateOnly today)
|
private void LoadSupportPlanReviews(DateOnly today)
|
||||||
@@ -352,12 +491,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));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -510,7 +647,8 @@ public partial class DashboardViewModel : ObservableObject
|
|||||||
"excuses" => "Offene Entschuldigungen", "upcoming" => "Anstehende Termine",
|
"excuses" => "Offene Entschuldigungen", "upcoming" => "Anstehende Termine",
|
||||||
"corrections" => "Offene Korrekturen", "unplanned" => "Ungeplante Stunden", "alerts" => "Auffälligkeiten",
|
"corrections" => "Offene Korrekturen", "unplanned" => "Ungeplante Stunden", "alerts" => "Auffälligkeiten",
|
||||||
"attendance" => "Fehlzeiten-Warnung", "support" => "Förderplan-Wiedervorlage",
|
"attendance" => "Fehlzeiten-Warnung", "support" => "Förderplan-Wiedervorlage",
|
||||||
"groups" => "Meine Lerngruppen", _ => key,
|
"groups" => "Meine Lerngruppen", "examload" => "Klausurwochen",
|
||||||
|
"missingteachingtime" => "Unterrichtszeit nacherfassen", _ => key,
|
||||||
};
|
};
|
||||||
|
|
||||||
private void ApplyCardLayout()
|
private void ApplyCardLayout()
|
||||||
@@ -560,6 +698,15 @@ public partial class DashboardViewModel : ObservableObject
|
|||||||
[RelayCommand] private void OpenStudentSupportPlan(SupportPlanDueItem? item)
|
[RelayCommand] private void OpenStudentSupportPlan(SupportPlanDueItem? item)
|
||||||
{ if (item is not null) OnNavigateToStudent?.Invoke(item.StudentId); }
|
{ if (item is not null) OnNavigateToStudent?.Invoke(item.StudentId); }
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
private async Task AddMissingTeachingTime(MissingTeachingTimeItem? item)
|
||||||
|
{
|
||||||
|
if (item is null || OnAddMissingTeachingTime is null) return;
|
||||||
|
await OnAddMissingTeachingTime(item);
|
||||||
|
LoadMissingTeachingTime(DateOnly.FromDateTime(DateTime.Today));
|
||||||
|
UpdateDashboardSummary();
|
||||||
|
}
|
||||||
|
|
||||||
private void LoadOpenExcuses(List<LearningGroup> groups, DateOnly today)
|
private void LoadOpenExcuses(List<LearningGroup> groups, DateOnly today)
|
||||||
{
|
{
|
||||||
OpenExcuses.Clear();
|
OpenExcuses.Clear();
|
||||||
@@ -592,6 +739,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()
|
||||||
@@ -716,9 +864,35 @@ public partial class DashboardViewModel : ObservableObject
|
|||||||
{
|
{
|
||||||
if (day is null) return;
|
if (day is null) return;
|
||||||
foreach (var cell in CalendarDays) cell.IsSelected = cell == day;
|
foreach (var cell in CalendarDays) cell.IsSelected = cell == day;
|
||||||
|
SelectedCalendarDate = day.Date;
|
||||||
SelectedDayLabel = day.Date.ToString("dddd, d. MMMM", De);
|
SelectedDayLabel = day.Date.ToString("dddd, d. MMMM", De);
|
||||||
SelectedDayEvents.Clear();
|
SelectedDayEvents.Clear();
|
||||||
foreach (var item in day.Events) SelectedDayEvents.Add(item);
|
foreach (var item in day.Events) SelectedDayEvents.Add(item);
|
||||||
|
SortCurrentGroups(day.Date);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void SortCurrentGroups(DateOnly date)
|
||||||
|
{
|
||||||
|
var schoolHolidays = _schoolHolidays.GetAll();
|
||||||
|
var publicHolidays = _publicHolidays.GetHolidays(date.Year, _calendarSettings.State)
|
||||||
|
.Select(h => h.Date).ToHashSet();
|
||||||
|
var isFreeDay = IsFreeDay(date, schoolHolidays, publicHolidays);
|
||||||
|
var cancelledPeriods = _substitutions.GetByDate(date)
|
||||||
|
.Where(s => s.Kind == SubstitutionKind.Cancelled)
|
||||||
|
.Select(s => s.PeriodNumber).ToHashSet();
|
||||||
|
|
||||||
|
foreach (var chip in CurrentGroups)
|
||||||
|
{
|
||||||
|
var hasLesson = _lessons.GetByGroupAndDate(chip.GroupId, date).Count > 0;
|
||||||
|
var hasActiveSlot = !isFreeDay && _timetableSlots.GetByGroup(chip.GroupId)
|
||||||
|
.Any(s => s.Weekday == date.DayOfWeek && !cancelledPeriods.Contains(s.PeriodNumber));
|
||||||
|
chip.IsOnSelectedDay = hasLesson || hasActiveSlot;
|
||||||
|
}
|
||||||
|
|
||||||
|
var sorted = CurrentGroups.OrderByDescending(g => g.IsOnSelectedDay)
|
||||||
|
.ThenBy(g => g.Name, StringComparer.CurrentCultureIgnoreCase).ToList();
|
||||||
|
CurrentGroups.Clear();
|
||||||
|
foreach (var chip in sorted) CurrentGroups.Add(chip);
|
||||||
}
|
}
|
||||||
|
|
||||||
[RelayCommand]
|
[RelayCommand]
|
||||||
@@ -858,7 +1032,13 @@ public class LessonItem
|
|||||||
public bool HasRoom => !string.IsNullOrWhiteSpace(Room);
|
public bool HasRoom => !string.IsNullOrWhiteSpace(Room);
|
||||||
}
|
}
|
||||||
public class TaskItem { public string Title { get; set; } = ""; public string DueDate { get; set; } = ""; public bool IsOverdue { get; set; } public bool IsReminder { get; set; } public bool IsHighPriority { get; set; } }
|
public class TaskItem { public string Title { get; set; } = ""; public string DueDate { get; set; } = ""; public bool IsOverdue { get; set; } public bool IsReminder { get; set; } public bool IsHighPriority { get; set; } }
|
||||||
public class GroupChip { public Guid GroupId { get; set; } public string Name { get; set; } = ""; public string Subject { get; set; } = ""; }
|
public class GroupChip
|
||||||
|
{
|
||||||
|
public Guid GroupId { get; set; }
|
||||||
|
public string Name { get; set; } = "";
|
||||||
|
public string Subject { get; set; } = "";
|
||||||
|
public bool IsOnSelectedDay { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
// ── Offene Entschuldigungen (aus Mitarbeit-Fehltagen) ────────────────────────
|
// ── Offene Entschuldigungen (aus Mitarbeit-Fehltagen) ────────────────────────
|
||||||
|
|
||||||
@@ -896,6 +1076,23 @@ public class AttendanceWarningItem(Guid studentId, string studentName, double ab
|
|||||||
public double AbsenceRatePercent { get; } = absenceRatePercent;
|
public double AbsenceRatePercent { get; } = absenceRatePercent;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public class ExamWeekLoadItem(int isoWeek, DateOnly weekStart, DateOnly weekEnd, int examCount)
|
||||||
|
{
|
||||||
|
public int ExamCount { get; } = examCount;
|
||||||
|
public string Label => $"KW {isoWeek} ({weekStart:dd.MM.}–{weekEnd:dd.MM.})";
|
||||||
|
public string CountDisplay => ExamCount == 1 ? "1 Klausur" : $"{ExamCount} Klausuren";
|
||||||
|
}
|
||||||
|
|
||||||
|
public class MissingTeachingTimeItem(DateOnly date, TimeOnly windowStart, TimeOnly windowEnd)
|
||||||
|
{
|
||||||
|
private static readonly CultureInfo De = new("de-DE");
|
||||||
|
|
||||||
|
public DateOnly Date { get; } = date;
|
||||||
|
public TimeOnly WindowStart { get; } = windowStart;
|
||||||
|
public TimeOnly WindowEnd { get; } = windowEnd;
|
||||||
|
public string DateDisplay { get; } = date.ToString("dddd, dd.MM.", De);
|
||||||
|
}
|
||||||
|
|
||||||
// ── Förderplan-Wiedervorlage (5.3.2) ──────────────────────────────────────────
|
// ── Förderplan-Wiedervorlage (5.3.2) ──────────────────────────────────────────
|
||||||
|
|
||||||
public class SupportPlanDueItem
|
public class SupportPlanDueItem
|
||||||
@@ -1034,18 +1231,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,259 @@
|
|||||||
|
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 Action? OnNavigateToGroups { get; set; }
|
||||||
|
public bool HasNoExams => Rows.Count == 0;
|
||||||
|
|
||||||
|
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." : "";
|
||||||
|
OnPropertyChanged(nameof(HasNoExams));
|
||||||
|
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 GoToGroups() => OnNavigateToGroups?.Invoke();
|
||||||
|
|
||||||
|
[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,215 @@
|
|||||||
|
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 ISubjectRepository _subjects;
|
||||||
|
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 Func<Task>? OnQuickAddGroupDocumentation { get; set; }
|
||||||
|
public Action? OnClose { get; set; }
|
||||||
|
|
||||||
|
public GlobalSearchViewModel(IStudentRepository students, IGroupRepository groups,
|
||||||
|
ISubjectRepository subjects, IExamRepository exams, IWorkTaskRepository tasks)
|
||||||
|
{
|
||||||
|
_students = students;
|
||||||
|
_groups = groups;
|
||||||
|
_subjects = subjects;
|
||||||
|
_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().Where(g => g.IsActive).ToList();
|
||||||
|
var groupNames = groups.ToDictionary(g => g.Id, g => g.Name);
|
||||||
|
var subjects = _subjects.GetAll().ToDictionary(s => s.Id);
|
||||||
|
Subject? GroupSubject(LearningGroup group) => group.SubjectId is { } subjectId
|
||||||
|
? subjects.GetValueOrDefault(subjectId)
|
||||||
|
: null;
|
||||||
|
static string SubjectLabel(Subject? subject) => subject is null ? ""
|
||||||
|
: string.IsNullOrWhiteSpace(subject.ShortName)
|
||||||
|
|| subject.Name.Equals(subject.ShortName, StringComparison.CurrentCultureIgnoreCase)
|
||||||
|
? subject.Name
|
||||||
|
: $"{subject.ShortName} – {subject.Name}";
|
||||||
|
var candidates = new List<GlobalSearchResult>();
|
||||||
|
|
||||||
|
candidates.AddRange(_students.GetAll().Where(s => s.IsActive)
|
||||||
|
.Where(s => Matches(s.FullName, query))
|
||||||
|
.Select(s => GlobalSearchResult.ForStudent(s)));
|
||||||
|
|
||||||
|
candidates.AddRange(groups
|
||||||
|
.Where(g => Matches($"{g.Name} {GroupSubject(g)?.Name} {GroupSubject(g)?.ShortName} {g.SchoolYear} {g.GradeLevel}", query))
|
||||||
|
.Select(g => GlobalSearchResult.ForGroup(g, SubjectLabel(GroupSubject(g)))));
|
||||||
|
|
||||||
|
candidates.AddRange(_exams.GetAll()
|
||||||
|
.Where(e => groupNames.ContainsKey(e.GroupId))
|
||||||
|
.Where(e => Matches($"{e.Title} {groupNames.GetValueOrDefault(e.GroupId)}", query))
|
||||||
|
.Select(e => GlobalSearchResult.ForExam(e, groupNames.GetValueOrDefault(e.GroupId) ?? "")));
|
||||||
|
|
||||||
|
candidates.AddRange(_tasks.GetAll()
|
||||||
|
.Where(t => t.GroupId is null || groupNames.ContainsKey(t.GroupId.Value))
|
||||||
|
.Where(t => Matches($"{t.Title} {t.Notes} {groupNames.GetValueOrDefault(t.GroupId ?? Guid.Empty)}", query))
|
||||||
|
.Select(t => GlobalSearchResult.ForTask(t, groupNames.GetValueOrDefault(t.GroupId ?? Guid.Empty) ?? "")));
|
||||||
|
|
||||||
|
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", "+"),
|
||||||
|
GlobalSearchResult.ForAction(GlobalSearchAction.NewGroupDocumentation, "Lerngruppen-Eintrag", "Planung, Klausur oder Erinnerung dokumentieren", "N"),
|
||||||
|
};
|
||||||
|
|
||||||
|
foreach (var action in actions.Where(a => query.Length == 0 || Matches(a.Title, query)))
|
||||||
|
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;
|
||||||
|
case GlobalSearchAction.NewGroupDocumentation:
|
||||||
|
if (OnQuickAddGroupDocumentation is not null) await OnQuickAddGroupDocumentation();
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
OnNavigate?.Invoke(result);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
OnClose?.Invoke();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum GlobalSearchResultKind { Action, Student, Group, Exam, Task }
|
||||||
|
public enum GlobalSearchAction { None, NewTask, NewReminder, NewStudent, NewGroupDocumentation }
|
||||||
|
|
||||||
|
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, string subjectName) => new()
|
||||||
|
{
|
||||||
|
Kind = GlobalSearchResultKind.Group, EntityId = group.Id, GroupId = group.Id,
|
||||||
|
Title = group.Name,
|
||||||
|
Subtitle = string.Join(" · ", new[] { subjectName, group.SchoolYear, $"Stufe {group.GradeLevel}" }
|
||||||
|
.Where(x => !string.IsNullOrWhiteSpace(x))),
|
||||||
|
Icon = "G",
|
||||||
|
};
|
||||||
|
|
||||||
|
public static GlobalSearchResult ForExam(Exam exam, string groupName) => new()
|
||||||
|
{
|
||||||
|
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 = "";
|
||||||
@@ -31,6 +32,7 @@ public partial class ExamDialogViewModel : ObservableObject
|
|||||||
[ObservableProperty] private string _titleError = "";
|
[ObservableProperty] private string _titleError = "";
|
||||||
[ObservableProperty] private string _dateTextError = "";
|
[ObservableProperty] private string _dateTextError = "";
|
||||||
[ObservableProperty] private string _returnedAtTextError = "";
|
[ObservableProperty] private string _returnedAtTextError = "";
|
||||||
|
[ObservableProperty] private string _weekLoadHint = "";
|
||||||
[ObservableProperty] private double _totalPoints;
|
[ObservableProperty] private double _totalPoints;
|
||||||
[ObservableProperty] private bool _hasCompetencyCatalog;
|
[ObservableProperty] private bool _hasCompetencyCatalog;
|
||||||
[ObservableProperty] private bool _useWeighting;
|
[ObservableProperty] private bool _useWeighting;
|
||||||
@@ -68,6 +70,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;
|
||||||
|
|
||||||
@@ -106,6 +109,7 @@ public partial class ExamDialogViewModel : ObservableObject
|
|||||||
}
|
}
|
||||||
foreach (var t in Tasks) t.ShowWeight = UseWeighting;
|
foreach (var t in Tasks) t.ShowWeight = UseWeighting;
|
||||||
RecomputeTotals();
|
RecomputeTotals();
|
||||||
|
UpdateWeekLoadHint();
|
||||||
}
|
}
|
||||||
|
|
||||||
partial void OnUseWeightingChanged(bool value)
|
partial void OnUseWeightingChanged(bool value)
|
||||||
@@ -113,6 +117,26 @@ public partial class ExamDialogViewModel : ObservableObject
|
|||||||
foreach (var t in Tasks) t.ShowWeight = value;
|
foreach (var t in Tasks) t.ShowWeight = value;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
partial void OnDateTextChanged(string value) => UpdateWeekLoadHint();
|
||||||
|
|
||||||
|
/// Nutzer-Feedback: persönliche Klausurlast pro Woche — die klassenbezogene
|
||||||
|
/// Kollisionsprüfung übernimmt bereits der schulische Klausurplaner (siehe
|
||||||
|
/// ExamWeekLoadService), hier geht es um die eigene Häufung über alle Kurse hinweg.
|
||||||
|
private void UpdateWeekLoadHint()
|
||||||
|
{
|
||||||
|
if (!DateOnly.TryParseExact(DateText, "dd.MM.yyyy", null, DateTimeStyles.None, out var date))
|
||||||
|
{
|
||||||
|
WeekLoadHint = "";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var existing = ExamWeekLoadService.CountInSameWeek(_exams.GetAll(), date, _editingExam?.Id);
|
||||||
|
var totalIfSaved = existing + 1;
|
||||||
|
WeekLoadHint = totalIfSaved >= ExamWeekLoadService.WarningThreshold
|
||||||
|
? $"Damit hättest du {totalIfSaved} Klausuren in dieser Woche."
|
||||||
|
: "";
|
||||||
|
}
|
||||||
|
|
||||||
[RelayCommand]
|
[RelayCommand]
|
||||||
private void AddTask()
|
private void AddTask()
|
||||||
{
|
{
|
||||||
@@ -304,6 +328,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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -54,6 +54,7 @@ public partial class GradeOverviewTabViewModel : ObservableObject
|
|||||||
public Func<GradeOverviewRow, Task>? OnManageStudentGrades { get; set; }
|
public Func<GradeOverviewRow, Task>? OnManageStudentGrades { get; set; }
|
||||||
public Func<Task>? OnCollectiveGrade { get; set; }
|
public Func<Task>? OnCollectiveGrade { get; set; }
|
||||||
public Func<Task>? OnReportGrades { get; set; }
|
public Func<Task>? OnReportGrades { get; set; }
|
||||||
|
public Func<GradeOverviewRow, Task>? OnShowPerformanceOverview { get; set; }
|
||||||
|
|
||||||
public GradeOverviewTabViewModel(IGradeRepository grades, IExamRepository exams,
|
public GradeOverviewTabViewModel(IGradeRepository grades, IExamRepository exams,
|
||||||
IExamResultRepository results, IStudentRepository students,
|
IExamResultRepository results, IStudentRepository students,
|
||||||
@@ -107,6 +108,14 @@ public partial class GradeOverviewTabViewModel : ObservableObject
|
|||||||
Recompute();
|
Recompute();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
private async Task ShowPerformanceOverview()
|
||||||
|
{
|
||||||
|
if (SelectedRow is null || OnShowPerformanceOverview is null) return;
|
||||||
|
await OnShowPerformanceOverview(SelectedRow);
|
||||||
|
Recompute();
|
||||||
|
}
|
||||||
|
|
||||||
[RelayCommand]
|
[RelayCommand]
|
||||||
private async Task CollectiveGrade()
|
private async Task CollectiveGrade()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ public partial class GroupDocumentationTabViewModel : ObservableObject
|
|||||||
private List<StudentOption> _groupStudents = [];
|
private List<StudentOption> _groupStudents = [];
|
||||||
|
|
||||||
public static readonly StudentOption AllStudentsOption = new(Guid.Empty, "Alle Schüler");
|
public static readonly StudentOption AllStudentsOption = new(Guid.Empty, "Alle Schüler");
|
||||||
|
public static readonly StudentOption WholeGroupOption = new(Guid.Empty, "Gesamte Lerngruppe");
|
||||||
|
|
||||||
public ObservableCollection<DocumentationItem> Entries { get; } = [];
|
public ObservableCollection<DocumentationItem> Entries { get; } = [];
|
||||||
public ObservableCollection<StudentOption> StudentFilterOptions { get; } = [AllStudentsOption];
|
public ObservableCollection<StudentOption> StudentFilterOptions { get; } = [AllStudentsOption];
|
||||||
@@ -62,8 +63,6 @@ public partial class GroupDocumentationTabViewModel : ObservableObject
|
|||||||
private void Load()
|
private void Load()
|
||||||
{
|
{
|
||||||
Entries.Clear();
|
Entries.Clear();
|
||||||
if (_groupStudents.Count == 0) return;
|
|
||||||
|
|
||||||
var studentNameById = _groupStudents.ToDictionary(s => s.Id, s => s.Name);
|
var studentNameById = _groupStudents.ToDictionary(s => s.Id, s => s.Name);
|
||||||
var groupNameCache = new Dictionary<Guid, string>();
|
var groupNameCache = new Dictionary<Guid, string>();
|
||||||
string GroupLabel(Guid id)
|
string GroupLabel(Guid id)
|
||||||
@@ -80,6 +79,10 @@ public partial class GroupDocumentationTabViewModel : ObservableObject
|
|||||||
|
|
||||||
var all = relevantStudentIds
|
var all = relevantStudentIds
|
||||||
.SelectMany(id => _docs.GetByStudent(id))
|
.SelectMany(id => _docs.GetByStudent(id))
|
||||||
|
.Concat(SelectedStudentFilter.Id == Guid.Empty
|
||||||
|
? _docs.GetAll().Where(d => d.StudentId == Guid.Empty && d.GroupId == _groupId)
|
||||||
|
: [])
|
||||||
|
.DistinctBy(d => d.Id)
|
||||||
.Where(d => !OnlyThisGroup || d.GroupId == _groupId)
|
.Where(d => !OnlyThisGroup || d.GroupId == _groupId)
|
||||||
.OrderByDescending(d => d.IsDraft)
|
.OrderByDescending(d => d.IsDraft)
|
||||||
.ThenByDescending(d => d.Date)
|
.ThenByDescending(d => d.Date)
|
||||||
@@ -90,7 +93,10 @@ public partial class GroupDocumentationTabViewModel : ObservableObject
|
|||||||
{
|
{
|
||||||
var isOwnGroup = d.GroupId is null || d.GroupId == _groupId;
|
var isOwnGroup = d.GroupId is null || d.GroupId == _groupId;
|
||||||
var otherGroupLabel = isOwnGroup ? "" : GroupLabel(d.GroupId!.Value);
|
var otherGroupLabel = isOwnGroup ? "" : GroupLabel(d.GroupId!.Value);
|
||||||
Entries.Add(new DocumentationItem(d, studentNameById.GetValueOrDefault(d.StudentId, ""),
|
var studentName = d.StudentId == Guid.Empty
|
||||||
|
? WholeGroupOption.Name
|
||||||
|
: studentNameById.GetValueOrDefault(d.StudentId, "");
|
||||||
|
Entries.Add(new DocumentationItem(d, studentName,
|
||||||
isOwnGroup, otherGroupLabel));
|
isOwnGroup, otherGroupLabel));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -100,7 +106,7 @@ public partial class GroupDocumentationTabViewModel : ObservableObject
|
|||||||
[RelayCommand]
|
[RelayCommand]
|
||||||
private async Task AddDocumentation()
|
private async Task AddDocumentation()
|
||||||
{
|
{
|
||||||
if (OnEditDocumentation is null || _groupStudents.Count == 0) return;
|
if (OnEditDocumentation is null) return;
|
||||||
var result = await OnEditDocumentation(_groupId, _groupStudents, null);
|
var result = await OnEditDocumentation(_groupId, _groupStudents, null);
|
||||||
if (result is null) return;
|
if (result is null) return;
|
||||||
_docs.Save(result);
|
_docs.Save(result);
|
||||||
|
|||||||
@@ -33,12 +33,15 @@ public partial class GroupOverviewViewModel : ObservableObject
|
|||||||
|
|
||||||
private readonly ILessonRepository _lessons;
|
private readonly ILessonRepository _lessons;
|
||||||
private readonly IExamRepository _exams;
|
private readonly IExamRepository _exams;
|
||||||
|
private readonly IExamResultRepository _examResults;
|
||||||
private readonly IParticipationSessionRepository _sessions;
|
private readonly IParticipationSessionRepository _sessions;
|
||||||
private readonly IParticipationRepository _entries;
|
private readonly IParticipationRepository _entries;
|
||||||
private readonly IStudentRepository _students;
|
private readonly IStudentRepository _students;
|
||||||
private readonly IDocumentationRepository _documentation;
|
private readonly IDocumentationRepository _documentation;
|
||||||
private readonly IWorkTaskRepository _tasks;
|
private readonly IWorkTaskRepository _tasks;
|
||||||
|
private readonly IGroupRepository _groups;
|
||||||
private readonly AttendanceBalanceService _attendanceBalance;
|
private readonly AttendanceBalanceService _attendanceBalance;
|
||||||
|
private readonly GradingService _grading;
|
||||||
private readonly SchoolYearService _schoolYear;
|
private readonly SchoolYearService _schoolYear;
|
||||||
|
|
||||||
private Guid _groupId;
|
private Guid _groupId;
|
||||||
@@ -48,6 +51,8 @@ public partial class GroupOverviewViewModel : ObservableObject
|
|||||||
[ObservableProperty] private string _nextLessonLabel = "";
|
[ObservableProperty] private string _nextLessonLabel = "";
|
||||||
[ObservableProperty] private bool _hasNextExam;
|
[ObservableProperty] private bool _hasNextExam;
|
||||||
[ObservableProperty] private string _nextExamLabel = "";
|
[ObservableProperty] private string _nextExamLabel = "";
|
||||||
|
[ObservableProperty] private bool _hasYearComparison;
|
||||||
|
[ObservableProperty] private string _yearComparisonLabel = "";
|
||||||
[ObservableProperty] private string _participationHintLabel = "";
|
[ObservableProperty] private string _participationHintLabel = "";
|
||||||
[ObservableProperty] private bool _participationHintIsStale;
|
[ObservableProperty] private bool _participationHintIsStale;
|
||||||
[ObservableProperty] private bool _hasOpenHomeworkCheck;
|
[ObservableProperty] private bool _hasOpenHomeworkCheck;
|
||||||
@@ -105,13 +110,14 @@ public partial class GroupOverviewViewModel : ObservableObject
|
|||||||
}
|
}
|
||||||
|
|
||||||
public GroupOverviewViewModel(ILessonRepository lessons, IExamRepository exams,
|
public GroupOverviewViewModel(ILessonRepository lessons, IExamRepository exams,
|
||||||
IParticipationSessionRepository sessions, IParticipationRepository entries,
|
IExamResultRepository examResults, IParticipationSessionRepository sessions,
|
||||||
IStudentRepository students, IDocumentationRepository documentation, IWorkTaskRepository tasks,
|
IParticipationRepository entries, IStudentRepository students,
|
||||||
AttendanceBalanceService attendanceBalance, SchoolYearService schoolYear)
|
IDocumentationRepository documentation, IWorkTaskRepository tasks, IGroupRepository groups,
|
||||||
|
AttendanceBalanceService attendanceBalance, GradingService grading, SchoolYearService schoolYear)
|
||||||
{
|
{
|
||||||
_lessons = lessons; _exams = exams; _sessions = sessions;
|
_lessons = lessons; _exams = exams; _examResults = examResults; _sessions = sessions;
|
||||||
_entries = entries; _students = students; _documentation = documentation; _tasks = tasks;
|
_entries = entries; _students = students; _documentation = documentation; _tasks = tasks;
|
||||||
_attendanceBalance = attendanceBalance; _schoolYear = schoolYear;
|
_groups = groups; _attendanceBalance = attendanceBalance; _grading = grading; _schoolYear = schoolYear;
|
||||||
}
|
}
|
||||||
|
|
||||||
public void Initialize(Guid groupId, string groupName)
|
public void Initialize(Guid groupId, string groupName)
|
||||||
@@ -126,6 +132,7 @@ public partial class GroupOverviewViewModel : ObservableObject
|
|||||||
var today = DateOnly.FromDateTime(DateTime.Today);
|
var today = DateOnly.FromDateTime(DateTime.Today);
|
||||||
LoadNextLesson(today);
|
LoadNextLesson(today);
|
||||||
LoadNextExam(today);
|
LoadNextExam(today);
|
||||||
|
LoadYearComparison();
|
||||||
LoadParticipationHint(today);
|
LoadParticipationHint(today);
|
||||||
LoadOpenHomeworkCheck(today);
|
LoadOpenHomeworkCheck(today);
|
||||||
LoadOpenExcuses(today);
|
LoadOpenExcuses(today);
|
||||||
@@ -138,7 +145,7 @@ public partial class GroupOverviewViewModel : ObservableObject
|
|||||||
private void LoadNextLesson(DateOnly today)
|
private void LoadNextLesson(DateOnly today)
|
||||||
{
|
{
|
||||||
var next = _lessons.GetByGroupAndRange(_groupId, today, today.AddDays(NextLessonLookaheadDays))
|
var next = _lessons.GetByGroupAndRange(_groupId, today, today.AddDays(NextLessonLookaheadDays))
|
||||||
.Where(l => l.Status == LessonStatus.Planned)
|
.Where(l => l.Status != LessonStatus.Conducted)
|
||||||
.OrderBy(l => l.Date).ThenBy(l => l.LessonNumber ?? 0)
|
.OrderBy(l => l.Date).ThenBy(l => l.LessonNumber ?? 0)
|
||||||
.FirstOrDefault();
|
.FirstOrDefault();
|
||||||
|
|
||||||
@@ -156,6 +163,38 @@ public partial class GroupOverviewViewModel : ObservableObject
|
|||||||
NextExamLabel = next is null ? "" : $"{next.Date:dd.MM.yyyy} — {next.Title}";
|
NextExamLabel = next is null ? "" : $"{next.Date:dd.MM.yyyy} — {next.Title}";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Klausurschnitt-Vorjahresvergleich (Nutzer-Feedback) ──────────────────
|
||||||
|
//
|
||||||
|
// Setzt voraus, dass diese Gruppe über GroupRolloverService aus einer Vorgängergruppe
|
||||||
|
// hochgestuft wurde (LearningGroup.PreviousGroupId) — vor Einführung dieses Felds
|
||||||
|
// hochgestufte Gruppen bleiben unverknüpft und zeigen keinen Vergleich. Nur sichtbar, wenn
|
||||||
|
// im Vorjahr tatsächlich Klausurergebnisse vorliegen; ohne die ist kein Vergleich möglich,
|
||||||
|
// auch wenn eine Verknüpfung besteht.
|
||||||
|
|
||||||
|
private void LoadYearComparison()
|
||||||
|
{
|
||||||
|
var group = _groups.GetById(_groupId);
|
||||||
|
var previous = group?.PreviousGroupId is Guid previousId ? ComputeExamAverage(previousId) : null;
|
||||||
|
if (previous is null) { HasYearComparison = false; return; }
|
||||||
|
|
||||||
|
var current = ComputeExamAverage(_groupId);
|
||||||
|
YearComparisonLabel = current is null
|
||||||
|
? $"Vorjahr: Ø {previous.Value.Average:0.0} ({previous.Value.Count} Klausuren) — dieses Jahr noch keine Klausur."
|
||||||
|
: $"Dieses Jahr: Ø {current.Value.Average:0.0} ({current.Value.Count} Klausuren) · " +
|
||||||
|
$"Vorjahr: Ø {previous.Value.Average:0.0} ({previous.Value.Count} Klausuren)";
|
||||||
|
HasYearComparison = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private (double Average, int Count)? ComputeExamAverage(Guid groupId)
|
||||||
|
{
|
||||||
|
var grades = _exams.GetByGroup(groupId)
|
||||||
|
.SelectMany(e => _examResults.GetByExam(e.Id))
|
||||||
|
.Where(r => !r.Absent && !string.IsNullOrWhiteSpace(r.Grade))
|
||||||
|
.Select(r => (Grade: r.Grade!, Weight: 1.0))
|
||||||
|
.ToList();
|
||||||
|
return grades.Count == 0 ? null : (_grading.WeightedAverage(grades), grades.Count);
|
||||||
|
}
|
||||||
|
|
||||||
/// Erinnert nicht an eine Note, sondern schlicht daran, überhaupt wieder eine Sitzung
|
/// Erinnert nicht an eine Note, sondern schlicht daran, überhaupt wieder eine Sitzung
|
||||||
/// anzulegen — genau das vergisst man in Kursen, die man seltener unterrichtet, zuerst.
|
/// anzulegen — genau das vergisst man in Kursen, die man seltener unterrichtet, zuerst.
|
||||||
private void LoadParticipationHint(DateOnly today)
|
private void LoadParticipationHint(DateOnly today)
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -158,6 +158,7 @@ public partial class ParticipationTabViewModel : ObservableObject
|
|||||||
row.OnCompetencyRatingChanged = (sid, code, val) => SaveCompetencyRating(sessionId, sid, code, val);
|
row.OnCompetencyRatingChanged = (sid, code, val) => SaveCompetencyRating(sessionId, sid, code, val);
|
||||||
row.HomeworkChangedCallback = (sid, val) => SaveHomework(sessionId, sid, val);
|
row.HomeworkChangedCallback = (sid, val) => SaveHomework(sessionId, sid, val);
|
||||||
row.AttendanceChangedCallback = (sid, val) => SaveAttendance(sessionId, sid, val);
|
row.AttendanceChangedCallback = (sid, val) => SaveAttendance(sessionId, sid, val);
|
||||||
|
row.DayHighlightChangedCallback = (sid, val) => SaveDayHighlight(sessionId, sid, val);
|
||||||
StudentRows.Add(row);
|
StudentRows.Add(row);
|
||||||
}
|
}
|
||||||
QuickInputCommand.NotifyCanExecuteChanged();
|
QuickInputCommand.NotifyCanExecuteChanged();
|
||||||
@@ -212,6 +213,15 @@ public partial class ParticipationTabViewModel : ObservableObject
|
|||||||
_entries.Save(entry);
|
_entries.Save(entry);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void SaveDayHighlight(Guid sessionId, Guid studentId, DayHighlightKind? value)
|
||||||
|
{
|
||||||
|
if (IsReadOnly) return;
|
||||||
|
var entry = _entries.GetBySessionAndStudent(sessionId, studentId)
|
||||||
|
?? new ParticipationEntry { SessionId = sessionId, StudentId = studentId };
|
||||||
|
entry.DayHighlight = value;
|
||||||
|
_entries.Save(entry);
|
||||||
|
}
|
||||||
|
|
||||||
[RelayCommand]
|
[RelayCommand]
|
||||||
private void ToggleCompetencyTags() => CompetencyTagsVisible = !CompetencyTagsVisible;
|
private void ToggleCompetencyTags() => CompetencyTagsVisible = !CompetencyTagsVisible;
|
||||||
|
|
||||||
@@ -389,6 +399,7 @@ public partial class ParticipationStudentRow : ObservableObject
|
|||||||
|
|
||||||
[ObservableProperty] private HomeworkStatus? _homework;
|
[ObservableProperty] private HomeworkStatus? _homework;
|
||||||
[ObservableProperty] private AttendanceStatus? _attendance;
|
[ObservableProperty] private AttendanceStatus? _attendance;
|
||||||
|
[ObservableProperty] private DayHighlightKind? _dayHighlight;
|
||||||
|
|
||||||
public string AttendanceLabel => AttendanceDisplay.ShortLabel(Attendance);
|
public string AttendanceLabel => AttendanceDisplay.ShortLabel(Attendance);
|
||||||
public string AttendanceTooltip => AttendanceDisplay.Label(Attendance);
|
public string AttendanceTooltip => AttendanceDisplay.Label(Attendance);
|
||||||
@@ -405,6 +416,7 @@ public partial class ParticipationStudentRow : ObservableObject
|
|||||||
public Action<Guid, string, int?>? OnCompetencyRatingChanged { get; set; }
|
public Action<Guid, string, int?>? OnCompetencyRatingChanged { get; set; }
|
||||||
public Action<Guid, HomeworkStatus?>? HomeworkChangedCallback { get; set; }
|
public Action<Guid, HomeworkStatus?>? HomeworkChangedCallback { get; set; }
|
||||||
public Action<Guid, AttendanceStatus?>? AttendanceChangedCallback { get; set; }
|
public Action<Guid, AttendanceStatus?>? AttendanceChangedCallback { get; set; }
|
||||||
|
public Action<Guid, DayHighlightKind?>? DayHighlightChangedCallback { get; set; }
|
||||||
|
|
||||||
public ParticipationStudentRow(Guid id, string name, ParticipationEntry entry,
|
public ParticipationStudentRow(Guid id, string name, ParticipationEntry entry,
|
||||||
List<AspectColumnDef> aspects, List<string> competencyCodes)
|
List<AspectColumnDef> aspects, List<string> competencyCodes)
|
||||||
@@ -414,6 +426,7 @@ public partial class ParticipationStudentRow : ObservableObject
|
|||||||
_aspectDefs = aspects;
|
_aspectDefs = aspects;
|
||||||
_homework = HomeworkDisplay.Effective(entry);
|
_homework = HomeworkDisplay.Effective(entry);
|
||||||
_attendance = entry.Attendance;
|
_attendance = entry.Attendance;
|
||||||
|
_dayHighlight = entry.DayHighlight;
|
||||||
|
|
||||||
foreach (var a in aspects)
|
foreach (var a in aspects)
|
||||||
{
|
{
|
||||||
@@ -463,6 +476,12 @@ public partial class ParticipationStudentRow : ObservableObject
|
|||||||
HomeworkChangedCallback?.Invoke(StudentId, value);
|
HomeworkChangedCallback?.Invoke(StudentId, value);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public void SetDayHighlight(DayHighlightKind? value)
|
||||||
|
{
|
||||||
|
DayHighlight = value;
|
||||||
|
DayHighlightChangedCallback?.Invoke(StudentId, value);
|
||||||
|
}
|
||||||
|
|
||||||
[RelayCommand]
|
[RelayCommand]
|
||||||
private void CycleAttendance()
|
private void CycleAttendance()
|
||||||
{
|
{
|
||||||
@@ -614,6 +633,27 @@ public static class AttendanceDisplay
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Tagesflagge (Nutzer-Feedback) ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
public static class DayHighlightDisplay
|
||||||
|
{
|
||||||
|
public static string Symbol(DayHighlightKind? kind) => kind switch
|
||||||
|
{
|
||||||
|
DayHighlightKind.Standout => "👑",
|
||||||
|
DayHighlightKind.Sleepy => "😴",
|
||||||
|
DayHighlightKind.Rough => "⚡",
|
||||||
|
_ => "",
|
||||||
|
};
|
||||||
|
|
||||||
|
public static string Label(DayHighlightKind? kind) => kind switch
|
||||||
|
{
|
||||||
|
DayHighlightKind.Standout => "Spitzentag",
|
||||||
|
DayHighlightKind.Sleepy => "Schlaftag",
|
||||||
|
DayHighlightKind.Rough => "Schlechter Tag",
|
||||||
|
_ => "Keine Markierung",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
// ── Eine Bewertungszelle ──────────────────────────────────────────────────────
|
// ── Eine Bewertungszelle ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
public partial class RatingCell : ObservableObject
|
public partial class RatingCell : ObservableObject
|
||||||
@@ -800,12 +840,31 @@ public partial class QuickInputViewModel : ObservableObject
|
|||||||
[ObservableProperty] private string _progressText = "";
|
[ObservableProperty] private string _progressText = "";
|
||||||
[ObservableProperty] private bool _currentStudentIsAbsent;
|
[ObservableProperty] private bool _currentStudentIsAbsent;
|
||||||
[ObservableProperty] private string _currentStudentAttendanceLabel = "";
|
[ObservableProperty] private string _currentStudentAttendanceLabel = "";
|
||||||
|
[ObservableProperty] private DayHighlightKind? _currentStudentDayHighlight;
|
||||||
|
|
||||||
/// Dimmt Name/Aspektliste, wenn der aktuelle Schüler abwesend ist — kein Blockieren der
|
/// Dimmt Name/Aspektliste, wenn der aktuelle Schüler abwesend ist — kein Blockieren der
|
||||||
/// Eingabe (manche Bewertungssysteme wollen trotzdem einen Eintrag, z.B. "0 Punkte"), nur ein
|
/// Eingabe (manche Bewertungssysteme wollen trotzdem einen Eintrag, z.B. "0 Punkte"), nur ein
|
||||||
/// visueller Hinweis, dass eine Bewertung hier normalerweise keinen Sinn ergibt.
|
/// visueller Hinweis, dass eine Bewertung hier normalerweise keinen Sinn ergibt.
|
||||||
public double CurrentStudentContentOpacity => CurrentStudentIsAbsent ? 0.4 : 1.0;
|
public double CurrentStudentContentOpacity => CurrentStudentIsAbsent ? 0.4 : 1.0;
|
||||||
|
|
||||||
|
/// Tagesflagge (Nutzer-Feedback): Fallback für Kurse ohne Sitzplan — im Sitzplatz-Dialog gibt
|
||||||
|
/// es dieselbe Auswahl bereits über SeatAssessmentViewModel.DayHighlightChoices.
|
||||||
|
public string CurrentStudentDayHighlightSymbol => DayHighlightDisplay.Symbol(CurrentStudentDayHighlight);
|
||||||
|
public string CurrentStudentDayHighlightLabel => DayHighlightDisplay.Label(CurrentStudentDayHighlight);
|
||||||
|
|
||||||
|
public void SetDayHighlight(DayHighlightKind? value)
|
||||||
|
{
|
||||||
|
if (_rows.Count == 0) return;
|
||||||
|
_rows[StudentIndex].SetDayHighlight(value);
|
||||||
|
CurrentStudentDayHighlight = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
partial void OnCurrentStudentDayHighlightChanged(DayHighlightKind? value)
|
||||||
|
{
|
||||||
|
OnPropertyChanged(nameof(CurrentStudentDayHighlightSymbol));
|
||||||
|
OnPropertyChanged(nameof(CurrentStudentDayHighlightLabel));
|
||||||
|
}
|
||||||
|
|
||||||
public ObservableCollection<QuickAspectRow> AspectRows { get; } = [];
|
public ObservableCollection<QuickAspectRow> AspectRows { get; } = [];
|
||||||
|
|
||||||
/// Wechselt je nach Typ des aktuell gewählten Aspekts (3.1.3) — Scale3/Binary haben andere
|
/// Wechselt je nach Typ des aktuell gewählten Aspekts (3.1.3) — Scale3/Binary haben andere
|
||||||
@@ -824,7 +883,8 @@ public partial class QuickInputViewModel : ObservableObject
|
|||||||
_ => "1–5 bewerten",
|
_ => "1–5 bewerten",
|
||||||
};
|
};
|
||||||
return $"{ratingHint} · Q/W/E/R/T Aspekt wählen · Leertaste/↓ nächster Aspekt · ↑ vorheriger Aspekt · " +
|
return $"{ratingHint} · Q/W/E/R/T Aspekt wählen · Leertaste/↓ nächster Aspekt · ↑ vorheriger Aspekt · " +
|
||||||
"Enter/→ nächster Schüler · Backspace/← vorheriger Schüler · +/− anpassen · Esc schließen";
|
"Enter/→ nächster Schüler · Backspace/← vorheriger Schüler · +/− anpassen · Entf nicht bewertet · " +
|
||||||
|
"⇧1/2/3 Tagesflagge, ⇧0 löschen · Esc schließen";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -852,6 +912,7 @@ public partial class QuickInputViewModel : ObservableObject
|
|||||||
ProgressText = $"{index + 1} / {_rows.Count}";
|
ProgressText = $"{index + 1} / {_rows.Count}";
|
||||||
CurrentStudentIsAbsent = row.IsAbsent;
|
CurrentStudentIsAbsent = row.IsAbsent;
|
||||||
CurrentStudentAttendanceLabel = row.AttendanceTooltip;
|
CurrentStudentAttendanceLabel = row.AttendanceTooltip;
|
||||||
|
CurrentStudentDayHighlight = row.DayHighlight;
|
||||||
|
|
||||||
AspectRows.Clear();
|
AspectRows.Clear();
|
||||||
foreach (var (a, i) in _aspects.Select((a, i) => (a, i)))
|
foreach (var (a, i) in _aspects.Select((a, i) => (a, i)))
|
||||||
@@ -902,6 +963,13 @@ public partial class QuickInputViewModel : ObservableObject
|
|||||||
ApplyRating(steps[Math.Clamp(idx + 1, 0, steps.Count - 1)]);
|
ApplyRating(steps[Math.Clamp(idx + 1, 0, steps.Count - 1)]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Nutzer-Feedback: eine aus Versehen gesetzte Bewertung (z.B. Qualität, obwohl der Schüler
|
||||||
|
/// sich nie gemeldet hat) muss sich wieder auf "nicht bewertet" zurücksetzen lassen, ohne
|
||||||
|
/// einen inhaltlich falschen Wert stehen lassen zu müssen. Bewusst eine eigene Aktion statt
|
||||||
|
/// über 0/1 zu laufen — 0 ("--") ist bei Quantität ein echter, beobachteter Wert ("hat sich
|
||||||
|
/// nicht gemeldet"), kein Platzhalter für "nicht ermittelt".
|
||||||
|
public void ClearRating() => ApplyRating(null);
|
||||||
|
|
||||||
public void DecrementRating()
|
public void DecrementRating()
|
||||||
{
|
{
|
||||||
var cell = GetCurrentCell();
|
var cell = GetCurrentCell();
|
||||||
@@ -918,7 +986,7 @@ public partial class QuickInputViewModel : ObservableObject
|
|||||||
ApplyRating(steps[Math.Clamp(idx - 1, 0, steps.Count - 1)]);
|
ApplyRating(steps[Math.Clamp(idx - 1, 0, steps.Count - 1)]);
|
||||||
}
|
}
|
||||||
|
|
||||||
private void ApplyRating(int val)
|
private void ApplyRating(int? val)
|
||||||
{
|
{
|
||||||
if (_rows.Count == 0 || !_aspects.Any()) return;
|
if (_rows.Count == 0 || !_aspects.Any()) return;
|
||||||
var key = _aspects[Math.Clamp(AspectIndex, 0, _aspects.Count - 1)].Key;
|
var key = _aspects[Math.Clamp(AspectIndex, 0, _aspects.Count - 1)].Key;
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ public partial class ParticipationWizardDialogViewModel : ObservableObject
|
|||||||
private readonly IExamRepository _exams;
|
private readonly IExamRepository _exams;
|
||||||
private readonly IExamResultRepository _results;
|
private readonly IExamResultRepository _results;
|
||||||
private readonly IGradeRepository _grades;
|
private readonly IGradeRepository _grades;
|
||||||
|
private readonly IUnitRepository _units;
|
||||||
private readonly Dictionary<Guid, GroupMembership> _membershipsByStudent;
|
private readonly Dictionary<Guid, GroupMembership> _membershipsByStudent;
|
||||||
private readonly GradingService _grading;
|
private readonly GradingService _grading;
|
||||||
private readonly Guid _groupId;
|
private readonly Guid _groupId;
|
||||||
@@ -75,11 +76,11 @@ public partial class ParticipationWizardDialogViewModel : ObservableObject
|
|||||||
IParticipationAspectRepository aspects, IParticipationSectionRepository sectionRepo,
|
IParticipationAspectRepository aspects, IParticipationSectionRepository sectionRepo,
|
||||||
IStudentRepository students, IGroupMembershipRepository memberships,
|
IStudentRepository students, IGroupMembershipRepository memberships,
|
||||||
IExamRepository exams, IExamResultRepository results,
|
IExamRepository exams, IExamResultRepository results,
|
||||||
IGradeRepository grades, GradingService grading,
|
IGradeRepository grades, IUnitRepository units, GradingService grading,
|
||||||
Guid groupId, string schoolYear, GradingSystem gradingSystem, string groupLabel)
|
Guid groupId, string schoolYear, GradingSystem gradingSystem, string groupLabel)
|
||||||
{
|
{
|
||||||
_sessions = sessions; _entries = entries; _sectionRepo = sectionRepo;
|
_sessions = sessions; _entries = entries; _sectionRepo = sectionRepo;
|
||||||
_exams = exams; _results = results; _grades = grades; _grading = grading;
|
_exams = exams; _results = results; _grades = grades; _units = units; _grading = grading;
|
||||||
_groupId = groupId; _schoolYear = schoolYear; _gradingSystem = gradingSystem;
|
_groupId = groupId; _schoolYear = schoolYear; _gradingSystem = gradingSystem;
|
||||||
GroupLabel = groupLabel;
|
GroupLabel = groupLabel;
|
||||||
_membershipsByStudent = memberships.GetByGroup(groupId).ToDictionary(m => m.StudentId);
|
_membershipsByStudent = memberships.GetByGroup(groupId).ToDictionary(m => m.StudentId);
|
||||||
@@ -98,6 +99,7 @@ public partial class ParticipationWizardDialogViewModel : ObservableObject
|
|||||||
|
|
||||||
_rollupPeriod = RollupPeriodOptions[0];
|
_rollupPeriod = RollupPeriodOptions[0];
|
||||||
NewSectionLabel = $"Abschnitt {_sectionList.Count + 1}";
|
NewSectionLabel = $"Abschnitt {_sectionList.Count + 1}";
|
||||||
|
NewSectionEndDateText = SuggestSectionEndDate().ToString("dd.MM.yyyy");
|
||||||
|
|
||||||
if (_students.Count > 0) ShowStudent(0);
|
if (_students.Count > 0) ShowStudent(0);
|
||||||
}
|
}
|
||||||
@@ -277,6 +279,23 @@ public partial class ParticipationWizardDialogViewModel : ObservableObject
|
|||||||
_sectionList.Count > 0 ? _sectionList.Max(s => s.EndDate).AddDays(1)
|
_sectionList.Count > 0 ? _sectionList.Max(s => s.EndDate).AddDays(1)
|
||||||
: (_allSessions.Count > 0 ? _allSessions.Min(s => s.Date) : DateOnly.FromDateTime(DateTime.Today));
|
: (_allSessions.Count > 0 ? _allSessions.Min(s => s.Date) : DateOnly.FromDateTime(DateTime.Today));
|
||||||
|
|
||||||
|
/// Schlägt als Abschnittsende bewusst nicht immer "heute" vor: Wenn seit Abschnittsbeginn
|
||||||
|
/// eine Unterrichtseinheit (4.2) abgeschlossen wurde, ist deren Ende meist die fachlich
|
||||||
|
/// sinnvollere Zäsur für eine Mitarbeit-Teilnote als das Bearbeitungsdatum im Wizard.
|
||||||
|
private DateOnly SuggestSectionEndDate()
|
||||||
|
{
|
||||||
|
var openStart = ComputeOpenStart();
|
||||||
|
var today = DateOnly.FromDateTime(DateTime.Today);
|
||||||
|
var completedUnitEnd = _units.GetByGroup(_groupId)
|
||||||
|
.Where(u => u.Status == UnitStatus.Completed && u.EndDate.HasValue)
|
||||||
|
.Select(u => u.EndDate!.Value)
|
||||||
|
.Where(d => d >= openStart && d <= today)
|
||||||
|
.OrderByDescending(d => d)
|
||||||
|
.Cast<DateOnly?>()
|
||||||
|
.FirstOrDefault();
|
||||||
|
return completedUnitEnd ?? today;
|
||||||
|
}
|
||||||
|
|
||||||
private void BuildSections(Guid studentId)
|
private void BuildSections(Guid studentId)
|
||||||
{
|
{
|
||||||
Sections.Clear();
|
Sections.Clear();
|
||||||
@@ -378,6 +397,7 @@ public partial class ParticipationWizardDialogViewModel : ObservableObject
|
|||||||
}
|
}
|
||||||
|
|
||||||
NewSectionLabel = $"Abschnitt {_sectionList.Count + 1}";
|
NewSectionLabel = $"Abschnitt {_sectionList.Count + 1}";
|
||||||
|
NewSectionEndDateText = SuggestSectionEndDate().ToString("dd.MM.yyyy");
|
||||||
BuildTimeline(CurrentStudentId);
|
BuildTimeline(CurrentStudentId);
|
||||||
BuildSections(CurrentStudentId);
|
BuildSections(CurrentStudentId);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,7 +13,8 @@ namespace LehrerApp.Desktop.ViewModels.Groups;
|
|||||||
|
|
||||||
// ── Ergebnisse der Verschieben-/Kopieren-/Serienerzeugungs-Dialoge (4.2.4 / 4.1.4 / 4.2.5) ───
|
// ── Ergebnisse der Verschieben-/Kopieren-/Serienerzeugungs-Dialoge (4.2.4 / 4.1.4 / 4.2.5) ───
|
||||||
|
|
||||||
public record MoveLessonTarget(DateOnly NewDate, bool ShiftFollowing);
|
public record MoveLessonTarget(DateOnly NewDate, bool ShiftFollowing, int? NewPeriod = null,
|
||||||
|
bool SplitDoubleLesson = false);
|
||||||
public record CopyUnitTarget(Guid TargetGroupId, DateOnly AnchorDate);
|
public record CopyUnitTarget(Guid TargetGroupId, DateOnly AnchorDate);
|
||||||
|
|
||||||
public record LessonSeriesResult(int Created, int SkippedHoliday, int SkippedExisting)
|
public record LessonSeriesResult(int Created, int SkippedHoliday, int SkippedExisting)
|
||||||
@@ -78,6 +79,7 @@ public partial class PlanningTabViewModel : ObservableObject
|
|||||||
public Func<Unit, Task<LessonSeriesResult?>>? OnGenerateLessonSeries { get; set; }
|
public Func<Unit, Task<LessonSeriesResult?>>? OnGenerateLessonSeries { get; set; }
|
||||||
public Func<Unit, Task<bool>>? OnAiAssist { get; set; }
|
public Func<Unit, Task<bool>>? OnAiAssist { get; set; }
|
||||||
public Action<string>? OnNotify { get; set; }
|
public Action<string>? OnNotify { get; set; }
|
||||||
|
public Action<string>? OnError { get; set; }
|
||||||
|
|
||||||
public PlanningTabViewModel(IUnitRepository units, ILessonRepository lessons,
|
public PlanningTabViewModel(IUnitRepository units, ILessonRepository lessons,
|
||||||
IGroupRepository groups, ISubjectRepository subjects,
|
IGroupRepository groups, ISubjectRepository subjects,
|
||||||
@@ -333,7 +335,8 @@ public partial class PlanningTabViewModel : ObservableObject
|
|||||||
var lesson = SelectedLesson.Model;
|
var lesson = SelectedLesson.Model;
|
||||||
var target = await OnPickMoveTarget(lesson);
|
var target = await OnPickMoveTarget(lesson);
|
||||||
if (target is null) return;
|
if (target is null) return;
|
||||||
MoveLessonInternal(lesson, target.NewDate, target.ShiftFollowing);
|
try { MoveLessonInternal(lesson, target.NewDate, target.ShiftFollowing, target.NewPeriod); }
|
||||||
|
catch (InvalidOperationException ex) { OnError?.Invoke(ex.Message); return; }
|
||||||
LoadUnits();
|
LoadUnits();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -341,33 +344,19 @@ public partial class PlanningTabViewModel : ObservableObject
|
|||||||
/// sich alle anderen noch geplanten Stunden derselben Einheit, die ursprünglich NACH der
|
/// sich alle anderen noch geplanten Stunden derselben Einheit, die ursprünglich NACH der
|
||||||
/// verschobenen Stunde lagen, um denselben Tages-Delta. Bereits durchgeführte Stunden werden
|
/// verschobenen Stunde lagen, um denselben Tages-Delta. Bereits durchgeführte Stunden werden
|
||||||
/// nie angefasst — nur Date ändert sich, UnitId/GroupId bleiben unverändert.
|
/// nie angefasst — nur Date ändert sich, UnitId/GroupId bleiben unverändert.
|
||||||
private void MoveLessonInternal(Lesson moved, DateOnly newDate, bool shiftFollowing)
|
private void MoveLessonInternal(Lesson moved, DateOnly newDate, bool shiftFollowing, int? newPeriod = null)
|
||||||
{
|
{
|
||||||
var oldDate = moved.Date;
|
new LessonSchedulingService(_lessons).Move(moved, newDate, newPeriod, shiftFollowing);
|
||||||
var delta = newDate.DayNumber - oldDate.DayNumber;
|
|
||||||
|
|
||||||
if (shiftFollowing && delta != 0)
|
|
||||||
{
|
|
||||||
foreach (var other in _lessons.GetByUnit(moved.UnitId))
|
|
||||||
{
|
|
||||||
if (other.Id == moved.Id) continue;
|
|
||||||
if (other.Status != LessonStatus.Planned) continue;
|
|
||||||
if (other.Date <= oldDate) continue;
|
|
||||||
other.Date = other.Date.AddDays(delta);
|
|
||||||
_lessons.Save(other);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
moved.Date = newDate;
|
|
||||||
_lessons.Save(moved);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[RelayCommand(CanExecute = nameof(HasSelectedLesson))]
|
[RelayCommand(CanExecute = nameof(HasSelectedLesson))]
|
||||||
private void AdvanceLessonStatus()
|
private void AdvanceLessonStatus()
|
||||||
{
|
{
|
||||||
if (SelectedLesson is null || SelectedLesson.Model.Status != LessonStatus.Planned) return;
|
if (SelectedLesson is null || SelectedLesson.Model.Status == LessonStatus.Conducted) return;
|
||||||
var lesson = SelectedLesson.Model;
|
var lesson = SelectedLesson.Model;
|
||||||
lesson.Status = LessonStatus.Conducted;
|
lesson.Status = lesson.Status == LessonStatus.Ready
|
||||||
|
? LessonStatus.Conducted
|
||||||
|
: LessonStatus.Ready;
|
||||||
_lessons.Save(lesson);
|
_lessons.Save(lesson);
|
||||||
LoadUnits();
|
LoadUnits();
|
||||||
}
|
}
|
||||||
@@ -479,8 +468,14 @@ public class LessonSummary
|
|||||||
Topic = l.Topic;
|
Topic = l.Topic;
|
||||||
StartTimeDisplay = l.StartTime?.ToString("HH:mm") ?? "–";
|
StartTimeDisplay = l.StartTime?.ToString("HH:mm") ?? "–";
|
||||||
Status = l.Status;
|
Status = l.Status;
|
||||||
StatusLabel = l.Status == LessonStatus.Conducted ? "Durchgeführt" : "Geplant";
|
StatusLabel = LessonStatusDisplay.ToName(l.Status);
|
||||||
StatusColorHex = l.Status == LessonStatus.Conducted ? "#43A047" : "#9E9E9E";
|
StatusColorHex = l.Status switch
|
||||||
|
{
|
||||||
|
LessonStatus.Draft => "#78909C",
|
||||||
|
LessonStatus.Ready => "#1976D2",
|
||||||
|
LessonStatus.Conducted => "#43A047",
|
||||||
|
_ => "#9E9E9E",
|
||||||
|
};
|
||||||
PhaseCountLabel = l.Phases.Count == 0 ? "–" : $"{l.Phases.Count} Phasen";
|
PhaseCountLabel = l.Phases.Count == 0 ? "–" : $"{l.Phases.Count} Phasen";
|
||||||
var totalMinutes = l.Phases.Sum(p => p.DurationMinutes);
|
var totalMinutes = l.Phases.Sum(p => p.DurationMinutes);
|
||||||
TotalDurationLabel = totalMinutes == 0 ? "–" : $"{totalMinutes} Min.";
|
TotalDurationLabel = totalMinutes == 0 ? "–" : $"{totalMinutes} Min.";
|
||||||
@@ -515,12 +510,23 @@ public static class UnitStatusDisplay
|
|||||||
|
|
||||||
public static class LessonStatusDisplay
|
public static class LessonStatusDisplay
|
||||||
{
|
{
|
||||||
public static string[] Options { get; } = ["Geplant", "Durchgeführt"];
|
public static string[] Options { get; } = ["Entwurf", "Geplant", "Bereit", "Durchgeführt"];
|
||||||
|
|
||||||
public static string ToName(LessonStatus s) => s == LessonStatus.Conducted ? "Durchgeführt" : "Geplant";
|
public static string ToName(LessonStatus s) => s switch
|
||||||
|
{
|
||||||
|
LessonStatus.Draft => "Entwurf",
|
||||||
|
LessonStatus.Ready => "Bereit",
|
||||||
|
LessonStatus.Conducted => "Durchgeführt",
|
||||||
|
_ => "Geplant",
|
||||||
|
};
|
||||||
|
|
||||||
public static LessonStatus FromName(string? name) =>
|
public static LessonStatus FromName(string? name) => name switch
|
||||||
name == "Durchgeführt" ? LessonStatus.Conducted : LessonStatus.Planned;
|
{
|
||||||
|
"Entwurf" => LessonStatus.Draft,
|
||||||
|
"Bereit" => LessonStatus.Ready,
|
||||||
|
"Durchgeführt" => LessonStatus.Conducted,
|
||||||
|
_ => LessonStatus.Planned,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Dialog: Einheit anlegen / bearbeiten (4.1.2 / 4.1.3) ─────────────────────
|
// ── Dialog: Einheit anlegen / bearbeiten (4.1.2 / 4.1.3) ─────────────────────
|
||||||
@@ -722,7 +728,8 @@ public partial class LessonDialogViewModel : ObservableObject
|
|||||||
IAlternativeLessonPathRepository alternativePaths, ITimetableSlotRepository timetableSlots,
|
IAlternativeLessonPathRepository alternativePaths, ITimetableSlotRepository timetableSlots,
|
||||||
PeriodScheduleService periodSchedule, IAttachmentStorage attachmentStorage,
|
PeriodScheduleService periodSchedule, IAttachmentStorage attachmentStorage,
|
||||||
Guid unitId, Guid groupId, string groupName, string subjectName,
|
Guid unitId, Guid groupId, string groupName, string subjectName,
|
||||||
List<string> materialSuggestions, List<string> shorthandHistorySuggestions, Lesson? editingLesson)
|
List<string> materialSuggestions, List<string> shorthandHistorySuggestions, Lesson? editingLesson,
|
||||||
|
DateOnly? suggestedDate = null, int? suggestedPeriod = null)
|
||||||
{
|
{
|
||||||
_lessons = lessons; _alternativePaths = alternativePaths;
|
_lessons = lessons; _alternativePaths = alternativePaths;
|
||||||
_timetableSlots = timetableSlots; _periodSchedule = periodSchedule;
|
_timetableSlots = timetableSlots; _periodSchedule = periodSchedule;
|
||||||
@@ -760,9 +767,12 @@ public partial class LessonDialogViewModel : ObservableObject
|
|||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
var (date, lessonNumber) = SuggestNextLesson();
|
var (date, lessonNumber) = suggestedDate.HasValue
|
||||||
|
? (suggestedDate.Value, suggestedPeriod)
|
||||||
|
: SuggestNextLesson();
|
||||||
DateText = date.ToString("dd.MM.yyyy");
|
DateText = date.ToString("dd.MM.yyyy");
|
||||||
LessonNumber = lessonNumber;
|
LessonNumber = lessonNumber;
|
||||||
|
StatusName = LessonStatusDisplay.ToName(LessonStatus.Draft);
|
||||||
}
|
}
|
||||||
RecomputeTimes();
|
RecomputeTimes();
|
||||||
}
|
}
|
||||||
@@ -1145,28 +1155,45 @@ public partial class AlternativePathDialogViewModel : ObservableObject
|
|||||||
public partial class MoveLessonDialogViewModel : ObservableObject
|
public partial class MoveLessonDialogViewModel : ObservableObject
|
||||||
{
|
{
|
||||||
[ObservableProperty] private string _newDateText;
|
[ObservableProperty] private string _newDateText;
|
||||||
|
[ObservableProperty] private int? _newPeriod;
|
||||||
[ObservableProperty] private bool _shiftFollowingPlanned = true;
|
[ObservableProperty] private bool _shiftFollowingPlanned = true;
|
||||||
|
[ObservableProperty] private bool _splitDoubleLesson;
|
||||||
[ObservableProperty] private string _newDateTextError = "";
|
[ObservableProperty] private string _newDateTextError = "";
|
||||||
|
[ObservableProperty] private string _newPeriodError = "";
|
||||||
|
|
||||||
public string CurrentDateDisplay { get; }
|
public string CurrentDateDisplay { get; }
|
||||||
|
public string CurrentPeriodDisplay { get; }
|
||||||
|
public bool CanSplitDoubleLesson { get; }
|
||||||
public MoveLessonTarget? Result { get; private set; }
|
public MoveLessonTarget? Result { get; private set; }
|
||||||
|
|
||||||
public MoveLessonDialogViewModel(DateOnly currentDate)
|
public MoveLessonDialogViewModel(DateOnly currentDate, int? currentPeriod = null,
|
||||||
|
bool canSplitDoubleLesson = false, bool splitByDefault = false)
|
||||||
{
|
{
|
||||||
CurrentDateDisplay = currentDate.ToString("dd.MM.yyyy");
|
CurrentDateDisplay = currentDate.ToString("dd.MM.yyyy");
|
||||||
|
CurrentPeriodDisplay = currentPeriod is null ? "nicht festgelegt" : $"{currentPeriod}. Stunde";
|
||||||
_newDateText = currentDate.ToString("dd.MM.yyyy");
|
_newDateText = currentDate.ToString("dd.MM.yyyy");
|
||||||
|
_newPeriod = currentPeriod;
|
||||||
|
CanSplitDoubleLesson = canSplitDoubleLesson;
|
||||||
|
_splitDoubleLesson = canSplitDoubleLesson && splitByDefault;
|
||||||
}
|
}
|
||||||
|
|
||||||
[RelayCommand]
|
[RelayCommand]
|
||||||
private void Save()
|
private void Save()
|
||||||
{
|
{
|
||||||
NewDateTextError = "";
|
NewDateTextError = "";
|
||||||
|
NewPeriodError = "";
|
||||||
if (!DateOnly.TryParseExact(NewDateText, "dd.MM.yyyy", null, DateTimeStyles.None, out var date))
|
if (!DateOnly.TryParseExact(NewDateText, "dd.MM.yyyy", null, DateTimeStyles.None, out var date))
|
||||||
{
|
{
|
||||||
NewDateTextError = "Format TT.MM.JJJJ.";
|
NewDateTextError = "Format TT.MM.JJJJ.";
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
Result = new MoveLessonTarget(date, ShiftFollowingPlanned);
|
if (NewPeriod is < 1 or > 20)
|
||||||
|
{
|
||||||
|
NewPeriodError = "Bitte eine Stundennummer zwischen 1 und 20 wählen.";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Result = new MoveLessonTarget(date, ShiftFollowingPlanned, NewPeriod,
|
||||||
|
CanSplitDoubleLesson && SplitDoubleLesson);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -93,14 +93,37 @@ public partial class SeatingPlanTabViewModel : ObservableObject
|
|||||||
/// Legt bei Bedarf die "Sitzung für heute" an, mit der Sitzplan-Bewertungen/-Markierungen
|
/// Legt bei Bedarf die "Sitzung für heute" an, mit der Sitzplan-Bewertungen/-Markierungen
|
||||||
/// verknüpft werden — aber erst, wenn tatsächlich etwas bewertet/markiert wird (AssessStudent/
|
/// verknüpft werden — aber erst, wenn tatsächlich etwas bewertet/markiert wird (AssessStudent/
|
||||||
/// ToggleSituationTag), nicht schon beim Öffnen des Tabs.
|
/// ToggleSituationTag), nicht schon beim Öffnen des Tabs.
|
||||||
|
///
|
||||||
|
/// Prüft dabei bewusst gegen das Repository und nicht nur gegen das lokal geladene
|
||||||
|
/// <see cref="TodaySessions"/> / <see cref="SelectedSession"/>: Der Unterrichtsmodus
|
||||||
|
/// (<see cref="SelectOrCreateSessionForLesson"/>) läuft auf einer eigenen, per DI frisch
|
||||||
|
/// aufgelösten <see cref="SeatingPlanTabViewModel"/>-Instanz (siehe TimetableView.ShowTeachingMode),
|
||||||
|
/// getrennt von der Instanz, die im Sitzplan-Tab der Kursübersicht hängt. Eine dort bereits
|
||||||
|
/// heute angelegte (ggf. verknüpfte) Sitzung ist dieser Instanz also unbekannt, solange sie nur
|
||||||
|
/// im lokalen Feld nachschaut — das führte dazu, dass eine zweite, unverknüpfte "Sitzplan"-
|
||||||
|
/// Sitzung für denselben Tag entstand, sobald im echten Sitzplan-Tab bewertet wurde.
|
||||||
private ParticipationSessionOption? EnsureTodaySession()
|
private ParticipationSessionOption? EnsureTodaySession()
|
||||||
{
|
{
|
||||||
if (SelectedSession is not null) return SelectedSession;
|
if (SelectedSession is not null) return SelectedSession;
|
||||||
if (!IsEditable) return null;
|
if (!IsEditable) return null;
|
||||||
|
|
||||||
|
var today = DateOnly.FromDateTime(DateTime.Today);
|
||||||
|
var existing = _sessions.GetByGroup(_groupId).FirstOrDefault(s => s.Date == today);
|
||||||
|
if (existing is not null)
|
||||||
|
{
|
||||||
|
var found = TodaySessions.FirstOrDefault(o => o.Id == existing.Id);
|
||||||
|
if (found is null)
|
||||||
|
{
|
||||||
|
found = new ParticipationSessionOption(existing);
|
||||||
|
TodaySessions.Add(found);
|
||||||
|
}
|
||||||
|
SelectedSession = found;
|
||||||
|
return found;
|
||||||
|
}
|
||||||
|
|
||||||
var created = new ParticipationSession
|
var created = new ParticipationSession
|
||||||
{
|
{
|
||||||
GroupId = _groupId, Date = DateOnly.FromDateTime(DateTime.Today), Comment = "Sitzplan",
|
GroupId = _groupId, Date = today, Comment = "Sitzplan",
|
||||||
};
|
};
|
||||||
_sessions.Save(created);
|
_sessions.Save(created);
|
||||||
var option = new ParticipationSessionOption(created);
|
var option = new ParticipationSessionOption(created);
|
||||||
@@ -204,7 +227,7 @@ public partial class SeatingPlanTabViewModel : ObservableObject
|
|||||||
?? StudentSeatOption.Empty;
|
?? StudentSeatOption.Empty;
|
||||||
var isHidden = hiddenSeats.Any(h => h.Row == row && h.Column == column);
|
var isHidden = hiddenSeats.Any(h => h.Row == row && h.Column == column);
|
||||||
Seats.Add(new SeatCellViewModel(row, column, StudentOptions, option, OnSeatChanged,
|
Seats.Add(new SeatCellViewModel(row, column, StudentOptions, option, OnSeatChanged,
|
||||||
CanEditLayout, ToggleSituationTag, IsEditable, isHidden, ToggleSeatHidden));
|
CanEditLayout, ToggleSituationTag, IsEditable, isHidden, ToggleSeatHidden, TallyParticipation));
|
||||||
}
|
}
|
||||||
UpdateAssignmentSummary();
|
UpdateAssignmentSummary();
|
||||||
RefreshSeatLessonData();
|
RefreshSeatLessonData();
|
||||||
@@ -213,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)
|
||||||
@@ -236,6 +266,22 @@ public partial class SeatingPlanTabViewModel : ObservableObject
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Strichliste im Sitzplan (Nutzer-Feedback): "drangekommen" ist immer auch eine Meldung und
|
||||||
|
/// erhöht daher beide Zähler — es gibt kein "drangekommen, ohne sich gemeldet zu haben".
|
||||||
|
private void TallyParticipation(SeatCellViewModel seat, bool calledOn)
|
||||||
|
{
|
||||||
|
if (!IsEditable || seat.SelectedOption.StudentId is not Guid studentId) return;
|
||||||
|
var session = EnsureTodaySession();
|
||||||
|
if (session is null) return;
|
||||||
|
|
||||||
|
var entry = _participation.GetBySessionAndStudent(session.Id, studentId)
|
||||||
|
?? new ParticipationEntry { SessionId = session.Id, StudentId = studentId };
|
||||||
|
entry.RaisedHandCount++;
|
||||||
|
if (calledOn) entry.CalledOnCount++;
|
||||||
|
_participation.Save(entry);
|
||||||
|
RefreshSeatLessonData();
|
||||||
|
}
|
||||||
|
|
||||||
private void ToggleSituationTag(SeatCellViewModel seat, string tag)
|
private void ToggleSituationTag(SeatCellViewModel seat, string tag)
|
||||||
{
|
{
|
||||||
if (!IsEditable || _documentation is null ||
|
if (!IsEditable || _documentation is null ||
|
||||||
@@ -501,9 +547,13 @@ public partial class SeatCellViewModel : ObservableObject
|
|||||||
[ObservableProperty] private double _lessonOpacity = 1.0;
|
[ObservableProperty] private double _lessonOpacity = 1.0;
|
||||||
[ObservableProperty] private string _attendanceBadge = "";
|
[ObservableProperty] private string _attendanceBadge = "";
|
||||||
[ObservableProperty] private string _homeworkBadge = "";
|
[ObservableProperty] private string _homeworkBadge = "";
|
||||||
|
[ObservableProperty] private string _dayHighlightBadge = "";
|
||||||
|
[ObservableProperty] private int _raisedHandCount;
|
||||||
|
[ObservableProperty] private int _calledOnCount;
|
||||||
public ObservableCollection<SituationTagChoice> SituationTags { get; } = [];
|
public ObservableCollection<SituationTagChoice> SituationTags { get; } = [];
|
||||||
public bool HasAttendanceBadge => AttendanceBadge.Length > 0;
|
public bool HasAttendanceBadge => AttendanceBadge.Length > 0;
|
||||||
public bool HasHomeworkBadge => HomeworkBadge.Length > 0;
|
public bool HasHomeworkBadge => HomeworkBadge.Length > 0;
|
||||||
|
public bool HasDayHighlightBadge => DayHighlightBadge.Length > 0;
|
||||||
public bool ShowLessonOverview => IsOccupied && !CanEdit;
|
public bool ShowLessonOverview => IsOccupied && !CanEdit;
|
||||||
[ObservableProperty] private bool _canRecordLesson;
|
[ObservableProperty] private bool _canRecordLesson;
|
||||||
public bool IsOccupied => SelectedOption.StudentId.HasValue;
|
public bool IsOccupied => SelectedOption.StudentId.HasValue;
|
||||||
@@ -525,10 +575,13 @@ public partial class SeatCellViewModel : ObservableObject
|
|||||||
/// die Abblendung für ausgeblendete Plätze hier statt per CSS-Klasse erfolgen.</summary>
|
/// die Abblendung für ausgeblendete Plätze hier statt per CSS-Klasse erfolgen.</summary>
|
||||||
public double DisplayOpacity => IsHidden ? 0.4 : LessonOpacity;
|
public double DisplayOpacity => IsHidden ? 0.4 : LessonOpacity;
|
||||||
|
|
||||||
|
private readonly Action<SeatCellViewModel, bool> _tally;
|
||||||
|
|
||||||
public SeatCellViewModel(int row, int column, ObservableCollection<StudentSeatOption> options,
|
public SeatCellViewModel(int row, int column, ObservableCollection<StudentSeatOption> options,
|
||||||
StudentSeatOption selectedOption, Action<SeatCellViewModel> onChanged, bool canEdit,
|
StudentSeatOption selectedOption, Action<SeatCellViewModel> onChanged, bool canEdit,
|
||||||
Action<SeatCellViewModel, string>? toggleSituationTag = null, bool canRecordLesson = false,
|
Action<SeatCellViewModel, string>? toggleSituationTag = null, bool canRecordLesson = false,
|
||||||
bool isHidden = false, Action<SeatCellViewModel>? toggleHidden = null)
|
bool isHidden = false, Action<SeatCellViewModel>? toggleHidden = null,
|
||||||
|
Action<SeatCellViewModel, bool>? tally = null)
|
||||||
{
|
{
|
||||||
Row = row;
|
Row = row;
|
||||||
Column = column;
|
Column = column;
|
||||||
@@ -540,10 +593,18 @@ public partial class SeatCellViewModel : ObservableObject
|
|||||||
_canRecordLesson = canRecordLesson;
|
_canRecordLesson = canRecordLesson;
|
||||||
_isHidden = isHidden;
|
_isHidden = isHidden;
|
||||||
_toggleHidden = toggleHidden ?? (_ => { });
|
_toggleHidden = toggleHidden ?? (_ => { });
|
||||||
|
_tally = tally ?? ((_, _) => { });
|
||||||
foreach (var tag in SituationTagChoice.DefaultTags)
|
foreach (var tag in SituationTagChoice.DefaultTags)
|
||||||
SituationTags.Add(new SituationTagChoice(tag, false, value => _toggleSituationTag(this, value)));
|
SituationTags.Add(new SituationTagChoice(tag, false, value => _toggleSituationTag(this, value)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Strichliste im Sitzplan (Nutzer-Feedback): schnelles Mitzählen während des Unterrichts,
|
||||||
|
/// ohne dafür den vollen Sitzplatz-Bewertungsdialog öffnen zu müssen — bewusst nur ein
|
||||||
|
/// Hochzählen ohne Korrekturmöglichkeit hier direkt auf der Kachel; die Rohwerte bleiben über
|
||||||
|
/// den Bewertungsdialog einsehbar und fließen dort als Quantitäts-Vorschlag ein.
|
||||||
|
[RelayCommand] private void TallyRaisedHand() => _tally(this, false);
|
||||||
|
[RelayCommand] private void TallyCalledOn() => _tally(this, true);
|
||||||
|
|
||||||
partial void OnSelectedOptionChanged(StudentSeatOption value)
|
partial void OnSelectedOptionChanged(StudentSeatOption value)
|
||||||
{
|
{
|
||||||
OnPropertyChanged(nameof(IsOccupied));
|
OnPropertyChanged(nameof(IsOccupied));
|
||||||
@@ -585,12 +646,16 @@ public partial class SeatCellViewModel : ObservableObject
|
|||||||
var attendance = entry?.Attendance;
|
var attendance = entry?.Attendance;
|
||||||
AttendanceBadge = attendance is null ? "" : AttendanceDisplay.ShortLabel(attendance);
|
AttendanceBadge = attendance is null ? "" : AttendanceDisplay.ShortLabel(attendance);
|
||||||
HomeworkBadge = entry is null ? "" : HomeworkDisplay.Symbol(HomeworkDisplay.Effective(entry));
|
HomeworkBadge = entry is null ? "" : HomeworkDisplay.Symbol(HomeworkDisplay.Effective(entry));
|
||||||
|
DayHighlightBadge = DayHighlightDisplay.Symbol(entry?.DayHighlight);
|
||||||
|
RaisedHandCount = entry?.RaisedHandCount ?? 0;
|
||||||
|
CalledOnCount = entry?.CalledOnCount ?? 0;
|
||||||
LessonOpacity = attendance is not null and not AttendanceStatus.Present
|
LessonOpacity = attendance is not null and not AttendanceStatus.Present
|
||||||
and not AttendanceStatus.Late and not AttendanceStatus.SignificantlyLate ? 0.42 : 1.0;
|
and not AttendanceStatus.Late and not AttendanceStatus.SignificantlyLate ? 0.42 : 1.0;
|
||||||
var selected = tags.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
var selected = tags.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||||
foreach (var choice in SituationTags) choice.IsSelected = selected.Contains(choice.Text);
|
foreach (var choice in SituationTags) choice.IsSelected = selected.Contains(choice.Text);
|
||||||
OnPropertyChanged(nameof(HasAttendanceBadge));
|
OnPropertyChanged(nameof(HasAttendanceBadge));
|
||||||
OnPropertyChanged(nameof(HasHomeworkBadge));
|
OnPropertyChanged(nameof(HasHomeworkBadge));
|
||||||
|
OnPropertyChanged(nameof(HasDayHighlightBadge));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -616,6 +681,7 @@ public partial class SeatAssessmentViewModel : ObservableObject
|
|||||||
[ObservableProperty] private int _selectedAspectIndex;
|
[ObservableProperty] private int _selectedAspectIndex;
|
||||||
[ObservableProperty] private string _attendanceLabel = "Noch nicht kontrolliert";
|
[ObservableProperty] private string _attendanceLabel = "Noch nicht kontrolliert";
|
||||||
[ObservableProperty] private string _homeworkLabel = "Keine Hausaufgabe aufgegeben";
|
[ObservableProperty] private string _homeworkLabel = "Keine Hausaufgabe aufgegeben";
|
||||||
|
[ObservableProperty] private string _dayHighlightLabel = "Keine Markierung";
|
||||||
|
|
||||||
public string StudentName { get; }
|
public string StudentName { get; }
|
||||||
public string SessionDisplay { get; }
|
public string SessionDisplay { get; }
|
||||||
@@ -626,6 +692,7 @@ public partial class SeatAssessmentViewModel : ObservableObject
|
|||||||
public ObservableCollection<SeatAssessmentAspectRow> AspectRows { get; } = [];
|
public ObservableCollection<SeatAssessmentAspectRow> AspectRows { get; } = [];
|
||||||
public ObservableCollection<SeatAttendanceChoice> AttendanceChoices { get; } = [];
|
public ObservableCollection<SeatAttendanceChoice> AttendanceChoices { get; } = [];
|
||||||
public ObservableCollection<SeatHomeworkChoice> HomeworkChoices { get; } = [];
|
public ObservableCollection<SeatHomeworkChoice> HomeworkChoices { get; } = [];
|
||||||
|
public ObservableCollection<SeatDayHighlightChoice> DayHighlightChoices { get; } = [];
|
||||||
|
|
||||||
public SeatAssessmentViewModel(IParticipationSessionRepository sessions,
|
public SeatAssessmentViewModel(IParticipationSessionRepository sessions,
|
||||||
IParticipationRepository entries, IParticipationAspectRepository aspects,
|
IParticipationRepository entries, IParticipationAspectRepository aspects,
|
||||||
@@ -660,11 +727,15 @@ public partial class SeatAssessmentViewModel : ObservableObject
|
|||||||
foreach (var (aspect, index) in aspectDefinitions.Select((a, i) => (a, i)))
|
foreach (var (aspect, index) in aspectDefinitions.Select((a, i) => (a, i)))
|
||||||
{
|
{
|
||||||
var value = _entry?.Ratings.FirstOrDefault(r => r.Key == aspect.Key)?.Value;
|
var value = _entry?.Ratings.FirstOrDefault(r => r.Key == aspect.Key)?.Value;
|
||||||
AspectRows.Add(new SeatAssessmentAspectRow(index, aspect, value, ApplyRating));
|
// Quantitäts-Vorschlag aus der Sitzplan-Strichliste (Nutzer-Feedback): nur für den
|
||||||
|
// eingebauten "quantity"-Aspekt, siehe ParticipationCountSuggestion.
|
||||||
|
var raisedHandCount = aspect.Key == "quantity" ? _entry?.RaisedHandCount : null;
|
||||||
|
AspectRows.Add(new SeatAssessmentAspectRow(index, aspect, value, ApplyRating, raisedHandCount));
|
||||||
}
|
}
|
||||||
|
|
||||||
BuildAttendanceChoices();
|
BuildAttendanceChoices();
|
||||||
BuildHomeworkChoices();
|
BuildHomeworkChoices();
|
||||||
|
BuildDayHighlightChoices();
|
||||||
RefreshStatusChoices();
|
RefreshStatusChoices();
|
||||||
SelectAspect(0);
|
SelectAspect(0);
|
||||||
}
|
}
|
||||||
@@ -697,6 +768,17 @@ public partial class SeatAssessmentViewModel : ObservableObject
|
|||||||
HomeworkChoices.Add(new("·", "Keine aufgegeben", "⌥X", null, SetHomework));
|
HomeworkChoices.Add(new("·", "Keine aufgegeben", "⌥X", null, SetHomework));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void BuildDayHighlightChoices()
|
||||||
|
{
|
||||||
|
DayHighlightChoices.Add(new(DayHighlightDisplay.Symbol(DayHighlightKind.Standout),
|
||||||
|
DayHighlightDisplay.Label(DayHighlightKind.Standout), "⇧1", DayHighlightKind.Standout, SetDayHighlight));
|
||||||
|
DayHighlightChoices.Add(new(DayHighlightDisplay.Symbol(DayHighlightKind.Sleepy),
|
||||||
|
DayHighlightDisplay.Label(DayHighlightKind.Sleepy), "⇧2", DayHighlightKind.Sleepy, SetDayHighlight));
|
||||||
|
DayHighlightChoices.Add(new(DayHighlightDisplay.Symbol(DayHighlightKind.Rough),
|
||||||
|
DayHighlightDisplay.Label(DayHighlightKind.Rough), "⇧3", DayHighlightKind.Rough, SetDayHighlight));
|
||||||
|
DayHighlightChoices.Add(new("·", "Keine Markierung", "⇧X", null, SetDayHighlight));
|
||||||
|
}
|
||||||
|
|
||||||
public void SelectAspect(int index)
|
public void SelectAspect(int index)
|
||||||
{
|
{
|
||||||
if (index < 0 || index >= AspectRows.Count) return;
|
if (index < 0 || index >= AspectRows.Count) return;
|
||||||
@@ -763,6 +845,17 @@ public partial class SeatAssessmentViewModel : ObservableObject
|
|||||||
if (clear || digit is 0 or 1 or 3 or 4 or 5 or 7 or 8) SetHomework(status);
|
if (clear || digit is 0 or 1 or 3 or 4 or 5 or 7 or 8) SetHomework(status);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public void ApplyDayHighlightShortcut(int? digit, bool clear)
|
||||||
|
{
|
||||||
|
if (!CanEdit) return;
|
||||||
|
var kind = clear ? null : digit switch
|
||||||
|
{
|
||||||
|
1 => DayHighlightKind.Standout, 2 => DayHighlightKind.Sleepy, 3 => DayHighlightKind.Rough,
|
||||||
|
_ => (DayHighlightKind?)null,
|
||||||
|
};
|
||||||
|
if (clear || digit is 1 or 2 or 3) SetDayHighlight(kind);
|
||||||
|
}
|
||||||
|
|
||||||
private void ApplyRating(string key, int? value)
|
private void ApplyRating(string key, int? value)
|
||||||
{
|
{
|
||||||
if (!CanEdit || _entry is null) return;
|
if (!CanEdit || _entry is null) return;
|
||||||
@@ -793,13 +886,23 @@ public partial class SeatAssessmentViewModel : ObservableObject
|
|||||||
RefreshStatusChoices();
|
RefreshStatusChoices();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void SetDayHighlight(DayHighlightKind? kind)
|
||||||
|
{
|
||||||
|
if (!CanEdit || _entry is null) return;
|
||||||
|
_entry.DayHighlight = kind;
|
||||||
|
_entries.Save(_entry);
|
||||||
|
RefreshStatusChoices();
|
||||||
|
}
|
||||||
|
|
||||||
private void RefreshStatusChoices()
|
private void RefreshStatusChoices()
|
||||||
{
|
{
|
||||||
AttendanceLabel = AttendanceDisplay.Label(_entry?.Attendance);
|
AttendanceLabel = AttendanceDisplay.Label(_entry?.Attendance);
|
||||||
HomeworkLabel = HomeworkDisplay.Label(_entry is null ? null : HomeworkDisplay.Effective(_entry));
|
HomeworkLabel = HomeworkDisplay.Label(_entry is null ? null : HomeworkDisplay.Effective(_entry));
|
||||||
|
DayHighlightLabel = DayHighlightDisplay.Label(_entry?.DayHighlight);
|
||||||
foreach (var choice in AttendanceChoices) choice.IsSelected = choice.Status == _entry?.Attendance;
|
foreach (var choice in AttendanceChoices) choice.IsSelected = choice.Status == _entry?.Attendance;
|
||||||
var homework = _entry is null ? null : HomeworkDisplay.Effective(_entry);
|
var homework = _entry is null ? null : HomeworkDisplay.Effective(_entry);
|
||||||
foreach (var choice in HomeworkChoices) choice.IsSelected = choice.Status == homework;
|
foreach (var choice in HomeworkChoices) choice.IsSelected = choice.Status == homework;
|
||||||
|
foreach (var choice in DayHighlightChoices) choice.IsSelected = choice.Kind == _entry?.DayHighlight;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -817,11 +920,22 @@ public partial class SeatAssessmentAspectRow : ObservableObject
|
|||||||
public string DisplayValue => ParticipationRatingScale.DisplayLabel(ValueType, Value);
|
public string DisplayValue => ParticipationRatingScale.DisplayLabel(ValueType, Value);
|
||||||
public ObservableCollection<SeatRatingChoice> Choices { get; } = [];
|
public ObservableCollection<SeatRatingChoice> Choices { get; } = [];
|
||||||
|
|
||||||
|
/// Quantitäts-Vorschlag aus der Sitzplan-Strichliste (Nutzer-Feedback), siehe
|
||||||
|
/// ParticipationCountSuggestion — null, wenn keine Strichliste zu diesem Aspekt gehört.
|
||||||
|
private readonly int? _raisedHandCount;
|
||||||
|
public int? SuggestedValue { get; }
|
||||||
|
public bool HasSuggestion => SuggestedValue.HasValue && SuggestedValue != Value;
|
||||||
|
public string SuggestionLabel =>
|
||||||
|
$"{_raisedHandCount}× gemeldet → Vorschlag: {ParticipationRatingScale.DisplayLabel(ValueType, SuggestedValue)}";
|
||||||
|
|
||||||
public SeatAssessmentAspectRow(int index, ParticipationAspect aspect, int? value,
|
public SeatAssessmentAspectRow(int index, ParticipationAspect aspect, int? value,
|
||||||
Action<string, int?> apply)
|
Action<string, int?> apply, int? raisedHandCount = null)
|
||||||
{
|
{
|
||||||
Index = index; Key = aspect.Key; Label = aspect.Label; ValueType = aspect.ValueType;
|
Index = index; Key = aspect.Key; Label = aspect.Label; ValueType = aspect.ValueType;
|
||||||
MaxPoints = aspect.MaxPoints; _value = value; _apply = apply;
|
MaxPoints = aspect.MaxPoints; _value = value; _apply = apply;
|
||||||
|
_raisedHandCount = raisedHandCount;
|
||||||
|
SuggestedValue = raisedHandCount is int rhc
|
||||||
|
? ParticipationCountSuggestion.SuggestQuantity(ValueType, rhc) : null;
|
||||||
var steps = ValueType == AspectValueType.Points
|
var steps = ValueType == AspectValueType.Points
|
||||||
? Enumerable.Range(0, Math.Min(MaxPoints, 9) + 1).Select(v => (v, v.ToString())).ToList()
|
? Enumerable.Range(0, Math.Min(MaxPoints, 9) + 1).Select(v => (v, v.ToString())).ToList()
|
||||||
: ParticipationRatingScale.Steps(ValueType).ToList();
|
: ParticipationRatingScale.Steps(ValueType).ToList();
|
||||||
@@ -834,10 +948,13 @@ public partial class SeatAssessmentAspectRow : ObservableObject
|
|||||||
{
|
{
|
||||||
Value = value;
|
Value = value;
|
||||||
OnPropertyChanged(nameof(DisplayValue));
|
OnPropertyChanged(nameof(DisplayValue));
|
||||||
|
OnPropertyChanged(nameof(HasSuggestion));
|
||||||
foreach (var choice in Choices) choice.IsSelected = choice.Value == value;
|
foreach (var choice in Choices) choice.IsSelected = choice.Value == value;
|
||||||
_apply(Key, value);
|
_apply(Key, value);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[RelayCommand] private void ApplySuggestion() => ApplyValue(SuggestedValue);
|
||||||
|
|
||||||
public void Adjust(int delta)
|
public void Adjust(int delta)
|
||||||
{
|
{
|
||||||
if (ValueType == AspectValueType.Points)
|
if (ValueType == AspectValueType.Points)
|
||||||
@@ -888,6 +1005,17 @@ public partial class SeatHomeworkChoice(string symbol, string label, string shor
|
|||||||
[RelayCommand] private void Apply() => apply(Status);
|
[RelayCommand] private void Apply() => apply(Status);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public partial class SeatDayHighlightChoice(string symbol, string label, string shortcut,
|
||||||
|
DayHighlightKind? kind, Action<DayHighlightKind?> apply) : ObservableObject
|
||||||
|
{
|
||||||
|
public string Symbol { get; } = symbol;
|
||||||
|
public string Label { get; } = label;
|
||||||
|
public string Shortcut { get; } = shortcut;
|
||||||
|
public DayHighlightKind? Kind { get; } = kind;
|
||||||
|
[ObservableProperty] private bool _isSelected;
|
||||||
|
[RelayCommand] private void Apply() => apply(Kind);
|
||||||
|
}
|
||||||
|
|
||||||
public partial class SeatingPlanDialogViewModel : ObservableObject
|
public partial class SeatingPlanDialogViewModel : ObservableObject
|
||||||
{
|
{
|
||||||
private readonly ISeatingPlanRepository _plans;
|
private readonly ISeatingPlanRepository _plans;
|
||||||
|
|||||||
@@ -0,0 +1,310 @@
|
|||||||
|
using CommunityToolkit.Mvvm.ComponentModel;
|
||||||
|
using CommunityToolkit.Mvvm.Input;
|
||||||
|
using LehrerApp.Core.Interfaces;
|
||||||
|
using LehrerApp.Core.Models;
|
||||||
|
using LehrerApp.Core.Services;
|
||||||
|
using LehrerApp.Desktop.ViewModels.Students;
|
||||||
|
using System.Collections.ObjectModel;
|
||||||
|
using System.Globalization;
|
||||||
|
|
||||||
|
namespace LehrerApp.Desktop.ViewModels.Groups;
|
||||||
|
|
||||||
|
// ── Schüler-Leistungsüberblick + Zielnoten-Rechner (Nutzer-Feedback) ─────────────
|
||||||
|
//
|
||||||
|
// Zeigt die Leistungen eines einzelnen Schülers in einem Kurs im Überblick — Klausuren,
|
||||||
|
// Mitarbeit- und sonstige Noten, die daraus berechnete Zeugnisnote sowie zwei Rechner
|
||||||
|
// ("Zielnote": was brauche ich noch? / "Was-wäre-wenn": wie wirkt sich eine zusätzliche
|
||||||
|
// Klausurnote aus?). Bewusst pro Kurs statt fächerübergreifend, da Gewichtungsschema und
|
||||||
|
// Notensystem am Kurs hängen. Zwei Modi über IsTeacherMode: im Bewertermodus zusätzlich
|
||||||
|
// Kursdurchschnitt je Klausur und Bearbeitbarkeit der Mitarbeit-/Sonstige-Noten; im
|
||||||
|
// Schülermodus ausschließlich die eigenen Werte, rein lesend — gedacht, um den Bildschirm im
|
||||||
|
// Gespräch umzudrehen. Exam-Noten bleiben bewusst nur lesbar: eine Korrektur läuft weiterhin
|
||||||
|
// über den bestehenden ExamGradingDialog, der die Punkte-Struktur konsistent hält — direktes
|
||||||
|
// Überschreiben von Exam.Grade hier könnte mit ExamResult.Points/TotalPoints auseinanderlaufen.
|
||||||
|
public partial class StudentPerformanceOverviewViewModel : ObservableObject
|
||||||
|
{
|
||||||
|
private readonly IGradeRepository _grades;
|
||||||
|
private readonly IExamRepository _exams;
|
||||||
|
private readonly IExamResultRepository _results;
|
||||||
|
private readonly IGroupMembershipRepository _memberships;
|
||||||
|
private readonly IGradingSchemeRepository _schemes;
|
||||||
|
private readonly GradingService _grading;
|
||||||
|
|
||||||
|
private readonly Guid _groupId;
|
||||||
|
private readonly Guid _studentId;
|
||||||
|
private readonly GroupType _groupType;
|
||||||
|
private readonly GradingSystem _gradingSystem;
|
||||||
|
private readonly string _schoolYear;
|
||||||
|
|
||||||
|
private GradingScheme _scheme = new();
|
||||||
|
private double? _examsAverage;
|
||||||
|
private double? _participationAverage;
|
||||||
|
private double? _otherAverage;
|
||||||
|
private List<(string Grade, double Weight)> _examGradesRaw = [];
|
||||||
|
private List<(string Grade, double Weight)> _participationGradesRaw = [];
|
||||||
|
private List<(string Grade, double Weight)> _otherGradesRaw = [];
|
||||||
|
|
||||||
|
public string StudentName { get; }
|
||||||
|
public string GroupLabel { get; }
|
||||||
|
|
||||||
|
[ObservableProperty] private ParticipationPeriodOption _selectedPeriod;
|
||||||
|
[ObservableProperty] private bool _isTeacherMode = true;
|
||||||
|
[ObservableProperty] private string _schemeSummary = "";
|
||||||
|
[ObservableProperty] private string? _examsAverageDisplay;
|
||||||
|
[ObservableProperty] private string? _participationAverageDisplay;
|
||||||
|
[ObservableProperty] private string? _otherAverageDisplay;
|
||||||
|
[ObservableProperty] private string _currentReportGradeDisplay = "";
|
||||||
|
|
||||||
|
[ObservableProperty] private string _targetGradeText = "";
|
||||||
|
[ObservableProperty] private GradeBucketKind _targetBucket = GradeBucketKind.Exams;
|
||||||
|
[ObservableProperty] private string _targetResultDisplay = "";
|
||||||
|
|
||||||
|
[ObservableProperty] private string _whatIfExamGradeText = "";
|
||||||
|
[ObservableProperty] private string _whatIfResultDisplay = "";
|
||||||
|
|
||||||
|
public List<ParticipationPeriodOption> PeriodOptions { get; } =
|
||||||
|
[
|
||||||
|
new(ParticipationPeriod.FullYear, "Gesamtes Schuljahr"),
|
||||||
|
new(ParticipationPeriod.H1, "1. Halbjahr"),
|
||||||
|
new(ParticipationPeriod.H2, "2. Halbjahr"),
|
||||||
|
];
|
||||||
|
|
||||||
|
public string[] TargetBucketOptions { get; } = ["Klausuren", "Mitarbeit", "Sonstige"];
|
||||||
|
|
||||||
|
public string TargetBucketName
|
||||||
|
{
|
||||||
|
get => TargetBucket switch
|
||||||
|
{
|
||||||
|
GradeBucketKind.Participation => "Mitarbeit",
|
||||||
|
GradeBucketKind.Other => "Sonstige",
|
||||||
|
_ => "Klausuren",
|
||||||
|
};
|
||||||
|
set => TargetBucket = value switch
|
||||||
|
{
|
||||||
|
"Mitarbeit" => GradeBucketKind.Participation,
|
||||||
|
"Sonstige" => GradeBucketKind.Other,
|
||||||
|
_ => GradeBucketKind.Exams,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public ObservableCollection<StudentExamRow> ExamRows { get; } = [];
|
||||||
|
/// Eigener Verlauf (Nutzer-Feedback): dieselbe Balken-Sparkline wie die bestehende
|
||||||
|
/// Notenentwicklung im Schülerdetail (2.5), hier nur auf die Klausuren dieses Kurses
|
||||||
|
/// beschränkt statt aller Lerngruppen — passt zum Kursdurchschnitt-Vergleich je Klausur.
|
||||||
|
public StudentGradeHistoryGroup ExamHistory { get; } = new("Klausurverlauf");
|
||||||
|
public ObservableCollection<StudentSimpleGradeRow> ParticipationRows { get; } = [];
|
||||||
|
public ObservableCollection<StudentSimpleGradeRow> OtherRows { get; } = [];
|
||||||
|
|
||||||
|
public StudentPerformanceOverviewViewModel(IGradeRepository grades, IExamRepository exams,
|
||||||
|
IExamResultRepository results, IGroupMembershipRepository memberships,
|
||||||
|
IGradingSchemeRepository schemes, GradingService grading,
|
||||||
|
Guid groupId, Guid studentId, GroupType groupType, GradingSystem gradingSystem,
|
||||||
|
string studentName, string groupLabel, string schoolYear)
|
||||||
|
{
|
||||||
|
_grades = grades; _exams = exams; _results = results; _memberships = memberships;
|
||||||
|
_schemes = schemes; _grading = grading;
|
||||||
|
_groupId = groupId; _studentId = studentId; _groupType = groupType;
|
||||||
|
_gradingSystem = gradingSystem; _schoolYear = schoolYear;
|
||||||
|
StudentName = studentName; GroupLabel = groupLabel;
|
||||||
|
|
||||||
|
_selectedPeriod = PeriodOptions[0];
|
||||||
|
Recompute();
|
||||||
|
}
|
||||||
|
|
||||||
|
partial void OnSelectedPeriodChanged(ParticipationPeriodOption value) => Recompute();
|
||||||
|
|
||||||
|
public string ModeButtonLabel => IsTeacherMode ? "Modus: Bewerter" : "Modus: Schüler";
|
||||||
|
|
||||||
|
partial void OnIsTeacherModeChanged(bool value) => OnPropertyChanged(nameof(ModeButtonLabel));
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
private void ToggleMode() => IsTeacherMode = !IsTeacherMode;
|
||||||
|
|
||||||
|
private GradingScheme ResolveScheme() =>
|
||||||
|
_schemes.GetByGroup(_groupId)
|
||||||
|
?? _schemes.GetDefaultForType(_groupType)
|
||||||
|
?? new GradingScheme { ExamsPercent = 50, ParticipationPercent = 40, OtherPercent = 10 };
|
||||||
|
|
||||||
|
private void Recompute()
|
||||||
|
{
|
||||||
|
_scheme = ResolveScheme();
|
||||||
|
SchemeSummary = $"Klausuren {_scheme.ExamsPercent:0.#} % · Mitarbeit {_scheme.ParticipationPercent:0.#} % · " +
|
||||||
|
$"Sonstige {_scheme.OtherPercent:0.#} %";
|
||||||
|
|
||||||
|
var (periodFrom, periodTo) = GroupMembershipService.SchoolYearPeriod(_schoolYear, SelectedPeriod.Period switch
|
||||||
|
{
|
||||||
|
ParticipationPeriod.H1 => SchoolYearPeriodKind.H1,
|
||||||
|
ParticipationPeriod.H2 => SchoolYearPeriodKind.H2,
|
||||||
|
_ => SchoolYearPeriodKind.FullYear,
|
||||||
|
});
|
||||||
|
var membership = _memberships.GetByGroup(_groupId).FirstOrDefault(m => m.StudentId == _studentId);
|
||||||
|
|
||||||
|
// ── Klausuren ─────────────────────────────────────────────────────
|
||||||
|
ExamRows.Clear();
|
||||||
|
ExamHistory.Points.Clear();
|
||||||
|
_examGradesRaw = [];
|
||||||
|
int? previousNoteEquivalent = null;
|
||||||
|
foreach (var exam in _exams.GetByGroup(_groupId)
|
||||||
|
.Where(e => e.Date >= periodFrom && e.Date <= periodTo)
|
||||||
|
.Where(e => membership is null || GroupMembershipService.IsActiveOn(membership, e.Date))
|
||||||
|
.Where(e => !e.Niveau.HasValue || membership?.Niveau == e.Niveau)
|
||||||
|
.OrderBy(e => e.Date))
|
||||||
|
{
|
||||||
|
var allResults = _results.GetByExam(exam.Id);
|
||||||
|
var own = allResults.FirstOrDefault(r => r.StudentId == _studentId);
|
||||||
|
string? ownGrade = own is { Absent: false, Grade: not null } ? own.Grade : null;
|
||||||
|
if (ownGrade is not null) _examGradesRaw.Add((ownGrade, 1.0));
|
||||||
|
|
||||||
|
var classGrades = allResults.Where(r => !r.Absent && r.Grade is not null)
|
||||||
|
.Select(r => (r.Grade!, 1.0)).ToList();
|
||||||
|
var classAverage = classGrades.Count > 0 ? _grading.WeightedAverage(classGrades) : (double?)null;
|
||||||
|
|
||||||
|
ExamRows.Add(new StudentExamRow(exam.Title, exam.Date, ownGrade,
|
||||||
|
classAverage.HasValue ? FormatGrade(classAverage.Value) : "–"));
|
||||||
|
|
||||||
|
// Gleiche Balken-Sparkline wie die bestehende Notenentwicklung (2.5): Rohwert auf die
|
||||||
|
// 1..6-Notenäquivalent-Achse abbilden, damit Grades1To6 und Points0To15 dieselbe
|
||||||
|
// Balkenhöhen-Formel nutzen können.
|
||||||
|
int? noteEquivalent = ownGrade is not null && int.TryParse(ownGrade, out var raw)
|
||||||
|
? (_gradingSystem == GradingSystem.Grades1To6 ? raw : int.Parse(PointsNoteMapping.PointsToNote(raw)))
|
||||||
|
: null;
|
||||||
|
var isFailing = noteEquivalent is >= 5;
|
||||||
|
var isDrop = noteEquivalent.HasValue && previousNoteEquivalent.HasValue
|
||||||
|
&& noteEquivalent.Value - previousNoteEquivalent.Value >= 1;
|
||||||
|
var warnings = new List<string>();
|
||||||
|
if (isDrop) warnings.Add("Abfall um ≥ 1 Note");
|
||||||
|
if (isFailing) warnings.Add("Versetzungsgefährdung");
|
||||||
|
ExamHistory.Points.Add(new GradeHistoryPoint(exam.Date, exam.Title, ownGrade ?? "–",
|
||||||
|
noteEquivalent, warnings.Count > 0, string.Join(" · ", warnings)));
|
||||||
|
if (noteEquivalent.HasValue) previousNoteEquivalent = noteEquivalent;
|
||||||
|
}
|
||||||
|
_examsAverage = _examGradesRaw.Count > 0 ? _grading.WeightedAverage(_examGradesRaw) : null;
|
||||||
|
|
||||||
|
// ── Mitarbeit & Sonstige ──────────────────────────────────────────
|
||||||
|
var allGrades = _grades.GetByStudentAndGroup(_studentId, _groupId)
|
||||||
|
.Where(g => g.Date >= periodFrom && g.Date <= periodTo)
|
||||||
|
.Where(g => membership is null || GroupMembershipService.IsActiveOn(membership, g.Date))
|
||||||
|
.OrderBy(g => g.Date).ToList();
|
||||||
|
|
||||||
|
ParticipationRows.Clear();
|
||||||
|
_participationGradesRaw = [];
|
||||||
|
foreach (var g in allGrades.Where(g => g.Category == GradeCategory.Participation))
|
||||||
|
{
|
||||||
|
_participationGradesRaw.Add((g.Value, g.Weight));
|
||||||
|
ParticipationRows.Add(new StudentSimpleGradeRow(g, SaveGrade));
|
||||||
|
}
|
||||||
|
_participationAverage = _participationGradesRaw.Count > 0 ? _grading.WeightedAverage(_participationGradesRaw) : null;
|
||||||
|
|
||||||
|
OtherRows.Clear();
|
||||||
|
_otherGradesRaw = [];
|
||||||
|
foreach (var g in allGrades.Where(g => g.Category != GradeCategory.Participation))
|
||||||
|
{
|
||||||
|
_otherGradesRaw.Add((g.Value, g.Weight));
|
||||||
|
OtherRows.Add(new StudentSimpleGradeRow(g, SaveGrade));
|
||||||
|
}
|
||||||
|
_otherAverage = _otherGradesRaw.Count > 0 ? _grading.WeightedAverage(_otherGradesRaw) : null;
|
||||||
|
|
||||||
|
ExamsAverageDisplay = _examsAverage.HasValue ? FormatGrade(_examsAverage.Value) : null;
|
||||||
|
ParticipationAverageDisplay = _participationAverage.HasValue ? FormatGrade(_participationAverage.Value) : null;
|
||||||
|
OtherAverageDisplay = _otherAverage.HasValue ? FormatGrade(_otherAverage.Value) : null;
|
||||||
|
|
||||||
|
var reportGrade = _grading.CalculateReportGrade(_examGradesRaw, _participationGradesRaw, _otherGradesRaw,
|
||||||
|
_scheme, _gradingSystem, RoundingRule.Commercial);
|
||||||
|
CurrentReportGradeDisplay = reportGrade is null
|
||||||
|
? "Noch nicht berechenbar — keine Werte im gewählten Zeitraum."
|
||||||
|
: $"Note {reportGrade}";
|
||||||
|
|
||||||
|
RecomputeTarget();
|
||||||
|
RecomputeWhatIf();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void SaveGrade(Grade grade)
|
||||||
|
{
|
||||||
|
_grades.Save(grade);
|
||||||
|
Recompute();
|
||||||
|
}
|
||||||
|
|
||||||
|
partial void OnTargetGradeTextChanged(string value) => RecomputeTarget();
|
||||||
|
partial void OnTargetBucketChanged(GradeBucketKind value)
|
||||||
|
{
|
||||||
|
OnPropertyChanged(nameof(TargetBucketName));
|
||||||
|
RecomputeTarget();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void RecomputeTarget()
|
||||||
|
{
|
||||||
|
if (!TryParseGrade(TargetGradeText, out var target))
|
||||||
|
{
|
||||||
|
TargetResultDisplay = "";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var result = ReportGradeTargetCalculator.SolveRequiredAverage(target, TargetBucket,
|
||||||
|
_examsAverage, _participationAverage, _otherAverage, _scheme, _gradingSystem);
|
||||||
|
|
||||||
|
TargetResultDisplay = result switch
|
||||||
|
{
|
||||||
|
null => $"{TargetBucketName} fließt laut Gewichtungsschema nicht in die Zeugnisnote ein.",
|
||||||
|
{ IsAchievable: true } r => $"Nötiger Schnitt in {TargetBucketName}: {FormatGrade(r.RequiredAverage)}",
|
||||||
|
{ IsAchievable: false } r =>
|
||||||
|
$"Mit den übrigen Bereichen rechnerisch nicht mehr erreichbar (bräuchte {FormatGrade(r.RequiredAverage)}).",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
partial void OnWhatIfExamGradeTextChanged(string value) => RecomputeWhatIf();
|
||||||
|
|
||||||
|
private void RecomputeWhatIf()
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(WhatIfExamGradeText))
|
||||||
|
{
|
||||||
|
WhatIfResultDisplay = "";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var hypothetical = new List<(string Grade, double Weight)>(_examGradesRaw) { (WhatIfExamGradeText.Trim(), 1.0) };
|
||||||
|
var result = _grading.CalculateReportGrade(hypothetical, _participationGradesRaw, _otherGradesRaw,
|
||||||
|
_scheme, _gradingSystem, RoundingRule.Commercial);
|
||||||
|
WhatIfResultDisplay = result is null
|
||||||
|
? "Ungültige Note."
|
||||||
|
: $"Zeugnisnote mit dieser zusätzlichen Klausurnote: {result}";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool TryParseGrade(string text, out double value) =>
|
||||||
|
double.TryParse(text.Trim().Replace(',', '.'), NumberStyles.Float, CultureInfo.InvariantCulture, out value);
|
||||||
|
|
||||||
|
private static string FormatGrade(double value) => value.ToString("0.0", CultureInfo.InvariantCulture);
|
||||||
|
}
|
||||||
|
|
||||||
|
public class StudentExamRow(string title, DateOnly date, string? ownGrade, string classAverageDisplay)
|
||||||
|
{
|
||||||
|
public string Title { get; } = title;
|
||||||
|
public string DateDisplay { get; } = date.ToString("dd.MM.yyyy");
|
||||||
|
public string OwnGradeDisplay { get; } = ownGrade ?? "–";
|
||||||
|
public string ClassAverageDisplay { get; } = classAverageDisplay;
|
||||||
|
}
|
||||||
|
|
||||||
|
public partial class StudentSimpleGradeRow : ObservableObject
|
||||||
|
{
|
||||||
|
private readonly Grade _grade;
|
||||||
|
private readonly Action<Grade> _save;
|
||||||
|
|
||||||
|
public string CategoryLabel { get; }
|
||||||
|
public string DateDisplay { get; }
|
||||||
|
[ObservableProperty] private string _value;
|
||||||
|
|
||||||
|
public StudentSimpleGradeRow(Grade grade, Action<Grade> save)
|
||||||
|
{
|
||||||
|
_grade = grade;
|
||||||
|
_save = save;
|
||||||
|
CategoryLabel = GradeCategoryDisplay.Label(grade.Category);
|
||||||
|
DateDisplay = grade.Date.ToString("dd.MM.yyyy");
|
||||||
|
_value = grade.Value;
|
||||||
|
}
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
private void Save()
|
||||||
|
{
|
||||||
|
_grade.Value = Value.Trim();
|
||||||
|
_save(_grade);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -0,0 +1,169 @@
|
|||||||
|
using LehrerApp.Core.Models;
|
||||||
|
|
||||||
|
namespace LehrerApp.Desktop.ViewModels.Planning;
|
||||||
|
|
||||||
|
public enum SchoolDayWeatherKind { Sunny, Cloudy, Rain, Storm, Thunderstorm }
|
||||||
|
|
||||||
|
/// <summary>Verdichtet die stündliche MOSMIX-Prognose auf ein planungsrelevantes Schultagsymbol.
|
||||||
|
/// Extremere Bedingungen haben bewusst Vorrang vor Mittelwerten.</summary>
|
||||||
|
public sealed class SchoolDayWeatherSummary
|
||||||
|
{
|
||||||
|
public DateOnly Date { get; init; }
|
||||||
|
public SchoolDayWeatherKind Kind { get; init; }
|
||||||
|
public double? MinTemperatureC { get; init; }
|
||||||
|
public double? MaxTemperatureC { get; init; }
|
||||||
|
public double PrecipitationMm { get; init; }
|
||||||
|
public double? MaxHourlyPrecipitationProbabilityPercent { get; init; }
|
||||||
|
public double? SunshineDurationHours { get; init; }
|
||||||
|
public double? MaxWindGustKmh { get; init; }
|
||||||
|
public IReadOnlyList<WeatherWarning> Warnings { get; init; } = [];
|
||||||
|
|
||||||
|
public string Symbol => Kind switch
|
||||||
|
{
|
||||||
|
SchoolDayWeatherKind.Thunderstorm => "⛈️",
|
||||||
|
SchoolDayWeatherKind.Storm => "💨",
|
||||||
|
SchoolDayWeatherKind.Rain => "🌧️",
|
||||||
|
SchoolDayWeatherKind.Cloudy => "☁️",
|
||||||
|
_ => "☀️",
|
||||||
|
};
|
||||||
|
|
||||||
|
public string Label => Kind switch
|
||||||
|
{
|
||||||
|
SchoolDayWeatherKind.Thunderstorm => "Gewitter",
|
||||||
|
SchoolDayWeatherKind.Storm => "Sturm",
|
||||||
|
SchoolDayWeatherKind.Rain => "Regen",
|
||||||
|
SchoolDayWeatherKind.Cloudy => "Bewölkt",
|
||||||
|
_ => "Sonnig",
|
||||||
|
};
|
||||||
|
|
||||||
|
public string Tooltip
|
||||||
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
var lines = new List<string> { $"{Label} · 08–16 Uhr" };
|
||||||
|
if (MinTemperatureC is { } min && MaxTemperatureC is { } max)
|
||||||
|
lines.Add("Temperatur: " + (Math.Abs(max - min) < 0.05
|
||||||
|
? $"{min:0.#} °C" : $"{min:0.#}–{max:0.#} °C"));
|
||||||
|
if (MaxHourlyPrecipitationProbabilityPercent is { } probability)
|
||||||
|
lines.Add($"Regenwahrscheinlichkeit: bis {probability:0} % (stündlich)");
|
||||||
|
if (PrecipitationMm >= 0.05) lines.Add($"Niederschlag: {PrecipitationMm:0.#} mm");
|
||||||
|
if (SunshineDurationHours is { } sunshine)
|
||||||
|
lines.Add($"Sonnenschein: {sunshine:0.#} h");
|
||||||
|
if (MaxWindGustKmh is { } gust) lines.Add($"Max. Böen: {gust:0.#} km/h");
|
||||||
|
foreach (var warning in Warnings)
|
||||||
|
{
|
||||||
|
var period = WarningPeriod(warning);
|
||||||
|
lines.Add($"⚠ {warning.Headline}{(period.Length > 0 ? $" ({period})" : "")}");
|
||||||
|
if (!string.IsNullOrWhiteSpace(warning.Instruction)) lines.Add(warning.Instruction.Trim());
|
||||||
|
}
|
||||||
|
return string.Join("\n", lines);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static SchoolDayWeatherSummary? From(WeatherSnapshot snapshot, DateOnly date)
|
||||||
|
{
|
||||||
|
var allHours = snapshot.Forecast
|
||||||
|
.Where(x => DateOnly.FromDateTime(Local(x.ValidAt)) == date)
|
||||||
|
.ToList();
|
||||||
|
// Für die Unterrichtsplanung zählt vor allem der Zeitraum, in dem Schule stattfindet.
|
||||||
|
// Falls MOSMIX für diesen Zeitraum noch keine Werte liefert, bleibt der Tagesrest nutzbar.
|
||||||
|
var schoolHours = allHours.Where(x =>
|
||||||
|
{
|
||||||
|
var hour = Local(x.ValidAt).Hour;
|
||||||
|
return hour >= 8 && hour <= 16;
|
||||||
|
}).ToList();
|
||||||
|
var hours = schoolHours.Count > 0 ? schoolHours : allHours;
|
||||||
|
if (hours.Count == 0) return null;
|
||||||
|
|
||||||
|
var warnings = snapshot.Warnings.Where(x => WarningTouchesDate(x, date)).ToList();
|
||||||
|
var codes = hours.Where(x => x.WeatherCode.HasValue).Select(x => x.WeatherCode!.Value).ToList();
|
||||||
|
var precipitation = hours.Sum(x => x.PrecipitationMm ?? 0);
|
||||||
|
var probabilities = hours.Where(x => x.PrecipitationProbabilityPercent.HasValue)
|
||||||
|
.Select(x => x.PrecipitationProbabilityPercent!.Value).ToList();
|
||||||
|
var sunshineMinutes = hours.Where(x => x.SunshineDurationMinutes.HasValue)
|
||||||
|
.Select(x => x.SunshineDurationMinutes!.Value).ToList();
|
||||||
|
var gust = hours.Where(x => x.WindGustKmh.HasValue).Select(x => x.WindGustKmh!.Value).DefaultIfEmpty().Max();
|
||||||
|
var hasGust = hours.Any(x => x.WindGustKmh.HasValue);
|
||||||
|
var warningText = string.Join(" ", warnings.SelectMany(x => new[] { x.Event, x.Headline }));
|
||||||
|
|
||||||
|
var kind = codes.Any(x => x is >= 95 and <= 99) ||
|
||||||
|
warningText.Contains("GEWITTER", StringComparison.OrdinalIgnoreCase)
|
||||||
|
? SchoolDayWeatherKind.Thunderstorm
|
||||||
|
: (hasGust && gust >= 62) || ContainsStormWarning(warningText)
|
||||||
|
? SchoolDayWeatherKind.Storm
|
||||||
|
: precipitation >= 0.2 || codes.Any(IsRainCode)
|
||||||
|
? SchoolDayWeatherKind.Rain
|
||||||
|
: IsCloudy(hours, codes)
|
||||||
|
? SchoolDayWeatherKind.Cloudy
|
||||||
|
: SchoolDayWeatherKind.Sunny;
|
||||||
|
|
||||||
|
var temperatures = hours.Where(x => x.TemperatureC.HasValue)
|
||||||
|
.Select(x => x.TemperatureC!.Value).ToList();
|
||||||
|
return new SchoolDayWeatherSummary
|
||||||
|
{
|
||||||
|
Date = date,
|
||||||
|
Kind = kind,
|
||||||
|
MinTemperatureC = temperatures.Count > 0 ? temperatures.Min() : null,
|
||||||
|
MaxTemperatureC = temperatures.Count > 0 ? temperatures.Max() : null,
|
||||||
|
PrecipitationMm = Math.Round(precipitation, 1, MidpointRounding.AwayFromZero),
|
||||||
|
MaxHourlyPrecipitationProbabilityPercent = probabilities.Count > 0
|
||||||
|
? Math.Round(probabilities.Max(), 0, MidpointRounding.AwayFromZero) : null,
|
||||||
|
SunshineDurationHours = sunshineMinutes.Count > 0
|
||||||
|
? Math.Round(sunshineMinutes.Sum() / 60, 1, MidpointRounding.AwayFromZero) : null,
|
||||||
|
MaxWindGustKmh = hasGust ? Math.Round(gust, 1, MidpointRounding.AwayFromZero) : null,
|
||||||
|
Warnings = warnings,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public static bool WarningTouchesDate(WeatherWarning warning, DateOnly date)
|
||||||
|
{
|
||||||
|
if (warning.Onset is null && warning.Expires is null) return false;
|
||||||
|
var start = warning.Onset is { } onset ? Local(onset) : Local(warning.Expires!.Value).AddHours(-12);
|
||||||
|
var end = warning.Expires is { } expires ? Local(expires) : start.AddHours(12);
|
||||||
|
var dayStart = date.ToDateTime(TimeOnly.MinValue);
|
||||||
|
var dayEnd = date.ToDateTime(TimeOnly.MaxValue);
|
||||||
|
return start <= dayEnd && end >= dayStart;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static string WarningPeriod(WeatherWarning warning)
|
||||||
|
{
|
||||||
|
var onset = warning.Onset is { } start ? Local(start) : (DateTime?)null;
|
||||||
|
var expires = warning.Expires is { } end ? Local(end) : (DateTime?)null;
|
||||||
|
return (onset, expires) switch
|
||||||
|
{
|
||||||
|
({ } a, { } b) when DateOnly.FromDateTime(a) == DateOnly.FromDateTime(b) =>
|
||||||
|
$"{a:dd.MM., HH:mm}–{b:HH:mm} Uhr",
|
||||||
|
({ } a, { } b) => $"{a:dd.MM., HH:mm}–{b:dd.MM., HH:mm} Uhr",
|
||||||
|
({ } a, null) => $"ab {a:dd.MM., HH:mm} Uhr",
|
||||||
|
(null, { } b) => $"bis {b:dd.MM., HH:mm} Uhr",
|
||||||
|
_ => "",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsRainCode(int code) => code is >= 51 and <= 67 or >= 80 and <= 82;
|
||||||
|
|
||||||
|
private static bool IsCloudy(IEnumerable<WeatherForecastHour> hours, IEnumerable<int> codes)
|
||||||
|
{
|
||||||
|
var cloudValues = hours.Where(x => x.CloudCoverPercent.HasValue)
|
||||||
|
.Select(x => x.CloudCoverPercent!.Value).ToList();
|
||||||
|
return codes.Any(x => x is 2 or 3 or 45 or 48) ||
|
||||||
|
cloudValues.Count > 0 && cloudValues.Average() >= 60;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool ContainsStormWarning(string text) =>
|
||||||
|
text.Contains("STURM", StringComparison.OrdinalIgnoreCase) ||
|
||||||
|
text.Contains("ORKAN", StringComparison.OrdinalIgnoreCase) ||
|
||||||
|
text.Contains("WIND", StringComparison.OrdinalIgnoreCase) ||
|
||||||
|
text.Contains("BÖEN", StringComparison.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
private static DateTime Local(DateTime value) => value.Kind == DateTimeKind.Utc
|
||||||
|
? value.ToLocalTime() : value;
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class WeekWeatherWarningItem(WeatherWarning warning)
|
||||||
|
{
|
||||||
|
public string Headline { get; } = string.IsNullOrWhiteSpace(warning.Headline) ? warning.Event : warning.Headline;
|
||||||
|
public string PeriodDisplay { get; } = SchoolDayWeatherSummary.WarningPeriod(warning);
|
||||||
|
public string Tooltip { get; } = string.Join("\n", new[] { warning.Description, warning.Instruction }
|
||||||
|
.Where(x => !string.IsNullOrWhiteSpace(x)));
|
||||||
|
}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
using CommunityToolkit.Mvvm.ComponentModel;
|
||||||
|
using CommunityToolkit.Mvvm.Input;
|
||||||
|
using LehrerApp.Core.Interfaces;
|
||||||
|
using LehrerApp.Core.Models;
|
||||||
|
using System.Collections.ObjectModel;
|
||||||
|
|
||||||
|
namespace LehrerApp.Desktop.ViewModels.Planning;
|
||||||
|
|
||||||
|
public sealed class TimetableUnitOption(Unit unit)
|
||||||
|
{
|
||||||
|
public Unit Model { get; } = unit;
|
||||||
|
public string Label { get; } = unit.Title;
|
||||||
|
public string Detail { get; } = unit.Status switch
|
||||||
|
{
|
||||||
|
UnitStatus.Active => "Laufende Einheit",
|
||||||
|
UnitStatus.Completed => "Abgeschlossene Einheit",
|
||||||
|
_ => "Geplante Einheit",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Ordnet eine direkt aus dem Stundenplan angelegte Stunde einer Einheit zu.</summary>
|
||||||
|
public partial class TimetableUnitPickerViewModel : ObservableObject
|
||||||
|
{
|
||||||
|
private readonly IUnitRepository _units;
|
||||||
|
private readonly Guid _groupId;
|
||||||
|
private readonly DateOnly _date;
|
||||||
|
|
||||||
|
public string ContextLabel { get; }
|
||||||
|
public ObservableCollection<TimetableUnitOption> Units { get; } = [];
|
||||||
|
[ObservableProperty] private TimetableUnitOption? _selectedUnit;
|
||||||
|
[ObservableProperty] private string _newUnitTitle = "";
|
||||||
|
[ObservableProperty] private string _error = "";
|
||||||
|
public Unit? Result { get; private set; }
|
||||||
|
public bool ResultIsNew { get; private set; }
|
||||||
|
public bool HasUnits => Units.Count > 0;
|
||||||
|
|
||||||
|
public TimetableUnitPickerViewModel(IUnitRepository units, Guid groupId, string groupName,
|
||||||
|
DateOnly date, int period)
|
||||||
|
{
|
||||||
|
_units = units;
|
||||||
|
_groupId = groupId;
|
||||||
|
_date = date;
|
||||||
|
ContextLabel = $"{groupName} · {date:dd.MM.yyyy} · {period}. Stunde";
|
||||||
|
|
||||||
|
foreach (var unit in units.GetByGroup(groupId)
|
||||||
|
.OrderBy(u => u.Status == UnitStatus.Active ? 0 : u.Status == UnitStatus.Planned ? 1 : 2)
|
||||||
|
.ThenByDescending(u => u.StartDate)
|
||||||
|
.ThenBy(u => u.Title, StringComparer.CurrentCultureIgnoreCase))
|
||||||
|
Units.Add(new TimetableUnitOption(unit));
|
||||||
|
SelectedUnit = Units.FirstOrDefault();
|
||||||
|
}
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
private void Save()
|
||||||
|
{
|
||||||
|
Error = "";
|
||||||
|
if (!string.IsNullOrWhiteSpace(NewUnitTitle))
|
||||||
|
{
|
||||||
|
Result = new Unit
|
||||||
|
{
|
||||||
|
GroupId = _groupId,
|
||||||
|
Title = NewUnitTitle.Trim(),
|
||||||
|
StartDate = _date,
|
||||||
|
Status = UnitStatus.Active,
|
||||||
|
};
|
||||||
|
// Erst speichern, wenn auch der anschließende Stunden-Dialog bestätigt wurde. So
|
||||||
|
// hinterlässt ein Abbruch keine leere Einheit.
|
||||||
|
ResultIsNew = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (SelectedUnit is null)
|
||||||
|
{
|
||||||
|
Error = "Bitte eine Einheit auswählen oder eine neue benennen.";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Result = SelectedUnit.Model;
|
||||||
|
ResultIsNew = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed record TimetableLessonRequest(Guid GroupId, DateOnly Date, int PeriodNumber);
|
||||||
|
public sealed record TimetableLessonMoveRequest(Lesson Lesson, int SelectedPeriod);
|
||||||
@@ -5,6 +5,7 @@ using LehrerApp.Core.Models;
|
|||||||
using LehrerApp.Core.Services;
|
using LehrerApp.Core.Services;
|
||||||
using LehrerApp.Desktop.Services;
|
using LehrerApp.Desktop.Services;
|
||||||
using LehrerApp.Desktop.ViewModels.Settings;
|
using LehrerApp.Desktop.ViewModels.Settings;
|
||||||
|
using LehrerApp.Desktop.ViewModels.Groups;
|
||||||
using System.Collections.ObjectModel;
|
using System.Collections.ObjectModel;
|
||||||
|
|
||||||
namespace LehrerApp.Desktop.ViewModels.Planning;
|
namespace LehrerApp.Desktop.ViewModels.Planning;
|
||||||
@@ -65,6 +66,10 @@ 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 SemaphoreSlim _weatherGate = new(1, 1);
|
||||||
|
private WeatherSnapshot? _weatherSnapshot;
|
||||||
|
|
||||||
public ObservableCollection<TimetableCellItem> Cells { get; } = [];
|
public ObservableCollection<TimetableCellItem> Cells { get; } = [];
|
||||||
public ObservableCollection<WeekCellItem> WeekItems { get; } = [];
|
public ObservableCollection<WeekCellItem> WeekItems { get; } = [];
|
||||||
@@ -73,6 +78,7 @@ public partial class TimetableViewModel : ObservableObject
|
|||||||
public ObservableCollection<TodaySupervisionItem> TodaySupervisions { get; } = [];
|
public ObservableCollection<TodaySupervisionItem> TodaySupervisions { get; } = [];
|
||||||
public ObservableCollection<TodaySpecialAssignmentItem> TodaySpecialAssignments { get; } = [];
|
public ObservableCollection<TodaySpecialAssignmentItem> TodaySpecialAssignments { get; } = [];
|
||||||
public ObservableCollection<UpcomingExamItem> UpcomingExams { get; } = [];
|
public ObservableCollection<UpcomingExamItem> UpcomingExams { get; } = [];
|
||||||
|
public ObservableCollection<WeekWeatherWarningItem> WeekWeatherWarnings { get; } = [];
|
||||||
|
|
||||||
/// Cells/WeekItems zeilenweise gruppiert (GridColumns Zellen je Zeile), zusätzlich zur
|
/// Cells/WeekItems zeilenweise gruppiert (GridColumns Zellen je Zeile), zusätzlich zur
|
||||||
/// flachen Liste - Grundlage für die zeilenweise Höhensteuerung in der View (Aufsicht-Zeilen
|
/// flachen Liste - Grundlage für die zeilenweise Höhensteuerung in der View (Aufsicht-Zeilen
|
||||||
@@ -99,22 +105,33 @@ 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; }
|
||||||
|
public Func<TimetableLessonRequest, Task>? OnCreateLesson { get; set; }
|
||||||
|
public Func<TimetableLessonMoveRequest, Task>? OnMoveLesson { get; set; }
|
||||||
|
|
||||||
|
/// Öffentlich statt intern (kein InternalsVisibleTo in dieser Codebasis) - erlaubt Tests, die
|
||||||
|
/// "heute"-abhängiges Verhalten (Wochenraster-Badges, Unterrichtszeit-Erkennung) prüfen, ohne
|
||||||
|
/// 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,
|
||||||
|
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;
|
||||||
Load();
|
Load();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -137,9 +154,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>();
|
||||||
@@ -152,12 +177,58 @@ public partial class TimetableViewModel : ObservableObject
|
|||||||
|
|
||||||
BuildGrid(holidayBadges, duties);
|
BuildGrid(holidayBadges, duties);
|
||||||
BuildWeekOverview(today, publicHolidayDates, duties);
|
BuildWeekOverview(today, publicHolidayDates, duties);
|
||||||
|
ApplyWeatherToWeekHeaders(today);
|
||||||
|
_ = LoadWeatherAsync(today);
|
||||||
BuildToday(today);
|
BuildToday(today);
|
||||||
BuildHoursWarnings();
|
BuildHoursWarnings();
|
||||||
BuildUpcomingExams(today);
|
BuildUpcomingExams(today);
|
||||||
LoadUntisMismatch();
|
LoadUntisMismatch();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async Task LoadWeatherAsync(DateOnly today)
|
||||||
|
{
|
||||||
|
if (_schoolWeather?.IsAvailable != true) return;
|
||||||
|
if (_weatherSnapshot is not null &&
|
||||||
|
DateTime.UtcNow - _weatherSnapshot.RetrievedAt.ToUniversalTime() < TimeSpan.FromMinutes(5))
|
||||||
|
return;
|
||||||
|
if (!await _weatherGate.WaitAsync(0)) return;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
_weatherSnapshot = await _schoolWeather.GetWeatherAsync();
|
||||||
|
ApplyWeatherToWeekHeaders(today);
|
||||||
|
}
|
||||||
|
catch (SchoolWeatherException) { /* Wetter ist eine optionale Planungshilfe. */ }
|
||||||
|
finally { _weatherGate.Release(); }
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ApplyWeatherToWeekHeaders(DateOnly today)
|
||||||
|
{
|
||||||
|
WeekWeatherWarnings.Clear();
|
||||||
|
var datedHeaders = WeekItems.Where(x => x.IsHeader && x.Date.HasValue).ToList();
|
||||||
|
foreach (var header in datedHeaders)
|
||||||
|
{
|
||||||
|
var date = header.Date!.Value;
|
||||||
|
var warnings = _weatherSnapshot?.Warnings
|
||||||
|
.Where(x => date >= today && SchoolDayWeatherSummary.WarningTouchesDate(x, date))
|
||||||
|
.ToList() ?? [];
|
||||||
|
var summary = date >= today && _weatherSnapshot is not null
|
||||||
|
? SchoolDayWeatherSummary.From(_weatherSnapshot, date)
|
||||||
|
: null;
|
||||||
|
header.SetWeather(summary, warnings);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_weatherSnapshot is null || datedHeaders.Count == 0) return;
|
||||||
|
var from = datedHeaders.Min(x => x.Date!.Value);
|
||||||
|
var until = datedHeaders.Max(x => x.Date!.Value);
|
||||||
|
foreach (var warning in _weatherSnapshot.Warnings
|
||||||
|
.Where(x => Enumerable.Range(0, until.DayNumber - from.DayNumber + 1)
|
||||||
|
.Select(from.AddDays)
|
||||||
|
.Any(date => date >= today && SchoolDayWeatherSummary.WarningTouchesDate(x, date)))
|
||||||
|
.DistinctBy(x => x.Identifier)
|
||||||
|
.OrderBy(x => x.Onset))
|
||||||
|
WeekWeatherWarnings.Add(new WeekWeatherWarningItem(warning));
|
||||||
|
}
|
||||||
|
|
||||||
private void LoadUntisMismatch()
|
private void LoadUntisMismatch()
|
||||||
{
|
{
|
||||||
if (!_untisSettings.Enabled || !_untisSettings.IsConfigured) { HasUntisMismatch = false; return; }
|
if (!_untisSettings.Enabled || !_untisSettings.IsConfigured) { HasUntisMismatch = false; return; }
|
||||||
@@ -233,9 +304,9 @@ public partial class TimetableViewModel : ObservableObject
|
|||||||
var cancelled = substitutionsToday.FirstOrDefault(s => s.Kind == SubstitutionKind.Cancelled && s.PeriodNumber == slot.PeriodNumber);
|
var cancelled = substitutionsToday.FirstOrDefault(s => s.Kind == SubstitutionKind.Cancelled && s.PeriodNumber == slot.PeriodNumber);
|
||||||
if (cancelled is not null) { items.Add(TodayLessonItem.ForCancelled(slot.GroupId, slot.PeriodNumber, group.Name, cancelled)); continue; }
|
if (cancelled is not null) { items.Add(TodayLessonItem.ForCancelled(slot.GroupId, slot.PeriodNumber, group.Name, cancelled)); continue; }
|
||||||
|
|
||||||
var lesson = _lessons.GetByGroupAndDate(slot.GroupId, today).FirstOrDefault();
|
var lesson = FindLessonForSlot(slot.GroupId, today, slot.PeriodNumber);
|
||||||
var exam = _exams.GetByGroup(slot.GroupId).FirstOrDefault(e => e.Date == today);
|
var exam = _exams.GetByGroup(slot.GroupId).FirstOrDefault(e => e.Date == today);
|
||||||
items.Add(new TodayLessonItem(slot.GroupId, slot.PeriodNumber, group.Name,
|
items.Add(new TodayLessonItem(slot.GroupId, today, slot.PeriodNumber, group.Name,
|
||||||
slot.Room ?? "", ColorFor(group.Name), lesson?.Topic, exam?.Title,
|
slot.Room ?? "", ColorFor(group.Name), lesson?.Topic, exam?.Title,
|
||||||
HasUnhandledHomework(slot.GroupId, today), lesson));
|
HasUnhandledHomework(slot.GroupId, today), lesson));
|
||||||
}
|
}
|
||||||
@@ -285,15 +356,15 @@ public partial class TimetableViewModel : ObservableObject
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Springt aus dem Stundenplan direkt in den Verlaufsplan-Viewer der zugehörigen Lesson (4.5.2)
|
/// Springt aus dem Stundenplan direkt in den Verlaufsplan-Viewer der zugehörigen Lesson (4.5.2)
|
||||||
/// — sofern für den Slot schon eine Lesson existiert. Ohne Lesson (Slot laut Stundenplan belegt,
|
/// — sofern für den Slot schon eine Lesson existiert. Ohne Lesson startet die Direktanlage;
|
||||||
/// aber noch keine konkrete Stunde geplant) bleibt es bei der bisherigen, gröberen Navigation
|
/// der vorgeschaltete Einheiten-Dialog löst dabei die notwendige Unit-Zuordnung explizit.
|
||||||
/// zum Planung-Tab der Gruppe: eine neue Lesson direkt von hier aus anzulegen bräuchte eine
|
|
||||||
/// Antwort auf "welcher Unit wird sie zugeordnet", die bewusst noch offen ist (siehe TODO 4.5.2).
|
|
||||||
[RelayCommand]
|
[RelayCommand]
|
||||||
private async Task OpenTodayLesson(TodayLessonItem? item)
|
private async Task OpenTodayLesson(TodayLessonItem? item)
|
||||||
{
|
{
|
||||||
if (item is null || item.GroupId == Guid.Empty) return;
|
if (item is null || item.GroupId == Guid.Empty) return;
|
||||||
if (item.Lesson is { } lesson && OnOpenLessonViewer is not null) await OnOpenLessonViewer(lesson);
|
if (item.Lesson is { } lesson && OnOpenLessonViewer is not null) await OnOpenLessonViewer(lesson);
|
||||||
|
else if (OnCreateLesson is not null)
|
||||||
|
await OnCreateLesson(new TimetableLessonRequest(item.GroupId, item.Date, item.PeriodNumber));
|
||||||
else OnNavigateToGroup?.Invoke(item.GroupId);
|
else OnNavigateToGroup?.Invoke(item.GroupId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -307,14 +378,48 @@ 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 die weiteren Ziele (einschließlich Anlegen/Verschieben) explizit an.
|
||||||
[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 if (item.Date is { } date && OnCreateLesson is not null)
|
||||||
|
await OnCreateLesson(new TimetableLessonRequest(item.GroupId, date, item.PeriodNumber));
|
||||||
else OnNavigateToGroup?.Invoke(item.GroupId);
|
else OnNavigateToGroup?.Invoke(item.GroupId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 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 das Datum der Lesson selbst entgegen; die Kachel trägt ihr Datum zusätzlich für die
|
||||||
|
/// Direktanlage einer noch nicht existierenden Stunde.
|
||||||
|
private static readonly TimeSpan TeachingTimeTolerance = TimeSpan.FromMinutes(10);
|
||||||
|
private bool IsAroundTeachingTime(DateOnly lessonDate, int periodNumber)
|
||||||
|
{
|
||||||
|
var nowSnapshot = Clock();
|
||||||
|
if (lessonDate != DateOnly.FromDateTime(nowSnapshot)) return false;
|
||||||
|
if (_periodSchedule.GetTimes(periodNumber) is not { } times) return false;
|
||||||
|
// DateTime statt TimeOnly.Add: Letzteres springt nahe Mitternacht auf den anderen
|
||||||
|
// Tagesrand und macht aus z.B. 00:05 ± 10 Minuten ein umgekehrtes Vergleichsfenster.
|
||||||
|
var start = lessonDate.ToDateTime(times.Start).Subtract(TeachingTimeTolerance);
|
||||||
|
var end = lessonDate.ToDateTime(times.End).Add(TeachingTimeTolerance);
|
||||||
|
if (end < start) end = end.AddDays(1); // nur für ein ggf. über Mitternacht laufendes Raster
|
||||||
|
return nowSnapshot >= start && nowSnapshot <= end;
|
||||||
|
}
|
||||||
|
|
||||||
[RelayCommand]
|
[RelayCommand]
|
||||||
private void OpenSettings() => OnNavigateToSettings?.Invoke(SettingsTab.Holidays);
|
private void OpenSettings() => OnNavigateToSettings?.Invoke(SettingsTab.Holidays);
|
||||||
|
|
||||||
@@ -391,14 +496,14 @@ public partial class TimetableViewModel : ObservableObject
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
var lesson = _lessons.GetByGroupAndDate(slot.GroupId, date).FirstOrDefault();
|
var lesson = FindLessonForSlot(slot.GroupId, date, period);
|
||||||
var hasExam = _exams.GetByGroup(slot.GroupId).Any(e => e.Date == date);
|
var hasExam = _exams.GetByGroup(slot.GroupId).Any(e => e.Date == date);
|
||||||
var isHoliday = IsFreeDay(date, schoolHolidays, publicHolidayDates);
|
var isHoliday = IsFreeDay(date, schoolHolidays, publicHolidayDates);
|
||||||
var colorHex = isHoliday ? "#BDBDBD" : ColorFor(group?.Name ?? "");
|
var colorHex = isHoliday ? "#BDBDBD" : ColorFor(group?.Name ?? "");
|
||||||
var holidayBadge = HolidayBadgeFor(date, weekday, schoolHolidays, publicHolidayDates);
|
var holidayBadge = HolidayBadgeFor(date, weekday, schoolHolidays, publicHolidayDates);
|
||||||
var isLastBeforeExam = IsLastBeforeExamFor(date, weekday, slot.GroupId, publicHolidayDates);
|
var isLastBeforeExam = IsLastBeforeExamFor(date, weekday, slot.GroupId, publicHolidayDates);
|
||||||
|
|
||||||
WeekItems.Add(WeekCellItem.ForSlot(weekday, period, date == today,
|
WeekItems.Add(WeekCellItem.ForSlot(weekday, period, date == today, date,
|
||||||
subject?.ShortName is { Length: > 0 } sn ? sn : subject?.Name ?? "",
|
subject?.ShortName is { Length: > 0 } sn ? sn : subject?.Name ?? "",
|
||||||
group?.Name ?? "?", slot.Room ?? "", lesson?.Topic ?? "",
|
group?.Name ?? "?", slot.Room ?? "", lesson?.Topic ?? "",
|
||||||
colorHex, holidayBadge, hasExam, isLastBeforeExam,
|
colorHex, holidayBadge, hasExam, isLastBeforeExam,
|
||||||
@@ -464,6 +569,40 @@ public partial class TimetableViewModel : ObservableObject
|
|||||||
p.Activity?.Contains("Experiment", StringComparison.OrdinalIgnoreCase) == true ||
|
p.Activity?.Contains("Experiment", StringComparison.OrdinalIgnoreCase) == true ||
|
||||||
p.Material?.Contains("Experiment", StringComparison.OrdinalIgnoreCase) == true)) == true;
|
p.Material?.Contains("Experiment", StringComparison.OrdinalIgnoreCase) == true)) == true;
|
||||||
|
|
||||||
|
/// Eine Doppelstunde wird als eine Lesson an der ersten Periode gespeichert. Für die zweite
|
||||||
|
/// Rasterzelle liefern wir dieselbe Lesson, wenn der Stundenplan dort unmittelbar mit derselben
|
||||||
|
/// Lerngruppe fortgesetzt wird. Das darf nicht von bereits eingetragenen Phasendauern abhängen:
|
||||||
|
/// Gerade ein früher Entwurf hat oft erst Datum und Thema, gehört aber trotzdem zum kompletten
|
||||||
|
/// Doppelstundenblock. Eine exakt an der Zielperiode verankerte Lesson hat immer Vorrang.
|
||||||
|
private Lesson? FindLessonForSlot(Guid groupId, DateOnly date, int period)
|
||||||
|
{
|
||||||
|
var lessons = _lessons.GetByGroupAndDate(groupId, date);
|
||||||
|
var exact = lessons.FirstOrDefault(l => l.LessonNumber == period);
|
||||||
|
if (exact is not null) return exact;
|
||||||
|
// Historische/manuell angelegte Einträge hatten häufig keine Stundennummer. Solange es
|
||||||
|
// davon nur einen an diesem Tag gibt, bleibt das frühere Verhalten erhalten und er wird
|
||||||
|
// dem vorhandenen Gruppen-Slot zugeordnet.
|
||||||
|
var withoutPeriod = lessons.Where(l => l.LessonNumber is null).ToList();
|
||||||
|
if (withoutPeriod.Count == 1) return withoutPeriod[0];
|
||||||
|
|
||||||
|
var periodsInBlock = _slots.GetByGroup(groupId)
|
||||||
|
.Where(s => s.Weekday == date.DayOfWeek)
|
||||||
|
.Select(s => s.PeriodNumber)
|
||||||
|
.ToHashSet();
|
||||||
|
|
||||||
|
// Vom angefragten Slot innerhalb desselben lückenlosen Blocks rückwärts zur verankerten
|
||||||
|
// Lesson laufen. Damit funktionieren neben Doppelstunden auch seltene Dreifachblöcke.
|
||||||
|
for (var candidatePeriod = period - 1;
|
||||||
|
periodsInBlock.Contains(candidatePeriod) && periodsInBlock.Contains(candidatePeriod + 1);
|
||||||
|
candidatePeriod--)
|
||||||
|
{
|
||||||
|
var blockLesson = lessons.Where(l => l.LessonNumber == candidatePeriod)
|
||||||
|
.OrderByDescending(l => l.UpdatedAt).FirstOrDefault();
|
||||||
|
if (blockLesson is not null) return blockLesson;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>4.5.4: Hat die letzte vor <paramref name="date"/> liegende Lesson dieser Gruppe eine
|
/// <summary>4.5.4: Hat die letzte vor <paramref name="date"/> liegende Lesson dieser Gruppe eine
|
||||||
/// Hausaufgabe, die weder als kontrolliert noch als bewusst übersprungen markiert ist? Schaut
|
/// Hausaufgabe, die weder als kontrolliert noch als bewusst übersprungen markiert ist? Schaut
|
||||||
/// bewusst nur auf die unmittelbar vorherige Lesson (nicht auf die gesamte Historie) — sobald
|
/// bewusst nur auf die unmittelbar vorherige Lesson (nicht auf die gesamte Historie) — sobald
|
||||||
@@ -689,7 +828,7 @@ public class TimetableRowItem(IReadOnlyList<TimetableCellItem> cells)
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Zelle im schreibgeschützten Wochenraster der "Heute"-Ansicht.</summary>
|
/// <summary>Zelle im schreibgeschützten Wochenraster der "Heute"-Ansicht.</summary>
|
||||||
public class WeekCellItem
|
public partial class WeekCellItem : ObservableObject
|
||||||
{
|
{
|
||||||
public bool IsHeader { get; private init; }
|
public bool IsHeader { get; private init; }
|
||||||
public bool IsPeriodLabel { get; private init; }
|
public bool IsPeriodLabel { get; private init; }
|
||||||
@@ -705,6 +844,7 @@ public class WeekCellItem
|
|||||||
public DayOfWeek? Weekday { get; private init; }
|
public DayOfWeek? Weekday { get; private init; }
|
||||||
public int PeriodNumber { get; private init; }
|
public int PeriodNumber { get; private init; }
|
||||||
public bool IsToday { get; private init; }
|
public bool IsToday { get; private init; }
|
||||||
|
public DateOnly? Date { get; private init; }
|
||||||
public Guid GroupId { get; private init; }
|
public Guid GroupId { get; private init; }
|
||||||
public string SubjectLabel { get; private init; } = "";
|
public string SubjectLabel { get; private init; } = "";
|
||||||
public string GroupName { get; private init; } = "";
|
public string GroupName { get; private init; } = "";
|
||||||
@@ -722,10 +862,36 @@ public class WeekCellItem
|
|||||||
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 string OpenButtonLabel => Lesson is not null ? "Verlaufsplan ansehen" : "Zur Lerngruppe";
|
public bool HasLesson => Lesson is not null;
|
||||||
|
public bool HasNoLesson => Lesson is null;
|
||||||
|
public string PlanningStatusLabel => Lesson is null ? "Nicht geplant" : LessonStatusDisplay.ToName(Lesson.Status);
|
||||||
|
public string OpenButtonLabel => Lesson is not null ? "Verlaufsplan ansehen" : "Stunde anlegen";
|
||||||
|
[ObservableProperty] private string _weatherSymbol = "";
|
||||||
|
[ObservableProperty] private string _weatherTooltip = "";
|
||||||
|
[ObservableProperty] private bool _hasWeatherWarning;
|
||||||
|
public bool HasWeather => WeatherSymbol.Length > 0;
|
||||||
|
public bool HasWeatherInformation => HasWeather || HasWeatherWarning;
|
||||||
|
|
||||||
|
partial void OnWeatherSymbolChanged(string value)
|
||||||
|
{
|
||||||
|
OnPropertyChanged(nameof(HasWeather));
|
||||||
|
OnPropertyChanged(nameof(HasWeatherInformation));
|
||||||
|
}
|
||||||
|
|
||||||
|
partial void OnHasWeatherWarningChanged(bool value) =>
|
||||||
|
OnPropertyChanged(nameof(HasWeatherInformation));
|
||||||
|
|
||||||
|
public void SetWeather(SchoolDayWeatherSummary? summary, IReadOnlyList<WeatherWarning> warnings)
|
||||||
|
{
|
||||||
|
WeatherSymbol = summary?.Symbol ?? "";
|
||||||
|
WeatherTooltip = summary?.Tooltip ?? string.Join("\n", warnings.Select(x =>
|
||||||
|
$"⚠ {x.Headline} ({SchoolDayWeatherSummary.WarningPeriod(x)})"));
|
||||||
|
HasWeatherWarning = warnings.Count > 0;
|
||||||
|
}
|
||||||
|
|
||||||
public static WeekCellItem Corner() => new() { IsHeader = true };
|
public static WeekCellItem Corner() => new() { IsHeader = true };
|
||||||
|
|
||||||
@@ -733,6 +899,7 @@ public class WeekCellItem
|
|||||||
{
|
{
|
||||||
IsHeader = true,
|
IsHeader = true,
|
||||||
IsToday = isToday,
|
IsToday = isToday,
|
||||||
|
Date = date,
|
||||||
Text = (day switch
|
Text = (day switch
|
||||||
{
|
{
|
||||||
DayOfWeek.Monday => "Mo", DayOfWeek.Tuesday => "Di", DayOfWeek.Wednesday => "Mi",
|
DayOfWeek.Monday => "Mo", DayOfWeek.Tuesday => "Di", DayOfWeek.Wednesday => "Mi",
|
||||||
@@ -757,18 +924,28 @@ public class WeekCellItem
|
|||||||
IsSubstitutionSupervision = isSubstitution,
|
IsSubstitutionSupervision = isSubstitution,
|
||||||
};
|
};
|
||||||
|
|
||||||
public static WeekCellItem ForSlot(DayOfWeek day, int period, bool isToday, string subjectLabel,
|
public static WeekCellItem ForSlot(DayOfWeek day, int period, bool isToday, DateOnly date, string subjectLabel,
|
||||||
string groupName, string room, string topic, string colorHex, string holidayBadge,
|
string groupName, string room, string topic, string colorHex, string holidayBadge,
|
||||||
bool hasExam, bool isLastBeforeExam, bool hasExperiment, Guid groupId, bool isHoliday,
|
bool hasExam, bool isLastBeforeExam, bool hasExperiment, Guid groupId, bool isHoliday,
|
||||||
bool hasUnhandledHomework = false, Lesson? lesson = null) => new()
|
bool hasUnhandledHomework = false, Lesson? lesson = null) => new()
|
||||||
{
|
{
|
||||||
Weekday = day, PeriodNumber = period, IsAssigned = true, IsToday = isToday,
|
Weekday = day, PeriodNumber = period, IsAssigned = true, IsToday = isToday, Date = date,
|
||||||
SubjectLabel = subjectLabel, GroupName = groupName, Room = room, Topic = topic,
|
SubjectLabel = subjectLabel, GroupName = groupName, Room = room, Topic = topic,
|
||||||
ColorHex = colorHex, HolidayBadge = holidayBadge, HasExam = hasExam,
|
ColorHex = colorHex, HolidayBadge = holidayBadge, HasExam = hasExam,
|
||||||
IsLastBeforeExam = isLastBeforeExam, HasExperiment = hasExperiment, GroupId = groupId,
|
IsLastBeforeExam = isLastBeforeExam, HasExperiment = hasExperiment, GroupId = groupId,
|
||||||
IsHoliday = isHoliday, HasUnhandledHomework = hasUnhandledHomework, Lesson = lesson,
|
IsHoliday = isHoliday, HasUnhandledHomework = hasUnhandledHomework, Lesson = lesson,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Kompatible Überladung für isolierte ViewModel-Tests und ältere Aufrufer ohne konkreten
|
||||||
|
// Wochenbezug. Produktiv wird die datierte Variante verwendet.
|
||||||
|
public static WeekCellItem ForSlot(DayOfWeek day, int period, bool isToday, string subjectLabel,
|
||||||
|
string groupName, string room, string topic, string colorHex, string holidayBadge,
|
||||||
|
bool hasExam, bool isLastBeforeExam, bool hasExperiment, Guid groupId, bool isHoliday,
|
||||||
|
bool hasUnhandledHomework = false, Lesson? lesson = null) => ForSlot(day, period, isToday,
|
||||||
|
DateOnly.FromDateTime(DateTime.Today), subjectLabel, groupName, room, topic, colorHex,
|
||||||
|
holidayBadge, hasExam, isLastBeforeExam, hasExperiment, groupId, isHoliday,
|
||||||
|
hasUnhandledHomework, lesson);
|
||||||
|
|
||||||
public static WeekCellItem ForSubstitutionLesson(DayOfWeek day, int period, bool isToday, SubstitutionEntry entry) => new()
|
public static WeekCellItem ForSubstitutionLesson(DayOfWeek day, int period, bool isToday, SubstitutionEntry entry) => new()
|
||||||
{
|
{
|
||||||
Weekday = day, PeriodNumber = period, IsAssigned = true, IsToday = isToday, IsSubstitutionLesson = true,
|
Weekday = day, PeriodNumber = period, IsAssigned = true, IsToday = isToday, IsSubstitutionLesson = true,
|
||||||
@@ -815,9 +992,13 @@ public class UpcomingExamItem(DateOnly date, string groupName, string title)
|
|||||||
public string Title { get; } = title;
|
public string Title { get; } = title;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public enum TimetableLessonDestination { TeachingMode, Viewer, Create, Move, 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; }
|
||||||
|
public DateOnly Date { get; private init; }
|
||||||
public int PeriodNumber { get; private init; }
|
public int PeriodNumber { get; private init; }
|
||||||
public string GroupName { get; private init; } = "";
|
public string GroupName { get; private init; } = "";
|
||||||
public string Room { get; private init; } = "";
|
public string Room { get; private init; } = "";
|
||||||
@@ -835,23 +1016,24 @@ public class TodayLessonItem
|
|||||||
/// Direktsprung in den Verlaufsplan-Viewer (4.5.2) und den Unterrichtsmodus (14.x).
|
/// Direktsprung in den Verlaufsplan-Viewer (4.5.2) und den Unterrichtsmodus (14.x).
|
||||||
public Lesson? Lesson { get; private init; }
|
public Lesson? Lesson { get; private init; }
|
||||||
public bool HasLesson => Lesson is not null;
|
public bool HasLesson => Lesson is not null;
|
||||||
public string OpenButtonLabel => Lesson is not null ? "Verlaufsplan ansehen" : "Zur Lerngruppe";
|
public string PlanningStatusLabel => Lesson is null ? "Nicht geplant" : LessonStatusDisplay.ToName(Lesson.Status);
|
||||||
|
public string OpenButtonLabel => Lesson is not null ? "Verlaufsplan ansehen" : "Stunde anlegen";
|
||||||
|
|
||||||
public TodayLessonItem(Guid groupId, int periodNumber, string groupName, string room,
|
public TodayLessonItem(Guid groupId, DateOnly date, int periodNumber, string groupName, string room,
|
||||||
string colorHex, string? lessonTopic, string? examTitle, bool hasUnhandledHomework = false,
|
string colorHex, string? lessonTopic, string? examTitle, bool hasUnhandledHomework = false,
|
||||||
Lesson? lesson = null)
|
Lesson? lesson = null)
|
||||||
{
|
{
|
||||||
GroupId = groupId; PeriodNumber = periodNumber; GroupName = groupName; Room = room;
|
GroupId = groupId; Date = date; PeriodNumber = periodNumber; GroupName = groupName; Room = room;
|
||||||
ColorHex = colorHex; LessonTopic = lessonTopic; ExamTitle = examTitle;
|
ColorHex = colorHex; LessonTopic = lessonTopic; ExamTitle = examTitle;
|
||||||
HasUnhandledHomework = hasUnhandledHomework; Lesson = lesson;
|
HasUnhandledHomework = hasUnhandledHomework; Lesson = lesson;
|
||||||
}
|
}
|
||||||
|
|
||||||
public static TodayLessonItem ForSubstitution(int periodNumber, SubstitutionEntry entry) => new(
|
public static TodayLessonItem ForSubstitution(int periodNumber, SubstitutionEntry entry) => new(
|
||||||
entry.GroupId ?? Guid.Empty, periodNumber, entry.GroupLabel, "", "#8E24AA", entry.Description, null)
|
entry.GroupId ?? Guid.Empty, entry.Date, periodNumber, entry.GroupLabel, "", "#8E24AA", entry.Description, null)
|
||||||
{ IsSubstitution = true };
|
{ IsSubstitution = true };
|
||||||
|
|
||||||
public static TodayLessonItem ForCancelled(Guid groupId, int periodNumber, string groupName, SubstitutionEntry entry) => new(
|
public static TodayLessonItem ForCancelled(Guid groupId, int periodNumber, string groupName, SubstitutionEntry entry) => new(
|
||||||
groupId, periodNumber, groupName, "", "#757575",
|
groupId, entry.Date, periodNumber, groupName, "", "#757575",
|
||||||
string.IsNullOrWhiteSpace(entry.Description) ? null : entry.Description, null)
|
string.IsNullOrWhiteSpace(entry.Description) ? null : entry.Description, null)
|
||||||
{ IsCancelled = true };
|
{ IsCancelled = true };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,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}";
|
||||||
|
}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
using CommunityToolkit.Mvvm.ComponentModel;
|
||||||
|
using CommunityToolkit.Mvvm.Input;
|
||||||
|
using LehrerApp.Core.Interfaces;
|
||||||
|
using LehrerApp.Core.Models;
|
||||||
|
using LehrerApp.Core.Services;
|
||||||
|
using LehrerApp.Data;
|
||||||
|
using LehrerApp.Desktop.Services;
|
||||||
|
using LehrerApp.Desktop.ViewModels.Planning;
|
||||||
|
using LehrerApp.Sync;
|
||||||
|
using LehrerApp.Sync.Crypto;
|
||||||
|
using System.Collections.ObjectModel;
|
||||||
|
using System.Globalization;
|
||||||
|
using System.Security.Cryptography;
|
||||||
|
using System.Text.Json;
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
|
|
||||||
|
namespace LehrerApp.Desktop.ViewModels.Settings;
|
||||||
|
|
||||||
|
public partial class SettingsViewModel
|
||||||
|
{
|
||||||
|
// ── KI-Unterstützung (4.5.9) ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
[ObservableProperty] private bool _aiEnabled;
|
||||||
|
[ObservableProperty] private string _aiUsername = "";
|
||||||
|
[ObservableProperty] private string _aiPassword = "";
|
||||||
|
[ObservableProperty] private string _aiLoginError = "";
|
||||||
|
[ObservableProperty] private bool _aiIsLoggedIn;
|
||||||
|
[ObservableProperty] private string _aiBalanceDisplay = "";
|
||||||
|
|
||||||
|
// ── KI-Unterstützung: Laden / Anmelden / Abmelden ────────────────────────
|
||||||
|
|
||||||
|
private void LoadAiSettings()
|
||||||
|
{
|
||||||
|
AiEnabled = _aiSettings.Enabled;
|
||||||
|
AiUsername = _aiSettings.Username;
|
||||||
|
AiIsLoggedIn = _aiSettings.IsLoggedIn;
|
||||||
|
if (AiIsLoggedIn) _ = RefreshAiBalance();
|
||||||
|
}
|
||||||
|
|
||||||
|
partial void OnAiEnabledChanged(bool value) => _aiSettings.SetEnabled(value);
|
||||||
|
|
||||||
|
private async Task RefreshAiBalance()
|
||||||
|
{
|
||||||
|
var token = _aiSettings.GetToken();
|
||||||
|
if (token is null) return;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var balance = await _aiPlanning.GetBalanceAsync(token);
|
||||||
|
AiBalanceDisplay = $"Guthaben: {balance:0.00} €";
|
||||||
|
}
|
||||||
|
catch (AiBackendException ex) { AiBalanceDisplay = ex.Message; }
|
||||||
|
}
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
private async Task AiLogin()
|
||||||
|
{
|
||||||
|
AiLoginError = "";
|
||||||
|
var valid = true;
|
||||||
|
if (string.IsNullOrWhiteSpace(AiUsername)) { AiLoginError = "Benutzername erforderlich."; valid = false; }
|
||||||
|
if (string.IsNullOrWhiteSpace(AiPassword)) { AiLoginError = "Passwort erforderlich."; valid = false; }
|
||||||
|
if (!valid) return;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var token = await _aiPlanning.LoginAsync(AiUsername, AiPassword);
|
||||||
|
_aiSettings.SetCredentialsAndToken(AiUsername, token);
|
||||||
|
AiPassword = "";
|
||||||
|
AiIsLoggedIn = true;
|
||||||
|
await RefreshAiBalance();
|
||||||
|
}
|
||||||
|
catch (AiBackendException ex) { AiLoginError = ex.Message; }
|
||||||
|
}
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
private void AiLogout()
|
||||||
|
{
|
||||||
|
_aiSettings.Logout();
|
||||||
|
AiIsLoggedIn = false;
|
||||||
|
AiBalanceDisplay = "";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
using CommunityToolkit.Mvvm.ComponentModel;
|
||||||
|
using CommunityToolkit.Mvvm.Input;
|
||||||
|
using LehrerApp.Core.Interfaces;
|
||||||
|
using LehrerApp.Core.Models;
|
||||||
|
using LehrerApp.Core.Services;
|
||||||
|
using LehrerApp.Data;
|
||||||
|
using LehrerApp.Desktop.Services;
|
||||||
|
using LehrerApp.Desktop.ViewModels.Planning;
|
||||||
|
using LehrerApp.Sync;
|
||||||
|
using LehrerApp.Sync.Crypto;
|
||||||
|
using System.Collections.ObjectModel;
|
||||||
|
using System.Globalization;
|
||||||
|
using System.Security.Cryptography;
|
||||||
|
using System.Text.Json;
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
|
|
||||||
|
namespace LehrerApp.Desktop.ViewModels.Settings;
|
||||||
|
|
||||||
|
public partial class SettingsViewModel
|
||||||
|
{
|
||||||
|
// ── Schulweiter Jahresplan (ClassyPlan-iCal) ─────────────────────────────
|
||||||
|
|
||||||
|
[ObservableProperty] private bool _annualPlanIsConfigured;
|
||||||
|
[ObservableProperty] private string _annualPlanIcalUrlInput = "";
|
||||||
|
[ObservableProperty] private string _annualPlanUrlError = "";
|
||||||
|
[ObservableProperty] private string _annualPlanStatusDisplay = "";
|
||||||
|
[ObservableProperty] private bool _annualPlanFetchBusy;
|
||||||
|
|
||||||
|
// ── Schulweiter Jahresplan: Laden / Speichern / Entfernen / Jetzt abrufen ─
|
||||||
|
|
||||||
|
private void LoadAnnualPlanSettings()
|
||||||
|
{
|
||||||
|
AnnualPlanIsConfigured = _annualPlanSettings.IsConfigured;
|
||||||
|
AnnualPlanStatusDisplay = _annualPlanSettings.LastSyncAt is { } at
|
||||||
|
? $"Letzter Abgleich: {at.ToLocalTime():dd.MM.yyyy HH:mm} — {_annualPlanSettings.LastSyncStatus}"
|
||||||
|
: "Noch kein Abgleich durchgeführt.";
|
||||||
|
}
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
private void AnnualPlanSaveUrl()
|
||||||
|
{
|
||||||
|
AnnualPlanUrlError = "";
|
||||||
|
if (string.IsNullOrWhiteSpace(AnnualPlanIcalUrlInput))
|
||||||
|
{
|
||||||
|
AnnualPlanUrlError = "iCal-URL erforderlich.";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!Uri.TryCreate(AnnualPlanIcalUrlInput, UriKind.Absolute, out var uri) ||
|
||||||
|
uri.Scheme is not ("http" or "https"))
|
||||||
|
{
|
||||||
|
AnnualPlanUrlError = "Ungültige HTTP(S)-URL.";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_annualPlanSettings.SetIcalUrl(AnnualPlanIcalUrlInput.Trim());
|
||||||
|
_annualPlanSettings.SetEnabled(true);
|
||||||
|
AnnualPlanIcalUrlInput = "";
|
||||||
|
AppBootstrapper.RestartApplication();
|
||||||
|
}
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
private void AnnualPlanRemove()
|
||||||
|
{
|
||||||
|
_annualPlanSync?.Clear();
|
||||||
|
_annualPlanSettings.ClearIcalUrl();
|
||||||
|
LoadAnnualPlanSettings();
|
||||||
|
AppBootstrapper.RestartApplication();
|
||||||
|
}
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
private async Task AnnualPlanFetchNow()
|
||||||
|
{
|
||||||
|
if (_annualPlanSync is null)
|
||||||
|
{
|
||||||
|
AnnualPlanStatusDisplay = "Abgleich nicht aktiv — App neu starten.";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
AnnualPlanFetchBusy = true;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await _annualPlanSync.PollAsync();
|
||||||
|
LoadAnnualPlanSettings();
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
AnnualPlanFetchBusy = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
using CommunityToolkit.Mvvm.ComponentModel;
|
||||||
|
using CommunityToolkit.Mvvm.Input;
|
||||||
|
using LehrerApp.Core.Interfaces;
|
||||||
|
using LehrerApp.Core.Models;
|
||||||
|
using LehrerApp.Core.Services;
|
||||||
|
using LehrerApp.Data;
|
||||||
|
using LehrerApp.Desktop.Services;
|
||||||
|
using LehrerApp.Desktop.ViewModels.Planning;
|
||||||
|
using LehrerApp.Sync;
|
||||||
|
using LehrerApp.Sync.Crypto;
|
||||||
|
using System.Collections.ObjectModel;
|
||||||
|
using System.Globalization;
|
||||||
|
using System.Security.Cryptography;
|
||||||
|
using System.Text.Json;
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
|
|
||||||
|
namespace LehrerApp.Desktop.ViewModels.Settings;
|
||||||
|
|
||||||
|
public partial class SettingsViewModel
|
||||||
|
{
|
||||||
|
// ── Sicherheit: App-Sperre nach Inaktivität (13.3.5) ─────────────────────
|
||||||
|
|
||||||
|
[ObservableProperty] private bool _appLockEnabled;
|
||||||
|
[ObservableProperty] private int _appLockTimeoutMinutes = 10;
|
||||||
|
[ObservableProperty] private string _appLockNewPassword = "";
|
||||||
|
[ObservableProperty] private string _appLockNewPasswordConfirm = "";
|
||||||
|
[ObservableProperty] private string _appLockPasswordError = "";
|
||||||
|
[ObservableProperty] private string _appLockStatus = "";
|
||||||
|
|
||||||
|
public bool AppLockHasPassword => _appLock.HasPassword;
|
||||||
|
|
||||||
|
/// Vom Code-Behind gesetzt: aktualisiert den laufenden Inaktivitäts-Timer sofort,
|
||||||
|
/// ohne dass die App neu gestartet werden muss.
|
||||||
|
public Action? OnAppLockChanged { get; set; }
|
||||||
|
|
||||||
|
// ── App-Sperre: Speichern ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
private void SaveAppLockSettings()
|
||||||
|
{
|
||||||
|
AppLockPasswordError = "";
|
||||||
|
var valid = true;
|
||||||
|
var wantsNewPassword = !string.IsNullOrWhiteSpace(AppLockNewPassword) || !string.IsNullOrWhiteSpace(AppLockNewPasswordConfirm);
|
||||||
|
|
||||||
|
if (AppLockEnabled && !_appLock.HasPassword && !wantsNewPassword)
|
||||||
|
{
|
||||||
|
AppLockPasswordError = "Bitte ein Passwort für die App-Sperre festlegen.";
|
||||||
|
valid = false;
|
||||||
|
}
|
||||||
|
else if (wantsNewPassword)
|
||||||
|
{
|
||||||
|
if (AppLockNewPassword != AppLockNewPasswordConfirm)
|
||||||
|
{
|
||||||
|
AppLockPasswordError = "Passwörter stimmen nicht überein.";
|
||||||
|
valid = false;
|
||||||
|
}
|
||||||
|
else if (AppLockNewPassword.Length < 4)
|
||||||
|
{
|
||||||
|
AppLockPasswordError = "Mindestens 4 Zeichen.";
|
||||||
|
valid = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!valid) return;
|
||||||
|
|
||||||
|
if (wantsNewPassword) _appLock.SetPassword(AppLockNewPassword.Trim());
|
||||||
|
_appLock.Configure(AppLockEnabled, AppLockTimeoutMinutes);
|
||||||
|
|
||||||
|
AppLockNewPassword = ""; AppLockNewPasswordConfirm = "";
|
||||||
|
OnPropertyChanged(nameof(AppLockHasPassword));
|
||||||
|
AppLockStatus = "Gespeichert.";
|
||||||
|
OnAppLockChanged?.Invoke();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
using CommunityToolkit.Mvvm.ComponentModel;
|
||||||
|
using CommunityToolkit.Mvvm.Input;
|
||||||
|
using LehrerApp.Core.Interfaces;
|
||||||
|
using LehrerApp.Core.Models;
|
||||||
|
using LehrerApp.Core.Services;
|
||||||
|
using LehrerApp.Data;
|
||||||
|
using LehrerApp.Desktop.Services;
|
||||||
|
using LehrerApp.Desktop.ViewModels.Planning;
|
||||||
|
using LehrerApp.Sync;
|
||||||
|
using LehrerApp.Sync.Crypto;
|
||||||
|
using System.Collections.ObjectModel;
|
||||||
|
using System.Globalization;
|
||||||
|
using System.Security.Cryptography;
|
||||||
|
using System.Text.Json;
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
|
|
||||||
|
namespace LehrerApp.Desktop.ViewModels.Settings;
|
||||||
|
|
||||||
|
public partial class SettingsViewModel
|
||||||
|
{
|
||||||
|
// ── Darstellung (12.4) ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
[ObservableProperty] private AppTheme _selectedTheme;
|
||||||
|
public string[] ThemeOptions { get; } = AppThemeDisplay.Options;
|
||||||
|
public string SelectedThemeName
|
||||||
|
{
|
||||||
|
get => AppThemeDisplay.Label(SelectedTheme);
|
||||||
|
set => SelectedTheme = AppThemeDisplay.FromLabel(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Vom Code-Behind gesetzt: wendet die Theme-Variante sofort an (Application.Current, siehe
|
||||||
|
/// App.ApplyTheme) — ViewModels fassen Avalonia-Framework-Typen nicht direkt an, gleiches
|
||||||
|
/// Muster wie die übrigen Code-Behind-Hooks in dieser Klasse.
|
||||||
|
public Action<AppTheme>? OnThemeChanged { get; set; }
|
||||||
|
|
||||||
|
partial void OnSelectedThemeChanged(AppTheme value)
|
||||||
|
{
|
||||||
|
_appearance.Save(value);
|
||||||
|
OnThemeChanged?.Invoke(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
using CommunityToolkit.Mvvm.ComponentModel;
|
||||||
|
using CommunityToolkit.Mvvm.Input;
|
||||||
|
using LehrerApp.Core.Interfaces;
|
||||||
|
using LehrerApp.Core.Models;
|
||||||
|
using LehrerApp.Core.Services;
|
||||||
|
using LehrerApp.Data;
|
||||||
|
using LehrerApp.Desktop.Services;
|
||||||
|
using LehrerApp.Desktop.ViewModels.Planning;
|
||||||
|
using LehrerApp.Sync;
|
||||||
|
using LehrerApp.Sync.Crypto;
|
||||||
|
using System.Collections.ObjectModel;
|
||||||
|
using System.Globalization;
|
||||||
|
using System.Security.Cryptography;
|
||||||
|
using System.Text.Json;
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
|
|
||||||
|
namespace LehrerApp.Desktop.ViewModels.Settings;
|
||||||
|
|
||||||
|
public partial class SettingsViewModel
|
||||||
|
{
|
||||||
|
// ── Sicherheit: Datensicherung (13.3.1/13.3.2) ───────────────────────────
|
||||||
|
|
||||||
|
[ObservableProperty] private string _backupStatus = "";
|
||||||
|
[ObservableProperty] private string _secondaryBackupDirectory = "";
|
||||||
|
public ObservableCollection<BackupListItem> Backups { get; } = [];
|
||||||
|
|
||||||
|
/// Vom Code-Behind gesetzt: zeigt einen Bestätigungsdialog vor dem Wiederherstellen.
|
||||||
|
public Func<BackupListItem, Task<bool>>? OnConfirmRestore { get; set; }
|
||||||
|
|
||||||
|
/// Vom Code-Behind gesetzt: öffnet einen Ordnerauswahl-Dialog für das zweite Sicherungsziel.
|
||||||
|
public Func<Task<string?>>? OnPickBackupDirectory { get; set; }
|
||||||
|
|
||||||
|
// ── Datensicherung: Laden / Erstellen / Wiederherstellen ─────────────────
|
||||||
|
|
||||||
|
private void LoadBackups()
|
||||||
|
{
|
||||||
|
Backups.Clear();
|
||||||
|
foreach (var b in _backups.ListBackups()) Backups.Add(new BackupListItem(b));
|
||||||
|
SecondaryBackupDirectory = _backupSettings.LoadSecondaryDirectory() ?? "";
|
||||||
|
}
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
private void CreateBackupNow()
|
||||||
|
{
|
||||||
|
var path = _backups.CreateBackup(AppBootstrapper.DbPath);
|
||||||
|
LoadBackups();
|
||||||
|
if (path is null) { BackupStatus = "Kein Backup erstellt (Datenbankdatei fehlt)."; return; }
|
||||||
|
|
||||||
|
BackupStatus = _dbEncryption.CanOpenAndRead(path, AppBootstrapper.DbPassword)
|
||||||
|
? "Backup erstellt und geprüft."
|
||||||
|
: "Backup erstellt — Prüfung fehlgeschlagen, Datei könnte beschädigt sein!";
|
||||||
|
}
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
private async Task PickBackupDirectory()
|
||||||
|
{
|
||||||
|
if (OnPickBackupDirectory is null) return;
|
||||||
|
var path = await OnPickBackupDirectory();
|
||||||
|
if (path is null) return;
|
||||||
|
|
||||||
|
_backupSettings.SaveSecondaryDirectory(path);
|
||||||
|
_backups.SecondaryBackupDirectory = path;
|
||||||
|
SecondaryBackupDirectory = path;
|
||||||
|
BackupStatus = "Zweiter Sicherungsordner gespeichert.";
|
||||||
|
}
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
private void ClearBackupDirectory()
|
||||||
|
{
|
||||||
|
_backupSettings.SaveSecondaryDirectory(null);
|
||||||
|
_backups.SecondaryBackupDirectory = null;
|
||||||
|
SecondaryBackupDirectory = "";
|
||||||
|
BackupStatus = "Zweiter Sicherungsordner entfernt.";
|
||||||
|
}
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
private async Task RestoreBackup(BackupListItem? item)
|
||||||
|
{
|
||||||
|
if (item is null) return;
|
||||||
|
if (OnConfirmRestore is not null && !await OnConfirmRestore(item)) return;
|
||||||
|
|
||||||
|
_dbContext.Dispose();
|
||||||
|
_backups.RestoreBackup(item.Path, AppBootstrapper.DbPath);
|
||||||
|
AppBootstrapper.RestartApplication();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public class BackupListItem(BackupInfo info)
|
||||||
|
{
|
||||||
|
public string Path { get; } = info.Path;
|
||||||
|
public string Display { get; } = $"{info.CreatedAt:dd.MM.yyyy HH:mm} · {info.SizeBytes / 1024.0:0} KB";
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user