Stundenplan: Wochenraster, Wochennavigation, Ferien/Feiertage (Kapitel 4.3)
Neuer Stundenplan mit "Heute"-Standardansicht (Tagesliste unten angedockt, gruppenübergreifendes Wochenraster mit Fach/Klasse/Raum/Thema, Vor-/Zurück- Navigation zwischen Kalenderwochen) und separatem Bearbeiten-Raster für die wöchentliche Zuordnung. Badges für Ferien-/Klausur-Nähe und ausgegraute Ferientage direkt im Plan statt einer separaten Liste. Ferien-/Feiertage- Pflege (Bundesland, Schulferien) sitzt jetzt in den Einstellungen statt im Stundenplan selbst. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -85,6 +85,19 @@ public interface ILessonRepository
|
||||
void Save(Lesson lesson);
|
||||
void Delete(Guid id);
|
||||
}
|
||||
public interface ITimetableSlotRepository
|
||||
{
|
||||
List<TimetableSlot> GetAll();
|
||||
List<TimetableSlot> GetByGroup(Guid groupId);
|
||||
void Save(TimetableSlot slot);
|
||||
void Delete(Guid id);
|
||||
}
|
||||
public interface ISchoolHolidayRepository
|
||||
{
|
||||
List<SchoolHoliday> GetAll();
|
||||
void Save(SchoolHoliday holiday);
|
||||
void Delete(Guid id);
|
||||
}
|
||||
public interface IDocumentationRepository
|
||||
{
|
||||
List<Documentation> GetByStudent(Guid studentId);
|
||||
|
||||
@@ -110,6 +110,39 @@ public class AlternativeLessonPath
|
||||
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Ein fester Termin im wöchentlichen Stundenplan (4.3): eine Lerngruppe trifft sich an einem
|
||||
/// Wochentag zu einer bestimmten Stunde. Wiederkehrendes Muster, kein konkretes Datum — für
|
||||
/// tatsächlich gehaltene Einzelstunden siehe <see cref="Lesson"/>. Pro Wochentag/Stunde ist
|
||||
/// höchstens eine Gruppe eingetragen (ein Lehrer kann nicht gleichzeitig an zwei Orten sein).
|
||||
/// </summary>
|
||||
public class TimetableSlot
|
||||
{
|
||||
public Guid Id { get; set; } = Guid.NewGuid();
|
||||
public Guid GroupId { get; set; }
|
||||
public DayOfWeek Weekday { get; set; }
|
||||
public int PeriodNumber { get; set; }
|
||||
public string? Room { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Unterrichtsfreier Zeitraum (4.3.5): Schulferien werden manuell gepflegt (nicht algorithmisch
|
||||
/// herleitbar, jährlich neu von den Bundesländern festgelegt). Gesetzliche Feiertage werden
|
||||
/// dagegen berechnet (PublicHolidayService) und nicht in der Datenbank gespeichert.
|
||||
/// </summary>
|
||||
public class SchoolHoliday
|
||||
{
|
||||
public Guid Id { get; set; } = Guid.NewGuid();
|
||||
public string Name { get; set; } = "";
|
||||
public DateOnly StartDate { get; set; }
|
||||
public DateOnly EndDate { get; set; }
|
||||
}
|
||||
|
||||
public enum GermanState
|
||||
{
|
||||
BW, BY, BE, BB, HB, HH, HE, MV, NI, NW, RP, SL, SN, ST, SH, TH
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Zeugnisnote eines Schülers in einer Lerngruppe für einen Zeitraum (Halbjahr/Gesamtjahr).
|
||||
/// <see cref="CalculatedValue"/> ist das zuletzt berechnete Ergebnis; <see cref="OverrideValue"/>
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
using LehrerApp.Core.Models;
|
||||
|
||||
namespace LehrerApp.Core.Services;
|
||||
|
||||
public record PublicHoliday(DateOnly Date, string Name);
|
||||
|
||||
/// <summary>
|
||||
/// Berechnet die gesetzlichen Feiertage eines Bundeslands für ein Kalenderjahr (4.3.5).
|
||||
/// Im Gegensatz zu Schulferien sind Feiertage über eine feste Regel je Bundesland herleitbar und
|
||||
/// werden deshalb nicht in der Datenbank gespeichert.
|
||||
/// </summary>
|
||||
public class PublicHolidayService
|
||||
{
|
||||
public List<PublicHoliday> GetHolidays(int year, GermanState state)
|
||||
{
|
||||
var easterSunday = EasterSunday(year);
|
||||
var holidays = new List<PublicHoliday>
|
||||
{
|
||||
new(new DateOnly(year, 1, 1), "Neujahr"),
|
||||
new(easterSunday.AddDays(-2), "Karfreitag"),
|
||||
new(easterSunday.AddDays(1), "Ostermontag"),
|
||||
new(new DateOnly(year, 5, 1), "Tag der Arbeit"),
|
||||
new(easterSunday.AddDays(39), "Christi Himmelfahrt"),
|
||||
new(easterSunday.AddDays(50), "Pfingstmontag"),
|
||||
new(new DateOnly(year, 10, 3), "Tag der Deutschen Einheit"),
|
||||
new(new DateOnly(year, 12, 25), "1. Weihnachtstag"),
|
||||
new(new DateOnly(year, 12, 26), "2. Weihnachtstag"),
|
||||
};
|
||||
|
||||
if (state is GermanState.BW or GermanState.BY or GermanState.ST)
|
||||
holidays.Add(new(new DateOnly(year, 1, 6), "Heilige Drei Könige"));
|
||||
|
||||
if (state is GermanState.BE)
|
||||
holidays.Add(new(new DateOnly(year, 3, 8), "Internationaler Frauentag"));
|
||||
|
||||
if (state is GermanState.BW or GermanState.BY or GermanState.HE or GermanState.NW
|
||||
or GermanState.RP or GermanState.SL)
|
||||
holidays.Add(new(easterSunday.AddDays(60), "Fronleichnam"));
|
||||
|
||||
if (state is GermanState.SL)
|
||||
holidays.Add(new(new DateOnly(year, 8, 15), "Mariä Himmelfahrt"));
|
||||
|
||||
if (state is GermanState.BB or GermanState.MV or GermanState.SN or GermanState.ST
|
||||
or GermanState.TH or GermanState.HB or GermanState.HH or GermanState.NI
|
||||
or GermanState.SH)
|
||||
holidays.Add(new(new DateOnly(year, 10, 31), "Reformationstag"));
|
||||
|
||||
if (state is GermanState.BW or GermanState.BY or GermanState.NW or GermanState.RP
|
||||
or GermanState.SL)
|
||||
holidays.Add(new(new DateOnly(year, 11, 1), "Allerheiligen"));
|
||||
|
||||
if (state is GermanState.SN)
|
||||
holidays.Add(new(BussUndBettag(year), "Buß- und Bettag"));
|
||||
|
||||
return holidays.OrderBy(h => h.Date).ToList();
|
||||
}
|
||||
|
||||
/// Gauß'sche Osterformel.
|
||||
private static DateOnly EasterSunday(int year)
|
||||
{
|
||||
int a = year % 19, b = year / 100, c = year % 100;
|
||||
int d = b / 4, e = b % 4, f = (b + 8) / 25, g = (b - f + 1) / 3;
|
||||
int h = (19 * a + b - d - g + 15) % 30;
|
||||
int i = c / 4, k = c % 4, l = (32 + 2 * e + 2 * i - h - k) % 7;
|
||||
int m = (a + 11 * h + 22 * l) / 451;
|
||||
int month = (h + l - 7 * m + 114) / 31;
|
||||
int day = (h + l - 7 * m + 114) % 31 + 1;
|
||||
return new DateOnly(year, month, day);
|
||||
}
|
||||
|
||||
/// Buß- und Bettag: Mittwoch vor dem 23. November (entspricht dem letzten Mittwoch vor dem
|
||||
/// ersten Advent).
|
||||
private static DateOnly BussUndBettag(int year)
|
||||
{
|
||||
var date = new DateOnly(year, 11, 23);
|
||||
while (date.DayOfWeek != DayOfWeek.Wednesday) date = date.AddDays(-1);
|
||||
return date;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
using System.Text.Json;
|
||||
using LehrerApp.Core.Models;
|
||||
|
||||
namespace LehrerApp.Core.Services;
|
||||
|
||||
internal class SchoolCalendarConfig
|
||||
{
|
||||
public GermanState State { get; set; } = GermanState.NW;
|
||||
}
|
||||
|
||||
/// <summary>Welches Bundesland für die Feiertagsberechnung (4.3.5) gilt.</summary>
|
||||
public class SchoolCalendarSettingsService
|
||||
{
|
||||
private readonly string _configPath;
|
||||
private SchoolCalendarConfig _config;
|
||||
|
||||
public GermanState State => _config.State;
|
||||
|
||||
public SchoolCalendarSettingsService(string appDataPath)
|
||||
{
|
||||
_configPath = Path.Combine(appDataPath, "schoolcalendar.json");
|
||||
_config = Load();
|
||||
}
|
||||
|
||||
public void SetState(GermanState state)
|
||||
{
|
||||
_config.State = state;
|
||||
File.WriteAllText(_configPath, JsonSerializer.Serialize(_config));
|
||||
}
|
||||
|
||||
private SchoolCalendarConfig Load()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (File.Exists(_configPath))
|
||||
return JsonSerializer.Deserialize<SchoolCalendarConfig>(File.ReadAllText(_configPath))
|
||||
?? new SchoolCalendarConfig();
|
||||
}
|
||||
catch { /* beschädigte Konfiguration -> Standardwert */ }
|
||||
return new SchoolCalendarConfig();
|
||||
}
|
||||
}
|
||||
@@ -529,4 +529,63 @@ public sealed class RepositoryTests
|
||||
|
||||
Assert.Empty(repo.GetAll());
|
||||
}
|
||||
|
||||
// ── TimetableSlotRepository ───────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void TimetableSlotRepository_GetByGroup_FindetNurSlotsDerGruppe()
|
||||
{
|
||||
using var db = NewInMemoryContext();
|
||||
var repo = new TimetableSlotRepository(db);
|
||||
var groupA = Guid.NewGuid();
|
||||
var groupB = Guid.NewGuid();
|
||||
repo.Save(new TimetableSlot { GroupId = groupA, Weekday = DayOfWeek.Monday, PeriodNumber = 1 });
|
||||
repo.Save(new TimetableSlot { GroupId = groupB, Weekday = DayOfWeek.Monday, PeriodNumber = 2 });
|
||||
|
||||
var result = repo.GetByGroup(groupA);
|
||||
|
||||
Assert.Single(result);
|
||||
Assert.Equal(groupA, result[0].GroupId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TimetableSlotRepository_Save_LehntDoppelbelegungDerselbenStundeAb()
|
||||
{
|
||||
using var db = NewInMemoryContext();
|
||||
var repo = new TimetableSlotRepository(db);
|
||||
repo.Save(new TimetableSlot { GroupId = Guid.NewGuid(), Weekday = DayOfWeek.Tuesday, PeriodNumber = 3 });
|
||||
|
||||
Assert.Throws<InvalidOperationException>(() =>
|
||||
repo.Save(new TimetableSlot { GroupId = Guid.NewGuid(), Weekday = DayOfWeek.Tuesday, PeriodNumber = 3 }));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TimetableSlotRepository_Save_AktualisierenDesselbenSlotsIstErlaubt()
|
||||
{
|
||||
using var db = NewInMemoryContext();
|
||||
var repo = new TimetableSlotRepository(db);
|
||||
var slot = new TimetableSlot { GroupId = Guid.NewGuid(), Weekday = DayOfWeek.Tuesday, PeriodNumber = 3 };
|
||||
repo.Save(slot);
|
||||
|
||||
slot.Room = "R204";
|
||||
repo.Save(slot);
|
||||
|
||||
Assert.Equal("R204", repo.GetAll().Single().Room);
|
||||
}
|
||||
|
||||
// ── SchoolHolidayRepository ───────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void SchoolHolidayRepository_GetAll_SortiertNachStartdatum()
|
||||
{
|
||||
using var db = NewInMemoryContext();
|
||||
var repo = new SchoolHolidayRepository(db);
|
||||
repo.Save(new SchoolHoliday { Name = "Sommerferien", StartDate = new DateOnly(2026, 7, 1), EndDate = new DateOnly(2026, 8, 10) });
|
||||
repo.Save(new SchoolHoliday { Name = "Osterferien", StartDate = new DateOnly(2026, 3, 30), EndDate = new DateOnly(2026, 4, 10) });
|
||||
|
||||
var result = repo.GetAll();
|
||||
|
||||
Assert.Equal("Osterferien", result[0].Name);
|
||||
Assert.Equal("Sommerferien", result[1].Name);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -57,6 +57,8 @@ public class LiteDbContext : IDisposable
|
||||
public ILiteCollection<CompetencyDomain> CompetencyDomains => _db.GetCollection<CompetencyDomain>("competency_domains");
|
||||
public ILiteCollection<ShorthandCode> ShorthandCodes => _db.GetCollection<ShorthandCode>("shorthand_codes");
|
||||
public ILiteCollection<AlternativeLessonPath> AlternativeLessonPaths => _db.GetCollection<AlternativeLessonPath>("alternative_lesson_paths");
|
||||
public ILiteCollection<TimetableSlot> TimetableSlots => _db.GetCollection<TimetableSlot>("timetable_slots");
|
||||
public ILiteCollection<SchoolHoliday> SchoolHolidays => _db.GetCollection<SchoolHoliday>("school_holidays");
|
||||
|
||||
public void Checkpoint() => _db.Checkpoint();
|
||||
|
||||
@@ -369,6 +371,9 @@ public class LiteDbContext : IDisposable
|
||||
CompetencyDomains.EnsureIndex(x => x.GradeLevel);
|
||||
ShorthandCodes.EnsureIndex("ux_shorthand_code", BsonExpression.Create("LOWER(TRIM($.Code))"), unique: true);
|
||||
AlternativeLessonPaths.EnsureIndex("ux_alt_lesson_path_name", BsonExpression.Create("LOWER(TRIM($.Name))"), unique: true);
|
||||
TimetableSlots.EnsureIndex(x => x.GroupId);
|
||||
TimetableSlots.EnsureIndex("ux_timetable_weekday_period",
|
||||
BsonExpression.Create("STRING($.Weekday) + ':' + STRING($.PeriodNumber)"), unique: true);
|
||||
}
|
||||
|
||||
public void Dispose() => _db.Dispose();
|
||||
|
||||
@@ -386,6 +386,30 @@ public class AlternativeLessonPathRepository(LiteDbContext db) : IAlternativeLes
|
||||
public void Delete(Guid id) => db.AlternativeLessonPaths.Delete(id);
|
||||
}
|
||||
|
||||
public class TimetableSlotRepository(LiteDbContext db) : ITimetableSlotRepository
|
||||
{
|
||||
public List<TimetableSlot> GetAll() =>
|
||||
db.TimetableSlots.FindAll().OrderBy(s => s.Weekday).ThenBy(s => s.PeriodNumber).ToList();
|
||||
public List<TimetableSlot> GetByGroup(Guid groupId) =>
|
||||
db.TimetableSlots.Find(s => s.GroupId == groupId).OrderBy(s => s.Weekday).ThenBy(s => s.PeriodNumber).ToList();
|
||||
public void Save(TimetableSlot slot)
|
||||
{
|
||||
var occupied = db.TimetableSlots.FindAll()
|
||||
.FirstOrDefault(s => s.Weekday == slot.Weekday && s.PeriodNumber == slot.PeriodNumber);
|
||||
if (occupied is not null && occupied.Id != slot.Id)
|
||||
throw new InvalidOperationException("Diese Stunde ist bereits belegt.");
|
||||
db.TimetableSlots.Upsert(slot);
|
||||
}
|
||||
public void Delete(Guid id) => db.TimetableSlots.Delete(id);
|
||||
}
|
||||
|
||||
public class SchoolHolidayRepository(LiteDbContext db) : ISchoolHolidayRepository
|
||||
{
|
||||
public List<SchoolHoliday> GetAll() => db.SchoolHolidays.FindAll().OrderBy(h => h.StartDate).ToList();
|
||||
public void Save(SchoolHoliday holiday) => db.SchoolHolidays.Upsert(holiday);
|
||||
public void Delete(Guid id) => db.SchoolHolidays.Delete(id);
|
||||
}
|
||||
|
||||
public class CompetencyDomainRepository(LiteDbContext db) : ICompetencyDomainRepository
|
||||
{
|
||||
public List<CompetencyDomain> GetBySubjectAndGrade(Guid subjectId, int gradeLevel) =>
|
||||
|
||||
@@ -98,6 +98,17 @@ public class FakeResults : IExamResultRepository
|
||||
public void SaveMany(List<ExamResult> results) { }
|
||||
}
|
||||
|
||||
public class FakeGradingKeyTemplates : IGradingKeyTemplateRepository
|
||||
{
|
||||
private readonly List<GradingKeyTemplate> _all = [];
|
||||
public List<GradingKeyTemplate> GetAll() => _all;
|
||||
public List<GradingKeyTemplate> GetByGradingSystem(GradingSystem system) =>
|
||||
_all.Where(t => t.GradingSystem == system).ToList();
|
||||
public GradingKeyTemplate? GetById(Guid id) => _all.FirstOrDefault(t => t.Id == id);
|
||||
public void Save(GradingKeyTemplate template) { _all.RemoveAll(t => t.Id == template.Id); _all.Add(template); }
|
||||
public void Delete(Guid id) => _all.RemoveAll(t => t.Id == id);
|
||||
}
|
||||
|
||||
public class FakeSchemes : IGradingSchemeRepository
|
||||
{
|
||||
private readonly Dictionary<Guid, GradingScheme> _byGroup = [];
|
||||
@@ -206,6 +217,32 @@ public class FakeAlternativeLessonPaths(List<AlternativeLessonPath> all) : IAlte
|
||||
public void Delete(Guid id) => all.RemoveAll(p => p.Id == id);
|
||||
}
|
||||
|
||||
public class FakeTimetableSlots : ITimetableSlotRepository
|
||||
{
|
||||
private readonly List<TimetableSlot> _all = [];
|
||||
public void Add(TimetableSlot s) => _all.Add(s);
|
||||
public List<TimetableSlot> GetAll() => _all.ToList();
|
||||
public List<TimetableSlot> GetByGroup(Guid groupId) => _all.Where(s => s.GroupId == groupId).ToList();
|
||||
public void Save(TimetableSlot slot)
|
||||
{
|
||||
var occupied = _all.FirstOrDefault(s => s.Weekday == slot.Weekday && s.PeriodNumber == slot.PeriodNumber);
|
||||
if (occupied is not null && occupied.Id != slot.Id)
|
||||
throw new InvalidOperationException("Diese Stunde ist bereits belegt.");
|
||||
_all.RemoveAll(s => s.Id == slot.Id);
|
||||
_all.Add(slot);
|
||||
}
|
||||
public void Delete(Guid id) => _all.RemoveAll(s => s.Id == id);
|
||||
}
|
||||
|
||||
public class FakeSchoolHolidays : ISchoolHolidayRepository
|
||||
{
|
||||
private readonly List<SchoolHoliday> _all = [];
|
||||
public void Add(SchoolHoliday h) => _all.Add(h);
|
||||
public List<SchoolHoliday> GetAll() => _all.ToList();
|
||||
public void Save(SchoolHoliday holiday) { _all.RemoveAll(h => h.Id == holiday.Id); _all.Add(holiday); }
|
||||
public void Delete(Guid id) => _all.RemoveAll(h => h.Id == id);
|
||||
}
|
||||
|
||||
public class FakeReportGrades : IReportGradeRepository
|
||||
{
|
||||
private readonly List<ReportGrade> _all = [];
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
using LehrerApp.Core.Models;
|
||||
using LehrerApp.Core.Services;
|
||||
using LehrerApp.Data;
|
||||
using LehrerApp.Desktop.ViewModels.Settings;
|
||||
using Xunit;
|
||||
|
||||
namespace LehrerApp.Desktop.Tests;
|
||||
|
||||
public sealed class SettingsViewModelTests
|
||||
{
|
||||
private static SettingsViewModel BuildViewModel(FakeSchoolHolidays? holidays = null)
|
||||
{
|
||||
// Bewusst kein "using": SchoolCalendarSettingsService liest den Pfad erst bei SetState,
|
||||
// das Verzeichnis muss über die Lebensdauer des ViewModels bestehen bleiben.
|
||||
var tempPath = System.IO.Path.Combine(
|
||||
System.IO.Path.GetTempPath(), $"lehrerapp-settingsvm-tests-{Guid.NewGuid():N}");
|
||||
Directory.CreateDirectory(tempPath);
|
||||
|
||||
return new SettingsViewModel(
|
||||
new FakeSubjects([]), new FakeCompetencyDomains(), new FakeGradingKeyTemplates(),
|
||||
new FakeSchemes(), new GradingService(), new BackupService(tempPath),
|
||||
new DatabaseEncryptionService(), new AppLockService(tempPath),
|
||||
new LiteDbContext(new MemoryStream()), new PrivacySettingsService(tempPath),
|
||||
new FakeDocumentation(), new FakeStudents([]), new FakeShorthandCodes([]),
|
||||
holidays ?? new FakeSchoolHolidays(), new SchoolCalendarSettingsService(tempPath));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddSchoolHoliday_GueltigeEingabe_WirdGespeichertUndInListeAngezeigt()
|
||||
{
|
||||
var holidays = new FakeSchoolHolidays();
|
||||
var vm = BuildViewModel(holidays);
|
||||
vm.NewHolidayName = "Sommerferien";
|
||||
vm.NewHolidayStartText = "01.07.2026";
|
||||
vm.NewHolidayEndText = "10.08.2026";
|
||||
|
||||
vm.AddSchoolHolidayCommand.Execute(null);
|
||||
|
||||
Assert.Single(holidays.GetAll());
|
||||
Assert.Single(vm.SchoolHolidayEntries);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddSchoolHoliday_EndeVorBeginn_SetztFehlerUndSpeichertNicht()
|
||||
{
|
||||
var holidays = new FakeSchoolHolidays();
|
||||
var vm = BuildViewModel(holidays);
|
||||
vm.NewHolidayName = "Ungültig";
|
||||
vm.NewHolidayStartText = "10.08.2026";
|
||||
vm.NewHolidayEndText = "01.07.2026";
|
||||
|
||||
vm.AddSchoolHolidayCommand.Execute(null);
|
||||
|
||||
Assert.Empty(holidays.GetAll());
|
||||
Assert.NotEqual("", vm.HolidayDateError);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddSchoolHoliday_FehlenderName_SetztFehlerUndSpeichertNicht()
|
||||
{
|
||||
var holidays = new FakeSchoolHolidays();
|
||||
var vm = BuildViewModel(holidays);
|
||||
vm.NewHolidayStartText = "01.07.2026";
|
||||
vm.NewHolidayEndText = "10.08.2026";
|
||||
|
||||
vm.AddSchoolHolidayCommand.Execute(null);
|
||||
|
||||
Assert.Empty(holidays.GetAll());
|
||||
Assert.NotEqual("", vm.HolidayNameError);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RemoveSchoolHoliday_EntferntEintragAusRepositoryUndListe()
|
||||
{
|
||||
var holidays = new FakeSchoolHolidays();
|
||||
var holiday = new SchoolHoliday { Name = "Herbstferien", StartDate = new DateOnly(2026, 10, 12), EndDate = new DateOnly(2026, 10, 24) };
|
||||
holidays.Add(holiday);
|
||||
var vm = BuildViewModel(holidays);
|
||||
|
||||
vm.RemoveSchoolHolidayCommand.Execute(vm.SchoolHolidayEntries[0]);
|
||||
|
||||
Assert.Empty(holidays.GetAll());
|
||||
Assert.Empty(vm.SchoolHolidayEntries);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SelectedStateName_Aendern_PersistiertUeberSchoolCalendarSettings()
|
||||
{
|
||||
var tempPath = System.IO.Path.Combine(
|
||||
System.IO.Path.GetTempPath(), $"lehrerapp-settingsvm-state-tests-{Guid.NewGuid():N}");
|
||||
Directory.CreateDirectory(tempPath);
|
||||
var calendarSettings = new SchoolCalendarSettingsService(tempPath);
|
||||
|
||||
var vm = new SettingsViewModel(
|
||||
new FakeSubjects([]), new FakeCompetencyDomains(), new FakeGradingKeyTemplates(),
|
||||
new FakeSchemes(), new GradingService(), new BackupService(tempPath),
|
||||
new DatabaseEncryptionService(), new AppLockService(tempPath),
|
||||
new LiteDbContext(new MemoryStream()), new PrivacySettingsService(tempPath),
|
||||
new FakeDocumentation(), new FakeStudents([]), new FakeShorthandCodes([]),
|
||||
new FakeSchoolHolidays(), calendarSettings);
|
||||
|
||||
vm.SelectedStateName = "Bayern";
|
||||
|
||||
Assert.Equal(GermanState.BY, calendarSettings.State);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
using LehrerApp.Core.Models;
|
||||
using LehrerApp.Core.Services;
|
||||
using LehrerApp.Desktop.ViewModels.Planning;
|
||||
using Xunit;
|
||||
|
||||
namespace LehrerApp.Desktop.Tests;
|
||||
|
||||
public sealed class TimetableSlotDialogViewModelTests
|
||||
{
|
||||
private static SchoolYearService SchoolYear() => new();
|
||||
|
||||
[Fact]
|
||||
public void Save_OhneAusgewaehlteGruppe_SetztFehlerUndSpeichertNicht()
|
||||
{
|
||||
var slots = new FakeTimetableSlots();
|
||||
var groups = new FakeGroups([]);
|
||||
var vm = new TimetableSlotDialogViewModel(slots, groups, SchoolYear(), DayOfWeek.Monday, 1, null);
|
||||
|
||||
vm.SaveCommand.Execute(null);
|
||||
|
||||
Assert.Null(vm.Result);
|
||||
Assert.NotEqual("", vm.GroupError);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Save_MitAusgewaehlterGruppe_LegtNeuenSlotAn()
|
||||
{
|
||||
var group = new LearningGroup { Name = "Q1 Chemie" };
|
||||
var slots = new FakeTimetableSlots();
|
||||
var groups = new FakeGroups([group]);
|
||||
var vm = new TimetableSlotDialogViewModel(slots, groups, SchoolYear(), DayOfWeek.Monday, 1, null)
|
||||
{
|
||||
SelectedGroupName = "Q1 Chemie", Room = "R204",
|
||||
};
|
||||
|
||||
vm.SaveCommand.Execute(null);
|
||||
|
||||
Assert.NotNull(vm.Result);
|
||||
Assert.Equal(group.Id, vm.Result!.GroupId);
|
||||
Assert.Equal("R204", vm.Result.Room);
|
||||
Assert.Single(slots.GetAll());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Save_BelegteStunde_ZeigtFreundlicheFehlermeldungStattAbsturz()
|
||||
{
|
||||
var groupA = new LearningGroup { Name = "Q1 Chemie" };
|
||||
var groupB = new LearningGroup { Name = "Q1 Physik" };
|
||||
var slots = new FakeTimetableSlots();
|
||||
slots.Add(new TimetableSlot { GroupId = groupA.Id, Weekday = DayOfWeek.Monday, PeriodNumber = 1 });
|
||||
var groups = new FakeGroups([groupA, groupB]);
|
||||
var vm = new TimetableSlotDialogViewModel(slots, groups, SchoolYear(), DayOfWeek.Monday, 1, null)
|
||||
{
|
||||
SelectedGroupName = "Q1 Physik",
|
||||
};
|
||||
|
||||
vm.SaveCommand.Execute(null);
|
||||
|
||||
Assert.Null(vm.Result);
|
||||
Assert.Equal("Diese Stunde ist bereits belegt.", vm.GroupError);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Delete_BeimBearbeitenEinesVorhandenenSlots_EntferntIhn()
|
||||
{
|
||||
var group = new LearningGroup { Name = "Q1 Chemie" };
|
||||
var slot = new TimetableSlot { GroupId = group.Id, Weekday = DayOfWeek.Monday, PeriodNumber = 1 };
|
||||
var slots = new FakeTimetableSlots();
|
||||
slots.Add(slot);
|
||||
var groups = new FakeGroups([group]);
|
||||
var vm = new TimetableSlotDialogViewModel(slots, groups, SchoolYear(), DayOfWeek.Monday, 1, slot);
|
||||
|
||||
vm.DeleteCommand.Execute(null);
|
||||
|
||||
Assert.True(vm.Deleted);
|
||||
Assert.Empty(slots.GetAll());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,413 @@
|
||||
using LehrerApp.Core.Models;
|
||||
using LehrerApp.Core.Services;
|
||||
using LehrerApp.Desktop.ViewModels.Planning;
|
||||
using Xunit;
|
||||
|
||||
namespace LehrerApp.Desktop.Tests;
|
||||
|
||||
public sealed class TimetableViewModelTests
|
||||
{
|
||||
private static TimetableViewModel BuildViewModel(
|
||||
FakeTimetableSlots slots, FakeGroups groups, FakeSchoolHolidays? holidays = null,
|
||||
FakeSubjects? subjects = null, FakeLessons? lessons = null, FakeExams? exams = null,
|
||||
SchoolCalendarSettingsService? calendarSettings = null)
|
||||
{
|
||||
// Bewusst kein "using": SchoolCalendarSettingsService liest den Pfad nur bei Bedarf
|
||||
// (SetState), das Verzeichnis muss über die Lebensdauer des ViewModels bestehen bleiben.
|
||||
var tempPath = System.IO.Path.Combine(
|
||||
System.IO.Path.GetTempPath(), $"lehrerapp-timetablevm-tests-{Guid.NewGuid():N}");
|
||||
Directory.CreateDirectory(tempPath);
|
||||
return new TimetableViewModel(
|
||||
slots, groups, subjects ?? new FakeSubjects([]),
|
||||
lessons ?? new FakeLessons(), exams ?? new FakeExams([]),
|
||||
holidays ?? new FakeSchoolHolidays(),
|
||||
calendarSettings ?? new SchoolCalendarSettingsService(tempPath),
|
||||
new PublicHolidayService(), new SchoolYearService());
|
||||
}
|
||||
|
||||
/// Nächstes Datum ab (inkl.) <paramref name="from"/>, das auf einen Wochentag Mo-Fr fällt —
|
||||
/// nur diese Tage haben eine Spalte im Raster. Für Badge-Tests reicht ein beliebiger
|
||||
/// Mo-Fr-Wochentag: die Badges hängen nur am Wochentag (nicht am konkreten Kalenderdatum),
|
||||
/// da `TimetableSlot` ein wiederkehrendes Muster ohne Datum ist.
|
||||
private static DateOnly NextGridWeekday(DateOnly from)
|
||||
{
|
||||
var d = from;
|
||||
while (d.DayOfWeek is DayOfWeek.Saturday or DayOfWeek.Sunday) d = d.AddDays(1);
|
||||
return d;
|
||||
}
|
||||
|
||||
/// Datum des angegebenen Wochentags in der laufenden Kalenderwoche — exakt wie
|
||||
/// `TimetableViewModel.BuildWeekOverview` es berechnet, für Tests der Wochenkachel-Inhalte
|
||||
/// (Thema, Klausur am Tag), die anders als die Badges an ein konkretes Datum gebunden sind.
|
||||
private static DateOnly DateInCurrentWeek(DayOfWeek weekday)
|
||||
{
|
||||
var today = DateOnly.FromDateTime(DateTime.Today);
|
||||
var monday = today.AddDays(-((int)today.DayOfWeek + 6) % 7);
|
||||
return monday.AddDays((int)weekday - (int)DayOfWeek.Monday);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Load_BautEineZelleProWochentagUndStundePlusKopfzeilen()
|
||||
{
|
||||
var vm = BuildViewModel(new FakeTimetableSlots(), new FakeGroups([]));
|
||||
|
||||
// 1 Ecke + 5 Wochentage + 10 Stunden × (1 Label + 5 Zellen)
|
||||
Assert.Equal(6 + 10 * 6, vm.Cells.Count);
|
||||
Assert.Equal(6 + 10 * 6, vm.WeekItems.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Load_ZeigtZugewieseneGruppeInDerPassendenZelle()
|
||||
{
|
||||
var weekday = NextGridWeekday(DateOnly.FromDateTime(DateTime.Today));
|
||||
var group = new LearningGroup { Name = "Q1 Chemie" };
|
||||
var slots = new FakeTimetableSlots();
|
||||
slots.Add(new TimetableSlot { GroupId = group.Id, Weekday = weekday.DayOfWeek, PeriodNumber = 1, Room = "R204" });
|
||||
var vm = BuildViewModel(slots, new FakeGroups([group]));
|
||||
|
||||
var cell = vm.Cells.Single(c => c.IsSlotCell && c.Weekday == weekday.DayOfWeek && c.PeriodNumber == 1);
|
||||
|
||||
Assert.True(cell.IsAssigned);
|
||||
Assert.Equal("Q1 Chemie · R204", cell.Text);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HoursWarnings_WeichtDieZugewieseneStundenzahlAb_ZeigtWarnung()
|
||||
{
|
||||
var group = new LearningGroup { Name = "Q1 Chemie", SchoolYear = new SchoolYearService().CurrentSchoolYear(), HoursPerWeek = 3 };
|
||||
var slots = new FakeTimetableSlots();
|
||||
slots.Add(new TimetableSlot { GroupId = group.Id, Weekday = DayOfWeek.Monday, PeriodNumber = 1 });
|
||||
var vm = BuildViewModel(slots, new FakeGroups([group]));
|
||||
|
||||
Assert.Single(vm.HoursWarnings);
|
||||
Assert.Contains("1 von 3", vm.HoursWarnings[0].Text);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HoursWarnings_PassendeStundenzahl_KeineWarnung()
|
||||
{
|
||||
var group = new LearningGroup { Name = "Q1 Chemie", SchoolYear = new SchoolYearService().CurrentSchoolYear(), HoursPerWeek = 1 };
|
||||
var slots = new FakeTimetableSlots();
|
||||
slots.Add(new TimetableSlot { GroupId = group.Id, Weekday = DayOfWeek.Monday, PeriodNumber = 1 });
|
||||
var vm = BuildViewModel(slots, new FakeGroups([group]));
|
||||
|
||||
Assert.Empty(vm.HoursWarnings);
|
||||
}
|
||||
|
||||
// ── "Heute"-Ansicht: Tagesliste (Nutzer-Feedback: nicht-editierende Standardansicht) ────
|
||||
|
||||
[Fact]
|
||||
public void Load_ZeigtHeutigeStundeMitGruppeUndRaum()
|
||||
{
|
||||
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 = 3, Room = "R204" });
|
||||
var vm = BuildViewModel(slots, new FakeGroups([group]));
|
||||
|
||||
var item = Assert.Single(vm.TodayItems);
|
||||
Assert.Equal("Q1 Chemie", item.GroupName);
|
||||
Assert.Equal("R204", item.Room);
|
||||
Assert.Equal(3, item.PeriodNumber);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Load_HeutigeStundeMitLektionUndKlausur_ZeigtBeides()
|
||||
{
|
||||
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 = 1 });
|
||||
var lessons = new FakeLessons();
|
||||
lessons.Add(new Lesson { GroupId = group.Id, Date = today, Topic = "Redoxreaktionen" });
|
||||
var exams = new FakeExams([new Exam { GroupId = group.Id, Date = today, Title = "Klausur Nr. 2" }]);
|
||||
var vm = BuildViewModel(slots, new FakeGroups([group]), lessons: lessons, exams: exams);
|
||||
|
||||
var item = Assert.Single(vm.TodayItems);
|
||||
Assert.Equal("Redoxreaktionen", item.LessonTopic);
|
||||
Assert.Equal("Klausur Nr. 2", item.ExamTitle);
|
||||
Assert.True(item.HasExam);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OpenGroup_RuftOnNavigateToGroupMitDerGroupIdAuf()
|
||||
{
|
||||
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 = 1 });
|
||||
var vm = BuildViewModel(slots, new FakeGroups([group]));
|
||||
Guid? navigatedTo = null;
|
||||
vm.OnNavigateToGroup = id => navigatedTo = id;
|
||||
|
||||
vm.OpenGroupCommand.Execute(vm.TodayItems[0].GroupId);
|
||||
|
||||
Assert.Equal(group.Id, navigatedTo);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ShowEditor_SchaltetAufBearbeiten_Tab()
|
||||
{
|
||||
var vm = BuildViewModel(new FakeTimetableSlots(), new FakeGroups([]));
|
||||
|
||||
vm.ShowEditorCommand.Execute(null);
|
||||
|
||||
Assert.Equal(1, vm.ActiveTabIndex);
|
||||
}
|
||||
|
||||
// ── "Heute"-Ansicht: Wochenraster (Nutzer-Feedback, zweite Iteration) ────────────────────
|
||||
|
||||
[Fact]
|
||||
public void Load_Wochenkachel_ZeigtFachKlasseRaumUndThema()
|
||||
{
|
||||
var subject = new Subject { Name = "Chemie", ShortName = "Ch" };
|
||||
var group = new LearningGroup { Name = "Q1 Chemie", SubjectId = subject.Id };
|
||||
var date = DateInCurrentWeek(DayOfWeek.Monday);
|
||||
var slots = new FakeTimetableSlots();
|
||||
slots.Add(new TimetableSlot { GroupId = group.Id, Weekday = DayOfWeek.Monday, PeriodNumber = 1, Room = "R204" });
|
||||
var lessons = new FakeLessons();
|
||||
lessons.Add(new Lesson { GroupId = group.Id, Date = date, Topic = "Redoxreaktionen" });
|
||||
var vm = BuildViewModel(slots, new FakeGroups([group]), subjects: new FakeSubjects([subject]), lessons: lessons);
|
||||
|
||||
var cell = vm.WeekItems.Single(c => c.IsSlotCell && c.Weekday == DayOfWeek.Monday && c.PeriodNumber == 1);
|
||||
Assert.Equal("Ch", cell.SubjectLabel);
|
||||
Assert.Equal("Q1 Chemie", cell.GroupName);
|
||||
Assert.Equal("R204", cell.Room);
|
||||
Assert.Equal("Redoxreaktionen", cell.Topic);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Load_Wochenkachel_UnbelegteZelle_IstNichtZugewiesen()
|
||||
{
|
||||
var vm = BuildViewModel(new FakeTimetableSlots(), new FakeGroups([]));
|
||||
|
||||
var cell = vm.WeekItems.Single(c => c.IsSlotCell && c.Weekday == DayOfWeek.Monday && c.PeriodNumber == 1);
|
||||
Assert.False(cell.IsAssigned);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Load_Wochenkachel_KlausurAmTag_ZeigtKlausurIcon()
|
||||
{
|
||||
var group = new LearningGroup { Name = "Q1 Chemie" };
|
||||
var date = DateInCurrentWeek(DayOfWeek.Monday);
|
||||
var slots = new FakeTimetableSlots();
|
||||
slots.Add(new TimetableSlot { GroupId = group.Id, Weekday = DayOfWeek.Monday, PeriodNumber = 1 });
|
||||
var exams = new FakeExams([new Exam { GroupId = group.Id, Date = date, Title = "Klausur" }]);
|
||||
var vm = BuildViewModel(slots, new FakeGroups([group]), exams: exams);
|
||||
|
||||
var cell = vm.WeekItems.Single(c => c.IsSlotCell && c.Weekday == DayOfWeek.Monday && c.PeriodNumber == 1);
|
||||
Assert.True(cell.HasExam);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Load_Wochenkachel_TagInSchulferien_WirdAusgegraut()
|
||||
{
|
||||
var group = new LearningGroup { Name = "Q1 Chemie" };
|
||||
var date = DateInCurrentWeek(DayOfWeek.Monday);
|
||||
var slots = new FakeTimetableSlots();
|
||||
slots.Add(new TimetableSlot { GroupId = group.Id, Weekday = DayOfWeek.Monday, PeriodNumber = 1 });
|
||||
var holidays = new FakeSchoolHolidays();
|
||||
holidays.Add(new SchoolHoliday { Name = "Ferien", StartDate = date, EndDate = date.AddDays(5) });
|
||||
var vm = BuildViewModel(slots, new FakeGroups([group]), holidays);
|
||||
|
||||
var cell = vm.WeekItems.Single(c => c.IsSlotCell && c.Weekday == DayOfWeek.Monday && c.PeriodNumber == 1);
|
||||
Assert.True(cell.IsHoliday);
|
||||
Assert.Equal("#BDBDBD", cell.ColorHex);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Load_Wochenkachel_TagAusserhalbVonFerien_WirdNichtAusgegraut()
|
||||
{
|
||||
var group = new LearningGroup { Name = "Q1 Chemie" };
|
||||
var slots = new FakeTimetableSlots();
|
||||
slots.Add(new TimetableSlot { GroupId = group.Id, Weekday = DayOfWeek.Monday, PeriodNumber = 1 });
|
||||
var vm = BuildViewModel(slots, new FakeGroups([group]));
|
||||
|
||||
var cell = vm.WeekItems.Single(c => c.IsSlotCell && c.Weekday == DayOfWeek.Monday && c.PeriodNumber == 1);
|
||||
Assert.False(cell.IsHoliday);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Load_Wochenkachel_ExperimentInPhaseGeplant_ZeigtExperimentIcon()
|
||||
{
|
||||
var group = new LearningGroup { Name = "Q1 Chemie" };
|
||||
var date = DateInCurrentWeek(DayOfWeek.Monday);
|
||||
var slots = new FakeTimetableSlots();
|
||||
slots.Add(new TimetableSlot { GroupId = group.Id, Weekday = DayOfWeek.Monday, PeriodNumber = 1 });
|
||||
var lessons = new FakeLessons();
|
||||
lessons.Add(new Lesson { GroupId = group.Id, Date = date, Phases = [new LessonPhaseStep { Activity = "Experiment: Redoxreihe" }] });
|
||||
var vm = BuildViewModel(slots, new FakeGroups([group]), lessons: lessons);
|
||||
|
||||
var cell = vm.WeekItems.Single(c => c.IsSlotCell && c.Weekday == DayOfWeek.Monday && c.PeriodNumber == 1);
|
||||
Assert.True(cell.HasExperiment);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Load_Wochenkachel_OhneExperimentErwaehnung_KeinExperimentIcon()
|
||||
{
|
||||
var group = new LearningGroup { Name = "Q1 Chemie" };
|
||||
var date = DateInCurrentWeek(DayOfWeek.Monday);
|
||||
var slots = new FakeTimetableSlots();
|
||||
slots.Add(new TimetableSlot { GroupId = group.Id, Weekday = DayOfWeek.Monday, PeriodNumber = 1 });
|
||||
var lessons = new FakeLessons();
|
||||
lessons.Add(new Lesson { GroupId = group.Id, Date = date, Phases = [new LessonPhaseStep { Activity = "Stillarbeit" }] });
|
||||
var vm = BuildViewModel(slots, new FakeGroups([group]), lessons: lessons);
|
||||
|
||||
var cell = vm.WeekItems.Single(c => c.IsSlotCell && c.Weekday == DayOfWeek.Monday && c.PeriodNumber == 1);
|
||||
Assert.False(cell.HasExperiment);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Load_Wochenkachel_LetzteStundeVorKlausur_ZeigtIcon()
|
||||
{
|
||||
var date = DateInCurrentWeek(DayOfWeek.Monday);
|
||||
var group = new LearningGroup { Name = "Q1 Chemie" };
|
||||
var slots = new FakeTimetableSlots();
|
||||
slots.Add(new TimetableSlot { GroupId = group.Id, Weekday = DayOfWeek.Monday, PeriodNumber = 1 });
|
||||
var exams = new FakeExams([new Exam { GroupId = group.Id, Date = date.AddDays(1), Title = "Klausur" }]);
|
||||
var vm = BuildViewModel(slots, new FakeGroups([group]), exams: exams);
|
||||
|
||||
var cell = vm.WeekItems.Single(c => c.IsSlotCell && c.Weekday == DayOfWeek.Monday && c.PeriodNumber == 1);
|
||||
Assert.True(cell.IsLastBeforeExam);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Load_Wochenkachel_KeineAnstehendeKlausur_KeinIcon()
|
||||
{
|
||||
var group = new LearningGroup { Name = "Q1 Chemie" };
|
||||
var slots = new FakeTimetableSlots();
|
||||
slots.Add(new TimetableSlot { GroupId = group.Id, Weekday = DayOfWeek.Monday, PeriodNumber = 1 });
|
||||
var vm = BuildViewModel(slots, new FakeGroups([group]));
|
||||
|
||||
var cell = vm.WeekItems.Single(c => c.IsSlotCell && c.Weekday == DayOfWeek.Monday && c.PeriodNumber == 1);
|
||||
Assert.False(cell.IsLastBeforeExam);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PreviousWeek_ZeigtVorherigeKalenderwoche()
|
||||
{
|
||||
var vm = BuildViewModel(new FakeTimetableSlots(), new FakeGroups([]));
|
||||
var initialLabel = vm.WeekRangeLabel;
|
||||
|
||||
vm.PreviousWeekCommand.Execute(null);
|
||||
|
||||
Assert.NotEqual(initialLabel, vm.WeekRangeLabel);
|
||||
Assert.False(vm.IsCurrentWeek);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NextWeek_GefolgtVonCurrentWeek_KehrtZurLaufendenWocheZurueck()
|
||||
{
|
||||
var vm = BuildViewModel(new FakeTimetableSlots(), new FakeGroups([]));
|
||||
var initialLabel = vm.WeekRangeLabel;
|
||||
|
||||
vm.NextWeekCommand.Execute(null);
|
||||
Assert.NotEqual(initialLabel, vm.WeekRangeLabel);
|
||||
|
||||
vm.CurrentWeekCommand.Execute(null);
|
||||
Assert.Equal(initialLabel, vm.WeekRangeLabel);
|
||||
Assert.True(vm.IsCurrentWeek);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NextWeek_BadgeGehoertZurAngezeigtenWoche_NichtZuHeute()
|
||||
{
|
||||
// Klausur liegt in der übernächsten Woche relativ zu "heute" — im Wochenraster einer
|
||||
// Woche dahinter (also der übernächsten Woche selbst) muss "letzte Stunde vor Klausur"
|
||||
// erscheinen, nicht bereits in der aktuellen Woche.
|
||||
var group = new LearningGroup { Name = "Q1 Chemie" };
|
||||
var currentWeekMonday = DateInCurrentWeek(DayOfWeek.Monday);
|
||||
var examWeekMonday = currentWeekMonday.AddDays(14);
|
||||
var slots = new FakeTimetableSlots();
|
||||
slots.Add(new TimetableSlot { GroupId = group.Id, Weekday = DayOfWeek.Monday, PeriodNumber = 1 });
|
||||
var exams = new FakeExams([new Exam { GroupId = group.Id, Date = examWeekMonday.AddDays(1), Title = "Klausur" }]);
|
||||
var vm = BuildViewModel(slots, new FakeGroups([group]), exams: exams);
|
||||
|
||||
var currentWeekCell = vm.WeekItems.Single(c => c.IsSlotCell && c.Weekday == DayOfWeek.Monday && c.PeriodNumber == 1);
|
||||
Assert.False(currentWeekCell.IsLastBeforeExam);
|
||||
|
||||
vm.NextWeekCommand.Execute(null);
|
||||
vm.NextWeekCommand.Execute(null);
|
||||
var examWeekCell = vm.WeekItems.Single(c => c.IsSlotCell && c.Weekday == DayOfWeek.Monday && c.PeriodNumber == 1);
|
||||
Assert.True(examWeekCell.IsLastBeforeExam);
|
||||
}
|
||||
|
||||
// ── Badge: letzte/vorletzte Stunde vor Ferien (Bearbeiten-Raster) ────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void Load_LetzteStundeVorFerien_ZeigtBadge1()
|
||||
{
|
||||
var weekday = NextGridWeekday(DateOnly.FromDateTime(DateTime.Today));
|
||||
var group = new LearningGroup { Name = "Q1 Chemie" };
|
||||
var slots = new FakeTimetableSlots();
|
||||
slots.Add(new TimetableSlot { GroupId = group.Id, Weekday = weekday.DayOfWeek, PeriodNumber = 1 });
|
||||
var holidays = new FakeSchoolHolidays();
|
||||
holidays.Add(new SchoolHoliday { Name = "Ferien", StartDate = weekday.AddDays(1), EndDate = weekday.AddDays(10) });
|
||||
var vm = BuildViewModel(slots, new FakeGroups([group]), holidays);
|
||||
|
||||
var cell = vm.Cells.Single(c => c.IsSlotCell && c.Weekday == weekday.DayOfWeek && c.PeriodNumber == 1);
|
||||
Assert.Equal("1", cell.BadgeText);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Load_Wochenkachel_LetzteStundeVorFerien_ZeigtBadge1()
|
||||
{
|
||||
var date = DateInCurrentWeek(DayOfWeek.Monday);
|
||||
var group = new LearningGroup { Name = "Q1 Chemie" };
|
||||
var slots = new FakeTimetableSlots();
|
||||
slots.Add(new TimetableSlot { GroupId = group.Id, Weekday = DayOfWeek.Monday, PeriodNumber = 1 });
|
||||
var holidays = new FakeSchoolHolidays();
|
||||
holidays.Add(new SchoolHoliday { Name = "Ferien", StartDate = date.AddDays(1), EndDate = date.AddDays(10) });
|
||||
var vm = BuildViewModel(slots, new FakeGroups([group]), holidays);
|
||||
|
||||
var weekCell = vm.WeekItems.Single(c => c.IsSlotCell && c.Weekday == DayOfWeek.Monday && c.PeriodNumber == 1);
|
||||
Assert.Equal("1", weekCell.HolidayBadge);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Load_VorletzteStundeVorFerien_ZeigtBadge2()
|
||||
{
|
||||
var firstOccurrence = NextGridWeekday(DateOnly.FromDateTime(DateTime.Today));
|
||||
var secondOccurrence = firstOccurrence.AddDays(7);
|
||||
var group = new LearningGroup { Name = "Q1 Chemie" };
|
||||
var slots = new FakeTimetableSlots();
|
||||
slots.Add(new TimetableSlot { GroupId = group.Id, Weekday = firstOccurrence.DayOfWeek, PeriodNumber = 1 });
|
||||
var holidays = new FakeSchoolHolidays();
|
||||
holidays.Add(new SchoolHoliday { Name = "Ferien", StartDate = secondOccurrence.AddDays(1), EndDate = secondOccurrence.AddDays(10) });
|
||||
var vm = BuildViewModel(slots, new FakeGroups([group]), holidays);
|
||||
|
||||
var cell = vm.Cells.Single(c => c.IsSlotCell && c.Weekday == firstOccurrence.DayOfWeek && c.PeriodNumber == 1);
|
||||
Assert.Equal("2", cell.BadgeText);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Load_Doppelstunde_BeideStundenZeigenDasselbeBadge()
|
||||
{
|
||||
var weekday = NextGridWeekday(DateOnly.FromDateTime(DateTime.Today));
|
||||
var group = new LearningGroup { Name = "Q1 Chemie" };
|
||||
var slots = new FakeTimetableSlots();
|
||||
slots.Add(new TimetableSlot { GroupId = group.Id, Weekday = weekday.DayOfWeek, PeriodNumber = 1 });
|
||||
slots.Add(new TimetableSlot { GroupId = group.Id, Weekday = weekday.DayOfWeek, PeriodNumber = 2 });
|
||||
var holidays = new FakeSchoolHolidays();
|
||||
holidays.Add(new SchoolHoliday { Name = "Ferien", StartDate = weekday.AddDays(1), EndDate = weekday.AddDays(10) });
|
||||
var vm = BuildViewModel(slots, new FakeGroups([group]), holidays);
|
||||
|
||||
var period1 = vm.Cells.Single(c => c.IsSlotCell && c.Weekday == weekday.DayOfWeek && c.PeriodNumber == 1);
|
||||
var period2 = vm.Cells.Single(c => c.IsSlotCell && c.Weekday == weekday.DayOfWeek && c.PeriodNumber == 2);
|
||||
Assert.Equal("1", period1.BadgeText);
|
||||
Assert.Equal("1", period2.BadgeText);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Load_KeineAnstehendenFerien_KeinBadge()
|
||||
{
|
||||
var weekday = NextGridWeekday(DateOnly.FromDateTime(DateTime.Today));
|
||||
var group = new LearningGroup { Name = "Q1 Chemie" };
|
||||
var slots = new FakeTimetableSlots();
|
||||
slots.Add(new TimetableSlot { GroupId = group.Id, Weekday = weekday.DayOfWeek, PeriodNumber = 1 });
|
||||
var vm = BuildViewModel(slots, new FakeGroups([group]));
|
||||
|
||||
var cell = vm.Cells.Single(c => c.IsSlotCell && c.Weekday == weekday.DayOfWeek && c.PeriodNumber == 1);
|
||||
Assert.False(cell.HasBadge);
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ using LehrerApp.Data;
|
||||
using LehrerApp.Desktop.Services;
|
||||
using LehrerApp.Desktop.ViewModels;
|
||||
using LehrerApp.Desktop.ViewModels.Groups;
|
||||
using LehrerApp.Desktop.ViewModels.Planning;
|
||||
using LehrerApp.Desktop.ViewModels.Students;
|
||||
using LehrerApp.Desktop.Views;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
@@ -103,6 +104,10 @@ public class App : Application
|
||||
var sl = Services.GetRequiredService<StudentListViewModel>();
|
||||
sl.OnNavigateToDetail = id => main.NavigateToStudent(id);
|
||||
sl.OnAddStudent = () => ShowAddStudentDialog();
|
||||
|
||||
// Stundenplan "Heute" → GroupDetail (Tab "Planung")
|
||||
var timetable = Services.GetRequiredService<TimetableViewModel>();
|
||||
timetable.OnNavigateToGroup = id => main.NavigateToGroupDetail(id, 5);
|
||||
}
|
||||
|
||||
private static async Task ShowAddStudentDialog()
|
||||
|
||||
@@ -5,6 +5,7 @@ using LehrerApp.Data.Repositories;
|
||||
using LehrerApp.Desktop.Services;
|
||||
using LehrerApp.Desktop.ViewModels;
|
||||
using LehrerApp.Desktop.ViewModels.Groups;
|
||||
using LehrerApp.Desktop.ViewModels.Planning;
|
||||
using LehrerApp.Desktop.ViewModels.Settings;
|
||||
using LehrerApp.Desktop.ViewModels.Students;
|
||||
using LehrerApp.Sync;
|
||||
@@ -130,10 +131,14 @@ public static class AppBootstrapper
|
||||
services.AddSingleton<IShorthandCodeRepository, ShorthandCodeRepository>();
|
||||
services.AddSingleton<IAlternativeLessonPathRepository, AlternativeLessonPathRepository>();
|
||||
services.AddSingleton<IAttachmentStorage, LiteAttachmentStorage>();
|
||||
services.AddSingleton<ITimetableSlotRepository, TimetableSlotRepository>();
|
||||
services.AddSingleton<ISchoolHolidayRepository, SchoolHolidayRepository>();
|
||||
|
||||
// ── Services ──────────────────────────────────────────────────────────
|
||||
services.AddSingleton<GradingService>();
|
||||
services.AddSingleton<SchoolYearService>();
|
||||
services.AddSingleton<PublicHolidayService>();
|
||||
services.AddSingleton(_ => new SchoolCalendarSettingsService(appData));
|
||||
|
||||
// ── Sync (optional – nur wenn Server konfiguriert) ────────────────────
|
||||
services.AddSingleton(_ => new EventQueue(queuePath));
|
||||
@@ -178,6 +183,7 @@ public static class AppBootstrapper
|
||||
new SyncStatusViewModel(sp.GetService<SyncEngine>()));
|
||||
services.AddSingleton<GroupListViewModel>();
|
||||
services.AddSingleton<StudentListViewModel>();
|
||||
services.AddSingleton<TimetableViewModel>();
|
||||
|
||||
// Transient: neue Instanz pro Navigation (für Detailseiten)
|
||||
services.AddTransient<GroupDetailViewModel>();
|
||||
|
||||
@@ -3,6 +3,7 @@ using CommunityToolkit.Mvvm.Input;
|
||||
using LehrerApp.Core.Services;
|
||||
using LehrerApp.Desktop.Services;
|
||||
using LehrerApp.Desktop.ViewModels.Groups;
|
||||
using LehrerApp.Desktop.ViewModels.Planning;
|
||||
using LehrerApp.Desktop.ViewModels.Settings;
|
||||
using LehrerApp.Desktop.ViewModels.Students;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
@@ -64,7 +65,7 @@ public partial class MainWindowViewModel : ObservableObject
|
||||
NavItem.Groups => _services.GetRequiredService<GroupListViewModel>(),
|
||||
NavItem.Students => _services.GetRequiredService<StudentListViewModel>(),
|
||||
NavItem.Exams => new PlaceholderViewModel { Title = "Klausuren", Icon = "📝" },
|
||||
NavItem.Planner => new PlaceholderViewModel { Title = "Unterrichtsplanung", Icon = "📅" },
|
||||
NavItem.Planner => GetTimetable(),
|
||||
NavItem.Workload => new PlaceholderViewModel { Title = "Arbeitszeit", Icon = "⏱" },
|
||||
NavItem.Settings => _services.GetRequiredService<SettingsViewModel>(),
|
||||
_ => CurrentPage,
|
||||
@@ -78,6 +79,15 @@ public partial class MainWindowViewModel : ObservableObject
|
||||
return dashboard;
|
||||
}
|
||||
|
||||
private TimetableViewModel GetTimetable()
|
||||
{
|
||||
var timetable = _services.GetRequiredService<TimetableViewModel>();
|
||||
timetable.WeekOffset = 0;
|
||||
timetable.Load();
|
||||
timetable.ActiveTabIndex = 0;
|
||||
return timetable;
|
||||
}
|
||||
|
||||
public void NavigateToGroupDetail(Guid groupId, int initialTab = 0)
|
||||
{
|
||||
ActiveNavItem = NavItem.Groups;
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using LehrerApp.Core.Interfaces;
|
||||
using LehrerApp.Core.Models;
|
||||
using LehrerApp.Core.Services;
|
||||
|
||||
namespace LehrerApp.Desktop.ViewModels.Planning;
|
||||
|
||||
/// <summary>Zuweisen/Bearbeiten/Entfernen eines Stundenplan-Termins (4.3.3).</summary>
|
||||
public partial class TimetableSlotDialogViewModel : ObservableObject
|
||||
{
|
||||
private readonly ITimetableSlotRepository _slots;
|
||||
private readonly Dictionary<string, Guid> _groupIdsByName;
|
||||
private readonly TimetableSlot? _editing;
|
||||
|
||||
public DayOfWeek Weekday { get; }
|
||||
public int PeriodNumber { get; }
|
||||
public string WeekdayLabel { get; }
|
||||
public string DialogTitle { get; }
|
||||
public bool IsEditing => _editing is not null;
|
||||
|
||||
[ObservableProperty] private string _selectedGroupName = "";
|
||||
[ObservableProperty] private string _room = "";
|
||||
[ObservableProperty] private string _groupError = "";
|
||||
|
||||
public string[] GroupOptions { get; }
|
||||
|
||||
/// null = unverändert/Abbruch, sonst das neue/aktualisierte Ergebnis.
|
||||
public TimetableSlot? Result { get; private set; }
|
||||
public bool Deleted { get; private set; }
|
||||
|
||||
public TimetableSlotDialogViewModel(ITimetableSlotRepository slots, IGroupRepository groups,
|
||||
SchoolYearService schoolYear, DayOfWeek weekday, int periodNumber, TimetableSlot? editing)
|
||||
{
|
||||
_slots = slots;
|
||||
_editing = editing;
|
||||
Weekday = weekday;
|
||||
PeriodNumber = periodNumber;
|
||||
WeekdayLabel = weekday switch
|
||||
{
|
||||
DayOfWeek.Monday => "Montag", DayOfWeek.Tuesday => "Dienstag",
|
||||
DayOfWeek.Wednesday => "Mittwoch", DayOfWeek.Thursday => "Donnerstag",
|
||||
DayOfWeek.Friday => "Freitag", _ => weekday.ToString(),
|
||||
};
|
||||
DialogTitle = $"{WeekdayLabel}, {periodNumber}. Stunde";
|
||||
|
||||
var availableGroups = groups.GetBySchoolYear(schoolYear.CurrentSchoolYear()).OrderBy(g => g.Name).ToList();
|
||||
_groupIdsByName = availableGroups.ToDictionary(g => g.Name, g => g.Id);
|
||||
GroupOptions = availableGroups.Select(g => g.Name).ToArray();
|
||||
|
||||
if (editing is not null)
|
||||
{
|
||||
SelectedGroupName = availableGroups.FirstOrDefault(g => g.Id == editing.GroupId)?.Name ?? "";
|
||||
Room = editing.Room ?? "";
|
||||
}
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void Save()
|
||||
{
|
||||
GroupError = "";
|
||||
if (string.IsNullOrWhiteSpace(SelectedGroupName) || !_groupIdsByName.TryGetValue(SelectedGroupName, out var groupId))
|
||||
{
|
||||
GroupError = "Bitte eine Gruppe auswählen.";
|
||||
return;
|
||||
}
|
||||
|
||||
var slot = _editing ?? new TimetableSlot { Weekday = Weekday, PeriodNumber = PeriodNumber };
|
||||
slot.GroupId = groupId;
|
||||
slot.Room = string.IsNullOrWhiteSpace(Room) ? null : Room.Trim();
|
||||
|
||||
try
|
||||
{
|
||||
_slots.Save(slot);
|
||||
Result = slot;
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
GroupError = ex.Message;
|
||||
}
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void Delete()
|
||||
{
|
||||
if (_editing is null) return;
|
||||
_slots.Delete(_editing.Id);
|
||||
Deleted = true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,456 @@
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using LehrerApp.Core.Interfaces;
|
||||
using LehrerApp.Core.Models;
|
||||
using LehrerApp.Core.Services;
|
||||
using System.Collections.ObjectModel;
|
||||
|
||||
namespace LehrerApp.Desktop.ViewModels.Planning;
|
||||
|
||||
// ── Bundesland: deutsche Anzeige (4.3.5) ─────────────────────────────────────
|
||||
// Wird sowohl vom Stundenplan (Anzeige) als auch von den Einstellungen (Pflege) verwendet.
|
||||
|
||||
public static class GermanStateDisplay
|
||||
{
|
||||
private static readonly (GermanState State, string Name)[] Entries =
|
||||
[
|
||||
(GermanState.BW, "Baden-Württemberg"),
|
||||
(GermanState.BY, "Bayern"),
|
||||
(GermanState.BE, "Berlin"),
|
||||
(GermanState.BB, "Brandenburg"),
|
||||
(GermanState.HB, "Bremen"),
|
||||
(GermanState.HH, "Hamburg"),
|
||||
(GermanState.HE, "Hessen"),
|
||||
(GermanState.MV, "Mecklenburg-Vorpommern"),
|
||||
(GermanState.NI, "Niedersachsen"),
|
||||
(GermanState.NW, "Nordrhein-Westfalen"),
|
||||
(GermanState.RP, "Rheinland-Pfalz"),
|
||||
(GermanState.SL, "Saarland"),
|
||||
(GermanState.SN, "Sachsen"),
|
||||
(GermanState.ST, "Sachsen-Anhalt"),
|
||||
(GermanState.SH, "Schleswig-Holstein"),
|
||||
(GermanState.TH, "Thüringen"),
|
||||
];
|
||||
|
||||
public static string[] Options { get; } = Entries.Select(e => e.Name).ToArray();
|
||||
public static string Label(GermanState s) => Entries.First(e => e.State == s).Name;
|
||||
public static GermanState FromLabel(string label) =>
|
||||
Entries.FirstOrDefault(e => e.Name == label).State;
|
||||
}
|
||||
|
||||
// ── Stundenplan: "Heute"-Übersicht (Standardansicht, Wochenraster + Tagesliste) + Bearbeiten (4.3) ──
|
||||
|
||||
public partial class TimetableViewModel : ObservableObject
|
||||
{
|
||||
private const int FirstPeriod = 1;
|
||||
private const int LastPeriod = 10;
|
||||
private static readonly DayOfWeek[] Weekdays =
|
||||
[DayOfWeek.Monday, DayOfWeek.Tuesday, DayOfWeek.Wednesday, DayOfWeek.Thursday, DayOfWeek.Friday];
|
||||
private static readonly string[] WeekdayColorPalette =
|
||||
["#7F77DD", "#1D9E75", "#D85A30", "#D4537E", "#378ADD", "#639922", "#EF9F27", "#4C86A8"];
|
||||
|
||||
private readonly ITimetableSlotRepository _slots;
|
||||
private readonly IGroupRepository _groups;
|
||||
private readonly ISubjectRepository _subjects;
|
||||
private readonly ILessonRepository _lessons;
|
||||
private readonly IExamRepository _exams;
|
||||
private readonly ISchoolHolidayRepository _schoolHolidays;
|
||||
private readonly SchoolCalendarSettingsService _calendarSettings;
|
||||
private readonly PublicHolidayService _publicHolidays;
|
||||
private readonly SchoolYearService _schoolYear;
|
||||
|
||||
public ObservableCollection<TimetableCellItem> Cells { get; } = [];
|
||||
public ObservableCollection<WeekCellItem> WeekItems { get; } = [];
|
||||
public ObservableCollection<HoursWarningItem> HoursWarnings { get; } = [];
|
||||
public ObservableCollection<TodayLessonItem> TodayItems { get; } = [];
|
||||
|
||||
[ObservableProperty] private int _activeTabIndex;
|
||||
[ObservableProperty] private string _todayLabel = "";
|
||||
[ObservableProperty] private string _weekRangeLabel = "";
|
||||
[ObservableProperty] private int _weekOffset;
|
||||
public bool IsCurrentWeek => WeekOffset == 0;
|
||||
|
||||
public Func<TimetableCellItem, Task>? OnEditSlot { get; set; }
|
||||
public Action<Guid>? OnNavigateToGroup { get; set; }
|
||||
|
||||
public TimetableViewModel(ITimetableSlotRepository slots, IGroupRepository groups,
|
||||
ISubjectRepository subjects, ILessonRepository lessons, IExamRepository exams,
|
||||
ISchoolHolidayRepository schoolHolidays, SchoolCalendarSettingsService calendarSettings,
|
||||
PublicHolidayService publicHolidays, SchoolYearService schoolYear)
|
||||
{
|
||||
_slots = slots; _groups = groups; _subjects = subjects; _lessons = lessons; _exams = exams;
|
||||
_schoolHolidays = schoolHolidays; _calendarSettings = calendarSettings;
|
||||
_publicHolidays = publicHolidays; _schoolYear = schoolYear;
|
||||
Load();
|
||||
}
|
||||
|
||||
partial void OnWeekOffsetChanged(int value) => OnPropertyChanged(nameof(IsCurrentWeek));
|
||||
|
||||
[RelayCommand]
|
||||
private void PreviousWeek() { WeekOffset--; Load(); }
|
||||
|
||||
[RelayCommand]
|
||||
private void NextWeek() { WeekOffset++; Load(); }
|
||||
|
||||
[RelayCommand]
|
||||
private void CurrentWeek() { WeekOffset = 0; Load(); }
|
||||
|
||||
public void Load()
|
||||
{
|
||||
var today = DateOnly.FromDateTime(DateTime.Today);
|
||||
TodayLabel = today.ToString("dddd, dd.MM.yyyy", System.Globalization.CultureInfo.GetCultureInfo("de-DE"));
|
||||
|
||||
var publicHolidayDates = new HashSet<DateOnly>();
|
||||
foreach (var year in new[] { today.Year - 1, today.Year, today.Year + 1 })
|
||||
foreach (var h in _publicHolidays.GetHolidays(year, _calendarSettings.State)) publicHolidayDates.Add(h.Date);
|
||||
|
||||
var holidayBadges = ComputeHolidayBadges(today, publicHolidayDates);
|
||||
var examProximity = ComputeExamProximity(today, publicHolidayDates);
|
||||
|
||||
BuildGrid(holidayBadges);
|
||||
BuildWeekOverview(today, publicHolidayDates);
|
||||
BuildToday(today);
|
||||
BuildHoursWarnings();
|
||||
}
|
||||
|
||||
// ── "Heute": Tagesliste ──────────────────────────────────────────────────
|
||||
|
||||
private void BuildToday(DateOnly today)
|
||||
{
|
||||
TodayItems.Clear();
|
||||
var slotsToday = _slots.GetAll().Where(s => s.Weekday == today.DayOfWeek).OrderBy(s => s.PeriodNumber).ToList();
|
||||
|
||||
foreach (var slot in slotsToday)
|
||||
{
|
||||
var group = _groups.GetById(slot.GroupId);
|
||||
if (group is null) continue;
|
||||
var lesson = _lessons.GetByGroupAndDate(slot.GroupId, today).FirstOrDefault();
|
||||
var exam = _exams.GetByGroup(slot.GroupId).FirstOrDefault(e => e.Date == today);
|
||||
TodayItems.Add(new TodayLessonItem(slot.GroupId, slot.PeriodNumber, group.Name,
|
||||
slot.Room ?? "", ColorFor(group.Name), lesson?.Topic, exam?.Title));
|
||||
}
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void OpenGroup(Guid groupId) => OnNavigateToGroup?.Invoke(groupId);
|
||||
|
||||
[RelayCommand]
|
||||
private void ShowEditor() => ActiveTabIndex = 1;
|
||||
|
||||
// ── "Heute": Wochenraster (Nutzer-Feedback) — wie das Bearbeiten-Raster, aber nur Anzeige ──
|
||||
|
||||
/// <summary>
|
||||
/// Zeigt Fach, Klasse, Raum und (falls für den Tag hinterlegt) das Thema der Stunde für die
|
||||
/// per <see cref="WeekOffset"/> gewählte Kalenderwoche (Mo–Fr, wie das Bearbeiten-Raster) —
|
||||
/// anders als die Tagesliste auch für Tage, die noch nicht "heute" sind, damit z.B. der
|
||||
/// parallele Kurs oder die nächste Stunde in der Woche auf einen Blick sichtbar sind. Die
|
||||
/// Badges (Ferien-Nähe, Klausur-Nähe) werden je Zelle am dort angezeigten Datum ausgerichtet,
|
||||
/// nicht am realen "heute" — sonst würde beim Blättern in andere Wochen ein Badge angezeigt,
|
||||
/// das eigentlich zu einer ganz anderen Woche gehört.
|
||||
/// </summary>
|
||||
private void BuildWeekOverview(DateOnly today, HashSet<DateOnly> publicHolidayDates)
|
||||
{
|
||||
WeekItems.Clear();
|
||||
var currentWeekMonday = today.AddDays(-((int)today.DayOfWeek + 6) % 7);
|
||||
var monday = currentWeekMonday.AddDays(WeekOffset * 7);
|
||||
var friday = monday.AddDays(4);
|
||||
WeekRangeLabel = $"{monday:dd.MM.} – {friday:dd.MM.yyyy}";
|
||||
|
||||
var allSlots = _slots.GetAll();
|
||||
var groups = _groups.GetAll(includeInactive: true).ToDictionary(g => g.Id);
|
||||
var schoolHolidays = _schoolHolidays.GetAll();
|
||||
var dateByWeekday = Weekdays.ToDictionary(w => w, w => monday.AddDays((int)w - (int)DayOfWeek.Monday));
|
||||
|
||||
WeekItems.Add(WeekCellItem.Corner());
|
||||
foreach (var weekday in Weekdays)
|
||||
WeekItems.Add(WeekCellItem.WeekdayHeader(weekday, dateByWeekday[weekday], dateByWeekday[weekday] == today));
|
||||
|
||||
for (var period = FirstPeriod; period <= LastPeriod; period++)
|
||||
{
|
||||
WeekItems.Add(WeekCellItem.PeriodLabel(period));
|
||||
foreach (var weekday in Weekdays)
|
||||
{
|
||||
var slot = allSlots.FirstOrDefault(s => s.Weekday == weekday && s.PeriodNumber == period);
|
||||
if (slot is null) { WeekItems.Add(WeekCellItem.Empty(weekday, period)); continue; }
|
||||
|
||||
var date = dateByWeekday[weekday];
|
||||
var group = groups.GetValueOrDefault(slot.GroupId);
|
||||
var subject = group?.SubjectId is { } subjectId ? _subjects.GetById(subjectId) : null;
|
||||
var lesson = _lessons.GetByGroupAndDate(slot.GroupId, date).FirstOrDefault();
|
||||
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,
|
||||
subject?.ShortName is { Length: > 0 } sn ? sn : subject?.Name ?? "",
|
||||
group?.Name ?? "?", slot.Room ?? "", lesson?.Topic ?? "",
|
||||
colorHex, holidayBadge, hasExam, isLastBeforeExam,
|
||||
MentionsExperiment(lesson), slot.GroupId, isHoliday));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Badge "1"/"2" für die Zelle mit Datum <paramref name="date"/> selbst, analog zu
|
||||
/// <see cref="ComputeHolidayBadges"/>, aber an diesem konkreten Datum statt an "heute"
|
||||
/// ausgerichtet — nötig, damit das Badge beim Blättern durch die Wochen zur richtigen Woche
|
||||
/// gehört.</summary>
|
||||
private string HolidayBadgeFor(DateOnly date, DayOfWeek weekday, List<SchoolHoliday> schoolHolidays,
|
||||
HashSet<DateOnly> publicHolidayDates)
|
||||
{
|
||||
var nextHoliday = schoolHolidays.Where(h => h.StartDate > date).MinBy(h => h.StartDate);
|
||||
if (nextHoliday is null) return "";
|
||||
var count = CountOccurrences(weekday, date, nextHoliday.StartDate, publicHolidayDates);
|
||||
return count is 1 or 2 ? count.ToString() : "";
|
||||
}
|
||||
|
||||
/// <summary>Analog zu <see cref="ComputeExamProximity"/>, aber am Zelldatum statt an "heute"
|
||||
/// ausgerichtet.</summary>
|
||||
private bool IsLastBeforeExamFor(DateOnly date, DayOfWeek weekday, Guid groupId, HashSet<DateOnly> publicHolidayDates)
|
||||
{
|
||||
var nextExam = _exams.GetByGroup(groupId).Where(e => e.Date >= date).MinBy(e => e.Date);
|
||||
if (nextExam is null) return false;
|
||||
return CountOccurrences(weekday, date, nextExam.Date, publicHolidayDates) == 1;
|
||||
}
|
||||
|
||||
private static bool MentionsExperiment(Lesson? lesson) =>
|
||||
lesson is not null && lesson.Phases.Any(p =>
|
||||
p.Name.Contains("Experiment", StringComparison.OrdinalIgnoreCase) ||
|
||||
p.Activity.Contains("Experiment", StringComparison.OrdinalIgnoreCase) ||
|
||||
p.Material.Contains("Experiment", StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
/// <summary>Fällt <paramref name="date"/> auf einen gesetzlichen Feiertag oder in Schulferien?</summary>
|
||||
private static bool IsFreeDay(DateOnly date, List<SchoolHoliday> schoolHolidays, HashSet<DateOnly> publicHolidayDates) =>
|
||||
publicHolidayDates.Contains(date) || schoolHolidays.Any(h => date >= h.StartDate && date <= h.EndDate);
|
||||
|
||||
// ── Bearbeiten-Raster ─────────────────────────────────────────────────────
|
||||
|
||||
private void BuildGrid(Dictionary<(DayOfWeek Weekday, Guid GroupId), string> badges)
|
||||
{
|
||||
Cells.Clear();
|
||||
var allSlots = _slots.GetAll();
|
||||
var groupNames = _groups.GetAll(includeInactive: true).ToDictionary(g => g.Id, g => g.Name);
|
||||
|
||||
Cells.Add(TimetableCellItem.Corner());
|
||||
foreach (var weekday in Weekdays)
|
||||
Cells.Add(TimetableCellItem.WeekdayHeader(weekday));
|
||||
|
||||
for (var period = FirstPeriod; period <= LastPeriod; period++)
|
||||
{
|
||||
Cells.Add(TimetableCellItem.PeriodLabel(period));
|
||||
foreach (var weekday in Weekdays)
|
||||
{
|
||||
var slot = allSlots.FirstOrDefault(s => s.Weekday == weekday && s.PeriodNumber == period);
|
||||
var groupName = slot is not null ? groupNames.GetValueOrDefault(slot.GroupId, "?") : "";
|
||||
var badge = slot is not null ? badges.GetValueOrDefault((weekday, slot.GroupId), "") : "";
|
||||
Cells.Add(TimetableCellItem.ForSlot(weekday, period, slot, groupName, ColorFor(groupName), badge));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static string ColorFor(string name)
|
||||
{
|
||||
if (name.Length == 0) return "#9E9E9E";
|
||||
var hash = 0;
|
||||
foreach (var c in name) hash = hash * 31 + c;
|
||||
return WeekdayColorPalette[Math.Abs(hash) % WeekdayColorPalette.Length];
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private async Task EditCell(TimetableCellItem? cell)
|
||||
{
|
||||
if (cell is null || !cell.IsSlotCell || OnEditSlot is null) return;
|
||||
await OnEditSlot(cell);
|
||||
Load();
|
||||
}
|
||||
|
||||
// ── Abgleich mit Wochenstunden (4.3.4) ────────────────────────────────────
|
||||
|
||||
private void BuildHoursWarnings()
|
||||
{
|
||||
HoursWarnings.Clear();
|
||||
var currentYear = _schoolYear.CurrentSchoolYear();
|
||||
var slotCounts = _slots.GetAll().GroupBy(s => s.GroupId).ToDictionary(g => g.Key, g => g.Count());
|
||||
|
||||
foreach (var group in _groups.GetBySchoolYear(currentYear).Where(g => g.HoursPerWeek.HasValue))
|
||||
{
|
||||
var assigned = slotCounts.GetValueOrDefault(group.Id, 0);
|
||||
if (assigned != group.HoursPerWeek!.Value)
|
||||
HoursWarnings.Add(new HoursWarningItem(group.Name, assigned, group.HoursPerWeek.Value));
|
||||
}
|
||||
}
|
||||
|
||||
// ── Badges: letzte/vorletzte Stunde vor Ferien, letzte Stunde vor Klausur ────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Zählt, wie oft <paramref name="weekday"/> zwischen (inkl.) <paramref name="from"/> und
|
||||
/// (exkl.) <paramref name="until"/> eintritt — gesetzliche Feiertage werden übersprungen, da
|
||||
/// an ihnen ohnehin kein Unterricht stattfindet.
|
||||
/// </summary>
|
||||
private static int CountOccurrences(DayOfWeek weekday, DateOnly from, DateOnly until, HashSet<DateOnly> publicHolidays)
|
||||
{
|
||||
var count = 0;
|
||||
for (var date = from; date < until; date = date.AddDays(1))
|
||||
if (date.DayOfWeek == weekday && !publicHolidays.Contains(date)) count++;
|
||||
return count;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Badge "1"/"2" für die letzte bzw. vorletzte Stunde eines Wochentags vor den nächsten
|
||||
/// anstehenden Schulferien. Pro (Wochentag, Gruppe) statt pro einzelnem <see cref="TimetableSlot"/>
|
||||
/// berechnet: eine Doppelstunde besteht aus zwei Slots mit demselben Wochentag/derselben
|
||||
/// Gruppe und bekommt dadurch automatisch dasselbe Badge, ohne gesonderte Blockerkennung.
|
||||
/// </summary>
|
||||
private Dictionary<(DayOfWeek Weekday, Guid GroupId), string> ComputeHolidayBadges(
|
||||
DateOnly today, HashSet<DateOnly> publicHolidayDates)
|
||||
{
|
||||
var result = new Dictionary<(DayOfWeek, Guid), string>();
|
||||
var nextHoliday = _schoolHolidays.GetAll().Where(h => h.StartDate > today).MinBy(h => h.StartDate);
|
||||
if (nextHoliday is null) return result;
|
||||
|
||||
foreach (var group in _slots.GetAll().GroupBy(s => (s.Weekday, s.GroupId)))
|
||||
{
|
||||
var count = CountOccurrences(group.Key.Weekday, today, nextHoliday.StartDate, publicHolidayDates);
|
||||
if (count is 1 or 2) result[group.Key] = count.ToString();
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Markiert die letzte Stunde eines Wochentags vor der nächsten anstehenden Klausur derselben
|
||||
/// Gruppe (sofern eine ansteht) — analog zum Ferien-Badge, aber je Gruppe an deren eigenem
|
||||
/// nächsten Klausurtermin statt an einem gemeinsamen Ferientermin ausgerichtet.
|
||||
/// </summary>
|
||||
private Dictionary<(DayOfWeek Weekday, Guid GroupId), bool> ComputeExamProximity(
|
||||
DateOnly today, HashSet<DateOnly> publicHolidayDates)
|
||||
{
|
||||
var result = new Dictionary<(DayOfWeek, Guid), bool>();
|
||||
foreach (var groupSlots in _slots.GetAll().GroupBy(s => s.GroupId))
|
||||
{
|
||||
var nextExam = _exams.GetByGroup(groupSlots.Key).Where(e => e.Date >= today).MinBy(e => e.Date);
|
||||
if (nextExam is null) continue;
|
||||
|
||||
foreach (var weekday in groupSlots.Select(s => s.Weekday).Distinct())
|
||||
{
|
||||
var count = CountOccurrences(weekday, today, nextExam.Date, publicHolidayDates);
|
||||
if (count == 1) result[(weekday, groupSlots.Key)] = true;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
public class TimetableCellItem
|
||||
{
|
||||
public bool IsHeader { get; private init; }
|
||||
public bool IsPeriodLabel { get; private init; }
|
||||
public string Text { get; private init; } = "";
|
||||
public DayOfWeek? Weekday { get; private init; }
|
||||
public int PeriodNumber { get; private init; }
|
||||
public TimetableSlot? Slot { get; private init; }
|
||||
public string GroupName { get; private init; } = "";
|
||||
public string ColorHex { get; private init; } = "#9E9E9E";
|
||||
public string BadgeText { get; private init; } = "";
|
||||
public bool HasBadge => BadgeText.Length > 0;
|
||||
public bool IsAssigned => Slot is not null;
|
||||
public bool IsSlotCell => !IsHeader && !IsPeriodLabel;
|
||||
|
||||
public static TimetableCellItem Corner() => new() { IsHeader = true, Text = "" };
|
||||
|
||||
public static TimetableCellItem WeekdayHeader(DayOfWeek day) => new()
|
||||
{
|
||||
IsHeader = true,
|
||||
Text = day.ToString() switch
|
||||
{
|
||||
"Monday" => "Mo", "Tuesday" => "Di", "Wednesday" => "Mi",
|
||||
"Thursday" => "Do", "Friday" => "Fr", _ => day.ToString(),
|
||||
},
|
||||
};
|
||||
|
||||
public static TimetableCellItem PeriodLabel(int period) => new() { IsPeriodLabel = true, Text = period.ToString() };
|
||||
|
||||
public static TimetableCellItem ForSlot(DayOfWeek day, int period, TimetableSlot? slot, string groupName,
|
||||
string colorHex, string badgeText) => new()
|
||||
{
|
||||
Weekday = day, PeriodNumber = period, Slot = slot, GroupName = groupName, ColorHex = colorHex,
|
||||
BadgeText = badgeText,
|
||||
Text = slot is null ? "" : groupName + (string.IsNullOrWhiteSpace(slot.Room) ? "" : $" · {slot.Room}"),
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>Zelle im schreibgeschützten Wochenraster der "Heute"-Ansicht.</summary>
|
||||
public class WeekCellItem
|
||||
{
|
||||
public bool IsHeader { get; private init; }
|
||||
public bool IsPeriodLabel { get; private init; }
|
||||
public bool IsSlotCell => !IsHeader && !IsPeriodLabel;
|
||||
public bool IsAssigned { get; private init; }
|
||||
public string Text { get; private init; } = "";
|
||||
public DayOfWeek? Weekday { get; private init; }
|
||||
public int PeriodNumber { get; private init; }
|
||||
public bool IsToday { get; private init; }
|
||||
public Guid GroupId { get; private init; }
|
||||
public string SubjectLabel { get; private init; } = "";
|
||||
public string GroupName { get; private init; } = "";
|
||||
public string Room { get; private init; } = "";
|
||||
public string Topic { get; private init; } = "";
|
||||
public string ColorHex { get; private init; } = "#9E9E9E";
|
||||
public string HolidayBadge { get; private init; } = "";
|
||||
public bool HasHolidayBadge => HolidayBadge.Length > 0;
|
||||
public bool HasExam { get; private init; }
|
||||
public bool IsLastBeforeExam { get; private init; }
|
||||
public bool HasExperiment { get; private init; }
|
||||
public bool IsHoliday { get; private init; }
|
||||
public bool HasRoom => !string.IsNullOrWhiteSpace(Room);
|
||||
public bool HasTopic => !string.IsNullOrWhiteSpace(Topic);
|
||||
|
||||
public static WeekCellItem Corner() => new() { IsHeader = true };
|
||||
|
||||
public static WeekCellItem WeekdayHeader(DayOfWeek day, DateOnly date, bool isToday) => new()
|
||||
{
|
||||
IsHeader = true,
|
||||
IsToday = isToday,
|
||||
Text = (day switch
|
||||
{
|
||||
DayOfWeek.Monday => "Mo", DayOfWeek.Tuesday => "Di", DayOfWeek.Wednesday => "Mi",
|
||||
DayOfWeek.Thursday => "Do", DayOfWeek.Friday => "Fr", _ => day.ToString(),
|
||||
}) + $" {date:dd.MM.}",
|
||||
};
|
||||
|
||||
public static WeekCellItem PeriodLabel(int period) => new() { IsPeriodLabel = true, Text = period.ToString() };
|
||||
|
||||
public static WeekCellItem Empty(DayOfWeek day, int period) => new() { Weekday = day, PeriodNumber = period };
|
||||
|
||||
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) => new()
|
||||
{
|
||||
Weekday = day, PeriodNumber = period, IsAssigned = true, IsToday = isToday,
|
||||
SubjectLabel = subjectLabel, GroupName = groupName, Room = room, Topic = topic,
|
||||
ColorHex = colorHex, HolidayBadge = holidayBadge, HasExam = hasExam,
|
||||
IsLastBeforeExam = isLastBeforeExam, HasExperiment = hasExperiment, GroupId = groupId,
|
||||
IsHoliday = isHoliday,
|
||||
};
|
||||
}
|
||||
|
||||
public class HoursWarningItem(string groupName, int assigned, int expected)
|
||||
{
|
||||
public string GroupName { get; } = groupName;
|
||||
public string Text { get; } = $"{groupName}: {assigned} von {expected} Wochenstunden eingetragen";
|
||||
}
|
||||
|
||||
public class TodayLessonItem(Guid groupId, int periodNumber, string groupName, string room,
|
||||
string colorHex, string? lessonTopic, string? examTitle)
|
||||
{
|
||||
public Guid GroupId { get; } = groupId;
|
||||
public int PeriodNumber { get; } = periodNumber;
|
||||
public string GroupName { get; } = groupName;
|
||||
public string Room { get; } = room;
|
||||
public string ColorHex { get; } = colorHex;
|
||||
public string? LessonTopic { get; } = lessonTopic;
|
||||
public string? ExamTitle { get; } = examTitle;
|
||||
public bool HasRoom => !string.IsNullOrWhiteSpace(Room);
|
||||
public bool HasLessonTopic => !string.IsNullOrWhiteSpace(LessonTopic);
|
||||
public bool HasExam => ExamTitle is not null;
|
||||
}
|
||||
@@ -4,7 +4,9 @@ using LehrerApp.Core.Interfaces;
|
||||
using LehrerApp.Core.Models;
|
||||
using LehrerApp.Core.Services;
|
||||
using LehrerApp.Data;
|
||||
using LehrerApp.Desktop.ViewModels.Planning;
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Globalization;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
@@ -113,14 +115,30 @@ public partial class SettingsViewModel : ObservableObject
|
||||
/// Vom Code-Behind gesetzt: zeigt einen Bestätigungsdialog vor dem endgültigen Löschen.
|
||||
public Func<ExpiredDocumentItem, Task<bool>>? OnConfirmHardDelete { get; set; }
|
||||
|
||||
// ── 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 = "";
|
||||
|
||||
public List<string> StateOptions { get; } = GermanStateDisplay.Options.ToList();
|
||||
public ObservableCollection<SchoolHolidayItem> SchoolHolidayEntries { get; } = [];
|
||||
|
||||
// ── Konstruktor ───────────────────────────────────────────────────────────
|
||||
|
||||
private readonly ISchoolHolidayRepository _schoolHolidays;
|
||||
private readonly SchoolCalendarSettingsService _calendarSettings;
|
||||
|
||||
public SettingsViewModel(ISubjectRepository subjects, ICompetencyDomainRepository domainRepo,
|
||||
IGradingKeyTemplateRepository gradingKeyTemplates, IGradingSchemeRepository gradingSchemes,
|
||||
GradingService grading, BackupService backups, DatabaseEncryptionService dbEncryption,
|
||||
AppLockService appLock, LiteDbContext dbContext, PrivacySettingsService privacy,
|
||||
IDocumentationRepository documentation, IStudentRepository students,
|
||||
IShorthandCodeRepository shorthandCodes)
|
||||
IShorthandCodeRepository shorthandCodes, ISchoolHolidayRepository schoolHolidays,
|
||||
SchoolCalendarSettingsService calendarSettings)
|
||||
{
|
||||
_subjects = subjects;
|
||||
_domainRepo = domainRepo;
|
||||
@@ -135,6 +153,8 @@ public partial class SettingsViewModel : ObservableObject
|
||||
_documentation = documentation;
|
||||
_students = students;
|
||||
_shorthandCodes = shorthandCodes;
|
||||
_schoolHolidays = schoolHolidays;
|
||||
_calendarSettings = calendarSettings;
|
||||
LoadSubjects();
|
||||
LoadShorthandCodes();
|
||||
LoadGradingKeyTemplates();
|
||||
@@ -145,6 +165,51 @@ public partial class SettingsViewModel : ObservableObject
|
||||
AppLockTimeoutMinutes = _appLock.TimeoutMinutes;
|
||||
RetentionYears = _privacy.RetentionYears;
|
||||
LoadExpiredDocuments();
|
||||
SelectedStateName = GermanStateDisplay.Label(_calendarSettings.State);
|
||||
LoadSchoolHolidays();
|
||||
}
|
||||
|
||||
// ── Ferien & Feiertage: Bundesland / Schulferien pflegen ─────────────────
|
||||
|
||||
partial void OnSelectedStateNameChanged(string value) =>
|
||||
_calendarSettings.SetState(GermanStateDisplay.FromLabel(value));
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
// ── Datenschutz: Löschfristen ─────────────────────────────────────────────
|
||||
@@ -770,6 +835,13 @@ public class ExpiredDocumentItem(Documentation d, string studentName)
|
||||
public string CreatedAtDisplay { get; } = d.CreatedAt.ToLocalTime().ToString("dd.MM.yyyy");
|
||||
}
|
||||
|
||||
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}";
|
||||
}
|
||||
|
||||
// ── JSON DTOs ─────────────────────────────────────────────────────────────────
|
||||
|
||||
internal class CatalogDto
|
||||
|
||||
@@ -9,6 +9,8 @@
|
||||
xmlns:vs="clr-namespace:LehrerApp.Desktop.Views.Students"
|
||||
xmlns:vset="clr-namespace:LehrerApp.Desktop.Views.Settings"
|
||||
xmlns:vmset="clr-namespace:LehrerApp.Desktop.ViewModels.Settings"
|
||||
xmlns:vp="clr-namespace:LehrerApp.Desktop.Views.Planning"
|
||||
xmlns:vmp="clr-namespace:LehrerApp.Desktop.ViewModels.Planning"
|
||||
xmlns:svc="clr-namespace:LehrerApp.Desktop.Services"
|
||||
x:Class="LehrerApp.Desktop.Views.MainWindow"
|
||||
x:DataType="vm:MainWindowViewModel"
|
||||
@@ -48,6 +50,9 @@
|
||||
<DataTemplate DataType="vmset:SettingsViewModel">
|
||||
<vset:SettingsView/>
|
||||
</DataTemplate>
|
||||
<DataTemplate DataType="vmp:TimetableViewModel">
|
||||
<vp:TimetableView/>
|
||||
</DataTemplate>
|
||||
<DataTemplate DataType="vm:PlaceholderViewModel">
|
||||
<views:PlaceholderView/>
|
||||
</DataTemplate>
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
<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.TimetableSlotDialog"
|
||||
x:DataType="vm:TimetableSlotDialogViewModel"
|
||||
Title="{Binding DialogTitle}"
|
||||
Width="360" SizeToContent="Height"
|
||||
CanResize="False" WindowStartupLocation="CenterOwner">
|
||||
|
||||
<StackPanel Margin="24" Spacing="12">
|
||||
<TextBlock Text="{Binding DialogTitle}" Classes="dialogtitle"/>
|
||||
|
||||
<StackPanel Spacing="4">
|
||||
<TextBlock Text="Lerngruppe *" FontSize="12" Opacity="0.7"/>
|
||||
<ComboBox ItemsSource="{Binding GroupOptions}" SelectedItem="{Binding SelectedGroupName}"
|
||||
HorizontalAlignment="Stretch"/>
|
||||
<TextBlock Text="{Binding GroupError}" Foreground="Red" FontSize="11"
|
||||
IsVisible="{Binding GroupError, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
|
||||
</StackPanel>
|
||||
|
||||
<StackPanel Spacing="4">
|
||||
<TextBlock Text="Raum (optional)" FontSize="12" Opacity="0.7"/>
|
||||
<TextBox Text="{Binding Room}"/>
|
||||
</StackPanel>
|
||||
|
||||
<Button Content="Eintrag entfernen" HorizontalAlignment="Stretch" Margin="0,10,0,0"
|
||||
IsVisible="{Binding IsEditing}" Click="OnDelete"/>
|
||||
|
||||
<Grid ColumnDefinitions="*,8,*" Margin="0,4,0,0">
|
||||
<Button Grid.Column="0" Content="Abbrechen" HorizontalAlignment="Stretch" Click="OnCancel"/>
|
||||
<Button Grid.Column="2" Content="Speichern" HorizontalAlignment="Stretch" Click="OnSave"/>
|
||||
</Grid>
|
||||
</StackPanel>
|
||||
</Window>
|
||||
@@ -0,0 +1,30 @@
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Interactivity;
|
||||
using LehrerApp.Desktop.ViewModels.Planning;
|
||||
|
||||
namespace LehrerApp.Desktop.Views.Planning;
|
||||
|
||||
public partial class TimetableSlotDialog : Window
|
||||
{
|
||||
public TimetableSlotDialog() => InitializeComponent();
|
||||
|
||||
private void OnSave(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
if (DataContext is TimetableSlotDialogViewModel vm)
|
||||
{
|
||||
vm.SaveCommand.Execute(null);
|
||||
if (vm.Result is not null) Close();
|
||||
}
|
||||
}
|
||||
|
||||
private void OnDelete(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
if (DataContext is TimetableSlotDialogViewModel vm)
|
||||
{
|
||||
vm.DeleteCommand.Execute(null);
|
||||
Close();
|
||||
}
|
||||
}
|
||||
|
||||
private void OnCancel(object? sender, RoutedEventArgs e) => Close();
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
<UserControl xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:vm="clr-namespace:LehrerApp.Desktop.ViewModels.Planning"
|
||||
xmlns:shared="clr-namespace:LehrerApp.Desktop.Views.Shared"
|
||||
x:Class="LehrerApp.Desktop.Views.Planning.TimetableView"
|
||||
x:DataType="vm:TimetableViewModel">
|
||||
|
||||
<UserControl.Styles>
|
||||
<Style Selector="Border.timetablecell">
|
||||
<Setter Property="Background" Value="{DynamicResource SystemControlBackgroundBaseLowBrush}"/>
|
||||
</Style>
|
||||
<Style Selector="Border.timetablecell.assigned">
|
||||
<Setter Property="Background" Value="Transparent"/>
|
||||
</Style>
|
||||
<Style Selector="Border.weekheader.today">
|
||||
<Setter Property="Background" Value="{DynamicResource SystemAccentColor}"/>
|
||||
</Style>
|
||||
</UserControl.Styles>
|
||||
|
||||
<Grid RowDefinitions="Auto,*">
|
||||
<shared:PageHeader Grid.Row="0" Margin="32,28,32,0" Title="Stundenplan"
|
||||
Subtitle="Wiederkehrendes wöchentliches Muster, keine konkreten Termine"/>
|
||||
|
||||
<TabbedPage Grid.Row="1" TabPlacement="Top" SelectedIndex="{Binding ActiveTabIndex}">
|
||||
|
||||
<!-- Tab: Heute (Standardansicht) -->
|
||||
<ContentPage Header="Heute">
|
||||
<DockPanel Margin="32,20,32,20">
|
||||
|
||||
<!-- Unten angedockt: heutige Stunden im Detail -->
|
||||
<Border DockPanel.Dock="Bottom" Margin="0,16,0,0"
|
||||
Background="{DynamicResource SystemControlBackgroundAltHighBrush}"
|
||||
CornerRadius="8" Padding="16" MaxHeight="240">
|
||||
<DockPanel>
|
||||
<TextBlock DockPanel.Dock="Top" Text="{Binding TodayLabel}" FontSize="15" FontWeight="SemiBold" Margin="0,0,0,8"/>
|
||||
<ScrollViewer>
|
||||
<ItemsControl ItemsSource="{Binding TodayItems}">
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:TodayLessonItem">
|
||||
<Border BorderBrush="{DynamicResource SystemControlForegroundBaseLowBrush}"
|
||||
BorderThickness="0,0,0,1" Padding="0,8">
|
||||
<Grid ColumnDefinitions="6,Auto,*,Auto">
|
||||
<Rectangle Grid.Column="0" Fill="{Binding ColorHex}" Width="5" HorizontalAlignment="Left"/>
|
||||
<TextBlock Grid.Column="1" Text="{Binding PeriodNumber}" FontSize="17" FontWeight="Bold"
|
||||
Width="30" HorizontalAlignment="Center" VerticalAlignment="Center" Margin="8,0"/>
|
||||
<StackPanel Grid.Column="2" Spacing="2">
|
||||
<TextBlock Text="{Binding GroupName}" FontSize="14" FontWeight="SemiBold"/>
|
||||
<TextBlock FontSize="11" Opacity="0.6" IsVisible="{Binding HasRoom}">
|
||||
<Run Text="Raum "/><Run Text="{Binding Room}"/>
|
||||
</TextBlock>
|
||||
<TextBlock Text="{Binding LessonTopic}" FontSize="11" Opacity="0.75"
|
||||
TextWrapping="Wrap" IsVisible="{Binding HasLessonTopic}"/>
|
||||
<TextBlock Text="{Binding ExamTitle}" FontSize="11" Foreground="#D85A30" FontWeight="SemiBold"
|
||||
IsVisible="{Binding HasExam}"/>
|
||||
</StackPanel>
|
||||
<Button Grid.Column="3" Content="Zur Lerngruppe" FontSize="11" Padding="9,4"
|
||||
VerticalAlignment="Center"
|
||||
Command="{Binding $parent[ItemsControl].((vm:TimetableViewModel)DataContext).OpenGroupCommand}"
|
||||
CommandParameter="{Binding GroupId}"/>
|
||||
</Grid>
|
||||
</Border>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
</ScrollViewer>
|
||||
</DockPanel>
|
||||
</Border>
|
||||
<TextBlock DockPanel.Dock="Bottom" Text="Heute sind keine Stunden im Stundenplan eingetragen." Classes="emptyhint"
|
||||
IsVisible="{Binding !TodayItems.Count}" Margin="0,8,0,0"/>
|
||||
|
||||
<!-- Rest: Wochenüberblick (nicht editierbar) -->
|
||||
<ScrollViewer>
|
||||
<StackPanel Spacing="14">
|
||||
|
||||
<Grid ColumnDefinitions="Auto,Auto,*,Auto,Auto">
|
||||
<Button Grid.Column="0" Content="‹" FontWeight="Bold" Padding="10,4"
|
||||
Command="{Binding PreviousWeekCommand}" ToolTip.Tip="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"/>
|
||||
<TextBlock Grid.Column="2" FontSize="16" FontWeight="SemiBold"
|
||||
VerticalAlignment="Center" Margin="12,0,0,0">
|
||||
<Run Text="Woche "/><Run Text="{Binding WeekRangeLabel}"/>
|
||||
</TextBlock>
|
||||
<Button Grid.Column="3" Content="Diese Woche" Margin="0,0,8,0"
|
||||
Command="{Binding CurrentWeekCommand}" IsVisible="{Binding !IsCurrentWeek}"/>
|
||||
<Button Grid.Column="4" Content="Stundenplan bearbeiten" Command="{Binding ShowEditorCommand}"/>
|
||||
</Grid>
|
||||
|
||||
<ItemsControl ItemsSource="{Binding WeekItems}">
|
||||
<ItemsControl.ItemsPanel>
|
||||
<ItemsPanelTemplate><UniformGrid Columns="6"/></ItemsPanelTemplate>
|
||||
</ItemsControl.ItemsPanel>
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:WeekCellItem">
|
||||
<Grid Margin="2" MinHeight="76">
|
||||
<Border Classes="weekheader" Classes.today="{Binding IsToday}" CornerRadius="4"
|
||||
IsVisible="{Binding IsHeader}">
|
||||
<TextBlock Text="{Binding Text}" FontWeight="SemiBold" FontSize="12" Opacity="0.75"
|
||||
HorizontalAlignment="Center" VerticalAlignment="Center" Margin="4"/>
|
||||
</Border>
|
||||
<TextBlock Text="{Binding Text}" IsVisible="{Binding IsPeriodLabel}"
|
||||
FontWeight="SemiBold" FontSize="13"
|
||||
HorizontalAlignment="Center" VerticalAlignment="Center"/>
|
||||
<Border Classes="timetablecell" Classes.assigned="{Binding IsAssigned}"
|
||||
CornerRadius="6" IsVisible="{Binding IsSlotCell}">
|
||||
<Button Background="{Binding ColorHex}" IsVisible="{Binding IsAssigned}"
|
||||
HorizontalAlignment="Stretch" VerticalAlignment="Stretch"
|
||||
HorizontalContentAlignment="Stretch" CornerRadius="6" Padding="6"
|
||||
Command="{Binding $parent[ItemsControl].((vm:TimetableViewModel)DataContext).OpenGroupCommand}"
|
||||
CommandParameter="{Binding GroupId}">
|
||||
<StackPanel Spacing="1">
|
||||
<TextBlock FontSize="11" FontWeight="Bold" Foreground="White" TextWrapping="Wrap">
|
||||
<Run Text="{Binding SubjectLabel}"/><Run Text=" · "/><Run Text="{Binding GroupName}"/>
|
||||
</TextBlock>
|
||||
<TextBlock Text="{Binding Room}" FontSize="10" Foreground="White" Opacity="0.9"
|
||||
IsVisible="{Binding HasRoom}"/>
|
||||
<TextBlock Text="{Binding Topic}" FontSize="10" Foreground="White" Opacity="0.8"
|
||||
TextWrapping="Wrap" IsVisible="{Binding HasTopic}"/>
|
||||
<TextBlock Text="Ferien" FontSize="10" Foreground="White" FontWeight="SemiBold"
|
||||
Opacity="0.9" IsVisible="{Binding IsHoliday}" Margin="0,2,0,0"/>
|
||||
<StackPanel Orientation="Horizontal" Spacing="3" Margin="0,2,0,0"
|
||||
IsVisible="{Binding !IsHoliday}">
|
||||
<Border Background="#B71C1C" CornerRadius="7" Padding="4,0" IsVisible="{Binding HasHolidayBadge}">
|
||||
<TextBlock Text="{Binding HolidayBadge}" FontSize="9" FontWeight="Bold" Foreground="White"/>
|
||||
</Border>
|
||||
<TextBlock Text="📝" FontSize="11" IsVisible="{Binding HasExam}" ToolTip.Tip="Klausur"/>
|
||||
<TextBlock Text="⏰" FontSize="11" IsVisible="{Binding IsLastBeforeExam}" ToolTip.Tip="Letzte Stunde vor der Klausur"/>
|
||||
<TextBlock Text="🧪" FontSize="11" IsVisible="{Binding HasExperiment}" ToolTip.Tip="Experiment geplant"/>
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
</Button>
|
||||
</Border>
|
||||
</Grid>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
|
||||
<Border Background="{DynamicResource SystemControlBackgroundAltHighBrush}"
|
||||
CornerRadius="8" Padding="16" IsVisible="{Binding HoursWarnings.Count}">
|
||||
<StackPanel Spacing="8">
|
||||
<TextBlock Text="ABWEICHENDE WOCHENSTUNDEN" FontSize="11" FontWeight="Bold" Opacity="0.5"/>
|
||||
<ItemsControl ItemsSource="{Binding HoursWarnings}">
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:HoursWarningItem">
|
||||
<TextBlock Text="{Binding Text}" FontSize="12" Foreground="#FB8C00"
|
||||
TextWrapping="Wrap" Margin="0,3"/>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
</DockPanel>
|
||||
</ContentPage>
|
||||
|
||||
<!-- Tab: Bearbeiten (Raster) -->
|
||||
<ContentPage Header="Bearbeiten">
|
||||
<ScrollViewer>
|
||||
<StackPanel Margin="32,20,32,28" Spacing="10">
|
||||
<ItemsControl ItemsSource="{Binding Cells}">
|
||||
<ItemsControl.ItemsPanel>
|
||||
<ItemsPanelTemplate><UniformGrid Columns="6"/></ItemsPanelTemplate>
|
||||
</ItemsControl.ItemsPanel>
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:TimetableCellItem">
|
||||
<Grid Margin="2" MinHeight="46">
|
||||
<TextBlock Text="{Binding Text}" IsVisible="{Binding IsHeader}"
|
||||
FontWeight="SemiBold" FontSize="12" Opacity="0.6"
|
||||
HorizontalAlignment="Center" VerticalAlignment="Center"/>
|
||||
<TextBlock Text="{Binding Text}" IsVisible="{Binding IsPeriodLabel}"
|
||||
FontWeight="SemiBold" FontSize="13"
|
||||
HorizontalAlignment="Center" VerticalAlignment="Center"/>
|
||||
<Border Classes="timetablecell" Classes.assigned="{Binding IsAssigned}"
|
||||
CornerRadius="6" IsVisible="{Binding IsSlotCell}">
|
||||
<Panel>
|
||||
<Button Background="{Binding ColorHex}"
|
||||
IsVisible="{Binding IsAssigned}"
|
||||
HorizontalAlignment="Stretch" VerticalAlignment="Stretch"
|
||||
HorizontalContentAlignment="Center" CornerRadius="6"
|
||||
Command="{Binding $parent[ItemsControl].((vm:TimetableViewModel)DataContext).EditCellCommand}"
|
||||
CommandParameter="{Binding}">
|
||||
<TextBlock Text="{Binding Text}" FontSize="11" Foreground="White"
|
||||
TextWrapping="Wrap" TextAlignment="Center"/>
|
||||
</Button>
|
||||
<Button Content="+" Opacity="0.35"
|
||||
IsVisible="{Binding !IsAssigned}"
|
||||
HorizontalAlignment="Stretch" VerticalAlignment="Stretch"
|
||||
HorizontalContentAlignment="Center" Background="Transparent"
|
||||
Command="{Binding $parent[ItemsControl].((vm:TimetableViewModel)DataContext).EditCellCommand}"
|
||||
CommandParameter="{Binding}"/>
|
||||
<Border Background="#D85A30" CornerRadius="8" Padding="5,1"
|
||||
IsVisible="{Binding HasBadge}"
|
||||
HorizontalAlignment="Right" VerticalAlignment="Top" Margin="2">
|
||||
<TextBlock Text="{Binding BadgeText}" FontSize="10" FontWeight="Bold" Foreground="White"/>
|
||||
</Border>
|
||||
</Panel>
|
||||
</Border>
|
||||
</Grid>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
</ContentPage>
|
||||
|
||||
</TabbedPage>
|
||||
</Grid>
|
||||
</UserControl>
|
||||
@@ -0,0 +1,33 @@
|
||||
using Avalonia.Controls;
|
||||
using LehrerApp.Core.Interfaces;
|
||||
using LehrerApp.Core.Services;
|
||||
using LehrerApp.Desktop.ViewModels.Planning;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace LehrerApp.Desktop.Views.Planning;
|
||||
|
||||
public partial class TimetableView : UserControl
|
||||
{
|
||||
public TimetableView() => InitializeComponent();
|
||||
|
||||
protected override void OnDataContextChanged(EventArgs e)
|
||||
{
|
||||
base.OnDataContextChanged(e);
|
||||
if (DataContext is TimetableViewModel vm) vm.OnEditSlot = ShowSlotDialog;
|
||||
}
|
||||
|
||||
private async Task ShowSlotDialog(TimetableCellItem cell)
|
||||
{
|
||||
var owner = TopLevel.GetTopLevel(this) as Window;
|
||||
if (owner is null || cell.Weekday is null) return;
|
||||
|
||||
var vm = new TimetableSlotDialogViewModel(
|
||||
App.Services.GetRequiredService<ITimetableSlotRepository>(),
|
||||
App.Services.GetRequiredService<IGroupRepository>(),
|
||||
App.Services.GetRequiredService<SchoolYearService>(),
|
||||
cell.Weekday.Value, cell.PeriodNumber, cell.Slot);
|
||||
|
||||
var dialog = new TimetableSlotDialog { DataContext = vm };
|
||||
await dialog.ShowDialog(owner);
|
||||
}
|
||||
}
|
||||
@@ -478,6 +478,64 @@
|
||||
</ScrollViewer>
|
||||
</ContentPage>
|
||||
|
||||
<!-- Tab: Ferien & Feiertage (4.3.5) -->
|
||||
<ContentPage Header="Ferien & Feiertage">
|
||||
<ScrollViewer>
|
||||
<StackPanel Margin="32,20,32,28" Spacing="14" MaxWidth="480">
|
||||
|
||||
<StackPanel Spacing="4">
|
||||
<TextBlock Text="Bundesland (für Feiertage)" FontSize="13" FontWeight="SemiBold"/>
|
||||
<ComboBox ItemsSource="{Binding StateOptions}" SelectedItem="{Binding SelectedStateName}"
|
||||
HorizontalAlignment="Stretch"/>
|
||||
</StackPanel>
|
||||
|
||||
<Separator Margin="0,4"/>
|
||||
|
||||
<TextBlock Text="Schulferien" FontSize="16" FontWeight="SemiBold"/>
|
||||
<TextBlock Text="Werden im Stundenplan als unterrichtsfreie Tage angezeigt."
|
||||
FontSize="12" Opacity="0.6" TextWrapping="Wrap"/>
|
||||
|
||||
<ItemsControl ItemsSource="{Binding SchoolHolidayEntries}">
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate DataType="vm:SchoolHolidayItem">
|
||||
<Border BorderBrush="{DynamicResource SystemControlForegroundBaseLowBrush}"
|
||||
BorderThickness="0,0,0,1" Padding="0,7">
|
||||
<Grid ColumnDefinitions="*,Auto">
|
||||
<StackPanel Grid.Column="0">
|
||||
<TextBlock Text="{Binding Name}" FontSize="13" FontWeight="SemiBold"/>
|
||||
<TextBlock Text="{Binding RangeDisplay}" FontSize="12" Opacity="0.6"/>
|
||||
</StackPanel>
|
||||
<Button Grid.Column="1" Content="×" FontSize="14" Padding="9,3"
|
||||
Command="{Binding $parent[ItemsControl].((vm:SettingsViewModel)DataContext).RemoveSchoolHolidayCommand}"
|
||||
CommandParameter="{Binding}"/>
|
||||
</Grid>
|
||||
</Border>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
<TextBlock Text="Noch keine Schulferien hinterlegt." Classes="emptyhint"
|
||||
IsVisible="{Binding !SchoolHolidayEntries.Count}"/>
|
||||
|
||||
<Separator Margin="0,4"/>
|
||||
|
||||
<StackPanel Spacing="4">
|
||||
<TextBox Text="{Binding NewHolidayName}" PlaceholderText="Name (z.B. Sommerferien)"/>
|
||||
<TextBlock Text="{Binding HolidayNameError}" Foreground="Red" FontSize="11"
|
||||
IsVisible="{Binding HolidayNameError, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
|
||||
<Grid ColumnDefinitions="*,8,*">
|
||||
<TextBox Grid.Column="0" Text="{Binding NewHolidayStartText}" PlaceholderText="Beginn TT.MM.JJJJ"/>
|
||||
<TextBox Grid.Column="2" Text="{Binding NewHolidayEndText}" PlaceholderText="Ende TT.MM.JJJJ"/>
|
||||
</Grid>
|
||||
<TextBlock Text="{Binding HolidayDateError}" Foreground="Red" FontSize="11"
|
||||
IsVisible="{Binding HolidayDateError, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
|
||||
<Button Content="+ Schulferien hinzufügen" Command="{Binding AddSchoolHolidayCommand}"
|
||||
HorizontalAlignment="Left"/>
|
||||
</StackPanel>
|
||||
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
</ContentPage>
|
||||
|
||||
</TabbedPage>
|
||||
</Grid>
|
||||
</UserControl>
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
using LehrerApp.Core.Models;
|
||||
using LehrerApp.Core.Services;
|
||||
using Xunit;
|
||||
|
||||
namespace LehrerApp.Tests;
|
||||
|
||||
public sealed class PublicHolidayServiceTests
|
||||
{
|
||||
private readonly PublicHolidayService _service = new();
|
||||
|
||||
[Theory]
|
||||
[InlineData(2023, "2023-04-09")]
|
||||
[InlineData(2024, "2024-03-31")]
|
||||
[InlineData(2025, "2025-04-20")]
|
||||
[InlineData(2026, "2026-04-05")]
|
||||
[InlineData(2027, "2027-03-28")]
|
||||
public void GetHolidays_Ostermontag_LiegtEinenTagNachDemBekanntenOstersonntag(int year, string easterSundayIso)
|
||||
{
|
||||
var easterSunday = DateOnly.Parse(easterSundayIso);
|
||||
var holidays = _service.GetHolidays(year, GermanState.NW);
|
||||
|
||||
Assert.Contains(holidays, h => h.Name == "Ostermontag" && h.Date == easterSunday.AddDays(1));
|
||||
Assert.Contains(holidays, h => h.Name == "Karfreitag" && h.Date == easterSunday.AddDays(-2));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetHolidays_AlleBundeslaender_EnthaltenDieBundesweitenFeiertage()
|
||||
{
|
||||
foreach (var state in Enum.GetValues<GermanState>())
|
||||
{
|
||||
var holidays = _service.GetHolidays(2026, state);
|
||||
Assert.Contains(holidays, h => h.Date == new DateOnly(2026, 1, 1)); // Neujahr
|
||||
Assert.Contains(holidays, h => h.Date == new DateOnly(2026, 5, 1)); // Tag der Arbeit
|
||||
Assert.Contains(holidays, h => h.Date == new DateOnly(2026, 10, 3)); // Tag der Deutschen Einheit
|
||||
Assert.Contains(holidays, h => h.Date == new DateOnly(2026, 12, 25));
|
||||
Assert.Contains(holidays, h => h.Date == new DateOnly(2026, 12, 26));
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetHolidays_Fronleichnam_NurInDenZustaendigenBundeslaendern()
|
||||
{
|
||||
var nrw = _service.GetHolidays(2026, GermanState.NW);
|
||||
var berlin = _service.GetHolidays(2026, GermanState.BE);
|
||||
|
||||
Assert.Contains(nrw, h => h.Name == "Fronleichnam");
|
||||
Assert.DoesNotContain(berlin, h => h.Name == "Fronleichnam");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetHolidays_Reformationstag_NurInDenZustaendigenBundeslaendern()
|
||||
{
|
||||
var sachsen = _service.GetHolidays(2026, GermanState.SN);
|
||||
var bayern = _service.GetHolidays(2026, GermanState.BY);
|
||||
|
||||
Assert.Contains(sachsen, h => h.Name == "Reformationstag");
|
||||
Assert.DoesNotContain(bayern, h => h.Name == "Reformationstag");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetHolidays_SindNachDatumSortiert()
|
||||
{
|
||||
var holidays = _service.GetHolidays(2026, GermanState.BY);
|
||||
|
||||
Assert.Equal(holidays.OrderBy(h => h.Date).Select(h => h.Date), holidays.Select(h => h.Date));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
using LehrerApp.Core.Models;
|
||||
using LehrerApp.Core.Services;
|
||||
using Xunit;
|
||||
|
||||
namespace LehrerApp.Tests;
|
||||
|
||||
public sealed class SchoolCalendarSettingsServiceTests
|
||||
{
|
||||
[Fact]
|
||||
public void NeueKonfiguration_HatNordrheinWestfalenAlsStandard()
|
||||
{
|
||||
using var temp = new TempAppData();
|
||||
var service = new SchoolCalendarSettingsService(temp.Path);
|
||||
|
||||
Assert.Equal(GermanState.NW, service.State);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SetState_WirdUeberNeueInstanzHinwegPersistiert()
|
||||
{
|
||||
using var temp = new TempAppData();
|
||||
new SchoolCalendarSettingsService(temp.Path).SetState(GermanState.BY);
|
||||
|
||||
var second = new SchoolCalendarSettingsService(temp.Path);
|
||||
|
||||
Assert.Equal(GermanState.BY, second.State);
|
||||
}
|
||||
|
||||
private sealed class TempAppData : IDisposable
|
||||
{
|
||||
public string Path { get; } = System.IO.Path.Combine(
|
||||
System.IO.Path.GetTempPath(), $"lehrerapp-schoolcal-tests-{Guid.NewGuid():N}");
|
||||
|
||||
public TempAppData() => Directory.CreateDirectory(Path);
|
||||
public void Dispose() { if (Directory.Exists(Path)) Directory.Delete(Path, recursive: true); }
|
||||
}
|
||||
}
|
||||
@@ -224,10 +224,10 @@ zusammenziehen, daraus die Halbjahresnote bilden":
|
||||
## 4. Unterrichtsplanung
|
||||
|
||||
Modelle `Unit` und `Lesson` existieren, `UnitRepository`/`LessonRepository` ebenfalls.
|
||||
Navigationspunkt "Unterrichtsplanung" ist weiterhin ein `PlaceholderViewModel`
|
||||
([MainWindowViewModel.cs](LehrerApp.Desktop/ViewModels/MainWindowViewModel.cs)) — er bleibt
|
||||
bewusst Platzhalter, bis die gruppenübergreifende Kalenderübersicht aus 4.4 steht. 4.1/4.2 leben
|
||||
stattdessen im gruppenspezifischen Tab "Planung" (`GroupDetailView`), siehe unten.
|
||||
Navigationspunkt "Unterrichtsplanung" zeigt seit 4.3 den Stundenplan (`TimetableView`,
|
||||
Standardtab "Heute" + Bearbeiten-Raster) statt eines `PlaceholderViewModel`. Eine
|
||||
gruppenübergreifende Wochenansicht fehlt noch, siehe 4.4. 4.1/4.2 leben unabhängig davon im
|
||||
gruppenspezifischen Tab "Planung" (`GroupDetailView`), siehe unten.
|
||||
|
||||
### 4.1 Unterrichtseinheiten (`Unit`)
|
||||
- [x] **4.1.1** Listenansicht der Einheiten je Gruppe mit Status und Zeitraum
|
||||
@@ -366,16 +366,146 @@ Redesign:
|
||||
Hauptweg — deutlich einfacher und für den schnellen Überblick ausreichend.
|
||||
|
||||
### 4.3 Stundenplan
|
||||
- [ ] **4.3.1** Neues Modell `TimetableSlot` (Gruppe, Wochentag, Stunde, Raum) + Repository.
|
||||
- [ ] **4.3.2** Wochenstundenplan-Ansicht als Raster mit Farbcodierung je Gruppe.
|
||||
- [ ] **4.3.3** Bearbeitung per Klick/Drag im Raster.
|
||||
- [ ] **4.3.4** Abgleich mit `LearningGroup.HoursPerWeek` (Warnung bei Abweichung).
|
||||
- [ ] **4.3.5** Schulferien und Feiertage hinterlegen (Bundesland wählbar) und aus der Planung ausnehmen.
|
||||
- [x] **4.3.1** Neues Modell `TimetableSlot` (Gruppe, Wochentag, Stunde, Raum) + Repository —
|
||||
[Planning.cs](LehrerApp.Core/Models/Planning.cs),
|
||||
[TimetableSlotRepository](LehrerApp.Data/Repositories/AllRepositories.cs). Wiederkehrendes
|
||||
Muster (kein Datum) — für konkret gehaltene Stunden bleibt `Lesson` (4.2) zuständig. Pro
|
||||
Wochentag/Stunde höchstens eine Gruppe (eindeutiger Index + freundliche Fehlermeldung im
|
||||
Repository selbst, nicht erst über eine rohe `LiteException`): ein Lehrer kann nicht
|
||||
gleichzeitig an zwei Orten unterrichten.
|
||||
- [x] **4.3.2** Wochenstundenplan-Ansicht als Raster mit Farbcodierung je Gruppe — neuer
|
||||
Navigationspunkt "Unterrichtsplanung" (bisher `PlaceholderViewModel`) zeigt jetzt
|
||||
[TimetableView.axaml](LehrerApp.Desktop/Views/Planning/TimetableView.axaml). Mo–Fr ×
|
||||
1.–10. Stunde, Farbe deterministisch aus dem Gruppennamen (gleiches Hash-in-Palette-Muster
|
||||
wie bei den alternativen Unterrichtsabläufen in 4.2.2).
|
||||
- [x] **4.3.3** Bearbeitung per Klick im Raster — Klick auf eine leere Zelle öffnet
|
||||
`TimetableSlotDialog` zum Zuweisen (Gruppenauswahl + optionaler Raum), Klick auf eine
|
||||
belegte Zelle öffnet denselben Dialog zum Ändern/Entfernen. **Drag bewusst nicht
|
||||
umgesetzt:** Klick deckt die vollständige Bearbeitung (Zuweisen/Ändern/Entfernen) bereits
|
||||
ab: Drag wäre nur eine schnellere Geste für "Zuordnung an eine andere Zelle verschieben",
|
||||
kein zusätzlicher Funktionsumfang — bei Bedarf später ergänzbar.
|
||||
- [x] **4.3.4** Abgleich mit `LearningGroup.HoursPerWeek` — Seitenleiste "Abweichende
|
||||
Wochenstunden" listet jede Gruppe der aktuellen Schuljahres, deren eingetragene
|
||||
Slot-Anzahl nicht der hinterlegten Wochenstundenzahl entspricht.
|
||||
- [x] **4.3.5** Schulferien und Feiertage — gesetzliche Feiertage werden je Bundesland berechnet
|
||||
([PublicHolidayService.cs](LehrerApp.Core/Services/PublicHolidayService.cs), Gauß'sche
|
||||
Osterformel + bundeslandspezifische Zusatzfeiertage, empirisch gegen bekannte
|
||||
Ostersonntage 2023–2027 verifiziert) statt gespeichert — anders als Schulferien sind sie
|
||||
algorithmisch herleitbar. Schulferien selbst sind **nicht** herleitbar (jährlich neu von
|
||||
den Bundesländern festgelegt) und werden deshalb manuell gepflegt
|
||||
(`SchoolHoliday`-Repository, CRUD in der Seitenleiste). Bundesland als persistente
|
||||
Einstellung ([SchoolCalendarSettingsService.cs](LehrerApp.Core/Services/SchoolCalendarSettingsService.cs)).
|
||||
"Aus der Planung ausnehmen" ist für dieses Kapitel als "nächste unterrichtsfreie Tage"
|
||||
sichtbar (kombinierte, sortierte Liste aus beiden Quellen) — die eigentliche Ausnahme aus
|
||||
generierten Stunden ist erst mit der (bewusst zurückgestellten) Serienerzeugung 4.2.5
|
||||
relevant und verwendet dieselben zwei Datenquellen.
|
||||
|
||||
**Nachtrag zu 4.3 (Nutzer-Feedback nach Erstumsetzung):**
|
||||
- **Ferien/Feiertage-Pflege verschoben:** Die Bundesland-Auswahl und das Schulferien-CRUD standen
|
||||
ursprünglich in der Seitenleiste des Stundenplans selbst — das wirkte dort deplatziert, da es
|
||||
eine einmalige Einstellung statt einer täglich genutzten Ansicht ist. Beides ist jetzt ein
|
||||
eigener Tab "Ferien & Feiertage" in den Einstellungen
|
||||
([SettingsViewModel.cs](LehrerApp.Desktop/ViewModels/Settings/SettingsViewModel.cs),
|
||||
[SettingsView.axaml](LehrerApp.Desktop/Views/Settings/SettingsView.axaml)). Der Stundenplan
|
||||
selbst zeigt Bundesland und nächste unterrichtsfreie Tage nur noch lesend an
|
||||
([TimetableViewModel.cs](LehrerApp.Desktop/ViewModels/Planning/TimetableViewModel.cs)).
|
||||
- **Badge "1"/"2" vor Ferien:** Die letzte bzw. vorletzte Stunde eines Wochentags vor den nächsten
|
||||
Schulferien wird im Bearbeiten-Raster mit einem kleinen Badge markiert — Erinnerung, rechtzeitig
|
||||
abzuschließen. Berechnet pro (Wochentag, Gruppe) statt pro einzelnem `TimetableSlot`: eine
|
||||
Doppelstunde besteht aus zwei Slots mit demselben Wochentag/derselben Gruppe und bekommt dadurch
|
||||
automatisch dasselbe Badge, ohne gesonderte Blockerkennung. Gesetzliche Feiertage werden beim
|
||||
Abzählen der verbleibenden Termine übersprungen. Bewusst nur an `SchoolHoliday` geknüpft, nicht
|
||||
an einzelne `PublicHoliday`-Tage — es geht um das Vorbereiten auf eine längere Unterbrechung,
|
||||
nicht um einen einzelnen freien Tag.
|
||||
- **"Heute" als neue Standardansicht:** Der Stundenplan öffnet jetzt auf einem
|
||||
nicht-editierenden Tab "Heute" (heutige Stunden mit Gruppe, Raum, Thema aus `Lesson.Topic` falls
|
||||
vorhanden, sowie ein Hinweis, wenn an dem Tag eine Klausur ansteht). Klick auf eine Stunde
|
||||
navigiert in die Lerngruppe (Tab "Planung"). Das bisherige Bearbeiten-Raster aus 4.3.2/4.3.3
|
||||
ist über einen Button ("Stundenplan bearbeiten") als zweiter Tab weiterhin erreichbar.
|
||||
|
||||
**Nachtrag zu 4.3, zweite Iteration (Wochenraster in der "Heute"-Ansicht):** Die Tagesliste allein
|
||||
beantwortete nicht "was steht in der Woche noch an" bzw. "was macht der parallele Kurs" — beides
|
||||
Fragen, die nur ein Blick über alle Gruppen hinweg beantwortet. Der Tab "Heute" zeigt deshalb jetzt
|
||||
zusätzlich ein schreibgeschütztes Wochenraster (Mo–Fr × 1.–10. Stunde, wie das Bearbeiten-Raster,
|
||||
aber ohne Klick-zum-Bearbeiten). Jede Kachel zeigt Fach (`Subject.ShortName`), Klasse, Raum und
|
||||
– falls für den Tag hinterlegt – das Thema der Stunde, dazu Symbole für: Ferien-Badge ("1"/"2",
|
||||
wie im Bearbeiten-Raster), Klausur an dem Tag (📝), letzte Stunde vor der nächsten Klausur dieser
|
||||
Gruppe (⏰) und ein geplantes Experiment (🧪). Die Tagesliste ist per `DockPanel` unten an die
|
||||
Seite angedockt, das Wochenraster füllt den verbleibenden Platz darüber
|
||||
([TimetableViewModel.cs](LehrerApp.Desktop/ViewModels/Planning/TimetableViewModel.cs),
|
||||
[TimetableView.axaml](LehrerApp.Desktop/Views/Planning/TimetableView.axaml)). Klick auf eine
|
||||
Kachel navigiert wie in der Tagesliste zur Lerngruppe.
|
||||
- **"Letzte Stunde vor Klausur"** wird wie das Ferien-Badge pro (Wochentag, Gruppe) berechnet,
|
||||
aber am nächsten `Exam.Date` der jeweiligen Gruppe statt an einem gemeinsamen Ferientermin
|
||||
ausgerichtet — beide Badges teilen sich denselben Zähl-Helfer (`CountOccurrences`).
|
||||
- **"Experiment geplant"** hat kein eigenes Datenfeld — es ist eine Texterkennung über die
|
||||
bereits im Verlaufsplan-Editor (4.2.2) gepflegten `LessonPhaseStep`-Felder (Name/Aktivität/
|
||||
Material enthält "Experiment", ohne Groß-/Kleinschreibung). Bewusst kein neues Modellfeld:
|
||||
die Information steckt in den meisten Fällen schon in der ohnehin gepflegten Phasenplanung.
|
||||
- Das Wochenraster zeigt bewusst nur die laufende Kalenderwoche ohne Vor-/Zurück-Navigation —
|
||||
für einen Blick auf zukünftige Wochen bleibt vorerst das Bearbeiten-Raster (zeigt das
|
||||
wiederkehrende Muster unabhängig vom Datum).
|
||||
|
||||
**Nachtrag zu 4.3, dritte Iteration (Ferientage im Wochenraster ausgegraut statt eigener Liste):**
|
||||
Die Box "Nächste unterrichtsfreie Tage" wirkte neben dem neuen Wochenraster redundant — welche
|
||||
Tage frei sind, sieht man dort jetzt direkt an den betroffenen Stunden. Die Box (samt
|
||||
`UpcomingFreeDayItem`, `UpcomingFreeDays`, `BuildUpcomingFreeDays`, `BundeslandLabel`) wurde
|
||||
entfernt. Stattdessen werden Wochenkacheln, deren Datum in Schulferien fällt oder ein gesetzlicher
|
||||
Feiertag ist, grau eingefärbt (`WeekCellItem.IsHoliday`, Prüfung gegen `SchoolHoliday`-Zeitraum und
|
||||
die bereits für die Badges berechneten `PublicHoliday`-Daten) und zeigen statt der Klausur-/
|
||||
Experiment-Symbole nur noch die Aufschrift "Ferien" — die Symbole wären an einem unterrichtsfreien
|
||||
Tag ohnehin nicht sinnvoll interpretierbar.
|
||||
|
||||
**Nachtrag zu 4.3, vierte Iteration (Wochennavigation):** Schalter "‹"/"›" plus "Diese Woche"
|
||||
erlauben jetzt, im Wochenraster vor und zurück zu blättern (`TimetableViewModel.WeekOffset`,
|
||||
`PreviousWeekCommand`/`NextWeekCommand`/`CurrentWeekCommand`). Die Ferien-/Klausur-Nähe-Badges
|
||||
("1"/"2" vor Ferien, ⏰ vor Klausur) waren bis dahin an "heute" verankert und pro (Wochentag,
|
||||
Gruppe) einmalig berechnet — das wäre beim Blättern falsch geworden (ein Badge hätte in jeder
|
||||
angezeigten Woche geklebt, nicht nur in der einen Woche, zu der es gehört). Für das Wochenraster
|
||||
berechnen `HolidayBadgeFor`/`IsLastBeforeExamFor` die Badges deshalb jetzt je Zelle am dort
|
||||
gezeigten Datum statt an "heute" — das Bearbeiten-Raster (zeigt ohnehin nur das wiederkehrende
|
||||
Muster ohne Datum) behält die alte, "heute"-verankerte Berechnung. Beim erneuten Navigieren in den
|
||||
Stundenplan (Sidebar-Klick) springt die Ansicht wieder auf die laufende Woche zurück.
|
||||
|
||||
### 4.4 Wochen-/Tagesansicht
|
||||
- [ ] **4.4.1** Kalenderansicht über alle Gruppen: Woche und Tag.
|
||||
- [ ] **4.4.2** Sprung von einer Stunde direkt in die Mitarbeitserfassung dieser Gruppe.
|
||||
- [ ] **4.4.3** Anzeige anstehender Klausurtermine und Abgabefristen im Kalender.
|
||||
- [x] **4.4.1** Kalenderansicht über alle Gruppen: Woche und Tag — siehe Nachtrag zu 4.3
|
||||
("Heute"-Tab: Tagesliste unten angedockt, gruppenübergreifendes Wochenraster darüber, inkl.
|
||||
Vor-/Zurück-Navigation zwischen Kalenderwochen seit der vierten Iteration).
|
||||
**Nicht enthalten:** ein eigenständiger Kalendermonat-Überblick — bei Bedarf später
|
||||
ergänzbar.
|
||||
- [x] **4.4.2** Sprung von einer Stunde direkt in die Lerngruppe — siehe Nachtrag zu 4.3
|
||||
("Heute"-Tab, Klick navigiert zur Planung der Gruppe statt direkt in die
|
||||
Mitarbeitserfassung, da das für den Vorbereitungs-Kontext des Tages passender ist).
|
||||
- [ ] **4.4.3** Anzeige anstehender Klausurtermine und Abgabefristen im Kalender. *(Heutige
|
||||
Klausuren bereits im "Heute"-Tab sichtbar — mehrtägiger Vorausblick fehlt noch.)*
|
||||
|
||||
### 4.5 Vernetzung Stundenplan ↔ Unterrichtsplanung (zurückgestellt)
|
||||
|
||||
Nutzer-Feedback nach der 4.3-Iterationen: Stundenplan (4.3, wiederkehrendes Muster) und
|
||||
Unterrichtsplanung (4.1/4.2, `Unit`/`Lesson` je Gruppe) laufen bisher zu getrennt nebeneinander.
|
||||
Ausdrücklich als Idee für später festgehalten, **nicht** jetzt umsetzen:
|
||||
|
||||
- [ ] **4.5.1** Terminvorschlag beim Anlegen einer `Lesson`: Tag/Stunde anhand des Stundenplans
|
||||
vorschlagen, an dem die betroffene Gruppe laut `TimetableSlot` tatsächlich Unterricht hat,
|
||||
statt Datum/Stunde komplett frei einzutragen.
|
||||
- [ ] **4.5.2** Vom Stundenplan (Wochenraster oder Bearbeiten-Raster) 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.
|
||||
- [ ] **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.
|
||||
- [ ] **4.5.4** Badges am Stundenplan/Lesson-Viewer, die anzeigen, ob eine Stunde bereits
|
||||
"kontrolliert" ist — u.a. ob in der vorherigen Stunde Hausaufgaben erteilt wurden und ob
|
||||
diese noch nicht kontrolliert sind. Muss sich pro Fall abschalten lassen: manchmal wird eine
|
||||
Hausaufgabe bewusst nicht kontrolliert, ohne dass bis Schuljahresende ständig daran erinnert
|
||||
werden soll.
|
||||
- [ ] **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
|
||||
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.
|
||||
|
||||
---
|
||||
|
||||
@@ -892,8 +1022,12 @@ Die Abschnitte sind thematisch, nicht chronologisch nummeriert. Sinnvolle Bearbe
|
||||
erledigt (13.1–13.4 vollständig; 13.4.2 bewusst zurückgestellt, siehe dort).
|
||||
5. ~~**Kapitel 5** (Schülerdokumentation)~~ — erledigt (5.2 als Auswertung des bestehenden
|
||||
Anwesenheits-Trackings statt zweiter Erfassung, siehe dort).
|
||||
6. ~~**Kapitel 4.1 + 4.2** (Unterrichtsplanung: Einheiten & Einzelstunden)~~ — erledigt.
|
||||
**Kapitel 4.3** (Stundenplan, neues Modell `TimetableSlot` + Ferien/Feiertage) und **4.4**
|
||||
(Wochen-/Tageskalender über alle Gruppen) bewusst zurückgestellt — eigenständige neue
|
||||
Subsysteme, kein Ausbau der bestehenden Unit/Lesson-UI. **→ nächster sinnvoller Schritt.**
|
||||
6. ~~**Kapitel 4.1 + 4.2 + 4.3** (Unterrichtsplanung: Einheiten, Einzelstunden, Stundenplan)~~ —
|
||||
erledigt, inkl. mehrerer Nachtrag-Iterationen aus Nutzer-Feedback (Ferien-Pflege in den
|
||||
Einstellungen, Wochenraster mit Wochennavigation, Ferientage ausgegraut). ~~**Kapitel 4.4.1/
|
||||
4.4.2**~~ — erledigt (im Zuge der 4.3-Nachträge miterledigt). Offen bleiben **4.4.3**
|
||||
(Abgabefristen im Kalender), **4.2.5** (Serienerzeugung von Stunden aus dem Stundenplan) und
|
||||
**4.5** (engere Vernetzung Stundenplan ↔ Lesson-Planung — ausdrücklich vom Nutzer
|
||||
zurückgestellt, nicht aus Unklarheit).
|
||||
**→ nächster sinnvoller Schritt: 4.4.3 oder 4.2.5, je nach Bedarf.**
|
||||
7. **Kapitel 6** (Arbeitszeit), **11** (Export), **10** (Sync) — danach.
|
||||
|
||||
Reference in New Issue
Block a user