494 lines
18 KiB
C#
494 lines
18 KiB
C#
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 string _schoolYear = "";
|
||
private bool _sortByTotal;
|
||
private bool _sortDescending;
|
||
|
||
public Guid GroupId => _groupId;
|
||
public GradingSystem GradingSystem => _gradingSystem;
|
||
public GroupType GroupType => _groupType;
|
||
public string GroupLabel => _groupLabel;
|
||
public string SchoolYear => _schoolYear;
|
||
|
||
[ObservableProperty] private ParticipationPeriodOption _selectedPeriod;
|
||
[ObservableProperty] private bool _showAsPoints = true;
|
||
[ObservableProperty] private GradeOverviewRow? _selectedRow;
|
||
[ObservableProperty] private int _rebuildColumnsSignal;
|
||
[ObservableProperty] private bool _isReadOnly;
|
||
|
||
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, string schoolYear, bool isReadOnly = false)
|
||
{
|
||
_groupId = groupId;
|
||
_gradingSystem = gradingSystem;
|
||
_groupType = groupType;
|
||
_groupLabel = groupLabel;
|
||
_schoolYear = schoolYear;
|
||
IsReadOnly = isReadOnly;
|
||
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 (periodFrom, periodTo) = GroupMembershipService.SchoolYearPeriod(_schoolYear, period switch
|
||
{
|
||
ParticipationPeriod.H1 => SchoolYearPeriodKind.H1,
|
||
ParticipationPeriod.H2 => SchoolYearPeriodKind.H2,
|
||
_ => SchoolYearPeriodKind.FullYear,
|
||
});
|
||
|
||
var students = _students.GetByGroup(_groupId);
|
||
var membershipsByStudent = _memberships.GetByGroup(_groupId).ToDictionary(m => m.StudentId);
|
||
|
||
var exams = _exams.GetByGroup(_groupId)
|
||
.Where(e => e.Date >= periodFrom && e.Date <= periodTo)
|
||
.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 => g.Date >= periodFrom && g.Date <= periodTo)
|
||
.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 (membership is not null && !GroupMembershipService.Overlaps(membership, periodFrom, periodTo)) continue;
|
||
|
||
var cells = new List<string>();
|
||
var numeric = new List<(string Grade, double Weight)>();
|
||
|
||
foreach (var exam in exams)
|
||
{
|
||
if (membership is not null && !GroupMembershipService.IsActiveOn(membership, exam.Date))
|
||
{ cells.Add(""); continue; }
|
||
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)
|
||
{
|
||
if (membership is not null && !GroupMembershipService.IsActiveOn(membership, key.Date))
|
||
{ cells.Add(""); continue; }
|
||
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);
|
||
}
|
||
|
||
}
|
||
|
||
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)
|
||
{
|
||
item.ValueError = ""; item.DateTextError = "";
|
||
var valid = true;
|
||
|
||
if (string.IsNullOrWhiteSpace(item.Value)) { item.ValueError = "Wert darf nicht leer sein."; valid = false; }
|
||
if (!DateOnly.TryParseExact(item.DateText, "dd.MM.yyyy", null,
|
||
System.Globalization.DateTimeStyles.None, out _))
|
||
{ item.DateTextError = "Format TT.MM.JJJJ."; valid = false; }
|
||
|
||
if (!valid) return;
|
||
|
||
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 _valueError = "";
|
||
[ObservableProperty] private string _dateTextError = "";
|
||
|
||
// 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 IGroupMembershipRepository _memberships;
|
||
private readonly List<Student> _students;
|
||
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 _dateTextError = "";
|
||
|
||
// 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,
|
||
IGroupMembershipRepository memberships, Guid groupId)
|
||
{
|
||
_grades = grades; _memberships = memberships; _groupId = groupId;
|
||
_students = students.GetByGroup(groupId).OrderBy(s => s.LastName).ThenBy(s => s.FirstName).ToList();
|
||
RebuildRows(DateOnly.FromDateTime(DateTime.Today));
|
||
}
|
||
|
||
partial void OnDateTextChanged(string value)
|
||
{
|
||
if (DateOnly.TryParseExact(value, "dd.MM.yyyy", null,
|
||
System.Globalization.DateTimeStyles.None, out var date))
|
||
RebuildRows(date);
|
||
}
|
||
|
||
private void RebuildRows(DateOnly date)
|
||
{
|
||
var previousValues = Rows.ToDictionary(r => r.StudentId, r => r.Value);
|
||
var membershipByStudent = _memberships.GetByGroup(_groupId).ToDictionary(m => m.StudentId);
|
||
Rows.Clear();
|
||
foreach (var student in _students)
|
||
{
|
||
if (membershipByStudent.TryGetValue(student.Id, out var membership)
|
||
&& !GroupMembershipService.IsActiveOn(membership, date))
|
||
continue;
|
||
Rows.Add(new CollectiveGradeStudentRow(student.Id, student.FullName)
|
||
{
|
||
Value = previousValues.GetValueOrDefault(student.Id, ""),
|
||
});
|
||
}
|
||
}
|
||
|
||
[RelayCommand]
|
||
private void SaveAll()
|
||
{
|
||
if (!DateOnly.TryParseExact(DateText, "dd.MM.yyyy", null,
|
||
System.Globalization.DateTimeStyles.None, out var date))
|
||
{ DateTextError = "Format TT.MM.JJJJ."; return; }
|
||
DateTextError = "";
|
||
|
||
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 = "";
|
||
}
|