Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
23abe7af63 | ||
|
|
3f71f7f371 |
@@ -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()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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));
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user