Compare commits
27
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ba52109eaa | ||
|
|
bd0d7e47ea | ||
|
|
2ec8adac61 | ||
|
|
e86125e5f9 | ||
|
|
86e114580f | ||
|
|
835cccadec | ||
|
|
709ea88c2a | ||
|
|
73453fd88d | ||
|
|
265190b3aa | ||
|
|
fedfceb81d | ||
|
|
7ef0c95a73 | ||
|
|
94946ffefb | ||
|
|
3cccb3226c | ||
|
|
988a293c42 | ||
|
|
92f9a75ec1 | ||
|
|
0a419c4c11 | ||
|
|
216708e879 | ||
|
|
0d5fb6d146 | ||
|
|
1d9521fc60 | ||
|
|
4af83c3e68 | ||
|
|
15463bc292 | ||
|
|
987b49e761 | ||
|
|
fb7a015b42 | ||
|
|
09e97efb61 | ||
|
|
d297f9f109 | ||
|
|
de6d4236a2 | ||
|
|
edf7510765 |
Vendored
+6
-5
@@ -47,11 +47,12 @@
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "Desktop + API (gleichzeitig)",
|
||||
"configurations": [
|
||||
"Desktop App starten",
|
||||
"API Server starten"
|
||||
]
|
||||
"name": "API Tests starten",
|
||||
"type": "coreclr",
|
||||
"request": "launch",
|
||||
"preLaunchTask": "API bauen",
|
||||
"program": "${workspaceFolder}/LehrerApp.Api.Tests/bin/Debug/net10.0/LehrerApp.Api.Tests.dll",
|
||||
"args": [],
|
||||
}
|
||||
],
|
||||
"compounds": [
|
||||
|
||||
@@ -100,6 +100,10 @@ public class AiPlanningResponse
|
||||
{
|
||||
public List<AiLesson> Lessons { get; set; } = [];
|
||||
public string? Summary { get; set; }
|
||||
// Vom Backend zusätzlich mitgelieferte, unveränderte Modellantwort. Wird nur benötigt, falls
|
||||
// die typisierte Deserialisierung scheitert; bei einer regulär verarbeiteten Antwort wird sie
|
||||
// weder angezeigt noch gespeichert.
|
||||
public string? RawResponse { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -170,6 +170,14 @@ public interface IUntisCacheFetchStateRepository
|
||||
UntisCacheFetchState? Get(string className, UntisCacheKind kind);
|
||||
void Save(UntisCacheFetchState state);
|
||||
}
|
||||
/// Zuletzt-Lauf-Status der Untis-Hub-Jobs (siehe TODO.md) - je (Kind, GroupId), GroupId null bei
|
||||
/// den dashboard-weiten Jobs.
|
||||
public interface IUntisHubJobStateRepository
|
||||
{
|
||||
UntisHubJobState? Get(UntisHubJobKind kind, Guid? groupId);
|
||||
List<UntisHubJobState> GetAll();
|
||||
void Save(UntisHubJobState state);
|
||||
}
|
||||
/// Vom Nutzer bestätigte Zuordnungen WebUntis-Wochenmuster → LearningGroup.
|
||||
public interface IUntisSlotMappingRepository
|
||||
{
|
||||
@@ -200,6 +208,15 @@ public interface IDocumentationRepository
|
||||
/// Entfernt den Eintrag endgültig — nur für die Löschfristen-Bereinigung (5.4.2).
|
||||
void HardDelete(Guid id);
|
||||
}
|
||||
public interface IVorgangRepository
|
||||
{
|
||||
List<Vorgang> GetAll();
|
||||
List<Vorgang> GetByStudent(Guid studentId);
|
||||
Vorgang? GetById(Guid id);
|
||||
void Save(Vorgang vorgang);
|
||||
/// Markiert den Vorgang als gelöscht, statt ihn hart zu entfernen (gleiches Muster wie Documentation).
|
||||
void Delete(Guid id);
|
||||
}
|
||||
public interface IWorkTaskRepository
|
||||
{
|
||||
List<WorkTask> GetByStatus(WorkTaskStatus status);
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
namespace LehrerApp.Core.Models;
|
||||
|
||||
public enum UntisHubJobKind
|
||||
{
|
||||
FehlzeitenKurz,
|
||||
FehlzeitenLang,
|
||||
OffenePeriods,
|
||||
Klassenbuchabgleich,
|
||||
Hausaufgabenabgleich,
|
||||
}
|
||||
|
||||
/// <summary>Wann welcher WebUntis-Abgleich ("Untis-Hub"-Job, siehe TODO.md) zuletzt lief und mit
|
||||
/// welchem Ergebnis - ein Datensatz pro (<see cref="Kind"/>, <see cref="GroupId"/>). Bei den drei
|
||||
/// dashboard-weiten Jobs (<see cref="UntisHubJobKind.OffenePeriods"/>,
|
||||
/// <see cref="UntisHubJobKind.Klassenbuchabgleich"/>, <see cref="UntisHubJobKind.Hausaufgabenabgleich"/>)
|
||||
/// ist <see cref="GroupId"/> null; die beiden Fehlzeiten-Kadenzen sind je Lerngruppe getrennt.</summary>
|
||||
public class UntisHubJobState
|
||||
{
|
||||
public Guid Id { get; set; } = Guid.NewGuid();
|
||||
public UntisHubJobKind Kind { get; set; }
|
||||
public Guid? GroupId { get; set; }
|
||||
public DateTime? LastRunAt { get; set; }
|
||||
public string? LastResultSummary { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
namespace LehrerApp.Core.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Fallmappe für ein konkretes, laufendes Problem mit einer/einem oder mehreren Schüler*innen
|
||||
/// (z.B. Schuleschwänzen, wiederholte Hausaufgaben-Verweigerung, ein Konflikt) — bündelt Titel,
|
||||
/// Problembeschreibung, Schlagwörter sowie die dazugehörigen <see cref="Documentation"/>-Einträge
|
||||
/// und eingefrorene WebUntis-Klassenbucheinträge an einem Ort, statt sie über datumsgefilterte
|
||||
/// Listen verstreut zu lassen. Bewusst nur Referenzen (<see cref="DocumentationIds"/>) statt
|
||||
/// gecachtem Inhalt — die Datenmenge pro Lehrkraft ist klein genug, dass ein Cache nur
|
||||
/// Invalidierungs-Komplexität einführen würde, ohne echten Nutzen (siehe TODO.md, Präzedenzfall
|
||||
/// 5.3.3: eine Plan-ID-Gruppierung für Förderpläne wurde aus demselben Grund verworfen).
|
||||
/// </summary>
|
||||
public class Vorgang
|
||||
{
|
||||
public Guid Id { get; set; } = Guid.NewGuid();
|
||||
public string Title { get; set; } = "";
|
||||
public string Description { get; set; } = "";
|
||||
public List<Guid> StudentIds { get; set; } = [];
|
||||
/// Freie Labels, z.B. "Absentismus", "Hausaufgaben", "Verspätungen", "Konflikte" —
|
||||
/// siehe VorgangTagDisplay.Suggestions im Desktop-Projekt für die Vorschlagsliste.
|
||||
public List<string> Tags { get; set; } = [];
|
||||
public VorgangStatus Status { get; set; } = VorgangStatus.Open;
|
||||
public List<Guid> DocumentationIds { get; set; } = [];
|
||||
public List<VorgangClassRegisterEntry> ClassRegisterEntries { get; set; } = [];
|
||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
|
||||
public DateTime? ClosedAt { get; set; }
|
||||
public bool IsDeleted { get; set; }
|
||||
public DateTime? DeletedAt { get; set; }
|
||||
}
|
||||
|
||||
public enum VorgangStatus { Open, Closed }
|
||||
|
||||
/// <summary>
|
||||
/// Eingefrorene Kopie einer WebUntis-Klassenbuchzeile zum Zeitpunkt des Anheftens an einen
|
||||
/// <see cref="Vorgang"/> — keine Referenz per ID, da weder <c>UntisForeignClassRegisterEventDto</c>
|
||||
/// noch <c>UntisClassRegisterCacheEntry</c> eine über einen Cache-Refresh hinweg stabile ID haben.
|
||||
/// </summary>
|
||||
public class VorgangClassRegisterEntry
|
||||
{
|
||||
public DateOnly Date { get; set; }
|
||||
public string StudentName { get; set; } = "";
|
||||
public string? Subject { get; set; }
|
||||
public string? TeacherUsername { get; set; }
|
||||
public string? CategoryName { get; set; }
|
||||
public string? CategoryGroup { get; set; }
|
||||
public string? Text { get; set; }
|
||||
public DateTime PinnedAt { get; set; } = DateTime.UtcNow;
|
||||
}
|
||||
@@ -14,9 +14,8 @@ public sealed class DashboardSettingsService
|
||||
{
|
||||
public static readonly string[] DefaultCardOrder =
|
||||
[
|
||||
"today", "tasks", "calendar", "excuses", "upcoming",
|
||||
"today", "tasks", "missingteachingtime", "calendar", "excuses", "upcoming",
|
||||
"corrections", "unplanned", "alerts", "attendance", "support", "groups", "examload",
|
||||
"missingteachingtime",
|
||||
];
|
||||
|
||||
private readonly string _configPath;
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
using System.Text.Json;
|
||||
|
||||
namespace LehrerApp.Core.Services;
|
||||
|
||||
/// <summary>Gewichte für ClassTeacherRosterRow.PatternScore (Klassenlehrer-Bereich, "Gesamtbild")
|
||||
/// — Default-Werte entsprechen den ursprünglich fest verdrahteten Konstanten.</summary>
|
||||
public sealed class PatternScoreWeights
|
||||
{
|
||||
public double UnexcusedDayWeight { get; set; } = 3.0;
|
||||
public double ExcusedDayWeight { get; set; } = 1.0;
|
||||
public double LateDayWeight { get; set; } = 0.5;
|
||||
public double NegativeClassRegisterEntryWeight { get; set; } = 1.0;
|
||||
public double ConcerningKeywordBonus { get; set; } = 1.5;
|
||||
public double SuspensionKeywordWeight { get; set; } = 15.0;
|
||||
}
|
||||
|
||||
/// <summary>Speichert die Gewichte lokal, gleiches Muster wie WorkloadSettingsService/
|
||||
/// DashboardSettingsService.</summary>
|
||||
public sealed class PatternScoreSettingsService
|
||||
{
|
||||
private readonly string _configPath;
|
||||
|
||||
public PatternScoreSettingsService(string appDataPath) =>
|
||||
_configPath = Path.Combine(appDataPath, "patternscoreweights.json");
|
||||
|
||||
public PatternScoreWeights Load()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (File.Exists(_configPath))
|
||||
return JsonSerializer.Deserialize<PatternScoreWeights>(File.ReadAllText(_configPath))
|
||||
?? new PatternScoreWeights();
|
||||
}
|
||||
catch { /* beschädigte Konfiguration -> Standardgewichte */ }
|
||||
return new PatternScoreWeights();
|
||||
}
|
||||
|
||||
public void Save(PatternScoreWeights weights) =>
|
||||
File.WriteAllText(_configPath, JsonSerializer.Serialize(weights));
|
||||
}
|
||||
@@ -193,4 +193,21 @@ public sealed class ChangeHookCascadeTests
|
||||
var call = Assert.Single(calls);
|
||||
Assert.Equal((nameof(Documentation), "Delete"), call);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void VorgangRepository_Delete_LoestSaveMitIsDeletedAus()
|
||||
{
|
||||
using var db = NewInMemoryContext();
|
||||
var repo = new VorgangRepository(db);
|
||||
var vorgang = new Vorgang { Title = "Konflikt" };
|
||||
repo.Save(vorgang);
|
||||
var calls = new List<(string EntityType, string Operation, object? Payload)>();
|
||||
db.OnChange = (type, id, op, payload) => calls.Add((type, op, payload));
|
||||
|
||||
repo.Delete(vorgang.Id);
|
||||
|
||||
var call = Assert.Single(calls);
|
||||
Assert.Equal((nameof(Vorgang), "Save"), (call.EntityType, call.Operation));
|
||||
Assert.True(((Vorgang)call.Payload!).IsDeleted);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -715,6 +715,67 @@ public sealed class RepositoryTests
|
||||
Assert.Null(storage.OpenRead(attachmentId));
|
||||
}
|
||||
|
||||
// ── VorgangRepository ─────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void VorgangRepository_Save_SetztUpdatedAt()
|
||||
{
|
||||
using var db = NewInMemoryContext();
|
||||
var repo = new VorgangRepository(db);
|
||||
var vorgang = new Vorgang { Title = "Schuleschwänzen Max" };
|
||||
|
||||
repo.Save(vorgang);
|
||||
|
||||
var raw = db.Vorgaenge.FindById(vorgang.Id);
|
||||
Assert.NotNull(raw);
|
||||
Assert.True((DateTime.UtcNow - raw!.UpdatedAt).TotalSeconds < 5);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void VorgangRepository_GetByStudent_FindetVorgaengeMitMehrerenSchuelern()
|
||||
{
|
||||
using var db = NewInMemoryContext();
|
||||
var repo = new VorgangRepository(db);
|
||||
var ada = Guid.NewGuid();
|
||||
var ben = Guid.NewGuid();
|
||||
var other = Guid.NewGuid();
|
||||
var shared = new Vorgang { Title = "Konflikt", StudentIds = [ada, ben] };
|
||||
var unrelated = new Vorgang { Title = "Anderes", StudentIds = [other] };
|
||||
repo.Save(shared);
|
||||
repo.Save(unrelated);
|
||||
|
||||
Assert.Equal([shared.Id], repo.GetByStudent(ada).Select(v => v.Id));
|
||||
Assert.Equal([shared.Id], repo.GetByStudent(ben).Select(v => v.Id));
|
||||
Assert.Empty(repo.GetByStudent(Guid.NewGuid()));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void VorgangRepository_Delete_MarkiertNurAlsGeloescht()
|
||||
{
|
||||
using var db = NewInMemoryContext();
|
||||
var repo = new VorgangRepository(db);
|
||||
var vorgang = new Vorgang { Title = "Hausaufgaben fehlen" };
|
||||
repo.Save(vorgang);
|
||||
|
||||
repo.Delete(vorgang.Id);
|
||||
|
||||
Assert.Empty(repo.GetAll());
|
||||
Assert.Null(repo.GetById(vorgang.Id));
|
||||
var raw = db.Vorgaenge.FindById(vorgang.Id);
|
||||
Assert.NotNull(raw);
|
||||
Assert.True(raw!.IsDeleted);
|
||||
Assert.NotNull(raw.DeletedAt);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void VorgangRepository_GetById_LiefertNullFuerUnbekannteId()
|
||||
{
|
||||
using var db = NewInMemoryContext();
|
||||
var repo = new VorgangRepository(db);
|
||||
|
||||
Assert.Null(repo.GetById(Guid.NewGuid()));
|
||||
}
|
||||
|
||||
// ── LessonRepository ──────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -51,6 +51,7 @@ public class LiteDbContext : IDisposable
|
||||
public ILiteCollection<Unit> Units => _db.GetCollection<Unit>("units");
|
||||
public ILiteCollection<Lesson> Lessons => _db.GetCollection<Lesson>("lessons");
|
||||
public ILiteCollection<Documentation> Documentation => _db.GetCollection<Documentation>("documentation");
|
||||
public ILiteCollection<Vorgang> Vorgaenge => _db.GetCollection<Vorgang>("vorgaenge");
|
||||
public ILiteStorage<string> Attachments => _db.GetStorage<string>("attachments", "attachments_chunks");
|
||||
public ILiteCollection<WorkTask> Tasks => _db.GetCollection<WorkTask>("tasks");
|
||||
public ILiteCollection<TimeEntry> TimeEntries => _db.GetCollection<TimeEntry>("time_entries");
|
||||
@@ -71,6 +72,7 @@ public class LiteDbContext : IDisposable
|
||||
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<UntisHubJobState> UntisHubJobStates => _db.GetCollection<UntisHubJobState>("untis_hub_job_states");
|
||||
public ILiteCollection<UntisStudentRosterCacheEntry> UntisStudentRosterCache => _db.GetCollection<UntisStudentRosterCacheEntry>("untis_student_roster_cache");
|
||||
public ILiteCollection<AnnualPlanEvent> AnnualPlanEvents => _db.GetCollection<AnnualPlanEvent>("annual_plan_events");
|
||||
public ILiteCollection<TrashedItem> TrashedItems => _db.GetCollection<TrashedItem>("trash");
|
||||
|
||||
@@ -357,6 +357,20 @@ public class UnitRepository(LiteDbContext db) : IUnitRepository
|
||||
public void Save(Unit u)
|
||||
{
|
||||
ArchivedGroupWriteGuard.EnsureActive(db, u.GroupId);
|
||||
// Persistenz-Invariante: auch Importe und künftig hinzukommende Aufrufer dürfen keine
|
||||
// zweite laufende Einheit in derselben Lerngruppe hinterlassen. Der Einheiten-Dialog
|
||||
// kündigt diese automatische Ablösung vorher sichtbar an.
|
||||
if (u.Status == UnitStatus.Active)
|
||||
{
|
||||
foreach (var previous in db.Units.Find(x => x.GroupId == u.GroupId &&
|
||||
x.Status == UnitStatus.Active && x.Id != u.Id))
|
||||
{
|
||||
previous.Status = UnitStatus.Completed;
|
||||
previous.UpdatedAt = DateTime.UtcNow;
|
||||
db.Units.Upsert(previous);
|
||||
db.OnChange?.Invoke(nameof(Unit), previous.Id.ToString(), "Save", previous);
|
||||
}
|
||||
}
|
||||
u.UpdatedAt = DateTime.UtcNow;
|
||||
db.Units.Upsert(u);
|
||||
db.OnChange?.Invoke(nameof(Unit), u.Id.ToString(), "Save", u);
|
||||
@@ -431,6 +445,37 @@ public class DocumentationRepository(LiteDbContext db) : IDocumentationRepositor
|
||||
}
|
||||
}
|
||||
|
||||
public class VorgangRepository(LiteDbContext db) : IVorgangRepository
|
||||
{
|
||||
public List<Vorgang> GetAll() =>
|
||||
db.Vorgaenge.Find(v => !v.IsDeleted).OrderByDescending(v => v.UpdatedAt).ToList();
|
||||
public List<Vorgang> GetByStudent(Guid studentId) =>
|
||||
GetAll().Where(v => v.StudentIds.Contains(studentId)).ToList();
|
||||
public Vorgang? GetById(Guid id)
|
||||
{
|
||||
var vorgang = db.Vorgaenge.FindById(id);
|
||||
return vorgang is null || vorgang.IsDeleted ? null : vorgang;
|
||||
}
|
||||
public void Save(Vorgang vorgang)
|
||||
{
|
||||
vorgang.UpdatedAt = DateTime.UtcNow;
|
||||
db.Vorgaenge.Upsert(vorgang);
|
||||
db.OnChange?.Invoke(nameof(Vorgang), vorgang.Id.ToString(), "Save", vorgang);
|
||||
}
|
||||
public void Delete(Guid id)
|
||||
{
|
||||
var vorgang = db.Vorgaenge.FindById(id);
|
||||
if (vorgang is null) return;
|
||||
vorgang.IsDeleted = true;
|
||||
vorgang.DeletedAt = DateTime.UtcNow;
|
||||
db.Vorgaenge.Update(vorgang);
|
||||
// Weiches Löschen ist inhaltlich eine Änderung, kein Entfernen -> "Save", damit ein
|
||||
// anwendendes Gerät den IsDeleted-Stand einfach übernimmt statt den Datensatz zu entfernen
|
||||
// (gleiches Muster wie DocumentationRepository.Delete).
|
||||
db.OnChange?.Invoke(nameof(Vorgang), id.ToString(), "Save", vorgang);
|
||||
}
|
||||
}
|
||||
|
||||
public class WorkTaskRepository(LiteDbContext db) : IWorkTaskRepository
|
||||
{
|
||||
public List<WorkTask> GetByStatus(WorkTaskStatus s) =>
|
||||
@@ -854,6 +899,16 @@ public class UntisCacheFetchStateRepository(LiteDbContext db) : IUntisCacheFetch
|
||||
public void Save(UntisCacheFetchState state) => db.UntisCacheFetchStates.Upsert(state);
|
||||
}
|
||||
|
||||
public class UntisHubJobStateRepository(LiteDbContext db) : IUntisHubJobStateRepository
|
||||
{
|
||||
public UntisHubJobState? Get(UntisHubJobKind kind, Guid? groupId) =>
|
||||
db.UntisHubJobStates.FindOne(s => s.Kind == kind && s.GroupId == groupId);
|
||||
|
||||
public List<UntisHubJobState> GetAll() => db.UntisHubJobStates.FindAll().ToList();
|
||||
|
||||
public void Save(UntisHubJobState state) => db.UntisHubJobStates.Upsert(state);
|
||||
}
|
||||
|
||||
public class CompetencyDomainRepository(LiteDbContext db) : ICompetencyDomainRepository
|
||||
{
|
||||
public List<CompetencyDomain> GetBySubjectAndGrade(Guid subjectId, int gradeLevel) =>
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
using LehrerApp.Core.AiPlanning;
|
||||
using LehrerApp.Core.Models;
|
||||
using LehrerApp.Desktop.Services;
|
||||
using LehrerApp.Desktop.ViewModels.Groups;
|
||||
using System.Net;
|
||||
using System.Text;
|
||||
using Xunit;
|
||||
|
||||
namespace LehrerApp.Desktop.Tests;
|
||||
@@ -15,6 +18,130 @@ public sealed class AiPlanningServiceTests
|
||||
FakeCompetencyDomains competencyDomains, FakeAlternativeLessonPaths altPaths) =>
|
||||
new(new HttpClient(), lessons, groups, subjects, competencyDomains, altPaths);
|
||||
|
||||
[Fact]
|
||||
public void ParsePlanningLessons_AkzeptiertNormaleAntwortUndDeutschesDatum()
|
||||
{
|
||||
const string json = """
|
||||
{"lessons":[{"id":null,"date":"15.09.2026","lessonNumber":2,"topic":"Redox","startTime":"08:35","phases":[]}],"summary":"ok"}
|
||||
""";
|
||||
|
||||
var lesson = Assert.Single(AiPlanningService.ParsePlanningLessons(json));
|
||||
|
||||
Assert.Equal("Redox", lesson.Topic);
|
||||
Assert.Equal(new DateOnly(2026, 9, 15), lesson.Date);
|
||||
Assert.Equal(new TimeOnly(8, 35), lesson.StartTime);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParsePlanningLessons_AkzeptiertEinzelneMarkierteStunde()
|
||||
{
|
||||
const string json = """
|
||||
{"topic":"Nur diese Stunde","phases":[{"name":"Einstieg","durationMinutes":5,"activity":"Impuls","material":"Bild","shorthand":"UG"}]}
|
||||
""";
|
||||
|
||||
var lesson = Assert.Single(AiPlanningService.ParsePlanningLessons(json));
|
||||
|
||||
Assert.Equal("Nur diese Stunde", lesson.Topic);
|
||||
Assert.Single(lesson.Phases);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParsePlanningLessons_UngueltigeMarkierung_LiefertVerstaendlichenFehler()
|
||||
{
|
||||
var error = Assert.Throws<AiBackendException>(() =>
|
||||
AiPlanningService.ParsePlanningLessons("Hier kommt das JSON: { kaputt }"));
|
||||
|
||||
Assert.Contains("noch kein gültiges Stunden-JSON", error.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RequestPlanAsync_Backendfehler_BewahrtOriginalantwortFuerRettung()
|
||||
{
|
||||
const string body = """
|
||||
{"error":"Kein gültiges JSON.","rawResponse":"Vorspann\n{kaputt}"}
|
||||
""";
|
||||
var service = BuildWithResponse(HttpStatusCode.BadGateway, body);
|
||||
|
||||
var error = await Assert.ThrowsAsync<AiBackendException>(() =>
|
||||
service.RequestPlanAsync(new Unit(), "", "token"));
|
||||
|
||||
Assert.Equal("Vorspann\n{kaputt}", error.RawResponse);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RequestPlanAsync_Typfehler_BewahrtEingebetteteOriginalantwortFuerRettung()
|
||||
{
|
||||
const string body = """
|
||||
{"lessons":[{"topic":"Test","date":"kein Datum"}],"rawResponse":"DIE ORIGINALANTWORT"}
|
||||
""";
|
||||
var service = BuildWithResponse(HttpStatusCode.OK, body);
|
||||
|
||||
var error = await Assert.ThrowsAsync<AiBackendException>(() =>
|
||||
service.RequestPlanAsync(new Unit(), "", "token"));
|
||||
|
||||
Assert.Equal("DIE ORIGINALANTWORT", error.RawResponse);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RequestPlanAsync_Zeitueberschreitung_LiefertKonkreteProxyMeldung()
|
||||
{
|
||||
var http = new HttpClient(new CanceledResponseHandler())
|
||||
{
|
||||
BaseAddress = new Uri("https://example.invalid/"),
|
||||
};
|
||||
var service = new AiPlanningService(http, new FakeLessons(), new FakeGroups([]), new FakeSubjects([]),
|
||||
new FakeCompetencyDomains(), new FakeAlternativeLessonPaths([]));
|
||||
|
||||
var error = await Assert.ThrowsAsync<AiBackendException>(() =>
|
||||
service.RequestPlanAsync(new Unit(), "", "token"));
|
||||
|
||||
Assert.Contains("3½ Minuten", error.Message);
|
||||
Assert.Contains("Webserver-Proxys", error.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Rettungsdialog_ImportiertMarkierteStundeAlsNeu()
|
||||
{
|
||||
var group = new LearningGroup();
|
||||
var unit = new Unit { GroupId = group.Id, Title = "T" };
|
||||
var lessons = new FakeLessons();
|
||||
var service = Build(lessons, new FakeGroups([group]), new FakeSubjects([]),
|
||||
new FakeCompetencyDomains(), new FakeAlternativeLessonPaths([]));
|
||||
var vm = new AiResponseRescueDialogViewModel(service, lessons, unit, "Rohantwort");
|
||||
|
||||
vm.ParseSelection("""{"topic":"Gerettete Stunde","phases":[]}""");
|
||||
vm.ImportAsNewCommand.Execute(null);
|
||||
|
||||
Assert.True(vm.Result);
|
||||
Assert.Equal("Gerettete Stunde", Assert.Single(lessons.GetByUnit(unit.Id)).Topic);
|
||||
}
|
||||
|
||||
private static AiPlanningService BuildWithResponse(HttpStatusCode status, string body)
|
||||
{
|
||||
var http = new HttpClient(new StaticResponseHandler(status, body))
|
||||
{
|
||||
BaseAddress = new Uri("https://example.invalid/"),
|
||||
};
|
||||
return new AiPlanningService(http, new FakeLessons(), new FakeGroups([]), new FakeSubjects([]),
|
||||
new FakeCompetencyDomains(), new FakeAlternativeLessonPaths([]));
|
||||
}
|
||||
|
||||
private sealed class StaticResponseHandler(HttpStatusCode status, string body) : HttpMessageHandler
|
||||
{
|
||||
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request,
|
||||
CancellationToken cancellationToken) => Task.FromResult(new HttpResponseMessage(status)
|
||||
{
|
||||
Content = new StringContent(body, Encoding.UTF8, "application/json"),
|
||||
});
|
||||
}
|
||||
|
||||
private sealed class CanceledResponseHandler : HttpMessageHandler
|
||||
{
|
||||
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request,
|
||||
CancellationToken cancellationToken) => Task.FromCanceled<HttpResponseMessage>(
|
||||
new CancellationToken(canceled: true));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildContext_FuelltGruppenUndFachKontext()
|
||||
{
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using LehrerApp.Core.Models;
|
||||
using LehrerApp.Core.Services;
|
||||
using LehrerApp.Desktop.Services;
|
||||
using LehrerApp.Desktop.ViewModels.ClassTeacher;
|
||||
using Xunit;
|
||||
@@ -7,6 +8,69 @@ namespace LehrerApp.Desktop.Tests;
|
||||
|
||||
public sealed class ClassTeacherViewModelsTests
|
||||
{
|
||||
[Fact]
|
||||
public void Monatskalender_KodiertFehlzeitenUndBlendetHausaufgabenStandardmaessigAus()
|
||||
{
|
||||
var month = new DateOnly(2026, 9, 1);
|
||||
var absences = new[]
|
||||
{
|
||||
new ClassAbsenceDaySummaryRow(new DateOnly(2026, 9, 2), "Müller Ada", 1, 0, 12,
|
||||
["Deu"], [1], ["entsch."], ["Verspätung"], null, null, false),
|
||||
new ClassAbsenceDaySummaryRow(new DateOnly(2026, 9, 3), "Schmidt Ben", 2, 2, 90,
|
||||
["Mathe"], [1, 2], ["nicht entsch."], ["Absent"], null, null, false),
|
||||
};
|
||||
var register = new[]
|
||||
{
|
||||
new ClassTeacherClassRegisterRow(new DateOnly(2026, 9, 3), "Deu", "Schmidt Ben", "test",
|
||||
"Störung", "Negativ", "Unterricht massiv gestört"),
|
||||
new ClassTeacherClassRegisterRow(new DateOnly(2026, 9, 4), "Mathe", "Müller Ada", "test",
|
||||
"Hausaufgaben", "Negativ", "Hausaufgaben fehlen"),
|
||||
};
|
||||
|
||||
var days = ClassTeacherDetailsViewModel.BuildCalendarDays(month, absences, register, includeHomework: false);
|
||||
|
||||
Assert.Equal(0, days.Count % 5);
|
||||
Assert.Contains(days.SelectMany(d => d.Events), e => e.Code == "V");
|
||||
Assert.Contains(days.SelectMany(d => d.Events), e => e.Code == "U");
|
||||
Assert.Contains(days.SelectMany(d => d.Events), e => e.Code == "!");
|
||||
Assert.DoesNotContain(days.SelectMany(d => d.Events), e => e.Code == "H");
|
||||
|
||||
var withHomework = ClassTeacherDetailsViewModel.BuildCalendarDays(month, absences, register, includeHomework: true);
|
||||
Assert.Contains(withHomework.SelectMany(d => d.Events), e => e.Code == "H");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Kompaktkalender_ZeigtNurMonatstageUndJeTagDasStaerksteSignal()
|
||||
{
|
||||
var month = new DateOnly(2026, 9, 1);
|
||||
var absences = new[]
|
||||
{
|
||||
new ClassAbsenceDaySummaryRow(new DateOnly(2026, 9, 3), "Müller Ada", 1, 2, 90,
|
||||
["Deu"], [1, 2], ["nicht entsch."], ["Absent"], null, null, false),
|
||||
new ClassAbsenceDaySummaryRow(new DateOnly(2026, 9, 4), "Schmidt Ben", 2, 2, 90,
|
||||
["Mathe"], [1, 2], ["entsch."], ["Absent"], null, null, false),
|
||||
};
|
||||
var register = new[]
|
||||
{
|
||||
new UntisForeignClassRegisterEventDto("6a", 20260903, "Deu", "Müller Ada", "test",
|
||||
"Störung", "Negativ", "Unterricht gestört"),
|
||||
new UntisForeignClassRegisterEventDto("6a", 20260905, "Mathe", "Schmidt Ben", "test",
|
||||
"Hausaufgaben", "Negativ", "Hausaufgaben fehlen"),
|
||||
};
|
||||
|
||||
var days = ClassTeacherOverviewViewModel.BuildCompactMonthDays(month, absences, register,
|
||||
new DateOnly(2026, 9, 3));
|
||||
|
||||
Assert.Equal(30, days.Count);
|
||||
Assert.False(days[0].HasSignal);
|
||||
Assert.Equal("1", days[0].DayNumber);
|
||||
Assert.Equal("U", days[2].SignalCode); // stärker als der parallele Klassenbucheintrag
|
||||
Assert.True(days[2].IsToday);
|
||||
Assert.Contains("Ada Müller", days[2].Tooltip);
|
||||
Assert.Equal("E", days[3].SignalCode);
|
||||
Assert.False(days[4].HasSignal); // Hausaufgaben bleiben im Widget bewusst ausgeblendet
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GroupByStudentAndDay_FasstFehlstundenProSchuelerUndTagZusammen()
|
||||
{
|
||||
@@ -228,6 +292,198 @@ public sealed class ClassTeacherViewModelsTests
|
||||
Assert.Null(row.YearSummaryTooltip);
|
||||
}
|
||||
|
||||
// ── Fehlquote behandelt Verspätung nicht mehr wie einen ganzen Fehltag (Nutzer-Feedback) ────
|
||||
|
||||
[Fact]
|
||||
public void RosterBuild_ReineVerspaetungZaehltNichtInDieFehlquote()
|
||||
{
|
||||
// Nutzer-Feedback: "10× 5 Min. verspätet" wurde bisher exakt wie "10× unentschuldigt
|
||||
// gefehlt" behandelt. Reine (nicht unentschuldigte) Verspätung darf die Quote nicht mehr
|
||||
// treiben, taucht aber weiterhin als eigene Zahl auf.
|
||||
var students = new[] { Student(1001, "Ada Müller") };
|
||||
var yearAbsences = Enumerable.Range(0, 3)
|
||||
.Select(i => new ClassAbsenceDaySummaryRow(new DateOnly(2026, 8, 10 + i), "Müller Ada", 1001, 0, 5,
|
||||
["Deu"], [1], ["entsch."], ["Verspätung"], null, null, false))
|
||||
.ToArray();
|
||||
|
||||
var row = Assert.Single(ClassTeacherRosterRow.Build(students, [], [],
|
||||
new DateOnly(2026, 8, 26), yearAbsences, schoolDaysElapsed: 20, termStart: new DateOnly(2026, 8, 1)));
|
||||
|
||||
Assert.Equal(0, row.YearAbsenceDayCount);
|
||||
Assert.Equal(3, row.YearLateDayCount);
|
||||
Assert.Equal(0, row.YearAbsenceRatePercent);
|
||||
Assert.True(row.HasYearSummary);
|
||||
Assert.Contains("verspätet", row.YearSummaryLabel);
|
||||
Assert.DoesNotContain("%", row.YearSummaryLabel);
|
||||
Assert.Contains("3 Tage verspätet", row.YearSummaryTooltip);
|
||||
Assert.Contains("zählt nicht zur Quote", row.YearSummaryTooltip);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RosterBuild_UnentschuldigtUndVerspaetetZaehltAlsUnentschuldigtNichtAlsVerspaetung()
|
||||
{
|
||||
// Wer unentschuldigt UND verspätet ist, bleibt der schwerwiegendere Fall (dieselbe
|
||||
// Rangfolge wie AttentionRank/StatusKind/BuildTrend: Unentschuldigt vor Verspätet) — zählt
|
||||
// deshalb weiter voll in die Quote statt in YearLateDayCount zu verschwinden.
|
||||
var students = new[] { Student(1001, "Ada Müller") };
|
||||
var yearAbsences = new[]
|
||||
{
|
||||
new ClassAbsenceDaySummaryRow(new DateOnly(2026, 8, 10), "Müller Ada", 1001, 0, 60,
|
||||
["Deu"], [1], ["nicht entsch."], ["Verspätung"], null, null, false),
|
||||
};
|
||||
|
||||
var row = Assert.Single(ClassTeacherRosterRow.Build(students, [], [],
|
||||
new DateOnly(2026, 8, 26), yearAbsences, schoolDaysElapsed: 20, termStart: new DateOnly(2026, 8, 1)));
|
||||
|
||||
Assert.Equal(1, row.YearAbsenceDayCount);
|
||||
Assert.Equal(1, row.YearUnexcusedDayCount);
|
||||
Assert.Equal(0, row.YearLateDayCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PatternScore_GewichtetUnentschuldigtDeutlichStaerkerAlsVerspaetung()
|
||||
{
|
||||
// Kernfall aus dem Nutzer-Feedback: 10× 5 Minuten verspätet darf nicht denselben Score
|
||||
// ergeben wie 10× ganztägig unentschuldigt gefehlt.
|
||||
var lateStudent = Student(1001, "Verpennt Timo");
|
||||
var truantStudent = Student(1002, "Geschwaenzt Cem");
|
||||
var yearAbsences = Enumerable.Range(0, 10)
|
||||
.SelectMany(i => new[]
|
||||
{
|
||||
new ClassAbsenceDaySummaryRow(new DateOnly(2026, 8, 3 + i), "Verpennt Timo", 1001, 0, 5,
|
||||
["Deu"], [1], ["entsch."], ["Verspätung"], null, null, false),
|
||||
new ClassAbsenceDaySummaryRow(new DateOnly(2026, 8, 3 + i), "Geschwaenzt Cem", 1002, 6, 270,
|
||||
["Deu"], [1, 2, 3, 4, 5, 6], ["nicht entsch."], ["Absent"], null, null, true),
|
||||
})
|
||||
.ToArray();
|
||||
|
||||
var roster = ClassTeacherRosterRow.Build([lateStudent, truantStudent], [], [],
|
||||
new DateOnly(2026, 8, 26), yearAbsences, schoolDaysElapsed: 20, termStart: new DateOnly(2026, 8, 1));
|
||||
|
||||
var timo = Assert.Single(roster, r => r.StudentName == "Verpennt Timo");
|
||||
var cem = Assert.Single(roster, r => r.StudentName == "Geschwaenzt Cem");
|
||||
Assert.Equal(5.0, timo.PatternScore);
|
||||
Assert.Equal(30.0, cem.PatternScore);
|
||||
Assert.True(cem.PatternScore > timo.PatternScore * 5);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PatternScore_ErfasstMusterAuchWennHeuteAnwesend()
|
||||
{
|
||||
// Nutzer-Feedback: ein/e Schüler*in, die/der heute da ist, aber zuvor mehrfach unentschuldigt
|
||||
// fehlte, soll nicht spurlos aus der Musterauswertung verschwinden — anders als bei der
|
||||
// reinen "heute auffällig"-Sortierung (HasAbsenceToday).
|
||||
var student = Student(1001, "Ada Müller");
|
||||
var yearAbsences = Enumerable.Range(0, 5)
|
||||
.Select(i => new ClassAbsenceDaySummaryRow(new DateOnly(2026, 8, 10 + i), "Müller Ada", 1001, 6, 270,
|
||||
["Deu"], [1, 2, 3, 4, 5, 6], ["nicht entsch."], ["Absent"], null, null, true))
|
||||
.ToArray();
|
||||
|
||||
// Kein todayAbsences-Eintrag: heute anwesend.
|
||||
var row = Assert.Single(ClassTeacherRosterRow.Build([student], [], [],
|
||||
new DateOnly(2026, 8, 26), yearAbsences, schoolDaysElapsed: 20, termStart: new DateOnly(2026, 8, 1)));
|
||||
|
||||
Assert.False(row.HasAbsenceToday);
|
||||
Assert.True(row.PatternScore > 0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PatternScore_IstNullOhneJahresdaten()
|
||||
{
|
||||
var students = new[] { Student(1001, "Ada Müller") };
|
||||
|
||||
var row = Assert.Single(ClassTeacherRosterRow.Build(students, [], [], new DateOnly(2026, 8, 26)));
|
||||
|
||||
Assert.Equal(0.0, row.PatternScore);
|
||||
}
|
||||
|
||||
// ── Klassenbucheinträge anderer Lehrkräfte fließen ebenfalls in den Score ein ────────────────
|
||||
|
||||
[Fact]
|
||||
public void PatternScore_NegativeKlassenbucheintraegeErhoehenDenScoreAuchOhneFehlzeiten()
|
||||
{
|
||||
var student = Student(1001, "Ada Müller");
|
||||
var yearRegister = Enumerable.Range(0, 4)
|
||||
.Select(i => new UntisForeignClassRegisterEventDto("6a", 20260810 + i, "Deu", "Müller Ada", "test",
|
||||
"Unterrichtsstörung", "Negativ", "Gestört"))
|
||||
.ToArray();
|
||||
|
||||
var row = Assert.Single(ClassTeacherRosterRow.Build([student], [], [],
|
||||
new DateOnly(2026, 8, 26), schoolDaysElapsed: 20, termStart: new DateOnly(2026, 8, 1),
|
||||
yearClassRegisterEntries: yearRegister));
|
||||
|
||||
Assert.Equal(4, row.YearNegativeClassRegisterCount);
|
||||
Assert.Equal(4.0, row.PatternScore);
|
||||
Assert.True(row.HasYearSummary);
|
||||
Assert.Contains("negative Klassenbucheinträge", row.YearSummaryLabel);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PatternScore_HausaufgabenUndMitarbeitSchluesselwoerterZaehlenStaerkerAlsPlainNegativ()
|
||||
{
|
||||
var withKeyword = Student(1001, "Keyword Kim");
|
||||
var plain = Student(1002, "Plain Priya");
|
||||
var yearRegister = new[]
|
||||
{
|
||||
new UntisForeignClassRegisterEventDto("6a", 20260810, "Deu", "Keyword Kim", "test",
|
||||
"Hausaufgaben fehlen", "Negativ", "HA nicht dabei"),
|
||||
new UntisForeignClassRegisterEventDto("6a", 20260810, "Deu", "Plain Priya", "test",
|
||||
"Sonstiges", "Negativ", "Gestört"),
|
||||
};
|
||||
|
||||
var roster = ClassTeacherRosterRow.Build([withKeyword, plain], [], [],
|
||||
new DateOnly(2026, 8, 26), schoolDaysElapsed: 20, termStart: new DateOnly(2026, 8, 1),
|
||||
yearClassRegisterEntries: yearRegister);
|
||||
|
||||
var kim = Assert.Single(roster, r => r.StudentName == "Keyword Kim");
|
||||
var priya = Assert.Single(roster, r => r.StudentName == "Plain Priya");
|
||||
Assert.True(kim.PatternScore > priya.PatternScore);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PatternScore_SuspendiertSchiesstDenScoreWeitUeberVieleAndereEintraegeHinaus()
|
||||
{
|
||||
var suspended = Student(1001, "Suspendiert Sam");
|
||||
var chronic = Student(1002, "Viele Vera");
|
||||
var yearRegister = new List<UntisForeignClassRegisterEventDto>
|
||||
{
|
||||
new("6a", 20260810, "Deu", "Suspendiert Sam", "test", "Ordnungsmaßnahme", "Negativ",
|
||||
"Vom Unterricht suspendiert"),
|
||||
};
|
||||
yearRegister.AddRange(Enumerable.Range(0, 6)
|
||||
.Select(i => new UntisForeignClassRegisterEventDto("6a", 20260810 + i, "Deu", "Viele Vera", "test",
|
||||
"Sonstiges", "Negativ", "Gestört")));
|
||||
|
||||
var roster = ClassTeacherRosterRow.Build([suspended, chronic], [], [],
|
||||
new DateOnly(2026, 8, 26), schoolDaysElapsed: 20, termStart: new DateOnly(2026, 8, 1),
|
||||
yearClassRegisterEntries: yearRegister);
|
||||
|
||||
var sam = Assert.Single(roster, r => r.StudentName == "Suspendiert Sam");
|
||||
var vera = Assert.Single(roster, r => r.StudentName == "Viele Vera");
|
||||
Assert.True(sam.HasSuspensionEntry);
|
||||
Assert.True(sam.PatternScore > vera.PatternScore);
|
||||
Assert.Contains("Suspendierung vermerkt", sam.YearSummaryTooltip);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PatternScore_OhneEigeneJahresklassenbuchlisteFaelltAufDenUebergebenenParameterZurueck()
|
||||
{
|
||||
// Rückwärtskompatibilität: Aufrufer, die (wie die bestehenden Tests) nur den ursprünglichen
|
||||
// recentClassRegisterEntries-Parameter befüllen, sollen trotzdem einen Score aus diesen
|
||||
// Einträgen bekommen statt stillschweigend leer auszugehen.
|
||||
var student = Student(1001, "Ada Müller");
|
||||
var entries = new[]
|
||||
{
|
||||
new UntisForeignClassRegisterEventDto("6a", 20260810, "Deu", "Müller Ada", "test",
|
||||
"Sonstiges", "Negativ", "Gestört"),
|
||||
};
|
||||
|
||||
var row = Assert.Single(ClassTeacherRosterRow.Build([student], [], entries,
|
||||
new DateOnly(2026, 8, 26)));
|
||||
|
||||
Assert.True(row.PatternScore > 0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RosterBuild_OrdnetJahresfehlzeitenAuchOhneExternKeyUeberNamenZu()
|
||||
{
|
||||
@@ -584,4 +840,325 @@ public sealed class ClassTeacherViewModelsTests
|
||||
|
||||
private static UntisStudentRosterCacheEntry Student(int? externKey, string displayName) =>
|
||||
new() { ClassName = "6a", ExternKey = externKey, DisplayName = displayName };
|
||||
|
||||
// ── Klassenbuch-Tab als Dokumentations-Hub: eigene Dokumentation neben WebUntis ─────────────
|
||||
|
||||
[Fact]
|
||||
public void FilterOwnDocumentation_ZeigtNurEintraegeZugeordneterSchuelerImZeitraum()
|
||||
{
|
||||
var ada = Guid.NewGuid();
|
||||
var fremd = Guid.NewGuid();
|
||||
var rosterMatches = new List<(Guid StudentId, string DisplayName)> { (ada, "Ada Müller") };
|
||||
var docs = new List<Documentation>
|
||||
{
|
||||
new() { StudentId = ada, Date = new DateOnly(2026, 8, 20), Title = "Im Zeitraum" },
|
||||
new() { StudentId = ada, Date = new DateOnly(2026, 7, 1), Title = "Vor dem Zeitraum" },
|
||||
new() { StudentId = fremd, Date = new DateOnly(2026, 8, 20), Title = "Anderer Schüler" },
|
||||
new() { StudentId = ada, Date = new DateOnly(2026, 8, 21), Title = "Gelöscht", IsDeleted = true },
|
||||
};
|
||||
|
||||
var result = ClassTeacherDetailsViewModel.FilterOwnDocumentation(docs, rosterMatches,
|
||||
new DateOnly(2026, 8, 1), new DateOnly(2026, 8, 31), "");
|
||||
|
||||
Assert.Equal(["Im Zeitraum"], result.Select(d => d.Title));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FilterOwnDocumentation_SortiertEntwuerfeVorNeuestenZuerst()
|
||||
{
|
||||
var id = Guid.NewGuid();
|
||||
var rosterMatches = new List<(Guid StudentId, string DisplayName)> { (id, "Ada Müller") };
|
||||
var docs = new List<Documentation>
|
||||
{
|
||||
new() { StudentId = id, Date = new DateOnly(2026, 8, 25), Title = "Neu, fertig" },
|
||||
new() { StudentId = id, Date = new DateOnly(2026, 8, 10), Title = "Alt, Entwurf", IsDraft = true },
|
||||
new() { StudentId = id, Date = new DateOnly(2026, 8, 20), Title = "Mittel, fertig" },
|
||||
};
|
||||
|
||||
var result = ClassTeacherDetailsViewModel.FilterOwnDocumentation(docs, rosterMatches,
|
||||
new DateOnly(2026, 8, 1), new DateOnly(2026, 8, 31), "");
|
||||
|
||||
Assert.Equal(["Alt, Entwurf", "Neu, fertig", "Mittel, fertig"], result.Select(d => d.Title));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FilterOwnDocumentation_SchuelerfilterErkenntVertauschteReihenfolge()
|
||||
{
|
||||
// Derselbe Namensabgleich wie bei den WebUntis-Klassenbuchzeilen (siehe
|
||||
// RosterBuild_ErkenntNamenAuchInVertauschterReihenfolge) - der Filter kommt aus der
|
||||
// Übersicht als "Nachname Vorname" oder "Vorname Nachname" je nach Bericht.
|
||||
var ada = Guid.NewGuid();
|
||||
var ben = Guid.NewGuid();
|
||||
var rosterMatches = new List<(Guid StudentId, string DisplayName)>
|
||||
{
|
||||
(ada, "Ada Müller"), (ben, "Ben Schmidt"),
|
||||
};
|
||||
var docs = new List<Documentation>
|
||||
{
|
||||
new() { StudentId = ada, Date = new DateOnly(2026, 8, 20), Title = "Ada" },
|
||||
new() { StudentId = ben, Date = new DateOnly(2026, 8, 20), Title = "Ben" },
|
||||
};
|
||||
|
||||
var result = ClassTeacherDetailsViewModel.FilterOwnDocumentation(docs, rosterMatches,
|
||||
new DateOnly(2026, 8, 1), new DateOnly(2026, 8, 31), "Müller Ada");
|
||||
|
||||
Assert.Equal(["Ada"], result.Select(d => d.Title));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FilterOwnDocumentation_ZeigtVerwaistenKlassenEintragUndFindetTeilnehmerPerVorname()
|
||||
{
|
||||
var rosterMatches = new List<(Guid StudentId, string DisplayName)>
|
||||
{
|
||||
(Guid.NewGuid(), "Karim Alshurbaji"), (Guid.NewGuid(), "Saleh Al-Anezi"),
|
||||
};
|
||||
var orphan = new Documentation
|
||||
{
|
||||
StudentId = Guid.Empty, GroupId = null, Date = new DateOnly(2026, 9, 7),
|
||||
Title = "Vorfall", Participants = ["Karim", "Saleh"],
|
||||
};
|
||||
|
||||
var result = ClassTeacherDetailsViewModel.FilterOwnDocumentation([orphan], rosterMatches,
|
||||
new DateOnly(2026, 9, 1), new DateOnly(2026, 9, 30), "Karim");
|
||||
|
||||
Assert.Same(orphan, Assert.Single(result));
|
||||
}
|
||||
|
||||
// ── Vorgang: Fallmappe für Klassenbuch- und Dokumentationseinträge ───────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void BuildClassRegisterSnapshot_KopiertAlleFelderDerZeile()
|
||||
{
|
||||
var row = new ClassTeacherClassRegisterRow(new DateOnly(2026, 8, 20), "Deu", "Schmidt Ben",
|
||||
"mueller", "Fehlende HA", "Negativ", "Buch vergessen");
|
||||
|
||||
var snapshot = ClassTeacherDetailsViewModel.BuildClassRegisterSnapshot(row);
|
||||
|
||||
Assert.Equal(new DateOnly(2026, 8, 20), snapshot.Date);
|
||||
Assert.Equal("Schmidt Ben", snapshot.StudentName);
|
||||
Assert.Equal("Deu", snapshot.Subject);
|
||||
Assert.Equal("mueller", snapshot.TeacherUsername);
|
||||
Assert.Equal("Fehlende HA", snapshot.CategoryName);
|
||||
Assert.Equal("Negativ", snapshot.CategoryGroup);
|
||||
Assert.Equal("Buch vergessen", snapshot.Text);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FilterAvailableDocumentation_ZeigtNurUnverknuepfteDokumentationDerVorgangsSchueler()
|
||||
{
|
||||
var ada = Guid.NewGuid();
|
||||
var ben = Guid.NewGuid();
|
||||
var fremd = Guid.NewGuid();
|
||||
var linked = new Documentation { StudentId = ada, Title = "Schon verknüpft" };
|
||||
var unlinked = new Documentation { StudentId = ada, Title = "Noch offen" };
|
||||
var otherStudentInCase = new Documentation { StudentId = ben, Title = "Ben, offen" };
|
||||
var unrelated = new Documentation { StudentId = fremd, Title = "Anderer Schüler" };
|
||||
var vorgang = new Vorgang { StudentIds = [ada, ben], DocumentationIds = [linked.Id] };
|
||||
var allDocs = new List<Documentation> { linked, unlinked, otherStudentInCase, unrelated };
|
||||
|
||||
var result = ClassTeacherCasesViewModel.FilterAvailableDocumentation(allDocs, vorgang);
|
||||
|
||||
Assert.Equal(["Noch offen", "Ben, offen"], result.Select(d => d.Title));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FilterAvailableDocumentation_LeerOhneUnverknuepfteEintraege()
|
||||
{
|
||||
var ada = Guid.NewGuid();
|
||||
var doc = new Documentation { StudentId = ada, Title = "Verknüpft" };
|
||||
var vorgang = new Vorgang { StudentIds = [ada], DocumentationIds = [doc.Id] };
|
||||
|
||||
var result = ClassTeacherCasesViewModel.FilterAvailableDocumentation([doc], vorgang);
|
||||
|
||||
Assert.Empty(result);
|
||||
}
|
||||
|
||||
// ── Konfigurierbare Gewichtung + Dämpfung/Rückfall-Warnung nach geschlossenem Vorgang ─────────
|
||||
|
||||
[Fact]
|
||||
public void MatchDomains_ErkenntBereicheAnSchluesselwoertern()
|
||||
{
|
||||
Assert.Equal([VorgangScoreDomain.Attendance], ClassTeacherRosterRow.MatchDomains(["Absentismus"]));
|
||||
Assert.Equal([VorgangScoreDomain.Lateness], ClassTeacherRosterRow.MatchDomains(["Verspätungen"]));
|
||||
Assert.Equal([VorgangScoreDomain.Classbook], ClassTeacherRosterRow.MatchDomains(["Konflikte"]));
|
||||
Assert.Equal([VorgangScoreDomain.Classbook], ClassTeacherRosterRow.MatchDomains(["Mitarbeit"]));
|
||||
Assert.Equal([VorgangScoreDomain.Classbook], ClassTeacherRosterRow.MatchDomains(["Hausaufgaben"]));
|
||||
Assert.Equal([VorgangScoreDomain.Classbook], ClassTeacherRosterRow.MatchDomains(["Eskalation"]));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MatchDomains_OhneErkanntesSchlagwortDaemptAlleBereiche()
|
||||
{
|
||||
// Nutzer-Entscheidung: ein Vorgang darf beim Schließen nie folgenlos bleiben, auch wenn sein
|
||||
// Schlagwort (z.B. "Elternkontakt") keinem bekannten Score-Bereich zugeordnet werden kann.
|
||||
var domains = ClassTeacherRosterRow.MatchDomains(["Elternkontakt"]);
|
||||
|
||||
Assert.Equal(3, domains.Count);
|
||||
Assert.Contains(VorgangScoreDomain.Attendance, domains);
|
||||
Assert.Contains(VorgangScoreDomain.Lateness, domains);
|
||||
Assert.Contains(VorgangScoreDomain.Classbook, domains);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComputeDampeningCutoffs_NimmtJeBereichDasJuengsteSchliessDatum()
|
||||
{
|
||||
var closed = new List<Vorgang>
|
||||
{
|
||||
new() { Tags = ["Absentismus"], Status = VorgangStatus.Closed, ClosedAt = new DateTime(2026, 8, 10) },
|
||||
new() { Tags = ["Absentismus"], Status = VorgangStatus.Closed, ClosedAt = new DateTime(2026, 8, 20) },
|
||||
new() { Tags = ["Verspätungen"], Status = VorgangStatus.Closed, ClosedAt = new DateTime(2026, 8, 5) },
|
||||
};
|
||||
|
||||
var cutoffs = ClassTeacherRosterRow.ComputeDampeningCutoffs(closed);
|
||||
|
||||
Assert.Equal(new DateOnly(2026, 8, 20), cutoffs[VorgangScoreDomain.Attendance]);
|
||||
Assert.Equal(new DateOnly(2026, 8, 5), cutoffs[VorgangScoreDomain.Lateness]);
|
||||
Assert.False(cutoffs.ContainsKey(VorgangScoreDomain.Classbook));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComputeDampeningCutoffs_IgnoriertVorgaengeOhneSchliessDatum()
|
||||
{
|
||||
var closed = new List<Vorgang> { new() { Tags = ["Absentismus"], Status = VorgangStatus.Open } };
|
||||
|
||||
var cutoffs = ClassTeacherRosterRow.ComputeDampeningCutoffs(closed);
|
||||
|
||||
Assert.Empty(cutoffs);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PatternScore_BenutztUebergebeneGewichteStattFesterKonstanten()
|
||||
{
|
||||
var student = Student(1001, "Ada Müller");
|
||||
var yearAbsences = new[]
|
||||
{
|
||||
new ClassAbsenceDaySummaryRow(new DateOnly(2026, 8, 10), "Müller Ada", 1001, 6, 270,
|
||||
["Deu"], [1, 2, 3, 4, 5, 6], ["nicht entsch."], ["Absent"], null, null, true),
|
||||
};
|
||||
var customWeights = new PatternScoreWeights { UnexcusedDayWeight = 10.0 };
|
||||
|
||||
var row = Assert.Single(ClassTeacherRosterRow.Build([student], [], [],
|
||||
new DateOnly(2026, 8, 26), yearAbsences, schoolDaysElapsed: 20, termStart: new DateOnly(2026, 8, 1),
|
||||
weights: customWeights));
|
||||
|
||||
Assert.Equal(10.0, row.PatternScore);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PatternScore_DaempftNurDenZumVorgangPassendenBereichNachAbschluss()
|
||||
{
|
||||
var student = Student(1001, "Ada Müller");
|
||||
var yearAbsences = new[]
|
||||
{
|
||||
// Vor dem Abschluss: darf für den Score nicht mehr zählen.
|
||||
new ClassAbsenceDaySummaryRow(new DateOnly(2026, 8, 5), "Müller Ada", 1001, 6, 270,
|
||||
["Deu"], [1, 2, 3, 4, 5, 6], ["nicht entsch."], ["Absent"], null, null, true),
|
||||
// Nach dem Abschluss: zählt weiter.
|
||||
new ClassAbsenceDaySummaryRow(new DateOnly(2026, 8, 20), "Müller Ada", 1001, 6, 270,
|
||||
["Deu"], [1, 2, 3, 4, 5, 6], ["nicht entsch."], ["Absent"], null, null, true),
|
||||
// Verspätung, anderer Bereich: bleibt von der auf "Absentismus" beschränkten Dämpfung unberührt.
|
||||
new ClassAbsenceDaySummaryRow(new DateOnly(2026, 8, 1), "Müller Ada", 1001, 0, 5,
|
||||
["Deu"], [1], ["entsch."], ["Verspätung"], null, null, false),
|
||||
};
|
||||
var closedVorgaengeByNameKey = new Dictionary<string, IReadOnlyList<Vorgang>>
|
||||
{
|
||||
[UntisNameMatching.NameKey("Ada Müller")] = [new Vorgang
|
||||
{
|
||||
Title = "Schuleschwänzen", Tags = ["Absentismus"], Status = VorgangStatus.Closed,
|
||||
ClosedAt = new DateTime(2026, 8, 12),
|
||||
}],
|
||||
};
|
||||
|
||||
var row = Assert.Single(ClassTeacherRosterRow.Build([student], [], [],
|
||||
new DateOnly(2026, 8, 26), yearAbsences, schoolDaysElapsed: 20, termStart: new DateOnly(2026, 8, 1),
|
||||
closedVorgaengeByNameKey: closedVorgaengeByNameKey));
|
||||
|
||||
// 1 unentschuldigter Tag nach Abschluss (3,0) + 1 unbeeinflusster Verspätungstag (0,5) = 3,5.
|
||||
Assert.Equal(3.5, row.PatternScore);
|
||||
// Die Quote bleibt von der Dämpfung unberührt: beide unentschuldigten Tage zählen weiter.
|
||||
Assert.Equal(2, row.YearUnexcusedDayCount);
|
||||
Assert.Equal(2, row.YearAbsenceDayCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PatternScore_VorgangOhneErkennbaresSchlagwortDaemptAlleBereiche()
|
||||
{
|
||||
var student = Student(1001, "Ada Müller");
|
||||
var yearAbsences = new[]
|
||||
{
|
||||
new ClassAbsenceDaySummaryRow(new DateOnly(2026, 8, 5), "Müller Ada", 1001, 0, 5,
|
||||
["Deu"], [1], ["entsch."], ["Verspätung"], null, null, false),
|
||||
};
|
||||
var closedVorgaengeByNameKey = new Dictionary<string, IReadOnlyList<Vorgang>>
|
||||
{
|
||||
[UntisNameMatching.NameKey("Ada Müller")] = [new Vorgang
|
||||
{
|
||||
Title = "Elterngespräch", Tags = ["Elternkontakt"], Status = VorgangStatus.Closed,
|
||||
ClosedAt = new DateTime(2026, 8, 12),
|
||||
}],
|
||||
};
|
||||
|
||||
var row = Assert.Single(ClassTeacherRosterRow.Build([student], [], [],
|
||||
new DateOnly(2026, 8, 26), yearAbsences, schoolDaysElapsed: 20, termStart: new DateOnly(2026, 8, 1),
|
||||
closedVorgaengeByNameKey: closedVorgaengeByNameKey));
|
||||
|
||||
Assert.Equal(0.0, row.PatternScore);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RegressionScore_IstNullOhneNeueEreignisseNachAbschluss()
|
||||
{
|
||||
var student = Student(1001, "Ada Müller");
|
||||
var yearAbsences = new[]
|
||||
{
|
||||
new ClassAbsenceDaySummaryRow(new DateOnly(2026, 8, 5), "Müller Ada", 1001, 6, 270,
|
||||
["Deu"], [1, 2, 3, 4, 5, 6], ["nicht entsch."], ["Absent"], null, null, true),
|
||||
};
|
||||
var closedVorgaengeByNameKey = new Dictionary<string, IReadOnlyList<Vorgang>>
|
||||
{
|
||||
[UntisNameMatching.NameKey("Ada Müller")] = [new Vorgang
|
||||
{
|
||||
Title = "Schuleschwänzen", Tags = ["Absentismus"], Status = VorgangStatus.Closed,
|
||||
ClosedAt = new DateTime(2026, 8, 12),
|
||||
}],
|
||||
};
|
||||
|
||||
var row = Assert.Single(ClassTeacherRosterRow.Build([student], [], [],
|
||||
new DateOnly(2026, 8, 26), yearAbsences, schoolDaysElapsed: 20, termStart: new DateOnly(2026, 8, 1),
|
||||
closedVorgaengeByNameKey: closedVorgaengeByNameKey));
|
||||
|
||||
Assert.Equal(0.0, row.RegressionScore);
|
||||
Assert.Equal("Schuleschwänzen", row.RegressionVorgangTitle);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RegressionScore_ErkenntNeueUnentschuldigteTageNachAbschluss()
|
||||
{
|
||||
var student = Student(1001, "Ada Müller");
|
||||
var yearAbsences = new[]
|
||||
{
|
||||
new ClassAbsenceDaySummaryRow(new DateOnly(2026, 8, 5), "Müller Ada", 1001, 6, 270,
|
||||
["Deu"], [1, 2, 3, 4, 5, 6], ["nicht entsch."], ["Absent"], null, null, true),
|
||||
// Nach Abschluss erneut unentschuldigt gefehlt.
|
||||
new ClassAbsenceDaySummaryRow(new DateOnly(2026, 8, 20), "Müller Ada", 1001, 6, 270,
|
||||
["Deu"], [1, 2, 3, 4, 5, 6], ["nicht entsch."], ["Absent"], null, null, true),
|
||||
};
|
||||
var closedVorgaengeByNameKey = new Dictionary<string, IReadOnlyList<Vorgang>>
|
||||
{
|
||||
[UntisNameMatching.NameKey("Ada Müller")] = [new Vorgang
|
||||
{
|
||||
Title = "Schuleschwänzen", Tags = ["Absentismus"], Status = VorgangStatus.Closed,
|
||||
ClosedAt = new DateTime(2026, 8, 12),
|
||||
}],
|
||||
};
|
||||
|
||||
var row = Assert.Single(ClassTeacherRosterRow.Build([student], [], [],
|
||||
new DateOnly(2026, 8, 26), yearAbsences, schoolDaysElapsed: 20, termStart: new DateOnly(2026, 8, 1),
|
||||
closedVorgaengeByNameKey: closedVorgaengeByNameKey));
|
||||
|
||||
Assert.Equal(3.0, row.RegressionScore);
|
||||
Assert.Equal("Schuleschwänzen", row.RegressionVorgangTitle);
|
||||
// Quote weiterhin unangetastet.
|
||||
Assert.Equal(2, row.YearUnexcusedDayCount);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -74,7 +74,8 @@ public sealed class DashboardViewModelTests
|
||||
slots ?? new FakeTimetableSlots(), periodSchedule ?? NewPeriodSchedule(),
|
||||
new AttendanceBalanceService(), new SchoolYearService(), dashboardSettings ?? NewDashboardSettings(),
|
||||
schoolHolidays ?? new FakeSchoolHolidays(), new PublicHolidayService(), NewCalendarSettings(),
|
||||
substitutions ?? new FakeSubstitutionEntries(), timeEntries ?? new FakeTimeEntries(), annualPlanEvents);
|
||||
substitutions ?? new FakeSubstitutionEntries(), timeEntries ?? new FakeTimeEntries(),
|
||||
TestSupport.BuildUntisHubService(), TestSupport.BuildWebUntisIntegrationService(), annualPlanEvents);
|
||||
}
|
||||
|
||||
/// Montag einer Woche, die garantiert in der Zukunft liegt und innerhalb der
|
||||
@@ -154,6 +155,7 @@ public sealed class DashboardViewModelTests
|
||||
|
||||
Assert.Contains(vm.MissingTeachingTimeEntries, i => i.Date == pastDay);
|
||||
Assert.True(vm.MissingTeachingTimeCard.EffectiveIsVisible);
|
||||
Assert.Equal(2, vm.AttentionCount); // fehlende Unterrichtszeit + bereits bestehende ungeplante Stunde
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -228,6 +228,20 @@ public sealed class DocumentationDialogViewModelTests
|
||||
Assert.NotEqual("", vm.StudentError);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OhneGeladeneSchuelerlisteUndOhneKontext_SaveErzeugtKeinenVerwaistenEintrag()
|
||||
{
|
||||
var vm = new DocumentationDialogViewModel(Guid.Empty, null, new FakeAttachmentStorage())
|
||||
{
|
||||
Title = "Vorfall", TypeName = "Vorkommnis",
|
||||
};
|
||||
|
||||
vm.SaveCommand.Execute(null);
|
||||
|
||||
Assert.Null(vm.Result);
|
||||
Assert.Contains("noch nicht geladen", vm.StudentError);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MitStudentOptions_AuswahlGetroffen_SaveUebernimmtSchuelerUndGruppe()
|
||||
{
|
||||
|
||||
@@ -39,6 +39,14 @@ public static class TestSupport
|
||||
return new WebUntisSettingsService(tempPath);
|
||||
}
|
||||
|
||||
/// Nicht konfiguriert (kein API-Login hinterlegt) - genügt für Tests, die nur den Konstruktor
|
||||
/// bedienen müssen und keinen echten WebUntis-Zugriff auslösen.
|
||||
public static WebUntisIntegrationService BuildWebUntisIntegrationService() =>
|
||||
new(new HttpClient(), BuildWebUntisSettingsService());
|
||||
|
||||
public static UntisHubService BuildUntisHubService(List<LearningGroup>? groups = null) =>
|
||||
new(new FakeGroups(groups ?? []), new FakeUntisHubJobStates(), new SchoolYearService());
|
||||
|
||||
/// Analog zu den übrigen dateibasierten Feed-Einstellungen: eigenes Temp-Verzeichnis.
|
||||
public static AnnualPlanSettingsService BuildAnnualPlanSettingsService()
|
||||
{
|
||||
@@ -525,6 +533,19 @@ public class FakeUntisCacheFetchStates : IUntisCacheFetchStateRepository
|
||||
}
|
||||
}
|
||||
|
||||
public class FakeUntisHubJobStates : IUntisHubJobStateRepository
|
||||
{
|
||||
private readonly List<UntisHubJobState> _all = [];
|
||||
public UntisHubJobState? Get(UntisHubJobKind kind, Guid? groupId) =>
|
||||
_all.FirstOrDefault(s => s.Kind == kind && s.GroupId == groupId);
|
||||
public List<UntisHubJobState> GetAll() => _all.ToList();
|
||||
public void Save(UntisHubJobState state)
|
||||
{
|
||||
_all.RemoveAll(s => s.Kind == state.Kind && s.GroupId == state.GroupId);
|
||||
_all.Add(state);
|
||||
}
|
||||
}
|
||||
|
||||
public class FakeWorkTasks : IWorkTaskRepository
|
||||
{
|
||||
private readonly List<WorkTask> _all = [];
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
using LehrerApp.Core.Models;
|
||||
using LehrerApp.Desktop.ViewModels.Groups;
|
||||
using Xunit;
|
||||
|
||||
namespace LehrerApp.Desktop.Tests;
|
||||
|
||||
/// Deckt die Kandidatensuche für den "Vorhandene Stunde verknüpfen"-Button im Stunden-anlegen-
|
||||
/// Dialog ab (Nutzer-Feedback: per JSON-Import/KI ohne Stundennummer angelegte Stunden tauchen im
|
||||
/// Stundenplan nicht auf, da der dort verwendete FindLessonForSlot per Datum+Stundennummer sucht).
|
||||
public class LessonFixItSearchTests
|
||||
{
|
||||
[Fact]
|
||||
public void FindCandidates_FindetVerwaisteStundeOhneStundennummerAmSelbenTag()
|
||||
{
|
||||
var lessons = new FakeLessons();
|
||||
var groupId = Guid.NewGuid();
|
||||
var unitId = Guid.NewGuid();
|
||||
var date = new DateOnly(2026, 9, 8);
|
||||
var orphan = new Lesson { UnitId = unitId, GroupId = groupId, Date = date, LessonNumber = null, Topic = "Reflexionsgesetz" };
|
||||
lessons.Add(orphan);
|
||||
|
||||
var candidates = LessonFixItSearch.FindCandidates(lessons, groupId, date);
|
||||
|
||||
var found = Assert.Single(candidates);
|
||||
Assert.Equal(orphan.Id, found.Id);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FindCandidates_FindetStundeAmSelbenTagMitAbweichenderStundennummer()
|
||||
{
|
||||
var lessons = new FakeLessons();
|
||||
var groupId = Guid.NewGuid();
|
||||
var unitId = Guid.NewGuid();
|
||||
var date = new DateOnly(2026, 9, 8);
|
||||
var wrongPeriod = new Lesson { UnitId = unitId, GroupId = groupId, Date = date, LessonNumber = 7, Topic = "Reflexionsgesetz" };
|
||||
lessons.Add(wrongPeriod);
|
||||
|
||||
var candidates = LessonFixItSearch.FindCandidates(lessons, groupId, date);
|
||||
|
||||
var found = Assert.Single(candidates);
|
||||
Assert.Equal(wrongPeriod.Id, found.Id);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FindCandidates_SortiertExaktesDatumVorNahenTerminenOhneStundennummer()
|
||||
{
|
||||
var lessons = new FakeLessons();
|
||||
var groupId = Guid.NewGuid();
|
||||
var unitId = Guid.NewGuid();
|
||||
var date = new DateOnly(2026, 9, 8);
|
||||
var near = new Lesson { UnitId = unitId, GroupId = groupId, Date = date.AddDays(-2), LessonNumber = null, Topic = "Nah" };
|
||||
var exact = new Lesson { UnitId = unitId, GroupId = groupId, Date = date, LessonNumber = null, Topic = "Exakt" };
|
||||
lessons.Add(near); lessons.Add(exact);
|
||||
|
||||
var candidates = LessonFixItSearch.FindCandidates(lessons, groupId, date);
|
||||
|
||||
Assert.Equal(2, candidates.Count);
|
||||
Assert.Equal(exact.Id, candidates[0].Id);
|
||||
Assert.Equal(near.Id, candidates[1].Id);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FindCandidates_IgnoriertStundenAusserhalbDesZeitfenstersUndAnderenGruppen()
|
||||
{
|
||||
var lessons = new FakeLessons();
|
||||
var groupId = Guid.NewGuid();
|
||||
var otherGroupId = Guid.NewGuid();
|
||||
var unitId = Guid.NewGuid();
|
||||
var date = new DateOnly(2026, 9, 8);
|
||||
lessons.Add(new Lesson { UnitId = unitId, GroupId = groupId, Date = date.AddDays(-30), LessonNumber = null, Topic = "Zu weit weg" });
|
||||
lessons.Add(new Lesson { UnitId = unitId, GroupId = otherGroupId, Date = date, LessonNumber = null, Topic = "Andere Gruppe" });
|
||||
// Gesetzte Stundennummer, aber exakt am gesuchten Datum — bewusst als Kandidat enthalten
|
||||
// (z.B. falsch nummerierter Import); der Nutzer entscheidet im Bestätigungs-/
|
||||
// Auswahldialog, ob sie passt.
|
||||
lessons.Add(new Lesson { UnitId = unitId, GroupId = groupId, Date = date, LessonNumber = 3, Topic = "Falsch nummeriert, aber am gesuchten Tag" });
|
||||
|
||||
var candidates = LessonFixItSearch.FindCandidates(lessons, groupId, date);
|
||||
|
||||
var found = Assert.Single(candidates);
|
||||
Assert.Equal("Falsch nummeriert, aber am gesuchten Tag", found.Topic);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FindCandidates_LeerOhnePassendeStunde()
|
||||
{
|
||||
var lessons = new FakeLessons();
|
||||
var groupId = Guid.NewGuid();
|
||||
var date = new DateOnly(2026, 9, 8);
|
||||
|
||||
var candidates = LessonFixItSearch.FindCandidates(lessons, groupId, date);
|
||||
|
||||
Assert.Empty(candidates);
|
||||
}
|
||||
}
|
||||
@@ -45,6 +45,21 @@ public class PlanningTabViewModelTests
|
||||
Assert.Contains("2 / 3", summary.ProgressText);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Initialize_WaehltLaufendeEinheitAlsArbeitskontext()
|
||||
{
|
||||
var (vm, units, _, groupId) = BuildScenario();
|
||||
units.Add(new Unit { GroupId = groupId, Title = "Früher", Status = UnitStatus.Completed,
|
||||
StartDate = new DateOnly(2025, 8, 1) });
|
||||
var active = new Unit { GroupId = groupId, Title = "Aktuell", Status = UnitStatus.Active,
|
||||
StartDate = new DateOnly(2025, 9, 1) };
|
||||
units.Add(active);
|
||||
|
||||
vm.Initialize(groupId);
|
||||
|
||||
Assert.Equal(active.Id, vm.SelectedUnit?.Id);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MoveLesson_MitNachrueckenVerschiebtNurGeplanteFolgestundenNachDemStichtag()
|
||||
{
|
||||
@@ -93,6 +108,30 @@ public class PlanningTabViewModelTests
|
||||
Assert.Equal(new DateOnly(2025, 9, 8), byUnit[l2.Id].Date); // unverändert
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ChangeLessonUnit_OrdnetStundeDerZieleinheitZuUndWaehltSieAus()
|
||||
{
|
||||
var (vm, units, lessons, groupId) = BuildScenario();
|
||||
var unitA = new Unit { GroupId = groupId, Title = "Mechanik" };
|
||||
var unitB = new Unit { GroupId = groupId, Title = "Optik" };
|
||||
units.Add(unitA); units.Add(unitB);
|
||||
var l1 = new Lesson { UnitId = unitA.Id, GroupId = groupId, Date = new DateOnly(2025, 9, 1), Status = LessonStatus.Planned, Topic = "1" };
|
||||
lessons.Add(l1);
|
||||
|
||||
vm.Initialize(groupId);
|
||||
vm.SelectedUnit = vm.Units.Single(u => u.Id == unitA.Id);
|
||||
vm.SelectedLesson = vm.Lessons.Single(l => l.Id == l1.Id);
|
||||
vm.OnPickUnitChange = _ => Task.FromResult<Unit?>(unitB);
|
||||
|
||||
await vm.ChangeLessonUnitCommand.ExecuteAsync(null);
|
||||
|
||||
Assert.Empty(lessons.GetByUnit(unitA.Id));
|
||||
var moved = Assert.Single(lessons.GetByUnit(unitB.Id));
|
||||
Assert.Equal(l1.Id, moved.Id);
|
||||
Assert.Equal(unitB.Id, vm.SelectedUnit?.Id);
|
||||
Assert.Equal(l1.Id, vm.SelectedLesson?.Id);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CopyUnit_ErhaeltAbstaendeUndSetztGroupIdAufZielgruppe()
|
||||
{
|
||||
@@ -294,4 +333,44 @@ public class PlanningTabViewModelTests
|
||||
Assert.Single(sessions.GetByGroup(groupId));
|
||||
Assert.Equal("Für diese Stunde existiert bereits eine Sitzung.", notified);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateParticipationSession_AndereStundeAmSelbenTagBereitsVerknuepft_LegtKeineZweiteSitzungAn()
|
||||
{
|
||||
// Nutzer-Feedback: eine dritte Stunde am selben Tag (z.B. Vertretung) soll die bereits
|
||||
// bestehende Sitzung des Tages weiterverwenden statt eine zweite anzulegen.
|
||||
var groupId = Guid.NewGuid();
|
||||
var group = new LearningGroup { Id = groupId, Name = "Testgruppe" };
|
||||
var groups = new FakeGroups([group]);
|
||||
var units = new FakeUnits();
|
||||
var lessons = new FakeLessons();
|
||||
var sessions = new FakeSessions([]);
|
||||
var vm = new PlanningTabViewModel(units, lessons, groups, new FakeSubjects([]),
|
||||
new FakeCompetencyDomains(), TestSupport.BuildAiSettingsService(), sessions);
|
||||
vm.Initialize(groupId);
|
||||
|
||||
var unit = new Unit { GroupId = groupId, Title = "Optik" };
|
||||
units.Add(unit);
|
||||
var date = new DateOnly(2025, 9, 1);
|
||||
var doubleLesson = new Lesson
|
||||
{ UnitId = unit.Id, GroupId = groupId, Date = date, Topic = "Brechung" };
|
||||
var thirdLesson = new Lesson
|
||||
{ UnitId = unit.Id, GroupId = groupId, Date = date, Topic = "Vertretung" };
|
||||
lessons.Add(doubleLesson);
|
||||
lessons.Add(thirdLesson);
|
||||
|
||||
vm.RefreshPlanning(unit.Id, doubleLesson.Id);
|
||||
vm.SelectedLesson = vm.Lessons.Single(l => l.Id == doubleLesson.Id);
|
||||
vm.CreateParticipationSessionCommand.Execute(null);
|
||||
|
||||
string? notified = null;
|
||||
vm.OnNotify = m => notified = m;
|
||||
vm.RefreshPlanning(unit.Id, thirdLesson.Id);
|
||||
vm.SelectedLesson = vm.Lessons.Single(l => l.Id == thirdLesson.Id);
|
||||
vm.CreateParticipationSessionCommand.Execute(null);
|
||||
|
||||
var created = Assert.Single(sessions.GetByGroup(groupId));
|
||||
Assert.Equal(doubleLesson.Id, created.LessonId);
|
||||
Assert.Equal("Für diesen Tag existiert bereits eine Sitzung.", notified);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -256,6 +256,30 @@ public sealed class SeatingPlanViewModelTests
|
||||
Assert.Single(sessions.GetByGroup(groupId));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SelectOrCreateSessionForLesson_AndereStundeAmSelbenTagBereitsVerknuepft_LegtKeineZweiteSitzungAn()
|
||||
{
|
||||
// Nutzer-Feedback: eine dritte Stunde am selben Tag (z.B. durch Vertretung, eigene Lesson-
|
||||
// Id) soll die bereits bestehende Sitzung der Doppelstunde weiterverwenden statt eine
|
||||
// zweite, unabhängige Sitzung für denselben Tag anzulegen.
|
||||
var groupId = Guid.NewGuid();
|
||||
var today = DateOnly.FromDateTime(DateTime.Today);
|
||||
var doubleLesson = new Lesson { GroupId = groupId, Date = today, Topic = "Redox" };
|
||||
var thirdLesson = new Lesson { GroupId = groupId, Date = today, Topic = "Vertretung" };
|
||||
var existingSession = new ParticipationSession
|
||||
{ GroupId = groupId, Date = today, LessonId = doubleLesson.Id, Comment = "Redox" };
|
||||
var sessions = new FakeSessions([existingSession]);
|
||||
var plan = new SeatingPlan { GroupId = groupId, Name = "Standard", Rows = 1, Columns = 1 };
|
||||
var vm = new SeatingPlanTabViewModel(new FakeSeatingPlans([plan]), new FakeStudents([]),
|
||||
new FakeMemberships([]), sessions, new FakeEntries(), new FakeAspects());
|
||||
vm.Initialize(groupId, isReadOnly: false);
|
||||
|
||||
vm.SelectOrCreateSessionForLesson(thirdLesson);
|
||||
|
||||
Assert.Equal(existingSession.Id, vm.SelectedSession?.Id);
|
||||
Assert.Single(sessions.GetByGroup(groupId));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SelectOrCreateSessionForLesson_KeineSitzungVorhanden_LegtVerknuepfteAnUndWaehltSieAus()
|
||||
{
|
||||
|
||||
@@ -219,6 +219,25 @@ public sealed class SettingsViewModelTests
|
||||
Assert.Empty(queue.GetUnreviewed());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ClearSyncConflicts_LeertProtokollUndQueue()
|
||||
{
|
||||
var queue = TestSupport.BuildEventQueue();
|
||||
foreach (var resolution in new[] { "LocalWon", "RemoteWon" })
|
||||
queue.AddConflict(new ConflictEntry
|
||||
{
|
||||
LocalEvent = new SyncEvent { EntityType = "Student", EntityId = Guid.NewGuid().ToString() },
|
||||
RemoteEvent = new SyncEvent { EntityType = "Student", EntityId = Guid.NewGuid().ToString() },
|
||||
Resolution = resolution,
|
||||
});
|
||||
var vm = BuildViewModel(eventQueue: queue);
|
||||
|
||||
vm.ClearSyncConflictsCommand.Execute(null);
|
||||
|
||||
Assert.Empty(vm.SyncConflicts);
|
||||
Assert.Empty(queue.GetUnreviewed());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddSchoolHoliday_GueltigeEingabe_WirdGespeichertUndInListeAngezeigt()
|
||||
{
|
||||
|
||||
@@ -25,4 +25,28 @@ public sealed class UnitDialogViewModelTests
|
||||
|
||||
Assert.Equal("10c · kein Fach hinterlegt (siehe Lerngruppe)", vm.GroupSubjectDisplay);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Laufend_SetztBisherigeLaufendeEinheitAufAbgeschlossenUndWarntVorher()
|
||||
{
|
||||
var groupId = Guid.NewGuid();
|
||||
var units = new FakeUnits();
|
||||
var previous = new LehrerApp.Core.Models.Unit
|
||||
{
|
||||
GroupId = groupId, Title = "Bruchrechnung", Status = LehrerApp.Core.Models.UnitStatus.Active,
|
||||
};
|
||||
units.Add(previous);
|
||||
var vm = new UnitDialogViewModel(units, new FakeCompetencyDomains(),
|
||||
groupId, null, 6, "6a", "Mathematik", editingUnit: null)
|
||||
{
|
||||
Title = "Prozentrechnung",
|
||||
StatusName = "Laufend",
|
||||
};
|
||||
|
||||
Assert.Contains("Bruchrechnung", vm.StatusNotice);
|
||||
vm.SaveCommand.Execute(null);
|
||||
|
||||
Assert.Equal(LehrerApp.Core.Models.UnitStatus.Completed, previous.Status);
|
||||
Assert.Single(units.GetByGroup(groupId), u => u.Status == LehrerApp.Core.Models.UnitStatus.Active);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
using LehrerApp.Core.Models;
|
||||
using LehrerApp.Desktop.Services;
|
||||
using Xunit;
|
||||
|
||||
namespace LehrerApp.Desktop.Tests;
|
||||
|
||||
public sealed class UntisHubServiceTests
|
||||
{
|
||||
private static readonly DateTime UtcNow = new(2026, 9, 10, 8, 0, 0, DateTimeKind.Utc);
|
||||
|
||||
private static LearningGroup Group(string name, int? webUntisLessonId, bool isActive = true) =>
|
||||
new() { Name = name, WebUntisLessonId = webUntisLessonId, IsActive = isActive };
|
||||
|
||||
[Fact]
|
||||
public void BuildRows_ProGruppeMitLessonId_ZweiFehlzeitenzeilenPlusDreiGlobaleZeilen()
|
||||
{
|
||||
var group = Group("9a", 42);
|
||||
|
||||
var rows = UntisHubService.BuildRows([group], [], UtcNow);
|
||||
|
||||
Assert.Equal(5, rows.Count);
|
||||
Assert.Equal(2, rows.Count(r => r.GroupId == group.Id));
|
||||
Assert.Contains(rows, r => r.Kind == UntisHubJobKind.FehlzeitenKurz && r.GroupId == group.Id);
|
||||
Assert.Contains(rows, r => r.Kind == UntisHubJobKind.FehlzeitenLang && r.GroupId == group.Id);
|
||||
Assert.Contains(rows, r => r.Kind == UntisHubJobKind.OffenePeriods && r.GroupId == null);
|
||||
Assert.Contains(rows, r => r.Kind == UntisHubJobKind.Klassenbuchabgleich && r.GroupId == null);
|
||||
Assert.Contains(rows, r => r.Kind == UntisHubJobKind.Hausaufgabenabgleich && r.GroupId == null);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildRows_NieGeprueft_GiltAlsUeberfaellig()
|
||||
{
|
||||
var rows = UntisHubService.BuildRows([], [], UtcNow);
|
||||
|
||||
Assert.All(rows, r => Assert.Equal(UntisHubDueState.Overdue, r.DueState));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildRows_FehlzeitenKurzVorSechsTagen_GiltAlsOk()
|
||||
{
|
||||
var group = Group("9a", 42);
|
||||
var state = new UntisHubJobState
|
||||
{
|
||||
Kind = UntisHubJobKind.FehlzeitenKurz, GroupId = group.Id, LastRunAt = UtcNow.AddDays(-6),
|
||||
};
|
||||
|
||||
var rows = UntisHubService.BuildRows([group], [state], UtcNow);
|
||||
|
||||
var row = rows.Single(r => r.Kind == UntisHubJobKind.FehlzeitenKurz);
|
||||
Assert.Equal(UntisHubDueState.Ok, row.DueState);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildRows_FehlzeitenKurzVorSechzehnTagen_GiltAlsFaellig()
|
||||
{
|
||||
var group = Group("9a", 42);
|
||||
var state = new UntisHubJobState
|
||||
{
|
||||
Kind = UntisHubJobKind.FehlzeitenKurz, GroupId = group.Id, LastRunAt = UtcNow.AddDays(-16),
|
||||
};
|
||||
|
||||
var rows = UntisHubService.BuildRows([group], [state], UtcNow);
|
||||
|
||||
var row = rows.Single(r => r.Kind == UntisHubJobKind.FehlzeitenKurz);
|
||||
Assert.Equal(UntisHubDueState.Due, row.DueState);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildRows_FehlzeitenKurzVorZweiundzwanzigTagen_GiltAlsUeberfaellig()
|
||||
{
|
||||
var group = Group("9a", 42);
|
||||
var state = new UntisHubJobState
|
||||
{
|
||||
Kind = UntisHubJobKind.FehlzeitenKurz, GroupId = group.Id, LastRunAt = UtcNow.AddDays(-22),
|
||||
};
|
||||
|
||||
var rows = UntisHubService.BuildRows([group], [state], UtcNow);
|
||||
|
||||
var row = rows.Single(r => r.Kind == UntisHubJobKind.FehlzeitenKurz);
|
||||
Assert.Equal(UntisHubDueState.Overdue, row.DueState);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildRows_FehlzeitenLangHatDeutlichLaengereKadenzAlsKurz()
|
||||
{
|
||||
var group = Group("9a", 42);
|
||||
var lastRunAt = UtcNow.AddDays(-40);
|
||||
var states = new List<UntisHubJobState>
|
||||
{
|
||||
new() { Kind = UntisHubJobKind.FehlzeitenKurz, GroupId = group.Id, LastRunAt = lastRunAt },
|
||||
new() { Kind = UntisHubJobKind.FehlzeitenLang, GroupId = group.Id, LastRunAt = lastRunAt },
|
||||
};
|
||||
|
||||
var rows = UntisHubService.BuildRows([group], states, UtcNow);
|
||||
|
||||
Assert.Equal(UntisHubDueState.Overdue, rows.Single(r => r.Kind == UntisHubJobKind.FehlzeitenKurz).DueState);
|
||||
Assert.Equal(UntisHubDueState.Ok, rows.Single(r => r.Kind == UntisHubJobKind.FehlzeitenLang).DueState);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildRows_GruppeOhneWebUntisLessonId_WirdVonAufrufseiteAusgeschlossen()
|
||||
{
|
||||
// GetRows() filtert vorab auf WebUntisLessonId != null (siehe UntisHubService.GetRows) -
|
||||
// BuildRows selbst bekommt bereits nur die eligible-Gruppen übergeben.
|
||||
var eligible = new List<LearningGroup> { Group("9a", 42) };
|
||||
|
||||
var rows = UntisHubService.BuildRows(eligible, [], UtcNow);
|
||||
|
||||
Assert.Equal(2, rows.Count(r => r.GroupId != null));
|
||||
}
|
||||
}
|
||||
@@ -61,7 +61,7 @@ public sealed class UntisSyncServiceTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ProcessIcsText_AbweichendesFach_SchreibtVertretungUndAktualisiertBeiWiederholung()
|
||||
public void ProcessIcsText_AbweichendesFach_SchreibtVertretungUndUeberspringtUnveraenderteWiederholung()
|
||||
{
|
||||
var mappings = new FakeUntisSlotMappings();
|
||||
mappings.Add(new UntisSlotMapping
|
||||
@@ -81,7 +81,7 @@ public sealed class UntisSyncServiceTests
|
||||
// Eintrag erzeugen - Idempotenz über SubstitutionEntry.ExternalId.
|
||||
var second = service.ProcessIcsText(BuildIcs("1", "20260817T075000", "NAT", "10c HED"));
|
||||
|
||||
Assert.Equal(1, second.SubstitutionCount);
|
||||
Assert.Equal(0, second.SubstitutionCount);
|
||||
Assert.Single(substitutions.GetAll());
|
||||
}
|
||||
|
||||
@@ -278,7 +278,7 @@ public sealed class UntisSyncServiceTests
|
||||
// Erneuter Poll mit demselben, weiterhin unverändert vorhandenen Termin darf keinen
|
||||
// zweiten Eintrag erzeugen (Idempotenz über ExternalId=Uid).
|
||||
var second = service.ProcessIcsText(BuildIcs("v1", dtstart, null, "HED"));
|
||||
Assert.Equal(1, second.SubstitutionCount);
|
||||
Assert.Equal(0, second.SubstitutionCount);
|
||||
Assert.Single(substitutions.GetAll());
|
||||
}
|
||||
|
||||
|
||||
@@ -572,6 +572,51 @@ public sealed class TimeTrackingViewModelTests
|
||||
|
||||
Assert.False(called);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MissingTeachingTime_VergangenerUnterrichtstag_WirdInZeiterfassungAngezeigt()
|
||||
{
|
||||
var pastDay = DateOnly.FromDateTime(DateTime.Today).AddDays(-1);
|
||||
var slots = new FakeTimetableSlots();
|
||||
slots.Add(new TimetableSlot { Weekday = pastDay.DayOfWeek, PeriodNumber = 1 });
|
||||
var periodSchedule = TestSupport.BuildPeriodScheduleService();
|
||||
periodSchedule.SetPeriods([new PeriodTimeEntry
|
||||
{ PeriodNumber = 1, Start = new TimeOnly(8, 0), End = new TimeOnly(8, 45) }]);
|
||||
|
||||
var vm = new TimeTrackingViewModel(
|
||||
new FakeTimeEntries(), new FakeWorkTasks(), slots, periodSchedule);
|
||||
|
||||
var gap = Assert.Single(vm.MissingTeachingTimeEntries, i => i.Date == pastDay);
|
||||
Assert.Equal(new TimeOnly(7, 45), gap.WindowStart);
|
||||
Assert.Equal(new TimeOnly(8, 55), gap.WindowEnd);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AddMissingTeachingTime_SpeichertVorgefuelltenTagUndEntferntIhnAusOffenerListe()
|
||||
{
|
||||
var pastDay = DateOnly.FromDateTime(DateTime.Today).AddDays(-1);
|
||||
var slots = new FakeTimetableSlots();
|
||||
slots.Add(new TimetableSlot { Weekday = pastDay.DayOfWeek, PeriodNumber = 1 });
|
||||
var periodSchedule = TestSupport.BuildPeriodScheduleService();
|
||||
periodSchedule.SetPeriods([new PeriodTimeEntry
|
||||
{ PeriodNumber = 1, Start = new TimeOnly(8, 0), End = new TimeOnly(8, 45) }]);
|
||||
var entries = new FakeTimeEntries();
|
||||
var vm = new TimeTrackingViewModel(entries, new FakeWorkTasks(), slots, periodSchedule);
|
||||
var gap = Assert.Single(vm.MissingTeachingTimeEntries, i => i.Date == pastDay);
|
||||
vm.OnAddMissingTeachingTime = item => Task.FromResult<TimeEntry?>(new TimeEntry
|
||||
{
|
||||
Date = item.Date,
|
||||
Category = TaskCategoryDisplay.Label(TaskCategory.Teaching),
|
||||
StartTime = item.WindowStart,
|
||||
EndTime = item.WindowEnd,
|
||||
DurationMinutes = (int)(item.WindowEnd - item.WindowStart).TotalMinutes,
|
||||
});
|
||||
|
||||
await vm.AddMissingTeachingTimeCommand.ExecuteAsync(gap);
|
||||
|
||||
Assert.Single(entries.GetByDate(pastDay));
|
||||
Assert.DoesNotContain(vm.MissingTeachingTimeEntries, i => i.Date == pastDay);
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class AddTimeEntryDialogViewModelTests
|
||||
|
||||
@@ -16,6 +16,8 @@
|
||||
<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>
|
||||
<!-- Klassenbuch mit Ausrufezeichen für noch offene Einträge. -->
|
||||
<StreamGeometry x:Key="IconOpenPeriods">M5,2 H19 A2,2 0 0 1 21,4 V20 A2,2 0 0 1 19,22 H5 A2,2 0 0 1 3,20 V4 A2,2 0 0 1 5,2 Z M7,4 V20 H19 V4 Z M11,7 H14 V13 H11 Z M11,15 H14 V18 H11 Z</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>
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Controls.ApplicationLifetimes;
|
||||
using Avalonia.Markup.Xaml;
|
||||
using Avalonia.Styling;
|
||||
using Avalonia.Threading;
|
||||
using LehrerApp.Core.Interfaces;
|
||||
using LehrerApp.Core.Models;
|
||||
using LehrerApp.Core.Services;
|
||||
@@ -37,6 +39,21 @@ public class App : Application
|
||||
ApplyTheme(new AppearanceSettingsService(AppBootstrapper.ResolveAppDataPath()).Load());
|
||||
|
||||
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
|
||||
{
|
||||
// Erst den Splashscreen anzeigen und die synchrone Initialisierung in den ersten
|
||||
// Dispatcher-Durchlauf verschieben, damit das Ladebild vorher gezeichnet wird.
|
||||
var splash = new SplashWindow();
|
||||
desktop.MainWindow = splash;
|
||||
Dispatcher.UIThread.Post(
|
||||
() => ContinueStartup(desktop, splash),
|
||||
DispatcherPriority.Background);
|
||||
}
|
||||
|
||||
base.OnFrameworkInitializationCompleted();
|
||||
}
|
||||
|
||||
private static async void ContinueStartup(
|
||||
IClassicDesktopStyleApplicationLifetime desktop, SplashWindow splash)
|
||||
{
|
||||
// Verschlüsselte Datenbank (13.3.4): Passwort abfragen, bevor die Datenbank
|
||||
// (und damit BuildServices, das sie öffnet) angefasst wird.
|
||||
@@ -45,25 +62,22 @@ public class App : Application
|
||||
var promptVm = new DbPasswordPromptViewModel(
|
||||
new DatabaseEncryptionService(), AppBootstrapper.ResolveDbPath());
|
||||
var promptWindow = new DbPasswordPromptWindow { DataContext = promptVm };
|
||||
promptVm.OnUnlocked = password =>
|
||||
promptVm.OnUnlocked = async password =>
|
||||
{
|
||||
AppBootstrapper.DbPassword = password;
|
||||
StartMainApp(desktop, showImmediately: true);
|
||||
promptWindow.Close();
|
||||
await StartMainAppAsync(desktop, promptWindow);
|
||||
};
|
||||
desktop.MainWindow = promptWindow;
|
||||
}
|
||||
else
|
||||
{
|
||||
StartMainApp(desktop, showImmediately: false);
|
||||
}
|
||||
promptWindow.Show();
|
||||
splash.Close();
|
||||
return;
|
||||
}
|
||||
|
||||
base.OnFrameworkInitializationCompleted();
|
||||
await StartMainAppAsync(desktop, splash);
|
||||
}
|
||||
|
||||
private static void StartMainApp(
|
||||
IClassicDesktopStyleApplicationLifetime desktop, bool showImmediately)
|
||||
private static async Task StartMainAppAsync(
|
||||
IClassicDesktopStyleApplicationLifetime desktop, Window? windowToClose = null)
|
||||
{
|
||||
_serviceProvider = AppBootstrapper.BuildServices();
|
||||
Services = _serviceProvider;
|
||||
@@ -83,20 +97,24 @@ public class App : Application
|
||||
var mainVm = Services.GetRequiredService<MainWindowViewModel>();
|
||||
WireCallbacks(mainVm);
|
||||
|
||||
// DI-Singletons werden erst beim ersten Auflösen erzeugt. Ohne dieses explizite
|
||||
// Auflösen entstand der Timer des optionalen WebUntis-Abgleichs erst, wenn die
|
||||
// Einstellungen geöffnet oder ein manueller Abruf gestartet wurde. Den Dienst beim
|
||||
// Anwendungsstart aktivieren und wie den Jahresplan sofort einmal abgleichen; danach
|
||||
// übernimmt sein stündlicher Timer.
|
||||
// Beide optionalen Erstabgleiche noch unter dem Splashscreen abschließen. Ihre
|
||||
// CPU-/Datenbankarbeit läuft innerhalb der Dienste im Threadpool; dadurch öffnet das
|
||||
// Hauptfenster mit fertigem Datenstand und friert nicht kurz danach ein. Das Auflösen
|
||||
// aktiviert zugleich die periodischen Timer.
|
||||
var initialPolls = new List<Task>();
|
||||
if (Services.GetService<UntisSyncService>() is { } untisSync)
|
||||
_ = untisSync.PollAsync();
|
||||
initialPolls.Add(untisSync.PollAsync());
|
||||
if (Services.GetService<AnnualPlanSyncService>() is { } annualPlanSync)
|
||||
initialPolls.Add(annualPlanSync.PollAsync());
|
||||
await Task.WhenAll(initialPolls);
|
||||
|
||||
var main = new MainWindow { DataContext = mainVm };
|
||||
main.EnableWindowSizePersistence(Services.GetRequiredService<WindowSettingsService>());
|
||||
if (Services.GetService<SyncEngine>() is { } syncEngine)
|
||||
main.EnableFinalSync(syncEngine);
|
||||
desktop.MainWindow = main;
|
||||
if (showImmediately) main.Show();
|
||||
main.Show();
|
||||
windowToClose?.Close();
|
||||
}
|
||||
|
||||
/// <summary>Wechselt die Darstellung sofort, ohne Neustart (12.4) — Avalonia stylt den
|
||||
|
||||
@@ -162,6 +162,7 @@ public static class AppBootstrapper
|
||||
services.AddSingleton<IUnitRepository, UnitRepository>();
|
||||
services.AddSingleton<ILessonRepository, LessonRepository>();
|
||||
services.AddSingleton<IDocumentationRepository, DocumentationRepository>();
|
||||
services.AddSingleton<IVorgangRepository, VorgangRepository>();
|
||||
services.AddSingleton<IWorkTaskRepository, WorkTaskRepository>();
|
||||
services.AddSingleton<ITimeEntryRepository, TimeEntryRepository>();
|
||||
services.AddSingleton<IParticipationSessionRepository, ParticipationSessionRepository>();
|
||||
@@ -184,6 +185,7 @@ public static class AppBootstrapper
|
||||
services.AddSingleton<IUntisClassRegisterCacheRepository, UntisClassRegisterCacheRepository>();
|
||||
services.AddSingleton<IUntisCacheFetchStateRepository, UntisCacheFetchStateRepository>();
|
||||
services.AddSingleton<IUntisStudentRosterCacheRepository, UntisStudentRosterCacheRepository>();
|
||||
services.AddSingleton<IUntisHubJobStateRepository, UntisHubJobStateRepository>();
|
||||
|
||||
// ── Services ──────────────────────────────────────────────────────────
|
||||
services.AddSingleton<GradingService>();
|
||||
@@ -195,13 +197,20 @@ public static class AppBootstrapper
|
||||
services.AddSingleton(_ => new PeriodScheduleService(appData));
|
||||
services.AddSingleton(_ => new WorkloadSettingsService(appData));
|
||||
services.AddSingleton(_ => new DashboardSettingsService(appData));
|
||||
services.AddSingleton(_ => new PatternScoreSettingsService(appData));
|
||||
services.AddSingleton(_ => new WindowSettingsService(appData));
|
||||
services.AddSingleton(_ => new AppearanceSettingsService(appData));
|
||||
services.AddSingleton<PlanningExchangeService>();
|
||||
|
||||
// ── KI-Unterstützung (4.5.9, optional – nur wenn in den Einstellungen aktiviert) ──────
|
||||
services.AddSingleton(_ => new AiSettingsService(appData));
|
||||
services.AddSingleton(_ => new HttpClient { BaseAddress = new Uri(AiBackendUrl) });
|
||||
services.AddSingleton(_ => new HttpClient
|
||||
{
|
||||
BaseAddress = new Uri(AiBackendUrl),
|
||||
// Das PHP-Backend wartet höchstens 180 s auf umfangreiche KI-Antworten. Der Client
|
||||
// bleibt bewusst etwas länger offen, damit dessen konkrete Fehlermeldung noch ankommt.
|
||||
Timeout = TimeSpan.FromSeconds(210),
|
||||
});
|
||||
services.AddSingleton<AiPlanningService>();
|
||||
|
||||
// ── WebUntis-iCal-Abgleich (optional – nur wenn URL hinterlegt und aktiviert) ─────────
|
||||
@@ -240,6 +249,7 @@ public static class AppBootstrapper
|
||||
services.AddSingleton(sp => new SchoolWeatherService(new HttpClient(), syncSettings));
|
||||
services.AddSingleton(sp => new WebUntisIntegrationService(new HttpClient(), untisSettings));
|
||||
services.AddSingleton<UntisReportCacheService>();
|
||||
services.AddSingleton<UntisHubService>();
|
||||
// War dieses Gerät schon eingeloggt, aber sync.key fehlt(e), wurde gerade eben (unten)
|
||||
// stillschweigend ein neuer, unabhängiger Schlüssel erzeugt - bisher unter dem ALTEN
|
||||
// Schlüssel synchronisierte Server-Daten sind für dieses Gerät dann nicht mehr lesbar.
|
||||
@@ -315,6 +325,7 @@ public static class AppBootstrapper
|
||||
services.AddSingleton<WorkloadEvaluationViewModel>();
|
||||
services.AddSingleton<WorkloadViewModel>();
|
||||
services.AddSingleton<ClassTeacherDetailsViewModel>();
|
||||
services.AddSingleton<ClassTeacherCasesViewModel>();
|
||||
services.AddSingleton<ClassTeacherOverviewViewModel>();
|
||||
services.AddSingleton<ExamsOverviewViewModel>();
|
||||
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 2.2 MiB |
@@ -0,0 +1,22 @@
|
||||
using System;
|
||||
using System.Globalization;
|
||||
using Avalonia.Data.Converters;
|
||||
|
||||
namespace LehrerApp.Desktop.Converters;
|
||||
|
||||
/// <summary>`CalendarDatePicker.SelectedDate` ist (anders als der eingebaute `DatePicker`) vom
|
||||
/// WPF-Toolkit übernommenes `DateTime?`, während Datumsfelder in dieser Codebasis durchgängig
|
||||
/// `DateTimeOffset`(?) sind. Avalonia bietet dafür keine automatische Konvertierung
|
||||
/// (`TypeUtilities.TryConvert`/`TryConvertImplicit` unterstützen `DateTime`↔`DateTimeOffset`
|
||||
/// nicht) — ohne diesen Converter bleibt die Bindung beim Anzeigen stumm leer und wirft beim
|
||||
/// Zurückschreiben eine `InvalidCastException`.</summary>
|
||||
public sealed class DateTimeOffsetToDateTimeConverter : IValueConverter
|
||||
{
|
||||
public static readonly DateTimeOffsetToDateTimeConverter Instance = new();
|
||||
|
||||
public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture) =>
|
||||
value is DateTimeOffset offset ? offset.DateTime : null;
|
||||
|
||||
public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) =>
|
||||
value is DateTime dateTime ? new DateTimeOffset(DateTime.SpecifyKind(dateTime, DateTimeKind.Local)) : null;
|
||||
}
|
||||
@@ -10,7 +10,14 @@ using System.Text.Json.Serialization;
|
||||
namespace LehrerApp.Desktop.Services;
|
||||
|
||||
/// <summary>Fehler beim Aufruf des KI-Backends, Message ist bereits deutsch und nutzergerichtet.</summary>
|
||||
public class AiBackendException(string userMessage) : Exception(userMessage);
|
||||
public class AiBackendException(string userMessage, string? rawResponse = null) : Exception(userMessage)
|
||||
{
|
||||
/// <summary>
|
||||
/// Unveränderte Modellantwort, sofern der Fehler beim Lesen einer Planungsantwort entstand.
|
||||
/// Sie wird ausschließlich für den ausdrücklich vom Nutzer gestarteten Rettungsdialog gehalten.
|
||||
/// </summary>
|
||||
public string? RawResponse { get; } = rawResponse;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Der KI-Wire-Vertrag verwendet deutsches Datumsformat (siehe ai-backend/plan.php Systemprompt,
|
||||
@@ -87,16 +94,19 @@ public class AiPlanningService(HttpClient http, ILessonRepository lessons,
|
||||
private static async Task<AiBackendException> BuildRequestFailedExceptionAsync(HttpResponseMessage resp)
|
||||
{
|
||||
string? backendReason = null;
|
||||
string? rawResponse = null;
|
||||
try
|
||||
{
|
||||
var body = await resp.Content.ReadFromJsonAsync<BackendErrorResult>(JsonOptions);
|
||||
var responseText = await resp.Content.ReadAsStringAsync();
|
||||
var body = JsonSerializer.Deserialize<BackendErrorResult>(responseText, JsonOptions);
|
||||
backendReason = string.IsNullOrWhiteSpace(body?.Error) ? null : body!.Error;
|
||||
rawResponse = string.IsNullOrWhiteSpace(body?.RawResponse) ? null : body!.RawResponse;
|
||||
}
|
||||
catch { /* Antwortkörper war kein valides {"error": "..."}-JSON - Fallback unten greift. */ }
|
||||
|
||||
return new AiBackendException(backendReason is null
|
||||
? "Die Anfrage an den KI-Dienst ist fehlgeschlagen."
|
||||
: $"Die Anfrage an den KI-Dienst ist fehlgeschlagen: {backendReason}");
|
||||
: $"Die Anfrage an den KI-Dienst ist fehlgeschlagen: {backendReason}", rawResponse);
|
||||
}
|
||||
|
||||
public async Task<string> LoginAsync(string username, string password)
|
||||
@@ -340,6 +350,12 @@ public class AiPlanningService(HttpClient http, ILessonRepository lessons,
|
||||
|
||||
HttpResponseMessage resp;
|
||||
try { resp = await http.SendAsync(req); }
|
||||
catch (TaskCanceledException)
|
||||
{
|
||||
throw new AiBackendException(
|
||||
"Die KI-Anfrage hat länger als 3½ Minuten gedauert und wurde beendet. " +
|
||||
"Falls das wiederholt passiert, bitte das Zeitlimit des Webserver-Proxys prüfen.");
|
||||
}
|
||||
catch (HttpRequestException)
|
||||
{
|
||||
throw new AiBackendException("Der KI-Dienst ist nicht erreichbar. Bitte Internetverbindung prüfen.");
|
||||
@@ -352,14 +368,64 @@ public class AiPlanningService(HttpClient http, ILessonRepository lessons,
|
||||
if (!resp.IsSuccessStatusCode)
|
||||
throw await BuildRequestFailedExceptionAsync(resp);
|
||||
|
||||
var responseText = await resp.Content.ReadAsStringAsync();
|
||||
try
|
||||
{
|
||||
var result = await resp.Content.ReadFromJsonAsync<AiPlanningResponse>(JsonOptions);
|
||||
var result = JsonSerializer.Deserialize<AiPlanningResponse>(responseText, JsonOptions);
|
||||
return result ?? throw new AiBackendException("Die Antwort der KI konnte nicht verarbeitet werden.");
|
||||
}
|
||||
catch (Exception ex) when (ex is not AiBackendException)
|
||||
{
|
||||
throw new AiBackendException("Die Antwort der KI konnte nicht verarbeitet werden. Bitte erneut versuchen.");
|
||||
string? rawModelResponse = null;
|
||||
try
|
||||
{
|
||||
using var envelope = JsonDocument.Parse(responseText);
|
||||
if (envelope.RootElement.TryGetProperty("rawResponse", out var rawElement)
|
||||
&& rawElement.ValueKind == JsonValueKind.String)
|
||||
rawModelResponse = rawElement.GetString();
|
||||
}
|
||||
catch (JsonException) { /* Die HTTP-Antwort selbst war ungültig; unten komplett zeigen. */ }
|
||||
|
||||
throw new AiBackendException(
|
||||
"Die Antwort der KI konnte nicht verarbeitet werden. Du kannst die Antwort manuell retten.",
|
||||
string.IsNullOrWhiteSpace(rawModelResponse) ? responseText : rawModelResponse);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Liest eine von Hand markierte Modellantwort. Akzeptiert die normale Antwort-Hülle, ein
|
||||
/// einzelnes Lesson-Objekt oder ein Array von Lessons, damit auch nur der relevante Ausschnitt
|
||||
/// der Rohantwort markiert werden kann.
|
||||
/// </summary>
|
||||
public static List<AiLesson> ParsePlanningLessons(string json)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(json))
|
||||
throw new AiBackendException("Bitte markiere zuerst den JSON-Abschnitt der Antwort.");
|
||||
|
||||
try
|
||||
{
|
||||
using var document = JsonDocument.Parse(json);
|
||||
var root = document.RootElement;
|
||||
List<AiLesson>? parsed = root.ValueKind switch
|
||||
{
|
||||
JsonValueKind.Object when root.TryGetProperty("lessons", out _) =>
|
||||
JsonSerializer.Deserialize<AiPlanningResponse>(json, JsonOptions)?.Lessons,
|
||||
JsonValueKind.Object =>
|
||||
[JsonSerializer.Deserialize<AiLesson>(json, JsonOptions)
|
||||
?? throw new JsonException("Leere Stunde")],
|
||||
JsonValueKind.Array => JsonSerializer.Deserialize<List<AiLesson>>(json, JsonOptions),
|
||||
_ => null,
|
||||
};
|
||||
|
||||
if (parsed is null || parsed.Count == 0)
|
||||
throw new JsonException("Keine Stunde enthalten");
|
||||
if (parsed.Any(l => string.IsNullOrWhiteSpace(l.Topic)))
|
||||
throw new JsonException("Mindestens einer Stunde fehlt das Feld topic");
|
||||
return parsed;
|
||||
}
|
||||
catch (JsonException ex)
|
||||
{
|
||||
throw new AiBackendException($"Der markierte Text ist noch kein gültiges Stunden-JSON: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -541,5 +607,9 @@ public class AiPlanningService(HttpClient http, ILessonRepository lessons,
|
||||
|
||||
private class LoginResult { public string Token { get; set; } = ""; }
|
||||
private class BalanceResult { public decimal BalanceUsd { get; set; } }
|
||||
private class BackendErrorResult { public string? Error { get; set; } }
|
||||
private class BackendErrorResult
|
||||
{
|
||||
public string? Error { get; set; }
|
||||
public string? RawResponse { get; set; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,7 +38,7 @@ public sealed class AnnualPlanSyncService : IDisposable
|
||||
|
||||
public async Task PollAsync()
|
||||
{
|
||||
if (!await _gate.WaitAsync(0)) return;
|
||||
if (!await _gate.WaitAsync(0).ConfigureAwait(false)) return;
|
||||
try
|
||||
{
|
||||
var url = _settings.GetIcalUrl();
|
||||
@@ -47,7 +47,7 @@ public sealed class AnnualPlanSyncService : IDisposable
|
||||
string icsText;
|
||||
try
|
||||
{
|
||||
icsText = await _http.GetStringAsync(url);
|
||||
icsText = await _http.GetStringAsync(url).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -59,7 +59,7 @@ public sealed class AnnualPlanSyncService : IDisposable
|
||||
AnnualPlanPollResult result;
|
||||
try
|
||||
{
|
||||
result = ProcessIcsText(icsText);
|
||||
result = await Task.Run(() => ProcessIcsText(icsText)).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
using Avalonia.Controls;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using LehrerApp.Core.Interfaces;
|
||||
using LehrerApp.Core.Models;
|
||||
using LehrerApp.Core.Services;
|
||||
using LehrerApp.Desktop.ViewModels.Groups;
|
||||
using LehrerApp.Desktop.ViewModels.Students;
|
||||
using LehrerApp.Desktop.Views;
|
||||
using LehrerApp.Desktop.Views.Groups;
|
||||
using LehrerApp.Desktop.Views.Students;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace LehrerApp.Desktop.Services;
|
||||
|
||||
/// <summary>Führt einen der vier bestehenden WebUntis-Abgleiche (unverändert, über ihre bestehenden
|
||||
/// Dialoge) aus und vermerkt das Ergebnis im <see cref="UntisHubService"/> - genutzt sowohl vom
|
||||
/// Untis-Hub-Dialog ("Jetzt prüfen" je Zeile) als auch von den WebUntis-Menüpunkten in
|
||||
/// <c>MainWindow</c>, damit beide Einstiegspunkte denselben Fälligkeitsstand pflegen.</summary>
|
||||
public static class UntisHubActions
|
||||
{
|
||||
public static async Task RunFehlzeitenAsync(Window owner, LearningGroup group, UntisHubJobKind kind,
|
||||
DateOnly start, DateOnly end, UntisHubService hub)
|
||||
{
|
||||
var vm = new WebUntisLessonAbsenceComparisonViewModel(group,
|
||||
App.Services.GetRequiredService<WebUntisIntegrationService>(),
|
||||
App.Services.GetRequiredService<IStudentRepository>(),
|
||||
App.Services.GetRequiredService<IParticipationSessionRepository>(),
|
||||
App.Services.GetRequiredService<IParticipationRepository>())
|
||||
{ StartDate = start.ToDateTime(TimeOnly.MinValue), EndDate = end.ToDateTime(TimeOnly.MinValue) };
|
||||
var loaded = TrackLoad(vm, v => v.Busy);
|
||||
await new WebUntisLessonAbsenceComparisonDialog { DataContext = vm }.ShowDialog(owner);
|
||||
if (loaded()) hub.RecordRun(kind, group.Id, vm.Status);
|
||||
}
|
||||
|
||||
public static async Task RunKlassenbuchAsync(Window owner, UntisHubService hub)
|
||||
{
|
||||
var vm = new WebUntisDocumentationComparisonViewModel(
|
||||
App.Services.GetRequiredService<WebUntisIntegrationService>(),
|
||||
App.Services.GetRequiredService<IStudentRepository>(),
|
||||
App.Services.GetRequiredService<IGroupRepository>(),
|
||||
App.Services.GetRequiredService<IDocumentationRepository>(),
|
||||
App.Services.GetRequiredService<SchoolYearService>());
|
||||
var loaded = TrackLoad(vm, v => v.Busy);
|
||||
await new WebUntisDocumentationComparisonDialog { DataContext = vm }.ShowDialog(owner);
|
||||
if (loaded()) hub.RecordRun(UntisHubJobKind.Klassenbuchabgleich, null, vm.Status);
|
||||
}
|
||||
|
||||
public static async Task RunHausaufgabenAsync(Window owner, UntisHubService hub)
|
||||
{
|
||||
var vm = new WebUntisHomeworkComparisonViewModel(
|
||||
App.Services.GetRequiredService<WebUntisIntegrationService>(),
|
||||
App.Services.GetRequiredService<IStudentRepository>(),
|
||||
App.Services.GetRequiredService<IGroupRepository>(),
|
||||
App.Services.GetRequiredService<ISubjectRepository>(),
|
||||
App.Services.GetRequiredService<IGroupMembershipRepository>(),
|
||||
App.Services.GetRequiredService<IParticipationSessionRepository>(),
|
||||
App.Services.GetRequiredService<IParticipationRepository>(),
|
||||
App.Services.GetRequiredService<SchoolYearService>());
|
||||
var loaded = TrackLoad(vm, v => v.Busy);
|
||||
await new WebUntisHomeworkComparisonDialog { DataContext = vm }.ShowDialog(owner);
|
||||
if (loaded()) hub.RecordRun(UntisHubJobKind.Hausaufgabenabgleich, null, vm.Status);
|
||||
}
|
||||
|
||||
public static async Task RunOffenePeriodsAsync(Window owner, UntisHubService hub)
|
||||
{
|
||||
var dialog = new OpenUntisPeriodsDialog();
|
||||
await dialog.ShowDialog(owner);
|
||||
hub.RecordRun(UntisHubJobKind.OffenePeriods, null, dialog.LastStatus);
|
||||
}
|
||||
|
||||
/// <summary>Erkennt nicht-invasiv (ohne die Vergleichs-ViewModels zu ändern), ob im Dialog
|
||||
/// tatsächlich ein Ladeversuch stattfand: <c>Busy</c> wechselt in <c>Load()</c> immer erst auf
|
||||
/// true und im <c>finally</c>-Block zurück auf false, egal ob erfolgreich oder mit Fehler
|
||||
/// abgebrochen - genau dieser Übergang wird hier beobachtet.</summary>
|
||||
private static Func<bool> TrackLoad<T>(T vm, Func<T, bool> isBusy) where T : ObservableObject
|
||||
{
|
||||
var loaded = false;
|
||||
var wasBusy = false;
|
||||
vm.PropertyChanged += (_, e) =>
|
||||
{
|
||||
if (e.PropertyName != "Busy") return;
|
||||
var busy = isBusy(vm);
|
||||
if (wasBusy && !busy) loaded = true;
|
||||
wasBusy = busy;
|
||||
};
|
||||
return () => loaded;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
using LehrerApp.Core.Interfaces;
|
||||
using LehrerApp.Core.Models;
|
||||
using LehrerApp.Core.Services;
|
||||
|
||||
namespace LehrerApp.Desktop.Services;
|
||||
|
||||
public enum UntisHubDueState { Ok, Due, Overdue }
|
||||
|
||||
/// <summary>Eine Zeile im Untis-Hub: eine Job-Instanz (Fehlzeiten-Kadenz je Lerngruppe, oder einer
|
||||
/// der drei dashboard-weiten Jobs) mit ihrer aktuellen Fälligkeit.</summary>
|
||||
public sealed record UntisHubJobRow(
|
||||
UntisHubJobKind Kind, Guid? GroupId, string GroupName,
|
||||
DateTime? LastRunAt, string? LastResultSummary, UntisHubDueState DueState, string DueLabel);
|
||||
|
||||
/// <summary>
|
||||
/// Zeigt, welche der bestehenden, rein manuell ausgelösten WebUntis-Abgleiche (Fehlzeiten pro
|
||||
/// Lerngruppe, offene Stunden, Klassenbuch-/Hausaufgabenabgleich) fällig sind - ohne selbst
|
||||
/// WebUntis anzufragen (Nutzer-Feedback: "nicht bei WebUntis auffallen", siehe
|
||||
/// <see cref="UntisReportCacheService"/>). Die eigentlichen Abgleiche laufen weiterhin über die
|
||||
/// bestehenden Vergleichsdialoge (siehe <see cref="UntisHubActions"/>); dieser Service verwaltet
|
||||
/// nur die Fälligkeits-Zeitstempel dazu.
|
||||
/// </summary>
|
||||
public sealed class UntisHubService(
|
||||
IGroupRepository groups, IUntisHubJobStateRepository jobStates, SchoolYearService schoolYears)
|
||||
{
|
||||
private static readonly TimeSpan FehlzeitenKurzDue = TimeSpan.FromDays(14);
|
||||
private static readonly TimeSpan FehlzeitenKurzOverdue = TimeSpan.FromDays(21);
|
||||
private static readonly TimeSpan FehlzeitenLangDue = TimeSpan.FromDays(60);
|
||||
private static readonly TimeSpan FehlzeitenLangOverdue = TimeSpan.FromDays(90);
|
||||
private static readonly TimeSpan GlobalDue = TimeSpan.FromDays(14);
|
||||
private static readonly TimeSpan GlobalOverdue = TimeSpan.FromDays(21);
|
||||
|
||||
private static readonly (UntisHubJobKind Kind, string Label)[] GlobalJobs =
|
||||
[
|
||||
(UntisHubJobKind.OffenePeriods, "Offene Stunden"),
|
||||
(UntisHubJobKind.Klassenbuchabgleich, "Klassenbuchabgleich"),
|
||||
(UntisHubJobKind.Hausaufgabenabgleich, "Hausaufgabenabgleich"),
|
||||
];
|
||||
|
||||
public List<UntisHubJobRow> GetRows()
|
||||
{
|
||||
var eligibleGroups = groups.GetBySchoolYear(schoolYears.CurrentSchoolYear())
|
||||
.Where(g => g.WebUntisLessonId is not null)
|
||||
.OrderBy(g => g.Name)
|
||||
.ToList();
|
||||
return BuildRows(eligibleGroups, jobStates.GetAll(), DateTime.UtcNow);
|
||||
}
|
||||
|
||||
public void RecordRun(UntisHubJobKind kind, Guid? groupId, string? summary) =>
|
||||
jobStates.Save(new UntisHubJobState
|
||||
{
|
||||
Id = jobStates.Get(kind, groupId)?.Id ?? Guid.NewGuid(),
|
||||
Kind = kind, GroupId = groupId, LastRunAt = DateTime.UtcNow, LastResultSummary = summary,
|
||||
});
|
||||
|
||||
/// Reine Entscheidungslogik ohne Repository-Zugriff (gleiches Muster wie
|
||||
/// <see cref="UntisReportCacheService.Plan"/>): aus den fälligkeitsrelevanten Lerngruppen und den
|
||||
/// zuletzt gespeicherten Job-Zuständen wird die vollständige Hub-Zeilenliste gebaut - zwei
|
||||
/// Fehlzeiten-Zeilen je Gruppe plus die drei dashboard-weiten Zeilen.
|
||||
public static List<UntisHubJobRow> BuildRows(
|
||||
IReadOnlyList<LearningGroup> eligibleGroups, IReadOnlyList<UntisHubJobState> states, DateTime utcNow)
|
||||
{
|
||||
UntisHubJobState? State(UntisHubJobKind kind, Guid? groupId) =>
|
||||
states.FirstOrDefault(s => s.Kind == kind && s.GroupId == groupId);
|
||||
|
||||
var rows = new List<UntisHubJobRow>();
|
||||
foreach (var group in eligibleGroups)
|
||||
{
|
||||
rows.Add(Row(UntisHubJobKind.FehlzeitenKurz, group.Id, group.Name,
|
||||
State(UntisHubJobKind.FehlzeitenKurz, group.Id), FehlzeitenKurzDue, FehlzeitenKurzOverdue, utcNow));
|
||||
rows.Add(Row(UntisHubJobKind.FehlzeitenLang, group.Id, group.Name,
|
||||
State(UntisHubJobKind.FehlzeitenLang, group.Id), FehlzeitenLangDue, FehlzeitenLangOverdue, utcNow));
|
||||
}
|
||||
foreach (var (kind, label) in GlobalJobs)
|
||||
rows.Add(Row(kind, null, label, State(kind, null), GlobalDue, GlobalOverdue, utcNow));
|
||||
return rows;
|
||||
}
|
||||
|
||||
private static UntisHubJobRow Row(UntisHubJobKind kind, Guid? groupId, string groupName,
|
||||
UntisHubJobState? state, TimeSpan due, TimeSpan overdue, DateTime utcNow)
|
||||
{
|
||||
var (dueState, dueLabel) = DueStatus(state?.LastRunAt, due, overdue, utcNow);
|
||||
return new UntisHubJobRow(kind, groupId, groupName, state?.LastRunAt, state?.LastResultSummary,
|
||||
dueState, dueLabel);
|
||||
}
|
||||
|
||||
private static (UntisHubDueState, string) DueStatus(
|
||||
DateTime? lastRunAt, TimeSpan due, TimeSpan overdue, DateTime utcNow)
|
||||
{
|
||||
if (lastRunAt is null) return (UntisHubDueState.Overdue, "noch nie geprüft");
|
||||
var age = utcNow - lastRunAt.Value;
|
||||
var days = (int)age.TotalDays;
|
||||
if (age >= overdue) return (UntisHubDueState.Overdue, $"fällig seit {days} Tagen");
|
||||
if (age >= due) return (UntisHubDueState.Due, $"fällig seit {days} Tagen");
|
||||
return (UntisHubDueState.Ok, days == 0 ? "gerade eben geprüft" : $"vor {days} Tag(en) geprüft");
|
||||
}
|
||||
}
|
||||
@@ -67,7 +67,7 @@ public class UntisSyncService : IDisposable
|
||||
|
||||
public async Task PollAsync()
|
||||
{
|
||||
if (!await _gate.WaitAsync(0)) return;
|
||||
if (!await _gate.WaitAsync(0).ConfigureAwait(false)) return;
|
||||
try
|
||||
{
|
||||
var url = _settings.GetIcalUrl();
|
||||
@@ -76,7 +76,7 @@ public class UntisSyncService : IDisposable
|
||||
string icsText;
|
||||
try
|
||||
{
|
||||
icsText = await _http.GetStringAsync(url);
|
||||
icsText = await _http.GetStringAsync(url).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -88,7 +88,7 @@ public class UntisSyncService : IDisposable
|
||||
UntisPollResult result;
|
||||
try
|
||||
{
|
||||
result = ProcessIcsText(icsText);
|
||||
result = await Task.Run(() => ProcessIcsText(icsText)).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -118,11 +118,20 @@ public class UntisSyncService : IDisposable
|
||||
var diffResult = _diffService.Diff(events, previousSnapshot, confirmedMappings, today,
|
||||
existingSupervisionDuties: _supervisionDuties.GetAll(), freeDates: BuildFreeDates(today));
|
||||
|
||||
var savedSubstitutionCount = 0;
|
||||
foreach (var candidate in diffResult.SubstitutionsToSave)
|
||||
{
|
||||
var existing = candidate.ExternalId is not null ? _substitutions.GetByExternalId(candidate.ExternalId) : null;
|
||||
if (existing is not null) candidate.Id = existing.Id;
|
||||
// Der Diff liefert auch weiterhin bestehende WebUntis-Abweichungen als Kandidaten.
|
||||
// Ein unverändertes Upsert würde über Repository.OnChange bei jedem Poll erneut ein
|
||||
// Sync-Ereignis erzeugen und bei vielen aktiven Abweichungen das Protokoll fluten.
|
||||
if (existing is not null)
|
||||
{
|
||||
candidate.Id = existing.Id;
|
||||
if (SubstitutionContentEquals(existing, candidate)) continue;
|
||||
}
|
||||
_substitutions.Save(candidate);
|
||||
savedSubstitutionCount++;
|
||||
}
|
||||
// Zuvor automatisch erzeugte Einträge, die jetzt nicht (mehr) gebraucht werden (siehe
|
||||
// UntisDiffResult.SubstitutionExternalIdsToDelete) - existiert keiner mit dieser
|
||||
@@ -135,9 +144,17 @@ public class UntisSyncService : IDisposable
|
||||
foreach (var snapshot in diffResult.SnapshotToSave) _snapshots.Save(snapshot);
|
||||
foreach (var id in diffResult.SnapshotIdsToDelete) _snapshots.Delete(id);
|
||||
|
||||
return new UntisPollResult(events.Count, diffResult.SubstitutionsToSave.Count);
|
||||
return new UntisPollResult(events.Count, savedSubstitutionCount);
|
||||
}
|
||||
|
||||
private static bool SubstitutionContentEquals(SubstitutionEntry left, SubstitutionEntry right) =>
|
||||
left.Date == right.Date && left.Kind == right.Kind &&
|
||||
left.PeriodNumber == right.PeriodNumber && left.AfterPeriod == right.AfterPeriod &&
|
||||
left.FromPeriod == right.FromPeriod && left.ToPeriod == right.ToPeriod &&
|
||||
left.IsAllDay == right.IsAllDay && left.GroupId == right.GroupId &&
|
||||
left.GroupLabel == right.GroupLabel && left.Description == right.Description &&
|
||||
left.Notes == right.Notes && left.ExternalId == right.ExternalId;
|
||||
|
||||
// Ferien-/Feiertagstage im relevanten Zeitfenster (deutlich über das Lookahead-Fenster
|
||||
// hinaus, kostet bei kleinen Ferienlisten nichts) - verhindert, dass die aktive
|
||||
// "fehlt komplett im Feed"-Prüfung in UntisDiffService Ferientage fälschlich als Ausfall
|
||||
|
||||
@@ -205,6 +205,13 @@ public sealed class WebUntisIntegrationService(HttpClient http, WebUntisSettings
|
||||
};
|
||||
}
|
||||
|
||||
public Task<UntisOpenPeriodsMeta> GetOpenPeriodsMetaAsync(int schoolYearId, CancellationToken token = default) =>
|
||||
ExecuteAsync(client => client.GetOpenPeriodsMetaAsync(schoolYearId, token), token);
|
||||
|
||||
public Task<IReadOnlyList<UntisOpenPeriod>> GetOpenPeriodsAsync(int schoolYearId, int? teacherId,
|
||||
int? classId, DateOnly start, DateOnly end, CancellationToken token = default) =>
|
||||
ExecuteAsync(client => client.GetOpenPeriodsAsync(schoolYearId, teacherId, classId, start, end, token), token);
|
||||
|
||||
private async Task<T> ExecuteAsync<T>(Func<WebUntisClient, Task<T>> operation, CancellationToken token)
|
||||
{
|
||||
try { return await operation(await GetClientAsync(token)); }
|
||||
|
||||
@@ -0,0 +1,249 @@
|
||||
using System.Collections.ObjectModel;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using LehrerApp.Core.Interfaces;
|
||||
using LehrerApp.Core.Models;
|
||||
using LehrerApp.Desktop.Services;
|
||||
using LehrerApp.Desktop.ViewModels.Students;
|
||||
|
||||
namespace LehrerApp.Desktop.ViewModels.ClassTeacher;
|
||||
|
||||
/// <summary>
|
||||
/// "Vorgänge"-Tab der Klassenlehreransicht: Fallmappen für konkrete, laufende Probleme mit einer/
|
||||
/// einem oder mehreren Schüler*innen der Klasse, die Dokumentation und (eingefrorene) WebUntis-
|
||||
/// Klassenbucheinträge bündeln. Löst wie <see cref="ClassTeacherDetailsViewModel"/> die Untis-
|
||||
/// Roster-Namen der Klasse auf lokale <see cref="Student"/>-Datensätze auf (bewusst ein eigener,
|
||||
/// nicht geteilter Abgleich — dasselbe kleine Muster steckt schon zweimal im Code, siehe
|
||||
/// <see cref="ClassTeacherOverviewViewModel.MatchStudent"/>, eine gemeinsame Abstraktion dafür ist
|
||||
/// hier nicht Teil der Aufgabe).
|
||||
/// </summary>
|
||||
public partial class ClassTeacherCasesViewModel : ObservableObject
|
||||
{
|
||||
private readonly IVorgangRepository _vorgaenge;
|
||||
private readonly IDocumentationRepository _documentation;
|
||||
private readonly IStudentRepository _students;
|
||||
private readonly UntisReportCacheService _cache;
|
||||
|
||||
private string _className = "";
|
||||
private List<StudentOption> _rosterStudentOptions = [];
|
||||
|
||||
public ObservableCollection<VorgangItem> Cases { get; } = [];
|
||||
|
||||
[ObservableProperty] private bool _onlyOpen = true;
|
||||
[ObservableProperty] private bool _busy;
|
||||
[ObservableProperty] private string _status = "";
|
||||
|
||||
public bool HasCases => Cases.Count > 0;
|
||||
|
||||
public Func<List<StudentOption>, Vorgang?, Task<Vorgang?>>? OnEditVorgang { get; set; }
|
||||
public Func<VorgangItem, Task<bool>>? OnConfirmDeleteVorgang { get; set; }
|
||||
public Func<Guid, List<StudentOption>, Documentation?, Task<Documentation?>>? OnEditDocumentation { get; set; }
|
||||
|
||||
public ClassTeacherCasesViewModel(IVorgangRepository vorgaenge, IDocumentationRepository documentation,
|
||||
IStudentRepository students, UntisReportCacheService cache)
|
||||
{
|
||||
_vorgaenge = vorgaenge;
|
||||
_documentation = documentation;
|
||||
_students = students;
|
||||
_cache = cache;
|
||||
}
|
||||
|
||||
public void Initialize(string className)
|
||||
{
|
||||
_className = className;
|
||||
Cases.Clear();
|
||||
Status = "Wird geladen…";
|
||||
NotifyState();
|
||||
_ = LoadInternal();
|
||||
}
|
||||
|
||||
partial void OnOnlyOpenChanged(bool value) => _ = LoadInternal();
|
||||
|
||||
[RelayCommand]
|
||||
private Task Refresh() => LoadInternal();
|
||||
|
||||
private async Task LoadInternal()
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(_className)) return;
|
||||
|
||||
// IsRevealed übersteht den Neuaufbau der Liste nicht automatisch (neue VorgangItem-Instanzen
|
||||
// pro Load) - deshalb hier gemerkt und danach wiederhergestellt, sonst klappt eine gerade
|
||||
// aufgeklappte Karte bei jeder Verknüpfungs-/Status-Aktion (die intern neu lädt) wieder zu.
|
||||
var revealedIds = Cases.Where(c => c.IsRevealed).Select(c => c.Model.Id).ToHashSet();
|
||||
|
||||
Busy = true;
|
||||
Cases.Clear();
|
||||
NotifyState();
|
||||
try
|
||||
{
|
||||
var roster = await _cache.GetStudentRosterAsync(_className);
|
||||
var localStudents = _students.GetAll();
|
||||
var matches = roster
|
||||
.Select(r => (Roster: r, Student: ClassTeacherOverviewViewModel.MatchStudent(r.DisplayName, localStudents)))
|
||||
.Where(x => x.Student is not null)
|
||||
.Select(x => (StudentId: x.Student!.Id, DisplayName: x.Roster.DisplayName))
|
||||
.DistinctBy(x => x.StudentId)
|
||||
.ToList();
|
||||
_rosterStudentOptions = matches.Select(m => new StudentOption(m.StudentId, m.DisplayName)).ToList();
|
||||
var rosterIds = matches.Select(m => m.StudentId).ToHashSet();
|
||||
var nameById = matches.ToDictionary(m => m.StudentId, m => m.DisplayName);
|
||||
|
||||
var relevant = _vorgaenge.GetAll().Where(v => v.StudentIds.Any(rosterIds.Contains));
|
||||
if (OnlyOpen) relevant = relevant.Where(v => v.Status == VorgangStatus.Open);
|
||||
|
||||
var allDocs = _documentation.GetAll();
|
||||
foreach (var vorgang in relevant.OrderByDescending(v => v.UpdatedAt))
|
||||
{
|
||||
var names = vorgang.StudentIds.Select(id => nameById.GetValueOrDefault(id, ""))
|
||||
.Where(n => n != "");
|
||||
var item = new VorgangItem(vorgang, string.Join(", ", names), LinkDocumentation,
|
||||
UnlinkDocumentation, RemoveClassRegisterEntry, EditDocumentation)
|
||||
{ IsRevealed = revealedIds.Contains(vorgang.Id) };
|
||||
|
||||
var linkedIds = vorgang.DocumentationIds.ToHashSet();
|
||||
foreach (var doc in allDocs.Where(d => linkedIds.Contains(d.Id)))
|
||||
item.LinkedDocumentation.Add(new DocumentationItem(doc, nameById.GetValueOrDefault(doc.StudentId, "")));
|
||||
foreach (var doc in FilterAvailableDocumentation(allDocs, vorgang))
|
||||
item.AvailableDocumentation.Add(new DocumentationItem(doc, nameById.GetValueOrDefault(doc.StudentId, "")));
|
||||
foreach (var entry in vorgang.ClassRegisterEntries.OrderByDescending(e => e.Date))
|
||||
item.ClassRegisterRows.Add(new VorgangClassRegisterEntryRow(entry));
|
||||
|
||||
Cases.Add(item);
|
||||
}
|
||||
Status = OnlyOpen ? $"{Cases.Count} offene Vorgänge" : $"{Cases.Count} Vorgänge";
|
||||
}
|
||||
finally { Busy = false; NotifyState(); }
|
||||
}
|
||||
|
||||
private void NotifyState() => OnPropertyChanged(nameof(HasCases));
|
||||
|
||||
[RelayCommand]
|
||||
private async Task AddVorgang()
|
||||
{
|
||||
if (OnEditVorgang is null) return;
|
||||
var result = await OnEditVorgang(_rosterStudentOptions, null);
|
||||
if (result is null) return;
|
||||
_vorgaenge.Save(result);
|
||||
await LoadInternal();
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private async Task EditVorgang(VorgangItem? item)
|
||||
{
|
||||
if (item is null || OnEditVorgang is null) return;
|
||||
var result = await OnEditVorgang(_rosterStudentOptions, item.Model);
|
||||
if (result is null) return;
|
||||
_vorgaenge.Save(result);
|
||||
await LoadInternal();
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private async Task ToggleStatus(VorgangItem? item)
|
||||
{
|
||||
if (item is null) return;
|
||||
item.Model.Status = item.Model.Status == VorgangStatus.Open ? VorgangStatus.Closed : VorgangStatus.Open;
|
||||
item.Model.ClosedAt = item.Model.Status == VorgangStatus.Closed ? DateTime.UtcNow : null;
|
||||
_vorgaenge.Save(item.Model);
|
||||
await LoadInternal();
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private async Task DeleteVorgang(VorgangItem? item)
|
||||
{
|
||||
if (item is null) return;
|
||||
if (OnConfirmDeleteVorgang is not null && !await OnConfirmDeleteVorgang(item)) return;
|
||||
_vorgaenge.Delete(item.Model.Id);
|
||||
await LoadInternal();
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private async Task CreateAndLinkDocumentation(VorgangItem? item)
|
||||
{
|
||||
if (item is null || OnEditDocumentation is null) return;
|
||||
var studentOptions = _rosterStudentOptions.Where(s => item.Model.StudentIds.Contains(s.Id)).ToList();
|
||||
var defaultStudentId = item.Model.StudentIds.FirstOrDefault();
|
||||
var result = await OnEditDocumentation(defaultStudentId, studentOptions, null);
|
||||
if (result is null) return;
|
||||
_documentation.Save(result);
|
||||
item.Model.DocumentationIds.Add(result.Id);
|
||||
_vorgaenge.Save(item.Model);
|
||||
await LoadInternal();
|
||||
}
|
||||
|
||||
/// Öffnet eine verknüpfte oder noch verknüpfbare Dokumentation im vollen Bearbeiten-Dialog, ohne
|
||||
/// den Vorgänge-Tab zu verlassen (Nutzer-Feedback: bisher musste man dafür zum Nachlesen in den
|
||||
/// Schülerdatensatz wechseln). Reine Bearbeitung der Dokumentation selbst — welche Vorgänge sie
|
||||
/// verlinken, ändert sich dadurch nicht.
|
||||
private async Task EditDocumentation(DocumentationItem doc)
|
||||
{
|
||||
if (OnEditDocumentation is null) return;
|
||||
var result = await OnEditDocumentation(doc.Model.StudentId, _rosterStudentOptions, doc.Model);
|
||||
if (result is null) return;
|
||||
_documentation.Save(result);
|
||||
await LoadInternal();
|
||||
}
|
||||
|
||||
/// Dokumentation der Vorgangs-Schüler*innen, die noch nicht mit diesem Vorgang verknüpft ist —
|
||||
/// reine, ohne Repository-Zugriff testbare Filterlogik (gleiches Muster wie
|
||||
/// <see cref="ClassTeacherRosterRow.Build"/>).
|
||||
public static List<Documentation> FilterAvailableDocumentation(IReadOnlyList<Documentation> allDocs, Vorgang vorgang)
|
||||
{
|
||||
var linkedIds = vorgang.DocumentationIds.ToHashSet();
|
||||
return allDocs.Where(d => vorgang.StudentIds.Contains(d.StudentId) && !linkedIds.Contains(d.Id)).ToList();
|
||||
}
|
||||
|
||||
private async Task LinkDocumentation(VorgangItem item, DocumentationItem doc)
|
||||
{
|
||||
item.Model.DocumentationIds.Add(doc.Model.Id);
|
||||
_vorgaenge.Save(item.Model);
|
||||
await LoadInternal();
|
||||
}
|
||||
|
||||
private async Task UnlinkDocumentation(VorgangItem item, DocumentationItem doc)
|
||||
{
|
||||
item.Model.DocumentationIds.Remove(doc.Model.Id);
|
||||
_vorgaenge.Save(item.Model);
|
||||
await LoadInternal();
|
||||
}
|
||||
|
||||
private async Task RemoveClassRegisterEntry(VorgangItem item, VorgangClassRegisterEntryRow row)
|
||||
{
|
||||
item.Model.ClassRegisterEntries.Remove(row.Model);
|
||||
_vorgaenge.Save(item.Model);
|
||||
await LoadInternal();
|
||||
}
|
||||
|
||||
// ── Anheften eines Klassenbuch-Eintrags aus dem Klassenbuch-Tab (ClassTeacherDetailsViewModel,
|
||||
// per Konstruktor-Injection dieser Instanz — kein Func-Hook, da hier keine UI im Spiel ist,
|
||||
// nur Zugriff auf schon geladene Daten dieser Geschwister-ViewModel-Instanz) ────────────────
|
||||
|
||||
public IReadOnlyList<StudentOption> RosterStudentOptions => _rosterStudentOptions;
|
||||
|
||||
/// Offene Vorgänge, an die sich ein Klassenbuch-Eintrag für diese/n Schüler*in anheften lässt
|
||||
/// (Namensabgleich wie beim restlichen Klassenlehrer-Bereich, siehe UntisNameMatching).
|
||||
public List<VorgangItem> OpenCasesForStudent(string untisDisplayName)
|
||||
{
|
||||
var match = _rosterStudentOptions.FirstOrDefault(s =>
|
||||
UntisNameMatching.NamesMatch(s.Name, untisDisplayName));
|
||||
if (match is null) return [];
|
||||
return Cases.Where(c => c.IsOpen && c.Model.StudentIds.Contains(match.Id)).ToList();
|
||||
}
|
||||
|
||||
public async Task PinClassRegisterEntryAsync(Guid vorgangId, VorgangClassRegisterEntry entry)
|
||||
{
|
||||
var vorgang = _vorgaenge.GetById(vorgangId);
|
||||
if (vorgang is null) return;
|
||||
vorgang.ClassRegisterEntries.Add(entry);
|
||||
_vorgaenge.Save(vorgang);
|
||||
await LoadInternal();
|
||||
}
|
||||
|
||||
public async Task<Vorgang> CreateAndPinAsync(string title, Guid studentId, VorgangClassRegisterEntry entry)
|
||||
{
|
||||
var vorgang = new Vorgang { Title = title, StudentIds = [studentId] };
|
||||
vorgang.ClassRegisterEntries.Add(entry);
|
||||
_vorgaenge.Save(vorgang);
|
||||
await LoadInternal();
|
||||
return vorgang;
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,10 @@
|
||||
using System.Collections.ObjectModel;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using LehrerApp.Core.Interfaces;
|
||||
using LehrerApp.Core.Models;
|
||||
using LehrerApp.Desktop.Services;
|
||||
using LehrerApp.Desktop.ViewModels.Students;
|
||||
|
||||
namespace LehrerApp.Desktop.ViewModels.ClassTeacher;
|
||||
|
||||
@@ -116,6 +119,24 @@ public sealed record ClassAbsenceDaySummaryRow(DateOnly Date, string StudentName
|
||||
DateOnly.TryParseExact(value.ToString(), "yyyyMMdd", out date);
|
||||
}
|
||||
|
||||
public enum ClassTeacherCalendarEventKind { Late, Absent, Unexcused, Excused, ClassRegister, Homework }
|
||||
|
||||
/// <summary>Kompakter, farblich codierter Marker in der Klassenlehrer-Monatsansicht.</summary>
|
||||
public sealed record ClassTeacherCalendarEvent(string StudentName, string Code, string Label,
|
||||
string ColorHex, ClassTeacherCalendarEventKind Kind)
|
||||
{
|
||||
public string Tooltip => $"{StudentName}: {Label}";
|
||||
}
|
||||
|
||||
/// <summary>Ein Werktag im 5-Spalten-Monatsraster; Tage außerhalb des Monats sind Platzhalter.</summary>
|
||||
public sealed record ClassTeacherCalendarDay(DateOnly? Date, IReadOnlyList<ClassTeacherCalendarEvent> Events)
|
||||
{
|
||||
public bool IsPlaceholder => Date is null;
|
||||
public string DayLabel => Date?.ToString("dd.") ?? "";
|
||||
public string WeekdayLabel => Date?.ToString("ddd", System.Globalization.CultureInfo.GetCultureInfo("de-DE")) ?? "";
|
||||
public bool HasEvents => Events.Count > 0;
|
||||
}
|
||||
|
||||
/// <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
|
||||
@@ -130,29 +151,71 @@ public sealed record ClassAbsenceDaySummaryRow(DateOnly Date, string StudentName
|
||||
public partial class ClassTeacherDetailsViewModel : ObservableObject
|
||||
{
|
||||
private readonly UntisReportCacheService _cache;
|
||||
private readonly IDocumentationRepository _documentation;
|
||||
private readonly IStudentRepository _students;
|
||||
private readonly ClassTeacherCasesViewModel _cases;
|
||||
private string _className = "";
|
||||
private DateOnly _loadedStart;
|
||||
private DateOnly _loadedEnd;
|
||||
/// Schüler*innen der Klasse, per Namensabgleich (<see cref="UntisNameMatching"/>) den lokalen
|
||||
/// <see cref="Student"/>-Datensätzen zugeordnet — Grundlage für die "Eigene Dokumentation"-Ansicht,
|
||||
/// die es (anders als der WebUntis-Klassenbuchbericht) nur lokal gibt. Nach jedem <see cref="LoadInternal"/>
|
||||
/// neu aufgebaut.
|
||||
private List<(Guid StudentId, string DisplayName)> _rosterMatches = [];
|
||||
|
||||
public ObservableCollection<ClassTeacherClassRegisterRow> Entries { get; } = [];
|
||||
public ObservableCollection<ClassAbsenceDaySummaryRow> AbsenceEntries { get; } = [];
|
||||
public ObservableCollection<ClassTeacherCategoryAggregateRow> CategoryAggregates { get; } = [];
|
||||
public ObservableCollection<DocumentationItem> OwnDocumentationEntries { get; } = [];
|
||||
public ObservableCollection<ClassTeacherCalendarDay> CalendarDays { get; } = [];
|
||||
|
||||
[ObservableProperty] private DateTimeOffset _startDate = DateTimeOffset.Now.AddDays(-6);
|
||||
[ObservableProperty] private DateTimeOffset _endDate = DateTimeOffset.Now;
|
||||
/// Für das Kontextmenü "→ An Vorgang anheften" (DataGrid.SelectedItem, zweigleisig gebunden).
|
||||
[ObservableProperty] private ClassTeacherClassRegisterRow? _selectedEntry;
|
||||
|
||||
[ObservableProperty] private DateTimeOffset? _startDate = DateTimeOffset.Now.AddDays(-6);
|
||||
[ObservableProperty] private DateTimeOffset? _endDate = DateTimeOffset.Now;
|
||||
[ObservableProperty] private string _status = "Zeitraum wählen und laden.";
|
||||
[ObservableProperty] private bool _busy;
|
||||
/// Von der Übersicht gesetzt (Klick auf eine Roster-Zeile) - leer zeigt alle Schüler*innen.
|
||||
[ObservableProperty] private string _studentFilter = "";
|
||||
[ObservableProperty] private int _quickRangeIndex = 1;
|
||||
[ObservableProperty] private bool _showMonthlyCalendar;
|
||||
[ObservableProperty] private bool _includeHomeworkInCalendar;
|
||||
[ObservableProperty] private DateOnly _calendarMonth = new(DateTime.Today.Year, DateTime.Today.Month, 1);
|
||||
/// Umschalter Klassenbuch (WebUntis, andere Lehrkräfte) ↔ eigene Dokumentation.
|
||||
[ObservableProperty] private bool _showOwnDocumentation;
|
||||
[ObservableProperty] private int _ownDocumentationCount;
|
||||
[ObservableProperty] private int _ownDocumentationFollowUpCount;
|
||||
[ObservableProperty] private int _ownDocumentationCriticalCount;
|
||||
|
||||
public Func<List<StudentOption>, Documentation?, Task<Documentation?>>? OnEditOwnDocumentation { get; set; }
|
||||
public Func<DocumentationItem, Task<bool>>? OnConfirmDeleteOwnDocumentation { get; set; }
|
||||
/// Zeigt die "→ Vorgang"-Auswahl für eine Klassenbuchzeile: bestehenden offenen Vorgang wählen
|
||||
/// oder einen neuen Titel eingeben. Liefert null bei Abbruch.
|
||||
public Func<ClassTeacherClassRegisterRow, Task<PinToVorgangChoice?>>? OnPickVorgangForPin { get; set; }
|
||||
|
||||
public bool HasEntries => Entries.Count > 0;
|
||||
public bool HasAbsenceEntries => AbsenceEntries.Count > 0;
|
||||
public bool ShowAbsenceListEmpty => !ShowMonthlyCalendar && !HasAbsenceEntries;
|
||||
public bool HasCategoryAggregates => CategoryAggregates.Count > 0;
|
||||
public bool HasOwnDocumentationEntries => OwnDocumentationEntries.Count > 0;
|
||||
public bool CanAddOwnDocumentation => !Busy && _rosterMatches.Count > 0;
|
||||
/// Die Kategorien-Chipreihe fasst nur WebUntis-Kategorien zusammen (<see cref="CategoryAggregates"/>)
|
||||
/// — im Modus "Eigene Dokumentation" ausgeblendet, dort gibt es kein Äquivalent zu CategoryGroup.
|
||||
public bool ShowCategoryAggregates => HasCategoryAggregates && !ShowOwnDocumentation;
|
||||
public int UntisCriticalCount => Entries.Count(e => e.IsDangerStatus);
|
||||
public string ActiveFilterLabel => string.IsNullOrWhiteSpace(StudentFilter)
|
||||
? "Alle Schüler*innen" : StudentFilter;
|
||||
public string CalendarMonthLabel => CalendarMonth.ToString("MMMM yyyy",
|
||||
System.Globalization.CultureInfo.GetCultureInfo("de-DE"));
|
||||
|
||||
public ClassTeacherDetailsViewModel(UntisReportCacheService cache)
|
||||
public ClassTeacherDetailsViewModel(UntisReportCacheService cache, IDocumentationRepository documentation,
|
||||
IStudentRepository students, ClassTeacherCasesViewModel cases)
|
||||
{
|
||||
_cache = cache;
|
||||
_documentation = documentation;
|
||||
_students = students;
|
||||
_cases = cases;
|
||||
}
|
||||
|
||||
public void Initialize(string className)
|
||||
@@ -161,13 +224,21 @@ public partial class ClassTeacherDetailsViewModel : ObservableObject
|
||||
StudentFilter = "";
|
||||
Entries.Clear();
|
||||
AbsenceEntries.Clear();
|
||||
CalendarDays.Clear();
|
||||
CategoryAggregates.Clear();
|
||||
OwnDocumentationEntries.Clear();
|
||||
_rosterMatches = [];
|
||||
Status = "Zeitraum wählen und laden.";
|
||||
NotifyListState();
|
||||
}
|
||||
|
||||
partial void OnStudentFilterChanged(string value) => OnPropertyChanged(nameof(ActiveFilterLabel));
|
||||
|
||||
partial void OnShowOwnDocumentationChanged(bool value) => OnPropertyChanged(nameof(ShowCategoryAggregates));
|
||||
partial void OnShowMonthlyCalendarChanged(bool value) => OnPropertyChanged(nameof(ShowAbsenceListEmpty));
|
||||
partial void OnIncludeHomeworkInCalendarChanged(bool value) => BuildCalendar();
|
||||
partial void OnCalendarMonthChanged(DateOnly value) => OnPropertyChanged(nameof(CalendarMonthLabel));
|
||||
|
||||
partial void OnQuickRangeIndexChanged(int value)
|
||||
{
|
||||
var days = value switch { 0 => 0, 1 => 6, 2 => 29, _ => 6 };
|
||||
@@ -178,6 +249,9 @@ public partial class ClassTeacherDetailsViewModel : ObservableObject
|
||||
[RelayCommand]
|
||||
private Task Load() => LoadInternal(forceRefresh: false);
|
||||
|
||||
[RelayCommand] private void ShowUntisRegister() => ShowOwnDocumentation = false;
|
||||
[RelayCommand] private void ShowOwnDocs() => ShowOwnDocumentation = true;
|
||||
|
||||
/// Umgeht bewusst die Stunden-Sperre von <see cref="UntisReportCacheService"/> — für den Fall,
|
||||
/// dass man sicher weiß, dass sich seit dem letzten automatischen Abruf etwas geändert hat.
|
||||
[RelayCommand]
|
||||
@@ -186,6 +260,37 @@ public partial class ClassTeacherDetailsViewModel : ObservableObject
|
||||
[RelayCommand]
|
||||
private Task ApplyFilter() => LoadInternal(forceRefresh: false);
|
||||
|
||||
[RelayCommand] private void ShowAbsenceList() => ShowMonthlyCalendar = false;
|
||||
|
||||
[RelayCommand]
|
||||
private async Task ShowCalendar()
|
||||
{
|
||||
ShowMonthlyCalendar = true;
|
||||
await LoadCalendarMonth(forceRefresh: false);
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private async Task PreviousCalendarMonth()
|
||||
{
|
||||
CalendarMonth = CalendarMonth.AddMonths(-1);
|
||||
await LoadCalendarMonth(forceRefresh: false);
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private async Task NextCalendarMonth()
|
||||
{
|
||||
CalendarMonth = CalendarMonth.AddMonths(1);
|
||||
await LoadCalendarMonth(forceRefresh: false);
|
||||
}
|
||||
|
||||
private Task LoadCalendarMonth(bool forceRefresh)
|
||||
{
|
||||
StartDate = new DateTimeOffset(CalendarMonth.ToDateTime(TimeOnly.MinValue));
|
||||
var end = CalendarMonth.AddMonths(1).AddDays(-1);
|
||||
EndDate = new DateTimeOffset(end.ToDateTime(TimeOnly.MinValue));
|
||||
return LoadInternal(forceRefresh);
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private async Task ClearStudentFilter()
|
||||
{
|
||||
@@ -195,17 +300,20 @@ public partial class ClassTeacherDetailsViewModel : ObservableObject
|
||||
|
||||
private async Task LoadInternal(bool forceRefresh)
|
||||
{
|
||||
var start = DateOnly.FromDateTime(StartDate.LocalDateTime);
|
||||
var end = DateOnly.FromDateTime(EndDate.LocalDateTime);
|
||||
var start = DateOnly.FromDateTime((StartDate ?? DateTimeOffset.Now).LocalDateTime);
|
||||
var end = DateOnly.FromDateTime((EndDate ?? DateTimeOffset.Now).LocalDateTime);
|
||||
if (end < start) { Status = "Das Enddatum darf nicht vor dem Startdatum liegen."; return; }
|
||||
if (string.IsNullOrWhiteSpace(_className)) { Status = "Keine Klasse ausgewählt."; return; }
|
||||
|
||||
Busy = true; Entries.Clear(); AbsenceEntries.Clear(); CategoryAggregates.Clear(); NotifyListState();
|
||||
Busy = true;
|
||||
Entries.Clear(); AbsenceEntries.Clear(); CategoryAggregates.Clear(); OwnDocumentationEntries.Clear();
|
||||
NotifyListState();
|
||||
try
|
||||
{
|
||||
var classRegisterTask = _cache.GetClassRegisterEventsAsync(_className, start, end, forceRefresh);
|
||||
var absencesTask = _cache.GetAbsencesAsync(_className, start, end, forceRefresh);
|
||||
await Task.WhenAll(classRegisterTask, absencesTask);
|
||||
var rosterTask = _cache.GetStudentRosterAsync(_className);
|
||||
await Task.WhenAll(classRegisterTask, absencesTask, rosterTask);
|
||||
|
||||
var ordered = classRegisterTask.Result
|
||||
.Where(MatchesStudentFilter)
|
||||
@@ -223,14 +331,234 @@ public partial class ClassTeacherDetailsViewModel : ObservableObject
|
||||
foreach (var row in ClassAbsenceDaySummaryRow.GroupByStudentAndDay(absences))
|
||||
AbsenceEntries.Add(row);
|
||||
|
||||
BuildCalendar();
|
||||
|
||||
var localStudents = _students.GetAll();
|
||||
_rosterMatches = rosterTask.Result
|
||||
.Select(r => (Roster: r, Student: ClassTeacherOverviewViewModel.MatchStudent(r.DisplayName, localStudents)))
|
||||
.Where(x => x.Student is not null)
|
||||
.Select(x => (StudentId: x.Student!.Id, DisplayName: x.Roster.DisplayName))
|
||||
.DistinctBy(x => x.StudentId)
|
||||
.ToList();
|
||||
_loadedStart = start;
|
||||
_loadedEnd = end;
|
||||
LoadOwnDocumentationEntries();
|
||||
|
||||
Status = $"{Entries.Count} Klassenbucheinträge anderer Lehrkräfte, " +
|
||||
$"{AbsenceEntries.Count} Fehlzeiten-Tage im Zeitraum.";
|
||||
$"{AbsenceEntries.Count} Fehlzeiten-Tage, {OwnDocumentationEntries.Count} eigene Dokumentation im Zeitraum.";
|
||||
NotifyListState();
|
||||
}
|
||||
catch (WebUntisIntegrationException ex) { Status = ex.Message; }
|
||||
finally { Busy = false; NotifyListState(); }
|
||||
}
|
||||
|
||||
private void BuildCalendar()
|
||||
{
|
||||
CalendarDays.Clear();
|
||||
foreach (var day in BuildCalendarDays(CalendarMonth, AbsenceEntries, Entries, IncludeHomeworkInCalendar))
|
||||
CalendarDays.Add(day);
|
||||
}
|
||||
|
||||
/// <summary>Baut ein Montag-bis-Freitag-Raster aus den ohnehin geladenen Berichten. Negative
|
||||
/// Klassenbucheinträge erscheinen als !; Hausaufgaben sind eine bewusst optionale Sonderlage.</summary>
|
||||
public static IReadOnlyList<ClassTeacherCalendarDay> BuildCalendarDays(DateOnly month,
|
||||
IReadOnlyList<ClassAbsenceDaySummaryRow> absences,
|
||||
IReadOnlyList<ClassTeacherClassRegisterRow> registerEntries, bool includeHomework)
|
||||
{
|
||||
var first = new DateOnly(month.Year, month.Month, 1);
|
||||
var last = first.AddMonths(1).AddDays(-1);
|
||||
var gridStart = first.AddDays(-(((int)first.DayOfWeek + 6) % 7));
|
||||
var gridEnd = last.AddDays((7 - (int)last.DayOfWeek) % 7);
|
||||
var result = new List<ClassTeacherCalendarDay>();
|
||||
|
||||
for (var date = gridStart; date <= gridEnd; date = date.AddDays(1))
|
||||
{
|
||||
if (date.DayOfWeek is DayOfWeek.Saturday or DayOfWeek.Sunday) continue;
|
||||
if (date.Month != first.Month)
|
||||
{
|
||||
result.Add(new ClassTeacherCalendarDay(null, []));
|
||||
continue;
|
||||
}
|
||||
|
||||
result.Add(new ClassTeacherCalendarDay(date,
|
||||
BuildDayEvents(date, absences, registerEntries, includeHomework)));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
public static IReadOnlyList<ClassTeacherCalendarEvent> BuildDayEvents(DateOnly date,
|
||||
IReadOnlyList<ClassAbsenceDaySummaryRow> absences,
|
||||
IReadOnlyList<ClassTeacherClassRegisterRow> registerEntries, bool includeHomework)
|
||||
{
|
||||
var events = new List<ClassTeacherCalendarEvent>();
|
||||
foreach (var absence in absences.Where(a => a.Date == date).OrderBy(a => a.StudentDisplayName))
|
||||
{
|
||||
var (code, label, color, kind) = absence.IsUnexcused
|
||||
? ("U", "Unentschuldigt gefehlt", "#C62828", ClassTeacherCalendarEventKind.Unexcused)
|
||||
: absence.IsLate
|
||||
? ("V", "Verspätet", "#EF6C00", ClassTeacherCalendarEventKind.Late)
|
||||
: absence.FriendlyStatusLabel.Contains("Entschuldigt", StringComparison.OrdinalIgnoreCase)
|
||||
? ("E", "Entschuldigt gefehlt", "#2E7D32", ClassTeacherCalendarEventKind.Excused)
|
||||
: ("A", "Abwesend", "#1565C0", ClassTeacherCalendarEventKind.Absent);
|
||||
events.Add(new ClassTeacherCalendarEvent(absence.StudentDisplayName, code, label, color, kind));
|
||||
}
|
||||
|
||||
foreach (var entry in registerEntries.Where(e => e.Date == date).OrderBy(e => e.StudentDisplayName))
|
||||
{
|
||||
var isHomework = ContainsHomework(entry);
|
||||
if (isHomework && !includeHomework) continue;
|
||||
if (!entry.IsDangerStatus && !isHomework) continue;
|
||||
events.Add(new ClassTeacherCalendarEvent(entry.StudentDisplayName,
|
||||
isHomework ? "H" : "!", isHomework ? "Hausaufgaben" : "Negativer Klassenbucheintrag",
|
||||
isHomework ? "#6A1B9A" : "#8E24AA",
|
||||
isHomework ? ClassTeacherCalendarEventKind.Homework : ClassTeacherCalendarEventKind.ClassRegister));
|
||||
}
|
||||
return events;
|
||||
}
|
||||
|
||||
private static bool ContainsHomework(ClassTeacherClassRegisterRow entry) =>
|
||||
entry.CategoryName?.Contains("Hausauf", StringComparison.OrdinalIgnoreCase) == true ||
|
||||
entry.Text?.Contains("Hausauf", StringComparison.OrdinalIgnoreCase) == true;
|
||||
|
||||
/// Baut <see cref="OwnDocumentationEntries"/> aus <see cref="_rosterMatches"/> und dem zuletzt
|
||||
/// geladenen Zeitraum neu auf — separat von <see cref="LoadInternal"/>, damit Anlegen/Bearbeiten/
|
||||
/// Löschen eines eigenen Eintrags nicht auch die WebUntis-Berichte neu abruft. Die eigentliche
|
||||
/// Filter-/Sortierlogik steckt in der reinen, ohne Repository-Zugriff testbaren
|
||||
/// <see cref="FilterOwnDocumentation"/> — analog zu <see cref="ClassTeacherRosterRow.Build"/>.
|
||||
private void LoadOwnDocumentationEntries()
|
||||
{
|
||||
OwnDocumentationEntries.Clear();
|
||||
var entries = FilterOwnDocumentation(_documentation.GetAll(), _rosterMatches,
|
||||
_loadedStart, _loadedEnd, StudentFilter);
|
||||
foreach (var d in entries)
|
||||
{
|
||||
var name = DocumentationStudentDisplay(d, _rosterMatches);
|
||||
OwnDocumentationEntries.Add(new DocumentationItem(d, name));
|
||||
}
|
||||
OwnDocumentationCount = OwnDocumentationEntries.Count;
|
||||
OwnDocumentationFollowUpCount = entries.Count(d => d.IsDraft);
|
||||
OwnDocumentationCriticalCount = entries.Count(d =>
|
||||
d.Tags.Any(t => string.Equals(t, "Kritisch", StringComparison.OrdinalIgnoreCase)));
|
||||
OnPropertyChanged(nameof(HasOwnDocumentationEntries));
|
||||
}
|
||||
|
||||
/// Eigene Dokumentation zu Schüler*innen der Klasse im gewählten Zeitraum: nur Einträge
|
||||
/// (nicht gelöschter) Schüler*innen, die sich per Namensabgleich der WebUntis-Klasse zuordnen
|
||||
/// ließen (<paramref name="rosterMatches"/>, siehe <see cref="ClassTeacherOverviewViewModel.MatchStudent"/>),
|
||||
/// mit demselben Schülerfilter wie die WebUntis-Klassenbuchzeilen. Entwürfe ("Nacharbeiten")
|
||||
/// zuerst, danach neueste zuerst.
|
||||
public static List<Documentation> FilterOwnDocumentation(IReadOnlyList<Documentation> all,
|
||||
IReadOnlyList<(Guid StudentId, string DisplayName)> rosterMatches,
|
||||
DateOnly start, DateOnly end, string studentFilter)
|
||||
{
|
||||
var matchedIds = rosterMatches.Select(m => m.StudentId).ToHashSet();
|
||||
return all
|
||||
.Where(d => !d.IsDeleted &&
|
||||
(matchedIds.Contains(d.StudentId) || IsUnassignedClassDocumentation(d)) &&
|
||||
d.Date >= start && d.Date <= end)
|
||||
.Where(d => MatchesOwnDocStudentFilter(d, rosterMatches, studentFilter))
|
||||
.OrderByDescending(d => d.IsDraft).ThenByDescending(d => d.Date)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
private static bool MatchesOwnDocStudentFilter(Documentation d,
|
||||
IReadOnlyList<(Guid StudentId, string DisplayName)> rosterMatches, string studentFilter)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(studentFilter)) return true;
|
||||
if (IsUnassignedClassDocumentation(d))
|
||||
return d.Participants.Any(p => NameContainsSearchTerm(p, studentFilter));
|
||||
var name = rosterMatches.FirstOrDefault(m => m.StudentId == d.StudentId).DisplayName;
|
||||
return name is not null && NameContainsSearchTerm(name, studentFilter);
|
||||
}
|
||||
|
||||
private static bool IsUnassignedClassDocumentation(Documentation d) =>
|
||||
d.StudentId == Guid.Empty && d.GroupId is null;
|
||||
|
||||
private static bool NameContainsSearchTerm(string name, string search)
|
||||
{
|
||||
if (UntisNameMatching.NamesMatch(name, search)) return true;
|
||||
var searchKey = UntisNameMatching.NameKey(search);
|
||||
return searchKey.Length > 0 && UntisNameMatching.NameKey(name).Split(' ').Contains(searchKey);
|
||||
}
|
||||
|
||||
private static string DocumentationStudentDisplay(Documentation d,
|
||||
IReadOnlyList<(Guid StudentId, string DisplayName)> rosterMatches)
|
||||
{
|
||||
if (d.StudentId != Guid.Empty)
|
||||
return rosterMatches.FirstOrDefault(m => m.StudentId == d.StudentId).DisplayName
|
||||
?? "Unbekannter Schülerbezug";
|
||||
return d.Participants.Count > 0
|
||||
? $"Ohne festen Bezug · {string.Join(", ", d.Participants)}"
|
||||
: "Ohne Schülerbezug";
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private async Task AddOwnDocumentation()
|
||||
{
|
||||
if (OnEditOwnDocumentation is null || !CanAddOwnDocumentation) return;
|
||||
var options = _rosterMatches.Select(m => new StudentOption(m.StudentId, m.DisplayName)).ToList();
|
||||
var result = await OnEditOwnDocumentation(options, null);
|
||||
if (result is null) return;
|
||||
_documentation.Save(result);
|
||||
LoadOwnDocumentationEntries();
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private async Task EditOwnDocumentation(DocumentationItem? item)
|
||||
{
|
||||
if (item is null || OnEditOwnDocumentation is null) return;
|
||||
var options = _rosterMatches.Select(m => new StudentOption(m.StudentId, m.DisplayName)).ToList();
|
||||
var result = await OnEditOwnDocumentation(options, item.Model);
|
||||
if (result is null) return;
|
||||
_documentation.Save(result);
|
||||
LoadOwnDocumentationEntries();
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private async Task DeleteOwnDocumentation(DocumentationItem? item)
|
||||
{
|
||||
if (item is null) return;
|
||||
if (OnConfirmDeleteOwnDocumentation is not null && !await OnConfirmDeleteOwnDocumentation(item)) return;
|
||||
_documentation.Delete(item.Model.Id);
|
||||
LoadOwnDocumentationEntries();
|
||||
}
|
||||
|
||||
/// Für den "→ Vorgang"-Auswahldialog im Code-behind: offene Vorgänge, an die sich diese
|
||||
/// Klassenbuchzeile anheften lässt (Durchreiche zur Geschwister-ViewModel-Instanz, siehe _cases).
|
||||
public List<VorgangItem> OpenCasesFor(string studentName) => _cases.OpenCasesForStudent(studentName);
|
||||
|
||||
/// Heftet eine WebUntis-Klassenbuchzeile als eingefrorene Kopie an einen (ggf. neuen) Vorgang
|
||||
/// im "Vorgänge"-Tab (<see cref="ClassTeacherCasesViewModel"/>) an — die Zeile selbst hat keine
|
||||
/// stabile ID, deshalb Werte-Kopie statt Referenz (siehe VorgangClassRegisterEntry).
|
||||
[RelayCommand]
|
||||
private async Task PinToVorgang(ClassTeacherClassRegisterRow? row)
|
||||
{
|
||||
if (row is null || OnPickVorgangForPin is null) return;
|
||||
var choice = await OnPickVorgangForPin(row);
|
||||
if (choice is null) return;
|
||||
|
||||
var entry = BuildClassRegisterSnapshot(row);
|
||||
|
||||
if (choice.ExistingVorgangId is { } vorgangId)
|
||||
await _cases.PinClassRegisterEntryAsync(vorgangId, entry);
|
||||
else if (!string.IsNullOrWhiteSpace(choice.NewVorgangTitle))
|
||||
{
|
||||
var student = _cases.RosterStudentOptions
|
||||
.FirstOrDefault(s => UntisNameMatching.NamesMatch(s.Name, row.StudentName));
|
||||
if (student is null) return;
|
||||
await _cases.CreateAndPinAsync(choice.NewVorgangTitle, student.Id, entry);
|
||||
}
|
||||
}
|
||||
|
||||
/// Baut die eingefrorene Klassenbuch-Kopie aus einer Zeile — reine, ohne Repository-Zugriff
|
||||
/// testbare Umwandlung.
|
||||
public static VorgangClassRegisterEntry BuildClassRegisterSnapshot(ClassTeacherClassRegisterRow row) => new()
|
||||
{
|
||||
Date = row.Date, StudentName = row.StudentName, Subject = row.Subject,
|
||||
TeacherUsername = row.TeacherUsername, CategoryName = row.CategoryName,
|
||||
CategoryGroup = row.CategoryGroup, Text = row.Text,
|
||||
};
|
||||
|
||||
// WebUntis liefert Namen je nach Bericht in anderer Reihenfolge als der Schülerreport, aus dem
|
||||
// StudentFilter beim Klick in der Übersicht gesetzt wird (siehe UntisNameMatching) - ein
|
||||
// exakter String-Vergleich hier ließ die gefilterten Listen fälschlich leer erscheinen.
|
||||
@@ -247,6 +575,11 @@ public partial class ClassTeacherDetailsViewModel : ObservableObject
|
||||
{
|
||||
OnPropertyChanged(nameof(HasEntries));
|
||||
OnPropertyChanged(nameof(HasAbsenceEntries));
|
||||
OnPropertyChanged(nameof(ShowAbsenceListEmpty));
|
||||
OnPropertyChanged(nameof(HasCategoryAggregates));
|
||||
OnPropertyChanged(nameof(ShowCategoryAggregates));
|
||||
OnPropertyChanged(nameof(HasOwnDocumentationEntries));
|
||||
OnPropertyChanged(nameof(CanAddOwnDocumentation));
|
||||
OnPropertyChanged(nameof(UntisCriticalCount));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,6 +14,10 @@ namespace LehrerApp.Desktop.ViewModels.ClassTeacher;
|
||||
/// ein hart codierter Hex-String aus dem ViewModel bliebe beim Wechsel unverändert stehen.</summary>
|
||||
public enum ClassTeacherStatusKind { Ok, Info, Warning, Danger }
|
||||
|
||||
/// <summary>Die drei Auffälligkeits-Achsen, die ein geschlossener <see cref="Vorgang"/> per
|
||||
/// Schlagwort gezielt dämpfen kann, siehe <see cref="ClassTeacherRosterRow.MatchDomains"/>.</summary>
|
||||
public enum VorgangScoreDomain { Attendance, Lateness, Classbook }
|
||||
|
||||
public sealed record ClassTeacherRosterRow(string StudentName, int? ExternKey, bool HasAbsenceToday,
|
||||
string? AbsenceTooltip, bool HasRecentClassRegisterEntry)
|
||||
{
|
||||
@@ -73,8 +77,35 @@ public sealed record ClassTeacherRosterRow(string StudentName, int? ExternKey, b
|
||||
/// Kumulierte Fehlzeiten seit Schuljahresbeginn (Nutzer-Feedback: der Heute-Snapshot allein
|
||||
/// sagt für Zeugnis/Attestpflicht wenig aus). <see cref="SchoolDaysElapsed"/> zählt Werktage
|
||||
/// abzüglich der über WebUntis geladenen Ferien (<see cref="ClassTeacherOverviewViewModel.CountSchoolWeekdays"/>).
|
||||
/// Zählt bewusst NUR echte Fehltage (unentschuldigt oder entschuldigt abwesend), keine reinen
|
||||
/// Verspätungstage mehr — Nutzer-Feedback: die Quote behandelte "10× 5 Min. verspätet" bisher
|
||||
/// exakt wie "10× ganztägig unentschuldigt gefehlt", obwohl beides für die eigene Reaktion
|
||||
/// (Elterngespräch vs. Achselzucken) grundverschieden ist. Reine Verspätung landet stattdessen
|
||||
/// separat in <see cref="YearLateDayCount"/> (gleiche Kategorisierung wie schon länger im
|
||||
/// Wochentrend, siehe <see cref="ClassTeacherOverviewViewModel.BuildTrend"/>: Unentschuldigt vor
|
||||
/// Verspätet vor Entschuldigt, überschneidungsfrei).
|
||||
public int YearAbsenceDayCount { get; init; }
|
||||
public int YearUnexcusedDayCount { get; init; }
|
||||
/// Tage, die WebUntis als Verspätung führt (ohne Unentschuldigt-Status) — zählen bewusst nicht
|
||||
/// in <see cref="YearAbsenceDayCount"/>/<see cref="YearAbsenceRatePercent"/>, tragen aber mit
|
||||
/// reduziertem Gewicht zu <see cref="PatternScore"/> bei.
|
||||
public int YearLateDayCount { get; init; }
|
||||
/// Entschuldigt abwesende Tage (weder unentschuldigt noch nur verspätet) — Kehrwert von
|
||||
/// <see cref="YearUnexcusedDayCount"/> innerhalb von <see cref="YearAbsenceDayCount"/>.
|
||||
public int YearExcusedDayCount { get; init; }
|
||||
/// Klassenbucheinträge anderer Lehrkräfte über das ganze bisherige Schuljahr mit
|
||||
/// <c>CategoryGroup</c> "Negativ" (Nutzer-Feedback: sollen ebenfalls ins Auffälligkeitsbild
|
||||
/// einfließen — "wenn die negativen Einträge durch die Decke gehen"). Anders als
|
||||
/// <see cref="HasRecentClassRegisterEntry"/> (nur letzte 7 Tage, für die "Klassenbuch"-Badges)
|
||||
/// bewusst über das ganze Jahr, damit sich ein Muster über die Zeit zeigen kann.
|
||||
public int YearNegativeClassRegisterCount { get; init; }
|
||||
/// Mindestens ein Klassenbucheintrag mit dem Schlüsselwort "suspendier(t)" im Zeitraum — soll
|
||||
/// den Score unabhängig von allem anderen sofort deutlich nach oben treiben (Nutzer-Feedback:
|
||||
/// "Suspendiert sollte direkt hochschießen").
|
||||
public bool HasSuspensionEntry { get; init; }
|
||||
/// Gewichtete Summe aus <see cref="ClassRegisterEntryWeight"/> über alle Klassenbucheinträge
|
||||
/// des Schuljahres — fließt in <see cref="PatternScore"/> ein.
|
||||
public double ClassRegisterScoreComponent { get; init; }
|
||||
public int SchoolDaysElapsed { get; init; }
|
||||
/// 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
|
||||
@@ -85,19 +116,134 @@ public sealed record ClassTeacherRosterRow(string StudentName, int? ExternKey, b
|
||||
/// 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 bool HasYearSummary => SchoolDaysElapsed > 0 &&
|
||||
(YearAbsenceDayCount > 0 || YearLateDayCount > 0 || YearNegativeClassRegisterCount > 0);
|
||||
public int YearAbsenceRatePercent =>
|
||||
SchoolDaysElapsed <= 0 ? 0 : (int)Math.Round(100d * YearAbsenceDayCount / SchoolDaysElapsed);
|
||||
public string YearSummaryLabel => HasYearSummary
|
||||
? $"{YearAbsenceRatePercent} % Fehlzeit seit Schuljahresbeginn" : "";
|
||||
public string YearSummaryLabel => !HasYearSummary ? "" :
|
||||
YearAbsenceDayCount > 0 ? $"{YearAbsenceRatePercent} % Fehlzeit seit Schuljahresbeginn"
|
||||
: YearLateDayCount > 0 ? $"{YearLateDayCount}× verspätet, keine Fehltage seit Schuljahresbeginn"
|
||||
: $"{YearNegativeClassRegisterCount} negative Klassenbucheinträge seit Schuljahresbeginn";
|
||||
public string? YearSummaryTooltip => !HasYearSummary ? null :
|
||||
$"{YearAbsenceDayCount} von {SchoolDaysElapsed} Schultagen seit {TermStart:dd.MM.} mit Fehlzeit" +
|
||||
(YearUnexcusedDayCount > 0 ? $" · {YearUnexcusedDayCount} unentschuldigt" : "") +
|
||||
(YearAbsenceDayCount > 0
|
||||
? $"{YearAbsenceDayCount} von {SchoolDaysElapsed} Schultagen seit {TermStart:dd.MM.} mit Fehlzeit" +
|
||||
(YearUnexcusedDayCount > 0 ? $" · {YearUnexcusedDayCount} unentschuldigt" : "")
|
||||
: $"Keine Fehltage seit {TermStart:dd.MM.}") +
|
||||
(YearLateDayCount > 0 ? $" · {YearLateDayCount} Tage verspätet (zählt nicht zur Quote)" : "") +
|
||||
(YearNegativeClassRegisterCount > 0 ? $" · {YearNegativeClassRegisterCount} negative Klassenbucheinträge" : "") +
|
||||
(HasSuspensionEntry ? " · Suspendierung vermerkt" : "") +
|
||||
(HolidayWeekdaysExcluded > 0 ? $" · {HolidayWeekdaysExcluded} Ferientage abgezogen" : "");
|
||||
|
||||
private static readonly string[] ConcerningKeywords = ["hausaufgabe", "mitarbeit"];
|
||||
private static readonly string[] SuspensionKeywords = ["suspendier"];
|
||||
|
||||
/// Auffälligkeits-Score über das ganze bisherige Schuljahr statt nur "heute" (Nutzer-Feedback:
|
||||
/// wer heute da ist, aber davor 5 Tage unentschuldigt fehlte, verschwand bisher komplett aus der
|
||||
/// Liste, während der Musterschüler beim ersten Fehltag des Jahres auf Rang eins landete, sobald
|
||||
/// er der/die einzige "heute Auffällige" war). Reine Summe ohne zeitliche Abklingung — für die
|
||||
/// vom Nutzer genannten Fälle (Muster über die letzten Tage/Wochen) reicht das, ohne die
|
||||
/// zusätzliche Komplexität einer Verfallskurve. Bezieht neben Fehlzeiten auch Klassenbucheinträge
|
||||
/// anderer Lehrkräfte mit ein (<see cref="ClassRegisterScoreComponent"/>) — beides sind
|
||||
/// unabhängige Auffälligkeits-Achsen (Anwesenheit vs. Verhalten/Leistung), die sich addieren
|
||||
/// statt sich gegenseitig zu verdrängen. Gewichte sind einstellbar (<see cref="PatternScoreWeights"/>,
|
||||
/// Nutzer-Entscheidung nach anfänglich festen Konstanten) und die Bereiche eines geschlossenen
|
||||
/// <see cref="Vorgang"/> sind ab dessen Schließ-Datum aus dem Score ausgeklammert (<see cref="Build"/>),
|
||||
/// deshalb jetzt ein zur Build-Zeit berechneter Wert statt einer reinen Formel über die
|
||||
/// (unveränderten, für die Quote weiter vollständigen) Year*-Zähler.
|
||||
public double PatternScore { get; init; }
|
||||
|
||||
/// Score-Anteil, der NACH dem Schließen des zuletzt geschlossenen Vorgangs dieser/dieses
|
||||
/// Schülers*in neu hinzugekommen ist, beschränkt auf dessen eigene Themenbereiche
|
||||
/// (<see cref="MatchDomains"/>) — Grundlage für die "erneut auffällig"-Warnung. 0 ohne
|
||||
/// geschlossenen Vorgang oder ohne neue Ereignisse seitdem.
|
||||
public double RegressionScore { get; init; }
|
||||
public string? RegressionVorgangTitle { get; init; }
|
||||
|
||||
private static bool IsLateReason(string reason) =>
|
||||
reason.Contains("verspät", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
private static bool IsNegativeClassRegisterEntry(UntisForeignClassRegisterEventDto e) =>
|
||||
e.CategoryGroup?.Contains("Negativ", StringComparison.OrdinalIgnoreCase) == true;
|
||||
|
||||
private static bool MatchesAnyKeyword(UntisForeignClassRegisterEventDto e, IReadOnlyList<string> keywords) =>
|
||||
keywords.Any(k =>
|
||||
e.CategoryName?.Contains(k, StringComparison.OrdinalIgnoreCase) == true ||
|
||||
e.Text?.Contains(k, StringComparison.OrdinalIgnoreCase) == true);
|
||||
|
||||
private static double ClassRegisterEntryWeight(UntisForeignClassRegisterEventDto e, PatternScoreWeights weights)
|
||||
{
|
||||
var weight = 0.0;
|
||||
if (IsNegativeClassRegisterEntry(e)) weight += weights.NegativeClassRegisterEntryWeight;
|
||||
if (MatchesAnyKeyword(e, ConcerningKeywords)) weight += weights.ConcerningKeywordBonus;
|
||||
if (MatchesAnyKeyword(e, SuspensionKeywords)) weight += weights.SuspensionKeywordWeight;
|
||||
return weight;
|
||||
}
|
||||
|
||||
// ── Dämpfung nach geschlossenem Vorgang (Nutzer-Entscheidung: bereichsbezogen statt komplett) ──
|
||||
|
||||
private static readonly string[] AttendanceDomainKeywords = ["absent", "fehlzeit", "schwänz", "unentschuldigt"];
|
||||
private static readonly string[] LatenessDomainKeywords = ["versp"];
|
||||
private static readonly string[] ClassbookDomainKeywords =
|
||||
["konflikt", "eskalat", "mitarbeit", "hausaufgabe", "klassenbuch"];
|
||||
|
||||
/// Ordnet die Freitext-Schlagwörter eines Vorgangs den Score-Bereichen zu, die er dämpfen soll
|
||||
/// (gleiche Contains-Konvention wie die "Negativ"/"verspät"-Erkennung andernorts). Kein Treffer
|
||||
/// auf irgendein Schlagwort -> alle drei Bereiche, damit ein geschlossener Vorgang nie
|
||||
/// folgenlos bleibt (z.B. bei einem reinen "Elternkontakt"-Schlagwort ohne klare Zuordnung).
|
||||
public static IReadOnlySet<VorgangScoreDomain> MatchDomains(IReadOnlyList<string> tags)
|
||||
{
|
||||
var domains = new HashSet<VorgangScoreDomain>();
|
||||
if (tags.Any(t => AttendanceDomainKeywords.Any(k => t.Contains(k, StringComparison.OrdinalIgnoreCase))))
|
||||
domains.Add(VorgangScoreDomain.Attendance);
|
||||
if (tags.Any(t => LatenessDomainKeywords.Any(k => t.Contains(k, StringComparison.OrdinalIgnoreCase))))
|
||||
domains.Add(VorgangScoreDomain.Lateness);
|
||||
if (tags.Any(t => ClassbookDomainKeywords.Any(k => t.Contains(k, StringComparison.OrdinalIgnoreCase))))
|
||||
domains.Add(VorgangScoreDomain.Classbook);
|
||||
if (domains.Count == 0)
|
||||
domains = [VorgangScoreDomain.Attendance, VorgangScoreDomain.Lateness, VorgangScoreDomain.Classbook];
|
||||
return domains;
|
||||
}
|
||||
|
||||
/// Pro Bereich das jüngste Schließ-Datum unter den passenden geschlossenen Vorgängen — Ereignisse
|
||||
/// vor diesem Datum zählen für diesen Bereich nicht mehr in <see cref="PatternScore"/>, die volle
|
||||
/// Historie (Year*-Zähler, Quote) bleibt davon unberührt.
|
||||
public static IReadOnlyDictionary<VorgangScoreDomain, DateOnly> ComputeDampeningCutoffs(
|
||||
IReadOnlyList<Vorgang> closedVorgaenge)
|
||||
{
|
||||
var cutoffs = new Dictionary<VorgangScoreDomain, DateOnly>();
|
||||
foreach (var v in closedVorgaenge)
|
||||
{
|
||||
if (v.ClosedAt is not { } closedAt) continue;
|
||||
var closedDate = DateOnly.FromDateTime(closedAt);
|
||||
foreach (var domain in MatchDomains(v.Tags))
|
||||
if (!cutoffs.TryGetValue(domain, out var existing) || closedDate > existing)
|
||||
cutoffs[domain] = closedDate;
|
||||
}
|
||||
return cutoffs;
|
||||
}
|
||||
|
||||
/// Gemeinsame Score-Formel für <see cref="PatternScore"/> und <see cref="RegressionScore"/>:
|
||||
/// <paramref name="cutoffFor"/> liefert je Bereich das früheste zählende Datum (Standard
|
||||
/// <see cref="DateOnly.MinValue"/> = alles zählt, <see cref="DateOnly.MaxValue"/> = Bereich
|
||||
/// bewusst ausgeklammert — so lässt sich derselbe Code für "gedämpfter Gesamt-Score" und
|
||||
/// "nur die Bereiche eines einzelnen Vorgangs, nur danach" wiederverwenden).
|
||||
private static double ComputeScore(IReadOnlyList<ClassAbsenceDaySummaryRow> yearEntries,
|
||||
IReadOnlyList<UntisForeignClassRegisterEventDto> yearRegisterEntries,
|
||||
Func<VorgangScoreDomain, DateOnly> cutoffFor, PatternScoreWeights weights)
|
||||
{
|
||||
var attendanceCutoff = cutoffFor(VorgangScoreDomain.Attendance);
|
||||
var latenessCutoff = cutoffFor(VorgangScoreDomain.Lateness);
|
||||
var classbookCutoff = cutoffFor(VorgangScoreDomain.Classbook);
|
||||
var unexcused = yearEntries.Count(r => r.IsUnexcused && r.Date >= attendanceCutoff);
|
||||
var excused = yearEntries.Count(r => !r.IsUnexcused && !r.IsLate && r.Date >= attendanceCutoff);
|
||||
var late = yearEntries.Count(r => !r.IsUnexcused && r.IsLate && r.Date >= latenessCutoff);
|
||||
var classRegister = yearRegisterEntries
|
||||
.Where(e => TryDate(e.Date, out var d) && d >= classbookCutoff)
|
||||
.Sum(e => ClassRegisterEntryWeight(e, weights));
|
||||
return unexcused * weights.UnexcusedDayWeight + excused * weights.ExcusedDayWeight +
|
||||
late * weights.LateDayWeight + classRegister;
|
||||
}
|
||||
|
||||
public static IReadOnlyList<ClassTeacherRosterRow> Build(
|
||||
IReadOnlyList<UntisStudentRosterCacheEntry> students,
|
||||
IReadOnlyList<ClassAbsenceDaySummaryRow> todayAbsences,
|
||||
@@ -106,8 +252,12 @@ public sealed record ClassTeacherRosterRow(string StudentName, int? ExternKey, b
|
||||
IReadOnlyList<ClassAbsenceDaySummaryRow>? yearAbsences = null,
|
||||
int schoolDaysElapsed = 0,
|
||||
int holidayWeekdaysExcluded = 0,
|
||||
DateOnly termStart = default)
|
||||
DateOnly termStart = default,
|
||||
IReadOnlyList<UntisForeignClassRegisterEventDto>? yearClassRegisterEntries = null,
|
||||
PatternScoreWeights? weights = null,
|
||||
IReadOnlyDictionary<string, IReadOnlyList<Vorgang>>? closedVorgaengeByNameKey = null)
|
||||
{
|
||||
var effectiveWeights = weights ?? new PatternScoreWeights();
|
||||
var referenceDate = today ?? todayAbsences.FirstOrDefault()?.Date ?? DateOnly.FromDateTime(DateTime.Today);
|
||||
var absenceByKey = todayAbsences.Where(a => a.ExternKey is not null)
|
||||
.GroupBy(a => a.ExternKey!.Value).ToDictionary(g => g.Key, g => g.First());
|
||||
@@ -115,6 +265,12 @@ public sealed record ClassTeacherRosterRow(string StudentName, int? ExternKey, b
|
||||
.ToDictionary(g => g.Key, g => g.First());
|
||||
var recentByName = recentClassRegisterEntries.GroupBy(e => UntisNameMatching.NameKey(e.StudentName))
|
||||
.ToDictionary(g => g.Key, g => g.ToList());
|
||||
// Für den Score bewusst über das ganze Schuljahr statt nur die letzten 7 Tage (anders als
|
||||
// recentClassRegisterEntries oben, das für HasRecentClassRegisterEntry/-Badges weiterhin nur
|
||||
// die aktuelle Woche abbildet) — fällt auf recentClassRegisterEntries zurück, wenn der
|
||||
// Aufrufer keine eigene Jahresliste mitgibt (z.B. bestehende Tests).
|
||||
var yearRegisterByName = (yearClassRegisterEntries ?? recentClassRegisterEntries)
|
||||
.GroupBy(e => UntisNameMatching.NameKey(e.StudentName)).ToDictionary(g => g.Key, g => g.ToList());
|
||||
var yearRows = yearAbsences ?? [];
|
||||
var yearByKey = yearRows.Where(a => a.ExternKey is not null)
|
||||
.GroupBy(a => a.ExternKey!.Value).ToDictionary(g => g.Key, g => g.ToList());
|
||||
@@ -129,14 +285,44 @@ public sealed record ClassTeacherRosterRow(string StudentName, int? ExternKey, b
|
||||
var registerEntries = recentByName.GetValueOrDefault(nameKey) ?? [];
|
||||
var yearEntries = (student.ExternKey is { } yearKey ? yearByKey.GetValueOrDefault(yearKey) : null)
|
||||
?? yearByName.GetValueOrDefault(nameKey) ?? [];
|
||||
var yearRegisterEntries = yearRegisterByName.GetValueOrDefault(nameKey) ?? [];
|
||||
|
||||
// Dämpfung/Rückfall (Nutzer-Entscheidung: bereichsbezogen statt kompletter Reset).
|
||||
var closedVorgaenge = closedVorgaengeByNameKey?.GetValueOrDefault(nameKey) ?? [];
|
||||
var dampeningCutoffs = ComputeDampeningCutoffs(closedVorgaenge);
|
||||
var patternScore = ComputeScore(yearEntries, yearRegisterEntries,
|
||||
d => dampeningCutoffs.GetValueOrDefault(d, DateOnly.MinValue), effectiveWeights);
|
||||
|
||||
var mostRecentClosed = closedVorgaenge.Where(v => v.ClosedAt.HasValue)
|
||||
.OrderByDescending(v => v.ClosedAt).FirstOrDefault();
|
||||
var regressionScore = 0.0;
|
||||
if (mostRecentClosed is not null)
|
||||
{
|
||||
var relevantDomains = MatchDomains(mostRecentClosed.Tags);
|
||||
var closedDate = DateOnly.FromDateTime(mostRecentClosed.ClosedAt!.Value);
|
||||
regressionScore = ComputeScore(yearEntries, yearRegisterEntries,
|
||||
d => relevantDomains.Contains(d) ? closedDate : DateOnly.MaxValue, effectiveWeights);
|
||||
}
|
||||
|
||||
return new ClassTeacherRosterRow(student.DisplayName, student.ExternKey, absence is not null,
|
||||
absence is null ? null : $"{absence.TotalAbsentPeriods} Stunde(n) — {absence.StatusLabel}",
|
||||
registerEntries.Count > 0)
|
||||
{
|
||||
TodayAbsence = absence,
|
||||
HasClassRegisterToday = registerEntries.Any(e => TryDate(e.Date, out var date) && date == referenceDate),
|
||||
YearAbsenceDayCount = yearEntries.Count,
|
||||
// Unentschuldigt vor Verspätet vor Entschuldigt, überschneidungsfrei — dieselbe
|
||||
// Kategorisierung wie im Wochentrend (BuildTrend). Nur Verspätung (nicht auch
|
||||
// unentschuldigt) fällt bewusst aus YearAbsenceDayCount heraus, siehe dort.
|
||||
YearAbsenceDayCount = yearEntries.Count(r => r.IsUnexcused || !r.IsLate),
|
||||
YearUnexcusedDayCount = yearEntries.Count(r => r.IsUnexcused),
|
||||
YearLateDayCount = yearEntries.Count(r => !r.IsUnexcused && r.IsLate),
|
||||
YearExcusedDayCount = yearEntries.Count(r => !r.IsUnexcused && !r.IsLate),
|
||||
YearNegativeClassRegisterCount = yearRegisterEntries.Count(IsNegativeClassRegisterEntry),
|
||||
HasSuspensionEntry = yearRegisterEntries.Any(e => MatchesAnyKeyword(e, SuspensionKeywords)),
|
||||
ClassRegisterScoreComponent = yearRegisterEntries.Sum(e => ClassRegisterEntryWeight(e, effectiveWeights)),
|
||||
PatternScore = patternScore,
|
||||
RegressionScore = regressionScore,
|
||||
RegressionVorgangTitle = mostRecentClosed?.Title,
|
||||
SchoolDaysElapsed = schoolDaysElapsed,
|
||||
HolidayWeekdaysExcluded = holidayWeekdaysExcluded,
|
||||
TermStart = termStart,
|
||||
@@ -206,6 +392,14 @@ public sealed record ClassTeacherOpenExcuseRow(string StudentName, DateOnly Date
|
||||
.ToList();
|
||||
}
|
||||
|
||||
/// <summary>Eine einzelne, auf ein Minimum verdichtete Monatszelle im Übersichts-Widget.</summary>
|
||||
public sealed record ClassTeacherCompactCalendarDay(DateOnly Date, string SignalCode,
|
||||
string SignalColorHex, string Tooltip, bool IsToday)
|
||||
{
|
||||
public string DayNumber => Date.Day.ToString();
|
||||
public bool HasSignal => !string.IsNullOrEmpty(SignalCode);
|
||||
}
|
||||
|
||||
public partial class ClassTeacherOverviewViewModel : ObservableObject
|
||||
{
|
||||
private readonly WebUntisSettingsService _settings;
|
||||
@@ -214,24 +408,33 @@ public partial class ClassTeacherOverviewViewModel : ObservableObject
|
||||
private readonly SchoolYearService _schoolYear;
|
||||
private readonly IWorkTaskRepository _workTasks;
|
||||
private readonly IStudentRepository _students;
|
||||
private readonly IDocumentationRepository _documentation;
|
||||
private readonly IVorgangRepository _vorgaenge;
|
||||
private readonly PatternScoreSettingsService _patternScoreSettings;
|
||||
private readonly IParticipationRepository _participation;
|
||||
private readonly IParticipationSessionRepository _participationSessions;
|
||||
private readonly AppLogger? _logger;
|
||||
|
||||
public ClassTeacherDetailsViewModel DetailsTab { get; }
|
||||
public ClassTeacherCasesViewModel CasesTab { get; }
|
||||
public ObservableCollection<ClassTeacherRosterRow> Roster { get; } = [];
|
||||
public ObservableCollection<ClassTeacherRosterRow> PrimaryRoster { get; } = [];
|
||||
public ObservableCollection<ClassTeacherRosterRow> SecondaryRoster { get; } = [];
|
||||
public ObservableCollection<ClassTeacherTrendDay> TrendDays { get; } = [];
|
||||
public ObservableCollection<ClassTeacherPatternNotice> PatternNotices { get; } = [];
|
||||
public ObservableCollection<ClassTeacherOpenExcuseRow> OpenExcuses { get; } = [];
|
||||
public ObservableCollection<ClassTeacherCompactCalendarDay> CompactMonthDays { 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;
|
||||
/// Standardmäßig der Muster-Reiter (3) statt "Auffällig" (0) — Nutzer-Feedback: die reine
|
||||
/// Heute-Ansicht lässt Schüler*innen mit Vorgeschichte (z.B. 5 Tage unentschuldigt letzte
|
||||
/// Woche, heute aber da) komplett verschwinden, während ein erstmaliger Einzelfehltag ganz oben
|
||||
/// steht, sobald er der/die einzige "heute Auffällige" ist.
|
||||
[ObservableProperty] private int _selectedRosterFilter = 3;
|
||||
[ObservableProperty] private string _primarySectionTitle = "Heute auffällig";
|
||||
[ObservableProperty] private string _secondarySectionTitle = "Weitere Schüler*innen";
|
||||
[ObservableProperty] private int _studentCount;
|
||||
@@ -244,6 +447,19 @@ public partial class ClassTeacherOverviewViewModel : ObservableObject
|
||||
[ObservableProperty] private int _unexcusedAbsenceCount;
|
||||
[ObservableProperty] private string _lastUpdatedLabel = "Noch nicht aktualisiert";
|
||||
[ObservableProperty] private int _openExcuseOverflowCount;
|
||||
/// Eigene Dokumentation zu Schüler*innen der Klasse mit "Nacharbeiten"-Status bzw. dem
|
||||
/// "Kritisch"-Tag — Kurzform derselben Zählung wie im Klassenbuch-Tab (siehe
|
||||
/// <see cref="ClassTeacherDetailsViewModel.OwnDocumentationFollowUpCount"/>), hier direkt an
|
||||
/// den "Klassenbuch öffnen"-Button gehängt statt in einer eigenen Kennzahlkarte.
|
||||
[ObservableProperty] private int _ownDocumentationFollowUpCount;
|
||||
[ObservableProperty] private int _ownDocumentationCriticalCount;
|
||||
|
||||
// Zuletzt per Load() geholte WebUntis-Rohdaten, für RefreshFromLocalDataOnly() - damit ein
|
||||
// eingehendes Sync-Ereignis die Ansicht neu aufbauen kann, ohne selbst WebUntis anzufragen.
|
||||
private IReadOnlyList<UntisStudentRosterCacheEntry>? _lastStudents;
|
||||
private IReadOnlyList<UntisClassAbsenceEntryDto>? _lastAbsences;
|
||||
private IReadOnlyList<UntisForeignClassRegisterEventDto>? _lastClassRegisterEvents;
|
||||
private IReadOnlyList<CachedUntisHoliday>? _lastHolidays;
|
||||
|
||||
public bool HomeroomClassConfigured => !string.IsNullOrWhiteSpace(HomeroomClassName);
|
||||
public bool HasPrimaryRoster => PrimaryRoster.Count > 0;
|
||||
@@ -252,9 +468,11 @@ public partial class ClassTeacherOverviewViewModel : ObservableObject
|
||||
public bool HasPatternNotices => PatternNotices.Count > 0;
|
||||
public bool HasOpenExcuses => OpenExcuses.Count > 0;
|
||||
public bool HasOpenExcuseOverflow => OpenExcuseOverflowCount > 0;
|
||||
public bool HasOwnDocumentationAlerts => OwnDocumentationFollowUpCount > 0 || OwnDocumentationCriticalCount > 0;
|
||||
public bool AlertsFilterSelected => SelectedRosterFilter == 0;
|
||||
public bool ClassRegisterFilterSelected => SelectedRosterFilter == 1;
|
||||
public bool AllFilterSelected => SelectedRosterFilter == 2;
|
||||
public bool PatternScoreFilterSelected => SelectedRosterFilter == 3;
|
||||
public int TodayUnexcusedPercent => Percent(TodayUnexcusedCount);
|
||||
public int LatePercent => Percent(LateCount);
|
||||
public int PresentPercent => Percent(PresentCount);
|
||||
@@ -283,15 +501,18 @@ public partial class ClassTeacherOverviewViewModel : ObservableObject
|
||||
public double UnexcusedAbsenceFraction => StudentCount == 0 ? 0 : (double)UnexcusedAbsenceCount / StudentCount;
|
||||
public string DayOverviewTooltip => $"{PresentCount} anwesend · {LateCount} verspätet · " +
|
||||
$"{ExcusedAbsenceCount} entschuldigt · {UnexcusedAbsenceCount} unentschuldigt";
|
||||
public string CompactMonthLabel => DateOnly.FromDateTime(DateTime.Today).ToString("MMMM yyyy",
|
||||
System.Globalization.CultureInfo.GetCultureInfo("de-DE"));
|
||||
|
||||
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,
|
||||
IStudentRepository students, IDocumentationRepository documentation, IVorgangRepository vorgaenge,
|
||||
PatternScoreSettingsService patternScoreSettings, IParticipationRepository participation,
|
||||
IParticipationSessionRepository participationSessions, ClassTeacherDetailsViewModel detailsTab,
|
||||
AppLogger? logger = null)
|
||||
ClassTeacherCasesViewModel casesTab, AppLogger? logger = null)
|
||||
{
|
||||
_settings = settings;
|
||||
_untis = untis;
|
||||
@@ -299,30 +520,65 @@ public partial class ClassTeacherOverviewViewModel : ObservableObject
|
||||
_schoolYear = schoolYear;
|
||||
_workTasks = workTasks;
|
||||
_students = students;
|
||||
_documentation = documentation;
|
||||
_vorgaenge = vorgaenge;
|
||||
_patternScoreSettings = patternScoreSettings;
|
||||
_participation = participation;
|
||||
_participationSessions = participationSessions;
|
||||
_logger = logger;
|
||||
DetailsTab = detailsTab;
|
||||
CasesTab = casesTab;
|
||||
}
|
||||
|
||||
partial void OnHomeroomClassNameChanged(string? value) => OnPropertyChanged(nameof(HomeroomClassConfigured));
|
||||
partial void OnOpenExcuseOverflowCountChanged(int value) => OnPropertyChanged(nameof(HasOpenExcuseOverflow));
|
||||
partial void OnOwnDocumentationFollowUpCountChanged(int value) => OnPropertyChanged(nameof(HasOwnDocumentationAlerts));
|
||||
partial void OnOwnDocumentationCriticalCountChanged(int value) => OnPropertyChanged(nameof(HasOwnDocumentationAlerts));
|
||||
partial void OnSearchTextChanged(string value) => ApplyRosterFilter();
|
||||
partial void OnSelectedRosterFilterChanged(int value)
|
||||
{
|
||||
OnPropertyChanged(nameof(AlertsFilterSelected));
|
||||
OnPropertyChanged(nameof(ClassRegisterFilterSelected));
|
||||
OnPropertyChanged(nameof(AllFilterSelected));
|
||||
OnPropertyChanged(nameof(PatternScoreFilterSelected));
|
||||
ApplyRosterFilter();
|
||||
}
|
||||
|
||||
private void ResetRosterState()
|
||||
{
|
||||
ActiveTabIndex = 0;
|
||||
Roster.Clear(); PrimaryRoster.Clear(); SecondaryRoster.Clear(); TrendDays.Clear(); PatternNotices.Clear();
|
||||
OpenExcuses.Clear(); CompactMonthDays.Clear(); OpenExcuseOverflowCount = 0;
|
||||
}
|
||||
|
||||
// Wird beim Navigieren auf diese Seite aufgerufen statt LoadCommand: baut nur den lokalen
|
||||
// Grundzustand auf (keine WebUntis-Anfrage), damit Öffnen der Ansicht nicht auf einen
|
||||
// WebUntis-Login+Report-Abruf wartet. Die eigentlichen Fehlzeiten/Klassenbuch-Daten holt sich
|
||||
// die Nutzerin bewusst über den "Aktualisieren"-Button (LoadCommand).
|
||||
public void PrepareForDisplay()
|
||||
{
|
||||
if (Busy) return;
|
||||
HomeroomClassName = _settings.HomeroomClassName;
|
||||
ResetRosterState();
|
||||
if (!HomeroomClassConfigured)
|
||||
{
|
||||
Status = "Noch keine Klasse ausgewählt.";
|
||||
}
|
||||
else
|
||||
{
|
||||
DetailsTab.Initialize(HomeroomClassName!);
|
||||
CasesTab.Initialize(HomeroomClassName!);
|
||||
if (StudentCount == 0)
|
||||
Status = "Noch nicht geladen – auf „Aktualisieren“ klicken, um aktuelle WebUntis-Daten zu holen.";
|
||||
}
|
||||
NotifyRosterState();
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private async Task Load()
|
||||
{
|
||||
HomeroomClassName = _settings.HomeroomClassName;
|
||||
ActiveTabIndex = 0;
|
||||
Roster.Clear(); PrimaryRoster.Clear(); SecondaryRoster.Clear(); TrendDays.Clear(); PatternNotices.Clear();
|
||||
OpenExcuses.Clear(); OpenExcuseOverflowCount = 0;
|
||||
ResetRosterState();
|
||||
if (!HomeroomClassConfigured)
|
||||
{
|
||||
Status = "Noch keine Klasse ausgewählt.";
|
||||
@@ -332,33 +588,95 @@ public partial class ClassTeacherOverviewViewModel : ObservableObject
|
||||
|
||||
var className = HomeroomClassName!;
|
||||
DetailsTab.Initialize(className);
|
||||
CasesTab.Initialize(className);
|
||||
Busy = true;
|
||||
NotifyRosterState();
|
||||
try
|
||||
{
|
||||
var today = DateOnly.FromDateTime(DateTime.Today);
|
||||
var sevenDayStart = today.AddDays(-6);
|
||||
var trendDays = LastSchoolDays(today, 7);
|
||||
// Fehlzeiten seit Schuljahresbeginn statt nur der letzten 7 Tage: liefert die
|
||||
// Kennzahl unten (YearSummaryLabel) und den Nenner für die Fehlquote in einem Abruf.
|
||||
// UntisReportCacheService cached diesen "kalten" Bereich dauerhaft (siehe dort) —
|
||||
// teuer ist nur der erste Abruf pro Schuljahr, nicht jedes Öffnen der Ansicht.
|
||||
var yearStart = _schoolYear.SchoolYearStart(_schoolYear.CurrentSchoolYear(today));
|
||||
var (today, yearStart, sevenDayStart, trendDays) = RosterDateWindow();
|
||||
var studentsTask = _cache.GetStudentRosterAsync(className);
|
||||
var absencesTask = _cache.GetAbsencesAsync(className, yearStart, today);
|
||||
var classRegisterTask = _cache.GetClassRegisterEventsAsync(className, sevenDayStart, today);
|
||||
// Seit Schuljahresbeginn statt nur die letzte Woche (Nutzer-Feedback: Klassenbucheinträge
|
||||
// anderer Lehrkräfte — z.B. fehlende Hausaufgaben, schlechte Mitarbeit, im Extremfall eine
|
||||
// Suspendierung — sollen wie die Fehlzeiten in den Auffälligkeits-Score einfließen, nicht
|
||||
// nur die letzten 7 Tage). Dieselbe "kalte Historie bleibt gecacht"-Logik wie bei den
|
||||
// Fehlzeiten oben, kein zusätzliches Abruf-Risiko. Die "letzte 7 Tage"-Badges
|
||||
// (RecentClassRegisterCount, HasRecentClassRegisterEntry) werden weiterhin unten aus
|
||||
// genau diesem einen Abruf herausgefiltert statt separat erneut abgerufen.
|
||||
var classRegisterTask = _cache.GetClassRegisterEventsAsync(className, yearStart, today);
|
||||
var holidaysTask = GetHolidaysAsync();
|
||||
await Task.WhenAll(studentsTask, absencesTask, classRegisterTask, holidaysTask);
|
||||
|
||||
var absenceDaysYear = ClassAbsenceDaySummaryRow.GroupByStudentAndDay(absencesTask.Result);
|
||||
_lastStudents = studentsTask.Result;
|
||||
_lastAbsences = absencesTask.Result;
|
||||
_lastClassRegisterEvents = classRegisterTask.Result;
|
||||
_lastHolidays = holidaysTask.Result;
|
||||
BuildRosterFromCachedData(studentsTask.Result, absencesTask.Result, classRegisterTask.Result,
|
||||
holidaysTask.Result, today, yearStart, sevenDayStart, trendDays);
|
||||
LastUpdatedLabel = $"Zuletzt aktualisiert: Heute, {DateTime.Now:HH:mm}";
|
||||
}
|
||||
catch (WebUntisIntegrationException ex) { Status = ex.Message; NotifyRosterState(); }
|
||||
finally { Busy = false; NotifyRosterState(); }
|
||||
}
|
||||
|
||||
// Baut die Ansicht ausschließlich aus den zuletzt per Load() geholten WebUntis-Daten plus
|
||||
// aktuellen lokalen Daten (Vorgänge/Dokumentation) neu auf - löst NIE einen WebUntis-Request
|
||||
// aus, auch nicht über UntisReportCacheService' stündliches Hot-Window-Refresh. Wird bei
|
||||
// eingehenden Sync-Ereignissen aufgerufen (siehe MainWindowViewModel.RefreshCurrentPage);
|
||||
// echte WebUntis-Daten holt bewusst ausschließlich der "Aktualisieren"-Button (LoadCommand),
|
||||
// wenn die Nutzerin sich dafür entscheidet.
|
||||
public void RefreshFromLocalDataOnly()
|
||||
{
|
||||
if (Busy || _lastStudents is null || _lastAbsences is null ||
|
||||
_lastClassRegisterEvents is null || _lastHolidays is null) return;
|
||||
var (today, yearStart, sevenDayStart, trendDays) = RosterDateWindow();
|
||||
ResetRosterState();
|
||||
BuildRosterFromCachedData(_lastStudents, _lastAbsences, _lastClassRegisterEvents, _lastHolidays,
|
||||
today, yearStart, sevenDayStart, trendDays);
|
||||
NotifyRosterState();
|
||||
}
|
||||
|
||||
private (DateOnly Today, DateOnly YearStart, DateOnly SevenDayStart, IReadOnlyList<DateOnly> TrendDays) RosterDateWindow()
|
||||
{
|
||||
var today = DateOnly.FromDateTime(DateTime.Today);
|
||||
var yearStart = _schoolYear.SchoolYearStart(_schoolYear.CurrentSchoolYear(today));
|
||||
return (today, yearStart, today.AddDays(-6), LastSchoolDays(today, 7));
|
||||
}
|
||||
|
||||
private void BuildRosterFromCachedData(
|
||||
IReadOnlyList<UntisStudentRosterCacheEntry> students,
|
||||
IReadOnlyList<UntisClassAbsenceEntryDto> absences,
|
||||
IReadOnlyList<UntisForeignClassRegisterEventDto> classRegisterEvents,
|
||||
IReadOnlyList<CachedUntisHoliday> holidays,
|
||||
DateOnly today, DateOnly yearStart, DateOnly sevenDayStart, IReadOnlyList<DateOnly> trendDays)
|
||||
{
|
||||
var absenceDaysYear = ClassAbsenceDaySummaryRow.GroupByStudentAndDay(absences);
|
||||
var todayAbsences = absenceDaysYear.Where(a => a.Date == today).ToList();
|
||||
var termStart = EstimateTermStart(absenceDaysYear, yearStart);
|
||||
var rawWeekdaysElapsed = CountSchoolWeekdays(termStart, today, []);
|
||||
var schoolDaysElapsed = CountSchoolWeekdays(termStart, today, holidaysTask.Result);
|
||||
var schoolDaysElapsed = CountSchoolWeekdays(termStart, today, holidays);
|
||||
var holidayWeekdaysExcluded = rawWeekdaysElapsed - schoolDaysElapsed;
|
||||
foreach (var row in ClassTeacherRosterRow.Build(studentsTask.Result, todayAbsences,
|
||||
classRegisterTask.Result, today, absenceDaysYear, schoolDaysElapsed,
|
||||
holidayWeekdaysExcluded, termStart)) Roster.Add(row);
|
||||
var recentRegisterEntries = classRegisterEvents
|
||||
.Where(e => TryDate(e.Date, out var d) && d >= sevenDayStart).ToList();
|
||||
// Geschlossene Vorgänge je Klassen-Roster-Namen (für die bereichsbezogene Score-Dämpfung/
|
||||
// Rückfall-Erkennung in Build) — gleicher Namensabgleich wie bei den übrigen Build*-Methoden
|
||||
// dieser Klasse (z.B. BuildAttendanceParticipationNotices).
|
||||
var localStudents = _students.GetAll();
|
||||
var closedVorgaengeByNameKey = students
|
||||
.Select(r => (Roster: r, Student: MatchStudent(r.DisplayName, localStudents)))
|
||||
.Where(x => x.Student is not null)
|
||||
.ToDictionary(
|
||||
x => UntisNameMatching.NameKey(x.Roster.DisplayName),
|
||||
x => (IReadOnlyList<Vorgang>)_vorgaenge.GetByStudent(x.Student!.Id)
|
||||
.Where(v => v.Status == VorgangStatus.Closed).ToList());
|
||||
foreach (var row in ClassTeacherRosterRow.Build(students, todayAbsences,
|
||||
recentRegisterEntries, today, absenceDaysYear, schoolDaysElapsed,
|
||||
holidayWeekdaysExcluded, termStart, classRegisterEvents,
|
||||
_patternScoreSettings.Load(), closedVorgaengeByNameKey)) Roster.Add(row);
|
||||
|
||||
StudentCount = Roster.Count;
|
||||
TodayAlertCount = Roster.Count(r => r.HasAbsenceToday);
|
||||
@@ -367,24 +685,24 @@ public partial class ClassTeacherOverviewViewModel : ObservableObject
|
||||
PresentCount = Roster.Count(r => !r.HasAbsenceToday);
|
||||
ExcusedAbsenceCount = Roster.Count(r => r.HasAbsenceToday && !r.IsLate && !r.IsUnexcused);
|
||||
UnexcusedAbsenceCount = Roster.Count(r => r.HasAbsenceToday && !r.IsLate && r.IsUnexcused);
|
||||
RecentClassRegisterCount = classRegisterTask.Result.Count;
|
||||
RecentClassRegisterCount = recentRegisterEntries.Count;
|
||||
BuildTrend(absenceDaysYear, trendDays);
|
||||
BuildCompactMonth(absenceDaysYear, classRegisterEvents, today);
|
||||
BuildPatternNotices(absenceDaysYear, sevenDayStart);
|
||||
BuildWeekdayPatternNotices(absenceDaysYear);
|
||||
BuildAttendanceParticipationNotices();
|
||||
BuildVorgangRegressionNotices();
|
||||
BuildOwnDocumentationCounts();
|
||||
BuildOpenExcuses(absenceDaysYear, today);
|
||||
LastUpdatedLabel = $"Zuletzt aktualisiert: Heute, {DateTime.Now:HH:mm}";
|
||||
Status = $"{StudentCount} Schüler*innen · {TodayAlertCount} heute auffällig";
|
||||
ApplyRosterFilter();
|
||||
NotifySummary();
|
||||
}
|
||||
catch (WebUntisIntegrationException ex) { Status = ex.Message; NotifyRosterState(); }
|
||||
finally { Busy = false; NotifyRosterState(); }
|
||||
}
|
||||
|
||||
[RelayCommand] private void ShowAlerts() => SelectedRosterFilter = 0;
|
||||
[RelayCommand] private void ShowClassRegister() => SelectedRosterFilter = 1;
|
||||
[RelayCommand] private void ShowAll() => SelectedRosterFilter = 2;
|
||||
[RelayCommand] private void ShowPatternScore() => SelectedRosterFilter = 3;
|
||||
|
||||
[RelayCommand]
|
||||
private void OpenClassRegister()
|
||||
@@ -402,6 +720,64 @@ public partial class ClassTeacherOverviewViewModel : ObservableObject
|
||||
DetailsTab.LoadCommand.Execute(null);
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void OpenMonthlyCalendar()
|
||||
{
|
||||
var today = DateOnly.FromDateTime(DateTime.Today);
|
||||
DetailsTab.StudentFilter = "";
|
||||
DetailsTab.CalendarMonth = new DateOnly(today.Year, today.Month, 1);
|
||||
ActiveTabIndex = 2;
|
||||
DetailsTab.ShowCalendarCommand.Execute(null);
|
||||
}
|
||||
|
||||
private void BuildCompactMonth(IReadOnlyList<ClassAbsenceDaySummaryRow> absences,
|
||||
IReadOnlyList<UntisForeignClassRegisterEventDto> registerEntries, DateOnly today)
|
||||
{
|
||||
CompactMonthDays.Clear();
|
||||
foreach (var day in BuildCompactMonthDays(new DateOnly(today.Year, today.Month, 1),
|
||||
absences, registerEntries, today))
|
||||
CompactMonthDays.Add(day);
|
||||
}
|
||||
|
||||
public static IReadOnlyList<ClassTeacherCompactCalendarDay> BuildCompactMonthDays(DateOnly month,
|
||||
IReadOnlyList<ClassAbsenceDaySummaryRow> absences,
|
||||
IReadOnlyList<UntisForeignClassRegisterEventDto> registerEntries, DateOnly today)
|
||||
{
|
||||
var first = new DateOnly(month.Year, month.Month, 1);
|
||||
var registerRows = registerEntries
|
||||
.Select(e => (Entry: e, Valid: TryDate(e.Date, out var date), Date: date))
|
||||
.Where(x => x.Valid && x.Date.Year == first.Year && x.Date.Month == first.Month)
|
||||
.Select(x => new ClassTeacherClassRegisterRow(x.Date, x.Entry.Subject, x.Entry.StudentName,
|
||||
x.Entry.TeacherUsername, x.Entry.CategoryName, x.Entry.CategoryGroup, x.Entry.Text))
|
||||
.ToList();
|
||||
var result = new List<ClassTeacherCompactCalendarDay>();
|
||||
var days = DateTime.DaysInMonth(first.Year, first.Month);
|
||||
|
||||
for (var number = 1; number <= days; number++)
|
||||
{
|
||||
var date = new DateOnly(first.Year, first.Month, number);
|
||||
var events = ClassTeacherDetailsViewModel.BuildDayEvents(date, absences, registerRows,
|
||||
includeHomework: false);
|
||||
var strongest = events.OrderBy(e => SignalPriority(e.Kind)).FirstOrDefault();
|
||||
var tooltip = events.Count == 0
|
||||
? $"{date:dd.MM.yyyy}: unauffällig"
|
||||
: $"{date:dd.MM.yyyy}\n" + string.Join("\n", events.Select(e => e.Tooltip));
|
||||
result.Add(new ClassTeacherCompactCalendarDay(date, strongest?.Code ?? "",
|
||||
strongest?.ColorHex ?? "#00000000", tooltip, date == today));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private static int SignalPriority(ClassTeacherCalendarEventKind kind) => kind switch
|
||||
{
|
||||
ClassTeacherCalendarEventKind.Unexcused => 0,
|
||||
ClassTeacherCalendarEventKind.ClassRegister => 1,
|
||||
ClassTeacherCalendarEventKind.Absent => 2,
|
||||
ClassTeacherCalendarEventKind.Late => 3,
|
||||
ClassTeacherCalendarEventKind.Excused => 4,
|
||||
_ => 5,
|
||||
};
|
||||
|
||||
[RelayCommand]
|
||||
private void ShowDetailsForStudent(ClassTeacherRosterRow? row)
|
||||
{
|
||||
@@ -432,6 +808,18 @@ public partial class ClassTeacherOverviewViewModel : ObservableObject
|
||||
PrimarySectionTitle = "Klassenbucheinträge der letzten 7 Tage";
|
||||
foreach (var row in query.Where(r => r.HasRecentClassRegisterEntry)) PrimaryRoster.Add(row);
|
||||
}
|
||||
else if (SelectedRosterFilter == 3)
|
||||
{
|
||||
// Nutzer-Feedback: "heute auffällig" allein lässt Schüler*innen mit echter Vorgeschichte
|
||||
// verschwinden, sobald sie an einem Tag zufällig anwesend sind, und lässt einen
|
||||
// einmaligen Erstfehltag ganz oben stehen, sobald er der/die einzige "heute Auffällige"
|
||||
// ist. Deshalb eigene Sortierung nach dem kumulierten Score übers Schuljahr statt nach
|
||||
// dem heutigen Status — bewusst unabhängig von HasAbsenceToday.
|
||||
PrimarySectionTitle = "Nach Muster seit Schuljahresbeginn";
|
||||
foreach (var row in query.Where(r => r.PatternScore > 0)
|
||||
.OrderByDescending(r => r.PatternScore).ThenBy(r => r.StudentName))
|
||||
PrimaryRoster.Add(row);
|
||||
}
|
||||
else
|
||||
{
|
||||
PrimarySectionTitle = "Heute auffällig";
|
||||
@@ -464,6 +852,9 @@ public partial class ClassTeacherOverviewViewModel : ObservableObject
|
||||
(double)day.Unexcused / max, (double)day.LateExcused / max, (double)day.Excused / max));
|
||||
}
|
||||
|
||||
private static bool TryDate(int value, out DateOnly date) =>
|
||||
DateOnly.TryParseExact(value.ToString(), "yyyyMMdd", out date);
|
||||
|
||||
/// Letzte <paramref name="count"/> Werktage bis einschließlich <paramref name="end"/>, ohne
|
||||
/// Ferienkalender (bewusste Vereinfachung, siehe TODO.md 12.4-Nachtrag) — die App kennt keine
|
||||
/// Schulferien, nur Wochenenden.
|
||||
@@ -687,6 +1078,44 @@ public partial class ClassTeacherOverviewViewModel : ObservableObject
|
||||
OnPropertyChanged(nameof(HasPatternNotices));
|
||||
}
|
||||
|
||||
/// Schwelle für die "erneut auffällig"-Warnung nach einem geschlossenen Vorgang (Nutzer-
|
||||
/// Entscheidung): erst ab einem spürbaren Muster, nicht schon bei der ersten Kleinigkeit nach
|
||||
/// Abschluss — ≈ 1 unentschuldigter Tag oder vergleichbar (<see cref="PatternScoreWeights.UnexcusedDayWeight"/>
|
||||
/// Default 3,0).
|
||||
private const double RegressionThreshold = 3.0;
|
||||
|
||||
private void BuildVorgangRegressionNotices()
|
||||
{
|
||||
foreach (var row in Roster.Where(r => r.RegressionScore >= RegressionThreshold &&
|
||||
r.RegressionVorgangTitle is not null))
|
||||
{
|
||||
if (PatternNotices.Any(n => n.StudentName == row.StudentName)) continue;
|
||||
PatternNotices.Add(new ClassTeacherPatternNotice(row.StudentName,
|
||||
$"Nach Bearbeitung von \"{row.RegressionVorgangTitle}\" erneut auffällig",
|
||||
ClassTeacherStatusKind.Danger, CanCreateReminder: true));
|
||||
}
|
||||
OnPropertyChanged(nameof(HasPatternNotices));
|
||||
}
|
||||
|
||||
/// Nacharbeiten-/Kritisch-Zählung für die eigene Dokumentation der Klasse — bewusst nicht auf
|
||||
/// die letzten 7 Tage begrenzt wie <see cref="RecentClassRegisterCount"/>: ein seit Wochen
|
||||
/// offener Entwurf oder ein als "Kritisch" markierter Eintrag soll nicht aus der Kennzahl
|
||||
/// verschwinden, nur weil er nicht mehr taufrisch ist. Selbe Namensabgleich-Logik wie
|
||||
/// <see cref="BuildAttendanceParticipationNotices"/>.
|
||||
private void BuildOwnDocumentationCounts()
|
||||
{
|
||||
var students = _students.GetAll();
|
||||
var matchedIds = Roster
|
||||
.Select(r => MatchStudent(r.StudentName, students))
|
||||
.Where(s => s is not null)
|
||||
.Select(s => s!.Id)
|
||||
.ToHashSet();
|
||||
var docs = _documentation.GetAll().Where(d => !d.IsDeleted && matchedIds.Contains(d.StudentId)).ToList();
|
||||
OwnDocumentationFollowUpCount = docs.Count(d => d.IsDraft);
|
||||
OwnDocumentationCriticalCount = docs.Count(d =>
|
||||
d.Tags.Any(t => string.Equals(t, "Kritisch", StringComparison.OrdinalIgnoreCase)));
|
||||
}
|
||||
|
||||
/// Reine, ohne Repository-Zugriff testbare Zuordnungslogik. Nutzt <c>FirstName</c>/<c>LastName</c>
|
||||
/// statt <see cref="Student.FullName"/>, weil dessen "Nachname, Vorname"-Format mit Komma den
|
||||
/// leerzeichenbasierten Wortabgleich in <see cref="UntisNameMatching"/> verfälschen würde
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using LehrerApp.Core.Services;
|
||||
|
||||
namespace LehrerApp.Desktop.ViewModels.ClassTeacher;
|
||||
|
||||
/// <summary>Dialog zum Anpassen der <see cref="PatternScoreWeights"/> für den "Gesamtbild"-Reiter
|
||||
/// der Klassenlehreransicht — ursprünglich feste Konstanten, auf Nutzerwunsch jetzt einstellbar.</summary>
|
||||
public partial class PatternScoreWeightsDialogViewModel : ObservableObject
|
||||
{
|
||||
private readonly PatternScoreSettingsService _settings;
|
||||
|
||||
[ObservableProperty] private double _unexcusedDayWeight;
|
||||
[ObservableProperty] private double _excusedDayWeight;
|
||||
[ObservableProperty] private double _lateDayWeight;
|
||||
[ObservableProperty] private double _negativeClassRegisterEntryWeight;
|
||||
[ObservableProperty] private double _concerningKeywordBonus;
|
||||
[ObservableProperty] private double _suspensionKeywordWeight;
|
||||
|
||||
public PatternScoreWeightsDialogViewModel(PatternScoreSettingsService settings)
|
||||
{
|
||||
_settings = settings;
|
||||
var weights = settings.Load();
|
||||
UnexcusedDayWeight = weights.UnexcusedDayWeight;
|
||||
ExcusedDayWeight = weights.ExcusedDayWeight;
|
||||
LateDayWeight = weights.LateDayWeight;
|
||||
NegativeClassRegisterEntryWeight = weights.NegativeClassRegisterEntryWeight;
|
||||
ConcerningKeywordBonus = weights.ConcerningKeywordBonus;
|
||||
SuspensionKeywordWeight = weights.SuspensionKeywordWeight;
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void Save() => _settings.Save(new PatternScoreWeights
|
||||
{
|
||||
UnexcusedDayWeight = UnexcusedDayWeight,
|
||||
ExcusedDayWeight = ExcusedDayWeight,
|
||||
LateDayWeight = LateDayWeight,
|
||||
NegativeClassRegisterEntryWeight = NegativeClassRegisterEntryWeight,
|
||||
ConcerningKeywordBonus = ConcerningKeywordBonus,
|
||||
SuspensionKeywordWeight = SuspensionKeywordWeight,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
using System.Collections.ObjectModel;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using LehrerApp.Core.Models;
|
||||
using LehrerApp.Desktop.ViewModels.Students;
|
||||
|
||||
namespace LehrerApp.Desktop.ViewModels.ClassTeacher;
|
||||
|
||||
// ── Anzeige-Helfer ────────────────────────────────────────────────────────
|
||||
|
||||
public static class VorgangTagDisplay
|
||||
{
|
||||
// Vom Nutzer selbst genannte Schlagwörter — eigene Labels bleiben trotzdem frei möglich
|
||||
// (AutoCompleteBox, gleiches Muster wie DocumentationTagDisplay.Suggestions).
|
||||
public static string[] Suggestions { get; } =
|
||||
["Absentismus", "Hausaufgaben", "Verspätungen", "Konflikte", "Mitarbeit", "Elternkontakt", "Eskalation"];
|
||||
}
|
||||
|
||||
public static class VorgangStatusDisplay
|
||||
{
|
||||
public static string Label(VorgangStatus status) => status == VorgangStatus.Closed ? "Geschlossen" : "Offen";
|
||||
}
|
||||
|
||||
/// Ergebnis des "→ Vorgang"-Auswahldialogs beim Anheften einer Klassenbuchzeile: entweder ein
|
||||
/// bestehender, offener Vorgang oder der Titel für einen neu anzulegenden.
|
||||
public sealed record PinToVorgangChoice(Guid? ExistingVorgangId, string? NewVorgangTitle);
|
||||
|
||||
/// Eingefrorene WebUntis-Klassenbuchzeile mit deutscher Anzeige-Aufbereitung — das Core-Modell
|
||||
/// <see cref="VorgangClassRegisterEntry"/> bleibt bewusst framework-frei ohne Formatierungslogik.
|
||||
public sealed record VorgangClassRegisterEntryRow(VorgangClassRegisterEntry Model)
|
||||
{
|
||||
public string DateLabel => Model.Date.ToString("dd.MM.yyyy");
|
||||
public string StudentName => Model.StudentName;
|
||||
public string SummaryLabel => string.Join(" · ", new[] { Model.Subject, Model.CategoryName, Model.Text }
|
||||
.Where(v => !string.IsNullOrWhiteSpace(v)));
|
||||
}
|
||||
|
||||
// ── Mehrfachauswahl Schüler*innen (Anlegen/Bearbeiten-Dialog) ────────────────
|
||||
|
||||
public partial class VorgangStudentOption(Guid studentId, string name) : ObservableObject
|
||||
{
|
||||
public Guid StudentId { get; } = studentId;
|
||||
public string Name { get; } = name;
|
||||
[ObservableProperty] private bool _isSelected;
|
||||
}
|
||||
|
||||
// ── Listen-Eintrag mit aufklappbarer Detailansicht ───────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Ein Vorgang in der Liste des "Vorgänge"-Tabs. Verknüpfte Dokumentation/Klassenbucheinträge
|
||||
/// werden von <see cref="ClassTeacherCasesViewModel"/> beim Laden befüllt; Link/Unlink-Aktionen
|
||||
/// laufen über die hier übergebenen Callbacks zurück ins ViewModel (statt eigenem Repository-
|
||||
/// Zugriff hier), damit dieser Wrapper ein reiner Anzeige-Baustein bleibt — analog
|
||||
/// <see cref="DocumentationItem"/>.
|
||||
/// </summary>
|
||||
public partial class VorgangItem : ObservableObject
|
||||
{
|
||||
private readonly Func<VorgangItem, DocumentationItem, Task> _onLink;
|
||||
private readonly Func<VorgangItem, DocumentationItem, Task> _onUnlink;
|
||||
private readonly Func<VorgangItem, VorgangClassRegisterEntryRow, Task> _onRemoveClassRegisterEntry;
|
||||
private readonly Func<DocumentationItem, Task> _onEditDocumentation;
|
||||
|
||||
public Vorgang Model { get; }
|
||||
public string StudentNames { get; }
|
||||
public List<TagChip> TagChips { get; }
|
||||
public string StatusLabel => VorgangStatusDisplay.Label(Model.Status);
|
||||
public bool IsOpen => Model.Status == VorgangStatus.Open;
|
||||
public string CreatedLabel => $"Angelegt {Model.CreatedAt:dd.MM.yyyy}";
|
||||
|
||||
public ObservableCollection<DocumentationItem> LinkedDocumentation { get; } = [];
|
||||
public ObservableCollection<DocumentationItem> AvailableDocumentation { get; } = [];
|
||||
public ObservableCollection<VorgangClassRegisterEntryRow> ClassRegisterRows { get; } = [];
|
||||
|
||||
public bool HasLinkedDocumentation => LinkedDocumentation.Count > 0;
|
||||
public bool HasAvailableDocumentation => AvailableDocumentation.Count > 0;
|
||||
public bool HasClassRegisterRows => ClassRegisterRows.Count > 0;
|
||||
|
||||
[ObservableProperty] private bool _isRevealed;
|
||||
|
||||
public VorgangItem(Vorgang model, string studentNames,
|
||||
Func<VorgangItem, DocumentationItem, Task> onLink,
|
||||
Func<VorgangItem, DocumentationItem, Task> onUnlink,
|
||||
Func<VorgangItem, VorgangClassRegisterEntryRow, Task> onRemoveClassRegisterEntry,
|
||||
Func<DocumentationItem, Task> onEditDocumentation)
|
||||
{
|
||||
Model = model;
|
||||
StudentNames = studentNames;
|
||||
TagChips = model.Tags.Select(t => new TagChip(t)).ToList();
|
||||
_onLink = onLink;
|
||||
_onUnlink = onUnlink;
|
||||
_onRemoveClassRegisterEntry = onRemoveClassRegisterEntry;
|
||||
_onEditDocumentation = onEditDocumentation;
|
||||
}
|
||||
|
||||
[RelayCommand] private void Reveal() => IsRevealed = !IsRevealed;
|
||||
|
||||
/// Öffnet den vollen Dokumentations-Dialog zum Nachlesen/Bearbeiten, ohne den Vorgänge-Tab zu
|
||||
/// verlassen (Nutzer-Feedback: bisher musste man dafür in den Schülerdatensatz wechseln).
|
||||
[RelayCommand]
|
||||
private async Task EditDocumentation(DocumentationItem? doc)
|
||||
{
|
||||
if (doc is not null) await _onEditDocumentation(doc);
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private async Task Link(DocumentationItem? doc)
|
||||
{
|
||||
if (doc is not null) await _onLink(this, doc);
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private async Task Unlink(DocumentationItem? doc)
|
||||
{
|
||||
if (doc is not null) await _onUnlink(this, doc);
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private async Task RemoveClassRegisterEntry(VorgangClassRegisterEntryRow? row)
|
||||
{
|
||||
if (row is not null) await _onRemoveClassRegisterEntry(this, row);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Dialog: Vorgang anlegen/bearbeiten ────────────────────────────────────
|
||||
|
||||
public partial class VorgangDialogViewModel : ObservableObject
|
||||
{
|
||||
private readonly Vorgang? _editing;
|
||||
|
||||
public ObservableCollection<VorgangStudentOption> StudentOptions { get; }
|
||||
|
||||
[ObservableProperty] private string _title = "";
|
||||
[ObservableProperty] private string _description = "";
|
||||
[ObservableProperty] private string _newTag = "";
|
||||
public ObservableCollection<string> Tags { get; } = [];
|
||||
public string[] TagSuggestions => VorgangTagDisplay.Suggestions;
|
||||
|
||||
[ObservableProperty] private string _titleError = "";
|
||||
[ObservableProperty] private string _studentsError = "";
|
||||
|
||||
public string DialogTitle => _editing is null ? "Vorgang anlegen" : "Vorgang bearbeiten";
|
||||
public Vorgang? Result { get; private set; }
|
||||
|
||||
public VorgangDialogViewModel(List<StudentOption> rosterStudents, Vorgang? editing)
|
||||
{
|
||||
_editing = editing;
|
||||
StudentOptions = new ObservableCollection<VorgangStudentOption>(rosterStudents.Select(s =>
|
||||
new VorgangStudentOption(s.Id, s.Name) { IsSelected = editing?.StudentIds.Contains(s.Id) == true }));
|
||||
if (editing is null) return;
|
||||
|
||||
Title = editing.Title;
|
||||
Description = editing.Description;
|
||||
foreach (var tag in editing.Tags) Tags.Add(tag);
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void AddTag()
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(NewTag)) return;
|
||||
var tag = NewTag.Trim();
|
||||
if (!Tags.Contains(tag)) Tags.Add(tag);
|
||||
NewTag = "";
|
||||
}
|
||||
|
||||
[RelayCommand] private void RemoveTag(string? tag) { if (tag is not null) Tags.Remove(tag); }
|
||||
|
||||
[RelayCommand]
|
||||
private void Save()
|
||||
{
|
||||
TitleError = ""; StudentsError = "";
|
||||
var valid = true;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(Title)) { TitleError = "Titel erforderlich."; valid = false; }
|
||||
var selectedIds = StudentOptions.Where(s => s.IsSelected).Select(s => s.StudentId).ToList();
|
||||
if (selectedIds.Count == 0) { StudentsError = "Mindestens eine/n Schüler*in auswählen."; valid = false; }
|
||||
|
||||
if (!valid) return;
|
||||
|
||||
Result = _editing ?? new Vorgang();
|
||||
Result.Title = Title.Trim();
|
||||
Result.Description = (Description ?? "").Trim();
|
||||
Result.StudentIds = selectedIds;
|
||||
Result.Tags = Tags.ToList();
|
||||
}
|
||||
}
|
||||
|
||||
// ── Dialog: Klassenbuchzeile an (bestehenden oder neuen) Vorgang anheften ────
|
||||
|
||||
public partial class PinToVorgangDialogViewModel : ObservableObject
|
||||
{
|
||||
public string RowSummary { get; }
|
||||
public List<VorgangItem> MatchingOpenCases { get; }
|
||||
public bool HasMatchingOpenCases => MatchingOpenCases.Count > 0;
|
||||
|
||||
[ObservableProperty] private VorgangItem? _selectedCase;
|
||||
[ObservableProperty] private string _newTitle = "";
|
||||
[ObservableProperty] private string _error = "";
|
||||
|
||||
public PinToVorgangChoice? Result { get; private set; }
|
||||
|
||||
public PinToVorgangDialogViewModel(string rowSummary, List<VorgangItem> matchingOpenCases)
|
||||
{
|
||||
RowSummary = rowSummary;
|
||||
MatchingOpenCases = matchingOpenCases;
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void Confirm()
|
||||
{
|
||||
Error = "";
|
||||
if (SelectedCase is not null) { Result = new PinToVorgangChoice(SelectedCase.Model.Id, null); return; }
|
||||
if (!string.IsNullOrWhiteSpace(NewTitle)) { Result = new PinToVorgangChoice(null, NewTitle.Trim()); return; }
|
||||
Error = "Bestehenden Vorgang wählen oder Titel für einen neuen Vorgang eingeben.";
|
||||
}
|
||||
}
|
||||
@@ -38,6 +38,8 @@ public partial class DashboardViewModel : ObservableObject
|
||||
private readonly ITimeEntryRepository _timeEntries;
|
||||
private readonly IAnnualPlanEventRepository? _annualPlanEvents;
|
||||
private readonly SchoolWeatherService? _schoolWeather;
|
||||
private readonly UntisHubService _untisHub;
|
||||
private readonly WebUntisIntegrationService _webUntis;
|
||||
|
||||
private const int OpenExcuseMaxAgeDays = 21;
|
||||
private const int SupportPlanDueWithinDays = 14;
|
||||
@@ -137,12 +139,16 @@ public partial class DashboardViewModel : ObservableObject
|
||||
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;
|
||||
+ OpenCorrections.Count + UnplannedLessons.Count + Alerts.Count + MissingTeachingTimeEntries.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";
|
||||
|
||||
[ObservableProperty] private string _webUntisHealthLabel = "";
|
||||
[ObservableProperty] private bool _isWebUntisHealthWarning;
|
||||
[ObservableProperty] private bool _isWebUntisHealthVisible;
|
||||
|
||||
public DashboardViewModel(IGroupRepository groups, ISubjectRepository subjects, ILessonRepository lessons,
|
||||
IExamRepository exams, IExamResultRepository examResults, IGradeRepository grades,
|
||||
IReportGradeRepository reportGrades, IGroupMembershipRepository memberships,
|
||||
@@ -153,6 +159,7 @@ public partial class DashboardViewModel : ObservableObject
|
||||
DashboardSettingsService dashboardSettings, ISchoolHolidayRepository schoolHolidays,
|
||||
PublicHolidayService publicHolidays, SchoolCalendarSettingsService calendarSettings,
|
||||
ISubstitutionEntryRepository substitutions, ITimeEntryRepository timeEntries,
|
||||
UntisHubService untisHub, WebUntisIntegrationService webUntis,
|
||||
IAnnualPlanEventRepository? annualPlanEvents = null,
|
||||
AnnualPlanSyncService? annualPlanSync = null, SchoolWeatherService? schoolWeather = null)
|
||||
{
|
||||
@@ -167,13 +174,28 @@ public partial class DashboardViewModel : ObservableObject
|
||||
_substitutions = substitutions;
|
||||
_annualPlanEvents = annualPlanEvents;
|
||||
_schoolWeather = schoolWeather;
|
||||
_untisHub = untisHub;
|
||||
_webUntis = webUntis;
|
||||
if (annualPlanSync is not null)
|
||||
{
|
||||
annualPlanSync.DataChanged += () => Avalonia.Threading.Dispatcher.UIThread.Post(LoadCalendar);
|
||||
_ = annualPlanSync.PollAsync();
|
||||
}
|
||||
LoadDashboardCards();
|
||||
Load();
|
||||
RefreshWebUntisHealth();
|
||||
}
|
||||
|
||||
/// <summary>Liest nur den gespeicherten Fälligkeitsstand der Untis-Hub-Jobs (kein
|
||||
/// WebUntis-Zugriff, siehe <see cref="UntisHubService.GetRows"/>) - aufgerufen bei jedem
|
||||
/// Dashboard-Refresh und erneut, nachdem der Nutzer den Hub geöffnet/einen Abgleich gemacht hat.</summary>
|
||||
public void RefreshWebUntisHealth()
|
||||
{
|
||||
IsWebUntisHealthVisible = _webUntis.IsAvailable;
|
||||
if (!IsWebUntisHealthVisible) return;
|
||||
var rows = _untisHub.GetRows();
|
||||
var due = rows.Count(r => r.DueState != UntisHubDueState.Ok);
|
||||
IsWebUntisHealthWarning = due > 0;
|
||||
WebUntisHealthLabel = due > 0 ? $"WebUntis ⚠ {due} fällig" : "WebUntis ✓";
|
||||
}
|
||||
|
||||
private DashboardCardOption Card(string key) => DashboardCards.First(c => c.Key == key);
|
||||
|
||||
@@ -5,6 +5,7 @@ using LehrerApp.Core.Interfaces;
|
||||
using LehrerApp.Core.Models;
|
||||
using LehrerApp.Core.Services;
|
||||
using LehrerApp.Desktop.Services;
|
||||
using LehrerApp.Desktop.ViewModels.Planning;
|
||||
using LehrerApp.Desktop.ViewModels.Students;
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Globalization;
|
||||
@@ -75,6 +76,7 @@ public partial class PlanningTabViewModel : ObservableObject
|
||||
public Func<Lesson, List<string>, List<string>, Task<bool>>? OnEditLesson { get; set; }
|
||||
public Func<LessonSummary, Task<bool>>? OnConfirmDeleteLesson { get; set; }
|
||||
public Func<Lesson, Task<MoveLessonTarget?>>? OnPickMoveTarget { get; set; }
|
||||
public Func<Lesson, Task<Unit?>>? OnPickUnitChange { get; set; }
|
||||
public Func<Lesson, Task>? OnShowLesson { get; set; }
|
||||
public Func<Unit, Task<LessonSeriesResult?>>? OnGenerateLessonSeries { get; set; }
|
||||
public Func<Unit, Task<bool>>? OnAiAssist { get; set; }
|
||||
@@ -100,10 +102,10 @@ public partial class PlanningTabViewModel : ObservableObject
|
||||
GradeLevel = group?.GradeLevel ?? 0;
|
||||
SubjectName = SubjectId is Guid sid ? _subjects.GetById(sid)?.Name ?? "" : "";
|
||||
GroupLabel = group?.Name ?? "";
|
||||
LoadUnits();
|
||||
LoadUnits(preferActive: true);
|
||||
}
|
||||
|
||||
private void LoadUnits()
|
||||
private void LoadUnits(bool preferActive = false)
|
||||
{
|
||||
var selectedId = SelectedUnit?.Id;
|
||||
Units.Clear();
|
||||
@@ -126,7 +128,11 @@ public partial class PlanningTabViewModel : ObservableObject
|
||||
KnownMaterials = materials.OrderBy(m => m, StringComparer.CurrentCultureIgnoreCase).ToList();
|
||||
KnownShorthands = shorthands.OrderBy(s => s, StringComparer.CurrentCultureIgnoreCase).ToList();
|
||||
|
||||
SelectedUnit = Units.FirstOrDefault(u => u.Id == selectedId) ?? Units.FirstOrDefault();
|
||||
// Beim ersten Öffnen bzw. beim Wechsel aus dem Stundenplan steht die laufende Einheit im
|
||||
// Fokus. Eine bewusste Auswahl innerhalb derselben Gruppe bleibt bei Refreshes erhalten.
|
||||
SelectedUnit = !preferActive && selectedId is not null
|
||||
? Units.FirstOrDefault(u => u.Id == selectedId) ?? Units.FirstOrDefault(u => u.Status == UnitStatus.Active) ?? Units.FirstOrDefault()
|
||||
: Units.FirstOrDefault(u => u.Status == UnitStatus.Active) ?? Units.FirstOrDefault();
|
||||
}
|
||||
|
||||
partial void OnSelectedUnitChanged(UnitSummary? value)
|
||||
@@ -160,6 +166,7 @@ public partial class PlanningTabViewModel : ObservableObject
|
||||
EditLessonCommand.NotifyCanExecuteChanged();
|
||||
DeleteLessonCommand.NotifyCanExecuteChanged();
|
||||
MoveLessonCommand.NotifyCanExecuteChanged();
|
||||
ChangeLessonUnitCommand.NotifyCanExecuteChanged();
|
||||
AdvanceLessonStatusCommand.NotifyCanExecuteChanged();
|
||||
}
|
||||
|
||||
@@ -349,6 +356,23 @@ public partial class PlanningTabViewModel : ObservableObject
|
||||
new LessonSchedulingService(_lessons).Move(moved, newDate, newPeriod, shiftFollowing);
|
||||
}
|
||||
|
||||
/// Ordnet eine bereits angelegte Stunde einer anderen Einheit derselben Gruppe zu (Korrektur
|
||||
/// einer im Erstellen-Dialog falsch gewählten Einheit, Nutzer-Feedback) — bisher war das nur
|
||||
/// über Löschen + Neuanlegen möglich. Datum/Stundennummer bleiben unverändert, nur UnitId ändert
|
||||
/// sich. Nach dem Speichern springt die Ansicht direkt zur Ziel-Einheit mit der Stunde
|
||||
/// vorausgewählt, damit sichtbar bleibt, wohin sie verschoben wurde.
|
||||
[RelayCommand(CanExecute = nameof(HasSelectedLesson))]
|
||||
private async Task ChangeLessonUnit()
|
||||
{
|
||||
if (OnPickUnitChange is null || SelectedLesson is null) return;
|
||||
var lesson = SelectedLesson.Model;
|
||||
var targetUnit = await OnPickUnitChange(lesson);
|
||||
if (targetUnit is null) return;
|
||||
lesson.UnitId = targetUnit.Id;
|
||||
_lessons.Save(lesson);
|
||||
RefreshPlanning(selectUnitId: targetUnit.Id, selectLessonId: lesson.Id);
|
||||
}
|
||||
|
||||
[RelayCommand(CanExecute = nameof(HasSelectedLesson))]
|
||||
private void AdvanceLessonStatus()
|
||||
{
|
||||
@@ -364,16 +388,25 @@ public partial class PlanningTabViewModel : ObservableObject
|
||||
/// Übernimmt Datum + Thema der Stunde in eine neue Mitarbeitssitzung (3.3.1) — verknüpft über
|
||||
/// das bisher ungenutzte Lesson.LessonId-Feld auf ParticipationSession, damit ein zweiter Klick
|
||||
/// auf dieselbe Stunde keine doppelte Sitzung anlegt, sondern nur darauf hinweist.
|
||||
///
|
||||
/// Prüft dabei zusätzlich auf JEDE bereits an diesem Tag bestehende Sitzung, nicht nur eine
|
||||
/// exakt mit `lesson.Id` verknüpfte (Nutzer-Feedback, analog
|
||||
/// <see cref="SeatingPlanTabViewModel.SelectOrCreateSessionForLesson"/>): kommt neben einer
|
||||
/// Doppelstunde noch eine dritte Stunde desselben Tages hinzu (eigene `Lesson`, z.B. durch
|
||||
/// Vertretung), soll das nicht zu einer zweiten Mitarbeitssitzung für den Tag führen.
|
||||
[RelayCommand(CanExecute = nameof(HasSelectedLesson))]
|
||||
private void CreateParticipationSession()
|
||||
{
|
||||
if (SelectedLesson is null) return;
|
||||
var lesson = SelectedLesson.Model;
|
||||
var existing = _participationSessions.GetByGroup(lesson.GroupId)
|
||||
.FirstOrDefault(s => s.LessonId == lesson.Id);
|
||||
var sessionsForGroup = _participationSessions.GetByGroup(lesson.GroupId);
|
||||
var existing = sessionsForGroup.FirstOrDefault(s => s.LessonId == lesson.Id)
|
||||
?? sessionsForGroup.FirstOrDefault(s => s.Date == lesson.Date);
|
||||
if (existing is not null)
|
||||
{
|
||||
OnNotify?.Invoke("Für diese Stunde existiert bereits eine Sitzung.");
|
||||
OnNotify?.Invoke(existing.LessonId == lesson.Id
|
||||
? "Für diese Stunde existiert bereits eine Sitzung."
|
||||
: "Für diesen Tag existiert bereits eine Sitzung.");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -545,6 +578,7 @@ public partial class UnitDialogViewModel : ObservableObject
|
||||
[ObservableProperty] private string _startDateText = "";
|
||||
[ObservableProperty] private string _endDateText = "";
|
||||
[ObservableProperty] private string _statusName = UnitStatusDisplay.Options[0];
|
||||
[ObservableProperty] private string _statusNotice = "";
|
||||
[ObservableProperty] private string _notes = "";
|
||||
[ObservableProperty] private bool _isCompetencyPanelOpen;
|
||||
[ObservableProperty] private string _titleError = "";
|
||||
@@ -565,6 +599,20 @@ public partial class UnitDialogViewModel : ObservableObject
|
||||
public string DialogTitle => _editingUnit is null ? "Neue Einheit anlegen" : "Einheit bearbeiten";
|
||||
public string SaveButtonText => _editingUnit is null ? "Anlegen" : "Speichern";
|
||||
|
||||
partial void OnStatusNameChanged(string value)
|
||||
{
|
||||
if (UnitStatusDisplay.FromName(value) != UnitStatus.Active)
|
||||
{
|
||||
StatusNotice = "";
|
||||
return;
|
||||
}
|
||||
|
||||
var previous = _units.GetByGroup(_groupId)
|
||||
.FirstOrDefault(u => u.Status == UnitStatus.Active && u.Id != _editingUnit?.Id);
|
||||
StatusNotice = previous is null ? ""
|
||||
: $"„{previous.Title}“ wird beim Speichern automatisch abgeschlossen.";
|
||||
}
|
||||
|
||||
public UnitDialogViewModel(IUnitRepository units, ICompetencyDomainRepository competencyDomains,
|
||||
Guid groupId, Guid? subjectId, int gradeLevel, string groupName, string subjectName, Unit? editingUnit)
|
||||
{
|
||||
@@ -650,6 +698,17 @@ public partial class UnitDialogViewModel : ObservableObject
|
||||
Result.Status = UnitStatusDisplay.FromName(StatusName);
|
||||
Result.Notes = string.IsNullOrWhiteSpace(Notes) ? null : Notes.Trim();
|
||||
Result.Competencies = _competencyCodes;
|
||||
if (Result.Status == UnitStatus.Active)
|
||||
{
|
||||
// Pro Lerngruppe gibt es genau einen aktuellen Arbeitskontext. Frühere laufende
|
||||
// Einheiten werden nicht verworfen, sondern fachlich sauber abgeschlossen.
|
||||
foreach (var previous in _units.GetByGroup(_groupId)
|
||||
.Where(u => u.Status == UnitStatus.Active && u.Id != Result.Id))
|
||||
{
|
||||
previous.Status = UnitStatus.Completed;
|
||||
_units.Save(previous);
|
||||
}
|
||||
}
|
||||
_units.Save(Result);
|
||||
}
|
||||
}
|
||||
@@ -716,8 +775,10 @@ public partial class LessonDialogViewModel : ObservableObject
|
||||
// gespeicherte Stunden sinnvoll, eine gerade erst angelegte, noch ungespeicherte Stunde hat
|
||||
// keine echte Id, auf die sich die KI beziehen könnte.
|
||||
public Guid UnitId => _unitId;
|
||||
public Guid GroupId => _groupId;
|
||||
public Lesson? EditingLesson => _editingLesson;
|
||||
public bool CanAiAssist => _editingLesson is not null;
|
||||
public bool IsNewLesson => _editingLesson is null;
|
||||
|
||||
/// Vom Code-Behind nach einer über die KI angewendeten Änderung aufgerufen: der Dialog schließt
|
||||
/// sich danach mit Result != null, damit die aufrufende Liste neu lädt — die eigenen, jetzt
|
||||
@@ -1197,6 +1258,88 @@ public partial class MoveLessonDialogViewModel : ObservableObject
|
||||
}
|
||||
}
|
||||
|
||||
// ── Dialog: Stunde einer anderen Einheit zuweisen (Korrektur der Einheit) ────
|
||||
|
||||
public partial class ChangeLessonUnitDialogViewModel : ObservableObject
|
||||
{
|
||||
[ObservableProperty] private TimetableUnitOption? _selectedUnit;
|
||||
[ObservableProperty] private string _error = "";
|
||||
|
||||
public string CurrentUnitLabel { get; }
|
||||
public ObservableCollection<TimetableUnitOption> Units { get; } = [];
|
||||
public bool HasUnits => Units.Count > 0;
|
||||
public Unit? Result { get; private set; }
|
||||
|
||||
public ChangeLessonUnitDialogViewModel(IUnitRepository units, Guid groupId, Guid currentUnitId,
|
||||
string currentUnitTitle)
|
||||
{
|
||||
CurrentUnitLabel = currentUnitTitle;
|
||||
foreach (var unit in units.GetByGroup(groupId).Where(u => u.Id != currentUnitId)
|
||||
.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));
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void Save()
|
||||
{
|
||||
Error = "";
|
||||
if (SelectedUnit is null) { Error = "Bitte eine Ziel-Einheit auswählen."; return; }
|
||||
Result = SelectedUnit.Model;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Dialog: Vorhandene Stunde zum Verknüpfen auswählen (Stundenplan-FixIt) ────
|
||||
|
||||
/// <summary>
|
||||
/// Sucht Stunden, die zu einem Stundenplan-Termin gehören könnten, aber (z.B. durch JSON-Import
|
||||
/// oder KI-Übernahme ohne Stundenplan-Bezug) keine passende <see cref="Lesson.LessonNumber"/>
|
||||
/// haben und deshalb von <c>TimetableViewModel.FindLessonForSlot</c> nicht gefunden werden.
|
||||
/// Als eigene, von Avalonia unabhängige Methode extrahiert, damit die Zuordnungslogik ohne Fenster
|
||||
/// testbar ist — Aufrufer ist <c>LessonDialog.axaml.cs</c> (Button "Vorhandene Stunde verknüpfen").
|
||||
/// </summary>
|
||||
public static class LessonFixItSearch
|
||||
{
|
||||
public static List<Lesson> FindCandidates(ILessonRepository lessons, Guid groupId, DateOnly date) =>
|
||||
[.. lessons.GetByGroupAndRange(groupId, date.AddDays(-14), date.AddDays(14))
|
||||
.Where(l => l.LessonNumber is null || l.Date == date)
|
||||
.OrderBy(l => l.Date == date ? 0 : 1)
|
||||
.ThenBy(l => Math.Abs(l.Date.DayNumber - date.DayNumber))];
|
||||
}
|
||||
|
||||
public sealed class LessonLinkOption(Lesson lesson, string unitTitle)
|
||||
{
|
||||
public Lesson Model { get; } = lesson;
|
||||
public string Label { get; } = string.IsNullOrWhiteSpace(lesson.Topic) ? "(ohne Thema)" : lesson.Topic;
|
||||
public string Detail { get; } =
|
||||
$"{lesson.Date:dd.MM.yyyy} · {(lesson.LessonNumber is int n ? $"{n}. Stunde" : "keine Stundennummer")} · Einheit „{unitTitle}“";
|
||||
}
|
||||
|
||||
public partial class LinkExistingLessonDialogViewModel : ObservableObject
|
||||
{
|
||||
[ObservableProperty] private LessonLinkOption? _selectedOption;
|
||||
[ObservableProperty] private string _error = "";
|
||||
|
||||
public ObservableCollection<LessonLinkOption> Options { get; } = [];
|
||||
public Lesson? Result { get; private set; }
|
||||
|
||||
public LinkExistingLessonDialogViewModel(List<Lesson> candidates, IUnitRepository units)
|
||||
{
|
||||
foreach (var lesson in candidates)
|
||||
Options.Add(new LessonLinkOption(lesson, units.GetById(lesson.UnitId)?.Title ?? "?"));
|
||||
SelectedOption = Options.FirstOrDefault();
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void Save()
|
||||
{
|
||||
Error = "";
|
||||
if (SelectedOption is null) { Error = "Bitte eine Stunde auswählen."; return; }
|
||||
Result = SelectedOption.Model;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Dialog: Stunden serienweise aus dem Stundenplan erzeugen (4.2.5) ────────
|
||||
|
||||
public partial class GenerateLessonSeriesDialogViewModel : ObservableObject
|
||||
@@ -1378,10 +1521,16 @@ public partial class AiAssistDialogViewModel : ObservableObject
|
||||
[ObservableProperty] private string _errorMessage = "";
|
||||
[ObservableProperty] private bool _hasResults;
|
||||
[ObservableProperty] private string? _summary;
|
||||
[ObservableProperty] private string? _rawResponse;
|
||||
|
||||
public string UnitSummary { get; }
|
||||
public ObservableCollection<AiLessonReviewItem> ReviewItems { get; } = [];
|
||||
public bool Result { get; private set; }
|
||||
public Unit Unit => _unit;
|
||||
public Guid? FocusLessonId => _focusLesson?.Id;
|
||||
public bool CanRescueResponse => !string.IsNullOrWhiteSpace(RawResponse);
|
||||
|
||||
partial void OnRawResponseChanged(string? value) => OnPropertyChanged(nameof(CanRescueResponse));
|
||||
|
||||
/// Aus dem Editor einer einzelnen Stunde heraus gestartet (statt aus der Einheiten-Übersicht,
|
||||
/// Nutzer-Feedback nach den ersten Live-Tests) — die KI darf dann ausschließlich diese eine
|
||||
@@ -1420,7 +1569,7 @@ public partial class AiAssistDialogViewModel : ObservableObject
|
||||
? ReviewItems.Where(i => i.Accepted).Select(i => i.Source).ToList()
|
||||
: null;
|
||||
|
||||
ErrorMessage = ""; IsBusy = true;
|
||||
ErrorMessage = ""; RawResponse = null; IsBusy = true;
|
||||
try
|
||||
{
|
||||
var response = await _aiPlanning.RequestPlanAsync(_unit, Instruction, token, AllowModifyingExisting,
|
||||
@@ -1452,7 +1601,11 @@ public partial class AiAssistDialogViewModel : ObservableObject
|
||||
Summary = response.Summary;
|
||||
HasResults = true;
|
||||
}
|
||||
catch (AiBackendException ex) { ErrorMessage = ex.Message; }
|
||||
catch (AiBackendException ex)
|
||||
{
|
||||
ErrorMessage = ex.Message;
|
||||
RawResponse = ex.RawResponse;
|
||||
}
|
||||
finally { IsBusy = false; }
|
||||
}
|
||||
|
||||
@@ -1466,6 +1619,132 @@ public partial class AiAssistDialogViewModel : ObservableObject
|
||||
}
|
||||
|
||||
[RelayCommand] private void Cancel() => Result = false;
|
||||
|
||||
public void MarkRescueImported() => Result = true;
|
||||
}
|
||||
|
||||
/// <summary>Aus einer manuell geprüften KI-Antwort auswählbare Stunde.</summary>
|
||||
public record AiRescueLessonOption(AiLesson Lesson, string Label);
|
||||
|
||||
/// <summary>Ziel für den manuellen Import in eine bereits vorhandene Stunde.</summary>
|
||||
public record AiRescueTargetOption(Lesson Lesson, string Label);
|
||||
|
||||
/// <summary>
|
||||
/// Rettungsdialog für syntaktisch fehlerhafte oder mit Freitext vermischte Modellantworten. Der
|
||||
/// Nutzer entscheidet selbst, welcher Textabschnitt geparst und wohin die Stunde importiert wird.
|
||||
/// </summary>
|
||||
public partial class AiResponseRescueDialogViewModel : ObservableObject
|
||||
{
|
||||
private readonly AiPlanningService _aiPlanning;
|
||||
private readonly ILessonRepository _lessons;
|
||||
private readonly Unit _unit;
|
||||
|
||||
[ObservableProperty] private string _responseText;
|
||||
[ObservableProperty] private string _parseMessage =
|
||||
"Markiere den gültigen JSON-Abschnitt oder bearbeite den Text und klicke auf „Markierung prüfen“.";
|
||||
[ObservableProperty] private AiRescueLessonOption? _selectedParsedLesson;
|
||||
[ObservableProperty] private AiRescueTargetOption? _selectedTarget;
|
||||
|
||||
public ObservableCollection<AiRescueLessonOption> ParsedLessons { get; } = [];
|
||||
public ObservableCollection<AiRescueTargetOption> ExistingLessons { get; } = [];
|
||||
public bool HasParsedLesson => SelectedParsedLesson is not null;
|
||||
public bool CanImportIntoExisting => SelectedParsedLesson is not null && SelectedTarget is not null;
|
||||
public bool Result { get; private set; }
|
||||
|
||||
partial void OnSelectedParsedLessonChanged(AiRescueLessonOption? value)
|
||||
{
|
||||
OnPropertyChanged(nameof(HasParsedLesson));
|
||||
OnPropertyChanged(nameof(CanImportIntoExisting));
|
||||
}
|
||||
|
||||
partial void OnSelectedTargetChanged(AiRescueTargetOption? value) =>
|
||||
OnPropertyChanged(nameof(CanImportIntoExisting));
|
||||
|
||||
public AiResponseRescueDialogViewModel(AiPlanningService aiPlanning, ILessonRepository lessons,
|
||||
Unit unit, string rawResponse, Guid? preferredTargetId = null)
|
||||
{
|
||||
_aiPlanning = aiPlanning;
|
||||
_lessons = lessons;
|
||||
_unit = unit;
|
||||
_responseText = rawResponse;
|
||||
|
||||
foreach (var lesson in lessons.GetByUnit(unit.Id).OrderBy(l => l.Date).ThenBy(l => l.LessonNumber))
|
||||
{
|
||||
var date = lesson.Date.ToString("dd.MM.yyyy", CultureInfo.InvariantCulture);
|
||||
var number = lesson.LessonNumber is { } n ? $", Stunde {n}" : "";
|
||||
ExistingLessons.Add(new AiRescueTargetOption(lesson, $"{date}{number}: {lesson.Topic}"));
|
||||
}
|
||||
|
||||
SelectedTarget = ExistingLessons.FirstOrDefault(x => x.Lesson.Id == preferredTargetId)
|
||||
?? ExistingLessons.FirstOrDefault();
|
||||
}
|
||||
|
||||
public void ParseSelection(string selectedText)
|
||||
{
|
||||
ParsedLessons.Clear();
|
||||
SelectedParsedLesson = null;
|
||||
try
|
||||
{
|
||||
var parsed = AiPlanningService.ParsePlanningLessons(selectedText);
|
||||
foreach (var lesson in parsed)
|
||||
{
|
||||
var date = lesson.Date?.ToString("dd.MM.yyyy", CultureInfo.InvariantCulture) ?? "ohne Datum";
|
||||
ParsedLessons.Add(new AiRescueLessonOption(lesson, $"{date}: {lesson.Topic}"));
|
||||
}
|
||||
SelectedParsedLesson = ParsedLessons[0];
|
||||
ParseMessage = parsed.Count == 1
|
||||
? "Eine gültige Stunde erkannt."
|
||||
: $"{parsed.Count} gültige Stunden erkannt. Bitte die gewünschte Stunde auswählen.";
|
||||
}
|
||||
catch (AiBackendException ex)
|
||||
{
|
||||
ParseMessage = ex.Message;
|
||||
}
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void ImportIntoExisting()
|
||||
{
|
||||
if (SelectedParsedLesson is null || SelectedTarget is null) return;
|
||||
var lesson = CloneForImport(SelectedParsedLesson.Lesson, SelectedTarget.Lesson.Id);
|
||||
Save([lesson], focusLessonId: SelectedTarget.Lesson.Id);
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void ImportAsNew()
|
||||
{
|
||||
if (SelectedParsedLesson is null) return;
|
||||
Save([CloneForImport(SelectedParsedLesson.Lesson, null)]);
|
||||
}
|
||||
|
||||
private void Save(List<AiLesson> source, Guid? focusLessonId = null)
|
||||
{
|
||||
foreach (var lesson in _aiPlanning.ApplyResponse(_unit, source,
|
||||
allowModifyingExistingLessons: true, focusLessonId))
|
||||
_lessons.Save(lesson);
|
||||
Result = true;
|
||||
}
|
||||
|
||||
private static AiLesson CloneForImport(AiLesson source, Guid? id) => new()
|
||||
{
|
||||
Id = id,
|
||||
Date = source.Date,
|
||||
LessonNumber = source.LessonNumber,
|
||||
Topic = source.Topic,
|
||||
StartTime = source.StartTime,
|
||||
Homework = source.Homework,
|
||||
Reflection = source.Reflection,
|
||||
Phases = source.Phases.Select(p => new AiPhaseStep
|
||||
{
|
||||
Name = p.Name,
|
||||
DurationMinutes = p.DurationMinutes,
|
||||
Activity = p.Activity,
|
||||
Material = p.Material,
|
||||
Shorthand = p.Shorthand,
|
||||
AlternativePathName = p.AlternativePathName,
|
||||
MaterialSuggestion = p.MaterialSuggestion,
|
||||
}).ToList(),
|
||||
};
|
||||
}
|
||||
|
||||
// ── Dialog: Einheit als Vorlage in andere Gruppe kopieren (4.1.4) ────────────
|
||||
|
||||
@@ -141,10 +141,18 @@ public partial class SeatingPlanTabViewModel : ObservableObject
|
||||
/// erlaubt: welche Stunde gemeint ist, steht durch die explizite Auswahl der Lehrkraft
|
||||
/// (Klick auf "Unterrichtsmodus starten" für genau diese Stunde) bereits unzweideutig fest -
|
||||
/// keine Geistersitzungs-Gefahr wie beim bloßen Öffnen eines Tabs.
|
||||
///
|
||||
/// Fällt bewusst auf JEDE an diesem Tag bereits bestehende Sitzung zurück, nicht nur auf eine
|
||||
/// exakt mit `lesson.Id` verknüpfte (Nutzer-Feedback): kommt neben einer Doppelstunde noch eine
|
||||
/// dritte Stunde am selben Tag hinzu (z.B. Vertretung, eigene `Lesson` mit eigener Id), soll
|
||||
/// keine zweite Mitarbeitssitzung für denselben Tag entstehen — die Lehrkraft passt stattdessen
|
||||
/// die Einschätzung der bereits bestehenden Sitzung an. Das entspricht dem Verhalten von
|
||||
/// <see cref="EnsureTodaySession"/>, das schon immer pro Tag statt pro Stunde arbeitet.
|
||||
/// </summary>
|
||||
public void SelectOrCreateSessionForLesson(Lesson lesson)
|
||||
{
|
||||
var existing = TodaySessions.FirstOrDefault(s => s.LessonId == lesson.Id);
|
||||
var existing = TodaySessions.FirstOrDefault(s => s.LessonId == lesson.Id)
|
||||
?? TodaySessions.FirstOrDefault(s => s.Date == lesson.Date);
|
||||
if (existing is not null) { SelectedSession = existing; return; }
|
||||
if (!IsEditable) return;
|
||||
|
||||
|
||||
@@ -0,0 +1,229 @@
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Text.RegularExpressions;
|
||||
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.Groups;
|
||||
|
||||
/// <summary>Eine Zeile bleibt auch ohne automatische Zuordnung sichtbar - <see cref="AssignedStudent"/>
|
||||
/// kann manuell per Auswahlliste gesetzt werden, gleiches Muster wie
|
||||
/// <see cref="WebUntisLessonAbsenceComparisonViewModel"/> und
|
||||
/// <see cref="LehrerApp.Desktop.ViewModels.Students.WebUntisDocumentationComparisonViewModel"/>.</summary>
|
||||
public partial class WebUntisHomeworkRow : ObservableObject
|
||||
{
|
||||
public required string ClassName { get; init; }
|
||||
public required DateOnly Date { get; init; }
|
||||
public required string UntisStudentName { get; init; }
|
||||
public string? SubjectLabel { get; init; }
|
||||
public string? Text { get; init; }
|
||||
public required IReadOnlyList<Student> Candidates { get; init; }
|
||||
internal Guid? MatchedSubjectId { get; init; }
|
||||
internal Action<WebUntisHomeworkRow>? OnAssignmentChanged { 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>Abgleich negativer Klassenbucheinträge zum Stichwort "Hausaufgabe" (eigene, siehe
|
||||
/// <see cref="WebUntisIntegrationService.GetOwnClassRegisterEventsAsync"/>) gegen
|
||||
/// <see cref="ParticipationEntry.Homework"/> - Dashboard-weit statt pro Lerngruppe, aus demselben
|
||||
/// Grund wie beim Dokumentations-Abgleich (der WebUntis-"-alle-"-Bericht ist klassenübergreifend,
|
||||
/// siehe TODO.md). Nutzer-Feedback: WebUntis liefert für Klassenbucheinträge weder eine externe
|
||||
/// Schülerkennung noch eine feste Fach-/Lerngruppenzuordnung, aber der Lehrkraft-eigene Text enthält
|
||||
/// praktisch immer das Untis-Fachkürzel - darüber wird die passende Lerngruppe (SubjectId + aktive
|
||||
/// Mitgliedschaft am Eintragsdatum) aufgelöst, mehrdeutige Treffer bleiben unaufgelöst statt zu raten.
|
||||
/// Ein Eintrag setzt lokal ausschließlich <see cref="HomeworkStatus.MissingOpen"/> vor, und auch nur,
|
||||
/// wenn dort noch gar kein Status hinterlegt ist - bereits vorhandene, feinere Erfassungen ("Teilweise
|
||||
/// angefertigt", "nachgereicht" usw.) werden nie automatisch überschrieben, sondern nur zum Vergleich
|
||||
/// danebengestellt (Nutzerwunsch).</summary>
|
||||
public partial class WebUntisHomeworkComparisonViewModel : ObservableObject
|
||||
{
|
||||
private readonly WebUntisIntegrationService _untis;
|
||||
private readonly IStudentRepository _students;
|
||||
private readonly IGroupRepository _groups;
|
||||
private readonly ISubjectRepository _subjects;
|
||||
private readonly IGroupMembershipRepository _memberships;
|
||||
private readonly IParticipationSessionRepository _sessions;
|
||||
private readonly IParticipationRepository _participation;
|
||||
private readonly SchoolYearService _schoolYears;
|
||||
|
||||
public ObservableCollection<WebUntisHomeworkRow> Rows { get; } = [];
|
||||
[ObservableProperty] private DateTimeOffset? _startDate = DateTimeOffset.Now.AddDays(-7);
|
||||
[ObservableProperty] private DateTimeOffset? _endDate = DateTimeOffset.Now;
|
||||
[ObservableProperty] private string _status = "Zeitraum wählen und Klassenbucheinträge laden.";
|
||||
[ObservableProperty] private bool _busy;
|
||||
|
||||
public WebUntisHomeworkComparisonViewModel(WebUntisIntegrationService untis, IStudentRepository students,
|
||||
IGroupRepository groups, ISubjectRepository subjects, IGroupMembershipRepository memberships,
|
||||
IParticipationSessionRepository sessions, IParticipationRepository participation,
|
||||
SchoolYearService schoolYears)
|
||||
{
|
||||
_untis = untis; _students = students; _groups = groups; _subjects = subjects;
|
||||
_memberships = memberships; _sessions = sessions; _participation = participation;
|
||||
_schoolYears = schoolYears;
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private async Task Load()
|
||||
{
|
||||
var start = DateOnly.FromDateTime((StartDate ?? DateTimeOffset.Now).LocalDateTime);
|
||||
var end = DateOnly.FromDateTime((EndDate ?? DateTimeOffset.Now).LocalDateTime);
|
||||
if (end < start) { Status = "Das Enddatum darf nicht vor dem Startdatum liegen."; return; }
|
||||
Busy = true; Rows.Clear();
|
||||
try
|
||||
{
|
||||
var ownStudents = _students.GetAll();
|
||||
var globalIndex = BuildNameIndex(ownStudents);
|
||||
// Wie beim Dokumentations-Abgleich zusätzlich pro Klasse indiziert, um gleiche Namen in
|
||||
// verschiedenen Klassen unterscheiden zu können.
|
||||
var classIndexes = _groups.GetAll()
|
||||
.Where(g => g.Type == GroupType.Class)
|
||||
.GroupBy(g => g.Name, StringComparer.OrdinalIgnoreCase)
|
||||
.ToDictionary(
|
||||
g => g.Key,
|
||||
g => BuildNameIndex(g.SelectMany(x => _students.GetByGroup(x.Id)).Distinct().ToList()),
|
||||
StringComparer.OrdinalIgnoreCase);
|
||||
var shortNameIndex = _subjects.GetAll()
|
||||
.Where(s => !string.IsNullOrWhiteSpace(s.ShortName))
|
||||
.GroupBy(s => s.ShortName.Trim().ToUpperInvariant())
|
||||
.Where(g => g.Count() == 1) // mehrdeutiges Kürzel lieber nicht zuordnen als raten
|
||||
.ToDictionary(g => g.Key, g => g.First());
|
||||
|
||||
var entries = await _untis.GetOwnClassRegisterEventsAsync(start, end);
|
||||
var ordered = entries
|
||||
.Where(IsMissingHomeworkEntry)
|
||||
.Select(e => (Entry: e, Date: TryDate(e.Date, out var d) ? d : (DateOnly?)null))
|
||||
.Where(x => x.Date is not null)
|
||||
.OrderBy(x => x.Date).ThenBy(x => x.Entry.StudentName);
|
||||
|
||||
void ResolveLocalMatch(WebUntisHomeworkRow row)
|
||||
{
|
||||
if (row.AssignedStudent is not { } student)
|
||||
{
|
||||
row.SessionId = null; row.LocalStatus = "ohne Zuordnung"; row.Selected = false;
|
||||
return;
|
||||
}
|
||||
var group = ResolveGroup(student, row.MatchedSubjectId, row.Date);
|
||||
var session = group is null ? null
|
||||
: _sessions.GetByGroup(group.Id).FirstOrDefault(s => s.Date == row.Date);
|
||||
var entry = session is null ? null : _participation.GetBySessionAndStudent(session.Id, student.Id);
|
||||
var current = entry is null ? null : HomeworkDisplay.Effective(entry);
|
||||
row.SessionId = session?.Id;
|
||||
row.LocalStatus = session is null
|
||||
? row.MatchedSubjectId is null ? "Fachkürzel nicht erkannt"
|
||||
: group is null ? "kein passender Kurs gefunden" : "keine lokale Stunde"
|
||||
: HomeworkDisplay.Label(current);
|
||||
// Nur vorbelegen, wenn lokal noch überhaupt nichts erfasst ist - jeder vorhandene
|
||||
// Status (auch ein bereits gesetztes "fehlt") bleibt unangetastet, siehe Klassenkommentar.
|
||||
row.Selected = session is not null && current is null;
|
||||
}
|
||||
|
||||
foreach (var (entry, date) in ordered)
|
||||
{
|
||||
var nameKey = NameKey(entry.StudentName);
|
||||
var match = (classIndexes.TryGetValue(entry.ClassName, out var classIndex)
|
||||
? classIndex.GetValueOrDefault(nameKey)
|
||||
: null)
|
||||
?? globalIndex.GetValueOrDefault(nameKey);
|
||||
var subject = MatchSubject(entry.Text, shortNameIndex) ?? MatchSubject(entry.CategoryName, shortNameIndex);
|
||||
|
||||
var row = new WebUntisHomeworkRow
|
||||
{
|
||||
ClassName = entry.ClassName, Date = date!.Value, UntisStudentName = entry.StudentName,
|
||||
SubjectLabel = subject?.ShortName, Text = entry.Text, Candidates = ownStudents,
|
||||
MatchedSubjectId = subject?.Id, OnAssignmentChanged = ResolveLocalMatch,
|
||||
};
|
||||
Rows.Add(row);
|
||||
row.AssignedStudent = match; // löst OnAssignedStudentChanged aus und setzt SessionId/LocalStatus/Selected
|
||||
}
|
||||
|
||||
var unresolvedStudent = Rows.Count(x => x.AssignedStudent is null);
|
||||
var unresolvedSubject = Rows.Count(x => x.MatchedSubjectId is null);
|
||||
Status = $"{Rows.Count} Einträge \"fehlende Hausaufgabe\" erhalten" +
|
||||
(unresolvedStudent > 0 ? $", {unresolvedStudent} bitte manuell zuordnen" : "") +
|
||||
(unresolvedSubject > 0 ? $", bei {unresolvedSubject} kein Fachkürzel im Text erkannt" : "") +
|
||||
$". {Rows.Count(x => x.CanApply)} einer lokalen Stunde 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.Homework = HomeworkStatus.MissingOpen;
|
||||
entry.HomeworkMissing = true;
|
||||
entry.UpdatedAt = DateTime.UtcNow;
|
||||
_participation.Save(entry);
|
||||
row.LocalStatus = HomeworkDisplay.Label(HomeworkStatus.MissingOpen);
|
||||
row.Selected = false;
|
||||
}
|
||||
Status = $"{selected.Count} Hausaufgaben-Status übernommen.";
|
||||
}
|
||||
|
||||
/// Nur Lerngruppen (nicht die Klasse selbst), bei denen der/die Schüler*in am Eintragsdatum aktiv
|
||||
/// Mitglied ist und deren Fach zum erkannten Kürzel passt - mehr als ein Treffer bleibt bewusst
|
||||
/// unaufgelöst statt irgendeinen davon zu wählen.
|
||||
private LearningGroup? ResolveGroup(Student student, Guid? subjectId, DateOnly date)
|
||||
{
|
||||
if (subjectId is null) return null;
|
||||
var schoolYear = _schoolYears.CurrentSchoolYear(date);
|
||||
var candidates = _memberships.GetByStudent(student.Id)
|
||||
.Where(m => GroupMembershipService.IsActiveOn(m, date))
|
||||
.Select(m => _groups.GetById(m.GroupId))
|
||||
.Where(g => g is not null && g.SubjectId == subjectId && g.SchoolYear == schoolYear)
|
||||
.Cast<LearningGroup>()
|
||||
.ToList();
|
||||
return candidates.Count == 1 ? candidates[0] : null;
|
||||
}
|
||||
|
||||
// Gleiche Heuristik wie ClassTeacherDetailsViewModel.ContainsHomework, zusätzlich auf negative
|
||||
// Einträge eingeschränkt (eine positive "Hausaufgabe"-Kategorie wäre kein Fehlen-Signal).
|
||||
private static bool IsMissingHomeworkEntry(UntisClassRegisterEventDto entry) =>
|
||||
string.Equals(entry.CategoryGroup, "Negativ", StringComparison.OrdinalIgnoreCase) &&
|
||||
(entry.CategoryName?.Contains("Hausauf", StringComparison.OrdinalIgnoreCase) == true ||
|
||||
entry.Text?.Contains("Hausauf", StringComparison.OrdinalIgnoreCase) == true);
|
||||
|
||||
private static Subject? MatchSubject(string? text, IReadOnlyDictionary<string, Subject> shortNameIndex)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(text)) return null;
|
||||
foreach (var token in Regex.Split(text, @"[^\p{L}\p{Nd}]+"))
|
||||
if (token.Length > 0 && shortNameIndex.TryGetValue(token.ToUpperInvariant(), out var subject))
|
||||
return subject;
|
||||
return null;
|
||||
}
|
||||
|
||||
private static string NameKey(string value) => value.Trim().ToLowerInvariant();
|
||||
|
||||
// Wie bei den übrigen WebUntis-Abgleichen: beide Namensreihenfolgen registriert, aber nur falls
|
||||
// innerhalb der Kandidaten eindeutig.
|
||||
private static Dictionary<string, Student> BuildNameIndex(IReadOnlyList<Student> candidates) =>
|
||||
candidates
|
||||
.SelectMany(student => new[]
|
||||
{
|
||||
NameKey($"{student.LastName} {student.FirstName}"),
|
||||
NameKey($"{student.FirstName} {student.LastName}"),
|
||||
}.Select(key => (Key: key, Student: student)))
|
||||
.GroupBy(x => x.Key)
|
||||
.Where(group => group.Select(x => x.Student).Distinct().Count() == 1)
|
||||
.ToDictionary(group => group.Key, group => group.First().Student);
|
||||
|
||||
private static bool TryDate(int value, out DateOnly date) =>
|
||||
DateOnly.TryParseExact(value.ToString(), "yyyyMMdd", out date);
|
||||
}
|
||||
@@ -48,8 +48,8 @@ public partial class WebUntisLessonAbsenceComparisonViewModel : ObservableObject
|
||||
new Dictionary<DateOnly, ParticipationSession>();
|
||||
|
||||
public ObservableCollection<WebUntisLessonAbsenceRow> Rows { get; } = [];
|
||||
[ObservableProperty] private DateTimeOffset _startDate = DateTimeOffset.Now.AddMonths(-2);
|
||||
[ObservableProperty] private DateTimeOffset _endDate = DateTimeOffset.Now;
|
||||
[ObservableProperty] private DateTimeOffset? _startDate = DateTimeOffset.Now.AddMonths(-2);
|
||||
[ObservableProperty] private DateTimeOffset? _endDate = DateTimeOffset.Now;
|
||||
[ObservableProperty] private string _status = "Zeitraum wählen und Fehlzeiten laden.";
|
||||
[ObservableProperty] private bool _busy;
|
||||
[ObservableProperty] private bool _markUnknownAsPresent;
|
||||
@@ -73,8 +73,8 @@ public partial class WebUntisLessonAbsenceComparisonViewModel : ObservableObject
|
||||
return;
|
||||
}
|
||||
|
||||
var start = DateOnly.FromDateTime(StartDate.LocalDateTime);
|
||||
var end = DateOnly.FromDateTime(EndDate.LocalDateTime);
|
||||
var start = DateOnly.FromDateTime((StartDate ?? DateTimeOffset.Now).LocalDateTime);
|
||||
var end = DateOnly.FromDateTime((EndDate ?? DateTimeOffset.Now).LocalDateTime);
|
||||
if (end < start) { Status = "Das Enddatum darf nicht vor dem Startdatum liegen."; return; }
|
||||
Busy = true; Rows.Clear();
|
||||
try
|
||||
|
||||
@@ -90,7 +90,10 @@ public partial class MainWindowViewModel : ObservableObject
|
||||
case TimetableViewModel vm: vm.Load(); break;
|
||||
case WorkloadViewModel vm:
|
||||
vm.Tasks.Load(); vm.TimeTracking.Load(); vm.Evaluation.Load(); break;
|
||||
case ClassTeacherOverviewViewModel vm: vm.LoadCommand.Execute(null); break;
|
||||
// RefreshFromLocalDataOnly() statt LoadCommand: baut die Ansicht aus den zuletzt
|
||||
// geholten WebUntis-Daten + aktuellen lokalen Daten neu auf, löst aber selbst nie einen
|
||||
// WebUntis-Request aus - den holt bewusst ausschließlich der "Aktualisieren"-Button.
|
||||
case ClassTeacherOverviewViewModel vm: vm.RefreshFromLocalDataOnly(); break;
|
||||
case ExamsOverviewViewModel vm: vm.LoadCommand.Execute(null); break;
|
||||
case GroupDetailViewModel { Group: { } group } vm: vm.LoadGroup(group.Id); break;
|
||||
// Inline-Bearbeitung (IsEditing) nicht überschreiben - anders als die Gruppenansicht
|
||||
@@ -147,8 +150,11 @@ public partial class MainWindowViewModel : ObservableObject
|
||||
|
||||
private TimetableViewModel GetTimetable()
|
||||
{
|
||||
// WeekOffset bewusst nicht zurücksetzen: TimetableViewModel ist ein DI-Singleton, die
|
||||
// zuletzt angezeigte Woche bleibt also automatisch über Tab-Wechsel hinweg erhalten
|
||||
// (Nutzer-Feedback: beim Planen der nächsten Woche nervt es, nach jedem kurzen Abstecher
|
||||
// in einen anderen Bereich wieder manuell dorthin zurückblättern zu müssen).
|
||||
var timetable = _services.GetRequiredService<TimetableViewModel>();
|
||||
timetable.WeekOffset = 0;
|
||||
timetable.Load();
|
||||
timetable.ActiveTabIndex = 0;
|
||||
return timetable;
|
||||
@@ -176,7 +182,11 @@ public partial class MainWindowViewModel : ObservableObject
|
||||
var vm = _services.GetRequiredService<ClassTeacherOverviewViewModel>();
|
||||
vm.OnNavigateToSettings = () => { NavigateToSettings(SettingsTab.WebUntis); return Task.CompletedTask; };
|
||||
vm.OnNavigateToWorkload = () => { NavigateToWorkload(); return Task.CompletedTask; };
|
||||
vm.LoadCommand.Execute(null);
|
||||
// Kein automatischer WebUntis-Abruf beim Navigieren: PrepareForDisplay() baut nur den
|
||||
// lokalen Grundzustand auf, die eigentlichen Report-Daten holt die Nutzerin bewusst über
|
||||
// den "Aktualisieren"-Button (LoadCommand) - vermeidet WebUntis-Login+Report-Latenz beim
|
||||
// bloßen Öffnen der Seite.
|
||||
vm.PrepareForDisplay();
|
||||
return vm;
|
||||
}
|
||||
|
||||
|
||||
@@ -37,7 +37,7 @@ public partial class WebUntisTimetableImportViewModel : ObservableObject
|
||||
public ObservableCollection<UntisTeacherDto> Teachers { get; } = [];
|
||||
public ObservableCollection<WebUntisTimetableRow> Rows { get; } = [];
|
||||
[ObservableProperty] private UntisTeacherDto? _selectedTeacher;
|
||||
[ObservableProperty] private DateTimeOffset _weekDate = DateTimeOffset.Now;
|
||||
[ObservableProperty] private DateTimeOffset? _weekDate = DateTimeOffset.Now;
|
||||
[ObservableProperty] private string _status = "Lehrkraft auswählen und Untis-Woche laden.";
|
||||
[ObservableProperty] private bool _busy;
|
||||
public bool Saved { get; private set; }
|
||||
@@ -71,7 +71,7 @@ public partial class WebUntisTimetableImportViewModel : ObservableObject
|
||||
Busy = true; Rows.Clear();
|
||||
try
|
||||
{
|
||||
var selected = DateOnly.FromDateTime(WeekDate.LocalDateTime);
|
||||
var selected = DateOnly.FromDateTime((WeekDate ?? DateTimeOffset.Now).LocalDateTime);
|
||||
var monday = selected.AddDays(-(((int)selected.DayOfWeek + 6) % 7));
|
||||
var periods = await _untis.GetTimetableAsync(SelectedTeacher.UntisId, monday, monday.AddDays(6));
|
||||
var grid = await _untis.GetTimeGridAsync();
|
||||
|
||||
@@ -229,6 +229,13 @@ public partial class SettingsViewModel
|
||||
_eventQueue.MarkReviewed(item.Id);
|
||||
SyncConflicts.Remove(item);
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void ClearSyncConflicts()
|
||||
{
|
||||
_eventQueue.ClearConflicts();
|
||||
SyncConflicts.Clear();
|
||||
}
|
||||
}
|
||||
|
||||
public class SyncConflictListItem(ConflictEntry c)
|
||||
|
||||
@@ -304,6 +304,11 @@ public partial class DocumentationDialogViewModel : ObservableObject
|
||||
var valid = true;
|
||||
|
||||
if (CanPickStudent && SelectedStudent is null) { StudentError = "Bezug auswählen."; valid = false; }
|
||||
if (!CanPickStudent && _studentId == Guid.Empty && _contextGroupId is null)
|
||||
{
|
||||
StudentError = "Die Schülerliste ist noch nicht geladen. Dialog schließen und erneut öffnen.";
|
||||
valid = false;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(Title)) { TitleError = "Titel erforderlich."; valid = false; }
|
||||
|
||||
|
||||
@@ -58,8 +58,8 @@ public partial class WebUntisDocumentationComparisonViewModel : ObservableObject
|
||||
|
||||
public ObservableCollection<WebUntisDocumentationRow> Rows { get; } = [];
|
||||
public ObservableCollection<LocalOnlyDocumentationRow> LocalOnlyRows { get; } = [];
|
||||
[ObservableProperty] private DateTimeOffset _startDate = DateTimeOffset.Now.AddDays(-7);
|
||||
[ObservableProperty] private DateTimeOffset _endDate = DateTimeOffset.Now;
|
||||
[ObservableProperty] private DateTimeOffset? _startDate = DateTimeOffset.Now.AddDays(-7);
|
||||
[ObservableProperty] private DateTimeOffset? _endDate = DateTimeOffset.Now;
|
||||
[ObservableProperty] private string _status = "Zeitraum wählen und Klassenbucheinträge laden.";
|
||||
[ObservableProperty] private bool _busy;
|
||||
|
||||
@@ -74,8 +74,8 @@ public partial class WebUntisDocumentationComparisonViewModel : ObservableObject
|
||||
[RelayCommand]
|
||||
private async Task Load()
|
||||
{
|
||||
var start = DateOnly.FromDateTime(StartDate.LocalDateTime);
|
||||
var end = DateOnly.FromDateTime(EndDate.LocalDateTime);
|
||||
var start = DateOnly.FromDateTime((StartDate ?? DateTimeOffset.Now).LocalDateTime);
|
||||
var end = DateOnly.FromDateTime((EndDate ?? DateTimeOffset.Now).LocalDateTime);
|
||||
if (end < start) { Status = "Das Enddatum darf nicht vor dem Startdatum liegen."; return; }
|
||||
Busy = true; Rows.Clear(); LocalOnlyRows.Clear();
|
||||
try
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
using System.Collections.ObjectModel;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using LehrerApp.Core.Interfaces;
|
||||
using LehrerApp.Core.Models;
|
||||
using LehrerApp.Desktop.Services;
|
||||
|
||||
namespace LehrerApp.Desktop.ViewModels.UntisHub;
|
||||
|
||||
/// <summary>Eine Zeile im Untis-Hub-Fenster - reine Anzeige-Projektion von
|
||||
/// <see cref="UntisHubJobRow"/>, neu aufgebaut bei jedem <see cref="UntisHubViewModel.Load"/>.</summary>
|
||||
public sealed class UntisHubRowViewModel
|
||||
{
|
||||
public UntisHubJobKind Kind { get; }
|
||||
public Guid? GroupId { get; }
|
||||
public string GroupName { get; }
|
||||
public string JobLabel { get; }
|
||||
public string DueLabel { get; }
|
||||
public string? LastResultSummary { get; }
|
||||
public bool IsWarning { get; }
|
||||
public bool IsDanger { get; }
|
||||
|
||||
public UntisHubRowViewModel(UntisHubJobRow row)
|
||||
{
|
||||
Kind = row.Kind; GroupId = row.GroupId; GroupName = row.GroupName;
|
||||
JobLabel = Label(row.Kind); DueLabel = row.DueLabel; LastResultSummary = row.LastResultSummary;
|
||||
IsWarning = row.DueState == UntisHubDueState.Due;
|
||||
IsDanger = row.DueState == UntisHubDueState.Overdue;
|
||||
}
|
||||
|
||||
private static string Label(UntisHubJobKind kind) => kind switch
|
||||
{
|
||||
UntisHubJobKind.FehlzeitenKurz => "Fehlzeiten (kurzfristig)",
|
||||
UntisHubJobKind.FehlzeitenLang => "Fehlzeiten (seit Schuljahresbeginn)",
|
||||
UntisHubJobKind.OffenePeriods => "Offene Stunden",
|
||||
UntisHubJobKind.Klassenbuchabgleich => "Klassenbuchabgleich",
|
||||
UntisHubJobKind.Hausaufgabenabgleich => "Hausaufgabenabgleich",
|
||||
_ => kind.ToString(),
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>ViewModel des Untis-Hub-Fensters (siehe TODO.md) - zeigt nur den gespeicherten
|
||||
/// Fälligkeitsstand an (<see cref="UntisHubService.GetRows"/>, rein lesend aus LiteDB). Das
|
||||
/// tatsächliche Ausführen eines Jobs (inkl. WebUntis-Anfrage) übernimmt die Code-Behind-Klasse über
|
||||
/// <see cref="UntisHubActions"/>, weil dafür ein Fenster-Owner für <c>ShowDialog</c> gebraucht wird.</summary>
|
||||
public partial class UntisHubViewModel : ObservableObject
|
||||
{
|
||||
private readonly UntisHubService _hub;
|
||||
private readonly IGroupRepository _groups;
|
||||
|
||||
public ObservableCollection<UntisHubRowViewModel> Rows { get; } = [];
|
||||
[ObservableProperty] private bool _isAvailable;
|
||||
[ObservableProperty] private string _status = "";
|
||||
|
||||
public UntisHubViewModel(UntisHubService hub, IGroupRepository groups, WebUntisIntegrationService untis)
|
||||
{
|
||||
_hub = hub; _groups = groups;
|
||||
IsAvailable = untis.IsAvailable;
|
||||
Load();
|
||||
}
|
||||
|
||||
public void Load()
|
||||
{
|
||||
Rows.Clear();
|
||||
if (!IsAvailable)
|
||||
{
|
||||
Status = "WebUntis ist nicht konfiguriert (siehe Einstellungen).";
|
||||
return;
|
||||
}
|
||||
foreach (var row in _hub.GetRows()) Rows.Add(new UntisHubRowViewModel(row));
|
||||
var overdue = Rows.Count(r => r.IsDanger);
|
||||
var due = Rows.Count(r => r.IsWarning);
|
||||
Status = overdue > 0 || due > 0
|
||||
? $"{overdue + due} von {Rows.Count} Prüfungen fällig ({overdue} überfällig)."
|
||||
: $"Alle {Rows.Count} Prüfungen aktuell.";
|
||||
}
|
||||
|
||||
public LearningGroup? FindGroup(Guid id) => _groups.GetById(id);
|
||||
}
|
||||
@@ -467,6 +467,10 @@ public partial class TimeTrackingViewModel : ObservableObject
|
||||
private readonly IWorkTaskRepository _tasks;
|
||||
private readonly ITimetableSlotRepository _timetableSlots;
|
||||
private readonly PeriodScheduleService _periodSchedule;
|
||||
private readonly ISchoolHolidayRepository? _schoolHolidays;
|
||||
private readonly PublicHolidayService? _publicHolidays;
|
||||
private readonly SchoolCalendarSettingsService? _calendarSettings;
|
||||
private readonly ISubstitutionEntryRepository? _substitutions;
|
||||
|
||||
// Nutzer-Feedback: "man beginnt ja auch vermutlich vor 7:50" (erste Stunde) und "wird auch
|
||||
// nicht aus dem Unterricht nach Hause rennen" (nach der letzten) - grobe, aber plausible
|
||||
@@ -474,6 +478,8 @@ public partial class TimeTrackingViewModel : ObservableObject
|
||||
// vor, gespeichert wird erst nach ausdrücklicher Bestätigung dort (siehe SuggestTeachingTime).
|
||||
private const int BufferBeforeFirstPeriodMinutes = 15;
|
||||
private const int BufferAfterLastPeriodMinutes = 10;
|
||||
private const int MissingTeachingTimeLookbackDays = 14;
|
||||
private const int MissingTeachingTimeTodayDelayMinutes = 30;
|
||||
|
||||
public const string NoTaskOption = "Keine Aufgabe";
|
||||
|
||||
@@ -490,6 +496,7 @@ public partial class TimeTrackingViewModel : ObservableObject
|
||||
|
||||
public ObservableCollection<TimeEntryListItem> WeekEntries { get; } = [];
|
||||
public ObservableCollection<CategoryTimeSummary> CategorySummaries { get; } = [];
|
||||
public ObservableCollection<TeachingTimeGapItem> MissingTeachingTimeEntries { get; } = [];
|
||||
public string TotalWeekMinutesDisplay => $"{WeekEntries.Sum(e => e.Model.DurationMinutes)} min diese Woche";
|
||||
|
||||
/// Ob heute laut Stundenplan überhaupt Unterricht ansteht - steuert, ob der
|
||||
@@ -502,14 +509,22 @@ public partial class TimeTrackingViewModel : ObservableObject
|
||||
/// im Dialog bleibt aber immer nötig, nichts wird automatisch gespeichert (siehe Puffer-
|
||||
/// Konstanten oben).
|
||||
public Func<TimeOnly, TimeOnly, Task<TimeEntry?>>? OnSuggestTeachingTime { get; set; }
|
||||
public Func<TeachingTimeGapItem, Task<TimeEntry?>>? OnAddMissingTeachingTime { get; set; }
|
||||
|
||||
public TimeTrackingViewModel(ITimeEntryRepository entries, IWorkTaskRepository tasks,
|
||||
ITimetableSlotRepository timetableSlots, PeriodScheduleService periodSchedule)
|
||||
ITimetableSlotRepository timetableSlots, PeriodScheduleService periodSchedule,
|
||||
ISchoolHolidayRepository? schoolHolidays = null, PublicHolidayService? publicHolidays = null,
|
||||
SchoolCalendarSettingsService? calendarSettings = null,
|
||||
ISubstitutionEntryRepository? substitutions = null)
|
||||
{
|
||||
_entries = entries;
|
||||
_tasks = tasks;
|
||||
_timetableSlots = timetableSlots;
|
||||
_periodSchedule = periodSchedule;
|
||||
_schoolHolidays = schoolHolidays;
|
||||
_publicHolidays = publicHolidays;
|
||||
_calendarSettings = calendarSettings;
|
||||
_substitutions = substitutions;
|
||||
Load();
|
||||
}
|
||||
|
||||
@@ -550,6 +565,7 @@ public partial class TimeTrackingViewModel : ObservableObject
|
||||
minutes, maxMinutes > 0 ? minutes / (double)maxMinutes : 0));
|
||||
}
|
||||
|
||||
RefreshMissingTeachingTime(today);
|
||||
OnPropertyChanged(nameof(TotalWeekMinutesDisplay));
|
||||
}
|
||||
|
||||
@@ -605,6 +621,59 @@ public partial class TimeTrackingViewModel : ObservableObject
|
||||
Refresh();
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private async Task AddMissingTeachingTime(TeachingTimeGapItem? item)
|
||||
{
|
||||
if (item is null || OnAddMissingTeachingTime is null) return;
|
||||
var result = await OnAddMissingTeachingTime(item);
|
||||
if (result is null) return;
|
||||
_entries.Save(result);
|
||||
Refresh();
|
||||
}
|
||||
|
||||
private void RefreshMissingTeachingTime(DateOnly today)
|
||||
{
|
||||
MissingTeachingTimeEntries.Clear();
|
||||
var firstDay = today.AddDays(-MissingTeachingTimeLookbackDays);
|
||||
var timetableSlots = _timetableSlots.GetAll();
|
||||
var schoolHolidays = _schoolHolidays?.GetAll() ?? [];
|
||||
var publicHolidayDates = _publicHolidays is not null && _calendarSettings is not null
|
||||
? Enumerable.Range(firstDay.Year, today.Year - firstDay.Year + 1)
|
||||
.SelectMany(y => _publicHolidays.GetHolidays(y, _calendarSettings.State))
|
||||
.Select(h => h.Date).ToHashSet()
|
||||
: [];
|
||||
var nowTime = TimeOnly.FromDateTime(DateTime.Now);
|
||||
var teachingCategory = TaskCategoryDisplay.Label(TaskCategory.Teaching);
|
||||
|
||||
for (var date = firstDay; date <= today; date = date.AddDays(1))
|
||||
{
|
||||
if (publicHolidayDates.Contains(date)
|
||||
|| schoolHolidays.Any(h => date >= h.StartDate && date <= h.EndDate))
|
||||
continue;
|
||||
|
||||
var daySlots = timetableSlots.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;
|
||||
|
||||
var lastPeriodEnd = periodTimes.Max(t => t.End);
|
||||
if (date == today && nowTime < lastPeriodEnd.AddMinutes(MissingTeachingTimeTodayDelayMinutes))
|
||||
continue;
|
||||
if (_entries.GetByDate(date).Any(e => e.Category == teachingCategory)) continue;
|
||||
|
||||
MissingTeachingTimeEntries.Add(new TeachingTimeGapItem(
|
||||
date,
|
||||
periodTimes.Min(t => t.Start).AddMinutes(-BufferBeforeFirstPeriodMinutes),
|
||||
lastPeriodEnd.AddMinutes(BufferAfterLastPeriodMinutes)));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Frühester Beginn / spätestes Ende aller heutigen Stundenplan-Perioden (alle Gruppen, nicht
|
||||
/// auf eine einzelne beschränkt - der Unterrichtstag als Ganzes), je um die oben definierten
|
||||
@@ -647,6 +716,17 @@ public class TimeEntryListItem(TimeEntry model, string? taskTitle)
|
||||
public string Description => Model.Description ?? "";
|
||||
}
|
||||
|
||||
public sealed class TeachingTimeGapItem(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);
|
||||
public string TimeDisplay { get; } = $"{windowStart:HH:mm}–{windowEnd:HH:mm} Uhr";
|
||||
}
|
||||
|
||||
public class CategoryTimeSummary(string category, int minutes, double barFraction)
|
||||
{
|
||||
public string Category { get; } = category;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<UserControl xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:vm="clr-namespace:LehrerApp.Desktop.ViewModels.ClassTeacher"
|
||||
xmlns:conv="clr-namespace:LehrerApp.Desktop.Converters"
|
||||
x:Class="LehrerApp.Desktop.Views.ClassTeacher.ClassTeacherAbsencesView"
|
||||
x:DataType="vm:ClassTeacherDetailsViewModel">
|
||||
|
||||
@@ -26,9 +27,20 @@
|
||||
<Style Selector="TextBlock.status.danger">
|
||||
<Setter Property="Foreground" Value="{DynamicResource AppStatusDangerBrush}"/>
|
||||
</Style>
|
||||
<Style Selector="Button.viewMode">
|
||||
<Setter Property="Background" Value="Transparent"/>
|
||||
<Setter Property="BorderBrush" Value="{DynamicResource AppFilterBorderBrush}"/>
|
||||
<Setter Property="BorderThickness" Value="1"/>
|
||||
<Setter Property="Padding" Value="12,6"/>
|
||||
</Style>
|
||||
<Style Selector="Button.viewMode.active">
|
||||
<Setter Property="Background" Value="{DynamicResource AppFilterActiveBackgroundBrush}"/>
|
||||
<Setter Property="BorderBrush" Value="{DynamicResource AppFilterActiveBorderBrush}"/>
|
||||
<Setter Property="Foreground" Value="{DynamicResource AppFilterActiveForegroundBrush}"/>
|
||||
</Style>
|
||||
</UserControl.Styles>
|
||||
|
||||
<Grid RowDefinitions="Auto,Auto,*,Auto" Margin="16" RowSpacing="10">
|
||||
<Grid RowDefinitions="Auto,Auto,Auto,*,Auto" Margin="16" RowSpacing="10">
|
||||
<Grid Grid.Row="0" ColumnDefinitions="*,Auto">
|
||||
<StackPanel>
|
||||
<TextBlock Text="Fehlzeiten" FontSize="22" FontWeight="SemiBold"/>
|
||||
@@ -38,16 +50,29 @@
|
||||
IsEnabled="{Binding !Busy}" Background="Transparent" Foreground="{DynamicResource AppAccentTextBrush}" VerticalAlignment="Center"/>
|
||||
</Grid>
|
||||
|
||||
<Border Grid.Row="1" Classes="filterCard">
|
||||
<Grid Grid.Row="1" ColumnDefinitions="Auto,Auto,*,Auto,Auto,Auto" ColumnSpacing="6">
|
||||
<Button Grid.Column="0" Content="Liste" Classes="viewMode" Classes.active="{Binding !ShowMonthlyCalendar}"
|
||||
Command="{Binding ShowAbsenceListCommand}"/>
|
||||
<Button Grid.Column="1" Content="Monatsübersicht" Classes="viewMode" Classes.active="{Binding ShowMonthlyCalendar}"
|
||||
Command="{Binding ShowCalendarCommand}"/>
|
||||
<Button Grid.Column="3" Content="‹" Width="36" Command="{Binding PreviousCalendarMonthCommand}"
|
||||
IsVisible="{Binding ShowMonthlyCalendar}" AutomationProperties.Name="Vorheriger Monat"/>
|
||||
<TextBlock Grid.Column="4" Text="{Binding CalendarMonthLabel}" FontSize="15" FontWeight="SemiBold"
|
||||
VerticalAlignment="Center" Margin="8,0" IsVisible="{Binding ShowMonthlyCalendar}"/>
|
||||
<Button Grid.Column="5" Content="›" Width="36" Command="{Binding NextCalendarMonthCommand}"
|
||||
IsVisible="{Binding ShowMonthlyCalendar}" AutomationProperties.Name="Nächster Monat"/>
|
||||
</Grid>
|
||||
|
||||
<Border Grid.Row="2" Classes="filterCard" IsVisible="{Binding !ShowMonthlyCalendar}">
|
||||
<Grid RowDefinitions="Auto,Auto" RowSpacing="8">
|
||||
<Grid Grid.Row="0" ColumnDefinitions="155,Auto,*,Auto,*" ColumnSpacing="8">
|
||||
<ComboBox Grid.Column="0" SelectedIndex="{Binding QuickRangeIndex, Mode=TwoWay}">
|
||||
<ComboBoxItem Content="Heute"/><ComboBoxItem Content="Letzte 7 Tage"/><ComboBoxItem Content="Letzte 30 Tage"/>
|
||||
</ComboBox>
|
||||
<TextBlock Grid.Column="1" Text="von" VerticalAlignment="Center" Opacity="0.6"/>
|
||||
<CalendarDatePicker Grid.Column="2" SelectedDate="{Binding StartDate}" HorizontalAlignment="Stretch"/>
|
||||
<CalendarDatePicker Grid.Column="2" SelectedDate="{Binding StartDate, Converter={x:Static conv:DateTimeOffsetToDateTimeConverter.Instance}}" HorizontalAlignment="Stretch"/>
|
||||
<TextBlock Grid.Column="3" Text="bis" VerticalAlignment="Center" Opacity="0.6"/>
|
||||
<CalendarDatePicker Grid.Column="4" SelectedDate="{Binding EndDate}" HorizontalAlignment="Stretch"/>
|
||||
<CalendarDatePicker Grid.Column="4" SelectedDate="{Binding EndDate, Converter={x:Static conv:DateTimeOffsetToDateTimeConverter.Instance}}" HorizontalAlignment="Stretch"/>
|
||||
</Grid>
|
||||
<Grid Grid.Row="1" ColumnDefinitions="*,Auto,Auto" ColumnSpacing="8">
|
||||
<TextBox Grid.Column="0" Text="{Binding StudentFilter, Mode=TwoWay}" PlaceholderText="Schüler*in filtern…"/>
|
||||
@@ -58,10 +83,11 @@
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<Grid Grid.Row="2">
|
||||
<Grid Grid.Row="3">
|
||||
<DataGrid ItemsSource="{Binding AbsenceEntries}" AutoGenerateColumns="False" IsReadOnly="True"
|
||||
GridLinesVisibility="Horizontal" BorderBrush="{DynamicResource AppCardBorderBrush}" BorderThickness="1"
|
||||
CanUserResizeColumns="True" CanUserSortColumns="True" RowHeight="48">
|
||||
CanUserResizeColumns="True" CanUserSortColumns="True" RowHeight="48"
|
||||
IsVisible="{Binding !ShowMonthlyCalendar}">
|
||||
<DataGrid.Columns>
|
||||
<DataGridTextColumn Header="Datum" Binding="{Binding DateLabel}" Width="105"/>
|
||||
<DataGridTextColumn Header="Schüler*in" Binding="{Binding StudentDisplayName}" Width="1.3*"/>
|
||||
@@ -91,11 +117,61 @@
|
||||
<DataGridTextColumn Header="Grund / Notiz" Binding="{Binding DetailLabel}" Width="2*"/>
|
||||
</DataGrid.Columns>
|
||||
</DataGrid>
|
||||
<StackPanel IsVisible="{Binding !HasAbsenceEntries}" HorizontalAlignment="Center" VerticalAlignment="Center" Spacing="5">
|
||||
<StackPanel IsVisible="{Binding ShowAbsenceListEmpty}" HorizontalAlignment="Center" VerticalAlignment="Center" Spacing="5">
|
||||
<TextBlock Text="Keine Fehlzeiten im gewählten Zeitraum" FontWeight="SemiBold"/>
|
||||
<TextBlock Text="Passe Zeitraum oder Schülerfilter an." FontSize="12" Opacity="0.55"/>
|
||||
</StackPanel>
|
||||
|
||||
<Grid IsVisible="{Binding ShowMonthlyCalendar}" RowDefinitions="Auto,Auto,*" RowSpacing="8">
|
||||
<WrapPanel Grid.Row="0" ItemSpacing="12" LineSpacing="6">
|
||||
<TextBlock Text="V verspätet" Foreground="#EF6C00" FontSize="11" FontWeight="SemiBold"/>
|
||||
<TextBlock Text="A abwesend" Foreground="#1565C0" FontSize="11" FontWeight="SemiBold"/>
|
||||
<TextBlock Text="U unentschuldigt" Foreground="#C62828" FontSize="11" FontWeight="SemiBold"/>
|
||||
<TextBlock Text="E entschuldigt" Foreground="#2E7D32" FontSize="11" FontWeight="SemiBold"/>
|
||||
<TextBlock Text="! negativer Klassenbucheintrag" Foreground="#8E24AA" FontSize="11" FontWeight="SemiBold"/>
|
||||
<CheckBox Content="Hausaufgaben (H) einblenden" IsChecked="{Binding IncludeHomeworkInCalendar}"
|
||||
FontSize="11" VerticalAlignment="Center"/>
|
||||
</WrapPanel>
|
||||
<Grid Grid.Row="1" ColumnDefinitions="*,*,*,*,*" Margin="0,2,0,0">
|
||||
<TextBlock Grid.Column="0" Text="Montag" FontWeight="SemiBold" HorizontalAlignment="Center"/>
|
||||
<TextBlock Grid.Column="1" Text="Dienstag" FontWeight="SemiBold" HorizontalAlignment="Center"/>
|
||||
<TextBlock Grid.Column="2" Text="Mittwoch" FontWeight="SemiBold" HorizontalAlignment="Center"/>
|
||||
<TextBlock Grid.Column="3" Text="Donnerstag" FontWeight="SemiBold" HorizontalAlignment="Center"/>
|
||||
<TextBlock Grid.Column="4" Text="Freitag" FontWeight="SemiBold" HorizontalAlignment="Center"/>
|
||||
</Grid>
|
||||
<TextBlock Grid.Row="3" Text="{Binding Status}" FontSize="11" Opacity="0.6"/>
|
||||
<ScrollViewer Grid.Row="2">
|
||||
<ItemsControl ItemsSource="{Binding CalendarDays}">
|
||||
<ItemsControl.ItemsPanel>
|
||||
<ItemsPanelTemplate><UniformGrid Columns="5"/></ItemsPanelTemplate>
|
||||
</ItemsControl.ItemsPanel>
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:ClassTeacherCalendarDay">
|
||||
<Border BorderBrush="{DynamicResource AppCardBorderBrush}" BorderThickness="1"
|
||||
Background="{DynamicResource AppCardBackgroundBrush}" MinHeight="112" Padding="7" Margin="2">
|
||||
<StackPanel Spacing="5">
|
||||
<TextBlock Text="{Binding DayLabel}" FontWeight="SemiBold" HorizontalAlignment="Right"/>
|
||||
<ItemsControl ItemsSource="{Binding Events}">
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:ClassTeacherCalendarEvent">
|
||||
<Grid ColumnDefinitions="22,*" Margin="0,1" ToolTip.Tip="{Binding Tooltip}">
|
||||
<Border Width="18" Height="18" CornerRadius="9" Background="{Binding ColorHex}">
|
||||
<TextBlock Text="{Binding Code}" Foreground="White" FontSize="10" FontWeight="Bold"
|
||||
HorizontalAlignment="Center" VerticalAlignment="Center"/>
|
||||
</Border>
|
||||
<TextBlock Grid.Column="1" Text="{Binding StudentName}" FontSize="10"
|
||||
TextTrimming="CharacterEllipsis" VerticalAlignment="Center"/>
|
||||
</Grid>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
</ScrollViewer>
|
||||
</Grid>
|
||||
</Grid>
|
||||
<TextBlock Grid.Row="4" Text="{Binding Status}" FontSize="11" Opacity="0.6"/>
|
||||
</Grid>
|
||||
</UserControl>
|
||||
|
||||
@@ -0,0 +1,197 @@
|
||||
<UserControl xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:vm="clr-namespace:LehrerApp.Desktop.ViewModels.ClassTeacher"
|
||||
xmlns:svm="clr-namespace:LehrerApp.Desktop.ViewModels.Students"
|
||||
x:Class="LehrerApp.Desktop.Views.ClassTeacher.ClassTeacherCasesView"
|
||||
x:DataType="vm:ClassTeacherCasesViewModel">
|
||||
|
||||
<UserControl.Styles>
|
||||
<Style Selector="Border.statusPill">
|
||||
<Setter Property="CornerRadius" Value="10"/>
|
||||
<Setter Property="Padding" Value="8,2"/>
|
||||
</Style>
|
||||
<Style Selector="Border.statusPill.open">
|
||||
<Setter Property="Background" Value="#FB8C00"/>
|
||||
</Style>
|
||||
<Style Selector="Border.statusPill.closed">
|
||||
<Setter Property="Background" Value="#43A047"/>
|
||||
</Style>
|
||||
<Style Selector="Border.statusPill TextBlock">
|
||||
<Setter Property="Foreground" Value="White"/>
|
||||
<Setter Property="FontSize" Value="11"/>
|
||||
<Setter Property="FontWeight" Value="SemiBold"/>
|
||||
</Style>
|
||||
</UserControl.Styles>
|
||||
|
||||
<Grid RowDefinitions="Auto,Auto,*" Margin="16" RowSpacing="10">
|
||||
<Grid Grid.Row="0" ColumnDefinitions="*,Auto,Auto">
|
||||
<StackPanel Grid.Column="0">
|
||||
<TextBlock Text="Vorgänge" FontSize="22" FontWeight="SemiBold"/>
|
||||
<TextBlock Text="Fallmappen für laufende Probleme mit einer/einem oder mehreren Schüler*innen der Klasse"
|
||||
FontSize="12" Opacity="0.55"/>
|
||||
</StackPanel>
|
||||
<CheckBox Grid.Column="1" Content="Nur offene" IsChecked="{Binding OnlyOpen}"
|
||||
VerticalAlignment="Center" Margin="0,0,12,0"/>
|
||||
<Button Grid.Column="2" Content="+ Vorgang" Command="{Binding AddVorgangCommand}"/>
|
||||
</Grid>
|
||||
|
||||
<TextBlock Grid.Row="1" Text="{Binding Status}" FontSize="11" Opacity="0.6"/>
|
||||
|
||||
<ScrollViewer Grid.Row="2">
|
||||
<StackPanel>
|
||||
<ItemsControl ItemsSource="{Binding Cases}">
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate DataType="vm:VorgangItem">
|
||||
<Border Background="{DynamicResource AppCardBackgroundBrush}"
|
||||
BorderBrush="{DynamicResource AppCardBorderBrush}" BorderThickness="1"
|
||||
CornerRadius="8" Padding="12,10" Margin="0,0,0,8">
|
||||
<StackPanel Spacing="8">
|
||||
<Grid ColumnDefinitions="*,Auto,Auto,Auto,Auto,Auto">
|
||||
<StackPanel Grid.Column="0">
|
||||
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||
<Border Classes="statusPill" Classes.open="{Binding IsOpen}" Classes.closed="{Binding !IsOpen}">
|
||||
<TextBlock Text="{Binding StatusLabel}"/>
|
||||
</Border>
|
||||
<TextBlock Text="{Binding Model.Title}" FontWeight="SemiBold" FontSize="14"/>
|
||||
</StackPanel>
|
||||
<TextBlock Text="{Binding StudentNames}" FontSize="12" Opacity="0.7" Margin="0,2,0,0"/>
|
||||
<ItemsControl ItemsSource="{Binding TagChips}">
|
||||
<ItemsControl.ItemsPanel>
|
||||
<ItemsPanelTemplate><WrapPanel ItemSpacing="6" LineSpacing="4"/></ItemsPanelTemplate>
|
||||
</ItemsControl.ItemsPanel>
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate DataType="svm:TagChip">
|
||||
<Border Background="{Binding ColorHex}" CornerRadius="10" Padding="8,2" Margin="0,4,0,0">
|
||||
<TextBlock Text="{Binding Text}" FontSize="10" Foreground="White"/>
|
||||
</Border>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
</StackPanel>
|
||||
<StackPanel Grid.Column="1" Orientation="Horizontal">
|
||||
<Button Content="Details anzeigen" FontSize="11" Padding="8,3"
|
||||
Command="{Binding RevealCommand}" IsVisible="{Binding !IsRevealed}"/>
|
||||
<Button Content="Details ausblenden" FontSize="11" Padding="8,3"
|
||||
Command="{Binding RevealCommand}" IsVisible="{Binding IsRevealed}"/>
|
||||
</StackPanel>
|
||||
<Button Grid.Column="2" Content="Bearbeiten" FontSize="11" Padding="8,3"
|
||||
Command="{Binding $parent[ItemsControl].((vm:ClassTeacherCasesViewModel)DataContext).EditVorgangCommand}"
|
||||
CommandParameter="{Binding}"/>
|
||||
<StackPanel Grid.Column="3" Orientation="Horizontal">
|
||||
<Button Content="Schließen" FontSize="11" Padding="8,3" IsVisible="{Binding IsOpen}"
|
||||
Command="{Binding $parent[ItemsControl].((vm:ClassTeacherCasesViewModel)DataContext).ToggleStatusCommand}"
|
||||
CommandParameter="{Binding}"/>
|
||||
<Button Content="Wieder öffnen" FontSize="11" Padding="8,3" IsVisible="{Binding !IsOpen}"
|
||||
Command="{Binding $parent[ItemsControl].((vm:ClassTeacherCasesViewModel)DataContext).ToggleStatusCommand}"
|
||||
CommandParameter="{Binding}"/>
|
||||
</StackPanel>
|
||||
<Button Grid.Column="4" Content="Löschen" FontSize="11" Padding="8,3"
|
||||
Command="{Binding $parent[ItemsControl].((vm:ClassTeacherCasesViewModel)DataContext).DeleteVorgangCommand}"
|
||||
CommandParameter="{Binding}"/>
|
||||
</Grid>
|
||||
|
||||
<StackPanel Spacing="10" IsVisible="{Binding IsRevealed}" Margin="0,4,0,0">
|
||||
<TextBlock Text="{Binding Model.Description}" FontSize="12" TextWrapping="Wrap" Opacity="0.85"
|
||||
IsVisible="{Binding Model.Description, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
|
||||
|
||||
<StackPanel Spacing="4">
|
||||
<TextBlock Text="Verknüpfte Dokumentation" FontSize="12" FontWeight="SemiBold" Opacity="0.7"/>
|
||||
<ItemsControl ItemsSource="{Binding LinkedDocumentation}">
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate DataType="svm:DocumentationItem">
|
||||
<Grid ColumnDefinitions="70,*,Auto,Auto,Auto" Margin="0,2">
|
||||
<TextBlock Grid.Column="0" Text="{Binding DateDisplay}" FontSize="11" Opacity="0.5"/>
|
||||
<StackPanel Grid.Column="1">
|
||||
<StackPanel Orientation="Horizontal" Spacing="6">
|
||||
<TextBlock Text="{Binding StudentName}" FontSize="12" FontWeight="SemiBold"/>
|
||||
<TextBlock Text="{Binding Model.Title}" FontSize="12" IsVisible="{Binding IsRevealed}"/>
|
||||
<TextBlock Text="Vertraulich" FontSize="12" Opacity="0.6" IsVisible="{Binding !IsRevealed}"/>
|
||||
</StackPanel>
|
||||
<TextBlock Text="{Binding TypeLabel}" FontSize="10" Opacity="0.5"/>
|
||||
</StackPanel>
|
||||
<Button Grid.Column="2" Content="Anzeigen" FontSize="10" Padding="6,2" Margin="0,0,4,0"
|
||||
Command="{Binding RevealCommand}" IsVisible="{Binding !IsRevealed}"/>
|
||||
<!-- Nutzer-Feedback: verknüpfte Einträge sollen sich zum Nachlesen direkt
|
||||
hier öffnen lassen, ohne in den Schülerdatensatz wechseln zu müssen. -->
|
||||
<Button Grid.Column="3" Content="Bearbeiten" FontSize="10" Padding="6,2" Margin="0,0,4,0"
|
||||
IsVisible="{Binding IsRevealed}"
|
||||
Command="{Binding $parent[ItemsControl].((vm:VorgangItem)DataContext).EditDocumentationCommand}"
|
||||
CommandParameter="{Binding}"/>
|
||||
<Button Grid.Column="4" Content="Entfernen" FontSize="10" Padding="6,2"
|
||||
Command="{Binding $parent[ItemsControl].((vm:VorgangItem)DataContext).UnlinkCommand}"
|
||||
CommandParameter="{Binding}"/>
|
||||
</Grid>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
<TextBlock Text="Noch keine Dokumentation verknüpft." FontSize="11" Opacity="0.5"
|
||||
IsVisible="{Binding !HasLinkedDocumentation}"/>
|
||||
<Button Content="+ Neue Dokumentation anlegen" FontSize="11" Padding="8,3"
|
||||
HorizontalAlignment="Left"
|
||||
Command="{Binding $parent[ItemsControl].((vm:ClassTeacherCasesViewModel)DataContext).CreateAndLinkDocumentationCommand}"
|
||||
CommandParameter="{Binding}"/>
|
||||
</StackPanel>
|
||||
|
||||
<StackPanel Spacing="4" IsVisible="{Binding HasAvailableDocumentation}">
|
||||
<TextBlock Text="Bestehende Dokumentation der Schüler*innen verknüpfen" FontSize="12"
|
||||
FontWeight="SemiBold" Opacity="0.7"/>
|
||||
<ItemsControl ItemsSource="{Binding AvailableDocumentation}">
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate DataType="svm:DocumentationItem">
|
||||
<Grid ColumnDefinitions="70,*,Auto,Auto,Auto" Margin="0,2">
|
||||
<TextBlock Grid.Column="0" Text="{Binding DateDisplay}" FontSize="11" Opacity="0.5"/>
|
||||
<StackPanel Grid.Column="1">
|
||||
<StackPanel Orientation="Horizontal" Spacing="6">
|
||||
<TextBlock Text="{Binding StudentName}" FontSize="12" FontWeight="SemiBold"/>
|
||||
<TextBlock Text="{Binding Model.Title}" FontSize="12" IsVisible="{Binding IsRevealed}"/>
|
||||
<TextBlock Text="Vertraulich" FontSize="12" Opacity="0.6" IsVisible="{Binding !IsRevealed}"/>
|
||||
</StackPanel>
|
||||
<TextBlock Text="{Binding TypeLabel}" FontSize="10" Opacity="0.5"/>
|
||||
</StackPanel>
|
||||
<Button Grid.Column="2" Content="Anzeigen" FontSize="10" Padding="6,2" Margin="0,0,4,0"
|
||||
Command="{Binding RevealCommand}" IsVisible="{Binding !IsRevealed}"/>
|
||||
<Button Grid.Column="3" Content="Bearbeiten" FontSize="10" Padding="6,2" Margin="0,0,4,0"
|
||||
IsVisible="{Binding IsRevealed}"
|
||||
Command="{Binding $parent[ItemsControl].((vm:VorgangItem)DataContext).EditDocumentationCommand}"
|
||||
CommandParameter="{Binding}"/>
|
||||
<Button Grid.Column="4" Content="Verknüpfen" FontSize="10" Padding="6,2"
|
||||
Command="{Binding $parent[ItemsControl].((vm:VorgangItem)DataContext).LinkCommand}"
|
||||
CommandParameter="{Binding}"/>
|
||||
</Grid>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
</StackPanel>
|
||||
|
||||
<StackPanel Spacing="4">
|
||||
<TextBlock Text="Angeheftete Klassenbucheinträge" FontSize="12" FontWeight="SemiBold" Opacity="0.7"/>
|
||||
<ItemsControl ItemsSource="{Binding ClassRegisterRows}">
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate DataType="vm:VorgangClassRegisterEntryRow">
|
||||
<Grid ColumnDefinitions="70,*,Auto" Margin="0,2">
|
||||
<TextBlock Grid.Column="0" Text="{Binding DateLabel}" FontSize="11" Opacity="0.5"/>
|
||||
<StackPanel Grid.Column="1">
|
||||
<TextBlock Text="{Binding StudentName}" FontSize="12" FontWeight="SemiBold"/>
|
||||
<TextBlock Text="{Binding SummaryLabel}" FontSize="11" Opacity="0.7" TextWrapping="Wrap"/>
|
||||
</StackPanel>
|
||||
<Button Grid.Column="2" Content="Entfernen" FontSize="10" Padding="6,2"
|
||||
Command="{Binding $parent[ItemsControl].((vm:VorgangItem)DataContext).RemoveClassRegisterEntryCommand}"
|
||||
CommandParameter="{Binding}"/>
|
||||
</Grid>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
<TextBlock Text="Noch keine Klassenbucheinträge angeheftet — im Klassenbuch-Tab über „→ Vorgang“ möglich."
|
||||
FontSize="11" Opacity="0.5" IsVisible="{Binding !HasClassRegisterRows}" TextWrapping="Wrap"/>
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
<TextBlock Text="Keine Vorgänge." Classes="emptyhint" IsVisible="{Binding !HasCases}"/>
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
</Grid>
|
||||
</UserControl>
|
||||
@@ -0,0 +1,63 @@
|
||||
using Avalonia.Controls;
|
||||
using LehrerApp.Core.Interfaces;
|
||||
using LehrerApp.Core.Models;
|
||||
using LehrerApp.Desktop.ViewModels.ClassTeacher;
|
||||
using LehrerApp.Desktop.ViewModels.Students;
|
||||
using LehrerApp.Desktop.Views.Shared;
|
||||
using LehrerApp.Desktop.Views.Students;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace LehrerApp.Desktop.Views.ClassTeacher;
|
||||
|
||||
public partial class ClassTeacherCasesView : UserControl
|
||||
{
|
||||
public ClassTeacherCasesView() => InitializeComponent();
|
||||
|
||||
protected override void OnDataContextChanged(EventArgs e)
|
||||
{
|
||||
base.OnDataContextChanged(e);
|
||||
if (DataContext is not ClassTeacherCasesViewModel vm) return;
|
||||
vm.OnEditVorgang = ShowVorgangDialog;
|
||||
vm.OnConfirmDeleteVorgang = ShowDeleteVorgangDialog;
|
||||
vm.OnEditDocumentation = ShowDocumentationDialog;
|
||||
}
|
||||
|
||||
private async Task<Vorgang?> ShowVorgangDialog(List<StudentOption> rosterStudents, Vorgang? editing)
|
||||
{
|
||||
var owner = TopLevel.GetTopLevel(this) as Window;
|
||||
if (owner is null) return null;
|
||||
|
||||
var vm = new VorgangDialogViewModel(rosterStudents, editing);
|
||||
var dialog = new VorgangDialog { DataContext = vm };
|
||||
var saved = await dialog.ShowDialog<bool>(owner);
|
||||
return saved ? vm.Result : null;
|
||||
}
|
||||
|
||||
private async Task<bool> ShowDeleteVorgangDialog(VorgangItem item)
|
||||
{
|
||||
var info = new ConfirmDialogInfo
|
||||
{
|
||||
Title = "Vorgang löschen?",
|
||||
Message = $"\"{item.Model.Title}\" wird als gelöscht markiert und nicht mehr angezeigt. " +
|
||||
"Verknüpfte Dokumentation bleibt erhalten.",
|
||||
ConfirmText = "Löschen",
|
||||
};
|
||||
var dialog = new ConfirmDialog { DataContext = info };
|
||||
var owner = TopLevel.GetTopLevel(this) as Window;
|
||||
return owner is not null && await dialog.ShowDialog<bool>(owner);
|
||||
}
|
||||
|
||||
private async Task<Documentation?> ShowDocumentationDialog(
|
||||
Guid defaultStudentId, List<StudentOption> studentOptions, Documentation? editing)
|
||||
{
|
||||
var owner = TopLevel.GetTopLevel(this) as Window;
|
||||
if (owner is null) return null;
|
||||
|
||||
var vm = new DocumentationDialogViewModel(defaultStudentId, editing,
|
||||
App.Services.GetRequiredService<IAttachmentStorage>(), studentOptions);
|
||||
var dialog = new DocumentationDialog { DataContext = vm };
|
||||
var saved = await dialog.ShowDialog<bool>(owner);
|
||||
if (!saved) vm.DiscardUnsavedAttachments();
|
||||
return saved ? vm.Result : null;
|
||||
}
|
||||
}
|
||||
@@ -98,6 +98,19 @@
|
||||
<Style Selector="Border.statusFill.danger">
|
||||
<Setter Property="Background" Value="{DynamicResource AppStatusDangerBrush}"/>
|
||||
</Style>
|
||||
<Style Selector="Border.compactDay">
|
||||
<Setter Property="Width" Value="28"/>
|
||||
<Setter Property="Height" Value="28"/>
|
||||
<Setter Property="CornerRadius" Value="6"/>
|
||||
<Setter Property="Background" Value="{DynamicResource AppChipBackgroundBrush}"/>
|
||||
<Setter Property="BorderBrush" Value="{DynamicResource AppCardBorderBrush}"/>
|
||||
<Setter Property="BorderThickness" Value="1"/>
|
||||
<Setter Property="Margin" Value="2"/>
|
||||
</Style>
|
||||
<Style Selector="Border.compactDay.today">
|
||||
<Setter Property="BorderBrush" Value="{DynamicResource AppAccentTextBrush}"/>
|
||||
<Setter Property="BorderThickness" Value="2"/>
|
||||
</Style>
|
||||
</UserControl.Styles>
|
||||
|
||||
<UserControl.Resources>
|
||||
@@ -227,14 +240,26 @@
|
||||
<Border Grid.Row="0" Background="{DynamicResource AppCardBackgroundBrush}"
|
||||
BorderBrush="{DynamicResource AppCardBorderBrush}" BorderThickness="1"
|
||||
CornerRadius="8" Padding="8" Margin="0,0,0,4">
|
||||
<Grid ColumnDefinitions="Auto,Auto,Auto,*,250" ColumnSpacing="8">
|
||||
<Grid ColumnDefinitions="Auto,Auto,Auto,Auto,Auto,*,250" ColumnSpacing="8">
|
||||
<Button Grid.Column="0" Classes="filter" Classes.active="{Binding AlertsFilterSelected}"
|
||||
Content="Auffällig" Command="{Binding ShowAlertsCommand}"/>
|
||||
<Button Grid.Column="1" Classes="filter" Classes.active="{Binding ClassRegisterFilterSelected}"
|
||||
Content="Klassenbuch" Command="{Binding ShowClassRegisterCommand}"/>
|
||||
<Button Grid.Column="2" Classes="filter" Classes.active="{Binding AllFilterSelected}"
|
||||
Content="Alle" Command="{Binding ShowAllCommand}"/>
|
||||
<TextBox Grid.Column="4" Text="{Binding SearchText, Mode=TwoWay}" PlaceholderText="Schüler*in suchen…"
|
||||
<!-- Nutzer-Feedback: "Auffällig" zeigt nur den Heute-Snapshot — wer heute zufällig
|
||||
da ist, aber zuvor mehrfach unentschuldigt fehlte, taucht dort gar nicht auf,
|
||||
während ein einmaliger Erstfehltag ganz oben landet. Neuer Reiter sortiert
|
||||
stattdessen nach dem kumulierten Score seit Schuljahresbeginn
|
||||
(ClassTeacherRosterRow.PatternScore: unentschuldigt > entschuldigt >
|
||||
Verspätung) und ist deshalb der beim Öffnen aktive Standard-Reiter. -->
|
||||
<Button Grid.Column="3" Classes="filter" Classes.active="{Binding PatternScoreFilterSelected}"
|
||||
Content="Gesamtbild" Command="{Binding ShowPatternScoreCommand}"
|
||||
ToolTip.Tip="Sortiert nach Auffälligkeits-Score seit Schuljahresbeginn (unentschuldigt zählt am stärksten, Verspätung am wenigsten) statt nur nach dem heutigen Status."/>
|
||||
<Button Grid.Column="4" Content="⚙ Gewichtung" FontSize="12" Padding="10,4"
|
||||
Background="Transparent" Click="OnEditPatternScoreWeights"
|
||||
ToolTip.Tip="Legt fest, wie stark einzelne Ereignisse in den Auffälligkeits-Score der 'Gesamtbild'-Sortierung einfließen."/>
|
||||
<TextBox Grid.Column="6" Text="{Binding SearchText, Mode=TwoWay}" PlaceholderText="Schüler*in suchen…"
|
||||
FontSize="12" MinHeight="32"/>
|
||||
</Grid>
|
||||
</Border>
|
||||
@@ -397,6 +422,48 @@
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<!-- Verdichtete Monatsansicht: exakt eine Zelle je Kalendertag. Ohne Ereignis
|
||||
bleibt nur die Tageszahl sichtbar; bei Auffälligkeiten ersetzt das stärkste
|
||||
Statuskürzel die Zahl. Alle Details bleiben per Tooltip und Drill-down erhalten. -->
|
||||
<Border Classes="sidePanel">
|
||||
<StackPanel Spacing="8">
|
||||
<Grid ColumnDefinitions="*,Auto">
|
||||
<StackPanel>
|
||||
<TextBlock Text="Monat auf einen Blick" FontSize="14" FontWeight="SemiBold"/>
|
||||
<TextBlock Text="{Binding CompactMonthLabel}" FontSize="11" Opacity="0.55"/>
|
||||
</StackPanel>
|
||||
<Button Grid.Column="1" Content="Öffnen ›" Command="{Binding OpenMonthlyCalendarCommand}"
|
||||
Background="Transparent" BorderThickness="0" Padding="6,2"
|
||||
Foreground="{DynamicResource AppAccentTextBrush}"/>
|
||||
</Grid>
|
||||
<ItemsControl ItemsSource="{Binding CompactMonthDays}" HorizontalAlignment="Center">
|
||||
<ItemsControl.ItemsPanel>
|
||||
<ItemsPanelTemplate><UniformGrid Columns="7"/></ItemsPanelTemplate>
|
||||
</ItemsControl.ItemsPanel>
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:ClassTeacherCompactCalendarDay">
|
||||
<Border Classes="compactDay" Classes.today="{Binding IsToday}"
|
||||
ToolTip.Tip="{Binding Tooltip}">
|
||||
<Grid>
|
||||
<TextBlock Text="{Binding DayNumber}" FontSize="11" Opacity="0.7"
|
||||
HorizontalAlignment="Center" VerticalAlignment="Center"
|
||||
IsVisible="{Binding !HasSignal}"/>
|
||||
<Border Background="{Binding SignalColorHex}" CornerRadius="5"
|
||||
IsVisible="{Binding HasSignal}">
|
||||
<TextBlock Text="{Binding SignalCode}" Foreground="White" FontSize="11"
|
||||
FontWeight="Bold" HorizontalAlignment="Center"
|
||||
VerticalAlignment="Center"/>
|
||||
</Border>
|
||||
</Grid>
|
||||
</Border>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
<TextBlock Text="U unentsch. · ! Klassenbuch · A abwesend · V verspätet · E entsch."
|
||||
FontSize="9" Opacity="0.5" TextWrapping="Wrap"/>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<Border Classes="sidePanel" IsVisible="{Binding HasOpenExcuses}">
|
||||
<StackPanel Spacing="8">
|
||||
<TextBlock Text="Offene Entschuldigungen" FontSize="14" FontWeight="SemiBold"/>
|
||||
@@ -464,6 +531,22 @@
|
||||
<StackPanel Spacing="8">
|
||||
<TextBlock Text="Nächste Schritte" FontSize="14" FontWeight="SemiBold"/>
|
||||
<Button Content="Klassenbuch öffnen" Command="{Binding OpenClassRegisterCommand}" HorizontalAlignment="Stretch"/>
|
||||
<!-- Nutzer-Feedback: Zahlen-Badges für offene eigene Dokumentation direkt am
|
||||
Button, der zum Klassenbuch-Tab (jetzt Dokumentations-Hub) führt, statt in
|
||||
einer eigenen Kennzahlkarte in der bereits vollen 4-Spalten-Reihe oben. -->
|
||||
<StackPanel Orientation="Horizontal" Spacing="6" Margin="0,-4,0,0"
|
||||
IsVisible="{Binding HasOwnDocumentationAlerts}">
|
||||
<Border Background="#FB8C00" CornerRadius="9" Padding="7,2"
|
||||
IsVisible="{Binding !!OwnDocumentationFollowUpCount}">
|
||||
<TextBlock Text="{Binding OwnDocumentationFollowUpCount, StringFormat='Nacharbeit: {0}'}"
|
||||
Foreground="White" FontSize="10" FontWeight="SemiBold"/>
|
||||
</Border>
|
||||
<Border Background="#E53935" CornerRadius="9" Padding="7,2"
|
||||
IsVisible="{Binding !!OwnDocumentationCriticalCount}">
|
||||
<TextBlock Text="{Binding OwnDocumentationCriticalCount, StringFormat='kritisch: {0}'}"
|
||||
Foreground="White" FontSize="10" FontWeight="SemiBold"/>
|
||||
</Border>
|
||||
</StackPanel>
|
||||
<Button Content="Fehlzeiten öffnen" Command="{Binding OpenAbsencesCommand}" HorizontalAlignment="Stretch"/>
|
||||
<Button Content="Aufgaben & Wiedervorlagen" Command="{Binding GoToWorkloadCommand}"
|
||||
HorizontalAlignment="Stretch" Background="Transparent"/>
|
||||
@@ -482,6 +565,9 @@
|
||||
<ContentPage Header="Fehlzeiten">
|
||||
<views:ClassTeacherAbsencesView DataContext="{Binding DetailsTab}"/>
|
||||
</ContentPage>
|
||||
<ContentPage Header="Vorgänge">
|
||||
<views:ClassTeacherCasesView DataContext="{Binding CasesTab}"/>
|
||||
</ContentPage>
|
||||
</TabbedPage>
|
||||
</Grid>
|
||||
</UserControl>
|
||||
|
||||
@@ -1,8 +1,26 @@
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Interactivity;
|
||||
using LehrerApp.Core.Services;
|
||||
using LehrerApp.Desktop.ViewModels.ClassTeacher;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace LehrerApp.Desktop.Views.ClassTeacher;
|
||||
|
||||
public partial class ClassTeacherOverviewView : UserControl
|
||||
{
|
||||
public ClassTeacherOverviewView() => InitializeComponent();
|
||||
|
||||
private async void OnEditPatternScoreWeights(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
var owner = TopLevel.GetTopLevel(this) as Window;
|
||||
if (owner is null) return;
|
||||
|
||||
var vm = new PatternScoreWeightsDialogViewModel(App.Services.GetRequiredService<PatternScoreSettingsService>());
|
||||
var dialog = new PatternScoreWeightsDialog { DataContext = vm };
|
||||
await dialog.ShowDialog(owner);
|
||||
|
||||
// Neue Gewichtung soll sofort in der "Gesamtbild"-Sortierung wirken, nicht erst beim
|
||||
// nächsten manuellen Aktualisieren.
|
||||
if (DataContext is ClassTeacherOverviewViewModel overviewVm) overviewVm.LoadCommand.Execute(null);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
<UserControl xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:vm="clr-namespace:LehrerApp.Desktop.ViewModels.ClassTeacher"
|
||||
xmlns:svm="clr-namespace:LehrerApp.Desktop.ViewModels.Students"
|
||||
xmlns:conv="clr-namespace:LehrerApp.Desktop.Converters"
|
||||
x:Class="LehrerApp.Desktop.Views.ClassTeacher.ClassTeacherRegisterView"
|
||||
x:DataType="vm:ClassTeacherDetailsViewModel">
|
||||
|
||||
@@ -26,28 +28,86 @@
|
||||
<Style Selector="TextBlock.status.danger">
|
||||
<Setter Property="Foreground" Value="{DynamicResource AppStatusDangerBrush}"/>
|
||||
</Style>
|
||||
<Style Selector="Button.regSource">
|
||||
<Setter Property="Background" Value="Transparent"/>
|
||||
<Setter Property="BorderBrush" Value="{DynamicResource AppFilterBorderBrush}"/>
|
||||
<Setter Property="BorderThickness" Value="1"/>
|
||||
<Setter Property="CornerRadius" Value="6"/>
|
||||
<Setter Property="Padding" Value="14,6"/>
|
||||
<Setter Property="FontSize" Value="12"/>
|
||||
</Style>
|
||||
<Style Selector="Button.regSource.active">
|
||||
<Setter Property="Background" Value="{DynamicResource AppFilterActiveBackgroundBrush}"/>
|
||||
<Setter Property="BorderBrush" Value="{DynamicResource AppFilterActiveBorderBrush}"/>
|
||||
<Setter Property="Foreground" Value="{DynamicResource AppFilterActiveForegroundBrush}"/>
|
||||
</Style>
|
||||
<Style Selector="Border.countBadge">
|
||||
<Setter Property="Background" Value="{DynamicResource AppChipBackgroundBrush}"/>
|
||||
<Setter Property="CornerRadius" Value="11"/>
|
||||
<Setter Property="Padding" Value="9,3"/>
|
||||
</Style>
|
||||
<Style Selector="Border.countBadge.attention">
|
||||
<Setter Property="Background" Value="#E53935"/>
|
||||
</Style>
|
||||
<Style Selector="Border.countBadge TextBlock">
|
||||
<Setter Property="FontSize" Value="11"/>
|
||||
<Setter Property="FontWeight" Value="SemiBold"/>
|
||||
</Style>
|
||||
<Style Selector="Border.countBadge.attention TextBlock">
|
||||
<Setter Property="Foreground" Value="White"/>
|
||||
</Style>
|
||||
</UserControl.Styles>
|
||||
|
||||
<Grid RowDefinitions="Auto,Auto,Auto,*,Auto" Margin="16" RowSpacing="10">
|
||||
<Grid RowDefinitions="Auto,Auto,Auto,Auto,*,Auto" Margin="16" RowSpacing="10">
|
||||
<Grid Grid.Row="0" ColumnDefinitions="*,Auto">
|
||||
<StackPanel>
|
||||
<TextBlock Text="Klassenbucheinträge" FontSize="22" FontWeight="SemiBold"/>
|
||||
<TextBlock Text="Einträge anderer Lehrkräfte – nur zur Ansicht" FontSize="12" Opacity="0.55"/>
|
||||
<TextBlock Text="Klassenbuch" FontSize="22" FontWeight="SemiBold"/>
|
||||
<TextBlock Text="Einträge anderer Lehrkräfte – nur zur Ansicht. Rechtsklick auf einen Eintrag heftet ihn an einen Vorgang an."
|
||||
FontSize="12" Opacity="0.55" IsVisible="{Binding !ShowOwnDocumentation}"/>
|
||||
<TextBlock Text="Eigene Dokumentation zu Schüler*innen dieser Klasse" FontSize="12" Opacity="0.55"
|
||||
IsVisible="{Binding ShowOwnDocumentation}"/>
|
||||
</StackPanel>
|
||||
<Button Grid.Column="1" Content="↻ Aus WebUntis aktualisieren" Command="{Binding RefreshCommand}"
|
||||
IsEnabled="{Binding !Busy}" Background="Transparent" Foreground="{DynamicResource AppAccentTextBrush}" VerticalAlignment="Center"/>
|
||||
</Grid>
|
||||
|
||||
<Border Grid.Row="1" Classes="filterCard">
|
||||
<!-- Nutzer-Feedback: Klassenbuch-Tab als Dokumentations-Hub — Umschalter zwischen dem
|
||||
WebUntis-Bericht anderer Lehrkräfte und der eigenen Dokumentation zu Schüler*innen dieser
|
||||
Klasse, statt beide (strukturell unterschiedliche) Datensätze in eine Liste zu zwingen.
|
||||
Zahlen-Badges links/rechts zeigen die Anzahl je Quelle, in Rot hervorgehoben, wenn kritische
|
||||
bzw. nacharbeitsbedürftige Einträge dabei sind. -->
|
||||
<StackPanel Grid.Row="1" Orientation="Horizontal" Spacing="10" HorizontalAlignment="Center">
|
||||
<Border Classes="countBadge" Classes.attention="{Binding !!UntisCriticalCount}">
|
||||
<TextBlock Text="{Binding Entries.Count, StringFormat='Klassenbuch: {0}'}"/>
|
||||
</Border>
|
||||
<Border Classes="countBadge" Classes.attention="{Binding !!UntisCriticalCount}" IsVisible="{Binding !!UntisCriticalCount}">
|
||||
<TextBlock Text="{Binding UntisCriticalCount, StringFormat='davon kritisch: {0}'}"/>
|
||||
</Border>
|
||||
<Button Classes="regSource" Classes.active="{Binding !ShowOwnDocumentation}"
|
||||
Content="Klassenbuch (Untis)" Command="{Binding ShowUntisRegisterCommand}"/>
|
||||
<Button Classes="regSource" Classes.active="{Binding ShowOwnDocumentation}"
|
||||
Content="Eigene Dokumentation" Command="{Binding ShowOwnDocsCommand}"/>
|
||||
<Border Classes="countBadge">
|
||||
<TextBlock Text="{Binding OwnDocumentationCount, StringFormat='Dokumentation: {0}'}"/>
|
||||
</Border>
|
||||
<Border Classes="countBadge attention" IsVisible="{Binding !!OwnDocumentationFollowUpCount}">
|
||||
<TextBlock Text="{Binding OwnDocumentationFollowUpCount, StringFormat='Nacharbeit: {0}'}"/>
|
||||
</Border>
|
||||
<Border Classes="countBadge attention" IsVisible="{Binding !!OwnDocumentationCriticalCount}">
|
||||
<TextBlock Text="{Binding OwnDocumentationCriticalCount, StringFormat='kritisch: {0}'}"/>
|
||||
</Border>
|
||||
</StackPanel>
|
||||
|
||||
<Border Grid.Row="2" Classes="filterCard">
|
||||
<Grid RowDefinitions="Auto,Auto" RowSpacing="8">
|
||||
<Grid Grid.Row="0" ColumnDefinitions="155,Auto,*,Auto,*" ColumnSpacing="8">
|
||||
<ComboBox Grid.Column="0" SelectedIndex="{Binding QuickRangeIndex, Mode=TwoWay}">
|
||||
<ComboBoxItem Content="Heute"/><ComboBoxItem Content="Letzte 7 Tage"/><ComboBoxItem Content="Letzte 30 Tage"/>
|
||||
</ComboBox>
|
||||
<TextBlock Grid.Column="1" Text="von" VerticalAlignment="Center" Opacity="0.6"/>
|
||||
<CalendarDatePicker Grid.Column="2" SelectedDate="{Binding StartDate}" HorizontalAlignment="Stretch"/>
|
||||
<CalendarDatePicker Grid.Column="2" SelectedDate="{Binding StartDate, Converter={x:Static conv:DateTimeOffsetToDateTimeConverter.Instance}}" HorizontalAlignment="Stretch"/>
|
||||
<TextBlock Grid.Column="3" Text="bis" VerticalAlignment="Center" Opacity="0.6"/>
|
||||
<CalendarDatePicker Grid.Column="4" SelectedDate="{Binding EndDate}" HorizontalAlignment="Stretch"/>
|
||||
<CalendarDatePicker Grid.Column="4" SelectedDate="{Binding EndDate, Converter={x:Static conv:DateTimeOffsetToDateTimeConverter.Instance}}" HorizontalAlignment="Stretch"/>
|
||||
</Grid>
|
||||
<Grid Grid.Row="1" ColumnDefinitions="*,Auto,Auto" ColumnSpacing="8">
|
||||
<TextBox Grid.Column="0" Text="{Binding StudentFilter, Mode=TwoWay}" PlaceholderText="Schüler*in filtern…"/>
|
||||
@@ -62,7 +122,7 @@
|
||||
Kategorie ("Hausaufgaben fehlen: 12× — 5× Ben Schmidt, 3× Ada Müller") ist die eigentlich
|
||||
interessante Information. Als Chip-Reihe statt eigener Spalte in der Tabelle, damit die
|
||||
bestehenden Spalten unangetastet bleiben. -->
|
||||
<Border Grid.Row="2" Classes="filterCard" IsVisible="{Binding HasCategoryAggregates}">
|
||||
<Border Grid.Row="3" Classes="filterCard" IsVisible="{Binding ShowCategoryAggregates}">
|
||||
<StackPanel Spacing="6">
|
||||
<TextBlock Text="Kategorien im Zeitraum" FontSize="12" FontWeight="SemiBold" Opacity="0.7"/>
|
||||
<ItemsControl ItemsSource="{Binding CategoryAggregates}">
|
||||
@@ -86,8 +146,9 @@
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<Grid Grid.Row="3">
|
||||
<DataGrid ItemsSource="{Binding Entries}" AutoGenerateColumns="False" IsReadOnly="True"
|
||||
<Grid Grid.Row="4" IsVisible="{Binding !ShowOwnDocumentation}">
|
||||
<DataGrid ItemsSource="{Binding Entries}" SelectedItem="{Binding SelectedEntry, Mode=TwoWay}"
|
||||
AutoGenerateColumns="False" IsReadOnly="True"
|
||||
GridLinesVisibility="Horizontal" BorderBrush="{DynamicResource AppCardBorderBrush}" BorderThickness="1"
|
||||
CanUserResizeColumns="True" CanUserSortColumns="True" RowHeight="46">
|
||||
<DataGrid.Columns>
|
||||
@@ -99,12 +160,90 @@
|
||||
<DataGridTextColumn Header="Gruppe" Binding="{Binding CategoryGroup}" Width="0.8*"/>
|
||||
<DataGridTextColumn Header="Eintrag" Binding="{Binding Text}" Width="2*"/>
|
||||
</DataGrid.Columns>
|
||||
<DataGrid.ContextMenu>
|
||||
<ContextMenu>
|
||||
<MenuItem Header="→ An Vorgang anheften" Command="{Binding PinToVorgangCommand}"
|
||||
CommandParameter="{Binding SelectedEntry}"/>
|
||||
</ContextMenu>
|
||||
</DataGrid.ContextMenu>
|
||||
</DataGrid>
|
||||
<StackPanel IsVisible="{Binding !HasEntries}" HorizontalAlignment="Center" VerticalAlignment="Center" Spacing="5">
|
||||
<TextBlock Text="Keine Klassenbucheinträge im gewählten Zeitraum" FontWeight="SemiBold"/>
|
||||
<TextBlock Text="Passe Zeitraum oder Schülerfilter an." FontSize="12" Opacity="0.55"/>
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
<TextBlock Grid.Row="4" Text="{Binding Status}" FontSize="11" Opacity="0.6"/>
|
||||
|
||||
<Grid Grid.Row="4" IsVisible="{Binding ShowOwnDocumentation}" RowDefinitions="Auto,*">
|
||||
<Button Grid.Row="0" Content="+ Eintrag" Command="{Binding AddOwnDocumentationCommand}"
|
||||
IsEnabled="{Binding CanAddOwnDocumentation}"
|
||||
HorizontalAlignment="Right" Margin="0,0,0,8"/>
|
||||
<ScrollViewer Grid.Row="1">
|
||||
<StackPanel>
|
||||
<ItemsControl ItemsSource="{Binding OwnDocumentationEntries}">
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate DataType="svm:DocumentationItem">
|
||||
<Border Background="{DynamicResource SystemControlBackgroundAltHighBrush}"
|
||||
CornerRadius="6" Padding="12,10" Margin="0,0,0,8">
|
||||
<StackPanel Spacing="4">
|
||||
<Grid ColumnDefinitions="80,*,Auto,Auto,Auto,Auto">
|
||||
<TextBlock Grid.Column="0" Text="{Binding DateDisplay}" Opacity="0.5" FontSize="12"/>
|
||||
<StackPanel Grid.Column="1" Margin="8,0">
|
||||
<StackPanel Orientation="Horizontal" Spacing="6">
|
||||
<Border Background="#FB8C00" CornerRadius="8" Padding="6,1"
|
||||
IsVisible="{Binding IsDraft}">
|
||||
<TextBlock Text="ENTWURF" Foreground="White" FontSize="9" FontWeight="Bold"/>
|
||||
</Border>
|
||||
<TextBlock Text="{Binding StudentName}" FontWeight="SemiBold" FontSize="13"/>
|
||||
<TextBlock Text="{Binding Model.Title}" FontSize="13" Opacity="0.8"
|
||||
IsVisible="{Binding IsRevealed}"/>
|
||||
<TextBlock Text="Vertraulich" FontSize="13" Opacity="0.6"
|
||||
IsVisible="{Binding !IsRevealed}"/>
|
||||
</StackPanel>
|
||||
<TextBlock Text="{Binding TypeLabel}" FontSize="11" Opacity="0.5"/>
|
||||
</StackPanel>
|
||||
<TextBlock Grid.Column="2" Text="🔒" FontSize="14" VerticalAlignment="Center"
|
||||
IsVisible="{Binding IsConfidential}" ToolTip.Tip="Vertraulich"/>
|
||||
<Button Grid.Column="3" Content="Anzeigen" FontSize="11" Padding="8,3" Margin="6,0,0,0"
|
||||
Command="{Binding RevealCommand}" IsVisible="{Binding !IsRevealed}"/>
|
||||
<StackPanel Grid.Column="4" Orientation="Horizontal" Spacing="4" Margin="6,0,0,0"
|
||||
IsVisible="{Binding IsRevealed}">
|
||||
<Button Content="Bearbeiten" FontSize="11" Padding="8,3"
|
||||
Command="{Binding $parent[ItemsControl].((vm:ClassTeacherDetailsViewModel)DataContext).EditOwnDocumentationCommand}"
|
||||
CommandParameter="{Binding}"/>
|
||||
<Button Content="Löschen" FontSize="11" Padding="8,3"
|
||||
Command="{Binding $parent[ItemsControl].((vm:ClassTeacherDetailsViewModel)DataContext).DeleteOwnDocumentationCommand}"
|
||||
CommandParameter="{Binding}"/>
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
<TextBlock Text="{Binding Model.Content}" FontSize="12" TextWrapping="Wrap" Opacity="0.8"
|
||||
IsVisible="{Binding IsRevealed}"/>
|
||||
<StackPanel Orientation="Horizontal" Spacing="8" IsVisible="{Binding IsRevealed}">
|
||||
<TextBlock Text="{Binding StatusLabel}" FontSize="11" Opacity="0.55"
|
||||
IsVisible="{Binding StatusLabel, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
|
||||
<TextBlock Text="📎 Anhang" FontSize="11" Opacity="0.55" IsVisible="{Binding HasAttachments}"/>
|
||||
</StackPanel>
|
||||
<ItemsControl ItemsSource="{Binding TagChips}" IsVisible="{Binding IsRevealed}">
|
||||
<ItemsControl.ItemsPanel>
|
||||
<ItemsPanelTemplate><WrapPanel ItemSpacing="6" LineSpacing="4"/></ItemsPanelTemplate>
|
||||
</ItemsControl.ItemsPanel>
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate DataType="svm:TagChip">
|
||||
<Border Background="{Binding ColorHex}" CornerRadius="10" Padding="8,2">
|
||||
<TextBlock Text="{Binding Text}" FontSize="10" Foreground="White"/>
|
||||
</Border>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
<TextBlock Text="Keine eigene Dokumentation im gewählten Zeitraum." Classes="emptyhint"
|
||||
IsVisible="{Binding !HasOwnDocumentationEntries}"/>
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
</Grid>
|
||||
<TextBlock Grid.Row="5" Text="{Binding Status}" FontSize="11" Opacity="0.6"/>
|
||||
</Grid>
|
||||
</UserControl>
|
||||
|
||||
@@ -1,8 +1,65 @@
|
||||
using Avalonia.Controls;
|
||||
using LehrerApp.Core.Interfaces;
|
||||
using LehrerApp.Core.Models;
|
||||
using LehrerApp.Desktop.ViewModels.ClassTeacher;
|
||||
using LehrerApp.Desktop.ViewModels.Students;
|
||||
using LehrerApp.Desktop.Views.Shared;
|
||||
using LehrerApp.Desktop.Views.Students;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace LehrerApp.Desktop.Views.ClassTeacher;
|
||||
|
||||
public partial class ClassTeacherRegisterView : UserControl
|
||||
{
|
||||
public ClassTeacherRegisterView() => InitializeComponent();
|
||||
|
||||
protected override void OnDataContextChanged(EventArgs e)
|
||||
{
|
||||
base.OnDataContextChanged(e);
|
||||
if (DataContext is not ClassTeacherDetailsViewModel vm) return;
|
||||
vm.OnEditOwnDocumentation = ShowDocumentationDialog;
|
||||
vm.OnConfirmDeleteOwnDocumentation = ShowDeleteDocumentationDialog;
|
||||
vm.OnPickVorgangForPin = ShowPinToVorgangDialog;
|
||||
}
|
||||
|
||||
private async Task<PinToVorgangChoice?> ShowPinToVorgangDialog(ClassTeacherClassRegisterRow row)
|
||||
{
|
||||
if (DataContext is not ClassTeacherDetailsViewModel vm) return null;
|
||||
var owner = TopLevel.GetTopLevel(this) as Window;
|
||||
if (owner is null) return null;
|
||||
|
||||
var summary = $"{row.DateLabel} · {row.StudentDisplayName} · {row.CategoryName}";
|
||||
var matchingCases = vm.OpenCasesFor(row.StudentName);
|
||||
var dialogVm = new PinToVorgangDialogViewModel(summary, matchingCases);
|
||||
var dialog = new PinToVorgangDialog { DataContext = dialogVm };
|
||||
var confirmed = await dialog.ShowDialog<bool>(owner);
|
||||
return confirmed ? dialogVm.Result : null;
|
||||
}
|
||||
|
||||
private async Task<Documentation?> ShowDocumentationDialog(
|
||||
List<StudentOption> studentOptions, Documentation? editing)
|
||||
{
|
||||
var owner = TopLevel.GetTopLevel(this) as Window;
|
||||
if (owner is null) return null;
|
||||
|
||||
var vm = new DocumentationDialogViewModel(editing?.StudentId ?? Guid.Empty, editing,
|
||||
App.Services.GetRequiredService<IAttachmentStorage>(), studentOptions);
|
||||
var dialog = new DocumentationDialog { DataContext = vm };
|
||||
var saved = await dialog.ShowDialog<bool>(owner);
|
||||
if (!saved) vm.DiscardUnsavedAttachments();
|
||||
return saved ? vm.Result : null;
|
||||
}
|
||||
|
||||
private async Task<bool> ShowDeleteDocumentationDialog(DocumentationItem item)
|
||||
{
|
||||
var info = new ConfirmDialogInfo
|
||||
{
|
||||
Title = "Eintrag löschen?",
|
||||
Message = $"\"{item.Model.Title}\" ({item.StudentName}) wird als gelöscht markiert und nicht mehr angezeigt.",
|
||||
ConfirmText = "Löschen",
|
||||
};
|
||||
var dialog = new ConfirmDialog { DataContext = info };
|
||||
var owner = TopLevel.GetTopLevel(this) as Window;
|
||||
return owner is not null && await dialog.ShowDialog<bool>(owner);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
<Window xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:vm="clr-namespace:LehrerApp.Desktop.ViewModels.ClassTeacher"
|
||||
x:Class="LehrerApp.Desktop.Views.ClassTeacher.PatternScoreWeightsDialog"
|
||||
x:DataType="vm:PatternScoreWeightsDialogViewModel"
|
||||
Title="Gewichtung „Gesamtbild“"
|
||||
Width="440" SizeToContent="Height" MinHeight="300"
|
||||
CanResize="True" WindowStartupLocation="CenterOwner">
|
||||
|
||||
<Grid RowDefinitions="*,Auto" Margin="24">
|
||||
<StackPanel Grid.Row="0" Spacing="12">
|
||||
<TextBlock Text="Gewichtung „Gesamtbild“" Classes="dialogtitle"/>
|
||||
<TextBlock TextWrapping="Wrap" FontSize="12" Opacity="0.6"
|
||||
Text="Bestimmt, wie stark einzelne Ereignisse in den Auffälligkeits-Score einfließen, nach dem der „Gesamtbild“-Reiter sortiert. Wirkt erst nach erneutem Laden der Übersicht."/>
|
||||
|
||||
<Grid ColumnDefinitions="*,110" RowDefinitions="Auto,Auto,Auto,Auto,Auto,Auto" RowSpacing="10">
|
||||
<TextBlock Grid.Row="0" Grid.Column="0" Text="Unentschuldigt (pro Tag)" VerticalAlignment="Center"/>
|
||||
<NumericUpDown Grid.Row="0" Grid.Column="1" Value="{Binding UnexcusedDayWeight}"
|
||||
Minimum="0" Maximum="50" FormatString="0.0" Increment="0.1" ShowButtonSpinner="False"/>
|
||||
|
||||
<TextBlock Grid.Row="1" Grid.Column="0" Text="Entschuldigt abwesend (pro Tag)" VerticalAlignment="Center"/>
|
||||
<NumericUpDown Grid.Row="1" Grid.Column="1" Value="{Binding ExcusedDayWeight}"
|
||||
Minimum="0" Maximum="50" FormatString="0.0" Increment="0.1" ShowButtonSpinner="False"/>
|
||||
|
||||
<TextBlock Grid.Row="2" Grid.Column="0" Text="Nur verspätet (pro Tag)" VerticalAlignment="Center"/>
|
||||
<NumericUpDown Grid.Row="2" Grid.Column="1" Value="{Binding LateDayWeight}"
|
||||
Minimum="0" Maximum="50" FormatString="0.0" Increment="0.1" ShowButtonSpinner="False"/>
|
||||
|
||||
<TextBlock Grid.Row="3" Grid.Column="0" Text="Negativer Klassenbucheintrag" VerticalAlignment="Center"
|
||||
TextWrapping="Wrap"/>
|
||||
<NumericUpDown Grid.Row="3" Grid.Column="1" Value="{Binding NegativeClassRegisterEntryWeight}"
|
||||
Minimum="0" Maximum="50" FormatString="0.0" Increment="0.1" ShowButtonSpinner="False"/>
|
||||
|
||||
<TextBlock Grid.Row="4" Grid.Column="0" Text="+ Hausaufgaben-/Mitarbeit-Schlüsselwort" VerticalAlignment="Center"
|
||||
TextWrapping="Wrap"/>
|
||||
<NumericUpDown Grid.Row="4" Grid.Column="1" Value="{Binding ConcerningKeywordBonus}"
|
||||
Minimum="0" Maximum="50" FormatString="0.0" Increment="0.1" ShowButtonSpinner="False"/>
|
||||
|
||||
<TextBlock Grid.Row="5" Grid.Column="0" Text="+ „Suspendiert“-Schlüsselwort" VerticalAlignment="Center"
|
||||
TextWrapping="Wrap"/>
|
||||
<NumericUpDown Grid.Row="5" Grid.Column="1" Value="{Binding SuspensionKeywordWeight}"
|
||||
Minimum="0" Maximum="100" FormatString="0.0" Increment="0.5" ShowButtonSpinner="False"/>
|
||||
</Grid>
|
||||
</StackPanel>
|
||||
|
||||
<Button Grid.Row="1" Content="Fertig" HorizontalAlignment="Right" Margin="0,20,0,0" Click="OnClose"/>
|
||||
</Grid>
|
||||
</Window>
|
||||
@@ -0,0 +1,16 @@
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Interactivity;
|
||||
using LehrerApp.Desktop.ViewModels.ClassTeacher;
|
||||
|
||||
namespace LehrerApp.Desktop.Views.ClassTeacher;
|
||||
|
||||
public partial class PatternScoreWeightsDialog : Window
|
||||
{
|
||||
public PatternScoreWeightsDialog() => InitializeComponent();
|
||||
|
||||
private void OnClose(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
if (DataContext is PatternScoreWeightsDialogViewModel vm) vm.SaveCommand.Execute(null);
|
||||
Close();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
<Window xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:vm="clr-namespace:LehrerApp.Desktop.ViewModels.ClassTeacher"
|
||||
x:Class="LehrerApp.Desktop.Views.ClassTeacher.PinToVorgangDialog"
|
||||
x:DataType="vm:PinToVorgangDialogViewModel"
|
||||
Title="An Vorgang anheften"
|
||||
Width="420" SizeToContent="Height" MinHeight="260"
|
||||
CanResize="True" WindowStartupLocation="CenterOwner">
|
||||
|
||||
<Grid RowDefinitions="*,Auto" Margin="24">
|
||||
<StackPanel Grid.Row="0" Spacing="12">
|
||||
<TextBlock Text="An Vorgang anheften" Classes="dialogtitle"/>
|
||||
<TextBlock Text="{Binding RowSummary}" FontSize="12" Opacity="0.6" TextWrapping="Wrap"/>
|
||||
|
||||
<StackPanel Spacing="4" IsVisible="{Binding HasMatchingOpenCases}">
|
||||
<TextBlock Text="Bestehenden offenen Vorgang wählen" FontSize="12" Opacity="0.7"/>
|
||||
<ComboBox ItemsSource="{Binding MatchingOpenCases}" SelectedItem="{Binding SelectedCase}"
|
||||
HorizontalAlignment="Stretch">
|
||||
<ComboBox.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:VorgangItem">
|
||||
<TextBlock Text="{Binding Model.Title}"/>
|
||||
</DataTemplate>
|
||||
</ComboBox.ItemTemplate>
|
||||
</ComboBox>
|
||||
</StackPanel>
|
||||
|
||||
<TextBlock Text="— oder —" FontSize="11" Opacity="0.5" HorizontalAlignment="Center"
|
||||
IsVisible="{Binding HasMatchingOpenCases}"/>
|
||||
|
||||
<StackPanel Spacing="4">
|
||||
<TextBlock Text="Neuen Vorgang anlegen mit Titel" FontSize="12" Opacity="0.7"/>
|
||||
<TextBox Text="{Binding NewTitle}" PlaceholderText="z.B. Schuleschwänzen"/>
|
||||
</StackPanel>
|
||||
|
||||
<TextBlock Text="{Binding Error}" Foreground="Red" FontSize="11"
|
||||
IsVisible="{Binding Error, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
|
||||
</StackPanel>
|
||||
|
||||
<Grid Grid.Row="1" ColumnDefinitions="*,8,*" Margin="0,20,0,0">
|
||||
<Button Grid.Column="0" Content="Abbrechen" HorizontalAlignment="Stretch" Click="OnCancel"/>
|
||||
<Button Grid.Column="2" Content="Anheften" HorizontalAlignment="Stretch" Click="OnConfirm"/>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Window>
|
||||
@@ -0,0 +1,21 @@
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Interactivity;
|
||||
using LehrerApp.Desktop.ViewModels.ClassTeacher;
|
||||
|
||||
namespace LehrerApp.Desktop.Views.ClassTeacher;
|
||||
|
||||
public partial class PinToVorgangDialog : Window
|
||||
{
|
||||
public PinToVorgangDialog() => InitializeComponent();
|
||||
|
||||
private void OnConfirm(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
if (DataContext is PinToVorgangDialogViewModel vm)
|
||||
{
|
||||
vm.ConfirmCommand.Execute(null);
|
||||
if (vm.Result is not null) Close(true);
|
||||
}
|
||||
}
|
||||
|
||||
private void OnCancel(object? sender, RoutedEventArgs e) => Close(false);
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
<Window xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:vm="clr-namespace:LehrerApp.Desktop.ViewModels.ClassTeacher"
|
||||
x:Class="LehrerApp.Desktop.Views.ClassTeacher.VorgangDialog"
|
||||
x:DataType="vm:VorgangDialogViewModel"
|
||||
Title="{Binding DialogTitle}"
|
||||
Width="480" SizeToContent="Height" MinHeight="360"
|
||||
CanResize="True" WindowStartupLocation="CenterOwner">
|
||||
|
||||
<Grid RowDefinitions="*,Auto" Margin="24">
|
||||
<ScrollViewer Grid.Row="0">
|
||||
<StackPanel Spacing="12">
|
||||
<TextBlock Text="{Binding DialogTitle}" Classes="dialogtitle"/>
|
||||
|
||||
<StackPanel Spacing="4">
|
||||
<TextBlock Text="Titel *" FontSize="12" Opacity="0.7"/>
|
||||
<TextBox Text="{Binding Title}"/>
|
||||
<TextBlock Text="{Binding TitleError}" Foreground="Red" FontSize="11"
|
||||
IsVisible="{Binding TitleError, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
|
||||
</StackPanel>
|
||||
|
||||
<StackPanel Spacing="4">
|
||||
<TextBlock Text="Beschreibung" FontSize="12" Opacity="0.7"/>
|
||||
<TextBox Text="{Binding Description}" AcceptsReturn="True" Height="90" TextWrapping="Wrap"
|
||||
PlaceholderText="Worum geht es? Was ist bisher passiert?"/>
|
||||
</StackPanel>
|
||||
|
||||
<StackPanel Spacing="6">
|
||||
<TextBlock Text="Schlagwörter" FontSize="12" Opacity="0.7"/>
|
||||
<ItemsControl ItemsSource="{Binding Tags}">
|
||||
<ItemsControl.ItemsPanel>
|
||||
<ItemsPanelTemplate><WrapPanel ItemSpacing="6" LineSpacing="6"/></ItemsPanelTemplate>
|
||||
</ItemsControl.ItemsPanel>
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate>
|
||||
<Border Background="{DynamicResource SystemControlBackgroundBaseMediumBrush}"
|
||||
CornerRadius="10" Padding="8,3">
|
||||
<StackPanel Orientation="Horizontal" Spacing="5">
|
||||
<TextBlock Text="{Binding}" FontSize="11"/>
|
||||
<Button Content="×" FontSize="11" Padding="0" Background="Transparent"
|
||||
Command="{Binding $parent[ItemsControl].((vm:VorgangDialogViewModel)DataContext).RemoveTagCommand}"
|
||||
CommandParameter="{Binding}"/>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
<Grid ColumnDefinitions="*,8,Auto">
|
||||
<AutoCompleteBox Grid.Column="0" Text="{Binding NewTag}"
|
||||
ItemsSource="{Binding TagSuggestions}"
|
||||
FilterMode="Contains" MinimumPrefixLength="0"
|
||||
PlaceholderText="Schlagwort (z.B. Absentismus)"/>
|
||||
<Button Grid.Column="2" Content="+" Command="{Binding AddTagCommand}"/>
|
||||
</Grid>
|
||||
</StackPanel>
|
||||
|
||||
<StackPanel Spacing="4">
|
||||
<TextBlock Text="Betroffene Schüler*innen *" FontSize="12" Opacity="0.7"/>
|
||||
<ItemsControl ItemsSource="{Binding StudentOptions}">
|
||||
<ItemsControl.ItemsPanel>
|
||||
<ItemsPanelTemplate><WrapPanel/></ItemsPanelTemplate>
|
||||
</ItemsControl.ItemsPanel>
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:VorgangStudentOption">
|
||||
<CheckBox Content="{Binding Name}" IsChecked="{Binding IsSelected}" Margin="0,2,14,2"/>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
<TextBlock Text="{Binding StudentsError}" Foreground="Red" FontSize="11"
|
||||
IsVisible="{Binding StudentsError, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
|
||||
<Grid Grid.Row="1" ColumnDefinitions="*,8,*" Margin="0,20,0,0">
|
||||
<Button Grid.Column="0" Content="Abbrechen" HorizontalAlignment="Stretch" Click="OnCancel"/>
|
||||
<Button Grid.Column="2" Content="Speichern" HorizontalAlignment="Stretch" Click="OnSave"/>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Window>
|
||||
@@ -0,0 +1,21 @@
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Interactivity;
|
||||
using LehrerApp.Desktop.ViewModels.ClassTeacher;
|
||||
|
||||
namespace LehrerApp.Desktop.Views.ClassTeacher;
|
||||
|
||||
public partial class VorgangDialog : Window
|
||||
{
|
||||
public VorgangDialog() => InitializeComponent();
|
||||
|
||||
private void OnSave(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
if (DataContext is VorgangDialogViewModel vm)
|
||||
{
|
||||
vm.SaveCommand.Execute(null);
|
||||
if (vm.Result is not null) Close(true);
|
||||
}
|
||||
}
|
||||
|
||||
private void OnCancel(object? sender, RoutedEventArgs e) => Close(false);
|
||||
}
|
||||
@@ -9,6 +9,12 @@
|
||||
<Setter Property="Foreground" Value="Red"/>
|
||||
<Setter Property="FontWeight" Value="SemiBold"/>
|
||||
</Style>
|
||||
<Style Selector="Button.webuntishealth">
|
||||
<Setter Property="Foreground" Value="{DynamicResource AppStatusOkBrush}"/>
|
||||
</Style>
|
||||
<Style Selector="Button.webuntishealth.warning">
|
||||
<Setter Property="Foreground" Value="{DynamicResource AppStatusWarningBrush}"/>
|
||||
</Style>
|
||||
<Style Selector="Border.daycell.selected">
|
||||
<Setter Property="Background" Value="{DynamicResource SystemControlBackgroundAccentBrush}"/>
|
||||
<Setter Property="BorderThickness" Value="2"/>
|
||||
@@ -26,7 +32,11 @@
|
||||
<TextBlock Text="{Binding CurrentDate}" FontSize="24" FontWeight="SemiBold"/>
|
||||
</StackPanel>
|
||||
<WrapPanel Grid.Row="1" Orientation="Horizontal" ItemSpacing="8" LineSpacing="8" Margin="0,10,0,0">
|
||||
<Button Content="Klassenbuch abgleichen…" Click="OnCompareWebUntisDocumentationClick"
|
||||
<!-- Fasst die vier WebUntis-Abgleiche zusammen, die jetzt nur noch im Menü "WebUntis"
|
||||
erreichbar sind (siehe TODO.md, Untis-Hub) - Klick öffnet den Hub. -->
|
||||
<Button Content="{Binding WebUntisHealthLabel}" Click="OnOpenUntisHubClick"
|
||||
IsVisible="{Binding IsWebUntisHealthVisible}"
|
||||
Classes="webuntishealth" Classes.warning="{Binding IsWebUntisHealthWarning}"
|
||||
VerticalAlignment="Center"/>
|
||||
<Button Content="Bereiche anpassen" Command="{Binding ToggleDashboardSettingsCommand}"
|
||||
VerticalAlignment="Center"/>
|
||||
@@ -132,7 +142,7 @@
|
||||
<TextBlock Text="HEUTE UND HANDLUNGSBEDARF" FontSize="11" FontWeight="Bold" Opacity="0.5"
|
||||
Margin="2,2,0,-8"/>
|
||||
|
||||
<Grid ColumnDefinitions="3*,2*" RowDefinitions="Auto,Auto,Auto,Auto,Auto,Auto">
|
||||
<Grid ColumnDefinitions="3*,2*" RowDefinitions="Auto,Auto,Auto,Auto,Auto,Auto,Auto">
|
||||
|
||||
<!-- Heutige Stunden -->
|
||||
<Border Grid.Column="{Binding TodayCard.Column}" Grid.Row="{Binding TodayCard.Row}"
|
||||
|
||||
@@ -1,12 +1,9 @@
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Interactivity;
|
||||
using LehrerApp.Core.Interfaces;
|
||||
using LehrerApp.Core.Models;
|
||||
using LehrerApp.Core.Services;
|
||||
using LehrerApp.Desktop.Services;
|
||||
using LehrerApp.Desktop.ViewModels;
|
||||
using LehrerApp.Desktop.ViewModels.Students;
|
||||
using LehrerApp.Desktop.Views.Students;
|
||||
using LehrerApp.Desktop.Views.UntisHub;
|
||||
using LehrerApp.Desktop.Views.Workload;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
@@ -30,16 +27,11 @@ public partial class DashboardView : UserControl
|
||||
return await WorkTaskDialogHelper.ShowDialog(owner, startAsReminder: startAsReminder);
|
||||
}
|
||||
|
||||
private async void OnCompareWebUntisDocumentationClick(object? sender, RoutedEventArgs e)
|
||||
private async void OnOpenUntisHubClick(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
var owner = TopLevel.GetTopLevel(this) as Window;
|
||||
if (owner is null) return;
|
||||
var dialogVm = new WebUntisDocumentationComparisonViewModel(
|
||||
App.Services.GetRequiredService<WebUntisIntegrationService>(),
|
||||
App.Services.GetRequiredService<IStudentRepository>(),
|
||||
App.Services.GetRequiredService<IGroupRepository>(),
|
||||
App.Services.GetRequiredService<IDocumentationRepository>(),
|
||||
App.Services.GetRequiredService<SchoolYearService>());
|
||||
await new WebUntisDocumentationComparisonDialog { DataContext = dialogVm }.ShowDialog(owner);
|
||||
await new UntisHubDialog().ShowDialog(owner);
|
||||
if (DataContext is DashboardViewModel vm) vm.RefreshWebUntisHealth();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -78,8 +78,12 @@
|
||||
<TextBlock Text="Anfrage läuft…" FontSize="12" Opacity="0.6"/>
|
||||
<ProgressBar IsIndeterminate="True" Height="4"/>
|
||||
</StackPanel>
|
||||
<TextBlock Text="{Binding ErrorMessage}" Foreground="Red" FontSize="12" TextWrapping="Wrap"
|
||||
IsVisible="{Binding ErrorMessage, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
|
||||
<StackPanel Spacing="8" IsVisible="{Binding ErrorMessage, Converter={x:Static StringConverters.IsNotNullOrEmpty}}">
|
||||
<TextBlock Text="{Binding ErrorMessage}" Foreground="Red" FontSize="12" TextWrapping="Wrap"/>
|
||||
<Button Content="🛟 Anfrage retten…" HorizontalAlignment="Left" Click="OnRescue"
|
||||
IsVisible="{Binding CanRescueResponse}"
|
||||
ToolTip.Tip="Vollständige KI-Antwort öffnen, JSON markieren oder korrigieren und manuell importieren."/>
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
|
||||
|
||||
@@ -27,6 +27,22 @@ public partial class AiAssistDialog : Window
|
||||
await vm.SendCommand.ExecuteAsync(null);
|
||||
}
|
||||
|
||||
private async void OnRescue(object? s, RoutedEventArgs e)
|
||||
{
|
||||
if (DataContext is not AiAssistDialogViewModel { RawResponse: { } raw } vm) return;
|
||||
|
||||
var rescueVm = new AiResponseRescueDialogViewModel(
|
||||
App.Services.GetRequiredService<AiPlanningService>(),
|
||||
App.Services.GetRequiredService<LehrerApp.Core.Interfaces.ILessonRepository>(),
|
||||
vm.Unit, raw, vm.FocusLessonId);
|
||||
var rescue = new AiResponseRescueDialog { DataContext = rescueVm };
|
||||
if (await rescue.ShowDialog<bool>(this))
|
||||
{
|
||||
vm.MarkRescueImported();
|
||||
Close(true);
|
||||
}
|
||||
}
|
||||
|
||||
private void OnApply(object? s, RoutedEventArgs e)
|
||||
{
|
||||
if (DataContext is AiAssistDialogViewModel vm && vm.ApplyCommand.CanExecute(null))
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
<Window xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:vm="clr-namespace:LehrerApp.Desktop.ViewModels.Groups"
|
||||
x:Class="LehrerApp.Desktop.Views.Groups.AiResponseRescueDialog"
|
||||
x:DataType="vm:AiResponseRescueDialogViewModel"
|
||||
Title="KI-Antwort retten"
|
||||
Width="820" Height="720" MinWidth="620" MinHeight="520"
|
||||
CanResize="True" WindowStartupLocation="CenterOwner">
|
||||
|
||||
<Grid RowDefinitions="Auto,*,Auto,Auto,Auto" Margin="24">
|
||||
<StackPanel Grid.Row="0" Spacing="6" Margin="0,0,0,12">
|
||||
<TextBlock Text="KI-Antwort retten" Classes="dialogtitle"/>
|
||||
<TextBlock Text="Die vollständige Antwort steht unten. Markiere ein vollständiges JSON-Objekt, ein Array von Stunden oder die normale Antwort mit dem Feld „lessons“. Du kannst den Text vorher auch korrigieren. Ohne Markierung wird der gesamte Text geprüft."
|
||||
TextWrapping="Wrap" FontSize="12" Opacity="0.75"/>
|
||||
</StackPanel>
|
||||
|
||||
<TextBox x:Name="ResponseTextBox" Grid.Row="1" Text="{Binding ResponseText}"
|
||||
AcceptsReturn="True" AcceptsTab="True" TextWrapping="NoWrap"
|
||||
FontFamily="Monospace" FontSize="12"
|
||||
ScrollViewer.HorizontalScrollBarVisibility="Auto"
|
||||
ScrollViewer.VerticalScrollBarVisibility="Auto"/>
|
||||
|
||||
<Grid Grid.Row="2" ColumnDefinitions="Auto,*" Margin="0,12,0,0">
|
||||
<Button Grid.Column="0" Content="Markierung prüfen" Click="OnValidateSelection"/>
|
||||
<TextBlock Grid.Column="1" Text="{Binding ParseMessage}" Margin="12,0,0,0"
|
||||
VerticalAlignment="Center" TextWrapping="Wrap" FontSize="12"/>
|
||||
</Grid>
|
||||
|
||||
<Grid Grid.Row="3" ColumnDefinitions="*,12,*" Margin="0,14,0,0">
|
||||
<StackPanel Grid.Column="0" Spacing="5">
|
||||
<TextBlock Text="Erkannte Stunde" FontSize="12" Opacity="0.7"/>
|
||||
<ComboBox ItemsSource="{Binding ParsedLessons}" SelectedItem="{Binding SelectedParsedLesson}"
|
||||
DisplayMemberBinding="{Binding Label}" IsEnabled="{Binding HasParsedLesson}"/>
|
||||
</StackPanel>
|
||||
<StackPanel Grid.Column="2" Spacing="5">
|
||||
<TextBlock Text="Vorhandene Zielstunde" FontSize="12" Opacity="0.7"/>
|
||||
<ComboBox ItemsSource="{Binding ExistingLessons}" SelectedItem="{Binding SelectedTarget}"
|
||||
DisplayMemberBinding="{Binding Label}"/>
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
|
||||
<Grid Grid.Row="4" ColumnDefinitions="Auto,*,Auto,10,Auto" Margin="0,20,0,0">
|
||||
<Button Grid.Column="0" Content="Abbrechen" Click="OnCancel"/>
|
||||
<Button Grid.Column="2" Content="In Zielstunde importieren" Click="OnImportIntoExisting"
|
||||
IsEnabled="{Binding CanImportIntoExisting}"/>
|
||||
<Button Grid.Column="4" Content="Als neue Stunde importieren" Click="OnImportAsNew"
|
||||
IsEnabled="{Binding HasParsedLesson}"/>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Window>
|
||||
@@ -0,0 +1,35 @@
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Interactivity;
|
||||
using LehrerApp.Desktop.ViewModels.Groups;
|
||||
|
||||
namespace LehrerApp.Desktop.Views.Groups;
|
||||
|
||||
public partial class AiResponseRescueDialog : Window
|
||||
{
|
||||
public AiResponseRescueDialog() => InitializeComponent();
|
||||
|
||||
private void OnValidateSelection(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
if (DataContext is not AiResponseRescueDialogViewModel vm) return;
|
||||
var candidate = string.IsNullOrWhiteSpace(ResponseTextBox.SelectedText)
|
||||
? ResponseTextBox.Text ?? ""
|
||||
: ResponseTextBox.SelectedText;
|
||||
vm.ParseSelection(candidate);
|
||||
}
|
||||
|
||||
private void OnImportIntoExisting(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
if (DataContext is not AiResponseRescueDialogViewModel vm || !vm.CanImportIntoExisting) return;
|
||||
vm.ImportIntoExistingCommand.Execute(null);
|
||||
if (vm.Result) Close(true);
|
||||
}
|
||||
|
||||
private void OnImportAsNew(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
if (DataContext is not AiResponseRescueDialogViewModel vm || !vm.HasParsedLesson) return;
|
||||
vm.ImportAsNewCommand.Execute(null);
|
||||
if (vm.Result) Close(true);
|
||||
}
|
||||
|
||||
private void OnCancel(object? sender, RoutedEventArgs e) => Close(false);
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
<Window xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:vm="clr-namespace:LehrerApp.Desktop.ViewModels.Groups"
|
||||
xmlns:vmp="clr-namespace:LehrerApp.Desktop.ViewModels.Planning"
|
||||
x:Class="LehrerApp.Desktop.Views.Groups.ChangeLessonUnitDialog"
|
||||
x:DataType="vm:ChangeLessonUnitDialogViewModel"
|
||||
Title="Einheit wechseln"
|
||||
Width="440" Height="360" MinWidth="400" MinHeight="320"
|
||||
CanResize="False" WindowStartupLocation="CenterOwner">
|
||||
|
||||
<Grid RowDefinitions="*,Auto" Margin="24">
|
||||
<StackPanel Grid.Row="0" Spacing="14">
|
||||
<TextBlock Text="Einheit wechseln" Classes="dialogtitle"/>
|
||||
<TextBlock FontSize="12" Opacity="0.6">
|
||||
<Run Text="Bisherige Einheit: "/>
|
||||
<Run Text="{Binding CurrentUnitLabel}"/>
|
||||
</TextBlock>
|
||||
|
||||
<StackPanel Spacing="5" IsVisible="{Binding HasUnits}">
|
||||
<TextBlock Text="Neue Einheit" FontSize="12" Opacity="0.7"/>
|
||||
<ComboBox ItemsSource="{Binding Units}" SelectedItem="{Binding SelectedUnit}"
|
||||
HorizontalAlignment="Stretch">
|
||||
<ComboBox.ItemTemplate>
|
||||
<DataTemplate x:DataType="vmp:TimetableUnitOption">
|
||||
<StackPanel>
|
||||
<TextBlock Text="{Binding Label}" FontWeight="SemiBold"/>
|
||||
<TextBlock Text="{Binding Detail}" FontSize="11" Opacity="0.6"/>
|
||||
</StackPanel>
|
||||
</DataTemplate>
|
||||
</ComboBox.ItemTemplate>
|
||||
</ComboBox>
|
||||
</StackPanel>
|
||||
|
||||
<TextBlock Text="Keine andere Einheit in dieser Gruppe vorhanden."
|
||||
FontSize="12" Opacity="0.7" TextWrapping="Wrap"
|
||||
IsVisible="{Binding !HasUnits}"/>
|
||||
|
||||
<TextBlock Text="{Binding Error}" Foreground="Red" FontSize="11"
|
||||
IsVisible="{Binding Error, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
|
||||
</StackPanel>
|
||||
<Grid Grid.Row="1" ColumnDefinitions="*,8,*" Margin="0,20,0,0">
|
||||
<Button Grid.Column="0" Content="Abbrechen" HorizontalAlignment="Stretch" Click="OnCancel"/>
|
||||
<Button Grid.Column="2" Content="Verschieben" HorizontalAlignment="Stretch" Click="OnSave"
|
||||
IsEnabled="{Binding HasUnits}"/>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Window>
|
||||
@@ -0,0 +1,21 @@
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Interactivity;
|
||||
using LehrerApp.Desktop.ViewModels.Groups;
|
||||
|
||||
namespace LehrerApp.Desktop.Views.Groups;
|
||||
|
||||
public partial class ChangeLessonUnitDialog : Window
|
||||
{
|
||||
public ChangeLessonUnitDialog() => InitializeComponent();
|
||||
|
||||
private void OnSave(object? s, RoutedEventArgs e)
|
||||
{
|
||||
if (DataContext is ChangeLessonUnitDialogViewModel vm && vm.SaveCommand.CanExecute(null))
|
||||
{
|
||||
vm.SaveCommand.Execute(null);
|
||||
if (vm.Result is not null) Close(true);
|
||||
}
|
||||
}
|
||||
|
||||
private void OnCancel(object? s, RoutedEventArgs e) => Close(false);
|
||||
}
|
||||
@@ -185,6 +185,9 @@
|
||||
<Button Grid.Column="2" Content="🤖 KI-Unterstützung für diese Stunde" HorizontalAlignment="Stretch"
|
||||
Click="OnAiAssist" IsVisible="{Binding CanAiAssist}"
|
||||
ToolTip.Tip="Fragt die KI gezielt zu genau dieser Stunde — Speicherstand geht dabei direkt in die Datenbank, nicht über die Felder hier."/>
|
||||
<Button Grid.Column="2" Content="🔧 Vorhandene Stunde verknüpfen" HorizontalAlignment="Stretch"
|
||||
Click="OnFixIt" IsVisible="{Binding IsNewLesson}"
|
||||
ToolTip.Tip="Sucht nach einer bereits vorhandenen Stunde (z.B. durch Import oder KI ohne Stundenplan-Bezug angelegt) und verknüpft sie mit diesem Termin, statt eine neue Stunde anzulegen."/>
|
||||
<Button Grid.Column="4" Content="{Binding SaveButtonText}"
|
||||
HorizontalAlignment="Stretch" Click="OnSave"/>
|
||||
</Grid>
|
||||
|
||||
@@ -6,7 +6,9 @@ using LehrerApp.Core.Models;
|
||||
using LehrerApp.Desktop.Services;
|
||||
using LehrerApp.Desktop.ViewModels.Groups;
|
||||
using LehrerApp.Desktop.ViewModels.Students;
|
||||
using LehrerApp.Desktop.Views.Shared;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using System.Globalization;
|
||||
|
||||
namespace LehrerApp.Desktop.Views.Groups;
|
||||
|
||||
@@ -200,4 +202,59 @@ public partial class LessonDialog : Window
|
||||
if (updated is not null) vm.MarkAppliedExternally(updated);
|
||||
Close(true);
|
||||
}
|
||||
|
||||
/// Deckt die Lücke ab, dass der Stundenplan eine Stunde nur über Datum+Stundennummer findet
|
||||
/// (kein gespeichertes Verknüpfungsfeld) — eine per JSON-Import oder KI ohne Stundennummer
|
||||
/// angelegte Stunde taucht dort nie auf und würde sonst beim Klick auf den Termin dupliziert.
|
||||
/// Statt neu anzulegen, wird die gefundene vorhandene Stunde auf diesen Termin umgehängt.
|
||||
private async void OnFixIt(object? s, RoutedEventArgs e)
|
||||
{
|
||||
if (DataContext is not LessonDialogViewModel vm) return;
|
||||
if (!DateOnly.TryParseExact(vm.DateText, "dd.MM.yyyy", null, DateTimeStyles.None, out var date))
|
||||
{
|
||||
App.Services.GetRequiredService<NotificationService>()
|
||||
.ShowError("Bitte zuerst ein gültiges Datum eintragen.");
|
||||
return;
|
||||
}
|
||||
|
||||
var lessonRepo = App.Services.GetRequiredService<ILessonRepository>();
|
||||
var candidates = LessonFixItSearch.FindCandidates(lessonRepo, vm.GroupId, date);
|
||||
|
||||
if (candidates.Count == 0)
|
||||
{
|
||||
App.Services.GetRequiredService<NotificationService>()
|
||||
.ShowError("Keine passende bestehende Stunde gefunden.");
|
||||
return;
|
||||
}
|
||||
|
||||
Lesson chosen;
|
||||
if (candidates.Count == 1)
|
||||
{
|
||||
var info = new ConfirmDialogInfo
|
||||
{
|
||||
Title = "Vorhandene Stunde verknüpfen?",
|
||||
Message = $"Vorhandene Stunde „{candidates[0].Topic}“ vom {candidates[0].Date:dd.MM.yyyy} gefunden. " +
|
||||
"Mit diesem Termin verknüpfen, statt eine neue Stunde anzulegen?",
|
||||
ConfirmText = "Verknüpfen",
|
||||
};
|
||||
var confirm = new ConfirmDialog { DataContext = info };
|
||||
if (!await confirm.ShowDialog<bool>(this)) return;
|
||||
chosen = candidates[0];
|
||||
}
|
||||
else
|
||||
{
|
||||
var pickerVm = new LinkExistingLessonDialogViewModel(candidates,
|
||||
App.Services.GetRequiredService<IUnitRepository>());
|
||||
var picker = new LinkExistingLessonDialog { DataContext = pickerVm };
|
||||
if (!await picker.ShowDialog<bool>(this) || pickerVm.Result is not { } picked) return;
|
||||
chosen = picked;
|
||||
}
|
||||
|
||||
chosen.UnitId = vm.UnitId;
|
||||
chosen.Date = date;
|
||||
chosen.LessonNumber = vm.LessonNumber;
|
||||
lessonRepo.Save(chosen);
|
||||
vm.MarkAppliedExternally(chosen);
|
||||
Close(true);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
<Window xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:vm="clr-namespace:LehrerApp.Desktop.ViewModels.Groups"
|
||||
x:Class="LehrerApp.Desktop.Views.Groups.LinkExistingLessonDialog"
|
||||
x:DataType="vm:LinkExistingLessonDialogViewModel"
|
||||
Title="Vorhandene Stunde verknüpfen"
|
||||
Width="470" Height="360" MinWidth="420" MinHeight="320"
|
||||
CanResize="False" WindowStartupLocation="CenterOwner">
|
||||
<Grid RowDefinitions="*,Auto" Margin="24">
|
||||
<StackPanel Grid.Row="0" Spacing="16">
|
||||
<TextBlock Text="Vorhandene Stunde verknüpfen" Classes="dialogtitle"/>
|
||||
<TextBlock Text="Mehrere Stunden kommen für diesen Termin infrage — bitte die passende auswählen."
|
||||
FontSize="12" TextWrapping="Wrap" Opacity="0.75"/>
|
||||
|
||||
<StackPanel Spacing="5">
|
||||
<TextBlock Text="Stunde" FontSize="12" Opacity="0.7"/>
|
||||
<ComboBox ItemsSource="{Binding Options}" SelectedItem="{Binding SelectedOption}"
|
||||
HorizontalAlignment="Stretch">
|
||||
<ComboBox.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:LessonLinkOption">
|
||||
<StackPanel>
|
||||
<TextBlock Text="{Binding Label}" FontWeight="SemiBold"/>
|
||||
<TextBlock Text="{Binding Detail}" FontSize="11" Opacity="0.6"/>
|
||||
</StackPanel>
|
||||
</DataTemplate>
|
||||
</ComboBox.ItemTemplate>
|
||||
</ComboBox>
|
||||
</StackPanel>
|
||||
|
||||
<TextBlock Text="{Binding Error}" Foreground="Red" FontSize="11"
|
||||
IsVisible="{Binding Error, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
|
||||
</StackPanel>
|
||||
<Grid Grid.Row="1" ColumnDefinitions="*,8,*" Margin="0,20,0,0">
|
||||
<Button Grid.Column="0" Content="Abbrechen" HorizontalAlignment="Stretch" Click="OnCancel"/>
|
||||
<Button Grid.Column="2" Content="Verknüpfen" HorizontalAlignment="Stretch" Click="OnSave"/>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Window>
|
||||
@@ -0,0 +1,21 @@
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Interactivity;
|
||||
using LehrerApp.Desktop.ViewModels.Groups;
|
||||
|
||||
namespace LehrerApp.Desktop.Views.Groups;
|
||||
|
||||
public partial class LinkExistingLessonDialog : Window
|
||||
{
|
||||
public LinkExistingLessonDialog() => InitializeComponent();
|
||||
|
||||
private void OnSave(object? s, RoutedEventArgs e)
|
||||
{
|
||||
if (DataContext is LinkExistingLessonDialogViewModel vm && vm.SaveCommand.CanExecute(null))
|
||||
{
|
||||
vm.SaveCommand.Execute(null);
|
||||
if (vm.Result is not null) Close(true);
|
||||
}
|
||||
}
|
||||
|
||||
private void OnCancel(object? s, RoutedEventArgs e) => Close(false);
|
||||
}
|
||||
@@ -6,9 +6,10 @@
|
||||
|
||||
<Grid RowDefinitions="Auto,2*,Auto,Auto,2*" Margin="16">
|
||||
|
||||
<!-- Einheiten-Toolbar -->
|
||||
<Grid Grid.Row="0" ColumnDefinitions="*,Auto" Margin="0,0,0,8">
|
||||
<StackPanel Grid.Column="0" Orientation="Horizontal" Spacing="8" VerticalAlignment="Center">
|
||||
<!-- Einheiten-Toolbar: Aktionen bewusst unter der Überschrift und in umbruchfähigen
|
||||
Gruppen. So kollidieren Beschriftung und Buttons auch in schmaleren Fenstern nicht. -->
|
||||
<Grid Grid.Row="0" RowDefinitions="Auto,Auto" Margin="0,0,0,8" RowSpacing="6">
|
||||
<StackPanel Grid.Row="0" Orientation="Horizontal" Spacing="8" VerticalAlignment="Center">
|
||||
<TextBlock Text="Unterrichtseinheiten" FontSize="14" FontWeight="SemiBold"
|
||||
VerticalAlignment="Center"/>
|
||||
<Button Content="↕ JSON" FontSize="11" Padding="7,3"
|
||||
@@ -30,13 +31,16 @@
|
||||
</Button.Flyout>
|
||||
</Button>
|
||||
</StackPanel>
|
||||
<StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="8">
|
||||
<WrapPanel Grid.Row="1" ItemSpacing="6" LineSpacing="6">
|
||||
<Button Content="+ Einheit" Command="{Binding AddUnitCommand}" IsEnabled="{Binding !IsReadOnly}"/>
|
||||
<Button Content="Bearbeiten" Command="{Binding EditUnitCommand}" IsEnabled="{Binding !IsReadOnly}"/>
|
||||
<Button Content="Als Vorlage kopieren" Command="{Binding CopyUnitCommand}"/>
|
||||
<Button Content="Löschen" Command="{Binding DeleteUnitCommand}" IsEnabled="{Binding !IsReadOnly}"/>
|
||||
<Button Content="🤖 KI-Unterstützung" Command="{Binding AiAssistCommand}"/>
|
||||
</StackPanel>
|
||||
<Button Content="✎" Width="38" Command="{Binding EditUnitCommand}" IsEnabled="{Binding !IsReadOnly}"
|
||||
ToolTip.Tip="Ausgewählte Einheit bearbeiten" AutomationProperties.Name="Einheit bearbeiten"/>
|
||||
<Button Content="⧉" Width="38" Command="{Binding CopyUnitCommand}"
|
||||
ToolTip.Tip="Als Vorlage in eine andere Lerngruppe kopieren" AutomationProperties.Name="Einheit als Vorlage kopieren"/>
|
||||
<Button Content="⌫" Width="38" Command="{Binding DeleteUnitCommand}" IsEnabled="{Binding !IsReadOnly}"
|
||||
ToolTip.Tip="Ausgewählte Einheit löschen" AutomationProperties.Name="Einheit löschen"/>
|
||||
<Button Content="✦ KI-Unterstützung" Command="{Binding AiAssistCommand}"/>
|
||||
</WrapPanel>
|
||||
</Grid>
|
||||
|
||||
<!-- Einheiten-Tabelle -->
|
||||
@@ -76,28 +80,34 @@
|
||||
|
||||
<Separator Grid.Row="2" Margin="0,0,0,8"/>
|
||||
|
||||
<!-- Stunden-Toolbar -->
|
||||
<Grid Grid.Row="3" ColumnDefinitions="*,Auto" Margin="0,0,0,8">
|
||||
<StackPanel Grid.Column="0" Orientation="Horizontal" VerticalAlignment="Center">
|
||||
<!-- Stunden-Toolbar: häufige Aktionen bleiben beschriftet, sekundäre Aktionen sind als
|
||||
kompakte Symbolbuttons gruppiert. Das WrapPanel darf bei Bedarf umbrechen. -->
|
||||
<Grid Grid.Row="3" RowDefinitions="Auto,Auto" Margin="0,0,0,8" RowSpacing="6">
|
||||
<StackPanel Grid.Row="0" Orientation="Horizontal" VerticalAlignment="Center">
|
||||
<TextBlock Text="Stunden" FontSize="14" FontWeight="SemiBold"/>
|
||||
<TextBlock Text="{Binding SelectedUnitTitleSuffix}" FontSize="14" FontWeight="SemiBold"/>
|
||||
</StackPanel>
|
||||
<StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="8">
|
||||
<WrapPanel Grid.Row="1" ItemSpacing="6" LineSpacing="6">
|
||||
<Button Content="+ Stunde" Command="{Binding AddLessonCommand}" IsEnabled="{Binding !IsReadOnly}"/>
|
||||
<Button Content="Serie erzeugen" Command="{Binding GenerateLessonSeriesCommand}"
|
||||
<Button Content="+ Serie" Command="{Binding GenerateLessonSeriesCommand}"
|
||||
IsEnabled="{Binding !IsReadOnly}"
|
||||
ToolTip.Tip="Stunden für alle Termine aus dem Stundenplan im gewählten Zeitraum anlegen."/>
|
||||
<Button Content="Anzeigen" Command="{Binding ShowLessonCommand}"
|
||||
ToolTip.Tip="Verlaufsplan schreibgeschützt und größer anzeigen — zum Mitnehmen in den Unterricht."/>
|
||||
<Button Content="Bearbeiten" Command="{Binding EditLessonCommand}" IsEnabled="{Binding !IsReadOnly}"/>
|
||||
<Button Content="Verschieben" Command="{Binding MoveLessonCommand}" IsEnabled="{Binding !IsReadOnly}"/>
|
||||
<Button Content="Status weiter" Command="{Binding AdvanceLessonStatusCommand}" IsEnabled="{Binding !IsReadOnly}"
|
||||
<Button Content="✎" Width="38" Command="{Binding EditLessonCommand}" IsEnabled="{Binding !IsReadOnly}"
|
||||
ToolTip.Tip="Ausgewählte Stunde bearbeiten" AutomationProperties.Name="Stunde bearbeiten"/>
|
||||
<Button Content="↔" Width="38" Command="{Binding MoveLessonCommand}" IsEnabled="{Binding !IsReadOnly}"
|
||||
ToolTip.Tip="Ausgewählte Stunde verschieben" AutomationProperties.Name="Stunde verschieben"/>
|
||||
<Button Content="⇄" Width="38" Command="{Binding ChangeLessonUnitCommand}" IsEnabled="{Binding !IsReadOnly}"
|
||||
ToolTip.Tip="Ordnet die ausgewählte Stunde einer anderen Einheit derselben Gruppe zu."/>
|
||||
<Button Content="Status ›" Command="{Binding AdvanceLessonStatusCommand}" IsEnabled="{Binding !IsReadOnly}"
|
||||
ToolTip.Tip="Entwurf/Geplant → Bereit → Durchgeführt"/>
|
||||
<Button Content="Sitzung erzeugen" Command="{Binding CreateParticipationSessionCommand}"
|
||||
<Button Content="+ Sitzung" Command="{Binding CreateParticipationSessionCommand}"
|
||||
IsEnabled="{Binding !IsReadOnly}"
|
||||
ToolTip.Tip="Legt eine Mitarbeitssitzung mit Datum und Thema dieser Stunde an."/>
|
||||
<Button Content="Löschen" Command="{Binding DeleteLessonCommand}" IsEnabled="{Binding !IsReadOnly}"/>
|
||||
</StackPanel>
|
||||
<Button Content="⌫" Width="38" Command="{Binding DeleteLessonCommand}" IsEnabled="{Binding !IsReadOnly}"
|
||||
ToolTip.Tip="Ausgewählte Stunde löschen" AutomationProperties.Name="Stunde löschen"/>
|
||||
</WrapPanel>
|
||||
</Grid>
|
||||
|
||||
<!-- Stunden-Tabelle -->
|
||||
|
||||
@@ -38,6 +38,7 @@ public partial class PlanningTabView : UserControl
|
||||
ShowLessonDialog(lesson.UnitId, lesson.GroupId, materials, shorthands, editingLesson: lesson);
|
||||
vm.OnConfirmDeleteLesson = ShowDeleteLessonDialog;
|
||||
vm.OnPickMoveTarget = ShowMoveLessonDialog;
|
||||
vm.OnPickUnitChange = ShowChangeLessonUnitDialog;
|
||||
vm.OnShowLesson = ShowLessonViewerDialog;
|
||||
vm.OnGenerateLessonSeries = ShowGenerateLessonSeriesDialog;
|
||||
vm.OnAiAssist = ShowAiAssistDialog;
|
||||
@@ -136,6 +137,20 @@ public partial class PlanningTabView : UserControl
|
||||
return ok ? dialogVm.Result : null;
|
||||
}
|
||||
|
||||
private async Task<Unit?> ShowChangeLessonUnitDialog(Lesson lesson)
|
||||
{
|
||||
var dialogVm = new ChangeLessonUnitDialogViewModel(
|
||||
App.Services.GetRequiredService<IUnitRepository>(),
|
||||
lesson.GroupId, lesson.UnitId,
|
||||
(DataContext as PlanningTabViewModel)?.SelectedUnit?.Title ?? "");
|
||||
var dialog = new ChangeLessonUnitDialog { DataContext = dialogVm };
|
||||
var owner = TopLevel.GetTopLevel(this) as Window;
|
||||
if (owner is null) return null;
|
||||
|
||||
var ok = await dialog.ShowDialog<bool>(owner);
|
||||
return ok ? dialogVm.Result : null;
|
||||
}
|
||||
|
||||
private async Task ShowLessonViewerDialog(Lesson lesson)
|
||||
{
|
||||
var viewerVm = new LessonViewerViewModel(lesson,
|
||||
|
||||
@@ -41,6 +41,10 @@
|
||||
<TextBlock Text="Status" FontSize="12" Opacity="0.7"/>
|
||||
<ComboBox ItemsSource="{Binding StatusOptions}" SelectedItem="{Binding StatusName}"
|
||||
HorizontalAlignment="Stretch"/>
|
||||
<Border Background="#FFF3CD" BorderBrush="#D97706" BorderThickness="1" CornerRadius="5"
|
||||
Padding="9,6" IsVisible="{Binding StatusNotice, Converter={x:Static StringConverters.IsNotNullOrEmpty}}">
|
||||
<TextBlock Text="{Binding StatusNotice}" Foreground="#92400E" FontSize="11" TextWrapping="Wrap"/>
|
||||
</Border>
|
||||
</StackPanel>
|
||||
|
||||
<StackPanel Spacing="4">
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
<Window xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:vm="clr-namespace:LehrerApp.Desktop.ViewModels.Groups"
|
||||
xmlns:conv="clr-namespace:LehrerApp.Desktop.Converters"
|
||||
x:Class="LehrerApp.Desktop.Views.Groups.WebUntisHomeworkComparisonDialog"
|
||||
x:DataType="vm:WebUntisHomeworkComparisonViewModel"
|
||||
Title="Fehlende Hausaufgaben mit WebUntis abgleichen" Width="1100" Height="680"
|
||||
MinWidth="900" MinHeight="480" WindowStartupLocation="CenterOwner">
|
||||
<Grid RowDefinitions="Auto,Auto,Auto,*,Auto" Margin="24" RowSpacing="8">
|
||||
<StackPanel Grid.Row="0" Spacing="4">
|
||||
<TextBlock Text="Fehlende Hausaufgaben mit WebUntis abgleichen" Classes="dialogtitle"/>
|
||||
<TextBlock TextWrapping="Wrap" FontSize="12" Opacity="0.65"
|
||||
Text="Nur eigene, negative Klassenbucheinträge zum Stichwort „Hausaufgabe“. Fach wird über das Untis-Fachkürzel im Text erkannt. Zeilen mit bereits vorhandenem lokalem Hausaufgaben-Status (z.B. „Teilweise“, „nachgereicht“) werden nie überschrieben, nur zum Vergleich angezeigt."/>
|
||||
</StackPanel>
|
||||
<StackPanel Grid.Row="1" Orientation="Horizontal" Spacing="8">
|
||||
<CalendarDatePicker SelectedDate="{Binding StartDate, Converter={x:Static conv:DateTimeOffsetToDateTimeConverter.Instance}}"/>
|
||||
<TextBlock Text="bis" VerticalAlignment="Center"/>
|
||||
<CalendarDatePicker SelectedDate="{Binding EndDate, Converter={x:Static conv:DateTimeOffsetToDateTimeConverter.Instance}}"/>
|
||||
<Button Content="Klassenbucheinträge laden" Command="{Binding LoadCommand}" IsEnabled="{Binding !Busy}"/>
|
||||
</StackPanel>
|
||||
|
||||
<Grid Grid.Row="2" ColumnDefinitions="Auto,70,60,55,1.1*,1.1*,1.4*,1.2*" ColumnSpacing="8" Margin="4,0">
|
||||
<TextBlock Grid.Column="1" Text="Datum" FontSize="11" Opacity="0.6"/>
|
||||
<TextBlock Grid.Column="2" Text="Klasse" FontSize="11" Opacity="0.6"/>
|
||||
<TextBlock Grid.Column="3" Text="Fach" FontSize="11" Opacity="0.6"/>
|
||||
<TextBlock Grid.Column="4" Text="Name (WebUntis)" FontSize="11" Opacity="0.6"/>
|
||||
<TextBlock Grid.Column="5" Text="Zuordnung" FontSize="11" Opacity="0.6"/>
|
||||
<TextBlock Grid.Column="6" Text="Text" FontSize="11" Opacity="0.6"/>
|
||||
<TextBlock Grid.Column="7" Text="Lokaler Hausaufgaben-Status" FontSize="11" Opacity="0.6"/>
|
||||
</Grid>
|
||||
<ScrollViewer Grid.Row="3">
|
||||
<ItemsControl ItemsSource="{Binding Rows}">
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:WebUntisHomeworkRow">
|
||||
<Grid ColumnDefinitions="Auto,70,60,55,1.1*,1.1*,1.4*,1.2*" ColumnSpacing="8" Margin="0,3">
|
||||
<CheckBox Grid.Column="0" IsChecked="{Binding Selected}" IsEnabled="{Binding CanApply}"/>
|
||||
<TextBlock Grid.Column="1" Text="{Binding DateLabel}" VerticalAlignment="Center"/>
|
||||
<TextBlock Grid.Column="2" Text="{Binding ClassName}" VerticalAlignment="Center"/>
|
||||
<TextBlock Grid.Column="3" Text="{Binding SubjectLabel}" VerticalAlignment="Center"/>
|
||||
<TextBlock Grid.Column="4" Text="{Binding UntisStudentName}" VerticalAlignment="Center" TextWrapping="Wrap"/>
|
||||
<ComboBox Grid.Column="5" ItemsSource="{Binding Candidates}" SelectedItem="{Binding AssignedStudent}"
|
||||
DisplayMemberBinding="{Binding FullName}" PlaceholderText="Schüler*in wählen…"
|
||||
HorizontalAlignment="Stretch"/>
|
||||
<TextBlock Grid.Column="6" Text="{Binding Text}" TextWrapping="Wrap" VerticalAlignment="Center"/>
|
||||
<TextBlock Grid.Column="7" Text="{Binding LocalStatus}" TextWrapping="Wrap" VerticalAlignment="Center"/>
|
||||
</Grid>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
</ScrollViewer>
|
||||
|
||||
<Grid Grid.Row="4" ColumnDefinitions="*,Auto,Auto" ColumnSpacing="8">
|
||||
<TextBlock Grid.Column="0" Text="{Binding Status}" FontSize="12" TextWrapping="Wrap" VerticalAlignment="Center"/>
|
||||
<Button Grid.Column="1" Content="Schließen" Click="OnClose"/>
|
||||
<Button Grid.Column="2" Content="Markierte übernehmen" Command="{Binding ApplyCommand}" IsEnabled="{Binding !Busy}"/>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Window>
|
||||
@@ -0,0 +1,11 @@
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Interactivity;
|
||||
|
||||
namespace LehrerApp.Desktop.Views.Groups;
|
||||
|
||||
public partial class WebUntisHomeworkComparisonDialog : Window
|
||||
{
|
||||
public WebUntisHomeworkComparisonDialog() => InitializeComponent();
|
||||
|
||||
private void OnClose(object? sender, RoutedEventArgs e) => Close();
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
<Window xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:vm="clr-namespace:LehrerApp.Desktop.ViewModels.Groups"
|
||||
xmlns:conv="clr-namespace:LehrerApp.Desktop.Converters"
|
||||
x:Class="LehrerApp.Desktop.Views.Groups.WebUntisLessonAbsenceComparisonDialog"
|
||||
x:DataType="vm:WebUntisLessonAbsenceComparisonViewModel"
|
||||
Title="Fehlzeiten je Unterricht mit WebUntis abgleichen" Width="1000" Height="640"
|
||||
@@ -12,9 +13,9 @@
|
||||
FontSize="12" Opacity="0.65" TextWrapping="Wrap"/>
|
||||
</StackPanel>
|
||||
<StackPanel Grid.Row="1" Orientation="Horizontal" Spacing="8">
|
||||
<DatePicker SelectedDate="{Binding StartDate}"/>
|
||||
<CalendarDatePicker SelectedDate="{Binding StartDate, Converter={x:Static conv:DateTimeOffsetToDateTimeConverter.Instance}}"/>
|
||||
<TextBlock Text="bis" VerticalAlignment="Center"/>
|
||||
<DatePicker SelectedDate="{Binding EndDate}"/>
|
||||
<CalendarDatePicker SelectedDate="{Binding EndDate, Converter={x:Static conv:DateTimeOffsetToDateTimeConverter.Instance}}"/>
|
||||
<Button Content="Fehlzeiten laden" Command="{Binding LoadCommand}" IsEnabled="{Binding !Busy}"/>
|
||||
</StackPanel>
|
||||
<Grid Grid.Row="2" ColumnDefinitions="Auto,1.2*,1.2*,80,80,1.1*,1.1*" ColumnSpacing="8" Margin="4,0">
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<Window xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:vm="clr-namespace:LehrerApp.Desktop.ViewModels.Groups"
|
||||
xmlns:conv="clr-namespace:LehrerApp.Desktop.Converters"
|
||||
x:Class="LehrerApp.Desktop.Views.Groups.WithdrawStudentDialog"
|
||||
x:DataType="vm:WithdrawStudentDialogViewModel"
|
||||
Title="Schüler austragen"
|
||||
@@ -23,8 +24,8 @@
|
||||
|
||||
<StackPanel Spacing="5">
|
||||
<TextBlock Text="Austrittsdatum" FontSize="12" Opacity="0.7"/>
|
||||
<CalendarDatePicker SelectedDate="{Binding SelectedDate}"
|
||||
DisplayDateStart="{Binding EarliestDate}"
|
||||
<CalendarDatePicker SelectedDate="{Binding SelectedDate, Converter={x:Static conv:DateTimeOffsetToDateTimeConverter.Instance}}"
|
||||
DisplayDateStart="{Binding EarliestDate, Converter={x:Static conv:DateTimeOffsetToDateTimeConverter.Instance}}"
|
||||
HorizontalAlignment="Stretch"/>
|
||||
<TextBlock Text="{Binding DateError}" Foreground="Red" FontSize="12"
|
||||
IsVisible="{Binding DateError, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
|
||||
|
||||
@@ -31,6 +31,16 @@
|
||||
darunter als Overlay-Drawer mit Hamburger-Button (automatisch).
|
||||
Kein eigener Code nötig.
|
||||
-->
|
||||
<DockPanel>
|
||||
<Menu DockPanel.Dock="Top">
|
||||
<MenuItem Header="WebUntis">
|
||||
<MenuItem Header="Untis-Hub…" Click="OnOpenUntisHub"/>
|
||||
<Separator/>
|
||||
<MenuItem Header="Offene Stunden…" Click="OnOpenUntisPeriods"/>
|
||||
<MenuItem Header="Klassenbuchabgleich…" Click="OnCompareUntisKlassenbuch"/>
|
||||
<MenuItem Header="Hausaufgabenabgleich…" Click="OnCompareUntisHausaufgaben"/>
|
||||
</MenuItem>
|
||||
</Menu>
|
||||
<DrawerPage x:Name="RootDrawer"
|
||||
DrawerLength="220"
|
||||
DrawerBehavior="Auto"
|
||||
@@ -267,6 +277,7 @@
|
||||
</DrawerPage.Drawer>
|
||||
|
||||
</DrawerPage>
|
||||
</DockPanel>
|
||||
|
||||
<!-- Globale Suche und Schnellerfassung (14.2). Bewusst als Overlay auf der aktuellen Seite:
|
||||
Der Nutzer behält den Kontext und kann mit Escape ohne Navigation zurückkehren. -->
|
||||
|
||||
@@ -3,7 +3,9 @@ using Avalonia.Input;
|
||||
using Avalonia.Threading;
|
||||
using LehrerApp.Desktop.Services;
|
||||
using LehrerApp.Desktop.ViewModels;
|
||||
using LehrerApp.Desktop.Views.UntisHub;
|
||||
using LehrerApp.Sync;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace LehrerApp.Desktop.Views;
|
||||
|
||||
@@ -23,6 +25,26 @@ public partial class MainWindow : Window
|
||||
KeyDown += OnWindowKeyDown;
|
||||
}
|
||||
|
||||
private async void OnOpenUntisHub(object? sender, Avalonia.Interactivity.RoutedEventArgs e)
|
||||
{
|
||||
await new UntisHubDialog().ShowDialog(this);
|
||||
}
|
||||
|
||||
private async void OnOpenUntisPeriods(object? sender, Avalonia.Interactivity.RoutedEventArgs e)
|
||||
{
|
||||
await UntisHubActions.RunOffenePeriodsAsync(this, App.Services.GetRequiredService<UntisHubService>());
|
||||
}
|
||||
|
||||
private async void OnCompareUntisKlassenbuch(object? sender, Avalonia.Interactivity.RoutedEventArgs e)
|
||||
{
|
||||
await UntisHubActions.RunKlassenbuchAsync(this, App.Services.GetRequiredService<UntisHubService>());
|
||||
}
|
||||
|
||||
private async void OnCompareUntisHausaufgaben(object? sender, Avalonia.Interactivity.RoutedEventArgs e)
|
||||
{
|
||||
await UntisHubActions.RunHausaufgabenAsync(this, App.Services.GetRequiredService<UntisHubService>());
|
||||
}
|
||||
|
||||
private void OnWindowKeyDown(object? sender, KeyEventArgs e)
|
||||
{
|
||||
if (DataContext is not MainWindowViewModel vm) return;
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using LehrerApp.Desktop.Services;
|
||||
using LehrerApp.WebUntis;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace LehrerApp.Desktop.Views;
|
||||
|
||||
public sealed class OpenUntisPeriodsDialog : Window
|
||||
{
|
||||
private readonly WebUntisIntegrationService _service = App.Services.GetRequiredService<WebUntisIntegrationService>();
|
||||
private readonly WebUntisSettingsService _settings = App.Services.GetRequiredService<WebUntisSettingsService>();
|
||||
private readonly CancellationTokenSource _closed = new();
|
||||
private readonly ComboBox _mode = new() { ItemsSource = new[] { "Mein Unterricht", "Meine Klasse" }, SelectedIndex = 0 };
|
||||
private readonly ComboBox _element = new() { MinWidth = 150 };
|
||||
private readonly DatePicker _start = new();
|
||||
private readonly DatePicker _end = new() { SelectedDate = DateTimeOffset.Now };
|
||||
private readonly Button _load = new() { Content = "Abrufen" };
|
||||
private readonly Button _schoolYear = new() { Content = "Seit Schuljahresbeginn" };
|
||||
private readonly TextBlock _status = new() { TextWrapping = Avalonia.Media.TextWrapping.Wrap };
|
||||
private readonly StackPanel _rows = new() { Spacing = 12 };
|
||||
private readonly StackPanel _filters = new() { Spacing = 10, IsEnabled = false };
|
||||
private UntisOpenPeriodsMeta? _meta;
|
||||
private int _yearId;
|
||||
|
||||
/// <summary>Für den Untis-Hub (<see cref="UntisHubActions.RunOffenePeriodsAsync"/>): letzter
|
||||
/// Status-Text nach dem automatischen Laden beim Öffnen.</summary>
|
||||
public string? LastStatus => _status.Text;
|
||||
|
||||
public OpenUntisPeriodsDialog()
|
||||
{
|
||||
Title = "Offene WebUntis-Stunden";
|
||||
Width = 850; Height = 650; MinWidth = 600; MinHeight = 450;
|
||||
WindowStartupLocation = WindowStartupLocation.CenterOwner;
|
||||
var root = new DockPanel { Margin = new Thickness(20) };
|
||||
var header = new StackPanel { Spacing = 12 };
|
||||
header.Children.Add(new TextBlock { Text = Title, FontSize = 22 });
|
||||
var selection = new StackPanel { Orientation = Avalonia.Layout.Orientation.Horizontal, Spacing = 12 };
|
||||
selection.Children.Add(_mode); selection.Children.Add(_element);
|
||||
_filters.Children.Add(selection);
|
||||
var dates = new WrapPanel { Orientation = Avalonia.Layout.Orientation.Horizontal };
|
||||
dates.Children.Add(new TextBlock { Text = "Von ", VerticalAlignment = Avalonia.Layout.VerticalAlignment.Center });
|
||||
dates.Children.Add(_start);
|
||||
dates.Children.Add(new TextBlock { Text = " bis ", VerticalAlignment = Avalonia.Layout.VerticalAlignment.Center });
|
||||
dates.Children.Add(_end);
|
||||
_filters.Children.Add(dates);
|
||||
var actions = new StackPanel { Orientation = Avalonia.Layout.Orientation.Horizontal, Spacing = 12 };
|
||||
actions.Children.Add(_schoolYear); actions.Children.Add(_load);
|
||||
_filters.Children.Add(actions);
|
||||
header.Children.Add(_filters); header.Children.Add(_status);
|
||||
DockPanel.SetDock(header, Dock.Top); root.Children.Add(header);
|
||||
root.Children.Add(new ScrollViewer { Content = _rows, Margin = new Thickness(0, 20, 0, 0) });
|
||||
Content = root;
|
||||
_mode.SelectionChanged += (_, _) => SelectMode();
|
||||
_element.SelectionChanged += (_, _) => ClearResults();
|
||||
_start.SelectedDateChanged += (_, _) => ClearResults();
|
||||
_end.SelectedDateChanged += (_, _) => ClearResults();
|
||||
_schoolYear.Click += (_, _) => { if (_meta is not null) _start.SelectedDate = new DateTimeOffset(_meta.SchoolYearStart.ToDateTime(TimeOnly.MinValue)); };
|
||||
_load.Click += async (_, _) => await LoadAsync();
|
||||
Opened += async (_, _) => await InitializeAsync();
|
||||
Closed += (_, _) => _closed.Cancel();
|
||||
}
|
||||
|
||||
private void ClearResults() { _rows.Children.Clear(); _status.Text = "Auswahl festlegen und Abrufen wählen."; }
|
||||
|
||||
private void SelectMode()
|
||||
{
|
||||
if (_meta is null) return;
|
||||
var own = _mode.SelectedIndex == 0;
|
||||
var elements = own ? _meta.Teachers : _meta.Classes;
|
||||
_element.ItemsSource = elements;
|
||||
var preferred = own ? _settings.TeacherUntisId ?? _meta.OwnTeacherId
|
||||
: _settings.HomeroomClassUntisId ?? _meta.MyClassIds.Cast<int?>().FirstOrDefault();
|
||||
_element.SelectedItem = elements.FirstOrDefault(x => x.Id == preferred)
|
||||
?? (!own ? elements.FirstOrDefault(x => _meta.MyClassIds.Contains(x.Id)) : null);
|
||||
ClearResults();
|
||||
}
|
||||
|
||||
private async Task InitializeAsync()
|
||||
{
|
||||
_status.Text = "WebUntis-Auswahl wird geladen …";
|
||||
try
|
||||
{
|
||||
var today = DateOnly.FromDateTime(DateTime.Today);
|
||||
var date = today.Year * 10000 + today.Month * 100 + today.Day;
|
||||
var years = await _service.GetSchoolYearsAsync(_closed.Token);
|
||||
var year = years.FirstOrDefault(x => x.StartDate <= date && date <= x.EndDate)
|
||||
?? throw new InvalidDataException("Kein aktuelles WebUntis-Schuljahr gefunden.");
|
||||
_yearId = year.UntisId;
|
||||
_meta = await _service.GetOpenPeriodsMetaAsync(_yearId, _closed.Token);
|
||||
_start.SelectedDate = new DateTimeOffset(_meta.SchoolYearStart.ToDateTime(TimeOnly.MinValue));
|
||||
SelectMode();
|
||||
_filters.IsEnabled = true;
|
||||
if (_element.SelectedItem is not null) await LoadAsync();
|
||||
}
|
||||
catch (OperationCanceledException) when (_closed.IsCancellationRequested) { }
|
||||
catch (Exception ex) { _status.Text = $"Abruf fehlgeschlagen: {ex.Message}"; }
|
||||
}
|
||||
|
||||
private async Task LoadAsync()
|
||||
{
|
||||
_rows.Children.Clear();
|
||||
if (_element.SelectedItem is not UntisOpenElement element || _start.SelectedDate is null || _end.SelectedDate is null)
|
||||
{ _status.Text = "Bitte Lehrkraft oder Klasse und Zeitraum auswählen."; return; }
|
||||
var start = DateOnly.FromDateTime(_start.SelectedDate.Value.DateTime);
|
||||
var end = DateOnly.FromDateTime(_end.SelectedDate.Value.DateTime);
|
||||
if (start > end || start < _meta!.SchoolYearStart || end > _meta.SchoolYearEnd)
|
||||
{ _status.Text = "Bitte einen gültigen Zeitraum innerhalb des Schuljahres auswählen."; return; }
|
||||
_filters.IsEnabled = false;
|
||||
_status.Text = "Offene Stunden werden geladen …";
|
||||
try
|
||||
{
|
||||
var own = _mode.SelectedIndex == 0;
|
||||
var periods = await _service.GetOpenPeriodsAsync(_yearId, own ? element.Id : null,
|
||||
own ? null : element.Id, start, end, _closed.Token);
|
||||
foreach (var period in periods)
|
||||
_rows.Children.Add(new TextBlock
|
||||
{
|
||||
Text = $"{period.When}\n{period.Classes} · {period.Subject} · {period.Teachers}\n{period.Missing}",
|
||||
TextWrapping = Avalonia.Media.TextWrapping.Wrap
|
||||
});
|
||||
_status.Text = periods.Count == 0 ? "Keine offenen Stunden im gewählten Zeitraum."
|
||||
: $"{periods.Count} offene Stunde(n) · Stand {DateTime.Now:HH:mm}";
|
||||
}
|
||||
catch (OperationCanceledException) when (_closed.IsCancellationRequested) { }
|
||||
catch (Exception ex) { _status.Text = $"Abruf fehlgeschlagen: {ex.Message}"; }
|
||||
finally { _filters.IsEnabled = true; }
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:vm="clr-namespace:LehrerApp.Desktop.ViewModels.Planning"
|
||||
xmlns:svc="clr-namespace:LehrerApp.Desktop.Services"
|
||||
xmlns:conv="clr-namespace:LehrerApp.Desktop.Converters"
|
||||
x:Class="LehrerApp.Desktop.Views.Planning.WebUntisTimetableImportDialog"
|
||||
x:DataType="vm:WebUntisTimetableImportViewModel"
|
||||
Title="Stundenplan aus WebUntis" Width="820" Height="650"
|
||||
@@ -18,7 +19,7 @@
|
||||
<DataTemplate x:DataType="svc:UntisTeacherDto"><TextBlock Text="{Binding DisplayName}"/></DataTemplate>
|
||||
</ComboBox.ItemTemplate>
|
||||
</ComboBox>
|
||||
<CalendarDatePicker Grid.Column="1" SelectedDate="{Binding WeekDate}"/>
|
||||
<CalendarDatePicker Grid.Column="1" SelectedDate="{Binding WeekDate, Converter={x:Static conv:DateTimeOffsetToDateTimeConverter.Instance}}"/>
|
||||
<Button Grid.Column="2" Content="Woche laden" Command="{Binding LoadCommand}" IsEnabled="{Binding !Busy}"/>
|
||||
</Grid>
|
||||
<ScrollViewer Grid.Row="2">
|
||||
|
||||
@@ -895,11 +895,15 @@
|
||||
IsVisible="{Binding PairingStatus, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
|
||||
</StackPanel>
|
||||
|
||||
<TextBlock Text="Konflikte" FontSize="14" FontWeight="SemiBold" Margin="0,10,0,0"
|
||||
IsVisible="{Binding SyncConflicts.Count}"/>
|
||||
<Grid ColumnDefinitions="*,Auto" Margin="0,10,0,0" IsVisible="{Binding SyncConflicts.Count}">
|
||||
<TextBlock Grid.Column="0" Text="Automatisch gelöste Konflikte" FontSize="14"
|
||||
FontWeight="SemiBold" VerticalAlignment="Center"/>
|
||||
<Button Grid.Column="1" Content="Protokoll leeren" FontSize="11" Padding="8,3"
|
||||
Command="{Binding ClearSyncConflictsCommand}"/>
|
||||
</Grid>
|
||||
<TextBlock FontSize="12" Opacity="0.6" TextWrapping="Wrap"
|
||||
IsVisible="{Binding SyncConflicts.Count}"
|
||||
Text="Ein anderes Gerät hat dieselbe Änderung gleichzeitig gemacht — hier steht, welche Seite jeweils übernommen wurde."/>
|
||||
Text="Ein anderes Gerät hat dieselbe Änderung gleichzeitig gemacht. Der Konflikt ist bereits automatisch aufgelöst; hier steht nur das Ergebnis zur Kontrolle."/>
|
||||
<ItemsControl ItemsSource="{Binding SyncConflicts}">
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate DataType="vm:SyncConflictListItem">
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
<Window xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
x:Class="LehrerApp.Desktop.Views.SplashWindow"
|
||||
Title="LehrerPlaner Organizer wird geladen"
|
||||
Width="450" Height="600"
|
||||
CanResize="False"
|
||||
ShowInTaskbar="False"
|
||||
WindowDecorations="None"
|
||||
WindowStartupLocation="CenterScreen">
|
||||
<Image Source="/Assets/SplashScreen.png"
|
||||
Stretch="UniformToFill"
|
||||
HorizontalAlignment="Stretch"
|
||||
VerticalAlignment="Stretch"/>
|
||||
</Window>
|
||||
@@ -0,0 +1,8 @@
|
||||
using Avalonia.Controls;
|
||||
|
||||
namespace LehrerApp.Desktop.Views;
|
||||
|
||||
public partial class SplashWindow : Window
|
||||
{
|
||||
public SplashWindow() => InitializeComponent();
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
<Window xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:vm="clr-namespace:LehrerApp.Desktop.ViewModels.Students"
|
||||
xmlns:conv="clr-namespace:LehrerApp.Desktop.Converters"
|
||||
x:Class="LehrerApp.Desktop.Views.Students.CreateLetterDialog"
|
||||
x:DataType="vm:CreateLetterDialogViewModel"
|
||||
Title="PDF-Brief erstellen" Width="580" Height="760"
|
||||
@@ -40,7 +41,7 @@
|
||||
</StackPanel>
|
||||
<StackPanel Grid.Column="2" Spacing="4">
|
||||
<TextBlock Text="Briefdatum *" FontSize="12" Opacity="0.7"/>
|
||||
<CalendarDatePicker SelectedDate="{Binding LetterDate}" HorizontalAlignment="Stretch"/>
|
||||
<CalendarDatePicker SelectedDate="{Binding LetterDate, Converter={x:Static conv:DateTimeOffsetToDateTimeConverter.Instance}}" HorizontalAlignment="Stretch"/>
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
<StackPanel Spacing="4">
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<Window xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:vm="clr-namespace:LehrerApp.Desktop.ViewModels.Students"
|
||||
xmlns:conv="clr-namespace:LehrerApp.Desktop.Converters"
|
||||
x:Class="LehrerApp.Desktop.Views.Students.WebUntisDocumentationComparisonDialog"
|
||||
x:DataType="vm:WebUntisDocumentationComparisonViewModel"
|
||||
Title="Klassenbucheinträge mit WebUntis abgleichen" Width="1150" Height="720"
|
||||
@@ -12,9 +13,9 @@
|
||||
Text="Nur eigene WebUntis-Einträge (Benutzer = eigener Login). Zeilen ohne automatische Zuordnung bitte manuell einem/einer Schüler*in zuweisen. Bereits lokal vorhandene Einträge sind gesperrt."/>
|
||||
</StackPanel>
|
||||
<StackPanel Grid.Row="1" Orientation="Horizontal" Spacing="8">
|
||||
<CalendarDatePicker SelectedDate="{Binding StartDate}"/>
|
||||
<CalendarDatePicker SelectedDate="{Binding StartDate, Converter={x:Static conv:DateTimeOffsetToDateTimeConverter.Instance}}"/>
|
||||
<TextBlock Text="bis" VerticalAlignment="Center"/>
|
||||
<CalendarDatePicker SelectedDate="{Binding EndDate}"/>
|
||||
<CalendarDatePicker SelectedDate="{Binding EndDate, Converter={x:Static conv:DateTimeOffsetToDateTimeConverter.Instance}}"/>
|
||||
<Button Content="Klassenbucheinträge laden" Command="{Binding LoadCommand}" IsEnabled="{Binding !Busy}"/>
|
||||
</StackPanel>
|
||||
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
<Window xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:vm="clr-namespace:LehrerApp.Desktop.ViewModels.UntisHub"
|
||||
x:Class="LehrerApp.Desktop.Views.UntisHub.UntisHubDialog"
|
||||
x:DataType="vm:UntisHubViewModel"
|
||||
Title="Untis-Hub"
|
||||
Width="900" Height="600" MinWidth="640" MinHeight="400"
|
||||
WindowStartupLocation="CenterOwner">
|
||||
|
||||
<Window.Styles>
|
||||
<Style Selector="TextBlock.duestatus">
|
||||
<Setter Property="Foreground" Value="{DynamicResource AppStatusOkBrush}"/>
|
||||
</Style>
|
||||
<Style Selector="TextBlock.duestatus.warning">
|
||||
<Setter Property="Foreground" Value="{DynamicResource AppStatusWarningBrush}"/>
|
||||
</Style>
|
||||
<Style Selector="TextBlock.duestatus.danger">
|
||||
<Setter Property="Foreground" Value="{DynamicResource AppStatusDangerBrush}"/>
|
||||
</Style>
|
||||
</Window.Styles>
|
||||
|
||||
<DockPanel Margin="20">
|
||||
<StackPanel DockPanel.Dock="Top" Spacing="10" Margin="0,0,0,14">
|
||||
<Grid ColumnDefinitions="*,Auto">
|
||||
<TextBlock Text="Untis-Hub" FontSize="22" FontWeight="SemiBold"/>
|
||||
<Button Grid.Column="1" Content="Aktualisieren" Click="OnRefreshClick"/>
|
||||
</Grid>
|
||||
<TextBlock Text="{Binding Status}" FontSize="12" Opacity="0.7" TextWrapping="Wrap"/>
|
||||
</StackPanel>
|
||||
|
||||
<DataGrid ItemsSource="{Binding Rows}" AutoGenerateColumns="False" IsReadOnly="True"
|
||||
GridLinesVisibility="Horizontal" BorderBrush="{DynamicResource AppCardBorderBrush}"
|
||||
BorderThickness="1" CanUserResizeColumns="True" CanUserSortColumns="True" RowHeight="46"
|
||||
IsVisible="{Binding IsAvailable}">
|
||||
<DataGrid.Columns>
|
||||
<DataGridTextColumn Header="Bereich" Binding="{Binding GroupName}" Width="1.3*"/>
|
||||
<DataGridTextColumn Header="Prüfung" Binding="{Binding JobLabel}" Width="1.3*"/>
|
||||
<DataGridTemplateColumn Header="Status" Width="1.1*">
|
||||
<DataGridTemplateColumn.CellTemplate>
|
||||
<DataTemplate x:DataType="vm:UntisHubRowViewModel">
|
||||
<TextBlock Text="{Binding DueLabel}" Classes="duestatus"
|
||||
Classes.warning="{Binding IsWarning}" Classes.danger="{Binding IsDanger}"
|
||||
VerticalAlignment="Center"/>
|
||||
</DataTemplate>
|
||||
</DataGridTemplateColumn.CellTemplate>
|
||||
</DataGridTemplateColumn>
|
||||
<DataGridTextColumn Header="Letztes Ergebnis" Binding="{Binding LastResultSummary}" Width="2*"/>
|
||||
<DataGridTemplateColumn Header="" Width="130">
|
||||
<DataGridTemplateColumn.CellTemplate>
|
||||
<DataTemplate x:DataType="vm:UntisHubRowViewModel">
|
||||
<Button Content="Jetzt prüfen" Click="OnCheckClick" DataContext="{Binding}"/>
|
||||
</DataTemplate>
|
||||
</DataGridTemplateColumn.CellTemplate>
|
||||
</DataGridTemplateColumn>
|
||||
</DataGrid.Columns>
|
||||
</DataGrid>
|
||||
|
||||
<TextBlock IsVisible="{Binding !IsAvailable}" Text="WebUntis ist nicht konfiguriert. Bitte zuerst in den Einstellungen hinterlegen."
|
||||
FontSize="14" Opacity="0.7" HorizontalAlignment="Center" VerticalAlignment="Center"/>
|
||||
</DockPanel>
|
||||
</Window>
|
||||
@@ -0,0 +1,58 @@
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Interactivity;
|
||||
using LehrerApp.Core.Interfaces;
|
||||
using LehrerApp.Core.Models;
|
||||
using LehrerApp.Core.Services;
|
||||
using LehrerApp.Desktop.Services;
|
||||
using LehrerApp.Desktop.ViewModels.UntisHub;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace LehrerApp.Desktop.Views.UntisHub;
|
||||
|
||||
public partial class UntisHubDialog : Window
|
||||
{
|
||||
public UntisHubDialog()
|
||||
{
|
||||
InitializeComponent();
|
||||
DataContext = new UntisHubViewModel(
|
||||
App.Services.GetRequiredService<UntisHubService>(),
|
||||
App.Services.GetRequiredService<IGroupRepository>(),
|
||||
App.Services.GetRequiredService<WebUntisIntegrationService>());
|
||||
}
|
||||
|
||||
private void OnRefreshClick(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
if (DataContext is UntisHubViewModel vm) vm.Load();
|
||||
}
|
||||
|
||||
private async void OnCheckClick(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
if (DataContext is not UntisHubViewModel vm || sender is not Button { DataContext: UntisHubRowViewModel row })
|
||||
return;
|
||||
var hub = App.Services.GetRequiredService<UntisHubService>();
|
||||
var today = DateOnly.FromDateTime(DateTime.Today);
|
||||
|
||||
switch (row.Kind)
|
||||
{
|
||||
case UntisHubJobKind.FehlzeitenKurz or UntisHubJobKind.FehlzeitenLang:
|
||||
var group = row.GroupId is { } id ? vm.FindGroup(id) : null;
|
||||
if (group is null) return;
|
||||
var schoolYears = App.Services.GetRequiredService<SchoolYearService>();
|
||||
var start = row.Kind == UntisHubJobKind.FehlzeitenKurz
|
||||
? today.AddDays(-30)
|
||||
: schoolYears.SchoolYearStart(schoolYears.CurrentSchoolYear());
|
||||
await UntisHubActions.RunFehlzeitenAsync(this, group, row.Kind, start, today, hub);
|
||||
break;
|
||||
case UntisHubJobKind.OffenePeriods:
|
||||
await UntisHubActions.RunOffenePeriodsAsync(this, hub);
|
||||
break;
|
||||
case UntisHubJobKind.Klassenbuchabgleich:
|
||||
await UntisHubActions.RunKlassenbuchAsync(this, hub);
|
||||
break;
|
||||
case UntisHubJobKind.Hausaufgabenabgleich:
|
||||
await UntisHubActions.RunHausaufgabenAsync(this, hub);
|
||||
break;
|
||||
}
|
||||
vm.Load();
|
||||
}
|
||||
}
|
||||
@@ -65,6 +65,30 @@
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<Border Background="{DynamicResource SystemControlBackgroundAltHighBrush}" CornerRadius="8" Padding="16">
|
||||
<StackPanel Spacing="10">
|
||||
<TextBlock Text="Unterrichtszeit nacherfassen" FontWeight="SemiBold" FontSize="14"/>
|
||||
<TextBlock Text="Arbeitstage der letzten 14 Tage, an denen laut Stundenplan Unterricht war, aber noch keine Unterrichtszeit erfasst wurde."
|
||||
FontSize="12" Opacity="0.7" TextWrapping="Wrap"/>
|
||||
<ItemsControl ItemsSource="{Binding MissingTeachingTimeEntries}">
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:TeachingTimeGapItem">
|
||||
<Grid ColumnDefinitions="*,Auto,Auto" Margin="0,3">
|
||||
<TextBlock Grid.Column="0" Text="{Binding DateDisplay}" FontSize="13" VerticalAlignment="Center"/>
|
||||
<TextBlock Grid.Column="1" Text="{Binding TimeDisplay}" FontSize="12" Opacity="0.65"
|
||||
Margin="12,0" VerticalAlignment="Center"/>
|
||||
<Button Grid.Column="2" Content="Erfassen" FontSize="12" Padding="10,4"
|
||||
Command="{Binding $parent[ItemsControl].((vm:TimeTrackingViewModel)DataContext).AddMissingTeachingTimeCommand}"
|
||||
CommandParameter="{Binding}"/>
|
||||
</Grid>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
<TextBlock Text="Keine Unterrichtszeit offen." Opacity="0.6" FontSize="12"
|
||||
IsVisible="{Binding !MissingTeachingTimeEntries.Count}"/>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<!-- Einträge / Nacherfassung (6.2.2) -->
|
||||
<Grid ColumnDefinitions="*,Auto,Auto">
|
||||
<TextBlock Grid.Column="0" Text="Einträge" FontWeight="SemiBold" FontSize="14" VerticalAlignment="Center"/>
|
||||
|
||||
@@ -18,11 +18,13 @@ public partial class TimeTrackingView : UserControl
|
||||
vm.OnAddEntry = () => ShowAddEntryDialog();
|
||||
vm.OnSuggestTeachingTime = (start, end) => ShowAddEntryDialog(prefillCategory: "Unterricht",
|
||||
prefillStart: start, prefillEnd: end);
|
||||
vm.OnAddMissingTeachingTime = item => ShowAddEntryDialog(prefillCategory: "Unterricht",
|
||||
prefillDate: item.Date, prefillStart: item.WindowStart, prefillEnd: item.WindowEnd);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<TimeEntry?> ShowAddEntryDialog(string? prefillCategory = null,
|
||||
TimeOnly? prefillStart = null, TimeOnly? prefillEnd = null)
|
||||
DateOnly? prefillDate = null, TimeOnly? prefillStart = null, TimeOnly? prefillEnd = null)
|
||||
{
|
||||
var owner = TopLevel.GetTopLevel(this) as Window;
|
||||
if (owner is null) return null;
|
||||
@@ -31,6 +33,7 @@ public partial class TimeTrackingView : UserControl
|
||||
.GetAll().Where(t => t.Status != WorkTaskStatus.Done).ToList();
|
||||
var vm = new AddTimeEntryDialogViewModel(tasks);
|
||||
if (prefillCategory is not null) vm.SelectedCategory = prefillCategory;
|
||||
if (prefillDate is { } date) vm.DateText = date.ToString("dd.MM.yyyy");
|
||||
if (prefillStart is { } s) vm.StartTimeText = s.ToString("HH:mm");
|
||||
if (prefillEnd is { } e) vm.EndTimeText = e.ToString("HH:mm");
|
||||
var dialog = new AddTimeEntryDialog { DataContext = vm };
|
||||
|
||||
@@ -33,6 +33,25 @@ public sealed class EventQueueTests
|
||||
Assert.Null(exception);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ClearConflicts_EntferntAlleKonflikte()
|
||||
{
|
||||
using var temp = new TempEventQueue();
|
||||
temp.Queue.AddConflict(new ConflictEntry
|
||||
{
|
||||
LocalEvent = MakeEvent(), RemoteEvent = MakeEvent(), Resolution = "LocalWon",
|
||||
});
|
||||
temp.Queue.AddConflict(new ConflictEntry
|
||||
{
|
||||
LocalEvent = MakeEvent(), RemoteEvent = MakeEvent(), Resolution = "RemoteWon",
|
||||
});
|
||||
|
||||
temp.Queue.ClearConflicts();
|
||||
|
||||
Assert.Empty(temp.Queue.GetUnreviewed());
|
||||
Assert.Equal(0, temp.Queue.ConflictCount());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetKnownServerSeq_UnbekannteEntitaet_LiefertNull()
|
||||
{
|
||||
|
||||
@@ -146,6 +146,7 @@ public class EventApplier(LiteDbContext db, byte[] syncKey, HttpClient? http = n
|
||||
Simple<SupervisionDuty>(context => context.SupervisionDuties);
|
||||
Simple<SubstitutionEntry>(context => context.SubstitutionEntries);
|
||||
Simple<CompetencyDomain>(context => context.CompetencyDomains);
|
||||
Simple<Vorgang>(context => context.Vorgaenge);
|
||||
|
||||
// Kaskaden-Fälle: dieselben internen LiteDbContext-Hilfsmethoden wie die jeweiligen
|
||||
// Repositories, damit die Kaskade nur an einer Stelle im Code existiert.
|
||||
|
||||
@@ -74,6 +74,7 @@ public class EventQueue : IDisposable
|
||||
conflict.Reviewed = true;
|
||||
_conflicts.Update(conflict);
|
||||
}
|
||||
public void ClearConflicts() => _conflicts.DeleteAll();
|
||||
|
||||
// ── Lokale Versionsverfolgung je Entität (optimistische Nebenläufigkeitskontrolle) ──────
|
||||
// Merkt sich pro Entität die zuletzt bekannte ServerSeq — Grundlage für SyncEvent.
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user