Compare commits
13
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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": [
|
||||
|
||||
@@ -200,6 +200,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,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;
|
||||
}
|
||||
@@ -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");
|
||||
|
||||
@@ -431,6 +431,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) =>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using LehrerApp.Core.Models;
|
||||
using LehrerApp.Core.Services;
|
||||
using LehrerApp.Desktop.Services;
|
||||
using LehrerApp.Desktop.ViewModels.ClassTeacher;
|
||||
using Xunit;
|
||||
@@ -228,6 +229,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 +777,306 @@ 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));
|
||||
}
|
||||
|
||||
// ── 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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -294,4 +294,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()
|
||||
{
|
||||
|
||||
@@ -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;
|
||||
@@ -38,32 +40,45 @@ public class App : Application
|
||||
|
||||
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
|
||||
{
|
||||
// Verschlüsselte Datenbank (13.3.4): Passwort abfragen, bevor die Datenbank
|
||||
// (und damit BuildServices, das sie öffnet) angefasst wird.
|
||||
if (AppBootstrapper.IsDbEncrypted())
|
||||
{
|
||||
var promptVm = new DbPasswordPromptViewModel(
|
||||
new DatabaseEncryptionService(), AppBootstrapper.ResolveDbPath());
|
||||
var promptWindow = new DbPasswordPromptWindow { DataContext = promptVm };
|
||||
promptVm.OnUnlocked = password =>
|
||||
{
|
||||
AppBootstrapper.DbPassword = password;
|
||||
StartMainApp(desktop, showImmediately: true);
|
||||
promptWindow.Close();
|
||||
};
|
||||
desktop.MainWindow = promptWindow;
|
||||
}
|
||||
else
|
||||
{
|
||||
StartMainApp(desktop, showImmediately: false);
|
||||
}
|
||||
// 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 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.
|
||||
if (AppBootstrapper.IsDbEncrypted())
|
||||
{
|
||||
var promptVm = new DbPasswordPromptViewModel(
|
||||
new DatabaseEncryptionService(), AppBootstrapper.ResolveDbPath());
|
||||
var promptWindow = new DbPasswordPromptWindow { DataContext = promptVm };
|
||||
promptVm.OnUnlocked = password =>
|
||||
{
|
||||
AppBootstrapper.DbPassword = password;
|
||||
StartMainApp(desktop, splash);
|
||||
promptWindow.Close();
|
||||
};
|
||||
desktop.MainWindow = promptWindow;
|
||||
promptWindow.Show();
|
||||
splash.Close();
|
||||
return;
|
||||
}
|
||||
|
||||
StartMainApp(desktop, splash);
|
||||
}
|
||||
|
||||
private static void StartMainApp(
|
||||
IClassicDesktopStyleApplicationLifetime desktop, bool showImmediately)
|
||||
IClassicDesktopStyleApplicationLifetime desktop, Window? windowToClose = null)
|
||||
{
|
||||
_serviceProvider = AppBootstrapper.BuildServices();
|
||||
Services = _serviceProvider;
|
||||
@@ -96,7 +111,8 @@ public class App : Application
|
||||
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>();
|
||||
@@ -195,6 +196,7 @@ 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>();
|
||||
@@ -315,6 +317,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;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -130,29 +133,63 @@ 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; } = [];
|
||||
|
||||
[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;
|
||||
/// 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 HasCategoryAggregates => CategoryAggregates.Count > 0;
|
||||
public bool HasOwnDocumentationEntries => OwnDocumentationEntries.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 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)
|
||||
@@ -162,12 +199,16 @@ public partial class ClassTeacherDetailsViewModel : ObservableObject
|
||||
Entries.Clear();
|
||||
AbsenceEntries.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 OnQuickRangeIndexChanged(int value)
|
||||
{
|
||||
var days = value switch { 0 => 0, 1 => 6, 2 => 29, _ => 6 };
|
||||
@@ -178,6 +219,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]
|
||||
@@ -195,17 +239,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 +270,139 @@ public partial class ClassTeacherDetailsViewModel : ObservableObject
|
||||
foreach (var row in ClassAbsenceDaySummaryRow.GroupByStudentAndDay(absences))
|
||||
AbsenceEntries.Add(row);
|
||||
|
||||
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(); }
|
||||
}
|
||||
|
||||
/// 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 = _rosterMatches.First(m => m.StudentId == d.StudentId).DisplayName;
|
||||
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) && 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;
|
||||
var name = rosterMatches.FirstOrDefault(m => m.StudentId == d.StudentId).DisplayName;
|
||||
return name is not null && UntisNameMatching.NamesMatch(name, studentFilter);
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private async Task AddOwnDocumentation()
|
||||
{
|
||||
if (OnEditOwnDocumentation is null) 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.
|
||||
@@ -248,5 +420,8 @@ public partial class ClassTeacherDetailsViewModel : ObservableObject
|
||||
OnPropertyChanged(nameof(HasEntries));
|
||||
OnPropertyChanged(nameof(HasAbsenceEntries));
|
||||
OnPropertyChanged(nameof(HasCategoryAggregates));
|
||||
OnPropertyChanged(nameof(ShowCategoryAggregates));
|
||||
OnPropertyChanged(nameof(HasOwnDocumentationEntries));
|
||||
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,
|
||||
@@ -214,11 +400,15 @@ 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; } = [];
|
||||
@@ -231,7 +421,11 @@ public partial class ClassTeacherOverviewViewModel : ObservableObject
|
||||
[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 +438,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 +459,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);
|
||||
@@ -289,9 +498,10 @@ public partial class ClassTeacherOverviewViewModel : ObservableObject
|
||||
|
||||
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 +509,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(); 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,59 +577,120 @@ 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);
|
||||
var todayAbsences = absenceDaysYear.Where(a => a.Date == today).ToList();
|
||||
var termStart = EstimateTermStart(absenceDaysYear, yearStart);
|
||||
var rawWeekdaysElapsed = CountSchoolWeekdays(termStart, today, []);
|
||||
var schoolDaysElapsed = CountSchoolWeekdays(termStart, today, holidaysTask.Result);
|
||||
var holidayWeekdaysExcluded = rawWeekdaysElapsed - schoolDaysElapsed;
|
||||
foreach (var row in ClassTeacherRosterRow.Build(studentsTask.Result, todayAbsences,
|
||||
classRegisterTask.Result, today, absenceDaysYear, schoolDaysElapsed,
|
||||
holidayWeekdaysExcluded, termStart)) Roster.Add(row);
|
||||
|
||||
StudentCount = Roster.Count;
|
||||
TodayAlertCount = Roster.Count(r => r.HasAbsenceToday);
|
||||
TodayUnexcusedCount = Roster.Count(r => r.IsUnexcused);
|
||||
LateCount = Roster.Count(r => r.IsLate);
|
||||
PresentCount = Roster.Count(r => !r.HasAbsenceToday);
|
||||
ExcusedAbsenceCount = Roster.Count(r => r.HasAbsenceToday && !r.IsLate && !r.IsUnexcused);
|
||||
UnexcusedAbsenceCount = Roster.Count(r => r.HasAbsenceToday && !r.IsLate && r.IsUnexcused);
|
||||
RecentClassRegisterCount = classRegisterTask.Result.Count;
|
||||
BuildTrend(absenceDaysYear, trendDays);
|
||||
BuildPatternNotices(absenceDaysYear, sevenDayStart);
|
||||
BuildWeekdayPatternNotices(absenceDaysYear);
|
||||
BuildAttendanceParticipationNotices();
|
||||
BuildOpenExcuses(absenceDaysYear, today);
|
||||
_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}";
|
||||
Status = $"{StudentCount} Schüler*innen · {TodayAlertCount} heute auffällig";
|
||||
ApplyRosterFilter();
|
||||
NotifySummary();
|
||||
}
|
||||
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, holidays);
|
||||
var holidayWeekdaysExcluded = rawWeekdaysElapsed - schoolDaysElapsed;
|
||||
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);
|
||||
TodayUnexcusedCount = Roster.Count(r => r.IsUnexcused);
|
||||
LateCount = Roster.Count(r => r.IsLate);
|
||||
PresentCount = Roster.Count(r => !r.HasAbsenceToday);
|
||||
ExcusedAbsenceCount = Roster.Count(r => r.HasAbsenceToday && !r.IsLate && !r.IsUnexcused);
|
||||
UnexcusedAbsenceCount = Roster.Count(r => r.HasAbsenceToday && !r.IsLate && r.IsUnexcused);
|
||||
RecentClassRegisterCount = recentRegisterEntries.Count;
|
||||
BuildTrend(absenceDaysYear, trendDays);
|
||||
BuildPatternNotices(absenceDaysYear, sevenDayStart);
|
||||
BuildWeekdayPatternNotices(absenceDaysYear);
|
||||
BuildAttendanceParticipationNotices();
|
||||
BuildVorgangRegressionNotices();
|
||||
BuildOwnDocumentationCounts();
|
||||
BuildOpenExcuses(absenceDaysYear, today);
|
||||
Status = $"{StudentCount} Schüler*innen · {TodayAlertCount} heute auffällig";
|
||||
ApplyRosterFilter();
|
||||
NotifySummary();
|
||||
}
|
||||
|
||||
[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()
|
||||
@@ -432,6 +738,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 +782,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 +1008,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.";
|
||||
}
|
||||
}
|
||||
@@ -364,16 +364,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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -176,7 +179,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();
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -45,9 +46,9 @@
|
||||
<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…"/>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -227,14 +227,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>
|
||||
@@ -464,6 +476,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 +510,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,89 @@
|
||||
<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}"
|
||||
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);
|
||||
}
|
||||
@@ -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}}"/>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -10,12 +10,26 @@ public sealed class WebUntisClient : IAsyncDisposable
|
||||
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
|
||||
private static readonly UTF8Encoding StrictUtf8 = new(false, true);
|
||||
private static readonly TimeSpan LogoutTimeout = TimeSpan.FromSeconds(2);
|
||||
// Feste (nicht zufällige) kurze Pause zwischen Login und erstem Report-Request, damit beides
|
||||
// nicht im Millisekundenabstand hintereinander passiert.
|
||||
private static readonly TimeSpan PostLoginDelay = TimeSpan.FromSeconds(1);
|
||||
|
||||
// WebUntis blockt Anfragen ohne die Header, die die eigene WebApp immer mitschickt, inzwischen
|
||||
// serverseitig mit HTTP 403. Header-Satz per "Copy as cURL" aus einem echten Browser-Request
|
||||
// 1:1 übernommen (kein X-Requested-With, WebUntis' Angular-Frontend sendet das nicht).
|
||||
private const string BrowserUserAgent =
|
||||
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.6.2 Safari/605.1.15";
|
||||
private readonly HttpClient _http;
|
||||
private readonly WebUntisOptions _options;
|
||||
private readonly SemaphoreSlim _sessionGate = new(1, 1);
|
||||
private readonly Timer _sessionExpiryTimer;
|
||||
private string? _sessionId;
|
||||
private int? _myTeacherUntisId;
|
||||
// Cookies, die WebUntis beim Login per Set-Cookie mitschickt (z.B. Tenant-Id, traceId) und die
|
||||
// die WebApp bei jedem weiteren Request 1:1 zurücksendet. JSESSIONID wird separat verwaltet
|
||||
// (siehe _sessionId), deshalb hier ausgeklammert.
|
||||
private string? _serverCookies;
|
||||
private string? _tenantId;
|
||||
private DateTimeOffset _sessionExpiresAt;
|
||||
private int _activeRequests;
|
||||
private bool _disposed;
|
||||
@@ -330,13 +344,14 @@ public sealed class WebUntisClient : IAsyncDisposable
|
||||
user = configuration.Username,
|
||||
password = configuration.Password,
|
||||
client = configuration.Client,
|
||||
}, null, cancellationToken);
|
||||
}, null, cancellationToken, CaptureServerCookies);
|
||||
_sessionId = OptionalString(result, "sessionId")
|
||||
?? throw new WebUntisException(
|
||||
$"WebUntis-Login fehlgeschlagen: {configuration.Host} hat für die Schulkennung " +
|
||||
$"„{configuration.School}“ keine Sitzung geliefert. Bitte insbesondere Server und " +
|
||||
"Schulkennung mit der WebUntis-Anmeldeseite vergleichen.");
|
||||
_myTeacherUntisId = OptionalInt(result, "personId");
|
||||
await Task.Delay(PostLoginDelay, cancellationToken);
|
||||
}
|
||||
|
||||
_activeRequests++;
|
||||
@@ -424,7 +439,7 @@ public sealed class WebUntisClient : IAsyncDisposable
|
||||
TimeSpan.FromMinutes(Math.Clamp(_options.SessionIdleTimeoutMinutes, 1, 30));
|
||||
|
||||
private async Task<JsonElement> RpcAsync(string method, object parameters, string? sessionId,
|
||||
CancellationToken cancellationToken)
|
||||
CancellationToken cancellationToken, Action<HttpResponseMessage>? onResponse = null)
|
||||
{
|
||||
var configuration = GetConfiguration();
|
||||
var uri = $"https://{configuration.Host}/WebUntis/jsonrpc.do?school={Uri.EscapeDataString(configuration.School)}";
|
||||
@@ -433,9 +448,11 @@ public sealed class WebUntisClient : IAsyncDisposable
|
||||
Content = JsonContent.Create(new { id = "lehrerapp-webuntis", method, @params = parameters, jsonrpc = "2.0" },
|
||||
options: JsonOptions),
|
||||
};
|
||||
if (sessionId is not null) request.Headers.Add("Cookie", $"JSESSIONID={sessionId}");
|
||||
ApplyBrowserHeaders(request, configuration.Host);
|
||||
if (sessionId is not null) request.Headers.Add("Cookie", SessionCookie(sessionId));
|
||||
|
||||
using var response = await SendAsync(request, TimeSpan.FromSeconds(20), cancellationToken);
|
||||
onResponse?.Invoke(response);
|
||||
var payload = await ReadJsonAsync(response, cancellationToken);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
throw new WebUntisException($"WebUntis RPC ({method}) fehlgeschlagen: {ErrorMessage(payload, response)}");
|
||||
@@ -521,10 +538,48 @@ public sealed class WebUntisClient : IAsyncDisposable
|
||||
{
|
||||
var request = new HttpRequestMessage(HttpMethod.Get, uri);
|
||||
if (acceptJson) request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
|
||||
request.Headers.Add("Cookie", $"JSESSIONID={sessionId}; schoolname=\"_{SchoolCookie()}\"");
|
||||
ApplyBrowserHeaders(request, GetConfiguration().Host);
|
||||
request.Headers.Add("Cookie", SessionCookie(sessionId));
|
||||
if (_tenantId is not null) request.Headers.Add("Tenant-Id", _tenantId);
|
||||
return request;
|
||||
}
|
||||
|
||||
private string SessionCookie(string sessionId)
|
||||
{
|
||||
var cookie = $"JSESSIONID={sessionId}; schoolname=\"_{SchoolCookie()}\"";
|
||||
return _serverCookies is null ? cookie : $"{cookie}; {_serverCookies}";
|
||||
}
|
||||
|
||||
// Sammelt Set-Cookie-Werte aus der Login-Antwort ein (z.B. Tenant-Id, traceId), die WebUntis'
|
||||
// WebApp danach bei jedem Request unverändert zurückschickt. JSESSIONID wird ausgeklammert, da
|
||||
// sie bereits separat über _sessionId verwaltet wird.
|
||||
private void CaptureServerCookies(HttpResponseMessage response)
|
||||
{
|
||||
if (!response.Headers.TryGetValues("Set-Cookie", out var setCookieHeaders)) return;
|
||||
var pairs = new List<string>();
|
||||
foreach (var header in setCookieHeaders)
|
||||
{
|
||||
var nameValue = header.Split(';', 2)[0].Trim();
|
||||
var name = nameValue.Split('=', 2)[0];
|
||||
if (name.Equals("JSESSIONID", StringComparison.OrdinalIgnoreCase)) continue;
|
||||
pairs.Add(nameValue);
|
||||
if (name.Equals("Tenant-Id", StringComparison.OrdinalIgnoreCase) &&
|
||||
nameValue.Split('=', 2) is [_, var value])
|
||||
_tenantId = value.Trim('"');
|
||||
}
|
||||
_serverCookies = pairs.Count > 0 ? string.Join("; ", pairs) : null;
|
||||
}
|
||||
|
||||
private static void ApplyBrowserHeaders(HttpRequestMessage request, string host)
|
||||
{
|
||||
request.Headers.UserAgent.ParseAdd(BrowserUserAgent);
|
||||
request.Headers.Referrer = new Uri($"https://{host}/WebUntis/embedded.do?showSidebar=true");
|
||||
request.Headers.Add("Accept-Language", "de-DE,de;q=0.9");
|
||||
request.Headers.Add("Sec-Fetch-Site", "same-origin");
|
||||
request.Headers.Add("Sec-Fetch-Mode", "cors");
|
||||
request.Headers.Add("Sec-Fetch-Dest", "empty");
|
||||
}
|
||||
|
||||
private async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, TimeSpan timeout,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
|
||||
@@ -1399,11 +1399,31 @@ Zeitraum-Datumsfelder mit diesem `DatePicker` auf `CalendarDatePicker` umgestell
|
||||
`DateTimeOffset?`-Bindung, kein Typwechsel nötig) — bereits das etablierte Muster für frei wählbare
|
||||
Einzeldatumsfelder in dieser Codebasis (`WithdrawStudentDialog`, `CreateLetterDialog`, 7.2.3), dort
|
||||
ohne dieses Problem, da ein Klick auf einen Kalendertag sofort übernimmt statt einen
|
||||
Bestätigungsschritt zu verlangen. Betroffen: [ClassTeacherRegisterView.axaml](LehrerApp.Desktop/Views/ClassTeacher/ClassTeacherRegisterView.axaml),
|
||||
Bestätigungsschritt zu verlangen. Betroffen: [WebUntisLessonAbsenceComparisonDialog.axaml](LehrerApp.Desktop/Views/Groups/WebUntisLessonAbsenceComparisonDialog.axaml),
|
||||
[ClassTeacherRegisterView.axaml](LehrerApp.Desktop/Views/ClassTeacher/ClassTeacherRegisterView.axaml),
|
||||
[ClassTeacherAbsencesView.axaml](LehrerApp.Desktop/Views/ClassTeacher/ClassTeacherAbsencesView.axaml),
|
||||
[WebUntisTimetableImportDialog.axaml](LehrerApp.Desktop/Views/Planning/WebUntisTimetableImportDialog.axaml)
|
||||
und [WebUntisDocumentationComparisonDialog.axaml](LehrerApp.Desktop/Views/Students/WebUntisDocumentationComparisonDialog.axaml).
|
||||
|
||||
**Nachtrag (September 2026, Nutzer-Feedback) — `CalendarDatePicker` blieb nach der Umstellung leer
|
||||
und warf beim Auswählen eine `InvalidCastException`:** Der erste Erklärungsversuch (Nullability der
|
||||
ViewModel-Eigenschaften) war falsch und hat das Problem nicht behoben. Per Reflection gegen die
|
||||
tatsächliche Avalonia-12.0.4-DLL verifiziert: `CalendarDatePicker.SelectedDate` (und
|
||||
`DisplayDateStart`/`DisplayDateEnd`) sind — vom WPF-Toolkit-Ursprung dieses Controls geerbt —
|
||||
`DateTime?`, nicht `DateTimeOffset?` wie beim eingebauten `DatePicker`. Avalonias
|
||||
`TypeUtilities.TryConvert`/`TryConvertImplicit` unterstützen keine automatische Konvertierung
|
||||
zwischen `DateTime` und `DateTimeOffset` (in keiner Richtung, nullbar oder nicht) — mit einem
|
||||
`DateTimeOffset`-Feld direkt gebunden bleibt `CalendarDatePicker` beim Anzeigen stumm leer und
|
||||
wirft beim Zurückschreiben (Klick auf einen Kalendertag) eine `InvalidCastException`. Das betraf
|
||||
nicht nur die fünf oben umgestellten Zeitraum-Felder, sondern genauso die bereits bestehenden
|
||||
`CalendarDatePicker`-Bindungen in `WithdrawStudentDialog` (`SelectedDate`, `DisplayDateStart`) und
|
||||
`CreateLetterDialog` (`LetterDate`) — nur bislang unbemerkt, weil dort niemand konsequent ein Datum
|
||||
ausgewählt hatte. Behoben mit einem neuen [DateTimeOffsetToDateTimeConverter](LehrerApp.Desktop/Converters/DateTimeOffsetToDateTimeConverter.cs),
|
||||
an allen elf betroffenen Bindings in den sieben Dialogen/Views eingehängt (`Converter={x:Static
|
||||
conv:DateTimeOffsetToDateTimeConverter.Instance}`). End-to-End mit einer echten kompilierten
|
||||
Ansicht in einer Avalonia.Headless-Instanz gegengeprüft (Anzeige des Anfangswerts und
|
||||
Zurückschreiben nach Kalenderauswahl, beides ohne Exception).
|
||||
|
||||
**Nachtrag zu 4.3, Fehlzeiten je Unterricht (August 2026):** Der ursprüngliche Fehlzeitenabgleich
|
||||
rief `getTimetableWithAbsences` ohne Element auf und bekam damit den kompletten Lehrer-Stundenplan
|
||||
zurück (einmal pro Kursmitglied, siehe damalige Ineffizienz-Korrektur) — das erfordert mehr
|
||||
@@ -1760,6 +1780,155 @@ eigenen Unterricht abfragt und deshalb mit den regulären Lehrkraft-Rechten funk
|
||||
Fehlzeiten-Einträge an Tagen mit tatsächlichem Unterricht) — von echter Anwesenheit ist das aus
|
||||
dem WebUntis-Fehlzeitenbericht allein nicht unterscheidbar, dafür bräuchte es Daten darüber, ob
|
||||
für eine Stunde überhaupt eine Anwesenheitsprüfung stattfand.
|
||||
- [x] **"Klassenlehrer"-Feature — Klassenbuch-Tab als Dokumentations-Hub (September 2026):**
|
||||
Nutzer-Feedback: der Klassenbuch-Tab zeigte bisher nur den WebUntis-Bericht anderer Lehrkräfte —
|
||||
die eigene Dokumentation zu Schüler*innen der Klasse (Gespräche, Vorkommnisse, Förderpläne, …)
|
||||
hatte dort keinen Platz, obwohl der reale Arbeitsablauf beides mischt. Erwogen wurde ein Merge
|
||||
beider Datensätze in eine Liste, verworfen: `ClassTeacherClassRegisterRow` (flache,
|
||||
ID-lose WebUntis-Berichtszeile) und `Documentation` (LiteDB-Entität mit typspezifischen
|
||||
Unterdaten, Anhängen, Vertraulichkeits-Freigabe, Tags) sind strukturell zu verschieden — ein
|
||||
Merge hätte genau die vom Nutzer selbst befürchtete fummelige Konverter-Klasse gebraucht, für
|
||||
zwei Dinge, die inhaltlich verschieden sind (was eine Kollegin notiert hat vs. was ich selbst
|
||||
dokumentiert habe).
|
||||
Stattdessen ein Umschalter `ClassTeacherDetailsViewModel.ShowOwnDocumentation` zwischen
|
||||
"Klassenbuch (Untis)" und "Eigene Dokumentation", mit Zahlen-Badges links/rechts (Gesamtzahl je
|
||||
Quelle, in Rot hervorgehoben bei kritischen WebUntis-Kategorien bzw. eigenen Einträgen mit dem
|
||||
Tag "Kritisch"/`IsDraft`-Status "Nacharbeiten") — dieselben Badges zusätzlich klein am
|
||||
"Klassenbuch öffnen"-Button der Übersicht (`ClassTeacherOverviewViewModel.BuildOwnDocumentationCounts`).
|
||||
Die eigene Dokumentation gibt es nur lokal, nicht in WebUntis — `Documentation.StudentId` lässt
|
||||
sich der (rein WebUntis-basierten) Klasse deshalb nur über denselben Namensabgleich zuordnen, den
|
||||
die Übersicht schon für ihr Roster nutzt (`ClassTeacherOverviewViewModel.MatchStudent`). Liste,
|
||||
Anlegen/Bearbeiten/Löschen und Vertraulichkeits-Freigabe sind eins zu eins vom Gruppen-Tab
|
||||
übernommen (`DocumentationItem`, `DocumentationDialogViewModel`/`DocumentationDialog`,
|
||||
`ConfirmDialog`) statt neu gebaut. Die Filter-/Sortierlogik steckt in der reinen, ohne
|
||||
Repository-Zugriff testbaren `ClassTeacherDetailsViewModel.FilterOwnDocumentation` (gleiches
|
||||
Muster wie `ClassTeacherRosterRow.Build`), damit Anlegen/Bearbeiten/Löschen eines eigenen
|
||||
Eintrags nicht auch die WebUntis-Berichte neu abruft.
|
||||
**Bewusst zurückgestellt:** Kategorien-Chipreihe (bisher nur WebUntis-`CategoryGroup`) auf eigene
|
||||
Tags erweitern; "Gespräch begleiten" (Elternanruf-Durchführung) aus dem Gruppen-Tab wurde hier
|
||||
nicht übernommen, da dieser Hub bewusst schlank gehalten wurde.
|
||||
- [x] **"Klassenlehrer"-Feature — Fehlquote behandelte Verspätung wie Schwänzen, Roster-Sortierung
|
||||
ignorierte Muster über "heute" hinaus (September 2026):** Zwei verwandte Nutzer-Feedback-Punkte
|
||||
zur selben Ursache: kein Unterschied zwischen "10× 5 Min. verspätet" und "10× ganztägig
|
||||
unentschuldigt gefehlt".
|
||||
- **Fehlquote:** `ClassTeacherRosterRow.YearAbsenceDayCount` (Zähler von
|
||||
`YearAbsenceRatePercent`, angezeigt als "X % Fehlzeit seit Schuljahresbeginn") zählte bisher
|
||||
jeden Tag mit irgendeinem Fehlzeiten-Eintrag gleich, egal ob ganztägig unentschuldigt oder nur
|
||||
5 Minuten verspätet. Jetzt dieselbe überschneidungsfreie Kategorisierung wie im Wochentrend
|
||||
(`BuildTrend`: Unentschuldigt vor Verspätet vor Entschuldigt) — reine Verspätung (nicht auch
|
||||
unentschuldigt) zählt nicht mehr in die Quote, sondern separat in neuem
|
||||
`YearLateDayCount`/`YearExcusedDayCount`. Ist jemand unentschuldigt UND verspätet, bleibt das
|
||||
der schwerwiegendere Fall und zählt weiter voll in die Quote (gleiche Rangfolge wie
|
||||
`AttentionRank`/`StatusKind`). `YearSummaryLabel`/`-Tooltip` zeigen reine Verspätungstage jetzt
|
||||
als eigene Zeile statt sie in die Prozentzahl einzurechnen.
|
||||
- **Roster-Sortierung:** die bisherige "Auffällig"-Ansicht filtert rein auf den Heute-Snapshot
|
||||
(`HasAbsenceToday`) — ein/e Schüler*in mit 5 Tagen unentschuldigter Vorgeschichte, aber heute
|
||||
zufällig anwesend, verschwand komplett aus der Liste, während ein einmaliger Erstfehltag ganz
|
||||
oben landete, sobald er der/die einzige "heute Auffällige" war. Neuer vierter Reiter
|
||||
"Gesamtbild" (`SelectedRosterFilter == 3`, jetzt der beim Öffnen aktive Standard-Reiter statt
|
||||
"Auffällig") sortiert stattdessen nach neuem `ClassTeacherRosterRow.PatternScore` — kumulierte,
|
||||
gewichtete Summe über das ganze bisherige Schuljahr (unentschuldigt × 3,0, entschuldigt
|
||||
abwesend × 1,0, nur verspätet × 0,5; feste statt einstellbare Gewichte, Nutzer-Entscheidung),
|
||||
bewusst ohne zeitliche Abklingkurve (für die genannten Fälle reicht die reine Summe). Die drei
|
||||
bestehenden Reiter (Auffällig/Klassenbuch/Alle) blieben unverändert.
|
||||
- **Nachtrag (September 2026):** `PatternScore` bezog zunächst nur Fehlzeiten ein — Nutzer-Feedback:
|
||||
Klassenbucheinträge anderer Lehrkräfte (fehlende Hausaufgaben, schlechte Mitarbeit, im
|
||||
Extremfall eine Suspendierung) sollen ebenfalls einfließen, "wenn die negativen Einträge durch
|
||||
die Decke gehen". `ClassTeacherRosterRow` bekam dafür `YearNegativeClassRegisterCount`/
|
||||
`HasSuspensionEntry`/`ClassRegisterScoreComponent`: jeder Klassenbucheintrag mit
|
||||
`CategoryGroup` "Negativ" zählt mit Gewicht 1,0 (ein einzelner Eintrag fällt kaum ins Gewicht,
|
||||
erst die Häufung), `CategoryName`/`Text`-Schlüsselwörter "hausaufgabe"/"mitarbeit" zählen mit
|
||||
zusätzlichem Bonus 1,5, das Schlüsselwort "suspendier" mit Ausreißer-Gewicht 15,0 (schießt den
|
||||
Score sofort deutlich über jedes normale Muster, wie gewünscht) — alles einfache
|
||||
case-insensitive `Contains`-Prüfungen, gleiches Muster wie die bestehende "Negativ"/"verspät"-
|
||||
Erkennung. Dafür musste `ClassTeacherOverviewViewModel.Load` den Klassenbuch-Abruf von "letzte
|
||||
7 Tage" auf "seit Schuljahresbeginn" ausweiten (`_cache.GetClassRegisterEventsAsync(...,
|
||||
yearStart, today)`, dieselbe dauerhaft gecachte "kalte Historie" wie bei den Fehlzeiten) — die
|
||||
bestehenden 7-Tage-Badges (`RecentClassRegisterCount`, `HasRecentClassRegisterEntry`,
|
||||
"Klassenbuch"-Reiter) filtern seitdem clientseitig aus genau diesem einen Abruf heraus, statt
|
||||
weiterhin separat und enger abzurufen, damit sich an ihrem Verhalten nichts ändert.
|
||||
`ClassTeacherRosterRow.Build` bekam dafür einen neuen optionalen Parameter
|
||||
`yearClassRegisterEntries`, der ohne Angabe auf den bisherigen `recentClassRegisterEntries`-
|
||||
Parameter zurückfällt (Rückwärtskompatibilität für bestehende Aufrufer/Tests).
|
||||
**Bewusst zurückgestellt:** einstellbare Gewichte (Schieberegler o.ä.) für `PatternScore` —
|
||||
Nutzer-Entscheidung für feste Werte zunächst, lässt sich bei Bedarf später ergänzen.
|
||||
- [x] **"Klassenlehrer"-Feature — "Vorgang": Fallmappe für Klassenbuch- und Dokumentationseinträge
|
||||
(September 2026):** Nutzer-Feedback: manche Probleme (Schuleschwänzen, wiederholte Hausaufgaben-
|
||||
Verweigerung, ein Konflikt) müssen intensiver abgearbeitet werden, dafür fehlte ein Ort, an dem
|
||||
Titel, Problembeschreibung, Schlagwörter, alle dazugehörigen `Documentation`-Einträge und die
|
||||
relevanten Klassenbucheinträge gebündelt einsehbar sind (auch für den Nachweis der Maßnahmen
|
||||
gegenüber der Schulleitung) — statt über datumsgefilterte Listen verstreut.
|
||||
Neues, eigenständiges Modell `Vorgang` (`LehrerApp.Core/Models/Vorgang.cs`): Titel, Beschreibung,
|
||||
`StudentIds` (eine/r oder mehrere), freie `Tags` (`VorgangTagDisplay.Suggestions`: Absentismus,
|
||||
Hausaufgaben, Verspätungen, Konflikte, Mitarbeit, Elternkontakt, Eskalation), Status Offen/
|
||||
Geschlossen, `DocumentationIds` (**nur Referenzen, kein gecachter Inhalt trotz Nutzer-Idee einer
|
||||
"Uploadversionsnummer"** — es gibt keine app-seitig nutzbare Versionsnummer pro Objekt, nur einen
|
||||
internen Server-Sequenzzähler in `EventQueue`, und die Datenmenge pro Lehrkraft ist klein genug,
|
||||
dass ein Cache nur Invalidierungs-Komplexität eingeführt hätte; gleicher Präzedenzfall wie die
|
||||
bewusst verworfene Plan-ID-Gruppierung für Förderpläne, 5.3.3), sowie eingefrorene
|
||||
`ClassRegisterEntries` (**Werte-Kopie statt Referenz** — `UntisForeignClassRegisterEventDto`/
|
||||
`UntisClassRegisterCacheEntry` haben keine über einen Cache-Refresh hinweg stabile ID). Sync-fähig
|
||||
nach demselben Muster wie `Documentation` (`VorgangRepository.Save`/`Delete` feuern `db.OnChange`,
|
||||
`EventApplier` nutzt dafür den generischen `Simple<Vorgang>`-Helfer statt eines Kaskaden-
|
||||
Sonderfalls, da kein Hard-Delete existiert).
|
||||
Neuer vierter Tab "Vorgänge" in der Klassenlehreransicht (`ClassTeacherCasesViewModel`/
|
||||
`ClassTeacherCasesView`, Geschwister-ViewModel zu `DetailsTab`, löst wie dieses die Untis-Roster-
|
||||
Namen der Klasse auf lokale `Student`s auf — bewusst ein dritter, nicht geteilter Abgleich nach
|
||||
demselben kleinen Muster, siehe `ClassTeacherOverviewViewModel.MatchStudent`). Jede Fallmappe ist
|
||||
eine aufklappbare Karte (`VorgangItem`, gleiches Reveal-Muster wie `DocumentationItem`) mit
|
||||
verknüpfter Dokumentation (neu anlegen-und-verknüpfen über den bestehenden `DocumentationDialog`,
|
||||
oder bestehende Einträge der Vorgangs-Schüler*innen nachträglich verknüpfen/entfernen) und
|
||||
angehefteten Klassenbucheinträgen. Anheften läuft vom Klassenbuch-Tab aus: Rechtsklick auf eine
|
||||
Zeile → Kontextmenü "→ An Vorgang anheften" (bewusst kein `DataGridTemplateColumn`-Button, da ein
|
||||
`$parent[DataGrid]`-Bindungspfad in Zellen-Templates in diesem Codebasis-Stand nirgends erprobt
|
||||
ist — `DataGrid.SelectedItem` zweigleisig gebunden + `ContextMenu.CommandParameter` ist das
|
||||
bereits an anderer Stelle (`GroupDetailView`) bewährte Muster für Zeilen-Aktionen), öffnet einen
|
||||
Auswahldialog (bestehenden offenen, zur/zum Schüler*in passenden Vorgang wählen oder per Titel
|
||||
einen neuen anlegen). `ClassTeacherDetailsViewModel` bekommt dafür `ClassTeacherCasesViewModel`
|
||||
per Konstruktor-Injection (Geschwister-VM-Zugriff für reine Datenabfragen statt Func-Hook, da
|
||||
keine UI im Spiel ist).
|
||||
- **Nachtrag:** Nutzer-Feedback — verknüpfte Dokumentation ließ sich nur als kurze Karte sehen,
|
||||
zum Nachlesen musste man in den Schülerdatensatz wechseln. Statt eines neuen Seitenpanels
|
||||
(`DockPanel`/`Dock="Right"`) oder eines eigenen Lese-Fensters wird der bereits vorhandene
|
||||
`DocumentationDialog` wiederverwendet: "Bearbeiten"-Button an jedem verknüpften bzw. noch
|
||||
verknüpfbaren Eintrag (nur sichtbar nach `IsRevealed`, gleiche Vertraulichkeits-Gate-Konvention
|
||||
wie im Gruppen-Tab) öffnet ihn zum vollständigen Lesen/Bearbeiten, ohne den Vorgänge-Tab zu
|
||||
verlassen. `VorgangItem` bekam dafür einen weiteren Callback (`onEditDocumentation`, gleiches
|
||||
Konstruktor-Callback-Muster wie `onLink`/`onUnlink`), `ClassTeacherCasesViewModel.EditDocumentation`
|
||||
speichert das Ergebnis direkt über `IDocumentationRepository`, unabhängig davon, welche Vorgänge
|
||||
den Eintrag verlinken.
|
||||
**"Runde 2" (September 2026, direkt im Anschluss umgesetzt):** einstellbare Gewichte plus
|
||||
Score-Dämpfung nach geschlossenem Vorgang inkl. Rückfall-Warnung — siehe eigener Eintrag unten.
|
||||
- [x] **"Klassenlehrer"-Feature — konfigurierbare Gewichtung + Score-Dämpfung/Rückfall-Warnung nach
|
||||
geschlossenem Vorgang (September 2026, "Runde 2"):**
|
||||
- **Gewichtung:** `PatternScoreWeights`/`PatternScoreSettingsService`
|
||||
(`LehrerApp.Core/Services/PatternScoreSettingsService.cs`, gleiches JSON-in-appData-Muster wie
|
||||
`WorkloadSettingsService`/`DashboardSettingsService`) ersetzen die bisherigen festen Konstanten.
|
||||
Neuer Dialog `PatternScoreWeightsDialog` (Vorbild `ParticipationAspectsDialog`: `NumericUpDown`
|
||||
pro Gewicht), erreichbar über einen neuen "⚙ Gewichtung"-Button neben dem "Gesamtbild"-Filter;
|
||||
beim Schließen wird `LoadCommand` erneut ausgeführt, damit die neue Gewichtung sofort wirkt.
|
||||
- **Dämpfung (Nutzer-Entscheidung: bereichsbezogen statt komplett):** die Schlagwörter eines
|
||||
geschlossenen Vorgangs bestimmen per Schlüsselwort-Erkennung (`ClassTeacherRosterRow.MatchDomains`,
|
||||
gleiche Contains-Konvention wie die bestehende "Negativ"/"verspät"-Erkennung), welcher der drei
|
||||
`VorgangScoreDomain`-Bereiche (Attendance/Lateness/Classbook) gedämpft wird — Ereignisse vor dem
|
||||
Schließ-Datum zählen ab dann nicht mehr in `PatternScore`. Kein Schlagwort erkannt (z.B. reines
|
||||
"Elternkontakt") → vorsichtshalber alle drei Bereiche gedämpft, damit ein geschlossener Vorgang
|
||||
nie folgenlos bleibt. **Bewusst unverändert:** die faktische "% Fehlzeit"-Quote
|
||||
(`YearAbsenceDayCount`/`YearAbsenceRatePercent`) — nur die `PatternScore`-Sortierung wird
|
||||
gedämpft, nicht die für Zeugnis/Attestpflicht relevante Historie.
|
||||
- **Rückfall-Warnung:** neues `RegressionScore`/`RegressionVorgangTitle` je Roster-Zeile — der
|
||||
Score-Anteil, der seit dem Schließ-Datum des zuletzt geschlossenen Vorgangs (nur in dessen
|
||||
eigenen Bereichen) neu hinzugekommen ist. `ClassTeacherOverviewViewModel.BuildVorgangRegressionNotices`
|
||||
(Vorbild `BuildAttendanceParticipationNotices`) meldet ab einer Schwelle von 3,0 (≈ 1
|
||||
unentschuldigter Tag, Nutzer-Entscheidung: "erst ab spürbarem Muster", nicht bei jeder
|
||||
Kleinigkeit) einen Danger-Hinweis "Nach Bearbeitung von '{Titel}' erneut auffällig" in die
|
||||
bestehende "Muster erkannt"-Seitenleiste, inkl. vorhandenem "+"-Wiedervorlage-Button — keine
|
||||
neue UI dafür nötig.
|
||||
- `ClassTeacherRosterRow.Build` bekam dafür zwei weitere optionale Parameter (`weights`,
|
||||
`closedVorgaengeByNameKey`, beide mit Default = heutiges Verhalten, volle Rückwärtskompatibilität
|
||||
für bestehende Tests/Aufrufer). `ClassTeacherOverviewViewModel.Load` löst die geschlossenen
|
||||
Vorgänge je Klassen-Roster-Namen über denselben Namensabgleich wie die übrigen `Build*`-Methoden
|
||||
dieser Klasse auf (`IVorgangRepository.GetByStudent`).
|
||||
|
||||
### 4.4 Wochen-/Tagesansicht
|
||||
- [x] **4.4.1** Kalenderansicht über alle Gruppen: Woche und Tag — siehe Nachtrag zu 4.3
|
||||
@@ -2153,6 +2322,22 @@ folgenden Punkte gehören direkt in `LehrerApp.Desktop`:
|
||||
den vollen Verlaufsplan-Editor öffnen müssen. Neue kleine
|
||||
`TeachingModeHomeworkViewModel : ObservableObject`, da `TeachingModeViewModel` selbst (wie
|
||||
`LessonViewerViewModel`) keine Bindable-Basisklasse hat.
|
||||
|
||||
**Nachtrag (September 2026, Nutzer-Feedback) — zweite Sitzung bei zusätzlicher Stunde am
|
||||
selben Tag:** `SelectOrCreateSessionForLesson` prüfte bisher ausschließlich auf eine bereits
|
||||
exakt mit `lesson.Id` verknüpfte Sitzung. Kam neben einer Doppelstunde noch eine dritte
|
||||
Stunde desselben Tages hinzu (eigene `Lesson`-Id, z.B. durch Vertretung), fand die Methode
|
||||
keinen Treffer und legte eine zweite, unabhängige Mitarbeitssitzung für denselben Tag an —
|
||||
dadurch zeigten Unterrichtsmodus und der normale Mitarbeit-Tab der Gruppe (der über
|
||||
`ParticipationTabViewModel.LoadSessions()` unabhängig davon die nach Datum jüngste Sitzung
|
||||
wählt) je nach Reihenfolge unterschiedliche Sitzungen für denselben Tag. Nutzer-Entscheidung:
|
||||
strikt nach Datum statt nach Stunde gehen — eine zusätzliche Stunde am selben Tag (Vertretung
|
||||
o.ä.) bekommt keine eigene Sitzung, sondern die Lehrkraft passt die Einschätzung der bereits
|
||||
bestehenden Sitzung des Tages an. `SelectOrCreateSessionForLesson` fällt jetzt, wenn keine
|
||||
exakt verknüpfte Sitzung existiert, zusätzlich auf jede andere Sitzung desselben Tages
|
||||
zurück (Verhalten analog zu `EnsureTodaySession`, das schon immer pro Tag statt pro Stunde
|
||||
arbeitet). Dieselbe Lücke bestand in `PlanningTabViewModel.CreateParticipationSession`
|
||||
("Sitzung aus der Stunde erstellen", 3.3.1) — gleicher Fallback dort ergänzt.
|
||||
- [x] **4.5.24** Popup-Menü im Wochenraster für Unterrichtsansicht/Sitzplan/Planung/Planungsviewer
|
||||
(August 2026, Nutzer-Feedback, zweite Runde). Die erste Fassung hatte das Problem am
|
||||
falschen Ort gelöst — ein Dropdown in der "Heute"-**Tagesliste** (unten angedockt), obwohl
|
||||
@@ -2428,8 +2613,27 @@ CSV-Exports der Auswertung (6.3.3) über die gemeinsame Export-Infrastruktur aus
|
||||
genau der manuelle "Mail raussuchen und archivieren"-Schritt entfällt damit. Echtes Löschen
|
||||
bleibt eine separate, bewusst zusätzliche Aktion (eigener Button, nicht automatisch beim
|
||||
Archivieren mitgemacht).
|
||||
- **Architektur-Vorbild:** wie `LehrerApp.WebUntis` ein eigenständiger, serverunabhängiger
|
||||
Client — neues `LehrerApp.Mail`-Projekt (IMAP-Verbindung + Nachrichtenliste + Move/Delete,
|
||||
- **Bevorzugt zu prüfen — eM Client 11 MCP-Support (Stand 2026-09-02, laut Ankündigung des
|
||||
Herstellers noch in der Beta):** eM Client kündigt für v11 einen MCP-Server an ("find
|
||||
accounts and folders, search and retrieve emails or conversations, perform selected mailbox
|
||||
actions, draft and send messages, and more"). Klingt breit genug, um genau die hier
|
||||
benötigten Primitive abzudecken (Ordner ansprechen, Mails lesen, Mailbox-Aktion ausführen)
|
||||
— und würde, wenn der Funktionsumfang das hergibt, die IMAP-Variante unten komplett
|
||||
überflüssig machen: LehrerApp bräuchte dann **gar keine eigenen Postfach-Zugangsdaten**,
|
||||
sondern spräche als MCP-Client die bereits eingeloggte eM-Client-Instanz auf demselben
|
||||
Gerät an. Löst den Mehrgeräte-Fall (siehe unten) sogar eleganter als der IMAP-Ansatz: die
|
||||
Aktion ist automatisch nur dort verfügbar, wo eM Client lokal läuft und angemeldet ist —
|
||||
ganz ohne eigenen `SourceMailAccount`-Abgleich. Vor einer Festlegung zu klären, sobald die
|
||||
Beta zugänglich ist: (1) deckt "perform selected mailbox actions" wirklich gezieltes
|
||||
Verschieben in einen bestimmten Zielordner ab, oder nur eine feste Auswahl (z.B.
|
||||
Lesen/Flag/Junk)? (2) welcher Transport (lokaler Prozess über stdio, lokaler HTTP-Port?) und
|
||||
gibt es dafür schon eine nutzbare .NET/C#-MCP-Client-Bibliothek? (3) muss eM Client dafür im
|
||||
Vordergrund laufen oder reicht ein im Hintergrund laufender Prozess? (4) Stabilität der
|
||||
Schnittstelle vor Verlassen der Beta. Solange das offen ist, bleibt der IMAP-Weg unten der
|
||||
Fallback-Plan — funktioniert unabhängig vom Mail-Programm und auch, falls der Nutzer eM
|
||||
Client je wechselt.
|
||||
- **Fallback-Architektur (IMAP via MailKit):** wie `LehrerApp.WebUntis` ein eigenständiger,
|
||||
serverunabhängiger Client — neues `LehrerApp.Mail`-Projekt (IMAP-Verbindung + Nachrichtenliste + Move/Delete,
|
||||
via `MailKit` — .NET hat kein natives IMAP in der BCL, `System.Net.Mail` deckt nur SMTP ab;
|
||||
MailKit ist der De-facto-Standard, MIT-lizenziert, Eintrag in `Directory.Packages.props`),
|
||||
Desktop bindet es analog zu `LehrerApp.WebUntis` direkt ein. Zugangsdaten
|
||||
|
||||
Reference in New Issue
Block a user