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 @@
-
+
+
+
+
diff --git a/LehrerApp.Desktop/Views/Groups/CompetencyOverviewTabView.axaml.cs b/LehrerApp.Desktop/Views/Groups/CompetencyOverviewTabView.axaml.cs
index 1606d0e..6d02d0a 100644
--- a/LehrerApp.Desktop/Views/Groups/CompetencyOverviewTabView.axaml.cs
+++ b/LehrerApp.Desktop/Views/Groups/CompetencyOverviewTabView.axaml.cs
@@ -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().BuildCompetencyReportPdf(data);
+
+ await App.Services.GetRequiredService().SaveAsync(topLevel.StorageProvider,
+ ExportFile.Pdf("Kompetenzbericht als PDF speichern",
+ $"Kompetenzbericht_{vm.GroupLabel}_{studentName}", pdf));
+ }
}
diff --git a/LehrerApp.Desktop/Views/Groups/ExamEvaluationDialog.axaml b/LehrerApp.Desktop/Views/Groups/ExamEvaluationDialog.axaml
index c2ad1c3..e2b9cc4 100644
--- a/LehrerApp.Desktop/Views/Groups/ExamEvaluationDialog.axaml
+++ b/LehrerApp.Desktop/Views/Groups/ExamEvaluationDialog.axaml
@@ -147,9 +147,10 @@
-
+
-
+
+
diff --git a/LehrerApp.Desktop/Views/Groups/ExamEvaluationDialog.axaml.cs b/LehrerApp.Desktop/Views/Groups/ExamEvaluationDialog.axaml.cs
index 2ebda88..ee67467 100644
--- a/LehrerApp.Desktop/Views/Groups/ExamEvaluationDialog.axaml.cs
+++ b/LehrerApp.Desktop/Views/Groups/ExamEvaluationDialog.axaml.cs
@@ -21,5 +21,33 @@ public partial class ExamEvaluationDialog : Window
await App.Services.GetRequiredService().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
+ {
+ 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().BuildExamStatisticsPdf(data);
+
+ await App.Services.GetRequiredService().SaveAsync(topLevel.StorageProvider,
+ ExportFile.Pdf("Klausurauswertung als PDF speichern", $"Auswertung_{vm.ExamTitle}", pdf));
+ }
+
private void OnClose(object? sender, RoutedEventArgs e) => Close();
}
diff --git a/LehrerApp.Desktop/Views/Groups/GradeOverviewTabView.axaml b/LehrerApp.Desktop/Views/Groups/GradeOverviewTabView.axaml
index 6dedec4..1541205 100644
--- a/LehrerApp.Desktop/Views/Groups/GradeOverviewTabView.axaml
+++ b/LehrerApp.Desktop/Views/Groups/GradeOverviewTabView.axaml
@@ -21,6 +21,8 @@
IsEnabled="{Binding !IsReadOnly}"/>
+
c.Header).ToList(),
+ _vm.Rows.Select(r => new GradeListPrintRow(r.Name, r.Cells, r.TotalDisplay)).ToList());
+ var pdf = App.Services.GetRequiredService().BuildGradeListPdf(data);
+
+ await App.Services.GetRequiredService().SaveAsync(topLevel.StorageProvider,
+ ExportFile.Pdf("Notenliste als PDF speichern",
+ $"Notenliste_{_vm.GroupLabel}_{_vm.SchoolYear.Replace('/', '-')}", pdf));
+ }
+
private void BuildColumns()
{
var grid = this.FindControl("GradesGrid");
diff --git a/LehrerApp.Desktop/Views/Groups/SeatingPlanTabView.axaml b/LehrerApp.Desktop/Views/Groups/SeatingPlanTabView.axaml
index 384e525..229ebae 100644
--- a/LehrerApp.Desktop/Views/Groups/SeatingPlanTabView.axaml
+++ b/LehrerApp.Desktop/Views/Groups/SeatingPlanTabView.axaml
@@ -79,8 +79,11 @@
-
+
+
+
+
diff --git a/LehrerApp.Desktop/Views/Groups/SeatingPlanTabView.axaml.cs b/LehrerApp.Desktop/Views/Groups/SeatingPlanTabView.axaml.cs
index 9f3123b..da1d6d3 100644
--- a/LehrerApp.Desktop/Views/Groups/SeatingPlanTabView.axaml.cs
+++ b/LehrerApp.Desktop/Views/Groups/SeatingPlanTabView.axaml.cs
@@ -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().BuildSeatingPlanPdf(data);
+
+ await App.Services.GetRequiredService().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 };
diff --git a/LehrerApp.Desktop/Views/Students/StudentDetailView.axaml b/LehrerApp.Desktop/Views/Students/StudentDetailView.axaml
index d3678c1..20f7dc5 100644
--- a/LehrerApp.Desktop/Views/Students/StudentDetailView.axaml
+++ b/LehrerApp.Desktop/Views/Students/StudentDetailView.axaml
@@ -245,12 +245,16 @@
-
+
-
+
-
+
diff --git a/LehrerApp.Desktop/Views/Students/StudentDetailView.axaml.cs b/LehrerApp.Desktop/Views/Students/StudentDetailView.axaml.cs
index 9000821..5090d0e 100644
--- a/LehrerApp.Desktop/Views/Students/StudentDetailView.axaml.cs
+++ b/LehrerApp.Desktop/Views/Students/StudentDetailView.axaml.cs
@@ -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(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().BuildStudentDocumentationPdf(data);
+
+ await App.Services.GetRequiredService().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);
diff --git a/TODO.md b/TODO.md
index 84260a9..e91a8f2 100644
--- a/TODO.md
+++ b/TODO.md
@@ -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