Jahresplanimport und Ablgeich von Untis
This commit is contained in:
@@ -167,6 +167,7 @@ public static class AppBootstrapper
|
||||
services.AddSingleton<ISubstitutionEntryRepository, SubstitutionEntryRepository>();
|
||||
services.AddSingleton<IUntisSnapshotRepository, UntisSnapshotRepository>();
|
||||
services.AddSingleton<IUntisSlotMappingRepository, UntisSlotMappingRepository>();
|
||||
services.AddSingleton<IAnnualPlanEventRepository, AnnualPlanEventRepository>();
|
||||
|
||||
// ── Services ──────────────────────────────────────────────────────────
|
||||
services.AddSingleton<GradingService>();
|
||||
@@ -206,6 +207,17 @@ public static class AppBootstrapper
|
||||
sp.GetRequiredService<AppLogger>()));
|
||||
}
|
||||
|
||||
// ── Schulweiter Jahresplan (informativer ClassyPlan-iCal, kein Stundenplan-Abgleich) ──
|
||||
var annualPlanSettings = new AnnualPlanSettingsService(appData);
|
||||
services.AddSingleton(annualPlanSettings);
|
||||
if (annualPlanSettings.Enabled && !string.IsNullOrEmpty(annualPlanSettings.GetIcalUrl()))
|
||||
{
|
||||
services.AddSingleton(sp => new AnnualPlanSyncService(
|
||||
new HttpClient(), annualPlanSettings,
|
||||
sp.GetRequiredService<IAnnualPlanEventRepository>(),
|
||||
sp.GetRequiredService<AppLogger>()));
|
||||
}
|
||||
|
||||
// ── Sync (optional – nur wenn Server konfiguriert) ────────────────────
|
||||
var syncSettings = new SyncSettingsService(appData);
|
||||
services.AddSingleton(syncSettings);
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
using System.Text.Json;
|
||||
using LehrerApp.Sync.Crypto;
|
||||
|
||||
namespace LehrerApp.Desktop.Services;
|
||||
|
||||
internal sealed class AnnualPlanSettingsConfig
|
||||
{
|
||||
public bool Enabled { get; set; }
|
||||
public string? EncryptedIcalUrl { get; set; }
|
||||
public DateTime? LastSyncAt { get; set; }
|
||||
public string LastSyncStatus { get; set; } = "";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gerätebezogene Einstellungen für den externen Schuljahresplan. Der eingebettete Feed-Schlüssel
|
||||
/// wird wie die WebUntis-URL verschlüsselt in einer separaten Datei gespeichert.
|
||||
/// </summary>
|
||||
public sealed class AnnualPlanSettingsService
|
||||
{
|
||||
private readonly string _configPath;
|
||||
private readonly byte[] _urlKey;
|
||||
private AnnualPlanSettingsConfig _config;
|
||||
|
||||
public bool Enabled => _config.Enabled;
|
||||
public bool IsConfigured => _config.EncryptedIcalUrl is not null;
|
||||
public DateTime? LastSyncAt => _config.LastSyncAt;
|
||||
public string LastSyncStatus => _config.LastSyncStatus;
|
||||
|
||||
public AnnualPlanSettingsService(string appDataPath)
|
||||
{
|
||||
_configPath = Path.Combine(appDataPath, "annual-plan-settings.json");
|
||||
var keyPath = Path.Combine(appDataPath, "annual-plan-url.key");
|
||||
_urlKey = SyncCrypto.LoadKey(keyPath) ?? GenerateAndSaveKey(keyPath);
|
||||
_config = Load();
|
||||
}
|
||||
|
||||
public void SetIcalUrl(string url)
|
||||
{
|
||||
_config.EncryptedIcalUrl = SyncCrypto.EncryptObject(url, _urlKey);
|
||||
Save();
|
||||
}
|
||||
|
||||
public string? GetIcalUrl() => _config.EncryptedIcalUrl is null
|
||||
? null
|
||||
: SyncCrypto.DecryptObject<string>(_config.EncryptedIcalUrl, _urlKey);
|
||||
|
||||
public void SetEnabled(bool enabled)
|
||||
{
|
||||
_config.Enabled = enabled;
|
||||
Save();
|
||||
}
|
||||
|
||||
public void ClearIcalUrl()
|
||||
{
|
||||
_config.EncryptedIcalUrl = null;
|
||||
_config.Enabled = false;
|
||||
Save();
|
||||
}
|
||||
|
||||
public void SetLastSync(DateTime at, string status)
|
||||
{
|
||||
_config.LastSyncAt = at;
|
||||
_config.LastSyncStatus = status;
|
||||
Save();
|
||||
}
|
||||
|
||||
private byte[] GenerateAndSaveKey(string keyPath)
|
||||
{
|
||||
var key = SyncCrypto.GenerateKey();
|
||||
SyncCrypto.SaveKey(key, keyPath);
|
||||
return key;
|
||||
}
|
||||
|
||||
private AnnualPlanSettingsConfig Load()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (File.Exists(_configPath))
|
||||
return JsonSerializer.Deserialize<AnnualPlanSettingsConfig>(File.ReadAllText(_configPath))
|
||||
?? new AnnualPlanSettingsConfig();
|
||||
}
|
||||
catch { /* beschädigte lokale Konfiguration -> Standardwerte */ }
|
||||
return new AnnualPlanSettingsConfig();
|
||||
}
|
||||
|
||||
private void Save() => File.WriteAllText(_configPath, JsonSerializer.Serialize(_config));
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
using LehrerApp.Core.Interfaces;
|
||||
using LehrerApp.Core.Models;
|
||||
using LehrerApp.Core.Services;
|
||||
|
||||
namespace LehrerApp.Desktop.Services;
|
||||
|
||||
public sealed record AnnualPlanPollResult(int EventCount, int Added, int Updated, int Deleted)
|
||||
{
|
||||
public int ChangeCount => Added + Updated + Deleted;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Periodischer Vollabgleich des ClassyPlan-Jahresplans. Dieser Dienst hat bewusst keinerlei
|
||||
/// Abhängigkeit zu Stundenplan-, Vertretungs- oder Lerngruppen-Repositories.
|
||||
/// </summary>
|
||||
public sealed class AnnualPlanSyncService : IDisposable
|
||||
{
|
||||
private static readonly TimeSpan PollInterval = TimeSpan.FromHours(6);
|
||||
|
||||
private readonly HttpClient _http;
|
||||
private readonly AnnualPlanSettingsService _settings;
|
||||
private readonly IAnnualPlanEventRepository _events;
|
||||
private readonly AppLogger? _logger;
|
||||
private readonly SemaphoreSlim _gate = new(1, 1);
|
||||
private readonly Timer _timer;
|
||||
|
||||
public event Action? DataChanged;
|
||||
|
||||
public AnnualPlanSyncService(HttpClient http, AnnualPlanSettingsService settings,
|
||||
IAnnualPlanEventRepository events, AppLogger? logger = null)
|
||||
{
|
||||
_http = http;
|
||||
_settings = settings;
|
||||
_events = events;
|
||||
_logger = logger;
|
||||
_timer = new Timer(async _ => await PollAsync(), null, PollInterval, PollInterval);
|
||||
}
|
||||
|
||||
public async Task PollAsync()
|
||||
{
|
||||
if (!await _gate.WaitAsync(0)) return;
|
||||
try
|
||||
{
|
||||
var url = _settings.GetIcalUrl();
|
||||
if (string.IsNullOrWhiteSpace(url)) return;
|
||||
|
||||
string icsText;
|
||||
try
|
||||
{
|
||||
icsText = await _http.GetStringAsync(url);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger?.Error("Jahresplan: Abruf fehlgeschlagen", ex);
|
||||
_settings.SetLastSync(DateTime.UtcNow, $"Fehler beim Abruf: {ex.Message}");
|
||||
return;
|
||||
}
|
||||
|
||||
AnnualPlanPollResult result;
|
||||
try
|
||||
{
|
||||
result = ProcessIcsText(icsText);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger?.Error("Jahresplan: Verarbeitung fehlgeschlagen", ex);
|
||||
_settings.SetLastSync(DateTime.UtcNow, $"Fehler bei der Verarbeitung: {ex.Message}");
|
||||
return;
|
||||
}
|
||||
|
||||
_settings.SetLastSync(DateTime.UtcNow,
|
||||
$"{result.EventCount} Termine — {result.Added} neu, {result.Updated} geändert, {result.Deleted} entfernt.");
|
||||
_logger?.Info($"Jahresplan: {result.EventCount} Termine, {result.ChangeCount} Änderung(en).");
|
||||
if (result.ChangeCount > 0) DataChanged?.Invoke();
|
||||
}
|
||||
finally
|
||||
{
|
||||
_gate.Release();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>HTTP-freier, vollständig testbarer Snapshot-Abgleich.</summary>
|
||||
public AnnualPlanPollResult ProcessIcsText(string icsText)
|
||||
{
|
||||
// Parse muss vollständig erfolgreich sein, bevor das Repository verändert wird. Der
|
||||
// strikte Parser verhindert so Teilimporte und fälschliches Löschen des Restbestands.
|
||||
var imported = AnnualPlanIcsParser.Parse(icsText);
|
||||
var existing = _events.GetAll();
|
||||
var existingByExternalId = existing.ToDictionary(e => e.ExternalId, StringComparer.Ordinal);
|
||||
var importedIds = imported.Select(e => e.ExternalId).ToHashSet(StringComparer.Ordinal);
|
||||
var added = 0;
|
||||
var updated = 0;
|
||||
|
||||
foreach (var entry in imported)
|
||||
{
|
||||
if (!existingByExternalId.TryGetValue(entry.ExternalId, out var previous))
|
||||
{
|
||||
_events.Save(entry);
|
||||
added++;
|
||||
continue;
|
||||
}
|
||||
|
||||
entry.Id = previous.Id;
|
||||
if (SameContent(previous, entry)) continue;
|
||||
_events.Save(entry);
|
||||
updated++;
|
||||
}
|
||||
|
||||
var stale = existing.Where(e => !importedIds.Contains(e.ExternalId)).ToList();
|
||||
foreach (var entry in stale) _events.Delete(entry.Id);
|
||||
|
||||
return new AnnualPlanPollResult(imported.Count, added, updated, stale.Count);
|
||||
}
|
||||
|
||||
private static bool SameContent(AnnualPlanEvent left, AnnualPlanEvent right) =>
|
||||
left.ExternalId == right.ExternalId &&
|
||||
left.Title == right.Title &&
|
||||
left.Description == right.Description &&
|
||||
left.Location == right.Location &&
|
||||
left.StartDate == right.StartDate &&
|
||||
left.EndDate == right.EndDate &&
|
||||
left.StartTime == right.StartTime &&
|
||||
left.EndTime == right.EndTime &&
|
||||
left.IsAllDay == right.IsAllDay &&
|
||||
left.CalendarGroup == right.CalendarGroup &&
|
||||
left.Color == right.Color &&
|
||||
left.Status == right.Status &&
|
||||
left.Sequence == right.Sequence &&
|
||||
SameInstant(left.SourceLastModifiedUtc, right.SourceLastModifiedUtc);
|
||||
|
||||
private static bool SameInstant(DateTime? left, DateTime? right) =>
|
||||
left is null && right is null ||
|
||||
left is not null && right is not null && left.Value.ToUniversalTime() == right.Value.ToUniversalTime();
|
||||
|
||||
public void Clear()
|
||||
{
|
||||
var existing = _events.GetAll();
|
||||
foreach (var entry in existing) _events.Delete(entry.Id);
|
||||
if (existing.Count > 0) DataChanged?.Invoke();
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_timer.Dispose();
|
||||
_gate.Dispose();
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -214,6 +214,7 @@
|
||||
HorizontalAlignment="Center" VerticalAlignment="Bottom">
|
||||
<Ellipse Width="5" Height="5" Fill="#1E88E5" IsVisible="{Binding HasSession}"/>
|
||||
<Ellipse Width="5" Height="5" Fill="#E53935" IsVisible="{Binding HasExam}"/>
|
||||
<Ellipse Width="5" Height="5" Fill="#FF8A00" IsVisible="{Binding HasAnnualPlanEvent}"/>
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</Border>
|
||||
@@ -222,25 +223,29 @@
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
|
||||
<StackPanel Orientation="Horizontal" Spacing="14" Margin="0,4,0,0">
|
||||
<StackPanel Orientation="Horizontal" Spacing="4">
|
||||
<WrapPanel Orientation="Horizontal" Margin="0,4,0,0">
|
||||
<StackPanel Orientation="Horizontal" Spacing="4" Margin="0,0,14,4">
|
||||
<Border Width="10" Height="10" CornerRadius="3" Background="#14808080"/>
|
||||
<TextBlock Text="Unterricht" FontSize="10" Opacity="0.6"/>
|
||||
</StackPanel>
|
||||
<StackPanel Orientation="Horizontal" Spacing="4">
|
||||
<StackPanel Orientation="Horizontal" Spacing="4" Margin="0,0,14,4">
|
||||
<Ellipse Width="7" Height="7" Fill="#E53935"/>
|
||||
<TextBlock Text="Klausur" FontSize="10" Opacity="0.6"/>
|
||||
</StackPanel>
|
||||
<StackPanel Orientation="Horizontal" Spacing="4">
|
||||
<StackPanel Orientation="Horizontal" Spacing="4" Margin="0,0,14,4">
|
||||
<Ellipse Width="7" Height="7" Fill="#1E88E5"/>
|
||||
<TextBlock Text="Sitzung" FontSize="10" Opacity="0.6"/>
|
||||
</StackPanel>
|
||||
<StackPanel Orientation="Horizontal" Spacing="4">
|
||||
<StackPanel Orientation="Horizontal" Spacing="4" Margin="0,0,14,4">
|
||||
<Ellipse Width="7" Height="7" Fill="#FF8A00"/>
|
||||
<TextBlock Text="Jahresplan" FontSize="10" Opacity="0.6"/>
|
||||
</StackPanel>
|
||||
<StackPanel Orientation="Horizontal" Spacing="4" Margin="0,0,14,4">
|
||||
<Border Width="10" Height="10" CornerRadius="3" BorderThickness="2"
|
||||
BorderBrush="{DynamicResource SystemControlBackgroundAccentBrush}"/>
|
||||
<TextBlock Text="Meine Klasse" FontSize="10" Opacity="0.6"/>
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
</WrapPanel>
|
||||
|
||||
<Separator Margin="0,4"/>
|
||||
<TextBlock Text="{Binding SelectedDayLabel}" FontWeight="SemiBold" FontSize="12"/>
|
||||
@@ -256,6 +261,9 @@
|
||||
<StackPanel Grid.Column="1">
|
||||
<TextBlock Text="{Binding Title}" FontSize="12" FontWeight="SemiBold"/>
|
||||
<TextBlock Text="{Binding Subtitle}" FontSize="10" Opacity="0.6"/>
|
||||
<TextBlock Text="{Binding Description}" FontSize="10" Opacity="0.6"
|
||||
TextWrapping="Wrap"
|
||||
IsVisible="{Binding Description, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</Button>
|
||||
|
||||
@@ -921,10 +921,14 @@
|
||||
</ScrollViewer>
|
||||
</ContentPage>
|
||||
|
||||
<!-- Tab: WebUntis-iCal-Abgleich (Nutzer-Feedback) -->
|
||||
<ContentPage Header="Stundenplan-Abgleich">
|
||||
<!-- Gemeinsamer UI-Tab; beide Importpfade bleiben fachlich und technisch getrennt. -->
|
||||
<ContentPage Header="Untis-Einbettung">
|
||||
<ScrollViewer>
|
||||
<StackPanel Margin="32,20,32,28" Spacing="14" MaxWidth="420">
|
||||
<StackPanel Margin="32,20,32,28" Spacing="14" MaxWidth="460">
|
||||
|
||||
<TextBlock Text="Untis-Einbettung" FontSize="18" FontWeight="SemiBold"/>
|
||||
<TextBlock FontSize="12" Opacity="0.6" TextWrapping="Wrap"
|
||||
Text="Bindet den persönlichen WebUntis-Stundenplan und den schulweiten Jahresplan als zwei unabhängige iCal-Quellen ein."/>
|
||||
|
||||
<TextBlock Text="WebUntis-Stundenplan-Abgleich" FontSize="16" FontWeight="SemiBold"/>
|
||||
<TextBlock FontSize="12" Opacity="0.6" TextWrapping="Wrap"
|
||||
@@ -952,6 +956,36 @@
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
|
||||
<Separator Margin="0,8"/>
|
||||
|
||||
<TextBlock Text="Schulweiter Jahresplan" FontSize="16" FontWeight="SemiBold"/>
|
||||
<TextBlock FontSize="12" Opacity="0.6" TextWrapping="Wrap"
|
||||
Text="Importiert Konferenzen, Prüfungszeiträume, Fortbildungen und weitere schulweite Termine aus einem iCal-Feed. Diese Termine werden zusätzlich im Dashboard-Kalender angezeigt und verändern den persönlichen Stundenplan nicht."/>
|
||||
|
||||
<StackPanel Spacing="8" IsVisible="{Binding !AnnualPlanIsConfigured}">
|
||||
<StackPanel Spacing="4">
|
||||
<TextBlock Text="iCal-URL" FontSize="12" Opacity="0.7"/>
|
||||
<TextBox Text="{Binding AnnualPlanIcalUrlInput}" PasswordChar="●"
|
||||
PlaceholderText="https://…/export.php?type=ics…"/>
|
||||
</StackPanel>
|
||||
<TextBlock Text="{Binding AnnualPlanUrlError}" Foreground="Red" FontSize="12"
|
||||
IsVisible="{Binding AnnualPlanUrlError, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
|
||||
<Button Content="Speichern und aktivieren" Command="{Binding AnnualPlanSaveUrlCommand}"
|
||||
HorizontalAlignment="Left"/>
|
||||
</StackPanel>
|
||||
|
||||
<StackPanel Spacing="8" IsVisible="{Binding AnnualPlanIsConfigured}">
|
||||
<TextBlock Text="Jahresplan-iCal hinterlegt (verschlüsselt gespeichert)."
|
||||
FontSize="13" FontWeight="SemiBold"/>
|
||||
<TextBlock Text="{Binding AnnualPlanStatusDisplay}" FontSize="12" Opacity="0.7"
|
||||
TextWrapping="Wrap"/>
|
||||
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||
<Button Content="Jetzt abrufen" Command="{Binding AnnualPlanFetchNowCommand}"
|
||||
IsEnabled="{Binding !AnnualPlanFetchBusy}"/>
|
||||
<Button Content="Entfernen" Command="{Binding AnnualPlanRemoveCommand}"/>
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
</ContentPage>
|
||||
|
||||
Reference in New Issue
Block a user