Notenverwaltung (Kapitel 2) und Mitarbeits-Assistent
Notenübersicht der Gruppe (Matrix, Gesamt-Spalte, Sortierung, Halbjahresfilter), Einzelnoten-Pflege samt Sammelerfassung, Gewichtungsschema mit Voreinstellung je Gruppentyp, Zeugnisnoten-Berechnung mit Übersteuern/Festschreiben/Export und Notenentwicklung im Schülerdetail. Dazu Anwesenheits-/Hausaufgaben-Tracking je Mitarbeit-Sitzung, ein neuer Mitarbeits-Assistent (Zeitleiste mit Abschnitten, automatischer Notenvorschlag, Zusammenzug zur Halbjahresnote) und eine Dashboard-Kachel für offene Entschuldigungen. Außerdem: verbliebene englische Begriffe in Auswahlfeldern und Buttons auf Deutsch umgestellt.
This commit is contained in:
@@ -17,8 +17,13 @@ public partial class DashboardViewModel : ObservableObject
|
||||
private readonly ILessonRepository _lessons;
|
||||
private readonly IExamRepository _exams;
|
||||
private readonly IWorkTaskRepository _tasks;
|
||||
private readonly IParticipationSessionRepository _participationSessions;
|
||||
private readonly IParticipationRepository _participationEntries;
|
||||
private readonly IStudentRepository _students;
|
||||
private readonly SchoolYearService _sy;
|
||||
|
||||
private const int OpenExcuseMaxAgeDays = 21;
|
||||
|
||||
[ObservableProperty] private string _greeting = "";
|
||||
[ObservableProperty] private string _currentDate = "";
|
||||
[ObservableProperty] private string _currentSchoolYear = "";
|
||||
@@ -30,15 +35,19 @@ public partial class DashboardViewModel : ObservableObject
|
||||
public ObservableCollection<TaskItem> OpenTasks { get; } = [];
|
||||
public ObservableCollection<GroupChip> CurrentGroups { get; } = [];
|
||||
public ObservableCollection<CalendarDayCell> CalendarDays { get; } = [];
|
||||
public ObservableCollection<OpenExcuseItem> OpenExcuses { get; } = [];
|
||||
public static string[] CalendarWeekdayHeaders { get; } = ["Mo", "Di", "Mi", "Do", "Fr", "Sa", "So"];
|
||||
|
||||
// Navigation-Callback – wird von App.axaml.cs verdrahtet
|
||||
public Action<Guid>? OnNavigateToGroup { get; set; }
|
||||
|
||||
public DashboardViewModel(IGroupRepository groups, ISubjectRepository subjects, ILessonRepository lessons,
|
||||
IExamRepository exams, IWorkTaskRepository tasks, SchoolYearService sy)
|
||||
IExamRepository exams, IWorkTaskRepository tasks, IParticipationSessionRepository participationSessions,
|
||||
IParticipationRepository participationEntries, IStudentRepository students, SchoolYearService sy)
|
||||
{
|
||||
_groups = groups; _subjects = subjects; _lessons = lessons; _exams = exams; _tasks = tasks; _sy = sy;
|
||||
_groups = groups; _subjects = subjects; _lessons = lessons; _exams = exams; _tasks = tasks;
|
||||
_participationSessions = participationSessions; _participationEntries = participationEntries;
|
||||
_students = students; _sy = sy;
|
||||
Load();
|
||||
}
|
||||
|
||||
@@ -80,6 +89,41 @@ public partial class DashboardViewModel : ObservableObject
|
||||
|
||||
CalendarMonth = FirstOfMonth(now);
|
||||
LoadCalendar();
|
||||
LoadOpenExcuses(groups.Values.ToList(), today);
|
||||
}
|
||||
|
||||
private void LoadOpenExcuses(List<LearningGroup> groups, DateOnly today)
|
||||
{
|
||||
OpenExcuses.Clear();
|
||||
var cutoff = today.AddDays(-OpenExcuseMaxAgeDays);
|
||||
|
||||
var items = new List<OpenExcuseItem>();
|
||||
foreach (var g in groups)
|
||||
{
|
||||
foreach (var session in _participationSessions.GetByGroup(g.Id).Where(s => s.Date >= cutoff && s.Date <= today))
|
||||
{
|
||||
foreach (var entry in _participationEntries.GetBySession(session.Id)
|
||||
.Where(e => e.Attendance == AttendanceStatus.ExcusePending))
|
||||
{
|
||||
var student = _students.GetById(entry.StudentId);
|
||||
if (student is null) continue;
|
||||
var item = new OpenExcuseItem(session.Id, entry.StudentId, student.FullName, g.Name, session.Date);
|
||||
item.OnResolve = ResolveExcuse;
|
||||
items.Add(item);
|
||||
}
|
||||
}
|
||||
}
|
||||
foreach (var item in items.OrderBy(i => i.Date))
|
||||
OpenExcuses.Add(item);
|
||||
}
|
||||
|
||||
private void ResolveExcuse(OpenExcuseItem item, AttendanceStatus status)
|
||||
{
|
||||
var entry = _participationEntries.GetBySessionAndStudent(item.SessionId, item.StudentId);
|
||||
if (entry is null) return;
|
||||
entry.Attendance = status;
|
||||
_participationEntries.Save(entry);
|
||||
OpenExcuses.Remove(item);
|
||||
}
|
||||
|
||||
private void LoadCalendar()
|
||||
@@ -172,6 +216,33 @@ public class LessonItem { public string GroupName { get; set; } = ""; public str
|
||||
public class TaskItem { public string Title { get; set; } = ""; public string DueDate { get; set; } = ""; public bool IsOverdue { get; set; } }
|
||||
public class GroupChip { public Guid GroupId { get; set; } public string Name { get; set; } = ""; public string Subject { get; set; } = ""; }
|
||||
|
||||
// ── Offene Entschuldigungen (aus Mitarbeit-Fehltagen) ────────────────────────
|
||||
|
||||
public partial class OpenExcuseItem : ObservableObject
|
||||
{
|
||||
public Guid SessionId { get; }
|
||||
public Guid StudentId { get; }
|
||||
public string StudentName { get; }
|
||||
public string GroupName { get; }
|
||||
public DateOnly Date { get; }
|
||||
public string DateDisplay { get; }
|
||||
|
||||
public Action<OpenExcuseItem, AttendanceStatus>? OnResolve { get; set; }
|
||||
|
||||
public OpenExcuseItem(Guid sessionId, Guid studentId, string studentName, string groupName, DateOnly date)
|
||||
{
|
||||
SessionId = sessionId;
|
||||
StudentId = studentId;
|
||||
StudentName = studentName;
|
||||
GroupName = groupName;
|
||||
Date = date;
|
||||
DateDisplay = date.ToString("dd.MM.yyyy");
|
||||
}
|
||||
|
||||
[RelayCommand] private void MarkExcused() => OnResolve?.Invoke(this, AttendanceStatus.Excused);
|
||||
[RelayCommand] private void MarkUnexcused() => OnResolve?.Invoke(this, AttendanceStatus.Unexcused);
|
||||
}
|
||||
|
||||
public class CalendarDayCell
|
||||
{
|
||||
public int DayNumber { get; }
|
||||
|
||||
@@ -0,0 +1,461 @@
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using LehrerApp.Core.Interfaces;
|
||||
using LehrerApp.Core.Models;
|
||||
using LehrerApp.Core.Services;
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Globalization;
|
||||
|
||||
namespace LehrerApp.Desktop.ViewModels.Groups;
|
||||
|
||||
// ── Notenübersicht der Gruppe (2.1) ──────────────────────────────────────────
|
||||
|
||||
public partial class GradeOverviewTabViewModel : ObservableObject
|
||||
{
|
||||
private readonly IGradeRepository _grades;
|
||||
private readonly IExamRepository _exams;
|
||||
private readonly IExamResultRepository _results;
|
||||
private readonly IStudentRepository _students;
|
||||
private readonly IGroupMembershipRepository _memberships;
|
||||
private readonly GradingService _grading;
|
||||
|
||||
private Guid _groupId;
|
||||
private GradingSystem _gradingSystem;
|
||||
private GroupType _groupType;
|
||||
private string _groupLabel = "";
|
||||
private bool _sortByTotal;
|
||||
private bool _sortDescending;
|
||||
|
||||
public Guid GroupId => _groupId;
|
||||
public GradingSystem GradingSystem => _gradingSystem;
|
||||
public GroupType GroupType => _groupType;
|
||||
public string GroupLabel => _groupLabel;
|
||||
|
||||
[ObservableProperty] private ParticipationPeriodOption _selectedPeriod;
|
||||
[ObservableProperty] private bool _showAsPoints = true;
|
||||
[ObservableProperty] private GradeOverviewRow? _selectedRow;
|
||||
[ObservableProperty] private int _rebuildColumnsSignal;
|
||||
|
||||
public bool CanTogglePointsView => _gradingSystem == GradingSystem.Points0To15;
|
||||
|
||||
public List<ParticipationPeriodOption> PeriodOptions { get; } =
|
||||
[
|
||||
new(ParticipationPeriod.FullYear, "Gesamtes Schuljahr"),
|
||||
new(ParticipationPeriod.H1, "1. Halbjahr"),
|
||||
new(ParticipationPeriod.H2, "2. Halbjahr"),
|
||||
];
|
||||
|
||||
public ObservableCollection<GradeOverviewColumnDef> Columns { get; } = [];
|
||||
public ObservableCollection<GradeOverviewRow> Rows { get; } = [];
|
||||
|
||||
public Func<GradeOverviewRow, Task>? OnManageStudentGrades { get; set; }
|
||||
public Func<Task>? OnCollectiveGrade { get; set; }
|
||||
public Func<Task>? OnReportGrades { get; set; }
|
||||
|
||||
public GradeOverviewTabViewModel(IGradeRepository grades, IExamRepository exams,
|
||||
IExamResultRepository results, IStudentRepository students,
|
||||
IGroupMembershipRepository memberships, GradingService grading)
|
||||
{
|
||||
_grades = grades; _exams = exams; _results = results;
|
||||
_students = students; _memberships = memberships; _grading = grading;
|
||||
_selectedPeriod = PeriodOptions[0];
|
||||
}
|
||||
|
||||
public void Initialize(Guid groupId, GradingSystem gradingSystem, GroupType groupType, string groupLabel)
|
||||
{
|
||||
_groupId = groupId;
|
||||
_gradingSystem = gradingSystem;
|
||||
_groupType = groupType;
|
||||
_groupLabel = groupLabel;
|
||||
ShowAsPoints = gradingSystem == GradingSystem.Points0To15;
|
||||
OnPropertyChanged(nameof(CanTogglePointsView));
|
||||
Recompute();
|
||||
}
|
||||
|
||||
public void Refresh() => Recompute();
|
||||
|
||||
partial void OnSelectedPeriodChanged(ParticipationPeriodOption value) => Recompute();
|
||||
partial void OnShowAsPointsChanged(bool value) => Recompute();
|
||||
|
||||
[RelayCommand]
|
||||
private void SortByName()
|
||||
{
|
||||
if (!_sortByTotal) _sortDescending = !_sortDescending;
|
||||
else { _sortByTotal = false; _sortDescending = false; }
|
||||
ApplySort();
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void SortByTotal()
|
||||
{
|
||||
if (_sortByTotal) _sortDescending = !_sortDescending;
|
||||
else { _sortByTotal = true; _sortDescending = false; }
|
||||
ApplySort();
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private async Task ManageStudentGrades()
|
||||
{
|
||||
if (SelectedRow is null || OnManageStudentGrades is null) return;
|
||||
await OnManageStudentGrades(SelectedRow);
|
||||
Recompute();
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private async Task CollectiveGrade()
|
||||
{
|
||||
if (OnCollectiveGrade is null) return;
|
||||
await OnCollectiveGrade();
|
||||
Recompute();
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private async Task ReportGrades()
|
||||
{
|
||||
if (OnReportGrades is null) return;
|
||||
await OnReportGrades();
|
||||
}
|
||||
|
||||
private void Recompute()
|
||||
{
|
||||
var period = SelectedPeriod.Period;
|
||||
|
||||
var students = _students.GetByGroup(_groupId);
|
||||
var membershipsByStudent = _memberships.GetByGroup(_groupId).ToDictionary(m => m.StudentId);
|
||||
|
||||
var exams = _exams.GetByGroup(_groupId)
|
||||
.Where(e => InPeriod(e.Date, period))
|
||||
.OrderBy(e => e.Date)
|
||||
.ToList();
|
||||
var resultsByExam = exams.ToDictionary(e => e.Id, e => _results.GetByExam(e.Id).ToDictionary(r => r.StudentId));
|
||||
|
||||
var otherGrades = _grades.GetByGroup(_groupId)
|
||||
.Where(g => InPeriod(g.Date, period))
|
||||
.ToList();
|
||||
var gradeColumnKeys = otherGrades
|
||||
.Select(g => (g.Category, Note: g.Note ?? "", g.Date))
|
||||
.Distinct()
|
||||
.OrderBy(k => k.Date)
|
||||
.ToList();
|
||||
|
||||
Columns.Clear();
|
||||
foreach (var exam in exams)
|
||||
Columns.Add(new GradeOverviewColumnDef($"{exam.Date:dd.MM.} {exam.Title}"));
|
||||
foreach (var key in gradeColumnKeys)
|
||||
{
|
||||
var header = string.IsNullOrWhiteSpace(key.Note)
|
||||
? $"{GradeCategoryDisplay.Label(key.Category)} {key.Date:dd.MM.}"
|
||||
: key.Note;
|
||||
Columns.Add(new GradeOverviewColumnDef(header));
|
||||
}
|
||||
|
||||
Rows.Clear();
|
||||
foreach (var student in students)
|
||||
{
|
||||
membershipsByStudent.TryGetValue(student.Id, out var membership);
|
||||
if (!StudentActiveInPeriod(membership, period)) continue;
|
||||
|
||||
var cells = new List<string>();
|
||||
var numeric = new List<(string Grade, double Weight)>();
|
||||
|
||||
foreach (var exam in exams)
|
||||
{
|
||||
if (exam.Niveau.HasValue && membership?.Niveau != exam.Niveau) { cells.Add(""); continue; }
|
||||
resultsByExam[exam.Id].TryGetValue(student.Id, out var result);
|
||||
if (result is null) { cells.Add(""); continue; }
|
||||
if (result.Absent) { cells.Add("abwesend"); continue; }
|
||||
var display = FormatValue(result.Grade);
|
||||
cells.Add(display);
|
||||
if (result.Grade is not null) numeric.Add((result.Grade, 1.0));
|
||||
}
|
||||
|
||||
foreach (var key in gradeColumnKeys)
|
||||
{
|
||||
var grade = otherGrades.FirstOrDefault(g =>
|
||||
g.StudentId == student.Id && g.Category == key.Category &&
|
||||
(g.Note ?? "") == key.Note && g.Date == key.Date);
|
||||
if (grade is null) { cells.Add(""); continue; }
|
||||
cells.Add(FormatValue(grade.Value));
|
||||
numeric.Add((grade.Value, grade.Weight));
|
||||
}
|
||||
|
||||
var total = numeric.Count == 0 ? (double?)null : _grading.WeightedAverage(numeric);
|
||||
var totalDisplay = total is null ? "–" : total.Value.ToString("0.00", CultureInfo.InvariantCulture);
|
||||
|
||||
Rows.Add(new GradeOverviewRow(student.Id, student.FullName, cells, totalDisplay, total));
|
||||
}
|
||||
|
||||
ApplySort();
|
||||
RebuildColumnsSignal++;
|
||||
}
|
||||
|
||||
private string FormatValue(string? raw)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(raw)) return "";
|
||||
if (!ShowAsPoints && _gradingSystem == GradingSystem.Points0To15 && int.TryParse(raw, out var points))
|
||||
return PointsNoteMapping.PointsToNote(points);
|
||||
return raw;
|
||||
}
|
||||
|
||||
private void ApplySort()
|
||||
{
|
||||
var sorted = _sortByTotal
|
||||
? Rows.OrderBy(r => r.TotalSortValue is null).ThenBy(r => r.TotalSortValue).ToList()
|
||||
: Rows.OrderBy(r => r.Name).ToList();
|
||||
if (_sortDescending) sorted.Reverse();
|
||||
for (var i = 0; i < sorted.Count; i++) Rows.Move(Rows.IndexOf(sorted[i]), i);
|
||||
}
|
||||
|
||||
private static bool InPeriod(DateOnly date, ParticipationPeriod period) => period switch
|
||||
{
|
||||
ParticipationPeriod.H1 => date.Month >= 8 || date.Month <= 1,
|
||||
ParticipationPeriod.H2 => date.Month >= 2 && date.Month <= 7,
|
||||
_ => true,
|
||||
};
|
||||
|
||||
private static bool StudentActiveInPeriod(GroupMembership? m, ParticipationPeriod period)
|
||||
{
|
||||
if (period == ParticipationPeriod.FullYear || m is null) return true;
|
||||
return m.Period switch
|
||||
{
|
||||
MembershipPeriod.H1Only => period == ParticipationPeriod.H1,
|
||||
MembershipPeriod.H2Only => period == ParticipationPeriod.H2,
|
||||
_ => true,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
public class GradeOverviewColumnDef(string header)
|
||||
{
|
||||
public string Header { get; } = header;
|
||||
}
|
||||
|
||||
public class GradeOverviewRow(Guid studentId, string name, List<string> cells, string totalDisplay, double? totalSortValue)
|
||||
{
|
||||
public Guid StudentId { get; } = studentId;
|
||||
public string Name { get; } = name;
|
||||
public List<string> Cells { get; } = cells;
|
||||
public string TotalDisplay { get; } = totalDisplay;
|
||||
public double? TotalSortValue { get; } = totalSortValue;
|
||||
}
|
||||
|
||||
// ── Kategorie-Anzeige & Punkte/Noten-Umrechnung ──────────────────────────────
|
||||
|
||||
public static class GradeCategoryDisplay
|
||||
{
|
||||
public static string Label(GradeCategory c) => c switch
|
||||
{
|
||||
GradeCategory.Oral => "Mündlich",
|
||||
GradeCategory.Homework => "Hausaufgaben",
|
||||
GradeCategory.Participation => "Mitarbeit",
|
||||
GradeCategory.Project => "Projekt",
|
||||
GradeCategory.Other => "Sonstiges",
|
||||
_ => c.ToString(),
|
||||
};
|
||||
|
||||
public static string[] Options { get; } = Enum.GetValues<GradeCategory>().Select(Label).ToArray();
|
||||
|
||||
public static GradeCategory FromLabel(string? label) =>
|
||||
Enum.GetValues<GradeCategory>().FirstOrDefault(c => Label(c) == label, GradeCategory.Other);
|
||||
}
|
||||
|
||||
public static class PointsNoteMapping
|
||||
{
|
||||
// Grobe, standardübliche Punkte-Noten-Umrechnung (Oberstufe), nur für die Anzeige.
|
||||
public static string PointsToNote(int points) => points switch
|
||||
{
|
||||
>= 13 => "1",
|
||||
>= 10 => "2",
|
||||
>= 7 => "3",
|
||||
>= 4 => "4",
|
||||
>= 1 => "5",
|
||||
_ => "6",
|
||||
};
|
||||
}
|
||||
|
||||
// ── Note hinzufügen/bearbeiten für einen Schüler (2.2.1, 2.2.2) ──────────────
|
||||
|
||||
public partial class StudentGradesDialogViewModel : ObservableObject
|
||||
{
|
||||
private readonly IGradeRepository _grades;
|
||||
private readonly Guid _studentId;
|
||||
private readonly Guid _groupId;
|
||||
|
||||
public string StudentName { get; }
|
||||
|
||||
public ObservableCollection<GradeEditItem> Entries { get; } = [];
|
||||
|
||||
public StudentGradesDialogViewModel(IGradeRepository grades, Guid studentId, Guid groupId, string studentName)
|
||||
{
|
||||
_grades = grades; _studentId = studentId; _groupId = groupId;
|
||||
StudentName = studentName;
|
||||
Load();
|
||||
}
|
||||
|
||||
private void Load()
|
||||
{
|
||||
Entries.Clear();
|
||||
foreach (var g in _grades.GetByStudentAndGroup(_studentId, _groupId).OrderByDescending(g => g.Date))
|
||||
Entries.Add(new GradeEditItem(g) { OnSave = Save, OnDelete = Delete });
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void AddEntry()
|
||||
{
|
||||
var item = new GradeEditItem(new Grade
|
||||
{
|
||||
StudentId = _studentId,
|
||||
GroupId = _groupId,
|
||||
Category = GradeCategory.Other,
|
||||
Date = DateOnly.FromDateTime(DateTime.Today),
|
||||
})
|
||||
{ OnSave = Save, OnDelete = Delete, IsNew = true };
|
||||
Entries.Insert(0, item);
|
||||
}
|
||||
|
||||
private void Save(GradeEditItem item)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(item.Value)) { item.ValidationMessage = "Wert darf nicht leer sein."; return; }
|
||||
if (!DateOnly.TryParseExact(item.DateText, "dd.MM.yyyy", null,
|
||||
System.Globalization.DateTimeStyles.None, out _))
|
||||
{ item.ValidationMessage = "Datum im Format TT.MM.JJJJ eingeben."; return; }
|
||||
item.ValidationMessage = "";
|
||||
var grade = item.ToModel();
|
||||
_grades.Save(grade);
|
||||
item.IsNew = false;
|
||||
item.MarkSaved(grade.CreatedAt);
|
||||
}
|
||||
|
||||
private void Delete(GradeEditItem item)
|
||||
{
|
||||
if (!item.IsNew) _grades.Delete(item.Id);
|
||||
Entries.Remove(item);
|
||||
}
|
||||
}
|
||||
|
||||
public partial class GradeEditItem : ObservableObject
|
||||
{
|
||||
public Guid Id { get; }
|
||||
private readonly Guid _studentId;
|
||||
private readonly Guid _groupId;
|
||||
public bool IsNew { get; set; }
|
||||
|
||||
[ObservableProperty] private GradeCategory _category;
|
||||
[ObservableProperty] private string _value;
|
||||
[ObservableProperty] private string _dateText;
|
||||
[ObservableProperty] private double _weight;
|
||||
[ObservableProperty] private string? _note;
|
||||
[ObservableProperty] private string _validationMessage = "";
|
||||
|
||||
// Für die ComboBox: deutsche Anzeige statt des rohen Enum-Namens.
|
||||
public string CategoryName
|
||||
{
|
||||
get => GradeCategoryDisplay.Label(Category);
|
||||
set => Category = GradeCategoryDisplay.FromLabel(value);
|
||||
}
|
||||
|
||||
public string CreatedAtDisplay { get; private set; }
|
||||
|
||||
public Action<GradeEditItem>? OnSave { get; set; }
|
||||
public Action<GradeEditItem>? OnDelete { get; set; }
|
||||
|
||||
public GradeEditItem(Grade g)
|
||||
{
|
||||
Id = g.Id;
|
||||
_studentId = g.StudentId;
|
||||
_groupId = g.GroupId;
|
||||
_category = g.Category;
|
||||
_value = g.Value;
|
||||
_dateText = g.Date.ToString("dd.MM.yyyy");
|
||||
_weight = g.Weight;
|
||||
_note = g.Note;
|
||||
CreatedAtDisplay = $"erfasst am {g.CreatedAt.ToLocalTime():dd.MM.yyyy HH:mm}";
|
||||
}
|
||||
|
||||
public void MarkSaved(DateTime createdAt) => CreatedAtDisplay = $"erfasst am {createdAt.ToLocalTime():dd.MM.yyyy HH:mm}";
|
||||
|
||||
public Grade ToModel() => new()
|
||||
{
|
||||
Id = Id,
|
||||
StudentId = _studentId,
|
||||
GroupId = _groupId,
|
||||
Category = Category,
|
||||
Value = Value.Trim(),
|
||||
Date = DateOnly.ParseExact(DateText, "dd.MM.yyyy"),
|
||||
Weight = Weight,
|
||||
Note = string.IsNullOrWhiteSpace(Note) ? null : Note.Trim(),
|
||||
};
|
||||
|
||||
[RelayCommand]
|
||||
private void Save() => OnSave?.Invoke(this);
|
||||
|
||||
[RelayCommand]
|
||||
private void Delete() => OnDelete?.Invoke(this);
|
||||
}
|
||||
|
||||
// ── Sammelerfassung für die ganze Gruppe (2.2.3) ─────────────────────────────
|
||||
|
||||
public partial class CollectiveGradeDialogViewModel : ObservableObject
|
||||
{
|
||||
private readonly IGradeRepository _grades;
|
||||
private readonly Guid _groupId;
|
||||
|
||||
[ObservableProperty] private GradeCategory _category = GradeCategory.Other;
|
||||
[ObservableProperty] private string _dateText = DateOnly.FromDateTime(DateTime.Today).ToString("dd.MM.yyyy");
|
||||
[ObservableProperty] private double _weight = 1.0;
|
||||
[ObservableProperty] private string? _note;
|
||||
[ObservableProperty] private string _statusMessage = "";
|
||||
[ObservableProperty] private string _validationMessage = "";
|
||||
|
||||
// Für die ComboBox: deutsche Anzeige statt des rohen Enum-Namens.
|
||||
public string CategoryName
|
||||
{
|
||||
get => GradeCategoryDisplay.Label(Category);
|
||||
set => Category = GradeCategoryDisplay.FromLabel(value);
|
||||
}
|
||||
|
||||
public ObservableCollection<CollectiveGradeStudentRow> Rows { get; } = [];
|
||||
|
||||
public CollectiveGradeDialogViewModel(IGradeRepository grades, IStudentRepository students, Guid groupId)
|
||||
{
|
||||
_grades = grades; _groupId = groupId;
|
||||
foreach (var s in students.GetByGroup(groupId).OrderBy(s => s.LastName).ThenBy(s => s.FirstName))
|
||||
Rows.Add(new CollectiveGradeStudentRow(s.Id, s.FullName));
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void SaveAll()
|
||||
{
|
||||
if (!DateOnly.TryParseExact(DateText, "dd.MM.yyyy", null,
|
||||
System.Globalization.DateTimeStyles.None, out var date))
|
||||
{ ValidationMessage = "Datum im Format TT.MM.JJJJ eingeben."; return; }
|
||||
ValidationMessage = "";
|
||||
|
||||
var count = 0;
|
||||
foreach (var row in Rows)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(row.Value)) continue;
|
||||
_grades.Save(new Grade
|
||||
{
|
||||
StudentId = row.StudentId,
|
||||
GroupId = _groupId,
|
||||
Category = Category,
|
||||
Value = row.Value.Trim(),
|
||||
Date = date,
|
||||
Weight = Weight,
|
||||
Note = string.IsNullOrWhiteSpace(Note) ? null : Note.Trim(),
|
||||
});
|
||||
count++;
|
||||
}
|
||||
StatusMessage = count == 0
|
||||
? "Keine Werte eingegeben."
|
||||
: $"{count} Note(n) gespeichert.";
|
||||
}
|
||||
}
|
||||
|
||||
public partial class CollectiveGradeStudentRow(Guid studentId, string name) : ObservableObject
|
||||
{
|
||||
public Guid StudentId { get; } = studentId;
|
||||
public string Name { get; } = name;
|
||||
[ObservableProperty] private string _value = "";
|
||||
}
|
||||
@@ -182,6 +182,7 @@ public partial class GroupDetailViewModel : ObservableObject
|
||||
public ObservableCollection<ExamSummary> Exams { get; } = [];
|
||||
|
||||
public ParticipationTabViewModel ParticipationTab { get; }
|
||||
public GradeOverviewTabViewModel GradeOverviewTab { get; }
|
||||
public Func<Task<bool>>? OnAddStudent { get; set; }
|
||||
public Func<Guid, Task<bool>>? OnAddExam { get; set; }
|
||||
public Func<Exam, Task<bool>>? OnEditExam { get; set; }
|
||||
@@ -193,11 +194,12 @@ public partial class GroupDetailViewModel : ObservableObject
|
||||
public GroupDetailViewModel(IGroupRepository groups, IStudentRepository students,
|
||||
IGroupMembershipRepository memberships, ISubjectRepository subjects,
|
||||
IExamRepository exams, IGradeRepository grades,
|
||||
ParticipationTabViewModel participationTab)
|
||||
ParticipationTabViewModel participationTab, GradeOverviewTabViewModel gradeOverviewTab)
|
||||
{
|
||||
_groups = groups; _students = students; _memberships = memberships; _subjects = subjects;
|
||||
_exams = exams; _grades = grades;
|
||||
ParticipationTab = participationTab;
|
||||
GradeOverviewTab = gradeOverviewTab;
|
||||
}
|
||||
|
||||
public void LoadGroup(Guid id)
|
||||
@@ -214,6 +216,7 @@ public partial class GroupDetailViewModel : ObservableObject
|
||||
LoadStudents();
|
||||
ReloadExams();
|
||||
ParticipationTab.Initialize(Group.Id, Group.SchoolYear);
|
||||
GradeOverviewTab.Initialize(Group.Id, Group.GradingSystem, Group.Type, GroupTitle);
|
||||
}
|
||||
|
||||
private void ReloadExams()
|
||||
|
||||
@@ -28,6 +28,7 @@ public partial class ParticipationTabViewModel : ObservableObject
|
||||
public Guid GroupId => _groupId;
|
||||
public string SchoolYear => _schoolYear;
|
||||
public GradingSystem GradingSystem => _gradingSystem;
|
||||
public string GroupLabel { get; private set; } = "";
|
||||
|
||||
[ObservableProperty] private ParticipationSessionItem? _selectedSession;
|
||||
[ObservableProperty] private string _noDataText = "Keine Bewertungssitzung ausgewählt.";
|
||||
@@ -47,6 +48,7 @@ public partial class ParticipationTabViewModel : ObservableObject
|
||||
public Func<Task<ParticipationSession?>>? OnAddSession { get; set; }
|
||||
public Func<ParticipationTabViewModel, Task>? OnQuickInput { get; set; }
|
||||
public Func<ParticipationTabViewModel, Task>? OnComputeGrade { get; set; }
|
||||
public Func<ParticipationTabViewModel, Task>? OnOpenWizard { get; set; }
|
||||
|
||||
public ParticipationTabViewModel(
|
||||
IParticipationSessionRepository sessions,
|
||||
@@ -72,6 +74,7 @@ public partial class ParticipationTabViewModel : ObservableObject
|
||||
_subjectId = group?.SubjectId;
|
||||
_gradeLevel = group?.GradeLevel ?? 0;
|
||||
_gradingSystem = group?.GradingSystem ?? GradingSystem.Grades1To6;
|
||||
GroupLabel = group?.Name ?? "";
|
||||
|
||||
HasCompetencyCatalog = _subjectId.HasValue
|
||||
&& _competencyDomains.GetBySubjectAndGrade(_subjectId.Value, _gradeLevel).Count > 0;
|
||||
@@ -143,6 +146,8 @@ public partial class ParticipationTabViewModel : ObservableObject
|
||||
var row = new ParticipationStudentRow(s.Id, s.FullName, entry, Aspects.ToList(), ActiveCompetencyCodes);
|
||||
row.OnRatingChanged = (sid, key, val) => SaveRating(sessionId, sid, key, val);
|
||||
row.OnCompetencyRatingChanged = (sid, code, val) => SaveCompetencyRating(sessionId, sid, code, val);
|
||||
row.HomeworkChangedCallback = (sid, val) => SaveHomework(sessionId, sid, val);
|
||||
row.AttendanceChangedCallback = (sid, val) => SaveAttendance(sessionId, sid, val);
|
||||
StudentRows.Add(row);
|
||||
}
|
||||
QuickInputCommand.NotifyCanExecuteChanged();
|
||||
@@ -185,6 +190,22 @@ public partial class ParticipationTabViewModel : ObservableObject
|
||||
if (SelectedSession is not null) LoadGrid(SelectedSession.Id);
|
||||
}
|
||||
|
||||
private void SaveHomework(Guid sessionId, Guid studentId, bool value)
|
||||
{
|
||||
var entry = _entries.GetBySessionAndStudent(sessionId, studentId)
|
||||
?? new ParticipationEntry { SessionId = sessionId, StudentId = studentId };
|
||||
entry.HomeworkMissing = value;
|
||||
_entries.Save(entry);
|
||||
}
|
||||
|
||||
private void SaveAttendance(Guid sessionId, Guid studentId, AttendanceStatus? value)
|
||||
{
|
||||
var entry = _entries.GetBySessionAndStudent(sessionId, studentId)
|
||||
?? new ParticipationEntry { SessionId = sessionId, StudentId = studentId };
|
||||
entry.Attendance = value;
|
||||
_entries.Save(entry);
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void ToggleCompetencyTags() => CompetencyTagsVisible = !CompetencyTagsVisible;
|
||||
|
||||
@@ -259,6 +280,13 @@ public partial class ParticipationTabViewModel : ObservableObject
|
||||
await OnComputeGrade(this);
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private async Task OpenWizard()
|
||||
{
|
||||
if (OnOpenWizard is null) return;
|
||||
await OnOpenWizard(this);
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void DeleteSession()
|
||||
{
|
||||
@@ -310,8 +338,16 @@ public partial class ParticipationStudentRow : ObservableObject
|
||||
public ObservableCollection<RatingCell> Cells { get; } = [];
|
||||
public ObservableCollection<RatingCell> CompetencyCells { get; } = [];
|
||||
|
||||
[ObservableProperty] private bool _homeworkMissing;
|
||||
[ObservableProperty] private AttendanceStatus? _attendance;
|
||||
|
||||
public string AttendanceLabel => AttendanceDisplay.ShortLabel(Attendance);
|
||||
public string AttendanceTooltip => AttendanceDisplay.Label(Attendance);
|
||||
|
||||
public Action<Guid, string, int?>? OnRatingChanged { get; set; }
|
||||
public Action<Guid, string, int?>? OnCompetencyRatingChanged { get; set; }
|
||||
public Action<Guid, bool>? HomeworkChangedCallback { get; set; }
|
||||
public Action<Guid, AttendanceStatus?>? AttendanceChangedCallback { get; set; }
|
||||
|
||||
public ParticipationStudentRow(Guid id, string name, ParticipationEntry entry,
|
||||
List<AspectColumnDef> aspects, List<string> competencyCodes)
|
||||
@@ -320,6 +356,8 @@ public partial class ParticipationStudentRow : ObservableObject
|
||||
Name = name;
|
||||
_entry = entry;
|
||||
_aspectDefs = aspects;
|
||||
_homeworkMissing = entry.HomeworkMissing;
|
||||
_attendance = entry.Attendance;
|
||||
|
||||
foreach (var a in aspects)
|
||||
{
|
||||
@@ -347,6 +385,61 @@ public partial class ParticipationStudentRow : ObservableObject
|
||||
cell?.SetValue(value);
|
||||
OnRatingChanged?.Invoke(StudentId, key, value);
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void ToggleHomework()
|
||||
{
|
||||
HomeworkMissing = !HomeworkMissing;
|
||||
HomeworkChangedCallback?.Invoke(StudentId, HomeworkMissing);
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void CycleAttendance()
|
||||
{
|
||||
Attendance = Attendance switch
|
||||
{
|
||||
null => AttendanceStatus.ExcusePending,
|
||||
AttendanceStatus.ExcusePending => AttendanceStatus.Excused,
|
||||
AttendanceStatus.Excused => AttendanceStatus.Unexcused,
|
||||
AttendanceStatus.Unexcused => null,
|
||||
_ => null,
|
||||
};
|
||||
OnPropertyChanged(nameof(AttendanceLabel));
|
||||
OnPropertyChanged(nameof(AttendanceTooltip));
|
||||
AttendanceChangedCallback?.Invoke(StudentId, Attendance);
|
||||
}
|
||||
|
||||
// Direktes Setzen (z.B. aus dem Grading-Wizard heraus), ohne den Zyklus zu durchlaufen.
|
||||
public void SetAttendance(AttendanceStatus? value)
|
||||
{
|
||||
Attendance = value;
|
||||
OnPropertyChanged(nameof(AttendanceLabel));
|
||||
OnPropertyChanged(nameof(AttendanceTooltip));
|
||||
AttendanceChangedCallback?.Invoke(StudentId, value);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Anwesenheits-Anzeige ──────────────────────────────────────────────────────
|
||||
|
||||
public static class AttendanceDisplay
|
||||
{
|
||||
public static string Label(AttendanceStatus? s) => s switch
|
||||
{
|
||||
null => "Anwesend",
|
||||
AttendanceStatus.ExcusePending => "Krank (Entschuldigung offen)",
|
||||
AttendanceStatus.Excused => "Krank, entschuldigt",
|
||||
AttendanceStatus.Unexcused => "Krank, unentschuldigt",
|
||||
_ => "Anwesend",
|
||||
};
|
||||
|
||||
public static string ShortLabel(AttendanceStatus? s) => s switch
|
||||
{
|
||||
null => "",
|
||||
AttendanceStatus.ExcusePending => "K ?",
|
||||
AttendanceStatus.Excused => "K ✓",
|
||||
AttendanceStatus.Unexcused => "K ✗",
|
||||
_ => "",
|
||||
};
|
||||
}
|
||||
|
||||
// ── Eine Bewertungszelle ──────────────────────────────────────────────────────
|
||||
|
||||
@@ -0,0 +1,427 @@
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using LehrerApp.Core.Interfaces;
|
||||
using LehrerApp.Core.Models;
|
||||
using LehrerApp.Core.Services;
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Globalization;
|
||||
|
||||
namespace LehrerApp.Desktop.ViewModels.Groups;
|
||||
|
||||
// ── Grading-Wizard: Mitarbeit als Zeitleiste mit Abschnitten ────────────────
|
||||
|
||||
public partial class ParticipationWizardDialogViewModel : ObservableObject
|
||||
{
|
||||
private const string AbschnittPrefix = "Abschnitt: ";
|
||||
|
||||
private readonly IParticipationSessionRepository _sessions;
|
||||
private readonly IParticipationRepository _entries;
|
||||
private readonly IParticipationSectionRepository _sectionRepo;
|
||||
private readonly IExamRepository _exams;
|
||||
private readonly IExamResultRepository _results;
|
||||
private readonly IGradeRepository _grades;
|
||||
private readonly GradingService _grading;
|
||||
private readonly Guid _groupId;
|
||||
private readonly string _schoolYear;
|
||||
private readonly GradingSystem _gradingSystem;
|
||||
|
||||
private readonly List<Student> _students;
|
||||
private readonly List<ParticipationSession> _allSessions;
|
||||
private readonly List<ParticipationSection> _sectionList;
|
||||
private readonly Dictionary<string, double> _aspectWeights;
|
||||
|
||||
public string GroupLabel { get; }
|
||||
|
||||
[ObservableProperty] private int _studentIndex;
|
||||
[ObservableProperty] private string _studentName = "";
|
||||
[ObservableProperty] private string _progressText = "";
|
||||
[ObservableProperty] private string _newSectionLabel = "";
|
||||
[ObservableProperty] private string _newSectionEndDateText = DateOnly.FromDateTime(DateTime.Today).ToString("dd.MM.yyyy");
|
||||
[ObservableProperty] private string _sectionValidationMessage = "";
|
||||
[ObservableProperty] private ParticipationPeriodOption _rollupPeriod;
|
||||
[ObservableProperty] private string _rollupStatusMessage = "";
|
||||
|
||||
public List<ParticipationPeriodOption> RollupPeriodOptions { get; } =
|
||||
[
|
||||
new(ParticipationPeriod.H1, "1. Halbjahr"),
|
||||
new(ParticipationPeriod.H2, "2. Halbjahr"),
|
||||
new(ParticipationPeriod.FullYear, "Gesamtes Schuljahr"),
|
||||
];
|
||||
|
||||
public ObservableCollection<WizardSectionGroup> Timeline { get; } = [];
|
||||
public ObservableCollection<WizardSectionRow> Sections { get; } = [];
|
||||
|
||||
public bool CanGoPrevious => StudentIndex > 0;
|
||||
public bool CanGoNext => StudentIndex < _students.Count - 1;
|
||||
|
||||
private Guid CurrentStudentId => _students[StudentIndex].Id;
|
||||
|
||||
public ParticipationWizardDialogViewModel(
|
||||
IParticipationSessionRepository sessions, IParticipationRepository entries,
|
||||
IParticipationAspectRepository aspects, IParticipationSectionRepository sectionRepo,
|
||||
IStudentRepository students, IExamRepository exams, IExamResultRepository results,
|
||||
IGradeRepository grades, GradingService grading,
|
||||
Guid groupId, string schoolYear, GradingSystem gradingSystem, string groupLabel)
|
||||
{
|
||||
_sessions = sessions; _entries = entries; _sectionRepo = sectionRepo;
|
||||
_exams = exams; _results = results; _grades = grades; _grading = grading;
|
||||
_groupId = groupId; _schoolYear = schoolYear; _gradingSystem = gradingSystem;
|
||||
GroupLabel = groupLabel;
|
||||
|
||||
_aspectWeights = aspects.GetDefaults().Concat(aspects.GetByGroup(groupId))
|
||||
.ToDictionary(a => a.Key, a => a.Weight);
|
||||
_students = students.GetByGroup(groupId).OrderBy(s => s.LastName).ThenBy(s => s.FirstName).ToList();
|
||||
_allSessions = sessions.GetByGroup(groupId).OrderBy(s => s.Date).ToList();
|
||||
_sectionList = sectionRepo.GetByGroup(groupId).OrderBy(s => s.StartDate).ToList();
|
||||
|
||||
_rollupPeriod = RollupPeriodOptions[0];
|
||||
NewSectionLabel = $"Abschnitt {_sectionList.Count + 1}";
|
||||
|
||||
if (_students.Count > 0) ShowStudent(0);
|
||||
}
|
||||
|
||||
[RelayCommand(CanExecute = nameof(CanGoPrevious))]
|
||||
private void PreviousStudent()
|
||||
{
|
||||
if (StudentIndex > 0) ShowStudent(StudentIndex - 1);
|
||||
}
|
||||
|
||||
[RelayCommand(CanExecute = nameof(CanGoNext))]
|
||||
private void NextStudent()
|
||||
{
|
||||
if (StudentIndex < _students.Count - 1) ShowStudent(StudentIndex + 1);
|
||||
}
|
||||
|
||||
private void ShowStudent(int index)
|
||||
{
|
||||
StudentIndex = index;
|
||||
StudentName = _students[index].FullName;
|
||||
ProgressText = $"{index + 1} / {_students.Count}";
|
||||
PreviousStudentCommand.NotifyCanExecuteChanged();
|
||||
NextStudentCommand.NotifyCanExecuteChanged();
|
||||
BuildTimeline(CurrentStudentId);
|
||||
BuildSections(CurrentStudentId);
|
||||
}
|
||||
|
||||
// ── Zeitleiste ────────────────────────────────────────────────────────────
|
||||
|
||||
private void BuildTimeline(Guid studentId)
|
||||
{
|
||||
var points = new List<(DateOnly Date, WizardTimelinePoint Point)>();
|
||||
|
||||
foreach (var session in _allSessions)
|
||||
{
|
||||
var entry = _entries.GetBySessionAndStudent(session.Id, studentId);
|
||||
points.Add((session.Date, BuildSessionPoint(session, entry, studentId)));
|
||||
}
|
||||
foreach (var exam in _exams.GetByGroup(_groupId).OrderBy(e => e.Date))
|
||||
{
|
||||
var result = _results.GetByExamAndStudent(exam.Id, studentId);
|
||||
if (result is null) continue;
|
||||
points.Add((exam.Date, BuildExamPoint(exam, result)));
|
||||
}
|
||||
points = points.OrderBy(p => p.Date).ToList();
|
||||
|
||||
Timeline.Clear();
|
||||
foreach (var section in _sectionList)
|
||||
{
|
||||
var group = new WizardSectionGroup($"{section.Label}\n{section.StartDate:dd.MM.}–{section.EndDate:dd.MM.}", isOpen: false);
|
||||
foreach (var (date, point) in points.Where(p => p.Date >= section.StartDate && p.Date <= section.EndDate))
|
||||
group.Points.Add(point);
|
||||
Timeline.Add(group);
|
||||
}
|
||||
|
||||
var openStart = ComputeOpenStart();
|
||||
var openGroup = new WizardSectionGroup($"läuft seit {openStart:dd.MM.}", isOpen: true);
|
||||
foreach (var (date, point) in points.Where(p => p.Date >= openStart))
|
||||
openGroup.Points.Add(point);
|
||||
Timeline.Add(openGroup);
|
||||
}
|
||||
|
||||
private WizardTimelinePoint BuildSessionPoint(ParticipationSession session, ParticipationEntry? entry, Guid studentId)
|
||||
{
|
||||
var ratingLabel = entry is not null ? WeightedRatingLabel(entry) : "";
|
||||
var note = entry?.Note;
|
||||
var tooltip = $"{session.Date:dd.MM.yyyy}" +
|
||||
(ratingLabel.Length > 0 ? $" · {ratingLabel}" : "") +
|
||||
(string.IsNullOrWhiteSpace(note) ? "" : $" · {note}");
|
||||
|
||||
var point = new WizardTimelinePoint(session.Date, isExam: false, ratingLabel, examLabel: "",
|
||||
hasNote: !string.IsNullOrWhiteSpace(note), tooltip: tooltip)
|
||||
{
|
||||
HasHomework = entry?.HomeworkMissing ?? false,
|
||||
AttendanceIcon = AttendanceDisplay.ShortLabel(entry?.Attendance),
|
||||
AttendanceTooltip = AttendanceDisplay.Label(entry?.Attendance),
|
||||
};
|
||||
point.ToggleHomeworkCommand = new RelayCommand(() => ToggleHomeworkAt(session.Id, studentId, point));
|
||||
point.CycleAttendanceCommand = new RelayCommand(() => CycleAttendanceAt(session.Id, studentId, point));
|
||||
return point;
|
||||
}
|
||||
|
||||
private WizardTimelinePoint BuildExamPoint(Exam exam, ExamResult result)
|
||||
{
|
||||
var examLabel = result.Absent ? $"{exam.Title}: abw." : $"{exam.Title}: {result.Grade}";
|
||||
return new WizardTimelinePoint(exam.Date, isExam: true, ratingLabel: "", examLabel: examLabel,
|
||||
hasNote: false, tooltip: $"{exam.Date:dd.MM.yyyy} · Klausur {examLabel}");
|
||||
}
|
||||
|
||||
private string WeightedRatingLabel(ParticipationEntry entry)
|
||||
{
|
||||
if (entry.Ratings.Count == 0) return "";
|
||||
var weightSum = 0.0; var valueSum = 0.0;
|
||||
foreach (var r in entry.Ratings)
|
||||
{
|
||||
var w = _aspectWeights.TryGetValue(r.Key, out var aw) ? aw : 1.0;
|
||||
if (w <= 0) continue;
|
||||
valueSum += r.Value * w; weightSum += w;
|
||||
}
|
||||
if (weightSum <= 0) return "";
|
||||
return RatingLabel((int)Math.Round(valueSum / weightSum, MidpointRounding.AwayFromZero));
|
||||
}
|
||||
|
||||
private static string RatingLabel(int v) => v switch
|
||||
{
|
||||
>= 2 => "++", 1 => "+", 0 => "~", -1 => "−", _ => "−−",
|
||||
};
|
||||
|
||||
private void ToggleHomeworkAt(Guid sessionId, Guid studentId, WizardTimelinePoint point)
|
||||
{
|
||||
var entry = _entries.GetBySessionAndStudent(sessionId, studentId)
|
||||
?? new ParticipationEntry { SessionId = sessionId, StudentId = studentId };
|
||||
entry.HomeworkMissing = !entry.HomeworkMissing;
|
||||
_entries.Save(entry);
|
||||
point.HasHomework = entry.HomeworkMissing;
|
||||
}
|
||||
|
||||
private void CycleAttendanceAt(Guid sessionId, Guid studentId, WizardTimelinePoint point)
|
||||
{
|
||||
var entry = _entries.GetBySessionAndStudent(sessionId, studentId)
|
||||
?? new ParticipationEntry { SessionId = sessionId, StudentId = studentId };
|
||||
entry.Attendance = entry.Attendance switch
|
||||
{
|
||||
null => AttendanceStatus.ExcusePending,
|
||||
AttendanceStatus.ExcusePending => AttendanceStatus.Excused,
|
||||
AttendanceStatus.Excused => AttendanceStatus.Unexcused,
|
||||
AttendanceStatus.Unexcused => null,
|
||||
_ => null,
|
||||
};
|
||||
_entries.Save(entry);
|
||||
point.AttendanceIcon = AttendanceDisplay.ShortLabel(entry.Attendance);
|
||||
point.AttendanceTooltip = AttendanceDisplay.Label(entry.Attendance);
|
||||
}
|
||||
|
||||
// ── Abschnitte ────────────────────────────────────────────────────────────
|
||||
|
||||
private DateOnly ComputeOpenStart() =>
|
||||
_sectionList.Count > 0 ? _sectionList.Max(s => s.EndDate).AddDays(1)
|
||||
: (_allSessions.Count > 0 ? _allSessions.Min(s => s.Date) : DateOnly.FromDateTime(DateTime.Today));
|
||||
|
||||
private void BuildSections(Guid studentId)
|
||||
{
|
||||
Sections.Clear();
|
||||
var studentGrades = _grades.GetByStudentAndGroup(studentId, _groupId)
|
||||
.Where(g => g.Category == GradeCategory.Participation && g.Note is not null && g.Note.StartsWith(AbschnittPrefix))
|
||||
.ToList();
|
||||
|
||||
foreach (var section in _sectionList)
|
||||
{
|
||||
var grade = studentGrades.FirstOrDefault(g => g.Note == AbschnittPrefix + section.Label);
|
||||
var row = new WizardSectionRow(section.Label, section.StartDate, section.EndDate,
|
||||
grade?.Value ?? "", isOpen: false);
|
||||
row.OnSave = SaveSectionGrade;
|
||||
Sections.Add(row);
|
||||
}
|
||||
|
||||
var openStart = ComputeOpenStart();
|
||||
var suggestion = ComputeSuggestion(studentId, openStart, DateOnly.FromDateTime(DateTime.Today));
|
||||
Sections.Add(new WizardSectionRow("(läuft)", openStart, DateOnly.FromDateTime(DateTime.Today),
|
||||
suggestion ?? "", isOpen: true));
|
||||
}
|
||||
|
||||
private string? ComputeSuggestion(Guid studentId, DateOnly start, DateOnly end)
|
||||
{
|
||||
var points = new List<double>();
|
||||
foreach (var session in _allSessions.Where(s => s.Date >= start && s.Date <= end))
|
||||
{
|
||||
var entry = _entries.GetBySessionAndStudent(session.Id, studentId);
|
||||
if (entry is null || entry.Ratings.Count == 0) continue;
|
||||
var weightSum = 0.0; var valueSum = 0.0;
|
||||
foreach (var r in entry.Ratings)
|
||||
{
|
||||
var w = _aspectWeights.TryGetValue(r.Key, out var aw) ? aw : 1.0;
|
||||
if (w <= 0) continue;
|
||||
valueSum += r.Value * w; weightSum += w;
|
||||
}
|
||||
if (weightSum > 0) points.Add(valueSum / weightSum);
|
||||
}
|
||||
return points.Count == 0 ? null : _grading.ParticipationGrade(points.Average(), _gradingSystem);
|
||||
}
|
||||
|
||||
private void SaveSectionGrade(WizardSectionRow row)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(row.Value)) { row.StatusMessage = "Wert darf nicht leer sein."; return; }
|
||||
var noteTag = AbschnittPrefix + row.Label;
|
||||
var record = _grades.GetByStudentAndGroup(CurrentStudentId, _groupId)
|
||||
.FirstOrDefault(g => g.Category == GradeCategory.Participation && g.Note == noteTag)
|
||||
?? new Grade
|
||||
{
|
||||
StudentId = CurrentStudentId,
|
||||
GroupId = _groupId,
|
||||
Category = GradeCategory.Participation,
|
||||
Note = noteTag,
|
||||
Date = row.EndDate,
|
||||
};
|
||||
record.Value = row.Value.Trim();
|
||||
_grades.Save(record);
|
||||
row.StatusMessage = "Gespeichert.";
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void CloseSection()
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(NewSectionLabel)) { SectionValidationMessage = "Bezeichnung erforderlich."; return; }
|
||||
if (!DateOnly.TryParseExact(NewSectionEndDateText, "dd.MM.yyyy", null,
|
||||
DateTimeStyles.None, out var end))
|
||||
{ SectionValidationMessage = "Datum im Format TT.MM.JJJJ eingeben."; return; }
|
||||
|
||||
var start = ComputeOpenStart();
|
||||
if (end < start) { SectionValidationMessage = "Enddatum liegt vor Abschnittsbeginn."; return; }
|
||||
|
||||
var section = new ParticipationSection { GroupId = _groupId, Label = NewSectionLabel.Trim(), StartDate = start, EndDate = end };
|
||||
_sectionRepo.Save(section);
|
||||
_sectionList.Add(section);
|
||||
|
||||
foreach (var student in _students)
|
||||
{
|
||||
var suggestion = ComputeSuggestion(student.Id, start, end);
|
||||
if (suggestion is null) continue;
|
||||
_grades.Save(new Grade
|
||||
{
|
||||
StudentId = student.Id,
|
||||
GroupId = _groupId,
|
||||
Category = GradeCategory.Participation,
|
||||
Note = AbschnittPrefix + section.Label,
|
||||
Value = suggestion,
|
||||
Date = end,
|
||||
Weight = 1.0,
|
||||
});
|
||||
}
|
||||
|
||||
SectionValidationMessage = "";
|
||||
NewSectionLabel = $"Abschnitt {_sectionList.Count + 1}";
|
||||
BuildTimeline(CurrentStudentId);
|
||||
BuildSections(CurrentStudentId);
|
||||
}
|
||||
|
||||
// ── Halbjahresnote aus Abschnitten ────────────────────────────────────────
|
||||
|
||||
[RelayCommand]
|
||||
private void ApplyRollup()
|
||||
{
|
||||
var periodTag = $"Mitarbeit {RollupPeriod.Label} {_schoolYear}";
|
||||
var applied = 0;
|
||||
foreach (var student in _students)
|
||||
{
|
||||
var sectionGrades = _grades.GetByStudentAndGroup(student.Id, _groupId)
|
||||
.Where(g => g.Category == GradeCategory.Participation && g.Note is not null && g.Note.StartsWith(AbschnittPrefix))
|
||||
.Where(g => InPeriod(g.Date, RollupPeriod.Period))
|
||||
.Select(g => (g.Value, g.Weight))
|
||||
.ToList();
|
||||
if (sectionGrades.Count == 0) continue;
|
||||
|
||||
var average = _grading.WeightedAverage(sectionGrades);
|
||||
var rounded = _grading.RoundToGrade(average, _gradingSystem, RoundingRule.Commercial);
|
||||
|
||||
var record = _grades.GetByStudentAndGroup(student.Id, _groupId)
|
||||
.FirstOrDefault(g => g.Category == GradeCategory.Participation && g.Note == periodTag)
|
||||
?? new Grade { StudentId = student.Id, GroupId = _groupId, Category = GradeCategory.Participation, Note = periodTag };
|
||||
record.Value = rounded;
|
||||
record.Date = DateOnly.FromDateTime(DateTime.Today);
|
||||
_grades.Save(record);
|
||||
applied++;
|
||||
}
|
||||
RollupStatusMessage = applied == 0
|
||||
? "Keine Abschnittsnoten im gewählten Zeitraum."
|
||||
: $"{applied} Halbjahresnote(n) aus Abschnitten übernommen.";
|
||||
}
|
||||
|
||||
private static bool InPeriod(DateOnly date, ParticipationPeriod period) => period switch
|
||||
{
|
||||
ParticipationPeriod.H1 => date.Month >= 8 || date.Month <= 1,
|
||||
ParticipationPeriod.H2 => date.Month >= 2 && date.Month <= 7,
|
||||
_ => true,
|
||||
};
|
||||
}
|
||||
|
||||
// ── Zeitleisten-Bausteine ────────────────────────────────────────────────────
|
||||
|
||||
public class WizardSectionGroup(string bandLabel, bool isOpen)
|
||||
{
|
||||
public string BandLabel { get; } = bandLabel;
|
||||
public bool IsOpen { get; } = isOpen;
|
||||
public ObservableCollection<WizardTimelinePoint> Points { get; } = [];
|
||||
}
|
||||
|
||||
public partial class WizardTimelinePoint : ObservableObject
|
||||
{
|
||||
public string DateDisplay { get; }
|
||||
public bool IsExam { get; }
|
||||
public string RatingLabel { get; }
|
||||
public string ExamLabel { get; }
|
||||
public bool HasNote { get; }
|
||||
public string TooltipText { get; }
|
||||
|
||||
[ObservableProperty] private bool _hasHomework;
|
||||
[ObservableProperty] private string _attendanceIcon = "";
|
||||
[ObservableProperty] private string _attendanceTooltip = "Anwesend";
|
||||
|
||||
public bool IsAbsent => AttendanceIcon.Length > 0;
|
||||
public string AttendanceButtonLabel => AttendanceIcon.Length > 0 ? AttendanceIcon : "Anw";
|
||||
|
||||
partial void OnAttendanceIconChanged(string value)
|
||||
{
|
||||
OnPropertyChanged(nameof(IsAbsent));
|
||||
OnPropertyChanged(nameof(AttendanceButtonLabel));
|
||||
}
|
||||
|
||||
public IRelayCommand? ToggleHomeworkCommand { get; set; }
|
||||
public IRelayCommand? CycleAttendanceCommand { get; set; }
|
||||
|
||||
public WizardTimelinePoint(DateOnly date, bool isExam, string ratingLabel, string examLabel,
|
||||
bool hasNote, string tooltip)
|
||||
{
|
||||
DateDisplay = date.ToString("dd.MM.", CultureInfo.InvariantCulture);
|
||||
IsExam = isExam;
|
||||
RatingLabel = ratingLabel;
|
||||
ExamLabel = examLabel;
|
||||
HasNote = hasNote;
|
||||
TooltipText = tooltip;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Abschnittsnote-Zeile ─────────────────────────────────────────────────────
|
||||
|
||||
public partial class WizardSectionRow : ObservableObject
|
||||
{
|
||||
public string Label { get; }
|
||||
public string RangeDisplay { get; }
|
||||
public DateOnly EndDate { get; }
|
||||
public bool IsOpen { get; }
|
||||
|
||||
[ObservableProperty] private string _value;
|
||||
[ObservableProperty] private string _statusMessage = "";
|
||||
|
||||
public Action<WizardSectionRow>? OnSave { get; set; }
|
||||
|
||||
public WizardSectionRow(string label, DateOnly start, DateOnly end, string value, bool isOpen)
|
||||
{
|
||||
Label = label;
|
||||
RangeDisplay = $"{start:dd.MM.yyyy} – {end:dd.MM.yyyy}";
|
||||
EndDate = end;
|
||||
IsOpen = isOpen;
|
||||
_value = value;
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void Save() => OnSave?.Invoke(this);
|
||||
}
|
||||
@@ -0,0 +1,254 @@
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using LehrerApp.Core.Interfaces;
|
||||
using LehrerApp.Core.Models;
|
||||
using LehrerApp.Core.Services;
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Globalization;
|
||||
using System.Text;
|
||||
|
||||
namespace LehrerApp.Desktop.ViewModels.Groups;
|
||||
|
||||
// ── Zeugnisnote (2.4) ─────────────────────────────────────────────────────────
|
||||
|
||||
public partial class ReportGradeDialogViewModel : ObservableObject
|
||||
{
|
||||
private readonly IGradeRepository _grades;
|
||||
private readonly IExamRepository _exams;
|
||||
private readonly IExamResultRepository _results;
|
||||
private readonly IStudentRepository _students;
|
||||
private readonly IGroupMembershipRepository _memberships;
|
||||
private readonly IGradingSchemeRepository _schemes;
|
||||
private readonly IReportGradeRepository _reportGrades;
|
||||
private readonly GradingService _grading;
|
||||
private readonly Guid _groupId;
|
||||
private readonly GroupType _groupType;
|
||||
private readonly GradingSystem _gradingSystem;
|
||||
private readonly string _groupLabel;
|
||||
|
||||
[ObservableProperty] private ParticipationPeriodOption _selectedPeriod;
|
||||
[ObservableProperty] private RoundingRule _roundingRule = RoundingRule.Commercial;
|
||||
[ObservableProperty] private string _schemeSummary = "";
|
||||
|
||||
public string GroupLabel => _groupLabel;
|
||||
public List<ParticipationPeriodOption> PeriodOptions { get; } =
|
||||
[
|
||||
new(ParticipationPeriod.FullYear, "Gesamtes Schuljahr"),
|
||||
new(ParticipationPeriod.H1, "1. Halbjahr"),
|
||||
new(ParticipationPeriod.H2, "2. Halbjahr"),
|
||||
];
|
||||
public string[] RoundingOptions { get; } = RoundingRuleDisplay.Options;
|
||||
|
||||
// Für die ComboBox: deutsche Anzeige statt des rohen Enum-Namens.
|
||||
public string RoundingRuleName
|
||||
{
|
||||
get => RoundingRuleDisplay.Label(RoundingRule);
|
||||
set => RoundingRule = RoundingRuleDisplay.FromLabel(value);
|
||||
}
|
||||
|
||||
public ObservableCollection<ReportGradeRow> Rows { get; } = [];
|
||||
|
||||
public ReportGradeDialogViewModel(IGradeRepository grades, IExamRepository exams,
|
||||
IExamResultRepository results, IStudentRepository students, IGroupMembershipRepository memberships,
|
||||
IGradingSchemeRepository schemes, IReportGradeRepository reportGrades, GradingService grading,
|
||||
Guid groupId, GroupType groupType, GradingSystem gradingSystem, string groupLabel)
|
||||
{
|
||||
_grades = grades; _exams = exams; _results = results; _students = students;
|
||||
_memberships = memberships; _schemes = schemes; _reportGrades = reportGrades; _grading = grading;
|
||||
_groupId = groupId; _groupType = groupType; _gradingSystem = gradingSystem; _groupLabel = groupLabel;
|
||||
|
||||
_selectedPeriod = PeriodOptions[0];
|
||||
Recompute();
|
||||
}
|
||||
|
||||
partial void OnSelectedPeriodChanged(ParticipationPeriodOption value) => Recompute();
|
||||
partial void OnRoundingRuleChanged(RoundingRule value) => Recompute();
|
||||
|
||||
private GradingScheme ResolveScheme() =>
|
||||
_schemes.GetByGroup(_groupId)
|
||||
?? _schemes.GetDefaultForType(_groupType)
|
||||
?? new GradingScheme { ExamsPercent = 50, ParticipationPercent = 40, OtherPercent = 10 };
|
||||
|
||||
private void Recompute()
|
||||
{
|
||||
var scheme = ResolveScheme();
|
||||
SchemeSummary = $"Klausuren {scheme.ExamsPercent:0.#} % · Mitarbeit {scheme.ParticipationPercent:0.#} % · " +
|
||||
$"Sonstige {scheme.OtherPercent:0.#} %";
|
||||
|
||||
var period = SelectedPeriod.Period;
|
||||
var periodTag = SelectedPeriod.Label;
|
||||
|
||||
var students = _students.GetByGroup(_groupId);
|
||||
var membershipsByStudent = _memberships.GetByGroup(_groupId).ToDictionary(m => m.StudentId);
|
||||
var exams = _exams.GetByGroup(_groupId).Where(e => InPeriod(e.Date, period)).ToList();
|
||||
var resultsByExam = exams.ToDictionary(e => e.Id, e => _results.GetByExam(e.Id).ToDictionary(r => r.StudentId));
|
||||
var allGrades = _grades.GetByGroup(_groupId).Where(g => InPeriod(g.Date, period)).ToList();
|
||||
|
||||
Rows.Clear();
|
||||
foreach (var student in students.OrderBy(s => s.LastName).ThenBy(s => s.FirstName))
|
||||
{
|
||||
membershipsByStudent.TryGetValue(student.Id, out var membership);
|
||||
if (!StudentActiveInPeriod(membership, period)) continue;
|
||||
|
||||
var existing = _reportGrades.GetByStudentGroupPeriod(student.Id, _groupId, periodTag);
|
||||
|
||||
if (existing is { IsLocked: true })
|
||||
{
|
||||
Rows.Add(ReportGradeRow.FromLocked(student.Id, student.FullName, existing, Save, ToggleLock));
|
||||
continue;
|
||||
}
|
||||
|
||||
var examGrades = new List<(string Grade, double Weight)>();
|
||||
foreach (var exam in exams)
|
||||
{
|
||||
if (exam.Niveau.HasValue && membership?.Niveau != exam.Niveau) continue;
|
||||
if (!resultsByExam[exam.Id].TryGetValue(student.Id, out var result)) continue;
|
||||
if (result.Absent || result.Grade is null) continue;
|
||||
examGrades.Add((result.Grade, 1.0));
|
||||
}
|
||||
|
||||
var participationGrades = allGrades
|
||||
.Where(g => g.StudentId == student.Id && g.Category == GradeCategory.Participation)
|
||||
.Select(g => (g.Value, g.Weight)).ToList();
|
||||
var otherGrades = allGrades
|
||||
.Where(g => g.StudentId == student.Id && g.Category != GradeCategory.Participation)
|
||||
.Select(g => (g.Value, g.Weight)).ToList();
|
||||
|
||||
var calculated = _grading.CalculateReportGrade(examGrades, participationGrades, otherGrades,
|
||||
scheme, _gradingSystem, RoundingRule);
|
||||
|
||||
Rows.Add(ReportGradeRow.FromCalculated(student.Id, student.FullName, calculated, existing, Save, ToggleLock));
|
||||
}
|
||||
}
|
||||
|
||||
private void Save(ReportGradeRow row)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(row.OverrideValue) && string.IsNullOrWhiteSpace(row.OverrideReason))
|
||||
{
|
||||
row.ValidationMessage = "Für ein manuelles Übersteuern ist eine Begründung Pflicht.";
|
||||
return;
|
||||
}
|
||||
row.ValidationMessage = "";
|
||||
|
||||
var record = _reportGrades.GetByStudentGroupPeriod(row.StudentId, _groupId, SelectedPeriod.Label)
|
||||
?? new ReportGrade { StudentId = row.StudentId, GroupId = _groupId, Period = SelectedPeriod.Label };
|
||||
record.CalculatedValue = row.CalculatedValue ?? "";
|
||||
record.OverrideValue = string.IsNullOrWhiteSpace(row.OverrideValue) ? null : row.OverrideValue.Trim();
|
||||
record.OverrideReason = string.IsNullOrWhiteSpace(row.OverrideReason) ? null : row.OverrideReason.Trim();
|
||||
_reportGrades.Save(record);
|
||||
row.MarkSaved();
|
||||
}
|
||||
|
||||
private void ToggleLock(ReportGradeRow row)
|
||||
{
|
||||
var record = _reportGrades.GetByStudentGroupPeriod(row.StudentId, _groupId, SelectedPeriod.Label);
|
||||
if (record is null)
|
||||
{
|
||||
if (!row.IsLocked)
|
||||
{
|
||||
// Festschreiben ohne vorherigen Save: aktuellen Stand zuerst sichern.
|
||||
Save(row);
|
||||
record = _reportGrades.GetByStudentGroupPeriod(row.StudentId, _groupId, SelectedPeriod.Label);
|
||||
if (record is null) return;
|
||||
}
|
||||
else return;
|
||||
}
|
||||
record.IsLocked = !record.IsLocked;
|
||||
_reportGrades.Save(record);
|
||||
Recompute();
|
||||
}
|
||||
|
||||
public string ExportCsv()
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
sb.AppendLine($"Zeugnisnoten;{_groupLabel};{SelectedPeriod.Label}");
|
||||
sb.AppendLine("Schüler;Berechnet;Übersteuert;Begründung;Endnote;Gesperrt");
|
||||
foreach (var r in Rows)
|
||||
sb.AppendLine($"{r.Name};{r.CalculatedValue};{r.OverrideValue};{r.OverrideReason};{r.FinalDisplay};{(r.IsLocked ? "ja" : "")}");
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
private static bool InPeriod(DateOnly date, ParticipationPeriod period) => period switch
|
||||
{
|
||||
ParticipationPeriod.H1 => date.Month >= 8 || date.Month <= 1,
|
||||
ParticipationPeriod.H2 => date.Month >= 2 && date.Month <= 7,
|
||||
_ => true,
|
||||
};
|
||||
|
||||
private static bool StudentActiveInPeriod(GroupMembership? m, ParticipationPeriod period)
|
||||
{
|
||||
if (period == ParticipationPeriod.FullYear || m is null) return true;
|
||||
return m.Period switch
|
||||
{
|
||||
MembershipPeriod.H1Only => period == ParticipationPeriod.H1,
|
||||
MembershipPeriod.H2Only => period == ParticipationPeriod.H2,
|
||||
_ => true,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// ── Rundungsregel-Anzeige ─────────────────────────────────────────────────────
|
||||
|
||||
public static class RoundingRuleDisplay
|
||||
{
|
||||
public static string Label(RoundingRule r) => r switch
|
||||
{
|
||||
RoundingRule.Commercial => "Kaufmännisch",
|
||||
RoundingRule.Pedagogical => "Pädagogisch",
|
||||
_ => r.ToString(),
|
||||
};
|
||||
|
||||
public static string[] Options { get; } = [Label(RoundingRule.Commercial), Label(RoundingRule.Pedagogical)];
|
||||
|
||||
public static RoundingRule FromLabel(string? label) =>
|
||||
label == Label(RoundingRule.Pedagogical) ? RoundingRule.Pedagogical : RoundingRule.Commercial;
|
||||
}
|
||||
|
||||
// ── Zeile: Zeugnisnote eines Schülers ────────────────────────────────────────
|
||||
|
||||
public partial class ReportGradeRow : ObservableObject
|
||||
{
|
||||
public Guid StudentId { get; }
|
||||
public string Name { get; }
|
||||
public string? CalculatedValue { get; private set; }
|
||||
public bool IsLocked { get; private set; }
|
||||
|
||||
[ObservableProperty] private string? _overrideValue;
|
||||
[ObservableProperty] private string? _overrideReason;
|
||||
[ObservableProperty] private string _validationMessage = "";
|
||||
|
||||
public string CalculatedDisplay => CalculatedValue ?? "–";
|
||||
public string FinalDisplay => !string.IsNullOrWhiteSpace(OverrideValue) ? OverrideValue! : CalculatedDisplay;
|
||||
public string LockLabel => IsLocked ? "Entsperren" : "Festschreiben";
|
||||
|
||||
public IRelayCommand SaveCommand { get; }
|
||||
public IRelayCommand ToggleLockCommand { get; }
|
||||
|
||||
private ReportGradeRow(Guid studentId, string name, string? calculated, ReportGrade? existing,
|
||||
bool locked, Action<ReportGradeRow> onSave, Action<ReportGradeRow> onToggleLock)
|
||||
{
|
||||
StudentId = studentId;
|
||||
Name = name;
|
||||
CalculatedValue = calculated;
|
||||
IsLocked = locked;
|
||||
_overrideValue = existing?.OverrideValue;
|
||||
_overrideReason = existing?.OverrideReason;
|
||||
SaveCommand = new RelayCommand(() => onSave(this));
|
||||
ToggleLockCommand = new RelayCommand(() => onToggleLock(this));
|
||||
}
|
||||
|
||||
public static ReportGradeRow FromCalculated(Guid studentId, string name, string? calculated,
|
||||
ReportGrade? existing, Action<ReportGradeRow> onSave, Action<ReportGradeRow> onToggleLock) =>
|
||||
new(studentId, name, calculated, existing, existing?.IsLocked ?? false, onSave, onToggleLock);
|
||||
|
||||
public static ReportGradeRow FromLocked(Guid studentId, string name, ReportGrade locked,
|
||||
Action<ReportGradeRow> onSave, Action<ReportGradeRow> onToggleLock) =>
|
||||
new(studentId, name, locked.CalculatedValue, locked, true, onSave, onToggleLock);
|
||||
|
||||
public void MarkSaved()
|
||||
{
|
||||
OnPropertyChanged(nameof(FinalDisplay));
|
||||
}
|
||||
|
||||
partial void OnOverrideValueChanged(string? value) => OnPropertyChanged(nameof(FinalDisplay));
|
||||
}
|
||||
@@ -16,6 +16,7 @@ public partial class SettingsViewModel : ObservableObject
|
||||
private readonly ISubjectRepository _subjects;
|
||||
private readonly ICompetencyDomainRepository _domainRepo;
|
||||
private readonly IGradingKeyTemplateRepository _gradingKeyTemplates;
|
||||
private readonly IGradingSchemeRepository _gradingSchemes;
|
||||
private readonly GradingService _grading;
|
||||
|
||||
// ── Fächer ────────────────────────────────────────────────────────────────
|
||||
@@ -45,17 +46,39 @@ public partial class SettingsViewModel : ObservableObject
|
||||
public List<string> GradingSystemOptions { get; } = ["Noten 1–6", "Punkte 0–15"];
|
||||
public ObservableCollection<GradingKeyTemplateEditItem> GradingKeyTemplateList { get; } = [];
|
||||
|
||||
// ── Gewichtungsschema-Voreinstellungen (2.3.3) ───────────────────────────
|
||||
|
||||
[ObservableProperty] private GradingSchemeEditItem _classScheme = null!;
|
||||
[ObservableProperty] private GradingSchemeEditItem _courseScheme = null!;
|
||||
|
||||
// ── Konstruktor ───────────────────────────────────────────────────────────
|
||||
|
||||
public SettingsViewModel(ISubjectRepository subjects, ICompetencyDomainRepository domainRepo,
|
||||
IGradingKeyTemplateRepository gradingKeyTemplates, GradingService grading)
|
||||
IGradingKeyTemplateRepository gradingKeyTemplates, IGradingSchemeRepository gradingSchemes,
|
||||
GradingService grading)
|
||||
{
|
||||
_subjects = subjects;
|
||||
_domainRepo = domainRepo;
|
||||
_gradingKeyTemplates = gradingKeyTemplates;
|
||||
_gradingSchemes = gradingSchemes;
|
||||
_grading = grading;
|
||||
LoadSubjects();
|
||||
LoadGradingKeyTemplates();
|
||||
LoadGradingSchemes();
|
||||
}
|
||||
|
||||
// ── Gewichtungsschema-Voreinstellungen: Laden ────────────────────────────
|
||||
|
||||
private void LoadGradingSchemes()
|
||||
{
|
||||
ClassScheme = new GradingSchemeEditItem(
|
||||
_gradingSchemes.GetDefaultForType(GroupType.Class)
|
||||
?? new GradingScheme { GroupType = GroupType.Class, ExamsPercent = 50, ParticipationPercent = 40, OtherPercent = 10 },
|
||||
"Klassen", _gradingSchemes, _grading);
|
||||
CourseScheme = new GradingSchemeEditItem(
|
||||
_gradingSchemes.GetDefaultForType(GroupType.Course)
|
||||
?? new GradingScheme { GroupType = GroupType.Course, ExamsPercent = 60, ParticipationPercent = 30, OtherPercent = 10 },
|
||||
"Kurse", _gradingSchemes, _grading);
|
||||
}
|
||||
|
||||
// ── Notenschlüssel-Vorlagen: Laden / Hinzufügen / Löschen ────────────────
|
||||
@@ -407,6 +430,47 @@ public class GradingKeyEntryVm
|
||||
}
|
||||
}
|
||||
|
||||
// ── GradingSchemeEditItem (2.3) ────────────────────────────────────────────────
|
||||
|
||||
public partial class GradingSchemeEditItem : ObservableObject
|
||||
{
|
||||
private readonly GradingScheme _scheme;
|
||||
private readonly IGradingSchemeRepository _repo;
|
||||
private readonly GradingService _grading;
|
||||
|
||||
public string Label { get; }
|
||||
|
||||
[ObservableProperty] private double _examsPercent;
|
||||
[ObservableProperty] private double _participationPercent;
|
||||
[ObservableProperty] private double _otherPercent;
|
||||
[ObservableProperty] private string _validationMessage = "";
|
||||
[ObservableProperty] private string _statusMessage = "";
|
||||
|
||||
public GradingSchemeEditItem(GradingScheme scheme, string label, IGradingSchemeRepository repo, GradingService grading)
|
||||
{
|
||||
_scheme = scheme; _repo = repo; _grading = grading;
|
||||
Label = label;
|
||||
_examsPercent = scheme.ExamsPercent;
|
||||
_participationPercent = scheme.ParticipationPercent;
|
||||
_otherPercent = scheme.OtherPercent;
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void Save()
|
||||
{
|
||||
_scheme.ExamsPercent = ExamsPercent;
|
||||
_scheme.ParticipationPercent = ParticipationPercent;
|
||||
_scheme.OtherPercent = OtherPercent;
|
||||
|
||||
var error = _grading.ValidateGradingScheme(_scheme);
|
||||
if (error is not null) { ValidationMessage = error; StatusMessage = ""; return; }
|
||||
|
||||
ValidationMessage = "";
|
||||
_repo.Save(_scheme);
|
||||
StatusMessage = "Gespeichert.";
|
||||
}
|
||||
}
|
||||
|
||||
// ── Hilfklassen ───────────────────────────────────────────────────────────────
|
||||
|
||||
public class SubjectListItem(Subject s)
|
||||
|
||||
@@ -2,7 +2,9 @@ using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using LehrerApp.Core.Interfaces;
|
||||
using LehrerApp.Core.Models;
|
||||
using LehrerApp.Desktop.ViewModels.Groups;
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Globalization;
|
||||
|
||||
namespace LehrerApp.Desktop.ViewModels.Students;
|
||||
|
||||
@@ -72,6 +74,9 @@ public partial class StudentDetailViewModel : ObservableObject
|
||||
private readonly IGroupRepository _groups;
|
||||
private readonly ISubjectRepository _subjects;
|
||||
private readonly IDocumentationRepository _docs;
|
||||
private readonly IExamRepository _exams;
|
||||
private readonly IExamResultRepository _examResults;
|
||||
private readonly IGradeRepository _grades;
|
||||
|
||||
[ObservableProperty] private Student? _student;
|
||||
[ObservableProperty] private string _studentTitle = "";
|
||||
@@ -83,16 +88,19 @@ public partial class StudentDetailViewModel : ObservableObject
|
||||
public ObservableCollection<GroupMembershipEntry> GroupMemberships { get; } = [];
|
||||
public ObservableCollection<DocEntry> Documentation { get; } = [];
|
||||
public ObservableCollection<ContactItem> Contacts { get; } = [];
|
||||
public ObservableCollection<StudentGradeHistoryGroup> GradeHistory { get; } = [];
|
||||
public bool HasNoContacts => Contacts.Count == 0;
|
||||
public Func<Contact?, Task<Contact?>>? OnEditContact { get; set; }
|
||||
public Action<ContactItem>? OnViewAddress { get; set; }
|
||||
|
||||
public StudentDetailViewModel(IStudentRepository students,
|
||||
IGroupMembershipRepository memberships, IGroupRepository groups, ISubjectRepository subjects,
|
||||
IDocumentationRepository docs)
|
||||
IDocumentationRepository docs, IExamRepository exams, IExamResultRepository examResults,
|
||||
IGradeRepository grades)
|
||||
{
|
||||
_students = students; _memberships = memberships;
|
||||
_groups = groups; _subjects = subjects; _docs = docs;
|
||||
_exams = exams; _examResults = examResults; _grades = grades;
|
||||
}
|
||||
|
||||
public void LoadStudent(Guid id)
|
||||
@@ -104,12 +112,16 @@ public partial class StudentDetailViewModel : ObservableObject
|
||||
EditLastName = Student.LastName;
|
||||
|
||||
GroupMemberships.Clear();
|
||||
GradeHistory.Clear();
|
||||
foreach (var membership in _memberships.GetByStudent(Student.Id))
|
||||
{
|
||||
var g = _groups.GetById(membership.GroupId);
|
||||
if (g is null) continue;
|
||||
var subject = g.SubjectId is Guid subjectId ? _subjects.GetById(subjectId)?.Name ?? "" : "";
|
||||
GroupMemberships.Add(new() { SchoolYear = g.SchoolYear, GroupName = g.Name, Subject = subject });
|
||||
|
||||
var historyGroup = BuildGradeHistory(g, subject);
|
||||
if (historyGroup is not null) GradeHistory.Add(historyGroup);
|
||||
}
|
||||
|
||||
LoadContacts();
|
||||
@@ -128,6 +140,49 @@ public partial class StudentDetailViewModel : ObservableObject
|
||||
IsConfidential = d.IsConfidential });
|
||||
}
|
||||
|
||||
// ── Notenentwicklung (2.5) ────────────────────────────────────────────────
|
||||
|
||||
private StudentGradeHistoryGroup? BuildGradeHistory(LearningGroup g, string subject)
|
||||
{
|
||||
var label = string.IsNullOrEmpty(subject) ? g.Name : $"{g.Name} · {subject}";
|
||||
var entries = new List<(DateOnly Date, string PointLabel, string Value)>();
|
||||
|
||||
foreach (var exam in _exams.GetByGroup(g.Id))
|
||||
{
|
||||
var result = _examResults.GetByExamAndStudent(exam.Id, Student!.Id);
|
||||
if (result is null || result.Absent || result.Grade is null) continue;
|
||||
entries.Add((exam.Date, exam.Title, result.Grade));
|
||||
}
|
||||
foreach (var grade in _grades.GetByStudentAndGroup(Student!.Id, g.Id))
|
||||
entries.Add((grade.Date, GradeCategoryDisplay.Label(grade.Category), grade.Value));
|
||||
|
||||
var ordered = entries.OrderBy(e => e.Date).ToList();
|
||||
if (ordered.Count == 0) return null;
|
||||
|
||||
var historyGroup = new StudentGradeHistoryGroup(label);
|
||||
int? previousNoteEquivalent = null;
|
||||
foreach (var e in ordered)
|
||||
{
|
||||
int? noteEquivalent = int.TryParse(e.Value, out var raw)
|
||||
? (g.GradingSystem == GradingSystem.Grades1To6 ? raw : int.Parse(PointsNoteMapping.PointsToNote(raw)))
|
||||
: null;
|
||||
|
||||
var isFailing = noteEquivalent is >= 5;
|
||||
var isDrop = noteEquivalent.HasValue && previousNoteEquivalent.HasValue
|
||||
&& noteEquivalent.Value - previousNoteEquivalent.Value >= 1;
|
||||
|
||||
var warnings = new List<string>();
|
||||
if (isDrop) warnings.Add("Abfall um ≥ 1 Note");
|
||||
if (isFailing) warnings.Add("Versetzungsgefährdung");
|
||||
|
||||
historyGroup.Points.Add(new GradeHistoryPoint(e.Date, e.PointLabel, e.Value,
|
||||
noteEquivalent, warnings.Count > 0, string.Join(" · ", warnings)));
|
||||
|
||||
if (noteEquivalent.HasValue) previousNoteEquivalent = noteEquivalent;
|
||||
}
|
||||
return historyGroup;
|
||||
}
|
||||
|
||||
[RelayCommand] private void StartEdit() => IsEditing = true;
|
||||
[RelayCommand] private void CancelEdit()
|
||||
{
|
||||
@@ -200,6 +255,43 @@ public partial class StudentDetailViewModel : ObservableObject
|
||||
public class GroupMembershipEntry { public string SchoolYear { get; set; } = ""; public string GroupName { get; set; } = ""; public string Subject { get; set; } = ""; }
|
||||
public class DocEntry { public string Date { get; set; } = ""; public string Title { get; set; } = ""; public string TypeLabel { get; set; } = ""; public bool IsConfidential { get; set; } }
|
||||
|
||||
// ── Notenentwicklung (2.5) ──────────────────────────────────────────────────
|
||||
|
||||
public class StudentGradeHistoryGroup(string label)
|
||||
{
|
||||
public string Label { get; } = label;
|
||||
public ObservableCollection<GradeHistoryPoint> Points { get; } = [];
|
||||
public bool HasWarnings => Points.Any(p => p.IsWarning);
|
||||
}
|
||||
|
||||
public class GradeHistoryPoint
|
||||
{
|
||||
public string DateDisplay { get; }
|
||||
public string Label { get; }
|
||||
public string Value { get; }
|
||||
public double BarHeight { get; }
|
||||
public bool IsWarning { get; }
|
||||
public string WarningText { get; }
|
||||
public string TooltipText { get; }
|
||||
|
||||
public GradeHistoryPoint(DateOnly date, string label, string value, int? noteEquivalent,
|
||||
bool isWarning, string warningText)
|
||||
{
|
||||
DateDisplay = date.ToString("dd.MM.", CultureInfo.InvariantCulture);
|
||||
Label = label;
|
||||
Value = value;
|
||||
IsWarning = isWarning;
|
||||
WarningText = warningText;
|
||||
// Balkenhöhe nach Notenqualität (1 = beste Note) auf 6..60px, sonst neutrale Mindesthöhe.
|
||||
BarHeight = noteEquivalent.HasValue
|
||||
? 6 + Math.Clamp((6 - noteEquivalent.Value) / 5.0, 0, 1) * 54
|
||||
: 6;
|
||||
TooltipText = warningText.Length > 0
|
||||
? $"{date:dd.MM.yyyy} · {label}: {value} ⚠ {warningText}"
|
||||
: $"{date:dd.MM.yyyy} · {label}: {value}";
|
||||
}
|
||||
}
|
||||
|
||||
public class ContactItem
|
||||
{
|
||||
public Contact Model { get; }
|
||||
|
||||
Reference in New Issue
Block a user