Dashboard warnings (attendance, homework, lateness) get a "Details" flyout with key figures, a list of dates and the compact month calendar from the class teacher overview, now extracted as a shared control. The calendar now aligns days under their weekday (Mo-So header, blank leading cells). Creating Vorgänge and documentation is supported by quick blocks with the current figures, dated standard phrases (e.g. "Klassenleitung informiert") and documentation templates (status snapshot, conversation, informing the class/subject teachers, parent call, parent letter). The class teacher Vorgänge tab uses WebUntis figures (absences across all subjects, class register) for the same blocks and templates. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
0f6cadc9c8
commit
aa326cf8b6
25 files changed
+1571
-75
No files matched your search
@@ -0,0 +1,167 @@
|
||||
using LehrerApp.Core.Models;
|
||||
using LehrerApp.Core.Services;
|
||||
using LehrerApp.Desktop.Services;
|
||||
using LehrerApp.Desktop.ViewModels;
|
||||
using LehrerApp.Desktop.ViewModels.ClassTeacher;
|
||||
using LehrerApp.Desktop.ViewModels.Students;
|
||||
using Xunit;
|
||||
|
||||
namespace LehrerApp.Desktop.Tests;
|
||||
|
||||
/// Tests für die Belege hinter den klassenübergreifenden Dashboard-Warnungen: das Detail-Popup
|
||||
/// (StudentWarningDetailViewModel), die Schnellbausteine im Vorgang-Dialog und die
|
||||
/// Dokumentationsvorlagen im schülerbezogenen Vorgänge-Tab. Alle Termine liegen ab
|
||||
/// Schuljahresbeginn, damit die Tests unabhängig vom Ausführungsdatum im laufenden Schuljahr landen.
|
||||
public sealed class StudentWarningEvidenceTests
|
||||
{
|
||||
private readonly Student _student = new() { FirstName = "Anna", LastName = "Beispiel" };
|
||||
private readonly LearningGroup _group = new() { Name = "9c Mathe" };
|
||||
private readonly List<ParticipationSession> _sessions = [];
|
||||
private readonly FakeEntries _entries = new();
|
||||
private readonly DateOnly _start;
|
||||
|
||||
public StudentWarningEvidenceTests()
|
||||
{
|
||||
var sy = new SchoolYearService();
|
||||
_start = sy.SchoolYearStart(sy.CurrentSchoolYear());
|
||||
}
|
||||
|
||||
private void AddEntry(int dayOffset, AttendanceStatus? attendance = null, HomeworkStatus? homework = null)
|
||||
{
|
||||
var session = new ParticipationSession { GroupId = _group.Id, Date = _start.AddDays(dayOffset) };
|
||||
_sessions.Add(session);
|
||||
_entries.Add(new ParticipationEntry
|
||||
{ SessionId = session.Id, StudentId = _student.Id, Attendance = attendance, Homework = homework });
|
||||
}
|
||||
|
||||
private StudentWarningEvidenceService BuildService() => new(new FakeStudents([_student]),
|
||||
new FakeSessions(_sessions), _entries, new FakeGroups([_group]), new SchoolYearService(),
|
||||
new AttendanceBalanceService());
|
||||
|
||||
[Fact]
|
||||
public void Build_ZaehltWieDieDashboardWarnungen()
|
||||
{
|
||||
AddEntry(0, AttendanceStatus.Present);
|
||||
AddEntry(1, AttendanceStatus.Excused);
|
||||
AddEntry(2, AttendanceStatus.Unexcused);
|
||||
AddEntry(3, AttendanceStatus.ExcusePending);
|
||||
AddEntry(4, AttendanceStatus.OtherSchoolEvent);
|
||||
AddEntry(5, AttendanceStatus.Late, HomeworkStatus.MissingOpen);
|
||||
AddEntry(6, AttendanceStatus.SignificantlyLate, HomeworkStatus.Completed);
|
||||
|
||||
var e = BuildService().Build(_student.Id);
|
||||
|
||||
Assert.Equal(7, e.Balance.TotalChecked);
|
||||
Assert.Equal(3, e.Absences.Count); // Schulveranstaltung zählt nicht als Fehlzeit
|
||||
Assert.Equal(["A", "U", "E"], e.Absences.Select(a => a.Code)); // neueste zuerst
|
||||
Assert.Equal(2, e.Lateness.Count);
|
||||
Assert.Equal(1, e.SignificantlyLateCount);
|
||||
Assert.Equal("9c Mathe", Assert.Single(e.MissingHomework).GroupName);
|
||||
Assert.Equal("Anna Beispiel", e.StudentNaturalName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void QuickBlocks_NurBausteineMitInhalt()
|
||||
{
|
||||
AddEntry(0, AttendanceStatus.Unexcused);
|
||||
AddEntry(1, AttendanceStatus.Present);
|
||||
|
||||
var blocks = StudentEvidenceText.QuickBlocks(BuildService().Build(_student.Id));
|
||||
|
||||
Assert.Equal(["Fehlquote", "Fehltermine"], blocks.Select(b => b.Label));
|
||||
Assert.Contains("Fehlquote 50 %", blocks[0].Text);
|
||||
Assert.Contains("9c Mathe", blocks[1].Text);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void VorgangDialog_InsertQuickBlock_HaengtAnStattZuErsetzen()
|
||||
{
|
||||
var vm = new VorgangDialogViewModel([new StudentOption(_student.Id, "Anna")], null,
|
||||
[new VorgangQuickBlock("Fehlquote", "Stand: 25 %")]) { Description = "Eigene Notiz" };
|
||||
|
||||
vm.InsertQuickBlockCommand.Execute(vm.QuickBlocks[0]);
|
||||
|
||||
Assert.Equal("Eigene Notiz\n\nStand: 25 %", vm.Description);
|
||||
Assert.Equal("Vorgang anlegen", vm.DialogTitle);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Vorlage_ElternanrufUebernimmtZahlenPassendZumVorgangsTag()
|
||||
{
|
||||
for (var i = 0; i < 3; i++) AddEntry(i, AttendanceStatus.Present, HomeworkStatus.MissingOverdue);
|
||||
var vorgang = new Vorgang { Title = "Hausaufgaben", StudentIds = [_student.Id], Tags = ["Hausaufgaben"] };
|
||||
|
||||
var doc = VorgangDocumentationTemplates.Build(VorgangDocumentationTemplates.ParentCall, vorgang,
|
||||
BuildService().Build(_student.Id))!;
|
||||
|
||||
Assert.Equal(DocumentationType.ParentCall, doc.Type);
|
||||
Assert.Equal(_student.Id, doc.StudentId);
|
||||
Assert.StartsWith("Aktueller Stand: 3 nicht bzw. unvollständig erledigte Hausaufgaben", doc.ParentCallData!.Points[0].Text);
|
||||
Assert.DoesNotContain(doc.ParentCallData.Points, p => p.Text.Contains("Fehlquote"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DetailPopup_StartetImMonatDesJuengstenBelegsUndZeigtKuerzel()
|
||||
{
|
||||
AddEntry(0, AttendanceStatus.Unexcused);
|
||||
AddEntry(0, AttendanceStatus.Excused); // zweite Stunde am selben Tag: U gewinnt
|
||||
AddEntry(1, AttendanceStatus.Late);
|
||||
|
||||
var vm = new StudentWarningDetailViewModel(AttentionKind.Attendance, BuildService().Build(_student.Id));
|
||||
|
||||
Assert.Equal(new DateOnly(_start.Year, _start.Month, 1), vm.Month);
|
||||
var first = vm.CalendarDays.Single(d => d.Date == _start);
|
||||
Assert.Equal("U", first.SignalCode);
|
||||
Assert.Contains("Krank, entschuldigt", first.Tooltip);
|
||||
Assert.Equal("V", vm.CalendarDays.Single(d => d.Date == _start.AddDays(1)).SignalCode);
|
||||
Assert.False(vm.PreviousMonthCommand.CanExecute(null)); // vor Schuljahresbeginn gibt es nichts
|
||||
Assert.Equal(2, vm.Events.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HandleWarningNavigation_BelegtBeschreibungMitAktuellemStandVor()
|
||||
{
|
||||
for (var i = 0; i < 4; i++) AddEntry(i, AttendanceStatus.Unexcused);
|
||||
var vm = new StudentVorgaengeViewModel(new FakeVorgangRepository(), new FakeDocumentation(),
|
||||
new FakeStudents([_student]), BuildService());
|
||||
vm.Load(_student.Id);
|
||||
vm.OnConfirmCreateForWarning = _ => Task.FromResult(true);
|
||||
Vorgang? draft = null;
|
||||
vm.OnEditVorgang = (_, editing) => { draft = editing; return Task.FromResult<Vorgang?>(null); };
|
||||
|
||||
await vm.HandleWarningNavigation("Absentismus", "Fehlzeiten");
|
||||
|
||||
Assert.Contains("Fehlquote 100 %", draft!.Description);
|
||||
Assert.Contains("Fehltermine:", draft.Description);
|
||||
Assert.False(vm.IsPersisted(draft));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task VorlageAusVorgangstab_OeffnetEntwurfUndVerknuepftNachSpeichern()
|
||||
{
|
||||
AddEntry(0, AttendanceStatus.Unexcused);
|
||||
var vorgaenge = new FakeVorgangRepository();
|
||||
var vorgang = new Vorgang { Title = "Fehlzeiten", StudentIds = [_student.Id], Tags = ["Absentismus"] };
|
||||
vorgaenge.Add(vorgang);
|
||||
var documentation = new FakeDocumentation();
|
||||
var vm = new StudentVorgaengeViewModel(vorgaenge, documentation, new FakeStudents([_student]), BuildService());
|
||||
vm.Load(_student.Id);
|
||||
var wasDraft = false;
|
||||
vm.OnEditDocumentation = (_, editing) =>
|
||||
{
|
||||
wasDraft = vm.IsNewDocumentationDraft(editing!);
|
||||
return Task.FromResult(editing);
|
||||
};
|
||||
|
||||
var item = Assert.Single(vm.Cases);
|
||||
Assert.True(item.HasDocumentationTemplates);
|
||||
await item.CreateFromTemplateCommand.ExecuteAsync(VorgangDocumentationTemplates.Conversation);
|
||||
|
||||
Assert.True(wasDraft);
|
||||
var saved = Assert.Single(documentation.GetByStudent(_student.Id));
|
||||
Assert.Equal(DocumentationType.Conversation, saved.Type);
|
||||
Assert.Contains("Fehlquote 100 %", saved.Content);
|
||||
Assert.Contains(saved.Id, vorgang.DocumentationIds);
|
||||
Assert.True(Assert.Single(vm.Cases).IsRevealed);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
using LehrerApp.Core.Models;
|
||||
using LehrerApp.Desktop.ViewModels.ClassTeacher;
|
||||
using LehrerApp.Desktop.ViewModels.Students;
|
||||
using LehrerApp.Desktop.Views.Shared;
|
||||
using Xunit;
|
||||
|
||||
namespace LehrerApp.Desktop.Tests;
|
||||
|
||||
/// Tests für Standardformulierungen, schülerabhängige Bausteine im Vorgang-Dialog, den WebUntis-Stand
|
||||
/// der Klassenlehrer-Vorgänge und die Wochentags-Ausrichtung des kompakten Monatskalenders.
|
||||
public sealed class VorgangBausteineTests
|
||||
{
|
||||
[Fact]
|
||||
public void KompakterKalender_BeginntInDerSpalteDesWochentags()
|
||||
{
|
||||
// 1. September 2026 ist ein Dienstag -> eine Leerzelle (Montag) davor.
|
||||
var days = Enumerable.Range(1, 30).Select(d => new ClassTeacherCompactCalendarDay(
|
||||
new DateOnly(2026, 9, d), "", "#00000000", "", false)).ToList();
|
||||
|
||||
var cells = CompactMonthCalendar.BuildCells(days);
|
||||
|
||||
Assert.True(cells[0].IsPlaceholder);
|
||||
Assert.Equal(new DateOnly(2026, 9, 1), cells[1].Day!.Date);
|
||||
Assert.Equal(31, cells.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void KompakterKalender_MonatsbeginnAmMontag_OhneLeerzelle()
|
||||
{
|
||||
var days = new[] { new ClassTeacherCompactCalendarDay(new DateOnly(2026, 6, 1), "", "", "", false) };
|
||||
Assert.False(CompactMonthCalendar.BuildCells(days)[0].IsPlaceholder);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Standardformulierung_WirdAlsZeileAngehaengt()
|
||||
{
|
||||
var vm = new VorgangDialogViewModel([new StudentOption(Guid.NewGuid(), "Anna")], null,
|
||||
phraseBlocks: VorgangPhraseBlocks.ForStudent(new DateOnly(2026, 9, 26))) { Description = "Notiz" };
|
||||
|
||||
vm.InsertPhraseCommand.Execute(vm.PhraseBlocks.First(p => p.Label == "Klassenleitung informiert"));
|
||||
|
||||
Assert.Equal("Notiz\n26.09.2026: Klassenleitung informiert.", vm.Description);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SchuelerBausteine_FolgenDerAuswahlImDialog()
|
||||
{
|
||||
var anna = new StudentOption(Guid.NewGuid(), "Anna");
|
||||
var ben = new StudentOption(Guid.NewGuid(), "Ben");
|
||||
var vm = new VorgangDialogViewModel([anna, ben], null,
|
||||
studentQuickBlocks: ids => ids.Select(id => new VorgangQuickBlock(id == anna.Id ? "A" : "B", "")).ToList());
|
||||
|
||||
Assert.False(vm.HasQuickBlocks);
|
||||
vm.StudentOptions[1].IsSelected = true;
|
||||
Assert.Equal(["B"], vm.QuickBlocks.Select(b => b.Label));
|
||||
vm.StudentOptions[0].IsSelected = true;
|
||||
Assert.Equal(["A", "B"], vm.QuickBlocks.Select(b => b.Label));
|
||||
}
|
||||
|
||||
private static ClassAbsenceDaySummaryRow Absence(DateOnly date, string name, int periods, bool unexcused,
|
||||
bool fullDay = false, string reason = "Krankheit") => new(date, name, null, periods, periods * 45,
|
||||
["M"], [1], [unexcused ? "nicht entschuldigt" : "entschuldigt"], [reason], null, null, fullDay);
|
||||
|
||||
[Fact]
|
||||
public void KlassenlehrerStand_ZaehltFehlzeitenVerspaetungenUndKlassenbuch()
|
||||
{
|
||||
var student = new StudentOption(Guid.NewGuid(), "Beispiel Anna");
|
||||
var today = new DateOnly(2026, 9, 26);
|
||||
var absences = new[]
|
||||
{
|
||||
Absence(today.AddDays(-3), "Anna Beispiel", 6, unexcused: true, fullDay: true),
|
||||
Absence(today.AddDays(-5), "Anna Beispiel", 2, unexcused: false),
|
||||
Absence(today.AddDays(-6), "Anna Beispiel", 0, unexcused: false, reason: "Verspätung"),
|
||||
Absence(today.AddDays(-3), "Ben Fremd", 6, unexcused: true),
|
||||
};
|
||||
var register = new[]
|
||||
{
|
||||
new ClassTeacherClassRegisterRow(today.AddDays(-2), "D", "Anna Beispiel", "MUE", "Hausaufgaben fehlen", "Negativ", null),
|
||||
new ClassTeacherClassRegisterRow(today.AddDays(-1), "E", "Anna Beispiel", "SCH", "Lob", "Positiv", null),
|
||||
};
|
||||
|
||||
var facts = ClassTeacherStudentFacts.Build(student, "Anna Beispiel", "2026/27", today, absences, register);
|
||||
|
||||
Assert.Equal(8, facts.AbsentPeriods);
|
||||
Assert.Equal(1, facts.UnexcusedDays);
|
||||
Assert.Single(facts.LateDays);
|
||||
Assert.Equal(1, facts.NegativeRegisterCount);
|
||||
Assert.Single(facts.HomeworkEntries);
|
||||
Assert.Contains("8 Fehlstunden an 2 Tagen", facts.AbsenceShort);
|
||||
|
||||
var labels = facts.QuickBlocks(withName: true).Select(b => b.Label).ToList();
|
||||
Assert.Contains("Fehlzeiten · Beispiel Anna", labels);
|
||||
|
||||
var ctx = facts.TemplateContext(new Vorgang { Title = "Fehlzeiten", Tags = ["Absentismus"] });
|
||||
Assert.Equal([facts.AbsenceShort], ctx.SituationLines);
|
||||
var letter = VorgangDocumentationTemplates.Build(VorgangDocumentationTemplates.ParentLetter,
|
||||
new Vorgang { Title = "Fehlzeiten", Tags = ["Absentismus"] }, ctx)!;
|
||||
Assert.StartsWith("Sehr geehrte Erziehungsberechtigte von Anna Beispiel", letter.ParentLetterData!.DraftContent);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Vorlage_KlassenleitungInformieren()
|
||||
{
|
||||
var id = Guid.NewGuid();
|
||||
var ctx = new VorgangTemplateContext(id, "Anna Beispiel", new DateOnly(2026, 9, 26), ["Fehlquote 25 %"],
|
||||
new Dictionary<string, VorgangSnapshot>());
|
||||
|
||||
var doc = VorgangDocumentationTemplates.Build(VorgangDocumentationTemplates.InformClassTeacher,
|
||||
new Vorgang { Title = "Fehlzeiten" }, ctx)!;
|
||||
|
||||
Assert.Equal(DocumentationType.Conversation, doc.Type);
|
||||
Assert.Equal(["Klassenleitung"], doc.Participants);
|
||||
Assert.Contains("Klassenleitung am 26.09.2026 informiert über:\n- Fehlquote 25 %", doc.Content);
|
||||
Assert.Equal(id, doc.StudentId);
|
||||
}
|
||||
}
|
||||
@@ -358,6 +358,7 @@ public static class AppBootstrapper
|
||||
// Transient: neue Instanz pro Navigation (für Detailseiten)
|
||||
services.AddTransient<GroupDetailViewModel>();
|
||||
services.AddTransient<StudentDetailViewModel>();
|
||||
services.AddTransient<StudentWarningEvidenceService>();
|
||||
services.AddTransient<StudentVorgaengeViewModel>();
|
||||
services.AddTransient<GroupOverviewViewModel>();
|
||||
services.AddTransient<ParticipationTabViewModel>();
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
using System.Globalization;
|
||||
using LehrerApp.Core.Interfaces;
|
||||
using LehrerApp.Core.Models;
|
||||
using LehrerApp.Core.Services;
|
||||
using LehrerApp.Desktop.ViewModels.Groups;
|
||||
|
||||
namespace LehrerApp.Desktop.Services;
|
||||
|
||||
public enum StudentEvidenceKind { Absence, Lateness, Homework }
|
||||
|
||||
/// <summary>Ein einzelner Beleg hinter einer klassenübergreifenden Warnung (ein Fehltermin, eine
|
||||
/// Verspätung, eine offene Hausaufgabe) — Code/Farbe bewusst identisch mit der Klassenlehrer-
|
||||
/// Monatsansicht (ClassTeacherDetailsViewModel.BuildDayEvents: U/E/A/V/H), damit dasselbe Kürzel in
|
||||
/// beiden Widgets dasselbe bedeutet.</summary>
|
||||
public sealed record StudentEvidenceEvent(DateOnly Date, string GroupName, string Label, string Code,
|
||||
string ColorHex, StudentEvidenceKind Kind)
|
||||
{
|
||||
private static readonly CultureInfo De = new("de-DE");
|
||||
public string DateLabel => Date.ToString("ddd dd.MM.", De);
|
||||
public string ListLine => $"{Date:dd.MM.yyyy} · {GroupName}: {Label}";
|
||||
}
|
||||
|
||||
/// <summary>Momentaufnahme aller Belege einer/eines Schüler*in im laufenden Schuljahr. Grundlage
|
||||
/// sowohl für das Detail-Popup der Dashboard-Warnungen als auch für die Schnellbausteine/Vorlagen
|
||||
/// beim Anlegen von Vorgängen und Dokumentation — beides soll exakt dieselben Zahlen zeigen, die
|
||||
/// die Warnung ausgelöst haben.</summary>
|
||||
public sealed record StudentWarningEvidence(
|
||||
Guid StudentId, string StudentName, string StudentFirstName, string StudentNaturalName, string SchoolYear, DateOnly From, DateOnly To, DateOnly AsOf,
|
||||
AttendanceBalance Balance,
|
||||
IReadOnlyList<StudentEvidenceEvent> Absences,
|
||||
IReadOnlyList<StudentEvidenceEvent> Lateness,
|
||||
IReadOnlyList<StudentEvidenceEvent> MissingHomework)
|
||||
{
|
||||
public int SignificantlyLateCount => Lateness.Count(e => e.Code == "V!");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sammelt die Belege hinter den drei klassenübergreifenden Dashboard-Warnungen (Fehlzeiten,
|
||||
/// Hausaufgaben-Rückstand, Verspätungen) für eine/n einzelne/n Schüler*in. Zählt bewusst nach
|
||||
/// denselben Regeln wie DashboardViewModel.LoadAttendanceWarnings/LoadMissingHomeworkWarnings/
|
||||
/// LoadLatenessWarnings (Schuljahreszeitraum, AttendanceBalanceService, HomeworkDisplay.CountsAsMissing,
|
||||
/// Late/SignificantlyLate) — eine abweichende Zahl im Popup als in der Warnzeile wäre verwirrend.
|
||||
/// Keine eigene Persistenz, rein lesend.
|
||||
/// </summary>
|
||||
public class StudentWarningEvidenceService
|
||||
{
|
||||
private readonly IStudentRepository _students;
|
||||
private readonly IParticipationSessionRepository _sessions;
|
||||
private readonly IParticipationRepository _entries;
|
||||
private readonly IGroupRepository _groups;
|
||||
private readonly SchoolYearService _sy;
|
||||
private readonly AttendanceBalanceService _attendanceBalance;
|
||||
private readonly Func<DateTime> _now;
|
||||
|
||||
public StudentWarningEvidenceService(IStudentRepository students, IParticipationSessionRepository sessions,
|
||||
IParticipationRepository entries, IGroupRepository groups, SchoolYearService sy,
|
||||
AttendanceBalanceService attendanceBalance, Func<DateTime>? now = null)
|
||||
{
|
||||
_students = students;
|
||||
_sessions = sessions;
|
||||
_entries = entries;
|
||||
_groups = groups;
|
||||
_sy = sy;
|
||||
_attendanceBalance = attendanceBalance;
|
||||
_now = now ?? (() => DateTime.Now);
|
||||
}
|
||||
|
||||
public StudentWarningEvidence Build(Guid studentId)
|
||||
{
|
||||
var schoolYear = _sy.CurrentSchoolYear();
|
||||
var from = _sy.SchoolYearStart(schoolYear);
|
||||
var to = _sy.SchoolYearEnd(schoolYear);
|
||||
var sessions = _sessions.GetAll().ToDictionary(s => s.Id);
|
||||
var groupNames = _groups.GetAll(includeInactive: true).ToDictionary(g => g.Id, g => g.Name);
|
||||
|
||||
var dated = _entries.GetByStudent(studentId)
|
||||
.Select(e => (Entry: e, Session: sessions.GetValueOrDefault(e.SessionId)))
|
||||
.Where(x => x.Session is not null && x.Session.Date >= from && x.Session.Date <= to)
|
||||
.Select(x => (x.Entry, x.Session!.Date, Group: groupNames.GetValueOrDefault(x.Session.GroupId, "")))
|
||||
.OrderByDescending(x => x.Date).ThenBy(x => x.Group)
|
||||
.ToList();
|
||||
|
||||
var balance = _attendanceBalance.Calculate(dated.Select(x => (x.Date, x.Entry.Attendance)), from, to);
|
||||
|
||||
var absences = new List<StudentEvidenceEvent>();
|
||||
var lateness = new List<StudentEvidenceEvent>();
|
||||
var homework = new List<StudentEvidenceEvent>();
|
||||
foreach (var (entry, date, group) in dated)
|
||||
{
|
||||
if (AbsenceEvent(entry.Attendance, date, group) is { } absence) absences.Add(absence);
|
||||
if (entry.Attendance is AttendanceStatus.Late or AttendanceStatus.SignificantlyLate)
|
||||
lateness.Add(new StudentEvidenceEvent(date, group, AttendanceDisplay.Label(entry.Attendance),
|
||||
entry.Attendance == AttendanceStatus.SignificantlyLate ? "V!" : "V", "#EF6C00",
|
||||
StudentEvidenceKind.Lateness));
|
||||
var hw = HomeworkDisplay.Effective(entry);
|
||||
if (HomeworkDisplay.CountsAsMissing(hw))
|
||||
homework.Add(new StudentEvidenceEvent(date, group, HomeworkDisplay.Label(hw), "H", "#6A1B9A",
|
||||
StudentEvidenceKind.Homework));
|
||||
}
|
||||
|
||||
var student = _students.GetById(studentId);
|
||||
return new StudentWarningEvidence(studentId, student?.FullName ?? "", student?.FirstName ?? "",
|
||||
student is null ? "" : $"{student.FirstName} {student.LastName}".Trim(), schoolYear, from, to, DateOnly.FromDateTime(_now()),
|
||||
balance, absences, lateness, homework);
|
||||
}
|
||||
|
||||
/// Nur die Status, die AttendanceBalanceService in die Fehlquote einrechnet (entschuldigt,
|
||||
/// unentschuldigt, Entschuldigung offen) — schulisch veranlasste Abwesenheit zählt dort nicht als
|
||||
/// Fehlzeit und taucht deshalb auch hier nicht als Beleg auf.
|
||||
private static StudentEvidenceEvent? AbsenceEvent(AttendanceStatus? status, DateOnly date, string group)
|
||||
{
|
||||
var (code, color) = status switch
|
||||
{
|
||||
AttendanceStatus.Unexcused or AttendanceStatus.Truant or AttendanceStatus.LeftDuringClass => ("U", "#C62828"),
|
||||
AttendanceStatus.ExcusePending => ("A", "#1565C0"),
|
||||
AttendanceStatus.Excused => ("E", "#2E7D32"),
|
||||
_ => ("", ""),
|
||||
};
|
||||
return code == "" ? null
|
||||
: new StudentEvidenceEvent(date, group, AttendanceDisplay.Label(status), code, color, StudentEvidenceKind.Absence);
|
||||
}
|
||||
}
|
||||
@@ -59,12 +59,20 @@ public sealed class AttentionItem
|
||||
public bool IsDefused { get; }
|
||||
public IReadOnlyList<AttentionAction> Actions { get; }
|
||||
public Action? Navigate { get; }
|
||||
/// <summary>Nur bei Attendance/MissingHomework/Lateness gesetzt: Inhalt des "Details"-Popups
|
||||
/// (Nutzer-Feedback: im Dashboard-Eintrag mehr erfahren). Erst beim ersten Zugriff erzeugt — die
|
||||
/// Flyout-Bindung greift darauf erst beim Öffnen zu, damit das Dashboard nicht für jede Warnzeile
|
||||
/// vorab alle Belege sammelt.</summary>
|
||||
public StudentWarningDetailViewModel? Detail => _detail ??= _detailFactory?.Invoke();
|
||||
private readonly Func<StudentWarningDetailViewModel>? _detailFactory;
|
||||
private StudentWarningDetailViewModel? _detail;
|
||||
|
||||
public AttentionItem(AttentionKind kind, string title, string subtitle = "", string trailingText = "",
|
||||
string dateDisplay = "", bool isOverdue = false, AlertSeverity? severity = null,
|
||||
int progressPercent = 0, string progressLabel = "", bool hasProgress = false,
|
||||
bool isWarningTrailing = false, bool isDefused = false,
|
||||
IReadOnlyList<AttentionAction>? actions = null, Action? navigate = null)
|
||||
IReadOnlyList<AttentionAction>? actions = null, Action? navigate = null,
|
||||
Func<StudentWarningDetailViewModel>? detailFactory = null)
|
||||
{
|
||||
Kind = kind;
|
||||
Title = title;
|
||||
@@ -80,12 +88,15 @@ public sealed class AttentionItem
|
||||
IsDefused = isDefused;
|
||||
Actions = actions ?? [];
|
||||
Navigate = navigate;
|
||||
_detailFactory = detailFactory;
|
||||
}
|
||||
|
||||
public bool HasSubtitle => !string.IsNullOrWhiteSpace(Subtitle);
|
||||
public bool HasTrailingText => !string.IsNullOrWhiteSpace(TrailingText);
|
||||
public bool HasDate => !string.IsNullOrWhiteSpace(DateDisplay);
|
||||
public bool HasActions => Actions.Count > 0;
|
||||
public bool HasDetail => _detailFactory is not null;
|
||||
public bool HasFooter => HasActions || HasDetail;
|
||||
public bool HasSeverity => Severity is not null;
|
||||
public bool IsHighSeverity => Severity == AlertSeverity.High;
|
||||
public bool IsMediumSeverity => Severity == AlertSeverity.Medium;
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
using System.Text;
|
||||
using LehrerApp.Core.Models;
|
||||
using LehrerApp.Desktop.ViewModels.Students;
|
||||
|
||||
namespace LehrerApp.Desktop.ViewModels.ClassTeacher;
|
||||
|
||||
/// <summary>
|
||||
/// WebUntis-Stand einer/eines Schüler*in der eigenen Klasse für Schnellbausteine und Vorlagen im
|
||||
/// Klassenlehrer-"Vorgänge"-Tab — Gegenstück zu StudentWarningEvidence im Schülerdetail. Dort zählen
|
||||
/// nur die lokal erfassten Mitarbeits-Einträge der eigenen Stunden; die Klassenleitung braucht
|
||||
/// dagegen die Fehlzeiten über alle Fächer und die Klassenbucheinträge der Kolleg*innen, die
|
||||
/// WebUntis ohnehin liefert (und <see cref="Services.UntisReportCacheService"/> für das laufende
|
||||
/// Schuljahr bereits gecacht hat). Rein aus schon geladenen Zeilen berechnet, testbar ohne Untis.
|
||||
/// </summary>
|
||||
/// <param name="NaturalName">"Vorname Nachname" aus dem lokalen Schülerdatensatz — der Untis-Anzeigename
|
||||
/// hat je nach Schule eine andere Reihenfolge und taugt nicht für eine Briefanrede.</param>
|
||||
public sealed record ClassTeacherStudentFacts(StudentOption Student, string NaturalName, string SchoolYear, DateOnly AsOf,
|
||||
IReadOnlyList<ClassAbsenceDaySummaryRow> AbsenceDays, IReadOnlyList<ClassAbsenceDaySummaryRow> LateDays,
|
||||
IReadOnlyList<ClassTeacherClassRegisterRow> RegisterEntries)
|
||||
{
|
||||
public const int MaxListed = 15;
|
||||
|
||||
public int AbsentPeriods => AbsenceDays.Sum(d => d.TotalAbsentPeriods);
|
||||
public int FullDays => AbsenceDays.Count(d => d.CountsAsFullDay);
|
||||
public int UnexcusedDays => AbsenceDays.Count(d => d.IsUnexcused);
|
||||
public int NegativeRegisterCount => RegisterEntries.Count(r => r.IsDangerStatus);
|
||||
public IReadOnlyList<ClassTeacherClassRegisterRow> HomeworkEntries => RegisterEntries
|
||||
.Where(r => ($"{r.CategoryName} {r.Text}").Contains("Hausauf", StringComparison.OrdinalIgnoreCase)).ToList();
|
||||
|
||||
private string Stand => $"WebUntis-Stand {AsOf:dd.MM.yyyy}";
|
||||
|
||||
public string AbsenceShort =>
|
||||
$"{AbsentPeriods} Fehlstunde{(AbsentPeriods == 1 ? "" : "n")} an {AbsenceDays.Count} Tag{(AbsenceDays.Count == 1 ? "" : "en")} " +
|
||||
$"im Schuljahr {SchoolYear} (davon {UnexcusedDays} Tag{(UnexcusedDays == 1 ? "" : "e")} unentschuldigt, " +
|
||||
$"{FullDays} ganze Fehltag{(FullDays == 1 ? "" : "e")})";
|
||||
public string LatenessShort => $"{LateDays.Count} Verspätung{(LateDays.Count == 1 ? "" : "en")} im Schuljahr {SchoolYear}";
|
||||
public string RegisterShort =>
|
||||
$"{RegisterEntries.Count} Klassenbucheintr{(RegisterEntries.Count == 1 ? "ag" : "äge")} im Schuljahr {SchoolYear}, davon {NegativeRegisterCount} negativ";
|
||||
public string HomeworkShort => $"{HomeworkEntries.Count} Klassenbucheintr{(HomeworkEntries.Count == 1 ? "ag" : "äge")} zu Hausaufgaben im Schuljahr {SchoolYear}";
|
||||
|
||||
public string AbsenceBlock => $"{Stand}: {AbsenceShort}.\n" + List("Fehltage", AbsenceDays.Select(d =>
|
||||
$"{d.DateLabel}: {d.KindLabel} · {d.FriendlyStatusLabel}" +
|
||||
(string.IsNullOrWhiteSpace(d.DetailLabel) ? "" : $" · {d.DetailLabel}")).ToList());
|
||||
public string LatenessBlock => $"{Stand}: {LatenessShort}.\n" + List("Termine", LateDays.Select(d =>
|
||||
$"{d.DateLabel}: {d.KindLabel}").ToList());
|
||||
public string RegisterBlock => $"{Stand}: {RegisterShort}.\n" + List("Einträge", RegisterEntries.Select(RegisterLine).ToList());
|
||||
public string HomeworkBlock => $"{Stand}: {HomeworkShort}.\n" + List("Einträge", HomeworkEntries.Select(RegisterLine).ToList());
|
||||
|
||||
private static string RegisterLine(ClassTeacherClassRegisterRow r) =>
|
||||
$"{r.DateLabel}" + (string.IsNullOrWhiteSpace(r.Subject) ? "" : $" · {r.Subject}") + ": " +
|
||||
string.Join(" – ", new[] { r.CategoryName, r.Text }.Where(v => !string.IsNullOrWhiteSpace(v)));
|
||||
|
||||
private static string List(string header, IReadOnlyList<string> lines)
|
||||
{
|
||||
var sb = new StringBuilder($"{header}:");
|
||||
foreach (var line in lines.Take(MaxListed)) sb.Append("\n- ").Append(line);
|
||||
if (lines.Count > MaxListed) sb.Append($"\n- … und {lines.Count - MaxListed} weitere");
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
/// Nur Bausteine mit Inhalt. Mit <paramref name="withName"/> (mehrere Beteiligte ausgewählt)
|
||||
/// tragen Label und Text den Namen, sonst wäre unklar, wessen Zahlen gerade eingefügt werden.
|
||||
public IEnumerable<VorgangQuickBlock> QuickBlocks(bool withName)
|
||||
{
|
||||
string L(string label) => withName ? $"{label} · {Student.Name}" : label;
|
||||
string T(string text) => withName ? $"{Student.Name} – {text}" : text;
|
||||
if (AbsenceDays.Count > 0) yield return new(L("Fehlzeiten"), T(AbsenceBlock));
|
||||
if (LateDays.Count > 0) yield return new(L("Verspätungen"), T(LatenessBlock));
|
||||
if (HomeworkEntries.Count > 0) yield return new(L("Hausaufgaben (Klassenbuch)"), T(HomeworkBlock));
|
||||
if (RegisterEntries.Count > 0) yield return new(L("Klassenbucheinträge"), T(RegisterBlock));
|
||||
}
|
||||
|
||||
/// Anlass-Zeilen passend zu den Vorgangs-Tags; ohne passendes Tag (z.B. "Konflikte") dienen die
|
||||
/// negativen Klassenbucheinträge als Anlass, sofern vorhanden.
|
||||
public VorgangTemplateContext TemplateContext(Vorgang vorgang)
|
||||
{
|
||||
var lines = new List<string>();
|
||||
foreach (var tag in vorgang.Tags.Select(t => t.ToLowerInvariant()).Distinct())
|
||||
{
|
||||
if (tag == "absentismus" && AbsenceDays.Count > 0) lines.Add(AbsenceShort);
|
||||
if (tag == "verspätungen" && LateDays.Count > 0) lines.Add(LatenessShort);
|
||||
if (tag == "hausaufgaben" && HomeworkEntries.Count > 0) lines.Add(HomeworkShort);
|
||||
}
|
||||
if (lines.Count == 0 && NegativeRegisterCount > 0) lines.Add(RegisterShort);
|
||||
|
||||
return new VorgangTemplateContext(Student.Id, NaturalName, AsOf, lines,
|
||||
new Dictionary<string, VorgangSnapshot>
|
||||
{
|
||||
[VorgangDocumentationTemplates.UntisAbsenceStatus] = new($"Fehlzeiten-Stand (WebUntis) {AsOf:dd.MM.yyyy}", AbsenceBlock),
|
||||
[VorgangDocumentationTemplates.LatenessStatus] = new($"Verspätungs-Stand (WebUntis) {AsOf:dd.MM.yyyy}", LatenessBlock),
|
||||
[VorgangDocumentationTemplates.ClassRegisterStatus] = new($"Klassenbuch-Stand {AsOf:dd.MM.yyyy}", RegisterBlock),
|
||||
});
|
||||
}
|
||||
|
||||
public static ClassTeacherStudentFacts Build(StudentOption student, string naturalName, string schoolYear, DateOnly asOf,
|
||||
IReadOnlyList<ClassAbsenceDaySummaryRow> classAbsenceDays, IReadOnlyList<ClassTeacherClassRegisterRow> classRegister)
|
||||
{
|
||||
var own = classAbsenceDays.Where(a => UntisNameMatching.NamesMatch(a.StudentName, student.Name))
|
||||
.OrderByDescending(a => a.Date).ToList();
|
||||
var register = classRegister.Where(r => UntisNameMatching.NamesMatch(r.StudentName, student.Name))
|
||||
.OrderByDescending(r => r.Date).ToList();
|
||||
return new ClassTeacherStudentFacts(student, naturalName, schoolYear, asOf,
|
||||
own.Where(a => !a.IsLate).ToList(), own.Where(a => a.IsLate).ToList(), register);
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using LehrerApp.Core.Interfaces;
|
||||
using LehrerApp.Core.Models;
|
||||
using LehrerApp.Core.Services;
|
||||
using LehrerApp.Desktop.Services;
|
||||
using LehrerApp.Desktop.ViewModels.Students;
|
||||
|
||||
@@ -26,6 +27,10 @@ public partial class ClassTeacherCasesViewModel : ObservableObject
|
||||
|
||||
private string _className = "";
|
||||
private List<StudentOption> _rosterStudentOptions = [];
|
||||
// WebUntis-Stand je Schüler*in für Schnellbausteine/Vorlagen (ClassTeacherStudentFacts) — erst
|
||||
// beim Öffnen eines Dialogs geladen (EnsureFactsAsync), nicht bei jedem Neuladen der Liste.
|
||||
private Dictionary<Guid, ClassTeacherStudentFacts> _facts = [];
|
||||
private Guid? _documentationDraftId;
|
||||
|
||||
public ObservableCollection<VorgangItem> Cases { get; } = [];
|
||||
|
||||
@@ -98,7 +103,10 @@ public partial class ClassTeacherCasesViewModel : ObservableObject
|
||||
.Where(n => n != "");
|
||||
var item = new VorgangItem(vorgang, string.Join(", ", names), LinkDocumentation,
|
||||
UnlinkDocumentation, RemoveClassRegisterEntry, EditDocumentation)
|
||||
{ IsRevealed = revealedIds.Contains(vorgang.Id) };
|
||||
{
|
||||
IsRevealed = revealedIds.Contains(vorgang.Id),
|
||||
OnCreateFromTemplate = CreateDocumentationFromTemplate,
|
||||
};
|
||||
|
||||
var linkedIds = vorgang.DocumentationIds.ToHashSet();
|
||||
foreach (var doc in allDocs.Where(d => linkedIds.Contains(d.Id)))
|
||||
@@ -117,10 +125,83 @@ public partial class ClassTeacherCasesViewModel : ObservableObject
|
||||
|
||||
private void NotifyState() => OnPropertyChanged(nameof(HasCases));
|
||||
|
||||
/// Lädt Fehlzeiten und Klassenbucheinträge des laufenden Schuljahres (aus dem Untis-Cache, der
|
||||
/// kalte Bereich ist dort dauerhaft gespeichert) und leitet daraus den Stand je Schüler*in ab.
|
||||
/// Scheitert der Abruf (offline, Sitzung abgelaufen), gibt es eben nur die Standardformulierungen
|
||||
/// statt eines Fehlers — der Dialog selbst soll davon nicht abhängen.
|
||||
private async Task EnsureFactsAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
var sy = new SchoolYearService();
|
||||
var schoolYear = sy.CurrentSchoolYear();
|
||||
var today = DateOnly.FromDateTime(DateTime.Today);
|
||||
var start = sy.SchoolYearStart(schoolYear);
|
||||
var absencesTask = _cache.GetAbsencesAsync(_className, start, today);
|
||||
var registerTask = _cache.GetClassRegisterEventsAsync(_className, start, today);
|
||||
await Task.WhenAll(absencesTask, registerTask);
|
||||
|
||||
var absenceDays = ClassAbsenceDaySummaryRow.GroupByStudentAndDay(absencesTask.Result);
|
||||
var register = registerTask.Result
|
||||
.Select(e => (Entry: e, Valid: DateOnly.TryParseExact(e.Date.ToString(), "yyyyMMdd", out var d), Date: d))
|
||||
.Where(x => x.Valid)
|
||||
.Select(x => new ClassTeacherClassRegisterRow(x.Date, x.Entry.Subject, x.Entry.StudentName,
|
||||
x.Entry.TeacherUsername, x.Entry.CategoryName, x.Entry.CategoryGroup, x.Entry.Text))
|
||||
.ToList();
|
||||
_facts = _rosterStudentOptions.ToDictionary(o => o.Id, o => ClassTeacherStudentFacts.Build(o,
|
||||
NaturalName(o), schoolYear, today, absenceDays, register));
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
// Bewusst still: ohne WebUntis fehlen nur die Zahlen-Bausteine, Standardformulierungen
|
||||
// und Vorlagen (dann ohne Zahlen) bleiben nutzbar.
|
||||
_facts = [];
|
||||
}
|
||||
}
|
||||
|
||||
private string NaturalName(StudentOption option) =>
|
||||
_students.GetById(option.Id) is { } s ? $"{s.FirstName} {s.LastName}".Trim() : option.Name;
|
||||
|
||||
/// Schnellbausteine für die aktuell im Dialog ausgewählten Schüler*innen — bei mehreren mit Namen.
|
||||
public IReadOnlyList<VorgangQuickBlock> QuickBlocksFor(IReadOnlyList<Guid> studentIds) => studentIds
|
||||
.Where(_facts.ContainsKey)
|
||||
.SelectMany(id => _facts[id].QuickBlocks(withName: studentIds.Count > 1))
|
||||
.ToList();
|
||||
|
||||
public bool IsNewDocumentationDraft(Documentation doc) => doc.Id == _documentationDraftId;
|
||||
|
||||
/// Vorlagen beziehen sich auf die/den erste/n Beteiligte/n des Vorgangs (Dokumentation gehört immer
|
||||
/// zu genau einer Person) — der Dialog bietet deshalb auch nur diese Person an, sonst passten die
|
||||
/// eingesetzten Zahlen nicht mehr zur gewählten Person.
|
||||
private async Task CreateDocumentationFromTemplate(VorgangItem item, string key)
|
||||
{
|
||||
if (OnEditDocumentation is null) return;
|
||||
var option = _rosterStudentOptions.FirstOrDefault(o => item.Model.StudentIds.Contains(o.Id));
|
||||
if (option is null) return;
|
||||
await EnsureFactsAsync();
|
||||
var context = _facts.TryGetValue(option.Id, out var facts)
|
||||
? facts.TemplateContext(item.Model)
|
||||
: new VorgangTemplateContext(option.Id, NaturalName(option), DateOnly.FromDateTime(DateTime.Today), [],
|
||||
new Dictionary<string, VorgangSnapshot>());
|
||||
var draft = VorgangDocumentationTemplates.Build(key, item.Model, context);
|
||||
if (draft is null) return;
|
||||
|
||||
_documentationDraftId = draft.Id;
|
||||
Documentation? result;
|
||||
try { result = await OnEditDocumentation(option.Id, [option], draft); }
|
||||
finally { _documentationDraftId = null; }
|
||||
if (result is null) return;
|
||||
_documentation.Save(result);
|
||||
item.Model.DocumentationIds.Add(result.Id);
|
||||
_vorgaenge.Save(item.Model);
|
||||
await LoadInternal();
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private async Task AddVorgang()
|
||||
{
|
||||
if (OnEditVorgang is null) return;
|
||||
await EnsureFactsAsync();
|
||||
var result = await OnEditVorgang(_rosterStudentOptions, null);
|
||||
if (result is null) return;
|
||||
_vorgaenge.Save(result);
|
||||
@@ -131,6 +212,7 @@ public partial class ClassTeacherCasesViewModel : ObservableObject
|
||||
private async Task EditVorgang(VorgangItem? item)
|
||||
{
|
||||
if (item is null || OnEditVorgang is null) return;
|
||||
await EnsureFactsAsync();
|
||||
var result = await OnEditVorgang(_rosterStudentOptions, item.Model);
|
||||
if (result is null) return;
|
||||
_vorgaenge.Save(result);
|
||||
|
||||
@@ -94,6 +94,19 @@ public partial class VorgangItem : ObservableObject
|
||||
|
||||
[RelayCommand] private void Reveal() => IsRevealed = !IsRevealed;
|
||||
|
||||
/// Nutzer-Feedback: standardisierte Dokumentationseinträge statt alles von Hand — nur im
|
||||
/// schülerbezogenen Vorgänge-Tab gesetzt (StudentVorgaengeViewModel), der Parameter ist ein
|
||||
/// Schlüssel aus VorgangDocumentationTemplates. Eigener Command je Item statt $parent-Bindung,
|
||||
/// weil die Auswahl in einem MenuFlyout-Popup liegt, das nicht im normalen visuellen Baum hängt.
|
||||
public Func<VorgangItem, string, Task>? OnCreateFromTemplate { get; set; }
|
||||
public bool HasDocumentationTemplates => OnCreateFromTemplate is not null;
|
||||
|
||||
[RelayCommand]
|
||||
private async Task CreateFromTemplate(string? key)
|
||||
{
|
||||
if (key is not null && OnCreateFromTemplate is not null) await OnCreateFromTemplate(this, key);
|
||||
}
|
||||
|
||||
/// Öffnet den vollen Dokumentations-Dialog zum Nachlesen/Bearbeiten, ohne den Vorgänge-Tab zu
|
||||
/// verlassen (Nutzer-Feedback: bisher musste man dafür in den Schülerdatensatz wechseln).
|
||||
[RelayCommand]
|
||||
@@ -138,12 +151,35 @@ public partial class VorgangDialogViewModel : ObservableObject
|
||||
[ObservableProperty] private string _titleError = "";
|
||||
[ObservableProperty] private string _studentsError = "";
|
||||
|
||||
public string DialogTitle => _editing is null ? "Vorgang anlegen" : "Vorgang bearbeiten";
|
||||
private readonly bool _isNewDraft;
|
||||
public string DialogTitle => _editing is null || _isNewDraft ? "Vorgang anlegen" : "Vorgang bearbeiten";
|
||||
public Vorgang? Result { get; private set; }
|
||||
|
||||
public VorgangDialogViewModel(List<StudentOption> rosterStudents, Vorgang? editing)
|
||||
/// Per Klick in die Beschreibung einfügbare Textbausteine mit dem aktuellen Stand (Fehlquote,
|
||||
/// Fehltermine, WebUntis-Fehlzeiten, …). Setzt sich aus festen Bausteinen (Schülerdetail) und —
|
||||
/// im Klassenlehrer-Tab mit Mehrfachauswahl — aus Bausteinen je ausgewählter/m Schüler*in
|
||||
/// zusammen, die bei jeder Änderung der Auswahl neu berechnet werden.
|
||||
public ObservableCollection<VorgangQuickBlock> QuickBlocks { get; } = [];
|
||||
public bool HasQuickBlocks => QuickBlocks.Count > 0;
|
||||
/// Datumsgestempelte Standardformulierungen ("Klassenleitung informiert", …), siehe VorgangPhraseBlocks.
|
||||
public IReadOnlyList<VorgangQuickBlock> PhraseBlocks { get; }
|
||||
public bool HasPhraseBlocks => PhraseBlocks.Count > 0;
|
||||
|
||||
private readonly IReadOnlyList<VorgangQuickBlock> _fixedQuickBlocks;
|
||||
private readonly Func<IReadOnlyList<Guid>, IReadOnlyList<VorgangQuickBlock>>? _studentQuickBlocks;
|
||||
|
||||
/// <param name="isNewDraft">editing ist ein noch nicht gespeicherter, vorbefüllter Entwurf (z.B.
|
||||
/// aus einer Dashboard-Warnung) — Titel "anlegen" statt "bearbeiten".</param>
|
||||
public VorgangDialogViewModel(List<StudentOption> rosterStudents, Vorgang? editing,
|
||||
IReadOnlyList<VorgangQuickBlock>? quickBlocks = null, bool isNewDraft = false,
|
||||
IReadOnlyList<VorgangQuickBlock>? phraseBlocks = null,
|
||||
Func<IReadOnlyList<Guid>, IReadOnlyList<VorgangQuickBlock>>? studentQuickBlocks = null)
|
||||
{
|
||||
_editing = editing;
|
||||
_isNewDraft = isNewDraft;
|
||||
_fixedQuickBlocks = quickBlocks ?? [];
|
||||
_studentQuickBlocks = studentQuickBlocks;
|
||||
PhraseBlocks = phraseBlocks ?? [];
|
||||
// Bei genau einem Kandidaten (z.B. der schülerbezogene Vorgänge-Tab im Schülerdetail) und
|
||||
// einem neuen Vorgang ist ohnehin nur eine Auswahl möglich — Nutzer-Feedback: der sonst
|
||||
// nötige Extra-Klick auf die einzige Checkbox ist unnötige Reibung.
|
||||
@@ -153,6 +189,13 @@ public partial class VorgangDialogViewModel : ObservableObject
|
||||
IsSelected = editing?.StudentIds.Contains(s.Id) == true
|
||||
|| (editing is null && rosterStudents.Count == 1),
|
||||
}));
|
||||
if (_studentQuickBlocks is not null)
|
||||
foreach (var option in StudentOptions)
|
||||
option.PropertyChanged += (_, e) =>
|
||||
{
|
||||
if (e.PropertyName == nameof(VorgangStudentOption.IsSelected)) RebuildQuickBlocks();
|
||||
};
|
||||
RebuildQuickBlocks();
|
||||
if (editing is null) return;
|
||||
|
||||
Title = editing.Title;
|
||||
@@ -171,6 +214,36 @@ public partial class VorgangDialogViewModel : ObservableObject
|
||||
|
||||
[RelayCommand] private void RemoveTag(string? tag) { if (tag is not null) Tags.Remove(tag); }
|
||||
|
||||
/// Hängt den Baustein als eigenen Absatz an — nie überschreiben, damit bereits Getipptes bleibt.
|
||||
[RelayCommand]
|
||||
private void InsertQuickBlock(VorgangQuickBlock? block)
|
||||
{
|
||||
if (block is null) return;
|
||||
var current = (Description ?? "").TrimEnd();
|
||||
Description = current.Length == 0 ? block.Text : current + "\n\n" + block.Text;
|
||||
}
|
||||
|
||||
/// Standardformulierungen sind Verlaufsnotizen — eine Zeile, kein eigener Absatz.
|
||||
[RelayCommand]
|
||||
private void InsertPhrase(VorgangQuickBlock? block)
|
||||
{
|
||||
if (block is null) return;
|
||||
var current = (Description ?? "").TrimEnd();
|
||||
Description = current.Length == 0 ? block.Text : current + "\n" + block.Text;
|
||||
}
|
||||
|
||||
private void RebuildQuickBlocks()
|
||||
{
|
||||
QuickBlocks.Clear();
|
||||
foreach (var block in _fixedQuickBlocks) QuickBlocks.Add(block);
|
||||
if (_studentQuickBlocks is not null)
|
||||
{
|
||||
var selected = StudentOptions.Where(o => o.IsSelected).Select(o => o.StudentId).ToList();
|
||||
foreach (var block in _studentQuickBlocks(selected)) QuickBlocks.Add(block);
|
||||
}
|
||||
OnPropertyChanged(nameof(HasQuickBlocks));
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void Save()
|
||||
{
|
||||
|
||||
@@ -43,6 +43,7 @@ public partial class DashboardViewModel : ObservableObject
|
||||
private readonly SchoolWeatherService? _schoolWeather;
|
||||
private readonly UntisHubService _untisHub;
|
||||
private readonly WebUntisIntegrationService _webUntis;
|
||||
private readonly StudentWarningEvidenceService _evidence;
|
||||
private readonly Func<DateTime> _now;
|
||||
|
||||
private const int OpenExcuseMaxAgeDays = 21;
|
||||
@@ -240,6 +241,11 @@ public partial class DashboardViewModel : ObservableObject
|
||||
// waeren Tests fuer "kurz vor/nach Ablauf einer Wartezeit" je nach Ausfuehrungsuhrzeit
|
||||
// zufaellig rot oder gruen (siehe TODO.md, Abschnitt 9, Nachtrag Uhrzeit-Wraparound).
|
||||
_now = now ?? (() => DateTime.Now);
|
||||
// Bewusst hier aus den ohnehin injizierten Repositories gebaut statt als weiterer
|
||||
// Konstruktor-Parameter — dieselben Datenquellen wie die Warnungen selbst, und die lange
|
||||
// Parameterliste (inkl. aller Test-Aufrufer) bleibt unverändert.
|
||||
_evidence = new StudentWarningEvidenceService(students, participationSessions, participationEntries,
|
||||
groups, sy, attendanceBalance, _now);
|
||||
if (annualPlanSync is not null)
|
||||
{
|
||||
annualPlanSync.DataChanged += () => Avalonia.Threading.Dispatcher.UIThread.Post(LoadCalendar);
|
||||
@@ -518,7 +524,8 @@ public partial class DashboardViewModel : ObservableObject
|
||||
AttentionKind.Attendance, w.StudentName, trailingText: $"{w.AbsenceRatePercent:0.#} %",
|
||||
isWarningTrailing: true, isDefused: isDefused,
|
||||
actions: [SnoozeAction(w.StudentId, AbsentismusTag, $"Fehlzeiten – {w.StudentName}")],
|
||||
navigate: () => OnNavigateToStudentCase?.Invoke(w.StudentId, AbsentismusTag, "Fehlzeiten"));
|
||||
navigate: () => OnNavigateToStudentCase?.Invoke(w.StudentId, AbsentismusTag, "Fehlzeiten"),
|
||||
detailFactory: () => new StudentWarningDetailViewModel(AttentionKind.Attendance, _evidence.Build(w.StudentId)));
|
||||
|
||||
// ── Hausaufgaben-Warnung (Nutzer-Feedback: analog zur Fehlzeiten-Warnung, klassenübergreifend) ──
|
||||
|
||||
@@ -552,7 +559,8 @@ public partial class DashboardViewModel : ObservableObject
|
||||
AttentionKind.MissingHomework, w.StudentName, trailingText: $"{w.MissingCount} offen",
|
||||
isWarningTrailing: true, isDefused: isDefused,
|
||||
actions: [SnoozeAction(w.StudentId, HausaufgabenTag, $"Hausaufgaben – {w.StudentName}")],
|
||||
navigate: () => OnNavigateToStudentCase?.Invoke(w.StudentId, HausaufgabenTag, "Hausaufgaben"));
|
||||
navigate: () => OnNavigateToStudentCase?.Invoke(w.StudentId, HausaufgabenTag, "Hausaufgaben"),
|
||||
detailFactory: () => new StudentWarningDetailViewModel(AttentionKind.MissingHomework, _evidence.Build(w.StudentId)));
|
||||
|
||||
// ── Verspätungs-Warnung (Nutzer-Feedback: analog zur Fehlzeiten-Warnung, klassenübergreifend) ──
|
||||
//
|
||||
@@ -590,7 +598,8 @@ public partial class DashboardViewModel : ObservableObject
|
||||
AttentionKind.Lateness, w.StudentName, trailingText: $"{w.LateCount}× verspätet",
|
||||
isWarningTrailing: true, isDefused: isDefused,
|
||||
actions: [SnoozeAction(w.StudentId, VerspaetungenTag, $"Verspätungen – {w.StudentName}")],
|
||||
navigate: () => OnNavigateToStudentCase?.Invoke(w.StudentId, VerspaetungenTag, "Verspätungen"));
|
||||
navigate: () => OnNavigateToStudentCase?.Invoke(w.StudentId, VerspaetungenTag, "Verspätungen"),
|
||||
detailFactory: () => new StudentWarningDetailViewModel(AttentionKind.Lateness, _evidence.Build(w.StudentId)));
|
||||
|
||||
// ── Klausurwochen (Nutzer-Feedback) ───────────────────────────────────────
|
||||
//
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Globalization;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using LehrerApp.Desktop.Services;
|
||||
using LehrerApp.Desktop.ViewModels.ClassTeacher;
|
||||
|
||||
namespace LehrerApp.Desktop.ViewModels;
|
||||
|
||||
public sealed record WarningDetailFigure(string Label, string Value);
|
||||
|
||||
/// <summary>
|
||||
/// Inhalt des "Details"-Popups einer klassenübergreifenden Dashboard-Warnung (Nutzer-Feedback: im
|
||||
/// Eintrag mehr erfahren, ohne erst ins Schülerdetail zu springen). Recycelt das Monats-Widget der
|
||||
/// Klassenlehrer-Übersicht ("Monat auf einen Blick", <see cref="ClassTeacherCompactCalendarDay"/> +
|
||||
/// Views/Shared/CompactMonthCalendar) — dort aus WebUntis-Daten der eigenen Klasse, hier aus den
|
||||
/// lokal erfassten Mitarbeits-Einträgen aller Lerngruppen, mit denselben Kürzeln/Farben.
|
||||
/// Wird erst beim Öffnen des Popups erzeugt (AttentionItem.Detail ist lazy), damit das Dashboard
|
||||
/// nicht für jede Warnzeile vorab alle Belege zusammensucht.
|
||||
/// </summary>
|
||||
public partial class StudentWarningDetailViewModel : ObservableObject
|
||||
{
|
||||
private static readonly CultureInfo De = new("de-DE");
|
||||
private const int MaxListedEvents = 10;
|
||||
|
||||
private readonly IReadOnlyList<StudentEvidenceEvent> _calendarEvents;
|
||||
private readonly DateOnly _firstMonth;
|
||||
private readonly DateOnly _lastMonth;
|
||||
private readonly DateOnly _today;
|
||||
|
||||
public string Header { get; }
|
||||
public string Summary { get; }
|
||||
public IReadOnlyList<WarningDetailFigure> Figures { get; }
|
||||
public string ListHeader { get; }
|
||||
public IReadOnlyList<StudentEvidenceEvent> Events { get; }
|
||||
public string MoreEventsHint { get; }
|
||||
public bool HasMoreEvents => !string.IsNullOrEmpty(MoreEventsHint);
|
||||
public bool HasEvents => Events.Count > 0;
|
||||
public string Legend { get; }
|
||||
|
||||
public ObservableCollection<ClassTeacherCompactCalendarDay> CalendarDays { get; } = [];
|
||||
[ObservableProperty]
|
||||
[NotifyPropertyChangedFor(nameof(MonthLabel))]
|
||||
[NotifyCanExecuteChangedFor(nameof(PreviousMonthCommand), nameof(NextMonthCommand))]
|
||||
private DateOnly _month;
|
||||
public string MonthLabel => Month.ToString("MMMM yyyy", De);
|
||||
|
||||
public StudentWarningDetailViewModel(AttentionKind kind, StudentWarningEvidence e)
|
||||
{
|
||||
var b = e.Balance;
|
||||
Header = e.StudentName;
|
||||
switch (kind)
|
||||
{
|
||||
case AttentionKind.MissingHomework:
|
||||
Summary = $"{e.MissingHomework.Count} offene Hausaufgaben · Schuljahr {e.SchoolYear}";
|
||||
Figures =
|
||||
[
|
||||
new("Nicht gemacht", e.MissingHomework.Count(x => x.Label.StartsWith("Nicht", StringComparison.Ordinal)).ToString()),
|
||||
new("Teilweise", e.MissingHomework.Count(x => x.Label.StartsWith("Teilweise", StringComparison.Ordinal)).ToString()),
|
||||
];
|
||||
ListHeader = "Betroffene Termine";
|
||||
_calendarEvents = e.MissingHomework;
|
||||
Events = e.MissingHomework;
|
||||
Legend = "H Hausaufgabe offen/unvollständig";
|
||||
break;
|
||||
case AttentionKind.Lateness:
|
||||
Summary = $"{e.Lateness.Count}× verspätet · Schuljahr {e.SchoolYear}";
|
||||
Figures =
|
||||
[
|
||||
new("Verspätet", (e.Lateness.Count - e.SignificantlyLateCount).ToString()),
|
||||
new("Erheblich", e.SignificantlyLateCount.ToString()),
|
||||
new("Fehlquote", $"{b.AbsenceRatePercent.ToString("0.#", De)} %"),
|
||||
];
|
||||
ListHeader = "Verspätungen";
|
||||
_calendarEvents = [.. e.Absences, .. e.Lateness];
|
||||
Events = e.Lateness;
|
||||
Legend = "V verspätet · U unentsch. · A Entsch. offen · E entsch.";
|
||||
break;
|
||||
default:
|
||||
Summary = $"Fehlquote {b.AbsenceRatePercent.ToString("0.#", De)} % · {b.TotalChecked} Stunden erfasst · Schuljahr {e.SchoolYear}";
|
||||
Figures =
|
||||
[
|
||||
new("Entschuldigt", b.Excused.ToString()),
|
||||
new("Unentschuldigt", b.Unexcused.ToString()),
|
||||
new("Entsch. offen", b.ExcusePending.ToString()),
|
||||
new("Verspätet", e.Lateness.Count.ToString()),
|
||||
];
|
||||
ListHeader = "Fehltermine";
|
||||
_calendarEvents = [.. e.Absences, .. e.Lateness];
|
||||
Events = e.Absences;
|
||||
Legend = "U unentsch. · A Entsch. offen · E entsch. · V verspätet";
|
||||
break;
|
||||
}
|
||||
|
||||
if (Events.Count > MaxListedEvents)
|
||||
{
|
||||
MoreEventsHint = $"… und {Events.Count - MaxListedEvents} weitere";
|
||||
Events = Events.Take(MaxListedEvents).ToList();
|
||||
}
|
||||
else MoreEventsHint = "";
|
||||
|
||||
_firstMonth = FirstOfMonth(e.From);
|
||||
_lastMonth = FirstOfMonth(e.AsOf < e.To ? e.AsOf : e.To);
|
||||
if (_lastMonth < _firstMonth) _lastMonth = _firstMonth;
|
||||
// Startet beim Monat des jüngsten Belegs — der aktuelle Monat kann zu Monatsbeginn noch
|
||||
// leer sein, obwohl die Warnung gerade wegen der Vorwochen anschlägt.
|
||||
var latest = Events.Count > 0 ? FirstOfMonth(Events[0].Date) : _lastMonth;
|
||||
_today = e.AsOf;
|
||||
Month = latest < _firstMonth ? _firstMonth : latest > _lastMonth ? _lastMonth : latest;
|
||||
RebuildCalendar();
|
||||
}
|
||||
|
||||
private static DateOnly FirstOfMonth(DateOnly d) => new(d.Year, d.Month, 1);
|
||||
|
||||
partial void OnMonthChanged(DateOnly value) => RebuildCalendar();
|
||||
|
||||
private bool CanGoPrevious() => Month > _firstMonth;
|
||||
private bool CanGoNext() => Month < _lastMonth;
|
||||
|
||||
[RelayCommand(CanExecute = nameof(CanGoPrevious))] private void PreviousMonth() => Month = Month.AddMonths(-1);
|
||||
[RelayCommand(CanExecute = nameof(CanGoNext))] private void NextMonth() => Month = Month.AddMonths(1);
|
||||
|
||||
private void RebuildCalendar()
|
||||
{
|
||||
CalendarDays.Clear();
|
||||
foreach (var day in BuildMonth(Month, _calendarEvents, _today)) CalendarDays.Add(day);
|
||||
}
|
||||
|
||||
/// Gleiche Verdichtung wie ClassTeacherOverviewViewModel.BuildCompactMonthDays: eine Zelle je
|
||||
/// Kalendertag, das stärkste Kürzel ersetzt die Tageszahl, alle Details stehen im Tooltip.
|
||||
public static IReadOnlyList<ClassTeacherCompactCalendarDay> BuildMonth(DateOnly month,
|
||||
IReadOnlyList<StudentEvidenceEvent> events, DateOnly today)
|
||||
{
|
||||
var result = new List<ClassTeacherCompactCalendarDay>();
|
||||
var byDate = events.Where(e => e.Date.Year == month.Year && e.Date.Month == month.Month)
|
||||
.GroupBy(e => e.Date).ToDictionary(g => g.Key, g => g.ToList());
|
||||
for (var n = 1; n <= DateTime.DaysInMonth(month.Year, month.Month); n++)
|
||||
{
|
||||
var date = new DateOnly(month.Year, month.Month, n);
|
||||
if (!byDate.TryGetValue(date, out var dayEvents))
|
||||
{
|
||||
result.Add(new ClassTeacherCompactCalendarDay(date, "", "#00000000",
|
||||
$"{date:dd.MM.yyyy}: unauffällig", date == today));
|
||||
continue;
|
||||
}
|
||||
var strongest = dayEvents.OrderBy(e => Priority(e.Code)).First();
|
||||
var tooltip = $"{date:dd.MM.yyyy}\n" + string.Join("\n", dayEvents.Select(e => $"{e.GroupName}: {e.Label}"));
|
||||
result.Add(new ClassTeacherCompactCalendarDay(date, strongest.Code.TrimEnd('!'), strongest.ColorHex,
|
||||
tooltip, date == today));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private static int Priority(string code) => code switch
|
||||
{
|
||||
"U" => 0, "A" => 1, "E" => 2, "V!" => 3, "V" => 4, _ => 5,
|
||||
};
|
||||
}
|
||||
@@ -157,16 +157,20 @@ public partial class DocumentationDialogViewModel : ObservableObject
|
||||
|
||||
public string[] TypeOptions => DocumentationTypeDisplay.Options;
|
||||
public string[] SupportStatusOptions => SupportStatusDisplay.Options;
|
||||
public string DialogTitle => _editing is null ? "Dokumentation hinzufügen" : "Dokumentation bearbeiten";
|
||||
private readonly bool _isNewDraft;
|
||||
public string DialogTitle => _editing is null || _isNewDraft ? "Dokumentation hinzufügen" : "Dokumentation bearbeiten";
|
||||
public long MaxAttachmentSizeBytes => IAttachmentStorage.MaxSizeBytes;
|
||||
|
||||
public Documentation? Result { get; private set; }
|
||||
|
||||
public DocumentationDialogViewModel(Guid studentId, Documentation? editing, IAttachmentStorage attachmentStorage,
|
||||
List<StudentOption>? studentOptions = null, Guid? contextGroupId = null)
|
||||
List<StudentOption>? studentOptions = null, Guid? contextGroupId = null, bool isNewDraft = false)
|
||||
{
|
||||
_studentId = studentId;
|
||||
_editing = editing;
|
||||
// editing ist dann ein vorbefüllter, noch ungespeicherter Entwurf (z.B. aus einer
|
||||
// Vorgangs-Vorlage) — wird wie ein Bestandseintrag vorbelegt, aber als "hinzufügen" betitelt.
|
||||
_isNewDraft = isNewDraft;
|
||||
_attachmentStorage = attachmentStorage;
|
||||
_contextGroupId = contextGroupId;
|
||||
StudentOptions = studentOptions ?? [];
|
||||
|
||||
@@ -3,6 +3,7 @@ using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using LehrerApp.Core.Interfaces;
|
||||
using LehrerApp.Core.Models;
|
||||
using LehrerApp.Desktop.Services;
|
||||
using LehrerApp.Desktop.ViewModels.ClassTeacher;
|
||||
|
||||
namespace LehrerApp.Desktop.ViewModels.Students;
|
||||
@@ -21,8 +22,12 @@ public partial class StudentVorgaengeViewModel : ObservableObject
|
||||
private readonly IVorgangRepository _vorgaenge;
|
||||
private readonly IDocumentationRepository _documentation;
|
||||
private readonly IStudentRepository _students;
|
||||
private readonly StudentWarningEvidenceService? _evidence;
|
||||
|
||||
private Guid _studentId;
|
||||
// Id eines aus einer Vorlage erzeugten, noch ungespeicherten Dokumentations-Entwurfs — damit
|
||||
// der Dialog "hinzufügen" statt "bearbeiten" titelt (siehe IsNewDocumentationDraft).
|
||||
private Guid? _documentationDraftId;
|
||||
private string _studentName = "";
|
||||
private List<StudentOption> _studentOptions = [];
|
||||
|
||||
@@ -39,14 +44,29 @@ public partial class StudentVorgaengeViewModel : ObservableObject
|
||||
/// (siehe <see cref="HandleWarningNavigation"/>) — Parameter ist das gesuchte Tag.
|
||||
public Func<string, Task<bool>>? OnConfirmCreateForWarning { get; set; }
|
||||
|
||||
/// <param name="evidence">Quelle für Schnellbausteine/Vorlagen mit dem aktuellen Stand
|
||||
/// (Fehlquote, Hausaufgaben, Verspätungen). Optional, damit Tests ohne Mitarbeits-Repositories
|
||||
/// auskommen — ohne sie entfallen Bausteine und Vorlagen einfach.</param>
|
||||
public StudentVorgaengeViewModel(IVorgangRepository vorgaenge, IDocumentationRepository documentation,
|
||||
IStudentRepository students)
|
||||
IStudentRepository students, StudentWarningEvidenceService? evidence = null)
|
||||
{
|
||||
_vorgaenge = vorgaenge;
|
||||
_documentation = documentation;
|
||||
_students = students;
|
||||
_evidence = evidence;
|
||||
}
|
||||
|
||||
/// Frisch berechnet bei jedem Dialog-Aufruf statt beim Laden gecacht — der Tab kann lange offen
|
||||
/// bleiben, während im Unterricht weiter Anwesenheit erfasst wird.
|
||||
public IReadOnlyList<VorgangQuickBlock> BuildQuickBlocks() =>
|
||||
_evidence is null ? [] : StudentEvidenceText.QuickBlocks(_evidence.Build(_studentId));
|
||||
|
||||
/// Ob ein an OnEditVorgang übergebener Vorgang schon gespeichert ist oder nur ein vorbefüllter
|
||||
/// Entwurf (HandleWarningNavigation) — steuert den Dialogtitel "anlegen"/"bearbeiten".
|
||||
public bool IsPersisted(Vorgang vorgang) => _vorgaenge.GetById(vorgang.Id) is not null;
|
||||
|
||||
public bool IsNewDocumentationDraft(Documentation doc) => doc.Id == _documentationDraftId;
|
||||
|
||||
partial void OnOnlyOpenChanged(bool value) => Load(_studentId);
|
||||
|
||||
[RelayCommand] private void Refresh() => Load(_studentId);
|
||||
@@ -80,7 +100,10 @@ public partial class StudentVorgaengeViewModel : ObservableObject
|
||||
.Where(n => !string.IsNullOrEmpty(n)));
|
||||
var item = new VorgangItem(vorgang, names, LinkDocumentation, UnlinkDocumentation,
|
||||
RemoveClassRegisterEntry, EditDocumentation)
|
||||
{ IsRevealed = revealedIds.Contains(vorgang.Id) };
|
||||
{
|
||||
IsRevealed = revealedIds.Contains(vorgang.Id),
|
||||
OnCreateFromTemplate = _evidence is null ? null : CreateDocumentationFromTemplate,
|
||||
};
|
||||
|
||||
var linkedIds = vorgang.DocumentationIds.ToHashSet();
|
||||
foreach (var doc in ownDocs.Where(d => linkedIds.Contains(d.Id)))
|
||||
@@ -146,6 +169,27 @@ public partial class StudentVorgaengeViewModel : ObservableObject
|
||||
Load(_studentId);
|
||||
}
|
||||
|
||||
/// Erzeugt aus einer Vorlage (VorgangDocumentationTemplates) einen vorbefüllten Entwurf mit dem
|
||||
/// aktuellen Stand, öffnet ihn im normalen Dokumentations-Dialog und verknüpft ihn nach dem
|
||||
/// Speichern mit dem Vorgang — gleicher Abschluss wie CreateAndLinkDocumentation.
|
||||
private async Task CreateDocumentationFromTemplate(VorgangItem item, string key)
|
||||
{
|
||||
if (_evidence is null || OnEditDocumentation is null) return;
|
||||
var draft = VorgangDocumentationTemplates.Build(key, item.Model, _evidence.Build(_studentId));
|
||||
if (draft is null) return;
|
||||
_documentationDraftId = draft.Id;
|
||||
Documentation? result;
|
||||
try { result = await OnEditDocumentation(_studentId, draft); }
|
||||
finally { _documentationDraftId = null; }
|
||||
if (result is null) return;
|
||||
_documentation.Save(result);
|
||||
item.Model.DocumentationIds.Add(result.Id);
|
||||
_vorgaenge.Save(item.Model);
|
||||
Load(_studentId);
|
||||
var reloaded = Cases.FirstOrDefault(c => c.Model.Id == item.Model.Id);
|
||||
if (reloaded is not null) reloaded.IsRevealed = true;
|
||||
}
|
||||
|
||||
private async Task EditDocumentation(DocumentationItem doc)
|
||||
{
|
||||
if (OnEditDocumentation is null) return;
|
||||
@@ -199,7 +243,14 @@ public partial class StudentVorgaengeViewModel : ObservableObject
|
||||
if (OnConfirmCreateForWarning is null || OnEditVorgang is null) return;
|
||||
if (!await OnConfirmCreateForWarning(suggestedTag)) return;
|
||||
|
||||
var draft = new Vorgang { Title = suggestedTitle, StudentIds = [_studentId], Tags = [suggestedTag] };
|
||||
// Nutzer-Feedback: die Zahlen, die die Warnung ausgelöst haben, gleich als Beschreibung
|
||||
// mitgeben, statt sie im Dialog von Hand abzutippen — bleibt im Dialog frei editierbar.
|
||||
var description = _evidence is null ? ""
|
||||
: StudentEvidenceText.StatusBlockForTag(suggestedTag, _evidence.Build(_studentId)) ?? "";
|
||||
var draft = new Vorgang
|
||||
{
|
||||
Title = suggestedTitle, Description = description, StudentIds = [_studentId], Tags = [suggestedTag],
|
||||
};
|
||||
var result = await OnEditVorgang(_studentOptions, draft);
|
||||
if (result is null) return;
|
||||
_vorgaenge.Save(result);
|
||||
|
||||
@@ -0,0 +1,246 @@
|
||||
using System.Globalization;
|
||||
using System.Text;
|
||||
using LehrerApp.Core.Models;
|
||||
using LehrerApp.Desktop.Services;
|
||||
|
||||
namespace LehrerApp.Desktop.ViewModels.Students;
|
||||
|
||||
/// <summary>Ein per Klick einfügbarer Textbaustein im Vorgang-Dialog (Nutzer-Feedback: Fehlquoten
|
||||
/// etc. nicht von Hand abtippen müssen).</summary>
|
||||
public sealed record VorgangQuickBlock(string Label, string Text);
|
||||
|
||||
/// <summary>
|
||||
/// Formuliert die Belege aus <see cref="StudentWarningEvidence"/> als deutschen Fließtext — für
|
||||
/// Schnellbausteine im Vorgang-Dialog, die Vorbelegung eines aus einer Dashboard-Warnung heraus
|
||||
/// angelegten Vorgangs und die Dokumentationsvorlagen (<see cref="VorgangDocumentationTemplates"/>).
|
||||
/// Jeder Block nennt das Stichtagsdatum, weil der Text eingefroren in Vorgang/Dokumentation landet,
|
||||
/// während die Zahlen dahinter weiterlaufen.
|
||||
/// </summary>
|
||||
public static class StudentEvidenceText
|
||||
{
|
||||
private static readonly CultureInfo De = new("de-DE");
|
||||
/// Die Terminlisten sollen den Beleg stützen, nicht die Beschreibung fluten.
|
||||
public const int MaxListedEvents = 15;
|
||||
|
||||
public static string AttendanceShort(StudentWarningEvidence e)
|
||||
{
|
||||
var b = e.Balance;
|
||||
var absent = b.Excused + b.Unexcused + b.ExcusePending;
|
||||
return $"Fehlquote {b.AbsenceRatePercent.ToString("0.#", De)} % im Schuljahr {e.SchoolYear} " +
|
||||
$"({absent} von {b.TotalChecked} erfassten Stunden; entschuldigt {b.Excused}, " +
|
||||
$"unentschuldigt {b.Unexcused}, Entschuldigung offen {b.ExcusePending})";
|
||||
}
|
||||
|
||||
public static string HomeworkShort(StudentWarningEvidence e) =>
|
||||
$"{e.MissingHomework.Count} nicht bzw. unvollständig erledigte Hausaufgabe{(e.MissingHomework.Count == 1 ? "" : "n")} im Schuljahr {e.SchoolYear}";
|
||||
|
||||
public static string LatenessShort(StudentWarningEvidence e) =>
|
||||
$"{e.Lateness.Count} Verspätung{(e.Lateness.Count == 1 ? "" : "en")} im Schuljahr {e.SchoolYear}" +
|
||||
(e.SignificantlyLateCount > 0 ? $" (davon {e.SignificantlyLateCount} erheblich)" : "");
|
||||
|
||||
public static string AttendanceBlock(StudentWarningEvidence e) =>
|
||||
$"Stand {e.AsOf:dd.MM.yyyy}: {AttendanceShort(e)}.";
|
||||
|
||||
public static string AbsenceListBlock(StudentWarningEvidence e) => ListBlock("Fehltermine", e.Absences);
|
||||
|
||||
public static string HomeworkBlock(StudentWarningEvidence e) =>
|
||||
$"Stand {e.AsOf:dd.MM.yyyy}: {HomeworkShort(e)}.\n" + ListBlock("Betroffene Termine", e.MissingHomework);
|
||||
|
||||
public static string LatenessBlock(StudentWarningEvidence e) =>
|
||||
$"Stand {e.AsOf:dd.MM.yyyy}: {LatenessShort(e)}.\n" + ListBlock("Betroffene Termine", e.Lateness);
|
||||
|
||||
private static string ListBlock(string header, IReadOnlyList<StudentEvidenceEvent> events)
|
||||
{
|
||||
var sb = new StringBuilder($"{header}:");
|
||||
foreach (var ev in events.Take(MaxListedEvents)) sb.Append("\n- ").Append(ev.ListLine);
|
||||
if (events.Count > MaxListedEvents) sb.Append($"\n- … und {events.Count - MaxListedEvents} weitere");
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
/// Welcher Stand zu welchem Vorgangs-Schlagwort gehört — dieselben drei Tags, mit denen das
|
||||
/// Dashboard seine Warnungen an Vorgänge knüpft (DashboardViewModel.AbsentismusTag usw.).
|
||||
public static string? StatusBlockForTag(string tag, StudentWarningEvidence e) => tag.ToLowerInvariant() switch
|
||||
{
|
||||
"absentismus" => AttendanceBlock(e) + "\n" + AbsenceListBlock(e),
|
||||
"hausaufgaben" => HomeworkBlock(e),
|
||||
"verspätungen" => LatenessBlock(e),
|
||||
_ => null,
|
||||
};
|
||||
|
||||
/// Kurzfassungen für die Tags eines Vorgangs, z.B. als Anlass eines Gesprächs. Ohne passendes
|
||||
/// Tag bleibt die Liste leer statt ungefragt alle Zahlen einzustreuen.
|
||||
public static List<string> ShortLinesForTags(IEnumerable<string> tags, StudentWarningEvidence e)
|
||||
{
|
||||
var lines = new List<string>();
|
||||
foreach (var tag in tags.Select(t => t.ToLowerInvariant()).Distinct())
|
||||
{
|
||||
var line = tag switch
|
||||
{
|
||||
"absentismus" => AttendanceShort(e),
|
||||
"hausaufgaben" => HomeworkShort(e),
|
||||
"verspätungen" => LatenessShort(e),
|
||||
_ => null,
|
||||
};
|
||||
if (line is not null) lines.Add(line);
|
||||
}
|
||||
return lines;
|
||||
}
|
||||
|
||||
/// Schnellbausteine für den Vorgang-Dialog — nur solche mit tatsächlichem Inhalt, ein
|
||||
/// "0 Verspätungen"-Baustein wäre nur Rauschen.
|
||||
public static List<VorgangQuickBlock> QuickBlocks(StudentWarningEvidence e)
|
||||
{
|
||||
var blocks = new List<VorgangQuickBlock>();
|
||||
if (e.Balance.TotalChecked > 0) blocks.Add(new("Fehlquote", AttendanceBlock(e)));
|
||||
if (e.Absences.Count > 0) blocks.Add(new("Fehltermine", AbsenceListBlock(e)));
|
||||
if (e.MissingHomework.Count > 0) blocks.Add(new("Hausaufgaben", HomeworkBlock(e)));
|
||||
if (e.Lateness.Count > 0) blocks.Add(new("Verspätungen", LatenessBlock(e)));
|
||||
return blocks;
|
||||
}
|
||||
|
||||
/// Übersetzt die lokalen Mitarbeits-Belege in den neutralen Vorlagen-Kontext (die
|
||||
/// Klassenlehreransicht baut ihren Kontext stattdessen aus WebUntis-Daten, siehe
|
||||
/// ClassTeacherCaseFacts).
|
||||
public static VorgangTemplateContext TemplateContext(Vorgang vorgang, StudentWarningEvidence e) => new(
|
||||
e.StudentId, e.StudentNaturalName, e.AsOf, ShortLinesForTags(vorgang.Tags, e),
|
||||
new Dictionary<string, VorgangSnapshot>
|
||||
{
|
||||
[VorgangDocumentationTemplates.AttendanceStatus] = new($"Fehlzeiten-Stand {e.AsOf:dd.MM.yyyy}",
|
||||
AttendanceBlock(e) + "\n\n" + AbsenceListBlock(e)),
|
||||
[VorgangDocumentationTemplates.HomeworkStatus] = new($"Hausaufgaben-Stand {e.AsOf:dd.MM.yyyy}", HomeworkBlock(e)),
|
||||
[VorgangDocumentationTemplates.LatenessStatus] = new($"Verspätungs-Stand {e.AsOf:dd.MM.yyyy}", LatenessBlock(e)),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
/// <summary>Ein festhaltbarer Stand ("Fehlzeiten-Stand …") als fertiger Titel + Text.</summary>
|
||||
public sealed record VorgangSnapshot(string Title, string Content);
|
||||
|
||||
/// <summary>Neutraler Zahlen-Kontext für <see cref="VorgangDocumentationTemplates"/> — gespeist
|
||||
/// entweder aus den lokalen Mitarbeits-Einträgen (Schülerdetail, <see cref="StudentEvidenceText"/>)
|
||||
/// oder aus WebUntis (Klassenlehrer-Vorgänge, siehe ClassTeacherCaseFacts). So bleiben die
|
||||
/// Vorlagentexte an einer Stelle.</summary>
|
||||
/// <param name="SituationLines">Kurzfassungen passend zu den Tags des Vorgangs (Anlass).</param>
|
||||
/// <param name="Snapshots">Je Vorlagen-Schlüssel ein festhaltbarer Stand.</param>
|
||||
public sealed record VorgangTemplateContext(Guid StudentId, string StudentNaturalName, DateOnly AsOf,
|
||||
IReadOnlyList<string> SituationLines, IReadOnlyDictionary<string, VorgangSnapshot> Snapshots);
|
||||
|
||||
/// <summary>Standardformulierungen für den Vorgang-Dialog (Nutzer-Feedback: "den Klassenlehrer
|
||||
/// informiert" u.ä. nicht jedes Mal tippen). Datumsgestempelt, weil sie als Verlaufsnotiz in der
|
||||
/// Beschreibung landen. Getrennte Listen je Kontext: aus Sicht der Fachlehrkraft (Schülerdetail) wird
|
||||
/// die Klassenleitung informiert, aus Sicht der Klassenleitung die Fachlehrkräfte.</summary>
|
||||
public static class VorgangPhraseBlocks
|
||||
{
|
||||
public static IReadOnlyList<VorgangQuickBlock> ForStudent(DateOnly today) =>
|
||||
[
|
||||
new("Klassenleitung informiert", $"{today:dd.MM.yyyy}: Klassenleitung informiert."),
|
||||
new("Eltern informiert", $"{today:dd.MM.yyyy}: Erziehungsberechtigte informiert."),
|
||||
new("Gespräch geführt", $"{today:dd.MM.yyyy}: Gespräch mit der Schülerin/dem Schüler geführt."),
|
||||
new("Vereinbarung getroffen", $"{today:dd.MM.yyyy}: Vereinbarung getroffen: "),
|
||||
new("Weiter beobachten", $"{today:dd.MM.yyyy}: Weiter beobachten, Überprüfung am "),
|
||||
];
|
||||
|
||||
public static IReadOnlyList<VorgangQuickBlock> ForClassTeacher(DateOnly today) =>
|
||||
[
|
||||
new("Fachlehrkräfte informiert", $"{today:dd.MM.yyyy}: Fachlehrkräfte der Klasse informiert."),
|
||||
new("Eltern informiert", $"{today:dd.MM.yyyy}: Erziehungsberechtigte informiert."),
|
||||
new("Gespräch geführt", $"{today:dd.MM.yyyy}: Gespräch mit der Schülerin/dem Schüler geführt."),
|
||||
new("Attestpflicht angekündigt", $"{today:dd.MM.yyyy}: Attestpflicht angekündigt – Fehlzeiten gelten künftig nur mit ärztlichem Attest als entschuldigt."),
|
||||
new("Schulsozialarbeit eingebunden", $"{today:dd.MM.yyyy}: Schulsozialarbeit/Beratung eingebunden."),
|
||||
new("Schulleitung informiert", $"{today:dd.MM.yyyy}: Schulleitung informiert."),
|
||||
new("Vereinbarung getroffen", $"{today:dd.MM.yyyy}: Vereinbarung getroffen: "),
|
||||
new("Weiter beobachten", $"{today:dd.MM.yyyy}: Weiter beobachten, Überprüfung am "),
|
||||
];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Standardisierte Dokumentationseinträge für einen Vorgang (Nutzer-Feedback: nicht jeden Eintrag
|
||||
/// komplett von Hand anlegen). Erzeugt nur einen vorbefüllten, noch nicht gespeicherten Entwurf —
|
||||
/// der landet anschließend im normalen Dokumentations-Dialog, gespeichert wird erst nach bewusster
|
||||
/// Bestätigung dort.
|
||||
/// </summary>
|
||||
public static class VorgangDocumentationTemplates
|
||||
{
|
||||
// Stand festhalten — lokal (Schülerdetail) …
|
||||
public const string AttendanceStatus = "attendance";
|
||||
public const string HomeworkStatus = "homework";
|
||||
public const string LatenessStatus = "lateness";
|
||||
// … bzw. aus WebUntis (Klassenlehrer-Vorgänge)
|
||||
public const string UntisAbsenceStatus = "untisabsences";
|
||||
public const string ClassRegisterStatus = "classregister";
|
||||
|
||||
public const string Conversation = "conversation";
|
||||
public const string ParentCall = "parentcall";
|
||||
public const string ParentLetter = "parentletter";
|
||||
public const string InformClassTeacher = "informclassteacher";
|
||||
public const string InformSubjectTeachers = "informsubjectteachers";
|
||||
|
||||
public static Documentation? Build(string key, Vorgang vorgang, StudentWarningEvidence e) =>
|
||||
Build(key, vorgang, StudentEvidenceText.TemplateContext(vorgang, e));
|
||||
|
||||
public static Documentation? Build(string key, Vorgang vorgang, VorgangTemplateContext ctx)
|
||||
{
|
||||
var doc = new Documentation { StudentId = ctx.StudentId, Date = ctx.AsOf };
|
||||
if (ctx.Snapshots.TryGetValue(key, out var snapshot))
|
||||
{
|
||||
doc.Type = DocumentationType.Incident;
|
||||
doc.Title = snapshot.Title;
|
||||
doc.Content = snapshot.Content;
|
||||
doc.Tags = ["Beobachten"];
|
||||
return doc;
|
||||
}
|
||||
switch (key)
|
||||
{
|
||||
case Conversation:
|
||||
doc.Type = DocumentationType.Conversation;
|
||||
doc.Title = $"Gespräch: {vorgang.Title}";
|
||||
doc.Participants = [ctx.StudentNaturalName];
|
||||
doc.Content = "Anlass:\n" + Occasion(vorgang, ctx) +
|
||||
"\n\nSicht der Schülerin/des Schülers:\n\n\nVereinbarung:\n\n\nÜberprüfung am:\n";
|
||||
return doc;
|
||||
case InformClassTeacher:
|
||||
case InformSubjectTeachers:
|
||||
var who = key == InformClassTeacher ? "Klassenleitung" : "Fachlehrkräfte";
|
||||
doc.Type = DocumentationType.Conversation;
|
||||
doc.Title = $"{who} informiert: {vorgang.Title}";
|
||||
doc.Participants = [who];
|
||||
doc.Content = $"{who} am {ctx.AsOf:dd.MM.yyyy} informiert über:\n" + Occasion(vorgang, ctx) +
|
||||
"\n\nWeg (persönlich / Nachricht / E-Mail):\n\n\nRückmeldung / Absprache:\n";
|
||||
return doc;
|
||||
case ParentCall:
|
||||
doc.Type = DocumentationType.ParentCall;
|
||||
doc.Title = $"Elternanruf: {vorgang.Title}";
|
||||
var points = ctx.SituationLines.Select(l => $"Aktueller Stand: {l}").ToList();
|
||||
if (points.Count == 0) points.Add($"Anlass schildern: {vorgang.Title}");
|
||||
points.AddRange(["Sicht der Eltern / mögliche Gründe erfragen", "Unterstützung und Vereinbarung abstimmen",
|
||||
"Nächsten Kontakt vereinbaren"]);
|
||||
doc.ParentCallData = new ParentCallData
|
||||
{ Points = points.Select(p => new ParentCallPoint { Text = p }).ToList() };
|
||||
return doc;
|
||||
case ParentLetter:
|
||||
doc.Type = DocumentationType.ParentLetter;
|
||||
doc.Title = $"Elternbrief: {vorgang.Title}";
|
||||
doc.ParentLetterData = new ParentLetterData { DraftContent = LetterDraft(vorgang, ctx) };
|
||||
return doc;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static string Occasion(Vorgang vorgang, VorgangTemplateContext ctx) =>
|
||||
ctx.SituationLines.Count == 0 ? $"- {vorgang.Title}"
|
||||
: string.Join("\n", ctx.SituationLines.Select(l => $"- {l} (Stand {ctx.AsOf:dd.MM.yyyy})"));
|
||||
|
||||
private static string LetterDraft(Vorgang vorgang, VorgangTemplateContext ctx)
|
||||
{
|
||||
var facts = ctx.SituationLines.Count == 0
|
||||
? $"es gibt Anlass zur Sorge ({vorgang.Title})."
|
||||
: $"mit Stand {ctx.AsOf:dd.MM.yyyy} ergibt sich folgendes Bild:\n" +
|
||||
string.Join("\n", ctx.SituationLines.Select(l => $"- {l}"));
|
||||
return $"Sehr geehrte Erziehungsberechtigte von {ctx.StudentNaturalName},\n\n" +
|
||||
$"ich möchte Sie darüber informieren, dass {facts}\n\n" +
|
||||
"Ich bitte Sie, mit Ihrem Kind darüber zu sprechen und sich bei Fragen mit mir in Verbindung zu setzen. " +
|
||||
"Für ein gemeinsames Gespräch stehe ich gerne zur Verfügung.\n\n" +
|
||||
"Mit freundlichen Grüßen";
|
||||
}
|
||||
}
|
||||
@@ -126,10 +126,36 @@
|
||||
</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 Orientation="Horizontal" Spacing="6">
|
||||
<Button Content="+ Neue Dokumentation anlegen" FontSize="11" Padding="8,3"
|
||||
Command="{Binding $parent[ItemsControl].((vm:ClassTeacherCasesViewModel)DataContext).CreateAndLinkDocumentationCommand}"
|
||||
CommandParameter="{Binding}"/>
|
||||
<!-- Nutzer-Feedback: standardisierte Einträge statt alles von Hand. Zahlen aus
|
||||
WebUntis (Fehlzeiten über alle Fächer, Klassenbuch), siehe
|
||||
ClassTeacherStudentFacts; bezieht sich auf die/den erste/n Beteiligte/n. -->
|
||||
<Button Content="+ Aus Vorlage ▾" FontSize="11" Padding="8,3"
|
||||
IsVisible="{Binding HasDocumentationTemplates}">
|
||||
<Button.Flyout>
|
||||
<MenuFlyout>
|
||||
<MenuItem Header="Fehlzeiten-Stand (WebUntis) festhalten"
|
||||
Command="{Binding CreateFromTemplateCommand}" CommandParameter="untisabsences"/>
|
||||
<MenuItem Header="Verspätungs-Stand (WebUntis) festhalten"
|
||||
Command="{Binding CreateFromTemplateCommand}" CommandParameter="lateness"/>
|
||||
<MenuItem Header="Klassenbuch-Stand festhalten"
|
||||
Command="{Binding CreateFromTemplateCommand}" CommandParameter="classregister"/>
|
||||
<Separator/>
|
||||
<MenuItem Header="Gespräch mit Schüler*in"
|
||||
Command="{Binding CreateFromTemplateCommand}" CommandParameter="conversation"/>
|
||||
<MenuItem Header="Fachlehrkräfte informieren"
|
||||
Command="{Binding CreateFromTemplateCommand}" CommandParameter="informsubjectteachers"/>
|
||||
<MenuItem Header="Elternanruf vorbereiten"
|
||||
Command="{Binding CreateFromTemplateCommand}" CommandParameter="parentcall"/>
|
||||
<MenuItem Header="Elternbrief entwerfen"
|
||||
Command="{Binding CreateFromTemplateCommand}" CommandParameter="parentletter"/>
|
||||
</MenuFlyout>
|
||||
</Button.Flyout>
|
||||
</Button>
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
|
||||
<StackPanel Spacing="4" IsVisible="{Binding HasAvailableDocumentation}">
|
||||
|
||||
@@ -27,7 +27,10 @@ public partial class ClassTeacherCasesView : UserControl
|
||||
var owner = TopLevel.GetTopLevel(this) as Window;
|
||||
if (owner is null) return null;
|
||||
|
||||
var vm = new VorgangDialogViewModel(rosterStudents, editing);
|
||||
var cases = DataContext as ClassTeacherCasesViewModel;
|
||||
var vm = new VorgangDialogViewModel(rosterStudents, editing,
|
||||
phraseBlocks: VorgangPhraseBlocks.ForClassTeacher(DateOnly.FromDateTime(DateTime.Today)),
|
||||
studentQuickBlocks: cases is null ? null : cases.QuickBlocksFor);
|
||||
var dialog = new VorgangDialog { DataContext = vm };
|
||||
var saved = await dialog.ShowDialog<bool>(owner);
|
||||
return saved ? vm.Result : null;
|
||||
@@ -53,8 +56,10 @@ public partial class ClassTeacherCasesView : UserControl
|
||||
var owner = TopLevel.GetTopLevel(this) as Window;
|
||||
if (owner is null) return null;
|
||||
|
||||
var isNewDraft = editing is not null
|
||||
&& (DataContext as ClassTeacherCasesViewModel)?.IsNewDocumentationDraft(editing) == true;
|
||||
var vm = new DocumentationDialogViewModel(defaultStudentId, editing,
|
||||
App.Services.GetRequiredService<IAttachmentStorage>(), studentOptions);
|
||||
App.Services.GetRequiredService<IAttachmentStorage>(), studentOptions, isNewDraft: isNewDraft);
|
||||
var dialog = new DocumentationDialog { DataContext = vm };
|
||||
var saved = await dialog.ShowDialog<bool>(owner);
|
||||
if (!saved) vm.DiscardUnsavedAttachments();
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:vm="clr-namespace:LehrerApp.Desktop.ViewModels.ClassTeacher"
|
||||
xmlns:views="clr-namespace:LehrerApp.Desktop.Views.ClassTeacher"
|
||||
xmlns:shared="clr-namespace:LehrerApp.Desktop.Views.Shared"
|
||||
xmlns:conv="clr-namespace:LehrerApp.Desktop.Converters"
|
||||
x:Class="LehrerApp.Desktop.Views.ClassTeacher.ClassTeacherOverviewView"
|
||||
x:DataType="vm:ClassTeacherOverviewViewModel">
|
||||
@@ -98,19 +99,6 @@
|
||||
<Style Selector="Border.statusFill.danger">
|
||||
<Setter Property="Background" Value="{DynamicResource AppStatusDangerBrush}"/>
|
||||
</Style>
|
||||
<Style Selector="Border.compactDay">
|
||||
<Setter Property="Width" Value="28"/>
|
||||
<Setter Property="Height" Value="28"/>
|
||||
<Setter Property="CornerRadius" Value="6"/>
|
||||
<Setter Property="Background" Value="{DynamicResource AppChipBackgroundBrush}"/>
|
||||
<Setter Property="BorderBrush" Value="{DynamicResource AppCardBorderBrush}"/>
|
||||
<Setter Property="BorderThickness" Value="1"/>
|
||||
<Setter Property="Margin" Value="2"/>
|
||||
</Style>
|
||||
<Style Selector="Border.compactDay.today">
|
||||
<Setter Property="BorderBrush" Value="{DynamicResource AppAccentTextBrush}"/>
|
||||
<Setter Property="BorderThickness" Value="2"/>
|
||||
</Style>
|
||||
</UserControl.Styles>
|
||||
|
||||
<UserControl.Resources>
|
||||
@@ -424,7 +412,9 @@
|
||||
|
||||
<!-- Verdichtete Monatsansicht: exakt eine Zelle je Kalendertag. Ohne Ereignis
|
||||
bleibt nur die Tageszahl sichtbar; bei Auffälligkeiten ersetzt das stärkste
|
||||
Statuskürzel die Zahl. Alle Details bleiben per Tooltip und Drill-down erhalten. -->
|
||||
Statuskürzel die Zahl. Alle Details bleiben per Tooltip und Drill-down erhalten.
|
||||
Das Widget selbst liegt in Views/Shared/CompactMonthCalendar (auch im
|
||||
Detail-Popup der Dashboard-Warnungen genutzt). -->
|
||||
<Border Classes="sidePanel">
|
||||
<StackPanel Spacing="8">
|
||||
<Grid ColumnDefinitions="*,Auto">
|
||||
@@ -441,29 +431,7 @@
|
||||
DisplayMemberBinding="{Binding DisplayName}"
|
||||
HorizontalAlignment="Stretch"
|
||||
AutomationProperties.Name="Schüler für Monatskalender auswählen"/>
|
||||
<ItemsControl ItemsSource="{Binding CompactMonthDays}" HorizontalAlignment="Center">
|
||||
<ItemsControl.ItemsPanel>
|
||||
<ItemsPanelTemplate><UniformGrid Columns="7"/></ItemsPanelTemplate>
|
||||
</ItemsControl.ItemsPanel>
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:ClassTeacherCompactCalendarDay">
|
||||
<Border Classes="compactDay" Classes.today="{Binding IsToday}"
|
||||
ToolTip.Tip="{Binding Tooltip}">
|
||||
<Grid>
|
||||
<TextBlock Text="{Binding DayNumber}" FontSize="11" Opacity="0.7"
|
||||
HorizontalAlignment="Center" VerticalAlignment="Center"
|
||||
IsVisible="{Binding !HasSignal}"/>
|
||||
<Border Background="{Binding SignalColorHex}" CornerRadius="5"
|
||||
IsVisible="{Binding HasSignal}">
|
||||
<TextBlock Text="{Binding SignalCode}" Foreground="White" FontSize="11"
|
||||
FontWeight="Bold" HorizontalAlignment="Center"
|
||||
VerticalAlignment="Center"/>
|
||||
</Border>
|
||||
</Grid>
|
||||
</Border>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
<shared:CompactMonthCalendar Days="{Binding CompactMonthDays}"/>
|
||||
<TextBlock Text="U unentsch. · ! Klassenbuch · A abwesend · V verspätet · E entsch."
|
||||
FontSize="9" Opacity="0.5" TextWrapping="Wrap"/>
|
||||
</StackPanel>
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
<Window 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.VorgangDialog"
|
||||
x:DataType="vm:VorgangDialogViewModel"
|
||||
Title="{Binding DialogTitle}"
|
||||
Width="480" SizeToContent="Height" MinHeight="360"
|
||||
Width="560" SizeToContent="Height" MinHeight="360" MaxHeight="860"
|
||||
CanResize="True" WindowStartupLocation="CenterOwner">
|
||||
|
||||
<Grid RowDefinitions="*,Auto" Margin="24">
|
||||
@@ -21,8 +22,44 @@
|
||||
|
||||
<StackPanel Spacing="4">
|
||||
<TextBlock Text="Beschreibung" FontSize="12" Opacity="0.7"/>
|
||||
<TextBox Text="{Binding Description}" AcceptsReturn="True" Height="90" TextWrapping="Wrap"
|
||||
<TextBox Text="{Binding Description}" AcceptsReturn="True" Height="120" TextWrapping="Wrap"
|
||||
PlaceholderText="Worum geht es? Was ist bisher passiert?"/>
|
||||
<!-- Nutzer-Feedback: Fehlquoten etc. nicht von Hand abtippen — Bausteine mit dem aktuellen
|
||||
Stand werden an die Beschreibung angehängt (nicht ersetzt). -->
|
||||
<StackPanel Spacing="4" IsVisible="{Binding HasQuickBlocks}" Margin="0,2,0,0">
|
||||
<TextBlock Text="Schnellbausteine (aktueller Stand einfügen)" FontSize="11" Opacity="0.6"/>
|
||||
<ItemsControl ItemsSource="{Binding QuickBlocks}">
|
||||
<ItemsControl.ItemsPanel>
|
||||
<ItemsPanelTemplate><WrapPanel ItemSpacing="6" LineSpacing="6"/></ItemsPanelTemplate>
|
||||
</ItemsControl.ItemsPanel>
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate x:DataType="svm:VorgangQuickBlock">
|
||||
<Button Content="{Binding Label, StringFormat='+ {0}'}" FontSize="11" Padding="8,3"
|
||||
ToolTip.Tip="{Binding Text}"
|
||||
Command="{Binding $parent[ItemsControl].((vm:VorgangDialogViewModel)DataContext).InsertQuickBlockCommand}"
|
||||
CommandParameter="{Binding}"/>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
</StackPanel>
|
||||
<!-- Nutzer-Feedback: "den Klassenlehrer informiert" & Co. nicht jedes Mal tippen —
|
||||
datumsgestempelte Verlaufszeile, angehängt statt ersetzt. -->
|
||||
<StackPanel Spacing="4" IsVisible="{Binding HasPhraseBlocks}" Margin="0,2,0,0">
|
||||
<TextBlock Text="Standardformulierungen (mit heutigem Datum)" FontSize="11" Opacity="0.6"/>
|
||||
<ItemsControl ItemsSource="{Binding PhraseBlocks}">
|
||||
<ItemsControl.ItemsPanel>
|
||||
<ItemsPanelTemplate><WrapPanel ItemSpacing="6" LineSpacing="6"/></ItemsPanelTemplate>
|
||||
</ItemsControl.ItemsPanel>
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate x:DataType="svm:VorgangQuickBlock">
|
||||
<Button Content="{Binding Label}" FontSize="11" Padding="8,3"
|
||||
ToolTip.Tip="{Binding Text}"
|
||||
Command="{Binding $parent[ItemsControl].((vm:VorgangDialogViewModel)DataContext).InsertPhraseCommand}"
|
||||
CommandParameter="{Binding}"/>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
|
||||
<StackPanel Spacing="6">
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<UserControl xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:vm="clr-namespace:LehrerApp.Desktop.ViewModels"
|
||||
xmlns:shared="clr-namespace:LehrerApp.Desktop.Views.Shared"
|
||||
x:Class="LehrerApp.Desktop.Views.Dashboard.DashboardView"
|
||||
x:DataType="vm:DashboardViewModel">
|
||||
|
||||
@@ -544,18 +545,33 @@
|
||||
</StackPanel>
|
||||
</Button>
|
||||
</Grid>
|
||||
<ItemsControl ItemsSource="{Binding Actions}" Margin="13,4,0,0"
|
||||
IsVisible="{Binding HasActions}">
|
||||
<ItemsControl.ItemsPanel>
|
||||
<ItemsPanelTemplate><StackPanel Orientation="Horizontal" Spacing="4"/></ItemsPanelTemplate>
|
||||
</ItemsControl.ItemsPanel>
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:AttentionAction">
|
||||
<Button Content="{Binding Label}" FontSize="11" Padding="7,3"
|
||||
Command="{Binding Command}" CommandParameter="{Binding Parameter}"/>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
<StackPanel Orientation="Horizontal" Spacing="4" Margin="13,4,0,0"
|
||||
IsVisible="{Binding HasFooter}">
|
||||
<!-- Nutzer-Feedback: im Eintrag mehr erfahren, ohne erst ins Schülerdetail
|
||||
zu springen. Per Klick (Flyout) statt Hover-Tooltip, weil ein Klick
|
||||
auf die Zeile selbst bereits in den Vorgänge-Tab navigiert und das
|
||||
Popup einen blätterbaren Monatskalender enthält. Detail wird erst
|
||||
beim Öffnen berechnet (siehe AttentionItem.Detail). -->
|
||||
<Button Content="Details ▾" FontSize="11" Padding="7,3" IsVisible="{Binding HasDetail}"
|
||||
ToolTip.Tip="Fehlzeiten, Verspätungen bzw. offene Hausaufgaben im Überblick">
|
||||
<Button.Flyout>
|
||||
<Flyout Placement="BottomEdgeAlignedLeft">
|
||||
<shared:StudentWarningDetailView DataContext="{Binding Detail}"/>
|
||||
</Flyout>
|
||||
</Button.Flyout>
|
||||
</Button>
|
||||
<ItemsControl ItemsSource="{Binding Actions}" IsVisible="{Binding HasActions}">
|
||||
<ItemsControl.ItemsPanel>
|
||||
<ItemsPanelTemplate><StackPanel Orientation="Horizontal" Spacing="4"/></ItemsPanelTemplate>
|
||||
</ItemsControl.ItemsPanel>
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:AttentionAction">
|
||||
<Button Content="{Binding Label}" FontSize="11" Padding="7,3"
|
||||
Command="{Binding Command}" CommandParameter="{Binding Parameter}"/>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
<UserControl xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:local="clr-namespace:LehrerApp.Desktop.Views.Shared"
|
||||
x:Class="LehrerApp.Desktop.Views.Shared.CompactMonthCalendar"
|
||||
x:Name="Root">
|
||||
|
||||
<!-- Verdichtete Monatsansicht: exakt eine Zelle je Kalendertag, nach Wochentag ausgerichtet
|
||||
(Leerzellen vor dem 1., siehe CompactMonthCalendar.BuildCells). Ohne Ereignis bleibt nur die
|
||||
Tageszahl sichtbar; bei Auffälligkeiten ersetzt das stärkste Statuskürzel die Zahl. Alle
|
||||
Details bleiben per Tooltip erhalten. Aus ClassTeacherOverviewView herausgelöst, damit das
|
||||
Detail-Popup der Dashboard-Warnungen dasselbe Widget wiederverwendet. -->
|
||||
<UserControl.Styles>
|
||||
<Style Selector="Border.compactDay">
|
||||
<Setter Property="Width" Value="28"/>
|
||||
<Setter Property="Height" Value="28"/>
|
||||
<Setter Property="CornerRadius" Value="6"/>
|
||||
<Setter Property="Background" Value="{DynamicResource AppChipBackgroundBrush}"/>
|
||||
<Setter Property="BorderBrush" Value="{DynamicResource AppCardBorderBrush}"/>
|
||||
<Setter Property="BorderThickness" Value="1"/>
|
||||
<Setter Property="Margin" Value="2"/>
|
||||
</Style>
|
||||
<Style Selector="Border.compactDay.today">
|
||||
<Setter Property="BorderBrush" Value="{DynamicResource AppAccentTextBrush}"/>
|
||||
<Setter Property="BorderThickness" Value="2"/>
|
||||
</Style>
|
||||
</UserControl.Styles>
|
||||
|
||||
<StackPanel HorizontalAlignment="Center">
|
||||
<ItemsControl ItemsSource="{x:Static local:CompactMonthCalendar.WeekdayHeaders}">
|
||||
<ItemsControl.ItemsPanel>
|
||||
<ItemsPanelTemplate><UniformGrid Columns="7"/></ItemsPanelTemplate>
|
||||
</ItemsControl.ItemsPanel>
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate>
|
||||
<TextBlock Text="{Binding}" FontSize="9" FontWeight="Bold" Opacity="0.5"
|
||||
Width="32" TextAlignment="Center"/>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
|
||||
<ItemsControl ItemsSource="{Binding #Root.Cells}">
|
||||
<ItemsControl.ItemsPanel>
|
||||
<ItemsPanelTemplate><UniformGrid Columns="7"/></ItemsPanelTemplate>
|
||||
</ItemsControl.ItemsPanel>
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate x:DataType="local:CompactMonthCell">
|
||||
<Panel Width="32" Height="32">
|
||||
<Border Classes="compactDay" Classes.today="{Binding Day.IsToday, FallbackValue=False}"
|
||||
IsVisible="{Binding IsDay}" ToolTip.Tip="{Binding Day.Tooltip}">
|
||||
<Grid>
|
||||
<TextBlock Text="{Binding Day.DayNumber}" FontSize="11" Opacity="0.7"
|
||||
HorizontalAlignment="Center" VerticalAlignment="Center"
|
||||
IsVisible="{Binding !Day.HasSignal, FallbackValue=False}"/>
|
||||
<Border Background="{Binding Day.SignalColorHex}" CornerRadius="5"
|
||||
IsVisible="{Binding Day.HasSignal, FallbackValue=False}">
|
||||
<TextBlock Text="{Binding Day.SignalCode}" Foreground="White" FontSize="11"
|
||||
FontWeight="Bold" HorizontalAlignment="Center"
|
||||
VerticalAlignment="Center"/>
|
||||
</Border>
|
||||
</Grid>
|
||||
</Border>
|
||||
</Panel>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
</StackPanel>
|
||||
</UserControl>
|
||||
@@ -0,0 +1,78 @@
|
||||
using System.Collections;
|
||||
using System.Collections.Specialized;
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using LehrerApp.Desktop.ViewModels.ClassTeacher;
|
||||
|
||||
namespace LehrerApp.Desktop.Views.Shared;
|
||||
|
||||
/// <summary>Eine Rasterzelle des kompakten Monatskalenders; <see cref="Day"/> ist null für die
|
||||
/// Leerzellen vor dem Monatsersten.</summary>
|
||||
public sealed record CompactMonthCell(ClassTeacherCompactCalendarDay? Day)
|
||||
{
|
||||
public bool IsPlaceholder => Day is null;
|
||||
public bool IsDay => Day is not null;
|
||||
}
|
||||
|
||||
/// <summary>Kompakter Monatskalender (eine Zelle je Tag, Statuskürzel statt Tageszahl) — erwartet
|
||||
/// <see cref="ClassTeacherCompactCalendarDay"/>-Einträge in <see cref="Days"/>, genau einen je
|
||||
/// Kalendertag ab dem Monatsersten. Genutzt von der Klassenlehrer-Übersicht und dem Detail-Popup der
|
||||
/// Dashboard-Warnungen.
|
||||
/// Nutzer-Feedback: vorher begann jeder Monat oben links, der 1. stand also unabhängig vom
|
||||
/// Wochentag in der Montags-Spalte. Die Ausrichtung passiert bewusst hier in der Anzeige (Leerzellen
|
||||
/// vor dem Monatsersten + Kopfzeile Mo–So) statt in den beiden Buildern — deren Ergebnis bleibt
|
||||
/// "ein Eintrag je Kalendertag" und damit für Tests/Aufrufer unverändert.</summary>
|
||||
public partial class CompactMonthCalendar : UserControl
|
||||
{
|
||||
public static readonly StyledProperty<IEnumerable?> DaysProperty =
|
||||
AvaloniaProperty.Register<CompactMonthCalendar, IEnumerable?>(nameof(Days));
|
||||
|
||||
public static readonly DirectProperty<CompactMonthCalendar, IReadOnlyList<CompactMonthCell>> CellsProperty =
|
||||
AvaloniaProperty.RegisterDirect<CompactMonthCalendar, IReadOnlyList<CompactMonthCell>>(nameof(Cells), c => c.Cells);
|
||||
|
||||
public static string[] WeekdayHeaders { get; } = ["Mo", "Di", "Mi", "Do", "Fr", "Sa", "So"];
|
||||
|
||||
private IReadOnlyList<CompactMonthCell> _cells = [];
|
||||
private INotifyCollectionChanged? _observed;
|
||||
|
||||
public IEnumerable? Days
|
||||
{
|
||||
get => GetValue(DaysProperty);
|
||||
set => SetValue(DaysProperty, value);
|
||||
}
|
||||
|
||||
public IReadOnlyList<CompactMonthCell> Cells
|
||||
{
|
||||
get => _cells;
|
||||
private set => SetAndRaise(CellsProperty, ref _cells, value);
|
||||
}
|
||||
|
||||
public CompactMonthCalendar() => InitializeComponent();
|
||||
|
||||
protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change)
|
||||
{
|
||||
base.OnPropertyChanged(change);
|
||||
if (change.Property != DaysProperty) return;
|
||||
// Beide Aufrufer befüllen eine ObservableCollection per Clear/Add neu (Monat blättern,
|
||||
// Schülerauswahl) — deshalb auf Änderungen der Sammlung selbst lauschen, nicht nur auf
|
||||
// einen neuen Days-Wert.
|
||||
if (_observed is not null) _observed.CollectionChanged -= OnDaysCollectionChanged;
|
||||
_observed = Days as INotifyCollectionChanged;
|
||||
if (_observed is not null) _observed.CollectionChanged += OnDaysCollectionChanged;
|
||||
RebuildCells();
|
||||
}
|
||||
|
||||
private void OnDaysCollectionChanged(object? sender, NotifyCollectionChangedEventArgs e) => RebuildCells();
|
||||
|
||||
private void RebuildCells() => Cells = BuildCells(Days?.OfType<ClassTeacherCompactCalendarDay>() ?? []);
|
||||
|
||||
public static IReadOnlyList<CompactMonthCell> BuildCells(IEnumerable<ClassTeacherCompactCalendarDay> days)
|
||||
{
|
||||
var list = days.ToList();
|
||||
if (list.Count == 0) return [];
|
||||
var leading = ((int)list[0].Date.DayOfWeek + 6) % 7; // Montag = 0 … Sonntag = 6
|
||||
return Enumerable.Repeat(new CompactMonthCell(null), leading)
|
||||
.Concat(list.Select(d => new CompactMonthCell(d)))
|
||||
.ToList();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
<UserControl xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:vm="clr-namespace:LehrerApp.Desktop.ViewModels"
|
||||
xmlns:svc="clr-namespace:LehrerApp.Desktop.Services"
|
||||
xmlns:shared="clr-namespace:LehrerApp.Desktop.Views.Shared"
|
||||
x:Class="LehrerApp.Desktop.Views.Shared.StudentWarningDetailView"
|
||||
x:DataType="vm:StudentWarningDetailViewModel"
|
||||
Width="320">
|
||||
|
||||
<!-- Inhalt des "Details"-Popups einer Dashboard-Warnung (Fehlzeiten/Hausaufgaben/Verspätungen). -->
|
||||
<StackPanel Spacing="10">
|
||||
<StackPanel Spacing="2">
|
||||
<TextBlock Text="{Binding Header}" FontSize="14" FontWeight="SemiBold"/>
|
||||
<TextBlock Text="{Binding Summary}" FontSize="11" Opacity="0.65" TextWrapping="Wrap"/>
|
||||
</StackPanel>
|
||||
|
||||
<ItemsControl ItemsSource="{Binding Figures}">
|
||||
<ItemsControl.ItemsPanel>
|
||||
<ItemsPanelTemplate><WrapPanel ItemSpacing="6" LineSpacing="6"/></ItemsPanelTemplate>
|
||||
</ItemsControl.ItemsPanel>
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:WarningDetailFigure">
|
||||
<Border Background="{DynamicResource AppChipBackgroundBrush}" CornerRadius="6" Padding="8,4">
|
||||
<StackPanel>
|
||||
<TextBlock Text="{Binding Value}" FontSize="15" FontWeight="SemiBold"/>
|
||||
<TextBlock Text="{Binding Label}" FontSize="10" Opacity="0.6"/>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
|
||||
<Grid ColumnDefinitions="Auto,*,Auto">
|
||||
<Button Grid.Column="0" Content="‹" Padding="8,2" Command="{Binding PreviousMonthCommand}"
|
||||
ToolTip.Tip="Vorheriger Monat" AutomationProperties.Name="Vorheriger Monat"/>
|
||||
<TextBlock Grid.Column="1" Text="{Binding MonthLabel}" FontSize="12" FontWeight="SemiBold"
|
||||
HorizontalAlignment="Center" VerticalAlignment="Center"/>
|
||||
<Button Grid.Column="2" Content="›" Padding="8,2" Command="{Binding NextMonthCommand}"
|
||||
ToolTip.Tip="Nächster Monat" AutomationProperties.Name="Nächster Monat"/>
|
||||
</Grid>
|
||||
<shared:CompactMonthCalendar Days="{Binding CalendarDays}"/>
|
||||
<TextBlock Text="{Binding Legend}" FontSize="9" Opacity="0.5" TextWrapping="Wrap"/>
|
||||
|
||||
<StackPanel Spacing="3" IsVisible="{Binding HasEvents}">
|
||||
<TextBlock Text="{Binding ListHeader}" FontSize="11" FontWeight="SemiBold" Opacity="0.7"/>
|
||||
<ItemsControl ItemsSource="{Binding Events}">
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate x:DataType="svc:StudentEvidenceEvent">
|
||||
<Grid ColumnDefinitions="18,62,*" Margin="0,1">
|
||||
<Border Width="14" Height="14" CornerRadius="7" Background="{Binding ColorHex}"
|
||||
VerticalAlignment="Center">
|
||||
<TextBlock Text="{Binding Code}" Foreground="White" FontSize="8" FontWeight="Bold"
|
||||
HorizontalAlignment="Center" VerticalAlignment="Center"/>
|
||||
</Border>
|
||||
<TextBlock Grid.Column="1" Text="{Binding DateLabel}" FontSize="11" Opacity="0.65"
|
||||
VerticalAlignment="Center"/>
|
||||
<TextBlock Grid.Column="2" FontSize="11" TextTrimming="CharacterEllipsis" VerticalAlignment="Center"
|
||||
ToolTip.Tip="{Binding ListLine}">
|
||||
<Run Text="{Binding GroupName}" FontWeight="SemiBold"/>
|
||||
<Run Text=" · "/>
|
||||
<Run Text="{Binding Label}"/>
|
||||
</TextBlock>
|
||||
</Grid>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
<TextBlock Text="{Binding MoreEventsHint}" FontSize="10" Opacity="0.55" IsVisible="{Binding HasMoreEvents}"/>
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
</UserControl>
|
||||
@@ -0,0 +1,8 @@
|
||||
using Avalonia.Controls;
|
||||
|
||||
namespace LehrerApp.Desktop.Views.Shared;
|
||||
|
||||
public partial class StudentWarningDetailView : UserControl
|
||||
{
|
||||
public StudentWarningDetailView() => InitializeComponent();
|
||||
}
|
||||
@@ -122,10 +122,36 @@
|
||||
</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].((svm:StudentVorgaengeViewModel)DataContext).CreateAndLinkDocumentationCommand}"
|
||||
CommandParameter="{Binding}"/>
|
||||
<StackPanel Orientation="Horizontal" Spacing="6">
|
||||
<Button Content="+ Neue Dokumentation anlegen" FontSize="11" Padding="8,3"
|
||||
Command="{Binding $parent[ItemsControl].((svm:StudentVorgaengeViewModel)DataContext).CreateAndLinkDocumentationCommand}"
|
||||
CommandParameter="{Binding}"/>
|
||||
<!-- Nutzer-Feedback: standardisierte Einträge statt alles von Hand — jede
|
||||
Vorlage öffnet den normalen Dokumentations-Dialog vorbefüllt mit dem
|
||||
aktuellen Stand (Zahlen, Termine), gespeichert wird erst dort. -->
|
||||
<Button Content="+ Aus Vorlage ▾" FontSize="11" Padding="8,3"
|
||||
IsVisible="{Binding HasDocumentationTemplates}">
|
||||
<Button.Flyout>
|
||||
<MenuFlyout>
|
||||
<MenuItem Header="Fehlzeiten-Stand festhalten"
|
||||
Command="{Binding CreateFromTemplateCommand}" CommandParameter="attendance"/>
|
||||
<MenuItem Header="Hausaufgaben-Stand festhalten"
|
||||
Command="{Binding CreateFromTemplateCommand}" CommandParameter="homework"/>
|
||||
<MenuItem Header="Verspätungs-Stand festhalten"
|
||||
Command="{Binding CreateFromTemplateCommand}" CommandParameter="lateness"/>
|
||||
<Separator/>
|
||||
<MenuItem Header="Gespräch mit Schüler*in"
|
||||
Command="{Binding CreateFromTemplateCommand}" CommandParameter="conversation"/>
|
||||
<MenuItem Header="Klassenleitung informieren"
|
||||
Command="{Binding CreateFromTemplateCommand}" CommandParameter="informclassteacher"/>
|
||||
<MenuItem Header="Elternanruf vorbereiten"
|
||||
Command="{Binding CreateFromTemplateCommand}" CommandParameter="parentcall"/>
|
||||
<MenuItem Header="Elternbrief entwerfen"
|
||||
Command="{Binding CreateFromTemplateCommand}" CommandParameter="parentletter"/>
|
||||
</MenuFlyout>
|
||||
</Button.Flyout>
|
||||
</Button>
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
|
||||
<StackPanel Spacing="4" IsVisible="{Binding HasAvailableDocumentation}">
|
||||
|
||||
@@ -29,7 +29,10 @@ public partial class StudentVorgaengeTabView : UserControl
|
||||
var owner = TopLevel.GetTopLevel(this) as Window;
|
||||
if (owner is null) return null;
|
||||
|
||||
var vm = new VorgangDialogViewModel(students, editing);
|
||||
var tab = DataContext as StudentVorgaengeViewModel;
|
||||
var vm = new VorgangDialogViewModel(students, editing, tab?.BuildQuickBlocks(),
|
||||
isNewDraft: editing is not null && tab?.IsPersisted(editing) == false,
|
||||
phraseBlocks: VorgangPhraseBlocks.ForStudent(DateOnly.FromDateTime(DateTime.Today)));
|
||||
var dialog = new VorgangDialog { DataContext = vm };
|
||||
var saved = await dialog.ShowDialog<bool>(owner);
|
||||
return saved ? vm.Result : null;
|
||||
@@ -54,8 +57,10 @@ public partial class StudentVorgaengeTabView : UserControl
|
||||
var owner = TopLevel.GetTopLevel(this) as Window;
|
||||
if (owner is null) return null;
|
||||
|
||||
var isNewDraft = editing is not null
|
||||
&& (DataContext as StudentVorgaengeViewModel)?.IsNewDocumentationDraft(editing) == true;
|
||||
var vm = new DocumentationDialogViewModel(studentId, editing,
|
||||
App.Services.GetRequiredService<IAttachmentStorage>());
|
||||
App.Services.GetRequiredService<IAttachmentStorage>(), isNewDraft: isNewDraft);
|
||||
var dialog = new DocumentationDialog { DataContext = vm };
|
||||
var saved = await dialog.ShowDialog<bool>(owner);
|
||||
if (!saved) vm.DiscardUnsavedAttachments();
|
||||
|
||||
@@ -2005,6 +2005,51 @@ eigenen Unterricht abfragt und deshalb mit den regulären Lehrkraft-Rechten funk
|
||||
`DashboardViewModel.Load()`/`RebuildAttention()`. `DashboardView.axaml` bekam dafür einen neuen
|
||||
`.defused`-Stil (nach `.attendance` deklariert, damit er bei gleicher Klassenanzahl gewinnt) für
|
||||
Severity-Balken und Fehlzeiten-Prozent-Text.
|
||||
- **Nachtrag, Detail-Popup und Schnellbausteine (September 2026):** Nutzer-Feedback: (a) im
|
||||
Dashboard-Eintrag mehr erfahren, (b) beim Anlegen von Vorgang/Dokumentation Unterstützung statt
|
||||
alles von Hand. Gemeinsame Grundlage ist der neue `StudentWarningEvidenceService` (Desktop/
|
||||
Services): sammelt für eine/n Schüler*in Fehlquote (`AttendanceBalanceService`), Fehltermine,
|
||||
Verspätungen und offene Hausaufgaben im laufenden Schuljahr — bewusst nach exakt denselben
|
||||
Zählregeln wie die drei `Load*Warnings`-Methoden, damit Popup/Bausteine dieselben Zahlen zeigen
|
||||
wie die Warnzeile. (a) Jede der drei Warnungen hat jetzt einen Button **"Details ▾"** (Flyout per
|
||||
Klick, nicht Hover — ein Klick auf die Zeile navigiert bereits, und das Popup enthält einen
|
||||
blätterbaren Kalender) mit Kennzahlen, Terminliste und dem recycelten Monats-Widget der
|
||||
Klassenlehrer-Übersicht: "Monat auf einen Blick" wurde als `Views/Shared/CompactMonthCalendar`
|
||||
herausgelöst und wird von `ClassTeacherOverviewView` und dem neuen `StudentWarningDetailView`
|
||||
gleichermaßen genutzt (gleiche Kürzel/Farben U/A/E/V/H, `ClassTeacherCompactCalendarDay`). Der
|
||||
Popup-Inhalt (`StudentWarningDetailViewModel`) wird über `AttentionItem.Detail` erst beim Öffnen
|
||||
berechnet. (b) `StudentEvidenceText` formuliert die Belege als Text mit Stichtag:
|
||||
**Schnellbausteine** im Vorgang-Dialog (Fehlquote, Fehltermine, Hausaufgaben, Verspätungen —
|
||||
nur solche mit Inhalt, werden angehängt statt ersetzt), die automatische Vorbelegung der
|
||||
Beschreibung beim Anlegen aus einer Dashboard-Warnung, und **Dokumentationsvorlagen**
|
||||
(`VorgangDocumentationTemplates`, Menü "+ Aus Vorlage ▾" im Vorgang): Stand Fehlzeiten/
|
||||
Hausaufgaben/Verspätungen festhalten, Gespräch mit Schüler*in, Elternanruf vorbereiten (Punkte
|
||||
mit aktuellem Stand), Elternbrief entwerfen. Vorlagen erzeugen nur einen Entwurf im normalen
|
||||
Dokumentations-Dialog (Titel "hinzufügen" über neues `isNewDraft`), gespeichert und mit dem
|
||||
Vorgang verknüpft wird erst nach Bestätigung. **Bewusst nur im schülerbezogenen Vorgänge-Tab**
|
||||
— der Klassenlehrer-Tab nutzt dieselben Dialoge ohne Bausteine (Parameter optional); dort
|
||||
liefern die Zahlen ohnehin WebUntis statt der lokalen Mitarbeits-Einträge.
|
||||
- **Nachtrag 2, Wochentage, Standardformulierungen, Klassenlehrer-Bausteine (September 2026):**
|
||||
(1) Bugfix: der kompakte Monatskalender begann jeden Monat oben links, der 1. stand also
|
||||
unabhängig vom Wochentag in der Montags-Spalte. `CompactMonthCalendar` richtet jetzt selbst aus
|
||||
(Kopfzeile Mo–So, Leerzellen vor dem Monatsersten, `BuildCells`) — bewusst in der Anzeige statt in
|
||||
den beiden Buildern, deren Ergebnis "ein Eintrag je Kalendertag" für Tests/Aufrufer gleich bleibt;
|
||||
wirkt damit in der Klassenlehrer-Übersicht und im Dashboard-Popup. (2) Nutzer-Wunsch "den
|
||||
Klassenlehrer informiert": neue **Standardformulierungen** im Vorgang-Dialog
|
||||
(`VorgangPhraseBlocks`, datumsgestempelte Verlaufszeile, angehängt statt ersetzt) — im
|
||||
Schülerdetail u.a. "Klassenleitung informiert", im Klassenlehrer-Tab u.a. "Fachlehrkräfte
|
||||
informiert", "Attestpflicht angekündigt", "Schulsozialarbeit eingebunden", "Schulleitung
|
||||
informiert"; dazu die Vorlagen "Klassenleitung informieren" bzw. "Fachlehrkräfte informieren".
|
||||
(3) Der **Klassenlehrer-Vorgänge-Tab** bekommt Zahlen-Bausteine und Vorlagen aus WebUntis
|
||||
(`ClassTeacherStudentFacts`: Fehlstunden/-tage über alle Fächer, unentschuldigte und ganze
|
||||
Fehltage, Verspätungen, Klassenbucheinträge inkl. Hausaufgaben-Einträgen), erst beim Öffnen eines
|
||||
Dialogs aus dem Untis-Cache geladen; scheitert der Abruf, fehlen nur die Zahlen. Die
|
||||
Schülerbausteine im Dialog folgen der Mehrfachauswahl (bei mehreren Beteiligten mit Namen).
|
||||
Vorlagentexte liegen dafür jetzt an einer Stelle hinter einem neutralen `VorgangTemplateContext`
|
||||
(lokal aus `StudentEvidenceText.TemplateContext`, im Klassenlehrer-Tab aus
|
||||
`ClassTeacherStudentFacts.TemplateContext`). Vorlagen im Klassenlehrer-Tab beziehen sich auf
|
||||
die/den erste/n Beteiligte/n des Vorgangs, der Dokumentations-Dialog bietet dann nur diese Person
|
||||
an (sonst passten die eingesetzten Zahlen nicht zur gewählten Person).
|
||||
|
||||
**Nachtrag zu 4.3, Bugfix Wochennavigation (September 2026):** Nutzer-Feedback: beim Planen der
|
||||
nächsten Woche musste nach jedem kurzen Abstecher in einen anderen Bereich der Sidebar wieder
|
||||
|
||||
Reference in new issue
Block a user