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,
FakeWorkTasks? tasks = null, FakeStudents? students = null, FakeDocumentation? documentation = 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.Add(lesson);
@@ -48,7 +49,7 @@ public sealed class DashboardViewModelTests
new FakeGroups([group]), new FakeSubjects([]), lessons,
exams ?? new FakeExams([]), results ?? new FakeResults(), grades ?? new FakeGrades(),
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(),
slots ?? new FakeTimetableSlots(), periodSchedule ?? NewPeriodSchedule(),
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");
}
[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]
public void DashboardKacheln_SichtbarkeitUndReihenfolgeWerdenGespeichert()
{
@@ -85,7 +85,7 @@ public sealed class GenerateLessonSeriesDialogViewModelTests
}
[Fact]
public void Save_Doppelstunde_ErzeugtZweiStundenAmSelbenTagMitUnterschiedlicherStundennummer()
public void Save_Doppelstunde_ErzeugtNurEineStundeVerankertAnDerErstenPeriode()
{
var groupId = Guid.NewGuid();
var unitId = Guid.NewGuid();
@@ -98,9 +98,50 @@ public sealed class GenerateLessonSeriesDialogViewModelTests
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);
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]
@@ -22,7 +22,7 @@ public sealed class GroupDetailViewModelTests
students, memberships, groups, new FakeCompetencyDomains()),
new GradeOverviewTabViewModel(grades, exams, new FakeResults(), students, memberships, new GradingService()),
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 FakeCompetencyDomains(), students, new CompetencyAnalysisService()),
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.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 vm = new PlanningTabViewModel(units, lessons, groups, subjects, competencyDomains,
TestSupport.BuildAiSettingsService());
TestSupport.BuildAiSettingsService(), new FakeSessions([]));
vm.Initialize(groupId);
return (vm, units, lessons, groupId);
@@ -227,4 +227,71 @@ public class PlanningTabViewModelTests
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,
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++)
@@ -561,8 +571,8 @@ public partial class DashboardViewModel : ObservableObject
var date = gridStart.AddDays(i);
byDay.TryGetValue(date, out var agg);
CalendarDays.Add(new CalendarDayCell(date, date.Month == firstOfMonth.Month, date == today,
agg?.HasLesson ?? false, agg?.HasExam ?? false, agg?.IsOwnClassDay ?? false,
agg?.Details ?? []));
agg?.HasLesson ?? false, agg?.HasExam ?? false, agg?.HasSession ?? false,
agg?.IsOwnClassDay ?? false, agg?.Details ?? []));
}
SelectCalendarDay(CalendarDays.FirstOrDefault(d => d.Date == today && d.IsCurrentMonth)
?? CalendarDays.First(d => d.IsCurrentMonth));
@@ -583,7 +593,7 @@ public partial class DashboardViewModel : ObservableObject
{
if (item is null) return;
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]
@@ -633,6 +643,7 @@ public partial class DashboardViewModel : ObservableObject
{
public bool HasLesson;
public bool HasExam;
public bool HasSession;
public bool IsOwnClassDay;
public List<CalendarEventItem> Details { get; } = [];
}
@@ -716,12 +727,13 @@ public partial class CalendarDayCell : ObservableObject
public bool IsToday { get; }
public bool HasLesson { get; }
public bool HasExam { get; }
public bool HasSession { get; }
public bool IsOwnClassDay { get; }
public string Tooltip { get; }
public IReadOnlyList<CalendarEventItem> Events { get; }
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;
DayNumber = date.Day;
@@ -729,6 +741,7 @@ public partial class CalendarDayCell : ObservableObject
IsToday = isToday;
HasLesson = hasLesson;
HasExam = hasExam;
HasSession = hasSession;
IsOwnClassDay = isOwnClassDay;
Events = details;
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,
string subtitle, Guid groupId)
@@ -746,7 +759,12 @@ public sealed class CalendarEventItem(CalendarEventKind kind, DateOnly date, str
public string Title { get; } = title;
public string Subtitle { get; } = subtitle;
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 }
@@ -54,6 +54,7 @@ public partial class ParticipationTabViewModel : ObservableObject
public Func<ParticipationTabViewModel, Task>? OnComputeGrade { get; set; }
public Func<ParticipationTabViewModel, Task>? OnOpenWizard { get; set; }
public Func<ParticipationTabViewModel, Task>? OnManageAspects { get; set; }
public Func<ParticipationSessionItem, int, Task<bool>>? OnConfirmDeleteSession { get; set; }
public ParticipationTabViewModel(
IParticipationSessionRepository sessions,
@@ -120,6 +121,7 @@ public partial class ParticipationTabViewModel : ObservableObject
{
OnPropertyChanged(nameof(SelectedSessionDisplay));
EditSessionCommand.NotifyCanExecuteChanged();
DeleteSessionCommand.NotifyCanExecuteChanged();
if (value is null)
{
StudentRows.Clear();
@@ -282,7 +284,11 @@ public partial class ParticipationTabViewModel : ObservableObject
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))]
private async Task QuickInput()
@@ -325,14 +331,20 @@ public partial class ParticipationTabViewModel : ObservableObject
if (SelectedSession is not null) LoadGrid(SelectedSession.Id);
}
[RelayCommand]
private void DeleteSession()
[RelayCommand(CanExecute = nameof(CanDeleteSession))]
private async Task DeleteSession()
{
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);
LoadSessions();
}
private bool CanDeleteSession() => !IsReadOnly && SelectedSession is not null;
public void SaveNote(Guid sessionId, Guid studentId, string note)
{
var entry = _entries.GetBySessionAndStudent(sessionId, studentId);
@@ -32,6 +32,7 @@ public partial class PlanningTabViewModel : ObservableObject
private readonly ISubjectRepository _subjects;
private readonly ICompetencyDomainRepository _competencyDomains;
private readonly AiSettingsService _aiSettings;
private readonly IParticipationSessionRepository _participationSessions;
private Guid _groupId;
@@ -75,14 +76,16 @@ public partial class PlanningTabViewModel : ObservableObject
public Func<Lesson, Task>? OnShowLesson { get; set; }
public Func<Unit, Task<LessonSeriesResult?>>? OnGenerateLessonSeries { get; set; }
public Func<Unit, Task<bool>>? OnAiAssist { get; set; }
public Action<string>? OnNotify { get; set; }
public PlanningTabViewModel(IUnitRepository units, ILessonRepository lessons,
IGroupRepository groups, ISubjectRepository subjects,
ICompetencyDomainRepository competencyDomains, AiSettingsService aiSettings)
ICompetencyDomainRepository competencyDomains, AiSettingsService aiSettings,
IParticipationSessionRepository participationSessions)
{
_units = units; _lessons = lessons; _groups = groups;
_subjects = subjects; _competencyDomains = competencyDomains;
_aiSettings = aiSettings;
_aiSettings = aiSettings; _participationSessions = participationSessions;
}
public void Initialize(Guid groupId, bool isReadOnly = false)
@@ -367,6 +370,32 @@ public partial class PlanningTabViewModel : ObservableObject
_lessons.Save(lesson);
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 ──────────────────────────────────────────────────────────────
@@ -1122,6 +1151,12 @@ public partial class GenerateLessonSeriesDialogViewModel : ObservableObject
/// halten), wird nichts doppelt angelegt. Neue Stunden bekommen bewusst kein Thema — die
/// sonst übliche "Thema erforderlich"-Regel des manuellen "+Stunde"-Dialogs gilt hier nicht,
/// 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]
private void Save()
{
@@ -1136,6 +1171,9 @@ public partial class GenerateLessonSeriesDialogViewModel : ObservableObject
if (slotsForGroup.Count == 0)
{ 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 publicHolidayDates = new HashSet<DateOnly>();
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))
{
// 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 (existing.Contains((date, (int?)slot.PeriodNumber))) { skippedExisting++; continue; }
@@ -199,9 +199,11 @@
<TextBlock Classes="daynum" Classes.today="{Binding IsToday}"
Text="{Binding DayNumber}" FontSize="12"
HorizontalAlignment="Center" VerticalAlignment="Center"/>
<Ellipse Width="5" Height="5" Fill="#E53935" Margin="0,0,0,3"
HorizontalAlignment="Center" VerticalAlignment="Bottom"
IsVisible="{Binding HasExam}"/>
<StackPanel Orientation="Horizontal" Spacing="2" Margin="0,0,0,3"
HorizontalAlignment="Center" VerticalAlignment="Bottom">
<Ellipse Width="5" Height="5" Fill="#1E88E5" IsVisible="{Binding HasSession}"/>
<Ellipse Width="5" Height="5" Fill="#E53935" IsVisible="{Binding HasExam}"/>
</StackPanel>
</Grid>
</Border>
</Button>
@@ -218,6 +220,10 @@
<Ellipse Width="7" Height="7" Fill="#E53935"/>
<TextBlock Text="Klausur" FontSize="10" Opacity="0.6"/>
</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">
<Border Width="10" Height="10" CornerRadius="3" BorderThickness="2"
BorderBrush="{DynamicResource SystemControlBackgroundAccentBrush}"/>
@@ -56,6 +56,8 @@
<StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="6">
<Button Content="✎ Sitzung bearbeiten" Command="{Binding EditSessionCommand}"
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"
IsChecked="{Binding CompetencyTagsVisible}"
IsVisible="{Binding HasCompetencyCatalog}"
@@ -5,6 +5,7 @@ using LehrerApp.Core.Interfaces;
using LehrerApp.Core.Models;
using LehrerApp.Core.Services;
using LehrerApp.Desktop.ViewModels.Groups;
using LehrerApp.Desktop.Views.Shared;
using Microsoft.Extensions.DependencyInjection;
namespace LehrerApp.Desktop.Views.Groups;
@@ -29,6 +30,7 @@ public partial class ParticipationTabView : UserControl
vm.OnComputeGrade = ShowComputeGradeDialog;
vm.OnOpenWizard = ShowWizardDialog;
vm.OnManageAspects = ShowManageAspectsDialog;
vm.OnConfirmDeleteSession = ConfirmDeleteSession;
vm.Aspects.CollectionChanged += (_, _) => BuildColumns();
vm.PropertyChanged += (_, pe) =>
{
@@ -367,4 +369,23 @@ public partial class ParticipationTabView : UserControl
if (owner is not null)
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="Verschieben" Command="{Binding MoveLessonCommand}" 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}"/>
</StackPanel>
</Grid>
@@ -41,6 +41,7 @@ public partial class PlanningTabView : UserControl
vm.OnShowLesson = ShowLessonViewerDialog;
vm.OnGenerateLessonSeries = ShowGenerateLessonSeriesDialog;
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).
### 3.3 Sitzungen
- [ ] **3.3.1** Sitzung automatisch aus einer geplanten `Lesson` erzeugen
(Datum + Thema übernehmen) — Abhängigkeit zu 4.2.
- [x] **3.3.1** Sitzung automatisch aus einer geplanten `Lesson` erzeugen
(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`
(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
über alle Stundentypen hinweg, nur innerhalb von Mitarbeit-Sitzungen).
- [ ] **3.3.3** Sitzung bearbeiten/löschen inkl. Rückfrage bei vorhandenen Bewertungen.
- [ ] **3.3.4** Sitzungen mehrerer Gruppen im Kalenderüberblick.
- [x] **3.3.3** Sitzung bearbeiten/löschen inkl. Rückfrage bei vorhandenen Bewertungen.
**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)
@@ -338,6 +359,13 @@ gruppenspezifischen Tab "Planung" (`GroupDetailView`), siehe unten.
`NotificationService.ShowSuccess`, bis dahin nur `ShowError` im Einsatz). Neue Stunden haben
bewusst kein Thema (Platzhalter zum Ausfüllen) — die "Thema erforderlich"-Pflicht des
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
[GroupDetailView.axaml](LehrerApp.Desktop/Views/Groups/GroupDetailView.axaml)