Sitzungen (3.3): aus Stunde erzeugen, löschen, Kalenderüberblick; Doppelstunden-Bugfix Serienerzeugung

Kapitel 3.3: neuer Button erzeugt eine Mitarbeitssitzung aus einer geplanten
Stunde (Datum/Thema, dupl.-sicher über Lesson.LessonId), Sitzungen lassen
sich jetzt mit Rückfrage bei vorhandenen Bewertungen löschen, und der
Dashboard-Kalender zeigt Sitzungen als dritten Termintyp neben Stunden/
Klausuren. Das Bearbeiten von Sitzungen war bereits vorhanden (undokumentiert
aus früherer Ad-hoc-Arbeit) und wird hier nur nachgezogen.

Bugfix: "Serie erzeugen" legte für Doppelstunden zwei Lessons mit gleichem
Datum an statt einer, da jede Stundenplan-Periode einzeln behandelt wurde.
Folgeperioden werden jetzt der Lesson der ersten Periode zugerechnet.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-19 22:17:46 +02:00
co-authored by Claude Sonnet 5
parent 0faa3f54de
commit f73bd6c9ec
14 changed files with 330 additions and 24 deletions
@@ -40,7 +40,8 @@ public sealed class DashboardViewModelTests
FakeReportGrades? reportGrades = null, FakeMemberships? memberships = null, FakeReportGrades? reportGrades = null, FakeMemberships? memberships = null,
FakeWorkTasks? tasks = null, FakeStudents? students = null, FakeDocumentation? documentation = null, FakeWorkTasks? tasks = null, FakeStudents? students = null, FakeDocumentation? documentation = null,
DashboardSettingsService? dashboardSettings = null, FakeSchoolHolidays? schoolHolidays = null, DashboardSettingsService? dashboardSettings = null, FakeSchoolHolidays? schoolHolidays = null,
FakeLessons? lessons = null, FakeSubstitutionEntries? substitutions = null) FakeLessons? lessons = null, FakeSubstitutionEntries? substitutions = null,
FakeSessions? sessions = null)
{ {
lessons ??= new FakeLessons(); lessons ??= new FakeLessons();
lessons.Add(lesson); lessons.Add(lesson);
@@ -48,7 +49,7 @@ public sealed class DashboardViewModelTests
new FakeGroups([group]), new FakeSubjects([]), lessons, new FakeGroups([group]), new FakeSubjects([]), lessons,
exams ?? new FakeExams([]), results ?? new FakeResults(), grades ?? new FakeGrades(), exams ?? new FakeExams([]), results ?? new FakeResults(), grades ?? new FakeGrades(),
reportGrades ?? new FakeReportGrades(), memberships ?? new FakeMemberships([]), reportGrades ?? new FakeReportGrades(), memberships ?? new FakeMemberships([]),
tasks ?? new FakeWorkTasks(), new FakeSessions([]), new FakeEntries(), tasks ?? new FakeWorkTasks(), sessions ?? new FakeSessions([]), new FakeEntries(),
students ?? new FakeStudents([]), documentation ?? new FakeDocumentation(), students ?? new FakeStudents([]), documentation ?? new FakeDocumentation(),
slots ?? new FakeTimetableSlots(), periodSchedule ?? NewPeriodSchedule(), slots ?? new FakeTimetableSlots(), periodSchedule ?? NewPeriodSchedule(),
new AttendanceBalanceService(), new SchoolYearService(), dashboardSettings ?? NewDashboardSettings(), new AttendanceBalanceService(), new SchoolYearService(), dashboardSettings ?? NewDashboardSettings(),
@@ -223,6 +224,25 @@ public sealed class DashboardViewModelTests
Assert.Contains(vm.SelectedDayEvents, e => e.Kind == CalendarEventKind.Exam && e.Title == "Test"); Assert.Contains(vm.SelectedDayEvents, e => e.Kind == CalendarEventKind.Exam && e.Title == "Test");
} }
[Fact]
public void Kalender_ZeigtMitarbeitssitzungenAlsEigenenTermintyp()
{
var today = DateOnly.FromDateTime(DateTime.Today);
var group = new LearningGroup { Name = "9c" };
var sessionDate = today.AddDays(3);
var session = new ParticipationSession { GroupId = group.Id, Date = sessionDate, Comment = "Aufsatz" };
var vm = BuildVm(group, new Lesson { GroupId = group.Id, Date = today },
sessions: new FakeSessions([session]));
var day = vm.CalendarDays.Single(d => d.Date == sessionDate);
Assert.True(day.HasSession);
vm.SelectCalendarDayCommand.Execute(day);
Assert.Contains(vm.SelectedDayEvents,
e => e.Kind == CalendarEventKind.ParticipationSession && e.Subtitle == "Aufsatz");
}
[Fact] [Fact]
public void DashboardKacheln_SichtbarkeitUndReihenfolgeWerdenGespeichert() public void DashboardKacheln_SichtbarkeitUndReihenfolgeWerdenGespeichert()
{ {
@@ -85,7 +85,7 @@ public sealed class GenerateLessonSeriesDialogViewModelTests
} }
[Fact] [Fact]
public void Save_Doppelstunde_ErzeugtZweiStundenAmSelbenTagMitUnterschiedlicherStundennummer() public void Save_Doppelstunde_ErzeugtNurEineStundeVerankertAnDerErstenPeriode()
{ {
var groupId = Guid.NewGuid(); var groupId = Guid.NewGuid();
var unitId = Guid.NewGuid(); var unitId = Guid.NewGuid();
@@ -98,9 +98,50 @@ public sealed class GenerateLessonSeriesDialogViewModelTests
vm.SaveCommand.Execute(null); vm.SaveCommand.Execute(null);
Assert.Equal(1, vm.Result!.Created);
var created = lessons.GetByGroupAndRange(groupId, from, from).Select(l => l.LessonNumber).ToList();
Assert.Equal([3], created);
}
[Fact]
public void Save_Dreifachstunde_ErzeugtNurEineStundeVerankertAnDerErstenPeriode()
{
var groupId = Guid.NewGuid();
var unitId = Guid.NewGuid();
var from = new DateOnly(2025, 9, 1);
var slots = new FakeTimetableSlots();
slots.Add(new TimetableSlot { GroupId = groupId, Weekday = from.DayOfWeek, PeriodNumber = 3 });
slots.Add(new TimetableSlot { GroupId = groupId, Weekday = from.DayOfWeek, PeriodNumber = 4 });
slots.Add(new TimetableSlot { GroupId = groupId, Weekday = from.DayOfWeek, PeriodNumber = 5 });
var lessons = new FakeLessons();
var vm = BuildVm(slots, lessons, new FakeSchoolHolidays(), unitId, groupId, from, from);
vm.SaveCommand.Execute(null);
Assert.Equal(1, vm.Result!.Created);
var created = lessons.GetByGroupAndRange(groupId, from, from).Select(l => l.LessonNumber).ToList();
Assert.Equal([3], created);
}
[Fact]
public void Save_ZweiGetrennteEinzelstundenAmSelbenTagBleibenGetrennt()
{
// Perioden 1 und 3 sind NICHT aufeinanderfolgend (Periode 2 fehlt) — keine Doppelstunde,
// beide bekommen eine eigene Lesson.
var groupId = Guid.NewGuid();
var unitId = Guid.NewGuid();
var from = new DateOnly(2025, 9, 1);
var slots = new FakeTimetableSlots();
slots.Add(new TimetableSlot { GroupId = groupId, Weekday = from.DayOfWeek, PeriodNumber = 1 });
slots.Add(new TimetableSlot { GroupId = groupId, Weekday = from.DayOfWeek, PeriodNumber = 3 });
var lessons = new FakeLessons();
var vm = BuildVm(slots, lessons, new FakeSchoolHolidays(), unitId, groupId, from, from);
vm.SaveCommand.Execute(null);
Assert.Equal(2, vm.Result!.Created); Assert.Equal(2, vm.Result!.Created);
var created = lessons.GetByGroupAndRange(groupId, from, from).Select(l => l.LessonNumber).OrderBy(n => n).ToList(); var created = lessons.GetByGroupAndRange(groupId, from, from).Select(l => l.LessonNumber).OrderBy(n => n).ToList();
Assert.Equal([3, 4], created); Assert.Equal([1, 3], created);
} }
[Fact] [Fact]
@@ -22,7 +22,7 @@ public sealed class GroupDetailViewModelTests
students, memberships, groups, new FakeCompetencyDomains()), students, memberships, groups, new FakeCompetencyDomains()),
new GradeOverviewTabViewModel(grades, exams, new FakeResults(), students, memberships, new GradingService()), new GradeOverviewTabViewModel(grades, exams, new FakeResults(), students, memberships, new GradingService()),
new PlanningTabViewModel(new FakeUnits(), new FakeLessons(), groups, subjects, new PlanningTabViewModel(new FakeUnits(), new FakeLessons(), groups, subjects,
new FakeCompetencyDomains(), TestSupport.BuildAiSettingsService()), new FakeCompetencyDomains(), TestSupport.BuildAiSettingsService(), new FakeSessions([])),
new CompetencyOverviewTabViewModel(new FakeUnits(), exams, new FakeResults(), new CompetencyOverviewTabViewModel(new FakeUnits(), exams, new FakeResults(),
new FakeCompetencyDomains(), students, new CompetencyAnalysisService()), new FakeCompetencyDomains(), students, new CompetencyAnalysisService()),
new SeatingPlanTabViewModel(new FakeSeatingPlans(), students, memberships, new SeatingPlanTabViewModel(new FakeSeatingPlans(), students, memberships,
@@ -59,4 +59,49 @@ public sealed class ParticipationSessionEditingTests
Assert.Equal("Nachbesprechung Elektrizität", sessions.GetById(session.Id)?.Comment); Assert.Equal("Nachbesprechung Elektrizität", sessions.GetById(session.Id)?.Comment);
Assert.Contains("Nachbesprechung Elektrizität", vm.SelectedSessionDisplay); Assert.Contains("Nachbesprechung Elektrizität", vm.SelectedSessionDisplay);
} }
[Fact]
public async Task LoeschenBefehl_FragtBestaetigungAbUndEntferntSessionMitsamtBewertungen()
{
var group = new LearningGroup { Name = "8a", SchoolYear = "2026/27" };
var session = new ParticipationSession { GroupId = group.Id, Date = new DateOnly(2026, 8, 19) };
var sessions = new FakeSessions([session]);
var entries = new FakeEntries();
entries.Add(new ParticipationEntry { SessionId = session.Id, StudentId = Guid.NewGuid() });
var vm = new ParticipationTabViewModel(
sessions, entries, new FakeAspects(), new FakeStudents([]),
new FakeMemberships([]), new FakeGroups([group]), new FakeCompetencyDomains());
vm.Initialize(group.Id, group.SchoolYear);
vm.SelectedSession = vm.Sessions.Single(s => s.Id == session.Id);
int? passedEntryCount = null;
vm.OnConfirmDeleteSession = (_, count) =>
{
passedEntryCount = count;
return Task.FromResult(true);
};
await vm.DeleteSessionCommand.ExecuteAsync(null);
Assert.Equal(1, passedEntryCount);
Assert.Null(sessions.GetById(session.Id));
}
[Fact]
public async Task LoeschenBefehl_BrichtBeiAbgelehnterBestaetigungAb()
{
var group = new LearningGroup { Name = "8a", SchoolYear = "2026/27" };
var session = new ParticipationSession { GroupId = group.Id, Date = new DateOnly(2026, 8, 19) };
var sessions = new FakeSessions([session]);
var vm = new ParticipationTabViewModel(
sessions, new FakeEntries(), new FakeAspects(), new FakeStudents([]),
new FakeMemberships([]), new FakeGroups([group]), new FakeCompetencyDomains());
vm.Initialize(group.Id, group.SchoolYear);
vm.SelectedSession = vm.Sessions.Single(s => s.Id == session.Id);
vm.OnConfirmDeleteSession = (_, _) => Task.FromResult(false);
await vm.DeleteSessionCommand.ExecuteAsync(null);
Assert.NotNull(sessions.GetById(session.Id));
}
} }
@@ -20,7 +20,7 @@ public class PlanningTabViewModelTests
var lessons = new FakeLessons(); var lessons = new FakeLessons();
var vm = new PlanningTabViewModel(units, lessons, groups, subjects, competencyDomains, var vm = new PlanningTabViewModel(units, lessons, groups, subjects, competencyDomains,
TestSupport.BuildAiSettingsService()); TestSupport.BuildAiSettingsService(), new FakeSessions([]));
vm.Initialize(groupId); vm.Initialize(groupId);
return (vm, units, lessons, groupId); return (vm, units, lessons, groupId);
@@ -227,4 +227,71 @@ public class PlanningTabViewModelTests
Assert.Equal(["L->AB01", "Plenum"], vm.KnownShorthands); Assert.Equal(["L->AB01", "Plenum"], vm.KnownShorthands);
} }
[Fact]
public void CreateParticipationSession_LegtSitzungMitDatumUndThemaDerStundeAn()
{
var groupId = Guid.NewGuid();
var group = new LearningGroup { Id = groupId, Name = "Testgruppe" };
var groups = new FakeGroups([group]);
var units = new FakeUnits();
var lessons = new FakeLessons();
var sessions = new FakeSessions([]);
var vm = new PlanningTabViewModel(units, lessons, groups, new FakeSubjects([]),
new FakeCompetencyDomains(), TestSupport.BuildAiSettingsService(), sessions);
vm.Initialize(groupId);
var unit = new Unit { GroupId = groupId, Title = "Optik" };
units.Add(unit);
var lesson = new Lesson
{
UnitId = unit.Id, GroupId = groupId, Date = new DateOnly(2025, 9, 1), Topic = "Brechung",
};
lessons.Add(lesson);
vm.RefreshPlanning(unit.Id, lesson.Id);
vm.SelectedLesson = vm.Lessons.Single(l => l.Id == lesson.Id);
string? notified = null;
vm.OnNotify = m => notified = m;
vm.CreateParticipationSessionCommand.Execute(null);
var created = Assert.Single(sessions.GetByGroup(groupId));
Assert.Equal(lesson.Id, created.LessonId);
Assert.Equal(lesson.Date, created.Date);
Assert.Equal("Brechung", created.Comment);
Assert.Equal("Sitzung aus der Stunde erstellt.", notified);
}
[Fact]
public void CreateParticipationSession_ZweiterAufrufLegtKeineDoppelteSitzungAn()
{
var groupId = Guid.NewGuid();
var group = new LearningGroup { Id = groupId, Name = "Testgruppe" };
var groups = new FakeGroups([group]);
var units = new FakeUnits();
var lessons = new FakeLessons();
var sessions = new FakeSessions([]);
var vm = new PlanningTabViewModel(units, lessons, groups, new FakeSubjects([]),
new FakeCompetencyDomains(), TestSupport.BuildAiSettingsService(), sessions);
vm.Initialize(groupId);
var unit = new Unit { GroupId = groupId, Title = "Optik" };
units.Add(unit);
var lesson = new Lesson
{
UnitId = unit.Id, GroupId = groupId, Date = new DateOnly(2025, 9, 1), Topic = "Brechung",
};
lessons.Add(lesson);
vm.RefreshPlanning(unit.Id, lesson.Id);
vm.SelectedLesson = vm.Lessons.Single(l => l.Id == lesson.Id);
vm.CreateParticipationSessionCommand.Execute(null);
string? notified = null;
vm.OnNotify = m => notified = m;
vm.CreateParticipationSessionCommand.Execute(null);
Assert.Single(sessions.GetByGroup(groupId));
Assert.Equal("Für diese Stunde existiert bereits eine Sitzung.", notified);
}
} }
@@ -554,6 +554,16 @@ public partial class DashboardViewModel : ObservableObject
agg.Details.Add(new CalendarEventItem(CalendarEventKind.Exam, exam.Date, agg.Details.Add(new CalendarEventItem(CalendarEventKind.Exam, exam.Date,
exam.Title, g.Name, g.Id)); exam.Title, g.Name, g.Id));
} }
foreach (var session in _participationSessions.GetByGroup(g.Id)
.Where(s => s.Date >= gridStart && s.Date <= gridEnd))
{
var agg = Agg(session.Date);
agg.HasSession = true;
if (g.IsOwnClass) agg.IsOwnClassDay = true;
agg.Details.Add(new CalendarEventItem(CalendarEventKind.ParticipationSession, session.Date,
g.Name, session.Comment ?? "", g.Id));
}
} }
for (var i = 0; i < 42; i++) for (var i = 0; i < 42; i++)
@@ -561,8 +571,8 @@ public partial class DashboardViewModel : ObservableObject
var date = gridStart.AddDays(i); var date = gridStart.AddDays(i);
byDay.TryGetValue(date, out var agg); byDay.TryGetValue(date, out var agg);
CalendarDays.Add(new CalendarDayCell(date, date.Month == firstOfMonth.Month, date == today, CalendarDays.Add(new CalendarDayCell(date, date.Month == firstOfMonth.Month, date == today,
agg?.HasLesson ?? false, agg?.HasExam ?? false, agg?.IsOwnClassDay ?? false, agg?.HasLesson ?? false, agg?.HasExam ?? false, agg?.HasSession ?? false,
agg?.Details ?? [])); agg?.IsOwnClassDay ?? false, agg?.Details ?? []));
} }
SelectCalendarDay(CalendarDays.FirstOrDefault(d => d.Date == today && d.IsCurrentMonth) SelectCalendarDay(CalendarDays.FirstOrDefault(d => d.Date == today && d.IsCurrentMonth)
?? CalendarDays.First(d => d.IsCurrentMonth)); ?? CalendarDays.First(d => d.IsCurrentMonth));
@@ -583,7 +593,7 @@ public partial class DashboardViewModel : ObservableObject
{ {
if (item is null) return; if (item is null) return;
if (item.Kind == CalendarEventKind.Exam) OnNavigateToExam?.Invoke(item.GroupId); if (item.Kind == CalendarEventKind.Exam) OnNavigateToExam?.Invoke(item.GroupId);
else OnNavigateToLesson?.Invoke(item.GroupId); else OnNavigateToLesson?.Invoke(item.GroupId); // auch für ParticipationSession: Tab "Mitarbeit"
} }
[RelayCommand] [RelayCommand]
@@ -633,6 +643,7 @@ public partial class DashboardViewModel : ObservableObject
{ {
public bool HasLesson; public bool HasLesson;
public bool HasExam; public bool HasExam;
public bool HasSession;
public bool IsOwnClassDay; public bool IsOwnClassDay;
public List<CalendarEventItem> Details { get; } = []; public List<CalendarEventItem> Details { get; } = [];
} }
@@ -716,12 +727,13 @@ public partial class CalendarDayCell : ObservableObject
public bool IsToday { get; } public bool IsToday { get; }
public bool HasLesson { get; } public bool HasLesson { get; }
public bool HasExam { get; } public bool HasExam { get; }
public bool HasSession { get; }
public bool IsOwnClassDay { get; } public bool IsOwnClassDay { get; }
public string Tooltip { get; } public string Tooltip { get; }
public IReadOnlyList<CalendarEventItem> Events { get; } public IReadOnlyList<CalendarEventItem> Events { get; }
internal CalendarDayCell(DateOnly date, bool isCurrentMonth, bool isToday, internal CalendarDayCell(DateOnly date, bool isCurrentMonth, bool isToday,
bool hasLesson, bool hasExam, bool isOwnClassDay, List<CalendarEventItem> details) bool hasLesson, bool hasExam, bool hasSession, bool isOwnClassDay, List<CalendarEventItem> details)
{ {
Date = date; Date = date;
DayNumber = date.Day; DayNumber = date.Day;
@@ -729,6 +741,7 @@ public partial class CalendarDayCell : ObservableObject
IsToday = isToday; IsToday = isToday;
HasLesson = hasLesson; HasLesson = hasLesson;
HasExam = hasExam; HasExam = hasExam;
HasSession = hasSession;
IsOwnClassDay = isOwnClassDay; IsOwnClassDay = isOwnClassDay;
Events = details; Events = details;
Tooltip = details.Count == 0 ? date.ToString("dd.MM.yyyy") Tooltip = details.Count == 0 ? date.ToString("dd.MM.yyyy")
@@ -736,7 +749,7 @@ public partial class CalendarDayCell : ObservableObject
} }
} }
public enum CalendarEventKind { Lesson, Exam } public enum CalendarEventKind { Lesson, Exam, ParticipationSession }
public sealed class CalendarEventItem(CalendarEventKind kind, DateOnly date, string title, public sealed class CalendarEventItem(CalendarEventKind kind, DateOnly date, string title,
string subtitle, Guid groupId) string subtitle, Guid groupId)
@@ -746,7 +759,12 @@ public sealed class CalendarEventItem(CalendarEventKind kind, DateOnly date, str
public string Title { get; } = title; public string Title { get; } = title;
public string Subtitle { get; } = subtitle; public string Subtitle { get; } = subtitle;
public Guid GroupId { get; } = groupId; public Guid GroupId { get; } = groupId;
public string KindLabel => Kind == CalendarEventKind.Exam ? "Klausur" : "Unterricht"; public string KindLabel => Kind switch
{
CalendarEventKind.Exam => "Klausur",
CalendarEventKind.ParticipationSession => "Sitzung",
_ => "Unterricht"
};
} }
public enum UpcomingDateKind { Exam, SupportPlan, Deadline } public enum UpcomingDateKind { Exam, SupportPlan, Deadline }
@@ -54,6 +54,7 @@ public partial class ParticipationTabViewModel : ObservableObject
public Func<ParticipationTabViewModel, Task>? OnComputeGrade { get; set; } public Func<ParticipationTabViewModel, Task>? OnComputeGrade { get; set; }
public Func<ParticipationTabViewModel, Task>? OnOpenWizard { get; set; } public Func<ParticipationTabViewModel, Task>? OnOpenWizard { get; set; }
public Func<ParticipationTabViewModel, Task>? OnManageAspects { get; set; } public Func<ParticipationTabViewModel, Task>? OnManageAspects { get; set; }
public Func<ParticipationSessionItem, int, Task<bool>>? OnConfirmDeleteSession { get; set; }
public ParticipationTabViewModel( public ParticipationTabViewModel(
IParticipationSessionRepository sessions, IParticipationSessionRepository sessions,
@@ -120,6 +121,7 @@ public partial class ParticipationTabViewModel : ObservableObject
{ {
OnPropertyChanged(nameof(SelectedSessionDisplay)); OnPropertyChanged(nameof(SelectedSessionDisplay));
EditSessionCommand.NotifyCanExecuteChanged(); EditSessionCommand.NotifyCanExecuteChanged();
DeleteSessionCommand.NotifyCanExecuteChanged();
if (value is null) if (value is null)
{ {
StudentRows.Clear(); StudentRows.Clear();
@@ -282,7 +284,11 @@ public partial class ParticipationTabViewModel : ObservableObject
private bool CanEditSession() => !IsReadOnly && SelectedSession is not null; private bool CanEditSession() => !IsReadOnly && SelectedSession is not null;
partial void OnIsReadOnlyChanged(bool value) => EditSessionCommand.NotifyCanExecuteChanged(); partial void OnIsReadOnlyChanged(bool value)
{
EditSessionCommand.NotifyCanExecuteChanged();
DeleteSessionCommand.NotifyCanExecuteChanged();
}
[RelayCommand(CanExecute = nameof(CanQuickInput))] [RelayCommand(CanExecute = nameof(CanQuickInput))]
private async Task QuickInput() private async Task QuickInput()
@@ -325,14 +331,20 @@ public partial class ParticipationTabViewModel : ObservableObject
if (SelectedSession is not null) LoadGrid(SelectedSession.Id); if (SelectedSession is not null) LoadGrid(SelectedSession.Id);
} }
[RelayCommand] [RelayCommand(CanExecute = nameof(CanDeleteSession))]
private void DeleteSession() private async Task DeleteSession()
{ {
if (SelectedSession is null) return; if (SelectedSession is null) return;
var entryCount = _entries.GetBySession(SelectedSession.Id).Count;
if (OnConfirmDeleteSession is not null
&& !await OnConfirmDeleteSession(SelectedSession, entryCount)) return;
_sessions.Delete(SelectedSession.Id); _sessions.Delete(SelectedSession.Id);
LoadSessions(); LoadSessions();
} }
private bool CanDeleteSession() => !IsReadOnly && SelectedSession is not null;
public void SaveNote(Guid sessionId, Guid studentId, string note) public void SaveNote(Guid sessionId, Guid studentId, string note)
{ {
var entry = _entries.GetBySessionAndStudent(sessionId, studentId); var entry = _entries.GetBySessionAndStudent(sessionId, studentId);
@@ -32,6 +32,7 @@ public partial class PlanningTabViewModel : ObservableObject
private readonly ISubjectRepository _subjects; private readonly ISubjectRepository _subjects;
private readonly ICompetencyDomainRepository _competencyDomains; private readonly ICompetencyDomainRepository _competencyDomains;
private readonly AiSettingsService _aiSettings; private readonly AiSettingsService _aiSettings;
private readonly IParticipationSessionRepository _participationSessions;
private Guid _groupId; private Guid _groupId;
@@ -75,14 +76,16 @@ public partial class PlanningTabViewModel : ObservableObject
public Func<Lesson, Task>? OnShowLesson { get; set; } public Func<Lesson, Task>? OnShowLesson { get; set; }
public Func<Unit, Task<LessonSeriesResult?>>? OnGenerateLessonSeries { get; set; } public Func<Unit, Task<LessonSeriesResult?>>? OnGenerateLessonSeries { get; set; }
public Func<Unit, Task<bool>>? OnAiAssist { get; set; } public Func<Unit, Task<bool>>? OnAiAssist { get; set; }
public Action<string>? OnNotify { get; set; }
public PlanningTabViewModel(IUnitRepository units, ILessonRepository lessons, public PlanningTabViewModel(IUnitRepository units, ILessonRepository lessons,
IGroupRepository groups, ISubjectRepository subjects, IGroupRepository groups, ISubjectRepository subjects,
ICompetencyDomainRepository competencyDomains, AiSettingsService aiSettings) ICompetencyDomainRepository competencyDomains, AiSettingsService aiSettings,
IParticipationSessionRepository participationSessions)
{ {
_units = units; _lessons = lessons; _groups = groups; _units = units; _lessons = lessons; _groups = groups;
_subjects = subjects; _competencyDomains = competencyDomains; _subjects = subjects; _competencyDomains = competencyDomains;
_aiSettings = aiSettings; _aiSettings = aiSettings; _participationSessions = participationSessions;
} }
public void Initialize(Guid groupId, bool isReadOnly = false) public void Initialize(Guid groupId, bool isReadOnly = false)
@@ -367,6 +370,32 @@ public partial class PlanningTabViewModel : ObservableObject
_lessons.Save(lesson); _lessons.Save(lesson);
LoadUnits(); LoadUnits();
} }
/// Übernimmt Datum + Thema der Stunde in eine neue Mitarbeitssitzung (3.3.1) — verknüpft über
/// das bisher ungenutzte Lesson.LessonId-Feld auf ParticipationSession, damit ein zweiter Klick
/// auf dieselbe Stunde keine doppelte Sitzung anlegt, sondern nur darauf hinweist.
[RelayCommand(CanExecute = nameof(HasSelectedLesson))]
private void CreateParticipationSession()
{
if (SelectedLesson is null) return;
var lesson = SelectedLesson.Model;
var existing = _participationSessions.GetByGroup(lesson.GroupId)
.FirstOrDefault(s => s.LessonId == lesson.Id);
if (existing is not null)
{
OnNotify?.Invoke("Für diese Stunde existiert bereits eine Sitzung.");
return;
}
_participationSessions.Save(new ParticipationSession
{
GroupId = lesson.GroupId,
Date = lesson.Date,
LessonId = lesson.Id,
Comment = lesson.Topic,
});
OnNotify?.Invoke("Sitzung aus der Stunde erstellt.");
}
} }
// ── Anzeige-DTOs ────────────────────────────────────────────────────────────── // ── Anzeige-DTOs ──────────────────────────────────────────────────────────────
@@ -1122,6 +1151,12 @@ public partial class GenerateLessonSeriesDialogViewModel : ObservableObject
/// halten), wird nichts doppelt angelegt. Neue Stunden bekommen bewusst kein Thema — die /// halten), wird nichts doppelt angelegt. Neue Stunden bekommen bewusst kein Thema — die
/// sonst übliche "Thema erforderlich"-Regel des manuellen "+Stunde"-Dialogs gilt hier nicht, /// sonst übliche "Thema erforderlich"-Regel des manuellen "+Stunde"-Dialogs gilt hier nicht,
/// da diese Platzhalter zum späteren Ausfüllen gedacht sind. /// da diese Platzhalter zum späteren Ausfüllen gedacht sind.
///
/// Doppelstunden (zwei direkt aufeinanderfolgende Perioden desselben Wochentags/derselben
/// Gruppe im Stundenplan — dieselbe Konvention wie bei <see cref="LessonDialogViewModel.RecomputeTimeBudget"/>
/// und den Ferien-Badges in TimetableViewModel) bekommen bewusst nur EINE Lesson, verankert an
/// der ersten Periode: eine Folgeperiode, deren Vorgängerperiode ebenfalls im Stundenplan steht,
/// wird übersprungen, statt eine zweite Lesson mit gleichem Datum anzulegen.
[RelayCommand] [RelayCommand]
private void Save() private void Save()
{ {
@@ -1136,6 +1171,9 @@ public partial class GenerateLessonSeriesDialogViewModel : ObservableObject
if (slotsForGroup.Count == 0) if (slotsForGroup.Count == 0)
{ DateError = "Für diese Gruppe ist noch keine Stunde im Stundenplan eingetragen."; return; } { DateError = "Für diese Gruppe ist noch keine Stunde im Stundenplan eingetragen."; return; }
var periodsByWeekday = slotsForGroup.GroupBy(s => s.Weekday)
.ToDictionary(g => g.Key, g => g.Select(s => s.PeriodNumber).ToHashSet());
var schoolHolidays = _schoolHolidays.GetAll(); var schoolHolidays = _schoolHolidays.GetAll();
var publicHolidayDates = new HashSet<DateOnly>(); var publicHolidayDates = new HashSet<DateOnly>();
for (var year = from.Year; year <= to.Year; year++) for (var year = from.Year; year <= to.Year; year++)
@@ -1152,6 +1190,10 @@ public partial class GenerateLessonSeriesDialogViewModel : ObservableObject
foreach (var slot in slotsForGroup.Where(s => s.Weekday == date.DayOfWeek)) foreach (var slot in slotsForGroup.Where(s => s.Weekday == date.DayOfWeek))
{ {
// Zweite (und weitere) Periode einer Doppelstunde: gehört zur Lesson der ersten
// Periode, keine eigene Lesson.
if (periodsByWeekday[slot.Weekday].Contains(slot.PeriodNumber - 1)) continue;
if (isFreeDay) { skippedHoliday++; continue; } if (isFreeDay) { skippedHoliday++; continue; }
if (existing.Contains((date, (int?)slot.PeriodNumber))) { skippedExisting++; continue; } if (existing.Contains((date, (int?)slot.PeriodNumber))) { skippedExisting++; continue; }
@@ -199,9 +199,11 @@
<TextBlock Classes="daynum" Classes.today="{Binding IsToday}" <TextBlock Classes="daynum" Classes.today="{Binding IsToday}"
Text="{Binding DayNumber}" FontSize="12" Text="{Binding DayNumber}" FontSize="12"
HorizontalAlignment="Center" VerticalAlignment="Center"/> HorizontalAlignment="Center" VerticalAlignment="Center"/>
<Ellipse Width="5" Height="5" Fill="#E53935" Margin="0,0,0,3" <StackPanel Orientation="Horizontal" Spacing="2" Margin="0,0,0,3"
HorizontalAlignment="Center" VerticalAlignment="Bottom" HorizontalAlignment="Center" VerticalAlignment="Bottom">
IsVisible="{Binding HasExam}"/> <Ellipse Width="5" Height="5" Fill="#1E88E5" IsVisible="{Binding HasSession}"/>
<Ellipse Width="5" Height="5" Fill="#E53935" IsVisible="{Binding HasExam}"/>
</StackPanel>
</Grid> </Grid>
</Border> </Border>
</Button> </Button>
@@ -218,6 +220,10 @@
<Ellipse Width="7" Height="7" Fill="#E53935"/> <Ellipse Width="7" Height="7" Fill="#E53935"/>
<TextBlock Text="Klausur" FontSize="10" Opacity="0.6"/> <TextBlock Text="Klausur" FontSize="10" Opacity="0.6"/>
</StackPanel> </StackPanel>
<StackPanel Orientation="Horizontal" Spacing="4">
<Ellipse Width="7" Height="7" Fill="#1E88E5"/>
<TextBlock Text="Sitzung" FontSize="10" Opacity="0.6"/>
</StackPanel>
<StackPanel Orientation="Horizontal" Spacing="4"> <StackPanel Orientation="Horizontal" Spacing="4">
<Border Width="10" Height="10" CornerRadius="3" BorderThickness="2" <Border Width="10" Height="10" CornerRadius="3" BorderThickness="2"
BorderBrush="{DynamicResource SystemControlBackgroundAccentBrush}"/> BorderBrush="{DynamicResource SystemControlBackgroundAccentBrush}"/>
@@ -56,6 +56,8 @@
<StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="6"> <StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="6">
<Button Content="✎ Sitzung bearbeiten" Command="{Binding EditSessionCommand}" <Button Content="✎ Sitzung bearbeiten" Command="{Binding EditSessionCommand}"
IsVisible="{Binding !IsReadOnly}" FontSize="11" Padding="8,3"/> IsVisible="{Binding !IsReadOnly}" FontSize="11" Padding="8,3"/>
<Button Content="🗑 Sitzung löschen" Command="{Binding DeleteSessionCommand}"
IsVisible="{Binding !IsReadOnly}" FontSize="11" Padding="8,3"/>
<ToggleButton Content="◇ Kompetenzen" <ToggleButton Content="◇ Kompetenzen"
IsChecked="{Binding CompetencyTagsVisible}" IsChecked="{Binding CompetencyTagsVisible}"
IsVisible="{Binding HasCompetencyCatalog}" IsVisible="{Binding HasCompetencyCatalog}"
@@ -5,6 +5,7 @@ using LehrerApp.Core.Interfaces;
using LehrerApp.Core.Models; using LehrerApp.Core.Models;
using LehrerApp.Core.Services; using LehrerApp.Core.Services;
using LehrerApp.Desktop.ViewModels.Groups; using LehrerApp.Desktop.ViewModels.Groups;
using LehrerApp.Desktop.Views.Shared;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
namespace LehrerApp.Desktop.Views.Groups; namespace LehrerApp.Desktop.Views.Groups;
@@ -29,6 +30,7 @@ public partial class ParticipationTabView : UserControl
vm.OnComputeGrade = ShowComputeGradeDialog; vm.OnComputeGrade = ShowComputeGradeDialog;
vm.OnOpenWizard = ShowWizardDialog; vm.OnOpenWizard = ShowWizardDialog;
vm.OnManageAspects = ShowManageAspectsDialog; vm.OnManageAspects = ShowManageAspectsDialog;
vm.OnConfirmDeleteSession = ConfirmDeleteSession;
vm.Aspects.CollectionChanged += (_, _) => BuildColumns(); vm.Aspects.CollectionChanged += (_, _) => BuildColumns();
vm.PropertyChanged += (_, pe) => vm.PropertyChanged += (_, pe) =>
{ {
@@ -367,4 +369,23 @@ public partial class ParticipationTabView : UserControl
if (owner is not null) if (owner is not null)
await dialog.ShowDialog(owner); await dialog.ShowDialog(owner);
} }
private async Task<bool> ConfirmDeleteSession(ParticipationSessionItem session, int entryCount)
{
var owner = TopLevel.GetTopLevel(this) as Window;
if (owner is null) return false;
var info = new ConfirmDialogInfo
{
Title = "Sitzung endgültig löschen?",
Message = entryCount > 0
? $"Die Sitzung „{session.Display}“ wird endgültig gelöscht. Dabei gehen auch " +
$"alle {entryCount} bereits erfassten Schüler-Bewertungen dieser Sitzung " +
"unwiderruflich verloren."
: $"Die Sitzung „{session.Display}“ wird endgültig gelöscht.",
ConfirmText = "Endgültig löschen",
};
var dialog = new ConfirmDialog { DataContext = info };
return await dialog.ShowDialog<bool>(owner);
}
} }
@@ -92,6 +92,9 @@
<Button Content="Bearbeiten" Command="{Binding EditLessonCommand}" IsEnabled="{Binding !IsReadOnly}"/> <Button Content="Bearbeiten" Command="{Binding EditLessonCommand}" IsEnabled="{Binding !IsReadOnly}"/>
<Button Content="Verschieben" Command="{Binding MoveLessonCommand}" IsEnabled="{Binding !IsReadOnly}"/> <Button Content="Verschieben" Command="{Binding MoveLessonCommand}" IsEnabled="{Binding !IsReadOnly}"/>
<Button Content="Status → Durchgeführt" Command="{Binding AdvanceLessonStatusCommand}" IsEnabled="{Binding !IsReadOnly}"/> <Button Content="Status → Durchgeführt" Command="{Binding AdvanceLessonStatusCommand}" IsEnabled="{Binding !IsReadOnly}"/>
<Button Content="Sitzung erzeugen" Command="{Binding CreateParticipationSessionCommand}"
IsEnabled="{Binding !IsReadOnly}"
ToolTip.Tip="Legt eine Mitarbeitssitzung mit Datum und Thema dieser Stunde an."/>
<Button Content="Löschen" Command="{Binding DeleteLessonCommand}" IsEnabled="{Binding !IsReadOnly}"/> <Button Content="Löschen" Command="{Binding DeleteLessonCommand}" IsEnabled="{Binding !IsReadOnly}"/>
</StackPanel> </StackPanel>
</Grid> </Grid>
@@ -41,6 +41,7 @@ public partial class PlanningTabView : UserControl
vm.OnShowLesson = ShowLessonViewerDialog; vm.OnShowLesson = ShowLessonViewerDialog;
vm.OnGenerateLessonSeries = ShowGenerateLessonSeriesDialog; vm.OnGenerateLessonSeries = ShowGenerateLessonSeriesDialog;
vm.OnAiAssist = ShowAiAssistDialog; vm.OnAiAssist = ShowAiAssistDialog;
vm.OnNotify = Notifications.ShowSuccess;
} }
} }
+32 -4
View File
@@ -252,14 +252,35 @@ der ersten mit der zweiten Hälfte der Sitzungen. "Übernehmen" aktualisiert ein
Mitarbeit-Note für denselben Zeitraum statt sie zu duplizieren (erkannt über den `Grade.Note`-Tag). Mitarbeit-Note für denselben Zeitraum statt sie zu duplizieren (erkannt über den `Grade.Note`-Tag).
### 3.3 Sitzungen ### 3.3 Sitzungen
- [ ] **3.3.1** Sitzung automatisch aus einer geplanten `Lesson` erzeugen - [x] **3.3.1** Sitzung automatisch aus einer geplanten `Lesson` erzeugen
(Datum + Thema übernehmen) — Abhängigkeit zu 4.2. (Datum + Thema übernehmen) — Abhängigkeit zu 4.2. Neuer Button "Sitzung erzeugen" in der
Stunden-Toolbar der Planung ([PlanningTabView.axaml](LehrerApp.Desktop/Views/Groups/PlanningTabView.axaml));
`PlanningTabViewModel.CreateParticipationSessionCommand` legt eine `ParticipationSession`
mit `Date`/`Comment` aus der Stunde an und verknüpft sie über das bis dahin ungenutzte
`ParticipationSession.LessonId`-Feld. Ein zweiter Klick auf dieselbe Stunde legt keine
Dopplung an, sondern zeigt nur einen Hinweis (Prüfung über `LessonId` innerhalb der Gruppe).
- [x] **3.3.2** Anwesenheit in der Sitzung erfassen — als `ParticipationEntry.Attendance` - [x] **3.3.2** Anwesenheit in der Sitzung erfassen — als `ParticipationEntry.Attendance`
(krank: Entschuldigung offen/entschuldigt/unentschuldigt), nicht als "verspätet"; siehe unten. (krank: Entschuldigung offen/entschuldigt/unentschuldigt), nicht als "verspätet"; siehe unten.
Vorgriff auf 5.2, ersetzt dessen 5.2.1/5.2.4 aber nicht vollständig (kein Fehlzeitenmodul Vorgriff auf 5.2, ersetzt dessen 5.2.1/5.2.4 aber nicht vollständig (kein Fehlzeitenmodul
über alle Stundentypen hinweg, nur innerhalb von Mitarbeit-Sitzungen). über alle Stundentypen hinweg, nur innerhalb von Mitarbeit-Sitzungen).
- [ ] **3.3.3** Sitzung bearbeiten/löschen inkl. Rückfrage bei vorhandenen Bewertungen. - [x] **3.3.3** Sitzung bearbeiten/löschen inkl. Rückfrage bei vorhandenen Bewertungen.
- [ ] **3.3.4** Sitzungen mehrerer Gruppen im Kalenderüberblick. **Nachtrag:** Das Bearbeiten (`EditSessionCommand`/`AddSessionDialog`) war bereits vor
diesem Durchgang ad-hoc umgesetzt (Commit „Edit Stammdaten Mitarbeitsessions“), ohne dass
diese Liste nachgezogen wurde — daher hier nur nachträglich dokumentiert, nicht neu gebaut.
Neu ergänzt wurde das Löschen: Button "🗑 Sitzung löschen" neben "✎ Sitzung bearbeiten"
([ParticipationTabView.axaml](LehrerApp.Desktop/Views/Groups/ParticipationTabView.axaml)),
`DeleteSessionCommand` fragt über den generischen `ConfirmDialog` nach (Muster aus
`ParticipationAspectsDialog`) und nennt die Anzahl betroffener `ParticipationEntry`-Datensätze,
damit vor dem endgültigen Löschen klar ist, ob bereits Bewertungen existieren. Das Löschen
selbst kaskadiert wie zuvor über `ParticipationSessionRepository.Delete`.
- [x] **3.3.4** Sitzungen mehrerer Gruppen im Kalenderüberblick. Erweitert den bestehenden
Dashboard-Monatskalender ([DashboardView.axaml](LehrerApp.Desktop/Views/Dashboard/DashboardView.axaml))
um einen dritten `CalendarEventKind.ParticipationSession` neben `Lesson`/`Exam` — blauer
Punkt am Tag, eigener Legenden-Eintrag, Klick navigiert wie bei Lesson-Terminen in den Tab
"Mitarbeit". Bewusst keine eigene neue Ansicht: `DashboardViewModel` hatte `IParticipationSessionRepository`
und die Kalender-Aggregation (`DayAgg`/`CalendarDayCell`/`CalendarEventItem`) bereits, sodass
sich der dritte Termintyp am Exam-Vorbild (In-Memory-Filterung nach `GetByGroup`, keine neue
Repository-Methode) ergänzen ließ.
### Abschnittsnoten & Mitarbeits-Assistent (nicht aus dieser Liste, eigener Workflow-Bedarf) ### Abschnittsnoten & Mitarbeits-Assistent (nicht aus dieser Liste, eigener Workflow-Bedarf)
@@ -338,6 +359,13 @@ gruppenspezifischen Tab "Planung" (`GroupDetailView`), siehe unten.
`NotificationService.ShowSuccess`, bis dahin nur `ShowError` im Einsatz). Neue Stunden haben `NotificationService.ShowSuccess`, bis dahin nur `ShowError` im Einsatz). Neue Stunden haben
bewusst kein Thema (Platzhalter zum Ausfüllen) — die "Thema erforderlich"-Pflicht des bewusst kein Thema (Platzhalter zum Ausfüllen) — die "Thema erforderlich"-Pflicht des
manuellen " Stunde"-Dialogs gilt hier nicht. manuellen " Stunde"-Dialogs gilt hier nicht.
**Bugfix (Nutzer-Feedback):** Doppelstunden (zwei direkt aufeinanderfolgende Perioden
desselben Wochentags/derselben Gruppe im Stundenplan) erzeugten zunächst zwei `Lesson`s mit
gleichem Datum statt einer — dieselbe Konvention wie bei der Doppelstunden-Erkennung in
`LessonDialogViewModel.RecomputeTimeBudget` (Zeitbudget-Anzeige) und den Ferien-Badges in
`TimetableViewModel` fehlte hier. Jetzt bekommt nur die erste Periode eines zusammenhängenden
Blocks eine `Lesson`; Folgeperioden (deren Vorgängerperiode ebenfalls im Stundenplan steht)
werden übersprungen.
Umgesetzt über den neuen Tab "Planung" in Umgesetzt über den neuen Tab "Planung" in
[GroupDetailView.axaml](LehrerApp.Desktop/Views/Groups/GroupDetailView.axaml) [GroupDetailView.axaml](LehrerApp.Desktop/Views/Groups/GroupDetailView.axaml)