feat: Untis-Hub für WebUntis-Sync-Gesundheitsstatus
CI / build-and-test (push) Canceled after 0s

Zeigt die Fälligkeit der vier bestehenden, manuell ausgelösten WebUntis-Abgleiche
(Fehlzeiten je Lerngruppe, offene Stunden, Klassenbuch-, Hausaufgabenabgleich) in
einem neuen Hub-Fenster, ohne selbst WebUntis anzufragen - nur gespeicherte
Zeitstempel werden ausgewertet. Konsolidiert die bisher verstreuten Einstiegspunkte
(Sidebar-Button, Dashboard-Buttons) in ein neues WebUntis-Menü plus einen
kompakten Gesundheits-Indikator auf dem Dashboard.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-10 23:41:12 +02:00
co-authored by Claude Sonnet 5
parent bd0d7e47ea
commit ba52109eaa
20 changed files with 660 additions and 44 deletions
+2
View File
@@ -185,6 +185,7 @@ public static class AppBootstrapper
services.AddSingleton<IUntisClassRegisterCacheRepository, UntisClassRegisterCacheRepository>();
services.AddSingleton<IUntisCacheFetchStateRepository, UntisCacheFetchStateRepository>();
services.AddSingleton<IUntisStudentRosterCacheRepository, UntisStudentRosterCacheRepository>();
services.AddSingleton<IUntisHubJobStateRepository, UntisHubJobStateRepository>();
// ── Services ──────────────────────────────────────────────────────────
services.AddSingleton<GradingService>();
@@ -248,6 +249,7 @@ public static class AppBootstrapper
services.AddSingleton(sp => new SchoolWeatherService(new HttpClient(), syncSettings));
services.AddSingleton(sp => new WebUntisIntegrationService(new HttpClient(), untisSettings));
services.AddSingleton<UntisReportCacheService>();
services.AddSingleton<UntisHubService>();
// War dieses Gerät schon eingeloggt, aber sync.key fehlt(e), wurde gerade eben (unten)
// stillschweigend ein neuer, unabhängiger Schlüssel erzeugt - bisher unter dem ALTEN
// Schlüssel synchronisierte Server-Daten sind für dieses Gerät dann nicht mehr lesbar.
@@ -0,0 +1,88 @@
using Avalonia.Controls;
using CommunityToolkit.Mvvm.ComponentModel;
using LehrerApp.Core.Interfaces;
using LehrerApp.Core.Models;
using LehrerApp.Core.Services;
using LehrerApp.Desktop.ViewModels.Groups;
using LehrerApp.Desktop.ViewModels.Students;
using LehrerApp.Desktop.Views;
using LehrerApp.Desktop.Views.Groups;
using LehrerApp.Desktop.Views.Students;
using Microsoft.Extensions.DependencyInjection;
namespace LehrerApp.Desktop.Services;
/// <summary>Führt einen der vier bestehenden WebUntis-Abgleiche (unverändert, über ihre bestehenden
/// Dialoge) aus und vermerkt das Ergebnis im <see cref="UntisHubService"/> - genutzt sowohl vom
/// Untis-Hub-Dialog ("Jetzt prüfen" je Zeile) als auch von den WebUntis-Menüpunkten in
/// <c>MainWindow</c>, damit beide Einstiegspunkte denselben Fälligkeitsstand pflegen.</summary>
public static class UntisHubActions
{
public static async Task RunFehlzeitenAsync(Window owner, LearningGroup group, UntisHubJobKind kind,
DateOnly start, DateOnly end, UntisHubService hub)
{
var vm = new WebUntisLessonAbsenceComparisonViewModel(group,
App.Services.GetRequiredService<WebUntisIntegrationService>(),
App.Services.GetRequiredService<IStudentRepository>(),
App.Services.GetRequiredService<IParticipationSessionRepository>(),
App.Services.GetRequiredService<IParticipationRepository>())
{ StartDate = start.ToDateTime(TimeOnly.MinValue), EndDate = end.ToDateTime(TimeOnly.MinValue) };
var loaded = TrackLoad(vm, v => v.Busy);
await new WebUntisLessonAbsenceComparisonDialog { DataContext = vm }.ShowDialog(owner);
if (loaded()) hub.RecordRun(kind, group.Id, vm.Status);
}
public static async Task RunKlassenbuchAsync(Window owner, UntisHubService hub)
{
var vm = new WebUntisDocumentationComparisonViewModel(
App.Services.GetRequiredService<WebUntisIntegrationService>(),
App.Services.GetRequiredService<IStudentRepository>(),
App.Services.GetRequiredService<IGroupRepository>(),
App.Services.GetRequiredService<IDocumentationRepository>(),
App.Services.GetRequiredService<SchoolYearService>());
var loaded = TrackLoad(vm, v => v.Busy);
await new WebUntisDocumentationComparisonDialog { DataContext = vm }.ShowDialog(owner);
if (loaded()) hub.RecordRun(UntisHubJobKind.Klassenbuchabgleich, null, vm.Status);
}
public static async Task RunHausaufgabenAsync(Window owner, UntisHubService hub)
{
var vm = new WebUntisHomeworkComparisonViewModel(
App.Services.GetRequiredService<WebUntisIntegrationService>(),
App.Services.GetRequiredService<IStudentRepository>(),
App.Services.GetRequiredService<IGroupRepository>(),
App.Services.GetRequiredService<ISubjectRepository>(),
App.Services.GetRequiredService<IGroupMembershipRepository>(),
App.Services.GetRequiredService<IParticipationSessionRepository>(),
App.Services.GetRequiredService<IParticipationRepository>(),
App.Services.GetRequiredService<SchoolYearService>());
var loaded = TrackLoad(vm, v => v.Busy);
await new WebUntisHomeworkComparisonDialog { DataContext = vm }.ShowDialog(owner);
if (loaded()) hub.RecordRun(UntisHubJobKind.Hausaufgabenabgleich, null, vm.Status);
}
public static async Task RunOffenePeriodsAsync(Window owner, UntisHubService hub)
{
var dialog = new OpenUntisPeriodsDialog();
await dialog.ShowDialog(owner);
hub.RecordRun(UntisHubJobKind.OffenePeriods, null, dialog.LastStatus);
}
/// <summary>Erkennt nicht-invasiv (ohne die Vergleichs-ViewModels zu ändern), ob im Dialog
/// tatsächlich ein Ladeversuch stattfand: <c>Busy</c> wechselt in <c>Load()</c> immer erst auf
/// true und im <c>finally</c>-Block zurück auf false, egal ob erfolgreich oder mit Fehler
/// abgebrochen - genau dieser Übergang wird hier beobachtet.</summary>
private static Func<bool> TrackLoad<T>(T vm, Func<T, bool> isBusy) where T : ObservableObject
{
var loaded = false;
var wasBusy = false;
vm.PropertyChanged += (_, e) =>
{
if (e.PropertyName != "Busy") return;
var busy = isBusy(vm);
if (wasBusy && !busy) loaded = true;
wasBusy = busy;
};
return () => loaded;
}
}
@@ -0,0 +1,97 @@
using LehrerApp.Core.Interfaces;
using LehrerApp.Core.Models;
using LehrerApp.Core.Services;
namespace LehrerApp.Desktop.Services;
public enum UntisHubDueState { Ok, Due, Overdue }
/// <summary>Eine Zeile im Untis-Hub: eine Job-Instanz (Fehlzeiten-Kadenz je Lerngruppe, oder einer
/// der drei dashboard-weiten Jobs) mit ihrer aktuellen Fälligkeit.</summary>
public sealed record UntisHubJobRow(
UntisHubJobKind Kind, Guid? GroupId, string GroupName,
DateTime? LastRunAt, string? LastResultSummary, UntisHubDueState DueState, string DueLabel);
/// <summary>
/// Zeigt, welche der bestehenden, rein manuell ausgelösten WebUntis-Abgleiche (Fehlzeiten pro
/// Lerngruppe, offene Stunden, Klassenbuch-/Hausaufgabenabgleich) fällig sind - ohne selbst
/// WebUntis anzufragen (Nutzer-Feedback: "nicht bei WebUntis auffallen", siehe
/// <see cref="UntisReportCacheService"/>). Die eigentlichen Abgleiche laufen weiterhin über die
/// bestehenden Vergleichsdialoge (siehe <see cref="UntisHubActions"/>); dieser Service verwaltet
/// nur die Fälligkeits-Zeitstempel dazu.
/// </summary>
public sealed class UntisHubService(
IGroupRepository groups, IUntisHubJobStateRepository jobStates, SchoolYearService schoolYears)
{
private static readonly TimeSpan FehlzeitenKurzDue = TimeSpan.FromDays(14);
private static readonly TimeSpan FehlzeitenKurzOverdue = TimeSpan.FromDays(21);
private static readonly TimeSpan FehlzeitenLangDue = TimeSpan.FromDays(60);
private static readonly TimeSpan FehlzeitenLangOverdue = TimeSpan.FromDays(90);
private static readonly TimeSpan GlobalDue = TimeSpan.FromDays(14);
private static readonly TimeSpan GlobalOverdue = TimeSpan.FromDays(21);
private static readonly (UntisHubJobKind Kind, string Label)[] GlobalJobs =
[
(UntisHubJobKind.OffenePeriods, "Offene Stunden"),
(UntisHubJobKind.Klassenbuchabgleich, "Klassenbuchabgleich"),
(UntisHubJobKind.Hausaufgabenabgleich, "Hausaufgabenabgleich"),
];
public List<UntisHubJobRow> GetRows()
{
var eligibleGroups = groups.GetBySchoolYear(schoolYears.CurrentSchoolYear())
.Where(g => g.WebUntisLessonId is not null)
.OrderBy(g => g.Name)
.ToList();
return BuildRows(eligibleGroups, jobStates.GetAll(), DateTime.UtcNow);
}
public void RecordRun(UntisHubJobKind kind, Guid? groupId, string? summary) =>
jobStates.Save(new UntisHubJobState
{
Id = jobStates.Get(kind, groupId)?.Id ?? Guid.NewGuid(),
Kind = kind, GroupId = groupId, LastRunAt = DateTime.UtcNow, LastResultSummary = summary,
});
/// Reine Entscheidungslogik ohne Repository-Zugriff (gleiches Muster wie
/// <see cref="UntisReportCacheService.Plan"/>): aus den fälligkeitsrelevanten Lerngruppen und den
/// zuletzt gespeicherten Job-Zuständen wird die vollständige Hub-Zeilenliste gebaut - zwei
/// Fehlzeiten-Zeilen je Gruppe plus die drei dashboard-weiten Zeilen.
public static List<UntisHubJobRow> BuildRows(
IReadOnlyList<LearningGroup> eligibleGroups, IReadOnlyList<UntisHubJobState> states, DateTime utcNow)
{
UntisHubJobState? State(UntisHubJobKind kind, Guid? groupId) =>
states.FirstOrDefault(s => s.Kind == kind && s.GroupId == groupId);
var rows = new List<UntisHubJobRow>();
foreach (var group in eligibleGroups)
{
rows.Add(Row(UntisHubJobKind.FehlzeitenKurz, group.Id, group.Name,
State(UntisHubJobKind.FehlzeitenKurz, group.Id), FehlzeitenKurzDue, FehlzeitenKurzOverdue, utcNow));
rows.Add(Row(UntisHubJobKind.FehlzeitenLang, group.Id, group.Name,
State(UntisHubJobKind.FehlzeitenLang, group.Id), FehlzeitenLangDue, FehlzeitenLangOverdue, utcNow));
}
foreach (var (kind, label) in GlobalJobs)
rows.Add(Row(kind, null, label, State(kind, null), GlobalDue, GlobalOverdue, utcNow));
return rows;
}
private static UntisHubJobRow Row(UntisHubJobKind kind, Guid? groupId, string groupName,
UntisHubJobState? state, TimeSpan due, TimeSpan overdue, DateTime utcNow)
{
var (dueState, dueLabel) = DueStatus(state?.LastRunAt, due, overdue, utcNow);
return new UntisHubJobRow(kind, groupId, groupName, state?.LastRunAt, state?.LastResultSummary,
dueState, dueLabel);
}
private static (UntisHubDueState, string) DueStatus(
DateTime? lastRunAt, TimeSpan due, TimeSpan overdue, DateTime utcNow)
{
if (lastRunAt is null) return (UntisHubDueState.Overdue, "noch nie geprüft");
var age = utcNow - lastRunAt.Value;
var days = (int)age.TotalDays;
if (age >= overdue) return (UntisHubDueState.Overdue, $"fällig seit {days} Tagen");
if (age >= due) return (UntisHubDueState.Due, $"fällig seit {days} Tagen");
return (UntisHubDueState.Ok, days == 0 ? "gerade eben geprüft" : $"vor {days} Tag(en) geprüft");
}
}
@@ -38,6 +38,8 @@ public partial class DashboardViewModel : ObservableObject
private readonly ITimeEntryRepository _timeEntries;
private readonly IAnnualPlanEventRepository? _annualPlanEvents;
private readonly SchoolWeatherService? _schoolWeather;
private readonly UntisHubService _untisHub;
private readonly WebUntisIntegrationService _webUntis;
private const int OpenExcuseMaxAgeDays = 21;
private const int SupportPlanDueWithinDays = 14;
@@ -143,6 +145,10 @@ public partial class DashboardViewModel : ObservableObject
public string AttentionSummary => AttentionCount == 1 ? "1 offener Punkt" : $"{AttentionCount} offene Punkte";
public string UpcomingSummary => UpcomingCount == 1 ? "1 Termin" : $"{UpcomingCount} Termine";
[ObservableProperty] private string _webUntisHealthLabel = "";
[ObservableProperty] private bool _isWebUntisHealthWarning;
[ObservableProperty] private bool _isWebUntisHealthVisible;
public DashboardViewModel(IGroupRepository groups, ISubjectRepository subjects, ILessonRepository lessons,
IExamRepository exams, IExamResultRepository examResults, IGradeRepository grades,
IReportGradeRepository reportGrades, IGroupMembershipRepository memberships,
@@ -153,6 +159,7 @@ public partial class DashboardViewModel : ObservableObject
DashboardSettingsService dashboardSettings, ISchoolHolidayRepository schoolHolidays,
PublicHolidayService publicHolidays, SchoolCalendarSettingsService calendarSettings,
ISubstitutionEntryRepository substitutions, ITimeEntryRepository timeEntries,
UntisHubService untisHub, WebUntisIntegrationService webUntis,
IAnnualPlanEventRepository? annualPlanEvents = null,
AnnualPlanSyncService? annualPlanSync = null, SchoolWeatherService? schoolWeather = null)
{
@@ -167,12 +174,28 @@ public partial class DashboardViewModel : ObservableObject
_substitutions = substitutions;
_annualPlanEvents = annualPlanEvents;
_schoolWeather = schoolWeather;
_untisHub = untisHub;
_webUntis = webUntis;
if (annualPlanSync is not null)
{
annualPlanSync.DataChanged += () => Avalonia.Threading.Dispatcher.UIThread.Post(LoadCalendar);
}
LoadDashboardCards();
Load();
RefreshWebUntisHealth();
}
/// <summary>Liest nur den gespeicherten Fälligkeitsstand der Untis-Hub-Jobs (kein
/// WebUntis-Zugriff, siehe <see cref="UntisHubService.GetRows"/>) - aufgerufen bei jedem
/// Dashboard-Refresh und erneut, nachdem der Nutzer den Hub geöffnet/einen Abgleich gemacht hat.</summary>
public void RefreshWebUntisHealth()
{
IsWebUntisHealthVisible = _webUntis.IsAvailable;
if (!IsWebUntisHealthVisible) return;
var rows = _untisHub.GetRows();
var due = rows.Count(r => r.DueState != UntisHubDueState.Ok);
IsWebUntisHealthWarning = due > 0;
WebUntisHealthLabel = due > 0 ? $"WebUntis ⚠ {due} fällig" : "WebUntis ✓";
}
private DashboardCardOption Card(string key) => DashboardCards.First(c => c.Key == key);
@@ -0,0 +1,78 @@
using System.Collections.ObjectModel;
using CommunityToolkit.Mvvm.ComponentModel;
using LehrerApp.Core.Interfaces;
using LehrerApp.Core.Models;
using LehrerApp.Desktop.Services;
namespace LehrerApp.Desktop.ViewModels.UntisHub;
/// <summary>Eine Zeile im Untis-Hub-Fenster - reine Anzeige-Projektion von
/// <see cref="UntisHubJobRow"/>, neu aufgebaut bei jedem <see cref="UntisHubViewModel.Load"/>.</summary>
public sealed class UntisHubRowViewModel
{
public UntisHubJobKind Kind { get; }
public Guid? GroupId { get; }
public string GroupName { get; }
public string JobLabel { get; }
public string DueLabel { get; }
public string? LastResultSummary { get; }
public bool IsWarning { get; }
public bool IsDanger { get; }
public UntisHubRowViewModel(UntisHubJobRow row)
{
Kind = row.Kind; GroupId = row.GroupId; GroupName = row.GroupName;
JobLabel = Label(row.Kind); DueLabel = row.DueLabel; LastResultSummary = row.LastResultSummary;
IsWarning = row.DueState == UntisHubDueState.Due;
IsDanger = row.DueState == UntisHubDueState.Overdue;
}
private static string Label(UntisHubJobKind kind) => kind switch
{
UntisHubJobKind.FehlzeitenKurz => "Fehlzeiten (kurzfristig)",
UntisHubJobKind.FehlzeitenLang => "Fehlzeiten (seit Schuljahresbeginn)",
UntisHubJobKind.OffenePeriods => "Offene Stunden",
UntisHubJobKind.Klassenbuchabgleich => "Klassenbuchabgleich",
UntisHubJobKind.Hausaufgabenabgleich => "Hausaufgabenabgleich",
_ => kind.ToString(),
};
}
/// <summary>ViewModel des Untis-Hub-Fensters (siehe TODO.md) - zeigt nur den gespeicherten
/// Fälligkeitsstand an (<see cref="UntisHubService.GetRows"/>, rein lesend aus LiteDB). Das
/// tatsächliche Ausführen eines Jobs (inkl. WebUntis-Anfrage) übernimmt die Code-Behind-Klasse über
/// <see cref="UntisHubActions"/>, weil dafür ein Fenster-Owner für <c>ShowDialog</c> gebraucht wird.</summary>
public partial class UntisHubViewModel : ObservableObject
{
private readonly UntisHubService _hub;
private readonly IGroupRepository _groups;
public ObservableCollection<UntisHubRowViewModel> Rows { get; } = [];
[ObservableProperty] private bool _isAvailable;
[ObservableProperty] private string _status = "";
public UntisHubViewModel(UntisHubService hub, IGroupRepository groups, WebUntisIntegrationService untis)
{
_hub = hub; _groups = groups;
IsAvailable = untis.IsAvailable;
Load();
}
public void Load()
{
Rows.Clear();
if (!IsAvailable)
{
Status = "WebUntis ist nicht konfiguriert (siehe Einstellungen).";
return;
}
foreach (var row in _hub.GetRows()) Rows.Add(new UntisHubRowViewModel(row));
var overdue = Rows.Count(r => r.IsDanger);
var due = Rows.Count(r => r.IsWarning);
Status = overdue > 0 || due > 0
? $"{overdue + due} von {Rows.Count} Prüfungen fällig ({overdue} überfällig)."
: $"Alle {Rows.Count} Prüfungen aktuell.";
}
public LearningGroup? FindGroup(Guid id) => _groups.GetById(id);
}
@@ -9,6 +9,12 @@
<Setter Property="Foreground" Value="Red"/>
<Setter Property="FontWeight" Value="SemiBold"/>
</Style>
<Style Selector="Button.webuntishealth">
<Setter Property="Foreground" Value="{DynamicResource AppStatusOkBrush}"/>
</Style>
<Style Selector="Button.webuntishealth.warning">
<Setter Property="Foreground" Value="{DynamicResource AppStatusWarningBrush}"/>
</Style>
<Style Selector="Border.daycell.selected">
<Setter Property="Background" Value="{DynamicResource SystemControlBackgroundAccentBrush}"/>
<Setter Property="BorderThickness" Value="2"/>
@@ -26,9 +32,11 @@
<TextBlock Text="{Binding CurrentDate}" FontSize="24" FontWeight="SemiBold"/>
</StackPanel>
<WrapPanel Grid.Row="1" Orientation="Horizontal" ItemSpacing="8" LineSpacing="8" Margin="0,10,0,0">
<Button Content="Klassenbuch abgleichen…" Click="OnCompareWebUntisDocumentationClick"
VerticalAlignment="Center"/>
<Button Content="Fehlende Hausaufgaben abgleichen…" Click="OnCompareWebUntisHomeworkClick"
<!-- Fasst die vier WebUntis-Abgleiche zusammen, die jetzt nur noch im Menü "WebUntis"
erreichbar sind (siehe TODO.md, Untis-Hub) - Klick öffnet den Hub. -->
<Button Content="{Binding WebUntisHealthLabel}" Click="OnOpenUntisHubClick"
IsVisible="{Binding IsWebUntisHealthVisible}"
Classes="webuntishealth" Classes.warning="{Binding IsWebUntisHealthWarning}"
VerticalAlignment="Center"/>
<Button Content="Bereiche anpassen" Command="{Binding ToggleDashboardSettingsCommand}"
VerticalAlignment="Center"/>
@@ -1,14 +1,9 @@
using Avalonia.Controls;
using Avalonia.Interactivity;
using LehrerApp.Core.Interfaces;
using LehrerApp.Core.Models;
using LehrerApp.Core.Services;
using LehrerApp.Desktop.Services;
using LehrerApp.Desktop.ViewModels;
using LehrerApp.Desktop.ViewModels.Groups;
using LehrerApp.Desktop.ViewModels.Students;
using LehrerApp.Desktop.Views.Groups;
using LehrerApp.Desktop.Views.Students;
using LehrerApp.Desktop.Views.UntisHub;
using LehrerApp.Desktop.Views.Workload;
using Microsoft.Extensions.DependencyInjection;
@@ -32,32 +27,11 @@ public partial class DashboardView : UserControl
return await WorkTaskDialogHelper.ShowDialog(owner, startAsReminder: startAsReminder);
}
private async void OnCompareWebUntisDocumentationClick(object? sender, RoutedEventArgs e)
private async void OnOpenUntisHubClick(object? sender, RoutedEventArgs e)
{
var owner = TopLevel.GetTopLevel(this) as Window;
if (owner is null) return;
var dialogVm = new WebUntisDocumentationComparisonViewModel(
App.Services.GetRequiredService<WebUntisIntegrationService>(),
App.Services.GetRequiredService<IStudentRepository>(),
App.Services.GetRequiredService<IGroupRepository>(),
App.Services.GetRequiredService<IDocumentationRepository>(),
App.Services.GetRequiredService<SchoolYearService>());
await new WebUntisDocumentationComparisonDialog { DataContext = dialogVm }.ShowDialog(owner);
}
private async void OnCompareWebUntisHomeworkClick(object? sender, RoutedEventArgs e)
{
var owner = TopLevel.GetTopLevel(this) as Window;
if (owner is null) return;
var dialogVm = new WebUntisHomeworkComparisonViewModel(
App.Services.GetRequiredService<WebUntisIntegrationService>(),
App.Services.GetRequiredService<IStudentRepository>(),
App.Services.GetRequiredService<IGroupRepository>(),
App.Services.GetRequiredService<ISubjectRepository>(),
App.Services.GetRequiredService<IGroupMembershipRepository>(),
App.Services.GetRequiredService<IParticipationSessionRepository>(),
App.Services.GetRequiredService<IParticipationRepository>(),
App.Services.GetRequiredService<SchoolYearService>());
await new WebUntisHomeworkComparisonDialog { DataContext = dialogVm }.ShowDialog(owner);
await new UntisHubDialog().ShowDialog(owner);
if (DataContext is DashboardViewModel vm) vm.RefreshWebUntisHealth();
}
}
+11 -9
View File
@@ -31,6 +31,16 @@
darunter als Overlay-Drawer mit Hamburger-Button (automatisch).
Kein eigener Code nötig.
-->
<DockPanel>
<Menu DockPanel.Dock="Top">
<MenuItem Header="WebUntis">
<MenuItem Header="Untis-Hub…" Click="OnOpenUntisHub"/>
<Separator/>
<MenuItem Header="Offene Stunden…" Click="OnOpenUntisPeriods"/>
<MenuItem Header="Klassenbuchabgleich…" Click="OnCompareUntisKlassenbuch"/>
<MenuItem Header="Hausaufgabenabgleich…" Click="OnCompareUntisHausaufgaben"/>
</MenuItem>
</Menu>
<DrawerPage x:Name="RootDrawer"
DrawerLength="220"
DrawerBehavior="Auto"
@@ -250,15 +260,6 @@
<TextBlock Classes="navlabel" Text="Klassenlehrer"/>
</StackPanel>
</Button>
<Button Classes="navitem" HorizontalAlignment="Stretch"
HorizontalContentAlignment="Left" CornerRadius="6"
Click="OnOpenUntisPeriods"
ToolTip.Tip="Offene Untis-Stunden" AutomationProperties.Name="Offene Untis-Stunden">
<StackPanel Classes="navcontent" Orientation="Horizontal" Spacing="10">
<PathIcon Classes="navicon" Data="{StaticResource IconOpenPeriods}"/>
<TextBlock Classes="navlabel" Text="Offene Untis-Stunden"/>
</StackPanel>
</Button>
<Button Classes="navitem" Classes.active="{Binding IsSettingsActive}" HorizontalAlignment="Stretch"
HorizontalContentAlignment="Left"
CornerRadius="6"
@@ -276,6 +277,7 @@
</DrawerPage.Drawer>
</DrawerPage>
</DockPanel>
<!-- Globale Suche und Schnellerfassung (14.2). Bewusst als Overlay auf der aktuellen Seite:
Der Nutzer behält den Kontext und kann mit Escape ohne Navigation zurückkehren. -->
+18 -1
View File
@@ -3,7 +3,9 @@ using Avalonia.Input;
using Avalonia.Threading;
using LehrerApp.Desktop.Services;
using LehrerApp.Desktop.ViewModels;
using LehrerApp.Desktop.Views.UntisHub;
using LehrerApp.Sync;
using Microsoft.Extensions.DependencyInjection;
namespace LehrerApp.Desktop.Views;
@@ -23,9 +25,24 @@ public partial class MainWindow : Window
KeyDown += OnWindowKeyDown;
}
private async void OnOpenUntisHub(object? sender, Avalonia.Interactivity.RoutedEventArgs e)
{
await new UntisHubDialog().ShowDialog(this);
}
private async void OnOpenUntisPeriods(object? sender, Avalonia.Interactivity.RoutedEventArgs e)
{
await new OpenUntisPeriodsDialog().ShowDialog(this);
await UntisHubActions.RunOffenePeriodsAsync(this, App.Services.GetRequiredService<UntisHubService>());
}
private async void OnCompareUntisKlassenbuch(object? sender, Avalonia.Interactivity.RoutedEventArgs e)
{
await UntisHubActions.RunKlassenbuchAsync(this, App.Services.GetRequiredService<UntisHubService>());
}
private async void OnCompareUntisHausaufgaben(object? sender, Avalonia.Interactivity.RoutedEventArgs e)
{
await UntisHubActions.RunHausaufgabenAsync(this, App.Services.GetRequiredService<UntisHubService>());
}
private void OnWindowKeyDown(object? sender, KeyEventArgs e)
@@ -23,6 +23,10 @@ public sealed class OpenUntisPeriodsDialog : Window
private UntisOpenPeriodsMeta? _meta;
private int _yearId;
/// <summary>Für den Untis-Hub (<see cref="UntisHubActions.RunOffenePeriodsAsync"/>): letzter
/// Status-Text nach dem automatischen Laden beim Öffnen.</summary>
public string? LastStatus => _status.Text;
public OpenUntisPeriodsDialog()
{
Title = "Offene WebUntis-Stunden";
@@ -0,0 +1,61 @@
<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="clr-namespace:LehrerApp.Desktop.ViewModels.UntisHub"
x:Class="LehrerApp.Desktop.Views.UntisHub.UntisHubDialog"
x:DataType="vm:UntisHubViewModel"
Title="Untis-Hub"
Width="900" Height="600" MinWidth="640" MinHeight="400"
WindowStartupLocation="CenterOwner">
<Window.Styles>
<Style Selector="TextBlock.duestatus">
<Setter Property="Foreground" Value="{DynamicResource AppStatusOkBrush}"/>
</Style>
<Style Selector="TextBlock.duestatus.warning">
<Setter Property="Foreground" Value="{DynamicResource AppStatusWarningBrush}"/>
</Style>
<Style Selector="TextBlock.duestatus.danger">
<Setter Property="Foreground" Value="{DynamicResource AppStatusDangerBrush}"/>
</Style>
</Window.Styles>
<DockPanel Margin="20">
<StackPanel DockPanel.Dock="Top" Spacing="10" Margin="0,0,0,14">
<Grid ColumnDefinitions="*,Auto">
<TextBlock Text="Untis-Hub" FontSize="22" FontWeight="SemiBold"/>
<Button Grid.Column="1" Content="Aktualisieren" Click="OnRefreshClick"/>
</Grid>
<TextBlock Text="{Binding Status}" FontSize="12" Opacity="0.7" TextWrapping="Wrap"/>
</StackPanel>
<DataGrid ItemsSource="{Binding Rows}" AutoGenerateColumns="False" IsReadOnly="True"
GridLinesVisibility="Horizontal" BorderBrush="{DynamicResource AppCardBorderBrush}"
BorderThickness="1" CanUserResizeColumns="True" CanUserSortColumns="True" RowHeight="46"
IsVisible="{Binding IsAvailable}">
<DataGrid.Columns>
<DataGridTextColumn Header="Bereich" Binding="{Binding GroupName}" Width="1.3*"/>
<DataGridTextColumn Header="Prüfung" Binding="{Binding JobLabel}" Width="1.3*"/>
<DataGridTemplateColumn Header="Status" Width="1.1*">
<DataGridTemplateColumn.CellTemplate>
<DataTemplate x:DataType="vm:UntisHubRowViewModel">
<TextBlock Text="{Binding DueLabel}" Classes="duestatus"
Classes.warning="{Binding IsWarning}" Classes.danger="{Binding IsDanger}"
VerticalAlignment="Center"/>
</DataTemplate>
</DataGridTemplateColumn.CellTemplate>
</DataGridTemplateColumn>
<DataGridTextColumn Header="Letztes Ergebnis" Binding="{Binding LastResultSummary}" Width="2*"/>
<DataGridTemplateColumn Header="" Width="130">
<DataGridTemplateColumn.CellTemplate>
<DataTemplate x:DataType="vm:UntisHubRowViewModel">
<Button Content="Jetzt prüfen" Click="OnCheckClick" DataContext="{Binding}"/>
</DataTemplate>
</DataGridTemplateColumn.CellTemplate>
</DataGridTemplateColumn>
</DataGrid.Columns>
</DataGrid>
<TextBlock IsVisible="{Binding !IsAvailable}" Text="WebUntis ist nicht konfiguriert. Bitte zuerst in den Einstellungen hinterlegen."
FontSize="14" Opacity="0.7" HorizontalAlignment="Center" VerticalAlignment="Center"/>
</DockPanel>
</Window>
@@ -0,0 +1,58 @@
using Avalonia.Controls;
using Avalonia.Interactivity;
using LehrerApp.Core.Interfaces;
using LehrerApp.Core.Models;
using LehrerApp.Core.Services;
using LehrerApp.Desktop.Services;
using LehrerApp.Desktop.ViewModels.UntisHub;
using Microsoft.Extensions.DependencyInjection;
namespace LehrerApp.Desktop.Views.UntisHub;
public partial class UntisHubDialog : Window
{
public UntisHubDialog()
{
InitializeComponent();
DataContext = new UntisHubViewModel(
App.Services.GetRequiredService<UntisHubService>(),
App.Services.GetRequiredService<IGroupRepository>(),
App.Services.GetRequiredService<WebUntisIntegrationService>());
}
private void OnRefreshClick(object? sender, RoutedEventArgs e)
{
if (DataContext is UntisHubViewModel vm) vm.Load();
}
private async void OnCheckClick(object? sender, RoutedEventArgs e)
{
if (DataContext is not UntisHubViewModel vm || sender is not Button { DataContext: UntisHubRowViewModel row })
return;
var hub = App.Services.GetRequiredService<UntisHubService>();
var today = DateOnly.FromDateTime(DateTime.Today);
switch (row.Kind)
{
case UntisHubJobKind.FehlzeitenKurz or UntisHubJobKind.FehlzeitenLang:
var group = row.GroupId is { } id ? vm.FindGroup(id) : null;
if (group is null) return;
var schoolYears = App.Services.GetRequiredService<SchoolYearService>();
var start = row.Kind == UntisHubJobKind.FehlzeitenKurz
? today.AddDays(-30)
: schoolYears.SchoolYearStart(schoolYears.CurrentSchoolYear());
await UntisHubActions.RunFehlzeitenAsync(this, group, row.Kind, start, today, hub);
break;
case UntisHubJobKind.OffenePeriods:
await UntisHubActions.RunOffenePeriodsAsync(this, hub);
break;
case UntisHubJobKind.Klassenbuchabgleich:
await UntisHubActions.RunKlassenbuchAsync(this, hub);
break;
case UntisHubJobKind.Hausaufgabenabgleich:
await UntisHubActions.RunHausaufgabenAsync(this, hub);
break;
}
vm.Load();
}
}