UI Improvements: Planungsdialog, Fehlzeitendarstellung, Vorauswahl der Einheit
CI / build-and-test (push) Canceled after 0s
CI / build-and-test (push) Canceled after 0s
This commit is contained in:
@@ -119,6 +119,24 @@ public sealed record ClassAbsenceDaySummaryRow(DateOnly Date, string StudentName
|
||||
DateOnly.TryParseExact(value.ToString(), "yyyyMMdd", out date);
|
||||
}
|
||||
|
||||
public enum ClassTeacherCalendarEventKind { Late, Absent, Unexcused, Excused, ClassRegister, Homework }
|
||||
|
||||
/// <summary>Kompakter, farblich codierter Marker in der Klassenlehrer-Monatsansicht.</summary>
|
||||
public sealed record ClassTeacherCalendarEvent(string StudentName, string Code, string Label,
|
||||
string ColorHex, ClassTeacherCalendarEventKind Kind)
|
||||
{
|
||||
public string Tooltip => $"{StudentName}: {Label}";
|
||||
}
|
||||
|
||||
/// <summary>Ein Werktag im 5-Spalten-Monatsraster; Tage außerhalb des Monats sind Platzhalter.</summary>
|
||||
public sealed record ClassTeacherCalendarDay(DateOnly? Date, IReadOnlyList<ClassTeacherCalendarEvent> Events)
|
||||
{
|
||||
public bool IsPlaceholder => Date is null;
|
||||
public string DayLabel => Date?.ToString("dd.") ?? "";
|
||||
public string WeekdayLabel => Date?.ToString("ddd", System.Globalization.CultureInfo.GetCultureInfo("de-DE")) ?? "";
|
||||
public bool HasEvents => Events.Count > 0;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Details-Ansicht des Klassenlehrer-Bereichs: Klassenbucheinträge, die andere Lehrkräfte zu
|
||||
/// Schülern der Klasse angelegt haben (WebUntis "-alle-"-Bericht, gefiltert auf fremde statt der
|
||||
@@ -149,6 +167,7 @@ public partial class ClassTeacherDetailsViewModel : ObservableObject
|
||||
public ObservableCollection<ClassAbsenceDaySummaryRow> AbsenceEntries { get; } = [];
|
||||
public ObservableCollection<ClassTeacherCategoryAggregateRow> CategoryAggregates { get; } = [];
|
||||
public ObservableCollection<DocumentationItem> OwnDocumentationEntries { get; } = [];
|
||||
public ObservableCollection<ClassTeacherCalendarDay> CalendarDays { get; } = [];
|
||||
|
||||
/// Für das Kontextmenü "→ An Vorgang anheften" (DataGrid.SelectedItem, zweigleisig gebunden).
|
||||
[ObservableProperty] private ClassTeacherClassRegisterRow? _selectedEntry;
|
||||
@@ -160,6 +179,9 @@ public partial class ClassTeacherDetailsViewModel : ObservableObject
|
||||
/// Von der Übersicht gesetzt (Klick auf eine Roster-Zeile) - leer zeigt alle Schüler*innen.
|
||||
[ObservableProperty] private string _studentFilter = "";
|
||||
[ObservableProperty] private int _quickRangeIndex = 1;
|
||||
[ObservableProperty] private bool _showMonthlyCalendar;
|
||||
[ObservableProperty] private bool _includeHomeworkInCalendar;
|
||||
[ObservableProperty] private DateOnly _calendarMonth = new(DateTime.Today.Year, DateTime.Today.Month, 1);
|
||||
/// Umschalter Klassenbuch (WebUntis, andere Lehrkräfte) ↔ eigene Dokumentation.
|
||||
[ObservableProperty] private bool _showOwnDocumentation;
|
||||
[ObservableProperty] private int _ownDocumentationCount;
|
||||
@@ -174,6 +196,7 @@ public partial class ClassTeacherDetailsViewModel : ObservableObject
|
||||
|
||||
public bool HasEntries => Entries.Count > 0;
|
||||
public bool HasAbsenceEntries => AbsenceEntries.Count > 0;
|
||||
public bool ShowAbsenceListEmpty => !ShowMonthlyCalendar && !HasAbsenceEntries;
|
||||
public bool HasCategoryAggregates => CategoryAggregates.Count > 0;
|
||||
public bool HasOwnDocumentationEntries => OwnDocumentationEntries.Count > 0;
|
||||
/// Die Kategorien-Chipreihe fasst nur WebUntis-Kategorien zusammen (<see cref="CategoryAggregates"/>)
|
||||
@@ -182,6 +205,8 @@ public partial class ClassTeacherDetailsViewModel : ObservableObject
|
||||
public int UntisCriticalCount => Entries.Count(e => e.IsDangerStatus);
|
||||
public string ActiveFilterLabel => string.IsNullOrWhiteSpace(StudentFilter)
|
||||
? "Alle Schüler*innen" : StudentFilter;
|
||||
public string CalendarMonthLabel => CalendarMonth.ToString("MMMM yyyy",
|
||||
System.Globalization.CultureInfo.GetCultureInfo("de-DE"));
|
||||
|
||||
public ClassTeacherDetailsViewModel(UntisReportCacheService cache, IDocumentationRepository documentation,
|
||||
IStudentRepository students, ClassTeacherCasesViewModel cases)
|
||||
@@ -198,6 +223,7 @@ public partial class ClassTeacherDetailsViewModel : ObservableObject
|
||||
StudentFilter = "";
|
||||
Entries.Clear();
|
||||
AbsenceEntries.Clear();
|
||||
CalendarDays.Clear();
|
||||
CategoryAggregates.Clear();
|
||||
OwnDocumentationEntries.Clear();
|
||||
_rosterMatches = [];
|
||||
@@ -208,6 +234,9 @@ public partial class ClassTeacherDetailsViewModel : ObservableObject
|
||||
partial void OnStudentFilterChanged(string value) => OnPropertyChanged(nameof(ActiveFilterLabel));
|
||||
|
||||
partial void OnShowOwnDocumentationChanged(bool value) => OnPropertyChanged(nameof(ShowCategoryAggregates));
|
||||
partial void OnShowMonthlyCalendarChanged(bool value) => OnPropertyChanged(nameof(ShowAbsenceListEmpty));
|
||||
partial void OnIncludeHomeworkInCalendarChanged(bool value) => BuildCalendar();
|
||||
partial void OnCalendarMonthChanged(DateOnly value) => OnPropertyChanged(nameof(CalendarMonthLabel));
|
||||
|
||||
partial void OnQuickRangeIndexChanged(int value)
|
||||
{
|
||||
@@ -230,6 +259,37 @@ public partial class ClassTeacherDetailsViewModel : ObservableObject
|
||||
[RelayCommand]
|
||||
private Task ApplyFilter() => LoadInternal(forceRefresh: false);
|
||||
|
||||
[RelayCommand] private void ShowAbsenceList() => ShowMonthlyCalendar = false;
|
||||
|
||||
[RelayCommand]
|
||||
private async Task ShowCalendar()
|
||||
{
|
||||
ShowMonthlyCalendar = true;
|
||||
await LoadCalendarMonth(forceRefresh: false);
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private async Task PreviousCalendarMonth()
|
||||
{
|
||||
CalendarMonth = CalendarMonth.AddMonths(-1);
|
||||
await LoadCalendarMonth(forceRefresh: false);
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private async Task NextCalendarMonth()
|
||||
{
|
||||
CalendarMonth = CalendarMonth.AddMonths(1);
|
||||
await LoadCalendarMonth(forceRefresh: false);
|
||||
}
|
||||
|
||||
private Task LoadCalendarMonth(bool forceRefresh)
|
||||
{
|
||||
StartDate = new DateTimeOffset(CalendarMonth.ToDateTime(TimeOnly.MinValue));
|
||||
var end = CalendarMonth.AddMonths(1).AddDays(-1);
|
||||
EndDate = new DateTimeOffset(end.ToDateTime(TimeOnly.MinValue));
|
||||
return LoadInternal(forceRefresh);
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private async Task ClearStudentFilter()
|
||||
{
|
||||
@@ -270,6 +330,8 @@ public partial class ClassTeacherDetailsViewModel : ObservableObject
|
||||
foreach (var row in ClassAbsenceDaySummaryRow.GroupByStudentAndDay(absences))
|
||||
AbsenceEntries.Add(row);
|
||||
|
||||
BuildCalendar();
|
||||
|
||||
var localStudents = _students.GetAll();
|
||||
_rosterMatches = rosterTask.Result
|
||||
.Select(r => (Roster: r, Student: ClassTeacherOverviewViewModel.MatchStudent(r.DisplayName, localStudents)))
|
||||
@@ -289,6 +351,67 @@ public partial class ClassTeacherDetailsViewModel : ObservableObject
|
||||
finally { Busy = false; NotifyListState(); }
|
||||
}
|
||||
|
||||
private void BuildCalendar()
|
||||
{
|
||||
CalendarDays.Clear();
|
||||
foreach (var day in BuildCalendarDays(CalendarMonth, AbsenceEntries, Entries, IncludeHomeworkInCalendar))
|
||||
CalendarDays.Add(day);
|
||||
}
|
||||
|
||||
/// <summary>Baut ein Montag-bis-Freitag-Raster aus den ohnehin geladenen Berichten. Negative
|
||||
/// Klassenbucheinträge erscheinen als !; Hausaufgaben sind eine bewusst optionale Sonderlage.</summary>
|
||||
public static IReadOnlyList<ClassTeacherCalendarDay> BuildCalendarDays(DateOnly month,
|
||||
IReadOnlyList<ClassAbsenceDaySummaryRow> absences,
|
||||
IReadOnlyList<ClassTeacherClassRegisterRow> registerEntries, bool includeHomework)
|
||||
{
|
||||
var first = new DateOnly(month.Year, month.Month, 1);
|
||||
var last = first.AddMonths(1).AddDays(-1);
|
||||
var gridStart = first.AddDays(-(((int)first.DayOfWeek + 6) % 7));
|
||||
var gridEnd = last.AddDays((7 - (int)last.DayOfWeek) % 7);
|
||||
var result = new List<ClassTeacherCalendarDay>();
|
||||
|
||||
for (var date = gridStart; date <= gridEnd; date = date.AddDays(1))
|
||||
{
|
||||
if (date.DayOfWeek is DayOfWeek.Saturday or DayOfWeek.Sunday) continue;
|
||||
if (date.Month != first.Month)
|
||||
{
|
||||
result.Add(new ClassTeacherCalendarDay(null, []));
|
||||
continue;
|
||||
}
|
||||
|
||||
var events = new List<ClassTeacherCalendarEvent>();
|
||||
foreach (var absence in absences.Where(a => a.Date == date).OrderBy(a => a.StudentDisplayName))
|
||||
{
|
||||
var (code, label, color, kind) = absence.IsUnexcused
|
||||
? ("U", "Unentschuldigt gefehlt", "#C62828", ClassTeacherCalendarEventKind.Unexcused)
|
||||
: absence.IsLate
|
||||
? ("V", "Verspätet", "#EF6C00", ClassTeacherCalendarEventKind.Late)
|
||||
: absence.FriendlyStatusLabel.Contains("Entschuldigt", StringComparison.OrdinalIgnoreCase)
|
||||
? ("E", "Entschuldigt gefehlt", "#2E7D32", ClassTeacherCalendarEventKind.Excused)
|
||||
: ("A", "Abwesend", "#1565C0", ClassTeacherCalendarEventKind.Absent);
|
||||
events.Add(new ClassTeacherCalendarEvent(absence.StudentDisplayName, code, label, color, kind));
|
||||
}
|
||||
|
||||
foreach (var entry in registerEntries.Where(e => e.Date == date).OrderBy(e => e.StudentDisplayName))
|
||||
{
|
||||
var isHomework = ContainsHomework(entry);
|
||||
if (isHomework && !includeHomework) continue;
|
||||
if (!entry.IsDangerStatus && !isHomework) continue;
|
||||
events.Add(new ClassTeacherCalendarEvent(entry.StudentDisplayName,
|
||||
isHomework ? "H" : "!", isHomework ? "Hausaufgaben" : "Negativer Klassenbucheintrag",
|
||||
isHomework ? "#6A1B9A" : "#8E24AA",
|
||||
isHomework ? ClassTeacherCalendarEventKind.Homework : ClassTeacherCalendarEventKind.ClassRegister));
|
||||
}
|
||||
|
||||
result.Add(new ClassTeacherCalendarDay(date, events));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private static bool ContainsHomework(ClassTeacherClassRegisterRow entry) =>
|
||||
entry.CategoryName?.Contains("Hausauf", StringComparison.OrdinalIgnoreCase) == true ||
|
||||
entry.Text?.Contains("Hausauf", StringComparison.OrdinalIgnoreCase) == true;
|
||||
|
||||
/// Baut <see cref="OwnDocumentationEntries"/> aus <see cref="_rosterMatches"/> und dem zuletzt
|
||||
/// geladenen Zeitraum neu auf — separat von <see cref="LoadInternal"/>, damit Anlegen/Bearbeiten/
|
||||
/// Löschen eines eigenen Eintrags nicht auch die WebUntis-Berichte neu abruft. Die eigentliche
|
||||
@@ -419,6 +542,7 @@ public partial class ClassTeacherDetailsViewModel : ObservableObject
|
||||
{
|
||||
OnPropertyChanged(nameof(HasEntries));
|
||||
OnPropertyChanged(nameof(HasAbsenceEntries));
|
||||
OnPropertyChanged(nameof(ShowAbsenceListEmpty));
|
||||
OnPropertyChanged(nameof(HasCategoryAggregates));
|
||||
OnPropertyChanged(nameof(ShowCategoryAggregates));
|
||||
OnPropertyChanged(nameof(HasOwnDocumentationEntries));
|
||||
|
||||
@@ -102,10 +102,10 @@ public partial class PlanningTabViewModel : ObservableObject
|
||||
GradeLevel = group?.GradeLevel ?? 0;
|
||||
SubjectName = SubjectId is Guid sid ? _subjects.GetById(sid)?.Name ?? "" : "";
|
||||
GroupLabel = group?.Name ?? "";
|
||||
LoadUnits();
|
||||
LoadUnits(preferActive: true);
|
||||
}
|
||||
|
||||
private void LoadUnits()
|
||||
private void LoadUnits(bool preferActive = false)
|
||||
{
|
||||
var selectedId = SelectedUnit?.Id;
|
||||
Units.Clear();
|
||||
@@ -128,7 +128,11 @@ public partial class PlanningTabViewModel : ObservableObject
|
||||
KnownMaterials = materials.OrderBy(m => m, StringComparer.CurrentCultureIgnoreCase).ToList();
|
||||
KnownShorthands = shorthands.OrderBy(s => s, StringComparer.CurrentCultureIgnoreCase).ToList();
|
||||
|
||||
SelectedUnit = Units.FirstOrDefault(u => u.Id == selectedId) ?? Units.FirstOrDefault();
|
||||
// Beim ersten Öffnen bzw. beim Wechsel aus dem Stundenplan steht die laufende Einheit im
|
||||
// Fokus. Eine bewusste Auswahl innerhalb derselben Gruppe bleibt bei Refreshes erhalten.
|
||||
SelectedUnit = !preferActive && selectedId is not null
|
||||
? Units.FirstOrDefault(u => u.Id == selectedId) ?? Units.FirstOrDefault(u => u.Status == UnitStatus.Active) ?? Units.FirstOrDefault()
|
||||
: Units.FirstOrDefault(u => u.Status == UnitStatus.Active) ?? Units.FirstOrDefault();
|
||||
}
|
||||
|
||||
partial void OnSelectedUnitChanged(UnitSummary? value)
|
||||
@@ -574,6 +578,7 @@ public partial class UnitDialogViewModel : ObservableObject
|
||||
[ObservableProperty] private string _startDateText = "";
|
||||
[ObservableProperty] private string _endDateText = "";
|
||||
[ObservableProperty] private string _statusName = UnitStatusDisplay.Options[0];
|
||||
[ObservableProperty] private string _statusNotice = "";
|
||||
[ObservableProperty] private string _notes = "";
|
||||
[ObservableProperty] private bool _isCompetencyPanelOpen;
|
||||
[ObservableProperty] private string _titleError = "";
|
||||
@@ -594,6 +599,20 @@ public partial class UnitDialogViewModel : ObservableObject
|
||||
public string DialogTitle => _editingUnit is null ? "Neue Einheit anlegen" : "Einheit bearbeiten";
|
||||
public string SaveButtonText => _editingUnit is null ? "Anlegen" : "Speichern";
|
||||
|
||||
partial void OnStatusNameChanged(string value)
|
||||
{
|
||||
if (UnitStatusDisplay.FromName(value) != UnitStatus.Active)
|
||||
{
|
||||
StatusNotice = "";
|
||||
return;
|
||||
}
|
||||
|
||||
var previous = _units.GetByGroup(_groupId)
|
||||
.FirstOrDefault(u => u.Status == UnitStatus.Active && u.Id != _editingUnit?.Id);
|
||||
StatusNotice = previous is null ? ""
|
||||
: $"„{previous.Title}“ wird beim Speichern automatisch abgeschlossen.";
|
||||
}
|
||||
|
||||
public UnitDialogViewModel(IUnitRepository units, ICompetencyDomainRepository competencyDomains,
|
||||
Guid groupId, Guid? subjectId, int gradeLevel, string groupName, string subjectName, Unit? editingUnit)
|
||||
{
|
||||
@@ -679,6 +698,17 @@ public partial class UnitDialogViewModel : ObservableObject
|
||||
Result.Status = UnitStatusDisplay.FromName(StatusName);
|
||||
Result.Notes = string.IsNullOrWhiteSpace(Notes) ? null : Notes.Trim();
|
||||
Result.Competencies = _competencyCodes;
|
||||
if (Result.Status == UnitStatus.Active)
|
||||
{
|
||||
// Pro Lerngruppe gibt es genau einen aktuellen Arbeitskontext. Frühere laufende
|
||||
// Einheiten werden nicht verworfen, sondern fachlich sauber abgeschlossen.
|
||||
foreach (var previous in _units.GetByGroup(_groupId)
|
||||
.Where(u => u.Status == UnitStatus.Active && u.Id != Result.Id))
|
||||
{
|
||||
previous.Status = UnitStatus.Completed;
|
||||
_units.Save(previous);
|
||||
}
|
||||
}
|
||||
_units.Save(Result);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user