2 Commits
Author SHA1 Message Date
adminandClaude Sonnet 5 23abe7af63 Dashboard-Erinnerung für fehlende Unterrichtszeit-Erfassung
CI / build-and-test (push) Canceled after 0s
Neue Karte "Unterrichtszeit nacherfassen" schaut 14 Tage zurück und
listet Schultage mit Unterricht laut Stundenplan ohne zugehörigen
"Unterricht"-Zeiteintrag (Ferien/Feiertage/komplett ausgefallene Tage
ausgenommen). Für heute erscheint der Eintrag erst 30 Minuten nach dem
laut Stundenplan letzten Unterrichtsende. Klick auf "Erfassen" öffnet
den bestehenden Nacherfassen-Dialog vorbelegt mit Datum, Kategorie
"Unterricht" und berechnetem Zeitfenster.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-30 19:38:57 +02:00
admin 3f71f7f371 Add visual template coordinate editor 2026-08-30 19:29:06 +02:00
13 changed files with 759 additions and 14 deletions
@@ -16,6 +16,7 @@ public sealed class DashboardSettingsService
[ [
"today", "tasks", "calendar", "excuses", "upcoming", "today", "tasks", "calendar", "excuses", "upcoming",
"corrections", "unplanned", "alerts", "attendance", "support", "groups", "examload", "corrections", "unplanned", "alerts", "attendance", "support", "groups", "examload",
"missingteachingtime",
]; ];
private readonly string _configPath; private readonly string _configPath;
@@ -60,7 +60,8 @@ public sealed class DashboardViewModelTests
DashboardSettingsService? dashboardSettings = null, FakeSchoolHolidays? schoolHolidays = null, DashboardSettingsService? dashboardSettings = null, FakeSchoolHolidays? schoolHolidays = null,
FakeLessons? lessons = null, FakeSubstitutionEntries? substitutions = null, FakeLessons? lessons = null, FakeSubstitutionEntries? substitutions = null,
FakeSessions? sessions = null, FakeEntries? entries = null, FakeSessions? sessions = null, FakeEntries? entries = null,
FakeAnnualPlanEvents? annualPlanEvents = null, List<LearningGroup>? allGroups = null) FakeAnnualPlanEvents? annualPlanEvents = null, List<LearningGroup>? allGroups = null,
FakeTimeEntries? timeEntries = null)
{ {
lessons ??= new FakeLessons(); lessons ??= new FakeLessons();
lessons.Add(lesson); lessons.Add(lesson);
@@ -73,7 +74,7 @@ public sealed class DashboardViewModelTests
slots ?? new FakeTimetableSlots(), periodSchedule ?? NewPeriodSchedule(), slots ?? new FakeTimetableSlots(), periodSchedule ?? NewPeriodSchedule(),
new AttendanceBalanceService(), new SchoolYearService(), dashboardSettings ?? NewDashboardSettings(), new AttendanceBalanceService(), new SchoolYearService(), dashboardSettings ?? NewDashboardSettings(),
schoolHolidays ?? new FakeSchoolHolidays(), new PublicHolidayService(), NewCalendarSettings(), schoolHolidays ?? new FakeSchoolHolidays(), new PublicHolidayService(), NewCalendarSettings(),
substitutions ?? new FakeSubstitutionEntries(), annualPlanEvents); substitutions ?? new FakeSubstitutionEntries(), timeEntries ?? new FakeTimeEntries(), annualPlanEvents);
} }
/// Montag einer Woche, die garantiert in der Zukunft liegt und innerhalb der /// Montag einer Woche, die garantiert in der Zukunft liegt und innerhalb der
@@ -137,6 +138,120 @@ public sealed class DashboardViewModelTests
Assert.Empty(vm.ExamWeekLoads); Assert.Empty(vm.ExamWeekLoads);
} }
[Fact]
public void MissingTeachingTime_VergangenerUnterrichtstagOhneErfassung_WirdGemeldet()
{
var group = new LearningGroup { Name = "9c" };
var today = DateOnly.FromDateTime(DateTime.Today);
var pastDay = today.AddDays(-1);
var slots = new FakeTimetableSlots();
slots.Add(new TimetableSlot { GroupId = group.Id, Weekday = pastDay.DayOfWeek, PeriodNumber = 1 });
var periodSchedule = NewPeriodSchedule();
periodSchedule.SetPeriods([new PeriodTimeEntry
{ PeriodNumber = 1, Start = new TimeOnly(8, 0), End = new TimeOnly(8, 45) }]);
var vm = BuildVm(group, new Lesson { GroupId = group.Id, Date = today }, slots: slots, periodSchedule: periodSchedule);
Assert.Contains(vm.MissingTeachingTimeEntries, i => i.Date == pastDay);
Assert.True(vm.MissingTeachingTimeCard.EffectiveIsVisible);
}
[Fact]
public void MissingTeachingTime_BereitsErfassterTag_WirdNichtGemeldet()
{
var group = new LearningGroup { Name = "9c" };
var today = DateOnly.FromDateTime(DateTime.Today);
var pastDay = today.AddDays(-1);
var slots = new FakeTimetableSlots();
slots.Add(new TimetableSlot { GroupId = group.Id, Weekday = pastDay.DayOfWeek, PeriodNumber = 1 });
var periodSchedule = NewPeriodSchedule();
periodSchedule.SetPeriods([new PeriodTimeEntry
{ PeriodNumber = 1, Start = new TimeOnly(8, 0), End = new TimeOnly(8, 45) }]);
var timeEntries = new FakeTimeEntries();
timeEntries.Add(new TimeEntry { Date = pastDay, Category = "Unterricht", DurationMinutes = 45 });
var vm = BuildVm(group, new Lesson { GroupId = group.Id, Date = today },
slots: slots, periodSchedule: periodSchedule, timeEntries: timeEntries);
Assert.DoesNotContain(vm.MissingTeachingTimeEntries, i => i.Date == pastDay);
}
[Fact]
public void MissingTeachingTime_KomplettAusgefallenerTag_WirdNichtGemeldet()
{
var group = new LearningGroup { Name = "9c" };
var today = DateOnly.FromDateTime(DateTime.Today);
var pastDay = today.AddDays(-1);
var slots = new FakeTimetableSlots();
slots.Add(new TimetableSlot { GroupId = group.Id, Weekday = pastDay.DayOfWeek, PeriodNumber = 1 });
var periodSchedule = NewPeriodSchedule();
periodSchedule.SetPeriods([new PeriodTimeEntry
{ PeriodNumber = 1, Start = new TimeOnly(8, 0), End = new TimeOnly(8, 45) }]);
var substitutions = new FakeSubstitutionEntries();
substitutions.Add(new SubstitutionEntry { Date = pastDay, Kind = SubstitutionKind.Cancelled, PeriodNumber = 1 });
var vm = BuildVm(group, new Lesson { GroupId = group.Id, Date = today },
slots: slots, periodSchedule: periodSchedule, substitutions: substitutions);
Assert.DoesNotContain(vm.MissingTeachingTimeEntries, i => i.Date == pastDay);
}
[Fact]
public void MissingTeachingTime_SchulferienTagWirdNichtGemeldet()
{
var group = new LearningGroup { Name = "9c" };
var today = DateOnly.FromDateTime(DateTime.Today);
var pastDay = today.AddDays(-1);
var slots = new FakeTimetableSlots();
slots.Add(new TimetableSlot { GroupId = group.Id, Weekday = pastDay.DayOfWeek, PeriodNumber = 1 });
var periodSchedule = NewPeriodSchedule();
periodSchedule.SetPeriods([new PeriodTimeEntry
{ PeriodNumber = 1, Start = new TimeOnly(8, 0), End = new TimeOnly(8, 45) }]);
var schoolHolidays = new FakeSchoolHolidays();
schoolHolidays.Add(new SchoolHoliday { Name = "Ferien", StartDate = pastDay, EndDate = pastDay });
var vm = BuildVm(group, new Lesson { GroupId = group.Id, Date = today },
slots: slots, periodSchedule: periodSchedule, schoolHolidays: schoolHolidays);
Assert.DoesNotContain(vm.MissingTeachingTimeEntries, i => i.Date == pastDay);
}
[Fact]
public void MissingTeachingTime_HeuteVorAblaufDerWartezeit_WirdNichtGemeldet()
{
var group = new LearningGroup { Name = "9c" };
var today = DateOnly.FromDateTime(DateTime.Today);
var slots = new FakeTimetableSlots();
slots.Add(new TimetableSlot { GroupId = group.Id, Weekday = today.DayOfWeek, PeriodNumber = 1 });
var periodSchedule = NewPeriodSchedule();
// Unterrichtsende liegt garantiert noch keine 30 Minuten zurück.
var futureEnd = TimeOnly.FromDateTime(DateTime.Now).AddHours(2);
periodSchedule.SetPeriods([new PeriodTimeEntry
{ PeriodNumber = 1, Start = futureEnd.AddHours(-1), End = futureEnd }]);
var vm = BuildVm(group, new Lesson { GroupId = group.Id, Date = today }, slots: slots, periodSchedule: periodSchedule);
Assert.DoesNotContain(vm.MissingTeachingTimeEntries, i => i.Date == today);
}
[Fact]
public void MissingTeachingTime_HeuteNachAblaufDerWartezeit_WirdGemeldet()
{
var group = new LearningGroup { Name = "9c" };
var today = DateOnly.FromDateTime(DateTime.Today);
var slots = new FakeTimetableSlots();
slots.Add(new TimetableSlot { GroupId = group.Id, Weekday = today.DayOfWeek, PeriodNumber = 1 });
var periodSchedule = NewPeriodSchedule();
// Unterrichtsende liegt garantiert mehr als 30 Minuten zurück.
var pastEnd = TimeOnly.FromDateTime(DateTime.Now).AddHours(-2);
periodSchedule.SetPeriods([new PeriodTimeEntry
{ PeriodNumber = 1, Start = pastEnd.AddHours(-1), End = pastEnd }]);
var vm = BuildVm(group, new Lesson { GroupId = group.Id, Date = today }, slots: slots, periodSchedule: periodSchedule);
Assert.Contains(vm.MissingTeachingTimeEntries, i => i.Date == today);
}
[Fact] [Fact]
public void TodaysLessons_LoestRaumUeberPassendenStundenplanSlotAuf() public void TodaysLessons_LoestRaumUeberPassendenStundenplanSlotAuf()
{ {
+25
View File
@@ -3,6 +3,7 @@ using Avalonia.Controls.ApplicationLifetimes;
using Avalonia.Markup.Xaml; using Avalonia.Markup.Xaml;
using Avalonia.Styling; using Avalonia.Styling;
using LehrerApp.Core.Interfaces; using LehrerApp.Core.Interfaces;
using LehrerApp.Core.Models;
using LehrerApp.Core.Services; using LehrerApp.Core.Services;
using LehrerApp.Data; using LehrerApp.Data;
using LehrerApp.Desktop.Services; using LehrerApp.Desktop.Services;
@@ -153,6 +154,7 @@ public class App : Application
dash.OnNavigateToLesson = id => main.NavigateToGroupDetail(id, 3); // Tab "Mitarbeit" dash.OnNavigateToLesson = id => main.NavigateToGroupDetail(id, 3); // Tab "Mitarbeit"
dash.OnNavigateToExam = id => main.NavigateToGroupDetail(id, 4); // Tab "Klausuren" dash.OnNavigateToExam = id => main.NavigateToGroupDetail(id, 4); // Tab "Klausuren"
dash.OnNavigateToUnplannedLesson = id => main.NavigateToGroupDetail(id, 6); // Tab "Planung" dash.OnNavigateToUnplannedLesson = id => main.NavigateToGroupDetail(id, 6); // Tab "Planung"
dash.OnAddMissingTeachingTime = item => ShowMissingTeachingTimeDialog(item, dash);
var examsOverview = Services.GetRequiredService<ViewModels.Exams.ExamsOverviewViewModel>(); var examsOverview = Services.GetRequiredService<ViewModels.Exams.ExamsOverviewViewModel>();
examsOverview.OnNavigateToGroups = () => main.NavigateToCommand.Execute(NavItem.Groups); examsOverview.OnNavigateToGroups = () => main.NavigateToCommand.Execute(NavItem.Groups);
@@ -208,6 +210,29 @@ public class App : Application
Services.GetRequiredService<WorkTaskListViewModel>().Load(); Services.GetRequiredService<WorkTaskListViewModel>().Load();
} }
private static async Task ShowMissingTeachingTimeDialog(MissingTeachingTimeItem item, DashboardViewModel dashboard)
{
if (Application.Current?.ApplicationLifetime is not IClassicDesktopStyleApplicationLifetime
{ MainWindow: { } owner }) return;
var tasks = Services.GetRequiredService<IWorkTaskRepository>()
.GetAll().Where(t => t.Status != WorkTaskStatus.Done).ToList();
var vm = new AddTimeEntryDialogViewModel(tasks)
{
DateText = item.Date.ToString("dd.MM.yyyy"),
SelectedCategory = "Unterricht",
StartTimeText = item.WindowStart.ToString("HH:mm"),
EndTimeText = item.WindowEnd.ToString("HH:mm"),
};
var dialog = new AddTimeEntryDialog { DataContext = vm };
await dialog.ShowDialog<bool>(owner);
if (vm.Result is null) return;
Services.GetRequiredService<ITimeEntryRepository>().Save(vm.Result);
dashboard.RefreshCommand.Execute(null);
Services.GetRequiredService<TimeTrackingViewModel>().Load();
}
private static async Task ShowQuickGroupDocumentationDialog(DashboardViewModel dashboard) private static async Task ShowQuickGroupDocumentationDialog(DashboardViewModel dashboard)
{ {
if (Application.Current?.ApplicationLifetime is not IClassicDesktopStyleApplicationLifetime if (Application.Current?.ApplicationLifetime is not IClassicDesktopStyleApplicationLifetime
@@ -35,6 +35,7 @@ public partial class DashboardViewModel : ObservableObject
private readonly PublicHolidayService _publicHolidays; private readonly PublicHolidayService _publicHolidays;
private readonly SchoolCalendarSettingsService _calendarSettings; private readonly SchoolCalendarSettingsService _calendarSettings;
private readonly ISubstitutionEntryRepository _substitutions; private readonly ISubstitutionEntryRepository _substitutions;
private readonly ITimeEntryRepository _timeEntries;
private readonly IAnnualPlanEventRepository? _annualPlanEvents; private readonly IAnnualPlanEventRepository? _annualPlanEvents;
private readonly SchoolWeatherService? _schoolWeather; private readonly SchoolWeatherService? _schoolWeather;
@@ -50,6 +51,18 @@ public partial class DashboardViewModel : ObservableObject
/// hat rein rechnerisch schon 100 %, das ist noch kein auffälliges Muster, nur eine zu kleine /// hat rein rechnerisch schon 100 %, das ist noch kein auffälliges Muster, nur eine zu kleine
/// Stichprobe. Dieselbe Konstante wie GroupOverviewViewModel.AttendanceMinSampleSize. /// Stichprobe. Dieselbe Konstante wie GroupOverviewViewModel.AttendanceMinSampleSize.
private const int AttendanceMinSampleSize = 8; private const int AttendanceMinSampleSize = 8;
/// Nutzer-Feedback: "ich vergesse es, und fasse die App auch außerhalb der Schule manchmal
/// nicht mehr an" — die Erinnerung an fehlende Unterrichtszeit-Erfassung schaut deshalb nicht
/// nur auf heute zurück, sondern auf ein paar Tage, damit ein vergessener Tag nicht verloren
/// geht, sobald der nächste Schultag anbricht.
private const int MissingTeachingTimeLookbackDays = 14;
/// Für den heutigen Tag soll die Erinnerung nicht schon mitten im Unterricht auftauchen —
/// erst diese Zeitspanne nach dem laut Stundenplan letzten Unterrichtsende.
private const int MissingTeachingTimeTodayDelayMinutes = 30;
/// Gleiche Puffer-Idee wie TimeTrackingViewModel.ComputeTodaysTeachingWindow (bewusst hier
/// noch einmal definiert statt geteilt — der Vorschlag ist nur zwei Zeilen Rechnung).
private const int TeachingWindowBufferBeforeMinutes = 15;
private const int TeachingWindowBufferAfterMinutes = 10;
/// Vorausschau für die Klausurwochen-Karte (Nutzer-Feedback): weit genug, um eine sich /// Vorausschau für die Klausurwochen-Karte (Nutzer-Feedback): weit genug, um eine sich
/// anbahnende Häufung noch rechtzeitig vor dem Anlegen weiterer Klausuren zu zeigen, aber /// anbahnende Häufung noch rechtzeitig vor dem Anlegen weiterer Klausuren zu zeigen, aber
/// keine Vorschau auf das ganze Schuljahr. /// keine Vorschau auf das ganze Schuljahr.
@@ -77,6 +90,7 @@ public partial class DashboardViewModel : ObservableObject
public ObservableCollection<OpenExcuseItem> OpenExcuses { get; } = []; public ObservableCollection<OpenExcuseItem> OpenExcuses { get; } = [];
public ObservableCollection<AttendanceWarningItem> AttendanceWarnings { get; } = []; public ObservableCollection<AttendanceWarningItem> AttendanceWarnings { get; } = [];
public ObservableCollection<ExamWeekLoadItem> ExamWeekLoads { get; } = []; public ObservableCollection<ExamWeekLoadItem> ExamWeekLoads { get; } = [];
public ObservableCollection<MissingTeachingTimeItem> MissingTeachingTimeEntries { get; } = [];
public ObservableCollection<SupportPlanDueItem> SupportPlanReviews { get; } = []; public ObservableCollection<SupportPlanDueItem> SupportPlanReviews { get; } = [];
public ObservableCollection<UpcomingDateItem> UpcomingDates { get; } = []; public ObservableCollection<UpcomingDateItem> UpcomingDates { get; } = [];
public ObservableCollection<CorrectionProgressItem> OpenCorrections { get; } = []; public ObservableCollection<CorrectionProgressItem> OpenCorrections { get; } = [];
@@ -101,6 +115,10 @@ public partial class DashboardViewModel : ObservableObject
// Sprungziel für eine ungeplante Stunde — führt direkt in den Verlaufsplan-Tab der Gruppe // Sprungziel für eine ungeplante Stunde — führt direkt in den Verlaufsplan-Tab der Gruppe
// (nicht den Standard-Tab von OnNavigateToGroup), damit das Thema gleich ergänzt werden kann. // (nicht den Standard-Tab von OnNavigateToGroup), damit das Thema gleich ergänzt werden kann.
public Action<Guid>? OnNavigateToUnplannedLesson { get; set; } public Action<Guid>? OnNavigateToUnplannedLesson { get; set; }
// Öffnet den Nacherfassen-Dialog für einen Tag mit fehlender Unterrichtszeit-Erfassung
// (Nutzer-Feedback), vorbelegt mit Datum, Kategorie "Unterricht" und dem laut Stundenplan
// berechneten Zeitfenster — echtes Speichern bleibt eine bewusste Bestätigung im Dialog.
public Func<MissingTeachingTimeItem, Task>? OnAddMissingTeachingTime { get; set; }
public DashboardCardOption TodayCard => Card("today"); public DashboardCardOption TodayCard => Card("today");
public DashboardCardOption TasksCard => Card("tasks"); public DashboardCardOption TasksCard => Card("tasks");
@@ -112,6 +130,7 @@ public partial class DashboardViewModel : ObservableObject
public DashboardCardOption AlertsCard => Card("alerts"); public DashboardCardOption AlertsCard => Card("alerts");
public DashboardCardOption AttendanceCard => Card("attendance"); public DashboardCardOption AttendanceCard => Card("attendance");
public DashboardCardOption ExamLoadCard => Card("examload"); public DashboardCardOption ExamLoadCard => Card("examload");
public DashboardCardOption MissingTeachingTimeCard => Card("missingteachingtime");
public DashboardCardOption SupportCard => Card("support"); public DashboardCardOption SupportCard => Card("support");
public DashboardCardOption GroupsCard => Card("groups"); public DashboardCardOption GroupsCard => Card("groups");
public int TodayLessonCount => TodaysLessons.Count; public int TodayLessonCount => TodaysLessons.Count;
@@ -133,7 +152,8 @@ public partial class DashboardViewModel : ObservableObject
PeriodScheduleService periodSchedule, AttendanceBalanceService attendanceBalance, SchoolYearService sy, PeriodScheduleService periodSchedule, AttendanceBalanceService attendanceBalance, SchoolYearService sy,
DashboardSettingsService dashboardSettings, ISchoolHolidayRepository schoolHolidays, DashboardSettingsService dashboardSettings, ISchoolHolidayRepository schoolHolidays,
PublicHolidayService publicHolidays, SchoolCalendarSettingsService calendarSettings, PublicHolidayService publicHolidays, SchoolCalendarSettingsService calendarSettings,
ISubstitutionEntryRepository substitutions, IAnnualPlanEventRepository? annualPlanEvents = null, ISubstitutionEntryRepository substitutions, ITimeEntryRepository timeEntries,
IAnnualPlanEventRepository? annualPlanEvents = null,
AnnualPlanSyncService? annualPlanSync = null, SchoolWeatherService? schoolWeather = null) AnnualPlanSyncService? annualPlanSync = null, SchoolWeatherService? schoolWeather = null)
{ {
_groups = groups; _subjects = subjects; _lessons = lessons; _exams = exams; _tasks = tasks; _groups = groups; _subjects = subjects; _lessons = lessons; _exams = exams; _tasks = tasks;
@@ -143,6 +163,7 @@ public partial class DashboardViewModel : ObservableObject
_timetableSlots = timetableSlots; _periodSchedule = periodSchedule; _timetableSlots = timetableSlots; _periodSchedule = periodSchedule;
_attendanceBalance = attendanceBalance; _sy = sy; _dashboardSettings = dashboardSettings; _attendanceBalance = attendanceBalance; _sy = sy; _dashboardSettings = dashboardSettings;
_schoolHolidays = schoolHolidays; _publicHolidays = publicHolidays; _calendarSettings = calendarSettings; _schoolHolidays = schoolHolidays; _publicHolidays = publicHolidays; _calendarSettings = calendarSettings;
_timeEntries = timeEntries;
_substitutions = substitutions; _substitutions = substitutions;
_annualPlanEvents = annualPlanEvents; _annualPlanEvents = annualPlanEvents;
_schoolWeather = schoolWeather; _schoolWeather = schoolWeather;
@@ -216,6 +237,7 @@ public partial class DashboardViewModel : ObservableObject
LoadOpenExcuses(groups.Values.ToList(), today); LoadOpenExcuses(groups.Values.ToList(), today);
LoadAttendanceWarnings(today); LoadAttendanceWarnings(today);
LoadExamWeekLoads(groups, today); LoadExamWeekLoads(groups, today);
LoadMissingTeachingTime(today);
LoadSupportPlanReviews(today); LoadSupportPlanReviews(today);
LoadUpcomingDates(groups, today); LoadUpcomingDates(groups, today);
LoadOpenCorrections(groups, today); LoadOpenCorrections(groups, today);
@@ -236,6 +258,7 @@ public partial class DashboardViewModel : ObservableObject
AlertsCard.IsEmpty = Alerts.Count == 0; AlertsCard.IsEmpty = Alerts.Count == 0;
AttendanceCard.IsEmpty = AttendanceWarnings.Count == 0; AttendanceCard.IsEmpty = AttendanceWarnings.Count == 0;
ExamLoadCard.IsEmpty = ExamWeekLoads.Count == 0; ExamLoadCard.IsEmpty = ExamWeekLoads.Count == 0;
MissingTeachingTimeCard.IsEmpty = MissingTeachingTimeEntries.Count == 0;
SupportCard.IsEmpty = SupportPlanReviews.Count == 0; SupportCard.IsEmpty = SupportPlanReviews.Count == 0;
GroupsCard.IsEmpty = CurrentGroups.Count == 0; GroupsCard.IsEmpty = CurrentGroups.Count == 0;
@@ -351,6 +374,54 @@ public partial class DashboardViewModel : ObservableObject
ExamWeekLoads.Add(new ExamWeekLoadItem(week.IsoWeek, week.WeekStart, week.WeekEnd, week.ExamCount)); ExamWeekLoads.Add(new ExamWeekLoadItem(week.IsoWeek, week.WeekStart, week.WeekEnd, week.ExamCount));
} }
// ── Unterrichtszeit-Erfassung nachholen (Nutzer-Feedback) ─────────────────
//
// "Ich vergesse es, und fasse die App außerhalb der Schule manchmal nicht mehr an" — schaut
// deshalb bewusst ein paar Tage zurück statt nur auf heute. Ein Tag zählt als erfasst, sobald
// irgendein TimeEntry der Kategorie "Unterricht" an diesem Datum existiert; die genaue
// Zeitüberschneidung mit dem Stundenplan wird nicht geprüft (dieselbe grobe Betrachtung wie
// beim bestehenden "Unterrichtszeit übernehmen"-Vorschlag für heute).
private void LoadMissingTeachingTime(DateOnly today)
{
MissingTeachingTimeEntries.Clear();
var firstDay = today.AddDays(-MissingTeachingTimeLookbackDays);
var publicHolidayDates = Enumerable.Range(firstDay.Year, today.Year - firstDay.Year + 1)
.SelectMany(y => _publicHolidays.GetHolidays(y, _calendarSettings.State))
.Select(h => h.Date).ToHashSet();
var schoolHolidays = _schoolHolidays.GetAll();
var nowTime = TimeOnly.FromDateTime(DateTime.Now);
var items = new List<MissingTeachingTimeItem>();
for (var date = firstDay; date <= today; date = date.AddDays(1))
{
if (IsFreeDay(date, schoolHolidays, publicHolidayDates)) continue;
var daySlots = _timetableSlots.GetAll().Where(s => s.Weekday == date.DayOfWeek).ToList();
if (daySlots.Count == 0) continue;
var cancelledPeriods = _substitutions.GetByDate(date)
.Where(s => s.Kind == SubstitutionKind.Cancelled)
.Select(s => s.PeriodNumber).ToHashSet();
var periodTimes = daySlots.Where(s => !cancelledPeriods.Contains(s.PeriodNumber))
.Select(s => _periodSchedule.GetTimes(s.PeriodNumber))
.Where(t => t is not null).Select(t => t!.Value).ToList();
if (periodTimes.Count == 0) continue; // alle Stunden entfallen oder kein Zeitraster hinterlegt
var lastPeriodEnd = periodTimes.Max(t => t.End);
if (date == today && nowTime < lastPeriodEnd.AddMinutes(MissingTeachingTimeTodayDelayMinutes))
continue; // heute: erst 30 Minuten nach Unterrichtsschluss erinnern
if (_timeEntries.GetByDate(date).Any(e => e.Category == "Unterricht")) continue;
var windowStart = periodTimes.Min(t => t.Start).AddMinutes(-TeachingWindowBufferBeforeMinutes);
var windowEnd = lastPeriodEnd.AddMinutes(TeachingWindowBufferAfterMinutes);
items.Add(new MissingTeachingTimeItem(date, windowStart, windowEnd));
}
foreach (var item in items.OrderBy(i => i.Date))
MissingTeachingTimeEntries.Add(item);
}
// ── Förderplan-Wiedervorlage (5.3.2) ────────────────────────────────────── // ── Förderplan-Wiedervorlage (5.3.2) ──────────────────────────────────────
private void LoadSupportPlanReviews(DateOnly today) private void LoadSupportPlanReviews(DateOnly today)
@@ -576,7 +647,8 @@ public partial class DashboardViewModel : ObservableObject
"excuses" => "Offene Entschuldigungen", "upcoming" => "Anstehende Termine", "excuses" => "Offene Entschuldigungen", "upcoming" => "Anstehende Termine",
"corrections" => "Offene Korrekturen", "unplanned" => "Ungeplante Stunden", "alerts" => "Auffälligkeiten", "corrections" => "Offene Korrekturen", "unplanned" => "Ungeplante Stunden", "alerts" => "Auffälligkeiten",
"attendance" => "Fehlzeiten-Warnung", "support" => "Förderplan-Wiedervorlage", "attendance" => "Fehlzeiten-Warnung", "support" => "Förderplan-Wiedervorlage",
"groups" => "Meine Lerngruppen", "examload" => "Klausurwochen", _ => key, "groups" => "Meine Lerngruppen", "examload" => "Klausurwochen",
"missingteachingtime" => "Unterrichtszeit nacherfassen", _ => key,
}; };
private void ApplyCardLayout() private void ApplyCardLayout()
@@ -626,6 +698,15 @@ public partial class DashboardViewModel : ObservableObject
[RelayCommand] private void OpenStudentSupportPlan(SupportPlanDueItem? item) [RelayCommand] private void OpenStudentSupportPlan(SupportPlanDueItem? item)
{ if (item is not null) OnNavigateToStudent?.Invoke(item.StudentId); } { if (item is not null) OnNavigateToStudent?.Invoke(item.StudentId); }
[RelayCommand]
private async Task AddMissingTeachingTime(MissingTeachingTimeItem? item)
{
if (item is null || OnAddMissingTeachingTime is null) return;
await OnAddMissingTeachingTime(item);
LoadMissingTeachingTime(DateOnly.FromDateTime(DateTime.Today));
UpdateDashboardSummary();
}
private void LoadOpenExcuses(List<LearningGroup> groups, DateOnly today) private void LoadOpenExcuses(List<LearningGroup> groups, DateOnly today)
{ {
OpenExcuses.Clear(); OpenExcuses.Clear();
@@ -1002,6 +1083,16 @@ public class ExamWeekLoadItem(int isoWeek, DateOnly weekStart, DateOnly weekEnd,
public string CountDisplay => ExamCount == 1 ? "1 Klausur" : $"{ExamCount} Klausuren"; public string CountDisplay => ExamCount == 1 ? "1 Klausur" : $"{ExamCount} Klausuren";
} }
public class MissingTeachingTimeItem(DateOnly date, TimeOnly windowStart, TimeOnly windowEnd)
{
private static readonly CultureInfo De = new("de-DE");
public DateOnly Date { get; } = date;
public TimeOnly WindowStart { get; } = windowStart;
public TimeOnly WindowEnd { get; } = windowEnd;
public string DateDisplay { get; } = date.ToString("dddd, dd.MM.", De);
}
// ── Förderplan-Wiedervorlage (5.3.2) ────────────────────────────────────────── // ── Förderplan-Wiedervorlage (5.3.2) ──────────────────────────────────────────
public class SupportPlanDueItem public class SupportPlanDueItem
@@ -444,6 +444,32 @@
</StackPanel> </StackPanel>
</Border> </Border>
<!-- Unterrichtszeit nacherfassen (Nutzer-Feedback) -->
<Border Grid.Column="{Binding MissingTeachingTimeCard.Column}" Grid.Row="{Binding MissingTeachingTimeCard.Row}"
IsVisible="{Binding MissingTeachingTimeCard.EffectiveIsVisible}" Margin="0,0,8,8" VerticalAlignment="Top"
Background="{DynamicResource SystemControlBackgroundAltHighBrush}"
CornerRadius="8" Padding="16">
<StackPanel>
<TextBlock Text="UNTERRICHTSZEIT NACHERFASSEN" FontSize="11" FontWeight="Bold"
Opacity="0.5" Margin="0,0,0,10"/>
<ItemsControl ItemsSource="{Binding MissingTeachingTimeEntries}">
<ItemsControl.ItemTemplate>
<DataTemplate DataType="vm:MissingTeachingTimeItem">
<Grid ColumnDefinitions="*,Auto" Margin="0,4">
<TextBlock Grid.Column="0" Text="{Binding DateDisplay}" FontSize="13"
VerticalAlignment="Center"/>
<Button Grid.Column="1" Content="Erfassen" FontSize="12" Padding="10,4"
Command="{Binding $parent[ItemsControl].((vm:DashboardViewModel)DataContext).AddMissingTeachingTimeCommand}"
CommandParameter="{Binding}"/>
</Grid>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<TextBlock Text="Keine fehlende Unterrichtszeit-Erfassung." Classes="emptyhint"
IsVisible="{Binding !MissingTeachingTimeEntries.Count}"/>
</StackPanel>
</Border>
<!-- Förderplan-Wiedervorlage (5.3.2) --> <!-- Förderplan-Wiedervorlage (5.3.2) -->
<Border Grid.Column="{Binding SupportCard.Column}" Grid.Row="{Binding SupportCard.Row}" <Border Grid.Column="{Binding SupportCard.Column}" Grid.Row="{Binding SupportCard.Row}"
IsVisible="{Binding SupportCard.EffectiveIsVisible}" Margin="8,0,0,8" VerticalAlignment="Top" IsVisible="{Binding SupportCard.EffectiveIsVisible}" Margin="8,0,0,8" VerticalAlignment="Top"
@@ -0,0 +1,73 @@
using Avalonia;
using LehrerApp.TemplateDesigner;
using Xunit;
namespace LehrerApp.TemplateDesigner.Tests;
public sealed class OverlayEditorTests
{
[Fact]
public void Koordinatenabbildung_BeruecksichtigtLetterboxUndIstUmkehrbar()
{
var displayedPage = OverlayCoordinateMapper.PageRect(new Size(1000, 1000), 210, 297);
var dslRect = new Rect(20, 30, 80, 45);
var controlRect = OverlayCoordinateMapper.ToControl(dslRect, displayedPage, 210, 297);
var mappedTopLeft = OverlayCoordinateMapper.ToDsl(controlRect.TopLeft, displayedPage, 210, 297);
var mappedBottomRight = OverlayCoordinateMapper.ToDsl(controlRect.BottomRight, displayedPage, 210, 297);
Assert.Equal(1000, displayedPage.Height, 6);
Assert.True(displayedPage.X > 140);
Assert.Equal(dslRect.X, mappedTopLeft.X, 6);
Assert.Equal(dslRect.Y, mappedTopLeft.Y, 6);
Assert.Equal(dslRect.Right, mappedBottomRight.X, 6);
Assert.Equal(dslRect.Bottom, mappedBottomRight.Y, 6);
}
[Fact]
public void Messbereich_WirdInElementformularUebernommen()
{
var viewModel = new DesignerViewModel();
viewModel.ApplyMeasurement(new(12.5, 34.25, 80, 22.5, true));
Assert.Equal("12.5", viewModel.NewX);
Assert.Equal("34.25", viewModel.NewY);
Assert.Equal("80", viewModel.NewWidth);
Assert.Equal("22.5", viewModel.NewHeight);
}
[Fact]
public void TextVerschieben_ErhaeltInhaltUndAttribute()
{
var viewModel = new DesignerViewModel
{ LayoutSource = "PAGE 210 297 mm\nTEXT 20 30 \"Hallo Welt\" size=10 bold=true" };
viewModel.ApplyElementGeometry(new(2, "TEXT", 25.5, 40, 70, 5));
Assert.Contains("TEXT 25.5 40 \"Hallo Welt\" size=10 bold=true", viewModel.LayoutSource);
}
[Fact]
public void SkaliertesBildResize_RechnetEffektivenRahmenZurueckUndErhaeltScale()
{
var viewModel = new DesignerViewModel
{ LayoutSource = "PAGE 210 297 mm\nIMG logo.png 10 15 30 12 scale=50%" };
viewModel.ApplyElementGeometry(new(2, "IMG", 20, 25, 30, 12));
Assert.Contains("IMG logo.png 20 25 60 24 scale=50%", viewModel.LayoutSource);
}
[Fact]
public void DslSeitengroesse_SteuertOverlayAuchBeiAbweichendemManifestformular()
{
var viewModel = new DesignerViewModel { PageWidth = 210, PageHeight = 297 };
viewModel.LayoutSource = "PAGE 200 200 pt";
Assert.Equal(200, viewModel.OverlayPageWidth);
Assert.Equal(200, viewModel.OverlayPageHeight);
Assert.Equal("pt", viewModel.OverlayPageUnit);
}
}
@@ -30,6 +30,14 @@ public partial class DesignerViewModel : ObservableObject
[ObservableProperty] private DesignerPlaceholder? _selectedPlaceholder; [ObservableProperty] private DesignerPlaceholder? _selectedPlaceholder;
[ObservableProperty] private DesignerAsset? _selectedAsset; [ObservableProperty] private DesignerAsset? _selectedAsset;
[ObservableProperty] private StarterTemplateItem? _selectedStarterTemplate; [ObservableProperty] private StarterTemplateItem? _selectedStarterTemplate;
[ObservableProperty] private OverlayEditorMode _overlayMode = OverlayEditorMode.Measure;
[ObservableProperty] private bool _snapOverlayToGrid = true;
[ObservableProperty] private double _overlayGridSize = 1;
[ObservableProperty] private string _overlayCoordinates = "x= · y=";
[ObservableProperty] private string _selectedOverlayElement = "Kein Element ausgewählt";
[ObservableProperty] private double _overlayPageWidth = 210;
[ObservableProperty] private double _overlayPageHeight = 297;
[ObservableProperty] private string _overlayPageUnit = "mm";
public IReadOnlyList<string> Units { get; } = ["mm", "cm", "pt", "in"]; public IReadOnlyList<string> Units { get; } = ["mm", "cm", "pt", "in"];
public IReadOnlyList<PlaceholderType> PlaceholderTypes { get; } = Enum.GetValues<PlaceholderType>(); public IReadOnlyList<PlaceholderType> PlaceholderTypes { get; } = Enum.GetValues<PlaceholderType>();
@@ -190,7 +198,54 @@ public partial class DesignerViewModel : ObservableObject
: $"Asset „{selected.Name}“ und {removedReferences} Layout-Referenz(en) wurden entfernt.", false); : $"Asset „{selected.Name}“ und {removedReferences} Layout-Referenz(en) wurden entfernt.", false);
} }
partial void OnLayoutSourceChanged(string value) => RefreshAssetUsage(); public void ApplyMeasurement(OverlayMeasurement measurement)
{
NewX = FormatNumber(measurement.X); NewY = FormatNumber(measurement.Y);
if (measurement.HasArea)
{
NewWidth = FormatNumber(measurement.Width); NewHeight = FormatNumber(measurement.Height);
SetStatus($"Bereich übernommen: x={NewX}, y={NewY}, b={NewWidth}, h={NewHeight} {OverlayPageUnit}.", false);
}
else SetStatus($"Koordinate übernommen: x={NewX}, y={NewY} {OverlayPageUnit}.", false);
}
public void ApplyElementGeometry(OverlayElementGeometry geometry)
{
var layout = new LayoutParser().Parse(LayoutSource);
var element = layout.Elements.FirstOrDefault(x => x.Line == geometry.Line)
?? throw new InvalidDataException($"Element in Zeile {geometry.Line} wurde nicht gefunden.");
var lines = LayoutSource.Replace("\r\n", "\n", StringComparison.Ordinal).Split('\n').ToList();
var index = geometry.Line - 1;
if (index < 0 || index >= lines.Count) throw new InvalidDataException("Elementzeile liegt außerhalb des Layouts.");
var x = FormatNumber(geometry.X); var y = FormatNumber(geometry.Y);
var width = geometry.Width; var height = geometry.Height;
lines[index] = element switch
{
ImageElement image => BuildImageLine(image, x, y, width, height),
TextElement text => $"TEXT {x} {y} {Content(text.Content, text.Placeholder, text.Format)}{Attributes(text.Attributes)}",
TextBoxElement box => $"TEXTBOX {x} {y} {FormatNumber(width)} {FormatNumber(height)} "
+ $"{Content(box.Content, box.Placeholder, box.Format)}{Attributes(box.Attributes)}",
TableElement table => $"TABLE {x} {y} {FormatNumber(width)} {FormatNumber(height)} "
+ $"${table.Placeholder}{Attributes(table.Attributes)}",
ChartElement chart => $"CHART {x} {y} {FormatNumber(width)} {FormatNumber(height)} "
+ $"${chart.Placeholder}{Attributes(chart.Attributes)}",
_ => lines[index],
};
LayoutSource = string.Join('\n', lines); CanExport = false;
SelectedOverlayElement = $"{geometry.Keyword} · Zeile {geometry.Line}";
SetStatus($"{geometry.Keyword} verschoben/skalisiert. PDF-Vorschau wird aktualisiert.", false);
}
partial void OnLayoutSourceChanged(string value)
{
RefreshAssetUsage();
try
{
var page = new LayoutParser().Parse(value);
OverlayPageWidth = page.Width; OverlayPageHeight = page.Height; OverlayPageUnit = page.Unit;
}
catch (TemplateValidationException) { }
}
private DesignerAsset AddOrReplaceAsset(string sourceName, byte[] bytes, bool keepName) private DesignerAsset AddOrReplaceAsset(string sourceName, byte[] bytes, bool keepName)
{ {
@@ -253,6 +308,19 @@ public partial class DesignerViewModel : ObservableObject
return value.ToString("0.###", CultureInfo.InvariantCulture); return value.ToString("0.###", CultureInfo.InvariantCulture);
} }
private static string BuildImageLine(ImageElement image, string x, string y, double effectiveWidth, double effectiveHeight)
{
var scale = image.Attributes.TryGetValue("scale", out var raw) ? LayoutParser.Percentage(raw) : 1f;
return $"IMG {image.Path} {x} {y} {FormatNumber(effectiveWidth / scale)} "
+ $"{FormatNumber(effectiveHeight / scale)}{Attributes(image.Attributes)}";
}
private static string Content(string original, string? placeholder, string? format) => placeholder is null
? $"\"{original.Replace("\"", "\\\"")}\""
: $"${placeholder}{(format is null ? "" : "|" + format)}";
private static string Attributes(IReadOnlyDictionary<string, string> attributes) => attributes.Count == 0
? "" : " " + string.Join(' ', attributes.Select(x => $"{x.Key}={x.Value}"));
private static string FormatNumber(double value) => value.ToString("0.###", CultureInfo.InvariantCulture);
public void SetStatus(string text, bool error) public void SetStatus(string text, bool error)
{ Status = text; StatusColor = error ? "#B91C1C" : "#166534"; } { Status = text; StatusColor = error ? "#B91C1C" : "#166534"; }
private static string QuoteIfLiteral(string value) => value.StartsWith('$') ? value : $"\"{value.Replace("\"", "\\\"")}\""; private static string QuoteIfLiteral(string value) => value.StartsWith('$') ? value : $"\"{value.Replace("\"", "\\\"")}\"";
@@ -0,0 +1,276 @@
using Avalonia;
using Avalonia.Controls;
using Avalonia.Input;
using Avalonia.Media;
using Avalonia.Media.Imaging;
using LehrerApp.Templating;
namespace LehrerApp.TemplateDesigner;
public enum OverlayEditorMode { Measure, Edit }
public sealed record OverlayMeasurement(double X, double Y, double Width, double Height, bool HasArea);
public sealed record OverlayElementGeometry(int Line, string Keyword, double X, double Y, double Width, double Height);
public sealed class LayoutOverlayEditor : Control
{
public static readonly StyledProperty<Bitmap?> PreviewImageProperty =
AvaloniaProperty.Register<LayoutOverlayEditor, Bitmap?>(nameof(PreviewImage));
public static readonly StyledProperty<string> LayoutSourceProperty =
AvaloniaProperty.Register<LayoutOverlayEditor, string>(nameof(LayoutSource), "");
public static readonly StyledProperty<double> PageWidthProperty =
AvaloniaProperty.Register<LayoutOverlayEditor, double>(nameof(PageWidth), 210);
public static readonly StyledProperty<double> PageHeightProperty =
AvaloniaProperty.Register<LayoutOverlayEditor, double>(nameof(PageHeight), 297);
public static readonly StyledProperty<string> PageUnitProperty =
AvaloniaProperty.Register<LayoutOverlayEditor, string>(nameof(PageUnit), "mm");
public static readonly StyledProperty<OverlayEditorMode> ModeProperty =
AvaloniaProperty.Register<LayoutOverlayEditor, OverlayEditorMode>(nameof(Mode), OverlayEditorMode.Measure);
public static readonly StyledProperty<bool> SnapToGridProperty =
AvaloniaProperty.Register<LayoutOverlayEditor, bool>(nameof(SnapToGrid), true);
public static readonly StyledProperty<double> GridSizeProperty =
AvaloniaProperty.Register<LayoutOverlayEditor, double>(nameof(GridSize), 1);
private readonly Pen _normalPen = new(new SolidColorBrush(Color.Parse("#2563EB")), 1.5);
private readonly Pen _selectedPen = new(new SolidColorBrush(Color.Parse("#DC2626")), 2.5);
private readonly Pen _measurePen = new(new SolidColorBrush(Color.Parse("#D97706")), 2);
private readonly Pen _gridPen = new(new SolidColorBrush(Color.FromArgb(55, 37, 99, 235)), 1);
private readonly IBrush _normalFill = new SolidColorBrush(Color.FromArgb(30, 37, 99, 235));
private readonly IBrush _selectedFill = new SolidColorBrush(Color.FromArgb(35, 220, 38, 38));
private readonly IBrush _handleFill = new SolidColorBrush(Color.Parse("#DC2626"));
private readonly List<OverlayItem> _items = [];
private OverlayItem? _selected;
private Point? _pointerStartDsl;
private Rect? _workingRectDsl;
private DragKind _dragKind;
public Bitmap? PreviewImage { get => GetValue(PreviewImageProperty); set => SetValue(PreviewImageProperty, value); }
public string LayoutSource { get => GetValue(LayoutSourceProperty); set => SetValue(LayoutSourceProperty, value); }
public double PageWidth { get => GetValue(PageWidthProperty); set => SetValue(PageWidthProperty, value); }
public double PageHeight { get => GetValue(PageHeightProperty); set => SetValue(PageHeightProperty, value); }
public string PageUnit { get => GetValue(PageUnitProperty); set => SetValue(PageUnitProperty, value); }
public OverlayEditorMode Mode { get => GetValue(ModeProperty); set => SetValue(ModeProperty, value); }
public bool SnapToGrid { get => GetValue(SnapToGridProperty); set => SetValue(SnapToGridProperty, value); }
public double GridSize { get => GetValue(GridSizeProperty); set => SetValue(GridSizeProperty, value); }
public event EventHandler<OverlayMeasurement>? MeasurementCompleted;
public event EventHandler<OverlayElementGeometry>? ElementGeometryChanged;
public event EventHandler<string>? CursorCoordinatesChanged;
public event EventHandler<string>? ElementSelected;
static LayoutOverlayEditor()
{
AffectsRender<LayoutOverlayEditor>(PreviewImageProperty, LayoutSourceProperty, PageWidthProperty,
PageHeightProperty, PageUnitProperty, ModeProperty, SnapToGridProperty, GridSizeProperty);
}
public LayoutOverlayEditor() { Focusable = true; ClipToBounds = true; }
public override void Render(DrawingContext context)
{
base.Render(context);
var page = PageRect();
context.FillRectangle(Brushes.White, page);
if (PreviewImage is { } image)
context.DrawImage(image, new Rect(image.Size), page);
DrawGrid(context, page);
RefreshItems();
foreach (var item in _items)
{
var geometry = ReferenceEquals(item, _selected) && _workingRectDsl is { } working ? working : item.Rect;
var rect = ToControl(geometry, page);
var selected = ReferenceEquals(item, _selected);
context.DrawRectangle(selected ? _selectedFill : _normalFill, selected ? _selectedPen : _normalPen,
rect, 2, 2);
if (selected && item.Resizable)
context.FillRectangle(_handleFill, new Rect(rect.Right - 6, rect.Bottom - 6, 12, 12), 2);
}
if (Mode == OverlayEditorMode.Measure && _workingRectDsl is { } measurement)
context.DrawRectangle(null, _measurePen, ToControl(measurement, page), 2, 2);
}
protected override void OnPointerPressed(PointerPressedEventArgs e)
{
base.OnPointerPressed(e);
var point = e.GetPosition(this);
if (!PageRect().Contains(point)) return;
Focus(); e.Pointer.Capture(this);
var dsl = Snap(ToDsl(point));
_pointerStartDsl = dsl;
if (Mode == OverlayEditorMode.Measure)
{
_selected = null; _dragKind = DragKind.Measure;
_workingRectDsl = new Rect(dsl, dsl); InvalidateVisual(); return;
}
RefreshItems();
_selected = _items.LastOrDefault(item => ToControl(item.Rect, PageRect()).Inflate(4).Contains(point));
if (_selected is null) { _workingRectDsl = null; InvalidateVisual(); return; }
_workingRectDsl = _selected.Rect;
var selectedControl = ToControl(_selected.Rect, PageRect());
_dragKind = _selected.Resizable && new Point(selectedControl.Right, selectedControl.Bottom).Distance(point) <= 14
? DragKind.Resize : DragKind.Move;
ElementSelected?.Invoke(this, $"{_selected.Keyword} · Zeile {_selected.Line}");
InvalidateVisual();
}
protected override void OnPointerMoved(PointerEventArgs e)
{
base.OnPointerMoved(e);
var point = e.GetPosition(this); var page = PageRect();
if (page.Contains(point))
{
var coordinate = ToDsl(point);
CursorCoordinatesChanged?.Invoke(this,
$"x={coordinate.X:0.##} · y={coordinate.Y:0.##} {PageUnit}");
}
if (_pointerStartDsl is not { } start || _dragKind == DragKind.None) return;
var current = Snap(ToDsl(Clamp(point, page)));
if (_dragKind == DragKind.Measure)
_workingRectDsl = Normalize(start, current);
else if (_selected is not null)
{
var source = _selected.Rect;
if (_dragKind == DragKind.Move)
{
var x = Math.Clamp(source.X + current.X - start.X, 0, Math.Max(0, PageWidth - source.Width));
var y = Math.Clamp(source.Y + current.Y - start.Y, 0, Math.Max(0, PageHeight - source.Height));
_workingRectDsl = new Rect(x, y, source.Width, source.Height);
}
else
{
var width = Math.Max(GridStep(), source.Width + current.X - start.X);
var height = Math.Max(GridStep(), source.Height + current.Y - start.Y);
_workingRectDsl = new Rect(source.X, source.Y,
Math.Min(width, PageWidth - source.X), Math.Min(height, PageHeight - source.Y));
}
}
InvalidateVisual();
}
protected override void OnPointerReleased(PointerReleasedEventArgs e)
{
base.OnPointerReleased(e); e.Pointer.Capture(null);
if (_workingRectDsl is { } result)
{
if (_dragKind == DragKind.Measure)
{
var hasArea = result.Width >= GridStep() / 2 && result.Height >= GridStep() / 2;
MeasurementCompleted?.Invoke(this, new(result.X, result.Y, result.Width, result.Height, hasArea));
}
else if (_selected is not null)
ElementGeometryChanged?.Invoke(this, new(_selected.Line, _selected.Keyword,
result.X, result.Y, result.Width, result.Height));
}
_pointerStartDsl = null; _dragKind = DragKind.None;
if (Mode == OverlayEditorMode.Edit) _workingRectDsl = null;
InvalidateVisual();
}
protected override void OnKeyDown(KeyEventArgs e)
{
base.OnKeyDown(e);
if (Mode != OverlayEditorMode.Edit || _selected is null) return;
var step = GridStep(); var dx = 0d; var dy = 0d;
switch (e.Key)
{ case Key.Left: dx = -step; break; case Key.Right: dx = step; break;
case Key.Up: dy = -step; break; case Key.Down: dy = step; break; default: return; }
var source = _selected.Rect;
var rect = new Rect(Math.Clamp(source.X + dx, 0, Math.Max(0, PageWidth - source.Width)),
Math.Clamp(source.Y + dy, 0, Math.Max(0, PageHeight - source.Height)), source.Width, source.Height);
ElementGeometryChanged?.Invoke(this, new(_selected.Line, _selected.Keyword, rect.X, rect.Y, rect.Width, rect.Height));
e.Handled = true;
}
private void RefreshItems()
{
_items.Clear();
try
{
var layout = new LayoutParser().Parse(LayoutSource);
foreach (var element in layout.Elements)
{
if (element is BackgroundElement) continue;
var keyword = element switch
{ ImageElement => "IMG", TextElement => "TEXT", TextBoxElement => "TEXTBOX",
TableElement => "TABLE", ChartElement => "CHART", _ => "?" };
var (width, height, resizable) = element switch
{
TextElement text => (Math.Min(70d, Math.Max(5, PageWidth - text.X)),
Math.Max(3, PointsToUnit(ParseSize(text.Attributes) * 1.8)), false),
ImageElement image => (image.Width * ImageScale(image), image.Height * ImageScale(image), true),
_ => ((double)element.Width, element.Height, true),
};
_items.Add(new(element.Line, keyword, new Rect(element.X, element.Y, width, height), resizable));
}
if (_selected is not null)
_selected = _items.FirstOrDefault(x => x.Line == _selected.Line);
}
catch (TemplateValidationException) { _selected = null; }
}
private void DrawGrid(DrawingContext context, Rect page)
{
if (!SnapToGrid || GridSize <= 0) return;
var xStep = page.Width * GridSize / PageWidth; var yStep = page.Height * GridSize / PageHeight;
if (xStep < 5 || yStep < 5) return;
for (var x = page.X + xStep; x < page.Right; x += xStep)
context.DrawLine(_gridPen, new(x, page.Y), new(x, page.Bottom));
for (var y = page.Y + yStep; y < page.Bottom; y += yStep)
context.DrawLine(_gridPen, new(page.X, y), new(page.Right, y));
}
private Rect PageRect()
{
return OverlayCoordinateMapper.PageRect(Bounds.Size, PageWidth, PageHeight);
}
private Rect ToControl(Rect rect, Rect page) => OverlayCoordinateMapper.ToControl(rect, page, PageWidth, PageHeight);
private Point ToDsl(Point point) => OverlayCoordinateMapper.ToDsl(point, PageRect(), PageWidth, PageHeight);
private Point Snap(Point point)
{
if (!SnapToGrid) return point;
var step = GridStep(); return new(Math.Round(point.X / step) * step, Math.Round(point.Y / step) * step);
}
private double GridStep() => SnapToGrid && GridSize > 0 ? GridSize : 0.5;
private static Rect Normalize(Point first, Point second) => new(Math.Min(first.X, second.X), Math.Min(first.Y, second.Y),
Math.Abs(second.X - first.X), Math.Abs(second.Y - first.Y));
private static Point Clamp(Point point, Rect rect) => new(Math.Clamp(point.X, rect.X, rect.Right), Math.Clamp(point.Y, rect.Y, rect.Bottom));
private static float ParseSize(IReadOnlyDictionary<string, string> attributes) =>
attributes.TryGetValue("size", out var raw) && float.TryParse(raw,
System.Globalization.NumberStyles.Float, System.Globalization.CultureInfo.InvariantCulture, out var size) ? size : 11;
private static float ImageScale(ImageElement image) => image.Attributes.TryGetValue("scale", out var raw)
? LayoutParser.Percentage(raw) : 1;
private double PointsToUnit(double points) => PageUnit.ToLowerInvariant() switch
{ "mm" => points * 25.4 / 72, "cm" => points * 2.54 / 72, "in" => points / 72, _ => points };
private sealed record OverlayItem(int Line, string Keyword, Rect Rect, bool Resizable);
private enum DragKind { None, Measure, Move, Resize }
}
public static class OverlayCoordinateMapper
{
public static Rect PageRect(Size viewport, double pageWidth, double pageHeight)
{
if (pageWidth <= 0 || pageHeight <= 0 || viewport.Width <= 0 || viewport.Height <= 0)
return new Rect(viewport);
var scale = Math.Min(viewport.Width / pageWidth, viewport.Height / pageHeight);
var width = pageWidth * scale; var height = pageHeight * scale;
return new((viewport.Width - width) / 2, (viewport.Height - height) / 2, width, height);
}
public static Point ToDsl(Point point, Rect displayedPage, double pageWidth, double pageHeight) =>
new((point.X - displayedPage.X) / displayedPage.Width * pageWidth,
(point.Y - displayedPage.Y) / displayedPage.Height * pageHeight);
public static Rect ToControl(Rect dslRect, Rect displayedPage, double pageWidth, double pageHeight) =>
new(displayedPage.X + dslRect.X / pageWidth * displayedPage.Width,
displayedPage.Y + dslRect.Y / pageHeight * displayedPage.Height,
dslRect.Width / pageWidth * displayedPage.Width,
dslRect.Height / pageHeight * displayedPage.Height);
}
internal static class PointDistanceExtensions
{
public static double Distance(this Point point, Point other) =>
Math.Sqrt(Math.Pow(point.X - other.X, 2) + Math.Pow(point.Y - other.Y, 2));
}
+32 -8
View File
@@ -114,15 +114,39 @@
</Grid> </Grid>
<Border Grid.Column="2" Background="#E2E8F0" Padding="18"> <Border Grid.Column="2" Background="#E2E8F0" Padding="18">
<Grid RowDefinitions="Auto,*,Auto"> <Grid RowDefinitions="Auto,Auto,*,Auto">
<TextBlock Text="PDF-Vorschau" Classes="section"/> <Grid ColumnDefinitions="*,Auto">
<Border Grid.Row="1" Margin="0,12" Background="White" BorderBrush="#94A3B8" BorderThickness="1"> <TextBlock Text="Visueller Layout-Editor" Classes="section"/>
<ScrollViewer HorizontalScrollBarVisibility="Auto" VerticalScrollBarVisibility="Auto"> <TextBlock Grid.Column="1" Text="{Binding OverlayCoordinates}" FontFamily="Monospace"
<Image Source="{Binding PreviewImage}" Stretch="Uniform" MaxWidth="740"/> FontSize="11" VerticalAlignment="Center"/>
</ScrollViewer> </Grid>
<StackPanel Grid.Row="1" Spacing="7" Margin="0,10,0,0">
<Grid ColumnDefinitions="*,8,*">
<Button Grid.Column="0" Content="Koordinaten messen" Click="OnMeasureOverlayMode"/>
<Button Grid.Column="2" Content="Elemente verschieben" Click="OnEditOverlayMode"/>
</Grid>
<Grid ColumnDefinitions="Auto,8,90,*">
<CheckBox Content="Rasterfang" IsChecked="{Binding SnapOverlayToGrid}" VerticalAlignment="Center"/>
<NumericUpDown Grid.Column="2" Value="{Binding OverlayGridSize}" Minimum="0.1" Maximum="50"
Increment="0.5" FormatString="0.##"/>
<TextBlock Grid.Column="3" Margin="8,0,0,0" Text="{Binding OverlayPageUnit}" VerticalAlignment="Center"/>
</Grid>
<TextBlock Text="{Binding SelectedOverlayElement}" FontSize="11" Opacity="0.7"/>
</StackPanel>
<Border Grid.Row="2" Margin="0,12" Background="#CBD5E1" BorderBrush="#94A3B8" BorderThickness="1">
<local:LayoutOverlayEditor PreviewImage="{Binding PreviewImage}"
LayoutSource="{Binding LayoutSource, Mode=TwoWay}"
PageWidth="{Binding OverlayPageWidth}" PageHeight="{Binding OverlayPageHeight}"
PageUnit="{Binding OverlayPageUnit}" Mode="{Binding OverlayMode}"
SnapToGrid="{Binding SnapOverlayToGrid}" GridSize="{Binding OverlayGridSize}"
MeasurementCompleted="OnOverlayMeasurementCompleted"
ElementGeometryChanged="OnOverlayElementGeometryChanged"
CursorCoordinatesChanged="OnOverlayCursorCoordinatesChanged"
ElementSelected="OnOverlayElementSelected"/>
</Border> </Border>
<StackPanel Grid.Row="2" Spacing="4"><TextBlock Text="{Binding Status}" TextWrapping="Wrap" Foreground="{Binding StatusColor}"/> <StackPanel Grid.Row="3" Spacing="4"><TextBlock Text="{Binding Status}" TextWrapping="Wrap" Foreground="{Binding StatusColor}"/>
<TextBlock Text="Die Vorschau wird mit derselben QuestPDF-Pipeline wie in LehrerApp erzeugt." FontSize="11" Opacity="0.65" TextWrapping="Wrap"/></StackPanel> <TextBlock Text="Messen: klicken oder Bereich aufziehen. Bearbeiten: Rahmen ziehen, unten rechts skalieren; Pfeiltasten verschieben."
FontSize="11" Opacity="0.65" TextWrapping="Wrap"/></StackPanel>
</Grid> </Grid>
</Border> </Border>
</Grid> </Grid>
@@ -29,6 +29,21 @@ public partial class MainWindow : Window
} }
private void OnAddElement(object? sender, RoutedEventArgs e) private void OnAddElement(object? sender, RoutedEventArgs e)
{ try { _viewModel.AddElement(); } catch (Exception ex) { _viewModel.SetStatus(ex.Message, true); } } { try { _viewModel.AddElement(); } catch (Exception ex) { _viewModel.SetStatus(ex.Message, true); } }
private void OnMeasureOverlayMode(object? sender, RoutedEventArgs e)
{ _viewModel.OverlayMode = OverlayEditorMode.Measure; _viewModel.SelectedOverlayElement = "Messmodus aktiv"; }
private void OnEditOverlayMode(object? sender, RoutedEventArgs e)
{ _viewModel.OverlayMode = OverlayEditorMode.Edit; _viewModel.SelectedOverlayElement = "Elementmodus aktiv"; }
private void OnOverlayMeasurementCompleted(object? sender, OverlayMeasurement measurement) =>
_viewModel.ApplyMeasurement(measurement);
private void OnOverlayCursorCoordinatesChanged(object? sender, string coordinates) =>
_viewModel.OverlayCoordinates = coordinates;
private void OnOverlayElementSelected(object? sender, string element) =>
_viewModel.SelectedOverlayElement = element;
private void OnOverlayElementGeometryChanged(object? sender, OverlayElementGeometry geometry)
{
try { _viewModel.ApplyElementGeometry(geometry); OnPreview(sender, new RoutedEventArgs()); }
catch (Exception ex) { _viewModel.SetStatus($"Elementänderung fehlgeschlagen: {ex.Message}", true); }
}
private void OnInsertSelectedAsset(object? sender, RoutedEventArgs e) private void OnInsertSelectedAsset(object? sender, RoutedEventArgs e)
{ try { _viewModel.InsertSelectedAssetAsImage(); } catch (Exception ex) { _viewModel.SetStatus(ex.Message, true); } } { try { _viewModel.InsertSelectedAssetAsImage(); } catch (Exception ex) { _viewModel.SetStatus(ex.Message, true); } }
private void OnRemoveSelectedAsset(object? sender, RoutedEventArgs e) private void OnRemoveSelectedAsset(object? sender, RoutedEventArgs e)
+1 -1
View File
@@ -99,7 +99,7 @@ public sealed class LayoutParser
private static float Number(string value) => float.TryParse(value, NumberStyles.Float, private static float Number(string value) => float.TryParse(value, NumberStyles.Float,
CultureInfo.InvariantCulture, out var result) ? result : throw new FormatException($"„{value}“ ist keine Zahl."); CultureInfo.InvariantCulture, out var result) ? result : throw new FormatException($"„{value}“ ist keine Zahl.");
internal static float Percentage(string value) public static float Percentage(string value)
{ {
var normalized = value.EndsWith('%') ? value[..^1] : value; var normalized = value.EndsWith('%') ? value[..^1] : value;
var result = Number(normalized); var result = Number(normalized);
+17
View File
@@ -25,3 +25,20 @@ ist weiterhin ein normales `.lavorlage`-Paket und kann deshalb importiert oder e
Die Bibliothek liegt unter `LehrerApp/TemplateDesigner/starter-templates` im plattformspezifischen Die Bibliothek liegt unter `LehrerApp/TemplateDesigner/starter-templates` im plattformspezifischen
Anwendungsdatenverzeichnis und wird nicht in das LehrerApp-Repository oder Release eingebettet. Anwendungsdatenverzeichnis und wird nicht in das LehrerApp-Repository oder Release eingebettet.
## Visueller Koordinateneditor
Die QuestPDF-Vorschau dient gleichzeitig als maßstabsgetreue Zeichenfläche. Der Designer bildet
das tatsächlich sichtbare Seitenrechteck unabhängig von Zoom und freien Rändern auf die `PAGE`-
Koordinaten ab:
```text
dslX = (mausX - seitenrandLinks) / angezeigteSeitenbreite * pageWidth
dslY = (mausY - seitenrandOben) / angezeigteSeitenhöhe * pageHeight
```
Im Messmodus übernimmt ein Klick `x/y`; ein aufgezogener Bereich übernimmt zusätzlich Breite und
Höhe ins Elementformular. Im Bearbeitungsmodus lassen sich vorhandene Elemente verschieben und -
außer einzeiligem `TEXT` - am rechten unteren Anfasser skalieren. Rasterfang und Pfeiltasten sind
für Feinkorrekturen verfügbar. Änderungen werden in die ursprüngliche DSL-Zeile zurückgeschrieben,
wobei Inhalte, Platzhalter, Formatangaben und Attribute erhalten bleiben.
+14
View File
@@ -2444,6 +2444,20 @@ Buchung ohne Blick darauf. Neuer `TaskCategory.Teaching`-Wert (`TaskCategoryDisp
"Unterricht") ans Ende des Enums angehängt, um bestehende serialisierte Werte nicht zu "Unterricht") ans Ende des Enums angehängt, um bestehende serialisierte Werte nicht zu
verschieben. verschieben.
**Nachtrag — Dashboard-Erinnerung "Unterrichtszeit nacherfassen" (Nutzer-Feedback):** "Ich vergesse
es, und fasse die App außerhalb der Schule manchmal nicht mehr an" — der Vorschlag oben deckte nur
den heutigen Tag ab und nur, solange man die Arbeitszeit-Ansicht überhaupt öffnet. Neue
Dashboard-Karte (`DashboardViewModel.LoadMissingTeachingTime`) schaut die letzten 14 Tage zurück
und listet jeden Schultag, an dem laut Stundenplan (alle Gruppen, wie beim bestehenden Vorschlag)
Unterricht stattfand, aber noch kein `TimeEntry` der Kategorie "Unterricht" existiert — Ferien,
Feiertage und per `SubstitutionKind.Cancelled` komplett ausgefallene Tage zählen nicht mit
(gleiche Prüfung wie bei "Ungeplante Stunden", 9.x). Für den heutigen Tag erscheint der Eintrag
bewusst erst 30 Minuten nach dem laut Stundenplan letzten Unterrichtsende
(`MissingTeachingTimeTodayDelayMinutes`), damit die Erinnerung nicht schon mitten im Unterricht
auftaucht. Klick auf "Erfassen" öffnet denselben `AddTimeEntryDialog` wie der bestehende
Tages-Vorschlag, vorbelegt mit Datum, Kategorie "Unterricht" und dem für diesen Tag berechneten
Zeitfenster — auch hier bleibt die Bestätigung im Dialog Pflicht, nichts wird automatisch gebucht.
### 6.3 Auswertung ### 6.3 Auswertung
- [x] **6.3.1** Monats-/Jahresauswertung nach Kategorie und Gruppe (Diagramm + Tabelle) — - [x] **6.3.1** Monats-/Jahresauswertung nach Kategorie und Gruppe (Diagramm + Tabelle) —
dritter Tab "Auswertung" (`WorkloadEvaluationViewModel`). Zeitraum wahlweise Monat dritter Tab "Auswertung" (`WorkloadEvaluationViewModel`). Zeitraum wahlweise Monat