2 Commits
Author SHA1 Message Date
admin 50f3b2d6a9 feat: add seating plan drag and quick assessment 2026-08-17 01:49:05 +02:00
admin ad40d11d6e feat: complete dashboard chapter 2026-08-17 01:14:31 +02:00
20 changed files with 1650 additions and 93 deletions
@@ -0,0 +1,62 @@
using System.Text.Json;
namespace LehrerApp.Core.Services;
public sealed class DashboardCardSetting
{
public string Key { get; set; } = "";
public bool IsVisible { get; set; } = true;
public int Order { get; set; }
}
/// <summary>Speichert Sichtbarkeit und Reihenfolge der Dashboard-Kacheln lokal.</summary>
public sealed class DashboardSettingsService
{
public static readonly string[] DefaultCardOrder =
[
"today", "tasks", "calendar", "excuses", "upcoming",
"corrections", "alerts", "attendance", "support", "groups",
];
private readonly string _configPath;
public DashboardSettingsService(string appDataPath) =>
_configPath = Path.Combine(appDataPath, "dashboardsettings.json");
public List<DashboardCardSetting> Load()
{
try
{
if (File.Exists(_configPath))
{
var saved = JsonSerializer.Deserialize<List<DashboardCardSetting>>(
File.ReadAllText(_configPath)) ?? [];
var byKey = saved
.Where(s => DefaultCardOrder.Contains(s.Key))
.GroupBy(s => s.Key).ToDictionary(g => g.Key, g => g.First());
return DefaultCardOrder.Select((key, defaultOrder) => byKey.TryGetValue(key, out var item)
? new DashboardCardSetting { Key = key, IsVisible = item.IsVisible, Order = item.Order }
: new DashboardCardSetting { Key = key, IsVisible = true, Order = defaultOrder })
.OrderBy(s => s.Order).ThenBy(s => Array.IndexOf(DefaultCardOrder, s.Key))
.Select((s, index) => new DashboardCardSetting
{ Key = s.Key, IsVisible = s.IsVisible, Order = index })
.ToList();
}
}
catch { /* beschädigte Konfiguration -> Standardreihenfolge */ }
return DefaultCardOrder.Select((key, index) => new DashboardCardSetting
{ Key = key, IsVisible = true, Order = index }).ToList();
}
public void Save(IEnumerable<DashboardCardSetting> settings)
{
var normalized = settings.Select((s, index) => new DashboardCardSetting
{
Key = s.Key,
IsVisible = s.IsVisible,
Order = index,
}).ToList();
File.WriteAllText(_configPath, JsonSerializer.Serialize(normalized));
}
}
+32
View File
@@ -299,6 +299,38 @@ public sealed class RepositoryTests
Assert.Throws<InvalidOperationException>(() => new SeatingPlanRepository(db).Save(plan));
}
[Fact]
public void SeatingPlanRepository_AktualisiertPlanMitFehlendemOptionalenRaum()
{
using var db = NewInMemoryContext();
var group = new LearningGroup { Name = "8a", SchoolYear = "2025/26" };
new GroupRepository(db).Save(group);
var student = new Student { FirstName = "Anna", LastName = "A" };
db.Students.Insert(student);
db.Memberships.Insert(new GroupMembership { GroupId = group.Id, StudentId = student.Id });
var plan = new SeatingPlan
{
GroupId = group.Id,
Name = "Standard",
Room = null!,
Rows = 2,
Columns = 2,
};
db.SeatingPlans.Insert(plan);
plan.Assignments.Add(new SeatAssignment { Row = 0, Column = 0, StudentId = student.Id });
new SeatingPlanRepository(db).Save(plan);
var saved = db.SeatingPlans.FindById(plan.Id);
Assert.NotNull(saved);
Assert.True(string.IsNullOrEmpty(saved.Room));
Assert.Equal(student.Id, saved.Assignments.Single().StudentId);
// LiteDB may materialize the empty optional value as null again. A later
// drag/drop save must therefore remain safe as well.
new SeatingPlanRepository(db).Save(saved);
}
[Fact]
public void GroupMembershipRepository_Save_LehntZweiteZuordnungFuerGleichesPaarAb()
{
@@ -162,8 +162,11 @@ public class SeatingPlanRepository(LiteDbContext db) : ISeatingPlanRepository
public void Save(SeatingPlan plan)
{
ArchivedGroupWriteGuard.EnsureActive(db, plan.GroupId);
plan.Name = plan.Name.Trim();
plan.Room = plan.Room.Trim();
// LiteDB can deserialize missing/legacy optional string fields as null even
// though the current model initializes them with an empty string.
plan.Name = plan.Name?.Trim() ?? "";
plan.Room = plan.Room?.Trim() ?? "";
plan.Assignments ??= [];
if (plan.Name.Length == 0)
throw new ArgumentException("Der Name des Sitzplans darf nicht leer sein.");
if (plan.Rows is < 1 or > 10 || plan.Columns is < 1 or > 10)
@@ -18,17 +18,31 @@ public sealed class DashboardViewModelTests
return new PeriodScheduleService(tempPath);
}
private static DashboardSettingsService NewDashboardSettings()
{
var tempPath = System.IO.Path.Combine(
System.IO.Path.GetTempPath(), $"lehrerapp-dashboardsettings-tests-{Guid.NewGuid():N}");
Directory.CreateDirectory(tempPath);
return new DashboardSettingsService(tempPath);
}
private static DashboardViewModel BuildVm(LearningGroup group, Lesson lesson,
FakeTimetableSlots? slots = null, PeriodScheduleService? periodSchedule = null)
FakeTimetableSlots? slots = null, PeriodScheduleService? periodSchedule = null,
FakeExams? exams = null, FakeResults? results = null, FakeGrades? grades = null,
FakeReportGrades? reportGrades = null, FakeMemberships? memberships = null,
FakeWorkTasks? tasks = null, FakeStudents? students = null, FakeDocumentation? documentation = null,
DashboardSettingsService? dashboardSettings = null)
{
var lessons = new FakeLessons();
lessons.Add(lesson);
return new DashboardViewModel(
new FakeGroups([group]), new FakeSubjects([]), lessons,
new FakeExams([]), new FakeWorkTasks(), new FakeSessions([]), new FakeEntries(),
new FakeStudents([]), new FakeDocumentation(),
exams ?? new FakeExams([]), results ?? new FakeResults(), grades ?? new FakeGrades(),
reportGrades ?? new FakeReportGrades(), memberships ?? new FakeMemberships([]),
tasks ?? new FakeWorkTasks(), new FakeSessions([]), new FakeEntries(),
students ?? new FakeStudents([]), documentation ?? new FakeDocumentation(),
slots ?? new FakeTimetableSlots(), periodSchedule ?? NewPeriodSchedule(),
new AttendanceBalanceService(), new SchoolYearService());
new AttendanceBalanceService(), new SchoolYearService(), dashboardSettings ?? NewDashboardSettings());
}
[Fact]
@@ -108,4 +122,109 @@ public sealed class DashboardViewModelTests
Assert.Equal(group.Id, navigatedTo);
}
[Fact]
public void UpcomingDates_BuendeltKlausurenFristenUndFoerderplanPruefungen()
{
var today = DateOnly.FromDateTime(DateTime.Today);
var group = new LearningGroup { Name = "9c" };
var exams = new FakeExams([
new Exam { GroupId = group.Id, Title = "Chemie-Test", Date = today.AddDays(5) },
]);
var tasks = new FakeWorkTasks();
tasks.Add(new WorkTask { Title = "Notenschluss", DueDate = today.AddDays(10) });
var student = new Student { FirstName = "Anna", LastName = "Beispiel" };
var documentation = new FakeDocumentation();
documentation.Add(new Documentation
{
StudentId = student.Id, Type = DocumentationType.SupportPlan, Title = "Leseförderung",
SupportData = new SupportData { Status = SupportStatus.Active, ReviewDate = today.AddDays(3) },
});
var vm = BuildVm(group, new Lesson { GroupId = group.Id, Date = today }, exams: exams,
tasks: tasks, students: new FakeStudents([student]), documentation: documentation);
Assert.Contains(vm.UpcomingDates, i => i.Kind == UpcomingDateKind.Exam && i.Title == "Chemie-Test");
Assert.Contains(vm.UpcomingDates, i => i.Kind == UpcomingDateKind.Deadline && i.Title == "Notenschluss");
Assert.Contains(vm.UpcomingDates, i => i.Kind == UpcomingDateKind.SupportPlan && i.StudentId == student.Id);
}
[Fact]
public void OpenCorrections_ZeigtBewertungsfortschrittJeKlausur()
{
var today = DateOnly.FromDateTime(DateTime.Today);
var group = new LearningGroup { Name = "9c" };
var exam = new Exam
{ GroupId = group.Id, Title = "Klausur 1", Date = today.AddDays(-2), Status = ExamStatus.Conducted };
var anna = new Student { FirstName = "Anna", LastName = "A" };
var ben = new Student { FirstName = "Ben", LastName = "B" };
var memberships = new FakeMemberships([
new GroupMembership { GroupId = group.Id, StudentId = anna.Id },
new GroupMembership { GroupId = group.Id, StudentId = ben.Id },
]);
var results = new FakeResults();
results.Add(new ExamResult { ExamId = exam.Id, StudentId = anna.Id, Grade = "2" });
var vm = BuildVm(group, new Lesson { GroupId = group.Id, Date = today },
exams: new FakeExams([exam]), results: results, memberships: memberships,
students: new FakeStudents([anna, ben]));
var correction = Assert.Single(vm.OpenCorrections);
Assert.Equal(1, correction.Completed);
Assert.Equal(2, correction.Total);
Assert.Equal(50, correction.Percent);
}
[Fact]
public void Alerts_ErkenntDeutlichenNotenabfall()
{
var today = DateOnly.FromDateTime(DateTime.Today);
var group = new LearningGroup { Name = "9c", GradingSystem = GradingSystem.Grades1To6 };
var student = new Student { FirstName = "Anna", LastName = "Beispiel" };
var memberships = new FakeMemberships([
new GroupMembership { GroupId = group.Id, StudentId = student.Id },
]);
var grades = new FakeGrades();
grades.Add(new Grade { GroupId = group.Id, StudentId = student.Id, Date = today.AddDays(-20), Value = "2" });
grades.Add(new Grade { GroupId = group.Id, StudentId = student.Id, Date = today.AddDays(-15), Value = "2" });
grades.Add(new Grade { GroupId = group.Id, StudentId = student.Id, Date = today.AddDays(-10), Value = "4" });
grades.Add(new Grade { GroupId = group.Id, StudentId = student.Id, Date = today.AddDays(-5), Value = "4" });
var vm = BuildVm(group, new Lesson { GroupId = group.Id, Date = today }, grades: grades,
memberships: memberships, students: new FakeStudents([student]));
Assert.Contains(vm.Alerts, a => a.StudentId == student.Id && a.KindLabel == "Notenabfall");
}
[Fact]
public void Kalenderauswahl_ZeigtTermineDesGeklicktenTages()
{
var today = DateOnly.FromDateTime(DateTime.Today);
var group = new LearningGroup { Name = "9c" };
var examDate = today.AddDays(2);
var vm = BuildVm(group, new Lesson { GroupId = group.Id, Date = today },
exams: new FakeExams([new Exam { GroupId = group.Id, Title = "Test", Date = examDate }]));
var day = vm.CalendarDays.Single(d => d.Date == examDate);
vm.SelectCalendarDayCommand.Execute(day);
Assert.True(day.IsSelected);
Assert.Contains(vm.SelectedDayEvents, e => e.Kind == CalendarEventKind.Exam && e.Title == "Test");
}
[Fact]
public void DashboardKacheln_SichtbarkeitUndReihenfolgeWerdenGespeichert()
{
var settings = NewDashboardSettings();
var today = DateOnly.FromDateTime(DateTime.Today);
var group = new LearningGroup { Name = "9c" };
var vm = BuildVm(group, new Lesson { GroupId = group.Id, Date = today }, dashboardSettings: settings);
vm.TodayCard.IsVisible = false;
vm.MoveCardDownCommand.Execute(vm.TasksCard);
var saved = settings.Load();
Assert.False(saved.Single(c => c.Key == "today").IsVisible);
Assert.True(saved.FindIndex(c => c.Key == "tasks") > 1);
}
}
+6 -2
View File
@@ -78,8 +78,12 @@ public class FakeSessions(List<ParticipationSession> all) : IParticipationSessio
{
public List<ParticipationSession> GetByGroup(Guid groupId) => all.Where(s => s.GroupId == groupId).ToList();
public ParticipationSession? GetById(Guid id) => all.FirstOrDefault(s => s.Id == id);
public void Save(ParticipationSession session) { }
public void Delete(Guid id) { }
public void Save(ParticipationSession session)
{
all.RemoveAll(s => s.Id == session.Id);
all.Add(session);
}
public void Delete(Guid id) => all.RemoveAll(s => s.Id == id);
}
public class FakeEntries : IParticipationRepository
@@ -25,7 +25,8 @@ public sealed class GroupDetailViewModelTests
new FakeCompetencyDomains(), TestSupport.BuildAiSettingsService()),
new CompetencyOverviewTabViewModel(new FakeUnits(), exams, new FakeResults(),
new FakeCompetencyDomains(), students, new CompetencyAnalysisService()),
new SeatingPlanTabViewModel(new FakeSeatingPlans(), students, memberships));
new SeatingPlanTabViewModel(new FakeSeatingPlans(), students, memberships,
new FakeSessions([]), new FakeEntries(), new FakeAspects()));
vm.LoadGroup(group.Id);
vm.SelectedExam = vm.Exams.First(e => e.Id == exam.Id);
@@ -24,7 +24,8 @@ public sealed class SeatingPlanViewModelTests
Assignments = [new SeatAssignment { Row = 0, Column = 0, StudentId = student.Id }],
};
var plans = new FakeSeatingPlans([plan]);
var vm = new SeatingPlanTabViewModel(plans, students, memberships);
var vm = new SeatingPlanTabViewModel(plans, students, memberships,
new FakeSessions([]), new FakeEntries(), new FakeAspects());
vm.Initialize(groupId, isReadOnly: false);
vm.Seats[1].SelectedOption = vm.StudentOptions.Single(o => o.StudentId == student.Id);
@@ -40,7 +41,8 @@ public sealed class SeatingPlanViewModelTests
var groupId = Guid.NewGuid();
var plan = new SeatingPlan { GroupId = groupId, Name = "Standard", Rows = 1, Columns = 1 };
var vm = new SeatingPlanTabViewModel(
new FakeSeatingPlans([plan]), new FakeStudents([]), new FakeMemberships([]));
new FakeSeatingPlans([plan]), new FakeStudents([]), new FakeMemberships([]),
new FakeSessions([]), new FakeEntries(), new FakeAspects());
vm.Initialize(groupId, isReadOnly: true);
@@ -48,4 +50,74 @@ public sealed class SeatingPlanViewModelTests
Assert.False(vm.Seats.Single().CanEdit);
Assert.False(vm.AddPlanCommand.CanExecute(null));
}
[Fact]
public void DragDrop_ZwischenBelegtenPlaetzen_TauschtDieSchueler()
{
var groupId = Guid.NewGuid();
var anna = new Student { FirstName = "Anna", LastName = "A" };
var ben = new Student { FirstName = "Ben", LastName = "B" };
var students = new FakeStudents([anna, ben]);
var memberships = new FakeMemberships([
new GroupMembership { GroupId = groupId, StudentId = anna.Id },
new GroupMembership { GroupId = groupId, StudentId = ben.Id },
]);
var plan = new SeatingPlan
{
GroupId = groupId, Name = "Standard", Rows = 1, Columns = 2,
Assignments =
[
new SeatAssignment { Row = 0, Column = 0, StudentId = anna.Id },
new SeatAssignment { Row = 0, Column = 1, StudentId = ben.Id },
],
};
var plans = new FakeSeatingPlans([plan]);
var vm = new SeatingPlanTabViewModel(plans, students, memberships,
new FakeSessions([]), new FakeEntries(), new FakeAspects());
vm.Initialize(groupId, isReadOnly: false);
vm.MoveSeat(vm.Seats[0], vm.Seats[1]);
Assert.Equal(ben.Id, vm.Seats[0].SelectedOption.StudentId);
Assert.Equal(anna.Id, vm.Seats[1].SelectedOption.StudentId);
Assert.Equal(2, plans.GetById(plan.Id)!.Assignments.Count);
}
[Fact]
public void SitzplatzBewertung_ErstelltHeutigeSitzungUndSpeichertAlleDreiBereiche()
{
var groupId = Guid.NewGuid();
var studentId = Guid.NewGuid();
var sessions = new FakeSessions([]);
var entries = new FakeEntries();
var vm = new SeatAssessmentViewModel(sessions, entries, new FakeAspects(),
groupId, studentId, "Beispiel, Anna", canEdit: true);
vm.SetRatingByNumber(5);
vm.ApplyAttendanceShortcut(1, clear: false);
vm.ApplyHomeworkShortcut(7, clear: false);
var session = Assert.Single(sessions.GetByGroup(groupId));
Assert.Equal(DateOnly.FromDateTime(DateTime.Today), session.Date);
Assert.Equal("Sitzplan", session.Comment);
var entry = entries.GetBySessionAndStudent(session.Id, studentId)!;
Assert.Equal(2, entry.Ratings.Single(r => r.Key == "quality").Value);
Assert.Equal(AttendanceStatus.Present, entry.Attendance);
Assert.Equal(HomeworkStatus.MissingOpen, entry.Homework);
Assert.True(entry.HomeworkMissing);
}
[Fact]
public void SitzplatzBewertung_ArchiviertOhneSitzung_LegtKeineNeueSitzungAn()
{
var groupId = Guid.NewGuid();
var sessions = new FakeSessions([]);
var vm = new SeatAssessmentViewModel(sessions, new FakeEntries(), new FakeAspects(),
groupId, Guid.NewGuid(), "Beispiel, Anna", canEdit: false);
vm.SetRatingByNumber(5);
Assert.False(vm.CanEdit);
Assert.Empty(sessions.GetByGroup(groupId));
}
}
+3 -2
View File
@@ -99,7 +99,8 @@ public class App : Application
var dash = Services.GetRequiredService<DashboardViewModel>();
dash.OnNavigateToGroup = id => main.NavigateToGroupDetail(id);
dash.OnNavigateToStudent = id => main.NavigateToStudent(id);
dash.OnNavigateToLesson = id => main.NavigateToGroupDetail(id, 2); // Tab "Mitarbeit"
dash.OnNavigateToLesson = id => main.NavigateToGroupDetail(id, 3); // Tab "Mitarbeit"
dash.OnNavigateToExam = id => main.NavigateToGroupDetail(id, 4); // Tab "Klausuren"
// StudentList → StudentDetail + Anlegen
var sl = Services.GetRequiredService<StudentListViewModel>();
@@ -109,7 +110,7 @@ public class App : Application
// Stundenplan "Heute" → GroupDetail (Tab "Planung") / Einstellungen (Zahnrad, Tab "Ferien & Feiertage")
var timetable = Services.GetRequiredService<TimetableViewModel>();
timetable.OnNavigateToSettings = () => main.NavigateToSettings(7);
timetable.OnNavigateToGroup = id => main.NavigateToGroupDetail(id, 5);
timetable.OnNavigateToGroup = id => main.NavigateToGroupDetail(id, 6);
}
private static async Task ShowAddStudentDialog()
+1
View File
@@ -154,6 +154,7 @@ public static class AppBootstrapper
services.AddSingleton(_ => new SchoolCalendarSettingsService(appData));
services.AddSingleton(_ => new PeriodScheduleService(appData));
services.AddSingleton(_ => new WorkloadSettingsService(appData));
services.AddSingleton(_ => new DashboardSettingsService(appData));
services.AddSingleton(_ => new LetterTemplateService(appData));
// ── KI-Unterstützung (4.5.9, optional nur wenn in den Einstellungen aktiviert) ──────
@@ -16,6 +16,10 @@ public partial class DashboardViewModel : ObservableObject
private readonly ISubjectRepository _subjects;
private readonly ILessonRepository _lessons;
private readonly IExamRepository _exams;
private readonly IExamResultRepository _examResults;
private readonly IGradeRepository _grades;
private readonly IReportGradeRepository _reportGrades;
private readonly IGroupMembershipRepository _memberships;
private readonly IWorkTaskRepository _tasks;
private readonly IParticipationSessionRepository _participationSessions;
private readonly IParticipationRepository _participationEntries;
@@ -25,14 +29,18 @@ public partial class DashboardViewModel : ObservableObject
private readonly PeriodScheduleService _periodSchedule;
private readonly AttendanceBalanceService _attendanceBalance;
private readonly SchoolYearService _sy;
private readonly DashboardSettingsService _dashboardSettings;
private const int OpenExcuseMaxAgeDays = 21;
private const int SupportPlanDueWithinDays = 14;
private const int UpcomingWithinDays = 30;
[ObservableProperty] private string _greeting = "";
[ObservableProperty] private string _currentDate = "";
[ObservableProperty] private string _currentSchoolYear = "";
[ObservableProperty] private DateOnly _calendarMonth = FirstOfMonth(DateTime.Today);
[ObservableProperty] private string _selectedDayLabel = "";
[ObservableProperty] private bool _isDashboardSettingsOpen;
public string CalendarMonthLabel => CalendarMonth.ToString("MMMM yyyy", De);
@@ -43,6 +51,11 @@ public partial class DashboardViewModel : ObservableObject
public ObservableCollection<OpenExcuseItem> OpenExcuses { get; } = [];
public ObservableCollection<AttendanceWarningItem> AttendanceWarnings { get; } = [];
public ObservableCollection<SupportPlanDueItem> SupportPlanReviews { get; } = [];
public ObservableCollection<UpcomingDateItem> UpcomingDates { get; } = [];
public ObservableCollection<CorrectionProgressItem> OpenCorrections { get; } = [];
public ObservableCollection<DashboardAlertItem> Alerts { get; } = [];
public ObservableCollection<CalendarEventItem> SelectedDayEvents { get; } = [];
public ObservableCollection<DashboardCardOption> DashboardCards { get; } = [];
public static string[] CalendarWeekdayHeaders { get; } = ["Mo", "Di", "Mi", "Do", "Fr", "Sa", "So"];
// Navigation-Callback wird von App.axaml.cs verdrahtet
@@ -52,21 +65,40 @@ public partial class DashboardViewModel : ObservableObject
// OnNavigateToGroup (Lerngruppen-Kacheln, Tab "Übersicht"), da der Sprung von einer konkreten
// Stunde aus sinnvollerweise direkt in die Mitarbeitserfassung führt.
public Action<Guid>? OnNavigateToLesson { get; set; }
public Action<Guid>? OnNavigateToExam { get; set; }
public DashboardCardOption TodayCard => Card("today");
public DashboardCardOption TasksCard => Card("tasks");
public DashboardCardOption CalendarCard => Card("calendar");
public DashboardCardOption ExcusesCard => Card("excuses");
public DashboardCardOption UpcomingCard => Card("upcoming");
public DashboardCardOption CorrectionsCard => Card("corrections");
public DashboardCardOption AlertsCard => Card("alerts");
public DashboardCardOption AttendanceCard => Card("attendance");
public DashboardCardOption SupportCard => Card("support");
public DashboardCardOption GroupsCard => Card("groups");
public DashboardViewModel(IGroupRepository groups, ISubjectRepository subjects, ILessonRepository lessons,
IExamRepository exams, IWorkTaskRepository tasks, IParticipationSessionRepository participationSessions,
IExamRepository exams, IExamResultRepository examResults, IGradeRepository grades,
IReportGradeRepository reportGrades, IGroupMembershipRepository memberships,
IWorkTaskRepository tasks, IParticipationSessionRepository participationSessions,
IParticipationRepository participationEntries, IStudentRepository students,
IDocumentationRepository documentation, ITimetableSlotRepository timetableSlots,
PeriodScheduleService periodSchedule, AttendanceBalanceService attendanceBalance, SchoolYearService sy)
PeriodScheduleService periodSchedule, AttendanceBalanceService attendanceBalance, SchoolYearService sy,
DashboardSettingsService dashboardSettings)
{
_groups = groups; _subjects = subjects; _lessons = lessons; _exams = exams; _tasks = tasks;
_examResults = examResults; _grades = grades; _reportGrades = reportGrades; _memberships = memberships;
_participationSessions = participationSessions; _participationEntries = participationEntries;
_students = students; _documentation = documentation;
_timetableSlots = timetableSlots; _periodSchedule = periodSchedule;
_attendanceBalance = attendanceBalance; _sy = sy;
_attendanceBalance = attendanceBalance; _sy = sy; _dashboardSettings = dashboardSettings;
LoadDashboardCards();
Load();
}
private DashboardCardOption Card(string key) => DashboardCards.First(c => c.Key == key);
private static DateOnly FirstOfMonth(DateTime d) => new(d.Year, d.Month, 1);
private void Load()
@@ -123,6 +155,9 @@ public partial class DashboardViewModel : ObservableObject
LoadOpenExcuses(groups.Values.ToList(), today);
LoadAttendanceWarnings(today);
LoadSupportPlanReviews(today);
LoadUpcomingDates(groups, today);
LoadOpenCorrections(groups, today);
LoadAlerts(groups, today);
}
// ── Fehlzeiten-Warnung (5.2.3) ────────────────────────────────────────────
@@ -173,6 +208,174 @@ public partial class DashboardViewModel : ObservableObject
}
}
// ── Anstehende Termine (9.3) ─────────────────────────────────────────────
private void LoadUpcomingDates(IReadOnlyDictionary<Guid, LearningGroup> groups, DateOnly today)
{
UpcomingDates.Clear();
var dueBy = today.AddDays(UpcomingWithinDays);
var items = new List<UpcomingDateItem>();
foreach (var group in groups.Values)
foreach (var exam in _exams.GetByGroup(group.Id)
.Where(e => e.Date >= today && e.Date <= dueBy && e.Status == ExamStatus.Planned))
items.Add(new UpcomingDateItem(UpcomingDateKind.Exam, exam.Date, exam.Title,
group.Name, group.Id, null, today));
foreach (var task in _tasks.GetByStatus(WorkTaskStatus.Open)
.Concat(_tasks.GetByStatus(WorkTaskStatus.InProgress))
.Where(t => t.DueDate.HasValue && t.DueDate.Value <= dueBy))
items.Add(new UpcomingDateItem(UpcomingDateKind.Deadline, task.DueDate!.Value,
task.Title, task.GroupId is Guid groupId && groups.TryGetValue(groupId, out var group)
? group.Name : "Aufgabe", task.GroupId, null, today));
foreach (var doc in _documentation.GetAll()
.Where(d => d.Type == DocumentationType.SupportPlan
&& d.SupportData is { Status: SupportStatus.Active, ReviewDate: not null }
&& d.SupportData.ReviewDate.Value <= dueBy))
{
var student = _students.GetById(doc.StudentId);
if (student is not null)
items.Add(new UpcomingDateItem(UpcomingDateKind.SupportPlan,
doc.SupportData!.ReviewDate!.Value, doc.Title, student.FullName,
doc.GroupId, doc.StudentId, today));
}
foreach (var item in items.OrderBy(i => i.Date).ThenBy(i => i.Title).Take(8))
UpcomingDates.Add(item);
}
// ── Offene Korrekturen (9.4) ─────────────────────────────────────────────
private void LoadOpenCorrections(IReadOnlyDictionary<Guid, LearningGroup> groups, DateOnly today)
{
OpenCorrections.Clear();
foreach (var group in groups.Values)
foreach (var exam in _exams.GetByGroup(group.Id)
.Where(e => e.Status is ExamStatus.Conducted or ExamStatus.Graded)
.OrderBy(e => e.Date))
{
var expected = _memberships.GetByGroup(group.Id)
.Count(m => GroupMembershipService.IsActiveOn(m, exam.Date));
var evaluated = _examResults.GetByExam(exam.Id)
.Count(r => r.Absent || !string.IsNullOrWhiteSpace(r.Grade) || r.Points.Count > 0);
OpenCorrections.Add(new CorrectionProgressItem(exam.Id, group.Id, exam.Title,
group.Name, exam.Date, Math.Min(evaluated, expected), expected, today));
}
}
// ── Auffälligkeiten (9.5) ────────────────────────────────────────────────
private void LoadAlerts(IReadOnlyDictionary<Guid, LearningGroup> groups, DateOnly today)
{
Alerts.Clear();
foreach (var warning in AttendanceWarnings)
Alerts.Add(new DashboardAlertItem(warning.StudentId, null, warning.StudentName,
"Fehlzeiten", $"Fehlzeitenquote {warning.AbsenceRatePercent:0.#} %", AlertSeverity.High));
foreach (var group in groups.Values)
{
var groupReportGrades = _reportGrades.GetByGroup(group.Id);
foreach (var membership in _memberships.GetByGroup(group.Id)
.Where(m => GroupMembershipService.IsActiveOn(m, today)))
{
var student = _students.GetById(membership.StudentId);
if (student is null) continue;
var values = _grades.GetByStudentAndGroup(student.Id, group.Id)
.OrderBy(g => g.Date)
.Select(g => int.TryParse(g.Value, out var value) ? (int?)value : null)
.Where(v => v.HasValue).Select(v => v!.Value).ToList();
if (values.Count >= 4)
{
var previous = values.TakeLast(4).Take(2).Average();
var recent = values.TakeLast(2).Average();
var declined = group.GradingSystem == GradingSystem.Grades1To6
? recent - previous >= 1.0
: previous - recent >= 3.0;
if (declined)
Alerts.Add(new DashboardAlertItem(student.Id, group.Id, student.FullName,
"Notenabfall", $"{group.Name}: zuletzt {recent:0.0}, zuvor {previous:0.0}",
AlertSeverity.Medium));
}
var latestReport = groupReportGrades
.Where(r => r.StudentId == student.Id)
.OrderByDescending(r => r.UpdatedAt).FirstOrDefault();
var effective = latestReport?.OverrideValue ?? latestReport?.CalculatedValue;
if (int.TryParse(effective, out var reportValue)
&& (group.GradingSystem == GradingSystem.Grades1To6 ? reportValue >= 5 : reportValue <= 4))
Alerts.Add(new DashboardAlertItem(student.Id, group.Id, student.FullName,
"Versetzungsgefährdung", $"{group.Name}: aktueller Stand {reportValue}",
AlertSeverity.High));
}
}
}
// ── Konfigurierbare Kacheln (9.6) ────────────────────────────────────────
private void LoadDashboardCards()
{
DashboardCards.Clear();
foreach (var setting in _dashboardSettings.Load())
{
var option = new DashboardCardOption(setting.Key, CardTitle(setting.Key), setting.IsVisible);
option.OnVisibilityChanged = SaveAndApplyCardLayout;
DashboardCards.Add(option);
}
ApplyCardLayout();
}
private static string CardTitle(string key) => key switch
{
"today" => "Heute", "tasks" => "Offene Aufgaben", "calendar" => "Kalender",
"excuses" => "Offene Entschuldigungen", "upcoming" => "Anstehende Termine",
"corrections" => "Offene Korrekturen", "alerts" => "Auffälligkeiten",
"attendance" => "Fehlzeiten-Warnung", "support" => "Förderplan-Wiedervorlage",
"groups" => "Meine Lerngruppen", _ => key,
};
private void ApplyCardLayout()
{
var visibleIndex = 0;
foreach (var card in DashboardCards)
{
var index = card.IsVisible ? visibleIndex++ : 0;
card.Row = index / 2;
card.Column = index % 2;
}
}
private void SaveAndApplyCardLayout()
{
ApplyCardLayout();
_dashboardSettings.Save(DashboardCards.Select((c, i) => new DashboardCardSetting
{ Key = c.Key, IsVisible = c.IsVisible, Order = i }));
}
[RelayCommand] private void ToggleDashboardSettings() =>
IsDashboardSettingsOpen = !IsDashboardSettingsOpen;
[RelayCommand]
private void MoveCardUp(DashboardCardOption? card)
{
if (card is null) return;
var index = DashboardCards.IndexOf(card);
if (index <= 0) return;
DashboardCards.Move(index, index - 1);
SaveAndApplyCardLayout();
}
[RelayCommand]
private void MoveCardDown(DashboardCardOption? card)
{
if (card is null) return;
var index = DashboardCards.IndexOf(card);
if (index < 0 || index >= DashboardCards.Count - 1) return;
DashboardCards.Move(index, index + 1);
SaveAndApplyCardLayout();
}
[RelayCommand] private void OpenStudentAttendance(AttendanceWarningItem? item)
{ if (item is not null) OnNavigateToStudent?.Invoke(item.StudentId); }
@@ -240,8 +443,8 @@ public partial class DashboardViewModel : ObservableObject
var agg = Agg(lesson.Date);
agg.HasLesson = true;
if (g.IsOwnClass) agg.IsOwnClassDay = true;
agg.Details.Add($"Unterricht: {g.Name}" +
(string.IsNullOrWhiteSpace(lesson.Topic) ? "" : $" {lesson.Topic}"));
agg.Details.Add(new CalendarEventItem(CalendarEventKind.Lesson, lesson.Date,
g.Name, lesson.Topic, g.Id));
}
foreach (var exam in _exams.GetByGroup(g.Id).Where(e => e.Date >= gridStart && e.Date <= gridEnd))
@@ -249,7 +452,8 @@ public partial class DashboardViewModel : ObservableObject
var agg = Agg(exam.Date);
agg.HasExam = true;
if (g.IsOwnClass) agg.IsOwnClassDay = true;
agg.Details.Add($"Klausur: {exam.Title} ({g.Name})");
agg.Details.Add(new CalendarEventItem(CalendarEventKind.Exam, exam.Date,
exam.Title, g.Name, g.Id));
}
}
@@ -261,6 +465,26 @@ public partial class DashboardViewModel : ObservableObject
agg?.HasLesson ?? false, agg?.HasExam ?? false, agg?.IsOwnClassDay ?? false,
agg?.Details ?? []));
}
SelectCalendarDay(CalendarDays.FirstOrDefault(d => d.Date == today && d.IsCurrentMonth)
?? CalendarDays.First(d => d.IsCurrentMonth));
}
[RelayCommand]
private void SelectCalendarDay(CalendarDayCell? day)
{
if (day is null) return;
foreach (var cell in CalendarDays) cell.IsSelected = cell == day;
SelectedDayLabel = day.Date.ToString("dddd, d. MMMM", De);
SelectedDayEvents.Clear();
foreach (var item in day.Events) SelectedDayEvents.Add(item);
}
[RelayCommand]
private void OpenCalendarEvent(CalendarEventItem? item)
{
if (item is null) return;
if (item.Kind == CalendarEventKind.Exam) OnNavigateToExam?.Invoke(item.GroupId);
else OnNavigateToLesson?.Invoke(item.GroupId);
}
[RelayCommand]
@@ -289,6 +513,19 @@ public partial class DashboardViewModel : ObservableObject
[RelayCommand] private void OpenGroup(GroupChip? c) { if (c is not null) OnNavigateToGroup?.Invoke(c.GroupId); }
[RelayCommand] private void OpenLesson(LessonItem? l) { if (l is not null) OnNavigateToLesson?.Invoke(l.GroupId); }
[RelayCommand] private void OpenUpcomingDate(UpcomingDateItem? item)
{
if (item?.StudentId is Guid studentId) OnNavigateToStudent?.Invoke(studentId);
else if (item?.GroupId is Guid groupId)
{
if (item.Kind == UpcomingDateKind.Exam) OnNavigateToExam?.Invoke(groupId);
else OnNavigateToGroup?.Invoke(groupId);
}
}
[RelayCommand] private void OpenCorrection(CorrectionProgressItem? item)
{ if (item is not null) OnNavigateToExam?.Invoke(item.GroupId); }
[RelayCommand] private void OpenAlert(DashboardAlertItem? item)
{ if (item is not null) OnNavigateToStudent?.Invoke(item.StudentId); }
[RelayCommand] private void Refresh() => Load();
private class DayAgg
@@ -296,7 +533,7 @@ public partial class DashboardViewModel : ObservableObject
public bool HasLesson;
public bool HasExam;
public bool IsOwnClassDay;
public List<string> Details { get; } = [];
public List<CalendarEventItem> Details { get; } = [];
}
}
@@ -369,8 +606,10 @@ public class SupportPlanDueItem
}
}
public class CalendarDayCell
public partial class CalendarDayCell : ObservableObject
{
[ObservableProperty] private bool _isSelected;
public DateOnly Date { get; }
public int DayNumber { get; }
public bool IsCurrentMonth { get; }
public bool IsToday { get; }
@@ -378,16 +617,103 @@ public class CalendarDayCell
public bool HasExam { 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<string> details)
bool hasLesson, bool hasExam, bool isOwnClassDay, List<CalendarEventItem> details)
{
Date = date;
DayNumber = date.Day;
IsCurrentMonth = isCurrentMonth;
IsToday = isToday;
HasLesson = hasLesson;
HasExam = hasExam;
IsOwnClassDay = isOwnClassDay;
Tooltip = details.Count == 0 ? date.ToString("dd.MM.yyyy") : string.Join("\n", details);
Events = details;
Tooltip = details.Count == 0 ? date.ToString("dd.MM.yyyy")
: string.Join("\n", details.Select(d => $"{d.KindLabel}: {d.Title}"));
}
}
public enum CalendarEventKind { Lesson, Exam }
public sealed class CalendarEventItem(CalendarEventKind kind, DateOnly date, string title,
string subtitle, Guid groupId)
{
public CalendarEventKind Kind { get; } = kind;
public DateOnly Date { get; } = date;
public string Title { get; } = title;
public string Subtitle { get; } = subtitle;
public Guid GroupId { get; } = groupId;
public string KindLabel => Kind == CalendarEventKind.Exam ? "Klausur" : "Unterricht";
}
public enum UpcomingDateKind { Exam, SupportPlan, Deadline }
public sealed class UpcomingDateItem(UpcomingDateKind kind, DateOnly date, string title,
string subtitle, Guid? groupId, Guid? studentId, DateOnly today)
{
public UpcomingDateKind Kind { get; } = kind;
public DateOnly Date { get; } = date;
public string Title { get; } = title;
public string Subtitle { get; } = subtitle;
public Guid? GroupId { get; } = groupId;
public Guid? StudentId { get; } = studentId;
public bool IsOverdue { get; } = date < today;
public string DateDisplay => Date.ToString("dd.MM.");
public string KindLabel => Kind switch
{
UpcomingDateKind.Exam => "Klausur",
UpcomingDateKind.SupportPlan => "Förderplan",
_ => "Frist",
};
}
public sealed class CorrectionProgressItem(Guid examId, Guid groupId, string title, string groupName,
DateOnly date, int completed, int total, DateOnly today)
{
public Guid ExamId { get; } = examId;
public Guid GroupId { get; } = groupId;
public string Title { get; } = title;
public string GroupName { get; } = groupName;
public DateOnly Date { get; } = date;
public int Completed { get; } = completed;
public int Total { get; } = total;
public int Percent => Total == 0 ? 0 : (int)Math.Round(Completed * 100.0 / Total);
public string ProgressDisplay => $"{Completed} von {Total} Arbeiten bewertet";
public string DateDisplay => Date.ToString("dd.MM.yyyy");
public bool IsOverdue => Date < today.AddDays(-7) && Completed < Total;
}
public enum AlertSeverity { Medium, High }
public sealed class DashboardAlertItem(Guid studentId, Guid? groupId, string studentName,
string kindLabel, string detail, AlertSeverity severity)
{
public Guid StudentId { get; } = studentId;
public Guid? GroupId { get; } = groupId;
public string StudentName { get; } = studentName;
public string KindLabel { get; } = kindLabel;
public string Detail { get; } = detail;
public AlertSeverity Severity { get; } = severity;
public string SeverityColor => Severity == AlertSeverity.High ? "#D32F2F" : "#F59E0B";
}
public partial class DashboardCardOption : ObservableObject
{
[ObservableProperty] private bool _isVisible;
[ObservableProperty] private int _row;
[ObservableProperty] private int _column;
public string Key { get; }
public string Title { get; }
public Action? OnVisibilityChanged { get; set; }
public DashboardCardOption(string key, string title, bool isVisible)
{
Key = key;
Title = title;
_isVisible = isVisible;
}
partial void OnIsVisibleChanged(bool value) => OnVisibilityChanged?.Invoke();
}
@@ -243,6 +243,11 @@ public partial class GroupDetailViewModel : ObservableObject
PlanningTab = planningTab;
CompetencyOverviewTab = competencyOverviewTab;
SeatingPlanTab = seatingPlanTab;
SeatingPlanTab.OnAssessmentChanged = () =>
{
ParticipationTab.LoadSessions();
ParticipationTab.RefreshCurrentGrid();
};
}
public void LoadGroup(Guid id)
@@ -108,11 +108,11 @@ public partial class ParticipationTabViewModel : ObservableObject
public void LoadSessions()
{
var selectedId = SelectedSession?.Id;
Sessions.Clear();
foreach (var s in _sessions.GetByGroup(_groupId))
Sessions.Add(new ParticipationSessionItem(s));
if (SelectedSession is null && Sessions.Any())
SelectedSession = Sessions[0];
SelectedSession = Sessions.FirstOrDefault(s => s.Id == selectedId) ?? Sessions.FirstOrDefault();
}
partial void OnSelectedSessionChanged(ParticipationSessionItem? value)
@@ -12,6 +12,9 @@ public partial class SeatingPlanTabViewModel : ObservableObject
private readonly ISeatingPlanRepository _plans;
private readonly IStudentRepository _students;
private readonly IGroupMembershipRepository _memberships;
private readonly IParticipationSessionRepository _sessions;
private readonly IParticipationRepository _participation;
private readonly IParticipationAspectRepository _aspects;
private Guid _groupId;
private SeatingPlan? _currentPlan;
private bool _isReadOnly;
@@ -25,19 +28,26 @@ public partial class SeatingPlanTabViewModel : ObservableObject
public ObservableCollection<SeatingPlanSummary> Plans { get; } = [];
public ObservableCollection<SeatCellViewModel> Seats { get; } = [];
public ObservableCollection<StudentSeatOption> StudentOptions { get; } = [];
public ObservableCollection<StudentSeatOption> UnassignedStudents { get; } = [];
public bool HasPlans => Plans.Count > 0;
public bool HasSelectedPlan => _currentPlan is not null;
public bool IsEditable => !_isReadOnly;
public Func<SeatingPlan?, Task<SeatingPlan?>>? OnEditPlan { get; set; }
public Func<SeatingPlanSummary, Task<bool>>? OnConfirmDelete { get; set; }
public Func<SeatAssessmentViewModel, Task>? OnAssessStudent { get; set; }
public Action? OnAssessmentChanged { get; set; }
public SeatingPlanTabViewModel(ISeatingPlanRepository plans, IStudentRepository students,
IGroupMembershipRepository memberships)
IGroupMembershipRepository memberships, IParticipationSessionRepository sessions,
IParticipationRepository participation, IParticipationAspectRepository aspects)
{
_plans = plans;
_students = students;
_memberships = memberships;
_sessions = sessions;
_participation = participation;
_aspects = aspects;
}
public SeatingPlanDialogViewModel CreateDialogViewModel(SeatingPlan? plan) =>
@@ -87,6 +97,7 @@ public partial class SeatingPlanTabViewModel : ObservableObject
Seats.Clear();
if (plan is null)
{
UnassignedStudents.Clear();
PlanColumns = 1;
PlanTitle = "";
PlanSubtitle = "";
@@ -124,6 +135,49 @@ public partial class SeatingPlanTabViewModel : ObservableObject
other.SetSelectionSilently(StudentSeatOption.Empty);
}
SaveSeatAssignments();
}
private void UpdateAssignmentSummary()
{
var assigned = Seats.Count(s => s.SelectedOption.StudentId.HasValue);
var total = StudentOptions.Count - 1;
AssignmentSummary = $"{assigned} von {total} Schülern zugeordnet";
var assignedIds = Seats.Where(s => s.SelectedOption.StudentId.HasValue)
.Select(s => s.SelectedOption.StudentId!.Value).ToHashSet();
UnassignedStudents.Clear();
foreach (var option in StudentOptions.Where(o => o.StudentId.HasValue && !assignedIds.Contains(o.StudentId.Value)))
UnassignedStudents.Add(option);
}
public void MoveSeat(SeatCellViewModel source, SeatCellViewModel target)
{
if (!IsEditable || source == target || !source.SelectedOption.StudentId.HasValue) return;
var targetOption = target.SelectedOption;
target.SetSelectionSilently(source.SelectedOption);
source.SetSelectionSilently(targetOption);
SaveSeatAssignments();
}
public void AssignStudent(StudentSeatOption student, SeatCellViewModel target)
{
if (!IsEditable || !student.StudentId.HasValue) return;
foreach (var other in Seats.Where(s => s != target && s.SelectedOption.StudentId == student.StudentId))
other.SetSelectionSilently(StudentSeatOption.Empty);
target.SetSelectionSilently(student);
SaveSeatAssignments();
}
public void ClearSeat(SeatCellViewModel seat)
{
if (!IsEditable || !seat.SelectedOption.StudentId.HasValue) return;
seat.SetSelectionSilently(StudentSeatOption.Empty);
SaveSeatAssignments();
}
private void SaveSeatAssignments()
{
if (_currentPlan is null) return;
_currentPlan.Assignments = Seats
.Where(s => s.SelectedOption.StudentId.HasValue)
.Select(s => new SeatAssignment
@@ -136,11 +190,13 @@ public partial class SeatingPlanTabViewModel : ObservableObject
UpdateAssignmentSummary();
}
private void UpdateAssignmentSummary()
public async Task AssessStudent(SeatCellViewModel seat)
{
var assigned = Seats.Count(s => s.SelectedOption.StudentId.HasValue);
var total = StudentOptions.Count - 1;
AssignmentSummary = $"{assigned} von {total} Schülern zugeordnet";
if (!seat.SelectedOption.StudentId.HasValue || OnAssessStudent is null) return;
var assessment = new SeatAssessmentViewModel(_sessions, _participation, _aspects,
_groupId, seat.SelectedOption.StudentId.Value, seat.SelectedOption.DisplayName, IsEditable);
await OnAssessStudent(assessment);
OnAssessmentChanged?.Invoke();
}
[RelayCommand(CanExecute = nameof(CanEdit))]
@@ -203,11 +259,14 @@ public partial class SeatCellViewModel : ObservableObject
private bool _suppressChange;
[ObservableProperty] private StudentSeatOption _selectedOption;
[ObservableProperty] private bool _isDropTarget;
public int Row { get; }
public int Column { get; }
public string PositionLabel => $"Reihe {Row + 1} · Platz {Column + 1}";
public ObservableCollection<StudentSeatOption> Options { get; }
public bool CanEdit { get; }
public bool IsOccupied => SelectedOption.StudentId.HasValue;
public string StudentName => IsOccupied ? SelectedOption.DisplayName : "Freier Platz";
public SeatCellViewModel(int row, int column, ObservableCollection<StudentSeatOption> options,
StudentSeatOption selectedOption, Action<SeatCellViewModel> onChanged, bool canEdit)
@@ -222,6 +281,8 @@ public partial class SeatCellViewModel : ObservableObject
partial void OnSelectedOptionChanged(StudentSeatOption value)
{
OnPropertyChanged(nameof(IsOccupied));
OnPropertyChanged(nameof(StudentName));
if (!_suppressChange) _onChanged(this);
}
@@ -233,6 +294,281 @@ public partial class SeatCellViewModel : ObservableObject
}
}
public partial class SeatAssessmentViewModel : ObservableObject
{
private readonly IParticipationRepository _entries;
private readonly ParticipationEntry? _entry;
private readonly bool _canEdit;
[ObservableProperty] private int _selectedAspectIndex;
[ObservableProperty] private string _attendanceLabel = "Noch nicht kontrolliert";
[ObservableProperty] private string _homeworkLabel = "Keine Hausaufgabe aufgegeben";
public string StudentName { get; }
public string SessionDisplay { get; }
public bool CanEdit => _canEdit && _entry is not null;
public string ReadOnlyHint => _entry is null
? "Für heute existiert keine Sitzung. In einer archivierten Gruppe kann keine neue angelegt werden."
: "Archivierte Lerngruppe Bewertung nur ansehen.";
public ObservableCollection<SeatAssessmentAspectRow> AspectRows { get; } = [];
public ObservableCollection<SeatAttendanceChoice> AttendanceChoices { get; } = [];
public ObservableCollection<SeatHomeworkChoice> HomeworkChoices { get; } = [];
public SeatAssessmentViewModel(IParticipationSessionRepository sessions,
IParticipationRepository entries, IParticipationAspectRepository aspects,
Guid groupId, Guid studentId, string studentName, bool canEdit)
{
_entries = entries;
_canEdit = canEdit;
StudentName = studentName;
var today = DateOnly.FromDateTime(DateTime.Today);
var session = sessions.GetByGroup(groupId).FirstOrDefault(s => s.Date == today);
if (session is null && canEdit)
{
session = new ParticipationSession
{
GroupId = groupId,
Date = today,
Comment = "Sitzplan",
};
sessions.Save(session);
}
SessionDisplay = session is null ? "Keine Sitzung für heute" : $"{session.Date:dd.MM.yyyy} · {session.Comment}";
_entry = session is null ? null
: entries.GetBySessionAndStudent(session.Id, studentId)
?? new ParticipationEntry { SessionId = session.Id, StudentId = studentId };
var aspectDefinitions = aspects.GetDefaults().Concat(aspects.GetByGroup(groupId)).ToList();
if (aspectDefinitions.Count == 0) aspectDefinitions = DefaultParticipationAspects.All.Select(a => new ParticipationAspect
{
Key = a.Key, Label = a.Label, ValueType = a.ValueType, MaxPoints = a.MaxPoints,
}).ToList();
foreach (var (aspect, index) in aspectDefinitions.Select((a, i) => (a, i)))
{
var value = _entry?.Ratings.FirstOrDefault(r => r.Key == aspect.Key)?.Value;
AspectRows.Add(new SeatAssessmentAspectRow(index, aspect, value, ApplyRating));
}
BuildAttendanceChoices();
BuildHomeworkChoices();
RefreshStatusChoices();
SelectAspect(0);
}
private void BuildAttendanceChoices()
{
AttendanceChoices.Add(new("✓", "Anwesend", "Strg+1", AttendanceStatus.Present, SetAttendance));
AttendanceChoices.Add(new("?", "Entschuldigung offen", "Strg+2", AttendanceStatus.ExcusePending, SetAttendance));
AttendanceChoices.Add(new("⊘", "Entschuldigt", "Strg+5", AttendanceStatus.Excused, SetAttendance));
AttendanceChoices.Add(new("◇", "Schulveranstaltung", "Strg+7", AttendanceStatus.OtherSchoolEvent, SetAttendance));
AttendanceChoices.Add(new("✕", "Geschwänzt", "Strg+9", AttendanceStatus.Truant, SetAttendance));
AttendanceChoices.Add(new("!", "Unentschuldigt", "Strg+0", AttendanceStatus.Unexcused, SetAttendance));
AttendanceChoices.Add(new("·", "Nicht kontrolliert", "Strg+X", null, SetAttendance));
}
private void BuildHomeworkChoices()
{
HomeworkChoices.Add(new("✓", "Gemacht", "⌥1", HomeworkStatus.Completed, SetHomework));
HomeworkChoices.Add(new("◐", "Teilweise", "⌥3", HomeworkStatus.PartiallyCompleted, SetHomework));
HomeworkChoices.Add(new("◕", "Rest nachgereicht", "⌥4", HomeworkStatus.PartialSubmittedLate, SetHomework));
HomeworkChoices.Add(new("◒", "Rest fehlt", "⌥5", HomeworkStatus.PartialMissingOverdue, SetHomework));
HomeworkChoices.Add(new("!", "Nicht gemacht", "⌥7", HomeworkStatus.MissingOpen, SetHomework));
HomeworkChoices.Add(new("↺", "Nachgereicht", "⌥8", HomeworkStatus.SubmittedLate, SetHomework));
HomeworkChoices.Add(new("✕", "Nicht nachgereicht", "⌥0", HomeworkStatus.MissingOverdue, SetHomework));
HomeworkChoices.Add(new("·", "Keine aufgegeben", "⌥X", null, SetHomework));
}
public void SelectAspect(int index)
{
if (index < 0 || index >= AspectRows.Count) return;
SelectedAspectIndex = index;
foreach (var row in AspectRows) row.IsActive = row.Index == index;
}
public void MoveAspect(int delta)
{
if (AspectRows.Count == 0) return;
SelectAspect(Math.Clamp(SelectedAspectIndex + delta, 0, AspectRows.Count - 1));
}
public void SetRatingByNumber(int number)
{
if (!CanEdit) return;
var row = AspectRows.ElementAtOrDefault(SelectedAspectIndex);
if (row is null) return;
if (row.ValueType == AspectValueType.Points)
row.ApplyValue(Math.Clamp(number, 0, row.MaxPoints));
else
{
var steps = ParticipationRatingScale.Steps(row.ValueType);
if (number >= 1 && number <= steps.Count) row.ApplyValue(steps[number - 1].Value);
}
}
public void AdjustCurrentRating(int delta)
{
if (!CanEdit) return;
var row = AspectRows.ElementAtOrDefault(SelectedAspectIndex);
row?.Adjust(delta);
}
public void ClearCurrentRating()
{
if (!CanEdit) return;
AspectRows.ElementAtOrDefault(SelectedAspectIndex)?.ApplyValue(null);
}
public void ApplyAttendanceShortcut(int? digit, bool clear)
{
if (!CanEdit) return;
var status = clear ? null : digit switch
{
1 => AttendanceStatus.Present, 2 => AttendanceStatus.ExcusePending,
5 => AttendanceStatus.Excused, 7 => AttendanceStatus.OtherSchoolEvent,
9 => AttendanceStatus.Truant, 0 => AttendanceStatus.Unexcused,
_ => (AttendanceStatus?)null,
};
if (clear || digit is 0 or 1 or 2 or 5 or 7 or 9) SetAttendance(status);
}
public void ApplyHomeworkShortcut(int? digit, bool clear)
{
if (!CanEdit) return;
var status = clear ? null : digit switch
{
1 => HomeworkStatus.Completed, 3 => HomeworkStatus.PartiallyCompleted,
4 => HomeworkStatus.PartialSubmittedLate, 5 => HomeworkStatus.PartialMissingOverdue,
7 => HomeworkStatus.MissingOpen, 8 => HomeworkStatus.SubmittedLate,
0 => HomeworkStatus.MissingOverdue, _ => (HomeworkStatus?)null,
};
if (clear || digit is 0 or 1 or 3 or 4 or 5 or 7 or 8) SetHomework(status);
}
private void ApplyRating(string key, int? value)
{
if (!CanEdit || _entry is null) return;
var existing = _entry.Ratings.FirstOrDefault(r => r.Key == key);
if (value is null)
{
if (existing is not null) _entry.Ratings.Remove(existing);
}
else if (existing is null) _entry.Ratings.Add(new AspectRating { Key = key, Value = value.Value });
else existing.Value = value.Value;
_entries.Save(_entry);
}
private void SetAttendance(AttendanceStatus? status)
{
if (!CanEdit || _entry is null) return;
_entry.Attendance = status;
_entries.Save(_entry);
RefreshStatusChoices();
}
private void SetHomework(HomeworkStatus? status)
{
if (!CanEdit || _entry is null) return;
_entry.Homework = status;
_entry.HomeworkMissing = HomeworkDisplay.CountsAsMissing(status);
_entries.Save(_entry);
RefreshStatusChoices();
}
private void RefreshStatusChoices()
{
AttendanceLabel = AttendanceDisplay.Label(_entry?.Attendance);
HomeworkLabel = HomeworkDisplay.Label(_entry is null ? null : HomeworkDisplay.Effective(_entry));
foreach (var choice in AttendanceChoices) choice.IsSelected = choice.Status == _entry?.Attendance;
var homework = _entry is null ? null : HomeworkDisplay.Effective(_entry);
foreach (var choice in HomeworkChoices) choice.IsSelected = choice.Status == homework;
}
}
public partial class SeatAssessmentAspectRow : ObservableObject
{
private readonly Action<string, int?> _apply;
[ObservableProperty] private bool _isActive;
[ObservableProperty] private int? _value;
public int Index { get; }
public string Key { get; }
public string Label { get; }
public AspectValueType ValueType { get; }
public int MaxPoints { get; }
public string Shortcut => Index switch { 0 => "Q", 1 => "W", 2 => "E", 3 => "R", 4 => "T", _ => "" };
public string DisplayValue => ParticipationRatingScale.DisplayLabel(ValueType, Value);
public ObservableCollection<SeatRatingChoice> Choices { get; } = [];
public SeatAssessmentAspectRow(int index, ParticipationAspect aspect, int? value,
Action<string, int?> apply)
{
Index = index; Key = aspect.Key; Label = aspect.Label; ValueType = aspect.ValueType;
MaxPoints = aspect.MaxPoints; _value = value; _apply = apply;
var steps = ValueType == AspectValueType.Points
? Enumerable.Range(0, Math.Min(MaxPoints, 9) + 1).Select(v => (v, v.ToString())).ToList()
: ParticipationRatingScale.Steps(ValueType).ToList();
foreach (var (step, i) in steps.Select((s, i) => (s, i)))
Choices.Add(new SeatRatingChoice(step.Item2, ValueType == AspectValueType.Points ? step.Item1.ToString() : (i + 1).ToString(),
step.Item1, step.Item1 == value, ApplyValue));
}
public void ApplyValue(int? value)
{
Value = value;
OnPropertyChanged(nameof(DisplayValue));
foreach (var choice in Choices) choice.IsSelected = choice.Value == value;
_apply(Key, value);
}
public void Adjust(int delta)
{
if (ValueType == AspectValueType.Points)
{
ApplyValue(Math.Clamp((Value ?? (delta > 0 ? -1 : MaxPoints + 1)) + delta, 0, MaxPoints));
return;
}
var steps = ParticipationRatingScale.Steps(ValueType).Select(s => s.Value).ToList();
if (steps.Count == 0) return;
var index = Value.HasValue ? steps.IndexOf(Value.Value) : (delta > 0 ? -1 : steps.Count);
ApplyValue(steps[Math.Clamp(index + delta, 0, steps.Count - 1)]);
}
[RelayCommand] private void Clear() => ApplyValue(null);
[RelayCommand] private void Increment() => Adjust(1);
[RelayCommand] private void Decrement() => Adjust(-1);
}
public partial class SeatRatingChoice(string label, string shortcut, int value, bool isSelected,
Action<int?> apply) : ObservableObject
{
public string Label { get; } = label;
public string Shortcut { get; } = shortcut;
public int Value { get; } = value;
[ObservableProperty] private bool _isSelected = isSelected;
[RelayCommand] private void Apply() => apply(Value);
}
public partial class SeatAttendanceChoice(string symbol, string label, string shortcut,
AttendanceStatus? status, Action<AttendanceStatus?> apply) : ObservableObject
{
public string Symbol { get; } = symbol;
public string Label { get; } = label;
public string Shortcut { get; } = shortcut;
public AttendanceStatus? Status { get; } = status;
[ObservableProperty] private bool _isSelected;
[RelayCommand] private void Apply() => apply(Status);
}
public partial class SeatHomeworkChoice(string symbol, string label, string shortcut,
HomeworkStatus? status, Action<HomeworkStatus?> apply) : ObservableObject
{
public string Symbol { get; } = symbol;
public string Label { get; } = label;
public string Shortcut { get; } = shortcut;
public HomeworkStatus? Status { get; } = status;
[ObservableProperty] private bool _isSelected;
[RelayCommand] private void Apply() => apply(Status);
}
public partial class SeatingPlanDialogViewModel : ObservableObject
{
private readonly ISeatingPlanRepository _plans;
@@ -256,8 +592,8 @@ public partial class SeatingPlanDialogViewModel : ObservableObject
_groupId = groupId;
_editingPlan = editingPlan;
if (editingPlan is null) return;
Name = editingPlan.Name;
Room = editingPlan.Room;
Name = editingPlan.Name ?? "";
Room = editingPlan.Room ?? "";
Rows = editingPlan.Rows;
Columns = editingPlan.Columns;
}
@@ -281,8 +617,8 @@ public partial class SeatingPlanDialogViewModel : ObservableObject
if (!valid) return;
var plan = _editingPlan ?? new SeatingPlan { GroupId = _groupId };
plan.Name = Name.Trim();
plan.Room = Room.Trim();
plan.Name = Name?.Trim() ?? "";
plan.Room = Room?.Trim() ?? "";
plan.Rows = decimal.ToInt32(Rows);
plan.Columns = decimal.ToInt32(Columns);
try
@@ -9,21 +9,56 @@
<Setter Property="Foreground" Value="Red"/>
<Setter Property="FontWeight" Value="SemiBold"/>
</Style>
<Style Selector="Border.daycell.selected">
<Setter Property="Background" Value="{DynamicResource SystemControlBackgroundAccentBrush}"/>
<Setter Property="BorderThickness" Value="2"/>
<Setter Property="BorderBrush" Value="{DynamicResource SystemAccentColor}"/>
</Style>
</UserControl.Styles>
<ScrollViewer Padding="24">
<StackPanel Spacing="20">
<!-- Begrüßung -->
<Grid ColumnDefinitions="*,Auto">
<StackPanel>
<TextBlock Text="{Binding Greeting}" FontSize="14" Opacity="0.6"/>
<TextBlock Text="{Binding CurrentDate}" FontSize="24" FontWeight="SemiBold"/>
</StackPanel>
<Button Grid.Column="1" Content="Dashboard anpassen" Command="{Binding ToggleDashboardSettingsCommand}"
VerticalAlignment="Center"/>
</Grid>
<Grid ColumnDefinitions="*,*" RowDefinitions="Auto,Auto,Auto,Auto">
<Border Background="{DynamicResource SystemControlBackgroundAltHighBrush}" CornerRadius="8"
Padding="12" IsVisible="{Binding IsDashboardSettingsOpen}">
<ItemsControl ItemsSource="{Binding DashboardCards}">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate><WrapPanel Orientation="Horizontal"/></ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:DashboardCardOption">
<Border BorderBrush="{DynamicResource SystemControlForegroundBaseLowBrush}"
BorderThickness="1" CornerRadius="6" Padding="8" Margin="4">
<StackPanel Orientation="Horizontal" Spacing="6">
<CheckBox Content="{Binding Title}" IsChecked="{Binding IsVisible}" VerticalAlignment="Center"/>
<Button Content="↑" Padding="6,2"
Command="{Binding $parent[ItemsControl].((vm:DashboardViewModel)DataContext).MoveCardUpCommand}"
CommandParameter="{Binding}"/>
<Button Content="↓" Padding="6,2"
Command="{Binding $parent[ItemsControl].((vm:DashboardViewModel)DataContext).MoveCardDownCommand}"
CommandParameter="{Binding}"/>
</StackPanel>
</Border>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</Border>
<Grid ColumnDefinitions="*,*" RowDefinitions="Auto,Auto,Auto,Auto,Auto">
<!-- Heutige Stunden -->
<Border Grid.Column="0" Grid.Row="0" Margin="0,0,8,8"
<Border Grid.Column="{Binding TodayCard.Column}" Grid.Row="{Binding TodayCard.Row}"
IsVisible="{Binding TodayCard.IsVisible}" Margin="0,0,8,8"
Background="{DynamicResource SystemControlBackgroundAltHighBrush}"
CornerRadius="8" Padding="16">
<StackPanel>
@@ -63,7 +98,8 @@
</Border>
<!-- Offene Aufgaben -->
<Border Grid.Column="1" Grid.Row="0" Margin="8,0,0,8"
<Border Grid.Column="{Binding TasksCard.Column}" Grid.Row="{Binding TasksCard.Row}"
IsVisible="{Binding TasksCard.IsVisible}" Margin="8,0,0,8"
Background="{DynamicResource SystemControlBackgroundAltHighBrush}"
CornerRadius="8" Padding="16">
<StackPanel>
@@ -88,7 +124,8 @@
<!-- Kalender: feste Position direkt unter Heute/Aufgaben, damit die wachsende
Lerngruppen-Liste darunter ihn nicht nach unten verdrängt. -->
<Border Grid.Column="0" Grid.Row="1" Margin="0,0,8,8" HorizontalAlignment="Left"
<Border Grid.Column="{Binding CalendarCard.Column}" Grid.Row="{Binding CalendarCard.Row}"
IsVisible="{Binding CalendarCard.IsVisible}" Margin="0,0,8,8"
Background="{DynamicResource SystemControlBackgroundAltHighBrush}"
CornerRadius="8" Padding="16">
<StackPanel Spacing="8">
@@ -150,11 +187,14 @@
</ItemsControl.ItemsPanel>
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:CalendarDayCell">
<Button Background="Transparent" BorderThickness="0" Padding="0" Margin="1"
Command="{Binding $parent[ItemsControl].((vm:DashboardViewModel)DataContext).SelectCalendarDayCommand}"
CommandParameter="{Binding}" ToolTip.Tip="{Binding Tooltip}">
<Border Classes="daycell" Classes.ownclass="{Binding IsOwnClassDay}"
Classes.haslesson="{Binding HasLesson}"
Classes.outside="{Binding !IsCurrentMonth}"
Width="32" Height="32" Margin="1" CornerRadius="6"
ToolTip.Tip="{Binding Tooltip}">
Classes.selected="{Binding IsSelected}"
Width="32" Height="32" CornerRadius="6">
<Grid>
<TextBlock Classes="daynum" Classes.today="{Binding IsToday}"
Text="{Binding DayNumber}" FontSize="12"
@@ -164,6 +204,7 @@
IsVisible="{Binding HasExam}"/>
</Grid>
</Border>
</Button>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
@@ -183,11 +224,35 @@
<TextBlock Text="Meine Klasse" FontSize="10" Opacity="0.6"/>
</StackPanel>
</StackPanel>
<Separator Margin="0,4"/>
<TextBlock Text="{Binding SelectedDayLabel}" FontWeight="SemiBold" FontSize="12"/>
<ItemsControl ItemsSource="{Binding SelectedDayEvents}">
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:CalendarEventItem">
<Button Background="Transparent" BorderThickness="0" Padding="0" Margin="0,3"
HorizontalAlignment="Stretch" HorizontalContentAlignment="Stretch"
Command="{Binding $parent[ItemsControl].((vm:DashboardViewModel)DataContext).OpenCalendarEventCommand}"
CommandParameter="{Binding}">
<Grid ColumnDefinitions="Auto,*">
<TextBlock Text="{Binding KindLabel}" FontSize="10" Opacity="0.55" Width="58"/>
<StackPanel Grid.Column="1">
<TextBlock Text="{Binding Title}" FontSize="12" FontWeight="SemiBold"/>
<TextBlock Text="{Binding Subtitle}" FontSize="10" Opacity="0.6"/>
</StackPanel>
</Grid>
</Button>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<TextBlock Text="Keine Termine an diesem Tag." Classes="emptyhint"
IsVisible="{Binding !SelectedDayEvents.Count}"/>
</StackPanel>
</Border>
<!-- Offene Entschuldigungen: neben dem Kalender, ebenfalls feste Position -->
<Border Grid.Column="1" Grid.Row="1" Margin="8,0,0,8" VerticalAlignment="Top"
<Border Grid.Column="{Binding ExcusesCard.Column}" Grid.Row="{Binding ExcusesCard.Row}"
IsVisible="{Binding ExcusesCard.IsVisible}" Margin="8,0,0,8" VerticalAlignment="Top"
Background="{DynamicResource SystemControlBackgroundAltHighBrush}"
CornerRadius="8" Padding="16">
<StackPanel>
@@ -220,7 +285,8 @@
</Border>
<!-- Fehlzeiten-Warnung (5.2.3) -->
<Border Grid.Column="0" Grid.Row="2" Margin="0,0,8,8" VerticalAlignment="Top"
<Border Grid.Column="{Binding AttendanceCard.Column}" Grid.Row="{Binding AttendanceCard.Row}"
IsVisible="{Binding AttendanceCard.IsVisible}" Margin="0,0,8,8" VerticalAlignment="Top"
Background="{DynamicResource SystemControlBackgroundAltHighBrush}"
CornerRadius="8" Padding="16">
<StackPanel>
@@ -249,7 +315,8 @@
</Border>
<!-- Förderplan-Wiedervorlage (5.3.2) -->
<Border Grid.Column="1" Grid.Row="2" Margin="8,0,0,8" VerticalAlignment="Top"
<Border Grid.Column="{Binding SupportCard.Column}" Grid.Row="{Binding SupportCard.Row}"
IsVisible="{Binding SupportCard.IsVisible}" Margin="8,0,0,8" VerticalAlignment="Top"
Background="{DynamicResource SystemControlBackgroundAltHighBrush}"
CornerRadius="8" Padding="16">
<StackPanel>
@@ -278,8 +345,113 @@
</StackPanel>
</Border>
<!-- Meine Lerngruppen: wächst mit der Zeit, deshalb ganz unten und volle Breite -->
<Border Grid.Column="0" Grid.Row="3" Grid.ColumnSpan="2"
<!-- Anstehende Termine (9.3) -->
<Border Grid.Column="{Binding UpcomingCard.Column}" Grid.Row="{Binding UpcomingCard.Row}"
IsVisible="{Binding UpcomingCard.IsVisible}" Margin="0,0,8,8" VerticalAlignment="Top"
Background="{DynamicResource SystemControlBackgroundAltHighBrush}"
CornerRadius="8" Padding="16">
<StackPanel>
<TextBlock Text="ANSTEHENDE TERMINE" FontSize="11" FontWeight="Bold"
Opacity="0.5" Margin="0,0,0,10"/>
<ItemsControl ItemsSource="{Binding UpcomingDates}">
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:UpcomingDateItem">
<Button Background="Transparent" BorderThickness="0" Padding="0" Margin="0,4"
HorizontalAlignment="Stretch" HorizontalContentAlignment="Stretch"
Command="{Binding $parent[ItemsControl].((vm:DashboardViewModel)DataContext).OpenUpcomingDateCommand}"
CommandParameter="{Binding}">
<Grid ColumnDefinitions="Auto,*,Auto">
<Border Background="{DynamicResource SystemAccentColorLight2}" CornerRadius="4"
Padding="5,2" VerticalAlignment="Center">
<TextBlock Text="{Binding KindLabel}" FontSize="10"/>
</Border>
<StackPanel Grid.Column="1" Margin="8,0">
<TextBlock Text="{Binding Title}" FontSize="13" FontWeight="SemiBold"
TextTrimming="CharacterEllipsis"/>
<TextBlock Text="{Binding Subtitle}" FontSize="11" Opacity="0.6"/>
</StackPanel>
<TextBlock Grid.Column="2" Text="{Binding DateDisplay}" FontSize="12"
VerticalAlignment="Center" Classes.overdue="{Binding IsOverdue}"/>
</Grid>
</Button>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<TextBlock Text="Keine Termine in den nächsten 30 Tagen." Classes="emptyhint"
IsVisible="{Binding !UpcomingDates.Count}"/>
</StackPanel>
</Border>
<!-- Offene Korrekturen (9.4) -->
<Border Grid.Column="{Binding CorrectionsCard.Column}" Grid.Row="{Binding CorrectionsCard.Row}"
IsVisible="{Binding CorrectionsCard.IsVisible}" Margin="8,0,0,8" VerticalAlignment="Top"
Background="{DynamicResource SystemControlBackgroundAltHighBrush}"
CornerRadius="8" Padding="16">
<StackPanel>
<TextBlock Text="OFFENE KORREKTUREN" FontSize="11" FontWeight="Bold"
Opacity="0.5" Margin="0,0,0,10"/>
<ItemsControl ItemsSource="{Binding OpenCorrections}">
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:CorrectionProgressItem">
<Button Background="Transparent" BorderThickness="0" Padding="0" Margin="0,5"
HorizontalAlignment="Stretch" HorizontalContentAlignment="Stretch"
Command="{Binding $parent[ItemsControl].((vm:DashboardViewModel)DataContext).OpenCorrectionCommand}"
CommandParameter="{Binding}">
<StackPanel Spacing="3">
<Grid ColumnDefinitions="*,Auto">
<TextBlock Text="{Binding Title}" FontSize="13" FontWeight="SemiBold"/>
<TextBlock Grid.Column="1" Text="{Binding DateDisplay}" FontSize="11" Opacity="0.6"/>
</Grid>
<TextBlock Text="{Binding GroupName}" FontSize="11" Opacity="0.6"/>
<ProgressBar Minimum="0" Maximum="100" Value="{Binding Percent}" Height="6"/>
<TextBlock Text="{Binding ProgressDisplay}" FontSize="10" Opacity="0.65"/>
</StackPanel>
</Button>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<TextBlock Text="Keine offenen Korrekturen." Classes="emptyhint"
IsVisible="{Binding !OpenCorrections.Count}"/>
</StackPanel>
</Border>
<!-- Auffälligkeiten (9.5) -->
<Border Grid.Column="{Binding AlertsCard.Column}" Grid.Row="{Binding AlertsCard.Row}"
IsVisible="{Binding AlertsCard.IsVisible}" Margin="0,0,8,8" VerticalAlignment="Top"
Background="{DynamicResource SystemControlBackgroundAltHighBrush}"
CornerRadius="8" Padding="16">
<StackPanel>
<TextBlock Text="AUFFÄLLIGKEITEN" FontSize="11" FontWeight="Bold"
Opacity="0.5" Margin="0,0,0,10"/>
<ItemsControl ItemsSource="{Binding Alerts}">
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:DashboardAlertItem">
<Button Background="Transparent" BorderThickness="0" Padding="0" Margin="0,4"
HorizontalAlignment="Stretch" HorizontalContentAlignment="Stretch"
Command="{Binding $parent[ItemsControl].((vm:DashboardViewModel)DataContext).OpenAlertCommand}"
CommandParameter="{Binding}">
<Grid ColumnDefinitions="4,*">
<Border Background="{Binding SeverityColor}" CornerRadius="2" Margin="0,0,9,0"/>
<StackPanel Grid.Column="1">
<Grid ColumnDefinitions="*,Auto">
<TextBlock Text="{Binding StudentName}" FontSize="13" FontWeight="SemiBold"/>
<TextBlock Grid.Column="1" Text="{Binding KindLabel}" FontSize="10" Opacity="0.6"/>
</Grid>
<TextBlock Text="{Binding Detail}" FontSize="11" Opacity="0.65"/>
</StackPanel>
</Grid>
</Button>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<TextBlock Text="Keine Auffälligkeiten erkannt." Classes="emptyhint"
IsVisible="{Binding !Alerts.Count}"/>
</StackPanel>
</Border>
<!-- Meine Lerngruppen -->
<Border Grid.Column="{Binding GroupsCard.Column}" Grid.Row="{Binding GroupsCard.Row}"
IsVisible="{Binding GroupsCard.IsVisible}"
Background="{DynamicResource SystemControlBackgroundAltHighBrush}"
CornerRadius="8" Padding="16">
<StackPanel>
@@ -117,31 +117,36 @@
Padding="12,9"
Command="{Binding NavigateToSectionCommand}"
CommandParameter="1"/>
<Button Content="🪑 Sitzpläne"
HorizontalAlignment="Stretch" HorizontalContentAlignment="Left"
Padding="12,9"
Command="{Binding NavigateToSectionCommand}"
CommandParameter="2"/>
<Button Content="📝 Klausuren"
HorizontalAlignment="Stretch" HorizontalContentAlignment="Left"
Padding="12,9"
Command="{Binding NavigateToSectionCommand}"
CommandParameter="3"/>
CommandParameter="4"/>
<Button Content="🔢 Notenübersicht"
HorizontalAlignment="Stretch" HorizontalContentAlignment="Left"
Padding="12,9"
Command="{Binding NavigateToSectionCommand}"
CommandParameter="4"/>
CommandParameter="5"/>
<Button Content="📅 Unterrichtsplanung"
HorizontalAlignment="Stretch" HorizontalContentAlignment="Left"
Padding="12,9"
Command="{Binding NavigateToSectionCommand}"
CommandParameter="5"/>
CommandParameter="6"/>
<Button Content="🎯 Kompetenzübersicht"
HorizontalAlignment="Stretch" HorizontalContentAlignment="Left"
Padding="12,9"
Command="{Binding NavigateToSectionCommand}"
CommandParameter="6"/>
CommandParameter="7"/>
<Button Content="📋 Dokumentation"
HorizontalAlignment="Stretch" HorizontalContentAlignment="Left"
Padding="12,9"
Command="{Binding NavigateToSectionCommand}"
CommandParameter="7"/>
CommandParameter="8"/>
</StackPanel>
</StackPanel>
@@ -0,0 +1,133 @@
<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="clr-namespace:LehrerApp.Desktop.ViewModels.Groups"
x:Class="LehrerApp.Desktop.Views.Groups.SeatAssessmentDialog"
x:DataType="vm:SeatAssessmentViewModel"
Title="Sitzplatz-Schnelleingabe" Width="660" Height="700"
MinWidth="560" MinHeight="560" CanResize="True"
WindowStartupLocation="CenterOwner">
<Window.Styles>
<Style Selector="Button.choice">
<Setter Property="Background" Value="Transparent"/>
<Setter Property="BorderBrush" Value="{DynamicResource SystemControlForegroundBaseLowBrush}"/>
<Setter Property="BorderThickness" Value="1"/>
<Setter Property="Padding" Value="8,5"/>
</Style>
<Style Selector="Button.choice.selected">
<Setter Property="Background" Value="{DynamicResource SystemAccentColorLight2}"/>
<Setter Property="BorderBrush" Value="{DynamicResource SystemAccentColor}"/>
<Setter Property="BorderThickness" Value="2"/>
</Style>
<Style Selector="Border.aspectrow">
<Setter Property="BorderBrush" Value="Transparent"/>
<Setter Property="BorderThickness" Value="1"/>
<Setter Property="Background" Value="Transparent"/>
</Style>
<Style Selector="Border.aspectrow.active">
<Setter Property="BorderBrush" Value="{DynamicResource SystemAccentColor}"/>
<Setter Property="Background" Value="{DynamicResource SystemAccentColorLight3}"/>
</Style>
</Window.Styles>
<Grid RowDefinitions="Auto,*,Auto" Margin="20">
<Grid Grid.Row="0" ColumnDefinitions="*,Auto" Margin="0,0,0,14">
<StackPanel>
<TextBlock Text="{Binding StudentName}" FontSize="22" FontWeight="SemiBold"/>
<TextBlock Text="{Binding SessionDisplay}" FontSize="12" Opacity="0.55"/>
</StackPanel>
<Button Grid.Column="1" Content="Schließen" Click="OnClose" VerticalAlignment="Center"/>
</Grid>
<ScrollViewer Grid.Row="1">
<StackPanel Spacing="16" IsEnabled="{Binding CanEdit}">
<Border Background="#FFF3CD" CornerRadius="6" Padding="10"
IsVisible="{Binding CanEdit, Converter={x:Static BoolConverters.Not}}">
<TextBlock Text="{Binding ReadOnlyHint}" Foreground="#92400E" TextWrapping="Wrap"/>
</Border>
<StackPanel Spacing="7">
<TextBlock Text="MITARBEIT" FontSize="11" FontWeight="Bold" Opacity="0.5"/>
<ItemsControl ItemsSource="{Binding AspectRows}">
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:SeatAssessmentAspectRow">
<Border Classes="aspectrow" Classes.active="{Binding IsActive}"
CornerRadius="7" Padding="9" Margin="0,3">
<Grid ColumnDefinitions="150,*">
<StackPanel VerticalAlignment="Center">
<TextBlock FontWeight="SemiBold">
<Run Text="["/><Run Text="{Binding Shortcut}"/><Run Text="] "/><Run Text="{Binding Label}"/>
</TextBlock>
<TextBlock Text="{Binding DisplayValue}" FontSize="11" Opacity="0.6"/>
</StackPanel>
<WrapPanel Grid.Column="1" HorizontalAlignment="Right">
<ItemsControl ItemsSource="{Binding Choices}">
<ItemsControl.ItemsPanel><ItemsPanelTemplate><StackPanel Orientation="Horizontal"/></ItemsPanelTemplate></ItemsControl.ItemsPanel>
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:SeatRatingChoice">
<Button Classes="choice" Classes.selected="{Binding IsSelected}"
Command="{Binding ApplyCommand}" Margin="2">
<StackPanel>
<TextBlock Text="{Binding Label}" HorizontalAlignment="Center" FontWeight="SemiBold"/>
<TextBlock Text="{Binding Shortcut}" HorizontalAlignment="Center" FontSize="9" Opacity="0.5"/>
</StackPanel>
</Button>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<Button Classes="choice" Content="" Command="{Binding DecrementCommand}" Margin="2"/>
<Button Classes="choice" Content="+" Command="{Binding IncrementCommand}" Margin="2"/>
<Button Classes="choice" Content="Löschen" Command="{Binding ClearCommand}" Margin="2"/>
</WrapPanel>
</Grid>
</Border>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</StackPanel>
<StackPanel Spacing="7">
<TextBlock Text="ANWESENHEIT" FontSize="11" FontWeight="Bold" Opacity="0.5"/>
<TextBlock Text="{Binding AttendanceLabel}" FontSize="12"/>
<ItemsControl ItemsSource="{Binding AttendanceChoices}">
<ItemsControl.ItemsPanel><ItemsPanelTemplate><WrapPanel/></ItemsPanelTemplate></ItemsControl.ItemsPanel>
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:SeatAttendanceChoice">
<Button Classes="choice" Classes.selected="{Binding IsSelected}"
Command="{Binding ApplyCommand}" Margin="2">
<StackPanel Orientation="Horizontal" Spacing="5">
<TextBlock Text="{Binding Symbol}" FontWeight="Bold"/>
<TextBlock Text="{Binding Label}"/>
<TextBlock Text="{Binding Shortcut}" FontSize="9" Opacity="0.5" VerticalAlignment="Center"/>
</StackPanel>
</Button>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</StackPanel>
<StackPanel Spacing="7">
<TextBlock Text="HAUSAUFGABEN" FontSize="11" FontWeight="Bold" Opacity="0.5"/>
<TextBlock Text="{Binding HomeworkLabel}" FontSize="12"/>
<ItemsControl ItemsSource="{Binding HomeworkChoices}">
<ItemsControl.ItemsPanel><ItemsPanelTemplate><WrapPanel/></ItemsPanelTemplate></ItemsControl.ItemsPanel>
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:SeatHomeworkChoice">
<Button Classes="choice" Classes.selected="{Binding IsSelected}"
Command="{Binding ApplyCommand}" Margin="2">
<StackPanel Orientation="Horizontal" Spacing="5">
<TextBlock Text="{Binding Symbol}" FontWeight="Bold"/>
<TextBlock Text="{Binding Label}"/>
<TextBlock Text="{Binding Shortcut}" FontSize="9" Opacity="0.5" VerticalAlignment="Center"/>
</StackPanel>
</Button>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</StackPanel>
</StackPanel>
</ScrollViewer>
<TextBlock Grid.Row="2" Margin="0,12,0,0" FontSize="10" Opacity="0.55" TextWrapping="Wrap"
Text="Mitarbeit: Q/W/E/R/T Aspekt · 15 Bewertung · +/ anpassen · Backspace löschen · ←/→ Aspekt | Anwesenheit: Strg+1/2/5/7/9/0, Strg+X | Hausaufgaben: ⌥+1/3/4/5/7/8/0, ⌥+X | Esc schließen"/>
</Grid>
</Window>
@@ -0,0 +1,80 @@
using Avalonia.Controls;
using Avalonia.Input;
using Avalonia.Interactivity;
using LehrerApp.Desktop.ViewModels.Groups;
namespace LehrerApp.Desktop.Views.Groups;
public partial class SeatAssessmentDialog : Window
{
public SeatAssessmentDialog()
{
InitializeComponent();
AddHandler(KeyDownEvent, OnPreviewKeyDown, RoutingStrategies.Tunnel, handledEventsToo: true);
}
protected override void OnOpened(EventArgs e)
{
base.OnOpened(e);
Focus();
}
private void OnPreviewKeyDown(object? sender, KeyEventArgs e)
{
if (DataContext is not SeatAssessmentViewModel vm) return;
var digit = DigitFromEvent(e);
var clear = e.Key == Key.X || e.PhysicalKey == PhysicalKey.X;
if (e.KeyModifiers.HasFlag(KeyModifiers.Control))
{
if (clear || digit is not null) vm.ApplyAttendanceShortcut(digit, clear);
e.Handled = clear || digit is not null;
return;
}
if (e.KeyModifiers.HasFlag(KeyModifiers.Alt))
{
if (clear || digit is not null) vm.ApplyHomeworkShortcut(digit, clear);
e.Handled = clear || digit is not null;
return;
}
if (digit is not null)
{
vm.SetRatingByNumber(digit.Value);
e.Handled = true;
return;
}
switch (e.Key)
{
case Key.Q: vm.SelectAspect(0); e.Handled = true; break;
case Key.W: vm.SelectAspect(1); e.Handled = true; break;
case Key.E: vm.SelectAspect(2); e.Handled = true; break;
case Key.R: vm.SelectAspect(3); e.Handled = true; break;
case Key.T: vm.SelectAspect(4); e.Handled = true; break;
case Key.Left: vm.MoveAspect(-1); e.Handled = true; break;
case Key.Right: vm.MoveAspect(1); e.Handled = true; break;
case Key.OemPlus or Key.Add: vm.AdjustCurrentRating(1); e.Handled = true; break;
case Key.OemMinus or Key.Subtract: vm.AdjustCurrentRating(-1); e.Handled = true; break;
case Key.Back: vm.ClearCurrentRating(); e.Handled = true; break;
case Key.Escape: Close(); e.Handled = true; break;
}
}
private static int? DigitFromEvent(KeyEventArgs e) => e.PhysicalKey switch
{
PhysicalKey.Digit0 => 0, PhysicalKey.Digit1 => 1, PhysicalKey.Digit2 => 2,
PhysicalKey.Digit3 => 3, PhysicalKey.Digit4 => 4, PhysicalKey.Digit5 => 5,
PhysicalKey.Digit6 => 6, PhysicalKey.Digit7 => 7, PhysicalKey.Digit8 => 8,
PhysicalKey.Digit9 => 9,
_ => e.Key switch
{
Key.D0 or Key.NumPad0 => 0, Key.D1 or Key.NumPad1 => 1,
Key.D2 or Key.NumPad2 => 2, Key.D3 or Key.NumPad3 => 3,
Key.D4 or Key.NumPad4 => 4, Key.D5 or Key.NumPad5 => 5,
Key.D6 or Key.NumPad6 => 6, Key.D7 or Key.NumPad7 => 7,
Key.D8 or Key.NumPad8 => 8, Key.D9 or Key.NumPad9 => 9,
_ => null,
},
};
private void OnClose(object? sender, RoutedEventArgs e) => Close();
}
@@ -3,6 +3,21 @@
xmlns:vm="clr-namespace:LehrerApp.Desktop.ViewModels.Groups"
x:Class="LehrerApp.Desktop.Views.Groups.SeatingPlanTabView"
x:DataType="vm:SeatingPlanTabViewModel">
<UserControl.Styles>
<Style Selector="Border.seat">
<Setter Property="Background" Value="{DynamicResource SystemControlBackgroundAltHighBrush}"/>
<Setter Property="BorderBrush" Value="{DynamicResource SystemControlForegroundBaseLowBrush}"/>
<Setter Property="BorderThickness" Value="1"/>
</Style>
<Style Selector="Border.seat.occupied">
<Setter Property="BorderBrush" Value="{DynamicResource SystemAccentColor}"/>
</Style>
<Style Selector="Border.seat.droptarget">
<Setter Property="Background" Value="{DynamicResource SystemAccentColorLight2}"/>
<Setter Property="BorderBrush" Value="{DynamicResource SystemAccentColor}"/>
<Setter Property="BorderThickness" Value="2"/>
</Style>
</UserControl.Styles>
<Grid ColumnDefinitions="260,*">
<Border Grid.Column="0" BorderBrush="{DynamicResource SystemControlForegroundBaseLowBrush}"
BorderThickness="0,0,1,0" Padding="16">
@@ -51,8 +66,11 @@
<TextBlock Text="{Binding PlanTitle}" FontSize="22" FontWeight="SemiBold"/>
<TextBlock Text="{Binding PlanSubtitle}" Opacity="0.65"/>
</StackPanel>
<TextBlock Grid.Column="1" Text="{Binding AssignmentSummary}" VerticalAlignment="Bottom"
FontSize="12" Opacity="0.6"/>
<StackPanel Grid.Column="1" VerticalAlignment="Bottom">
<TextBlock Text="{Binding AssignmentSummary}" HorizontalAlignment="Right" FontSize="12" Opacity="0.6"/>
<TextBlock Text="Ziehen: Platz ändern · Klicken: bewerten" HorizontalAlignment="Right"
FontSize="11" Opacity="0.5"/>
</StackPanel>
</Grid>
<ScrollViewer Grid.Row="1" HorizontalScrollBarVisibility="Auto"
@@ -70,24 +88,60 @@
</ItemsControl.ItemsPanel>
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:SeatCellViewModel">
<Border Width="190" MinHeight="76" Margin="5" Padding="10"
BorderBrush="{DynamicResource SystemControlForegroundBaseLowBrush}"
BorderThickness="1" CornerRadius="7">
<Border Classes="seat" Classes.occupied="{Binding IsOccupied}"
Classes.droptarget="{Binding IsDropTarget}"
Width="190" MinHeight="76" Margin="5" Padding="10"
CornerRadius="7"
DragDrop.AllowDrop="True"
DragDrop.DragEnter="OnSeatDragEnter"
DragDrop.DragLeave="OnSeatDragLeave"
DragDrop.DragOver="OnSeatDragOver"
DragDrop.Drop="OnSeatDrop"
PointerPressed="OnDragSourcePressed"
PointerMoved="OnDragSourceMoved"
PointerReleased="OnDragSourceReleased"
Tapped="OnSeatTapped">
<StackPanel Spacing="5">
<Grid ColumnDefinitions="*,Auto">
<TextBlock Text="{Binding PositionLabel}" FontSize="10" Opacity="0.5"/>
<ComboBox ItemsSource="{Binding Options}" SelectedItem="{Binding SelectedOption}"
IsEnabled="{Binding CanEdit}" HorizontalAlignment="Stretch">
<ComboBox.ItemTemplate>
<DataTemplate x:DataType="vm:StudentSeatOption">
<TextBlock Text="{Binding DisplayName}"/>
</DataTemplate>
</ComboBox.ItemTemplate>
</ComboBox>
<TextBlock Grid.Column="1" Text="⠿" Opacity="0.45" IsVisible="{Binding IsOccupied}"/>
</Grid>
<TextBlock Text="{Binding StudentName}" FontWeight="SemiBold" FontSize="13"
HorizontalAlignment="Center" VerticalAlignment="Center"/>
</StackPanel>
</Border>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<Border DragDrop.AllowDrop="True" DragDrop.DragOver="OnUnassignedDragOver"
DragDrop.Drop="OnUnassignedDrop"
BorderBrush="{DynamicResource SystemControlForegroundBaseLowBrush}"
BorderThickness="1" CornerRadius="7" Padding="12" Margin="5,8">
<StackPanel Spacing="8">
<TextBlock Text="NICHT ZUGEORDNET" FontSize="10" FontWeight="Bold" Opacity="0.5"/>
<TextBlock Text="Schüler hierher ziehen, um einen Platz zu leeren."
FontSize="11" Opacity="0.5"/>
<ItemsControl ItemsSource="{Binding UnassignedStudents}">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate><WrapPanel/></ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:StudentSeatOption">
<Border Background="{DynamicResource SystemAccentColorLight2}" CornerRadius="5"
Padding="9,5" Margin="0,0,6,6"
PointerPressed="OnDragSourcePressed"
PointerMoved="OnDragSourceMoved"
PointerReleased="OnDragSourceReleased">
<TextBlock Text="{Binding DisplayName}" FontSize="12"/>
</Border>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<TextBlock Text="Alle Schüler sind zugeordnet." Classes="emptyhint"
IsVisible="{Binding !UnassignedStudents.Count}"/>
</StackPanel>
</Border>
</StackPanel>
</ScrollViewer>
</Grid>
@@ -1,4 +1,7 @@
using Avalonia.Controls;
using Avalonia.Input;
using Avalonia.Interactivity;
using Avalonia.Threading;
using LehrerApp.Core.Models;
using LehrerApp.Desktop.ViewModels.Groups;
using LehrerApp.Desktop.Views.Shared;
@@ -7,6 +10,15 @@ namespace LehrerApp.Desktop.Views.Groups;
public partial class SeatingPlanTabView : UserControl
{
private object? _dragCandidate;
private PointerPressedEventArgs? _dragTrigger;
private Avalonia.Point _pressPosition;
private SeatCellViewModel? _draggedSeat;
private StudentSeatOption? _draggedStudent;
private SeatCellViewModel? _pendingDropTarget;
private bool _pendingClearSeat;
private DateTime _ignoreTapUntil;
public SeatingPlanTabView() => InitializeComponent();
protected override void OnDataContextChanged(EventArgs e)
@@ -16,9 +28,128 @@ public partial class SeatingPlanTabView : UserControl
{
vm.OnEditPlan = ShowPlanDialog;
vm.OnConfirmDelete = ShowDeleteConfirmDialog;
vm.OnAssessStudent = ShowAssessmentDialog;
}
}
private void OnDragSourcePressed(object? sender, PointerPressedEventArgs e)
{
if (sender is not Control control ||
!e.GetCurrentPoint(control).Properties.IsLeftButtonPressed) return;
var candidate = control.DataContext;
if (candidate is SeatCellViewModel { IsOccupied: false } or null) return;
if (DataContext is not SeatingPlanTabViewModel { IsEditable: true }) return;
_dragCandidate = candidate;
_dragTrigger = e;
_pressPosition = e.GetPosition(this);
}
private async void OnDragSourceMoved(object? sender, PointerEventArgs e)
{
if (_dragCandidate is null || _dragTrigger is null ||
!e.GetCurrentPoint(this).Properties.IsLeftButtonPressed) return;
var current = e.GetPosition(this);
if (Math.Abs(current.X - _pressPosition.X) < 6 && Math.Abs(current.Y - _pressPosition.Y) < 6) return;
_draggedSeat = _dragCandidate as SeatCellViewModel;
_draggedStudent = _dragCandidate as StudentSeatOption;
_pendingDropTarget = null;
_pendingClearSeat = false;
_dragCandidate = null;
var trigger = _dragTrigger;
_dragTrigger = null;
var data = new DataTransfer();
data.Add(DataTransferItem.CreateText("LehrerApp-Sitzplatz"));
var effect = await DragDrop.DoDragDropAsync(trigger, data, DragDropEffects.Move);
var draggedSeat = _draggedSeat;
var draggedStudent = _draggedStudent;
var dropTarget = _pendingDropTarget;
var clearSeat = _pendingClearSeat;
_draggedSeat = null;
_draggedStudent = null;
_pendingDropTarget = null;
_pendingClearSeat = false;
_ignoreTapUntil = DateTime.UtcNow.AddMilliseconds(250);
// Never mutate an ItemsControl while Avalonia is still processing its native
// drop event. Assigning an unassigned student removes the dragged source item.
if ((effect & DragDropEffects.Move) == 0 || DataContext is not SeatingPlanTabViewModel vm) return;
Dispatcher.UIThread.Post(() =>
{
if (clearSeat && draggedSeat is not null) vm.ClearSeat(draggedSeat);
else if (dropTarget is not null && draggedSeat is not null) vm.MoveSeat(draggedSeat, dropTarget);
else if (dropTarget is not null && draggedStudent is not null) vm.AssignStudent(draggedStudent, dropTarget);
}, DispatcherPriority.Background);
}
private void OnDragSourceReleased(object? sender, PointerReleasedEventArgs e)
{
_dragCandidate = null;
_dragTrigger = null;
}
private void OnSeatDragEnter(object? sender, DragEventArgs e)
{
if (sender is Border { DataContext: SeatCellViewModel target } && CanDropOn(target))
target.IsDropTarget = true;
}
private void OnSeatDragLeave(object? sender, DragEventArgs e)
{
if (sender is Border { DataContext: SeatCellViewModel target }) target.IsDropTarget = false;
}
private void OnSeatDragOver(object? sender, DragEventArgs e)
{
e.DragEffects = sender is Border { DataContext: SeatCellViewModel target } && CanDropOn(target)
? DragDropEffects.Move : DragDropEffects.None;
}
private void OnSeatDrop(object? sender, DragEventArgs e)
{
if (sender is not Border { DataContext: SeatCellViewModel target }) return;
target.IsDropTarget = false;
if (!CanDropOn(target))
{
e.DragEffects = DragDropEffects.None;
return;
}
_pendingDropTarget = target;
_pendingClearSeat = false;
e.DragEffects = DragDropEffects.Move;
}
private bool CanDropOn(SeatCellViewModel target) =>
DataContext is SeatingPlanTabViewModel { IsEditable: true }
&& ((_draggedSeat is not null && _draggedSeat != target) || _draggedStudent?.StudentId is not null);
private void OnUnassignedDragOver(object? sender, DragEventArgs e) =>
e.DragEffects = _draggedSeat is not null ? DragDropEffects.Move : DragDropEffects.None;
private void OnUnassignedDrop(object? sender, DragEventArgs e)
{
if (_draggedSeat is not null)
{
_pendingDropTarget = null;
_pendingClearSeat = true;
e.DragEffects = DragDropEffects.Move;
}
}
private async void OnSeatTapped(object? sender, TappedEventArgs e)
{
if (DateTime.UtcNow < _ignoreTapUntil || sender is not Border { DataContext: SeatCellViewModel seat }
|| !seat.IsOccupied || DataContext is not SeatingPlanTabViewModel vm) return;
await vm.AssessStudent(seat);
}
private async Task ShowAssessmentDialog(SeatAssessmentViewModel vm)
{
var dialog = new SeatAssessmentDialog { DataContext = vm };
var owner = TopLevel.GetTopLevel(this) as Window;
if (owner is not null) await dialog.ShowDialog(owner);
}
private async Task<SeatingPlan?> ShowPlanDialog(SeatingPlan? plan)
{
if (DataContext is not SeatingPlanTabViewModel vm) return null;
+33 -13
View File
@@ -1151,10 +1151,14 @@ Hinweis in Kapitel 1 — betrifft auch Kurse, nicht nur Klassen.
die bereits vorhandenen getrennten Adressfelder. DOCX-Vorlagen verwenden Word-
Inhaltssteuerelemente mit dokumentierten Tags wie `Letter.Salutation`, `Contact.Address`,
`Student.FirstName` und `Group.Name`.
- [ ] **7.1.5** Sitzplan je Gruppe (Raster mit Drag & Drop), Sprung von Sitzplatz zur Bewertung.
Grundfunktion umgesetzt: mehrere benannte Sitzpläne je Lerngruppe und Raum, konfigurierbares
Raster sowie direkte Schülerzuordnung pro Platz. Offen bleiben Drag & Drop und der Sprung von
einem Sitzplatz in die Mitarbeitsbewertung.
- [x] **7.1.5** Sitzplan je Gruppe (Raster mit Drag & Drop), Sprung von Sitzplatz zur Bewertung.
Mehrere benannte Sitzpläne je Lerngruppe und Raum besitzen ein konfigurierbares Raster.
Schüler werden aus „Nicht zugeordnet“ auf Plätze gezogen, zwischen belegten Plätzen
getauscht oder durch Ablegen im freien Bereich wieder entfernt. Ein Klick auf einen
belegten Sitz öffnet die kompakte Sitzplatz-Schnelleingabe für alle aktiven
Mitarbeitsaspekte, Anwesenheit und Hausaufgaben. Die Eingabe unterstützt Maus sowie
Tastenkürzel und schreibt in die heutige Mitarbeitssitzung; falls noch keine existiert,
wird einmalig eine Sitzung „Sitzplan“ für heute angelegt.
### 7.2 Gruppen
- [x] **7.2.1** Gruppe bearbeiten und löschen — bereits vorhanden (`EditGroupCommand`/`DeleteGroupCommand`/
@@ -1260,20 +1264,36 @@ Hervorhebung "eigene Klasse" über `LearningGroup.IsOwnClass`, feste Kartenbreit
Stunde, für den vollständigen Tagesüberblick bleibt der Stundenplan zuständig.
- [x] **9.2** Direkter Absprung von einer Stunde in Mitarbeitserfassung bzw. Stundenplanung.
**Umsetzung:** Klick auf eine Stunde in "Heute" springt in die Lerngruppe, Tab "Mitarbeit"
(`DashboardViewModel.OnNavigateToLesson`, `NavigateToGroupDetail(id, 2)`) — bewusst anderes
(`DashboardViewModel.OnNavigateToLesson`, `NavigateToGroupDetail(id, 3)`) — bewusst anderes
Sprungziel als der bereits bestehende Klick in der Stundenplan-eigenen "Heute"-Ansicht
(springt dort auf Tab "Planung", siehe 4.4.2): vom Dashboard aus ist der naheliegende nächste
Schritt morgens eher die Mitarbeitserfassung als die Planung.
- [ ] **9.3** Kachel "Anstehende Termine": Klausuren, Förderplan-Überprüfungen, Abgabefristen.
- [ ] **9.4** Kachel "Offene Korrekturen" mit Fortschritt (x von y Klausuren bewertet).
- [ ] **9.5** Kachel "Auffälligkeiten": Fehlzeitenüberschreitungen, Notenabfall, Versetzungsgefährdung.
- [ ] **9.6** Dashboard-Kacheln ein-/ausblendbar und in der Reihenfolge konfigurierbar.
- [ ] **9.7** Automatische Aktualisierung beim Zurücknavigieren (aktuell nur manueller Refresh).
- [ ] **9.8** Kalender-Detailansicht: Tag im Monatskalender anklickbar/auswählbar, zeigt in
- [x] **9.3** Kachel "Anstehende Termine": Klausuren, Förderplan-Überprüfungen, Abgabefristen.
Bündelt geplante Klausuren, aktive Förderplan-Wiedervorlagen und offene Aufgaben mit
Fälligkeitsdatum für die nächsten 30 Tage; überfällige Einträge bleiben sichtbar und alle
Einträge springen zur passenden Lerngruppe bzw. zum Schüler.
- [x] **9.4** Kachel "Offene Korrekturen" mit Fortschritt (x von y Klausuren bewertet).
Durchgeführte und noch nicht zurückgegebene Klausuren zeigen den Fortschritt als Zahl und
Balken (`bewertete Arbeiten / am Klausurtag aktive Gruppenmitglieder`) und führen direkt in
den Klausuren-Tab der Lerngruppe.
- [x] **9.5** Kachel "Auffälligkeiten": Fehlzeitenüberschreitungen, Notenabfall, Versetzungsgefährdung.
Fehlzeiten nutzen den bestehenden konfigurierten Schwellenwert. Ein Notenabfall wird beim
Vergleich der letzten zwei mit den beiden vorherigen Einzelnoten erkannt (mindestens eine
Notenstufe bzw. drei Punkte); Versetzungsgefährdung basiert auf dem jüngsten gespeicherten
Zeugnisnotenstand (Note 5/6 bzw. höchstens 4 Punkte). Klick öffnet den betroffenen Schüler.
- [x] **9.6** Dashboard-Kacheln ein-/ausblendbar und in der Reihenfolge konfigurierbar.
„Dashboard anpassen“ bietet für jede Kachel Sichtbarkeit sowie Hoch-/Runter-Sortierung;
die Konfiguration wird lokal in `dashboardsettings.json` gespeichert und das Raster ohne
Lücken neu angeordnet.
- [x] **9.7** Automatische Aktualisierung beim Zurücknavigieren.
`MainWindowViewModel.GetDashboard()` führt beim Wechsel zurück zum Dashboard bereits den
`RefreshCommand` aus; damit werden Termine, Korrekturstände und Auffälligkeiten neu geladen.
- [x] **9.8** Kalender-Detailansicht: Tag im Monatskalender anklickbar/auswählbar, zeigt in
einem angrenzenden Feld die Termine dieses Tages (Unterricht, Klausuren, perspektivisch
Konferenzen/Sondertermine) mit Details und Sprungmöglichkeit in die jeweilige Ansicht.
Sinnvoll erst, wenn weitere Terminarten existieren — Abhängigkeit zu Kapitel 4
(Unterrichtsplanung/Termine) sowie ggf. einem neuen Termine-Modell.
Unterricht und Klausuren werden jetzt im Detailbereich der Kalenderkachel angezeigt und
verlinkt. Weitere Terminarten können später über das vorhandene `CalendarEventItem` ergänzt
werden, sobald dafür ein eigenes Termine-Modell existiert.
---