Files
LehrerApp/LehrerApp.Desktop/ViewModels/Groups/CompetencyOverviewViewModels.cs
adminandClaude Sonnet 5 7365947edc feat: PDF-Druck für Sitzplan, Notenliste, Klausur-Notenspiegel, Kompetenzbericht, Dokumentation
QuestPDF als PDF-Bibliothek eingeführt (11.3), bewusst nur in LehrerApp.Desktop referenziert -
zieht SkiaSharp-Native-Binaries mit, die im Server-Docker-Image nichts verloren haben. Neuer
PdfExportService mit gemeinsamem Kopf-/Fußzeilen-Layout (Titel, Stand-Datum, Seitenzahlen) und
schlanken Druck-DTOs statt ViewModels, damit die Erzeugung ohne Avalonia-Bezug testbar bleibt.
Speicherdialog läuft über den bestehenden ExportService (neues PDF-Format).

Fünf Druckvorlagen (11.4) als "Als PDF"-Button direkt in der jeweiligen Ansicht:
- Sitzplan: Raster mit Tafel-Banner, Tischabständen als echte Lücken, ausgeblendeten Plätzen als
  leere Rasterposition.
- Notenliste: druckt exakt die aktuell angezeigte Matrix (Zeitraum/Darstellung/Sortierung).
- Klausur-Notenspiegel: Kennzahlen, Notenverteilung und Aufgabenanalyse mit Balkendiagrammen,
  auffällig schwache Aufgaben rot markiert.
- Kompetenzbericht: Analyse-Tabelle für den ausgewählten Schüler (erledigt zugleich 8.3.3).
- Schülerdokumentation: alle Einträge chronologisch, vertrauliche/Entwurfs-Einträge markiert.

Layout an gerenderten Muster-PDFs visuell geprüft, nicht nur per Unit-Test.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-20 14:27:05 +02:00

