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:
@@ -170,6 +170,14 @@ public interface IUntisCacheFetchStateRepository
|
|||||||
UntisCacheFetchState? Get(string className, UntisCacheKind kind);
|
UntisCacheFetchState? Get(string className, UntisCacheKind kind);
|
||||||
void Save(UntisCacheFetchState state);
|
void Save(UntisCacheFetchState state);
|
||||||
}
|
}
|
||||||
|
/// Zuletzt-Lauf-Status der Untis-Hub-Jobs (siehe TODO.md) - je (Kind, GroupId), GroupId null bei
|
||||||
|
/// den dashboard-weiten Jobs.
|
||||||
|
public interface IUntisHubJobStateRepository
|
||||||
|
{
|
||||||
|
UntisHubJobState? Get(UntisHubJobKind kind, Guid? groupId);
|
||||||
|
List<UntisHubJobState> GetAll();
|
||||||
|
void Save(UntisHubJobState state);
|
||||||
|
}
|
||||||
/// Vom Nutzer bestätigte Zuordnungen WebUntis-Wochenmuster → LearningGroup.
|
/// Vom Nutzer bestätigte Zuordnungen WebUntis-Wochenmuster → LearningGroup.
|
||||||
public interface IUntisSlotMappingRepository
|
public interface IUntisSlotMappingRepository
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
namespace LehrerApp.Core.Models;
|
||||||
|
|
||||||
|
public enum UntisHubJobKind
|
||||||
|
{
|
||||||
|
FehlzeitenKurz,
|
||||||
|
FehlzeitenLang,
|
||||||
|
OffenePeriods,
|
||||||
|
Klassenbuchabgleich,
|
||||||
|
Hausaufgabenabgleich,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Wann welcher WebUntis-Abgleich ("Untis-Hub"-Job, siehe TODO.md) zuletzt lief und mit
|
||||||
|
/// welchem Ergebnis - ein Datensatz pro (<see cref="Kind"/>, <see cref="GroupId"/>). Bei den drei
|
||||||
|
/// dashboard-weiten Jobs (<see cref="UntisHubJobKind.OffenePeriods"/>,
|
||||||
|
/// <see cref="UntisHubJobKind.Klassenbuchabgleich"/>, <see cref="UntisHubJobKind.Hausaufgabenabgleich"/>)
|
||||||
|
/// ist <see cref="GroupId"/> null; die beiden Fehlzeiten-Kadenzen sind je Lerngruppe getrennt.</summary>
|
||||||
|
public class UntisHubJobState
|
||||||
|
{
|
||||||
|
public Guid Id { get; set; } = Guid.NewGuid();
|
||||||
|
public UntisHubJobKind Kind { get; set; }
|
||||||
|
public Guid? GroupId { get; set; }
|
||||||
|
public DateTime? LastRunAt { get; set; }
|
||||||
|
public string? LastResultSummary { get; set; }
|
||||||
|
}
|
||||||
@@ -72,6 +72,7 @@ public class LiteDbContext : IDisposable
|
|||||||
public ILiteCollection<UntisAbsenceCacheEntry> UntisAbsenceCache => _db.GetCollection<UntisAbsenceCacheEntry>("untis_absence_cache");
|
public ILiteCollection<UntisAbsenceCacheEntry> UntisAbsenceCache => _db.GetCollection<UntisAbsenceCacheEntry>("untis_absence_cache");
|
||||||
public ILiteCollection<UntisClassRegisterCacheEntry> UntisClassRegisterCache => _db.GetCollection<UntisClassRegisterCacheEntry>("untis_classregister_cache");
|
public ILiteCollection<UntisClassRegisterCacheEntry> UntisClassRegisterCache => _db.GetCollection<UntisClassRegisterCacheEntry>("untis_classregister_cache");
|
||||||
public ILiteCollection<UntisCacheFetchState> UntisCacheFetchStates => _db.GetCollection<UntisCacheFetchState>("untis_cache_fetch_state");
|
public ILiteCollection<UntisCacheFetchState> UntisCacheFetchStates => _db.GetCollection<UntisCacheFetchState>("untis_cache_fetch_state");
|
||||||
|
public ILiteCollection<UntisHubJobState> UntisHubJobStates => _db.GetCollection<UntisHubJobState>("untis_hub_job_states");
|
||||||
public ILiteCollection<UntisStudentRosterCacheEntry> UntisStudentRosterCache => _db.GetCollection<UntisStudentRosterCacheEntry>("untis_student_roster_cache");
|
public ILiteCollection<UntisStudentRosterCacheEntry> UntisStudentRosterCache => _db.GetCollection<UntisStudentRosterCacheEntry>("untis_student_roster_cache");
|
||||||
public ILiteCollection<AnnualPlanEvent> AnnualPlanEvents => _db.GetCollection<AnnualPlanEvent>("annual_plan_events");
|
public ILiteCollection<AnnualPlanEvent> AnnualPlanEvents => _db.GetCollection<AnnualPlanEvent>("annual_plan_events");
|
||||||
public ILiteCollection<TrashedItem> TrashedItems => _db.GetCollection<TrashedItem>("trash");
|
public ILiteCollection<TrashedItem> TrashedItems => _db.GetCollection<TrashedItem>("trash");
|
||||||
|
|||||||
@@ -899,6 +899,16 @@ public class UntisCacheFetchStateRepository(LiteDbContext db) : IUntisCacheFetch
|
|||||||
public void Save(UntisCacheFetchState state) => db.UntisCacheFetchStates.Upsert(state);
|
public void Save(UntisCacheFetchState state) => db.UntisCacheFetchStates.Upsert(state);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public class UntisHubJobStateRepository(LiteDbContext db) : IUntisHubJobStateRepository
|
||||||
|
{
|
||||||
|
public UntisHubJobState? Get(UntisHubJobKind kind, Guid? groupId) =>
|
||||||
|
db.UntisHubJobStates.FindOne(s => s.Kind == kind && s.GroupId == groupId);
|
||||||
|
|
||||||
|
public List<UntisHubJobState> GetAll() => db.UntisHubJobStates.FindAll().ToList();
|
||||||
|
|
||||||
|
public void Save(UntisHubJobState state) => db.UntisHubJobStates.Upsert(state);
|
||||||
|
}
|
||||||
|
|
||||||
public class CompetencyDomainRepository(LiteDbContext db) : ICompetencyDomainRepository
|
public class CompetencyDomainRepository(LiteDbContext db) : ICompetencyDomainRepository
|
||||||
{
|
{
|
||||||
public List<CompetencyDomain> GetBySubjectAndGrade(Guid subjectId, int gradeLevel) =>
|
public List<CompetencyDomain> GetBySubjectAndGrade(Guid subjectId, int gradeLevel) =>
|
||||||
|
|||||||
@@ -74,7 +74,8 @@ 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(), timeEntries ?? new FakeTimeEntries(), annualPlanEvents);
|
substitutions ?? new FakeSubstitutionEntries(), timeEntries ?? new FakeTimeEntries(),
|
||||||
|
TestSupport.BuildUntisHubService(), TestSupport.BuildWebUntisIntegrationService(), 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
|
||||||
|
|||||||
@@ -39,6 +39,14 @@ public static class TestSupport
|
|||||||
return new WebUntisSettingsService(tempPath);
|
return new WebUntisSettingsService(tempPath);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Nicht konfiguriert (kein API-Login hinterlegt) - genügt für Tests, die nur den Konstruktor
|
||||||
|
/// bedienen müssen und keinen echten WebUntis-Zugriff auslösen.
|
||||||
|
public static WebUntisIntegrationService BuildWebUntisIntegrationService() =>
|
||||||
|
new(new HttpClient(), BuildWebUntisSettingsService());
|
||||||
|
|
||||||
|
public static UntisHubService BuildUntisHubService(List<LearningGroup>? groups = null) =>
|
||||||
|
new(new FakeGroups(groups ?? []), new FakeUntisHubJobStates(), new SchoolYearService());
|
||||||
|
|
||||||
/// Analog zu den übrigen dateibasierten Feed-Einstellungen: eigenes Temp-Verzeichnis.
|
/// Analog zu den übrigen dateibasierten Feed-Einstellungen: eigenes Temp-Verzeichnis.
|
||||||
public static AnnualPlanSettingsService BuildAnnualPlanSettingsService()
|
public static AnnualPlanSettingsService BuildAnnualPlanSettingsService()
|
||||||
{
|
{
|
||||||
@@ -525,6 +533,19 @@ public class FakeUntisCacheFetchStates : IUntisCacheFetchStateRepository
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public class FakeUntisHubJobStates : IUntisHubJobStateRepository
|
||||||
|
{
|
||||||
|
private readonly List<UntisHubJobState> _all = [];
|
||||||
|
public UntisHubJobState? Get(UntisHubJobKind kind, Guid? groupId) =>
|
||||||
|
_all.FirstOrDefault(s => s.Kind == kind && s.GroupId == groupId);
|
||||||
|
public List<UntisHubJobState> GetAll() => _all.ToList();
|
||||||
|
public void Save(UntisHubJobState state)
|
||||||
|
{
|
||||||
|
_all.RemoveAll(s => s.Kind == state.Kind && s.GroupId == state.GroupId);
|
||||||
|
_all.Add(state);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public class FakeWorkTasks : IWorkTaskRepository
|
public class FakeWorkTasks : IWorkTaskRepository
|
||||||
{
|
{
|
||||||
private readonly List<WorkTask> _all = [];
|
private readonly List<WorkTask> _all = [];
|
||||||
|
|||||||
@@ -0,0 +1,111 @@
|
|||||||
|
using LehrerApp.Core.Models;
|
||||||
|
using LehrerApp.Desktop.Services;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace LehrerApp.Desktop.Tests;
|
||||||
|
|
||||||
|
public sealed class UntisHubServiceTests
|
||||||
|
{
|
||||||
|
private static readonly DateTime UtcNow = new(2026, 9, 10, 8, 0, 0, DateTimeKind.Utc);
|
||||||
|
|
||||||
|
private static LearningGroup Group(string name, int? webUntisLessonId, bool isActive = true) =>
|
||||||
|
new() { Name = name, WebUntisLessonId = webUntisLessonId, IsActive = isActive };
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void BuildRows_ProGruppeMitLessonId_ZweiFehlzeitenzeilenPlusDreiGlobaleZeilen()
|
||||||
|
{
|
||||||
|
var group = Group("9a", 42);
|
||||||
|
|
||||||
|
var rows = UntisHubService.BuildRows([group], [], UtcNow);
|
||||||
|
|
||||||
|
Assert.Equal(5, rows.Count);
|
||||||
|
Assert.Equal(2, rows.Count(r => r.GroupId == group.Id));
|
||||||
|
Assert.Contains(rows, r => r.Kind == UntisHubJobKind.FehlzeitenKurz && r.GroupId == group.Id);
|
||||||
|
Assert.Contains(rows, r => r.Kind == UntisHubJobKind.FehlzeitenLang && r.GroupId == group.Id);
|
||||||
|
Assert.Contains(rows, r => r.Kind == UntisHubJobKind.OffenePeriods && r.GroupId == null);
|
||||||
|
Assert.Contains(rows, r => r.Kind == UntisHubJobKind.Klassenbuchabgleich && r.GroupId == null);
|
||||||
|
Assert.Contains(rows, r => r.Kind == UntisHubJobKind.Hausaufgabenabgleich && r.GroupId == null);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void BuildRows_NieGeprueft_GiltAlsUeberfaellig()
|
||||||
|
{
|
||||||
|
var rows = UntisHubService.BuildRows([], [], UtcNow);
|
||||||
|
|
||||||
|
Assert.All(rows, r => Assert.Equal(UntisHubDueState.Overdue, r.DueState));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void BuildRows_FehlzeitenKurzVorSechsTagen_GiltAlsOk()
|
||||||
|
{
|
||||||
|
var group = Group("9a", 42);
|
||||||
|
var state = new UntisHubJobState
|
||||||
|
{
|
||||||
|
Kind = UntisHubJobKind.FehlzeitenKurz, GroupId = group.Id, LastRunAt = UtcNow.AddDays(-6),
|
||||||
|
};
|
||||||
|
|
||||||
|
var rows = UntisHubService.BuildRows([group], [state], UtcNow);
|
||||||
|
|
||||||
|
var row = rows.Single(r => r.Kind == UntisHubJobKind.FehlzeitenKurz);
|
||||||
|
Assert.Equal(UntisHubDueState.Ok, row.DueState);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void BuildRows_FehlzeitenKurzVorSechzehnTagen_GiltAlsFaellig()
|
||||||
|
{
|
||||||
|
var group = Group("9a", 42);
|
||||||
|
var state = new UntisHubJobState
|
||||||
|
{
|
||||||
|
Kind = UntisHubJobKind.FehlzeitenKurz, GroupId = group.Id, LastRunAt = UtcNow.AddDays(-16),
|
||||||
|
};
|
||||||
|
|
||||||
|
var rows = UntisHubService.BuildRows([group], [state], UtcNow);
|
||||||
|
|
||||||
|
var row = rows.Single(r => r.Kind == UntisHubJobKind.FehlzeitenKurz);
|
||||||
|
Assert.Equal(UntisHubDueState.Due, row.DueState);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void BuildRows_FehlzeitenKurzVorZweiundzwanzigTagen_GiltAlsUeberfaellig()
|
||||||
|
{
|
||||||
|
var group = Group("9a", 42);
|
||||||
|
var state = new UntisHubJobState
|
||||||
|
{
|
||||||
|
Kind = UntisHubJobKind.FehlzeitenKurz, GroupId = group.Id, LastRunAt = UtcNow.AddDays(-22),
|
||||||
|
};
|
||||||
|
|
||||||
|
var rows = UntisHubService.BuildRows([group], [state], UtcNow);
|
||||||
|
|
||||||
|
var row = rows.Single(r => r.Kind == UntisHubJobKind.FehlzeitenKurz);
|
||||||
|
Assert.Equal(UntisHubDueState.Overdue, row.DueState);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void BuildRows_FehlzeitenLangHatDeutlichLaengereKadenzAlsKurz()
|
||||||
|
{
|
||||||
|
var group = Group("9a", 42);
|
||||||
|
var lastRunAt = UtcNow.AddDays(-40);
|
||||||
|
var states = new List<UntisHubJobState>
|
||||||
|
{
|
||||||
|
new() { Kind = UntisHubJobKind.FehlzeitenKurz, GroupId = group.Id, LastRunAt = lastRunAt },
|
||||||
|
new() { Kind = UntisHubJobKind.FehlzeitenLang, GroupId = group.Id, LastRunAt = lastRunAt },
|
||||||
|
};
|
||||||
|
|
||||||
|
var rows = UntisHubService.BuildRows([group], states, UtcNow);
|
||||||
|
|
||||||
|
Assert.Equal(UntisHubDueState.Overdue, rows.Single(r => r.Kind == UntisHubJobKind.FehlzeitenKurz).DueState);
|
||||||
|
Assert.Equal(UntisHubDueState.Ok, rows.Single(r => r.Kind == UntisHubJobKind.FehlzeitenLang).DueState);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void BuildRows_GruppeOhneWebUntisLessonId_WirdVonAufrufseiteAusgeschlossen()
|
||||||
|
{
|
||||||
|
// GetRows() filtert vorab auf WebUntisLessonId != null (siehe UntisHubService.GetRows) -
|
||||||
|
// BuildRows selbst bekommt bereits nur die eligible-Gruppen übergeben.
|
||||||
|
var eligible = new List<LearningGroup> { Group("9a", 42) };
|
||||||
|
|
||||||
|
var rows = UntisHubService.BuildRows(eligible, [], UtcNow);
|
||||||
|
|
||||||
|
Assert.Equal(2, rows.Count(r => r.GroupId != null));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -185,6 +185,7 @@ public static class AppBootstrapper
|
|||||||
services.AddSingleton<IUntisClassRegisterCacheRepository, UntisClassRegisterCacheRepository>();
|
services.AddSingleton<IUntisClassRegisterCacheRepository, UntisClassRegisterCacheRepository>();
|
||||||
services.AddSingleton<IUntisCacheFetchStateRepository, UntisCacheFetchStateRepository>();
|
services.AddSingleton<IUntisCacheFetchStateRepository, UntisCacheFetchStateRepository>();
|
||||||
services.AddSingleton<IUntisStudentRosterCacheRepository, UntisStudentRosterCacheRepository>();
|
services.AddSingleton<IUntisStudentRosterCacheRepository, UntisStudentRosterCacheRepository>();
|
||||||
|
services.AddSingleton<IUntisHubJobStateRepository, UntisHubJobStateRepository>();
|
||||||
|
|
||||||
// ── Services ──────────────────────────────────────────────────────────
|
// ── Services ──────────────────────────────────────────────────────────
|
||||||
services.AddSingleton<GradingService>();
|
services.AddSingleton<GradingService>();
|
||||||
@@ -248,6 +249,7 @@ public static class AppBootstrapper
|
|||||||
services.AddSingleton(sp => new SchoolWeatherService(new HttpClient(), syncSettings));
|
services.AddSingleton(sp => new SchoolWeatherService(new HttpClient(), syncSettings));
|
||||||
services.AddSingleton(sp => new WebUntisIntegrationService(new HttpClient(), untisSettings));
|
services.AddSingleton(sp => new WebUntisIntegrationService(new HttpClient(), untisSettings));
|
||||||
services.AddSingleton<UntisReportCacheService>();
|
services.AddSingleton<UntisReportCacheService>();
|
||||||
|
services.AddSingleton<UntisHubService>();
|
||||||
// War dieses Gerät schon eingeloggt, aber sync.key fehlt(e), wurde gerade eben (unten)
|
// 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
|
// 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.
|
// 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 ITimeEntryRepository _timeEntries;
|
||||||
private readonly IAnnualPlanEventRepository? _annualPlanEvents;
|
private readonly IAnnualPlanEventRepository? _annualPlanEvents;
|
||||||
private readonly SchoolWeatherService? _schoolWeather;
|
private readonly SchoolWeatherService? _schoolWeather;
|
||||||
|
private readonly UntisHubService _untisHub;
|
||||||
|
private readonly WebUntisIntegrationService _webUntis;
|
||||||
|
|
||||||
private const int OpenExcuseMaxAgeDays = 21;
|
private const int OpenExcuseMaxAgeDays = 21;
|
||||||
private const int SupportPlanDueWithinDays = 14;
|
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 AttentionSummary => AttentionCount == 1 ? "1 offener Punkt" : $"{AttentionCount} offene Punkte";
|
||||||
public string UpcomingSummary => UpcomingCount == 1 ? "1 Termin" : $"{UpcomingCount} Termine";
|
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,
|
public DashboardViewModel(IGroupRepository groups, ISubjectRepository subjects, ILessonRepository lessons,
|
||||||
IExamRepository exams, IExamResultRepository examResults, IGradeRepository grades,
|
IExamRepository exams, IExamResultRepository examResults, IGradeRepository grades,
|
||||||
IReportGradeRepository reportGrades, IGroupMembershipRepository memberships,
|
IReportGradeRepository reportGrades, IGroupMembershipRepository memberships,
|
||||||
@@ -153,6 +159,7 @@ public partial class DashboardViewModel : ObservableObject
|
|||||||
DashboardSettingsService dashboardSettings, ISchoolHolidayRepository schoolHolidays,
|
DashboardSettingsService dashboardSettings, ISchoolHolidayRepository schoolHolidays,
|
||||||
PublicHolidayService publicHolidays, SchoolCalendarSettingsService calendarSettings,
|
PublicHolidayService publicHolidays, SchoolCalendarSettingsService calendarSettings,
|
||||||
ISubstitutionEntryRepository substitutions, ITimeEntryRepository timeEntries,
|
ISubstitutionEntryRepository substitutions, ITimeEntryRepository timeEntries,
|
||||||
|
UntisHubService untisHub, WebUntisIntegrationService webUntis,
|
||||||
IAnnualPlanEventRepository? annualPlanEvents = null,
|
IAnnualPlanEventRepository? annualPlanEvents = null,
|
||||||
AnnualPlanSyncService? annualPlanSync = null, SchoolWeatherService? schoolWeather = null)
|
AnnualPlanSyncService? annualPlanSync = null, SchoolWeatherService? schoolWeather = null)
|
||||||
{
|
{
|
||||||
@@ -167,12 +174,28 @@ public partial class DashboardViewModel : ObservableObject
|
|||||||
_substitutions = substitutions;
|
_substitutions = substitutions;
|
||||||
_annualPlanEvents = annualPlanEvents;
|
_annualPlanEvents = annualPlanEvents;
|
||||||
_schoolWeather = schoolWeather;
|
_schoolWeather = schoolWeather;
|
||||||
|
_untisHub = untisHub;
|
||||||
|
_webUntis = webUntis;
|
||||||
if (annualPlanSync is not null)
|
if (annualPlanSync is not null)
|
||||||
{
|
{
|
||||||
annualPlanSync.DataChanged += () => Avalonia.Threading.Dispatcher.UIThread.Post(LoadCalendar);
|
annualPlanSync.DataChanged += () => Avalonia.Threading.Dispatcher.UIThread.Post(LoadCalendar);
|
||||||
}
|
}
|
||||||
LoadDashboardCards();
|
LoadDashboardCards();
|
||||||
Load();
|
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);
|
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="Foreground" Value="Red"/>
|
||||||
<Setter Property="FontWeight" Value="SemiBold"/>
|
<Setter Property="FontWeight" Value="SemiBold"/>
|
||||||
</Style>
|
</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">
|
<Style Selector="Border.daycell.selected">
|
||||||
<Setter Property="Background" Value="{DynamicResource SystemControlBackgroundAccentBrush}"/>
|
<Setter Property="Background" Value="{DynamicResource SystemControlBackgroundAccentBrush}"/>
|
||||||
<Setter Property="BorderThickness" Value="2"/>
|
<Setter Property="BorderThickness" Value="2"/>
|
||||||
@@ -26,9 +32,11 @@
|
|||||||
<TextBlock Text="{Binding CurrentDate}" FontSize="24" FontWeight="SemiBold"/>
|
<TextBlock Text="{Binding CurrentDate}" FontSize="24" FontWeight="SemiBold"/>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
<WrapPanel Grid.Row="1" Orientation="Horizontal" ItemSpacing="8" LineSpacing="8" Margin="0,10,0,0">
|
<WrapPanel Grid.Row="1" Orientation="Horizontal" ItemSpacing="8" LineSpacing="8" Margin="0,10,0,0">
|
||||||
<Button Content="Klassenbuch abgleichen…" Click="OnCompareWebUntisDocumentationClick"
|
<!-- Fasst die vier WebUntis-Abgleiche zusammen, die jetzt nur noch im Menü "WebUntis"
|
||||||
VerticalAlignment="Center"/>
|
erreichbar sind (siehe TODO.md, Untis-Hub) - Klick öffnet den Hub. -->
|
||||||
<Button Content="Fehlende Hausaufgaben abgleichen…" Click="OnCompareWebUntisHomeworkClick"
|
<Button Content="{Binding WebUntisHealthLabel}" Click="OnOpenUntisHubClick"
|
||||||
|
IsVisible="{Binding IsWebUntisHealthVisible}"
|
||||||
|
Classes="webuntishealth" Classes.warning="{Binding IsWebUntisHealthWarning}"
|
||||||
VerticalAlignment="Center"/>
|
VerticalAlignment="Center"/>
|
||||||
<Button Content="Bereiche anpassen" Command="{Binding ToggleDashboardSettingsCommand}"
|
<Button Content="Bereiche anpassen" Command="{Binding ToggleDashboardSettingsCommand}"
|
||||||
VerticalAlignment="Center"/>
|
VerticalAlignment="Center"/>
|
||||||
|
|||||||
@@ -1,14 +1,9 @@
|
|||||||
using Avalonia.Controls;
|
using Avalonia.Controls;
|
||||||
using Avalonia.Interactivity;
|
using Avalonia.Interactivity;
|
||||||
using LehrerApp.Core.Interfaces;
|
|
||||||
using LehrerApp.Core.Models;
|
using LehrerApp.Core.Models;
|
||||||
using LehrerApp.Core.Services;
|
|
||||||
using LehrerApp.Desktop.Services;
|
using LehrerApp.Desktop.Services;
|
||||||
using LehrerApp.Desktop.ViewModels;
|
using LehrerApp.Desktop.ViewModels;
|
||||||
using LehrerApp.Desktop.ViewModels.Groups;
|
using LehrerApp.Desktop.Views.UntisHub;
|
||||||
using LehrerApp.Desktop.ViewModels.Students;
|
|
||||||
using LehrerApp.Desktop.Views.Groups;
|
|
||||||
using LehrerApp.Desktop.Views.Students;
|
|
||||||
using LehrerApp.Desktop.Views.Workload;
|
using LehrerApp.Desktop.Views.Workload;
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
|
||||||
@@ -32,32 +27,11 @@ public partial class DashboardView : UserControl
|
|||||||
return await WorkTaskDialogHelper.ShowDialog(owner, startAsReminder: startAsReminder);
|
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;
|
var owner = TopLevel.GetTopLevel(this) as Window;
|
||||||
if (owner is null) return;
|
if (owner is null) return;
|
||||||
var dialogVm = new WebUntisDocumentationComparisonViewModel(
|
await new UntisHubDialog().ShowDialog(owner);
|
||||||
App.Services.GetRequiredService<WebUntisIntegrationService>(),
|
if (DataContext is DashboardViewModel vm) vm.RefreshWebUntisHealth();
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,6 +31,16 @@
|
|||||||
darunter als Overlay-Drawer mit Hamburger-Button (automatisch).
|
darunter als Overlay-Drawer mit Hamburger-Button (automatisch).
|
||||||
Kein eigener Code nötig.
|
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"
|
<DrawerPage x:Name="RootDrawer"
|
||||||
DrawerLength="220"
|
DrawerLength="220"
|
||||||
DrawerBehavior="Auto"
|
DrawerBehavior="Auto"
|
||||||
@@ -250,15 +260,6 @@
|
|||||||
<TextBlock Classes="navlabel" Text="Klassenlehrer"/>
|
<TextBlock Classes="navlabel" Text="Klassenlehrer"/>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
</Button>
|
</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"
|
<Button Classes="navitem" Classes.active="{Binding IsSettingsActive}" HorizontalAlignment="Stretch"
|
||||||
HorizontalContentAlignment="Left"
|
HorizontalContentAlignment="Left"
|
||||||
CornerRadius="6"
|
CornerRadius="6"
|
||||||
@@ -276,6 +277,7 @@
|
|||||||
</DrawerPage.Drawer>
|
</DrawerPage.Drawer>
|
||||||
|
|
||||||
</DrawerPage>
|
</DrawerPage>
|
||||||
|
</DockPanel>
|
||||||
|
|
||||||
<!-- Globale Suche und Schnellerfassung (14.2). Bewusst als Overlay auf der aktuellen Seite:
|
<!-- 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. -->
|
Der Nutzer behält den Kontext und kann mit Escape ohne Navigation zurückkehren. -->
|
||||||
|
|||||||
@@ -3,7 +3,9 @@ using Avalonia.Input;
|
|||||||
using Avalonia.Threading;
|
using Avalonia.Threading;
|
||||||
using LehrerApp.Desktop.Services;
|
using LehrerApp.Desktop.Services;
|
||||||
using LehrerApp.Desktop.ViewModels;
|
using LehrerApp.Desktop.ViewModels;
|
||||||
|
using LehrerApp.Desktop.Views.UntisHub;
|
||||||
using LehrerApp.Sync;
|
using LehrerApp.Sync;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
|
||||||
namespace LehrerApp.Desktop.Views;
|
namespace LehrerApp.Desktop.Views;
|
||||||
|
|
||||||
@@ -23,9 +25,24 @@ public partial class MainWindow : Window
|
|||||||
KeyDown += OnWindowKeyDown;
|
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)
|
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)
|
private void OnWindowKeyDown(object? sender, KeyEventArgs e)
|
||||||
|
|||||||
@@ -23,6 +23,10 @@ public sealed class OpenUntisPeriodsDialog : Window
|
|||||||
private UntisOpenPeriodsMeta? _meta;
|
private UntisOpenPeriodsMeta? _meta;
|
||||||
private int _yearId;
|
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()
|
public OpenUntisPeriodsDialog()
|
||||||
{
|
{
|
||||||
Title = "Offene WebUntis-Stunden";
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1995,6 +1995,34 @@ Hausaufgabe selbst (`Lesson.Homework`), das bleibt ein offener, separater Punkt.
|
|||||||
gemockt, `WebUntisLessonAbsenceComparisonViewModel`/`WebUntisDocumentationComparisonViewModel`
|
gemockt, `WebUntisLessonAbsenceComparisonViewModel`/`WebUntisDocumentationComparisonViewModel`
|
||||||
haben aus demselben Grund ebenfalls keine Tests.
|
haben aus demselben Grund ebenfalls keine Tests.
|
||||||
|
|
||||||
|
**Nachtrag zu 4.3, Untis-Hub (September 2026):** Nutzer-Feedback: die vier bestehenden WebUntis-
|
||||||
|
Abgleiche (Fehlzeiten je Lerngruppe, offene Stunden, Klassenbuch-, Hausaufgabenabgleich) liefen
|
||||||
|
bislang alle rein manuell und ohne jede Übersicht, wann welcher zuletzt lief — bei bewusst fehlendem
|
||||||
|
Caching (s.o.) besteht sonst das Risiko, denselben Bericht aus Unsicherheit zu oft abzurufen ("bei
|
||||||
|
WebUntis auffallen").
|
||||||
|
- Neuer `UntisHubService` ([LehrerApp.Desktop/Services/UntisHubService.cs](LehrerApp.Desktop/Services/UntisHubService.cs))
|
||||||
|
verwaltet nur Fälligkeits-Zeitstempel (`UntisHubJobState`, LiteDB-Collection
|
||||||
|
`untis_hub_job_states`) — **kein** eigener WebUntis-Zugriff, reine Lesefunktion aus der
|
||||||
|
lokalen DB. Der Fehlzeitenabgleich bekommt zwei Kadenzen je Lerngruppe mit gesetzter
|
||||||
|
`WebUntisLessonId` (kurzfristig alle 14 Tage/fällig, 21 Tage/überfällig; langfristig seit
|
||||||
|
Schuljahresbeginn alle 60/90 Tage), die drei dashboard-weiten Abgleiche je 14/21 Tage.
|
||||||
|
- `UntisHubActions` ([LehrerApp.Desktop/Services/UntisHubActions.cs](LehrerApp.Desktop/Services/UntisHubActions.cs))
|
||||||
|
führt die vier bestehenden Vergleichsdialoge unverändert aus und erkennt nicht-invasiv (über den
|
||||||
|
`Busy`-Übergang der jeweiligen ViewModels) einen tatsächlichen Ladeversuch, um danach den
|
||||||
|
Fälligkeitsstand zu aktualisieren — egal ob über den Hub oder das Menü ausgelöst.
|
||||||
|
- Neues Fenster `UntisHubDialog` ([LehrerApp.Desktop/Views/UntisHub/](LehrerApp.Desktop/Views/UntisHub/))
|
||||||
|
zeigt alle Job-Zeilen tabellarisch (Bereich, Prüfung, Fälligkeit, letztes Ergebnis, "Jetzt
|
||||||
|
prüfen") — rein manuell, keine automatischen Hintergrund-Requests.
|
||||||
|
- **Einstiegspunkte konsolidiert:** Nutzer wollte keine weiteren Einzelfunktionen in der Sidebar.
|
||||||
|
`MainWindow` bekam dafür ihre erste klassische Menüleiste (bisher gab es keine) mit Menüpunkt
|
||||||
|
"WebUntis" (Untis-Hub, Offene Stunden, Klassenbuchabgleich, Hausaufgabenabgleich); der bisherige
|
||||||
|
Sidebar-Eintrag "Offene Untis-Stunden" sowie die beiden Dashboard-Buttons "Klassenbuch
|
||||||
|
abgleichen…"/"Fehlende Hausaufgaben abgleichen…" (siehe Nachtrag oben) sind dafür entfallen.
|
||||||
|
Auf dem Dashboard bleibt statt der Buttons nur noch ein kompakter Status-Badge ("WebUntis ✓" /
|
||||||
|
"WebUntis ⚠ N fällig", nur sichtbar wenn WebUntis konfiguriert ist), der den Hub öffnet.
|
||||||
|
Der Fehlzeitenabgleich-Button in der einzelnen Lerngruppe (`GroupDetailView`) blieb unverändert,
|
||||||
|
da kontextgebunden.
|
||||||
|
|
||||||
### 4.4 Wochen-/Tagesansicht
|
### 4.4 Wochen-/Tagesansicht
|
||||||
- [x] **4.4.1** Kalenderansicht über alle Gruppen: Woche und Tag — siehe Nachtrag zu 4.3
|
- [x] **4.4.1** Kalenderansicht über alle Gruppen: Woche und Tag — siehe Nachtrag zu 4.3
|
||||||
("Heute"-Tab: Tagesliste unten angedockt, gruppenübergreifendes Wochenraster darüber, inkl.
|
("Heute"-Tab: Tagesliste unten angedockt, gruppenübergreifendes Wochenraster darüber, inkl.
|
||||||
|
|||||||
Reference in New Issue
Block a user