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>
This commit is contained in:
2026-08-20 14:27:05 +02:00
co-authored by Claude Sonnet 5
parent 4cce84c7dd
commit 7365947edc
18 changed files with 785 additions and 15 deletions
+4
View File
@@ -17,6 +17,10 @@
<!-- Datenbank -->
<PackageVersion Include="LiteDB" Version="5.0.21" />
<!-- PDF-Erzeugung (11.3) - nur LehrerApp.Desktop: zieht SkiaSharp-Native-Binaries mit,
die im Server-Image (LehrerApp.Api) nichts verloren haben. -->
<PackageVersion Include="QuestPDF" Version="2025.7.0" />
<!-- API -->
<PackageVersion Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.0" />
@@ -0,0 +1,157 @@
using LehrerApp.Desktop.Services;
using Xunit;
namespace LehrerApp.Desktop.Tests;
public sealed class PdfExportServiceTests
{
private static readonly PdfExportService Service = new();
private static void AssertIsPdf(byte[] bytes)
{
Assert.True(bytes.Length > 500, "PDF ist verdächtig klein.");
Assert.Equal("%PDF"u8.ToArray(), bytes[..4]);
}
[Fact]
public void BuildSeatingPlanPdf_VollstaendigerPlan_LiefertGueltigesPdf()
{
var data = new SeatingPlanPrintData("Standard", "Raum B204 · 2 × 3 Plätze",
Columns: 3, IsBoardAtBottom: false, ColumnGapWidths: [0, 90],
Seats:
[
new SeatPrintCell(0, 0, "Beispiel, Anna", false),
new SeatPrintCell(0, 1, null, false),
new SeatPrintCell(0, 2, "Muster, Ben", false),
new SeatPrintCell(1, 0, null, false),
new SeatPrintCell(1, 1, "Probe, Cem", false),
new SeatPrintCell(1, 2, null, false),
]);
AssertIsPdf(Service.BuildSeatingPlanPdf(data));
}
/// Ausgeblendete Plätze (kein Tisch im Raum, siehe SeatingPlan.HiddenSeats) müssen als leere
/// Rasterposition mitgedruckt werden, ohne die Erzeugung zu stören.
[Fact]
public void BuildSeatingPlanPdf_MitAusgeblendetenPlaetzenUndTafelUnten_LiefertGueltigesPdf()
{
var data = new SeatingPlanPrintData("Physikraum", "",
Columns: 2, IsBoardAtBottom: true, ColumnGapWidths: [],
Seats:
[
new SeatPrintCell(0, 0, "Beispiel, Anna", false),
new SeatPrintCell(0, 1, null, true),
new SeatPrintCell(1, 0, null, true),
new SeatPrintCell(1, 1, null, false),
]);
AssertIsPdf(Service.BuildSeatingPlanPdf(data));
}
[Fact]
public void BuildGradeListPdf_MatrixMitMehrerenSpalten_LiefertGueltigesPdf()
{
var data = new GradeListPrintData("8a Chemie", "2026/27", "1. Halbjahr",
ColumnHeaders: ["12.09. Klausur 1", "Mündlich 20.09.", "Hausaufgaben 01.10."],
Rows:
[
new GradeListPrintRow("Beispiel, Anna", ["2", "1", ""], "1.67"),
new GradeListPrintRow("Muster, Ben", ["abwesend", "3", "2"], "2.50"),
new GradeListPrintRow("Probe, Cem", ["", "", ""], ""),
]);
AssertIsPdf(Service.BuildGradeListPdf(data));
}
/// Ein Kurs kann übers Jahr viele Notenspalten ansammeln - die Tabelle muss auch dann noch
/// erzeugbar sein (QuestPDF staucht die Spalten, statt zu werfen).
[Fact]
public void BuildGradeListPdf_VieleSpalten_LiefertGueltigesPdf()
{
var headers = Enumerable.Range(1, 20).Select(i => $"Note {i}").ToList();
var rows = Enumerable.Range(1, 30).Select(i => new GradeListPrintRow(
$"Schüler {i}", [.. Enumerable.Repeat("2", 20)], "2.00")).ToList();
AssertIsPdf(Service.BuildGradeListPdf(new GradeListPrintData("LK Chemie", "2026/27",
"Gesamtes Schuljahr", headers, rows)));
}
[Fact]
public void BuildExamStatisticsPdf_MitVerteilungUndAufgabenanalyse_LiefertGueltigesPdf()
{
var data = new ExamStatisticsPrintData("Klausur 1 - Säuren und Basen", "12.09.2026", "E",
Stats:
[
new LabelValue("Bewertet", "24"),
new LabelValue("Abwesend", "2"),
new LabelValue("Durchschnitt", "2.71"),
new LabelValue("Median", "3"),
new LabelValue("Anteil nicht ausreichend (Note 5/6)", "8.3 %"),
],
Distribution:
[
new GradeDistributionPrintRow("1", 3, 12.5),
new GradeDistributionPrintRow("2", 8, 33.3),
new GradeDistributionPrintRow("3", 7, 29.2),
new GradeDistributionPrintRow("4", 4, 16.7),
new GradeDistributionPrintRow("5", 2, 8.3),
new GradeDistributionPrintRow("6", 0, 0),
],
TaskAnalysis:
[
new TaskAnalysisPrintRow("1. Titration", 78.4, false),
new TaskAnalysisPrintRow("2. pH-Berechnung", 42.1, true),
]);
AssertIsPdf(Service.BuildExamStatisticsPdf(data));
}
/// Auch ohne Aufgabenstruktur (Klausur ohne Teilaufgaben) und ohne Niveau muss der
/// Notenspiegel druckbar sein - der Abschnitt Aufgabenanalyse entfällt dann einfach.
[Fact]
public void BuildExamStatisticsPdf_OhneAufgabenUndNiveau_LiefertGueltigesPdf()
{
var data = new ExamStatisticsPrintData("Test", "01.10.2026", "",
[new LabelValue("Bewertet", "0")], [], []);
AssertIsPdf(Service.BuildExamStatisticsPdf(data));
}
[Fact]
public void BuildCompetencyReportPdf_LiefertGueltigesPdf()
{
var data = new CompetencyReportPrintData("8a Chemie", "Beispiel, Anna",
"3 von 5 behandelt · 2 geprüft · 2 mit Ergebnisdaten", "Wiederholungsbedarf unter 60 %",
[
new CompetencyReportPrintRow("Stoff-Teilchen (ST)", "ST1",
"Aggregatzustände auf Teilchenebene erklären", "2× behandelt", "1× geprüft",
"72.5 % · 24 Schüler · 3 Aufgabenwerte", "85 % · 3 Aufgabenwerte", "Stand solide"),
new CompetencyReportPrintRow("Chemische Reaktion (CR)", "CR2",
"Reaktionsgleichungen aufstellen", "1× behandelt · 1× geplant", "1× geprüft",
"48.3 % · 24 Schüler · 2 Aufgabenwerte", "40 % · 2 Aufgabenwerte", "Wiederholungsbedarf"),
new CompetencyReportPrintRow("Energie (EN)", "", "Energiediagramme deuten",
"", "1× geplant", "", "", "Noch keine Bewertung"),
]);
AssertIsPdf(Service.BuildCompetencyReportPdf(data));
}
[Fact]
public void BuildStudentDocumentationPdf_MitVertraulichenUndEntwurfsEintraegen_LiefertGueltigesPdf()
{
var data = new StudentDocumentationPrintData("Beispiel, Anna",
[
new DocumentationPrintEntry("12.09.2026", "Beobachtung", "Unterricht gestört",
"Wiederholt dazwischengerufen, nach Ermahnung gebessert.",
["Kritisch"], IsConfidential: false, IsDraft: false),
new DocumentationPrintEntry("20.09.2026", "Elterngespräch", "Telefonat Mutter",
"Absprache: wöchentliche Rückmeldung im Hausaufgabenheft.",
[], IsConfidential: true, IsDraft: false),
new DocumentationPrintEntry("01.10.2026", "Beobachtung", "Schnellnotiz",
"", ["Nacharbeiten"], IsConfidential: false, IsDraft: true),
]);
AssertIsPdf(Service.BuildStudentDocumentationPdf(data));
}
}
+1
View File
@@ -114,6 +114,7 @@ public static class AppBootstrapper
services.AddSingleton(Logger);
services.AddSingleton<NotificationService>();
services.AddSingleton<ExportService>();
services.AddSingleton<PdfExportService>();
// ── Datensicherheit (13.3) ───────────────────────────────────────────
// Backup läuft vor dem Öffnen der Datenbank, rein dateibasiert (unabhängig von
@@ -18,6 +18,7 @@
<PackageReference Include="Avalonia.Controls.DataGrid" />
<PackageReference Include="CommunityToolkit.Mvvm" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" />
<PackageReference Include="QuestPDF" />
</ItemGroup>
<ItemGroup>
<AvaloniaResource Include="Assets\**" />
@@ -7,6 +7,7 @@ public sealed record ExportFormat(string Label, string Extension, string MimeTyp
{
public static ExportFormat Csv { get; } = new("CSV-Dateien", ".csv", "text/csv");
public static ExportFormat Json { get; } = new("JSON-Dateien", ".json", "application/json");
public static ExportFormat Pdf { get; } = new("PDF-Dateien", ".pdf", "application/pdf");
}
public sealed record ExportFile(
@@ -21,6 +22,10 @@ public sealed record ExportFile(
public static ExportFile Json(string dialogTitle, string suggestedFileName, string content) =>
FromText(dialogTitle, suggestedFileName, ExportFormat.Json, content);
public static ExportFile Pdf(string dialogTitle, string suggestedFileName, byte[] content) =>
new(dialogTitle, EnsureExtension(SanitizeFileName(suggestedFileName), ExportFormat.Pdf.Extension),
ExportFormat.Pdf, content);
private static ExportFile FromText(string dialogTitle, string suggestedFileName,
ExportFormat format, string content, bool includeUtf8Bom = false)
{
@@ -0,0 +1,431 @@
using QuestPDF.Fluent;
using QuestPDF.Helpers;
using QuestPDF.Infrastructure;
namespace LehrerApp.Desktop.Services;
// ── Druckdaten (bewusst schlanke DTOs statt ViewModels: die PDF-Erzeugung bleibt dadurch ohne
// Avalonia-/MVVM-Bezug testbar, und die Views entscheiden selbst, was gedruckt wird) ────────────
/// <param name="Seats">Alle Plätze des Rasters in Zeilen-Hauptordnung, auch ausgeblendete
/// (IsHidden) - sie belegen ihre Rasterposition weiterhin, werden aber leer gedruckt
/// (gleiche Logik wie SeatingPlanPanel/SeatCellViewModel.ShowSeat).</param>
public sealed record SeatingPlanPrintData(
string PlanTitle,
string PlanSubtitle,
int Columns,
bool IsBoardAtBottom,
IReadOnlyList<double> ColumnGapWidths,
IReadOnlyList<SeatPrintCell> Seats);
public sealed record SeatPrintCell(int Row, int Column, string? StudentName, bool IsHidden);
public sealed record GradeListPrintData(
string GroupLabel,
string SchoolYear,
string PeriodLabel,
IReadOnlyList<string> ColumnHeaders,
IReadOnlyList<GradeListPrintRow> Rows);
public sealed record GradeListPrintRow(string Name, IReadOnlyList<string> Cells, string Total);
public sealed record LabelValue(string Label, string Value);
public sealed record ExamStatisticsPrintData(
string ExamTitle,
string DateLabel,
string NiveauLabel,
IReadOnlyList<LabelValue> Stats,
IReadOnlyList<GradeDistributionPrintRow> Distribution,
IReadOnlyList<TaskAnalysisPrintRow> TaskAnalysis);
public sealed record GradeDistributionPrintRow(string Grade, int Count, double Percent);
public sealed record TaskAnalysisPrintRow(string Label, double AvgPercent, bool IsWeak);
public sealed record CompetencyReportPrintData(
string GroupLabel,
string StudentName,
string Summary,
string ThresholdLabel,
IReadOnlyList<CompetencyReportPrintRow> Rows);
public sealed record CompetencyReportPrintRow(
string Domain,
string Code,
string Description,
string InstructionCoverage,
string ExamCoverage,
string GroupResult,
string StudentResult,
string Recommendation);
public sealed record StudentDocumentationPrintData(
string StudentName,
IReadOnlyList<DocumentationPrintEntry> Entries);
public sealed record DocumentationPrintEntry(
string DateDisplay,
string TypeLabel,
string Title,
string Content,
IReadOnlyList<string> Tags,
bool IsConfidential,
bool IsDraft);
/// <summary>
/// PDF-Erzeugung über QuestPDF (11.3). Liefert fertige Bytes - der Speicherdialog läuft wie bei
/// allen Exporten über <see cref="ExportService"/>. Lebt bewusst in LehrerApp.Desktop statt Core:
/// QuestPDF zieht SkiaSharp-Native-Binaries mit, die im Server-Image (LehrerApp.Api, referenziert
/// Core transitiv über Sync) nichts verloren haben.
/// </summary>
public sealed class PdfExportService
{
static PdfExportService() => QuestPDF.Settings.License = LicenseType.Community;
// Bildschirm-Pixel (Tischabstände, 0-300) → Druckpunkte. 0.4 hält auch den Maximalabstand
// (120pt ≈ 4cm) auf einer A4-Querseite praktikabel.
private const float GapPixelToPoint = 0.4f;
public byte[] BuildSeatingPlanPdf(SeatingPlanPrintData data) =>
Document.Create(container => container.Page(page =>
{
page.Size(PageSizes.A4.Landscape());
page.Margin(36);
page.DefaultTextStyle(style => style.FontSize(10));
page.Header().Element(header => Header(header, data.PlanTitle, data.PlanSubtitle));
page.Content().PaddingVertical(10).Column(column =>
{
column.Spacing(10);
if (!data.IsBoardAtBottom) column.Item().Element(BoardBanner);
column.Item().Element(grid => SeatGrid(grid, data));
if (data.IsBoardAtBottom) column.Item().Element(BoardBanner);
});
page.Footer().Element(Footer);
})).GeneratePdf();
public byte[] BuildGradeListPdf(GradeListPrintData data) =>
Document.Create(container => container.Page(page =>
{
page.Size(PageSizes.A4.Landscape());
page.Margin(36);
page.DefaultTextStyle(style => style.FontSize(8));
page.Header().Element(header => Header(header,
$"Notenliste {data.GroupLabel}",
$"{data.SchoolYear} · {data.PeriodLabel}"));
page.Content().PaddingVertical(10).Table(table =>
{
table.ColumnsDefinition(columns =>
{
columns.RelativeColumn(2.4f);
foreach (var _ in data.ColumnHeaders) columns.RelativeColumn();
columns.RelativeColumn(0.8f);
});
table.Header(header =>
{
header.Cell().Element(HeaderCell).Text("Name").SemiBold();
foreach (var columnHeader in data.ColumnHeaders)
header.Cell().Element(HeaderCell).Text(columnHeader).SemiBold();
header.Cell().Element(HeaderCell).AlignRight().Text("Schnitt").SemiBold();
});
foreach (var row in data.Rows)
{
table.Cell().Element(BodyCell).Text(row.Name);
foreach (var cell in row.Cells)
table.Cell().Element(BodyCell).AlignCenter().Text(cell);
table.Cell().Element(BodyCell).AlignRight().Text(row.Total).SemiBold();
}
});
page.Footer().Element(Footer);
})).GeneratePdf();
public byte[] BuildExamStatisticsPdf(ExamStatisticsPrintData data) =>
Document.Create(container => container.Page(page =>
{
page.Size(PageSizes.A4);
page.Margin(36);
page.DefaultTextStyle(style => style.FontSize(10));
var subtitle = string.IsNullOrWhiteSpace(data.NiveauLabel)
? data.DateLabel
: $"{data.DateLabel} · Niveau {data.NiveauLabel}";
page.Header().Element(header => Header(header, data.ExamTitle, subtitle));
page.Content().PaddingVertical(10).Column(column =>
{
column.Spacing(14);
column.Item().Table(table =>
{
table.ColumnsDefinition(columns =>
{
columns.RelativeColumn(2);
columns.RelativeColumn();
});
foreach (var stat in data.Stats)
{
table.Cell().Element(BodyCell).Text(stat.Label);
table.Cell().Element(BodyCell).AlignRight().Text(stat.Value).SemiBold();
}
});
column.Item().Text("Notenspiegel").FontSize(12).SemiBold();
column.Item().Table(table =>
{
table.ColumnsDefinition(columns =>
{
columns.ConstantColumn(60);
columns.ConstantColumn(60);
columns.ConstantColumn(60);
columns.RelativeColumn();
});
table.Header(header =>
{
header.Cell().Element(HeaderCell).Text("Note").SemiBold();
header.Cell().Element(HeaderCell).AlignRight().Text("Anzahl").SemiBold();
header.Cell().Element(HeaderCell).AlignRight().Text("Anteil").SemiBold();
header.Cell().Element(HeaderCell);
});
var maxCount = data.Distribution.Count == 0 ? 0 : data.Distribution.Max(d => d.Count);
foreach (var entry in data.Distribution)
{
table.Cell().Element(BodyCell).Text(entry.Grade);
table.Cell().Element(BodyCell).AlignRight().Text(entry.Count.ToString());
table.Cell().Element(BodyCell).AlignRight().Text($"{entry.Percent:0.#} %");
table.Cell().Element(BodyCell).AlignMiddle().Element(bar =>
Bar(bar, maxCount == 0 ? 0 : entry.Count * 100.0 / maxCount, Colors.Blue.Medium));
}
});
if (data.TaskAnalysis.Count > 0)
{
column.Item().Text("Aufgabenanalyse").FontSize(12).SemiBold();
column.Item().Table(table =>
{
table.ColumnsDefinition(columns =>
{
columns.RelativeColumn(2);
columns.ConstantColumn(80);
columns.RelativeColumn();
});
table.Header(header =>
{
header.Cell().Element(HeaderCell).Text("Aufgabe").SemiBold();
header.Cell().Element(HeaderCell).AlignRight().Text("Ø Erfüllung").SemiBold();
header.Cell().Element(HeaderCell);
});
foreach (var task in data.TaskAnalysis)
{
table.Cell().Element(BodyCell).Text(text =>
{
text.Span(task.Label);
if (task.IsWeak) text.Span(" auffällig schwach")
.FontColor(Colors.Red.Darken1).FontSize(8);
});
table.Cell().Element(BodyCell).AlignRight().Text($"{task.AvgPercent:0.#} %");
table.Cell().Element(BodyCell).AlignMiddle().Element(bar =>
Bar(bar, task.AvgPercent, task.IsWeak ? Colors.Red.Medium : Colors.Green.Medium));
}
});
}
});
page.Footer().Element(Footer);
})).GeneratePdf();
public byte[] BuildCompetencyReportPdf(CompetencyReportPrintData data) =>
Document.Create(container => container.Page(page =>
{
page.Size(PageSizes.A4.Landscape());
page.Margin(36);
page.DefaultTextStyle(style => style.FontSize(8));
page.Header().Element(header => Header(header,
$"Kompetenzbericht · {data.StudentName}", $"{data.GroupLabel} · {data.Summary}"));
page.Content().PaddingVertical(10).Column(column =>
{
column.Spacing(8);
column.Item().Text(data.ThresholdLabel).FontSize(8).FontColor(Colors.Grey.Darken1);
column.Item().Table(table =>
{
table.ColumnsDefinition(columns =>
{
columns.RelativeColumn(1.4f); // Bereich
columns.RelativeColumn(0.5f); // Code
columns.RelativeColumn(2.3f); // Beschreibung
columns.RelativeColumn(1.1f); // Unterricht
columns.RelativeColumn(1.0f); // Klausuren
columns.RelativeColumn(1.4f); // Gruppe
columns.RelativeColumn(1.1f); // Schüler
// "Wiederholungsbedarf" (längster Wert) muss ohne Wortumbruch passen.
columns.ConstantColumn(88); // Empfehlung
});
table.Header(header =>
{
header.Cell().Element(HeaderCell).Text("Bereich").SemiBold();
header.Cell().Element(HeaderCell).Text("Code").SemiBold();
header.Cell().Element(HeaderCell).Text("Kompetenz").SemiBold();
header.Cell().Element(HeaderCell).Text("Unterricht").SemiBold();
header.Cell().Element(HeaderCell).Text("Klausuren").SemiBold();
header.Cell().Element(HeaderCell).Text("Gruppenergebnis").SemiBold();
header.Cell().Element(HeaderCell).Text("Schüler").SemiBold();
header.Cell().Element(HeaderCell).Text("Empfehlung").SemiBold();
});
foreach (var row in data.Rows)
{
table.Cell().Element(BodyCell).Text(row.Domain);
table.Cell().Element(BodyCell).Text(row.Code);
table.Cell().Element(BodyCell).Text(row.Description);
table.Cell().Element(BodyCell).Text(row.InstructionCoverage);
table.Cell().Element(BodyCell).Text(row.ExamCoverage);
table.Cell().Element(BodyCell).Text(row.GroupResult);
table.Cell().Element(BodyCell).Text(row.StudentResult).SemiBold();
table.Cell().Element(BodyCell).Text(row.Recommendation)
.FontColor(row.Recommendation == "Wiederholungsbedarf"
? Colors.Red.Darken1
: row.Recommendation == "Stand solide"
? Colors.Green.Darken1
: Colors.Grey.Darken1);
}
});
});
page.Footer().Element(Footer);
})).GeneratePdf();
public byte[] BuildStudentDocumentationPdf(StudentDocumentationPrintData data) =>
Document.Create(container => container.Page(page =>
{
page.Size(PageSizes.A4);
page.Margin(36);
page.DefaultTextStyle(style => style.FontSize(9));
page.Header().Element(header => Header(header,
$"Dokumentation · {data.StudentName}",
data.Entries.Count == 1 ? "1 Eintrag" : $"{data.Entries.Count} Einträge"));
page.Content().PaddingVertical(10).Column(column =>
{
column.Spacing(10);
foreach (var entry in data.Entries)
column.Item().Border(0.8f).BorderColor(Colors.Grey.Lighten2).Padding(8)
.Column(block =>
{
block.Spacing(3);
block.Item().Row(row =>
{
row.RelativeItem().Text(text =>
{
text.Span(entry.Title).FontSize(10).SemiBold();
if (entry.IsConfidential)
text.Span(" vertraulich").FontSize(8).FontColor(Colors.Red.Darken1);
if (entry.IsDraft)
text.Span(" Entwurf").FontSize(8).FontColor(Colors.Orange.Darken2);
});
row.ConstantItem(130).AlignRight()
.Text($"{entry.TypeLabel} · {entry.DateDisplay}")
.FontSize(8).FontColor(Colors.Grey.Darken1);
});
if (!string.IsNullOrWhiteSpace(entry.Content))
block.Item().Text(entry.Content);
if (entry.Tags.Count > 0)
block.Item().Text(string.Join(" · ", entry.Tags))
.FontSize(8).FontColor(Colors.Grey.Darken1);
});
});
page.Footer().Element(Footer);
})).GeneratePdf();
// ── Bausteine ───────────────────────────────────────────────────────────────
/// <summary>Horizontaler Balken, 0-100 % der verfügbaren Zellenbreite (max. 150pt).</summary>
private static void Bar(IContainer container, double percent, string color) =>
container.MaxWidth(150).Height(7).Background(Colors.Grey.Lighten3)
.AlignLeft().Width((float)(Math.Clamp(percent, 0, 100) * 1.5)).Background(color);
private static void Header(IContainer container, string title, string subtitle) =>
container.BorderBottom(1).BorderColor(Colors.Grey.Lighten2).PaddingBottom(6).Row(row =>
{
row.RelativeItem().Column(column =>
{
column.Item().Text(title).FontSize(16).SemiBold();
if (!string.IsNullOrWhiteSpace(subtitle))
column.Item().Text(subtitle).FontSize(10).FontColor(Colors.Grey.Darken1);
});
row.ConstantItem(110).AlignRight().AlignBottom()
.Text($"Stand: {DateTime.Now:dd.MM.yyyy}").FontSize(9).FontColor(Colors.Grey.Darken1);
});
private static void Footer(IContainer container) =>
container.AlignCenter().Text(text =>
{
text.DefaultTextStyle(style => style.FontSize(8).FontColor(Colors.Grey.Darken1));
text.Span("Seite ");
text.CurrentPageNumber();
text.Span(" von ");
text.TotalPages();
});
private static void BoardBanner(IContainer container) =>
container.AlignCenter().Background(Colors.Grey.Lighten3).PaddingVertical(4)
.PaddingHorizontal(40).Text("Tafel / Vorderseite").FontSize(9).SemiBold()
.FontColor(Colors.Grey.Darken2);
private static void SeatGrid(IContainer container, SeatingPlanPrintData data) =>
container.Table(table =>
{
// Tischabstände als eigene, schmale Leerspalten zwischen den Sitzspalten - dieselbe
// Idee wie SeatingPlanPanel, nur mit Tabellenspalten statt Panel-Arithmetik. Merkt
// sich je Sitzspalte den 1-basierten Tabellenspalten-Index.
var tableColumnOfSeatColumn = new int[Math.Max(1, data.Columns)];
table.ColumnsDefinition(columns =>
{
var next = 1;
for (var c = 0; c < data.Columns; c++)
{
columns.RelativeColumn();
tableColumnOfSeatColumn[c] = next++;
var gap = c < data.ColumnGapWidths.Count ? data.ColumnGapWidths[c] : 0;
if (c < data.Columns - 1 && gap > 0)
{
columns.ConstantColumn((float)gap * GapPixelToPoint);
next++;
}
}
});
foreach (var seat in data.Seats)
{
var cell = table.Cell()
.Row((uint)(seat.Row + 1))
.Column((uint)tableColumnOfSeatColumn[seat.Column])
.Padding(3);
// Ausgeblendete Plätze (kein Tisch im Raum) bleiben als leere Rasterposition
// erhalten, damit die übrigen Plätze der Reihe nicht verrutschen.
if (seat.IsHidden) { cell.MinHeight(38); continue; }
var box = cell.MinHeight(38).Border(0.8f)
.BorderColor(seat.StudentName is null ? Colors.Grey.Lighten2 : Colors.Grey.Darken1)
.Padding(4).AlignCenter().AlignMiddle();
if (seat.StudentName is null)
box.Text("frei").FontSize(8).FontColor(Colors.Grey.Lighten1);
else
box.Text(seat.StudentName).FontSize(9).SemiBold();
}
});
private static IContainer HeaderCell(IContainer container) => container
.BorderBottom(1).BorderColor(Colors.Grey.Darken1).PaddingVertical(3).PaddingHorizontal(2);
private static IContainer BodyCell(IContainer container) => container
.BorderBottom(0.5f).BorderColor(Colors.Grey.Lighten2).PaddingVertical(2.5f).PaddingHorizontal(2);
}
@@ -22,6 +22,8 @@ public partial class CompetencyOverviewTabViewModel : ObservableObject
[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;
@@ -10,7 +10,10 @@
<TextBlock Text="Kompetenzübersicht" FontSize="18" FontWeight="SemiBold"/>
<TextBlock Text="{Binding Summary}" FontSize="12" Opacity="0.65"/>
</StackPanel>
<Button Grid.Column="1" Content="Aktualisieren" Command="{Binding RefreshCommand}"/>
<StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="8" VerticalAlignment="Top">
<Button Content="Als PDF" Click="OnExportPdfClick" IsEnabled="{Binding HasRows}"/>
<Button Content="Aktualisieren" Command="{Binding RefreshCommand}"/>
</StackPanel>
</Grid>
<Grid ColumnDefinitions="*,16,210" IsVisible="{Binding HasRows}">
@@ -1,8 +1,30 @@
using Avalonia.Controls;
using Avalonia.Interactivity;
using LehrerApp.Desktop.Services;
using LehrerApp.Desktop.ViewModels.Groups;
using Microsoft.Extensions.DependencyInjection;
namespace LehrerApp.Desktop.Views.Groups;
public partial class CompetencyOverviewTabView : UserControl
{
public CompetencyOverviewTabView() => InitializeComponent();
private async void OnExportPdfClick(object? sender, RoutedEventArgs e)
{
var topLevel = TopLevel.GetTopLevel(this);
if (topLevel is null || DataContext is not CompetencyOverviewTabViewModel { HasRows: true } vm) return;
var studentName = vm.SelectedStudent?.DisplayName ?? "Kein Schüler ausgewählt";
var data = new CompetencyReportPrintData(vm.GroupLabel, studentName, vm.Summary,
vm.ThresholdLabel,
vm.Rows.Select(r => new CompetencyReportPrintRow(r.Domain, r.Code, r.Description,
r.InstructionCoverage, r.ExamCoverage, r.GroupResult, r.StudentResult,
r.Recommendation)).ToList());
var pdf = App.Services.GetRequiredService<PdfExportService>().BuildCompetencyReportPdf(data);
await App.Services.GetRequiredService<ExportService>().SaveAsync(topLevel.StorageProvider,
ExportFile.Pdf("Kompetenzbericht als PDF speichern",
$"Kompetenzbericht_{vm.GroupLabel}_{studentName}", pdf));
}
}
@@ -147,9 +147,10 @@
</StackPanel>
</ScrollViewer>
<Grid Grid.Row="1" ColumnDefinitions="Auto,*,Auto" Margin="0,16,0,0">
<Grid Grid.Row="1" ColumnDefinitions="Auto,8,Auto,*,Auto" Margin="0,16,0,0">
<Button Grid.Column="0" Content="Als CSV exportieren" Click="OnExportClick"/>
<Button Grid.Column="2" Content="Fertig" Click="OnClose"/>
<Button Grid.Column="2" Content="Als PDF" Click="OnExportPdfClick"/>
<Button Grid.Column="4" Content="Fertig" Click="OnClose"/>
</Grid>
</Grid>
</Window>
@@ -21,5 +21,33 @@ public partial class ExamEvaluationDialog : Window
await App.Services.GetRequiredService<ExportService>().SaveAsync(topLevel.StorageProvider, export);
}
private async void OnExportPdfClick(object? sender, RoutedEventArgs e)
{
if (DataContext is not ExamEvaluationDialogViewModel vm) return;
var topLevel = TopLevel.GetTopLevel(this);
if (topLevel is null) return;
var stats = new List<LabelValue>
{
new("Bewertet", vm.GradedCount.ToString()),
new("Abwesend", vm.AbsentCount.ToString()),
new("Durchschnitt", vm.AverageDisplay),
new("Median", vm.MedianDisplay),
};
if (!string.IsNullOrEmpty(vm.BelowThresholdLabel1))
stats.Add(new(vm.BelowThresholdLabel1, vm.BelowThresholdDisplay1));
if (!string.IsNullOrEmpty(vm.BelowThresholdLabel2))
stats.Add(new(vm.BelowThresholdLabel2, vm.BelowThresholdDisplay2));
var data = new ExamStatisticsPrintData(vm.ExamTitle, vm.ExamDateLabel, vm.NiveauLabel, stats,
vm.GradeDistribution.Select(g => new GradeDistributionPrintRow(g.Grade, g.Count,
vm.GradedCount == 0 ? 0 : g.Count * 100.0 / vm.GradedCount)).ToList(),
vm.TaskAnalysis.Select(t => new TaskAnalysisPrintRow(t.Label, t.AvgPercent, t.IsWeak)).ToList());
var pdf = App.Services.GetRequiredService<PdfExportService>().BuildExamStatisticsPdf(data);
await App.Services.GetRequiredService<ExportService>().SaveAsync(topLevel.StorageProvider,
ExportFile.Pdf("Klausurauswertung als PDF speichern", $"Auswertung_{vm.ExamTitle}", pdf));
}
private void OnClose(object? sender, RoutedEventArgs e) => Close();
}
@@ -21,6 +21,8 @@
IsEnabled="{Binding !IsReadOnly}"/>
<Button Content=" Sammelnote" Command="{Binding CollectiveGradeCommand}" IsEnabled="{Binding !IsReadOnly}"/>
<Button Content="Zeugnisnoten" Command="{Binding ReportGradesCommand}" IsEnabled="{Binding !IsReadOnly}"/>
<Button Content="Als PDF" Click="OnExportPdfClick" Margin="12,0,0,0"
IsEnabled="{Binding !!Rows.Count}"/>
</StackPanel>
<DataGrid Grid.Row="1"
@@ -1,7 +1,9 @@
using Avalonia.Controls;
using Avalonia.Data;
using Avalonia.Interactivity;
using LehrerApp.Core.Interfaces;
using LehrerApp.Core.Services;
using LehrerApp.Desktop.Services;
using LehrerApp.Desktop.ViewModels.Groups;
using Microsoft.Extensions.DependencyInjection;
@@ -77,6 +79,22 @@ public partial class GradeOverviewTabView : UserControl
if (owner is not null) await dialog.ShowDialog(owner);
}
private async void OnExportPdfClick(object? sender, RoutedEventArgs e)
{
var topLevel = TopLevel.GetTopLevel(this);
if (topLevel is null || _vm is null) return;
var data = new GradeListPrintData(
_vm.GroupLabel, _vm.SchoolYear, _vm.SelectedPeriod.Label,
_vm.Columns.Select(c => c.Header).ToList(),
_vm.Rows.Select(r => new GradeListPrintRow(r.Name, r.Cells, r.TotalDisplay)).ToList());
var pdf = App.Services.GetRequiredService<PdfExportService>().BuildGradeListPdf(data);
await App.Services.GetRequiredService<ExportService>().SaveAsync(topLevel.StorageProvider,
ExportFile.Pdf("Notenliste als PDF speichern",
$"Notenliste_{_vm.GroupLabel}_{_vm.SchoolYear.Replace('/', '-')}", pdf));
}
private void BuildColumns()
{
var grid = this.FindControl<DataGrid>("GradesGrid");
@@ -79,8 +79,11 @@
</StackPanel>
</StackPanel>
<StackPanel Grid.Column="1" VerticalAlignment="Bottom" Spacing="4">
<StackPanel Orientation="Horizontal" Spacing="10" HorizontalAlignment="Right">
<Button Content="Als PDF" Click="OnExportPdfClick" VerticalAlignment="Center"/>
<ToggleSwitch Content="Bearbeitungsmodus" IsChecked="{Binding IsEditMode}"
IsVisible="{Binding IsEditable}" HorizontalAlignment="Right"/>
IsVisible="{Binding IsEditable}"/>
</StackPanel>
<TextBlock Text="{Binding AssignmentSummary}" HorizontalAlignment="Right" FontSize="12" Opacity="0.6"/>
<TextBlock Text="Ziehen: Platz ändern · Klicken: bewerten" HorizontalAlignment="Right"
FontSize="11" Opacity="0.5" IsVisible="{Binding IsEditMode}"/>
@@ -5,8 +5,10 @@ using Avalonia.Interactivity;
using Avalonia.Threading;
using Avalonia.VisualTree;
using LehrerApp.Core.Models;
using LehrerApp.Desktop.Services;
using LehrerApp.Desktop.ViewModels.Groups;
using LehrerApp.Desktop.Views.Shared;
using Microsoft.Extensions.DependencyInjection;
namespace LehrerApp.Desktop.Views.Groups;
@@ -205,6 +207,21 @@ public partial class SeatingPlanTabView : UserControl
await vm.AssessStudent(seat);
}
private async void OnExportPdfClick(object? sender, RoutedEventArgs e)
{
var topLevel = TopLevel.GetTopLevel(this);
if (topLevel is null || DataContext is not SeatingPlanTabViewModel { HasSelectedPlan: true } vm) return;
var data = new SeatingPlanPrintData(
vm.PlanTitle, vm.PlanSubtitle, vm.PlanColumns, vm.IsBoardAtBottom, vm.ColumnGapWidths,
vm.Seats.Select(s => new SeatPrintCell(s.Row, s.Column,
s.IsOccupied ? s.SelectedOption.DisplayName : null, s.IsHidden)).ToList());
var pdf = App.Services.GetRequiredService<PdfExportService>().BuildSeatingPlanPdf(data);
await App.Services.GetRequiredService<ExportService>().SaveAsync(topLevel.StorageProvider,
ExportFile.Pdf("Sitzplan als PDF speichern", $"Sitzplan_{vm.PlanTitle}", pdf));
}
private async Task ShowAssessmentDialog(SeatAssessmentViewModel vm)
{
var dialog = new SeatAssessmentDialog { DataContext = vm };
@@ -245,12 +245,16 @@
</StackPanel>
</Border>
<Grid ColumnDefinitions="*,Auto,Auto">
<Grid ColumnDefinitions="*,Auto,Auto,Auto">
<TextBlock Grid.Column="0" Text="Einträge" FontSize="14" FontWeight="SemiBold" VerticalAlignment="Center"/>
<Button Grid.Column="1" Content="Datenauskunft exportieren" FontSize="12" Padding="10,4"
<Button Grid.Column="1" Content="Als PDF" FontSize="12" Padding="10,4"
Margin="0,0,8,0" Click="OnExportDocumentationPdfClick"
IsEnabled="{Binding !!Documentation.Count}"
ToolTip.Tip="Alle sichtbaren Dokumentationseinträge dieses Schülers als PDF drucken"/>
<Button Grid.Column="2" Content="Datenauskunft exportieren" FontSize="12" Padding="10,4"
Margin="0,0,8,0" Command="{Binding ExportPersonalDataCommand}"
ToolTip.Tip="Alle gespeicherten Daten dieses Schülers als JSON-Datei exportieren (Art. 15 DSGVO)"/>
<Button Grid.Column="2" Content=" Eintrag" Command="{Binding AddDocumentationCommand}"/>
<Button Grid.Column="3" Content=" Eintrag" Command="{Binding AddDocumentationCommand}"/>
</Grid>
<TextBlock Text="{Binding ExportStatus}" Foreground="Green" FontSize="12"
IsVisible="{Binding ExportStatus, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
@@ -1,8 +1,10 @@
using Avalonia.Controls;
using Avalonia.Input;
using Avalonia.Interactivity;
using Avalonia.Platform.Storage;
using LehrerApp.Core.Interfaces;
using LehrerApp.Core.Models;
using LehrerApp.Desktop.Services;
using LehrerApp.Desktop.ViewModels.Students;
using LehrerApp.Desktop.Views.Shared;
using Microsoft.Extensions.DependencyInjection;
@@ -119,6 +121,23 @@ public partial class StudentDetailView : UserControl
return owner is not null && await dialog.ShowDialog<bool>(owner);
}
private async void OnExportDocumentationPdfClick(object? sender, RoutedEventArgs e)
{
var topLevel = TopLevel.GetTopLevel(this);
if (topLevel is null || DataContext is not StudentDetailViewModel { Student: not null } vm
|| vm.Documentation.Count == 0) return;
var data = new StudentDocumentationPrintData(vm.Student.FullName,
vm.Documentation.Select(d => new DocumentationPrintEntry(
d.DateDisplay, d.TypeLabel, d.Model.Title, d.Model.Content,
d.Model.Tags, d.IsConfidential, d.IsDraft)).ToList());
var pdf = App.Services.GetRequiredService<PdfExportService>().BuildStudentDocumentationPdf(data);
await App.Services.GetRequiredService<ExportService>().SaveAsync(topLevel.StorageProvider,
ExportFile.Pdf("Dokumentation als PDF speichern",
$"Dokumentation_{vm.Student.LastName}_{vm.Student.FirstName}", pdf));
}
private async Task SaveExportFile(string json)
{
var topLevel = TopLevel.GetTopLevel(this);
+59 -7
View File
@@ -1397,9 +1397,23 @@ Hinweis in Kapitel 1 — betrifft auch Kurse, nicht nur Klassen.
in eine aktive Gruppe bleiben möglich.
### 7.3 Import
- [ ] **7.3.1** CSV-Import von Schülerlisten mit Spaltenzuordnung im Dialog.
- [ ] **7.3.2** Dublettenerkennung beim Import (Name + Geburtsdatum), Zusammenführen anbieten.
- [ ] **7.3.3** Import-Vorschau mit Fehlerliste vor dem endgültigen Übernehmen.
Nachträglich als erledigt markiert — die Umsetzung existierte bereits vollständig
(`StudentImportService`, `Core/Importing/`, `StudentImportDialogViewModel`), nur die Checkliste
war nie nachgezogen worden.
- [x] **7.3.1** CSV-Import von Schülerlisten. Abweichend vom ursprünglichen Plan keine manuelle
Spaltenzuordnung im Dialog, sondern drei fest hinterlegte, automatisch erkannte Formate
(`StudentMasterDataCsvImportHandler`, `LessonStudentListCsvImportHandler`,
`MarksPerLessonCsvImportHandler` über `ImportHandlerCatalog`) — deckt die real
vorkommenden Schulverwaltungs-Exporte ab, ohne Zuordnungs-UI.
- [x] **7.3.2** Dublettenerkennung mit Konfliktauflösung im Dialog: eindeutige Treffer werden
automatisch als "vorhanden" übernommen (`StudentImportResolutionKind.UseExisting`),
mehrdeutige Fälle als `ImportConflict` einzeln entschieden (bestehenden Schüler wählen /
neu anlegen / überspringen), inkl. "für alle übernehmen"-Abkürzung.
- [x] **7.3.3** Import-Vorschau (`StudentImportPreview`) mit Meldungsliste nach Schweregrad —
Fehler blockieren das Übernehmen (`CanApply`), dazu Zusammenfassung (neu / vorhanden / zu
ergänzen) und Lerngruppen-Zuordnungsübersicht vor dem endgültigen Übernehmen.
---
@@ -1439,7 +1453,9 @@ Format dokumentiert in [Kompetenzkatalog-KI-Prompt.md](docs/Kompetenzkatalog-KI-
- [x] **8.3.2** Gruppenübersicht: durchschnittlicher Erfüllungsgrad je Kompetenz,
Identifikation von Wiederholungsbedarf. Gruppenmittel, Zahl der beteiligten Schüler und
Aufgabenwerte werden angezeigt; die Schwelle für Wiederholungsbedarf ist frei einstellbar.
- [ ] **8.3.3** Kompetenzbericht je Schüler als Ausdruck/Export.
- [x] **8.3.3** Kompetenzbericht je Schüler als Ausdruck/Export — umgesetzt als PDF-Druckvorlage
der Kompetenzübersicht (Details siehe 11.4, "Kompetenzbericht"): druckt die Analyse für
den aktuell im Tab ausgewählten Schüler.
---
@@ -2107,9 +2123,45 @@ beides vor dem ersten produktiven Zwei-Geräte-Einsatz empfehlenswert nachzuhole
- [~] **11.2** CSV-Export für Notenlisten, Klausurauswertung, Arbeitszeit, Fehlzeiten —
Klausurauswertung, Zeugnisnotenliste und Arbeitszeitauswertung sind umgesetzt; allgemeine
Notenmatrix und Fehlzeitenbilanz fehlen noch.
- [ ] **11.3** PDF-Erzeugung (Bibliothek auswählen — z.B. QuestPDF) mit einheitlichem Layout.
- [ ] **11.4** Druckvorlagen: Notenliste, Klausur-Notenspiegel, Sitzplan, Kompetenzbericht,
Schülerdokumentation.
- [x] **11.3** PDF-Erzeugung mit einheitlichem Layout.
**Umsetzung:** QuestPDF (Community-Lizenz, in `Directory.Packages.props` gepinnt),
referenziert **nur von `LehrerApp.Desktop`** — bewusst nicht von Core: QuestPDF zieht
SkiaSharp-Native-Binaries mit, die sonst transitiv (Core → Sync → Api) im
Server-Docker-Image landen würden. Neuer `PdfExportService`
(`Desktop/Services/PdfExportService.cs`) mit gemeinsamen Layout-Bausteinen (Kopfzeile mit
Titel/Untertitel/Stand-Datum, Fußzeile mit Seitenzahlen, A4 quer) und schlanken Druck-DTOs
statt ViewModels — die Erzeugung bleibt dadurch ohne Avalonia-Bezug testbar
(`PdfExportServiceTests`, Layout zusätzlich an gerenderten Muster-PDFs visuell geprüft).
Der Speicherdialog läuft über den bestehenden `ExportService` (neues Format
`ExportFormat.Pdf` + `ExportFile.Pdf`).
- [x] **11.4** Druckvorlagen: Notenliste, Klausur-Notenspiegel, Sitzplan, Kompetenzbericht,
Schülerdokumentation — alle fünf umgesetzt, jeweils als "Als PDF"-Button direkt in der
zugehörigen Ansicht, alle über `PdfExportService` (11.3) mit einheitlichem Kopf/Fuß-Layout.
**Sitzplan** (Kopfzeile des Sitzplan-Tabs): druckt das Raster mit Tafel-Banner an der
konfigurierten Position, Tischabständen als echte Lücken (Spaltenbreiten analog
`SeatingPlanPanel`), ausgeblendeten Plätzen als leere Rasterposition (Reihen verrutschen
nicht) und freien Plätzen dezent markiert — gedacht z. B. für Vertretungslehrer oder
Klausuraufsicht.
**Notenliste** (Werkzeugleiste der Notenübersicht): druckt exakt die aktuell angezeigte
Matrix (gewählter Zeitraum, Punkte-/Noten-Darstellung, Sortierung) mit Namens-, Noten-
und Schnitt-Spalten — gedacht z. B. für die Zeugniskonferenz.
**Klausur-Notenspiegel** (Fußleiste des Klausurauswertungs-Dialogs, neben dem
CSV-Export): Kennzahlen (bewertet/abwesend/Durchschnitt/Median/Schwellen je nach
Notensystem), Notenverteilung und Aufgabenanalyse jeweils mit Balkendiagramm; auffällig
schwache Aufgaben (< 50 %) rot markiert — gedacht z. B. für die Klausurrückgabe oder die
Dokumentationspflicht gegenüber der Schulleitung.
**Kompetenzbericht** (Kopfzeile der Kompetenzübersicht): druckt die Analyse-Tabelle für
den aktuell ausgewählten Schüler (Bereich/Code/Kompetenz, Unterrichts- und
Klausurabdeckung, Gruppen- vs. Schülerergebnis, farbige Empfehlung) samt
Schwellenwert-Angabe — erledigt zugleich 8.3.3.
**Schülerdokumentation** (Kopfzeile der Eintragsliste im Schülerdetail): druckt alle
Einträge chronologisch als Blöcke mit Typ/Datum, Inhalt und Tags; vertrauliche Einträge
und Entwürfe sind deutlich markiert — vertrauliche Inhalte werden bewusst MIT gedruckt
(der Ausdruck ist ein Werkzeug der Lehrkraft selbst, z. B. zur Vorbereitung eines
Elterngesprächs), die Markierung erinnert an die nötige Sorgfalt beim Umgang mit dem
Papier.
- [x] **11.5a** Einzelner Elternbrief als bearbeitbare DOCX-Kopie aus einer in Word gestalteten
Vorlage. Vorlagenverwaltung in den Einstellungen; das Original bleibt unverändert. Vor
Import und erneut vor jeder Erzeugung werden Inhaltssteuerelemente auch in Kopf-/Fußzeilen