Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
15463bc292 | ||
|
|
987b49e761 | ||
|
|
fb7a015b42 | ||
|
|
09e97efb61 | ||
|
|
d297f9f109 | ||
|
|
de6d4236a2 | ||
|
|
edf7510765 |
@@ -200,6 +200,15 @@ public interface IDocumentationRepository
|
|||||||
/// Entfernt den Eintrag endgültig — nur für die Löschfristen-Bereinigung (5.4.2).
|
/// Entfernt den Eintrag endgültig — nur für die Löschfristen-Bereinigung (5.4.2).
|
||||||
void HardDelete(Guid id);
|
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
|
public interface IWorkTaskRepository
|
||||||
{
|
{
|
||||||
List<WorkTask> GetByStatus(WorkTaskStatus status);
|
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;
|
||||||
|
}
|
||||||
@@ -193,4 +193,21 @@ public sealed class ChangeHookCascadeTests
|
|||||||
var call = Assert.Single(calls);
|
var call = Assert.Single(calls);
|
||||||
Assert.Equal((nameof(Documentation), "Delete"), call);
|
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));
|
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 ──────────────────────────────────────────────────────
|
// ── LessonRepository ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|||||||
@@ -51,6 +51,7 @@ public class LiteDbContext : IDisposable
|
|||||||
public ILiteCollection<Unit> Units => _db.GetCollection<Unit>("units");
|
public ILiteCollection<Unit> Units => _db.GetCollection<Unit>("units");
|
||||||
public ILiteCollection<Lesson> Lessons => _db.GetCollection<Lesson>("lessons");
|
public ILiteCollection<Lesson> Lessons => _db.GetCollection<Lesson>("lessons");
|
||||||
public ILiteCollection<Documentation> Documentation => _db.GetCollection<Documentation>("documentation");
|
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 ILiteStorage<string> Attachments => _db.GetStorage<string>("attachments", "attachments_chunks");
|
||||||
public ILiteCollection<WorkTask> Tasks => _db.GetCollection<WorkTask>("tasks");
|
public ILiteCollection<WorkTask> Tasks => _db.GetCollection<WorkTask>("tasks");
|
||||||
public ILiteCollection<TimeEntry> TimeEntries => _db.GetCollection<TimeEntry>("time_entries");
|
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 class WorkTaskRepository(LiteDbContext db) : IWorkTaskRepository
|
||||||
{
|
{
|
||||||
public List<WorkTask> GetByStatus(WorkTaskStatus s) =>
|
public List<WorkTask> GetByStatus(WorkTaskStatus s) =>
|
||||||
|
|||||||
@@ -228,6 +228,198 @@ public sealed class ClassTeacherViewModelsTests
|
|||||||
Assert.Null(row.YearSummaryTooltip);
|
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]
|
[Fact]
|
||||||
public void RosterBuild_OrdnetJahresfehlzeitenAuchOhneExternKeyUeberNamenZu()
|
public void RosterBuild_OrdnetJahresfehlzeitenAuchOhneExternKeyUeberNamenZu()
|
||||||
{
|
{
|
||||||
@@ -584,4 +776,117 @@ public sealed class ClassTeacherViewModelsTests
|
|||||||
|
|
||||||
private static UntisStudentRosterCacheEntry Student(int? externKey, string displayName) =>
|
private static UntisStudentRosterCacheEntry Student(int? externKey, string displayName) =>
|
||||||
new() { ClassName = "6a", ExternKey = externKey, DisplayName = 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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -294,4 +294,44 @@ public class PlanningTabViewModelTests
|
|||||||
Assert.Single(sessions.GetByGroup(groupId));
|
Assert.Single(sessions.GetByGroup(groupId));
|
||||||
Assert.Equal("Für diese Stunde existiert bereits eine Sitzung.", notified);
|
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));
|
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]
|
[Fact]
|
||||||
public void SelectOrCreateSessionForLesson_KeineSitzungVorhanden_LegtVerknuepfteAnUndWaehltSieAus()
|
public void SelectOrCreateSessionForLesson_KeineSitzungVorhanden_LegtVerknuepfteAnUndWaehltSieAus()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -162,6 +162,7 @@ public static class AppBootstrapper
|
|||||||
services.AddSingleton<IUnitRepository, UnitRepository>();
|
services.AddSingleton<IUnitRepository, UnitRepository>();
|
||||||
services.AddSingleton<ILessonRepository, LessonRepository>();
|
services.AddSingleton<ILessonRepository, LessonRepository>();
|
||||||
services.AddSingleton<IDocumentationRepository, DocumentationRepository>();
|
services.AddSingleton<IDocumentationRepository, DocumentationRepository>();
|
||||||
|
services.AddSingleton<IVorgangRepository, VorgangRepository>();
|
||||||
services.AddSingleton<IWorkTaskRepository, WorkTaskRepository>();
|
services.AddSingleton<IWorkTaskRepository, WorkTaskRepository>();
|
||||||
services.AddSingleton<ITimeEntryRepository, TimeEntryRepository>();
|
services.AddSingleton<ITimeEntryRepository, TimeEntryRepository>();
|
||||||
services.AddSingleton<IParticipationSessionRepository, ParticipationSessionRepository>();
|
services.AddSingleton<IParticipationSessionRepository, ParticipationSessionRepository>();
|
||||||
@@ -315,6 +316,7 @@ public static class AppBootstrapper
|
|||||||
services.AddSingleton<WorkloadEvaluationViewModel>();
|
services.AddSingleton<WorkloadEvaluationViewModel>();
|
||||||
services.AddSingleton<WorkloadViewModel>();
|
services.AddSingleton<WorkloadViewModel>();
|
||||||
services.AddSingleton<ClassTeacherDetailsViewModel>();
|
services.AddSingleton<ClassTeacherDetailsViewModel>();
|
||||||
|
services.AddSingleton<ClassTeacherCasesViewModel>();
|
||||||
services.AddSingleton<ClassTeacherOverviewViewModel>();
|
services.AddSingleton<ClassTeacherOverviewViewModel>();
|
||||||
services.AddSingleton<ExamsOverviewViewModel>();
|
services.AddSingleton<ExamsOverviewViewModel>();
|
||||||
|
|
||||||
|
|||||||
@@ -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,235 @@
|
|||||||
|
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) { 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();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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 System.Collections.ObjectModel;
|
||||||
using CommunityToolkit.Mvvm.ComponentModel;
|
using CommunityToolkit.Mvvm.ComponentModel;
|
||||||
using CommunityToolkit.Mvvm.Input;
|
using CommunityToolkit.Mvvm.Input;
|
||||||
|
using LehrerApp.Core.Interfaces;
|
||||||
|
using LehrerApp.Core.Models;
|
||||||
using LehrerApp.Desktop.Services;
|
using LehrerApp.Desktop.Services;
|
||||||
|
using LehrerApp.Desktop.ViewModels.Students;
|
||||||
|
|
||||||
namespace LehrerApp.Desktop.ViewModels.ClassTeacher;
|
namespace LehrerApp.Desktop.ViewModels.ClassTeacher;
|
||||||
|
|
||||||
@@ -130,29 +133,63 @@ public sealed record ClassAbsenceDaySummaryRow(DateOnly Date, string StudentName
|
|||||||
public partial class ClassTeacherDetailsViewModel : ObservableObject
|
public partial class ClassTeacherDetailsViewModel : ObservableObject
|
||||||
{
|
{
|
||||||
private readonly UntisReportCacheService _cache;
|
private readonly UntisReportCacheService _cache;
|
||||||
|
private readonly IDocumentationRepository _documentation;
|
||||||
|
private readonly IStudentRepository _students;
|
||||||
|
private readonly ClassTeacherCasesViewModel _cases;
|
||||||
private string _className = "";
|
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<ClassTeacherClassRegisterRow> Entries { get; } = [];
|
||||||
public ObservableCollection<ClassAbsenceDaySummaryRow> AbsenceEntries { get; } = [];
|
public ObservableCollection<ClassAbsenceDaySummaryRow> AbsenceEntries { get; } = [];
|
||||||
public ObservableCollection<ClassTeacherCategoryAggregateRow> CategoryAggregates { get; } = [];
|
public ObservableCollection<ClassTeacherCategoryAggregateRow> CategoryAggregates { get; } = [];
|
||||||
|
public ObservableCollection<DocumentationItem> OwnDocumentationEntries { get; } = [];
|
||||||
|
|
||||||
[ObservableProperty] private DateTimeOffset _startDate = DateTimeOffset.Now.AddDays(-6);
|
/// Für das Kontextmenü "→ An Vorgang anheften" (DataGrid.SelectedItem, zweigleisig gebunden).
|
||||||
[ObservableProperty] private DateTimeOffset _endDate = DateTimeOffset.Now;
|
[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 string _status = "Zeitraum wählen und laden.";
|
||||||
[ObservableProperty] private bool _busy;
|
[ObservableProperty] private bool _busy;
|
||||||
/// Von der Übersicht gesetzt (Klick auf eine Roster-Zeile) - leer zeigt alle Schüler*innen.
|
/// Von der Übersicht gesetzt (Klick auf eine Roster-Zeile) - leer zeigt alle Schüler*innen.
|
||||||
[ObservableProperty] private string _studentFilter = "";
|
[ObservableProperty] private string _studentFilter = "";
|
||||||
[ObservableProperty] private int _quickRangeIndex = 1;
|
[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 HasEntries => Entries.Count > 0;
|
||||||
public bool HasAbsenceEntries => AbsenceEntries.Count > 0;
|
public bool HasAbsenceEntries => AbsenceEntries.Count > 0;
|
||||||
public bool HasCategoryAggregates => CategoryAggregates.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)
|
public string ActiveFilterLabel => string.IsNullOrWhiteSpace(StudentFilter)
|
||||||
? "Alle Schüler*innen" : StudentFilter;
|
? "Alle Schüler*innen" : StudentFilter;
|
||||||
|
|
||||||
public ClassTeacherDetailsViewModel(UntisReportCacheService cache)
|
public ClassTeacherDetailsViewModel(UntisReportCacheService cache, IDocumentationRepository documentation,
|
||||||
|
IStudentRepository students, ClassTeacherCasesViewModel cases)
|
||||||
{
|
{
|
||||||
_cache = cache;
|
_cache = cache;
|
||||||
|
_documentation = documentation;
|
||||||
|
_students = students;
|
||||||
|
_cases = cases;
|
||||||
}
|
}
|
||||||
|
|
||||||
public void Initialize(string className)
|
public void Initialize(string className)
|
||||||
@@ -162,12 +199,16 @@ public partial class ClassTeacherDetailsViewModel : ObservableObject
|
|||||||
Entries.Clear();
|
Entries.Clear();
|
||||||
AbsenceEntries.Clear();
|
AbsenceEntries.Clear();
|
||||||
CategoryAggregates.Clear();
|
CategoryAggregates.Clear();
|
||||||
|
OwnDocumentationEntries.Clear();
|
||||||
|
_rosterMatches = [];
|
||||||
Status = "Zeitraum wählen und laden.";
|
Status = "Zeitraum wählen und laden.";
|
||||||
NotifyListState();
|
NotifyListState();
|
||||||
}
|
}
|
||||||
|
|
||||||
partial void OnStudentFilterChanged(string value) => OnPropertyChanged(nameof(ActiveFilterLabel));
|
partial void OnStudentFilterChanged(string value) => OnPropertyChanged(nameof(ActiveFilterLabel));
|
||||||
|
|
||||||
|
partial void OnShowOwnDocumentationChanged(bool value) => OnPropertyChanged(nameof(ShowCategoryAggregates));
|
||||||
|
|
||||||
partial void OnQuickRangeIndexChanged(int value)
|
partial void OnQuickRangeIndexChanged(int value)
|
||||||
{
|
{
|
||||||
var days = value switch { 0 => 0, 1 => 6, 2 => 29, _ => 6 };
|
var days = value switch { 0 => 0, 1 => 6, 2 => 29, _ => 6 };
|
||||||
@@ -178,6 +219,9 @@ public partial class ClassTeacherDetailsViewModel : ObservableObject
|
|||||||
[RelayCommand]
|
[RelayCommand]
|
||||||
private Task Load() => LoadInternal(forceRefresh: false);
|
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,
|
/// 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.
|
/// dass man sicher weiß, dass sich seit dem letzten automatischen Abruf etwas geändert hat.
|
||||||
[RelayCommand]
|
[RelayCommand]
|
||||||
@@ -195,17 +239,20 @@ public partial class ClassTeacherDetailsViewModel : ObservableObject
|
|||||||
|
|
||||||
private async Task LoadInternal(bool forceRefresh)
|
private async Task LoadInternal(bool forceRefresh)
|
||||||
{
|
{
|
||||||
var start = DateOnly.FromDateTime(StartDate.LocalDateTime);
|
var start = DateOnly.FromDateTime((StartDate ?? DateTimeOffset.Now).LocalDateTime);
|
||||||
var end = DateOnly.FromDateTime(EndDate.LocalDateTime);
|
var end = DateOnly.FromDateTime((EndDate ?? DateTimeOffset.Now).LocalDateTime);
|
||||||
if (end < start) { Status = "Das Enddatum darf nicht vor dem Startdatum liegen."; return; }
|
if (end < start) { Status = "Das Enddatum darf nicht vor dem Startdatum liegen."; return; }
|
||||||
if (string.IsNullOrWhiteSpace(_className)) { Status = "Keine Klasse ausgewählt."; 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
|
try
|
||||||
{
|
{
|
||||||
var classRegisterTask = _cache.GetClassRegisterEventsAsync(_className, start, end, forceRefresh);
|
var classRegisterTask = _cache.GetClassRegisterEventsAsync(_className, start, end, forceRefresh);
|
||||||
var absencesTask = _cache.GetAbsencesAsync(_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
|
var ordered = classRegisterTask.Result
|
||||||
.Where(MatchesStudentFilter)
|
.Where(MatchesStudentFilter)
|
||||||
@@ -223,14 +270,139 @@ public partial class ClassTeacherDetailsViewModel : ObservableObject
|
|||||||
foreach (var row in ClassAbsenceDaySummaryRow.GroupByStudentAndDay(absences))
|
foreach (var row in ClassAbsenceDaySummaryRow.GroupByStudentAndDay(absences))
|
||||||
AbsenceEntries.Add(row);
|
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, " +
|
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();
|
NotifyListState();
|
||||||
}
|
}
|
||||||
catch (WebUntisIntegrationException ex) { Status = ex.Message; }
|
catch (WebUntisIntegrationException ex) { Status = ex.Message; }
|
||||||
finally { Busy = false; NotifyListState(); }
|
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
|
// 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
|
// StudentFilter beim Klick in der Übersicht gesetzt wird (siehe UntisNameMatching) - ein
|
||||||
// exakter String-Vergleich hier ließ die gefilterten Listen fälschlich leer erscheinen.
|
// 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(HasEntries));
|
||||||
OnPropertyChanged(nameof(HasAbsenceEntries));
|
OnPropertyChanged(nameof(HasAbsenceEntries));
|
||||||
OnPropertyChanged(nameof(HasCategoryAggregates));
|
OnPropertyChanged(nameof(HasCategoryAggregates));
|
||||||
|
OnPropertyChanged(nameof(ShowCategoryAggregates));
|
||||||
|
OnPropertyChanged(nameof(HasOwnDocumentationEntries));
|
||||||
|
OnPropertyChanged(nameof(UntisCriticalCount));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -73,8 +73,35 @@ public sealed record ClassTeacherRosterRow(string StudentName, int? ExternKey, b
|
|||||||
/// Kumulierte Fehlzeiten seit Schuljahresbeginn (Nutzer-Feedback: der Heute-Snapshot allein
|
/// Kumulierte Fehlzeiten seit Schuljahresbeginn (Nutzer-Feedback: der Heute-Snapshot allein
|
||||||
/// sagt für Zeugnis/Attestpflicht wenig aus). <see cref="SchoolDaysElapsed"/> zählt Werktage
|
/// sagt für Zeugnis/Attestpflicht wenig aus). <see cref="SchoolDaysElapsed"/> zählt Werktage
|
||||||
/// abzüglich der über WebUntis geladenen Ferien (<see cref="ClassTeacherOverviewViewModel.CountSchoolWeekdays"/>).
|
/// 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 YearAbsenceDayCount { get; init; }
|
||||||
public int YearUnexcusedDayCount { 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; }
|
public int SchoolDaysElapsed { get; init; }
|
||||||
/// Nutzer-Feedback: nachdem der Nenner zeitweise Ferientage mitzählte (siehe TODO.md), soll die
|
/// 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
|
/// Herkunft der Zahl nachvollziehbar bleiben, ohne dafür die WebUntis-Ferienliste separat
|
||||||
@@ -85,19 +112,78 @@ public sealed record ClassTeacherRosterRow(string StudentName, int? ExternKey, b
|
|||||||
/// sichtbar — WebUntis' Ferienkalender deckt die Sommerferien selbst nicht ab (siehe TODO.md),
|
/// sichtbar — WebUntis' Ferienkalender deckt die Sommerferien selbst nicht ab (siehe TODO.md),
|
||||||
/// die Korrektur passiert also am Startpunkt, nicht an abgezogenen Tagen mittendrin.
|
/// die Korrektur passiert also am Startpunkt, nicht an abgezogenen Tagen mittendrin.
|
||||||
public DateOnly TermStart { get; init; }
|
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 =>
|
public int YearAbsenceRatePercent =>
|
||||||
SchoolDaysElapsed <= 0 ? 0 : (int)Math.Round(100d * YearAbsenceDayCount / SchoolDaysElapsed);
|
SchoolDaysElapsed <= 0 ? 0 : (int)Math.Round(100d * YearAbsenceDayCount / SchoolDaysElapsed);
|
||||||
public string YearSummaryLabel => HasYearSummary
|
public string YearSummaryLabel => !HasYearSummary ? "" :
|
||||||
? $"{YearAbsenceRatePercent} % Fehlzeit seit Schuljahresbeginn" : "";
|
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 :
|
public string? YearSummaryTooltip => !HasYearSummary ? null :
|
||||||
$"{YearAbsenceDayCount} von {SchoolDaysElapsed} Schultagen seit {TermStart:dd.MM.} mit Fehlzeit" +
|
(YearAbsenceDayCount > 0
|
||||||
(YearUnexcusedDayCount > 0 ? $" · {YearUnexcusedDayCount} unentschuldigt" : "") +
|
? $"{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" : "");
|
(HolidayWeekdaysExcluded > 0 ? $" · {HolidayWeekdaysExcluded} Ferientage abgezogen" : "");
|
||||||
|
|
||||||
|
// Feste Gewichte statt einstellbarer Werte (Nutzer-Entscheidung): unentschuldigt fällt am
|
||||||
|
// stärksten ins Gewicht ("geschwänzt"), entschuldigte Abwesenheit mittel, reine Verspätung
|
||||||
|
// ("verpennt") am wenigsten — genau die vom Nutzer gewünschte Trennung, nur als Zahl statt als
|
||||||
|
// Statustext. Bei Bedarf später anpassbar, siehe TODO.md.
|
||||||
|
private const double UnexcusedDayWeight = 3.0;
|
||||||
|
private const double ExcusedDayWeight = 1.0;
|
||||||
|
private const double LateDayWeight = 0.5;
|
||||||
|
// Klassenbuch-Einträge anderer Lehrkräfte (Nutzer-Feedback): ein einzelner "Negativ"-Eintrag
|
||||||
|
// wiegt ungefähr wie eine entschuldigte Fehlzeit — erst die Häufung ("durch die Decke gehen")
|
||||||
|
// treibt den Score merklich. Die beiden genannten Muster (fehlende Hausaufgaben/schlechte
|
||||||
|
// Mitarbeit) zählen zusätzlich etwas stärker, weil sie explizit als wiederkehrend relevant
|
||||||
|
// genannt wurden. "Suspendiert" ist bewusst ein Ausreißer-Gewicht, das den Score sofort nach
|
||||||
|
// oben reißt, unabhängig vom sonstigen Verlauf.
|
||||||
|
private const double NegativeClassRegisterEntryWeight = 1.0;
|
||||||
|
private const double ConcerningKeywordBonus = 1.5;
|
||||||
|
private const double SuspensionKeywordWeight = 15.0;
|
||||||
|
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.
|
||||||
|
public double PatternScore =>
|
||||||
|
YearUnexcusedDayCount * UnexcusedDayWeight +
|
||||||
|
YearExcusedDayCount * ExcusedDayWeight +
|
||||||
|
YearLateDayCount * LateDayWeight +
|
||||||
|
ClassRegisterScoreComponent;
|
||||||
|
|
||||||
private static bool IsLateReason(string reason) =>
|
private static bool IsLateReason(string reason) =>
|
||||||
reason.Contains("verspät", StringComparison.OrdinalIgnoreCase);
|
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)
|
||||||
|
{
|
||||||
|
var weight = 0.0;
|
||||||
|
if (IsNegativeClassRegisterEntry(e)) weight += NegativeClassRegisterEntryWeight;
|
||||||
|
if (MatchesAnyKeyword(e, ConcerningKeywords)) weight += ConcerningKeywordBonus;
|
||||||
|
if (MatchesAnyKeyword(e, SuspensionKeywords)) weight += SuspensionKeywordWeight;
|
||||||
|
return weight;
|
||||||
|
}
|
||||||
|
|
||||||
public static IReadOnlyList<ClassTeacherRosterRow> Build(
|
public static IReadOnlyList<ClassTeacherRosterRow> Build(
|
||||||
IReadOnlyList<UntisStudentRosterCacheEntry> students,
|
IReadOnlyList<UntisStudentRosterCacheEntry> students,
|
||||||
IReadOnlyList<ClassAbsenceDaySummaryRow> todayAbsences,
|
IReadOnlyList<ClassAbsenceDaySummaryRow> todayAbsences,
|
||||||
@@ -106,7 +192,8 @@ public sealed record ClassTeacherRosterRow(string StudentName, int? ExternKey, b
|
|||||||
IReadOnlyList<ClassAbsenceDaySummaryRow>? yearAbsences = null,
|
IReadOnlyList<ClassAbsenceDaySummaryRow>? yearAbsences = null,
|
||||||
int schoolDaysElapsed = 0,
|
int schoolDaysElapsed = 0,
|
||||||
int holidayWeekdaysExcluded = 0,
|
int holidayWeekdaysExcluded = 0,
|
||||||
DateOnly termStart = default)
|
DateOnly termStart = default,
|
||||||
|
IReadOnlyList<UntisForeignClassRegisterEventDto>? yearClassRegisterEntries = null)
|
||||||
{
|
{
|
||||||
var referenceDate = today ?? todayAbsences.FirstOrDefault()?.Date ?? DateOnly.FromDateTime(DateTime.Today);
|
var referenceDate = today ?? todayAbsences.FirstOrDefault()?.Date ?? DateOnly.FromDateTime(DateTime.Today);
|
||||||
var absenceByKey = todayAbsences.Where(a => a.ExternKey is not null)
|
var absenceByKey = todayAbsences.Where(a => a.ExternKey is not null)
|
||||||
@@ -115,6 +202,12 @@ public sealed record ClassTeacherRosterRow(string StudentName, int? ExternKey, b
|
|||||||
.ToDictionary(g => g.Key, g => g.First());
|
.ToDictionary(g => g.Key, g => g.First());
|
||||||
var recentByName = recentClassRegisterEntries.GroupBy(e => UntisNameMatching.NameKey(e.StudentName))
|
var recentByName = recentClassRegisterEntries.GroupBy(e => UntisNameMatching.NameKey(e.StudentName))
|
||||||
.ToDictionary(g => g.Key, g => g.ToList());
|
.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 yearRows = yearAbsences ?? [];
|
||||||
var yearByKey = yearRows.Where(a => a.ExternKey is not null)
|
var yearByKey = yearRows.Where(a => a.ExternKey is not null)
|
||||||
.GroupBy(a => a.ExternKey!.Value).ToDictionary(g => g.Key, g => g.ToList());
|
.GroupBy(a => a.ExternKey!.Value).ToDictionary(g => g.Key, g => g.ToList());
|
||||||
@@ -129,14 +222,23 @@ public sealed record ClassTeacherRosterRow(string StudentName, int? ExternKey, b
|
|||||||
var registerEntries = recentByName.GetValueOrDefault(nameKey) ?? [];
|
var registerEntries = recentByName.GetValueOrDefault(nameKey) ?? [];
|
||||||
var yearEntries = (student.ExternKey is { } yearKey ? yearByKey.GetValueOrDefault(yearKey) : null)
|
var yearEntries = (student.ExternKey is { } yearKey ? yearByKey.GetValueOrDefault(yearKey) : null)
|
||||||
?? yearByName.GetValueOrDefault(nameKey) ?? [];
|
?? yearByName.GetValueOrDefault(nameKey) ?? [];
|
||||||
|
var yearRegisterEntries = yearRegisterByName.GetValueOrDefault(nameKey) ?? [];
|
||||||
return new ClassTeacherRosterRow(student.DisplayName, student.ExternKey, absence is not null,
|
return new ClassTeacherRosterRow(student.DisplayName, student.ExternKey, absence is not null,
|
||||||
absence is null ? null : $"{absence.TotalAbsentPeriods} Stunde(n) — {absence.StatusLabel}",
|
absence is null ? null : $"{absence.TotalAbsentPeriods} Stunde(n) — {absence.StatusLabel}",
|
||||||
registerEntries.Count > 0)
|
registerEntries.Count > 0)
|
||||||
{
|
{
|
||||||
TodayAbsence = absence,
|
TodayAbsence = absence,
|
||||||
HasClassRegisterToday = registerEntries.Any(e => TryDate(e.Date, out var date) && date == referenceDate),
|
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),
|
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(ClassRegisterEntryWeight),
|
||||||
SchoolDaysElapsed = schoolDaysElapsed,
|
SchoolDaysElapsed = schoolDaysElapsed,
|
||||||
HolidayWeekdaysExcluded = holidayWeekdaysExcluded,
|
HolidayWeekdaysExcluded = holidayWeekdaysExcluded,
|
||||||
TermStart = termStart,
|
TermStart = termStart,
|
||||||
@@ -214,11 +316,13 @@ public partial class ClassTeacherOverviewViewModel : ObservableObject
|
|||||||
private readonly SchoolYearService _schoolYear;
|
private readonly SchoolYearService _schoolYear;
|
||||||
private readonly IWorkTaskRepository _workTasks;
|
private readonly IWorkTaskRepository _workTasks;
|
||||||
private readonly IStudentRepository _students;
|
private readonly IStudentRepository _students;
|
||||||
|
private readonly IDocumentationRepository _documentation;
|
||||||
private readonly IParticipationRepository _participation;
|
private readonly IParticipationRepository _participation;
|
||||||
private readonly IParticipationSessionRepository _participationSessions;
|
private readonly IParticipationSessionRepository _participationSessions;
|
||||||
private readonly AppLogger? _logger;
|
private readonly AppLogger? _logger;
|
||||||
|
|
||||||
public ClassTeacherDetailsViewModel DetailsTab { get; }
|
public ClassTeacherDetailsViewModel DetailsTab { get; }
|
||||||
|
public ClassTeacherCasesViewModel CasesTab { get; }
|
||||||
public ObservableCollection<ClassTeacherRosterRow> Roster { get; } = [];
|
public ObservableCollection<ClassTeacherRosterRow> Roster { get; } = [];
|
||||||
public ObservableCollection<ClassTeacherRosterRow> PrimaryRoster { get; } = [];
|
public ObservableCollection<ClassTeacherRosterRow> PrimaryRoster { get; } = [];
|
||||||
public ObservableCollection<ClassTeacherRosterRow> SecondaryRoster { get; } = [];
|
public ObservableCollection<ClassTeacherRosterRow> SecondaryRoster { get; } = [];
|
||||||
@@ -231,7 +335,11 @@ public partial class ClassTeacherOverviewViewModel : ObservableObject
|
|||||||
[ObservableProperty] private string _status = "";
|
[ObservableProperty] private string _status = "";
|
||||||
[ObservableProperty] private bool _busy;
|
[ObservableProperty] private bool _busy;
|
||||||
[ObservableProperty] private string _searchText = "";
|
[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 _primarySectionTitle = "Heute auffällig";
|
||||||
[ObservableProperty] private string _secondarySectionTitle = "Weitere Schüler*innen";
|
[ObservableProperty] private string _secondarySectionTitle = "Weitere Schüler*innen";
|
||||||
[ObservableProperty] private int _studentCount;
|
[ObservableProperty] private int _studentCount;
|
||||||
@@ -244,6 +352,12 @@ public partial class ClassTeacherOverviewViewModel : ObservableObject
|
|||||||
[ObservableProperty] private int _unexcusedAbsenceCount;
|
[ObservableProperty] private int _unexcusedAbsenceCount;
|
||||||
[ObservableProperty] private string _lastUpdatedLabel = "Noch nicht aktualisiert";
|
[ObservableProperty] private string _lastUpdatedLabel = "Noch nicht aktualisiert";
|
||||||
[ObservableProperty] private int _openExcuseOverflowCount;
|
[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;
|
||||||
|
|
||||||
public bool HomeroomClassConfigured => !string.IsNullOrWhiteSpace(HomeroomClassName);
|
public bool HomeroomClassConfigured => !string.IsNullOrWhiteSpace(HomeroomClassName);
|
||||||
public bool HasPrimaryRoster => PrimaryRoster.Count > 0;
|
public bool HasPrimaryRoster => PrimaryRoster.Count > 0;
|
||||||
@@ -252,9 +366,11 @@ public partial class ClassTeacherOverviewViewModel : ObservableObject
|
|||||||
public bool HasPatternNotices => PatternNotices.Count > 0;
|
public bool HasPatternNotices => PatternNotices.Count > 0;
|
||||||
public bool HasOpenExcuses => OpenExcuses.Count > 0;
|
public bool HasOpenExcuses => OpenExcuses.Count > 0;
|
||||||
public bool HasOpenExcuseOverflow => OpenExcuseOverflowCount > 0;
|
public bool HasOpenExcuseOverflow => OpenExcuseOverflowCount > 0;
|
||||||
|
public bool HasOwnDocumentationAlerts => OwnDocumentationFollowUpCount > 0 || OwnDocumentationCriticalCount > 0;
|
||||||
public bool AlertsFilterSelected => SelectedRosterFilter == 0;
|
public bool AlertsFilterSelected => SelectedRosterFilter == 0;
|
||||||
public bool ClassRegisterFilterSelected => SelectedRosterFilter == 1;
|
public bool ClassRegisterFilterSelected => SelectedRosterFilter == 1;
|
||||||
public bool AllFilterSelected => SelectedRosterFilter == 2;
|
public bool AllFilterSelected => SelectedRosterFilter == 2;
|
||||||
|
public bool PatternScoreFilterSelected => SelectedRosterFilter == 3;
|
||||||
public int TodayUnexcusedPercent => Percent(TodayUnexcusedCount);
|
public int TodayUnexcusedPercent => Percent(TodayUnexcusedCount);
|
||||||
public int LatePercent => Percent(LateCount);
|
public int LatePercent => Percent(LateCount);
|
||||||
public int PresentPercent => Percent(PresentCount);
|
public int PresentPercent => Percent(PresentCount);
|
||||||
@@ -289,9 +405,9 @@ public partial class ClassTeacherOverviewViewModel : ObservableObject
|
|||||||
|
|
||||||
public ClassTeacherOverviewViewModel(WebUntisSettingsService settings, WebUntisIntegrationService untis,
|
public ClassTeacherOverviewViewModel(WebUntisSettingsService settings, WebUntisIntegrationService untis,
|
||||||
UntisReportCacheService cache, SchoolYearService schoolYear, IWorkTaskRepository workTasks,
|
UntisReportCacheService cache, SchoolYearService schoolYear, IWorkTaskRepository workTasks,
|
||||||
IStudentRepository students, IParticipationRepository participation,
|
IStudentRepository students, IDocumentationRepository documentation, IParticipationRepository participation,
|
||||||
IParticipationSessionRepository participationSessions, ClassTeacherDetailsViewModel detailsTab,
|
IParticipationSessionRepository participationSessions, ClassTeacherDetailsViewModel detailsTab,
|
||||||
AppLogger? logger = null)
|
ClassTeacherCasesViewModel casesTab, AppLogger? logger = null)
|
||||||
{
|
{
|
||||||
_settings = settings;
|
_settings = settings;
|
||||||
_untis = untis;
|
_untis = untis;
|
||||||
@@ -299,20 +415,25 @@ public partial class ClassTeacherOverviewViewModel : ObservableObject
|
|||||||
_schoolYear = schoolYear;
|
_schoolYear = schoolYear;
|
||||||
_workTasks = workTasks;
|
_workTasks = workTasks;
|
||||||
_students = students;
|
_students = students;
|
||||||
|
_documentation = documentation;
|
||||||
_participation = participation;
|
_participation = participation;
|
||||||
_participationSessions = participationSessions;
|
_participationSessions = participationSessions;
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
DetailsTab = detailsTab;
|
DetailsTab = detailsTab;
|
||||||
|
CasesTab = casesTab;
|
||||||
}
|
}
|
||||||
|
|
||||||
partial void OnHomeroomClassNameChanged(string? value) => OnPropertyChanged(nameof(HomeroomClassConfigured));
|
partial void OnHomeroomClassNameChanged(string? value) => OnPropertyChanged(nameof(HomeroomClassConfigured));
|
||||||
partial void OnOpenExcuseOverflowCountChanged(int value) => OnPropertyChanged(nameof(HasOpenExcuseOverflow));
|
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 OnSearchTextChanged(string value) => ApplyRosterFilter();
|
||||||
partial void OnSelectedRosterFilterChanged(int value)
|
partial void OnSelectedRosterFilterChanged(int value)
|
||||||
{
|
{
|
||||||
OnPropertyChanged(nameof(AlertsFilterSelected));
|
OnPropertyChanged(nameof(AlertsFilterSelected));
|
||||||
OnPropertyChanged(nameof(ClassRegisterFilterSelected));
|
OnPropertyChanged(nameof(ClassRegisterFilterSelected));
|
||||||
OnPropertyChanged(nameof(AllFilterSelected));
|
OnPropertyChanged(nameof(AllFilterSelected));
|
||||||
|
OnPropertyChanged(nameof(PatternScoreFilterSelected));
|
||||||
ApplyRosterFilter();
|
ApplyRosterFilter();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -332,6 +453,7 @@ public partial class ClassTeacherOverviewViewModel : ObservableObject
|
|||||||
|
|
||||||
var className = HomeroomClassName!;
|
var className = HomeroomClassName!;
|
||||||
DetailsTab.Initialize(className);
|
DetailsTab.Initialize(className);
|
||||||
|
CasesTab.Initialize(className);
|
||||||
Busy = true;
|
Busy = true;
|
||||||
NotifyRosterState();
|
NotifyRosterState();
|
||||||
try
|
try
|
||||||
@@ -346,7 +468,14 @@ public partial class ClassTeacherOverviewViewModel : ObservableObject
|
|||||||
var yearStart = _schoolYear.SchoolYearStart(_schoolYear.CurrentSchoolYear(today));
|
var yearStart = _schoolYear.SchoolYearStart(_schoolYear.CurrentSchoolYear(today));
|
||||||
var studentsTask = _cache.GetStudentRosterAsync(className);
|
var studentsTask = _cache.GetStudentRosterAsync(className);
|
||||||
var absencesTask = _cache.GetAbsencesAsync(className, yearStart, today);
|
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();
|
var holidaysTask = GetHolidaysAsync();
|
||||||
await Task.WhenAll(studentsTask, absencesTask, classRegisterTask, holidaysTask);
|
await Task.WhenAll(studentsTask, absencesTask, classRegisterTask, holidaysTask);
|
||||||
|
|
||||||
@@ -356,9 +485,11 @@ public partial class ClassTeacherOverviewViewModel : ObservableObject
|
|||||||
var rawWeekdaysElapsed = CountSchoolWeekdays(termStart, today, []);
|
var rawWeekdaysElapsed = CountSchoolWeekdays(termStart, today, []);
|
||||||
var schoolDaysElapsed = CountSchoolWeekdays(termStart, today, holidaysTask.Result);
|
var schoolDaysElapsed = CountSchoolWeekdays(termStart, today, holidaysTask.Result);
|
||||||
var holidayWeekdaysExcluded = rawWeekdaysElapsed - schoolDaysElapsed;
|
var holidayWeekdaysExcluded = rawWeekdaysElapsed - schoolDaysElapsed;
|
||||||
|
var recentRegisterEntries = classRegisterTask.Result
|
||||||
|
.Where(e => TryDate(e.Date, out var d) && d >= sevenDayStart).ToList();
|
||||||
foreach (var row in ClassTeacherRosterRow.Build(studentsTask.Result, todayAbsences,
|
foreach (var row in ClassTeacherRosterRow.Build(studentsTask.Result, todayAbsences,
|
||||||
classRegisterTask.Result, today, absenceDaysYear, schoolDaysElapsed,
|
recentRegisterEntries, today, absenceDaysYear, schoolDaysElapsed,
|
||||||
holidayWeekdaysExcluded, termStart)) Roster.Add(row);
|
holidayWeekdaysExcluded, termStart, classRegisterTask.Result)) Roster.Add(row);
|
||||||
|
|
||||||
StudentCount = Roster.Count;
|
StudentCount = Roster.Count;
|
||||||
TodayAlertCount = Roster.Count(r => r.HasAbsenceToday);
|
TodayAlertCount = Roster.Count(r => r.HasAbsenceToday);
|
||||||
@@ -367,11 +498,12 @@ public partial class ClassTeacherOverviewViewModel : ObservableObject
|
|||||||
PresentCount = Roster.Count(r => !r.HasAbsenceToday);
|
PresentCount = Roster.Count(r => !r.HasAbsenceToday);
|
||||||
ExcusedAbsenceCount = Roster.Count(r => r.HasAbsenceToday && !r.IsLate && !r.IsUnexcused);
|
ExcusedAbsenceCount = Roster.Count(r => r.HasAbsenceToday && !r.IsLate && !r.IsUnexcused);
|
||||||
UnexcusedAbsenceCount = Roster.Count(r => r.HasAbsenceToday && !r.IsLate && r.IsUnexcused);
|
UnexcusedAbsenceCount = Roster.Count(r => r.HasAbsenceToday && !r.IsLate && r.IsUnexcused);
|
||||||
RecentClassRegisterCount = classRegisterTask.Result.Count;
|
RecentClassRegisterCount = recentRegisterEntries.Count;
|
||||||
BuildTrend(absenceDaysYear, trendDays);
|
BuildTrend(absenceDaysYear, trendDays);
|
||||||
BuildPatternNotices(absenceDaysYear, sevenDayStart);
|
BuildPatternNotices(absenceDaysYear, sevenDayStart);
|
||||||
BuildWeekdayPatternNotices(absenceDaysYear);
|
BuildWeekdayPatternNotices(absenceDaysYear);
|
||||||
BuildAttendanceParticipationNotices();
|
BuildAttendanceParticipationNotices();
|
||||||
|
BuildOwnDocumentationCounts();
|
||||||
BuildOpenExcuses(absenceDaysYear, today);
|
BuildOpenExcuses(absenceDaysYear, today);
|
||||||
LastUpdatedLabel = $"Zuletzt aktualisiert: Heute, {DateTime.Now:HH:mm}";
|
LastUpdatedLabel = $"Zuletzt aktualisiert: Heute, {DateTime.Now:HH:mm}";
|
||||||
Status = $"{StudentCount} Schüler*innen · {TodayAlertCount} heute auffällig";
|
Status = $"{StudentCount} Schüler*innen · {TodayAlertCount} heute auffällig";
|
||||||
@@ -385,6 +517,7 @@ public partial class ClassTeacherOverviewViewModel : ObservableObject
|
|||||||
[RelayCommand] private void ShowAlerts() => SelectedRosterFilter = 0;
|
[RelayCommand] private void ShowAlerts() => SelectedRosterFilter = 0;
|
||||||
[RelayCommand] private void ShowClassRegister() => SelectedRosterFilter = 1;
|
[RelayCommand] private void ShowClassRegister() => SelectedRosterFilter = 1;
|
||||||
[RelayCommand] private void ShowAll() => SelectedRosterFilter = 2;
|
[RelayCommand] private void ShowAll() => SelectedRosterFilter = 2;
|
||||||
|
[RelayCommand] private void ShowPatternScore() => SelectedRosterFilter = 3;
|
||||||
|
|
||||||
[RelayCommand]
|
[RelayCommand]
|
||||||
private void OpenClassRegister()
|
private void OpenClassRegister()
|
||||||
@@ -432,6 +565,18 @@ public partial class ClassTeacherOverviewViewModel : ObservableObject
|
|||||||
PrimarySectionTitle = "Klassenbucheinträge der letzten 7 Tage";
|
PrimarySectionTitle = "Klassenbucheinträge der letzten 7 Tage";
|
||||||
foreach (var row in query.Where(r => r.HasRecentClassRegisterEntry)) PrimaryRoster.Add(row);
|
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
|
else
|
||||||
{
|
{
|
||||||
PrimarySectionTitle = "Heute auffällig";
|
PrimarySectionTitle = "Heute auffällig";
|
||||||
@@ -464,6 +609,9 @@ public partial class ClassTeacherOverviewViewModel : ObservableObject
|
|||||||
(double)day.Unexcused / max, (double)day.LateExcused / max, (double)day.Excused / max));
|
(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
|
/// 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
|
/// Ferienkalender (bewusste Vereinfachung, siehe TODO.md 12.4-Nachtrag) — die App kennt keine
|
||||||
/// Schulferien, nur Wochenenden.
|
/// Schulferien, nur Wochenenden.
|
||||||
@@ -687,6 +835,25 @@ public partial class ClassTeacherOverviewViewModel : ObservableObject
|
|||||||
OnPropertyChanged(nameof(HasPatternNotices));
|
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>
|
/// 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
|
/// statt <see cref="Student.FullName"/>, weil dessen "Nachname, Vorname"-Format mit Komma den
|
||||||
/// leerzeichenbasierten Wortabgleich in <see cref="UntisNameMatching"/> verfälschen würde
|
/// leerzeichenbasierten Wortabgleich in <see cref="UntisNameMatching"/> verfälschen würde
|
||||||
|
|||||||
@@ -0,0 +1,204 @@
|
|||||||
|
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;
|
||||||
|
|
||||||
|
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)
|
||||||
|
{
|
||||||
|
Model = model;
|
||||||
|
StudentNames = studentNames;
|
||||||
|
TagChips = model.Tags.Select(t => new TagChip(t)).ToList();
|
||||||
|
_onLink = onLink;
|
||||||
|
_onUnlink = onUnlink;
|
||||||
|
_onRemoveClassRegisterEntry = onRemoveClassRegisterEntry;
|
||||||
|
}
|
||||||
|
|
||||||
|
[RelayCommand] private void Reveal() => IsRevealed = !IsRevealed;
|
||||||
|
|
||||||
|
[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
|
/// Ü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
|
/// das bisher ungenutzte Lesson.LessonId-Feld auf ParticipationSession, damit ein zweiter Klick
|
||||||
/// auf dieselbe Stunde keine doppelte Sitzung anlegt, sondern nur darauf hinweist.
|
/// 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))]
|
[RelayCommand(CanExecute = nameof(HasSelectedLesson))]
|
||||||
private void CreateParticipationSession()
|
private void CreateParticipationSession()
|
||||||
{
|
{
|
||||||
if (SelectedLesson is null) return;
|
if (SelectedLesson is null) return;
|
||||||
var lesson = SelectedLesson.Model;
|
var lesson = SelectedLesson.Model;
|
||||||
var existing = _participationSessions.GetByGroup(lesson.GroupId)
|
var sessionsForGroup = _participationSessions.GetByGroup(lesson.GroupId);
|
||||||
.FirstOrDefault(s => s.LessonId == lesson.Id);
|
var existing = sessionsForGroup.FirstOrDefault(s => s.LessonId == lesson.Id)
|
||||||
|
?? sessionsForGroup.FirstOrDefault(s => s.Date == lesson.Date);
|
||||||
if (existing is not null)
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -141,10 +141,18 @@ public partial class SeatingPlanTabViewModel : ObservableObject
|
|||||||
/// erlaubt: welche Stunde gemeint ist, steht durch die explizite Auswahl der Lehrkraft
|
/// erlaubt: welche Stunde gemeint ist, steht durch die explizite Auswahl der Lehrkraft
|
||||||
/// (Klick auf "Unterrichtsmodus starten" für genau diese Stunde) bereits unzweideutig fest -
|
/// (Klick auf "Unterrichtsmodus starten" für genau diese Stunde) bereits unzweideutig fest -
|
||||||
/// keine Geistersitzungs-Gefahr wie beim bloßen Öffnen eines Tabs.
|
/// 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>
|
/// </summary>
|
||||||
public void SelectOrCreateSessionForLesson(Lesson lesson)
|
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 (existing is not null) { SelectedSession = existing; return; }
|
||||||
if (!IsEditable) return;
|
if (!IsEditable) return;
|
||||||
|
|
||||||
|
|||||||
@@ -48,8 +48,8 @@ public partial class WebUntisLessonAbsenceComparisonViewModel : ObservableObject
|
|||||||
new Dictionary<DateOnly, ParticipationSession>();
|
new Dictionary<DateOnly, ParticipationSession>();
|
||||||
|
|
||||||
public ObservableCollection<WebUntisLessonAbsenceRow> Rows { get; } = [];
|
public ObservableCollection<WebUntisLessonAbsenceRow> Rows { get; } = [];
|
||||||
[ObservableProperty] private DateTimeOffset _startDate = DateTimeOffset.Now.AddMonths(-2);
|
[ObservableProperty] private DateTimeOffset? _startDate = DateTimeOffset.Now.AddMonths(-2);
|
||||||
[ObservableProperty] private DateTimeOffset _endDate = DateTimeOffset.Now;
|
[ObservableProperty] private DateTimeOffset? _endDate = DateTimeOffset.Now;
|
||||||
[ObservableProperty] private string _status = "Zeitraum wählen und Fehlzeiten laden.";
|
[ObservableProperty] private string _status = "Zeitraum wählen und Fehlzeiten laden.";
|
||||||
[ObservableProperty] private bool _busy;
|
[ObservableProperty] private bool _busy;
|
||||||
[ObservableProperty] private bool _markUnknownAsPresent;
|
[ObservableProperty] private bool _markUnknownAsPresent;
|
||||||
@@ -73,8 +73,8 @@ public partial class WebUntisLessonAbsenceComparisonViewModel : ObservableObject
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
var start = DateOnly.FromDateTime(StartDate.LocalDateTime);
|
var start = DateOnly.FromDateTime((StartDate ?? DateTimeOffset.Now).LocalDateTime);
|
||||||
var end = DateOnly.FromDateTime(EndDate.LocalDateTime);
|
var end = DateOnly.FromDateTime((EndDate ?? DateTimeOffset.Now).LocalDateTime);
|
||||||
if (end < start) { Status = "Das Enddatum darf nicht vor dem Startdatum liegen."; return; }
|
if (end < start) { Status = "Das Enddatum darf nicht vor dem Startdatum liegen."; return; }
|
||||||
Busy = true; Rows.Clear();
|
Busy = true; Rows.Clear();
|
||||||
try
|
try
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ public partial class WebUntisTimetableImportViewModel : ObservableObject
|
|||||||
public ObservableCollection<UntisTeacherDto> Teachers { get; } = [];
|
public ObservableCollection<UntisTeacherDto> Teachers { get; } = [];
|
||||||
public ObservableCollection<WebUntisTimetableRow> Rows { get; } = [];
|
public ObservableCollection<WebUntisTimetableRow> Rows { get; } = [];
|
||||||
[ObservableProperty] private UntisTeacherDto? _selectedTeacher;
|
[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 string _status = "Lehrkraft auswählen und Untis-Woche laden.";
|
||||||
[ObservableProperty] private bool _busy;
|
[ObservableProperty] private bool _busy;
|
||||||
public bool Saved { get; private set; }
|
public bool Saved { get; private set; }
|
||||||
@@ -71,7 +71,7 @@ public partial class WebUntisTimetableImportViewModel : ObservableObject
|
|||||||
Busy = true; Rows.Clear();
|
Busy = true; Rows.Clear();
|
||||||
try
|
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 monday = selected.AddDays(-(((int)selected.DayOfWeek + 6) % 7));
|
||||||
var periods = await _untis.GetTimetableAsync(SelectedTeacher.UntisId, monday, monday.AddDays(6));
|
var periods = await _untis.GetTimetableAsync(SelectedTeacher.UntisId, monday, monday.AddDays(6));
|
||||||
var grid = await _untis.GetTimeGridAsync();
|
var grid = await _untis.GetTimeGridAsync();
|
||||||
|
|||||||
@@ -58,8 +58,8 @@ public partial class WebUntisDocumentationComparisonViewModel : ObservableObject
|
|||||||
|
|
||||||
public ObservableCollection<WebUntisDocumentationRow> Rows { get; } = [];
|
public ObservableCollection<WebUntisDocumentationRow> Rows { get; } = [];
|
||||||
public ObservableCollection<LocalOnlyDocumentationRow> LocalOnlyRows { get; } = [];
|
public ObservableCollection<LocalOnlyDocumentationRow> LocalOnlyRows { get; } = [];
|
||||||
[ObservableProperty] private DateTimeOffset _startDate = DateTimeOffset.Now.AddDays(-7);
|
[ObservableProperty] private DateTimeOffset? _startDate = DateTimeOffset.Now.AddDays(-7);
|
||||||
[ObservableProperty] private DateTimeOffset _endDate = DateTimeOffset.Now;
|
[ObservableProperty] private DateTimeOffset? _endDate = DateTimeOffset.Now;
|
||||||
[ObservableProperty] private string _status = "Zeitraum wählen und Klassenbucheinträge laden.";
|
[ObservableProperty] private string _status = "Zeitraum wählen und Klassenbucheinträge laden.";
|
||||||
[ObservableProperty] private bool _busy;
|
[ObservableProperty] private bool _busy;
|
||||||
|
|
||||||
@@ -74,8 +74,8 @@ public partial class WebUntisDocumentationComparisonViewModel : ObservableObject
|
|||||||
[RelayCommand]
|
[RelayCommand]
|
||||||
private async Task Load()
|
private async Task Load()
|
||||||
{
|
{
|
||||||
var start = DateOnly.FromDateTime(StartDate.LocalDateTime);
|
var start = DateOnly.FromDateTime((StartDate ?? DateTimeOffset.Now).LocalDateTime);
|
||||||
var end = DateOnly.FromDateTime(EndDate.LocalDateTime);
|
var end = DateOnly.FromDateTime((EndDate ?? DateTimeOffset.Now).LocalDateTime);
|
||||||
if (end < start) { Status = "Das Enddatum darf nicht vor dem Startdatum liegen."; return; }
|
if (end < start) { Status = "Das Enddatum darf nicht vor dem Startdatum liegen."; return; }
|
||||||
Busy = true; Rows.Clear(); LocalOnlyRows.Clear();
|
Busy = true; Rows.Clear(); LocalOnlyRows.Clear();
|
||||||
try
|
try
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<UserControl xmlns="https://github.com/avaloniaui"
|
<UserControl xmlns="https://github.com/avaloniaui"
|
||||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
xmlns:vm="clr-namespace:LehrerApp.Desktop.ViewModels.ClassTeacher"
|
xmlns:vm="clr-namespace:LehrerApp.Desktop.ViewModels.ClassTeacher"
|
||||||
|
xmlns:conv="clr-namespace:LehrerApp.Desktop.Converters"
|
||||||
x:Class="LehrerApp.Desktop.Views.ClassTeacher.ClassTeacherAbsencesView"
|
x:Class="LehrerApp.Desktop.Views.ClassTeacher.ClassTeacherAbsencesView"
|
||||||
x:DataType="vm:ClassTeacherDetailsViewModel">
|
x:DataType="vm:ClassTeacherDetailsViewModel">
|
||||||
|
|
||||||
@@ -45,9 +46,9 @@
|
|||||||
<ComboBoxItem Content="Heute"/><ComboBoxItem Content="Letzte 7 Tage"/><ComboBoxItem Content="Letzte 30 Tage"/>
|
<ComboBoxItem Content="Heute"/><ComboBoxItem Content="Letzte 7 Tage"/><ComboBoxItem Content="Letzte 30 Tage"/>
|
||||||
</ComboBox>
|
</ComboBox>
|
||||||
<TextBlock Grid.Column="1" Text="von" VerticalAlignment="Center" Opacity="0.6"/>
|
<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"/>
|
<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 Grid.Row="1" ColumnDefinitions="*,Auto,Auto" ColumnSpacing="8">
|
<Grid Grid.Row="1" ColumnDefinitions="*,Auto,Auto" ColumnSpacing="8">
|
||||||
<TextBox Grid.Column="0" Text="{Binding StudentFilter, Mode=TwoWay}" PlaceholderText="Schüler*in filtern…"/>
|
<TextBox Grid.Column="0" Text="{Binding StudentFilter, Mode=TwoWay}" PlaceholderText="Schüler*in filtern…"/>
|
||||||
|
|||||||
@@ -0,0 +1,187 @@
|
|||||||
|
<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" 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="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" 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="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,23 @@
|
|||||||
<Border Grid.Row="0" Background="{DynamicResource AppCardBackgroundBrush}"
|
<Border Grid.Row="0" Background="{DynamicResource AppCardBackgroundBrush}"
|
||||||
BorderBrush="{DynamicResource AppCardBorderBrush}" BorderThickness="1"
|
BorderBrush="{DynamicResource AppCardBorderBrush}" BorderThickness="1"
|
||||||
CornerRadius="8" Padding="8" Margin="0,0,0,4">
|
CornerRadius="8" Padding="8" Margin="0,0,0,4">
|
||||||
<Grid ColumnDefinitions="Auto,Auto,Auto,*,250" ColumnSpacing="8">
|
<Grid ColumnDefinitions="Auto,Auto,Auto,Auto,*,250" ColumnSpacing="8">
|
||||||
<Button Grid.Column="0" Classes="filter" Classes.active="{Binding AlertsFilterSelected}"
|
<Button Grid.Column="0" Classes="filter" Classes.active="{Binding AlertsFilterSelected}"
|
||||||
Content="Auffällig" Command="{Binding ShowAlertsCommand}"/>
|
Content="Auffällig" Command="{Binding ShowAlertsCommand}"/>
|
||||||
<Button Grid.Column="1" Classes="filter" Classes.active="{Binding ClassRegisterFilterSelected}"
|
<Button Grid.Column="1" Classes="filter" Classes.active="{Binding ClassRegisterFilterSelected}"
|
||||||
Content="Klassenbuch" Command="{Binding ShowClassRegisterCommand}"/>
|
Content="Klassenbuch" Command="{Binding ShowClassRegisterCommand}"/>
|
||||||
<Button Grid.Column="2" Classes="filter" Classes.active="{Binding AllFilterSelected}"
|
<Button Grid.Column="2" Classes="filter" Classes.active="{Binding AllFilterSelected}"
|
||||||
Content="Alle" Command="{Binding ShowAllCommand}"/>
|
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."/>
|
||||||
|
<TextBox Grid.Column="5" Text="{Binding SearchText, Mode=TwoWay}" PlaceholderText="Schüler*in suchen…"
|
||||||
FontSize="12" MinHeight="32"/>
|
FontSize="12" MinHeight="32"/>
|
||||||
</Grid>
|
</Grid>
|
||||||
</Border>
|
</Border>
|
||||||
@@ -464,6 +473,22 @@
|
|||||||
<StackPanel Spacing="8">
|
<StackPanel Spacing="8">
|
||||||
<TextBlock Text="Nächste Schritte" FontSize="14" FontWeight="SemiBold"/>
|
<TextBlock Text="Nächste Schritte" FontSize="14" FontWeight="SemiBold"/>
|
||||||
<Button Content="Klassenbuch öffnen" Command="{Binding OpenClassRegisterCommand}" HorizontalAlignment="Stretch"/>
|
<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="Fehlzeiten öffnen" Command="{Binding OpenAbsencesCommand}" HorizontalAlignment="Stretch"/>
|
||||||
<Button Content="Aufgaben & Wiedervorlagen" Command="{Binding GoToWorkloadCommand}"
|
<Button Content="Aufgaben & Wiedervorlagen" Command="{Binding GoToWorkloadCommand}"
|
||||||
HorizontalAlignment="Stretch" Background="Transparent"/>
|
HorizontalAlignment="Stretch" Background="Transparent"/>
|
||||||
@@ -482,6 +507,9 @@
|
|||||||
<ContentPage Header="Fehlzeiten">
|
<ContentPage Header="Fehlzeiten">
|
||||||
<views:ClassTeacherAbsencesView DataContext="{Binding DetailsTab}"/>
|
<views:ClassTeacherAbsencesView DataContext="{Binding DetailsTab}"/>
|
||||||
</ContentPage>
|
</ContentPage>
|
||||||
|
<ContentPage Header="Vorgänge">
|
||||||
|
<views:ClassTeacherCasesView DataContext="{Binding CasesTab}"/>
|
||||||
|
</ContentPage>
|
||||||
</TabbedPage>
|
</TabbedPage>
|
||||||
</Grid>
|
</Grid>
|
||||||
</UserControl>
|
</UserControl>
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
<UserControl xmlns="https://github.com/avaloniaui"
|
<UserControl xmlns="https://github.com/avaloniaui"
|
||||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
xmlns:vm="clr-namespace:LehrerApp.Desktop.ViewModels.ClassTeacher"
|
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:Class="LehrerApp.Desktop.Views.ClassTeacher.ClassTeacherRegisterView"
|
||||||
x:DataType="vm:ClassTeacherDetailsViewModel">
|
x:DataType="vm:ClassTeacherDetailsViewModel">
|
||||||
|
|
||||||
@@ -26,28 +28,86 @@
|
|||||||
<Style Selector="TextBlock.status.danger">
|
<Style Selector="TextBlock.status.danger">
|
||||||
<Setter Property="Foreground" Value="{DynamicResource AppStatusDangerBrush}"/>
|
<Setter Property="Foreground" Value="{DynamicResource AppStatusDangerBrush}"/>
|
||||||
</Style>
|
</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>
|
</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">
|
<Grid Grid.Row="0" ColumnDefinitions="*,Auto">
|
||||||
<StackPanel>
|
<StackPanel>
|
||||||
<TextBlock Text="Klassenbucheinträge" FontSize="22" FontWeight="SemiBold"/>
|
<TextBlock Text="Klassenbuch" FontSize="22" FontWeight="SemiBold"/>
|
||||||
<TextBlock Text="Einträge anderer Lehrkräfte – nur zur Ansicht" FontSize="12" Opacity="0.55"/>
|
<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>
|
</StackPanel>
|
||||||
<Button Grid.Column="1" Content="↻ Aus WebUntis aktualisieren" Command="{Binding RefreshCommand}"
|
<Button Grid.Column="1" Content="↻ Aus WebUntis aktualisieren" Command="{Binding RefreshCommand}"
|
||||||
IsEnabled="{Binding !Busy}" Background="Transparent" Foreground="{DynamicResource AppAccentTextBrush}" VerticalAlignment="Center"/>
|
IsEnabled="{Binding !Busy}" Background="Transparent" Foreground="{DynamicResource AppAccentTextBrush}" VerticalAlignment="Center"/>
|
||||||
</Grid>
|
</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 RowDefinitions="Auto,Auto" RowSpacing="8">
|
||||||
<Grid Grid.Row="0" ColumnDefinitions="155,Auto,*,Auto,*" ColumnSpacing="8">
|
<Grid Grid.Row="0" ColumnDefinitions="155,Auto,*,Auto,*" ColumnSpacing="8">
|
||||||
<ComboBox Grid.Column="0" SelectedIndex="{Binding QuickRangeIndex, Mode=TwoWay}">
|
<ComboBox Grid.Column="0" SelectedIndex="{Binding QuickRangeIndex, Mode=TwoWay}">
|
||||||
<ComboBoxItem Content="Heute"/><ComboBoxItem Content="Letzte 7 Tage"/><ComboBoxItem Content="Letzte 30 Tage"/>
|
<ComboBoxItem Content="Heute"/><ComboBoxItem Content="Letzte 7 Tage"/><ComboBoxItem Content="Letzte 30 Tage"/>
|
||||||
</ComboBox>
|
</ComboBox>
|
||||||
<TextBlock Grid.Column="1" Text="von" VerticalAlignment="Center" Opacity="0.6"/>
|
<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"/>
|
<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 Grid.Row="1" ColumnDefinitions="*,Auto,Auto" ColumnSpacing="8">
|
<Grid Grid.Row="1" ColumnDefinitions="*,Auto,Auto" ColumnSpacing="8">
|
||||||
<TextBox Grid.Column="0" Text="{Binding StudentFilter, Mode=TwoWay}" PlaceholderText="Schüler*in filtern…"/>
|
<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
|
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
|
interessante Information. Als Chip-Reihe statt eigener Spalte in der Tabelle, damit die
|
||||||
bestehenden Spalten unangetastet bleiben. -->
|
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">
|
<StackPanel Spacing="6">
|
||||||
<TextBlock Text="Kategorien im Zeitraum" FontSize="12" FontWeight="SemiBold" Opacity="0.7"/>
|
<TextBlock Text="Kategorien im Zeitraum" FontSize="12" FontWeight="SemiBold" Opacity="0.7"/>
|
||||||
<ItemsControl ItemsSource="{Binding CategoryAggregates}">
|
<ItemsControl ItemsSource="{Binding CategoryAggregates}">
|
||||||
@@ -86,8 +146,9 @@
|
|||||||
</StackPanel>
|
</StackPanel>
|
||||||
</Border>
|
</Border>
|
||||||
|
|
||||||
<Grid Grid.Row="3">
|
<Grid Grid.Row="4" IsVisible="{Binding !ShowOwnDocumentation}">
|
||||||
<DataGrid ItemsSource="{Binding Entries}" AutoGenerateColumns="False" IsReadOnly="True"
|
<DataGrid ItemsSource="{Binding Entries}" SelectedItem="{Binding SelectedEntry, Mode=TwoWay}"
|
||||||
|
AutoGenerateColumns="False" IsReadOnly="True"
|
||||||
GridLinesVisibility="Horizontal" BorderBrush="{DynamicResource AppCardBorderBrush}" BorderThickness="1"
|
GridLinesVisibility="Horizontal" BorderBrush="{DynamicResource AppCardBorderBrush}" BorderThickness="1"
|
||||||
CanUserResizeColumns="True" CanUserSortColumns="True" RowHeight="46">
|
CanUserResizeColumns="True" CanUserSortColumns="True" RowHeight="46">
|
||||||
<DataGrid.Columns>
|
<DataGrid.Columns>
|
||||||
@@ -99,12 +160,89 @@
|
|||||||
<DataGridTextColumn Header="Gruppe" Binding="{Binding CategoryGroup}" Width="0.8*"/>
|
<DataGridTextColumn Header="Gruppe" Binding="{Binding CategoryGroup}" Width="0.8*"/>
|
||||||
<DataGridTextColumn Header="Eintrag" Binding="{Binding Text}" Width="2*"/>
|
<DataGridTextColumn Header="Eintrag" Binding="{Binding Text}" Width="2*"/>
|
||||||
</DataGrid.Columns>
|
</DataGrid.Columns>
|
||||||
|
<DataGrid.ContextMenu>
|
||||||
|
<ContextMenu>
|
||||||
|
<MenuItem Header="→ An Vorgang anheften" Command="{Binding PinToVorgangCommand}"
|
||||||
|
CommandParameter="{Binding SelectedEntry}"/>
|
||||||
|
</ContextMenu>
|
||||||
|
</DataGrid.ContextMenu>
|
||||||
</DataGrid>
|
</DataGrid>
|
||||||
<StackPanel IsVisible="{Binding !HasEntries}" HorizontalAlignment="Center" VerticalAlignment="Center" Spacing="5">
|
<StackPanel IsVisible="{Binding !HasEntries}" HorizontalAlignment="Center" VerticalAlignment="Center" Spacing="5">
|
||||||
<TextBlock Text="Keine Klassenbucheinträge im gewählten Zeitraum" FontWeight="SemiBold"/>
|
<TextBlock Text="Keine Klassenbucheinträge im gewählten Zeitraum" FontWeight="SemiBold"/>
|
||||||
<TextBlock Text="Passe Zeitraum oder Schülerfilter an." FontSize="12" Opacity="0.55"/>
|
<TextBlock Text="Passe Zeitraum oder Schülerfilter an." FontSize="12" Opacity="0.55"/>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
</Grid>
|
</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>
|
</Grid>
|
||||||
</UserControl>
|
</UserControl>
|
||||||
|
|||||||
@@ -1,8 +1,65 @@
|
|||||||
using Avalonia.Controls;
|
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;
|
namespace LehrerApp.Desktop.Views.ClassTeacher;
|
||||||
|
|
||||||
public partial class ClassTeacherRegisterView : UserControl
|
public partial class ClassTeacherRegisterView : UserControl
|
||||||
{
|
{
|
||||||
public ClassTeacherRegisterView() => InitializeComponent();
|
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,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"
|
<Window xmlns="https://github.com/avaloniaui"
|
||||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
xmlns:vm="clr-namespace:LehrerApp.Desktop.ViewModels.Groups"
|
xmlns:vm="clr-namespace:LehrerApp.Desktop.ViewModels.Groups"
|
||||||
|
xmlns:conv="clr-namespace:LehrerApp.Desktop.Converters"
|
||||||
x:Class="LehrerApp.Desktop.Views.Groups.WebUntisLessonAbsenceComparisonDialog"
|
x:Class="LehrerApp.Desktop.Views.Groups.WebUntisLessonAbsenceComparisonDialog"
|
||||||
x:DataType="vm:WebUntisLessonAbsenceComparisonViewModel"
|
x:DataType="vm:WebUntisLessonAbsenceComparisonViewModel"
|
||||||
Title="Fehlzeiten je Unterricht mit WebUntis abgleichen" Width="1000" Height="640"
|
Title="Fehlzeiten je Unterricht mit WebUntis abgleichen" Width="1000" Height="640"
|
||||||
@@ -12,9 +13,9 @@
|
|||||||
FontSize="12" Opacity="0.65" TextWrapping="Wrap"/>
|
FontSize="12" Opacity="0.65" TextWrapping="Wrap"/>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
<StackPanel Grid.Row="1" Orientation="Horizontal" Spacing="8">
|
<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"/>
|
<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}"/>
|
<Button Content="Fehlzeiten laden" Command="{Binding LoadCommand}" IsEnabled="{Binding !Busy}"/>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
<Grid Grid.Row="2" ColumnDefinitions="Auto,1.2*,1.2*,80,80,1.1*,1.1*" ColumnSpacing="8" Margin="4,0">
|
<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"
|
<Window xmlns="https://github.com/avaloniaui"
|
||||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
xmlns:vm="clr-namespace:LehrerApp.Desktop.ViewModels.Groups"
|
xmlns:vm="clr-namespace:LehrerApp.Desktop.ViewModels.Groups"
|
||||||
|
xmlns:conv="clr-namespace:LehrerApp.Desktop.Converters"
|
||||||
x:Class="LehrerApp.Desktop.Views.Groups.WithdrawStudentDialog"
|
x:Class="LehrerApp.Desktop.Views.Groups.WithdrawStudentDialog"
|
||||||
x:DataType="vm:WithdrawStudentDialogViewModel"
|
x:DataType="vm:WithdrawStudentDialogViewModel"
|
||||||
Title="Schüler austragen"
|
Title="Schüler austragen"
|
||||||
@@ -23,8 +24,8 @@
|
|||||||
|
|
||||||
<StackPanel Spacing="5">
|
<StackPanel Spacing="5">
|
||||||
<TextBlock Text="Austrittsdatum" FontSize="12" Opacity="0.7"/>
|
<TextBlock Text="Austrittsdatum" FontSize="12" Opacity="0.7"/>
|
||||||
<CalendarDatePicker SelectedDate="{Binding SelectedDate}"
|
<CalendarDatePicker SelectedDate="{Binding SelectedDate, Converter={x:Static conv:DateTimeOffsetToDateTimeConverter.Instance}}"
|
||||||
DisplayDateStart="{Binding EarliestDate}"
|
DisplayDateStart="{Binding EarliestDate, Converter={x:Static conv:DateTimeOffsetToDateTimeConverter.Instance}}"
|
||||||
HorizontalAlignment="Stretch"/>
|
HorizontalAlignment="Stretch"/>
|
||||||
<TextBlock Text="{Binding DateError}" Foreground="Red" FontSize="12"
|
<TextBlock Text="{Binding DateError}" Foreground="Red" FontSize="12"
|
||||||
IsVisible="{Binding DateError, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
|
IsVisible="{Binding DateError, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
xmlns:vm="clr-namespace:LehrerApp.Desktop.ViewModels.Planning"
|
xmlns:vm="clr-namespace:LehrerApp.Desktop.ViewModels.Planning"
|
||||||
xmlns:svc="clr-namespace:LehrerApp.Desktop.Services"
|
xmlns:svc="clr-namespace:LehrerApp.Desktop.Services"
|
||||||
|
xmlns:conv="clr-namespace:LehrerApp.Desktop.Converters"
|
||||||
x:Class="LehrerApp.Desktop.Views.Planning.WebUntisTimetableImportDialog"
|
x:Class="LehrerApp.Desktop.Views.Planning.WebUntisTimetableImportDialog"
|
||||||
x:DataType="vm:WebUntisTimetableImportViewModel"
|
x:DataType="vm:WebUntisTimetableImportViewModel"
|
||||||
Title="Stundenplan aus WebUntis" Width="820" Height="650"
|
Title="Stundenplan aus WebUntis" Width="820" Height="650"
|
||||||
@@ -18,7 +19,7 @@
|
|||||||
<DataTemplate x:DataType="svc:UntisTeacherDto"><TextBlock Text="{Binding DisplayName}"/></DataTemplate>
|
<DataTemplate x:DataType="svc:UntisTeacherDto"><TextBlock Text="{Binding DisplayName}"/></DataTemplate>
|
||||||
</ComboBox.ItemTemplate>
|
</ComboBox.ItemTemplate>
|
||||||
</ComboBox>
|
</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}"/>
|
<Button Grid.Column="2" Content="Woche laden" Command="{Binding LoadCommand}" IsEnabled="{Binding !Busy}"/>
|
||||||
</Grid>
|
</Grid>
|
||||||
<ScrollViewer Grid.Row="2">
|
<ScrollViewer Grid.Row="2">
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<Window xmlns="https://github.com/avaloniaui"
|
<Window xmlns="https://github.com/avaloniaui"
|
||||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
xmlns:vm="clr-namespace:LehrerApp.Desktop.ViewModels.Students"
|
xmlns:vm="clr-namespace:LehrerApp.Desktop.ViewModels.Students"
|
||||||
|
xmlns:conv="clr-namespace:LehrerApp.Desktop.Converters"
|
||||||
x:Class="LehrerApp.Desktop.Views.Students.CreateLetterDialog"
|
x:Class="LehrerApp.Desktop.Views.Students.CreateLetterDialog"
|
||||||
x:DataType="vm:CreateLetterDialogViewModel"
|
x:DataType="vm:CreateLetterDialogViewModel"
|
||||||
Title="PDF-Brief erstellen" Width="580" Height="760"
|
Title="PDF-Brief erstellen" Width="580" Height="760"
|
||||||
@@ -40,7 +41,7 @@
|
|||||||
</StackPanel>
|
</StackPanel>
|
||||||
<StackPanel Grid.Column="2" Spacing="4">
|
<StackPanel Grid.Column="2" Spacing="4">
|
||||||
<TextBlock Text="Briefdatum *" FontSize="12" Opacity="0.7"/>
|
<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>
|
</StackPanel>
|
||||||
</Grid>
|
</Grid>
|
||||||
<StackPanel Spacing="4">
|
<StackPanel Spacing="4">
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<Window xmlns="https://github.com/avaloniaui"
|
<Window xmlns="https://github.com/avaloniaui"
|
||||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
xmlns:vm="clr-namespace:LehrerApp.Desktop.ViewModels.Students"
|
xmlns:vm="clr-namespace:LehrerApp.Desktop.ViewModels.Students"
|
||||||
|
xmlns:conv="clr-namespace:LehrerApp.Desktop.Converters"
|
||||||
x:Class="LehrerApp.Desktop.Views.Students.WebUntisDocumentationComparisonDialog"
|
x:Class="LehrerApp.Desktop.Views.Students.WebUntisDocumentationComparisonDialog"
|
||||||
x:DataType="vm:WebUntisDocumentationComparisonViewModel"
|
x:DataType="vm:WebUntisDocumentationComparisonViewModel"
|
||||||
Title="Klassenbucheinträge mit WebUntis abgleichen" Width="1150" Height="720"
|
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."/>
|
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>
|
||||||
<StackPanel Grid.Row="1" Orientation="Horizontal" Spacing="8">
|
<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"/>
|
<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}"/>
|
<Button Content="Klassenbucheinträge laden" Command="{Binding LoadCommand}" IsEnabled="{Binding !Busy}"/>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
|
|
||||||
|
|||||||
@@ -146,6 +146,7 @@ public class EventApplier(LiteDbContext db, byte[] syncKey, HttpClient? http = n
|
|||||||
Simple<SupervisionDuty>(context => context.SupervisionDuties);
|
Simple<SupervisionDuty>(context => context.SupervisionDuties);
|
||||||
Simple<SubstitutionEntry>(context => context.SubstitutionEntries);
|
Simple<SubstitutionEntry>(context => context.SubstitutionEntries);
|
||||||
Simple<CompetencyDomain>(context => context.CompetencyDomains);
|
Simple<CompetencyDomain>(context => context.CompetencyDomains);
|
||||||
|
Simple<Vorgang>(context => context.Vorgaenge);
|
||||||
|
|
||||||
// Kaskaden-Fälle: dieselben internen LiteDbContext-Hilfsmethoden wie die jeweiligen
|
// Kaskaden-Fälle: dieselben internen LiteDbContext-Hilfsmethoden wie die jeweiligen
|
||||||
// Repositories, damit die Kaskade nur an einer Stelle im Code existiert.
|
// Repositories, damit die Kaskade nur an einer Stelle im Code existiert.
|
||||||
|
|||||||
@@ -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
|
`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
|
Einzeldatumsfelder in dieser Codebasis (`WithdrawStudentDialog`, `CreateLetterDialog`, 7.2.3), dort
|
||||||
ohne dieses Problem, da ein Klick auf einen Kalendertag sofort übernimmt statt einen
|
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),
|
[ClassTeacherAbsencesView.axaml](LehrerApp.Desktop/Views/ClassTeacher/ClassTeacherAbsencesView.axaml),
|
||||||
[WebUntisTimetableImportDialog.axaml](LehrerApp.Desktop/Views/Planning/WebUntisTimetableImportDialog.axaml)
|
[WebUntisTimetableImportDialog.axaml](LehrerApp.Desktop/Views/Planning/WebUntisTimetableImportDialog.axaml)
|
||||||
und [WebUntisDocumentationComparisonDialog.axaml](LehrerApp.Desktop/Views/Students/WebUntisDocumentationComparisonDialog.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
|
**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
|
rief `getTimetableWithAbsences` ohne Element auf und bekam damit den kompletten Lehrer-Stundenplan
|
||||||
zurück (einmal pro Kursmitglied, siehe damalige Ineffizienz-Korrektur) — das erfordert mehr
|
zurück (einmal pro Kursmitglied, siehe damalige Ineffizienz-Korrektur) — das erfordert mehr
|
||||||
@@ -1760,6 +1780,117 @@ 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
|
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
|
dem WebUntis-Fehlzeitenbericht allein nicht unterscheidbar, dafür bräuchte es Daten darüber, ob
|
||||||
für eine Stunde überhaupt eine Anwesenheitsprüfung stattfand.
|
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).
|
||||||
|
**Bewusst zurückgestellt ("Runde 2", Nutzer-Wunsch):** dass ein bearbeiteter/geschlossener Vorgang
|
||||||
|
automatisch den `PatternScore` dämpft, sowie Trend-Indikatoren ("bewegt sich wieder in die
|
||||||
|
falsche Richtung", z.B. Fehlzeiten oder Konflikt-Einträge nehmen erneut zu) — reines Anlegen/
|
||||||
|
Verknüpfen/Verwalten in dieser Runde, keine Rückwirkung auf Score oder Sortierung.
|
||||||
|
|
||||||
### 4.4 Wochen-/Tagesansicht
|
### 4.4 Wochen-/Tagesansicht
|
||||||
- [x] **4.4.1** Kalenderansicht über alle Gruppen: Woche und Tag — siehe Nachtrag zu 4.3
|
- [x] **4.4.1** Kalenderansicht über alle Gruppen: Woche und Tag — siehe Nachtrag zu 4.3
|
||||||
@@ -2153,6 +2284,22 @@ folgenden Punkte gehören direkt in `LehrerApp.Desktop`:
|
|||||||
den vollen Verlaufsplan-Editor öffnen müssen. Neue kleine
|
den vollen Verlaufsplan-Editor öffnen müssen. Neue kleine
|
||||||
`TeachingModeHomeworkViewModel : ObservableObject`, da `TeachingModeViewModel` selbst (wie
|
`TeachingModeHomeworkViewModel : ObservableObject`, da `TeachingModeViewModel` selbst (wie
|
||||||
`LessonViewerViewModel`) keine Bindable-Basisklasse hat.
|
`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
|
- [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
|
(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
|
falschen Ort gelöst — ein Dropdown in der "Heute"-**Tagesliste** (unten angedockt), obwohl
|
||||||
|
|||||||
Reference in New Issue
Block a user