@@ -5,6 +5,7 @@ using LehrerApp.Core.Models;
|
||||
using LehrerApp.Core.Services;
|
||||
using LehrerApp.Desktop.Services;
|
||||
using LehrerApp.Desktop.ViewModels.Settings;
|
||||
using LehrerApp.Desktop.ViewModels.Groups;
|
||||
using System.Collections.ObjectModel;
|
||||
|
||||
namespace LehrerApp.Desktop.ViewModels.Planning;
|
||||
@@ -108,6 +109,8 @@ public partial class TimetableViewModel : ObservableObject
|
||||
public Action<SettingsTab>? OnNavigateToSettings { get; set; }
|
||||
public Func<Lesson, Task>? OnOpenLessonViewer { get; set; }
|
||||
public Func<Lesson, Task>? OnOpenTeachingMode { get; set; }
|
||||
public Func<TimetableLessonRequest, Task>? OnCreateLesson { get; set; }
|
||||
public Func<TimetableLessonMoveRequest, Task>? OnMoveLesson { get; set; }
|
||||
|
||||
/// Öffentlich statt intern (kein InternalsVisibleTo in dieser Codebasis) - erlaubt Tests, die
|
||||
/// "heute"-abhängiges Verhalten (Wochenraster-Badges, Unterrichtszeit-Erkennung) prüfen, ohne
|
||||
@@ -301,9 +304,9 @@ public partial class TimetableViewModel : ObservableObject
|
||||
var cancelled = substitutionsToday.FirstOrDefault(s => s.Kind == SubstitutionKind.Cancelled && s.PeriodNumber == slot.PeriodNumber);
|
||||
if (cancelled is not null) { items.Add(TodayLessonItem.ForCancelled(slot.GroupId, slot.PeriodNumber, group.Name, cancelled)); continue; }
|
||||
|
||||
var lesson = _lessons.GetByGroupAndDate(slot.GroupId, today).FirstOrDefault();
|
||||
var lesson = FindLessonForSlot(slot.GroupId, today, slot.PeriodNumber);
|
||||
var exam = _exams.GetByGroup(slot.GroupId).FirstOrDefault(e => e.Date == today);
|
||||
items.Add(new TodayLessonItem(slot.GroupId, slot.PeriodNumber, group.Name,
|
||||
items.Add(new TodayLessonItem(slot.GroupId, today, slot.PeriodNumber, group.Name,
|
||||
slot.Room ?? "", ColorFor(group.Name), lesson?.Topic, exam?.Title,
|
||||
HasUnhandledHomework(slot.GroupId, today), lesson));
|
||||
}
|
||||
@@ -353,15 +356,15 @@ public partial class TimetableViewModel : ObservableObject
|
||||
}
|
||||
|
||||
/// Springt aus dem Stundenplan direkt in den Verlaufsplan-Viewer der zugehörigen Lesson (4.5.2)
|
||||
/// — sofern für den Slot schon eine Lesson existiert. Ohne Lesson (Slot laut Stundenplan belegt,
|
||||
/// aber noch keine konkrete Stunde geplant) bleibt es bei der bisherigen, gröberen Navigation
|
||||
/// zum Planung-Tab der Gruppe: eine neue Lesson direkt von hier aus anzulegen bräuchte eine
|
||||
/// Antwort auf "welcher Unit wird sie zugeordnet", die bewusst noch offen ist (siehe TODO 4.5.2).
|
||||
/// — sofern für den Slot schon eine Lesson existiert. Ohne Lesson startet die Direktanlage;
|
||||
/// der vorgeschaltete Einheiten-Dialog löst dabei die notwendige Unit-Zuordnung explizit.
|
||||
[RelayCommand]
|
||||
private async Task OpenTodayLesson(TodayLessonItem? item)
|
||||
{
|
||||
if (item is null || item.GroupId == Guid.Empty) return;
|
||||
if (item.Lesson is { } lesson && OnOpenLessonViewer is not null) await OnOpenLessonViewer(lesson);
|
||||
else if (OnCreateLesson is not null)
|
||||
await OnCreateLesson(new TimetableLessonRequest(item.GroupId, item.Date, item.PeriodNumber));
|
||||
else OnNavigateToGroup?.Invoke(item.GroupId);
|
||||
}
|
||||
|
||||
@@ -380,7 +383,7 @@ public partial class TimetableViewModel : ObservableObject
|
||||
/// 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.
|
||||
/// daneben die weiteren Ziele (einschließlich Anlegen/Verschieben) explizit an.
|
||||
[RelayCommand]
|
||||
private async Task OpenWeekCell(WeekCellItem? item)
|
||||
{
|
||||
@@ -391,6 +394,8 @@ public partial class TimetableViewModel : ObservableObject
|
||||
await OnOpenTeachingMode(lesson);
|
||||
else if (OnOpenLessonViewer is not null) await OnOpenLessonViewer(lesson);
|
||||
}
|
||||
else if (item.Date is { } date && OnCreateLesson is not null)
|
||||
await OnCreateLesson(new TimetableLessonRequest(item.GroupId, date, item.PeriodNumber));
|
||||
else OnNavigateToGroup?.Invoke(item.GroupId);
|
||||
}
|
||||
|
||||
@@ -399,16 +404,20 @@ public partial class TimetableViewModel : ObservableObject
|
||||
/// 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).
|
||||
/// Nimmt das Datum der Lesson selbst entgegen; die Kachel trägt ihr Datum zusätzlich für die
|
||||
/// Direktanlage einer noch nicht existierenden Stunde.
|
||||
private static readonly TimeSpan TeachingTimeTolerance = TimeSpan.FromMinutes(10);
|
||||
private bool IsAroundTeachingTime(DateOnly lessonDate, int periodNumber)
|
||||
{
|
||||
var nowSnapshot = Clock();
|
||||
if (lessonDate != DateOnly.FromDateTime(nowSnapshot)) return false;
|
||||
if (_periodSchedule.GetTimes(periodNumber) is not { } times) return false;
|
||||
var now = TimeOnly.FromDateTime(nowSnapshot);
|
||||
return now >= times.Start.Add(-TeachingTimeTolerance) && now <= times.End.Add(TeachingTimeTolerance);
|
||||
// DateTime statt TimeOnly.Add: Letzteres springt nahe Mitternacht auf den anderen
|
||||
// Tagesrand und macht aus z.B. 00:05 ± 10 Minuten ein umgekehrtes Vergleichsfenster.
|
||||
var start = lessonDate.ToDateTime(times.Start).Subtract(TeachingTimeTolerance);
|
||||
var end = lessonDate.ToDateTime(times.End).Add(TeachingTimeTolerance);
|
||||
if (end < start) end = end.AddDays(1); // nur für ein ggf. über Mitternacht laufendes Raster
|
||||
return nowSnapshot >= start && nowSnapshot <= end;
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
@@ -487,14 +496,14 @@ public partial class TimetableViewModel : ObservableObject
|
||||
continue;
|
||||
}
|
||||
|
||||
var lesson = _lessons.GetByGroupAndDate(slot.GroupId, date).FirstOrDefault();
|
||||
var lesson = FindLessonForSlot(slot.GroupId, date, period);
|
||||
var hasExam = _exams.GetByGroup(slot.GroupId).Any(e => e.Date == date);
|
||||
var isHoliday = IsFreeDay(date, schoolHolidays, publicHolidayDates);
|
||||
var colorHex = isHoliday ? "#BDBDBD" : ColorFor(group?.Name ?? "");
|
||||
var holidayBadge = HolidayBadgeFor(date, weekday, schoolHolidays, publicHolidayDates);
|
||||
var isLastBeforeExam = IsLastBeforeExamFor(date, weekday, slot.GroupId, publicHolidayDates);
|
||||
|
||||
WeekItems.Add(WeekCellItem.ForSlot(weekday, period, date == today,
|
||||
WeekItems.Add(WeekCellItem.ForSlot(weekday, period, date == today, date,
|
||||
subject?.ShortName is { Length: > 0 } sn ? sn : subject?.Name ?? "",
|
||||
group?.Name ?? "?", slot.Room ?? "", lesson?.Topic ?? "",
|
||||
colorHex, holidayBadge, hasExam, isLastBeforeExam,
|
||||
@@ -560,6 +569,31 @@ public partial class TimetableViewModel : ObservableObject
|
||||
p.Activity?.Contains("Experiment", StringComparison.OrdinalIgnoreCase) == true ||
|
||||
p.Material?.Contains("Experiment", StringComparison.OrdinalIgnoreCase) == true)) == true;
|
||||
|
||||
/// Eine Doppelstunde wird als eine Lesson an der ersten Periode gespeichert. Für die zweite
|
||||
/// Rasterzelle liefern wir dieselbe Lesson nur dann, wenn der Stundenplan dort unmittelbar
|
||||
/// fortgesetzt wird und der geplante Verlauf länger als die erste Periode ist. Eine exakt an
|
||||
/// der Zielperiode verankerte Lesson hat immer Vorrang.
|
||||
private Lesson? FindLessonForSlot(Guid groupId, DateOnly date, int period)
|
||||
{
|
||||
var lessons = _lessons.GetByGroupAndDate(groupId, date);
|
||||
var exact = lessons.FirstOrDefault(l => l.LessonNumber == period);
|
||||
if (exact is not null) return exact;
|
||||
// Historische/manuell angelegte Einträge hatten häufig keine Stundennummer. Solange es
|
||||
// davon nur einen an diesem Tag gibt, bleibt das frühere Verhalten erhalten und er wird
|
||||
// dem vorhandenen Gruppen-Slot zugeordnet.
|
||||
var withoutPeriod = lessons.Where(l => l.LessonNumber is null).ToList();
|
||||
if (withoutPeriod.Count == 1) return withoutPeriod[0];
|
||||
|
||||
var previous = lessons.Where(l => l.LessonNumber is int p && p == period - 1)
|
||||
.OrderByDescending(l => l.UpdatedAt).FirstOrDefault();
|
||||
if (previous?.LessonNumber is not int anchor) return null;
|
||||
var isConsecutiveSlot = _slots.GetByGroup(groupId)
|
||||
.Any(s => s.Weekday == date.DayOfWeek && s.PeriodNumber == period);
|
||||
var firstPeriodMinutes = _periodSchedule.GetDurationMinutes(anchor);
|
||||
return isConsecutiveSlot && firstPeriodMinutes > 0 &&
|
||||
previous.Phases.Sum(p => p.DurationMinutes) > firstPeriodMinutes ? previous : null;
|
||||
}
|
||||
|
||||
/// <summary>4.5.4: Hat die letzte vor <paramref name="date"/> liegende Lesson dieser Gruppe eine
|
||||
/// Hausaufgabe, die weder als kontrolliert noch als bewusst übersprungen markiert ist? Schaut
|
||||
/// bewusst nur auf die unmittelbar vorherige Lesson (nicht auf die gesamte Historie) — sobald
|
||||
@@ -824,7 +858,9 @@ public partial class WeekCellItem : ObservableObject
|
||||
/// Grundlage für den Direktsprung in den Verlaufsplan-Viewer (4.5.2).
|
||||
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 bool HasNoLesson => Lesson is null;
|
||||
public string PlanningStatusLabel => Lesson is null ? "Nicht geplant" : LessonStatusDisplay.ToName(Lesson.Status);
|
||||
public string OpenButtonLabel => Lesson is not null ? "Verlaufsplan ansehen" : "Stunde anlegen";
|
||||
[ObservableProperty] private string _weatherSymbol = "";
|
||||
[ObservableProperty] private string _weatherTooltip = "";
|
||||
[ObservableProperty] private bool _hasWeatherWarning;
|
||||
@@ -879,18 +915,28 @@ public partial class WeekCellItem : ObservableObject
|
||||
IsSubstitutionSupervision = isSubstitution,
|
||||
};
|
||||
|
||||
public static WeekCellItem ForSlot(DayOfWeek day, int period, bool isToday, string subjectLabel,
|
||||
public static WeekCellItem ForSlot(DayOfWeek day, int period, bool isToday, DateOnly date, string subjectLabel,
|
||||
string groupName, string room, string topic, string colorHex, string holidayBadge,
|
||||
bool hasExam, bool isLastBeforeExam, bool hasExperiment, Guid groupId, bool isHoliday,
|
||||
bool hasUnhandledHomework = false, Lesson? lesson = null) => new()
|
||||
{
|
||||
Weekday = day, PeriodNumber = period, IsAssigned = true, IsToday = isToday,
|
||||
Weekday = day, PeriodNumber = period, IsAssigned = true, IsToday = isToday, Date = date,
|
||||
SubjectLabel = subjectLabel, GroupName = groupName, Room = room, Topic = topic,
|
||||
ColorHex = colorHex, HolidayBadge = holidayBadge, HasExam = hasExam,
|
||||
IsLastBeforeExam = isLastBeforeExam, HasExperiment = hasExperiment, GroupId = groupId,
|
||||
IsHoliday = isHoliday, HasUnhandledHomework = hasUnhandledHomework, Lesson = lesson,
|
||||
};
|
||||
|
||||
// Kompatible Überladung für isolierte ViewModel-Tests und ältere Aufrufer ohne konkreten
|
||||
// Wochenbezug. Produktiv wird die datierte Variante verwendet.
|
||||
public static WeekCellItem ForSlot(DayOfWeek day, int period, bool isToday, string subjectLabel,
|
||||
string groupName, string room, string topic, string colorHex, string holidayBadge,
|
||||
bool hasExam, bool isLastBeforeExam, bool hasExperiment, Guid groupId, bool isHoliday,
|
||||
bool hasUnhandledHomework = false, Lesson? lesson = null) => ForSlot(day, period, isToday,
|
||||
DateOnly.FromDateTime(DateTime.Today), subjectLabel, groupName, room, topic, colorHex,
|
||||
holidayBadge, hasExam, isLastBeforeExam, hasExperiment, groupId, isHoliday,
|
||||
hasUnhandledHomework, lesson);
|
||||
|
||||
public static WeekCellItem ForSubstitutionLesson(DayOfWeek day, int period, bool isToday, SubstitutionEntry entry) => new()
|
||||
{
|
||||
Weekday = day, PeriodNumber = period, IsAssigned = true, IsToday = isToday, IsSubstitutionLesson = true,
|
||||
@@ -937,12 +983,13 @@ public class UpcomingExamItem(DateOnly date, string groupName, string title)
|
||||
public string Title { get; } = title;
|
||||
}
|
||||
|
||||
public enum TimetableLessonDestination { TeachingMode, Viewer, SeatingPlan, Planning }
|
||||
public enum TimetableLessonDestination { TeachingMode, Viewer, Create, Move, SeatingPlan, Planning }
|
||||
public sealed record TimetableDestinationOption(TimetableLessonDestination Kind, string Label);
|
||||
|
||||
public class TodayLessonItem
|
||||
{
|
||||
public Guid GroupId { get; private init; }
|
||||
public DateOnly Date { get; private init; }
|
||||
public int PeriodNumber { get; private init; }
|
||||
public string GroupName { get; private init; } = "";
|
||||
public string Room { get; private init; } = "";
|
||||
@@ -960,23 +1007,24 @@ public class TodayLessonItem
|
||||
/// Direktsprung in den Verlaufsplan-Viewer (4.5.2) und den Unterrichtsmodus (14.x).
|
||||
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 PlanningStatusLabel => Lesson is null ? "Nicht geplant" : LessonStatusDisplay.ToName(Lesson.Status);
|
||||
public string OpenButtonLabel => Lesson is not null ? "Verlaufsplan ansehen" : "Stunde anlegen";
|
||||
|
||||
public TodayLessonItem(Guid groupId, int periodNumber, string groupName, string room,
|
||||
public TodayLessonItem(Guid groupId, DateOnly date, int periodNumber, string groupName, string room,
|
||||
string colorHex, string? lessonTopic, string? examTitle, bool hasUnhandledHomework = false,
|
||||
Lesson? lesson = null)
|
||||
{
|
||||
GroupId = groupId; PeriodNumber = periodNumber; GroupName = groupName; Room = room;
|
||||
GroupId = groupId; Date = date; PeriodNumber = periodNumber; GroupName = groupName; Room = room;
|
||||
ColorHex = colorHex; LessonTopic = lessonTopic; ExamTitle = examTitle;
|
||||
HasUnhandledHomework = hasUnhandledHomework; Lesson = lesson;
|
||||
}
|
||||
|
||||
public static TodayLessonItem ForSubstitution(int periodNumber, SubstitutionEntry entry) => new(
|
||||
entry.GroupId ?? Guid.Empty, periodNumber, entry.GroupLabel, "", "#8E24AA", entry.Description, null)
|
||||
entry.GroupId ?? Guid.Empty, entry.Date, periodNumber, entry.GroupLabel, "", "#8E24AA", entry.Description, null)
|
||||
{ IsSubstitution = true };
|
||||
|
||||
public static TodayLessonItem ForCancelled(Guid groupId, int periodNumber, string groupName, SubstitutionEntry entry) => new(
|
||||
groupId, periodNumber, groupName, "", "#757575",
|
||||
groupId, entry.Date, periodNumber, groupName, "", "#757575",
|
||||
string.IsNullOrWhiteSpace(entry.Description) ? null : entry.Description, null)
|
||||
{ IsCancelled = true };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user