Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dade41ee8d | ||
|
|
2b299fc940 | ||
|
|
dbd8777e64 | ||
|
|
89a6376fb6 | ||
|
|
b7a105af73 |
@@ -0,0 +1,24 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
jobs:
|
||||
build-and-test:
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: mcr.microsoft.com/dotnet/sdk:10.0
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Restore
|
||||
run: dotnet restore LehrerApp.sln
|
||||
|
||||
- name: Build
|
||||
run: dotnet build LehrerApp.sln --no-restore --configuration Release
|
||||
|
||||
- name: Test
|
||||
run: dotnet test LehrerApp.sln --no-build --configuration Release --logger "console;verbosity=normal"
|
||||
@@ -4,7 +4,7 @@
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<LangVersion>latest</LangVersion>
|
||||
<TreatWarningsAsErrors>false</TreatWarningsAsErrors>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
<!-- CS8618: falsch-positiv durch [ObservableProperty] Source Generator -->
|
||||
<NoWarn>CS8618</NoWarn>
|
||||
</PropertyGroup>
|
||||
|
||||
@@ -225,6 +225,9 @@ public interface IParticipationSessionRepository
|
||||
{
|
||||
List<ParticipationSession> GetByGroup(Guid groupId);
|
||||
ParticipationSession? GetById(Guid id);
|
||||
// Für Aggregationen über alle Gruppen (z.B. Dashboard-Fehlzeiten-Warnung), die sonst pro
|
||||
// Eintrag einzeln GetById aufrufen müssten — ein Bulk-Laden vermeidet dieses N+1-Muster.
|
||||
List<ParticipationSession> GetAll();
|
||||
void Save(ParticipationSession session);
|
||||
void Delete(Guid id);
|
||||
}
|
||||
|
||||
@@ -100,7 +100,10 @@ public class LessonPhaseStep
|
||||
}
|
||||
|
||||
public enum UnitStatus { Planned, Active, Completed }
|
||||
public enum LessonStatus { Planned, Conducted }
|
||||
// Planned=0 und Conducted=1 bleiben absichtlich an ihren bisherigen numerischen Positionen:
|
||||
// LiteDB hat diese Werte bereits gespeichert. Die neuen Zustände werden nur angehängt, damit
|
||||
// vorhandene Daten ohne Migration weiterhin korrekt gelesen werden.
|
||||
public enum LessonStatus { Planned = 0, Conducted = 1, Draft = 2, Ready = 3 }
|
||||
|
||||
/// <summary>
|
||||
/// Katalogeintrag für einen wiederverwendbaren "alternativen Ablauf" (z.B. "Kurzversion" bei
|
||||
|
||||
@@ -93,7 +93,7 @@ public interface IHasAttachments
|
||||
List<DocumentAttachment> Attachments { get; }
|
||||
}
|
||||
// Hinten angefügt, damit die numerischen Werte bereits gespeicherter LiteDB-Einträge stabil bleiben.
|
||||
public enum DocumentationType { Conversation, Incident, SupportPlan, Absence, ParentCall, ParentLetter }
|
||||
public enum DocumentationType { Conversation, Incident, SupportPlan, Absence, ParentCall, ParentLetter, Planning }
|
||||
public enum SupportStatus { Active, Completed, Paused }
|
||||
|
||||
public class WorkTask
|
||||
|
||||
@@ -14,6 +14,13 @@ public class BackupService
|
||||
|
||||
public string BackupDirectory => _backupDirectory;
|
||||
|
||||
/// Optionales zweites Sicherungsziel (z.B. USB-Stick, Netzlaufwerk) — Backups liegen sonst
|
||||
/// ausschließlich neben der Live-Datenbank und sind bei einem Plattenausfall oder einem
|
||||
/// versehentlich gelöschten Profilverzeichnis wertlos. Wird von außen gesetzt (Desktop-Settings,
|
||||
/// siehe BackupSettingsService); bewusst best-effort — ist das Ziel beim Sichern nicht
|
||||
/// erreichbar (Stick nicht eingesteckt, Freigabe offline), bleibt nur das Hauptbackup bestehen.
|
||||
public string? SecondaryBackupDirectory { get; set; }
|
||||
|
||||
public BackupService(string appDataPath)
|
||||
{
|
||||
_backupDirectory = Path.Combine(appDataPath, "backups");
|
||||
@@ -34,9 +41,36 @@ public class BackupService
|
||||
File.Copy(databasePath, target, overwrite: true);
|
||||
|
||||
PruneOldBackups(keepCount);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(SecondaryBackupDirectory))
|
||||
TryMirrorToSecondary(target, fileName, keepCount);
|
||||
|
||||
return target;
|
||||
}
|
||||
|
||||
private void TryMirrorToSecondary(string sourcePath, string fileName, int keepCount)
|
||||
{
|
||||
try
|
||||
{
|
||||
Directory.CreateDirectory(SecondaryBackupDirectory!);
|
||||
File.Copy(sourcePath, Path.Combine(SecondaryBackupDirectory!, fileName), overwrite: true);
|
||||
|
||||
foreach (var stale in Directory.GetFiles(SecondaryBackupDirectory!, "lehrerapp-*.db")
|
||||
.Select(p => new FileInfo(p))
|
||||
.OrderByDescending(f => f.LastWriteTime)
|
||||
.Skip(keepCount))
|
||||
{
|
||||
try { stale.Delete(); }
|
||||
catch { /* nächster Lauf versucht es erneut */ }
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Sicherungsziel nicht erreichbar — das Hauptbackup (_backupDirectory) ist davon
|
||||
// unberührt, ein manuelles/automatisches Backup soll daran nicht scheitern.
|
||||
}
|
||||
}
|
||||
|
||||
public List<BackupInfo> ListBackups() =>
|
||||
Directory.GetFiles(_backupDirectory, "lehrerapp-*.db")
|
||||
.Select(p => new BackupInfo(p, File.GetLastWriteTime(p), new FileInfo(p).Length))
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
using LehrerApp.Core.Interfaces;
|
||||
using LehrerApp.Core.Models;
|
||||
|
||||
namespace LehrerApp.Core.Services;
|
||||
|
||||
/// <summary>Gemeinsame Terminlogik für Verschieben und Trennen von Stunden.</summary>
|
||||
public sealed class LessonSchedulingService(ILessonRepository lessons)
|
||||
{
|
||||
public void Move(Lesson lesson, DateOnly newDate, int? newPeriod, bool shiftFollowing)
|
||||
{
|
||||
EnsureTargetIsFree(lesson, newDate, newPeriod);
|
||||
var oldDate = lesson.Date;
|
||||
var delta = newDate.DayNumber - oldDate.DayNumber;
|
||||
|
||||
if (shiftFollowing && delta != 0)
|
||||
{
|
||||
foreach (var other in lessons.GetByUnit(lesson.UnitId))
|
||||
{
|
||||
if (other.Id == lesson.Id || other.Status == LessonStatus.Conducted || other.Date <= oldDate)
|
||||
continue;
|
||||
other.Date = other.Date.AddDays(delta);
|
||||
lessons.Save(other);
|
||||
}
|
||||
}
|
||||
|
||||
lesson.Date = newDate;
|
||||
if (newPeriod.HasValue) lesson.LessonNumber = newPeriod;
|
||||
lessons.Save(lesson);
|
||||
}
|
||||
|
||||
public Lesson SplitAndMoveSecondPart(Lesson source, int splitAfterMinutes, DateOnly newDate,
|
||||
int newPeriod, TimeOnly? newStartTime)
|
||||
{
|
||||
if (splitAfterMinutes <= 0 || source.Phases.Sum(p => p.DurationMinutes) <= splitAfterMinutes)
|
||||
throw new InvalidOperationException("Der Verlauf reicht nicht über die erste Stunde hinaus.");
|
||||
EnsureTargetIsFree(source, newDate, newPeriod);
|
||||
|
||||
var first = new List<LessonPhaseStep>();
|
||||
var second = new List<LessonPhaseStep>();
|
||||
var elapsed = 0;
|
||||
foreach (var phase in source.Phases)
|
||||
{
|
||||
var remainingInFirst = splitAfterMinutes - elapsed;
|
||||
if (remainingInFirst <= 0)
|
||||
second.Add(Clone(phase, phase.DurationMinutes));
|
||||
else if (phase.DurationMinutes <= remainingInFirst)
|
||||
first.Add(Clone(phase, phase.DurationMinutes));
|
||||
else
|
||||
{
|
||||
first.Add(Clone(phase, remainingInFirst));
|
||||
second.Add(Clone(phase, phase.DurationMinutes - remainingInFirst));
|
||||
}
|
||||
elapsed += phase.DurationMinutes;
|
||||
}
|
||||
|
||||
source.Phases = first;
|
||||
var continuation = new Lesson
|
||||
{
|
||||
UnitId = source.UnitId,
|
||||
GroupId = source.GroupId,
|
||||
Date = newDate,
|
||||
LessonNumber = newPeriod,
|
||||
StartTime = newStartTime,
|
||||
Topic = string.IsNullOrWhiteSpace(source.Topic) ? "Fortsetzung" : $"{source.Topic} – Fortsetzung",
|
||||
Phases = second,
|
||||
Homework = source.Homework,
|
||||
HomeworkChecked = source.HomeworkChecked,
|
||||
HomeworkCheckDismissed = source.HomeworkCheckDismissed,
|
||||
Reflection = source.Reflection,
|
||||
Status = source.Status == LessonStatus.Conducted ? LessonStatus.Draft : source.Status,
|
||||
};
|
||||
source.Homework = null;
|
||||
source.HomeworkChecked = false;
|
||||
source.HomeworkCheckDismissed = false;
|
||||
source.Reflection = null;
|
||||
lessons.Save(source);
|
||||
lessons.Save(continuation);
|
||||
return continuation;
|
||||
}
|
||||
|
||||
private void EnsureTargetIsFree(Lesson source, DateOnly date, int? period)
|
||||
{
|
||||
if (period is null) return;
|
||||
var occupied = lessons.GetByGroupAndDate(source.GroupId, date)
|
||||
.Any(l => l.Id != source.Id && l.LessonNumber == period);
|
||||
if (occupied)
|
||||
throw new InvalidOperationException($"Für die Lerngruppe existiert am {date:dd.MM.yyyy} in der {period}. Stunde bereits eine Planung.");
|
||||
}
|
||||
|
||||
private static LessonPhaseStep Clone(LessonPhaseStep source, int duration) => new()
|
||||
{
|
||||
Name = source.Name,
|
||||
DurationMinutes = duration,
|
||||
Activity = source.Activity,
|
||||
Material = source.Material,
|
||||
Shorthand = source.Shorthand,
|
||||
AlternativePathId = source.AlternativePathId,
|
||||
};
|
||||
}
|
||||
@@ -60,6 +60,65 @@ public sealed class DatabaseEncryptionServiceTests
|
||||
Assert.Single(reopened.GetCollection<BsonDocument>("t").FindAll());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CanOpenAndRead_OhneVorhandeneDatei_GibtFalseZurueck()
|
||||
{
|
||||
using var temp = new TempDatabase();
|
||||
var service = new DatabaseEncryptionService();
|
||||
|
||||
Assert.False(service.CanOpenAndRead(temp.Path, null));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CanOpenAndRead_GueltigeUnverschluesselteDatenbank_GibtTrueZurueck()
|
||||
{
|
||||
using var temp = new TempDatabase();
|
||||
using (var db = new LiteDatabase(temp.Path))
|
||||
db.GetCollection<BsonDocument>("t").Insert(new BsonDocument { ["x"] = 1 });
|
||||
|
||||
var service = new DatabaseEncryptionService();
|
||||
|
||||
Assert.True(service.CanOpenAndRead(temp.Path, null));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CanOpenAndRead_GueltigeVerschluesselteDatenbankMitRichtigemPasswort_GibtTrueZurueck()
|
||||
{
|
||||
using var temp = new TempDatabase();
|
||||
using (var db = new LiteDatabase(new ConnectionString(temp.Path) { Password = "geheim123" }))
|
||||
db.GetCollection<BsonDocument>("t").Insert(new BsonDocument { ["x"] = 1 });
|
||||
|
||||
var service = new DatabaseEncryptionService();
|
||||
|
||||
Assert.True(service.CanOpenAndRead(temp.Path, "geheim123"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CanOpenAndRead_FalschesPasswort_GibtFalseZurueck()
|
||||
{
|
||||
using var temp = new TempDatabase();
|
||||
using (var db = new LiteDatabase(new ConnectionString(temp.Path) { Password = "geheim123" }))
|
||||
db.GetCollection<BsonDocument>("t").Insert(new BsonDocument { ["x"] = 1 });
|
||||
|
||||
var service = new DatabaseEncryptionService();
|
||||
|
||||
Assert.False(service.CanOpenAndRead(temp.Path, "falsch"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CanOpenAndRead_BeschaedigteDatei_GibtFalseZurueck()
|
||||
{
|
||||
using var temp = new TempDatabase();
|
||||
// Eine kurze Textdatei initialisiert LiteDB stillschweigend als neue, leere Datenbank
|
||||
// (kleiner als eine Seite) — erst genug Datenmüll jenseits der Seitengröße lässt das
|
||||
// Datei-Layout tatsächlich fehlschlagen und simuliert eine echte Beschädigung
|
||||
// (z.B. durch einen vollen Datenträger während der Backup-Kopie).
|
||||
File.WriteAllBytes(temp.Path, Enumerable.Repeat((byte)0x42, 32_768).ToArray());
|
||||
var service = new DatabaseEncryptionService();
|
||||
|
||||
Assert.False(service.CanOpenAndRead(temp.Path, null));
|
||||
}
|
||||
|
||||
private sealed class TempDatabase : IDisposable
|
||||
{
|
||||
private readonly string _directory = System.IO.Path.Combine(
|
||||
|
||||
@@ -37,6 +37,29 @@ public class DatabaseEncryptionService
|
||||
}
|
||||
}
|
||||
|
||||
/// Öffnet die Datei probeweise und liest die Sammlungsliste, um eine strukturell gültige
|
||||
/// (nicht nur vorhandene) LiteDB-Datei zu bestätigen — z.B. direkt nach dem Anlegen eines
|
||||
/// Backups, das durch einen vollen Datenträger oder einen vorzeitig entfernten USB-Stick
|
||||
/// beschädigt worden sein könnte. Bewusst breites Catch: jede Art von Fehlschlag beim Öffnen
|
||||
/// oder Lesen bedeutet hier "Backup nicht verifizierbar", nicht nur ein falsches Passwort.
|
||||
public bool CanOpenAndRead(string dbPath, string? password)
|
||||
{
|
||||
if (!File.Exists(dbPath)) return false;
|
||||
try
|
||||
{
|
||||
var connection = password is null
|
||||
? new ConnectionString(dbPath)
|
||||
: new ConnectionString(dbPath) { Password = password };
|
||||
using var db = new LiteDatabase(connection);
|
||||
_ = db.GetCollectionNames().ToList();
|
||||
return true;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Setzt (newPassword != null), ändert oder entfernt (newPassword == null) das
|
||||
/// Passwort der Datenbank. Der Aufrufer muss sicherstellen, dass keine andere
|
||||
/// Verbindung (z.B. der laufende <see cref="LiteDbContext"/>) die Datei offen hält.
|
||||
|
||||
@@ -492,6 +492,7 @@ public class ParticipationSessionRepository(LiteDbContext db) : IParticipationSe
|
||||
public List<ParticipationSession> GetByGroup(Guid groupId) =>
|
||||
db.ParticipationSessions.Find(s => s.GroupId == groupId).OrderByDescending(s => s.Date).ToList();
|
||||
public ParticipationSession? GetById(Guid id) => db.ParticipationSessions.FindById(id);
|
||||
public List<ParticipationSession> GetAll() => db.ParticipationSessions.FindAll().ToList();
|
||||
public void Save(ParticipationSession s)
|
||||
{
|
||||
ArchivedGroupWriteGuard.EnsureActive(db, s.GroupId);
|
||||
|
||||
@@ -59,12 +59,12 @@ public sealed class DashboardViewModelTests
|
||||
DashboardSettingsService? dashboardSettings = null, FakeSchoolHolidays? schoolHolidays = null,
|
||||
FakeLessons? lessons = null, FakeSubstitutionEntries? substitutions = null,
|
||||
FakeSessions? sessions = null, FakeEntries? entries = null,
|
||||
FakeAnnualPlanEvents? annualPlanEvents = null)
|
||||
FakeAnnualPlanEvents? annualPlanEvents = null, List<LearningGroup>? allGroups = null)
|
||||
{
|
||||
lessons ??= new FakeLessons();
|
||||
lessons.Add(lesson);
|
||||
return new DashboardViewModel(
|
||||
new FakeGroups([group]), new FakeSubjects([]), lessons,
|
||||
new FakeGroups(allGroups ?? [group]), new FakeSubjects([]), lessons,
|
||||
exams ?? new FakeExams([]), results ?? new FakeResults(), grades ?? new FakeGrades(),
|
||||
reportGrades ?? new FakeReportGrades(), memberships ?? new FakeMemberships([]),
|
||||
tasks ?? new FakeWorkTasks(), sessions ?? new FakeSessions([]), entries ?? new FakeEntries(),
|
||||
@@ -242,6 +242,26 @@ public sealed class DashboardViewModelTests
|
||||
Assert.Contains(vm.SelectedDayEvents, e => e.Kind == CalendarEventKind.Exam && e.Title == "Test");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Kalenderauswahl_SortiertKurseDesTagesVorDenAlphabetischenRest()
|
||||
{
|
||||
var today = DateOnly.FromDateTime(DateTime.Today);
|
||||
var selectedDate = today.AddDays(1);
|
||||
var alphabeticallyFirst = new LearningGroup { Name = "10a" };
|
||||
var selectedDayGroup = new LearningGroup { Name = "WAT 10c" };
|
||||
var lessons = new FakeLessons();
|
||||
lessons.Add(new Lesson { GroupId = selectedDayGroup.Id, Date = selectedDate });
|
||||
var vm = BuildVm(alphabeticallyFirst,
|
||||
new Lesson { GroupId = alphabeticallyFirst.Id, Date = today }, lessons: lessons,
|
||||
allGroups: [alphabeticallyFirst, selectedDayGroup]);
|
||||
|
||||
vm.SelectCalendarDayCommand.Execute(vm.CalendarDays.Single(d => d.Date == selectedDate));
|
||||
|
||||
Assert.Equal(selectedDayGroup.Id, vm.CurrentGroups[0].GroupId);
|
||||
Assert.True(vm.CurrentGroups[0].IsOnSelectedDay);
|
||||
Assert.Equal(alphabeticallyFirst.Id, vm.CurrentGroups[1].GroupId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Kalender_ZeigtMitarbeitssitzungenAlsEigenenTermintyp()
|
||||
{
|
||||
|
||||
@@ -247,6 +247,25 @@ public sealed class DocumentationDialogViewModelTests
|
||||
Assert.Equal(groupId, vm.Result.GroupId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GesamteLerngruppe_SaveSpeichertOhneSchuelerbezug()
|
||||
{
|
||||
var groupId = Guid.NewGuid();
|
||||
var wholeGroup = new StudentOption(Guid.Empty, "Gesamte Lerngruppe");
|
||||
var vm = new DocumentationDialogViewModel(Guid.Empty, null, new FakeAttachmentStorage(),
|
||||
[wholeGroup], groupId)
|
||||
{
|
||||
SelectedStudent = wholeGroup, Title = "Klausur planen", TypeName = "Planung / Erinnerung",
|
||||
};
|
||||
|
||||
vm.SaveCommand.Execute(null);
|
||||
|
||||
Assert.NotNull(vm.Result);
|
||||
Assert.Equal(Guid.Empty, vm.Result!.StudentId);
|
||||
Assert.Equal(groupId, vm.Result.GroupId);
|
||||
Assert.Equal(DocumentationType.Planning, vm.Result.Type);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MitStudentOptions_Bearbeiten_SchuelerIstVorausgewaehlt()
|
||||
{
|
||||
|
||||
@@ -29,6 +29,20 @@ public sealed class ExamsOverviewViewModelTests
|
||||
Assert.Equal("Neue Klausur", vm.Rows[1].Title);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LeereListe_BietetAbsprungZuLerngruppen()
|
||||
{
|
||||
var vm = BuildVm([], []);
|
||||
var navigated = false;
|
||||
vm.OnNavigateToGroups = () => navigated = true;
|
||||
|
||||
vm.LoadCommand.Execute(null);
|
||||
vm.GoToGroupsCommand.Execute(null);
|
||||
|
||||
Assert.True(vm.HasNoExams);
|
||||
Assert.True(navigated);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Load_ParallelkurseWerdenAlsGeschwisterErkannt()
|
||||
{
|
||||
|
||||
@@ -170,6 +170,7 @@ public class FakeSessions(List<ParticipationSession> all) : IParticipationSessio
|
||||
{
|
||||
public List<ParticipationSession> GetByGroup(Guid groupId) => all.Where(s => s.GroupId == groupId).ToList();
|
||||
public ParticipationSession? GetById(Guid id) => all.FirstOrDefault(s => s.Id == id);
|
||||
public List<ParticipationSession> GetAll() => all.ToList();
|
||||
public void Save(ParticipationSession session)
|
||||
{
|
||||
all.RemoveAll(s => s.Id == session.Id);
|
||||
|
||||
@@ -14,7 +14,8 @@ public sealed class GlobalSearchViewModelTests
|
||||
Assert.Collection(vm.Results,
|
||||
item => Assert.Equal(GlobalSearchAction.NewTask, item.Action),
|
||||
item => Assert.Equal(GlobalSearchAction.NewReminder, item.Action),
|
||||
item => Assert.Equal(GlobalSearchAction.NewStudent, item.Action));
|
||||
item => Assert.Equal(GlobalSearchAction.NewStudent, item.Action),
|
||||
item => Assert.Equal(GlobalSearchAction.NewGroupDocumentation, item.Action));
|
||||
Assert.Same(vm.Results[0], vm.SelectedResult);
|
||||
}
|
||||
|
||||
@@ -67,8 +68,36 @@ public sealed class GlobalSearchViewModelTests
|
||||
Assert.True(reminder);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Suche_FindetAktiveGruppeUeberFachUndZeigtFachImUntertitel()
|
||||
{
|
||||
var subject = new Subject { Name = "Wirtschaft-Arbeit-Technik", ShortName = "WAT" };
|
||||
var group = new LearningGroup { Name = "10c", SubjectId = subject.Id, SchoolYear = "2026/27", GradeLevel = 10 };
|
||||
var vm = BuildVm(groups: [group], subjects: [subject]);
|
||||
|
||||
vm.Query = "wat";
|
||||
|
||||
var result = Assert.Single(vm.Results, x => x.Kind == GlobalSearchResultKind.Group);
|
||||
Assert.Contains("WAT", result.Subtitle);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Suche_UeberspringtArchivierteSchuelerUndGruppenSamtDerenKlausuren()
|
||||
{
|
||||
var archivedGroup = new LearningGroup { Name = "Testkurs Vorjahr", IsActive = false };
|
||||
var archivedStudent = new Student { FirstName = "Test", LastName = "Archiv", IsActive = false };
|
||||
var exam = new Exam { GroupId = archivedGroup.Id, Title = "Testklausur" };
|
||||
var vm = BuildVm([archivedStudent], [archivedGroup], [exam]);
|
||||
|
||||
vm.Query = "Test";
|
||||
|
||||
Assert.DoesNotContain(vm.Results, x => x.Kind is GlobalSearchResultKind.Student
|
||||
or GlobalSearchResultKind.Group or GlobalSearchResultKind.Exam);
|
||||
}
|
||||
|
||||
private static GlobalSearchViewModel BuildVm(List<Student>? students = null,
|
||||
List<LearningGroup>? groups = null, List<Exam>? exams = null, FakeWorkTasks? tasks = null) =>
|
||||
List<LearningGroup>? groups = null, List<Exam>? exams = null, FakeWorkTasks? tasks = null,
|
||||
List<Subject>? subjects = null) =>
|
||||
new(new FakeStudents(students ?? []), new FakeGroups(groups ?? []),
|
||||
new FakeExams(exams ?? []), tasks ?? new FakeWorkTasks());
|
||||
new FakeSubjects(subjects ?? []), new FakeExams(exams ?? []), tasks ?? new FakeWorkTasks());
|
||||
}
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
using LehrerApp.Core.Models;
|
||||
using LehrerApp.Desktop.ViewModels.Students;
|
||||
using Xunit;
|
||||
|
||||
namespace LehrerApp.Desktop.Tests;
|
||||
|
||||
public sealed class GroupDocumentationQuickViewModelTests
|
||||
{
|
||||
[Fact]
|
||||
public void Save_ErstelltPlanungFuerGesamteLerngruppe()
|
||||
{
|
||||
var group = new GroupDocumentationOption(Guid.NewGuid(), "10c · WAT");
|
||||
var vm = new GroupDocumentationQuickViewModel([group])
|
||||
{
|
||||
SelectedGroup = group, Title = "Klausur ankündigen", Content = "Termin abstimmen",
|
||||
};
|
||||
|
||||
vm.SaveCommand.Execute(null);
|
||||
|
||||
Assert.NotNull(vm.Result);
|
||||
Assert.Equal(Guid.Empty, vm.Result!.StudentId);
|
||||
Assert.Equal(group.Id, vm.Result.GroupId);
|
||||
Assert.Equal(DocumentationType.Planning, vm.Result.Type);
|
||||
Assert.True(vm.Result.ExcludeFromWebUntisSync);
|
||||
}
|
||||
}
|
||||
@@ -36,6 +36,25 @@ public sealed class GroupDocumentationTabViewModelTests
|
||||
Assert.Equal("Alt", vm.Entries[1].Model.Title);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Initialize_ZeigtGruppenweitenEintragAuchOhneSchueler()
|
||||
{
|
||||
var group = new LearningGroup { Name = "10c" };
|
||||
var docs = new FakeDocumentation();
|
||||
docs.Add(new Documentation
|
||||
{
|
||||
StudentId = Guid.Empty, GroupId = group.Id, Type = DocumentationType.Planning,
|
||||
Title = "Klausur planen", Date = new DateOnly(2026, 9, 1),
|
||||
});
|
||||
|
||||
var vm = BuildVm([], [group], docs);
|
||||
vm.Initialize(group.Id);
|
||||
|
||||
var entry = Assert.Single(vm.Entries);
|
||||
Assert.Equal("Gesamte Lerngruppe", entry.StudentName);
|
||||
Assert.Equal("Planung / Erinnerung", entry.TypeLabel);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Initialize_EintragAusAndererGruppe_WirdMitangezeigtAberAlsFremdMarkiert()
|
||||
{
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
using LehrerApp.Core.Models;
|
||||
using LehrerApp.Core.Services;
|
||||
using LehrerApp.Desktop.ViewModels.Planning;
|
||||
using Xunit;
|
||||
|
||||
namespace LehrerApp.Desktop.Tests;
|
||||
|
||||
public sealed class LessonSchedulingServiceTests
|
||||
{
|
||||
[Fact]
|
||||
public void UnitPicker_SchlaegtLaufendeEinheitVor_UndSpeichertNeuanlageNochNicht()
|
||||
{
|
||||
var groupId = Guid.NewGuid();
|
||||
var planned = new Unit { GroupId = groupId, Title = "Später", Status = UnitStatus.Planned };
|
||||
var active = new Unit { GroupId = groupId, Title = "Aktuell", Status = UnitStatus.Active };
|
||||
var repo = new FakeUnits();
|
||||
repo.Add(planned); repo.Add(active);
|
||||
var vm = new TimetableUnitPickerViewModel(repo, groupId, "10c", new(2026, 9, 2), 3);
|
||||
|
||||
Assert.Equal(active.Id, vm.SelectedUnit?.Model.Id);
|
||||
vm.NewUnitTitle = "Neue Reihe";
|
||||
vm.SaveCommand.Execute(null);
|
||||
|
||||
Assert.True(vm.ResultIsNew);
|
||||
Assert.Equal("Neue Reihe", vm.Result?.Title);
|
||||
Assert.Null(repo.GetById(vm.Result!.Id));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Move_AendertDatumUndStunde_UndRuecktNurNichtDurchgefuehrteFolgestundenNach()
|
||||
{
|
||||
var unitId = Guid.NewGuid();
|
||||
var groupId = Guid.NewGuid();
|
||||
var moved = new Lesson { UnitId = unitId, GroupId = groupId, Date = new(2026, 9, 1), LessonNumber = 2 };
|
||||
var draftFollowing = new Lesson { UnitId = unitId, GroupId = groupId, Date = new(2026, 9, 3), Status = LessonStatus.Draft };
|
||||
var conducted = new Lesson { UnitId = unitId, GroupId = groupId, Date = new(2026, 9, 4), Status = LessonStatus.Conducted };
|
||||
var repo = new FakeLessons();
|
||||
repo.Add(moved); repo.Add(draftFollowing); repo.Add(conducted);
|
||||
|
||||
new LessonSchedulingService(repo).Move(moved, new(2026, 9, 8), 5, shiftFollowing: true);
|
||||
|
||||
Assert.Equal(new DateOnly(2026, 9, 8), moved.Date);
|
||||
Assert.Equal(5, moved.LessonNumber);
|
||||
Assert.Equal(new DateOnly(2026, 9, 10), draftFollowing.Date);
|
||||
Assert.Equal(new DateOnly(2026, 9, 4), conducted.Date);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SplitAndMoveSecondPart_TeiltAuchEineUeberDieGrenzeLaufendePhase()
|
||||
{
|
||||
var source = new Lesson
|
||||
{
|
||||
UnitId = Guid.NewGuid(), GroupId = Guid.NewGuid(), Date = new(2026, 9, 1),
|
||||
LessonNumber = 3, Topic = "Fotosynthese", Homework = "Aufgabe 2",
|
||||
Phases =
|
||||
[
|
||||
new LessonPhaseStep { Name = "Einstieg", DurationMinutes = 30 },
|
||||
new LessonPhaseStep { Name = "Experiment", DurationMinutes = 30 },
|
||||
new LessonPhaseStep { Name = "Sicherung", DurationMinutes = 20 },
|
||||
],
|
||||
};
|
||||
var repo = new FakeLessons();
|
||||
repo.Add(source);
|
||||
|
||||
var continuation = new LessonSchedulingService(repo).SplitAndMoveSecondPart(source, 45,
|
||||
new(2026, 9, 3), 6, new TimeOnly(12, 15));
|
||||
|
||||
Assert.Equal([30, 15], source.Phases.Select(p => p.DurationMinutes));
|
||||
Assert.Equal([15, 20], continuation.Phases.Select(p => p.DurationMinutes));
|
||||
Assert.Equal("Fotosynthese – Fortsetzung", continuation.Topic);
|
||||
Assert.Null(source.Homework);
|
||||
Assert.Equal("Aufgabe 2", continuation.Homework);
|
||||
Assert.Equal(6, continuation.LessonNumber);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Move_LehntBelegtenZielterminAb()
|
||||
{
|
||||
var groupId = Guid.NewGuid();
|
||||
var source = new Lesson { GroupId = groupId, Date = new(2026, 9, 1), LessonNumber = 1 };
|
||||
var occupied = new Lesson { GroupId = groupId, Date = new(2026, 9, 2), LessonNumber = 4 };
|
||||
var repo = new FakeLessons();
|
||||
repo.Add(source); repo.Add(occupied);
|
||||
|
||||
var error = Assert.Throws<InvalidOperationException>(() =>
|
||||
new LessonSchedulingService(repo).Move(source, occupied.Date, 4, false));
|
||||
|
||||
Assert.Contains("bereits eine Planung", error.Message);
|
||||
}
|
||||
}
|
||||
@@ -28,6 +28,7 @@ public sealed class SettingsViewModelTests
|
||||
return new SettingsViewModel(
|
||||
subjects ?? new FakeSubjects([]), competencyDomains ?? new FakeCompetencyDomains(), new FakeGradingKeyTemplates(),
|
||||
new FakeSchemes(), new GradingService(), new BackupService(tempPath),
|
||||
new BackupSettingsService(tempPath),
|
||||
new DatabaseEncryptionService(), new AppLockService(tempPath),
|
||||
new LiteDbContext(new MemoryStream()), new PrivacySettingsService(tempPath),
|
||||
new FakeDocumentation(), new FakeStudents([]), new FakeShorthandCodes([]),
|
||||
@@ -314,6 +315,7 @@ public sealed class SettingsViewModelTests
|
||||
var vm = new SettingsViewModel(
|
||||
new FakeSubjects([]), new FakeCompetencyDomains(), new FakeGradingKeyTemplates(),
|
||||
new FakeSchemes(), new GradingService(), new BackupService(tempPath),
|
||||
new BackupSettingsService(tempPath),
|
||||
new DatabaseEncryptionService(), new AppLockService(tempPath),
|
||||
new LiteDbContext(new MemoryStream()), new PrivacySettingsService(tempPath),
|
||||
new FakeDocumentation(), new FakeStudents([]), new FakeShorthandCodes([]),
|
||||
@@ -341,6 +343,7 @@ public sealed class SettingsViewModelTests
|
||||
var vm = new SettingsViewModel(
|
||||
new FakeSubjects([]), new FakeCompetencyDomains(), new FakeGradingKeyTemplates(),
|
||||
new FakeSchemes(), new GradingService(), new BackupService(tempPath),
|
||||
new BackupSettingsService(tempPath),
|
||||
new DatabaseEncryptionService(), new AppLockService(tempPath),
|
||||
new LiteDbContext(new MemoryStream()), new PrivacySettingsService(tempPath),
|
||||
new FakeDocumentation(), new FakeStudents([]), new FakeShorthandCodes([]),
|
||||
@@ -372,6 +375,7 @@ public sealed class SettingsViewModelTests
|
||||
var vm = new SettingsViewModel(
|
||||
new FakeSubjects([]), new FakeCompetencyDomains(), new FakeGradingKeyTemplates(),
|
||||
new FakeSchemes(), new GradingService(), new BackupService(tempPath),
|
||||
new BackupSettingsService(tempPath),
|
||||
new DatabaseEncryptionService(), new AppLockService(tempPath),
|
||||
new LiteDbContext(new MemoryStream()), new PrivacySettingsService(tempPath),
|
||||
new FakeDocumentation(), new FakeStudents([]), new FakeShorthandCodes([]),
|
||||
|
||||
@@ -51,6 +51,18 @@ public class StudentListViewModelTests
|
||||
Assert.Equal("M", vm.Students.Single(s => s.Id == _ben.Id).AvatarLabel);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SucheOhneTreffer_LiefertHilfreichenLeerzustand()
|
||||
{
|
||||
var vm = BuildViewModel();
|
||||
|
||||
vm.SearchText = "nicht vorhanden";
|
||||
|
||||
Assert.True(vm.HasNoStudents);
|
||||
Assert.False(vm.HasStudents);
|
||||
Assert.Contains("Suche", vm.EmptyListMessage);
|
||||
}
|
||||
|
||||
private StudentListViewModel BuildViewModel() => new(
|
||||
new FakeStudents([_anna, _ben]),
|
||||
new FakeGroups([_group]),
|
||||
|
||||
@@ -277,7 +277,23 @@ public sealed class TimetableViewModelTests
|
||||
|
||||
Assert.Equal(group.Id, navigatedTo);
|
||||
Assert.False(viewerOpened);
|
||||
Assert.Equal("Zur Lerngruppe", vm.TodayItems[0].OpenButtonLabel);
|
||||
Assert.Equal("Stunde anlegen", vm.TodayItems[0].OpenButtonLabel);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task OpenTodayLesson_OhneLesson_StartetDirektanlageMitExaktemTermin()
|
||||
{
|
||||
var today = DateOnly.FromDateTime(DateTime.Today);
|
||||
var group = new LearningGroup { Name = "Q1 Chemie" };
|
||||
var slots = new FakeTimetableSlots();
|
||||
slots.Add(new TimetableSlot { GroupId = group.Id, Weekday = today.DayOfWeek, PeriodNumber = 4 });
|
||||
var vm = BuildViewModel(slots, new FakeGroups([group]));
|
||||
TimetableLessonRequest? requested = null;
|
||||
vm.OnCreateLesson = request => { requested = request; return Task.CompletedTask; };
|
||||
|
||||
await vm.OpenTodayLessonCommand.ExecuteAsync(vm.TodayItems[0]);
|
||||
|
||||
Assert.Equal(new TimetableLessonRequest(group.Id, today, 4), requested);
|
||||
}
|
||||
|
||||
// ── Unterrichtsmodus (14.x) ────────────────────────────────────────────────
|
||||
|
||||
@@ -7,6 +7,17 @@
|
||||
<ResourceDictionary.MergedDictionaries>
|
||||
<ResourceInclude Source="avares://LehrerApp.Desktop/Styles/SemanticBrushes.axaml"/>
|
||||
</ResourceDictionary.MergedDictionaries>
|
||||
<!-- Plattformunabhängige Vektoricons für die Hauptnavigation (14.10). -->
|
||||
<StreamGeometry x:Key="IconSearch">M10,2 A8,8 0 1 0 10,18 A8,8 0 1 0 10,2 M10,5 A5,5 0 1 1 10,15 A5,5 0 1 1 10,5 M15,14 L22,21 L20,23 L13,16 Z</StreamGeometry>
|
||||
<StreamGeometry x:Key="IconDashboard">M3,3 H10 V10 H3 Z M14,3 H21 V7 H14 Z M14,11 H21 V21 H14 Z M3,14 H10 V21 H3 Z</StreamGeometry>
|
||||
<StreamGeometry x:Key="IconGroups">M3,8 L12,3 L21,8 V10 H3 Z M5,11 H8 V18 H5 Z M10,11 H14 V18 H10 Z M16,11 H19 V18 H16 Z M3,20 H21 V22 H3 Z</StreamGeometry>
|
||||
<StreamGeometry x:Key="IconStudents">M12,3 A4,4 0 1 0 12,11 A4,4 0 1 0 12,3 M4,21 C4,16 7,13 12,13 C17,13 20,16 20,21 Z</StreamGeometry>
|
||||
<StreamGeometry x:Key="IconExams">M5,2 H15 L20,7 V22 H5 Z M7,5 V19 H18 V9 H13 V4 H7 Z M9,11 H16 V13 H9 Z M9,15 H16 V17 H9 Z</StreamGeometry>
|
||||
<StreamGeometry x:Key="IconCalendar">M3,4 H21 V22 H3 Z M5,10 V20 H19 V10 Z M7,2 H9 V7 H7 Z M15,2 H17 V7 H15 Z M7,12 H10 V15 H7 Z M12,12 H15 V15 H12 Z M7,17 H10 V19 H7 Z M12,17 H15 V19 H12 Z</StreamGeometry>
|
||||
<StreamGeometry x:Key="IconClock">M12,2 A10,10 0 1 0 12,22 A10,10 0 1 0 12,2 M12,5 A7,7 0 1 1 12,19 A7,7 0 1 1 12,5 M11,7 H13 V12 L17,14 L16,16 L11,13 Z</StreamGeometry>
|
||||
<StreamGeometry x:Key="IconClassTeacher">M2,8 L12,3 L22,8 L12,13 Z M6,11 V16 C9,19 15,19 18,16 V11 M22,8 V15</StreamGeometry>
|
||||
<StreamGeometry x:Key="IconSettings">M3,5 H21 V7 H3 Z M8,3 A3,3 0 1 0 8,9 A3,3 0 1 0 8,3 M3,11 H21 V13 H3 Z M16,9 A3,3 0 1 0 16,15 A3,3 0 1 0 16,9 M3,17 H21 V19 H3 Z M10,15 A3,3 0 1 0 10,21 A3,3 0 1 0 10,15</StreamGeometry>
|
||||
<StreamGeometry x:Key="IconRefresh">M12,3 A9,9 0 0 1 21,12 H18 A6,6 0 0 0 8,7 L11,10 H3 V2 L6,5 A9,9 0 0 1 12,3 M12,21 A9,9 0 0 1 3,12 H6 A6,6 0 0 0 16,17 L13,14 H21 V22 L18,19 A9,9 0 0 1 12,21</StreamGeometry>
|
||||
</ResourceDictionary>
|
||||
</Application.Resources>
|
||||
|
||||
@@ -24,5 +35,17 @@
|
||||
<Setter Property="Opacity" Value="0.4"/>
|
||||
<Setter Property="FontSize" Value="13"/>
|
||||
</Style>
|
||||
<Style Selector="Button.touchTarget">
|
||||
<Setter Property="MinWidth" Value="40"/>
|
||||
<Setter Property="MinHeight" Value="40"/>
|
||||
</Style>
|
||||
<Style Selector="Button:focus-visible">
|
||||
<Setter Property="BorderBrush" Value="{DynamicResource SystemAccentColor}"/>
|
||||
<Setter Property="BorderThickness" Value="2"/>
|
||||
</Style>
|
||||
<Style Selector="TextBox:focus-visible, ComboBox:focus-visible">
|
||||
<Setter Property="BorderBrush" Value="{DynamicResource SystemAccentColor}"/>
|
||||
<Setter Property="BorderThickness" Value="2"/>
|
||||
</Style>
|
||||
</Application.Styles>
|
||||
</Application>
|
||||
|
||||
@@ -154,6 +154,9 @@ public class App : Application
|
||||
dash.OnNavigateToExam = id => main.NavigateToGroupDetail(id, 4); // Tab "Klausuren"
|
||||
dash.OnNavigateToUnplannedLesson = id => main.NavigateToGroupDetail(id, 6); // Tab "Planung"
|
||||
|
||||
var examsOverview = Services.GetRequiredService<ViewModels.Exams.ExamsOverviewViewModel>();
|
||||
examsOverview.OnNavigateToGroups = () => main.NavigateToCommand.Execute(NavItem.Groups);
|
||||
|
||||
// Globale Suche/Schnellerfassung (14.2): Navigation bleibt im MainWindow-VM, die
|
||||
// vorhandenen Dialog-Helfer übernehmen Eingabe und Validierung.
|
||||
var search = Services.GetRequiredService<GlobalSearchViewModel>();
|
||||
@@ -170,6 +173,7 @@ public class App : Application
|
||||
};
|
||||
search.OnQuickAddTask = startAsReminder => ShowQuickTaskDialog(startAsReminder, dash);
|
||||
search.OnQuickAddStudent = ShowAddStudentDialog;
|
||||
search.OnQuickAddGroupDocumentation = () => ShowQuickGroupDocumentationDialog(dash);
|
||||
|
||||
// StudentList → StudentDetail + Anlegen
|
||||
var sl = Services.GetRequiredService<StudentListViewModel>();
|
||||
@@ -203,4 +207,26 @@ public class App : Application
|
||||
dashboard.RefreshCommand.Execute(null);
|
||||
Services.GetRequiredService<WorkTaskListViewModel>().Load();
|
||||
}
|
||||
|
||||
private static async Task ShowQuickGroupDocumentationDialog(DashboardViewModel dashboard)
|
||||
{
|
||||
if (Application.Current?.ApplicationLifetime is not IClassicDesktopStyleApplicationLifetime
|
||||
{ MainWindow: { } owner }) return;
|
||||
|
||||
var subjects = Services.GetRequiredService<ISubjectRepository>().GetAll()
|
||||
.ToDictionary(s => s.Id);
|
||||
var options = Services.GetRequiredService<IGroupRepository>().GetAll()
|
||||
.Where(g => g.IsActive)
|
||||
.OrderBy(g => g.Name)
|
||||
.Select(g => new GroupDocumentationOption(g.Id,
|
||||
g.SubjectId is { } subjectId && subjects.TryGetValue(subjectId, out var subject)
|
||||
? $"{g.Name} · {(string.IsNullOrWhiteSpace(subject.ShortName) ? subject.Name : subject.ShortName)}"
|
||||
: g.Name));
|
||||
var vm = new GroupDocumentationQuickViewModel(options);
|
||||
var dialog = new Views.Students.GroupDocumentationQuickDialog { DataContext = vm };
|
||||
if (!await dialog.ShowDialog<bool>(owner) || vm.Result is null) return;
|
||||
|
||||
Services.GetRequiredService<IDocumentationRepository>().Save(vm.Result);
|
||||
dashboard.RefreshCommand.Execute(null);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -121,9 +121,15 @@ public static class AppBootstrapper
|
||||
// ── Datensicherheit (13.3) ───────────────────────────────────────────
|
||||
// Backup läuft vor dem Öffnen der Datenbank, rein dateibasiert (unabhängig von
|
||||
// Verschlüsselung) — reine Datei-Kopie, kein offener LiteDB-Handle nötig.
|
||||
var backup = new BackupService(appData);
|
||||
backup.CreateBackup(DbPath);
|
||||
var backupSettings = new BackupSettingsService(appData);
|
||||
var backup = new BackupService(appData) { SecondaryBackupDirectory = backupSettings.LoadSecondaryDirectory() };
|
||||
var backupPath = backup.CreateBackup(DbPath);
|
||||
// Best-effort-Prüfung des automatischen Startbackups: nur geloggt, kein Blocker für den
|
||||
// Programmstart — ein beschädigtes Backup soll auffallen, nicht den Nutzer aufhalten.
|
||||
if (backupPath is not null && !new DatabaseEncryptionService().CanOpenAndRead(backupPath, DbPassword))
|
||||
Logger.Warn($"Automatisches Backup {backupPath} lässt sich nicht öffnen/lesen — möglicherweise beschädigt.");
|
||||
services.AddSingleton(backup);
|
||||
services.AddSingleton(backupSettings);
|
||||
services.AddSingleton(_ => new AppLockService(appData));
|
||||
services.AddSingleton<DatabaseEncryptionService>();
|
||||
services.AddSingleton(_ => new PrivacySettingsService(appData));
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
using System.Text.Json;
|
||||
|
||||
namespace LehrerApp.Desktop.Services;
|
||||
|
||||
internal sealed class BackupSettingsConfig
|
||||
{
|
||||
public string? SecondaryDirectory { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>Merkt sich einen optionalen zweiten Sicherungsordner (13.3.1 Nachtrag) — z.B. ein
|
||||
/// USB-Stick oder Netzlaufwerk, damit ein Backup nicht ausschließlich neben der Live-Datenbank
|
||||
/// liegt und bei Plattenausfall oder gelöschtem Profilverzeichnis mit verloren geht. Gleiches
|
||||
/// Muster wie <see cref="AppearanceSettingsService"/>. Das eigentliche Kopieren übernimmt
|
||||
/// <see cref="LehrerApp.Core.Services.BackupService"/> bewusst best-effort.</summary>
|
||||
public sealed class BackupSettingsService
|
||||
{
|
||||
private readonly string _configPath;
|
||||
|
||||
public BackupSettingsService(string appDataPath) =>
|
||||
_configPath = Path.Combine(appDataPath, "backupsettings.json");
|
||||
|
||||
public string? LoadSecondaryDirectory()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (File.Exists(_configPath))
|
||||
return JsonSerializer.Deserialize<BackupSettingsConfig>(File.ReadAllText(_configPath))
|
||||
?.SecondaryDirectory;
|
||||
}
|
||||
catch { /* beschädigte Konfiguration -> kein zweites Ziel */ }
|
||||
return null;
|
||||
}
|
||||
|
||||
public void SaveSecondaryDirectory(string? path) =>
|
||||
File.WriteAllText(_configPath, JsonSerializer.Serialize(new BackupSettingsConfig { SecondaryDirectory = path }));
|
||||
}
|
||||
@@ -56,6 +56,7 @@ public partial class DashboardViewModel : ObservableObject
|
||||
[ObservableProperty] private string _currentSchoolYear = "";
|
||||
[ObservableProperty] private DateOnly _calendarMonth = FirstOfMonth(DateTime.Today);
|
||||
[ObservableProperty] private string _selectedDayLabel = "";
|
||||
[ObservableProperty] private DateOnly _selectedCalendarDate = DateOnly.FromDateTime(DateTime.Today);
|
||||
[ObservableProperty] private bool _isDashboardSettingsOpen;
|
||||
[ObservableProperty] private bool _isWeatherPanelVisible;
|
||||
[ObservableProperty] private string _weatherSummary = "";
|
||||
@@ -196,7 +197,7 @@ public partial class DashboardViewModel : ObservableObject
|
||||
IsHighPriority = t.Priority == TaskPriority.High });
|
||||
|
||||
CurrentGroups.Clear();
|
||||
foreach (var g in groups.Values.OrderBy(g => g.Name))
|
||||
foreach (var g in groups.Values)
|
||||
CurrentGroups.Add(new()
|
||||
{
|
||||
GroupId = g.Id,
|
||||
@@ -300,12 +301,16 @@ public partial class DashboardViewModel : ObservableObject
|
||||
var from = _sy.SchoolYearStart(schoolYear);
|
||||
var to = _sy.SchoolYearEnd(schoolYear);
|
||||
|
||||
// Ein Bulk-Laden aller Sitzungen vermeidet, für jeden Mitarbeits-Eintrag jedes Schülers
|
||||
// einzeln GetById aufzurufen (N+1 bei vielen Schülern/Einträgen).
|
||||
var sessionDates = _participationSessions.GetAll().ToDictionary(s => s.Id, s => s.Date);
|
||||
|
||||
var items = new List<AttendanceWarningItem>();
|
||||
foreach (var student in _students.GetAll())
|
||||
{
|
||||
var entries = _participationEntries.GetByStudent(student.Id)
|
||||
.Select(e => _participationSessions.GetById(e.SessionId) is { } session
|
||||
? ((DateOnly?)session.Date, e.Attendance) : (null, e.Attendance))
|
||||
.Select(e => sessionDates.TryGetValue(e.SessionId, out var date)
|
||||
? ((DateOnly?)date, e.Attendance) : (null, e.Attendance))
|
||||
.Where(t => t.Item1.HasValue)
|
||||
.Select(t => (t.Item1!.Value, t.Attendance));
|
||||
|
||||
@@ -749,9 +754,35 @@ public partial class DashboardViewModel : ObservableObject
|
||||
{
|
||||
if (day is null) return;
|
||||
foreach (var cell in CalendarDays) cell.IsSelected = cell == day;
|
||||
SelectedCalendarDate = day.Date;
|
||||
SelectedDayLabel = day.Date.ToString("dddd, d. MMMM", De);
|
||||
SelectedDayEvents.Clear();
|
||||
foreach (var item in day.Events) SelectedDayEvents.Add(item);
|
||||
SortCurrentGroups(day.Date);
|
||||
}
|
||||
|
||||
private void SortCurrentGroups(DateOnly date)
|
||||
{
|
||||
var schoolHolidays = _schoolHolidays.GetAll();
|
||||
var publicHolidays = _publicHolidays.GetHolidays(date.Year, _calendarSettings.State)
|
||||
.Select(h => h.Date).ToHashSet();
|
||||
var isFreeDay = IsFreeDay(date, schoolHolidays, publicHolidays);
|
||||
var cancelledPeriods = _substitutions.GetByDate(date)
|
||||
.Where(s => s.Kind == SubstitutionKind.Cancelled)
|
||||
.Select(s => s.PeriodNumber).ToHashSet();
|
||||
|
||||
foreach (var chip in CurrentGroups)
|
||||
{
|
||||
var hasLesson = _lessons.GetByGroupAndDate(chip.GroupId, date).Count > 0;
|
||||
var hasActiveSlot = !isFreeDay && _timetableSlots.GetByGroup(chip.GroupId)
|
||||
.Any(s => s.Weekday == date.DayOfWeek && !cancelledPeriods.Contains(s.PeriodNumber));
|
||||
chip.IsOnSelectedDay = hasLesson || hasActiveSlot;
|
||||
}
|
||||
|
||||
var sorted = CurrentGroups.OrderByDescending(g => g.IsOnSelectedDay)
|
||||
.ThenBy(g => g.Name, StringComparer.CurrentCultureIgnoreCase).ToList();
|
||||
CurrentGroups.Clear();
|
||||
foreach (var chip in sorted) CurrentGroups.Add(chip);
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
@@ -891,7 +922,13 @@ public class LessonItem
|
||||
public bool HasRoom => !string.IsNullOrWhiteSpace(Room);
|
||||
}
|
||||
public class TaskItem { public string Title { get; set; } = ""; public string DueDate { get; set; } = ""; public bool IsOverdue { get; set; } public bool IsReminder { get; set; } public bool IsHighPriority { get; set; } }
|
||||
public class GroupChip { public Guid GroupId { get; set; } public string Name { get; set; } = ""; public string Subject { get; set; } = ""; }
|
||||
public class GroupChip
|
||||
{
|
||||
public Guid GroupId { get; set; }
|
||||
public string Name { get; set; } = "";
|
||||
public string Subject { get; set; } = "";
|
||||
public bool IsOnSelectedDay { get; set; }
|
||||
}
|
||||
|
||||
// ── Offene Entschuldigungen (aus Mitarbeit-Fehltagen) ────────────────────────
|
||||
|
||||
|
||||
@@ -43,6 +43,8 @@ public partial class ExamsOverviewViewModel : ObservableObject
|
||||
public Func<Exam, Task>? OnGradeExam { get; set; }
|
||||
public Func<Exam, Task>? OnEvaluateExam { get; set; }
|
||||
public Action<Guid>? OnNavigateToGroup { get; set; }
|
||||
public Action? OnNavigateToGroups { get; set; }
|
||||
public bool HasNoExams => Rows.Count == 0;
|
||||
|
||||
public ExamsOverviewViewModel(IExamRepository exams, IExamResultRepository examResults,
|
||||
IGroupRepository groups, IGroupMembershipRepository memberships, GradingService grading,
|
||||
@@ -85,6 +87,7 @@ public partial class ExamsOverviewViewModel : ObservableObject
|
||||
Rows.Add(row);
|
||||
|
||||
EmptyHint = Rows.Count == 0 ? "Keine Klausuren angelegt." : "";
|
||||
OnPropertyChanged(nameof(HasNoExams));
|
||||
SelectedRow = selectedId is { } id
|
||||
? Rows.FirstOrDefault(r => r.Exam.Id == id) ?? Rows.FirstOrDefault()
|
||||
: Rows.FirstOrDefault();
|
||||
@@ -173,6 +176,9 @@ public partial class ExamsOverviewViewModel : ObservableObject
|
||||
OnNavigateToGroup?.Invoke(SelectedRow.GroupId);
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void GoToGroups() => OnNavigateToGroups?.Invoke();
|
||||
|
||||
[RelayCommand]
|
||||
private void ToggleApproval() => ToggleDate(SelectedRow?.Exam, e => e.ApprovalGrantedAt,
|
||||
(e, v) => e.ApprovalGrantedAt = v);
|
||||
|
||||
@@ -17,6 +17,7 @@ public partial class GlobalSearchViewModel : ObservableObject
|
||||
|
||||
private readonly IStudentRepository _students;
|
||||
private readonly IGroupRepository _groups;
|
||||
private readonly ISubjectRepository _subjects;
|
||||
private readonly IExamRepository _exams;
|
||||
private readonly IWorkTaskRepository _tasks;
|
||||
|
||||
@@ -30,13 +31,15 @@ public partial class GlobalSearchViewModel : ObservableObject
|
||||
public Action<GlobalSearchResult>? OnNavigate { get; set; }
|
||||
public Func<bool, Task>? OnQuickAddTask { get; set; }
|
||||
public Func<Task>? OnQuickAddStudent { get; set; }
|
||||
public Func<Task>? OnQuickAddGroupDocumentation { get; set; }
|
||||
public Action? OnClose { get; set; }
|
||||
|
||||
public GlobalSearchViewModel(IStudentRepository students, IGroupRepository groups,
|
||||
IExamRepository exams, IWorkTaskRepository tasks)
|
||||
ISubjectRepository subjects, IExamRepository exams, IWorkTaskRepository tasks)
|
||||
{
|
||||
_students = students;
|
||||
_groups = groups;
|
||||
_subjects = subjects;
|
||||
_exams = exams;
|
||||
_tasks = tasks;
|
||||
RefreshResults();
|
||||
@@ -59,23 +62,34 @@ public partial class GlobalSearchViewModel : ObservableObject
|
||||
|
||||
if (query.Length > 0)
|
||||
{
|
||||
var groups = _groups.GetAll(includeInactive: true);
|
||||
var groups = _groups.GetAll().Where(g => g.IsActive).ToList();
|
||||
var groupNames = groups.ToDictionary(g => g.Id, g => g.Name);
|
||||
var subjects = _subjects.GetAll().ToDictionary(s => s.Id);
|
||||
Subject? GroupSubject(LearningGroup group) => group.SubjectId is { } subjectId
|
||||
? subjects.GetValueOrDefault(subjectId)
|
||||
: null;
|
||||
static string SubjectLabel(Subject? subject) => subject is null ? ""
|
||||
: string.IsNullOrWhiteSpace(subject.ShortName)
|
||||
|| subject.Name.Equals(subject.ShortName, StringComparison.CurrentCultureIgnoreCase)
|
||||
? subject.Name
|
||||
: $"{subject.ShortName} – {subject.Name}";
|
||||
var candidates = new List<GlobalSearchResult>();
|
||||
|
||||
candidates.AddRange(_students.GetAll(includeInactive: true)
|
||||
candidates.AddRange(_students.GetAll().Where(s => s.IsActive)
|
||||
.Where(s => Matches(s.FullName, query))
|
||||
.Select(s => GlobalSearchResult.ForStudent(s)));
|
||||
|
||||
candidates.AddRange(groups
|
||||
.Where(g => Matches($"{g.Name} {g.SchoolYear} {g.GradeLevel}", query))
|
||||
.Select(GlobalSearchResult.ForGroup));
|
||||
.Where(g => Matches($"{g.Name} {GroupSubject(g)?.Name} {GroupSubject(g)?.ShortName} {g.SchoolYear} {g.GradeLevel}", query))
|
||||
.Select(g => GlobalSearchResult.ForGroup(g, SubjectLabel(GroupSubject(g)))));
|
||||
|
||||
candidates.AddRange(_exams.GetAll()
|
||||
.Where(e => groupNames.ContainsKey(e.GroupId))
|
||||
.Where(e => Matches($"{e.Title} {groupNames.GetValueOrDefault(e.GroupId)}", query))
|
||||
.Select(e => GlobalSearchResult.ForExam(e, groupNames.GetValueOrDefault(e.GroupId) ?? "")));
|
||||
|
||||
candidates.AddRange(_tasks.GetAll()
|
||||
.Where(t => t.GroupId is null || groupNames.ContainsKey(t.GroupId.Value))
|
||||
.Where(t => Matches($"{t.Title} {t.Notes} {groupNames.GetValueOrDefault(t.GroupId ?? Guid.Empty)}", query))
|
||||
.Select(t => GlobalSearchResult.ForTask(t, groupNames.GetValueOrDefault(t.GroupId ?? Guid.Empty) ?? "")));
|
||||
|
||||
@@ -99,6 +113,7 @@ public partial class GlobalSearchViewModel : ObservableObject
|
||||
GlobalSearchResult.ForAction(GlobalSearchAction.NewTask, "Aufgabe anlegen", "Mit Fälligkeit, Gruppe und Priorität", "+"),
|
||||
GlobalSearchResult.ForAction(GlobalSearchAction.NewReminder, "Erinnerung anlegen", "Kurze Notiz ohne Zeiterfassung", "◷"),
|
||||
GlobalSearchResult.ForAction(GlobalSearchAction.NewStudent, "Schüler anlegen", "Neue Stammdaten erfassen", "+"),
|
||||
GlobalSearchResult.ForAction(GlobalSearchAction.NewGroupDocumentation, "Lerngruppen-Eintrag", "Planung, Klausur oder Erinnerung dokumentieren", "N"),
|
||||
};
|
||||
|
||||
foreach (var action in actions.Where(a => query.Length == 0 || Matches(a.Title, query)))
|
||||
@@ -124,6 +139,9 @@ public partial class GlobalSearchViewModel : ObservableObject
|
||||
case GlobalSearchAction.NewStudent:
|
||||
if (OnQuickAddStudent is not null) await OnQuickAddStudent();
|
||||
break;
|
||||
case GlobalSearchAction.NewGroupDocumentation:
|
||||
if (OnQuickAddGroupDocumentation is not null) await OnQuickAddGroupDocumentation();
|
||||
break;
|
||||
default:
|
||||
OnNavigate?.Invoke(result);
|
||||
break;
|
||||
@@ -134,7 +152,7 @@ public partial class GlobalSearchViewModel : ObservableObject
|
||||
}
|
||||
|
||||
public enum GlobalSearchResultKind { Action, Student, Group, Exam, Task }
|
||||
public enum GlobalSearchAction { None, NewTask, NewReminder, NewStudent }
|
||||
public enum GlobalSearchAction { None, NewTask, NewReminder, NewStudent, NewGroupDocumentation }
|
||||
|
||||
public sealed class GlobalSearchResult
|
||||
{
|
||||
@@ -171,10 +189,13 @@ public sealed class GlobalSearchResult
|
||||
Title = student.FullName, Subtitle = student.IsActive ? "Aktiv" : "Inaktiv", Icon = "P",
|
||||
};
|
||||
|
||||
public static GlobalSearchResult ForGroup(LearningGroup group) => new()
|
||||
public static GlobalSearchResult ForGroup(LearningGroup group, string subjectName) => new()
|
||||
{
|
||||
Kind = GlobalSearchResultKind.Group, EntityId = group.Id, GroupId = group.Id,
|
||||
Title = group.Name, Subtitle = $"{group.SchoolYear} · Stufe {group.GradeLevel}", Icon = "G",
|
||||
Title = group.Name,
|
||||
Subtitle = string.Join(" · ", new[] { subjectName, group.SchoolYear, $"Stufe {group.GradeLevel}" }
|
||||
.Where(x => !string.IsNullOrWhiteSpace(x))),
|
||||
Icon = "G",
|
||||
};
|
||||
|
||||
public static GlobalSearchResult ForExam(Exam exam, string groupName) => new()
|
||||
|
||||
@@ -25,6 +25,7 @@ public partial class GroupDocumentationTabViewModel : ObservableObject
|
||||
private List<StudentOption> _groupStudents = [];
|
||||
|
||||
public static readonly StudentOption AllStudentsOption = new(Guid.Empty, "Alle Schüler");
|
||||
public static readonly StudentOption WholeGroupOption = new(Guid.Empty, "Gesamte Lerngruppe");
|
||||
|
||||
public ObservableCollection<DocumentationItem> Entries { get; } = [];
|
||||
public ObservableCollection<StudentOption> StudentFilterOptions { get; } = [AllStudentsOption];
|
||||
@@ -62,8 +63,6 @@ public partial class GroupDocumentationTabViewModel : ObservableObject
|
||||
private void Load()
|
||||
{
|
||||
Entries.Clear();
|
||||
if (_groupStudents.Count == 0) return;
|
||||
|
||||
var studentNameById = _groupStudents.ToDictionary(s => s.Id, s => s.Name);
|
||||
var groupNameCache = new Dictionary<Guid, string>();
|
||||
string GroupLabel(Guid id)
|
||||
@@ -80,6 +79,10 @@ public partial class GroupDocumentationTabViewModel : ObservableObject
|
||||
|
||||
var all = relevantStudentIds
|
||||
.SelectMany(id => _docs.GetByStudent(id))
|
||||
.Concat(SelectedStudentFilter.Id == Guid.Empty
|
||||
? _docs.GetAll().Where(d => d.StudentId == Guid.Empty && d.GroupId == _groupId)
|
||||
: [])
|
||||
.DistinctBy(d => d.Id)
|
||||
.Where(d => !OnlyThisGroup || d.GroupId == _groupId)
|
||||
.OrderByDescending(d => d.IsDraft)
|
||||
.ThenByDescending(d => d.Date)
|
||||
@@ -90,7 +93,10 @@ public partial class GroupDocumentationTabViewModel : ObservableObject
|
||||
{
|
||||
var isOwnGroup = d.GroupId is null || d.GroupId == _groupId;
|
||||
var otherGroupLabel = isOwnGroup ? "" : GroupLabel(d.GroupId!.Value);
|
||||
Entries.Add(new DocumentationItem(d, studentNameById.GetValueOrDefault(d.StudentId, ""),
|
||||
var studentName = d.StudentId == Guid.Empty
|
||||
? WholeGroupOption.Name
|
||||
: studentNameById.GetValueOrDefault(d.StudentId, "");
|
||||
Entries.Add(new DocumentationItem(d, studentName,
|
||||
isOwnGroup, otherGroupLabel));
|
||||
}
|
||||
}
|
||||
@@ -100,7 +106,7 @@ public partial class GroupDocumentationTabViewModel : ObservableObject
|
||||
[RelayCommand]
|
||||
private async Task AddDocumentation()
|
||||
{
|
||||
if (OnEditDocumentation is null || _groupStudents.Count == 0) return;
|
||||
if (OnEditDocumentation is null) return;
|
||||
var result = await OnEditDocumentation(_groupId, _groupStudents, null);
|
||||
if (result is null) return;
|
||||
_docs.Save(result);
|
||||
|
||||
@@ -138,7 +138,7 @@ public partial class GroupOverviewViewModel : ObservableObject
|
||||
private void LoadNextLesson(DateOnly today)
|
||||
{
|
||||
var next = _lessons.GetByGroupAndRange(_groupId, today, today.AddDays(NextLessonLookaheadDays))
|
||||
.Where(l => l.Status == LessonStatus.Planned)
|
||||
.Where(l => l.Status != LessonStatus.Conducted)
|
||||
.OrderBy(l => l.Date).ThenBy(l => l.LessonNumber ?? 0)
|
||||
.FirstOrDefault();
|
||||
|
||||
|
||||
@@ -13,7 +13,8 @@ namespace LehrerApp.Desktop.ViewModels.Groups;
|
||||
|
||||
// ── Ergebnisse der Verschieben-/Kopieren-/Serienerzeugungs-Dialoge (4.2.4 / 4.1.4 / 4.2.5) ───
|
||||
|
||||
public record MoveLessonTarget(DateOnly NewDate, bool ShiftFollowing);
|
||||
public record MoveLessonTarget(DateOnly NewDate, bool ShiftFollowing, int? NewPeriod = null,
|
||||
bool SplitDoubleLesson = false);
|
||||
public record CopyUnitTarget(Guid TargetGroupId, DateOnly AnchorDate);
|
||||
|
||||
public record LessonSeriesResult(int Created, int SkippedHoliday, int SkippedExisting)
|
||||
@@ -78,6 +79,7 @@ public partial class PlanningTabViewModel : ObservableObject
|
||||
public Func<Unit, Task<LessonSeriesResult?>>? OnGenerateLessonSeries { get; set; }
|
||||
public Func<Unit, Task<bool>>? OnAiAssist { get; set; }
|
||||
public Action<string>? OnNotify { get; set; }
|
||||
public Action<string>? OnError { get; set; }
|
||||
|
||||
public PlanningTabViewModel(IUnitRepository units, ILessonRepository lessons,
|
||||
IGroupRepository groups, ISubjectRepository subjects,
|
||||
@@ -333,7 +335,8 @@ public partial class PlanningTabViewModel : ObservableObject
|
||||
var lesson = SelectedLesson.Model;
|
||||
var target = await OnPickMoveTarget(lesson);
|
||||
if (target is null) return;
|
||||
MoveLessonInternal(lesson, target.NewDate, target.ShiftFollowing);
|
||||
try { MoveLessonInternal(lesson, target.NewDate, target.ShiftFollowing, target.NewPeriod); }
|
||||
catch (InvalidOperationException ex) { OnError?.Invoke(ex.Message); return; }
|
||||
LoadUnits();
|
||||
}
|
||||
|
||||
@@ -341,33 +344,19 @@ public partial class PlanningTabViewModel : ObservableObject
|
||||
/// sich alle anderen noch geplanten Stunden derselben Einheit, die ursprünglich NACH der
|
||||
/// verschobenen Stunde lagen, um denselben Tages-Delta. Bereits durchgeführte Stunden werden
|
||||
/// nie angefasst — nur Date ändert sich, UnitId/GroupId bleiben unverändert.
|
||||
private void MoveLessonInternal(Lesson moved, DateOnly newDate, bool shiftFollowing)
|
||||
private void MoveLessonInternal(Lesson moved, DateOnly newDate, bool shiftFollowing, int? newPeriod = null)
|
||||
{
|
||||
var oldDate = moved.Date;
|
||||
var delta = newDate.DayNumber - oldDate.DayNumber;
|
||||
|
||||
if (shiftFollowing && delta != 0)
|
||||
{
|
||||
foreach (var other in _lessons.GetByUnit(moved.UnitId))
|
||||
{
|
||||
if (other.Id == moved.Id) continue;
|
||||
if (other.Status != LessonStatus.Planned) continue;
|
||||
if (other.Date <= oldDate) continue;
|
||||
other.Date = other.Date.AddDays(delta);
|
||||
_lessons.Save(other);
|
||||
}
|
||||
}
|
||||
|
||||
moved.Date = newDate;
|
||||
_lessons.Save(moved);
|
||||
new LessonSchedulingService(_lessons).Move(moved, newDate, newPeriod, shiftFollowing);
|
||||
}
|
||||
|
||||
[RelayCommand(CanExecute = nameof(HasSelectedLesson))]
|
||||
private void AdvanceLessonStatus()
|
||||
{
|
||||
if (SelectedLesson is null || SelectedLesson.Model.Status != LessonStatus.Planned) return;
|
||||
if (SelectedLesson is null || SelectedLesson.Model.Status == LessonStatus.Conducted) return;
|
||||
var lesson = SelectedLesson.Model;
|
||||
lesson.Status = LessonStatus.Conducted;
|
||||
lesson.Status = lesson.Status == LessonStatus.Ready
|
||||
? LessonStatus.Conducted
|
||||
: LessonStatus.Ready;
|
||||
_lessons.Save(lesson);
|
||||
LoadUnits();
|
||||
}
|
||||
@@ -479,8 +468,14 @@ public class LessonSummary
|
||||
Topic = l.Topic;
|
||||
StartTimeDisplay = l.StartTime?.ToString("HH:mm") ?? "–";
|
||||
Status = l.Status;
|
||||
StatusLabel = l.Status == LessonStatus.Conducted ? "Durchgeführt" : "Geplant";
|
||||
StatusColorHex = l.Status == LessonStatus.Conducted ? "#43A047" : "#9E9E9E";
|
||||
StatusLabel = LessonStatusDisplay.ToName(l.Status);
|
||||
StatusColorHex = l.Status switch
|
||||
{
|
||||
LessonStatus.Draft => "#78909C",
|
||||
LessonStatus.Ready => "#1976D2",
|
||||
LessonStatus.Conducted => "#43A047",
|
||||
_ => "#9E9E9E",
|
||||
};
|
||||
PhaseCountLabel = l.Phases.Count == 0 ? "–" : $"{l.Phases.Count} Phasen";
|
||||
var totalMinutes = l.Phases.Sum(p => p.DurationMinutes);
|
||||
TotalDurationLabel = totalMinutes == 0 ? "–" : $"{totalMinutes} Min.";
|
||||
@@ -515,12 +510,23 @@ public static class UnitStatusDisplay
|
||||
|
||||
public static class LessonStatusDisplay
|
||||
{
|
||||
public static string[] Options { get; } = ["Geplant", "Durchgeführt"];
|
||||
public static string[] Options { get; } = ["Entwurf", "Geplant", "Bereit", "Durchgeführt"];
|
||||
|
||||
public static string ToName(LessonStatus s) => s == LessonStatus.Conducted ? "Durchgeführt" : "Geplant";
|
||||
public static string ToName(LessonStatus s) => s switch
|
||||
{
|
||||
LessonStatus.Draft => "Entwurf",
|
||||
LessonStatus.Ready => "Bereit",
|
||||
LessonStatus.Conducted => "Durchgeführt",
|
||||
_ => "Geplant",
|
||||
};
|
||||
|
||||
public static LessonStatus FromName(string? name) =>
|
||||
name == "Durchgeführt" ? LessonStatus.Conducted : LessonStatus.Planned;
|
||||
public static LessonStatus FromName(string? name) => name switch
|
||||
{
|
||||
"Entwurf" => LessonStatus.Draft,
|
||||
"Bereit" => LessonStatus.Ready,
|
||||
"Durchgeführt" => LessonStatus.Conducted,
|
||||
_ => LessonStatus.Planned,
|
||||
};
|
||||
}
|
||||
|
||||
// ── Dialog: Einheit anlegen / bearbeiten (4.1.2 / 4.1.3) ─────────────────────
|
||||
@@ -722,7 +728,8 @@ public partial class LessonDialogViewModel : ObservableObject
|
||||
IAlternativeLessonPathRepository alternativePaths, ITimetableSlotRepository timetableSlots,
|
||||
PeriodScheduleService periodSchedule, IAttachmentStorage attachmentStorage,
|
||||
Guid unitId, Guid groupId, string groupName, string subjectName,
|
||||
List<string> materialSuggestions, List<string> shorthandHistorySuggestions, Lesson? editingLesson)
|
||||
List<string> materialSuggestions, List<string> shorthandHistorySuggestions, Lesson? editingLesson,
|
||||
DateOnly? suggestedDate = null, int? suggestedPeriod = null)
|
||||
{
|
||||
_lessons = lessons; _alternativePaths = alternativePaths;
|
||||
_timetableSlots = timetableSlots; _periodSchedule = periodSchedule;
|
||||
@@ -760,9 +767,12 @@ public partial class LessonDialogViewModel : ObservableObject
|
||||
}
|
||||
else
|
||||
{
|
||||
var (date, lessonNumber) = SuggestNextLesson();
|
||||
var (date, lessonNumber) = suggestedDate.HasValue
|
||||
? (suggestedDate.Value, suggestedPeriod)
|
||||
: SuggestNextLesson();
|
||||
DateText = date.ToString("dd.MM.yyyy");
|
||||
LessonNumber = lessonNumber;
|
||||
StatusName = LessonStatusDisplay.ToName(LessonStatus.Draft);
|
||||
}
|
||||
RecomputeTimes();
|
||||
}
|
||||
@@ -1145,28 +1155,45 @@ public partial class AlternativePathDialogViewModel : ObservableObject
|
||||
public partial class MoveLessonDialogViewModel : ObservableObject
|
||||
{
|
||||
[ObservableProperty] private string _newDateText;
|
||||
[ObservableProperty] private int? _newPeriod;
|
||||
[ObservableProperty] private bool _shiftFollowingPlanned = true;
|
||||
[ObservableProperty] private bool _splitDoubleLesson;
|
||||
[ObservableProperty] private string _newDateTextError = "";
|
||||
[ObservableProperty] private string _newPeriodError = "";
|
||||
|
||||
public string CurrentDateDisplay { get; }
|
||||
public string CurrentPeriodDisplay { get; }
|
||||
public bool CanSplitDoubleLesson { get; }
|
||||
public MoveLessonTarget? Result { get; private set; }
|
||||
|
||||
public MoveLessonDialogViewModel(DateOnly currentDate)
|
||||
public MoveLessonDialogViewModel(DateOnly currentDate, int? currentPeriod = null,
|
||||
bool canSplitDoubleLesson = false, bool splitByDefault = false)
|
||||
{
|
||||
CurrentDateDisplay = currentDate.ToString("dd.MM.yyyy");
|
||||
CurrentPeriodDisplay = currentPeriod is null ? "nicht festgelegt" : $"{currentPeriod}. Stunde";
|
||||
_newDateText = currentDate.ToString("dd.MM.yyyy");
|
||||
_newPeriod = currentPeriod;
|
||||
CanSplitDoubleLesson = canSplitDoubleLesson;
|
||||
_splitDoubleLesson = canSplitDoubleLesson && splitByDefault;
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void Save()
|
||||
{
|
||||
NewDateTextError = "";
|
||||
NewPeriodError = "";
|
||||
if (!DateOnly.TryParseExact(NewDateText, "dd.MM.yyyy", null, DateTimeStyles.None, out var date))
|
||||
{
|
||||
NewDateTextError = "Format TT.MM.JJJJ.";
|
||||
return;
|
||||
}
|
||||
Result = new MoveLessonTarget(date, ShiftFollowingPlanned);
|
||||
if (NewPeriod is < 1 or > 20)
|
||||
{
|
||||
NewPeriodError = "Bitte eine Stundennummer zwischen 1 und 20 wählen.";
|
||||
return;
|
||||
}
|
||||
Result = new MoveLessonTarget(date, ShiftFollowingPlanned, NewPeriod,
|
||||
CanSplitDoubleLesson && SplitDoubleLesson);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using LehrerApp.Core.Interfaces;
|
||||
using LehrerApp.Core.Models;
|
||||
using System.Collections.ObjectModel;
|
||||
|
||||
namespace LehrerApp.Desktop.ViewModels.Planning;
|
||||
|
||||
public sealed class TimetableUnitOption(Unit unit)
|
||||
{
|
||||
public Unit Model { get; } = unit;
|
||||
public string Label { get; } = unit.Title;
|
||||
public string Detail { get; } = unit.Status switch
|
||||
{
|
||||
UnitStatus.Active => "Laufende Einheit",
|
||||
UnitStatus.Completed => "Abgeschlossene Einheit",
|
||||
_ => "Geplante Einheit",
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>Ordnet eine direkt aus dem Stundenplan angelegte Stunde einer Einheit zu.</summary>
|
||||
public partial class TimetableUnitPickerViewModel : ObservableObject
|
||||
{
|
||||
private readonly IUnitRepository _units;
|
||||
private readonly Guid _groupId;
|
||||
private readonly DateOnly _date;
|
||||
|
||||
public string ContextLabel { get; }
|
||||
public ObservableCollection<TimetableUnitOption> Units { get; } = [];
|
||||
[ObservableProperty] private TimetableUnitOption? _selectedUnit;
|
||||
[ObservableProperty] private string _newUnitTitle = "";
|
||||
[ObservableProperty] private string _error = "";
|
||||
public Unit? Result { get; private set; }
|
||||
public bool ResultIsNew { get; private set; }
|
||||
public bool HasUnits => Units.Count > 0;
|
||||
|
||||
public TimetableUnitPickerViewModel(IUnitRepository units, Guid groupId, string groupName,
|
||||
DateOnly date, int period)
|
||||
{
|
||||
_units = units;
|
||||
_groupId = groupId;
|
||||
_date = date;
|
||||
ContextLabel = $"{groupName} · {date:dd.MM.yyyy} · {period}. Stunde";
|
||||
|
||||
foreach (var unit in units.GetByGroup(groupId)
|
||||
.OrderBy(u => u.Status == UnitStatus.Active ? 0 : u.Status == UnitStatus.Planned ? 1 : 2)
|
||||
.ThenByDescending(u => u.StartDate)
|
||||
.ThenBy(u => u.Title, StringComparer.CurrentCultureIgnoreCase))
|
||||
Units.Add(new TimetableUnitOption(unit));
|
||||
SelectedUnit = Units.FirstOrDefault();
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void Save()
|
||||
{
|
||||
Error = "";
|
||||
if (!string.IsNullOrWhiteSpace(NewUnitTitle))
|
||||
{
|
||||
Result = new Unit
|
||||
{
|
||||
GroupId = _groupId,
|
||||
Title = NewUnitTitle.Trim(),
|
||||
StartDate = _date,
|
||||
Status = UnitStatus.Active,
|
||||
};
|
||||
// Erst speichern, wenn auch der anschließende Stunden-Dialog bestätigt wurde. So
|
||||
// hinterlässt ein Abbruch keine leere Einheit.
|
||||
ResultIsNew = true;
|
||||
return;
|
||||
}
|
||||
|
||||
if (SelectedUnit is null)
|
||||
{
|
||||
Error = "Bitte eine Einheit auswählen oder eine neue benennen.";
|
||||
return;
|
||||
}
|
||||
Result = SelectedUnit.Model;
|
||||
ResultIsNew = false;
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record TimetableLessonRequest(Guid GroupId, DateOnly Date, int PeriodNumber);
|
||||
public sealed record TimetableLessonMoveRequest(Lesson Lesson, int SelectedPeriod);
|
||||
@@ -5,6 +5,7 @@ using LehrerApp.Core.Models;
|
||||
using LehrerApp.Core.Services;
|
||||
using LehrerApp.Desktop.Services;
|
||||
using LehrerApp.Desktop.ViewModels.Settings;
|
||||
using LehrerApp.Desktop.ViewModels.Groups;
|
||||
using System.Collections.ObjectModel;
|
||||
|
||||
namespace LehrerApp.Desktop.ViewModels.Planning;
|
||||
@@ -108,6 +109,8 @@ public partial class TimetableViewModel : ObservableObject
|
||||
public Action<SettingsTab>? OnNavigateToSettings { get; set; }
|
||||
public Func<Lesson, Task>? OnOpenLessonViewer { get; set; }
|
||||
public Func<Lesson, Task>? OnOpenTeachingMode { get; set; }
|
||||
public Func<TimetableLessonRequest, Task>? OnCreateLesson { get; set; }
|
||||
public Func<TimetableLessonMoveRequest, Task>? OnMoveLesson { get; set; }
|
||||
|
||||
/// Öffentlich statt intern (kein InternalsVisibleTo in dieser Codebasis) - erlaubt Tests, die
|
||||
/// "heute"-abhängiges Verhalten (Wochenraster-Badges, Unterrichtszeit-Erkennung) prüfen, ohne
|
||||
@@ -301,9 +304,9 @@ public partial class TimetableViewModel : ObservableObject
|
||||
var cancelled = substitutionsToday.FirstOrDefault(s => s.Kind == SubstitutionKind.Cancelled && s.PeriodNumber == slot.PeriodNumber);
|
||||
if (cancelled is not null) { items.Add(TodayLessonItem.ForCancelled(slot.GroupId, slot.PeriodNumber, group.Name, cancelled)); continue; }
|
||||
|
||||
var lesson = _lessons.GetByGroupAndDate(slot.GroupId, today).FirstOrDefault();
|
||||
var lesson = FindLessonForSlot(slot.GroupId, today, slot.PeriodNumber);
|
||||
var exam = _exams.GetByGroup(slot.GroupId).FirstOrDefault(e => e.Date == today);
|
||||
items.Add(new TodayLessonItem(slot.GroupId, slot.PeriodNumber, group.Name,
|
||||
items.Add(new TodayLessonItem(slot.GroupId, today, slot.PeriodNumber, group.Name,
|
||||
slot.Room ?? "", ColorFor(group.Name), lesson?.Topic, exam?.Title,
|
||||
HasUnhandledHomework(slot.GroupId, today), lesson));
|
||||
}
|
||||
@@ -353,15 +356,15 @@ public partial class TimetableViewModel : ObservableObject
|
||||
}
|
||||
|
||||
/// Springt aus dem Stundenplan direkt in den Verlaufsplan-Viewer der zugehörigen Lesson (4.5.2)
|
||||
/// — sofern für den Slot schon eine Lesson existiert. Ohne Lesson (Slot laut Stundenplan belegt,
|
||||
/// aber noch keine konkrete Stunde geplant) bleibt es bei der bisherigen, gröberen Navigation
|
||||
/// zum Planung-Tab der Gruppe: eine neue Lesson direkt von hier aus anzulegen bräuchte eine
|
||||
/// Antwort auf "welcher Unit wird sie zugeordnet", die bewusst noch offen ist (siehe TODO 4.5.2).
|
||||
/// — sofern für den Slot schon eine Lesson existiert. Ohne Lesson startet die Direktanlage;
|
||||
/// der vorgeschaltete Einheiten-Dialog löst dabei die notwendige Unit-Zuordnung explizit.
|
||||
[RelayCommand]
|
||||
private async Task OpenTodayLesson(TodayLessonItem? item)
|
||||
{
|
||||
if (item is null || item.GroupId == Guid.Empty) return;
|
||||
if (item.Lesson is { } lesson && OnOpenLessonViewer is not null) await OnOpenLessonViewer(lesson);
|
||||
else if (OnCreateLesson is not null)
|
||||
await OnCreateLesson(new TimetableLessonRequest(item.GroupId, item.Date, item.PeriodNumber));
|
||||
else OnNavigateToGroup?.Invoke(item.GroupId);
|
||||
}
|
||||
|
||||
@@ -380,7 +383,7 @@ public partial class TimetableViewModel : ObservableObject
|
||||
/// nach der Stunde) Unterrichtszeit, geht es direkt in den Unterrichtsmodus — sonst wie
|
||||
/// bisher in den (schreibgeschützten) Planungsviewer bzw., ohne Lesson, zur Einheitenplanung
|
||||
/// der Gruppe. Das Popup-Menü (siehe TimetableView.axaml, MenuFlyout je Kachel) bietet
|
||||
/// daneben immer alle vier Ziele explizit an, unabhängig von dieser Automatik.
|
||||
/// daneben die weiteren Ziele (einschließlich Anlegen/Verschieben) explizit an.
|
||||
[RelayCommand]
|
||||
private async Task OpenWeekCell(WeekCellItem? item)
|
||||
{
|
||||
@@ -391,6 +394,8 @@ public partial class TimetableViewModel : ObservableObject
|
||||
await OnOpenTeachingMode(lesson);
|
||||
else if (OnOpenLessonViewer is not null) await OnOpenLessonViewer(lesson);
|
||||
}
|
||||
else if (item.Date is { } date && OnCreateLesson is not null)
|
||||
await OnCreateLesson(new TimetableLessonRequest(item.GroupId, date, item.PeriodNumber));
|
||||
else OnNavigateToGroup?.Invoke(item.GroupId);
|
||||
}
|
||||
|
||||
@@ -399,16 +404,20 @@ public partial class TimetableViewModel : ObservableObject
|
||||
/// Start-/Endzeit gebunden — man klickt auch kurz vor Stundenbeginn oder in einer kurzen
|
||||
/// Verzögerung danach noch typischerweise in Unterrichtsabsicht. Ohne konfiguriertes
|
||||
/// Stundenraster oder an einem anderen Tag als heute bleibt es beim Planungsviewer.
|
||||
/// Nimmt bewusst das Datum der Lesson selbst statt WeekCellItem.Date entgegen — Date ist dort
|
||||
/// nur bei Kopfzeilen (WeekdayHeader) gesetzt, nicht bei regulären Stunden-Kacheln (ForSlot).
|
||||
/// Nimmt das Datum der Lesson selbst entgegen; die Kachel trägt ihr Datum zusätzlich für die
|
||||
/// Direktanlage einer noch nicht existierenden Stunde.
|
||||
private static readonly TimeSpan TeachingTimeTolerance = TimeSpan.FromMinutes(10);
|
||||
private bool IsAroundTeachingTime(DateOnly lessonDate, int periodNumber)
|
||||
{
|
||||
var nowSnapshot = Clock();
|
||||
if (lessonDate != DateOnly.FromDateTime(nowSnapshot)) return false;
|
||||
if (_periodSchedule.GetTimes(periodNumber) is not { } times) return false;
|
||||
var now = TimeOnly.FromDateTime(nowSnapshot);
|
||||
return now >= times.Start.Add(-TeachingTimeTolerance) && now <= times.End.Add(TeachingTimeTolerance);
|
||||
// DateTime statt TimeOnly.Add: Letzteres springt nahe Mitternacht auf den anderen
|
||||
// Tagesrand und macht aus z.B. 00:05 ± 10 Minuten ein umgekehrtes Vergleichsfenster.
|
||||
var start = lessonDate.ToDateTime(times.Start).Subtract(TeachingTimeTolerance);
|
||||
var end = lessonDate.ToDateTime(times.End).Add(TeachingTimeTolerance);
|
||||
if (end < start) end = end.AddDays(1); // nur für ein ggf. über Mitternacht laufendes Raster
|
||||
return nowSnapshot >= start && nowSnapshot <= end;
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
@@ -487,14 +496,14 @@ public partial class TimetableViewModel : ObservableObject
|
||||
continue;
|
||||
}
|
||||
|
||||
var lesson = _lessons.GetByGroupAndDate(slot.GroupId, date).FirstOrDefault();
|
||||
var lesson = FindLessonForSlot(slot.GroupId, date, period);
|
||||
var hasExam = _exams.GetByGroup(slot.GroupId).Any(e => e.Date == date);
|
||||
var isHoliday = IsFreeDay(date, schoolHolidays, publicHolidayDates);
|
||||
var colorHex = isHoliday ? "#BDBDBD" : ColorFor(group?.Name ?? "");
|
||||
var holidayBadge = HolidayBadgeFor(date, weekday, schoolHolidays, publicHolidayDates);
|
||||
var isLastBeforeExam = IsLastBeforeExamFor(date, weekday, slot.GroupId, publicHolidayDates);
|
||||
|
||||
WeekItems.Add(WeekCellItem.ForSlot(weekday, period, date == today,
|
||||
WeekItems.Add(WeekCellItem.ForSlot(weekday, period, date == today, date,
|
||||
subject?.ShortName is { Length: > 0 } sn ? sn : subject?.Name ?? "",
|
||||
group?.Name ?? "?", slot.Room ?? "", lesson?.Topic ?? "",
|
||||
colorHex, holidayBadge, hasExam, isLastBeforeExam,
|
||||
@@ -560,6 +569,31 @@ public partial class TimetableViewModel : ObservableObject
|
||||
p.Activity?.Contains("Experiment", StringComparison.OrdinalIgnoreCase) == true ||
|
||||
p.Material?.Contains("Experiment", StringComparison.OrdinalIgnoreCase) == true)) == true;
|
||||
|
||||
/// Eine Doppelstunde wird als eine Lesson an der ersten Periode gespeichert. Für die zweite
|
||||
/// Rasterzelle liefern wir dieselbe Lesson nur dann, wenn der Stundenplan dort unmittelbar
|
||||
/// fortgesetzt wird und der geplante Verlauf länger als die erste Periode ist. Eine exakt an
|
||||
/// der Zielperiode verankerte Lesson hat immer Vorrang.
|
||||
private Lesson? FindLessonForSlot(Guid groupId, DateOnly date, int period)
|
||||
{
|
||||
var lessons = _lessons.GetByGroupAndDate(groupId, date);
|
||||
var exact = lessons.FirstOrDefault(l => l.LessonNumber == period);
|
||||
if (exact is not null) return exact;
|
||||
// Historische/manuell angelegte Einträge hatten häufig keine Stundennummer. Solange es
|
||||
// davon nur einen an diesem Tag gibt, bleibt das frühere Verhalten erhalten und er wird
|
||||
// dem vorhandenen Gruppen-Slot zugeordnet.
|
||||
var withoutPeriod = lessons.Where(l => l.LessonNumber is null).ToList();
|
||||
if (withoutPeriod.Count == 1) return withoutPeriod[0];
|
||||
|
||||
var previous = lessons.Where(l => l.LessonNumber is int p && p == period - 1)
|
||||
.OrderByDescending(l => l.UpdatedAt).FirstOrDefault();
|
||||
if (previous?.LessonNumber is not int anchor) return null;
|
||||
var isConsecutiveSlot = _slots.GetByGroup(groupId)
|
||||
.Any(s => s.Weekday == date.DayOfWeek && s.PeriodNumber == period);
|
||||
var firstPeriodMinutes = _periodSchedule.GetDurationMinutes(anchor);
|
||||
return isConsecutiveSlot && firstPeriodMinutes > 0 &&
|
||||
previous.Phases.Sum(p => p.DurationMinutes) > firstPeriodMinutes ? previous : null;
|
||||
}
|
||||
|
||||
/// <summary>4.5.4: Hat die letzte vor <paramref name="date"/> liegende Lesson dieser Gruppe eine
|
||||
/// Hausaufgabe, die weder als kontrolliert noch als bewusst übersprungen markiert ist? Schaut
|
||||
/// bewusst nur auf die unmittelbar vorherige Lesson (nicht auf die gesamte Historie) — sobald
|
||||
@@ -824,7 +858,9 @@ public partial class WeekCellItem : ObservableObject
|
||||
/// Grundlage für den Direktsprung in den Verlaufsplan-Viewer (4.5.2).
|
||||
public Lesson? Lesson { get; private init; }
|
||||
public bool HasLesson => Lesson is not null;
|
||||
public string OpenButtonLabel => Lesson is not null ? "Verlaufsplan ansehen" : "Zur Lerngruppe";
|
||||
public bool HasNoLesson => Lesson is null;
|
||||
public string PlanningStatusLabel => Lesson is null ? "Nicht geplant" : LessonStatusDisplay.ToName(Lesson.Status);
|
||||
public string OpenButtonLabel => Lesson is not null ? "Verlaufsplan ansehen" : "Stunde anlegen";
|
||||
[ObservableProperty] private string _weatherSymbol = "";
|
||||
[ObservableProperty] private string _weatherTooltip = "";
|
||||
[ObservableProperty] private bool _hasWeatherWarning;
|
||||
@@ -879,18 +915,28 @@ public partial class WeekCellItem : ObservableObject
|
||||
IsSubstitutionSupervision = isSubstitution,
|
||||
};
|
||||
|
||||
public static WeekCellItem ForSlot(DayOfWeek day, int period, bool isToday, string subjectLabel,
|
||||
public static WeekCellItem ForSlot(DayOfWeek day, int period, bool isToday, DateOnly date, string subjectLabel,
|
||||
string groupName, string room, string topic, string colorHex, string holidayBadge,
|
||||
bool hasExam, bool isLastBeforeExam, bool hasExperiment, Guid groupId, bool isHoliday,
|
||||
bool hasUnhandledHomework = false, Lesson? lesson = null) => new()
|
||||
{
|
||||
Weekday = day, PeriodNumber = period, IsAssigned = true, IsToday = isToday,
|
||||
Weekday = day, PeriodNumber = period, IsAssigned = true, IsToday = isToday, Date = date,
|
||||
SubjectLabel = subjectLabel, GroupName = groupName, Room = room, Topic = topic,
|
||||
ColorHex = colorHex, HolidayBadge = holidayBadge, HasExam = hasExam,
|
||||
IsLastBeforeExam = isLastBeforeExam, HasExperiment = hasExperiment, GroupId = groupId,
|
||||
IsHoliday = isHoliday, HasUnhandledHomework = hasUnhandledHomework, Lesson = lesson,
|
||||
};
|
||||
|
||||
// Kompatible Überladung für isolierte ViewModel-Tests und ältere Aufrufer ohne konkreten
|
||||
// Wochenbezug. Produktiv wird die datierte Variante verwendet.
|
||||
public static WeekCellItem ForSlot(DayOfWeek day, int period, bool isToday, string subjectLabel,
|
||||
string groupName, string room, string topic, string colorHex, string holidayBadge,
|
||||
bool hasExam, bool isLastBeforeExam, bool hasExperiment, Guid groupId, bool isHoliday,
|
||||
bool hasUnhandledHomework = false, Lesson? lesson = null) => ForSlot(day, period, isToday,
|
||||
DateOnly.FromDateTime(DateTime.Today), subjectLabel, groupName, room, topic, colorHex,
|
||||
holidayBadge, hasExam, isLastBeforeExam, hasExperiment, groupId, isHoliday,
|
||||
hasUnhandledHomework, lesson);
|
||||
|
||||
public static WeekCellItem ForSubstitutionLesson(DayOfWeek day, int period, bool isToday, SubstitutionEntry entry) => new()
|
||||
{
|
||||
Weekday = day, PeriodNumber = period, IsAssigned = true, IsToday = isToday, IsSubstitutionLesson = true,
|
||||
@@ -937,12 +983,13 @@ public class UpcomingExamItem(DateOnly date, string groupName, string title)
|
||||
public string Title { get; } = title;
|
||||
}
|
||||
|
||||
public enum TimetableLessonDestination { TeachingMode, Viewer, SeatingPlan, Planning }
|
||||
public enum TimetableLessonDestination { TeachingMode, Viewer, Create, Move, SeatingPlan, Planning }
|
||||
public sealed record TimetableDestinationOption(TimetableLessonDestination Kind, string Label);
|
||||
|
||||
public class TodayLessonItem
|
||||
{
|
||||
public Guid GroupId { get; private init; }
|
||||
public DateOnly Date { get; private init; }
|
||||
public int PeriodNumber { get; private init; }
|
||||
public string GroupName { get; private init; } = "";
|
||||
public string Room { get; private init; } = "";
|
||||
@@ -960,23 +1007,24 @@ public class TodayLessonItem
|
||||
/// Direktsprung in den Verlaufsplan-Viewer (4.5.2) und den Unterrichtsmodus (14.x).
|
||||
public Lesson? Lesson { get; private init; }
|
||||
public bool HasLesson => Lesson is not null;
|
||||
public string OpenButtonLabel => Lesson is not null ? "Verlaufsplan ansehen" : "Zur Lerngruppe";
|
||||
public string PlanningStatusLabel => Lesson is null ? "Nicht geplant" : LessonStatusDisplay.ToName(Lesson.Status);
|
||||
public string OpenButtonLabel => Lesson is not null ? "Verlaufsplan ansehen" : "Stunde anlegen";
|
||||
|
||||
public TodayLessonItem(Guid groupId, int periodNumber, string groupName, string room,
|
||||
public TodayLessonItem(Guid groupId, DateOnly date, int periodNumber, string groupName, string room,
|
||||
string colorHex, string? lessonTopic, string? examTitle, bool hasUnhandledHomework = false,
|
||||
Lesson? lesson = null)
|
||||
{
|
||||
GroupId = groupId; PeriodNumber = periodNumber; GroupName = groupName; Room = room;
|
||||
GroupId = groupId; Date = date; PeriodNumber = periodNumber; GroupName = groupName; Room = room;
|
||||
ColorHex = colorHex; LessonTopic = lessonTopic; ExamTitle = examTitle;
|
||||
HasUnhandledHomework = hasUnhandledHomework; Lesson = lesson;
|
||||
}
|
||||
|
||||
public static TodayLessonItem ForSubstitution(int periodNumber, SubstitutionEntry entry) => new(
|
||||
entry.GroupId ?? Guid.Empty, periodNumber, entry.GroupLabel, "", "#8E24AA", entry.Description, null)
|
||||
entry.GroupId ?? Guid.Empty, entry.Date, periodNumber, entry.GroupLabel, "", "#8E24AA", entry.Description, null)
|
||||
{ IsSubstitution = true };
|
||||
|
||||
public static TodayLessonItem ForCancelled(Guid groupId, int periodNumber, string groupName, SubstitutionEntry entry) => new(
|
||||
groupId, periodNumber, groupName, "", "#757575",
|
||||
groupId, entry.Date, periodNumber, groupName, "", "#757575",
|
||||
string.IsNullOrWhiteSpace(entry.Description) ? null : entry.Description, null)
|
||||
{ IsCancelled = true };
|
||||
}
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using LehrerApp.Core.Interfaces;
|
||||
using LehrerApp.Core.Models;
|
||||
using LehrerApp.Core.Services;
|
||||
using LehrerApp.Data;
|
||||
using LehrerApp.Desktop.Services;
|
||||
using LehrerApp.Desktop.ViewModels.Planning;
|
||||
using LehrerApp.Sync;
|
||||
using LehrerApp.Sync.Crypto;
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Globalization;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace LehrerApp.Desktop.ViewModels.Settings;
|
||||
|
||||
public partial class SettingsViewModel
|
||||
{
|
||||
// ── KI-Unterstützung (4.5.9) ──────────────────────────────────────────────
|
||||
|
||||
[ObservableProperty] private bool _aiEnabled;
|
||||
[ObservableProperty] private string _aiUsername = "";
|
||||
[ObservableProperty] private string _aiPassword = "";
|
||||
[ObservableProperty] private string _aiLoginError = "";
|
||||
[ObservableProperty] private bool _aiIsLoggedIn;
|
||||
[ObservableProperty] private string _aiBalanceDisplay = "";
|
||||
|
||||
// ── KI-Unterstützung: Laden / Anmelden / Abmelden ────────────────────────
|
||||
|
||||
private void LoadAiSettings()
|
||||
{
|
||||
AiEnabled = _aiSettings.Enabled;
|
||||
AiUsername = _aiSettings.Username;
|
||||
AiIsLoggedIn = _aiSettings.IsLoggedIn;
|
||||
if (AiIsLoggedIn) _ = RefreshAiBalance();
|
||||
}
|
||||
|
||||
partial void OnAiEnabledChanged(bool value) => _aiSettings.SetEnabled(value);
|
||||
|
||||
private async Task RefreshAiBalance()
|
||||
{
|
||||
var token = _aiSettings.GetToken();
|
||||
if (token is null) return;
|
||||
try
|
||||
{
|
||||
var balance = await _aiPlanning.GetBalanceAsync(token);
|
||||
AiBalanceDisplay = $"Guthaben: {balance:0.00} €";
|
||||
}
|
||||
catch (AiBackendException ex) { AiBalanceDisplay = ex.Message; }
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private async Task AiLogin()
|
||||
{
|
||||
AiLoginError = "";
|
||||
var valid = true;
|
||||
if (string.IsNullOrWhiteSpace(AiUsername)) { AiLoginError = "Benutzername erforderlich."; valid = false; }
|
||||
if (string.IsNullOrWhiteSpace(AiPassword)) { AiLoginError = "Passwort erforderlich."; valid = false; }
|
||||
if (!valid) return;
|
||||
|
||||
try
|
||||
{
|
||||
var token = await _aiPlanning.LoginAsync(AiUsername, AiPassword);
|
||||
_aiSettings.SetCredentialsAndToken(AiUsername, token);
|
||||
AiPassword = "";
|
||||
AiIsLoggedIn = true;
|
||||
await RefreshAiBalance();
|
||||
}
|
||||
catch (AiBackendException ex) { AiLoginError = ex.Message; }
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void AiLogout()
|
||||
{
|
||||
_aiSettings.Logout();
|
||||
AiIsLoggedIn = false;
|
||||
AiBalanceDisplay = "";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using LehrerApp.Core.Interfaces;
|
||||
using LehrerApp.Core.Models;
|
||||
using LehrerApp.Core.Services;
|
||||
using LehrerApp.Data;
|
||||
using LehrerApp.Desktop.Services;
|
||||
using LehrerApp.Desktop.ViewModels.Planning;
|
||||
using LehrerApp.Sync;
|
||||
using LehrerApp.Sync.Crypto;
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Globalization;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace LehrerApp.Desktop.ViewModels.Settings;
|
||||
|
||||
public partial class SettingsViewModel
|
||||
{
|
||||
// ── Schulweiter Jahresplan (ClassyPlan-iCal) ─────────────────────────────
|
||||
|
||||
[ObservableProperty] private bool _annualPlanIsConfigured;
|
||||
[ObservableProperty] private string _annualPlanIcalUrlInput = "";
|
||||
[ObservableProperty] private string _annualPlanUrlError = "";
|
||||
[ObservableProperty] private string _annualPlanStatusDisplay = "";
|
||||
[ObservableProperty] private bool _annualPlanFetchBusy;
|
||||
|
||||
// ── 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using LehrerApp.Core.Interfaces;
|
||||
using LehrerApp.Core.Models;
|
||||
using LehrerApp.Core.Services;
|
||||
using LehrerApp.Data;
|
||||
using LehrerApp.Desktop.Services;
|
||||
using LehrerApp.Desktop.ViewModels.Planning;
|
||||
using LehrerApp.Sync;
|
||||
using LehrerApp.Sync.Crypto;
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Globalization;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace LehrerApp.Desktop.ViewModels.Settings;
|
||||
|
||||
public partial class SettingsViewModel
|
||||
{
|
||||
// ── Sicherheit: App-Sperre nach Inaktivität (13.3.5) ─────────────────────
|
||||
|
||||
[ObservableProperty] private bool _appLockEnabled;
|
||||
[ObservableProperty] private int _appLockTimeoutMinutes = 10;
|
||||
[ObservableProperty] private string _appLockNewPassword = "";
|
||||
[ObservableProperty] private string _appLockNewPasswordConfirm = "";
|
||||
[ObservableProperty] private string _appLockPasswordError = "";
|
||||
[ObservableProperty] private string _appLockStatus = "";
|
||||
|
||||
public bool AppLockHasPassword => _appLock.HasPassword;
|
||||
|
||||
/// Vom Code-Behind gesetzt: aktualisiert den laufenden Inaktivitäts-Timer sofort,
|
||||
/// ohne dass die App neu gestartet werden muss.
|
||||
public Action? OnAppLockChanged { get; set; }
|
||||
|
||||
// ── App-Sperre: Speichern ─────────────────────────────────────────────────
|
||||
|
||||
[RelayCommand]
|
||||
private void SaveAppLockSettings()
|
||||
{
|
||||
AppLockPasswordError = "";
|
||||
var valid = true;
|
||||
var wantsNewPassword = !string.IsNullOrWhiteSpace(AppLockNewPassword) || !string.IsNullOrWhiteSpace(AppLockNewPasswordConfirm);
|
||||
|
||||
if (AppLockEnabled && !_appLock.HasPassword && !wantsNewPassword)
|
||||
{
|
||||
AppLockPasswordError = "Bitte ein Passwort für die App-Sperre festlegen.";
|
||||
valid = false;
|
||||
}
|
||||
else if (wantsNewPassword)
|
||||
{
|
||||
if (AppLockNewPassword != AppLockNewPasswordConfirm)
|
||||
{
|
||||
AppLockPasswordError = "Passwörter stimmen nicht überein.";
|
||||
valid = false;
|
||||
}
|
||||
else if (AppLockNewPassword.Length < 4)
|
||||
{
|
||||
AppLockPasswordError = "Mindestens 4 Zeichen.";
|
||||
valid = false;
|
||||
}
|
||||
}
|
||||
|
||||
if (!valid) return;
|
||||
|
||||
if (wantsNewPassword) _appLock.SetPassword(AppLockNewPassword.Trim());
|
||||
_appLock.Configure(AppLockEnabled, AppLockTimeoutMinutes);
|
||||
|
||||
AppLockNewPassword = ""; AppLockNewPasswordConfirm = "";
|
||||
OnPropertyChanged(nameof(AppLockHasPassword));
|
||||
AppLockStatus = "Gespeichert.";
|
||||
OnAppLockChanged?.Invoke();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using LehrerApp.Core.Interfaces;
|
||||
using LehrerApp.Core.Models;
|
||||
using LehrerApp.Core.Services;
|
||||
using LehrerApp.Data;
|
||||
using LehrerApp.Desktop.Services;
|
||||
using LehrerApp.Desktop.ViewModels.Planning;
|
||||
using LehrerApp.Sync;
|
||||
using LehrerApp.Sync.Crypto;
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Globalization;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace LehrerApp.Desktop.ViewModels.Settings;
|
||||
|
||||
public partial class SettingsViewModel
|
||||
{
|
||||
// ── Darstellung (12.4) ──────────────────────────────────────────────────────
|
||||
|
||||
[ObservableProperty] private AppTheme _selectedTheme;
|
||||
public string[] ThemeOptions { get; } = AppThemeDisplay.Options;
|
||||
public string SelectedThemeName
|
||||
{
|
||||
get => AppThemeDisplay.Label(SelectedTheme);
|
||||
set => SelectedTheme = AppThemeDisplay.FromLabel(value);
|
||||
}
|
||||
|
||||
/// Vom Code-Behind gesetzt: wendet die Theme-Variante sofort an (Application.Current, siehe
|
||||
/// App.ApplyTheme) — ViewModels fassen Avalonia-Framework-Typen nicht direkt an, gleiches
|
||||
/// Muster wie die übrigen Code-Behind-Hooks in dieser Klasse.
|
||||
public Action<AppTheme>? OnThemeChanged { get; set; }
|
||||
|
||||
partial void OnSelectedThemeChanged(AppTheme value)
|
||||
{
|
||||
_appearance.Save(value);
|
||||
OnThemeChanged?.Invoke(value);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using LehrerApp.Core.Interfaces;
|
||||
using LehrerApp.Core.Models;
|
||||
using LehrerApp.Core.Services;
|
||||
using LehrerApp.Data;
|
||||
using LehrerApp.Desktop.Services;
|
||||
using LehrerApp.Desktop.ViewModels.Planning;
|
||||
using LehrerApp.Sync;
|
||||
using LehrerApp.Sync.Crypto;
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Globalization;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace LehrerApp.Desktop.ViewModels.Settings;
|
||||
|
||||
public partial class SettingsViewModel
|
||||
{
|
||||
// ── Sicherheit: Datensicherung (13.3.1/13.3.2) ───────────────────────────
|
||||
|
||||
[ObservableProperty] private string _backupStatus = "";
|
||||
[ObservableProperty] private string _secondaryBackupDirectory = "";
|
||||
public ObservableCollection<BackupListItem> Backups { get; } = [];
|
||||
|
||||
/// Vom Code-Behind gesetzt: zeigt einen Bestätigungsdialog vor dem Wiederherstellen.
|
||||
public Func<BackupListItem, Task<bool>>? OnConfirmRestore { get; set; }
|
||||
|
||||
/// Vom Code-Behind gesetzt: öffnet einen Ordnerauswahl-Dialog für das zweite Sicherungsziel.
|
||||
public Func<Task<string?>>? OnPickBackupDirectory { get; set; }
|
||||
|
||||
// ── Datensicherung: Laden / Erstellen / Wiederherstellen ─────────────────
|
||||
|
||||
private void LoadBackups()
|
||||
{
|
||||
Backups.Clear();
|
||||
foreach (var b in _backups.ListBackups()) Backups.Add(new BackupListItem(b));
|
||||
SecondaryBackupDirectory = _backupSettings.LoadSecondaryDirectory() ?? "";
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void CreateBackupNow()
|
||||
{
|
||||
var path = _backups.CreateBackup(AppBootstrapper.DbPath);
|
||||
LoadBackups();
|
||||
if (path is null) { BackupStatus = "Kein Backup erstellt (Datenbankdatei fehlt)."; return; }
|
||||
|
||||
BackupStatus = _dbEncryption.CanOpenAndRead(path, AppBootstrapper.DbPassword)
|
||||
? "Backup erstellt und geprüft."
|
||||
: "Backup erstellt — Prüfung fehlgeschlagen, Datei könnte beschädigt sein!";
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private async Task PickBackupDirectory()
|
||||
{
|
||||
if (OnPickBackupDirectory is null) return;
|
||||
var path = await OnPickBackupDirectory();
|
||||
if (path is null) return;
|
||||
|
||||
_backupSettings.SaveSecondaryDirectory(path);
|
||||
_backups.SecondaryBackupDirectory = path;
|
||||
SecondaryBackupDirectory = path;
|
||||
BackupStatus = "Zweiter Sicherungsordner gespeichert.";
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void ClearBackupDirectory()
|
||||
{
|
||||
_backupSettings.SaveSecondaryDirectory(null);
|
||||
_backups.SecondaryBackupDirectory = null;
|
||||
SecondaryBackupDirectory = "";
|
||||
BackupStatus = "Zweiter Sicherungsordner entfernt.";
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private async Task RestoreBackup(BackupListItem? item)
|
||||
{
|
||||
if (item is null) return;
|
||||
if (OnConfirmRestore is not null && !await OnConfirmRestore(item)) return;
|
||||
|
||||
_dbContext.Dispose();
|
||||
_backups.RestoreBackup(item.Path, AppBootstrapper.DbPath);
|
||||
AppBootstrapper.RestartApplication();
|
||||
}
|
||||
}
|
||||
|
||||
public class BackupListItem(BackupInfo info)
|
||||
{
|
||||
public string Path { get; } = info.Path;
|
||||
public string Display { get; } = $"{info.CreatedAt:dd.MM.yyyy HH:mm} · {info.SizeBytes / 1024.0:0} KB";
|
||||
}
|
||||
@@ -0,0 +1,251 @@
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using LehrerApp.Core.Interfaces;
|
||||
using LehrerApp.Core.Models;
|
||||
using LehrerApp.Core.Services;
|
||||
using LehrerApp.Data;
|
||||
using LehrerApp.Desktop.Services;
|
||||
using LehrerApp.Desktop.ViewModels.Planning;
|
||||
using LehrerApp.Sync;
|
||||
using LehrerApp.Sync.Crypto;
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Globalization;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace LehrerApp.Desktop.ViewModels.Settings;
|
||||
|
||||
public partial class SettingsViewModel
|
||||
{
|
||||
// ── Kompetenzkatalog ──────────────────────────────────────────────────────
|
||||
|
||||
[ObservableProperty] private SubjectListItem? _catalogSubject;
|
||||
[ObservableProperty] private int _catalogGradeLevel = 10;
|
||||
[ObservableProperty] private string _newDomainName = "";
|
||||
[ObservableProperty] private string _newDomainCode = "";
|
||||
[ObservableProperty] private string _catalogValidation = "";
|
||||
|
||||
public ObservableCollection<DomainEditItem> Domains { get; } = [];
|
||||
|
||||
// ── Katalog: Laden ────────────────────────────────────────────────────────
|
||||
|
||||
partial void OnCatalogSubjectChanged(SubjectListItem? value) => LoadCatalog();
|
||||
partial void OnCatalogGradeLevelChanged(int value) => LoadCatalog();
|
||||
|
||||
private void LoadCatalog()
|
||||
{
|
||||
Domains.Clear();
|
||||
CatalogValidation = "";
|
||||
if (CatalogSubject is null) return;
|
||||
foreach (var d in _domainRepo.GetBySubjectAndGrade(CatalogSubject.Id, CatalogGradeLevel))
|
||||
Domains.Add(CreateDomainEditItem(d));
|
||||
RefreshDomainMoveState();
|
||||
}
|
||||
|
||||
// ── Katalog: Bereich hinzufügen / löschen ────────────────────────────────
|
||||
|
||||
[RelayCommand]
|
||||
private void AddDomain()
|
||||
{
|
||||
if (CatalogSubject is null) { CatalogValidation = "Bitte zuerst ein Fach auswählen."; return; }
|
||||
if (string.IsNullOrWhiteSpace(NewDomainName)) { CatalogValidation = "Bereichsname erforderlich."; return; }
|
||||
|
||||
var domain = new CompetencyDomain
|
||||
{
|
||||
SubjectId = CatalogSubject.Id,
|
||||
GradeLevel = CatalogGradeLevel,
|
||||
Name = NewDomainName.Trim(),
|
||||
Code = NewDomainCode.Trim(),
|
||||
SortOrder = Domains.Count,
|
||||
};
|
||||
_domainRepo.Save(domain);
|
||||
Domains.Add(CreateDomainEditItem(domain));
|
||||
RefreshDomainMoveState();
|
||||
NewDomainName = ""; NewDomainCode = ""; CatalogValidation = "";
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void DeleteDomain(DomainEditItem? item)
|
||||
{
|
||||
if (item is null) return;
|
||||
_domainRepo.Delete(item.Id);
|
||||
Domains.Remove(item);
|
||||
PersistDomainOrder();
|
||||
}
|
||||
|
||||
private DomainEditItem CreateDomainEditItem(CompetencyDomain domain) =>
|
||||
new(domain, _domainRepo, item => MoveDomain(item, -1), item => MoveDomain(item, 1));
|
||||
|
||||
private void MoveDomain(DomainEditItem item, int offset)
|
||||
{
|
||||
var oldIndex = Domains.IndexOf(item);
|
||||
var newIndex = oldIndex + offset;
|
||||
if (oldIndex < 0 || newIndex < 0 || newIndex >= Domains.Count) return;
|
||||
Domains.Move(oldIndex, newIndex);
|
||||
PersistDomainOrder();
|
||||
}
|
||||
|
||||
private void PersistDomainOrder()
|
||||
{
|
||||
for (var i = 0; i < Domains.Count; i++) Domains[i].SetSortOrder(i);
|
||||
RefreshDomainMoveState();
|
||||
}
|
||||
|
||||
private void RefreshDomainMoveState()
|
||||
{
|
||||
for (var i = 0; i < Domains.Count; i++)
|
||||
Domains[i].SetMoveState(i > 0, i < Domains.Count - 1);
|
||||
}
|
||||
}
|
||||
|
||||
// ── DomainEditItem ────────────────────────────────────────────────────────────
|
||||
|
||||
public partial class DomainEditItem : ObservableObject
|
||||
{
|
||||
private readonly CompetencyDomain _domain;
|
||||
private readonly ICompetencyDomainRepository _repo;
|
||||
|
||||
public Guid Id { get; }
|
||||
public string Name { get; }
|
||||
public string Code { get; }
|
||||
public string DisplayName { get; }
|
||||
|
||||
[ObservableProperty] private string _newItemCode = "";
|
||||
[ObservableProperty] private string _newItemDesc = "";
|
||||
|
||||
public ObservableCollection<CompetencyItemVm> Items { get; } = [];
|
||||
public IRelayCommand MoveUpCommand { get; }
|
||||
public IRelayCommand MoveDownCommand { get; }
|
||||
|
||||
public DomainEditItem(CompetencyDomain domain, ICompetencyDomainRepository repo,
|
||||
Action<DomainEditItem>? onMoveUp = null, Action<DomainEditItem>? onMoveDown = null)
|
||||
{
|
||||
_domain = domain;
|
||||
_repo = repo;
|
||||
Id = domain.Id;
|
||||
Name = domain.Name;
|
||||
Code = domain.Code;
|
||||
DisplayName = string.IsNullOrEmpty(domain.Code)
|
||||
? domain.Name
|
||||
: $"{domain.Name} ({domain.Code})";
|
||||
|
||||
MoveUpCommand = new RelayCommand(() => onMoveUp?.Invoke(this), () => _canMoveUp);
|
||||
MoveDownCommand = new RelayCommand(() => onMoveDown?.Invoke(this), () => _canMoveDown);
|
||||
|
||||
_domain.Items = domain.Items.OrderBy(i => i.SortOrder).ToList();
|
||||
foreach (var item in _domain.Items)
|
||||
Items.Add(CreateItemViewModel(item));
|
||||
RefreshItemMoveState();
|
||||
}
|
||||
|
||||
private bool _canMoveUp;
|
||||
private bool _canMoveDown;
|
||||
|
||||
internal void SetMoveState(bool canMoveUp, bool canMoveDown)
|
||||
{
|
||||
_canMoveUp = canMoveUp;
|
||||
_canMoveDown = canMoveDown;
|
||||
MoveUpCommand.NotifyCanExecuteChanged();
|
||||
MoveDownCommand.NotifyCanExecuteChanged();
|
||||
}
|
||||
|
||||
internal void SetSortOrder(int sortOrder)
|
||||
{
|
||||
if (_domain.SortOrder == sortOrder) return;
|
||||
_domain.SortOrder = sortOrder;
|
||||
_repo.Save(_domain);
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void AddItem()
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(NewItemDesc)) return;
|
||||
var item = new CompetencyItem
|
||||
{
|
||||
Code = NewItemCode.Trim(),
|
||||
Description = NewItemDesc.Trim(),
|
||||
SortOrder = _domain.Items.Count,
|
||||
};
|
||||
_domain.Items.Add(item);
|
||||
_repo.Save(_domain);
|
||||
Items.Add(CreateItemViewModel(item));
|
||||
RefreshItemMoveState();
|
||||
NewItemCode = ""; NewItemDesc = "";
|
||||
}
|
||||
|
||||
private void DeleteItem(CompetencyItemVm vm)
|
||||
{
|
||||
_domain.Items.RemoveAll(i => i.Id == vm.ItemId);
|
||||
Items.Remove(vm);
|
||||
PersistItemOrder();
|
||||
}
|
||||
|
||||
private CompetencyItemVm CreateItemViewModel(CompetencyItem item) =>
|
||||
new(item, DeleteItem, vm => MoveItem(vm, -1), vm => MoveItem(vm, 1));
|
||||
|
||||
private void MoveItem(CompetencyItemVm item, int offset)
|
||||
{
|
||||
var oldIndex = Items.IndexOf(item);
|
||||
var newIndex = oldIndex + offset;
|
||||
if (oldIndex < 0 || newIndex < 0 || newIndex >= Items.Count) return;
|
||||
Items.Move(oldIndex, newIndex);
|
||||
PersistItemOrder();
|
||||
}
|
||||
|
||||
private void PersistItemOrder()
|
||||
{
|
||||
_domain.Items = Items.Select(x => x.Model).ToList();
|
||||
for (var i = 0; i < _domain.Items.Count; i++) _domain.Items[i].SortOrder = i;
|
||||
_repo.Save(_domain);
|
||||
RefreshItemMoveState();
|
||||
}
|
||||
|
||||
private void RefreshItemMoveState()
|
||||
{
|
||||
for (var i = 0; i < Items.Count; i++)
|
||||
Items[i].SetMoveState(i > 0, i < Items.Count - 1);
|
||||
}
|
||||
}
|
||||
|
||||
// ── CompetencyItemVm ──────────────────────────────────────────────────────────
|
||||
|
||||
public class CompetencyItemVm
|
||||
{
|
||||
internal CompetencyItem Model { get; }
|
||||
public Guid ItemId { get; }
|
||||
public string Code { get; }
|
||||
public string Description { get; }
|
||||
public string Display { get; }
|
||||
public IRelayCommand DeleteCommand { get; }
|
||||
public IRelayCommand MoveUpCommand { get; }
|
||||
public IRelayCommand MoveDownCommand { get; }
|
||||
|
||||
public CompetencyItemVm(CompetencyItem item, Action<CompetencyItemVm> onDelete,
|
||||
Action<CompetencyItemVm>? onMoveUp = null, Action<CompetencyItemVm>? onMoveDown = null)
|
||||
{
|
||||
Model = item;
|
||||
ItemId = item.Id;
|
||||
Code = item.Code;
|
||||
Description = item.Description;
|
||||
Display = string.IsNullOrEmpty(item.Code)
|
||||
? item.Description
|
||||
: $"[{item.Code}] {item.Description}";
|
||||
DeleteCommand = new RelayCommand(() => onDelete(this));
|
||||
MoveUpCommand = new RelayCommand(() => onMoveUp?.Invoke(this), () => _canMoveUp);
|
||||
MoveDownCommand = new RelayCommand(() => onMoveDown?.Invoke(this), () => _canMoveDown);
|
||||
}
|
||||
|
||||
private bool _canMoveUp;
|
||||
private bool _canMoveDown;
|
||||
|
||||
internal void SetMoveState(bool canMoveUp, bool canMoveDown)
|
||||
{
|
||||
_canMoveUp = canMoveUp;
|
||||
_canMoveDown = canMoveDown;
|
||||
MoveUpCommand.NotifyCanExecuteChanged();
|
||||
MoveDownCommand.NotifyCanExecuteChanged();
|
||||
}
|
||||
}
|
||||
|
||||
// ── GradingKeyTemplateEditItem (1.3.2) ─────────────────────────────────────────
|
||||
@@ -0,0 +1,111 @@
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using LehrerApp.Core.Interfaces;
|
||||
using LehrerApp.Core.Models;
|
||||
using LehrerApp.Core.Services;
|
||||
using LehrerApp.Data;
|
||||
using LehrerApp.Desktop.Services;
|
||||
using LehrerApp.Desktop.ViewModels.Planning;
|
||||
using LehrerApp.Sync;
|
||||
using LehrerApp.Sync.Crypto;
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Globalization;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace LehrerApp.Desktop.ViewModels.Settings;
|
||||
|
||||
public partial class SettingsViewModel
|
||||
{
|
||||
// ── Geräte-Pairing (10.3.1: Schlüsselübertragung auf ein zweites Gerät) ───
|
||||
//
|
||||
// SnapshotService ist nur registriert, wenn bereits eine Server-URL konfiguriert ist (siehe
|
||||
// AppBootstrapper) — daher optional/nullable statt eines Pflicht-Konstruktorparameters.
|
||||
|
||||
[ObservableProperty] private string _pairingCode = "";
|
||||
[ObservableProperty] private string _pairingCodeInput = "";
|
||||
[ObservableProperty] private string _pairingStatus = "";
|
||||
[ObservableProperty] private bool _pairingBusy;
|
||||
|
||||
/// Vom Code-Behind gesetzt: zeigt einen Bestätigungsdialog, bevor die lokale Datenbank durch
|
||||
/// den Stand des anderen Geräts ersetzt wird.
|
||||
public Func<Task<bool>>? OnConfirmPairingRestore { get; set; }
|
||||
|
||||
/// Vom Code-Behind gesetzt: zeigt einen Fehlerdialog, den der Nutzer aktiv wegklicken muss —
|
||||
/// notwendig, weil RedeemPairingCode() nach einem Fehlschlag sofort neu startet (_dbContext ist
|
||||
/// zu dem Zeitpunkt bereits disposed, siehe dort) und PairingStatus dadurch sonst nie sichtbar
|
||||
/// gerendert würde.
|
||||
public Func<string, Task>? OnShowPairingError { get; set; }
|
||||
|
||||
// ── Geräte-Pairing: Code erzeugen / einlösen ─────────────────────────────
|
||||
//
|
||||
// CreateAndUploadAsync lädt einen verschlüsselten Snapshot der lokalen Datenbank samt
|
||||
// Sync-Schlüssel hoch, RestoreFromCodeAsync ersetzt auf dem ZWEITEN Gerät die dortige
|
||||
// Datenbank vollständig durch diesen Snapshot (vorheriger Stand wird automatisch als
|
||||
// .backup-Datei gesichert, siehe SnapshotService) — deshalb vor dem Einlösen ein
|
||||
// Bestätigungsdialog wie bei RestoreBackup.
|
||||
|
||||
[RelayCommand]
|
||||
private async Task CreatePairingCode()
|
||||
{
|
||||
if (_snapshotService is null) return;
|
||||
PairingBusy = true;
|
||||
PairingCode = "";
|
||||
PairingStatus = "";
|
||||
void OnProgress(SnapshotProgress p) => PairingStatus = p.Message;
|
||||
_snapshotService.ProgressChanged += OnProgress;
|
||||
try
|
||||
{
|
||||
var result = await _snapshotService.CreateAndUploadAsync();
|
||||
PairingCode = result.Code;
|
||||
PairingStatus = $"Gültig bis {result.ExpiresAt:dd.MM.yyyy HH:mm} — auf dem anderen Gerät eingeben.";
|
||||
}
|
||||
catch (Exception ex) when (ex is HttpRequestException or InvalidOperationException)
|
||||
{
|
||||
PairingStatus = $"Fehlgeschlagen: {ex.Message}";
|
||||
}
|
||||
finally
|
||||
{
|
||||
_snapshotService.ProgressChanged -= OnProgress;
|
||||
PairingBusy = false;
|
||||
}
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private async Task RedeemPairingCode()
|
||||
{
|
||||
if (_snapshotService is null) return;
|
||||
if (string.IsNullOrWhiteSpace(PairingCodeInput)) { PairingStatus = "Bitte Code eingeben."; return; }
|
||||
if (OnConfirmPairingRestore is not null && !await OnConfirmPairingRestore()) return;
|
||||
|
||||
PairingBusy = true;
|
||||
PairingStatus = "";
|
||||
void OnProgress(SnapshotProgress p) => PairingStatus = p.Message;
|
||||
_snapshotService.ProgressChanged += OnProgress;
|
||||
|
||||
// LiteDB hält beim Öffnen einen exklusiven Dateilock — muss vor dem Überschreiben
|
||||
// geschlossen sein. Läuft danach ein Fehler (falscher Code, Server nicht erreichbar), ist
|
||||
// _dbContext bereits disposed und die App nicht mehr sicher weiter benutzbar, ohne dass
|
||||
// die Datenbankdatei selbst angefasst wurde (RestoreFromCodeAsync schreibt sie erst ganz
|
||||
// am Ende) — deshalb in JEDEM Fall (Erfolg wie Fehlschlag) neu starten, nicht nur bei
|
||||
// Erfolg. Ein Neustart öffnet dann wieder dieselbe, unveränderte Datenbank.
|
||||
_dbContext.Dispose();
|
||||
try
|
||||
{
|
||||
await _snapshotService.RestoreFromCodeAsync(PairingCodeInput.Trim(), AppBootstrapper.DbPath);
|
||||
}
|
||||
catch (Exception ex) when (ex is SnapshotNotFoundException or InvalidOperationException or HttpRequestException)
|
||||
{
|
||||
// _dbContext ist bereits disposed, PairingStatus ist aber ein reines ViewModel-Feld
|
||||
// ohne DB-Zugriff - das Setzen ist unabhängig davon noch sicher.
|
||||
PairingStatus = $"Fehlgeschlagen: {ex.Message}";
|
||||
_logger.Error("Geräte-Pairing (Redeem) fehlgeschlagen", ex);
|
||||
// Ohne diesen Dialog würde PairingStatus nie gerendert: der direkt folgende Neustart
|
||||
// (unten, auch im Fehlerfall nötig, siehe Kommentar oben) beendet den Prozess, bevor
|
||||
// Avalonia den nächsten Frame zeichnen könnte.
|
||||
if (OnShowPairingError is not null) await OnShowPairingError(ex.Message);
|
||||
}
|
||||
AppBootstrapper.RestartApplication();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using LehrerApp.Core.Interfaces;
|
||||
using LehrerApp.Core.Models;
|
||||
using LehrerApp.Core.Services;
|
||||
using LehrerApp.Data;
|
||||
using LehrerApp.Desktop.Services;
|
||||
using LehrerApp.Desktop.ViewModels.Planning;
|
||||
using LehrerApp.Sync;
|
||||
using LehrerApp.Sync.Crypto;
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Globalization;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace LehrerApp.Desktop.ViewModels.Settings;
|
||||
|
||||
public partial class SettingsViewModel
|
||||
{
|
||||
// ── Sicherheit: Datenbank-Verschlüsselung (13.3.4) ───────────────────────
|
||||
|
||||
[ObservableProperty] private bool _isDbEncrypted;
|
||||
[ObservableProperty] private string _currentDbPassword = "";
|
||||
[ObservableProperty] private string _newDbPassword = "";
|
||||
[ObservableProperty] private string _newDbPasswordConfirm = "";
|
||||
[ObservableProperty] private string _dbPasswordError = "";
|
||||
|
||||
public string DbEncryptionStatusLabel => IsDbEncrypted ? "verschlüsselt" : "nicht verschlüsselt";
|
||||
|
||||
partial void OnIsDbEncryptedChanged(bool value) => OnPropertyChanged(nameof(DbEncryptionStatusLabel));
|
||||
|
||||
// ── Datenbank-Verschlüsselung: Setzen / Ändern / Entfernen ───────────────
|
||||
|
||||
[RelayCommand]
|
||||
private void SaveDbPassword()
|
||||
{
|
||||
DbPasswordError = "";
|
||||
var valid = true;
|
||||
var wantsPassword = !string.IsNullOrWhiteSpace(NewDbPassword) || !string.IsNullOrWhiteSpace(NewDbPasswordConfirm);
|
||||
|
||||
if (IsDbEncrypted && !_dbEncryption.VerifyPassword(AppBootstrapper.DbPath, CurrentDbPassword))
|
||||
{
|
||||
DbPasswordError = "Aktuelles Passwort ist falsch.";
|
||||
valid = false;
|
||||
}
|
||||
|
||||
if (!wantsPassword)
|
||||
{
|
||||
DbPasswordError = "Bitte ein neues Passwort eingeben.";
|
||||
valid = false;
|
||||
}
|
||||
else if (NewDbPassword != NewDbPasswordConfirm)
|
||||
{
|
||||
DbPasswordError = "Neue Passwörter stimmen nicht überein.";
|
||||
valid = false;
|
||||
}
|
||||
else if (NewDbPassword.Length < 4)
|
||||
{
|
||||
DbPasswordError = "Mindestens 4 Zeichen.";
|
||||
valid = false;
|
||||
}
|
||||
|
||||
if (!valid) return;
|
||||
|
||||
_dbContext.Dispose();
|
||||
_dbEncryption.SetPassword(AppBootstrapper.DbPath, IsDbEncrypted ? CurrentDbPassword : null, NewDbPassword.Trim());
|
||||
AppBootstrapper.RestartApplication();
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void RemoveDbPassword()
|
||||
{
|
||||
DbPasswordError = "";
|
||||
if (!_dbEncryption.VerifyPassword(AppBootstrapper.DbPath, CurrentDbPassword))
|
||||
{
|
||||
DbPasswordError = "Aktuelles Passwort ist falsch.";
|
||||
return;
|
||||
}
|
||||
|
||||
_dbContext.Dispose();
|
||||
_dbEncryption.SetPassword(AppBootstrapper.DbPath, CurrentDbPassword, null);
|
||||
AppBootstrapper.RestartApplication();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using LehrerApp.Core.Interfaces;
|
||||
using LehrerApp.Core.Models;
|
||||
using LehrerApp.Core.Services;
|
||||
using LehrerApp.Data;
|
||||
using LehrerApp.Desktop.Services;
|
||||
using LehrerApp.Desktop.ViewModels.Planning;
|
||||
using LehrerApp.Sync;
|
||||
using LehrerApp.Sync.Crypto;
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Globalization;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace LehrerApp.Desktop.ViewModels.Settings;
|
||||
|
||||
public partial class SettingsViewModel
|
||||
{
|
||||
// ── Notenschlüssel-Vorlagen (1.3.2) ──────────────────────────────────────
|
||||
|
||||
[ObservableProperty] private string _newTemplateName = "";
|
||||
[ObservableProperty] private string _newTemplateGradingSystemName = "Noten 1–6";
|
||||
[ObservableProperty] private string _newTemplateNameError = "";
|
||||
|
||||
public List<string> GradingSystemOptions { get; } = ["Noten 1–6", "Punkte 0–15"];
|
||||
public ObservableCollection<GradingKeyTemplateEditItem> GradingKeyTemplateList { get; } = [];
|
||||
|
||||
// ── Notenschlüssel-Vorlagen: Laden / Hinzufügen / Löschen ────────────────
|
||||
|
||||
private void LoadGradingKeyTemplates()
|
||||
{
|
||||
GradingKeyTemplateList.Clear();
|
||||
foreach (var t in _gradingKeyTemplates.GetAll())
|
||||
GradingKeyTemplateList.Add(new GradingKeyTemplateEditItem(t, _gradingKeyTemplates, _grading));
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void AddGradingKeyTemplate()
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(NewTemplateName)) { NewTemplateNameError = "Vorlagenname erforderlich."; return; }
|
||||
|
||||
var system = NewTemplateGradingSystemName == "Punkte 0–15"
|
||||
? GradingSystem.Points0To15 : GradingSystem.Grades1To6;
|
||||
var defaults = system == GradingSystem.Grades1To6
|
||||
? GradingService.DefaultKey1To6() : GradingService.DefaultKey0To15();
|
||||
|
||||
var template = new GradingKeyTemplate
|
||||
{
|
||||
Name = NewTemplateName.Trim(),
|
||||
GradingSystem = system,
|
||||
Entries = defaults.Select(e => new GradingKeyEntry { Grade = e.Grade, MinPercent = e.MinPercent }).ToList(),
|
||||
};
|
||||
_gradingKeyTemplates.Save(template);
|
||||
GradingKeyTemplateList.Add(new GradingKeyTemplateEditItem(template, _gradingKeyTemplates, _grading));
|
||||
NewTemplateName = "";
|
||||
NewTemplateNameError = "";
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void DeleteGradingKeyTemplate(GradingKeyTemplateEditItem? item)
|
||||
{
|
||||
if (item is null) return;
|
||||
_gradingKeyTemplates.Delete(item.Id);
|
||||
GradingKeyTemplateList.Remove(item);
|
||||
}
|
||||
}
|
||||
|
||||
public partial class GradingKeyTemplateEditItem : ObservableObject
|
||||
{
|
||||
private readonly GradingKeyTemplate _template;
|
||||
private readonly IGradingKeyTemplateRepository _repo;
|
||||
private readonly GradingService _grading;
|
||||
|
||||
public Guid Id { get; }
|
||||
public string Name { get; }
|
||||
public string GradingSystemLabel { get; }
|
||||
|
||||
[ObservableProperty] private string _newGrade = "";
|
||||
[ObservableProperty] private double _newMinPercent;
|
||||
[ObservableProperty] private string _newGradeError = "";
|
||||
[ObservableProperty] private string _newMinPercentError = "";
|
||||
[ObservableProperty] private string _completenessWarning = "";
|
||||
|
||||
public ObservableCollection<GradingKeyEntryVm> Entries { get; } = [];
|
||||
|
||||
public GradingKeyTemplateEditItem(GradingKeyTemplate template, IGradingKeyTemplateRepository repo,
|
||||
GradingService grading)
|
||||
{
|
||||
_template = template; _repo = repo; _grading = grading;
|
||||
Id = template.Id;
|
||||
Name = template.Name;
|
||||
GradingSystemLabel = template.GradingSystem == GradingSystem.Grades1To6
|
||||
? "Noten 1–6" : "Punkte 0–15";
|
||||
|
||||
foreach (var e in template.Entries.OrderByDescending(e => e.MinPercent))
|
||||
Entries.Add(new GradingKeyEntryVm(e, DeleteEntry));
|
||||
RecomputeCompleteness();
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void AddEntry()
|
||||
{
|
||||
NewGradeError = ""; NewMinPercentError = "";
|
||||
var valid = true;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(NewGrade)) { NewGradeError = "Bezeichnung erforderlich."; valid = false; }
|
||||
if (NewMinPercent is < 0 or > 100) { NewMinPercentError = "Muss zwischen 0 und 100 liegen."; valid = false; }
|
||||
else if (_template.Entries.Any(e => Math.Abs(e.MinPercent - NewMinPercent) < 0.0001))
|
||||
{ NewMinPercentError = "Diese Prozentgrenze existiert bereits."; valid = false; }
|
||||
|
||||
if (!valid) return;
|
||||
|
||||
var entry = new GradingKeyEntry { Grade = NewGrade.Trim(), MinPercent = NewMinPercent };
|
||||
_template.Entries.Add(entry);
|
||||
_template.Entries = _template.Entries.OrderByDescending(e => e.MinPercent).ToList();
|
||||
_repo.Save(_template);
|
||||
|
||||
Entries.Clear();
|
||||
foreach (var e in _template.Entries) Entries.Add(new GradingKeyEntryVm(e, DeleteEntry));
|
||||
NewGrade = ""; NewMinPercent = 0;
|
||||
RecomputeCompleteness();
|
||||
}
|
||||
|
||||
private void DeleteEntry(GradingKeyEntryVm vm)
|
||||
{
|
||||
_template.Entries.RemoveAll(e => e.Grade == vm.Grade && Math.Abs(e.MinPercent - vm.MinPercent) < 0.0001);
|
||||
_repo.Save(_template);
|
||||
Entries.Remove(vm);
|
||||
RecomputeCompleteness();
|
||||
}
|
||||
|
||||
private void RecomputeCompleteness() =>
|
||||
CompletenessWarning = _grading.ValidateGradingKey(_template.Entries) ?? "";
|
||||
}
|
||||
|
||||
// ── GradingKeyEntryVm ─────────────────────────────────────────────────────────
|
||||
|
||||
public class GradingKeyEntryVm
|
||||
{
|
||||
public string Grade { get; }
|
||||
public double MinPercent { get; }
|
||||
public string Display { get; }
|
||||
public IRelayCommand DeleteCommand { get; }
|
||||
|
||||
public GradingKeyEntryVm(GradingKeyEntry e, Action<GradingKeyEntryVm> onDelete)
|
||||
{
|
||||
Grade = e.Grade;
|
||||
MinPercent = e.MinPercent;
|
||||
Display = $"{Grade} — ab {MinPercent.ToString("0.##")} %";
|
||||
DeleteCommand = new RelayCommand(() => onDelete(this));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using LehrerApp.Core.Interfaces;
|
||||
using LehrerApp.Core.Models;
|
||||
using LehrerApp.Core.Services;
|
||||
using LehrerApp.Data;
|
||||
using LehrerApp.Desktop.Services;
|
||||
using LehrerApp.Desktop.ViewModels.Planning;
|
||||
using LehrerApp.Sync;
|
||||
using LehrerApp.Sync.Crypto;
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Globalization;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace LehrerApp.Desktop.ViewModels.Settings;
|
||||
|
||||
public partial class SettingsViewModel
|
||||
{
|
||||
// ── Ferien & Feiertage (4.3.5, aus dem Stundenplan hierher verschoben) ───
|
||||
|
||||
[ObservableProperty] private string _selectedStateName = "";
|
||||
[ObservableProperty] private string _newHolidayName = "";
|
||||
[ObservableProperty] private string _newHolidayStartText = "";
|
||||
[ObservableProperty] private string _newHolidayEndText = "";
|
||||
[ObservableProperty] private string _holidayNameError = "";
|
||||
[ObservableProperty] private string _holidayDateError = "";
|
||||
[ObservableProperty] private string _schoolName = "";
|
||||
[ObservableProperty] private string _schoolStreet = "";
|
||||
[ObservableProperty] private string _schoolPostalCode = "";
|
||||
[ObservableProperty] private string _schoolCity = "";
|
||||
[ObservableProperty] private string _schoolNameError = "";
|
||||
[ObservableProperty] private string _schoolStreetError = "";
|
||||
[ObservableProperty] private string _schoolPostalCodeError = "";
|
||||
[ObservableProperty] private string _schoolCityError = "";
|
||||
[ObservableProperty] private string _schoolLocationStatus = "";
|
||||
[ObservableProperty] private string _resolvedSchoolAddress = "";
|
||||
|
||||
public List<string> StateOptions { get; } = GermanStateDisplay.Options.ToList();
|
||||
public ObservableCollection<SchoolHolidayItem> SchoolHolidayEntries { get; } = [];
|
||||
|
||||
// ── Ferien & Feiertage: Bundesland / Schulferien pflegen ─────────────────
|
||||
|
||||
partial void OnSelectedStateNameChanged(string value) =>
|
||||
_calendarSettings.SetState(GermanStateDisplay.FromLabel(value));
|
||||
|
||||
[RelayCommand]
|
||||
private async Task LoadSchoolLocation()
|
||||
{
|
||||
if (_schoolWeather is null) return;
|
||||
try
|
||||
{
|
||||
var location = await _schoolWeather.GetLocationAsync();
|
||||
if (location is null) return;
|
||||
SchoolName = location.SchoolName;
|
||||
SchoolStreet = location.Street;
|
||||
SchoolPostalCode = location.PostalCode;
|
||||
SchoolCity = location.City;
|
||||
SelectedStateName = GermanStateDisplay.Label(location.State);
|
||||
ResolvedSchoolAddress = location.ResolvedAddress;
|
||||
}
|
||||
catch (SchoolWeatherException ex) { SchoolLocationStatus = ex.Message; }
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private async Task SaveSchoolLocation()
|
||||
{
|
||||
SchoolNameError = ""; SchoolStreetError = ""; SchoolPostalCodeError = "";
|
||||
SchoolCityError = ""; SchoolLocationStatus = "";
|
||||
var valid = true;
|
||||
if (string.IsNullOrWhiteSpace(SchoolName))
|
||||
{ SchoolNameError = "Name der Schule erforderlich."; valid = false; }
|
||||
if (string.IsNullOrWhiteSpace(SchoolStreet))
|
||||
{ SchoolStreetError = "Straße und Hausnummer erforderlich."; valid = false; }
|
||||
if (!System.Text.RegularExpressions.Regex.IsMatch(SchoolPostalCode.Trim(), @"^\d{5}$"))
|
||||
{ SchoolPostalCodeError = "Bitte eine fünfstellige PLZ angeben."; valid = false; }
|
||||
if (string.IsNullOrWhiteSpace(SchoolCity))
|
||||
{ SchoolCityError = "Ort erforderlich."; valid = false; }
|
||||
if (!valid) return;
|
||||
if (_schoolWeather is null)
|
||||
{
|
||||
SchoolLocationStatus = "Der Wetterdienst ist nicht verfügbar.";
|
||||
return;
|
||||
}
|
||||
|
||||
SchoolLocationStatus = "Adresse wird geprüft …";
|
||||
try
|
||||
{
|
||||
var profile = await _schoolWeather.SaveLocationAsync(new SchoolLocationRequest
|
||||
{
|
||||
SchoolName = SchoolName.Trim(), Street = SchoolStreet.Trim(),
|
||||
PostalCode = SchoolPostalCode.Trim(), City = SchoolCity.Trim(),
|
||||
State = GermanStateDisplay.FromLabel(SelectedStateName),
|
||||
});
|
||||
ResolvedSchoolAddress = profile.ResolvedAddress;
|
||||
SchoolLocationStatus = "Schulstandort gespeichert. Wetterdaten werden serverseitig abgerufen.";
|
||||
}
|
||||
catch (SchoolWeatherException ex) { SchoolLocationStatus = ex.Message; }
|
||||
}
|
||||
|
||||
private void LoadSchoolHolidays()
|
||||
{
|
||||
SchoolHolidayEntries.Clear();
|
||||
foreach (var h in _schoolHolidays.GetAll().OrderBy(h => h.StartDate))
|
||||
SchoolHolidayEntries.Add(new SchoolHolidayItem(h));
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void AddSchoolHoliday()
|
||||
{
|
||||
HolidayNameError = ""; HolidayDateError = "";
|
||||
var valid = true;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(NewHolidayName)) { HolidayNameError = "Name erforderlich."; valid = false; }
|
||||
|
||||
var hasStart = DateOnly.TryParseExact(NewHolidayStartText, "dd.MM.yyyy", CultureInfo.InvariantCulture,
|
||||
System.Globalization.DateTimeStyles.None, out var start);
|
||||
var hasEnd = DateOnly.TryParseExact(NewHolidayEndText, "dd.MM.yyyy", CultureInfo.InvariantCulture,
|
||||
System.Globalization.DateTimeStyles.None, out var end);
|
||||
|
||||
if (!hasStart || !hasEnd) { HolidayDateError = "Bitte Beginn und Ende im Format TT.MM.JJJJ angeben."; valid = false; }
|
||||
else if (end < start) { HolidayDateError = "Das Ende darf nicht vor dem Beginn liegen."; valid = false; }
|
||||
|
||||
if (!valid) return;
|
||||
|
||||
_schoolHolidays.Save(new SchoolHoliday { Name = NewHolidayName.Trim(), StartDate = start, EndDate = end });
|
||||
NewHolidayName = ""; NewHolidayStartText = ""; NewHolidayEndText = "";
|
||||
LoadSchoolHolidays();
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void RemoveSchoolHoliday(SchoolHolidayItem? item)
|
||||
{
|
||||
if (item is null) return;
|
||||
_schoolHolidays.Delete(item.Id);
|
||||
SchoolHolidayEntries.Remove(item);
|
||||
}
|
||||
}
|
||||
|
||||
public class SchoolHolidayItem(SchoolHoliday h)
|
||||
{
|
||||
public Guid Id { get; } = h.Id;
|
||||
public string Name { get; } = h.Name;
|
||||
public string RangeDisplay { get; } = $"{h.StartDate:dd.MM.yyyy} – {h.EndDate:dd.MM.yyyy}";
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using LehrerApp.Core.Interfaces;
|
||||
using LehrerApp.Core.Models;
|
||||
using LehrerApp.Core.Services;
|
||||
using LehrerApp.Data;
|
||||
using LehrerApp.Desktop.Services;
|
||||
using LehrerApp.Desktop.ViewModels.Planning;
|
||||
using LehrerApp.Sync;
|
||||
using LehrerApp.Sync.Crypto;
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Globalization;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace LehrerApp.Desktop.ViewModels.Settings;
|
||||
|
||||
public partial class SettingsViewModel
|
||||
{
|
||||
// ── JSON Import / Export ──────────────────────────────────────────────────
|
||||
|
||||
public CompetencyCatalogImportPreview? PrepareCatalogImport(string json)
|
||||
{
|
||||
if (CatalogSubject is null)
|
||||
{
|
||||
CatalogValidation = "Bitte zuerst ein Fach auswählen.";
|
||||
return null;
|
||||
}
|
||||
try
|
||||
{
|
||||
CatalogValidation = "";
|
||||
return _catalogImport.Analyze(
|
||||
json, CatalogSubject.Id, CatalogSubject.Name, CatalogGradeLevel);
|
||||
}
|
||||
catch (InvalidDataException ex)
|
||||
{
|
||||
CatalogValidation = ex.Message;
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public void ApplyCatalogImport(CompetencyCatalogImportPreview preview,
|
||||
CompetencyCatalogImportMode mode, IReadOnlySet<string> useImportedConflicts)
|
||||
{
|
||||
_catalogImport.Apply(preview, mode, useImportedConflicts);
|
||||
LoadCatalog();
|
||||
CatalogValidation = mode == CompetencyCatalogImportMode.Merge
|
||||
? "Kompetenzkatalog wurde sicher zusammengeführt."
|
||||
: "Kompetenzkatalog wurde vollständig ersetzt.";
|
||||
}
|
||||
|
||||
public string ExportCatalog()
|
||||
=> SerializeCatalog(CatalogGradeLevel);
|
||||
|
||||
public CompetencyCatalogImportPreview? PrepareCatalogCopy(int targetGradeLevel)
|
||||
{
|
||||
if (CatalogSubject is null)
|
||||
{
|
||||
CatalogValidation = "Bitte zuerst ein Fach auswählen.";
|
||||
return null;
|
||||
}
|
||||
if (Domains.Count == 0)
|
||||
{
|
||||
CatalogValidation = "Der ausgewählte Katalog enthält keine Bereiche.";
|
||||
return null;
|
||||
}
|
||||
if (targetGradeLevel is < 1 or > 13 || targetGradeLevel == CatalogGradeLevel)
|
||||
{
|
||||
CatalogValidation = "Bitte eine andere Zielklassenstufe zwischen 1 und 13 auswählen.";
|
||||
return null;
|
||||
}
|
||||
|
||||
CatalogValidation = "";
|
||||
return _catalogImport.Analyze(SerializeCatalog(targetGradeLevel), CatalogSubject.Id,
|
||||
CatalogSubject.Name, targetGradeLevel);
|
||||
}
|
||||
|
||||
public void SetCatalogCopyStatus(int targetGradeLevel) =>
|
||||
CatalogValidation = $"Kompetenzkatalog wurde in Klassenstufe {targetGradeLevel} kopiert.";
|
||||
|
||||
private string SerializeCatalog(int gradeLevel)
|
||||
{
|
||||
var dto = new CatalogDto
|
||||
{
|
||||
Subject = CatalogSubject?.Name ?? "",
|
||||
GradeLevel = gradeLevel,
|
||||
Domains = Domains.Select(d => new DomainDto
|
||||
{
|
||||
Name = d.Name,
|
||||
Code = d.Code,
|
||||
Competencies = d.Items.Select(i => new CompetencyDto
|
||||
{
|
||||
Code = i.Code,
|
||||
Description = i.Description,
|
||||
}).ToList(),
|
||||
}).ToList(),
|
||||
};
|
||||
return JsonSerializer.Serialize(dto, new JsonSerializerOptions { WriteIndented = true });
|
||||
}
|
||||
}
|
||||
|
||||
// ── JSON DTOs ─────────────────────────────────────────────────────────────────
|
||||
|
||||
internal class CatalogDto
|
||||
{
|
||||
[JsonPropertyName("subject")] public string? Subject { get; set; }
|
||||
[JsonPropertyName("gradeLevel")] public int GradeLevel { get; set; }
|
||||
[JsonPropertyName("domains")] public List<DomainDto>? Domains { get; set; }
|
||||
}
|
||||
|
||||
internal class DomainDto
|
||||
{
|
||||
[JsonPropertyName("name")] public string? Name { get; set; }
|
||||
[JsonPropertyName("code")] public string? Code { get; set; }
|
||||
[JsonPropertyName("competencies")] public List<CompetencyDto>? Competencies { get; set; }
|
||||
}
|
||||
|
||||
internal class CompetencyDto
|
||||
{
|
||||
[JsonPropertyName("code")] public string? Code { get; set; }
|
||||
[JsonPropertyName("description")] public string? Description { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using LehrerApp.Core.Interfaces;
|
||||
using LehrerApp.Core.Models;
|
||||
using LehrerApp.Core.Services;
|
||||
using LehrerApp.Data;
|
||||
using LehrerApp.Desktop.Services;
|
||||
using LehrerApp.Desktop.ViewModels.Planning;
|
||||
using LehrerApp.Sync;
|
||||
using LehrerApp.Sync.Crypto;
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Globalization;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace LehrerApp.Desktop.ViewModels.Settings;
|
||||
|
||||
public partial class SettingsViewModel
|
||||
{
|
||||
// ── Word-Briefvorlagen (7.1.4 / 11.5) ───────────────────────────────────
|
||||
|
||||
[ObservableProperty] private string _letterTemplateStatus = "";
|
||||
public ObservableCollection<LetterTemplateListItem> LetterTemplateList { get; } = [];
|
||||
public IReadOnlyList<LetterPlaceholder> SupportedLetterPlaceholders =>
|
||||
LetterTemplateService.SupportedPlaceholders;
|
||||
|
||||
// ── Word-Briefvorlagen: Import und Validierung ──────────────────────────
|
||||
|
||||
private void LoadLetterTemplates()
|
||||
{
|
||||
LetterTemplateList.Clear();
|
||||
foreach (var template in _letterTemplates.GetTemplates())
|
||||
LetterTemplateList.Add(new LetterTemplateListItem(template, _letterTemplates.Validate(template)));
|
||||
}
|
||||
|
||||
public void ImportLetterTemplate(string path)
|
||||
{
|
||||
LetterTemplateStatus = "";
|
||||
try
|
||||
{
|
||||
var template = _letterTemplates.Import(path);
|
||||
var validation = _letterTemplates.Validate(template);
|
||||
LoadLetterTemplates();
|
||||
LetterTemplateStatus = validation.Issues.Count == 0
|
||||
? "Vorlage importiert und ohne Auffälligkeiten geprüft."
|
||||
: $"Vorlage importiert. Die Prüfung meldet {validation.Issues.Count} Hinweis(e).";
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or InvalidDataException or UnauthorizedAccessException)
|
||||
{
|
||||
LetterTemplateStatus = $"Import fehlgeschlagen: {ex.Message}";
|
||||
}
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void ValidateLetterTemplate(LetterTemplateListItem? item)
|
||||
{
|
||||
if (item is null) return;
|
||||
var index = LetterTemplateList.IndexOf(item);
|
||||
var refreshed = new LetterTemplateListItem(item.Model, _letterTemplates.Validate(item.Model));
|
||||
if (index >= 0) LetterTemplateList[index] = refreshed;
|
||||
LetterTemplateStatus = refreshed.Validation.Issues.Count == 0
|
||||
? $"„{item.Name}“ ist ohne Auffälligkeiten."
|
||||
: $"„{item.Name}“: {refreshed.Validation.Issues.Count} Hinweis(e).";
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void DeleteLetterTemplate(LetterTemplateListItem? item)
|
||||
{
|
||||
if (item is null) return;
|
||||
_letterTemplates.Delete(item.Id);
|
||||
LetterTemplateList.Remove(item);
|
||||
LetterTemplateStatus = "Vorlage gelöscht.";
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class LetterTemplateListItem
|
||||
{
|
||||
public LetterTemplateInfo Model { get; }
|
||||
public TemplateValidationResult Validation { get; }
|
||||
public Guid Id => Model.Id;
|
||||
public string Name => Model.Name;
|
||||
public string OriginalFileName => Model.OriginalFileName;
|
||||
public bool HasIssues => Validation.Issues.Count > 0;
|
||||
public bool HasNoIssues => !HasIssues;
|
||||
public string ValidationSummary => HasNoIssues
|
||||
? $"{Validation.Tags.Count} Feld(er) · keine Auffälligkeiten"
|
||||
: $"{Validation.Tags.Count} Feld(er) · {Validation.Issues.Count} Hinweis(e)";
|
||||
public ObservableCollection<LetterTemplateIssueItem> Issues { get; }
|
||||
|
||||
public LetterTemplateListItem(LetterTemplateInfo model, TemplateValidationResult validation)
|
||||
{
|
||||
Model = model;
|
||||
Validation = validation;
|
||||
Issues = new(validation.Issues.Select(i => new LetterTemplateIssueItem(i)));
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class LetterTemplateIssueItem(TemplateValidationIssue issue)
|
||||
{
|
||||
public string Icon => issue.Severity switch
|
||||
{
|
||||
TemplateIssueSeverity.Error => "⛔",
|
||||
TemplateIssueSeverity.StrongWarning => "⚠",
|
||||
_ => "ⓘ",
|
||||
};
|
||||
public string Message => issue.Message;
|
||||
public string Color => issue.Severity switch
|
||||
{
|
||||
TemplateIssueSeverity.Error => "#DC2626",
|
||||
TemplateIssueSeverity.StrongWarning => "#D97706",
|
||||
_ => "#6B7280",
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using LehrerApp.Core.Interfaces;
|
||||
using LehrerApp.Core.Models;
|
||||
using LehrerApp.Core.Services;
|
||||
using LehrerApp.Data;
|
||||
using LehrerApp.Desktop.Services;
|
||||
using LehrerApp.Desktop.ViewModels.Planning;
|
||||
using LehrerApp.Sync;
|
||||
using LehrerApp.Sync.Crypto;
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Globalization;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace LehrerApp.Desktop.ViewModels.Settings;
|
||||
|
||||
public partial class SettingsViewModel
|
||||
{
|
||||
// ── Stundenraster: Uhrzeiten je Einzelstunde (4.2.2 Nachtrag) ────────────
|
||||
|
||||
[ObservableProperty] private string _periodTimesError = "";
|
||||
[ObservableProperty] private string _periodTimesStatus = "";
|
||||
|
||||
public ObservableCollection<PeriodTimeEditItem> PeriodTimes { get; } = [];
|
||||
|
||||
// ── Stundenraster: Laden / Speichern ─────────────────────────────────────
|
||||
|
||||
private void LoadPeriodTimes()
|
||||
{
|
||||
PeriodTimes.Clear();
|
||||
for (var period = 1; period <= 10; period++)
|
||||
{
|
||||
var times = _periodSchedule.GetTimes(period);
|
||||
PeriodTimes.Add(new PeriodTimeEditItem(period, times?.Start, times?.End));
|
||||
}
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void SavePeriodTimes()
|
||||
{
|
||||
PeriodTimesError = ""; PeriodTimesStatus = "";
|
||||
var entries = new List<PeriodTimeEntry>();
|
||||
|
||||
foreach (var item in PeriodTimes)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(item.StartText) && string.IsNullOrWhiteSpace(item.EndText))
|
||||
continue; // Stunde bewusst nicht konfiguriert — ok, keine Pflicht für alle 10.
|
||||
|
||||
if (!TimeOnly.TryParseExact(item.StartText, "HH:mm", CultureInfo.InvariantCulture, DateTimeStyles.None, out var start) ||
|
||||
!TimeOnly.TryParseExact(item.EndText, "HH:mm", CultureInfo.InvariantCulture, DateTimeStyles.None, out var end))
|
||||
{ PeriodTimesError = $"{item.PeriodLabel}: Format HH:MM."; return; }
|
||||
|
||||
if (end <= start)
|
||||
{ PeriodTimesError = $"{item.PeriodLabel}: Ende muss nach dem Beginn liegen."; return; }
|
||||
|
||||
entries.Add(new PeriodTimeEntry { PeriodNumber = item.PeriodNumber, Start = start, End = end });
|
||||
}
|
||||
|
||||
_periodSchedule.SetPeriods(entries);
|
||||
PeriodTimesStatus = "Gespeichert.";
|
||||
}
|
||||
}
|
||||
|
||||
public partial class PeriodTimeEditItem : ObservableObject
|
||||
{
|
||||
public int PeriodNumber { get; }
|
||||
public string PeriodLabel { get; }
|
||||
|
||||
[ObservableProperty] private string _startText;
|
||||
[ObservableProperty] private string _endText;
|
||||
|
||||
public PeriodTimeEditItem(int periodNumber, TimeOnly? start, TimeOnly? end)
|
||||
{
|
||||
PeriodNumber = periodNumber;
|
||||
PeriodLabel = $"{periodNumber}. Stunde";
|
||||
_startText = start?.ToString("HH:mm") ?? "";
|
||||
_endText = end?.ToString("HH:mm") ?? "";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using LehrerApp.Core.Interfaces;
|
||||
using LehrerApp.Core.Models;
|
||||
using LehrerApp.Core.Services;
|
||||
using LehrerApp.Data;
|
||||
using LehrerApp.Desktop.Services;
|
||||
using LehrerApp.Desktop.ViewModels.Planning;
|
||||
using LehrerApp.Sync;
|
||||
using LehrerApp.Sync.Crypto;
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Globalization;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace LehrerApp.Desktop.ViewModels.Settings;
|
||||
|
||||
public partial class SettingsViewModel
|
||||
{
|
||||
// ── Datenschutz: Löschfristen (5.4.2) ────────────────────────────────────
|
||||
|
||||
[ObservableProperty] private int _retentionYears = 3;
|
||||
[ObservableProperty] private string _retentionStatus = "";
|
||||
|
||||
public ObservableCollection<ExpiredDocumentItem> ExpiredDocuments { get; } = [];
|
||||
|
||||
/// Vom Code-Behind gesetzt: zeigt einen Bestätigungsdialog vor dem endgültigen Löschen.
|
||||
public Func<ExpiredDocumentItem, Task<bool>>? OnConfirmHardDelete { get; set; }
|
||||
|
||||
// ── Datenschutz: Löschfristen ─────────────────────────────────────────────
|
||||
|
||||
private void LoadExpiredDocuments()
|
||||
{
|
||||
ExpiredDocuments.Clear();
|
||||
var cutoff = _privacy.RetentionCutoff();
|
||||
foreach (var d in _documentation.GetAll().Where(d => d.CreatedAt < cutoff))
|
||||
{
|
||||
var student = _students.GetById(d.StudentId);
|
||||
ExpiredDocuments.Add(new ExpiredDocumentItem(d, student?.FullName ?? "?"));
|
||||
}
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void SaveRetentionYears()
|
||||
{
|
||||
_privacy.SetRetentionYears(RetentionYears);
|
||||
LoadExpiredDocuments();
|
||||
RetentionStatus = "Gespeichert.";
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private async Task HardDeleteDocument(ExpiredDocumentItem? item)
|
||||
{
|
||||
if (item is null) return;
|
||||
if (OnConfirmHardDelete is not null && !await OnConfirmHardDelete(item)) return;
|
||||
_documentation.HardDelete(item.Id);
|
||||
ExpiredDocuments.Remove(item);
|
||||
}
|
||||
}
|
||||
|
||||
public class ExpiredDocumentItem(Documentation d, string studentName)
|
||||
{
|
||||
public Guid Id { get; } = d.Id;
|
||||
public string StudentName { get; } = studentName;
|
||||
public string Title { get; } = d.Title;
|
||||
public string CreatedAtDisplay { get; } = d.CreatedAt.ToLocalTime().ToString("dd.MM.yyyy");
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using LehrerApp.Core.Interfaces;
|
||||
using LehrerApp.Core.Models;
|
||||
using LehrerApp.Core.Services;
|
||||
using LehrerApp.Data;
|
||||
using LehrerApp.Desktop.Services;
|
||||
using LehrerApp.Desktop.ViewModels.Planning;
|
||||
using LehrerApp.Sync;
|
||||
using LehrerApp.Sync.Crypto;
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Globalization;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace LehrerApp.Desktop.ViewModels.Settings;
|
||||
|
||||
public partial class SettingsViewModel
|
||||
{
|
||||
// ── Kürzel-Katalog (Stundenverlaufsplan, 4.2.2) ──────────────────────────
|
||||
|
||||
[ObservableProperty] private string _newShorthandCode = "";
|
||||
[ObservableProperty] private string _newShorthandLabel = "";
|
||||
[ObservableProperty] private string _newShorthandCodeError = "";
|
||||
|
||||
public ObservableCollection<ShorthandCodeListItem> ShorthandCodes { get; } = [];
|
||||
|
||||
// ── Kürzel-Katalog: Laden / Hinzufügen / Löschen ─────────────────────────
|
||||
|
||||
public void LoadShorthandCodes()
|
||||
{
|
||||
ShorthandCodes.Clear();
|
||||
foreach (var c in _shorthandCodes.GetAll())
|
||||
ShorthandCodes.Add(new ShorthandCodeListItem(c));
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void AddShorthandCode()
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(NewShorthandCode)) { NewShorthandCodeError = "Kürzel erforderlich."; return; }
|
||||
try
|
||||
{
|
||||
_shorthandCodes.Save(new ShorthandCode { Code = NewShorthandCode.Trim(), Label = NewShorthandLabel.Trim() });
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
NewShorthandCodeError = ex.Message;
|
||||
return;
|
||||
}
|
||||
NewShorthandCode = ""; NewShorthandLabel = ""; NewShorthandCodeError = "";
|
||||
LoadShorthandCodes();
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void DeleteShorthandCode(ShorthandCodeListItem? item)
|
||||
{
|
||||
if (item is null) return;
|
||||
_shorthandCodes.Delete(item.Id);
|
||||
LoadShorthandCodes();
|
||||
}
|
||||
}
|
||||
|
||||
public class ShorthandCodeListItem(ShorthandCode c)
|
||||
{
|
||||
public Guid Id { get; } = c.Id;
|
||||
public string Code { get; } = c.Code;
|
||||
public string Label { get; } = c.Label;
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using LehrerApp.Core.Interfaces;
|
||||
using LehrerApp.Core.Models;
|
||||
using LehrerApp.Core.Services;
|
||||
using LehrerApp.Data;
|
||||
using LehrerApp.Desktop.Services;
|
||||
using LehrerApp.Desktop.ViewModels.Planning;
|
||||
using LehrerApp.Sync;
|
||||
using LehrerApp.Sync.Crypto;
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Globalization;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace LehrerApp.Desktop.ViewModels.Settings;
|
||||
|
||||
public partial class SettingsViewModel
|
||||
{
|
||||
// ── Fächer ────────────────────────────────────────────────────────────────
|
||||
|
||||
[ObservableProperty] private string _newName = "";
|
||||
[ObservableProperty] private string _newShort = "";
|
||||
[ObservableProperty] private string _newNameError = "";
|
||||
|
||||
public ObservableCollection<SubjectListItem> Subjects { get; } = [];
|
||||
|
||||
// ── Fächer: Laden / Hinzufügen / Löschen ─────────────────────────────────
|
||||
|
||||
public void LoadSubjects()
|
||||
{
|
||||
Subjects.Clear();
|
||||
foreach (var s in _subjects.GetAll())
|
||||
Subjects.Add(new SubjectListItem(s));
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void AddSubject()
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(NewName)) { NewNameError = "Name erforderlich."; return; }
|
||||
try
|
||||
{
|
||||
_subjects.Save(new Subject { Name = NewName.Trim(), ShortName = NewShort.Trim() });
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
NewNameError = ex.Message;
|
||||
return;
|
||||
}
|
||||
NewName = ""; NewShort = ""; NewNameError = "";
|
||||
LoadSubjects();
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void DeleteSubject(SubjectListItem? item)
|
||||
{
|
||||
if (item is null) return;
|
||||
try
|
||||
{
|
||||
_subjects.Delete(item.Id);
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
NewNameError = ex.Message;
|
||||
return;
|
||||
}
|
||||
NewNameError = "";
|
||||
if (CatalogSubject?.Id == item.Id) CatalogSubject = null;
|
||||
LoadSubjects();
|
||||
}
|
||||
}
|
||||
|
||||
public class SubjectListItem(Subject s)
|
||||
{
|
||||
public Guid Id { get; } = s.Id;
|
||||
public string Name { get; } = s.Name;
|
||||
public string ShortName { get; } = s.ShortName;
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using LehrerApp.Core.Interfaces;
|
||||
using LehrerApp.Core.Models;
|
||||
using LehrerApp.Core.Services;
|
||||
using LehrerApp.Data;
|
||||
using LehrerApp.Desktop.Services;
|
||||
using LehrerApp.Desktop.ViewModels.Planning;
|
||||
using LehrerApp.Sync;
|
||||
using LehrerApp.Sync.Crypto;
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Globalization;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace LehrerApp.Desktop.ViewModels.Settings;
|
||||
|
||||
public partial class SettingsViewModel
|
||||
{
|
||||
// ── Aufsichten: wiederkehrende Pausenaufsicht (4.3 Nachtrag) ─────────────
|
||||
|
||||
[ObservableProperty] private string _newDutyWeekdayName = WeekdayDisplay.Options[0];
|
||||
[ObservableProperty] private int _newDutyAfterPeriod;
|
||||
[ObservableProperty] private string _newDutyLocation = "";
|
||||
[ObservableProperty] private string _newDutyError = "";
|
||||
|
||||
public string[] WeekdayOptions { get; } = WeekdayDisplay.Options;
|
||||
public ObservableCollection<SupervisionDutyItem> SupervisionDuties { get; } = [];
|
||||
|
||||
// ── Aufsichten: Laden / Hinzufügen / Löschen ─────────────────────────────
|
||||
|
||||
private void LoadSupervisionDuties()
|
||||
{
|
||||
SupervisionDuties.Clear();
|
||||
foreach (var d in _supervisionDuties.GetAll())
|
||||
SupervisionDuties.Add(new SupervisionDutyItem(d));
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void AddSupervisionDuty()
|
||||
{
|
||||
NewDutyError = "";
|
||||
if (string.IsNullOrWhiteSpace(NewDutyLocation)) { NewDutyError = "Ort/Bezeichnung erforderlich."; return; }
|
||||
if (NewDutyAfterPeriod is < 0 or > 10) { NewDutyError = "Muss zwischen 0 und 10 liegen."; return; }
|
||||
|
||||
try
|
||||
{
|
||||
_supervisionDuties.Save(new SupervisionDuty
|
||||
{
|
||||
Weekday = WeekdayDisplay.FromLabel(NewDutyWeekdayName),
|
||||
AfterPeriod = NewDutyAfterPeriod,
|
||||
Location = NewDutyLocation.Trim(),
|
||||
});
|
||||
}
|
||||
catch (InvalidOperationException ex) { NewDutyError = ex.Message; return; }
|
||||
|
||||
NewDutyLocation = ""; NewDutyAfterPeriod = 0;
|
||||
LoadSupervisionDuties();
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void RemoveSupervisionDuty(SupervisionDutyItem? item)
|
||||
{
|
||||
if (item is null) return;
|
||||
_supervisionDuties.Delete(item.Id);
|
||||
SupervisionDuties.Remove(item);
|
||||
}
|
||||
}
|
||||
|
||||
public class SupervisionDutyItem(SupervisionDuty d)
|
||||
{
|
||||
public Guid Id { get; } = d.Id;
|
||||
public string WeekdayLabel { get; } = WeekdayDisplay.Label(d.Weekday);
|
||||
public int AfterPeriod { get; } = d.AfterPeriod;
|
||||
public string PeriodLabel { get; } = d.AfterPeriod == 0 ? "Vor der 1. Stunde" : $"Nach der {d.AfterPeriod}. Stunde";
|
||||
public string Location { get; } = d.Location;
|
||||
}
|
||||
|
||||
// ── Wochentag: deutsche Anzeige (Mo–Fr, für Aufsichten) ────────────────────────
|
||||
|
||||
public static class WeekdayDisplay
|
||||
{
|
||||
private static readonly (DayOfWeek Day, string Name)[] Entries =
|
||||
[
|
||||
(DayOfWeek.Monday, "Montag"), (DayOfWeek.Tuesday, "Dienstag"), (DayOfWeek.Wednesday, "Mittwoch"),
|
||||
(DayOfWeek.Thursday, "Donnerstag"), (DayOfWeek.Friday, "Freitag"),
|
||||
];
|
||||
|
||||
public static string[] Options { get; } = Entries.Select(e => e.Name).ToArray();
|
||||
public static string Label(DayOfWeek d) => Entries.First(e => e.Day == d).Name;
|
||||
public static DayOfWeek FromLabel(string label) => Entries.FirstOrDefault(e => e.Name == label).Day;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,245 @@
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using LehrerApp.Core.Interfaces;
|
||||
using LehrerApp.Core.Models;
|
||||
using LehrerApp.Core.Services;
|
||||
using LehrerApp.Data;
|
||||
using LehrerApp.Desktop.Services;
|
||||
using LehrerApp.Desktop.ViewModels.Planning;
|
||||
using LehrerApp.Sync;
|
||||
using LehrerApp.Sync.Crypto;
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Globalization;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace LehrerApp.Desktop.ViewModels.Settings;
|
||||
|
||||
public partial class SettingsViewModel
|
||||
{
|
||||
// ── Synchronisation (Kapitel 10) ──────────────────────────────────────────
|
||||
|
||||
[ObservableProperty] private string _syncServerUrl = "";
|
||||
[ObservableProperty] private string _syncUsername = "";
|
||||
[ObservableProperty] private string _syncPassword = "";
|
||||
[ObservableProperty] private string _syncLoginError = "";
|
||||
[ObservableProperty] private bool _syncIsLoggedIn;
|
||||
[ObservableProperty] private string _syncConnectionStatus = "";
|
||||
[ObservableProperty] private string _syncForceResyncStatus = "";
|
||||
[ObservableProperty] private bool _syncForceResyncBusy;
|
||||
[ObservableProperty] private bool _syncKeyWasRegenerated;
|
||||
[ObservableProperty] private string _recoveryCode = "";
|
||||
[ObservableProperty] private string _recoveryCodeInput = "";
|
||||
[ObservableProperty] private string _recoveryStatus = "";
|
||||
[ObservableProperty] private bool _recoveryBusy;
|
||||
|
||||
/// Vom Code-Behind gesetzt: lässt den Nutzer die Wiederherstellungsdatei selbst speichern
|
||||
/// (z.B. USB-Stick, eigene Cloud). Liefert false bei Abbruch.
|
||||
public Func<string, Task<bool>>? OnSaveRecoveryFile { get; set; }
|
||||
/// Vom Code-Behind gesetzt: lässt den Nutzer eine zuvor gesicherte Wiederherstellungsdatei
|
||||
/// auswählen. Liefert null bei Abbruch.
|
||||
public Func<Task<string?>>? OnPickRecoveryFile { get; set; }
|
||||
/// Vom Code-Behind gesetzt: bestätigt vor dem Überschreiben des aktuellen Sync-Schlüssels
|
||||
/// dieses Geräts (die App startet danach neu, siehe RedeemRecoveryCode).
|
||||
public Func<Task<bool>>? OnConfirmRecoveryRestore { get; set; }
|
||||
|
||||
public ObservableCollection<SyncConflictListItem> SyncConflicts { get; } = [];
|
||||
|
||||
// ── Synchronisation: Laden / Anmelden / Abmelden / Verbindungstest ───────
|
||||
//
|
||||
// Server-URL, Zugangsdaten und Token werden erst nach erfolgreichem Login zusammen
|
||||
// gespeichert (ein Restart deckt beides ab) — SyncEngine/SnapshotService werden nur einmalig
|
||||
// beim Start registriert (siehe AppBootstrapper), es gibt keinen Live-Re-Registrierungspfad.
|
||||
|
||||
private void LoadSyncSettings()
|
||||
{
|
||||
SyncServerUrl = _syncSettings.ServerUrl;
|
||||
SyncUsername = _syncSettings.Username;
|
||||
SyncIsLoggedIn = _syncSettings.IsLoggedIn;
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private async Task SyncTestConnection()
|
||||
{
|
||||
SyncConnectionStatus = "Teste Verbindung…";
|
||||
if (string.IsNullOrWhiteSpace(SyncServerUrl))
|
||||
{
|
||||
SyncConnectionStatus = "Bitte Server-Adresse eingeben.";
|
||||
return;
|
||||
}
|
||||
var result = await _syncAuth.TestConnectionAsync(SyncServerUrl, _syncSettings.GetToken());
|
||||
SyncConnectionStatus = result switch
|
||||
{
|
||||
SyncConnectionTestResult.Ok => "Verbindung erfolgreich.",
|
||||
SyncConnectionTestResult.Unauthorized => "Server erreichbar, aber nicht angemeldet oder Anmeldung abgelaufen.",
|
||||
SyncConnectionTestResult.IncompatibleVersion =>
|
||||
"Diese App-Version ist veraltet oder nicht mehr mit dem Sync-Server kompatibel. " +
|
||||
"Bitte aktualisiere die LehrerApp.",
|
||||
_ => "Server nicht erreichbar. Bitte Adresse und Internetverbindung prüfen.",
|
||||
};
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private async Task SyncLogin()
|
||||
{
|
||||
SyncLoginError = "";
|
||||
var valid = true;
|
||||
if (string.IsNullOrWhiteSpace(SyncServerUrl)) { SyncLoginError = "Server-Adresse erforderlich."; valid = false; }
|
||||
if (string.IsNullOrWhiteSpace(SyncUsername)) { SyncLoginError = "Benutzername erforderlich."; valid = false; }
|
||||
if (string.IsNullOrWhiteSpace(SyncPassword)) { SyncLoginError = "Passwort erforderlich."; valid = false; }
|
||||
if (!valid) return;
|
||||
|
||||
try
|
||||
{
|
||||
var (token, userId) = await _syncAuth.LoginAsync(SyncServerUrl, SyncUsername, SyncPassword);
|
||||
// Kontowechsel-Erkennung anhand der KANONISCHEN userId aus der Server-Antwort, nicht
|
||||
// anhand von SyncUsername: LiteDBs case-insensitive Standard-Collation lässt einen
|
||||
// Login mit abweichender Groß-/Kleinschreibung erfolgreich durch (TODO 10.2.5), meldet
|
||||
// serverseitig aber trotzdem eine ANDERE userId zurück - die lokale Versionsverfolgung
|
||||
// (BasedOnServerSeq je Entität, Pull-Cursor) bezieht sich dann auf ein fremdes Konto
|
||||
// und muss verworfen werden, sonst lehnt der Server jeden weiteren Push mit einem
|
||||
// dauerhaften 404 beim Nachladen ab (siehe TODO 10.3.5).
|
||||
var accountChanged = _syncSettings.LastUserId is not null && _syncSettings.LastUserId != userId;
|
||||
_syncSettings.SetServerUrl(SyncServerUrl);
|
||||
_syncSettings.SetCredentialsAndToken(SyncUsername, token, userId);
|
||||
if (accountChanged)
|
||||
{
|
||||
_eventQueue.SetLastServerSeq(0);
|
||||
_eventQueue.ResetKnownServerSeqs();
|
||||
}
|
||||
SyncPassword = "";
|
||||
AppBootstrapper.RestartApplication();
|
||||
}
|
||||
catch (SyncAuthException ex) { SyncLoginError = ex.Message; }
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void SyncLogout()
|
||||
{
|
||||
_syncSettings.Logout();
|
||||
AppBootstrapper.RestartApplication();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Setzt den lokalen Sync-Fortschritt (Pull-Cursor UND die per-Entität-Versionsverfolgung für
|
||||
/// Push) vollständig zurück und lädt danach alle Ereignisse dieses Nutzers erneut vom Server -
|
||||
/// manueller Reparaturweg, falls dieses Gerät wiederholt keine Änderungen eines anderen Geräts
|
||||
/// erhält (z.B. nach einem lokal bereits zu weit vorgerückten Cursor, TODO 10.1.10) oder eigene
|
||||
/// Pushes dauerhaft mit 404 abgelehnt werden (z.B. nach einem Kontowechsel, TODO 10.3.5).
|
||||
/// Sicher wiederholbar: EventApplier wendet jedes Ereignis über Upsert/Delete-by-Id idempotent
|
||||
/// an, und ein Push ohne bekannten Vorstand wird vom Server als Neuanlage behandelt.
|
||||
/// </summary>
|
||||
[RelayCommand]
|
||||
private async Task SyncForceFullResync()
|
||||
{
|
||||
if (_syncEngine is null) { SyncForceResyncStatus = "Sync nicht konfiguriert."; return; }
|
||||
SyncForceResyncBusy = true;
|
||||
SyncForceResyncStatus = "Lade alle Ereignisse erneut…";
|
||||
try
|
||||
{
|
||||
_eventQueue.SetLastServerSeq(0);
|
||||
_eventQueue.ResetKnownServerSeqs();
|
||||
var result = await _syncEngine.SyncNowAsync();
|
||||
SyncForceResyncStatus = result.Success
|
||||
? $"Abgeschlossen - {result.EventsPulled} Ereignis(se) erneut geladen."
|
||||
: $"Fehlgeschlagen: {result.Reason}";
|
||||
LoadSyncConflicts();
|
||||
}
|
||||
finally { SyncForceResyncBusy = false; }
|
||||
}
|
||||
|
||||
// ── Sync-Schlüssel: selbstverwalteter Wiederherstellungscode (10.3.2) ────────────────────
|
||||
//
|
||||
// Der Sync-Schlüssel verlässt nie den Server im Klartext (siehe SyncCrypto) - eine echte
|
||||
// Wiederherstellung nach Verlust des einzigen Geräts mit dem Schlüssel ist deshalb nur
|
||||
// möglich, wenn vorher proaktiv ein Rettungsanker angelegt wurde. "Code erzeugen"
|
||||
// verschlüsselt den aktuellen Schlüssel mit einem einmalig angezeigten, zufälligen Code
|
||||
// (SyncCrypto.EncryptKeyWithRecoveryCode) und lässt den Nutzer die verschlüsselte Datei
|
||||
// selbst sichern (USB-Stick, eigene Cloud o.ä.) - Datei und Code getrennt aufbewahren, erst
|
||||
// beides zusammen ergibt den Schlüssel. Läuft komplett offline, ohne Server-Beteiligung.
|
||||
|
||||
[RelayCommand]
|
||||
private async Task CreateRecoveryCode()
|
||||
{
|
||||
if (OnSaveRecoveryFile is null) return;
|
||||
RecoveryBusy = true;
|
||||
RecoveryStatus = "";
|
||||
RecoveryCode = "";
|
||||
try
|
||||
{
|
||||
var code = SyncCrypto.GenerateRecoveryCode();
|
||||
var fileContent = _syncKeyRecovery.CreateRecoveryFile(code);
|
||||
if (!await OnSaveRecoveryFile(fileContent)) { RecoveryStatus = "Abgebrochen."; return; }
|
||||
RecoveryCode = code;
|
||||
RecoveryStatus = "Datei gespeichert. Den Code getrennt von der Datei notieren und " +
|
||||
"sicher aufbewahren - er wird nirgends gespeichert und lässt sich " +
|
||||
"nicht erneut anzeigen.";
|
||||
}
|
||||
finally { RecoveryBusy = false; }
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private async Task RedeemRecoveryCode()
|
||||
{
|
||||
RecoveryStatus = "";
|
||||
if (string.IsNullOrWhiteSpace(RecoveryCodeInput)) { RecoveryStatus = "Bitte Code eingeben."; return; }
|
||||
if (OnPickRecoveryFile is null) return;
|
||||
var fileContent = await OnPickRecoveryFile();
|
||||
if (fileContent is null) return;
|
||||
if (OnConfirmRecoveryRestore is not null && !await OnConfirmRecoveryRestore()) return;
|
||||
|
||||
try
|
||||
{
|
||||
_syncKeyRecovery.RestoreFromFile(fileContent, RecoveryCodeInput.Trim());
|
||||
}
|
||||
catch (Exception ex) when (ex is CryptographicException or InvalidOperationException or JsonException)
|
||||
{
|
||||
RecoveryStatus = "Fehlgeschlagen: Code und Datei passen nicht zusammen.";
|
||||
_logger.Error("Sync-Schlüssel-Wiederherstellung fehlgeschlagen", ex);
|
||||
return;
|
||||
}
|
||||
// Der neue Schlüssel liegt jetzt auf der Platte, aber der aktuell im Speicher gehaltene
|
||||
// (falsche) Schlüssel ist ein DI-Singleton und kann zur Laufzeit nicht getauscht werden -
|
||||
// gleiches Muster wie RedeemPairingCode. Lokale Cursor/Versionsverfolgung zurücksetzen,
|
||||
// falls in der Zwischenzeit (mit dem gerade ersetzten, falschen Schlüssel) bereits etwas
|
||||
// synchronisiert wurde - sicher wiederholbar, siehe SyncForceFullResync.
|
||||
_eventQueue.SetLastServerSeq(0);
|
||||
_eventQueue.ResetKnownServerSeqs();
|
||||
AppBootstrapper.RestartApplication();
|
||||
}
|
||||
|
||||
// ── Synchronisation: Konflikte ────────────────────────────────────────────
|
||||
//
|
||||
// Zeigt, was ConflictResolver bereits entschieden hat (welche Seite gewonnen hat) — kein
|
||||
// Feld-Diff für v1, die Payloads sind clientseitig verschlüsselt und würden hier ohnehin nur
|
||||
// rohes JSON zeigen. Minimal: Entität, Zeitpunkt, Ergebnis, "gesehen"-Aktion.
|
||||
|
||||
private void LoadSyncConflicts()
|
||||
{
|
||||
SyncConflicts.Clear();
|
||||
foreach (var c in _eventQueue.GetUnreviewed().OrderByDescending(c => c.DetectedAt))
|
||||
SyncConflicts.Add(new SyncConflictListItem(c));
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void MarkConflictReviewed(SyncConflictListItem? item)
|
||||
{
|
||||
if (item is null) return;
|
||||
_eventQueue.MarkReviewed(item.Id);
|
||||
SyncConflicts.Remove(item);
|
||||
}
|
||||
}
|
||||
|
||||
public class SyncConflictListItem(ConflictEntry c)
|
||||
{
|
||||
public Guid Id { get; } = c.Id;
|
||||
public string EntityDisplay { get; } = $"{c.RemoteEvent.EntityType} ({c.RemoteEvent.EntityId[..Math.Min(8, c.RemoteEvent.EntityId.Length)]}…)";
|
||||
public string DetectedAtDisplay { get; } = c.DetectedAt.ToLocalTime().ToString("dd.MM.yyyy HH:mm");
|
||||
public string ResolutionDisplay { get; } = c.Resolution switch
|
||||
{
|
||||
"LocalWon" => "Lokale Änderung übernommen (dieses Gerät)",
|
||||
"RemoteWon" => "Änderung vom anderen Gerät übernommen",
|
||||
_ => c.Resolution,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using LehrerApp.Core.Interfaces;
|
||||
using LehrerApp.Core.Models;
|
||||
using LehrerApp.Core.Services;
|
||||
using LehrerApp.Data;
|
||||
using LehrerApp.Desktop.Services;
|
||||
using LehrerApp.Desktop.ViewModels.Planning;
|
||||
using LehrerApp.Sync;
|
||||
using LehrerApp.Sync.Crypto;
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Globalization;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace LehrerApp.Desktop.ViewModels.Settings;
|
||||
|
||||
public partial class SettingsViewModel
|
||||
{
|
||||
// ── WebUntis-iCal-Abgleich (Nutzer-Feedback) ──────────────────────────────
|
||||
|
||||
[ObservableProperty] private bool _untisEnabled;
|
||||
[ObservableProperty] private bool _untisIsConfigured;
|
||||
[ObservableProperty] private string _untisIcalUrlInput = "";
|
||||
[ObservableProperty] private string _untisUrlError = "";
|
||||
[ObservableProperty] private string _untisStatusDisplay = "";
|
||||
[ObservableProperty] private bool _untisFetchBusy;
|
||||
[ObservableProperty] private bool _untisApiIsConfigured;
|
||||
[ObservableProperty] private string _untisSchool = "";
|
||||
[ObservableProperty] private string _untisHost = "";
|
||||
[ObservableProperty] private string _untisUsername = "";
|
||||
[ObservableProperty] private string _untisPassword = "";
|
||||
[ObservableProperty] private string _untisApiStatus = "";
|
||||
[ObservableProperty] private bool _untisApiBusy;
|
||||
[ObservableProperty] private string? _untisHomeroomClassName;
|
||||
public string UntisHomeroomClassDisplay => UntisHomeroomClassName is { Length: > 0 } name
|
||||
? $"Ausgewählt: {name}"
|
||||
: "Keine Klasse ausgewählt.";
|
||||
partial void OnUntisHomeroomClassNameChanged(string? value) => OnPropertyChanged(nameof(UntisHomeroomClassDisplay));
|
||||
public Func<Task>? OnReviewUntisMapping { get; set; }
|
||||
/// Vom Code-Behind gesetzt: öffnet den WebUntis-Klassenauswahldialog, liefert null bei Abbruch.
|
||||
public Func<Task<(int UntisId, string Name)?>>? OnPickHomeroomClass { get; set; }
|
||||
|
||||
// ── WebUntis-iCal-Abgleich: Laden / Speichern / Entfernen / Jetzt abrufen ────
|
||||
//
|
||||
// Wie bei Sync deckt ein Neustart das Registrieren von UntisSyncService ab
|
||||
// (AppBootstrapper registriert es nur einmalig beim Start, wenn URL+Enabled vorliegen).
|
||||
|
||||
private void LoadUntisSettings()
|
||||
{
|
||||
UntisEnabled = _untisSettings.Enabled;
|
||||
UntisIsConfigured = _untisSettings.IsConfigured;
|
||||
UntisStatusDisplay = _untisSettings.LastSyncAt is { } at
|
||||
? $"Letzter Abgleich: {at.ToLocalTime():dd.MM.yyyy HH:mm} — {_untisSettings.LastSyncStatus}"
|
||||
: "Noch kein Abgleich durchgeführt.";
|
||||
UntisApiIsConfigured = _untisSettings.ApiIsConfigured;
|
||||
if (_untisSettings.GetApiCredentials() is { } credentials)
|
||||
{
|
||||
UntisSchool = credentials.School;
|
||||
UntisHost = credentials.Host;
|
||||
UntisUsername = credentials.Username;
|
||||
UntisApiStatus = $"API-Zugang für {credentials.Username} ist lokal verschlüsselt gespeichert.";
|
||||
}
|
||||
UntisHomeroomClassName = _untisSettings.HomeroomClassName;
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private async Task UntisSaveApi()
|
||||
{
|
||||
UntisApiStatus = "";
|
||||
if (_untisIntegration is null)
|
||||
{
|
||||
UntisApiStatus = "Die WebUntis-Integration ist nicht verfügbar.";
|
||||
return;
|
||||
}
|
||||
if (string.IsNullOrWhiteSpace(UntisSchool) || string.IsNullOrWhiteSpace(UntisUsername) ||
|
||||
string.IsNullOrWhiteSpace(UntisPassword))
|
||||
{
|
||||
UntisApiStatus = "Schule, Benutzername und Passwort sind erforderlich.";
|
||||
return;
|
||||
}
|
||||
UntisApiBusy = true;
|
||||
try
|
||||
{
|
||||
var credentials = new WebUntisCredentials(UntisSchool.Trim(), UntisHost.Trim(),
|
||||
UntisUsername.Trim(), UntisPassword);
|
||||
await _untisIntegration.ConnectAsync(credentials);
|
||||
_untisSettings.SetApiCredentials(credentials);
|
||||
UntisPassword = "";
|
||||
UntisApiIsConfigured = true;
|
||||
UntisApiStatus = "Anmeldung erfolgreich. Die WebUntis-Session bleibt bei Nutzung bis zu 10 Minuten offen.";
|
||||
}
|
||||
catch (WebUntisIntegrationException ex) { UntisApiStatus = ex.Message; }
|
||||
finally { UntisApiBusy = false; }
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private async Task UntisRemoveApi()
|
||||
{
|
||||
UntisApiBusy = true;
|
||||
try { if (_untisIntegration is not null) await _untisIntegration.DisconnectAsync(); }
|
||||
catch (WebUntisIntegrationException) { /* lokale Zugangsdaten trotzdem sicher entfernen */ }
|
||||
finally
|
||||
{
|
||||
_untisSettings.ClearApiCredentials();
|
||||
UntisPassword = "";
|
||||
UntisApiIsConfigured = false;
|
||||
UntisApiStatus = "WebUntis-API-Zugang entfernt.";
|
||||
UntisApiBusy = false;
|
||||
}
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void UntisSaveUrl()
|
||||
{
|
||||
UntisUrlError = "";
|
||||
if (string.IsNullOrWhiteSpace(UntisIcalUrlInput)) { UntisUrlError = "iCal-URL erforderlich."; return; }
|
||||
if (!Uri.TryCreate(UntisIcalUrlInput, UriKind.Absolute, out _)) { UntisUrlError = "Ungültige URL."; return; }
|
||||
|
||||
_untisSettings.SetIcalUrl(UntisIcalUrlInput.Trim());
|
||||
_untisSettings.SetEnabled(true);
|
||||
UntisIcalUrlInput = "";
|
||||
AppBootstrapper.RestartApplication();
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void UntisRemove()
|
||||
{
|
||||
_untisSettings.ClearIcalUrl();
|
||||
LoadUntisSettings();
|
||||
AppBootstrapper.RestartApplication();
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private async Task UntisFetchNow()
|
||||
{
|
||||
if (_untisSync is null) { UntisStatusDisplay = "Abgleich nicht aktiv — App neu starten."; return; }
|
||||
UntisFetchBusy = true;
|
||||
try
|
||||
{
|
||||
await _untisSync.PollAsync();
|
||||
LoadUntisSettings();
|
||||
}
|
||||
finally { UntisFetchBusy = false; }
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private async Task UntisReviewMapping()
|
||||
{
|
||||
if (OnReviewUntisMapping is not null) await OnReviewUntisMapping();
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private async Task UntisPickHomeroomClass()
|
||||
{
|
||||
if (OnPickHomeroomClass is null) return;
|
||||
var result = await OnPickHomeroomClass();
|
||||
if (result is null) return;
|
||||
_untisSettings.SetHomeroomClass(result.Value.UntisId, result.Value.Name);
|
||||
LoadUntisSettings();
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void UntisClearHomeroomClass()
|
||||
{
|
||||
_untisSettings.SetHomeroomClass(null, null);
|
||||
LoadUntisSettings();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using LehrerApp.Core.Interfaces;
|
||||
using LehrerApp.Core.Models;
|
||||
using LehrerApp.Core.Services;
|
||||
using LehrerApp.Data;
|
||||
using LehrerApp.Desktop.Services;
|
||||
using LehrerApp.Desktop.ViewModels.Planning;
|
||||
using LehrerApp.Sync;
|
||||
using LehrerApp.Sync.Crypto;
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Globalization;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace LehrerApp.Desktop.ViewModels.Settings;
|
||||
|
||||
public partial class SettingsViewModel
|
||||
{
|
||||
// ── Gewichtungsschema-Voreinstellungen (2.3.3) ───────────────────────────
|
||||
|
||||
[ObservableProperty] private GradingSchemeEditItem _classScheme = null!;
|
||||
[ObservableProperty] private GradingSchemeEditItem _courseScheme = null!;
|
||||
|
||||
// ── Gewichtungsschema-Voreinstellungen: Laden ────────────────────────────
|
||||
|
||||
private void LoadGradingSchemes()
|
||||
{
|
||||
ClassScheme = new GradingSchemeEditItem(
|
||||
_gradingSchemes.GetDefaultForType(GroupType.Class)
|
||||
?? new GradingScheme { GroupType = GroupType.Class, ExamsPercent = 50, ParticipationPercent = 40, OtherPercent = 10 },
|
||||
"Klassen", _gradingSchemes, _grading);
|
||||
CourseScheme = new GradingSchemeEditItem(
|
||||
_gradingSchemes.GetDefaultForType(GroupType.Course)
|
||||
?? new GradingScheme { GroupType = GroupType.Course, ExamsPercent = 60, ParticipationPercent = 30, OtherPercent = 10 },
|
||||
"Kurse", _gradingSchemes, _grading);
|
||||
}
|
||||
}
|
||||
|
||||
// ── GradingSchemeEditItem (2.3) ────────────────────────────────────────────────
|
||||
|
||||
public partial class GradingSchemeEditItem : ObservableObject
|
||||
{
|
||||
private readonly GradingScheme _scheme;
|
||||
private readonly IGradingSchemeRepository _repo;
|
||||
private readonly GradingService _grading;
|
||||
|
||||
public string Label { get; }
|
||||
|
||||
[ObservableProperty] private double _examsPercent;
|
||||
[ObservableProperty] private double _participationPercent;
|
||||
[ObservableProperty] private double _otherPercent;
|
||||
[ObservableProperty] private string _validationMessage = "";
|
||||
[ObservableProperty] private string _statusMessage = "";
|
||||
|
||||
public GradingSchemeEditItem(GradingScheme scheme, string label, IGradingSchemeRepository repo, GradingService grading)
|
||||
{
|
||||
_scheme = scheme; _repo = repo; _grading = grading;
|
||||
Label = label;
|
||||
_examsPercent = scheme.ExamsPercent;
|
||||
_participationPercent = scheme.ParticipationPercent;
|
||||
_otherPercent = scheme.OtherPercent;
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void Save()
|
||||
{
|
||||
_scheme.ExamsPercent = ExamsPercent;
|
||||
_scheme.ParticipationPercent = ParticipationPercent;
|
||||
_scheme.OtherPercent = OtherPercent;
|
||||
|
||||
var error = _grading.ValidateGradingScheme(_scheme);
|
||||
if (error is not null) { ValidationMessage = error; StatusMessage = ""; return; }
|
||||
|
||||
ValidationMessage = "";
|
||||
_repo.Save(_scheme);
|
||||
StatusMessage = "Gespeichert.";
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -17,7 +17,7 @@ public record StudentOption(Guid Id, string Name);
|
||||
public static class DocumentationTypeDisplay
|
||||
{
|
||||
public static string[] Options { get; } =
|
||||
["Gespräch", "Vorkommnis", "Förderplan", "Fehlzeit", "Elternanruf", "Elternbrief"];
|
||||
["Gespräch", "Vorkommnis", "Förderplan", "Fehlzeit", "Elternanruf", "Elternbrief", "Planung / Erinnerung"];
|
||||
|
||||
public static string Label(DocumentationType t) => t switch
|
||||
{
|
||||
@@ -27,6 +27,7 @@ public static class DocumentationTypeDisplay
|
||||
DocumentationType.Absence => "Fehlzeit",
|
||||
DocumentationType.ParentCall => "Elternanruf",
|
||||
DocumentationType.ParentLetter => "Elternbrief",
|
||||
DocumentationType.Planning => "Planung / Erinnerung",
|
||||
_ => "",
|
||||
};
|
||||
|
||||
@@ -37,6 +38,7 @@ public static class DocumentationTypeDisplay
|
||||
"Fehlzeit" => DocumentationType.Absence,
|
||||
"Elternanruf" => DocumentationType.ParentCall,
|
||||
"Elternbrief" => DocumentationType.ParentLetter,
|
||||
"Planung / Erinnerung" => DocumentationType.Planning,
|
||||
_ => DocumentationType.Conversation,
|
||||
};
|
||||
}
|
||||
@@ -301,7 +303,7 @@ public partial class DocumentationDialogViewModel : ObservableObject
|
||||
LetterSentDateError = ""; LetterResponseDateError = "";
|
||||
var valid = true;
|
||||
|
||||
if (CanPickStudent && SelectedStudent is null) { StudentError = "Schüler auswählen."; valid = false; }
|
||||
if (CanPickStudent && SelectedStudent is null) { StudentError = "Bezug auswählen."; valid = false; }
|
||||
|
||||
if (string.IsNullOrWhiteSpace(Title)) { TitleError = "Titel erforderlich."; valid = false; }
|
||||
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using LehrerApp.Core.Models;
|
||||
using System.Globalization;
|
||||
|
||||
namespace LehrerApp.Desktop.ViewModels.Students;
|
||||
|
||||
public sealed record GroupDocumentationOption(Guid Id, string DisplayName);
|
||||
|
||||
/// Kompakte Erfassung einer gruppenweiten Planungsnotiz aus der globalen Befehlspalette.
|
||||
public partial class GroupDocumentationQuickViewModel(IEnumerable<GroupDocumentationOption> groups)
|
||||
: ObservableObject
|
||||
{
|
||||
public List<GroupDocumentationOption> Groups { get; } = groups.ToList();
|
||||
[ObservableProperty] private GroupDocumentationOption? _selectedGroup;
|
||||
[ObservableProperty] private string _title = "";
|
||||
[ObservableProperty] private string _content = "";
|
||||
[ObservableProperty] private string _dateText = DateOnly.FromDateTime(DateTime.Today).ToString("dd.MM.yyyy");
|
||||
[ObservableProperty] private string _groupError = "";
|
||||
[ObservableProperty] private string _titleError = "";
|
||||
[ObservableProperty] private string _dateError = "";
|
||||
|
||||
public Documentation? Result { get; private set; }
|
||||
|
||||
[RelayCommand]
|
||||
private void Save()
|
||||
{
|
||||
GroupError = TitleError = DateError = "";
|
||||
if (SelectedGroup is null) GroupError = "Lerngruppe auswählen.";
|
||||
if (string.IsNullOrWhiteSpace(Title)) TitleError = "Titel erforderlich.";
|
||||
if (!DateOnly.TryParseExact(DateText, "dd.MM.yyyy", null, DateTimeStyles.None, out var date))
|
||||
DateError = "Format TT.MM.JJJJ.";
|
||||
if (GroupError.Length > 0 || TitleError.Length > 0 || DateError.Length > 0) return;
|
||||
|
||||
Result = new Documentation
|
||||
{
|
||||
StudentId = Guid.Empty,
|
||||
GroupId = SelectedGroup!.Id,
|
||||
Type = DocumentationType.Planning,
|
||||
Date = date,
|
||||
Title = Title.Trim(),
|
||||
Content = Content.Trim(),
|
||||
ExcludeFromWebUntisSync = true,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -64,9 +64,15 @@ public partial class StudentListViewModel : ObservableObject
|
||||
[ObservableProperty] private string _searchText = "";
|
||||
[ObservableProperty] private bool _showInactive;
|
||||
[ObservableProperty] private StudentListItem? _selectedStudent;
|
||||
[ObservableProperty] private bool _isImporting;
|
||||
|
||||
public ObservableCollection<StudentListItem> Students { get; } = [];
|
||||
public string CountSummary => $"{Students.Count} Schüler gesamt";
|
||||
public bool HasNoStudents => Students.Count == 0;
|
||||
public bool HasStudents => !HasNoStudents;
|
||||
public string EmptyListMessage => string.IsNullOrWhiteSpace(SearchText)
|
||||
? ShowInactive ? "Noch keine Schüler vorhanden." : "Noch keine aktiven Schüler vorhanden."
|
||||
: "Keine Schüler passen zur aktuellen Suche.";
|
||||
|
||||
public StudentListViewModel(IStudentRepository students, IGroupRepository groups,
|
||||
IGroupMembershipRepository memberships)
|
||||
@@ -105,6 +111,9 @@ public partial class StudentListViewModel : ObservableObject
|
||||
}
|
||||
foreach (var s in f) Students.Add(new StudentListItem(s));
|
||||
OnPropertyChanged(nameof(CountSummary));
|
||||
OnPropertyChanged(nameof(HasNoStudents));
|
||||
OnPropertyChanged(nameof(HasStudents));
|
||||
OnPropertyChanged(nameof(EmptyListMessage));
|
||||
}
|
||||
|
||||
public Func<Task>? OnAddStudent { get; set; }
|
||||
|
||||
@@ -20,17 +20,17 @@
|
||||
<StackPanel Spacing="20">
|
||||
|
||||
<!-- Begrüßung -->
|
||||
<Grid ColumnDefinitions="*,Auto">
|
||||
<Grid RowDefinitions="Auto,Auto">
|
||||
<StackPanel>
|
||||
<TextBlock Text="{Binding Greeting}" FontSize="14" Opacity="0.6"/>
|
||||
<TextBlock Text="{Binding CurrentDate}" FontSize="24" FontWeight="SemiBold"/>
|
||||
</StackPanel>
|
||||
<StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="8">
|
||||
<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="Bereiche anpassen" Command="{Binding ToggleDashboardSettingsCommand}"
|
||||
VerticalAlignment="Center"/>
|
||||
</StackPanel>
|
||||
</WrapPanel>
|
||||
</Grid>
|
||||
|
||||
<!-- Der Tagesfokus beantwortet zuerst die vier Fragen, die beim Öffnen der App zählen:
|
||||
@@ -72,9 +72,13 @@
|
||||
<StackPanel Orientation="Horizontal" Spacing="6">
|
||||
<CheckBox Content="{Binding Title}" IsChecked="{Binding IsVisible}" VerticalAlignment="Center"/>
|
||||
<Button Content="↑" Padding="6,2"
|
||||
ToolTip.Tip="Bereich nach oben verschieben"
|
||||
AutomationProperties.Name="Dashboard-Bereich nach oben verschieben"
|
||||
Command="{Binding $parent[ItemsControl].((vm:DashboardViewModel)DataContext).MoveCardUpCommand}"
|
||||
CommandParameter="{Binding}"/>
|
||||
<Button Content="↓" Padding="6,2"
|
||||
ToolTip.Tip="Bereich nach unten verschieben"
|
||||
AutomationProperties.Name="Dashboard-Bereich nach unten verschieben"
|
||||
Command="{Binding $parent[ItemsControl].((vm:DashboardViewModel)DataContext).MoveCardDownCommand}"
|
||||
CommandParameter="{Binding}"/>
|
||||
</StackPanel>
|
||||
@@ -250,8 +254,10 @@
|
||||
Width="130" Margin="12,0"/>
|
||||
<Button Grid.Column="2" Content="Heute" FontSize="11" Padding="8,2"
|
||||
Command="{Binding CalendarTodayCommand}" Margin="0,0,4,0"/>
|
||||
<Button Grid.Column="3" Content="‹" Padding="8,2" Command="{Binding PrevMonthCommand}"/>
|
||||
<Button Grid.Column="4" Content="›" Padding="8,2" Command="{Binding NextMonthCommand}" Margin="4,0,0,0"/>
|
||||
<Button Grid.Column="3" Content="‹" Padding="8,2" Command="{Binding PrevMonthCommand}"
|
||||
ToolTip.Tip="Vorheriger Monat" AutomationProperties.Name="Vorheriger Monat"/>
|
||||
<Button Grid.Column="4" Content="›" Padding="8,2" Command="{Binding NextMonthCommand}" Margin="4,0,0,0"
|
||||
ToolTip.Tip="Nächster Monat" AutomationProperties.Name="Nächster Monat"/>
|
||||
</Grid>
|
||||
|
||||
<ItemsControl ItemsSource="{Binding CalendarWeekdayHeaders}">
|
||||
@@ -586,10 +592,10 @@
|
||||
<StackPanel>
|
||||
<TextBlock Text="MEINE LERNGRUPPEN" FontSize="11" FontWeight="Bold"
|
||||
Opacity="0.5" Margin="0,0,0,10"/>
|
||||
<ItemsControl ItemsSource="{Binding CurrentGroups}">
|
||||
<ItemsControl ItemsSource="{Binding CurrentGroups}" HorizontalAlignment="Stretch">
|
||||
<ItemsControl.ItemsPanel>
|
||||
<ItemsPanelTemplate>
|
||||
<WrapPanel Orientation="Horizontal"/>
|
||||
<WrapPanel Orientation="Horizontal" ItemSpacing="8" LineSpacing="8"/>
|
||||
</ItemsPanelTemplate>
|
||||
</ItemsControl.ItemsPanel>
|
||||
<ItemsControl.ItemTemplate>
|
||||
@@ -597,9 +603,15 @@
|
||||
<Button Command="{Binding $parent[ItemsControl].((vm:DashboardViewModel)DataContext).OpenGroupCommand}"
|
||||
CommandParameter="{Binding}"
|
||||
Background="{DynamicResource SystemAccentColorLight2}"
|
||||
CornerRadius="6" Padding="12,6" Margin="0,0,8,8">
|
||||
CornerRadius="6" Padding="12,6" MinWidth="150"
|
||||
ToolTip.Tip="Kurs öffnen">
|
||||
<StackPanel>
|
||||
<TextBlock Text="{Binding Name}" FontWeight="SemiBold" FontSize="13"/>
|
||||
<StackPanel Orientation="Horizontal" Spacing="6">
|
||||
<Border Width="6" Height="6" CornerRadius="3"
|
||||
Background="{DynamicResource SystemAccentColor}"
|
||||
IsVisible="{Binding IsOnSelectedDay}" VerticalAlignment="Center"/>
|
||||
<TextBlock Text="{Binding Name}" FontWeight="SemiBold" FontSize="13"/>
|
||||
</StackPanel>
|
||||
<TextBlock Text="{Binding Subject}" FontSize="11" Opacity="0.7"
|
||||
IsVisible="{Binding Subject, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
|
||||
</StackPanel>
|
||||
|
||||
@@ -182,8 +182,14 @@
|
||||
<!-- Liste aller Klausuren, nach Priorität sortiert (nicht nach Datum) ─────────── -->
|
||||
<ScrollViewer Grid.Row="2" Margin="20,0,20,16">
|
||||
<StackPanel>
|
||||
<TextBlock Text="{Binding EmptyHint}" Classes="emptyhint" Margin="0,20,0,0"
|
||||
IsVisible="{Binding EmptyHint, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
|
||||
<StackPanel Margin="0,28,0,0" Spacing="10" HorizontalAlignment="Center"
|
||||
IsVisible="{Binding HasNoExams}">
|
||||
<TextBlock Text="Noch keine Klausuren im aktuellen Schuljahr." Classes="emptyhint"
|
||||
FontSize="15" TextAlignment="Center"/>
|
||||
<TextBlock Text="Klausuren werden im jeweiligen Kurs angelegt." FontSize="12" Opacity="0.55"/>
|
||||
<Button Content="Zu den Lerngruppen" Command="{Binding GoToGroupsCommand}"
|
||||
HorizontalAlignment="Center"/>
|
||||
</StackPanel>
|
||||
<ItemsControl ItemsSource="{Binding Rows}">
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:ExamListRowViewModel">
|
||||
|
||||
@@ -48,7 +48,7 @@ public partial class ExamGradingDialog : Window
|
||||
{
|
||||
Header = string.IsNullOrWhiteSpace(task.Title) ? $"Aufg. {task.Nr}" : $"{task.Nr}. {task.Title}",
|
||||
Width = new DataGridLength(1, DataGridLengthUnitType.Star),
|
||||
CellTemplate = BuildPointsCellTemplate(i, grid),
|
||||
CellTemplate = BuildPointsCellTemplate(i, grid, vm.Tasks.Count),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -74,7 +74,7 @@ public partial class ExamGradingDialog : Window
|
||||
});
|
||||
}
|
||||
|
||||
private IDataTemplate BuildPointsCellTemplate(int taskIndex, DataGrid grid)
|
||||
private IDataTemplate BuildPointsCellTemplate(int taskIndex, DataGrid grid, int taskCount)
|
||||
{
|
||||
var cellName = $"Pts_{taskIndex}";
|
||||
return new FuncDataTemplate<ExamResultRow>((row, _) =>
|
||||
@@ -102,7 +102,7 @@ public partial class ExamGradingDialog : Window
|
||||
};
|
||||
|
||||
tb.LostFocus += (_, _) => CommitPointsCell(tb, cell);
|
||||
tb.KeyDown += (_, e) => HandleCellKeyDown(e, grid, row, cellName);
|
||||
tb.KeyDown += (_, e) => HandlePointsCellKeyDown(e, grid, row, taskIndex, taskCount);
|
||||
|
||||
return tb;
|
||||
});
|
||||
@@ -150,14 +150,53 @@ public partial class ExamGradingDialog : Window
|
||||
Padding = new Thickness(6, 4),
|
||||
};
|
||||
tb.LostFocus += (_, _) => row.Comment = tb.Text ?? "";
|
||||
tb.KeyDown += (_, e) => HandleCellKeyDown(e, grid, row, cellName);
|
||||
tb.KeyDown += (_, e) => HandleCommentCellKeyDown(e, grid, row, cellName);
|
||||
return tb;
|
||||
});
|
||||
}
|
||||
|
||||
/// Enter/Pfeil-Hoch/Pfeil-Runter springen zur gleichen Spalte in der Nachbarzeile
|
||||
/// (Tab funktioniert bereits über die normale Fokus-Reihenfolge).
|
||||
private void HandleCellKeyDown(KeyEventArgs e, DataGrid grid, object rowItem, string cellName)
|
||||
/// Zwei Korrektur-Workflows (Nutzer-Feedback): "Arbeit für Arbeit" (ein Schüler komplett,
|
||||
/// dann der nächste) und "Aufgabe für Aufgabe" (eine Aufgabe für alle Schüler, dann die
|
||||
/// nächste) — beide bewusst ohne Moduswechsel über zwei verschiedene Tasten, weil sich beide
|
||||
/// Reihenfolgen ohnehin schon auf die Tabelle abbilden (Zeile vs. Spalte). Enter läuft
|
||||
/// zeilenweise (am Zeilenende zurück zu Aufgabe 1 der nächsten Zeile), Pfeil-hoch/-runter
|
||||
/// spaltenweise (am Spaltenende weiter zur Nachbaraufgabe) — beides bricht am Tabellenende
|
||||
/// einfach ab, kein Rundlauf zurück zum Anfang.
|
||||
private void HandlePointsCellKeyDown(KeyEventArgs e, DataGrid grid, ExamResultRow row, int taskIndex, int taskCount)
|
||||
{
|
||||
if (e.Key is not (Key.Down or Key.Up or Key.Enter)) return;
|
||||
e.Handled = true;
|
||||
|
||||
if (grid.ItemsSource is not IList items) return;
|
||||
var rowIdx = items.IndexOf(row);
|
||||
if (rowIdx < 0) return;
|
||||
|
||||
int targetRowIdx;
|
||||
int targetTaskIndex;
|
||||
|
||||
if (e.Key == Key.Enter)
|
||||
{
|
||||
targetTaskIndex = taskIndex + 1;
|
||||
targetRowIdx = rowIdx;
|
||||
if (targetTaskIndex >= taskCount) { targetTaskIndex = 0; targetRowIdx = rowIdx + 1; }
|
||||
}
|
||||
else
|
||||
{
|
||||
targetTaskIndex = taskIndex;
|
||||
targetRowIdx = e.Key == Key.Up ? rowIdx - 1 : rowIdx + 1;
|
||||
if (targetRowIdx < 0) { targetRowIdx = items.Count - 1; targetTaskIndex = taskIndex - 1; }
|
||||
else if (targetRowIdx >= items.Count) { targetRowIdx = 0; targetTaskIndex = taskIndex + 1; }
|
||||
}
|
||||
|
||||
if (targetTaskIndex < 0 || targetTaskIndex >= taskCount
|
||||
|| targetRowIdx < 0 || targetRowIdx >= items.Count) return;
|
||||
|
||||
FocusPointsCell(grid, items[targetRowIdx]!, targetTaskIndex);
|
||||
}
|
||||
|
||||
/// Enter/Pfeil-Hoch/Pfeil-Runter springen im Kommentarfeld zur gleichen Spalte in der
|
||||
/// Nachbarzeile (Tab funktioniert bereits über die normale Fokus-Reihenfolge).
|
||||
private void HandleCommentCellKeyDown(KeyEventArgs e, DataGrid grid, object rowItem, string cellName)
|
||||
{
|
||||
if (e.Key is not (Key.Down or Key.Up or Key.Enter)) return;
|
||||
e.Handled = true;
|
||||
@@ -181,5 +220,25 @@ public partial class ExamGradingDialog : Window
|
||||
}, DispatcherPriority.Loaded);
|
||||
}
|
||||
|
||||
/// Spalte 0 ist "Schüler", danach folgen die Aufgaben-Spalten in Reihenfolge — daher der
|
||||
/// Offset von 1 beim Auflösen des Spaltenobjekts für ScrollIntoView (muss auch horizontal
|
||||
/// zur Nachbaraufgabe scrollen, nicht nur vertikal zur Nachbarzeile).
|
||||
private void FocusPointsCell(DataGrid grid, object targetItem, int targetTaskIndex)
|
||||
{
|
||||
var targetColumn = grid.Columns.ElementAtOrDefault(1 + targetTaskIndex);
|
||||
if (targetColumn is null) return;
|
||||
grid.ScrollIntoView(targetItem, targetColumn);
|
||||
|
||||
var cellName = $"Pts_{targetTaskIndex}";
|
||||
Dispatcher.UIThread.Post(() =>
|
||||
{
|
||||
var targetTb = grid.GetVisualDescendants().OfType<TextBox>()
|
||||
.FirstOrDefault(t => t.DataContext == targetItem && t.Name == cellName);
|
||||
if (targetTb is null) return;
|
||||
targetTb.Focus();
|
||||
targetTb.SelectAll();
|
||||
}, DispatcherPriority.Loaded);
|
||||
}
|
||||
|
||||
private void OnClose(object? sender, RoutedEventArgs e) => Close();
|
||||
}
|
||||
|
||||
@@ -13,9 +13,9 @@
|
||||
<Border Grid.Row="0" Padding="20,16"
|
||||
BorderBrush="{DynamicResource SystemControlForegroundBaseLowBrush}"
|
||||
BorderThickness="0,0,0,1">
|
||||
<Grid ColumnDefinitions="*,Auto">
|
||||
<shared:PageHeader Grid.Column="0" Title="{Binding GroupTitle}" Subtitle="{Binding GroupSubtitle}"/>
|
||||
<StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="8">
|
||||
<Grid RowDefinitions="Auto,Auto">
|
||||
<shared:PageHeader Title="{Binding GroupTitle}" Subtitle="{Binding GroupSubtitle}"/>
|
||||
<WrapPanel Grid.Row="1" Orientation="Horizontal" ItemSpacing="8" LineSpacing="8" Margin="0,10,0,0">
|
||||
<TextBlock VerticalAlignment="Center" Opacity="0.6" FontSize="13">
|
||||
<Run Text="{Binding StudentCount}"/>
|
||||
<Run Text=" Schüler"/>
|
||||
@@ -34,7 +34,7 @@
|
||||
<Button Content="Austragung zurücknehmen" Command="{Binding ReinstateStudentCommand}"
|
||||
IsVisible="{Binding SelectedStudent.HasExitDate}"/>
|
||||
<Button Content="+ Klausur" Command="{Binding AddExamCommand}" IsEnabled="{Binding IsEditable}"/>
|
||||
</StackPanel>
|
||||
</WrapPanel>
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
|
||||
@@ -28,8 +28,15 @@ public partial class GroupDocumentationTabView : UserControl
|
||||
var owner = TopLevel.GetTopLevel(this) as Window;
|
||||
if (owner is null) return null;
|
||||
|
||||
var options = new List<StudentOption> { GroupDocumentationTabViewModel.WholeGroupOption };
|
||||
options.AddRange(studentOptions);
|
||||
var vm = new DocumentationDialogViewModel(editing?.StudentId ?? Guid.Empty, editing,
|
||||
App.Services.GetRequiredService<IAttachmentStorage>(), studentOptions, groupId);
|
||||
App.Services.GetRequiredService<IAttachmentStorage>(), options, groupId);
|
||||
if (editing is null)
|
||||
{
|
||||
vm.SelectedStudent = GroupDocumentationTabViewModel.WholeGroupOption;
|
||||
vm.TypeName = DocumentationTypeDisplay.Label(DocumentationType.Planning);
|
||||
}
|
||||
var dialog = new DocumentationDialog { DataContext = vm };
|
||||
var saved = await dialog.ShowDialog<bool>(owner);
|
||||
if (!saved) vm.DiscardUnsavedAttachments();
|
||||
|
||||
@@ -11,13 +11,14 @@
|
||||
<Border Grid.Row="0" Padding="20,16"
|
||||
BorderBrush="{DynamicResource SystemControlForegroundBaseLowBrush}"
|
||||
BorderThickness="0,0,0,1">
|
||||
<Grid ColumnDefinitions="*,Auto,Auto">
|
||||
<shared:PageHeader Grid.Column="0" Title="Lerngruppen" Subtitle="{Binding ListSummary}"/>
|
||||
<ComboBox Grid.Column="1" ItemsSource="{Binding SchoolYears}"
|
||||
<Grid RowDefinitions="Auto,Auto" ColumnDefinitions="*,Auto,Auto">
|
||||
<shared:PageHeader Grid.ColumnSpan="3" Title="Lerngruppen" Subtitle="{Binding ListSummary}"/>
|
||||
<ComboBox Grid.Row="1" Grid.Column="1" ItemsSource="{Binding SchoolYears}"
|
||||
SelectedItem="{Binding SelectedSchoolYear}"
|
||||
Width="100" Margin="0,0,8,0" VerticalAlignment="Center"/>
|
||||
<Button Grid.Column="2" Content="+ Neue Gruppe"
|
||||
Command="{Binding AddGroupCommand}" VerticalAlignment="Center"/>
|
||||
Width="110" Margin="0,10,8,0" VerticalAlignment="Center"
|
||||
AutomationProperties.Name="Schuljahr auswählen"/>
|
||||
<Button Grid.Row="1" Grid.Column="2" Content="+ Neue Gruppe"
|
||||
Command="{Binding AddGroupCommand}" VerticalAlignment="Center" Margin="0,10,0,0"/>
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
@@ -76,7 +77,7 @@
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
</Button>
|
||||
<Button Grid.Column="2" Content="⋯" Width="36" Height="32" Margin="0,10,10,0"
|
||||
<Button Grid.Column="2" Content="⋯" Width="40" Height="40" Margin="0,10,10,0"
|
||||
Padding="0" VerticalAlignment="Top"
|
||||
ToolTip.Tip="Lerngruppe verwalten"
|
||||
AutomationProperties.Name="{Binding ManageAutomationName}">
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
x:Class="LehrerApp.Desktop.Views.Groups.MoveLessonDialog"
|
||||
x:DataType="vm:MoveLessonDialogViewModel"
|
||||
Title="Stunde verschieben"
|
||||
Width="400" Height="260" MinWidth="360" MinHeight="240"
|
||||
Width="440" Height="430" MinWidth="400" MinHeight="400"
|
||||
CanResize="False" WindowStartupLocation="CenterOwner">
|
||||
|
||||
<Grid RowDefinitions="*,Auto" Margin="24">
|
||||
@@ -13,6 +13,8 @@
|
||||
<TextBlock FontSize="12" Opacity="0.6">
|
||||
<Run Text="Bisheriges Datum: "/>
|
||||
<Run Text="{Binding CurrentDateDisplay}"/>
|
||||
<Run Text=" · "/>
|
||||
<Run Text="{Binding CurrentPeriodDisplay}"/>
|
||||
</TextBlock>
|
||||
|
||||
<StackPanel Spacing="4">
|
||||
@@ -22,6 +24,24 @@
|
||||
IsVisible="{Binding NewDateTextError, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
|
||||
</StackPanel>
|
||||
|
||||
<StackPanel Spacing="4">
|
||||
<TextBlock Text="Neue Stunde" FontSize="12" Opacity="0.7"/>
|
||||
<NumericUpDown Value="{Binding NewPeriod}" Minimum="1" Maximum="20"
|
||||
FormatString="0" PlaceholderText="Stundennummer"/>
|
||||
<TextBlock Text="{Binding NewPeriodError}" Foreground="Red" FontSize="11"
|
||||
IsVisible="{Binding NewPeriodError, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
|
||||
</StackPanel>
|
||||
|
||||
<Border Background="{DynamicResource SystemControlBackgroundAltHighBrush}" CornerRadius="6"
|
||||
Padding="10" IsVisible="{Binding CanSplitDoubleLesson}">
|
||||
<StackPanel Spacing="5">
|
||||
<CheckBox Content="Nur den zweiten Teil der Doppelstunde verschieben"
|
||||
IsChecked="{Binding SplitDoubleLesson}"/>
|
||||
<TextBlock Text="Der Verlauf wird an der Dauer der ersten Stunde geteilt. Der zweite Teil wird als eigene Fortsetzungsstunde angelegt."
|
||||
FontSize="11" Opacity="0.65" TextWrapping="Wrap"/>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<CheckBox Content="Folgestunden automatisch verschieben"
|
||||
IsChecked="{Binding ShiftFollowingPlanned}"
|
||||
ToolTip.Tip="Verschiebt alle noch geplanten Stunden derselben Einheit, die nach dieser Stunde liegen, um denselben Zeitraum. Bereits durchgeführte Stunden bleiben unverändert."/>
|
||||
|
||||
@@ -91,7 +91,8 @@
|
||||
ToolTip.Tip="Verlaufsplan schreibgeschützt und größer anzeigen — zum Mitnehmen in den Unterricht."/>
|
||||
<Button Content="Bearbeiten" Command="{Binding EditLessonCommand}" IsEnabled="{Binding !IsReadOnly}"/>
|
||||
<Button Content="Verschieben" Command="{Binding MoveLessonCommand}" IsEnabled="{Binding !IsReadOnly}"/>
|
||||
<Button Content="Status → Durchgeführt" Command="{Binding AdvanceLessonStatusCommand}" IsEnabled="{Binding !IsReadOnly}"/>
|
||||
<Button Content="Status weiter" Command="{Binding AdvanceLessonStatusCommand}" IsEnabled="{Binding !IsReadOnly}"
|
||||
ToolTip.Tip="Entwurf/Geplant → Bereit → Durchgeführt"/>
|
||||
<Button Content="Sitzung erzeugen" Command="{Binding CreateParticipationSessionCommand}"
|
||||
IsEnabled="{Binding !IsReadOnly}"
|
||||
ToolTip.Tip="Legt eine Mitarbeitssitzung mit Datum und Thema dieser Stunde an."/>
|
||||
|
||||
@@ -42,6 +42,7 @@ public partial class PlanningTabView : UserControl
|
||||
vm.OnGenerateLessonSeries = ShowGenerateLessonSeriesDialog;
|
||||
vm.OnAiAssist = ShowAiAssistDialog;
|
||||
vm.OnNotify = Notifications.ShowSuccess;
|
||||
vm.OnError = Notifications.ShowError;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -126,7 +127,7 @@ public partial class PlanningTabView : UserControl
|
||||
|
||||
private async Task<MoveLessonTarget?> ShowMoveLessonDialog(Lesson lesson)
|
||||
{
|
||||
var dialogVm = new MoveLessonDialogViewModel(lesson.Date);
|
||||
var dialogVm = new MoveLessonDialogViewModel(lesson.Date, lesson.LessonNumber);
|
||||
var dialog = new MoveLessonDialog { DataContext = dialogVm };
|
||||
var owner = TopLevel.GetTopLevel(this) as Window;
|
||||
if (owner is null) return null;
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
x:DataType="vm:MainWindowViewModel"
|
||||
Title="LehrerApp"
|
||||
Width="1280" Height="800"
|
||||
MinWidth="900" MinHeight="600">
|
||||
MinWidth="640" MinHeight="480">
|
||||
|
||||
<Panel>
|
||||
<!--
|
||||
@@ -81,14 +81,14 @@
|
||||
FontFamily explizit auf die farbige Emoji-Schrift gepinnt: Windows kann für
|
||||
Emoji-Codepoints je nach Fallback-Auflösung sonst auf "Segoe UI Symbol"
|
||||
(einfarbig/schwarz) statt "Segoe UI Emoji" (farbig) ausweichen. -->
|
||||
<Style Selector="TextBlock.navicon">
|
||||
<Setter Property="FontSize" Value="15"/>
|
||||
<Style Selector="PathIcon.navicon">
|
||||
<Setter Property="Width" Value="24"/>
|
||||
<Setter Property="FontFamily" Value="Segoe UI Emoji,Segoe UI Symbol,Segoe UI"/>
|
||||
<Setter Property="Height" Value="24"/>
|
||||
</Style>
|
||||
<Style Selector="Button.navitem">
|
||||
<Setter Property="Background" Value="Transparent"/>
|
||||
<Setter Property="Padding" Value="10,8"/>
|
||||
<Setter Property="MinHeight" Value="40"/>
|
||||
</Style>
|
||||
<Style Selector="Button.navitem.active">
|
||||
<Setter Property="Background" Value="{DynamicResource SystemControlBackgroundAccentBrush}"/>
|
||||
@@ -98,9 +98,9 @@
|
||||
<Style Selector="DockPanel.compact TextBlock.navlabel">
|
||||
<Setter Property="IsVisible" Value="False"/>
|
||||
</Style>
|
||||
<Style Selector="DockPanel.compact TextBlock.navicon">
|
||||
<Setter Property="FontSize" Value="20"/>
|
||||
<Style Selector="DockPanel.compact PathIcon.navicon">
|
||||
<Setter Property="Width" Value="28"/>
|
||||
<Setter Property="Height" Value="28"/>
|
||||
</Style>
|
||||
<Style Selector="DockPanel.compact Button.navitem">
|
||||
<Setter Property="HorizontalContentAlignment" Value="Center"/>
|
||||
@@ -157,7 +157,7 @@
|
||||
ToolTip.Tip="Suchen und schnell erfassen (Strg/⌘+K)"
|
||||
AutomationProperties.Name="Suchen und schnell erfassen">
|
||||
<Grid ColumnDefinitions="Auto,*,Auto">
|
||||
<TextBlock Classes="navicon" Text="⌕" TextAlignment="Center"/>
|
||||
<PathIcon Classes="navicon" Data="{StaticResource IconSearch}"/>
|
||||
<TextBlock Grid.Column="1" Classes="navlabel" Text="Suchen / Erfassen"/>
|
||||
<TextBlock Grid.Column="2" Classes="navlabel" Text="⌘K" FontSize="10" Opacity="0.45"
|
||||
VerticalAlignment="Center"/>
|
||||
@@ -169,9 +169,10 @@
|
||||
HorizontalContentAlignment="Left"
|
||||
CornerRadius="6"
|
||||
Command="{Binding NavigateToCommand}"
|
||||
CommandParameter="{x:Static vm:NavItem.Dashboard}">
|
||||
CommandParameter="{x:Static vm:NavItem.Dashboard}"
|
||||
ToolTip.Tip="Dashboard (Strg/⌘+1)" AutomationProperties.Name="Dashboard">
|
||||
<StackPanel Classes="navcontent" Orientation="Horizontal" Spacing="10">
|
||||
<TextBlock Classes="navicon" Text="📊" TextAlignment="Center"/>
|
||||
<PathIcon Classes="navicon" Data="{StaticResource IconDashboard}"/>
|
||||
<TextBlock Classes="navlabel" Text="Dashboard"/>
|
||||
</StackPanel>
|
||||
</Button>
|
||||
@@ -183,9 +184,10 @@
|
||||
HorizontalContentAlignment="Left"
|
||||
CornerRadius="6"
|
||||
Command="{Binding NavigateToCommand}"
|
||||
CommandParameter="{x:Static vm:NavItem.Groups}">
|
||||
CommandParameter="{x:Static vm:NavItem.Groups}"
|
||||
ToolTip.Tip="Lerngruppen (Strg/⌘+2)" AutomationProperties.Name="Lerngruppen">
|
||||
<StackPanel Classes="navcontent" Orientation="Horizontal" Spacing="10">
|
||||
<TextBlock Classes="navicon" Text="🏫" TextAlignment="Center"/>
|
||||
<PathIcon Classes="navicon" Data="{StaticResource IconGroups}"/>
|
||||
<TextBlock Classes="navlabel" Text="Lerngruppen"/>
|
||||
</StackPanel>
|
||||
</Button>
|
||||
@@ -193,9 +195,10 @@
|
||||
HorizontalContentAlignment="Left"
|
||||
CornerRadius="6"
|
||||
Command="{Binding NavigateToCommand}"
|
||||
CommandParameter="{x:Static vm:NavItem.Students}">
|
||||
CommandParameter="{x:Static vm:NavItem.Students}"
|
||||
ToolTip.Tip="Schüler (Strg/⌘+3)" AutomationProperties.Name="Schüler">
|
||||
<StackPanel Classes="navcontent" Orientation="Horizontal" Spacing="10">
|
||||
<TextBlock Classes="navicon" Text="👤" TextAlignment="Center"/>
|
||||
<PathIcon Classes="navicon" Data="{StaticResource IconStudents}"/>
|
||||
<TextBlock Classes="navlabel" Text="Schüler"/>
|
||||
</StackPanel>
|
||||
</Button>
|
||||
@@ -203,9 +206,10 @@
|
||||
HorizontalContentAlignment="Left"
|
||||
CornerRadius="6"
|
||||
Command="{Binding NavigateToCommand}"
|
||||
CommandParameter="{x:Static vm:NavItem.Exams}">
|
||||
CommandParameter="{x:Static vm:NavItem.Exams}"
|
||||
ToolTip.Tip="Klausuren (Strg/⌘+4)" AutomationProperties.Name="Klausuren">
|
||||
<StackPanel Classes="navcontent" Orientation="Horizontal" Spacing="10">
|
||||
<TextBlock Classes="navicon" Text="📝" TextAlignment="Center"/>
|
||||
<PathIcon Classes="navicon" Data="{StaticResource IconExams}"/>
|
||||
<TextBlock Classes="navlabel" Text="Klausuren"/>
|
||||
</StackPanel>
|
||||
</Button>
|
||||
@@ -213,9 +217,10 @@
|
||||
HorizontalContentAlignment="Left"
|
||||
CornerRadius="6"
|
||||
Command="{Binding NavigateToCommand}"
|
||||
CommandParameter="{x:Static vm:NavItem.Planner}">
|
||||
CommandParameter="{x:Static vm:NavItem.Planner}"
|
||||
ToolTip.Tip="Planung (Strg/⌘+5)" AutomationProperties.Name="Planung">
|
||||
<StackPanel Classes="navcontent" Orientation="Horizontal" Spacing="10">
|
||||
<TextBlock Classes="navicon" Text="📅" TextAlignment="Center"/>
|
||||
<PathIcon Classes="navicon" Data="{StaticResource IconCalendar}"/>
|
||||
<TextBlock Classes="navlabel" Text="Planung"/>
|
||||
</StackPanel>
|
||||
</Button>
|
||||
@@ -227,9 +232,10 @@
|
||||
HorizontalContentAlignment="Left"
|
||||
CornerRadius="6"
|
||||
Command="{Binding NavigateToCommand}"
|
||||
CommandParameter="{x:Static vm:NavItem.Workload}">
|
||||
CommandParameter="{x:Static vm:NavItem.Workload}"
|
||||
ToolTip.Tip="Arbeitszeit (Strg/⌘+6)" AutomationProperties.Name="Arbeitszeit">
|
||||
<StackPanel Classes="navcontent" Orientation="Horizontal" Spacing="10">
|
||||
<TextBlock Classes="navicon" Text="⏱" TextAlignment="Center"/>
|
||||
<PathIcon Classes="navicon" Data="{StaticResource IconClock}"/>
|
||||
<TextBlock Classes="navlabel" Text="Arbeitszeit"/>
|
||||
</StackPanel>
|
||||
</Button>
|
||||
@@ -237,9 +243,10 @@
|
||||
HorizontalContentAlignment="Left"
|
||||
CornerRadius="6"
|
||||
Command="{Binding NavigateToCommand}"
|
||||
CommandParameter="{x:Static vm:NavItem.ClassTeacher}">
|
||||
CommandParameter="{x:Static vm:NavItem.ClassTeacher}"
|
||||
ToolTip.Tip="Klassenlehrer (Strg/⌘+7)" AutomationProperties.Name="Klassenlehrer">
|
||||
<StackPanel Classes="navcontent" Orientation="Horizontal" Spacing="10">
|
||||
<TextBlock Classes="navicon" Text="🎓" TextAlignment="Center"/>
|
||||
<PathIcon Classes="navicon" Data="{StaticResource IconClassTeacher}"/>
|
||||
<TextBlock Classes="navlabel" Text="Klassenlehrer"/>
|
||||
</StackPanel>
|
||||
</Button>
|
||||
@@ -247,9 +254,10 @@
|
||||
HorizontalContentAlignment="Left"
|
||||
CornerRadius="6"
|
||||
Command="{Binding NavigateToCommand}"
|
||||
CommandParameter="{x:Static vm:NavItem.Settings}">
|
||||
CommandParameter="{x:Static vm:NavItem.Settings}"
|
||||
ToolTip.Tip="Einstellungen (Strg/⌘+8)" AutomationProperties.Name="Einstellungen">
|
||||
<StackPanel Classes="navcontent" Orientation="Horizontal" Spacing="10">
|
||||
<TextBlock Classes="navicon" Text="⚙️" TextAlignment="Center"/>
|
||||
<PathIcon Classes="navicon" Data="{StaticResource IconSettings}"/>
|
||||
<TextBlock Classes="navlabel" Text="Einstellungen"/>
|
||||
</StackPanel>
|
||||
</Button>
|
||||
|
||||
@@ -36,6 +36,13 @@ public partial class MainWindow : Window
|
||||
return;
|
||||
}
|
||||
|
||||
if (commandModifier && TryGetNavigationShortcut(e.Key, out var destination))
|
||||
{
|
||||
vm.NavigateToCommand.Execute(destination);
|
||||
e.Handled = true;
|
||||
return;
|
||||
}
|
||||
|
||||
if (!vm.IsCommandPaletteOpen) return;
|
||||
if (e.Key == Key.Escape)
|
||||
{
|
||||
@@ -54,6 +61,23 @@ public partial class MainWindow : Window
|
||||
}
|
||||
}
|
||||
|
||||
private static bool TryGetNavigationShortcut(Key key, out NavItem destination)
|
||||
{
|
||||
destination = key switch
|
||||
{
|
||||
Key.D1 or Key.NumPad1 => NavItem.Dashboard,
|
||||
Key.D2 or Key.NumPad2 => NavItem.Groups,
|
||||
Key.D3 or Key.NumPad3 => NavItem.Students,
|
||||
Key.D4 or Key.NumPad4 => NavItem.Exams,
|
||||
Key.D5 or Key.NumPad5 => NavItem.Planner,
|
||||
Key.D6 or Key.NumPad6 => NavItem.Workload,
|
||||
Key.D7 or Key.NumPad7 => NavItem.ClassTeacher,
|
||||
Key.D8 or Key.NumPad8 => NavItem.Settings,
|
||||
_ => default,
|
||||
};
|
||||
return key is >= Key.D1 and <= Key.D8 or >= Key.NumPad1 and <= Key.NumPad8;
|
||||
}
|
||||
|
||||
private void OnOpenCommandPaletteClick(object? sender, Avalonia.Interactivity.RoutedEventArgs e)
|
||||
{
|
||||
if (DataContext is MainWindowViewModel vm)
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
<Window xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:vm="clr-namespace:LehrerApp.Desktop.ViewModels.Planning"
|
||||
x:Class="LehrerApp.Desktop.Views.Planning.TimetableUnitPickerDialog"
|
||||
x:DataType="vm:TimetableUnitPickerViewModel"
|
||||
Title="Einheit für die Stunde wählen"
|
||||
Width="470" Height="390" MinWidth="420" MinHeight="360"
|
||||
CanResize="False" WindowStartupLocation="CenterOwner">
|
||||
<Grid RowDefinitions="*,Auto" Margin="24">
|
||||
<StackPanel Grid.Row="0" Spacing="16">
|
||||
<TextBlock Text="Stunde anlegen" Classes="dialogtitle"/>
|
||||
<TextBlock Text="{Binding ContextLabel}" FontSize="12" Opacity="0.65"/>
|
||||
<TextBlock Text="Die Stunde braucht eine Unterrichtseinheit. Laufende Einheiten werden zuerst vorgeschlagen."
|
||||
FontSize="12" TextWrapping="Wrap" Opacity="0.75"/>
|
||||
|
||||
<StackPanel Spacing="5" IsVisible="{Binding HasUnits}">
|
||||
<TextBlock Text="Vorhandene Einheit" FontSize="12" Opacity="0.7"/>
|
||||
<ComboBox ItemsSource="{Binding Units}" SelectedItem="{Binding SelectedUnit}"
|
||||
HorizontalAlignment="Stretch">
|
||||
<ComboBox.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:TimetableUnitOption">
|
||||
<StackPanel>
|
||||
<TextBlock Text="{Binding Label}" FontWeight="SemiBold"/>
|
||||
<TextBlock Text="{Binding Detail}" FontSize="11" Opacity="0.6"/>
|
||||
</StackPanel>
|
||||
</DataTemplate>
|
||||
</ComboBox.ItemTemplate>
|
||||
</ComboBox>
|
||||
</StackPanel>
|
||||
|
||||
<StackPanel Spacing="5">
|
||||
<TextBlock Text="Oder neue Einheit anlegen" FontSize="12" Opacity="0.7"/>
|
||||
<TextBox Text="{Binding NewUnitTitle}" PlaceholderText="Titel der neuen Einheit"/>
|
||||
</StackPanel>
|
||||
<TextBlock Text="{Binding Error}" Foreground="Red" FontSize="11"
|
||||
IsVisible="{Binding Error, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
|
||||
</StackPanel>
|
||||
<Grid Grid.Row="1" ColumnDefinitions="*,8,*" Margin="0,20,0,0">
|
||||
<Button Grid.Column="0" Content="Abbrechen" HorizontalAlignment="Stretch" Click="OnCancel"/>
|
||||
<Button Grid.Column="2" Content="Weiter zur Planung" HorizontalAlignment="Stretch" Click="OnSave"/>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Window>
|
||||
@@ -0,0 +1,19 @@
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Interactivity;
|
||||
using LehrerApp.Desktop.ViewModels.Planning;
|
||||
|
||||
namespace LehrerApp.Desktop.Views.Planning;
|
||||
|
||||
public partial class TimetableUnitPickerDialog : Window
|
||||
{
|
||||
public TimetableUnitPickerDialog() => InitializeComponent();
|
||||
|
||||
private void OnSave(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
if (DataContext is not TimetableUnitPickerViewModel vm) return;
|
||||
vm.SaveCommand.Execute(null);
|
||||
if (vm.Result is not null) Close(true);
|
||||
}
|
||||
|
||||
private void OnCancel(object? sender, RoutedEventArgs e) => Close(false);
|
||||
}
|
||||
@@ -120,6 +120,7 @@
|
||||
</TextBlock>
|
||||
<TextBlock Text="{Binding LessonTopic}" FontSize="11" Opacity="0.75"
|
||||
TextWrapping="Wrap" IsVisible="{Binding HasLessonTopic}"/>
|
||||
<TextBlock Text="{Binding PlanningStatusLabel}" FontSize="10" Opacity="0.62"/>
|
||||
<TextBlock Text="{Binding ExamTitle}" FontSize="11" Foreground="#D85A30" FontWeight="SemiBold"
|
||||
IsVisible="{Binding HasExam}"/>
|
||||
<TextBlock Text="📓 Hausaufgabe aus letzter Stunde noch nicht kontrolliert" FontSize="11"
|
||||
@@ -154,9 +155,11 @@
|
||||
|
||||
<Grid ColumnDefinitions="Auto,Auto,*,Auto,Auto,Auto">
|
||||
<Button Grid.Column="0" Content="‹" FontWeight="Bold" Padding="10,4"
|
||||
Command="{Binding PreviousWeekCommand}" ToolTip.Tip="Vorherige Woche"/>
|
||||
Command="{Binding PreviousWeekCommand}" ToolTip.Tip="Vorherige Woche"
|
||||
AutomationProperties.Name="Vorherige Woche"/>
|
||||
<Button Grid.Column="1" Content="›" FontWeight="Bold" Padding="10,4" Margin="4,0,0,0"
|
||||
Command="{Binding NextWeekCommand}" ToolTip.Tip="Nächste Woche"/>
|
||||
Command="{Binding NextWeekCommand}" ToolTip.Tip="Nächste Woche"
|
||||
AutomationProperties.Name="Nächste Woche"/>
|
||||
<TextBlock Grid.Column="2" FontSize="16" FontWeight="SemiBold"
|
||||
VerticalAlignment="Center" Margin="12,0,0,0">
|
||||
<Run Text="Woche "/><Run Text="{Binding WeekRangeLabel}"/>
|
||||
@@ -166,7 +169,8 @@
|
||||
<Button Grid.Column="4" Content="Ausnahme eintragen" Margin="0,0,8,0"
|
||||
Command="{Binding AddSubstitutionCommand}"/>
|
||||
<Button Grid.Column="5" Content="⚙️" Command="{Binding OpenSettingsCommand}"
|
||||
ToolTip.Tip="Einstellungen (Ferien, Aufsichten, Stundenraster)"/>
|
||||
ToolTip.Tip="Einstellungen (Ferien, Aufsichten, Stundenraster)"
|
||||
AutomationProperties.Name="Stundenplan-Einstellungen"/>
|
||||
</Grid>
|
||||
|
||||
<Border Background="#33F59E0B" BorderBrush="#F59E0B" BorderThickness="1"
|
||||
@@ -255,6 +259,8 @@
|
||||
IsVisible="{Binding HasRoom}"/>
|
||||
<TextBlock Text="{Binding Topic}" FontSize="10" Foreground="White" Opacity="0.8"
|
||||
TextWrapping="Wrap" IsVisible="{Binding HasTopic}"/>
|
||||
<TextBlock Text="{Binding PlanningStatusLabel}" FontSize="9" Foreground="White"
|
||||
Opacity="0.78" Margin="0,1,0,0"/>
|
||||
<TextBlock Text="Ferien" FontSize="10" Foreground="White" FontWeight="SemiBold"
|
||||
Opacity="0.9" IsVisible="{Binding IsHoliday}" Margin="0,2,0,0"/>
|
||||
<TextBlock Text="Ausfall" FontSize="10" Foreground="White" FontWeight="SemiBold"
|
||||
@@ -281,7 +287,7 @@
|
||||
<Button Classes="weekCellMenuTrigger" Content="⋮"
|
||||
HorizontalAlignment="Right" VerticalAlignment="Top"
|
||||
IsVisible="{Binding HasGroupId}"
|
||||
ToolTip.Tip="Weitere Ziele…">
|
||||
ToolTip.Tip="Weitere Ziele…" AutomationProperties.Name="Weitere Ziele">
|
||||
<Button.Flyout>
|
||||
<MenuFlyout Placement="BottomEdgeAlignedRight">
|
||||
<MenuItem Header="▶ Unterrichtsansicht" IsVisible="{Binding HasLesson}"
|
||||
@@ -290,6 +296,12 @@
|
||||
<MenuItem Header="📋 Planungsviewer" IsVisible="{Binding HasLesson}"
|
||||
Tag="{x:Static vm:TimetableLessonDestination.Viewer}"
|
||||
Click="OnWeekCellMenuItemClick"/>
|
||||
<MenuItem Header="+ Stunde anlegen" IsVisible="{Binding HasNoLesson}"
|
||||
Tag="{x:Static vm:TimetableLessonDestination.Create}"
|
||||
Click="OnWeekCellMenuItemClick"/>
|
||||
<MenuItem Header="↪ Stunde verschieben" IsVisible="{Binding HasLesson}"
|
||||
Tag="{x:Static vm:TimetableLessonDestination.Move}"
|
||||
Click="OnWeekCellMenuItemClick"/>
|
||||
<MenuItem Header="🪑 Sitzplan"
|
||||
Tag="{x:Static vm:TimetableLessonDestination.SeatingPlan}"
|
||||
Click="OnWeekCellMenuItemClick"/>
|
||||
@@ -404,7 +416,8 @@
|
||||
HorizontalAlignment="Stretch" VerticalAlignment="Stretch"
|
||||
HorizontalContentAlignment="Center" Background="Transparent"
|
||||
Command="{Binding $parent[ItemsControl;1].((vm:TimetableViewModel)DataContext).EditCellCommand}"
|
||||
CommandParameter="{Binding}"/>
|
||||
CommandParameter="{Binding}"
|
||||
AutomationProperties.Name="Stundenplan-Eintrag hinzufügen"/>
|
||||
<Border Background="#D85A30" CornerRadius="8" Padding="5,1"
|
||||
IsVisible="{Binding HasBadge}"
|
||||
HorizontalAlignment="Right" VerticalAlignment="Top" Margin="2">
|
||||
|
||||
@@ -26,6 +26,8 @@ public partial class TimetableView : UserControl
|
||||
vm.OnImportWebUntisTimetable = ShowWebUntisTimetableDialog;
|
||||
vm.OnOpenLessonViewer = ShowLessonViewerDialog;
|
||||
vm.OnOpenTeachingMode = ShowTeachingMode;
|
||||
vm.OnCreateLesson = ShowCreateLessonDialog;
|
||||
vm.OnMoveLesson = ShowMoveLessonDialog;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,10 +35,11 @@ public partial class TimetableView : UserControl
|
||||
/// Stelle gelöst, in der "Heute"-Tagesliste statt im Wochenraster): im Wochenraster oben
|
||||
/// führt ein Klick auf eine Stunden-Kachel bislang entweder in den Planungsviewer oder zur
|
||||
/// Einheitenplanung — je nachdem, ob schon eine Lesson existiert, ohne dass das von außen
|
||||
/// erkennbar wäre. Popup-Menü (MenuFlyout, kein ComboBox mehr) mit allen vier Zielen als
|
||||
/// erkennbar wäre. Popup-Menü (MenuFlyout, kein ComboBox mehr) mit allen Zielen einschließlich
|
||||
/// Direktanlage und Verschieben als
|
||||
/// Alternative zum Direktklick, siehe <see cref="TimetableViewModel.OpenWeekCellCommand"/>
|
||||
/// für den "einheitlicheren" Standard-Klick (Unterrichtsansicht bei laufender Stunde, sonst
|
||||
/// Planungsviewer, sonst Einheitenplanung). MenuItem.Click statt Command-Binding: ein
|
||||
/// Planungsviewer, sonst Direktanlage). MenuItem.Click statt Command-Binding: ein
|
||||
/// $parent[ItemsControl]-Vorfahrenpfad (wie beim Zeilen-Button) funktioniert innerhalb eines
|
||||
/// Flyouts nicht zuverlässig, weil dessen Popup nicht im normalen visuellen Baum hängt (siehe
|
||||
/// TODO.md-Nachtrag zum Klassenlehrer-Bereich) — DataContext-Vererbung (kein Pfad-Suchen,
|
||||
@@ -58,6 +61,14 @@ public partial class TimetableView : UserControl
|
||||
if (cell.Lesson is { } viewLesson && vm.OnOpenLessonViewer is not null)
|
||||
await vm.OnOpenLessonViewer(viewLesson);
|
||||
break;
|
||||
case TimetableLessonDestination.Create:
|
||||
if (cell.Date is { } createDate && vm.OnCreateLesson is not null)
|
||||
await vm.OnCreateLesson(new TimetableLessonRequest(cell.GroupId, createDate, cell.PeriodNumber));
|
||||
break;
|
||||
case TimetableLessonDestination.Move:
|
||||
if (cell.Lesson is { } moveLesson && vm.OnMoveLesson is not null)
|
||||
await vm.OnMoveLesson(new TimetableLessonMoveRequest(moveLesson, cell.PeriodNumber));
|
||||
break;
|
||||
case TimetableLessonDestination.Planning:
|
||||
if (cell.GroupId != Guid.Empty)
|
||||
App.Services.GetRequiredService<MainWindowViewModel>().NavigateToGroupDetail(cell.GroupId, 6);
|
||||
@@ -69,6 +80,96 @@ public partial class TimetableView : UserControl
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ShowCreateLessonDialog(TimetableLessonRequest request)
|
||||
{
|
||||
var owner = TopLevel.GetTopLevel(this) as Window;
|
||||
var groups = App.Services.GetRequiredService<IGroupRepository>();
|
||||
var group = groups.GetById(request.GroupId);
|
||||
if (owner is null || group is null || !group.IsActive) return;
|
||||
|
||||
var lessons = App.Services.GetRequiredService<ILessonRepository>();
|
||||
if (lessons.GetByGroupAndDate(request.GroupId, request.Date)
|
||||
.Any(l => l.LessonNumber == request.PeriodNumber))
|
||||
{
|
||||
App.Services.GetRequiredService<NotificationService>()
|
||||
.ShowError("Für diesen Termin existiert bereits eine Stundenplanung.");
|
||||
return;
|
||||
}
|
||||
|
||||
var units = App.Services.GetRequiredService<IUnitRepository>();
|
||||
var pickerVm = new TimetableUnitPickerViewModel(units, group.Id, group.Name,
|
||||
request.Date, request.PeriodNumber);
|
||||
var picker = new TimetableUnitPickerDialog { DataContext = pickerVm };
|
||||
if (!await picker.ShowDialog<bool>(owner) || pickerVm.Result is not { } unit) return;
|
||||
|
||||
var groupLessons = units.GetByGroup(group.Id).SelectMany(u => lessons.GetByUnit(u.Id)).ToList();
|
||||
var materials = groupLessons.SelectMany(l => l.Phases).Select(p => p.Material)
|
||||
.Where(x => !string.IsNullOrWhiteSpace(x)).Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.OrderBy(x => x, StringComparer.CurrentCultureIgnoreCase).ToList();
|
||||
var shorthands = groupLessons.SelectMany(l => l.Phases).Select(p => p.Shorthand)
|
||||
.Where(x => !string.IsNullOrWhiteSpace(x)).Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.OrderBy(x => x, StringComparer.CurrentCultureIgnoreCase).ToList();
|
||||
var subject = group.SubjectId is { } subjectId
|
||||
? App.Services.GetRequiredService<ISubjectRepository>().GetById(subjectId) : null;
|
||||
|
||||
var lessonVm = new LessonDialogViewModel(
|
||||
lessons,
|
||||
App.Services.GetRequiredService<IShorthandCodeRepository>(),
|
||||
App.Services.GetRequiredService<IAlternativeLessonPathRepository>(),
|
||||
App.Services.GetRequiredService<ITimetableSlotRepository>(),
|
||||
App.Services.GetRequiredService<PeriodScheduleService>(),
|
||||
App.Services.GetRequiredService<IAttachmentStorage>(),
|
||||
unit.Id, group.Id, group.Name, subject?.Name ?? "", materials, shorthands,
|
||||
editingLesson: null, suggestedDate: request.Date, suggestedPeriod: request.PeriodNumber);
|
||||
var lessonDialog = new LessonDialog { DataContext = lessonVm };
|
||||
if (await lessonDialog.ShowDialog<bool>(owner) && lessonVm.Result is not null)
|
||||
{
|
||||
if (pickerVm.ResultIsNew) units.Save(unit);
|
||||
(DataContext as TimetableViewModel)?.Load();
|
||||
App.Services.GetRequiredService<NotificationService>().ShowSuccess("Stunde angelegt.");
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ShowMoveLessonDialog(TimetableLessonMoveRequest request)
|
||||
{
|
||||
var owner = TopLevel.GetTopLevel(this) as Window;
|
||||
if (owner is null) return;
|
||||
var lesson = request.Lesson;
|
||||
var slots = App.Services.GetRequiredService<ITimetableSlotRepository>();
|
||||
var schedule = App.Services.GetRequiredService<PeriodScheduleService>();
|
||||
var anchor = lesson.LessonNumber;
|
||||
var firstPartMinutes = anchor is int p ? schedule.GetDurationMinutes(p) : 0;
|
||||
var isDouble = anchor is int start && firstPartMinutes > 0 &&
|
||||
slots.GetByGroup(lesson.GroupId).Any(s => s.Weekday == lesson.Date.DayOfWeek &&
|
||||
s.PeriodNumber == start + 1) && lesson.Phases.Sum(x => x.DurationMinutes) > firstPartMinutes;
|
||||
|
||||
var dialogVm = new MoveLessonDialogViewModel(lesson.Date, lesson.LessonNumber, isDouble,
|
||||
isDouble && anchor is int a && request.SelectedPeriod > a);
|
||||
var dialog = new MoveLessonDialog { DataContext = dialogVm };
|
||||
if (!await dialog.ShowDialog<bool>(owner) || dialogVm.Result is not { } target) return;
|
||||
|
||||
try
|
||||
{
|
||||
var service = new LessonSchedulingService(App.Services.GetRequiredService<ILessonRepository>());
|
||||
if (target.SplitDoubleLesson)
|
||||
{
|
||||
if (target.NewPeriod is not int newPeriod)
|
||||
throw new InvalidOperationException("Für den zweiten Teil ist eine Zielstunde erforderlich.");
|
||||
service.SplitAndMoveSecondPart(lesson, firstPartMinutes, target.NewDate, newPeriod,
|
||||
schedule.GetTimes(newPeriod)?.Start);
|
||||
}
|
||||
else service.Move(lesson, target.NewDate, target.NewPeriod, target.ShiftFollowing);
|
||||
|
||||
(DataContext as TimetableViewModel)?.Load();
|
||||
App.Services.GetRequiredService<NotificationService>().ShowSuccess(
|
||||
target.SplitDoubleLesson ? "Doppelstunde getrennt und Fortsetzung verschoben." : "Stunde verschoben.");
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
App.Services.GetRequiredService<NotificationService>().ShowError(ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ShowWebUntisTimetableDialog()
|
||||
{
|
||||
var owner = TopLevel.GetTopLevel(this) as Window;
|
||||
|
||||
@@ -619,6 +619,19 @@
|
||||
<TextBlock Text="{Binding BackupStatus}" Foreground="Green" FontSize="12"
|
||||
IsVisible="{Binding BackupStatus, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
|
||||
|
||||
<StackPanel Spacing="6" Margin="0,6,0,0">
|
||||
<TextBlock Text="Zweiter Sicherungsordner (optional)" FontSize="13" FontWeight="SemiBold"/>
|
||||
<TextBlock Text="Zusätzliches Ziel für jedes Backup, z. B. ein USB-Stick oder Netzlaufwerk — ist es beim Sichern nicht erreichbar, bleibt das Hauptbackup davon unberührt."
|
||||
FontSize="12" Opacity="0.6" TextWrapping="Wrap"/>
|
||||
<TextBlock Text="{Binding SecondaryBackupDirectory}" FontSize="12"
|
||||
IsVisible="{Binding SecondaryBackupDirectory, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
|
||||
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||
<Button Content="Ordner wählen…" Command="{Binding PickBackupDirectoryCommand}"/>
|
||||
<Button Content="Entfernen" Command="{Binding ClearBackupDirectoryCommand}"
|
||||
IsVisible="{Binding SecondaryBackupDirectory, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
|
||||
<ItemsControl ItemsSource="{Binding Backups}">
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate DataType="vm:BackupListItem">
|
||||
|
||||
@@ -34,6 +34,7 @@ public partial class SettingsView : UserControl
|
||||
vm.OnThemeChanged = App.ApplyTheme;
|
||||
vm.OnReviewUntisMapping = ShowUntisMappingReviewDialog;
|
||||
vm.OnPickHomeroomClass = PickHomeroomClassAsync;
|
||||
vm.OnPickBackupDirectory = PickBackupDirectoryAsync;
|
||||
_ = vm.LoadSchoolLocationCommand.ExecuteAsync(null);
|
||||
}
|
||||
}
|
||||
@@ -84,6 +85,19 @@ public partial class SettingsView : UserControl
|
||||
return true;
|
||||
}
|
||||
|
||||
private async Task<string?> PickBackupDirectoryAsync()
|
||||
{
|
||||
var topLevel = TopLevel.GetTopLevel(this);
|
||||
if (topLevel is null) return null;
|
||||
|
||||
var folders = await topLevel.StorageProvider.OpenFolderPickerAsync(new FolderPickerOpenOptions
|
||||
{
|
||||
Title = "Zweiten Sicherungsordner wählen",
|
||||
AllowMultiple = false,
|
||||
});
|
||||
return folders.Count == 0 ? null : folders[0].Path.LocalPath;
|
||||
}
|
||||
|
||||
private async Task<string?> PickRecoveryFile()
|
||||
{
|
||||
var topLevel = TopLevel.GetTopLevel(this);
|
||||
|
||||
@@ -13,10 +13,10 @@
|
||||
<TextBlock Text="{Binding DialogTitle}" Classes="dialogtitle"/>
|
||||
|
||||
<StackPanel Spacing="4" IsVisible="{Binding CanPickStudent}">
|
||||
<TextBlock Text="Schüler *" FontSize="12" Opacity="0.7"/>
|
||||
<TextBlock Text="Bezug *" FontSize="12" Opacity="0.7"/>
|
||||
<ComboBox ItemsSource="{Binding StudentOptions}" SelectedItem="{Binding SelectedStudent}"
|
||||
DisplayMemberBinding="{Binding Name}" HorizontalAlignment="Stretch"
|
||||
PlaceholderText="Schüler wählen"/>
|
||||
PlaceholderText="Schüler oder gesamte Lerngruppe wählen"/>
|
||||
<TextBlock Text="{Binding StudentError}" Foreground="Red" FontSize="11"
|
||||
IsVisible="{Binding StudentError, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
|
||||
</StackPanel>
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
<Window xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:vm="clr-namespace:LehrerApp.Desktop.ViewModels.Students"
|
||||
x:Class="LehrerApp.Desktop.Views.Students.GroupDocumentationQuickDialog"
|
||||
x:DataType="vm:GroupDocumentationQuickViewModel"
|
||||
Title="Lerngruppen-Eintrag" Width="480" SizeToContent="Height"
|
||||
CanResize="False" WindowStartupLocation="CenterOwner">
|
||||
<Grid RowDefinitions="*,Auto" Margin="24">
|
||||
<StackPanel Spacing="12">
|
||||
<TextBlock Text="Lerngruppen-Eintrag" Classes="dialogtitle"/>
|
||||
<TextBlock Text="Planung, Klausur oder Erinnerung für eine ganze Lerngruppe festhalten."
|
||||
TextWrapping="Wrap" Opacity="0.7"/>
|
||||
<StackPanel Spacing="4">
|
||||
<TextBlock Text="Lerngruppe *" FontSize="12" Opacity="0.7"/>
|
||||
<ComboBox ItemsSource="{Binding Groups}" SelectedItem="{Binding SelectedGroup}"
|
||||
DisplayMemberBinding="{Binding DisplayName}" PlaceholderText="Lerngruppe wählen"/>
|
||||
<TextBlock Text="{Binding GroupError}" Foreground="Red" FontSize="11"
|
||||
IsVisible="{Binding GroupError, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
|
||||
</StackPanel>
|
||||
<Grid ColumnDefinitions="*,12,140">
|
||||
<StackPanel Spacing="4">
|
||||
<TextBlock Text="Titel *" FontSize="12" Opacity="0.7"/>
|
||||
<TextBox Text="{Binding Title}" PlaceholderText="z. B. Klausur ankündigen"/>
|
||||
<TextBlock Text="{Binding TitleError}" Foreground="Red" FontSize="11"
|
||||
IsVisible="{Binding TitleError, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
|
||||
</StackPanel>
|
||||
<StackPanel Grid.Column="2" Spacing="4">
|
||||
<TextBlock Text="Datum *" FontSize="12" Opacity="0.7"/>
|
||||
<TextBox Text="{Binding DateText}"/>
|
||||
<TextBlock Text="{Binding DateError}" Foreground="Red" FontSize="11"
|
||||
IsVisible="{Binding DateError, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
<StackPanel Spacing="4">
|
||||
<TextBlock Text="Notiz" FontSize="12" Opacity="0.7"/>
|
||||
<TextBox Text="{Binding Content}" AcceptsReturn="True" Height="90" TextWrapping="Wrap"/>
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
<StackPanel Grid.Row="1" Orientation="Horizontal" HorizontalAlignment="Right" Spacing="8" Margin="0,20,0,0">
|
||||
<Button Content="Abbrechen" Click="OnCancel"/>
|
||||
<Button Content="Speichern" Classes="accent" Click="OnSave"/>
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</Window>
|
||||
@@ -0,0 +1,19 @@
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Interactivity;
|
||||
using LehrerApp.Desktop.ViewModels.Students;
|
||||
|
||||
namespace LehrerApp.Desktop.Views.Students;
|
||||
|
||||
public partial class GroupDocumentationQuickDialog : Window
|
||||
{
|
||||
public GroupDocumentationQuickDialog() => InitializeComponent();
|
||||
|
||||
private void OnSave(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
if (DataContext is not GroupDocumentationQuickViewModel vm) return;
|
||||
vm.SaveCommand.Execute(null);
|
||||
if (vm.Result is not null) Close(true);
|
||||
}
|
||||
|
||||
private void OnCancel(object? sender, RoutedEventArgs e) => Close(false);
|
||||
}
|
||||
@@ -8,18 +8,21 @@
|
||||
<Border Grid.Row="0" Padding="20,16"
|
||||
BorderBrush="{DynamicResource SystemControlForegroundBaseLowBrush}"
|
||||
BorderThickness="0,0,0,1">
|
||||
<Grid ColumnDefinitions="*,Auto,Auto,Auto">
|
||||
<shared:PageHeader Grid.Column="0" Title="Schüler" Subtitle="{Binding CountSummary}"/>
|
||||
<CheckBox Grid.Column="1" Content="Inaktive anzeigen"
|
||||
<Grid RowDefinitions="Auto,Auto" ColumnDefinitions="*,Auto,Auto,Auto">
|
||||
<shared:PageHeader Grid.ColumnSpan="4" Title="Schüler" Subtitle="{Binding CountSummary}"/>
|
||||
<CheckBox Grid.Row="1" Grid.Column="1" Content="Inaktive anzeigen"
|
||||
IsChecked="{Binding ShowInactive}"
|
||||
VerticalAlignment="Center" Margin="0,0,12,0"/>
|
||||
<Button Grid.Column="2" Content="⇩ Importieren…" Click="OnImportClick"
|
||||
VerticalAlignment="Center" Margin="0,0,8,0"/>
|
||||
<Button Grid.Column="3" Content="+ Neuer Schüler"
|
||||
Command="{Binding AddStudentCommand}" VerticalAlignment="Center"/>
|
||||
VerticalAlignment="Center" Margin="0,10,12,0"/>
|
||||
<Button Grid.Row="1" Grid.Column="2" Content="⇩ Importieren…" Click="OnImportClick"
|
||||
IsEnabled="{Binding !IsImporting}"
|
||||
VerticalAlignment="Center" Margin="0,10,8,0"
|
||||
AutomationProperties.Name="Schüler importieren"/>
|
||||
<Button Grid.Row="1" Grid.Column="3" Content="+ Neuer Schüler"
|
||||
Command="{Binding AddStudentCommand}" VerticalAlignment="Center" Margin="0,10,0,0"/>
|
||||
</Grid>
|
||||
</Border>
|
||||
<DockPanel Grid.Row="1">
|
||||
<Grid Grid.Row="1">
|
||||
<DockPanel IsVisible="{Binding HasStudents}">
|
||||
<TextBox DockPanel.Dock="Top" Text="{Binding SearchText}"
|
||||
PlaceholderText="Name oder Lerngruppe suchen…" Margin="16,10,16,4"/>
|
||||
<DataGrid ItemsSource="{Binding Students}"
|
||||
@@ -45,5 +48,22 @@
|
||||
</DataGrid.Columns>
|
||||
</DataGrid>
|
||||
</DockPanel>
|
||||
<StackPanel HorizontalAlignment="Center" VerticalAlignment="Center" Spacing="10"
|
||||
IsVisible="{Binding HasNoStudents}">
|
||||
<TextBlock Text="{Binding EmptyListMessage}" Classes="emptyhint" FontSize="15"
|
||||
TextAlignment="Center"/>
|
||||
<StackPanel Orientation="Horizontal" HorizontalAlignment="Center" Spacing="8"
|
||||
IsVisible="{Binding SearchText, Converter={x:Static StringConverters.IsNullOrEmpty}}">
|
||||
<Button Content="+ Ersten Schüler anlegen" Command="{Binding AddStudentCommand}"/>
|
||||
<Button Content="⇩ Schülerliste importieren" Click="OnImportClick"/>
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
<Border Background="#70000000" IsVisible="{Binding IsImporting}">
|
||||
<StackPanel HorizontalAlignment="Center" VerticalAlignment="Center" Spacing="10">
|
||||
<ProgressBar IsIndeterminate="True" Width="220" Height="5"/>
|
||||
<TextBlock Text="Schülerliste wird analysiert …" Foreground="White"/>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</UserControl>
|
||||
|
||||
@@ -36,6 +36,7 @@ public partial class StudentListView : UserControl
|
||||
});
|
||||
if (files.Count == 0) return;
|
||||
|
||||
list.IsImporting = true;
|
||||
try
|
||||
{
|
||||
await using var source = await files[0].OpenReadAsync();
|
||||
@@ -67,5 +68,9 @@ public partial class StudentListView : UserControl
|
||||
{
|
||||
App.Services.GetRequiredService<NotificationService>().ShowError(ex.Message);
|
||||
}
|
||||
finally
|
||||
{
|
||||
list.IsImporting = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,10 +14,17 @@
|
||||
Foreground="#D32F2F"/>
|
||||
<TextBlock Text="Sync angehalten" FontSize="10" Foreground="#D32F2F" Opacity="0.8"/>
|
||||
</StackPanel>
|
||||
<Button Grid.Column="1" Content="↻" FontSize="14"
|
||||
<Button Grid.Column="1" Classes="touchTarget"
|
||||
Command="{Binding SyncNowCommand}"
|
||||
IsVisible="{Binding CanAttemptSync}"
|
||||
Background="Transparent" Padding="6,4"
|
||||
ToolTip.Tip="Jetzt synchronisieren"/>
|
||||
Background="Transparent" Padding="8"
|
||||
ToolTip.Tip="Jetzt synchronisieren" AutomationProperties.Name="Jetzt synchronisieren">
|
||||
<Grid>
|
||||
<PathIcon Data="{StaticResource IconRefresh}" Width="18" Height="18"
|
||||
IsVisible="{Binding !IsSyncing}"/>
|
||||
<ProgressBar Width="22" Height="4" IsIndeterminate="True"
|
||||
IsVisible="{Binding IsSyncing}"/>
|
||||
</Grid>
|
||||
</Button>
|
||||
</Grid>
|
||||
</UserControl>
|
||||
|
||||
@@ -92,6 +92,62 @@ public sealed class BackupServiceTests
|
||||
service.RestoreBackup(Path.Combine(temp.Path, "backups", "fehlt.db"), Path.Combine(temp.Path, "lehrerapp.db")));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateBackup_MitSecondaryDirectory_SpiegeltDasBackupDorthin()
|
||||
{
|
||||
using var temp = new TempAppData();
|
||||
var dbPath = Path.Combine(temp.Path, "lehrerapp.db");
|
||||
File.WriteAllText(dbPath, "Testinhalt");
|
||||
var secondaryDir = Path.Combine(temp.Path, "secondary");
|
||||
var service = new BackupService(temp.Path) { SecondaryBackupDirectory = secondaryDir };
|
||||
|
||||
var backupPath = service.CreateBackup(dbPath);
|
||||
|
||||
Assert.NotNull(backupPath);
|
||||
var mirrored = Path.Combine(secondaryDir, Path.GetFileName(backupPath!));
|
||||
Assert.True(File.Exists(mirrored));
|
||||
Assert.Equal("Testinhalt", File.ReadAllText(mirrored));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateBackup_SecondaryDirectoryNichtErreichbar_HauptbackupBleibtErhalten()
|
||||
{
|
||||
using var temp = new TempAppData();
|
||||
var dbPath = Path.Combine(temp.Path, "lehrerapp.db");
|
||||
File.WriteAllText(dbPath, "Testinhalt");
|
||||
// Eine gewöhnliche Datei blockiert den Pfad, sodass darunter kein Verzeichnis angelegt
|
||||
// werden kann — simuliert plattformunabhängig ein nicht eingestecktes USB-Ziel bzw. eine
|
||||
// nicht erreichbare Netzwerkfreigabe.
|
||||
var blockingFile = Path.Combine(temp.Path, "blockiert");
|
||||
File.WriteAllText(blockingFile, "x");
|
||||
var unreachable = Path.Combine(blockingFile, "secondary");
|
||||
var service = new BackupService(temp.Path) { SecondaryBackupDirectory = unreachable };
|
||||
|
||||
var backupPath = service.CreateBackup(dbPath);
|
||||
|
||||
Assert.NotNull(backupPath);
|
||||
Assert.True(File.Exists(backupPath));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateBackup_MitSecondaryDirectory_BehaeltNurDieLetztenNAuchDort()
|
||||
{
|
||||
using var temp = new TempAppData();
|
||||
var dbPath = Path.Combine(temp.Path, "lehrerapp.db");
|
||||
var secondaryDir = Path.Combine(temp.Path, "secondary");
|
||||
var service = new BackupService(temp.Path) { SecondaryBackupDirectory = secondaryDir };
|
||||
|
||||
for (var i = 0; i < 5; i++)
|
||||
{
|
||||
File.WriteAllText(dbPath, $"v{i}");
|
||||
var backup = service.CreateBackup(dbPath, keepCount: 3)!;
|
||||
var mirrored = Path.Combine(secondaryDir, Path.GetFileName(backup));
|
||||
File.SetLastWriteTime(mirrored, DateTime.Now.AddMinutes(-5 + i));
|
||||
}
|
||||
|
||||
Assert.Equal(3, Directory.GetFiles(secondaryDir, "lehrerapp-*.db").Length);
|
||||
}
|
||||
|
||||
private sealed class TempAppData : IDisposable
|
||||
{
|
||||
public string Path { get; } = System.IO.Path.Combine(
|
||||
|
||||
@@ -40,6 +40,8 @@ Global
|
||||
{A1000001-0000-0000-0000-000000000001}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{A1000001-0000-0000-0000-000000000001}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{A1000001-0000-0000-0000-000000000001}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{A1000001-0000-0000-0000-000000000001}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{A1000001-0000-0000-0000-000000000001}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{A1000001-0000-0000-0000-000000000001}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{A1000001-0000-0000-0000-000000000001}.Release|x64.Build.0 = Release|Any CPU
|
||||
{A1000001-0000-0000-0000-000000000001}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
@@ -50,6 +52,8 @@ Global
|
||||
{A1000002-0000-0000-0000-000000000002}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{A1000002-0000-0000-0000-000000000002}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{A1000002-0000-0000-0000-000000000002}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{A1000002-0000-0000-0000-000000000002}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{A1000002-0000-0000-0000-000000000002}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{A1000002-0000-0000-0000-000000000002}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{A1000002-0000-0000-0000-000000000002}.Release|x64.Build.0 = Release|Any CPU
|
||||
{A1000002-0000-0000-0000-000000000002}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
@@ -60,6 +64,8 @@ Global
|
||||
{A1000003-0000-0000-0000-000000000003}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{A1000003-0000-0000-0000-000000000003}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{A1000003-0000-0000-0000-000000000003}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{A1000003-0000-0000-0000-000000000003}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{A1000003-0000-0000-0000-000000000003}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{A1000003-0000-0000-0000-000000000003}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{A1000003-0000-0000-0000-000000000003}.Release|x64.Build.0 = Release|Any CPU
|
||||
{A1000003-0000-0000-0000-000000000003}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
@@ -70,6 +76,8 @@ Global
|
||||
{A1000004-0000-0000-0000-000000000004}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{A1000004-0000-0000-0000-000000000004}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{A1000004-0000-0000-0000-000000000004}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{A1000004-0000-0000-0000-000000000004}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{A1000004-0000-0000-0000-000000000004}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{A1000004-0000-0000-0000-000000000004}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{A1000004-0000-0000-0000-000000000004}.Release|x64.Build.0 = Release|Any CPU
|
||||
{A1000004-0000-0000-0000-000000000004}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
@@ -80,6 +88,8 @@ Global
|
||||
{A1000005-0000-0000-0000-000000000005}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{A1000005-0000-0000-0000-000000000005}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{A1000005-0000-0000-0000-000000000005}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{A1000005-0000-0000-0000-000000000005}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{A1000005-0000-0000-0000-000000000005}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{A1000005-0000-0000-0000-000000000005}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{A1000005-0000-0000-0000-000000000005}.Release|x64.Build.0 = Release|Any CPU
|
||||
{A1000005-0000-0000-0000-000000000005}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
@@ -90,6 +100,8 @@ Global
|
||||
{A1000006-0000-0000-0000-000000000006}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{A1000006-0000-0000-0000-000000000006}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{A1000006-0000-0000-0000-000000000006}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{A1000006-0000-0000-0000-000000000006}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{A1000006-0000-0000-0000-000000000006}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{A1000006-0000-0000-0000-000000000006}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{A1000006-0000-0000-0000-000000000006}.Release|x64.Build.0 = Release|Any CPU
|
||||
{A1000006-0000-0000-0000-000000000006}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
|
||||
@@ -73,9 +73,16 @@ pragmatisch über "Abwesend" bei den übrigen Schülern statt über eine eigene
|
||||
Note wird live über `GradingService.CalculateGrade()` berechnet.
|
||||
Umgesetzt als eigener Dialog `ExamGradingDialog`, erreichbar über "Punkte eingeben"
|
||||
im Klausuren-Tab (Button/Kontextmenü bei ausgewählter Klausur).
|
||||
- [x] **1.4.2** Tastaturnavigation: Tab bewegt sich nativ zur nächsten Zelle, Enter/Pfeil-Hoch/
|
||||
Pfeil-Runter springen zur gleichen Spalte in der Nachbarzeile, direkte Zifferneingabe
|
||||
und halbe Punkte (Komma oder Punkt) erlaubt.
|
||||
- [x] **1.4.2** Tastaturnavigation unterstützt zwei Korrektur-Workflows (Nutzer-Feedback:
|
||||
"Arbeit für Arbeit" bei kleinen/leichten Klausuren z.B. Jahrgang 5, sonst eher "Aufgabe für
|
||||
Aufgabe") ohne Moduswechsel — beide Reihenfolgen bilden sich auf dieselbe Tabelle ab.
|
||||
Tab bewegt sich nativ zeilenweise zur nächsten Zelle. Enter läuft ebenfalls zeilenweise
|
||||
(nächste Aufgabe desselben Schülers, am Zeilenende zurück zu Aufgabe 1 der nächsten Zeile)
|
||||
— für "Arbeit für Arbeit". Pfeil-Hoch/Pfeil-Runter laufen spaltenweise (gleiche Aufgabe,
|
||||
nächster/vorheriger Schüler, am Spaltenende weiter zur Nachbaraufgabe, `ScrollIntoView`
|
||||
scrollt dabei automatisch auch horizontal mit) — für "Aufgabe für Aufgabe". Beides bricht
|
||||
am Tabellenende einfach ab, kein Rundlauf zurück zum Anfang. Direkte Zifferneingabe und
|
||||
halbe Punkte (Komma oder Punkt) weiterhin erlaubt.
|
||||
- [x] **1.4.3** Kennzeichnung "Abwesend" (`ExamResult.Absent`) per Checkbox — Note zeigt "abwesend"
|
||||
statt einer berechneten Note. Ausschluss aus Statistiken über 1.5 umgesetzt.
|
||||
- [x] **1.4.4** Kommentarfeld pro Schüler (`ExamResult.Comment`).
|
||||
@@ -1608,13 +1615,9 @@ zurückgestellt):
|
||||
einer Doppelstunde also die erste Periode, dieselbe Ankerkonvention wie bei 4.2.5) — über
|
||||
den bereits bestehenden `OnLessonNumberChanged`-Hook wird dadurch automatisch auch der
|
||||
Stundenbeginn aus dem Stundenraster übernommen, ganz ohne zusätzlichen Code.
|
||||
- [ ] **4.5.2** Vom Stundenplan (Wochenraster oder Bearbeiten-Raster) direkt in einen
|
||||
- [x] **4.5.2** Vom Stundenplan (Wochenraster oder Tagesliste) direkt in einen
|
||||
"Viewer" der zugehörigen `Lesson` springen können — und von dort auch eine neue `Lesson`
|
||||
anlegen können. **Offene Frage, vor Umsetzung zu klären:** `Lesson` hängt an einer
|
||||
übergeordneten `Unit` (4.1) — beim Anlegen direkt aus dem Stundenplan heraus ist unklar,
|
||||
welcher `Unit` die neue Lesson zugeordnet werden soll (aktuellste offene Einheit der Gruppe?
|
||||
Rückfrage an die Lehrkraft?). Muss überdacht werden, bevor das gebaut wird — deshalb weiterhin
|
||||
unimplementiert, auch nach diesem Durchgang.
|
||||
anlegen können.
|
||||
|
||||
**Teilweise umgesetzt:** Der erste Teil ("direkt in den Viewer springen") ist jetzt fertig,
|
||||
sofern für den angeklickten Slot schon eine `Lesson` existiert — genau der unstrittige Teil
|
||||
@@ -1623,9 +1626,11 @@ zurückgestellt):
|
||||
— dessen Klick öffnet weiterhin `TimetableSlotDialog` zur Slot-Zuweisung, das ist ein anderer,
|
||||
etablierter Zweck) öffnen bei vorhandener Lesson jetzt direkt `LessonViewerDialog` statt nur
|
||||
grob zum Planung-Tab der Gruppe zu springen (`TimetableViewModel.OnOpenLessonViewer`,
|
||||
`TodayLessonItem`/`WeekCellItem.Lesson`). Ohne vorhandene Lesson bleibt es unverändert bei der
|
||||
Navigation zum Planung-Tab — dort lässt sich über "+ Stunde" weiterhin manuell mit expliziter
|
||||
Unit-Auswahl eine neue Lesson anlegen.
|
||||
`TodayLessonItem`/`WeekCellItem.Lesson`). **Jetzt vollständig umgesetzt:** Ohne vorhandene
|
||||
Lesson startet der Klick die Direktanlage mit exakt vorbelegtem Datum und exakter Periode. Ein
|
||||
vorgeschalteter Dialog sortiert laufende Einheiten zuerst, lässt aber auch direkt eine neue
|
||||
Einheit anlegen; damit ist die frühere offene Unit-Frage ohne stillschweigende Zuordnung gelöst.
|
||||
Neue Direktanlagen starten als `Entwurf`.
|
||||
- [x] **4.5.3** Aus diesem Lesson-Viewer heraus weiter verzweigen können: in die Zeugnisnote/
|
||||
Bewertung der Gruppe, und in die Schnelldialoge für Mitarbeit sowie Anwesenheit/Hausaufgaben.
|
||||
**Umsetzung:** Zwei neue Buttons im Footer von `LessonViewerDialog` ("Zur Mitarbeit"/
|
||||
@@ -1661,11 +1666,17 @@ zurückgestellt):
|
||||
Editor neben dem Hausaufgabenfeld — genau dort, wo `Homework` ohnehin schon gepflegt wird.
|
||||
Klick auf die Stunde im Stundenplan navigiert wie gehabt zur Lerngruppe/Planung, von dort
|
||||
ist die betroffene (vorherige) Stunde ein Klick entfernt.
|
||||
- [ ] **4.5.5** Stunden aus dieser Ansicht heraus verschieben können, wenn kurzfristig etwas
|
||||
dazwischenkommt.
|
||||
- [ ] **4.5.6** Bei Doppelstunden (90-Minuten-Planung laut Verlaufsplan, 4.2.2) den Inhalt
|
||||
- [x] **4.5.5** Stunden aus dieser Ansicht heraus verschieben können, wenn kurzfristig etwas
|
||||
dazwischenkommt. **Umsetzung:** Das Kachelmenü bietet Datum und Zielperiode an; belegte
|
||||
Zieltermine werden abgewiesen. Optional rücken alle späteren, noch nicht durchgeführten
|
||||
Stunden der Einheit um dasselbe Datumsdelta nach.
|
||||
- [x] **4.5.6** Bei Doppelstunden (90-Minuten-Planung laut Verlaufsplan, 4.2.2) den Inhalt
|
||||
sinnvoll auf die beiden Kacheln/Perioden aufteilen, damit z.B. gezielt nur die zweite Stunde
|
||||
eines Blocks verschoben werden kann, ohne den ganzen Block anzufassen.
|
||||
eines Blocks verschoben werden kann, ohne den ganzen Block anzufassen. **Umsetzung:** Der
|
||||
Stundenplan erkennt eine Doppelstunde aus aufeinanderfolgenden Slots, Stundenraster und
|
||||
Phasendauer. Beim Verschieben der zweiten Kachel ist "nur zweiten Teil" vorausgewählt; die
|
||||
Phasen werden exakt an der Dauer der ersten Periode geteilt (auch mitten in einer Phase),
|
||||
Hausaufgabe und Reflexion wandern in die neu erzeugte Fortsetzungsstunde.
|
||||
|
||||
**Architekturentscheidung (Nachtrag, Konzeptgespräch):** Diskutiert wurde, ob eine eigene
|
||||
Multiplattform-App für die Einheiten-/Stundenplanung sinnvoll ist — analog zum separaten
|
||||
@@ -2077,6 +2088,13 @@ dadurch faktisch schon (Quick-Input-Dialog bzw. "Offene Entschuldigungen" im Das
|
||||
Auswahl gar nicht erst an. "Gespräch begleiten" (Elternanruf) und Anhänge funktionieren im
|
||||
Gruppen-Tab identisch zum Schüler-Tab, da beide dieselbe `DocumentationItem`/
|
||||
`DocumentationDialog`-Infrastruktur verwenden.
|
||||
- [x] **5.1.8** Gruppenweite Planungs- und Erinnerungseinträge ohne Schülerbezug. Im
|
||||
Dokumentationsdialog einer Lerngruppe steht jetzt „Gesamte Lerngruppe“ als eigener Bezug
|
||||
zur Wahl; solche Einträge verwenden den angehängten Typ `DocumentationType.Planning`, die
|
||||
bestehende `GroupId` und bewusst `Guid.Empty` als kompatiblen „kein Schüler“-Marker. Sie
|
||||
erscheinen auch in Gruppen ohne Schüler und werden eindeutig als „Gesamte Lerngruppe“
|
||||
beschriftet. Die globale Suche bietet zusätzlich die Schnellaktion „Lerngruppen-Eintrag“
|
||||
mit einem kompakten Formular für Gruppe, Datum, Titel und Notiz.
|
||||
|
||||
### 5.2 Fehlzeiten (als Auswertung des bestehenden Anwesenheits-Trackings, siehe oben)
|
||||
- [x] **5.2.1** Schnelle Abwesenheitserfassung je Stunde — bereits vorhanden über
|
||||
@@ -2644,6 +2662,13 @@ Bereich automatisch), die linke Inhaltsspalte erhält mehr Breite und leere rein
|
||||
aktivierter Dashboard-Konfiguration automatisch ausgeblendet. Inhalte und Direktaktionen bleiben
|
||||
unverändert erhalten; die Seite wird bei ruhiger Datenlage lediglich deutlich kürzer.
|
||||
|
||||
**Nachtrag Kursliste (August 2026):** Die Kachel „Meine Lerngruppen“ nutzt weiterhin ein echtes
|
||||
mehrzeiliges `WrapPanel`, jetzt mit explizitem Zeilen-/Elementabstand und Mindestbreite statt einer
|
||||
abschneidbaren Ein-Zeilen-Darstellung. Bei Auswahl eines Kalendertags werden Gruppen mit einer
|
||||
tatsächlichen Lesson oder einem an diesem Tag aktiven Stundenplan-Slot zuerst angezeigt und mit
|
||||
einem Akzentpunkt markiert; innerhalb dieser Gruppe sowie für den Rest gilt alphabetische Sortierung.
|
||||
Ferien, Feiertage und vollständig ausgefallene Stunden werden dabei berücksichtigt.
|
||||
|
||||
---
|
||||
|
||||
## 10. Sync & Server
|
||||
@@ -3500,15 +3525,21 @@ von 4.2.2) der Tab "Kürzel-Katalog" für die Von/Nach-Kürzel des Stundenverlau
|
||||
|
||||
## 14. UX-Querschnitt
|
||||
|
||||
- [ ] **14.1** Tastaturbedienung durchgängig: alle Hauptfunktionen ohne Maus erreichbar
|
||||
(Vorbild: Mitarbeit-Schnelleingabe).
|
||||
- [~] **14.1** Tastaturbedienung durchgängig: alle Hauptfunktionen ohne Maus erreichbar
|
||||
(Vorbild: Mitarbeit-Schnelleingabe). **Hauptnavigation umgesetzt:** `Strg/⌘+1…8` öffnet
|
||||
Dashboard, Lerngruppen, Schüler, Klausuren, Planung, Arbeitszeit, Klassenlehrer und
|
||||
Einstellungen direkt; `Strg/⌘+K`, Pfeiltasten, Enter und Escape bedienen weiterhin die
|
||||
globale Suche. Die vollständige Tastaturprüfung aller Fachdialoge bleibt offen.
|
||||
- [x] **14.2** Globale Suche (Schüler, Gruppe, Klausur) über Tastenkürzel.
|
||||
**Umsetzung:** `Strg+K` (Windows/Linux) bzw. `⌘K` (macOS) öffnet aus jeder Hauptansicht eine
|
||||
modale Befehlspalette. `GlobalSearchViewModel` durchsucht lokal und ohne zusätzlichen Index
|
||||
aktive wie inaktive Schüler/Lerngruppen sowie Klausuren und Aufgaben; Treffer springen direkt
|
||||
ausschließlich aktive Schüler/Lerngruppen sowie deren Klausuren und Aufgaben; Treffer springen direkt
|
||||
ins Schülerdetail, Gruppendetail, den Klausuren-Tab oder die Aufgabenverwaltung. Pfeiltasten,
|
||||
Enter und Escape bedienen die Palette vollständig ohne Maus. Bei leerer Suche stehen die
|
||||
Schnellaktionen „Aufgabe anlegen“, „Erinnerung anlegen“ und „Schüler anlegen“ bereit und
|
||||
Enter und Escape bedienen die Palette vollständig ohne Maus. Gruppentreffer werden
|
||||
zusätzlich über den zugeordneten Fachnamen gefunden und zeigen diesen
|
||||
im Untertitel, damit gleichnamige Klassen/Kurse eindeutig bleiben. Bei leerer Suche stehen die
|
||||
Schnellaktionen „Aufgabe anlegen“, „Erinnerung anlegen“, „Schüler anlegen“ und
|
||||
„Lerngruppen-Eintrag“ bereit und
|
||||
verwenden die bereits vorhandenen Dialoge samt Validierung. Zusätzlich ist der Einstieg als
|
||||
zugänglich benannte Schaltfläche im Navigationsbereich sichtbar. Tests in
|
||||
`GlobalSearchViewModelTests` decken Ergebnisarten, Navigation und Schnellerfassung ab.
|
||||
@@ -3553,8 +3584,15 @@ von 4.2.2) der Tab "Kürzel-Katalog" für die Von/Nach-Kürzel des Stundenverlau
|
||||
[TrashTests.cs](LehrerApp.Data.Tests/TrashTests.cs) (Löschen/Wiederherstellen je
|
||||
Repository, Sortierung, Bereinigung), 6 in
|
||||
[TrashViewModelTests.cs](LehrerApp.Desktop.Tests/TrashViewModelTests.cs).
|
||||
- [ ] **14.4** Ladeanzeigen bei längeren Operationen (Import, Sync, Export).
|
||||
- [ ] **14.5** Leere Zustände mit Handlungsaufforderung statt leerer Tabellen.
|
||||
- [~] **14.4** Ladeanzeigen bei längeren Operationen (Import, Sync, Export).
|
||||
Schülerimporte blockieren die Liste während der asynchronen Dateianalyse mit Fortschritts-
|
||||
anzeige und Status; der Sync-Button wechselt während des Abgleichs vom Vektoricon auf eine
|
||||
indeterminierte Fortschrittsanzeige. Bereits vorhandene WebUntis-/KI-Ladevorgänge bleiben
|
||||
erhalten. Eine einheitliche Anzeige für sämtliche PDF-/CSV-Exporte ist noch offen.
|
||||
- [~] **14.5** Leere Zustände mit Handlungsaufforderung statt leerer Tabellen. Lerngruppen hatten
|
||||
bereits „Erste Lerngruppe anlegen“; Schüler bieten jetzt „Ersten Schüler anlegen“ und
|
||||
„Schülerliste importieren“, die Klausuren-Hauptseite erklärt den Anlageort und springt zu
|
||||
den Lerngruppen. Fachspezifische Untertabellen werden schrittweise ergänzt.
|
||||
- [~] **14.6** Fenstergröße und Spaltenbreiten über Sitzungen hinweg merken.
|
||||
|
||||
**Umsetzung (Fenstergröße):**
|
||||
@@ -3576,14 +3614,23 @@ von 4.2.2) der Tab "Kürzel-Katalog" für die Von/Nach-Kürzel des Stundenverlau
|
||||
stabile Spalten-Identität, an der eine gespeicherte Breite verlässlich hängen könnte —
|
||||
eine generische Lösung ist damit kein "billiger" Zusatz, sondern ein eigener, größerer
|
||||
Umbau. Nicht in diesem Durchgang umgesetzt.
|
||||
- [ ] **14.7** Bedienung auf Touch-Geräten prüfen (Tablet im Unterricht).
|
||||
- [ ] **14.8** Responsive Layout und Windows-DPI prüfen (kleine Notebook-Auflösungen sowie
|
||||
- [~] **14.7** Bedienung auf Touch-Geräten prüfen (Tablet im Unterricht). Hauptnavigation und
|
||||
Sync-Aktion haben mindestens 40 px große Ziele; besonders kleine Karten-/Menüaktionen wurden
|
||||
vergrößert. Ein Test auf echter Tablet-Hardware bleibt offen.
|
||||
- [~] **14.8** Responsive Layout und Windows-DPI prüfen (kleine Notebook-Auflösungen sowie
|
||||
125/150/200 % Skalierung; starre Master-Detail-Spalten bei Bedarf stapeln). Der kompakte
|
||||
Drawer berücksichtigt bereits die schmalere verfügbare Breite mit reduziertem Außen-/
|
||||
Innenabstand und einer eigenen Iconfläche, damit Windows-Emoji nicht abgeschnitten werden.
|
||||
- [ ] **14.9** Barrierefreiheit prüfen: Automation-Namen für Icon-Buttons, sichtbare Fokusrahmen,
|
||||
Das Hauptfenster kann jetzt bis 640×480 verkleinert werden und wechselt dadurch tatsächlich
|
||||
in den Overlay-Drawer; zuvor verhinderte `MinWidth=900` exakt diesen Zustand. Aktionsleisten
|
||||
in Dashboard, Schüler-, Gruppenliste und Gruppendetail umbrechen bzw. liegen unter dem Titel.
|
||||
Ein echter Windows-DPI-Test bleibt offen.
|
||||
- [~] **14.9** Barrierefreiheit prüfen: Automation-Namen für Icon-Buttons, sichtbare Fokusrahmen,
|
||||
Kontraste und Status nicht ausschließlich über Farbe/Emoji vermitteln.
|
||||
- [ ] **14.10** Plattformübergreifend konsistentes SVG-/`PathIcon`-Set statt systemabhängiger
|
||||
Sichtbare Akzent-Fokusrahmen sind zentral für Buttons, Textfelder und Comboboxen definiert;
|
||||
Hauptnavigation, Kalender-/Dashboard-Sortierung und zentrale Stundenplan-Iconaktionen haben
|
||||
sprechende Automation-Namen und Tooltips. Die vollständige Prüfung aller Dialoge bleibt offen.
|
||||
- [~] **14.10** Plattformübergreifend konsistentes SVG-/`PathIcon`-Set statt systemabhängiger
|
||||
Emoji-Darstellung einführen. **Konkreter Bericht (Nutzer-Feedback):** Drawer-Icons erscheinen
|
||||
auf einem Windows-PC gegen 22 Uhr einfarbig schwarz statt farbig — passend zum bekannten
|
||||
Windows-Verhalten, dass Emoji-Codepunkte je nach Font-Fallback statt der farbigen
|
||||
@@ -3593,7 +3640,10 @@ von 4.2.2) der Tab "Kürzel-Katalog" für die Von/Nach-Kürzel des Stundenverlau
|
||||
explizit `FontFamily="Segoe UI Emoji,Segoe UI Symbol,Segoe UI"` — auf anderen Plattformen
|
||||
folgenlos, da unbekannte Fontnamen einfach übersprungen werden. Nur eine Abmilderung für die
|
||||
Drawer-Navigation, kein Nachweis der Ursache und keine Lösung für die übrigen Emoji im Rest
|
||||
der App — die eigentliche, dauerhafte Lösung bleibt dieser Punkt (echtes Icon-Set).
|
||||
der App. **Hauptnavigation jetzt dauerhaft gelöst:** Suche und alle acht Navigationsziele
|
||||
verwenden zentral hinterlegte `StreamGeometry`/`PathIcon`-Ressourcen; auch der Sync-Button
|
||||
verwendet ein Vektoricon. Fachaktionen im restlichen UI enthalten teilweise weiterhin Emoji
|
||||
und werden in einem späteren, separaten Austausch migriert.
|
||||
- [x] **14.11** Aktiven Navigationspunkt in der Seitenleiste sichtbar hervorheben; Zustand wird
|
||||
über `MainWindowViewModel.ActiveNavItem` gesteuert.
|
||||
- [x] **14.12** Tab "Übersicht" im Kurs (`GroupDetailView`, bislang nur Platzhaltertext) gefüllt —
|
||||
|
||||
Reference in New Issue
Block a user