From 7365947edc2408dbd7f3234b12e9565f6d7c9247 Mon Sep 17 00:00:00 2001 From: Sebastian Hedtrich Date: Thu, 20 Aug 2026 14:27:05 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20PDF-Druck=20f=C3=BCr=20Sitzplan,=20Note?= =?UTF-8?q?nliste,=20Klausur-Notenspiegel,=20Kompetenzbericht,=20Dokumenta?= =?UTF-8?q?tion?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- Directory.Packages.props | 4 + .../PdfExportServiceTests.cs | 157 +++++++ LehrerApp.Desktop/AppBootstrapper.cs | 1 + LehrerApp.Desktop/LehrerApp.Desktop.csproj | 1 + LehrerApp.Desktop/Services/ExportService.cs | 5 + .../Services/PdfExportService.cs | 431 ++++++++++++++++++ .../Groups/CompetencyOverviewViewModels.cs | 2 + .../Groups/CompetencyOverviewTabView.axaml | 5 +- .../Groups/CompetencyOverviewTabView.axaml.cs | 22 + .../Views/Groups/ExamEvaluationDialog.axaml | 5 +- .../Groups/ExamEvaluationDialog.axaml.cs | 28 ++ .../Views/Groups/GradeOverviewTabView.axaml | 2 + .../Groups/GradeOverviewTabView.axaml.cs | 18 + .../Views/Groups/SeatingPlanTabView.axaml | 7 +- .../Views/Groups/SeatingPlanTabView.axaml.cs | 17 + .../Views/Students/StudentDetailView.axaml | 10 +- .../Views/Students/StudentDetailView.axaml.cs | 19 + TODO.md | 66 ++- 18 files changed, 785 insertions(+), 15 deletions(-) create mode 100644 LehrerApp.Desktop.Tests/PdfExportServiceTests.cs create mode 100644 LehrerApp.Desktop/Services/PdfExportService.cs diff --git a/Directory.Packages.props b/Directory.Packages.props index f22fb7a..0545be3 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -17,6 +17,10 @@ + + + diff --git a/LehrerApp.Desktop.Tests/PdfExportServiceTests.cs b/LehrerApp.Desktop.Tests/PdfExportServiceTests.cs new file mode 100644 index 0000000..ce1e68b --- /dev/null +++ b/LehrerApp.Desktop.Tests/PdfExportServiceTests.cs @@ -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)); + } +} diff --git a/LehrerApp.Desktop/AppBootstrapper.cs b/LehrerApp.Desktop/AppBootstrapper.cs index bb2b906..1b812b8 100644 --- a/LehrerApp.Desktop/AppBootstrapper.cs +++ b/LehrerApp.Desktop/AppBootstrapper.cs @@ -114,6 +114,7 @@ public static class AppBootstrapper services.AddSingleton(Logger); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); // ── Datensicherheit (13.3) ─────────────────────────────────────────── // Backup läuft vor dem Öffnen der Datenbank, rein dateibasiert (unabhängig von diff --git a/LehrerApp.Desktop/LehrerApp.Desktop.csproj b/LehrerApp.Desktop/LehrerApp.Desktop.csproj index 63f5a40..a93a56b 100644 --- a/LehrerApp.Desktop/LehrerApp.Desktop.csproj +++ b/LehrerApp.Desktop/LehrerApp.Desktop.csproj @@ -18,6 +18,7 @@ + diff --git a/LehrerApp.Desktop/Services/ExportService.cs b/LehrerApp.Desktop/Services/ExportService.cs index b1b3f2e..a07a535 100644 --- a/LehrerApp.Desktop/Services/ExportService.cs +++ b/LehrerApp.Desktop/Services/ExportService.cs @@ -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) { diff --git a/LehrerApp.Desktop/Services/PdfExportService.cs b/LehrerApp.Desktop/Services/PdfExportService.cs new file mode 100644 index 0000000..3171982 --- /dev/null +++ b/LehrerApp.Desktop/Services/PdfExportService.cs @@ -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) ──────────── + +/// 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). +public sealed record SeatingPlanPrintData( + string PlanTitle, + string PlanSubtitle, + int Columns, + bool IsBoardAtBottom, + IReadOnlyList ColumnGapWidths, + IReadOnlyList Seats); + +public sealed record SeatPrintCell(int Row, int Column, string? StudentName, bool IsHidden); + +public sealed record GradeListPrintData( + string GroupLabel, + string SchoolYear, + string PeriodLabel, + IReadOnlyList ColumnHeaders, + IReadOnlyList Rows); + +public sealed record GradeListPrintRow(string Name, IReadOnlyList Cells, string Total); + +public sealed record LabelValue(string Label, string Value); + +public sealed record ExamStatisticsPrintData( + string ExamTitle, + string DateLabel, + string NiveauLabel, + IReadOnlyList Stats, + IReadOnlyList Distribution, + IReadOnlyList 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 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 Entries); + +public sealed record DocumentationPrintEntry( + string DateDisplay, + string TypeLabel, + string Title, + string Content, + IReadOnlyList Tags, + bool IsConfidential, + bool IsDraft); + +/// +/// PDF-Erzeugung über QuestPDF (11.3). Liefert fertige Bytes - der Speicherdialog läuft wie bei +/// allen Exporten über . 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. +/// +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 ─────────────────────────────────────────────────────────────── + + /// Horizontaler Balken, 0-100 % der verfügbaren Zellenbreite (max. 150pt). + 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); +} diff --git a/LehrerApp.Desktop/ViewModels/Groups/CompetencyOverviewViewModels.cs b/LehrerApp.Desktop/ViewModels/Groups/CompetencyOverviewViewModels.cs index fe818c1..89612e3 100644 --- a/LehrerApp.Desktop/ViewModels/Groups/CompetencyOverviewViewModels.cs +++ b/LehrerApp.Desktop/ViewModels/Groups/CompetencyOverviewViewModels.cs @@ -22,6 +22,8 @@ public partial class CompetencyOverviewTabViewModel : ObservableObject [ObservableProperty] private string _summary = ""; [ObservableProperty] private string _emptyMessage = ""; + public string GroupLabel => _group?.Name ?? ""; + public ObservableCollection StudentOptions { get; } = []; public ObservableCollection Rows { get; } = []; public bool HasRows => Rows.Count > 0; diff --git a/LehrerApp.Desktop/Views/Groups/CompetencyOverviewTabView.axaml b/LehrerApp.Desktop/Views/Groups/CompetencyOverviewTabView.axaml index 952f054..53cfcc6 100644 --- a/LehrerApp.Desktop/Views/Groups/CompetencyOverviewTabView.axaml +++ b/LehrerApp.Desktop/Views/Groups/CompetencyOverviewTabView.axaml @@ -10,7 +10,10 @@ -