177 lines
6.7 KiB
C#
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using LehrerApp.Core.Interfaces;
using LehrerApp.Core.Models;
using LehrerApp.Core.Services;
using System.Collections.ObjectModel;
namespace LehrerApp.Desktop.ViewModels.Groups;
public partial class CompetencyOverviewTabViewModel : ObservableObject
{
private readonly IUnitRepository _units;
private readonly IExamRepository _exams;
private readonly IExamResultRepository _results;
private readonly ICompetencyDomainRepository _catalog;
private readonly IStudentRepository _students;
private readonly CompetencyAnalysisService _analysis;
private LearningGroup? _group;
[ObservableProperty] private CompetencyStudentOption? _selectedStudent;
[ObservableProperty] private double _reviewThreshold = 60;
[ObservableProperty] private string _summary = "";
[ObservableProperty] private string _emptyMessage = "";
public string GroupLabel => _group?.Name ?? "";
public ObservableCollection<CompetencyStudentOption> StudentOptions { get; } = [];
public ObservableCollection<CompetencyOverviewRow> Rows { get; } = [];
public bool HasRows => Rows.Count > 0;
public bool HasStudents => StudentOptions.Count > 0;
public string ThresholdLabel => $"Wiederholungsbedarf unter {ReviewThreshold:0} %";
public CompetencyOverviewTabViewModel(IUnitRepository units, IExamRepository exams,
IExamResultRepository results, ICompetencyDomainRepository catalog,
IStudentRepository students, CompetencyAnalysisService analysis)
{
_units = units;
_exams = exams;
_results = results;
_catalog = catalog;
_students = students;
_analysis = analysis;
}
public void Initialize(LearningGroup group)
{
_group = group;
Refresh();
}
[RelayCommand]
public void Refresh()
{
if (_group is null) return;
var selectedStudentId = SelectedStudent?.Id;
var domains = _group.SubjectId is Guid subjectId
? _catalog.GetBySubjectAndGrade(subjectId, _group.GradeLevel)
: [];
var units = _units.GetByGroup(_group.Id);
var exams = _exams.GetByGroup(_group.Id);
var results = exams.SelectMany(x => _results.GetByExam(x.Id)).ToList();
var analysis = _analysis.Analyze(domains, units, exams, results);
StudentOptions.Clear();
foreach (var student in _students.GetByGroup(_group.Id)
.OrderBy(x => x.LastName).ThenBy(x => x.FirstName))
StudentOptions.Add(new CompetencyStudentOption(student.Id, student.FullName));
SelectedStudent = StudentOptions.FirstOrDefault(x => x.Id == selectedStudentId)
?? StudentOptions.FirstOrDefault();
Rows.Clear();
foreach (var item in analysis)
Rows.Add(new CompetencyOverviewRow(item, SelectedStudent?.Id, ReviewThreshold));
var treated = analysis.Count(x => x.TaughtUnitCount > 0);
var assessed = analysis.Count(x => x.AssessedExamCount > 0);
var evaluated = analysis.Count(x => x.GroupScore is not null);
Summary = $"{treated} von {analysis.Count} behandelt · {assessed} geprüft · {evaluated} mit Ergebnisdaten";
EmptyMessage = analysis.Count == 0
? "Noch keine Kompetenzen im Katalog oder in Unterrichtseinheiten und Klausuren zugeordnet."
: "";
OnPropertyChanged(nameof(HasRows));
OnPropertyChanged(nameof(HasStudents));
}
partial void OnSelectedStudentChanged(CompetencyStudentOption? value)
{
foreach (var row in Rows) row.SetStudent(value?.Id);
}
partial void OnReviewThresholdChanged(double value)
{
OnPropertyChanged(nameof(ThresholdLabel));
foreach (var row in Rows) row.SetThreshold(value);
}
}
public sealed record CompetencyStudentOption(Guid Id, string DisplayName);
public partial class CompetencyOverviewRow : ObservableObject
{
private readonly CompetencyAnalysisItem _item;
private double _threshold;
[ObservableProperty] private string _studentResult = "";
[ObservableProperty] private string _recommendation = "Noch keine Bewertung";
[ObservableProperty] private string _recommendationColor = "#6B7280";
public string Domain => string.IsNullOrWhiteSpace(_item.DomainCode)
? _item.DomainName
: $"{_item.DomainName} ({_item.DomainCode})";
public string Code => string.IsNullOrWhiteSpace(_item.Code) ? "" : _item.Code;
public string Description => _item.Description;
public string InstructionCoverage => CoverageText(
_item.TaughtUnitCount, "behandelt", _item.PlannedUnitCount, "geplant");
public string ExamCoverage => CoverageText(
_item.AssessedExamCount, "geprüft", _item.PlannedExamCount, "geplant");
public string GroupResult => ScoreText(_item.GroupScore, includeParticipants: true);
public CompetencyOverviewRow(CompetencyAnalysisItem item, Guid? studentId, double threshold)
{
_item = item;
_threshold = threshold;
SetStudent(studentId);
UpdateRecommendation();
}
public void SetStudent(Guid? studentId)
{
StudentResult = studentId is Guid id && _item.StudentScores.TryGetValue(id, out var score)
? ScoreText(score, includeParticipants: false)
: "";
}
public void SetThreshold(double threshold)
{
_threshold = threshold;
UpdateRecommendation();
}
private void UpdateRecommendation()
{
if (_item.GroupScore is null)
{
Recommendation = "Noch keine Bewertung";
RecommendationColor = "#6B7280";
}
else if (_item.GroupScore.Percent < _threshold)
{
Recommendation = "Wiederholungsbedarf";
RecommendationColor = "#C62828";
}
else
{
Recommendation = "Stand solide";
RecommendationColor = "#2E7D32";
}
}
private static string CoverageText(int completed, string completedLabel, int planned, string plannedLabel)
{
if (completed == 0 && planned == 0) return "";
if (completed == 0) return $"{planned}× {plannedLabel}";
if (planned == 0) return $"{completed}× {completedLabel}";
return $"{completed}× {completedLabel} · {planned}× {plannedLabel}";
}
private static string ScoreText(CompetencyScore? score, bool includeParticipants)
{
if (score is null) return "";
var evidence = score.EvidenceCount == 1 ? "1 Aufgabenwert" : $"{score.EvidenceCount} Aufgabenwerte";
return includeParticipants
? $"{score.Percent:0.#} % · {score.ParticipantCount} Schüler · {evidence}"
: $"{score.Percent:0.#} % · {evidence}";
}
}