UI Improvements: Planungsdialog, Fehlzeitendarstellung, Vorauswahl der Einheit
CI / build-and-test (push) Canceled after 0s

This commit is contained in:
2026-09-05 00:02:28 +02:00
parent fedfceb81d
commit 265190b3aa
9 changed files with 358 additions and 33 deletions
@@ -357,6 +357,20 @@ public class UnitRepository(LiteDbContext db) : IUnitRepository
public void Save(Unit u) public void Save(Unit u)
{ {
ArchivedGroupWriteGuard.EnsureActive(db, u.GroupId); ArchivedGroupWriteGuard.EnsureActive(db, u.GroupId);
// Persistenz-Invariante: auch Importe und künftig hinzukommende Aufrufer dürfen keine
// zweite laufende Einheit in derselben Lerngruppe hinterlassen. Der Einheiten-Dialog
// kündigt diese automatische Ablösung vorher sichtbar an.
if (u.Status == UnitStatus.Active)
{
foreach (var previous in db.Units.Find(x => x.GroupId == u.GroupId &&
x.Status == UnitStatus.Active && x.Id != u.Id))
{
previous.Status = UnitStatus.Completed;
previous.UpdatedAt = DateTime.UtcNow;
db.Units.Upsert(previous);
db.OnChange?.Invoke(nameof(Unit), previous.Id.ToString(), "Save", previous);
}
}
u.UpdatedAt = DateTime.UtcNow; u.UpdatedAt = DateTime.UtcNow;
db.Units.Upsert(u); db.Units.Upsert(u);
db.OnChange?.Invoke(nameof(Unit), u.Id.ToString(), "Save", u); db.OnChange?.Invoke(nameof(Unit), u.Id.ToString(), "Save", u);
@@ -8,6 +8,37 @@ namespace LehrerApp.Desktop.Tests;
public sealed class ClassTeacherViewModelsTests public sealed class ClassTeacherViewModelsTests
{ {
[Fact]
public void Monatskalender_KodiertFehlzeitenUndBlendetHausaufgabenStandardmaessigAus()
{
var month = new DateOnly(2026, 9, 1);
var absences = new[]
{
new ClassAbsenceDaySummaryRow(new DateOnly(2026, 9, 2), "Müller Ada", 1, 0, 12,
["Deu"], [1], ["entsch."], ["Verspätung"], null, null, false),
new ClassAbsenceDaySummaryRow(new DateOnly(2026, 9, 3), "Schmidt Ben", 2, 2, 90,
["Mathe"], [1, 2], ["nicht entsch."], ["Absent"], null, null, false),
};
var register = new[]
{
new ClassTeacherClassRegisterRow(new DateOnly(2026, 9, 3), "Deu", "Schmidt Ben", "test",
"Störung", "Negativ", "Unterricht massiv gestört"),
new ClassTeacherClassRegisterRow(new DateOnly(2026, 9, 4), "Mathe", "Müller Ada", "test",
"Hausaufgaben", "Negativ", "Hausaufgaben fehlen"),
};
var days = ClassTeacherDetailsViewModel.BuildCalendarDays(month, absences, register, includeHomework: false);
Assert.Equal(0, days.Count % 5);
Assert.Contains(days.SelectMany(d => d.Events), e => e.Code == "V");
Assert.Contains(days.SelectMany(d => d.Events), e => e.Code == "U");
Assert.Contains(days.SelectMany(d => d.Events), e => e.Code == "!");
Assert.DoesNotContain(days.SelectMany(d => d.Events), e => e.Code == "H");
var withHomework = ClassTeacherDetailsViewModel.BuildCalendarDays(month, absences, register, includeHomework: true);
Assert.Contains(withHomework.SelectMany(d => d.Events), e => e.Code == "H");
}
[Fact] [Fact]
public void GroupByStudentAndDay_FasstFehlstundenProSchuelerUndTagZusammen() public void GroupByStudentAndDay_FasstFehlstundenProSchuelerUndTagZusammen()
{ {
@@ -45,6 +45,21 @@ public class PlanningTabViewModelTests
Assert.Contains("2 / 3", summary.ProgressText); Assert.Contains("2 / 3", summary.ProgressText);
} }
[Fact]
public void Initialize_WaehltLaufendeEinheitAlsArbeitskontext()
{
var (vm, units, _, groupId) = BuildScenario();
units.Add(new Unit { GroupId = groupId, Title = "Früher", Status = UnitStatus.Completed,
StartDate = new DateOnly(2025, 8, 1) });
var active = new Unit { GroupId = groupId, Title = "Aktuell", Status = UnitStatus.Active,
StartDate = new DateOnly(2025, 9, 1) };
units.Add(active);
vm.Initialize(groupId);
Assert.Equal(active.Id, vm.SelectedUnit?.Id);
}
[Fact] [Fact]
public async Task MoveLesson_MitNachrueckenVerschiebtNurGeplanteFolgestundenNachDemStichtag() public async Task MoveLesson_MitNachrueckenVerschiebtNurGeplanteFolgestundenNachDemStichtag()
{ {
@@ -25,4 +25,28 @@ public sealed class UnitDialogViewModelTests
Assert.Equal("10c · kein Fach hinterlegt (siehe Lerngruppe)", vm.GroupSubjectDisplay); Assert.Equal("10c · kein Fach hinterlegt (siehe Lerngruppe)", vm.GroupSubjectDisplay);
} }
[Fact]
public void Laufend_SetztBisherigeLaufendeEinheitAufAbgeschlossenUndWarntVorher()
{
var groupId = Guid.NewGuid();
var units = new FakeUnits();
var previous = new LehrerApp.Core.Models.Unit
{
GroupId = groupId, Title = "Bruchrechnung", Status = LehrerApp.Core.Models.UnitStatus.Active,
};
units.Add(previous);
var vm = new UnitDialogViewModel(units, new FakeCompetencyDomains(),
groupId, null, 6, "6a", "Mathematik", editingUnit: null)
{
Title = "Prozentrechnung",
StatusName = "Laufend",
};
Assert.Contains("Bruchrechnung", vm.StatusNotice);
vm.SaveCommand.Execute(null);
Assert.Equal(LehrerApp.Core.Models.UnitStatus.Completed, previous.Status);
Assert.Single(units.GetByGroup(groupId), u => u.Status == LehrerApp.Core.Models.UnitStatus.Active);
}
} }
@@ -119,6 +119,24 @@ public sealed record ClassAbsenceDaySummaryRow(DateOnly Date, string StudentName
DateOnly.TryParseExact(value.ToString(), "yyyyMMdd", out date); 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> /// <summary>
/// Details-Ansicht des Klassenlehrer-Bereichs: Klassenbucheinträge, die andere Lehrkräfte zu /// 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 /// 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<ClassAbsenceDaySummaryRow> AbsenceEntries { get; } = [];
public ObservableCollection<ClassTeacherCategoryAggregateRow> CategoryAggregates { get; } = []; public ObservableCollection<ClassTeacherCategoryAggregateRow> CategoryAggregates { get; } = [];
public ObservableCollection<DocumentationItem> OwnDocumentationEntries { get; } = []; public ObservableCollection<DocumentationItem> OwnDocumentationEntries { get; } = [];
public ObservableCollection<ClassTeacherCalendarDay> CalendarDays { get; } = [];
/// Für das Kontextmenü "→ An Vorgang anheften" (DataGrid.SelectedItem, zweigleisig gebunden). /// Für das Kontextmenü "→ An Vorgang anheften" (DataGrid.SelectedItem, zweigleisig gebunden).
[ObservableProperty] private ClassTeacherClassRegisterRow? _selectedEntry; [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. /// Von der Übersicht gesetzt (Klick auf eine Roster-Zeile) - leer zeigt alle Schüler*innen.
[ObservableProperty] private string _studentFilter = ""; [ObservableProperty] private string _studentFilter = "";
[ObservableProperty] private int _quickRangeIndex = 1; [ObservableProperty] private int _quickRangeIndex = 1;
[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. /// Umschalter Klassenbuch (WebUntis, andere Lehrkräfte) ↔ eigene Dokumentation.
[ObservableProperty] private bool _showOwnDocumentation; [ObservableProperty] private bool _showOwnDocumentation;
[ObservableProperty] private int _ownDocumentationCount; [ObservableProperty] private int _ownDocumentationCount;
@@ -174,6 +196,7 @@ public partial class ClassTeacherDetailsViewModel : ObservableObject
public bool HasEntries => Entries.Count > 0; public bool HasEntries => Entries.Count > 0;
public bool HasAbsenceEntries => AbsenceEntries.Count > 0; public bool HasAbsenceEntries => AbsenceEntries.Count > 0;
public bool ShowAbsenceListEmpty => !ShowMonthlyCalendar && !HasAbsenceEntries;
public bool HasCategoryAggregates => CategoryAggregates.Count > 0; public bool HasCategoryAggregates => CategoryAggregates.Count > 0;
public bool HasOwnDocumentationEntries => OwnDocumentationEntries.Count > 0; public bool HasOwnDocumentationEntries => OwnDocumentationEntries.Count > 0;
/// Die Kategorien-Chipreihe fasst nur WebUntis-Kategorien zusammen (<see cref="CategoryAggregates"/>) /// 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 int UntisCriticalCount => Entries.Count(e => e.IsDangerStatus);
public string ActiveFilterLabel => string.IsNullOrWhiteSpace(StudentFilter) public string ActiveFilterLabel => string.IsNullOrWhiteSpace(StudentFilter)
? "Alle Schüler*innen" : StudentFilter; ? "Alle Schüler*innen" : StudentFilter;
public string CalendarMonthLabel => CalendarMonth.ToString("MMMM yyyy",
System.Globalization.CultureInfo.GetCultureInfo("de-DE"));
public ClassTeacherDetailsViewModel(UntisReportCacheService cache, IDocumentationRepository documentation, public ClassTeacherDetailsViewModel(UntisReportCacheService cache, IDocumentationRepository documentation,
IStudentRepository students, ClassTeacherCasesViewModel cases) IStudentRepository students, ClassTeacherCasesViewModel cases)
@@ -198,6 +223,7 @@ public partial class ClassTeacherDetailsViewModel : ObservableObject
StudentFilter = ""; StudentFilter = "";
Entries.Clear(); Entries.Clear();
AbsenceEntries.Clear(); AbsenceEntries.Clear();
CalendarDays.Clear();
CategoryAggregates.Clear(); CategoryAggregates.Clear();
OwnDocumentationEntries.Clear(); OwnDocumentationEntries.Clear();
_rosterMatches = []; _rosterMatches = [];
@@ -208,6 +234,9 @@ public partial class ClassTeacherDetailsViewModel : ObservableObject
partial void OnStudentFilterChanged(string value) => OnPropertyChanged(nameof(ActiveFilterLabel)); partial void OnStudentFilterChanged(string value) => OnPropertyChanged(nameof(ActiveFilterLabel));
partial void OnShowOwnDocumentationChanged(bool value) => OnPropertyChanged(nameof(ShowCategoryAggregates)); partial void 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) partial void OnQuickRangeIndexChanged(int value)
{ {
@@ -230,6 +259,37 @@ public partial class ClassTeacherDetailsViewModel : ObservableObject
[RelayCommand] [RelayCommand]
private Task ApplyFilter() => LoadInternal(forceRefresh: false); 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] [RelayCommand]
private async Task ClearStudentFilter() private async Task ClearStudentFilter()
{ {
@@ -270,6 +330,8 @@ public partial class ClassTeacherDetailsViewModel : ObservableObject
foreach (var row in ClassAbsenceDaySummaryRow.GroupByStudentAndDay(absences)) foreach (var row in ClassAbsenceDaySummaryRow.GroupByStudentAndDay(absences))
AbsenceEntries.Add(row); AbsenceEntries.Add(row);
BuildCalendar();
var localStudents = _students.GetAll(); var localStudents = _students.GetAll();
_rosterMatches = rosterTask.Result _rosterMatches = rosterTask.Result
.Select(r => (Roster: r, Student: ClassTeacherOverviewViewModel.MatchStudent(r.DisplayName, localStudents))) .Select(r => (Roster: r, Student: ClassTeacherOverviewViewModel.MatchStudent(r.DisplayName, localStudents)))
@@ -289,6 +351,67 @@ public partial class ClassTeacherDetailsViewModel : ObservableObject
finally { Busy = false; NotifyListState(); } 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 /// Baut <see cref="OwnDocumentationEntries"/> aus <see cref="_rosterMatches"/> und dem zuletzt
/// geladenen Zeitraum neu auf — separat von <see cref="LoadInternal"/>, damit Anlegen/Bearbeiten/ /// 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 /// 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(HasEntries));
OnPropertyChanged(nameof(HasAbsenceEntries)); OnPropertyChanged(nameof(HasAbsenceEntries));
OnPropertyChanged(nameof(ShowAbsenceListEmpty));
OnPropertyChanged(nameof(HasCategoryAggregates)); OnPropertyChanged(nameof(HasCategoryAggregates));
OnPropertyChanged(nameof(ShowCategoryAggregates)); OnPropertyChanged(nameof(ShowCategoryAggregates));
OnPropertyChanged(nameof(HasOwnDocumentationEntries)); OnPropertyChanged(nameof(HasOwnDocumentationEntries));
@@ -102,10 +102,10 @@ public partial class PlanningTabViewModel : ObservableObject
GradeLevel = group?.GradeLevel ?? 0; GradeLevel = group?.GradeLevel ?? 0;
SubjectName = SubjectId is Guid sid ? _subjects.GetById(sid)?.Name ?? "" : ""; SubjectName = SubjectId is Guid sid ? _subjects.GetById(sid)?.Name ?? "" : "";
GroupLabel = group?.Name ?? ""; GroupLabel = group?.Name ?? "";
LoadUnits(); LoadUnits(preferActive: true);
} }
private void LoadUnits() private void LoadUnits(bool preferActive = false)
{ {
var selectedId = SelectedUnit?.Id; var selectedId = SelectedUnit?.Id;
Units.Clear(); Units.Clear();
@@ -128,7 +128,11 @@ public partial class PlanningTabViewModel : ObservableObject
KnownMaterials = materials.OrderBy(m => m, StringComparer.CurrentCultureIgnoreCase).ToList(); KnownMaterials = materials.OrderBy(m => m, StringComparer.CurrentCultureIgnoreCase).ToList();
KnownShorthands = shorthands.OrderBy(s => s, 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) partial void OnSelectedUnitChanged(UnitSummary? value)
@@ -574,6 +578,7 @@ public partial class UnitDialogViewModel : ObservableObject
[ObservableProperty] private string _startDateText = ""; [ObservableProperty] private string _startDateText = "";
[ObservableProperty] private string _endDateText = ""; [ObservableProperty] private string _endDateText = "";
[ObservableProperty] private string _statusName = UnitStatusDisplay.Options[0]; [ObservableProperty] private string _statusName = UnitStatusDisplay.Options[0];
[ObservableProperty] private string _statusNotice = "";
[ObservableProperty] private string _notes = ""; [ObservableProperty] private string _notes = "";
[ObservableProperty] private bool _isCompetencyPanelOpen; [ObservableProperty] private bool _isCompetencyPanelOpen;
[ObservableProperty] private string _titleError = ""; [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 DialogTitle => _editingUnit is null ? "Neue Einheit anlegen" : "Einheit bearbeiten";
public string SaveButtonText => _editingUnit is null ? "Anlegen" : "Speichern"; 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, public UnitDialogViewModel(IUnitRepository units, ICompetencyDomainRepository competencyDomains,
Guid groupId, Guid? subjectId, int gradeLevel, string groupName, string subjectName, Unit? editingUnit) 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.Status = UnitStatusDisplay.FromName(StatusName);
Result.Notes = string.IsNullOrWhiteSpace(Notes) ? null : Notes.Trim(); Result.Notes = string.IsNullOrWhiteSpace(Notes) ? null : Notes.Trim();
Result.Competencies = _competencyCodes; 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); _units.Save(Result);
} }
} }
@@ -27,9 +27,20 @@
<Style Selector="TextBlock.status.danger"> <Style Selector="TextBlock.status.danger">
<Setter Property="Foreground" Value="{DynamicResource AppStatusDangerBrush}"/> <Setter Property="Foreground" Value="{DynamicResource AppStatusDangerBrush}"/>
</Style> </Style>
<Style Selector="Button.viewMode">
<Setter Property="Background" Value="Transparent"/>
<Setter Property="BorderBrush" Value="{DynamicResource AppFilterBorderBrush}"/>
<Setter Property="BorderThickness" Value="1"/>
<Setter Property="Padding" Value="12,6"/>
</Style>
<Style Selector="Button.viewMode.active">
<Setter Property="Background" Value="{DynamicResource AppFilterActiveBackgroundBrush}"/>
<Setter Property="BorderBrush" Value="{DynamicResource AppFilterActiveBorderBrush}"/>
<Setter Property="Foreground" Value="{DynamicResource AppFilterActiveForegroundBrush}"/>
</Style>
</UserControl.Styles> </UserControl.Styles>
<Grid RowDefinitions="Auto,Auto,*,Auto" Margin="16" RowSpacing="10"> <Grid RowDefinitions="Auto,Auto,Auto,*,Auto" Margin="16" RowSpacing="10">
<Grid Grid.Row="0" ColumnDefinitions="*,Auto"> <Grid Grid.Row="0" ColumnDefinitions="*,Auto">
<StackPanel> <StackPanel>
<TextBlock Text="Fehlzeiten" FontSize="22" FontWeight="SemiBold"/> <TextBlock Text="Fehlzeiten" FontSize="22" FontWeight="SemiBold"/>
@@ -39,7 +50,20 @@
IsEnabled="{Binding !Busy}" Background="Transparent" Foreground="{DynamicResource AppAccentTextBrush}" VerticalAlignment="Center"/> IsEnabled="{Binding !Busy}" Background="Transparent" Foreground="{DynamicResource AppAccentTextBrush}" VerticalAlignment="Center"/>
</Grid> </Grid>
<Border Grid.Row="1" Classes="filterCard"> <Grid Grid.Row="1" ColumnDefinitions="Auto,Auto,*,Auto,Auto,Auto" ColumnSpacing="6">
<Button Grid.Column="0" Content="Liste" Classes="viewMode" Classes.active="{Binding !ShowMonthlyCalendar}"
Command="{Binding ShowAbsenceListCommand}"/>
<Button Grid.Column="1" Content="Monatsübersicht" Classes="viewMode" Classes.active="{Binding ShowMonthlyCalendar}"
Command="{Binding ShowCalendarCommand}"/>
<Button Grid.Column="3" Content="" Width="36" Command="{Binding PreviousCalendarMonthCommand}"
IsVisible="{Binding ShowMonthlyCalendar}" AutomationProperties.Name="Vorheriger Monat"/>
<TextBlock Grid.Column="4" Text="{Binding CalendarMonthLabel}" FontSize="15" FontWeight="SemiBold"
VerticalAlignment="Center" Margin="8,0" IsVisible="{Binding ShowMonthlyCalendar}"/>
<Button Grid.Column="5" Content="" Width="36" Command="{Binding NextCalendarMonthCommand}"
IsVisible="{Binding ShowMonthlyCalendar}" AutomationProperties.Name="Nächster Monat"/>
</Grid>
<Border Grid.Row="2" Classes="filterCard" IsVisible="{Binding !ShowMonthlyCalendar}">
<Grid RowDefinitions="Auto,Auto" RowSpacing="8"> <Grid RowDefinitions="Auto,Auto" RowSpacing="8">
<Grid Grid.Row="0" ColumnDefinitions="155,Auto,*,Auto,*" ColumnSpacing="8"> <Grid Grid.Row="0" ColumnDefinitions="155,Auto,*,Auto,*" ColumnSpacing="8">
<ComboBox Grid.Column="0" SelectedIndex="{Binding QuickRangeIndex, Mode=TwoWay}"> <ComboBox Grid.Column="0" SelectedIndex="{Binding QuickRangeIndex, Mode=TwoWay}">
@@ -59,10 +83,11 @@
</Grid> </Grid>
</Border> </Border>
<Grid Grid.Row="2"> <Grid Grid.Row="3">
<DataGrid ItemsSource="{Binding AbsenceEntries}" AutoGenerateColumns="False" IsReadOnly="True" <DataGrid ItemsSource="{Binding AbsenceEntries}" AutoGenerateColumns="False" IsReadOnly="True"
GridLinesVisibility="Horizontal" BorderBrush="{DynamicResource AppCardBorderBrush}" BorderThickness="1" GridLinesVisibility="Horizontal" BorderBrush="{DynamicResource AppCardBorderBrush}" BorderThickness="1"
CanUserResizeColumns="True" CanUserSortColumns="True" RowHeight="48"> CanUserResizeColumns="True" CanUserSortColumns="True" RowHeight="48"
IsVisible="{Binding !ShowMonthlyCalendar}">
<DataGrid.Columns> <DataGrid.Columns>
<DataGridTextColumn Header="Datum" Binding="{Binding DateLabel}" Width="105"/> <DataGridTextColumn Header="Datum" Binding="{Binding DateLabel}" Width="105"/>
<DataGridTextColumn Header="Schüler*in" Binding="{Binding StudentDisplayName}" Width="1.3*"/> <DataGridTextColumn Header="Schüler*in" Binding="{Binding StudentDisplayName}" Width="1.3*"/>
@@ -92,11 +117,61 @@
<DataGridTextColumn Header="Grund / Notiz" Binding="{Binding DetailLabel}" Width="2*"/> <DataGridTextColumn Header="Grund / Notiz" Binding="{Binding DetailLabel}" Width="2*"/>
</DataGrid.Columns> </DataGrid.Columns>
</DataGrid> </DataGrid>
<StackPanel IsVisible="{Binding !HasAbsenceEntries}" HorizontalAlignment="Center" VerticalAlignment="Center" Spacing="5"> <StackPanel IsVisible="{Binding ShowAbsenceListEmpty}" HorizontalAlignment="Center" VerticalAlignment="Center" Spacing="5">
<TextBlock Text="Keine Fehlzeiten im gewählten Zeitraum" FontWeight="SemiBold"/> <TextBlock Text="Keine Fehlzeiten im gewählten Zeitraum" FontWeight="SemiBold"/>
<TextBlock Text="Passe Zeitraum oder Schülerfilter an." FontSize="12" Opacity="0.55"/> <TextBlock Text="Passe Zeitraum oder Schülerfilter an." FontSize="12" Opacity="0.55"/>
</StackPanel> </StackPanel>
<Grid IsVisible="{Binding ShowMonthlyCalendar}" RowDefinitions="Auto,Auto,*" RowSpacing="8">
<WrapPanel Grid.Row="0" ItemSpacing="12" LineSpacing="6">
<TextBlock Text="V verspätet" Foreground="#EF6C00" FontSize="11" FontWeight="SemiBold"/>
<TextBlock Text="A abwesend" Foreground="#1565C0" FontSize="11" FontWeight="SemiBold"/>
<TextBlock Text="U unentschuldigt" Foreground="#C62828" FontSize="11" FontWeight="SemiBold"/>
<TextBlock Text="E entschuldigt" Foreground="#2E7D32" FontSize="11" FontWeight="SemiBold"/>
<TextBlock Text="! negativer Klassenbucheintrag" Foreground="#8E24AA" FontSize="11" FontWeight="SemiBold"/>
<CheckBox Content="Hausaufgaben (H) einblenden" IsChecked="{Binding IncludeHomeworkInCalendar}"
FontSize="11" VerticalAlignment="Center"/>
</WrapPanel>
<Grid Grid.Row="1" ColumnDefinitions="*,*,*,*,*" Margin="0,2,0,0">
<TextBlock Grid.Column="0" Text="Montag" FontWeight="SemiBold" HorizontalAlignment="Center"/>
<TextBlock Grid.Column="1" Text="Dienstag" FontWeight="SemiBold" HorizontalAlignment="Center"/>
<TextBlock Grid.Column="2" Text="Mittwoch" FontWeight="SemiBold" HorizontalAlignment="Center"/>
<TextBlock Grid.Column="3" Text="Donnerstag" FontWeight="SemiBold" HorizontalAlignment="Center"/>
<TextBlock Grid.Column="4" Text="Freitag" FontWeight="SemiBold" HorizontalAlignment="Center"/>
</Grid> </Grid>
<TextBlock Grid.Row="3" Text="{Binding Status}" FontSize="11" Opacity="0.6"/> <ScrollViewer Grid.Row="2">
<ItemsControl ItemsSource="{Binding CalendarDays}">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate><UniformGrid Columns="5"/></ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:ClassTeacherCalendarDay">
<Border BorderBrush="{DynamicResource AppCardBorderBrush}" BorderThickness="1"
Background="{DynamicResource AppCardBackgroundBrush}" MinHeight="112" Padding="7" Margin="2">
<StackPanel Spacing="5">
<TextBlock Text="{Binding DayLabel}" FontWeight="SemiBold" HorizontalAlignment="Right"/>
<ItemsControl ItemsSource="{Binding Events}">
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:ClassTeacherCalendarEvent">
<Grid ColumnDefinitions="22,*" Margin="0,1" ToolTip.Tip="{Binding Tooltip}">
<Border Width="18" Height="18" CornerRadius="9" Background="{Binding ColorHex}">
<TextBlock Text="{Binding Code}" Foreground="White" FontSize="10" FontWeight="Bold"
HorizontalAlignment="Center" VerticalAlignment="Center"/>
</Border>
<TextBlock Grid.Column="1" Text="{Binding StudentName}" FontSize="10"
TextTrimming="CharacterEllipsis" VerticalAlignment="Center"/>
</Grid>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</StackPanel>
</Border>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</ScrollViewer>
</Grid>
</Grid>
<TextBlock Grid.Row="4" Text="{Binding Status}" FontSize="11" Opacity="0.6"/>
</Grid> </Grid>
</UserControl> </UserControl>
@@ -6,9 +6,10 @@
<Grid RowDefinitions="Auto,2*,Auto,Auto,2*" Margin="16"> <Grid RowDefinitions="Auto,2*,Auto,Auto,2*" Margin="16">
<!-- Einheiten-Toolbar --> <!-- Einheiten-Toolbar: Aktionen bewusst unter der Überschrift und in umbruchfähigen
<Grid Grid.Row="0" ColumnDefinitions="*,Auto" Margin="0,0,0,8"> Gruppen. So kollidieren Beschriftung und Buttons auch in schmaleren Fenstern nicht. -->
<StackPanel Grid.Column="0" Orientation="Horizontal" Spacing="8" VerticalAlignment="Center"> <Grid Grid.Row="0" RowDefinitions="Auto,Auto" Margin="0,0,0,8" RowSpacing="6">
<StackPanel Grid.Row="0" Orientation="Horizontal" Spacing="8" VerticalAlignment="Center">
<TextBlock Text="Unterrichtseinheiten" FontSize="14" FontWeight="SemiBold" <TextBlock Text="Unterrichtseinheiten" FontSize="14" FontWeight="SemiBold"
VerticalAlignment="Center"/> VerticalAlignment="Center"/>
<Button Content="↕ JSON" FontSize="11" Padding="7,3" <Button Content="↕ JSON" FontSize="11" Padding="7,3"
@@ -30,13 +31,16 @@
</Button.Flyout> </Button.Flyout>
</Button> </Button>
</StackPanel> </StackPanel>
<StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="8"> <WrapPanel Grid.Row="1" ItemSpacing="6" LineSpacing="6">
<Button Content=" Einheit" Command="{Binding AddUnitCommand}" IsEnabled="{Binding !IsReadOnly}"/> <Button Content=" Einheit" Command="{Binding AddUnitCommand}" IsEnabled="{Binding !IsReadOnly}"/>
<Button Content="Bearbeiten" Command="{Binding EditUnitCommand}" IsEnabled="{Binding !IsReadOnly}"/> <Button Content="✎" Width="38" Command="{Binding EditUnitCommand}" IsEnabled="{Binding !IsReadOnly}"
<Button Content="Als Vorlage kopieren" Command="{Binding CopyUnitCommand}"/> ToolTip.Tip="Ausgewählte Einheit bearbeiten" AutomationProperties.Name="Einheit bearbeiten"/>
<Button Content="Löschen" Command="{Binding DeleteUnitCommand}" IsEnabled="{Binding !IsReadOnly}"/> <Button Content="⧉" Width="38" Command="{Binding CopyUnitCommand}"
<Button Content="🤖 KI-Unterstützung" Command="{Binding AiAssistCommand}"/> ToolTip.Tip="Als Vorlage in eine andere Lerngruppe kopieren" AutomationProperties.Name="Einheit als Vorlage kopieren"/>
</StackPanel> <Button Content="⌫" Width="38" Command="{Binding DeleteUnitCommand}" IsEnabled="{Binding !IsReadOnly}"
ToolTip.Tip="Ausgewählte Einheit löschen" AutomationProperties.Name="Einheit löschen"/>
<Button Content="✦ KI-Unterstützung" Command="{Binding AiAssistCommand}"/>
</WrapPanel>
</Grid> </Grid>
<!-- Einheiten-Tabelle --> <!-- Einheiten-Tabelle -->
@@ -76,30 +80,34 @@
<Separator Grid.Row="2" Margin="0,0,0,8"/> <Separator Grid.Row="2" Margin="0,0,0,8"/>
<!-- Stunden-Toolbar --> <!-- Stunden-Toolbar: häufige Aktionen bleiben beschriftet, sekundäre Aktionen sind als
<Grid Grid.Row="3" ColumnDefinitions="*,Auto" Margin="0,0,0,8"> kompakte Symbolbuttons gruppiert. Das WrapPanel darf bei Bedarf umbrechen. -->
<StackPanel Grid.Column="0" Orientation="Horizontal" VerticalAlignment="Center"> <Grid Grid.Row="3" RowDefinitions="Auto,Auto" Margin="0,0,0,8" RowSpacing="6">
<StackPanel Grid.Row="0" Orientation="Horizontal" VerticalAlignment="Center">
<TextBlock Text="Stunden" FontSize="14" FontWeight="SemiBold"/> <TextBlock Text="Stunden" FontSize="14" FontWeight="SemiBold"/>
<TextBlock Text="{Binding SelectedUnitTitleSuffix}" FontSize="14" FontWeight="SemiBold"/> <TextBlock Text="{Binding SelectedUnitTitleSuffix}" FontSize="14" FontWeight="SemiBold"/>
</StackPanel> </StackPanel>
<StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="8"> <WrapPanel Grid.Row="1" ItemSpacing="6" LineSpacing="6">
<Button Content=" Stunde" Command="{Binding AddLessonCommand}" IsEnabled="{Binding !IsReadOnly}"/> <Button Content=" Stunde" Command="{Binding AddLessonCommand}" IsEnabled="{Binding !IsReadOnly}"/>
<Button Content="Serie erzeugen" Command="{Binding GenerateLessonSeriesCommand}" <Button Content=" Serie" Command="{Binding GenerateLessonSeriesCommand}"
IsEnabled="{Binding !IsReadOnly}" IsEnabled="{Binding !IsReadOnly}"
ToolTip.Tip="Stunden für alle Termine aus dem Stundenplan im gewählten Zeitraum anlegen."/> ToolTip.Tip="Stunden für alle Termine aus dem Stundenplan im gewählten Zeitraum anlegen."/>
<Button Content="Anzeigen" Command="{Binding ShowLessonCommand}" <Button Content="Anzeigen" Command="{Binding ShowLessonCommand}"
ToolTip.Tip="Verlaufsplan schreibgeschützt und größer anzeigen — zum Mitnehmen in den Unterricht."/> ToolTip.Tip="Verlaufsplan schreibgeschützt und größer anzeigen — zum Mitnehmen in den Unterricht."/>
<Button Content="Bearbeiten" Command="{Binding EditLessonCommand}" IsEnabled="{Binding !IsReadOnly}"/> <Button Content="✎" Width="38" Command="{Binding EditLessonCommand}" IsEnabled="{Binding !IsReadOnly}"
<Button Content="Verschieben" Command="{Binding MoveLessonCommand}" IsEnabled="{Binding !IsReadOnly}"/> ToolTip.Tip="Ausgewählte Stunde bearbeiten" AutomationProperties.Name="Stunde bearbeiten"/>
<Button Content="Einheit wechseln" Command="{Binding ChangeLessonUnitCommand}" IsEnabled="{Binding !IsReadOnly}" <Button Content="↔" Width="38" Command="{Binding MoveLessonCommand}" IsEnabled="{Binding !IsReadOnly}"
ToolTip.Tip="Ausgewählte Stunde verschieben" AutomationProperties.Name="Stunde verschieben"/>
<Button Content="⇄" Width="38" Command="{Binding ChangeLessonUnitCommand}" IsEnabled="{Binding !IsReadOnly}"
ToolTip.Tip="Ordnet die ausgewählte Stunde einer anderen Einheit derselben Gruppe zu."/> ToolTip.Tip="Ordnet die ausgewählte Stunde einer anderen Einheit derselben Gruppe zu."/>
<Button Content="Status weiter" Command="{Binding AdvanceLessonStatusCommand}" IsEnabled="{Binding !IsReadOnly}" <Button Content="Status " Command="{Binding AdvanceLessonStatusCommand}" IsEnabled="{Binding !IsReadOnly}"
ToolTip.Tip="Entwurf/Geplant → Bereit → Durchgeführt"/> ToolTip.Tip="Entwurf/Geplant → Bereit → Durchgeführt"/>
<Button Content="Sitzung erzeugen" Command="{Binding CreateParticipationSessionCommand}" <Button Content=" Sitzung" Command="{Binding CreateParticipationSessionCommand}"
IsEnabled="{Binding !IsReadOnly}" IsEnabled="{Binding !IsReadOnly}"
ToolTip.Tip="Legt eine Mitarbeitssitzung mit Datum und Thema dieser Stunde an."/> ToolTip.Tip="Legt eine Mitarbeitssitzung mit Datum und Thema dieser Stunde an."/>
<Button Content="Löschen" Command="{Binding DeleteLessonCommand}" IsEnabled="{Binding !IsReadOnly}"/> <Button Content="⌫" Width="38" Command="{Binding DeleteLessonCommand}" IsEnabled="{Binding !IsReadOnly}"
</StackPanel> ToolTip.Tip="Ausgewählte Stunde löschen" AutomationProperties.Name="Stunde löschen"/>
</WrapPanel>
</Grid> </Grid>
<!-- Stunden-Tabelle --> <!-- Stunden-Tabelle -->
@@ -41,6 +41,10 @@
<TextBlock Text="Status" FontSize="12" Opacity="0.7"/> <TextBlock Text="Status" FontSize="12" Opacity="0.7"/>
<ComboBox ItemsSource="{Binding StatusOptions}" SelectedItem="{Binding StatusName}" <ComboBox ItemsSource="{Binding StatusOptions}" SelectedItem="{Binding StatusName}"
HorizontalAlignment="Stretch"/> HorizontalAlignment="Stretch"/>
<Border Background="#FFF3CD" BorderBrush="#D97706" BorderThickness="1" CornerRadius="5"
Padding="9,6" IsVisible="{Binding StatusNotice, Converter={x:Static StringConverters.IsNotNullOrEmpty}}">
<TextBlock Text="{Binding StatusNotice}" Foreground="#92400E" FontSize="11" TextWrapping="Wrap"/>
</Border>
</StackPanel> </StackPanel>
<StackPanel Spacing="4"> <StackPanel Spacing="4">