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:
@@ -17,6 +17,10 @@
|
|||||||
<!-- Datenbank -->
|
<!-- Datenbank -->
|
||||||
<PackageVersion Include="LiteDB" Version="5.0.21" />
|
<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 -->
|
<!-- API -->
|
||||||
<PackageVersion Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.0" />
|
<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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -114,6 +114,7 @@ public static class AppBootstrapper
|
|||||||
services.AddSingleton(Logger);
|
services.AddSingleton(Logger);
|
||||||
services.AddSingleton<NotificationService>();
|
services.AddSingleton<NotificationService>();
|
||||||
services.AddSingleton<ExportService>();
|
services.AddSingleton<ExportService>();
|
||||||
|
services.AddSingleton<PdfExportService>();
|
||||||
|
|
||||||
// ── Datensicherheit (13.3) ───────────────────────────────────────────
|
// ── Datensicherheit (13.3) ───────────────────────────────────────────
|
||||||
// Backup läuft vor dem Öffnen der Datenbank, rein dateibasiert (unabhängig von
|
// Backup läuft vor dem Öffnen der Datenbank, rein dateibasiert (unabhängig von
|
||||||
|
|||||||
@@ -18,6 +18,7 @@
|
|||||||
<PackageReference Include="Avalonia.Controls.DataGrid" />
|
<PackageReference Include="Avalonia.Controls.DataGrid" />
|
||||||
<PackageReference Include="CommunityToolkit.Mvvm" />
|
<PackageReference Include="CommunityToolkit.Mvvm" />
|
||||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection" />
|
<PackageReference Include="Microsoft.Extensions.DependencyInjection" />
|
||||||
|
<PackageReference Include="QuestPDF" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<AvaloniaResource Include="Assets\**" />
|
<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 Csv { get; } = new("CSV-Dateien", ".csv", "text/csv");
|
||||||
public static ExportFormat Json { get; } = new("JSON-Dateien", ".json", "application/json");
|
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(
|
public sealed record ExportFile(
|
||||||
@@ -21,6 +22,10 @@ public sealed record ExportFile(
|
|||||||
public static ExportFile Json(string dialogTitle, string suggestedFileName, string content) =>
|
public static ExportFile Json(string dialogTitle, string suggestedFileName, string content) =>
|
||||||
FromText(dialogTitle, suggestedFileName, ExportFormat.Json, 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,
|
private static ExportFile FromText(string dialogTitle, string suggestedFileName,
|
||||||
ExportFormat format, string content, bool includeUtf8Bom = false)
|
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 _summary = "";
|
||||||
[ObservableProperty] private string _emptyMessage = "";
|
[ObservableProperty] private string _emptyMessage = "";
|
||||||
|
|
||||||
|
public string GroupLabel => _group?.Name ?? "";
|
||||||
|
|
||||||
public ObservableCollection<CompetencyStudentOption> StudentOptions { get; } = [];
|
public ObservableCollection<CompetencyStudentOption> StudentOptions { get; } = [];
|
||||||
public ObservableCollection<CompetencyOverviewRow> Rows { get; } = [];
|
public ObservableCollection<CompetencyOverviewRow> Rows { get; } = [];
|
||||||
public bool HasRows => Rows.Count > 0;
|
public bool HasRows => Rows.Count > 0;
|
||||||
|
|||||||
@@ -10,7 +10,10 @@
|
|||||||
<TextBlock Text="Kompetenzübersicht" FontSize="18" FontWeight="SemiBold"/>
|
<TextBlock Text="Kompetenzübersicht" FontSize="18" FontWeight="SemiBold"/>
|
||||||
<TextBlock Text="{Binding Summary}" FontSize="12" Opacity="0.65"/>
|
<TextBlock Text="{Binding Summary}" FontSize="12" Opacity="0.65"/>
|
||||||
</StackPanel>
|
</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>
|
||||||
|
|
||||||
<Grid ColumnDefinitions="*,16,210" IsVisible="{Binding HasRows}">
|
<Grid ColumnDefinitions="*,16,210" IsVisible="{Binding HasRows}">
|
||||||
|
|||||||
@@ -1,8 +1,30 @@
|
|||||||
using Avalonia.Controls;
|
using Avalonia.Controls;
|
||||||
|
using Avalonia.Interactivity;
|
||||||
|
using LehrerApp.Desktop.Services;
|
||||||
|
using LehrerApp.Desktop.ViewModels.Groups;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
|
||||||
namespace LehrerApp.Desktop.Views.Groups;
|
namespace LehrerApp.Desktop.Views.Groups;
|
||||||
|
|
||||||
public partial class CompetencyOverviewTabView : UserControl
|
public partial class CompetencyOverviewTabView : UserControl
|
||||||
{
|
{
|
||||||
public CompetencyOverviewTabView() => InitializeComponent();
|
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>
|
</StackPanel>
|
||||||
</ScrollViewer>
|
</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="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>
|
||||||
</Grid>
|
</Grid>
|
||||||
</Window>
|
</Window>
|
||||||
|
|||||||
@@ -21,5 +21,33 @@ public partial class ExamEvaluationDialog : Window
|
|||||||
await App.Services.GetRequiredService<ExportService>().SaveAsync(topLevel.StorageProvider, export);
|
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();
|
private void OnClose(object? sender, RoutedEventArgs e) => Close();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,6 +21,8 @@
|
|||||||
IsEnabled="{Binding !IsReadOnly}"/>
|
IsEnabled="{Binding !IsReadOnly}"/>
|
||||||
<Button Content="+ Sammelnote" Command="{Binding CollectiveGradeCommand}" IsEnabled="{Binding !IsReadOnly}"/>
|
<Button Content="+ Sammelnote" Command="{Binding CollectiveGradeCommand}" IsEnabled="{Binding !IsReadOnly}"/>
|
||||||
<Button Content="Zeugnisnoten" Command="{Binding ReportGradesCommand}" 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>
|
</StackPanel>
|
||||||
|
|
||||||
<DataGrid Grid.Row="1"
|
<DataGrid Grid.Row="1"
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
using Avalonia.Controls;
|
using Avalonia.Controls;
|
||||||
using Avalonia.Data;
|
using Avalonia.Data;
|
||||||
|
using Avalonia.Interactivity;
|
||||||
using LehrerApp.Core.Interfaces;
|
using LehrerApp.Core.Interfaces;
|
||||||
using LehrerApp.Core.Services;
|
using LehrerApp.Core.Services;
|
||||||
|
using LehrerApp.Desktop.Services;
|
||||||
using LehrerApp.Desktop.ViewModels.Groups;
|
using LehrerApp.Desktop.ViewModels.Groups;
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
|
||||||
@@ -77,6 +79,22 @@ public partial class GradeOverviewTabView : UserControl
|
|||||||
if (owner is not null) await dialog.ShowDialog(owner);
|
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()
|
private void BuildColumns()
|
||||||
{
|
{
|
||||||
var grid = this.FindControl<DataGrid>("GradesGrid");
|
var grid = this.FindControl<DataGrid>("GradesGrid");
|
||||||
|
|||||||
@@ -79,8 +79,11 @@
|
|||||||
</StackPanel>
|
</StackPanel>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
<StackPanel Grid.Column="1" VerticalAlignment="Bottom" Spacing="4">
|
<StackPanel Grid.Column="1" VerticalAlignment="Bottom" Spacing="4">
|
||||||
<ToggleSwitch Content="Bearbeitungsmodus" IsChecked="{Binding IsEditMode}"
|
<StackPanel Orientation="Horizontal" Spacing="10" HorizontalAlignment="Right">
|
||||||
IsVisible="{Binding IsEditable}" HorizontalAlignment="Right"/>
|
<Button Content="Als PDF" Click="OnExportPdfClick" VerticalAlignment="Center"/>
|
||||||
|
<ToggleSwitch Content="Bearbeitungsmodus" IsChecked="{Binding IsEditMode}"
|
||||||
|
IsVisible="{Binding IsEditable}"/>
|
||||||
|
</StackPanel>
|
||||||
<TextBlock Text="{Binding AssignmentSummary}" HorizontalAlignment="Right" FontSize="12" Opacity="0.6"/>
|
<TextBlock Text="{Binding AssignmentSummary}" HorizontalAlignment="Right" FontSize="12" Opacity="0.6"/>
|
||||||
<TextBlock Text="Ziehen: Platz ändern · Klicken: bewerten" HorizontalAlignment="Right"
|
<TextBlock Text="Ziehen: Platz ändern · Klicken: bewerten" HorizontalAlignment="Right"
|
||||||
FontSize="11" Opacity="0.5" IsVisible="{Binding IsEditMode}"/>
|
FontSize="11" Opacity="0.5" IsVisible="{Binding IsEditMode}"/>
|
||||||
|
|||||||
@@ -5,8 +5,10 @@ using Avalonia.Interactivity;
|
|||||||
using Avalonia.Threading;
|
using Avalonia.Threading;
|
||||||
using Avalonia.VisualTree;
|
using Avalonia.VisualTree;
|
||||||
using LehrerApp.Core.Models;
|
using LehrerApp.Core.Models;
|
||||||
|
using LehrerApp.Desktop.Services;
|
||||||
using LehrerApp.Desktop.ViewModels.Groups;
|
using LehrerApp.Desktop.ViewModels.Groups;
|
||||||
using LehrerApp.Desktop.Views.Shared;
|
using LehrerApp.Desktop.Views.Shared;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
|
||||||
namespace LehrerApp.Desktop.Views.Groups;
|
namespace LehrerApp.Desktop.Views.Groups;
|
||||||
|
|
||||||
@@ -205,6 +207,21 @@ public partial class SeatingPlanTabView : UserControl
|
|||||||
await vm.AssessStudent(seat);
|
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)
|
private async Task ShowAssessmentDialog(SeatAssessmentViewModel vm)
|
||||||
{
|
{
|
||||||
var dialog = new SeatAssessmentDialog { DataContext = vm };
|
var dialog = new SeatAssessmentDialog { DataContext = vm };
|
||||||
|
|||||||
@@ -245,12 +245,16 @@
|
|||||||
</StackPanel>
|
</StackPanel>
|
||||||
</Border>
|
</Border>
|
||||||
|
|
||||||
<Grid ColumnDefinitions="*,Auto,Auto">
|
<Grid ColumnDefinitions="*,Auto,Auto,Auto">
|
||||||
<TextBlock Grid.Column="0" Text="Einträge" FontSize="14" FontWeight="SemiBold" VerticalAlignment="Center"/>
|
<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}"
|
Margin="0,0,8,0" Command="{Binding ExportPersonalDataCommand}"
|
||||||
ToolTip.Tip="Alle gespeicherten Daten dieses Schülers als JSON-Datei exportieren (Art. 15 DSGVO)"/>
|
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>
|
</Grid>
|
||||||
<TextBlock Text="{Binding ExportStatus}" Foreground="Green" FontSize="12"
|
<TextBlock Text="{Binding ExportStatus}" Foreground="Green" FontSize="12"
|
||||||
IsVisible="{Binding ExportStatus, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
|
IsVisible="{Binding ExportStatus, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
using Avalonia.Controls;
|
using Avalonia.Controls;
|
||||||
using Avalonia.Input;
|
using Avalonia.Input;
|
||||||
|
using Avalonia.Interactivity;
|
||||||
using Avalonia.Platform.Storage;
|
using Avalonia.Platform.Storage;
|
||||||
using LehrerApp.Core.Interfaces;
|
using LehrerApp.Core.Interfaces;
|
||||||
using LehrerApp.Core.Models;
|
using LehrerApp.Core.Models;
|
||||||
|
using LehrerApp.Desktop.Services;
|
||||||
using LehrerApp.Desktop.ViewModels.Students;
|
using LehrerApp.Desktop.ViewModels.Students;
|
||||||
using LehrerApp.Desktop.Views.Shared;
|
using LehrerApp.Desktop.Views.Shared;
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
@@ -119,6 +121,23 @@ public partial class StudentDetailView : UserControl
|
|||||||
return owner is not null && await dialog.ShowDialog<bool>(owner);
|
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)
|
private async Task SaveExportFile(string json)
|
||||||
{
|
{
|
||||||
var topLevel = TopLevel.GetTopLevel(this);
|
var topLevel = TopLevel.GetTopLevel(this);
|
||||||
|
|||||||
@@ -1397,9 +1397,23 @@ Hinweis in Kapitel 1 — betrifft auch Kurse, nicht nur Klassen.
|
|||||||
in eine aktive Gruppe bleiben möglich.
|
in eine aktive Gruppe bleiben möglich.
|
||||||
|
|
||||||
### 7.3 Import
|
### 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.
|
Nachträglich als erledigt markiert — die Umsetzung existierte bereits vollständig
|
||||||
- [ ] **7.3.3** Import-Vorschau mit Fehlerliste vor dem endgültigen Übernehmen.
|
(`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,
|
- [x] **8.3.2** Gruppenübersicht: durchschnittlicher Erfüllungsgrad je Kompetenz,
|
||||||
Identifikation von Wiederholungsbedarf. Gruppenmittel, Zahl der beteiligten Schüler und
|
Identifikation von Wiederholungsbedarf. Gruppenmittel, Zahl der beteiligten Schüler und
|
||||||
Aufgabenwerte werden angezeigt; die Schwelle für Wiederholungsbedarf ist frei einstellbar.
|
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 —
|
- [~] **11.2** CSV-Export für Notenlisten, Klausurauswertung, Arbeitszeit, Fehlzeiten —
|
||||||
Klausurauswertung, Zeugnisnotenliste und Arbeitszeitauswertung sind umgesetzt; allgemeine
|
Klausurauswertung, Zeugnisnotenliste und Arbeitszeitauswertung sind umgesetzt; allgemeine
|
||||||
Notenmatrix und Fehlzeitenbilanz fehlen noch.
|
Notenmatrix und Fehlzeitenbilanz fehlen noch.
|
||||||
- [ ] **11.3** PDF-Erzeugung (Bibliothek auswählen — z.B. QuestPDF) mit einheitlichem Layout.
|
- [x] **11.3** PDF-Erzeugung mit einheitlichem Layout.
|
||||||
- [ ] **11.4** Druckvorlagen: Notenliste, Klausur-Notenspiegel, Sitzplan, Kompetenzbericht,
|
|
||||||
Schülerdokumentation.
|
**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
|
- [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
|
Vorlage. Vorlagenverwaltung in den Einstellungen; das Original bleibt unverändert. Vor
|
||||||
Import und erneut vor jeder Erzeugung werden Inhaltssteuerelemente auch in Kopf-/Fußzeilen
|
Import und erneut vor jeder Erzeugung werden Inhaltssteuerelemente auch in Kopf-/Fußzeilen
|
||||||
|
|||||||
Reference in New Issue
Block a user