Stundenplan: Popup-Menü im Wochenraster statt Dropdown in der Tagesliste

Nutzer-Feedback: die Tagesliste-Buttons waren schon in Ordnung, das Problem
lag im Wochenraster darüber — ein Klick auf eine Stunden-Kachel führt dort
entweder in den Planungsviewer oder zur Einheitenplanung, ohne dass von
außen erkennbar wäre welches Ziel man bekommt. Tagesliste auf den
ursprünglichen Stand zurückgesetzt.

Neu: ein kleiner "⋮"-Button pro Kachel öffnet ein Popup-Menü mit vier
ausdrücklich benannten Zielen (Unterrichtsansicht/Planungsviewer/Sitzplan/
Planung). MenuFlyout statt ComboBox — dabei verstanden, dass ein
$parent[ItemsControl]-Vorfahrenpfad im Flyout nicht funktioniert, eine
normale {Binding} über die DataContext-Vererbung aber sehr wohl. Der
Direktklick springt jetzt außerdem "einheitlicher": bei einer Lesson
während der eigentlichen Unterrichtszeit direkt in den Unterrichtsmodus
statt in den Viewer.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-27 23:21:49 +02:00
co-authored by Claude Sonnet 5
parent cd08bbbbc4
commit 229ef79e75
5 changed files with 275 additions and 135 deletions
@@ -14,10 +14,11 @@ public sealed class TimetableViewModelTests
FakeSubjects? subjects = null, FakeLessons? lessons = null, FakeExams? exams = null, FakeSubjects? subjects = null, FakeLessons? lessons = null, FakeExams? exams = null,
SchoolCalendarSettingsService? calendarSettings = null, SchoolCalendarSettingsService? calendarSettings = null,
FakeSupervisionDuties? supervisionDuties = null, FakeSubstitutionEntries? substitutions = null, FakeSupervisionDuties? supervisionDuties = null, FakeSubstitutionEntries? substitutions = null,
FakeUntisSlotMappings? untisMappings = null, WebUntisSettingsService? untisSettings = null) FakeUntisSlotMappings? untisMappings = null, WebUntisSettingsService? untisSettings = null,
PeriodScheduleService? periodSchedule = null)
{ {
// Bewusst kein "using": SchoolCalendarSettingsService liest den Pfad nur bei Bedarf // Bewusst kein "using": SchoolCalendarSettingsService/PeriodScheduleService lesen den Pfad
// (SetState), das Verzeichnis muss über die Lebensdauer des ViewModels bestehen bleiben. // nur bei Bedarf, das Verzeichnis muss über die Lebensdauer des ViewModels bestehen bleiben.
var tempPath = System.IO.Path.Combine( var tempPath = System.IO.Path.Combine(
System.IO.Path.GetTempPath(), $"lehrerapp-timetablevm-tests-{Guid.NewGuid():N}"); System.IO.Path.GetTempPath(), $"lehrerapp-timetablevm-tests-{Guid.NewGuid():N}");
Directory.CreateDirectory(tempPath); Directory.CreateDirectory(tempPath);
@@ -28,7 +29,8 @@ public sealed class TimetableViewModelTests
calendarSettings ?? new SchoolCalendarSettingsService(tempPath), calendarSettings ?? new SchoolCalendarSettingsService(tempPath),
new PublicHolidayService(), new SchoolYearService(), new PublicHolidayService(), new SchoolYearService(),
supervisionDuties ?? new FakeSupervisionDuties(), substitutions ?? new FakeSubstitutionEntries(), supervisionDuties ?? new FakeSupervisionDuties(), substitutions ?? new FakeSubstitutionEntries(),
untisMappings ?? new FakeUntisSlotMappings(), untisSettings ?? TestSupport.BuildWebUntisSettingsService()); untisMappings ?? new FakeUntisSlotMappings(), untisSettings ?? TestSupport.BuildWebUntisSettingsService(),
periodSchedule ?? new PeriodScheduleService(tempPath));
} }
/// Nächstes Datum ab (inkl.) <paramref name="from"/>, das auf einen Wochentag Mo-Fr fällt — /// Nächstes Datum ab (inkl.) <paramref name="from"/>, das auf einen Wochentag Mo-Fr fällt —
@@ -336,6 +338,80 @@ public sealed class TimetableViewModelTests
Assert.Equal(lesson.Id, openedLesson?.Id); Assert.Equal(lesson.Id, openedLesson?.Id);
} }
/// Nutzer-Feedback (zweite Runde): der Direktklick auf eine Wochenraster-Kachel soll
/// "einheitlicher" springen — bei einer Lesson HEUTE, während gerade Unterrichtszeit ist,
/// direkt in den Unterrichtsmodus statt in den (schreibgeschützten) Planungsviewer.
[Fact]
public async Task OpenWeekCell_LessonHeuteWaehrendUnterrichtszeit_OeffnetUnterrichtsmodus()
{
var group = new LearningGroup { Name = "Q1 Chemie" };
var today = DateOnly.FromDateTime(DateTime.Today);
var slots = new FakeTimetableSlots();
slots.Add(new TimetableSlot { GroupId = group.Id, Weekday = today.DayOfWeek, PeriodNumber = 1 });
var lesson = new Lesson { GroupId = group.Id, Date = today, LessonNumber = 1, Topic = "Redox" };
var lessons = new FakeLessons();
lessons.Add(lesson);
var periodSchedule = BuildPeriodSchedule(1);
var vm = BuildViewModel(slots, new FakeGroups([group]), lessons: lessons, periodSchedule: periodSchedule);
Lesson? teachingModeLesson = null;
Lesson? viewerLesson = null;
vm.OnOpenTeachingMode = l => { teachingModeLesson = l; return Task.CompletedTask; };
vm.OnOpenLessonViewer = l => { viewerLesson = l; return Task.CompletedTask; };
var cell = vm.WeekItems.Single(c => c.IsSlotCell && c.Weekday == today.DayOfWeek && c.PeriodNumber == 1);
await vm.OpenWeekCellCommand.ExecuteAsync(cell);
Assert.Equal(lesson.Id, teachingModeLesson?.Id);
Assert.Null(viewerLesson);
}
/// Dieselbe Stundenzeit passt, die Lesson liegt aber nicht heute — bleibt beim Viewer.
[Fact]
public async Task OpenWeekCell_LessonNichtHeuteTrotzPassenderUhrzeit_OeffnetViewer()
{
var group = new LearningGroup { Name = "Q1 Chemie" };
var today = DateOnly.FromDateTime(DateTime.Today);
var otherWeekday = today.DayOfWeek == DayOfWeek.Monday ? DayOfWeek.Tuesday : DayOfWeek.Monday;
var otherDate = DateInCurrentWeek(otherWeekday);
var slots = new FakeTimetableSlots();
slots.Add(new TimetableSlot { GroupId = group.Id, Weekday = otherWeekday, PeriodNumber = 1 });
var lesson = new Lesson { GroupId = group.Id, Date = otherDate, LessonNumber = 1, Topic = "Redox" };
var lessons = new FakeLessons();
lessons.Add(lesson);
var periodSchedule = BuildPeriodSchedule(1);
var vm = BuildViewModel(slots, new FakeGroups([group]), lessons: lessons, periodSchedule: periodSchedule);
Lesson? teachingModeLesson = null;
Lesson? viewerLesson = null;
vm.OnOpenTeachingMode = l => { teachingModeLesson = l; return Task.CompletedTask; };
vm.OnOpenLessonViewer = l => { viewerLesson = l; return Task.CompletedTask; };
var cell = vm.WeekItems.Single(c => c.IsSlotCell && c.Weekday == otherWeekday && c.PeriodNumber == 1);
await vm.OpenWeekCellCommand.ExecuteAsync(cell);
Assert.Equal(lesson.Id, viewerLesson?.Id);
Assert.Null(teachingModeLesson);
}
/// Weites Zeitfenster um <paramref name="around"/> herum (±2 Stunden), damit der Test nicht an
/// die exakte Toleranz von TimetableViewModel.TeachingTimeTolerance gebunden ist.
/// Start UND Ende exakt auf "jetzt" statt eines künstlich breiteren Fensters (z.B. ganzer Tag
/// via TimeOnly.MinValue/MaxValue): TimetableViewModel.IsAroundTeachingTime addiert selbst
/// noch ±10 Minuten Toleranz (TeachingTimeTolerance) auf Start/Ende — ein zusätzliches, vom
/// Test vorgegebenes Fenster würde nahe Mitternacht durch die TimeOnly-Arithmetik (wrappt bei
/// 24 Uhr) selbst kippen. "Jetzt" als Start/Ende bleibt nur dann riskant, wenn der Test
/// zufällig innerhalb der letzten/ersten 10 Minuten des Tages läuft — dasselbe inhärente
/// Randproblem hätte dann auch die Produktion, kein Testartefakt.
private static PeriodScheduleService BuildPeriodSchedule(int periodNumber)
{
var tempPath = System.IO.Path.Combine(
System.IO.Path.GetTempPath(), $"lehrerapp-periodschedule-tests-{Guid.NewGuid():N}");
Directory.CreateDirectory(tempPath);
var service = new PeriodScheduleService(tempPath);
var now = TimeOnly.FromDateTime(DateTime.Now);
service.SetPeriods([new PeriodTimeEntry { PeriodNumber = periodNumber, Start = now, End = now }]);
return service;
}
[Fact] [Fact]
public void OpenSettings_RuftOnNavigateToSettingsAuf() public void OpenSettings_RuftOnNavigateToSettingsAuf()
{ {
@@ -1178,44 +1254,42 @@ public sealed class TimetableViewModelTests
} }
} }
/// Nutzer-Feedback: unklar, wie man aus der "Heute"-Tagesliste zwischen Unterrichtsansicht, /// Nutzer-Feedback (zweite Runde): das Popup-Menü je Wochenraster-Kachel (TimetableView.axaml,
/// Sitzplan, Planung und Planungsviewer wechselt — TodayLessonItem.DestinationOptions ersetzt die /// MenuFlyout) blendet "Unterrichtsansicht"/"Planungsviewer" per IsVisible="{Binding HasLesson}"
/// bisherigen zwei Buttons durch ein Dropdown mit ausdrücklich benannten Zielen. /// aus, wenn für den Slot noch keine Lesson existiert, und den ganzen Menü-Trigger per
public sealed class TodayLessonItemDestinationOptionsTests /// IsVisible="{Binding HasGroupId}", wenn die Kachel gar keiner Gruppe zugeordnet ist (z.B. eine
/// GroupId-lose Vertretung). Diese beiden Properties sind die Grundlage dafür.
public sealed class WeekCellItemMenuVisibilityTests
{ {
[Fact] [Fact]
public void MitLesson_BietetAlleVierZiele() public void ForSlot_MitLesson_HatHasLessonUndHasGroupId()
{ {
var lesson = new Lesson { Topic = "Redox" }; var lesson = new Lesson { Topic = "Redox" };
var item = new TodayLessonItem(Guid.NewGuid(), 3, "Q1 Chemie", "R204", var cell = WeekCellItem.ForSlot(DayOfWeek.Monday, 3, isToday: true, "Che", "Q1 Chemie",
"#4C8DFF", "Redox", null, lesson: lesson); "R204", "Redox", "#4C8DFF", "", hasExam: false, isLastBeforeExam: false,
hasExperiment: false, Guid.NewGuid(), isHoliday: false, lesson: lesson);
Assert.True(item.HasDestinationOptions); Assert.True(cell.HasLesson);
Assert.Equal( Assert.True(cell.HasGroupId);
[
TimetableLessonDestination.TeachingMode, TimetableLessonDestination.Viewer,
TimetableLessonDestination.SeatingPlan, TimetableLessonDestination.Planning,
],
item.DestinationOptions.Select(o => o.Kind));
} }
[Fact] [Fact]
public void OhneLesson_BietetNurSitzplanUndPlanung() public void ForSlot_OhneLesson_HatKeinHasLesson()
{ {
var item = new TodayLessonItem(Guid.NewGuid(), 3, "Q1 Chemie", "R204", var cell = WeekCellItem.ForSlot(DayOfWeek.Monday, 3, isToday: true, "Che", "Q1 Chemie",
"#4C8DFF", null, null); "R204", "", "#4C8DFF", "", hasExam: false, isLastBeforeExam: false,
hasExperiment: false, Guid.NewGuid(), isHoliday: false);
Assert.Equal( Assert.False(cell.HasLesson);
[TimetableLessonDestination.SeatingPlan, TimetableLessonDestination.Planning], Assert.True(cell.HasGroupId);
item.DestinationOptions.Select(o => o.Kind));
} }
[Fact] [Fact]
public void OhneGroupId_BietetKeineZiele() public void ForSubstitutionLesson_OhneGroupId_HatKeinHasGroupId()
{ {
var item = TodayLessonItem.ForSubstitution(3, new SubstitutionEntry { GroupId = null, Description = "Vertretung" }); var cell = WeekCellItem.ForSubstitutionLesson(DayOfWeek.Monday, 3, isToday: true,
new SubstitutionEntry { GroupId = null, GroupLabel = "Fremde Klasse", Description = "Vertretung" });
Assert.False(item.HasDestinationOptions); Assert.False(cell.HasGroupId);
Assert.Empty(item.DestinationOptions);
} }
} }
@@ -65,6 +65,7 @@ public partial class TimetableViewModel : ObservableObject
private readonly ISubstitutionEntryRepository _substitutions; private readonly ISubstitutionEntryRepository _substitutions;
private readonly IUntisSlotMappingRepository _untisMappings; private readonly IUntisSlotMappingRepository _untisMappings;
private readonly WebUntisSettingsService _untisSettings; private readonly WebUntisSettingsService _untisSettings;
private readonly PeriodScheduleService _periodSchedule;
private readonly SchoolWeatherService? _schoolWeather; private readonly SchoolWeatherService? _schoolWeather;
private readonly SemaphoreSlim _weatherGate = new(1, 1); private readonly SemaphoreSlim _weatherGate = new(1, 1);
private WeatherSnapshot? _weatherSnapshot; private WeatherSnapshot? _weatherSnapshot;
@@ -114,13 +115,14 @@ public partial class TimetableViewModel : ObservableObject
PublicHolidayService publicHolidays, SchoolYearService schoolYear, PublicHolidayService publicHolidays, SchoolYearService schoolYear,
ISupervisionDutyRepository supervisionDuties, ISubstitutionEntryRepository substitutions, ISupervisionDutyRepository supervisionDuties, ISubstitutionEntryRepository substitutions,
IUntisSlotMappingRepository untisMappings, WebUntisSettingsService untisSettings, IUntisSlotMappingRepository untisMappings, WebUntisSettingsService untisSettings,
SchoolWeatherService? schoolWeather = null) PeriodScheduleService periodSchedule, SchoolWeatherService? schoolWeather = null)
{ {
_slots = slots; _groups = groups; _subjects = subjects; _lessons = lessons; _exams = exams; _slots = slots; _groups = groups; _subjects = subjects; _lessons = lessons; _exams = exams;
_schoolHolidays = schoolHolidays; _calendarSettings = calendarSettings; _schoolHolidays = schoolHolidays; _calendarSettings = calendarSettings;
_publicHolidays = publicHolidays; _schoolYear = schoolYear; _publicHolidays = publicHolidays; _schoolYear = schoolYear;
_supervisionDuties = supervisionDuties; _substitutions = substitutions; _supervisionDuties = supervisionDuties; _substitutions = substitutions;
_untisMappings = untisMappings; _untisSettings = untisSettings; _untisMappings = untisMappings; _untisSettings = untisSettings;
_periodSchedule = periodSchedule;
_schoolWeather = schoolWeather; _schoolWeather = schoolWeather;
Load(); Load();
} }
@@ -368,14 +370,41 @@ public partial class TimetableViewModel : ObservableObject
await OnOpenTeachingMode(lesson); await OnOpenTeachingMode(lesson);
} }
/// Nutzer-Feedback (zweite Runde): der Direktklick auf eine Wochenraster-Kachel soll
/// "einheitlicher" springen. Existiert eine Lesson UND ist gerade (heute, mit Toleranz vor/
/// nach der Stunde) Unterrichtszeit, geht es direkt in den Unterrichtsmodus — sonst wie
/// bisher in den (schreibgeschützten) Planungsviewer bzw., ohne Lesson, zur Einheitenplanung
/// der Gruppe. Das Popup-Menü (siehe TimetableView.axaml, MenuFlyout je Kachel) bietet
/// daneben immer alle vier Ziele explizit an, unabhängig von dieser Automatik.
[RelayCommand] [RelayCommand]
private async Task OpenWeekCell(WeekCellItem? item) private async Task OpenWeekCell(WeekCellItem? item)
{ {
if (item is null || item.GroupId == Guid.Empty) return; if (item is null || item.GroupId == Guid.Empty) return;
if (item.Lesson is { } lesson && OnOpenLessonViewer is not null) await OnOpenLessonViewer(lesson); if (item.Lesson is { } lesson)
{
if (IsAroundTeachingTime(lesson.Date, item.PeriodNumber) && OnOpenTeachingMode is not null)
await OnOpenTeachingMode(lesson);
else if (OnOpenLessonViewer is not null) await OnOpenLessonViewer(lesson);
}
else OnNavigateToGroup?.Invoke(item.GroupId); else OnNavigateToGroup?.Invoke(item.GroupId);
} }
/// Bewusst mit Toleranz vor/nach der eingetragenen Stundenzeit (siehe
/// <see cref="PeriodScheduleService"/>, "Stundenraster" in den Einstellungen) statt exakt an
/// Start-/Endzeit gebunden — man klickt auch kurz vor Stundenbeginn oder in einer kurzen
/// Verzögerung danach noch typischerweise in Unterrichtsabsicht. Ohne konfiguriertes
/// Stundenraster oder an einem anderen Tag als heute bleibt es beim Planungsviewer.
/// Nimmt bewusst das Datum der Lesson selbst statt WeekCellItem.Date entgegen — Date ist dort
/// nur bei Kopfzeilen (WeekdayHeader) gesetzt, nicht bei regulären Stunden-Kacheln (ForSlot).
private static readonly TimeSpan TeachingTimeTolerance = TimeSpan.FromMinutes(10);
private bool IsAroundTeachingTime(DateOnly lessonDate, int periodNumber)
{
if (lessonDate != DateOnly.FromDateTime(DateTime.Today)) return false;
if (_periodSchedule.GetTimes(periodNumber) is not { } times) return false;
var now = TimeOnly.FromDateTime(DateTime.Now);
return now >= times.Start.Add(-TeachingTimeTolerance) && now <= times.End.Add(TeachingTimeTolerance);
}
[RelayCommand] [RelayCommand]
private void OpenSettings() => OnNavigateToSettings?.Invoke(SettingsTab.Holidays); private void OpenSettings() => OnNavigateToSettings?.Invoke(SettingsTab.Holidays);
@@ -784,9 +813,11 @@ public partial class WeekCellItem : ObservableObject
public bool HasSupervision => SupervisionLocation.Length > 0; public bool HasSupervision => SupervisionLocation.Length > 0;
public bool HasRoom => !string.IsNullOrWhiteSpace(Room); public bool HasRoom => !string.IsNullOrWhiteSpace(Room);
public bool HasTopic => !string.IsNullOrWhiteSpace(Topic); public bool HasTopic => !string.IsNullOrWhiteSpace(Topic);
public bool HasGroupId => GroupId != Guid.Empty;
/// Nur bei einer regulären, zugewiesenen Zelle mit bereits existierender Lesson gesetzt — /// Nur bei einer regulären, zugewiesenen Zelle mit bereits existierender Lesson gesetzt —
/// Grundlage für den Direktsprung in den Verlaufsplan-Viewer (4.5.2). /// Grundlage für den Direktsprung in den Verlaufsplan-Viewer (4.5.2).
public Lesson? Lesson { get; private init; } public Lesson? Lesson { get; private init; }
public bool HasLesson => Lesson is not null;
public string OpenButtonLabel => Lesson is not null ? "Verlaufsplan ansehen" : "Zur Lerngruppe"; public string OpenButtonLabel => Lesson is not null ? "Verlaufsplan ansehen" : "Zur Lerngruppe";
[ObservableProperty] private string _weatherSymbol = ""; [ObservableProperty] private string _weatherSymbol = "";
[ObservableProperty] private string _weatherTooltip = ""; [ObservableProperty] private string _weatherTooltip = "";
@@ -925,33 +956,6 @@ public class TodayLessonItem
public bool HasLesson => Lesson is not null; public bool HasLesson => Lesson is not null;
public string OpenButtonLabel => Lesson is not null ? "Verlaufsplan ansehen" : "Zur Lerngruppe"; public string OpenButtonLabel => Lesson is not null ? "Verlaufsplan ansehen" : "Zur Lerngruppe";
/// Nutzer-Feedback: es war unklar, wie man aus der "Heute"-Tagesliste zwischen
/// Unterrichtsansicht, Sitzplan, Planung und Planungsviewer wechselt — bisher zwei Buttons
/// mit unterschiedlicher, teils vom Lesson-Status abhängiger Bedeutung
/// ("Verlaufsplan ansehen"/"Zur Lerngruppe"). Ein Dropdown mit ausdrücklich benannten Zielen
/// statt dessen (Wiring/Routing in TimetableView.axaml.cs). Unterrichtsansicht/Planungsviewer
/// brauchen eine existierende Lesson, Sitzplan/Planung sind auch ohne bereits möglich (Planung
/// ist ohnehin der Ort, an dem man eine Lesson für den Slot erst anlegt).
public IReadOnlyList<TimetableDestinationOption> DestinationOptions
{
get
{
var options = new List<TimetableDestinationOption>();
if (HasLesson)
{
options.Add(new(TimetableLessonDestination.TeachingMode, "▶ Unterrichtsansicht"));
options.Add(new(TimetableLessonDestination.Viewer, "📋 Planungsviewer"));
}
if (HasGroupId)
{
options.Add(new(TimetableLessonDestination.SeatingPlan, "🪑 Sitzplan"));
options.Add(new(TimetableLessonDestination.Planning, "✏️ Planung"));
}
return options;
}
}
public bool HasDestinationOptions => DestinationOptions.Count > 0;
public TodayLessonItem(Guid groupId, int periodNumber, string groupName, string room, public TodayLessonItem(Guid groupId, int periodNumber, string groupName, string room,
string colorHex, string? lessonTopic, string? examTitle, bool hasUnhandledHomework = false, string colorHex, string? lessonTopic, string? examTitle, bool hasUnhandledHomework = false,
Lesson? lesson = null) Lesson? lesson = null)
@@ -21,6 +21,21 @@
<Style Selector="Border.supervisioncell.substitution"> <Style Selector="Border.supervisioncell.substitution">
<Setter Property="Background" Value="#8E24AA"/> <Setter Property="Background" Value="#8E24AA"/>
</Style> </Style>
<Style Selector="Button.weekCellMenuTrigger">
<Setter Property="Width" Value="18"/>
<Setter Property="Height" Value="16"/>
<Setter Property="Padding" Value="0"/>
<Setter Property="Margin" Value="2"/>
<Setter Property="Background" Value="#33000000"/>
<Setter Property="Foreground" Value="White"/>
<Setter Property="FontSize" Value="11"/>
<Setter Property="CornerRadius" Value="4"/>
<Setter Property="HorizontalContentAlignment" Value="Center"/>
<Setter Property="VerticalContentAlignment" Value="Center"/>
</Style>
<Style Selector="Button.weekCellMenuTrigger:pointerover /template/ ContentPresenter">
<Setter Property="Background" Value="#55000000"/>
</Style>
</UserControl.Styles> </UserControl.Styles>
<Grid RowDefinitions="Auto,Auto,*"> <Grid RowDefinitions="Auto,Auto,*">
@@ -111,24 +126,17 @@
Foreground="#8E6C00" FontWeight="SemiBold" Foreground="#8E6C00" FontWeight="SemiBold"
IsVisible="{Binding HasUnhandledHomework}"/> IsVisible="{Binding HasUnhandledHomework}"/>
</StackPanel> </StackPanel>
<!-- Nutzer-Feedback: unklar, wie man aus der Tagesliste zwischen <StackPanel Grid.Column="3" Orientation="Horizontal" Spacing="6" VerticalAlignment="Center">
Unterrichtsansicht/Sitzplan/Planung/Planungsviewer wechselt — jetzt <Button Content="▶ Unterricht" FontSize="11" Padding="9,4"
ein Dropdown mit ausdrücklich benannten Zielen statt zweier Buttons IsVisible="{Binding HasLesson}"
mit vom Lesson-Status abhängiger Doppelbedeutung. Wiring/Routing in ToolTip.Tip="Unterrichtsmodus: Verlaufsplan und Sitzplan mit Schnellbewertung auf einem Bildschirm."
TimetableView.axaml.cs (SelectionChanged setzt die Auswahl danach Command="{Binding $parent[ItemsControl].((vm:TimetableViewModel)DataContext).StartTeachingModeCommand}"
bewusst zurück auf null — Menü-Charakter, keine dauerhafte CommandParameter="{Binding}"/>
Auswahl). --> <Button Content="{Binding OpenButtonLabel}" FontSize="11" Padding="9,4"
<ComboBox Grid.Column="3" FontSize="11" MinWidth="150" VerticalAlignment="Center" IsVisible="{Binding HasGroupId}"
IsVisible="{Binding HasDestinationOptions}" Command="{Binding $parent[ItemsControl].((vm:TimetableViewModel)DataContext).OpenTodayLessonCommand}"
PlaceholderText="Öffnen ▾" CommandParameter="{Binding}"/>
ItemsSource="{Binding DestinationOptions}" </StackPanel>
SelectionChanged="OnLessonDestinationSelected">
<ComboBox.ItemTemplate>
<DataTemplate x:DataType="vm:TimetableDestinationOption">
<TextBlock Text="{Binding Label}"/>
</DataTemplate>
</ComboBox.ItemTemplate>
</ComboBox>
</Grid> </Grid>
</Border> </Border>
</DataTemplate> </DataTemplate>
@@ -233,7 +241,8 @@
</Border> </Border>
<Border Classes="timetablecell" Classes.assigned="{Binding IsAssigned}" <Border Classes="timetablecell" Classes.assigned="{Binding IsAssigned}"
CornerRadius="6" IsVisible="{Binding IsSlotCell}"> CornerRadius="6" IsVisible="{Binding IsSlotCell}">
<Button Background="{Binding ColorHex}" IsVisible="{Binding IsAssigned}" <Panel IsVisible="{Binding IsAssigned}">
<Button Background="{Binding ColorHex}"
HorizontalAlignment="Stretch" VerticalAlignment="Stretch" HorizontalAlignment="Stretch" VerticalAlignment="Stretch"
HorizontalContentAlignment="Stretch" CornerRadius="6" Padding="6" HorizontalContentAlignment="Stretch" CornerRadius="6" Padding="6"
Command="{Binding $parent[ItemsControl;1].((vm:TimetableViewModel)DataContext).OpenWeekCellCommand}" Command="{Binding $parent[ItemsControl;1].((vm:TimetableViewModel)DataContext).OpenWeekCellCommand}"
@@ -263,6 +272,34 @@
</StackPanel> </StackPanel>
</StackPanel> </StackPanel>
</Button> </Button>
<!-- Nutzer-Feedback (zweite Runde): Popup-Menü statt Dropdown, hier
im Wochenraster statt in der Tagesliste (dort waren die
bisherigen zwei Buttons schon in Ordnung). Sibling-Button statt
verschachteltem Button/ContextMenu — Routing über
MenuItem.Click in TimetableView.axaml.cs, siehe Kommentar dort
zu DataContext-Vererbung vs. $parent-Vorfahrensuche im Flyout. -->
<Button Classes="weekCellMenuTrigger" Content="⋮"
HorizontalAlignment="Right" VerticalAlignment="Top"
IsVisible="{Binding HasGroupId}"
ToolTip.Tip="Weitere Ziele…">
<Button.Flyout>
<MenuFlyout Placement="BottomEdgeAlignedRight">
<MenuItem Header="▶ Unterrichtsansicht" IsVisible="{Binding HasLesson}"
Tag="{x:Static vm:TimetableLessonDestination.TeachingMode}"
Click="OnWeekCellMenuItemClick"/>
<MenuItem Header="📋 Planungsviewer" IsVisible="{Binding HasLesson}"
Tag="{x:Static vm:TimetableLessonDestination.Viewer}"
Click="OnWeekCellMenuItemClick"/>
<MenuItem Header="🪑 Sitzplan"
Tag="{x:Static vm:TimetableLessonDestination.SeatingPlan}"
Click="OnWeekCellMenuItemClick"/>
<MenuItem Header="✏️ Planung"
Tag="{x:Static vm:TimetableLessonDestination.Planning}"
Click="OnWeekCellMenuItemClick"/>
</MenuFlyout>
</Button.Flyout>
</Button>
</Panel>
</Border> </Border>
</Grid> </Grid>
</DataTemplate> </DataTemplate>
@@ -1,4 +1,5 @@
using Avalonia.Controls; using Avalonia.Controls;
using Avalonia.Interactivity;
using LehrerApp.Core.Interfaces; using LehrerApp.Core.Interfaces;
using LehrerApp.Core.Models; using LehrerApp.Core.Models;
using LehrerApp.Core.Services; using LehrerApp.Core.Services;
@@ -28,35 +29,42 @@ public partial class TimetableView : UserControl
} }
} }
/// Nutzer-Feedback: unklar, wie man aus der "Heute"-Tagesliste zwischen Unterrichtsansicht, /// Nutzer-Feedback (zweite Runde — die erste Fassung hatte das Problem an der falschen
/// Sitzplan, Planung und Planungsviewer wechselt. Statt der bisherigen zwei Buttons mit /// Stelle gelöst, in der "Heute"-Tagesliste statt im Wochenraster): im Wochenraster oben
/// teils vom Lesson-Status abhängiger Doppelbedeutung ein Dropdown mit ausdrücklich benannten /// führt ein Klick auf eine Stunden-Kachel bislang entweder in den Planungsviewer oder zur
/// Zielen (TodayLessonItem.DestinationOptions) — Menü-Charakter, die Auswahl wird danach /// Einheitenplanung — je nachdem, ob schon eine Lesson existiert, ohne dass das von außen
/// bewusst zurückgesetzt statt dauerhaft angezeigt. Drei der vier Ziele nutzen unverändert /// erkennbar wäre. Popup-Menü (MenuFlyout, kein ComboBox mehr) mit allen vier Zielen als
/// die bestehenden TimetableViewModel-Commands, nur "Sitzplan" ist neu (bisher nur über den /// Alternative zum Direktklick, siehe <see cref="TimetableViewModel.OpenWeekCellCommand"/>
/// Unterrichtsmodus erreichbar, siehe TODO.md 4.5.23-Nachtrag). /// für den "einheitlicheren" Standard-Klick (Unterrichtsansicht bei laufender Stunde, sonst
private void OnLessonDestinationSelected(object? sender, SelectionChangedEventArgs e) /// Planungsviewer, sonst Einheitenplanung). MenuItem.Click statt Command-Binding: ein
/// $parent[ItemsControl]-Vorfahrenpfad (wie beim Zeilen-Button) funktioniert innerhalb eines
/// Flyouts nicht zuverlässig, weil dessen Popup nicht im normalen visuellen Baum hängt (siehe
/// TODO.md-Nachtrag zum Klassenlehrer-Bereich) — DataContext-Vererbung (kein Pfad-Suchen,
/// nur der normale Eltern-Wert) funktioniert dort aber sehr wohl, deshalb liest der Handler
/// die Zelle über <c>((MenuItem)sender).DataContext</c> statt über eine Binding.
private async void OnWeekCellMenuItemClick(object? sender, RoutedEventArgs e)
{ {
if (sender is not ComboBox comboBox) return; if (sender is not MenuItem { Tag: TimetableLessonDestination destination } menuItem) return;
var item = comboBox.DataContext as TodayLessonItem; if (menuItem.DataContext is not WeekCellItem cell) return;
var option = e.AddedItems.Count > 0 ? e.AddedItems[0] as TimetableDestinationOption : null; if (DataContext is not TimetableViewModel vm) return;
comboBox.SelectedItem = null;
if (item is null || option is null || DataContext is not TimetableViewModel vm) return;
switch (option.Kind) switch (destination)
{ {
case TimetableLessonDestination.TeachingMode: case TimetableLessonDestination.TeachingMode:
vm.StartTeachingModeCommand.Execute(item); if (cell.Lesson is { } teachLesson && vm.OnOpenTeachingMode is not null)
await vm.OnOpenTeachingMode(teachLesson);
break; break;
case TimetableLessonDestination.Viewer: case TimetableLessonDestination.Viewer:
vm.OpenTodayLessonCommand.Execute(item); if (cell.Lesson is { } viewLesson && vm.OnOpenLessonViewer is not null)
await vm.OnOpenLessonViewer(viewLesson);
break; break;
case TimetableLessonDestination.Planning: case TimetableLessonDestination.Planning:
vm.OpenGroupCommand.Execute(item.GroupId); if (cell.GroupId != Guid.Empty)
App.Services.GetRequiredService<MainWindowViewModel>().NavigateToGroupDetail(cell.GroupId, 6);
break; break;
case TimetableLessonDestination.SeatingPlan: case TimetableLessonDestination.SeatingPlan:
App.Services.GetRequiredService<MainWindowViewModel>() if (cell.GroupId != Guid.Empty)
.NavigateToGroupDetail(item.GroupId, 2); App.Services.GetRequiredService<MainWindowViewModel>().NavigateToGroupDetail(cell.GroupId, 2);
break; break;
} }
} }
+29 -12
View File
@@ -1918,18 +1918,35 @@ folgenden Punkte gehören direkt in `LehrerApp.Desktop`:
den vollen Verlaufsplan-Editor öffnen müssen. Neue kleine den vollen Verlaufsplan-Editor öffnen müssen. Neue kleine
`TeachingModeHomeworkViewModel : ObservableObject`, da `TeachingModeViewModel` selbst (wie `TeachingModeHomeworkViewModel : ObservableObject`, da `TeachingModeViewModel` selbst (wie
`LessonViewerViewModel`) keine Bindable-Basisklasse hat. `LessonViewerViewModel`) keine Bindable-Basisklasse hat.
- [x] **4.5.24** Dropdown in der "Heute"-Tagesliste für Unterrichtsansicht/Sitzplan/Planung/ - [x] **4.5.24** Popup-Menü im Wochenraster für Unterrichtsansicht/Sitzplan/Planung/Planungsviewer
Planungsviewer (August 2026, Nutzer-Feedback): unklar, wie man aus dem Stundenplan zwischen (August 2026, Nutzer-Feedback, zweite Runde). Die erste Fassung hatte das Problem am
diesen vier Ansichten wechselt — bisher zwei Buttons mit teils vom Lesson-Status abhängiger falschen Ort gelöst — ein Dropdown in der "Heute"-**Tagesliste** (unten angedockt), obwohl
Doppelbedeutung ("Verlaufsplan ansehen"/"Zur Lerngruppe"). Ersetzt durch eine `ComboBox` pro die dortigen zwei Buttons laut Nutzer schon in Ordnung waren. Zurückgesetzt auf den
Zeile mit vier ausdrücklich benannten Zielen (`TodayLessonItem.DestinationOptions`, ursprünglichen Stand (zwei Buttons, `TodayLessonItem` ohne `DestinationOptions`). Das
`TimetableLessonDestination`-Enum) — Unterrichtsansicht/Planungsviewer nur wenn für den Slot eigentliche Problem lag im **Wochenraster darüber**: ein Klick auf eine Stunden-Kachel führt
schon eine `Lesson` existiert, Sitzplan/Planung immer. Menü-Charakter: `SelectionChanged` in dort in den Planungsviewer oder zur Einheitenplanung, je nachdem ob schon eine `Lesson`
`TimetableView.axaml.cs` setzt `SelectedItem` nach jeder Auswahl bewusst auf `null` zurück, existiert — von außen nicht erkennbar, welches Ziel man bekommt.
statt die Auswahl dauerhaft anzuzeigen. Drei der vier Ziele nutzen unverändert bestehende - **Popup-Menü:** kleiner "⋮"-Button pro Kachel (`Button.weekCellMenuTrigger`, oben rechts in
`TimetableViewModel`-Commands (`StartTeachingModeCommand`/`OpenTodayLessonCommand`/ der Zelle überlagert) öffnet ein `MenuFlyout` mit vier ausdrücklich benannten Zielen.
`OpenGroupCommand` — letzterer navigiert bereits zum Planung-Tab, Index 6); "Sitzplan" ist Unterrichtsansicht/Planungsviewer nur wenn schon eine `Lesson` existiert, Sitzplan/Planung
neu (Sitzpläne-Tab, Index 2 — bisher nur indirekt über den Unterrichtsmodus erreichbar). immer (Planung ist ohnehin der Ort, an dem man eine Lesson für den Slot erst anlegt).
**Wichtiger XAML-Fallstrick, diesmal genauer verstanden:** ein `$parent[ItemsControl]`-
Vorfahrenpfad (wie beim normalen Zeilen-Button) funktioniert in einem `MenuFlyout` nicht,
weil dessen Popup nicht im normalen visuellen Baum hängt — **aber** eine normale
`{Binding}` ohne Vorfahrensuche funktioniert dort sehr wohl, weil Avalonia die
DataContext-**Vererbung** (kein Baum-Durchsuchen, nur der geerbte Wert) auch in Flyouts
korrekt weiterreicht. Trotzdem bewusst `MenuItem.Click` statt `Command`-Binding gewählt
(kein `WeekCellItem`-eigenes Command nötig): Handler in `TimetableView.axaml.cs` liest die
Zelle über `((MenuItem)sender).DataContext`, das Ziel über `Tag="{x:Static
vm:TimetableLessonDestination.…}"` — beides ohne jede Vorfahrensuche.
- **Einheitlicherer Direktklick:** `OpenWeekCellCommand` prüft jetzt zusätzlich, ob gerade
(heute, ±10 Minuten Toleranz) Unterrichtszeit der Stunde ist (neues
`TimetableViewModel.IsAroundTeachingTime`, `PeriodScheduleService`/"Stundenraster" als
Grundlage) — dann direkt in den Unterrichtsmodus statt in den Planungsviewer. Ohne Lesson
bleibt es beim Sprung zur Einheitenplanung. Das Popup-Menü bietet immer alle vier Ziele
explizit an, unabhängig von dieser Automatik. Nutzt bewusst `lesson.Date` statt
`WeekCellItem.Date` — letzteres ist nur bei Kopfzeilen gesetzt, nicht bei
Stunden-Kacheln (führte im ersten Testlauf zu einem Bug: die Automatik griff nie).
**Wichtige Abweichung von der ursprünglichen Planung (5.2):** Vor der Umsetzung zeigte sich, **Wichtige Abweichung von der ursprünglichen Planung (5.2):** Vor der Umsetzung zeigte sich,
dass 5.2 wie ursprünglich beschrieben eine zweite, parallele Fehlzeiten-Erfassung neben dem dass 5.2 wie ursprünglich beschrieben eine zweite, parallele Fehlzeiten-Erfassung neben dem