Jahresplanimport und Ablgeich von Untis

This commit is contained in:
2026-08-23 19:23:05 +02:00
parent 4a59bff9de
commit dd5ecb0334
20 changed files with 1039 additions and 20 deletions
@@ -3,6 +3,7 @@ using CommunityToolkit.Mvvm.Input;
using LehrerApp.Core.Interfaces;
using LehrerApp.Core.Models;
using LehrerApp.Core.Services;
using LehrerApp.Desktop.Services;
using System.Collections.ObjectModel;
using System.Globalization;
@@ -34,6 +35,7 @@ public partial class DashboardViewModel : ObservableObject
private readonly PublicHolidayService _publicHolidays;
private readonly SchoolCalendarSettingsService _calendarSettings;
private readonly ISubstitutionEntryRepository _substitutions;
private readonly IAnnualPlanEventRepository? _annualPlanEvents;
private const int OpenExcuseMaxAgeDays = 21;
private const int SupportPlanDueWithinDays = 14;
@@ -108,7 +110,8 @@ public partial class DashboardViewModel : ObservableObject
PeriodScheduleService periodSchedule, AttendanceBalanceService attendanceBalance, SchoolYearService sy,
DashboardSettingsService dashboardSettings, ISchoolHolidayRepository schoolHolidays,
PublicHolidayService publicHolidays, SchoolCalendarSettingsService calendarSettings,
ISubstitutionEntryRepository substitutions)
ISubstitutionEntryRepository substitutions, IAnnualPlanEventRepository? annualPlanEvents = null,
AnnualPlanSyncService? annualPlanSync = null)
{
_groups = groups; _subjects = subjects; _lessons = lessons; _exams = exams; _tasks = tasks;
_examResults = examResults; _grades = grades; _reportGrades = reportGrades; _memberships = memberships;
@@ -118,6 +121,12 @@ public partial class DashboardViewModel : ObservableObject
_attendanceBalance = attendanceBalance; _sy = sy; _dashboardSettings = dashboardSettings;
_schoolHolidays = schoolHolidays; _publicHolidays = publicHolidays; _calendarSettings = calendarSettings;
_substitutions = substitutions;
_annualPlanEvents = annualPlanEvents;
if (annualPlanSync is not null)
{
annualPlanSync.DataChanged += () => Avalonia.Threading.Dispatcher.UIThread.Post(LoadCalendar);
_ = annualPlanSync.PollAsync();
}
LoadDashboardCards();
Load();
}
@@ -580,6 +589,27 @@ public partial class DashboardViewModel : ObservableObject
}
}
// Der Jahresplan wird lediglich in dieselbe Anzeigeprojektion eingemischt. Er nimmt an
// keiner Stundenplan-/Vertretungslogik teil; mehrtägige Termine erscheinen an jedem
// betroffenen Kalendertag.
if (_annualPlanEvents is not null)
{
foreach (var annualEvent in _annualPlanEvents.GetByRange(gridStart, gridEnd)
.Where(e => !string.Equals(e.Status, "CANCELLED", StringComparison.OrdinalIgnoreCase)))
{
var visibleStart = annualEvent.StartDate < gridStart ? gridStart : annualEvent.StartDate;
var visibleEnd = annualEvent.EndDate > gridEnd ? gridEnd : annualEvent.EndDate;
for (var date = visibleStart; date <= visibleEnd; date = date.AddDays(1))
{
var agg = Agg(date);
agg.HasAnnualPlanEvent = true;
agg.Details.Add(new CalendarEventItem(CalendarEventKind.AnnualPlan, date,
annualEvent.Title, FormatAnnualPlanSubtitle(annualEvent), null,
annualEvent.Description));
}
}
}
// "Meine Klasse"-Ring: bisher nur gesetzt, wenn für den Tag schon eine Lesson/Exam/Sitzung
// existiert — ein Tag, an dem laut Stundenplan (4.3) eine eigene Klasse ansteht, für den
// aber noch keine Lesson angelegt wurde (z.B. "morgen"), zeigte den Ring fälschlich nicht.
@@ -615,7 +645,7 @@ public partial class DashboardViewModel : ObservableObject
byDay.TryGetValue(date, out var agg);
CalendarDays.Add(new CalendarDayCell(date, date.Month == firstOfMonth.Month, date == today,
agg?.HasLesson ?? false, agg?.HasExam ?? false, agg?.HasSession ?? false,
agg?.IsOwnClassDay ?? false, agg?.Details ?? []));
agg?.HasAnnualPlanEvent ?? false, agg?.IsOwnClassDay ?? false, agg?.Details ?? []));
}
SelectCalendarDay(CalendarDays.FirstOrDefault(d => d.Date == today && d.IsCurrentMonth)
?? CalendarDays.First(d => d.IsCurrentMonth));
@@ -635,8 +665,9 @@ public partial class DashboardViewModel : ObservableObject
private void OpenCalendarEvent(CalendarEventItem? item)
{
if (item is null) return;
if (item.Kind == CalendarEventKind.Exam) OnNavigateToExam?.Invoke(item.GroupId);
else OnNavigateToLesson?.Invoke(item.GroupId); // auch für ParticipationSession: Tab "Mitarbeit"
if (item.GroupId is not { } groupId) return; // Jahresplantermine sind reine Information.
if (item.Kind == CalendarEventKind.Exam) OnNavigateToExam?.Invoke(groupId);
else OnNavigateToLesson?.Invoke(groupId); // auch für ParticipationSession: Tab "Mitarbeit"
}
[RelayCommand]
@@ -699,9 +730,35 @@ public partial class DashboardViewModel : ObservableObject
public bool HasLesson;
public bool HasExam;
public bool HasSession;
public bool HasAnnualPlanEvent;
public bool IsOwnClassDay;
public List<CalendarEventItem> Details { get; } = [];
}
private static string FormatAnnualPlanSubtitle(AnnualPlanEvent entry)
{
string time;
if (entry.IsAllDay)
{
time = entry.StartDate == entry.EndDate
? "Ganztägig"
: $"{entry.StartDate:dd.MM.}{entry.EndDate:dd.MM.yyyy} · ganztägig";
}
else if (entry.StartDate == entry.EndDate)
{
time = entry.EndTime is { } end
? $"{entry.StartTime:HH\\:mm}{end:HH\\:mm}"
: $"{entry.StartTime:HH\\:mm}";
}
else
{
time = $"{entry.StartDate:dd.MM.} {entry.StartTime:HH\\:mm}" +
$"{entry.EndDate:dd.MM.} {entry.EndTime:HH\\:mm}";
}
return string.Join(" · ", new[] { time, entry.CalendarGroup, entry.Location }
.Where(part => !string.IsNullOrWhiteSpace(part)));
}
}
public class LessonItem
@@ -783,12 +840,14 @@ public partial class CalendarDayCell : ObservableObject
public bool HasLesson { get; }
public bool HasExam { get; }
public bool HasSession { get; }
public bool HasAnnualPlanEvent { get; }
public bool IsOwnClassDay { get; }
public string Tooltip { get; }
public IReadOnlyList<CalendarEventItem> Events { get; }
internal CalendarDayCell(DateOnly date, bool isCurrentMonth, bool isToday,
bool hasLesson, bool hasExam, bool hasSession, bool isOwnClassDay, List<CalendarEventItem> details)
bool hasLesson, bool hasExam, bool hasSession, bool hasAnnualPlanEvent,
bool isOwnClassDay, List<CalendarEventItem> details)
{
Date = date;
DayNumber = date.Day;
@@ -797,6 +856,7 @@ public partial class CalendarDayCell : ObservableObject
HasLesson = hasLesson;
HasExam = hasExam;
HasSession = hasSession;
HasAnnualPlanEvent = hasAnnualPlanEvent;
IsOwnClassDay = isOwnClassDay;
Events = details;
Tooltip = details.Count == 0 ? date.ToString("dd.MM.yyyy")
@@ -804,20 +864,22 @@ public partial class CalendarDayCell : ObservableObject
}
}
public enum CalendarEventKind { Lesson, Exam, ParticipationSession }
public enum CalendarEventKind { Lesson, Exam, ParticipationSession, AnnualPlan }
public sealed class CalendarEventItem(CalendarEventKind kind, DateOnly date, string title,
string subtitle, Guid groupId)
string subtitle, Guid? groupId, string description = "")
{
public CalendarEventKind Kind { get; } = kind;
public DateOnly Date { get; } = date;
public string Title { get; } = title;
public string Subtitle { get; } = subtitle;
public Guid GroupId { get; } = groupId;
public Guid? GroupId { get; } = groupId;
public string Description { get; } = description;
public string KindLabel => Kind switch
{
CalendarEventKind.Exam => "Klausur",
CalendarEventKind.ParticipationSession => "Sitzung",
CalendarEventKind.AnnualPlan => "Jahresplan",
_ => "Unterricht"
};
}
@@ -202,6 +202,14 @@ public partial class SettingsViewModel : ObservableObject
[ObservableProperty] private bool _untisFetchBusy;
public Func<Task>? OnReviewUntisMapping { get; set; }
// ── Schulweiter Jahresplan (ClassyPlan-iCal) ─────────────────────────────
[ObservableProperty] private bool _annualPlanIsConfigured;
[ObservableProperty] private string _annualPlanIcalUrlInput = "";
[ObservableProperty] private string _annualPlanUrlError = "";
[ObservableProperty] private string _annualPlanStatusDisplay = "";
[ObservableProperty] private bool _annualPlanFetchBusy;
// ── Synchronisation (Kapitel 10) ──────────────────────────────────────────
[ObservableProperty] private string _syncServerUrl = "";
@@ -281,6 +289,8 @@ public partial class SettingsViewModel : ObservableObject
private readonly AiPlanningService _aiPlanning;
private readonly WebUntisSettingsService _untisSettings;
private readonly UntisSyncService? _untisSync;
private readonly AnnualPlanSettingsService _annualPlanSettings;
private readonly AnnualPlanSyncService? _annualPlanSync;
private readonly SyncSettingsService _syncSettings;
private readonly SyncAuthService _syncAuth;
private readonly EventQueue _eventQueue;
@@ -303,11 +313,12 @@ public partial class SettingsViewModel : ObservableObject
ISupervisionDutyRepository supervisionDuties, LetterTemplateService letterTemplates,
AiSettingsService aiSettings, AiPlanningService aiPlanning,
WebUntisSettingsService untisSettings,
AnnualPlanSettingsService annualPlanSettings,
SyncSettingsService syncSettings, SyncAuthService syncAuth, EventQueue eventQueue,
AppLogger logger, SyncKeyStatus syncKeyStatus, SyncKeyRecoveryService syncKeyRecovery,
AppearanceSettingsService appearance, TrashViewModel trashTab,
SnapshotService? snapshotService = null, SyncEngine? syncEngine = null,
UntisSyncService? untisSync = null)
UntisSyncService? untisSync = null, AnnualPlanSyncService? annualPlanSync = null)
{
_logger = logger;
_syncKeyRecovery = syncKeyRecovery;
@@ -337,6 +348,8 @@ public partial class SettingsViewModel : ObservableObject
_aiPlanning = aiPlanning;
_untisSettings = untisSettings;
_untisSync = untisSync;
_annualPlanSettings = annualPlanSettings;
_annualPlanSync = annualPlanSync;
_syncSettings = syncSettings;
_syncAuth = syncAuth;
_eventQueue = eventQueue;
@@ -360,6 +373,7 @@ public partial class SettingsViewModel : ObservableObject
LoadLetterTemplates();
LoadAiSettings();
LoadUntisSettings();
LoadAnnualPlanSettings();
LoadSyncSettings();
LoadSyncConflicts();
}
@@ -557,6 +571,68 @@ public partial class SettingsViewModel : ObservableObject
if (OnReviewUntisMapping is not null) await OnReviewUntisMapping();
}
// ── Schulweiter Jahresplan: Laden / Speichern / Entfernen / Jetzt abrufen ─
private void LoadAnnualPlanSettings()
{
AnnualPlanIsConfigured = _annualPlanSettings.IsConfigured;
AnnualPlanStatusDisplay = _annualPlanSettings.LastSyncAt is { } at
? $"Letzter Abgleich: {at.ToLocalTime():dd.MM.yyyy HH:mm} — {_annualPlanSettings.LastSyncStatus}"
: "Noch kein Abgleich durchgeführt.";
}
[RelayCommand]
private void AnnualPlanSaveUrl()
{
AnnualPlanUrlError = "";
if (string.IsNullOrWhiteSpace(AnnualPlanIcalUrlInput))
{
AnnualPlanUrlError = "iCal-URL erforderlich.";
return;
}
if (!Uri.TryCreate(AnnualPlanIcalUrlInput, UriKind.Absolute, out var uri) ||
uri.Scheme is not ("http" or "https"))
{
AnnualPlanUrlError = "Ungültige HTTP(S)-URL.";
return;
}
_annualPlanSettings.SetIcalUrl(AnnualPlanIcalUrlInput.Trim());
_annualPlanSettings.SetEnabled(true);
AnnualPlanIcalUrlInput = "";
AppBootstrapper.RestartApplication();
}
[RelayCommand]
private void AnnualPlanRemove()
{
_annualPlanSync?.Clear();
_annualPlanSettings.ClearIcalUrl();
LoadAnnualPlanSettings();
AppBootstrapper.RestartApplication();
}
[RelayCommand]
private async Task AnnualPlanFetchNow()
{
if (_annualPlanSync is null)
{
AnnualPlanStatusDisplay = "Abgleich nicht aktiv — App neu starten.";
return;
}
AnnualPlanFetchBusy = true;
try
{
await _annualPlanSync.PollAsync();
LoadAnnualPlanSettings();
}
finally
{
AnnualPlanFetchBusy = false;
}
}
// ── Synchronisation: Laden / Anmelden / Abmelden / Verbindungstest ───────
//
// Server-URL, Zugangsdaten und Token werden erst nach erfolgreichem Login zusammen