feat: PDF-Druck für Sitzplan, Notenliste, Klausur-Notenspiegel, Kompetenzbericht, Dokumentation

QuestPDF als PDF-Bibliothek eingeführt (11.3), bewusst nur in LehrerApp.Desktop referenziert -
zieht SkiaSharp-Native-Binaries mit, die im Server-Docker-Image nichts verloren haben. Neuer
PdfExportService mit gemeinsamem Kopf-/Fußzeilen-Layout (Titel, Stand-Datum, Seitenzahlen) und
schlanken Druck-DTOs statt ViewModels, damit die Erzeugung ohne Avalonia-Bezug testbar bleibt.
Speicherdialog läuft über den bestehenden ExportService (neues PDF-Format).

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

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

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-20 14:27:05 +02:00
co-authored by Claude Sonnet 5
parent 4cce84c7dd
commit 7365947edc
18 changed files with 785 additions and 15 deletions
+1
View File
@@ -114,6 +114,7 @@ public static class AppBootstrapper
services.AddSingleton(Logger);
services.AddSingleton<NotificationService>();
services.AddSingleton<ExportService>();
services.AddSingleton<PdfExportService>();
// ── Datensicherheit (13.3) ───────────────────────────────────────────
// Backup läuft vor dem Öffnen der Datenbank, rein dateibasiert (unabhängig von
@@ -18,6 +18,7 @@
<PackageReference Include="Avalonia.Controls.DataGrid" />
<PackageReference Include="CommunityToolkit.Mvvm" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" />
<PackageReference Include="QuestPDF" />
</ItemGroup>
<ItemGroup>
<AvaloniaResource Include="Assets\**" />
@@ -7,6 +7,7 @@ public sealed record ExportFormat(string Label, string Extension, string MimeTyp
{
public static ExportFormat Csv { get; } = new("CSV-Dateien", ".csv", "text/csv");
public static ExportFormat Json { get; } = new("JSON-Dateien", ".json", "application/json");
public static ExportFormat Pdf { get; } = new("PDF-Dateien", ".pdf", "application/pdf");
}
public sealed record ExportFile(
@@ -21,6 +22,10 @@ public sealed record ExportFile(
public static ExportFile Json(string dialogTitle, string suggestedFileName, string content) =>
FromText(dialogTitle, suggestedFileName, ExportFormat.Json, content);
public static ExportFile Pdf(string dialogTitle, string suggestedFileName, byte[] content) =>
new(dialogTitle, EnsureExtension(SanitizeFileName(suggestedFileName), ExportFormat.Pdf.Extension),
ExportFormat.Pdf, content);
private static ExportFile FromText(string dialogTitle, string suggestedFileName,
ExportFormat format, string content, bool includeUtf8Bom = false)
{
@@ -0,0 +1,431 @@
using QuestPDF.Fluent;
using QuestPDF.Helpers;
using QuestPDF.Infrastructure;
namespace LehrerApp.Desktop.Services;
// ── Druckdaten (bewusst schlanke DTOs statt ViewModels: die PDF-Erzeugung bleibt dadurch ohne
// Avalonia-/MVVM-Bezug testbar, und die Views entscheiden selbst, was gedruckt wird) ────────────
/// <param name="Seats">Alle Plätze des Rasters in Zeilen-Hauptordnung, auch ausgeblendete
/// (IsHidden) - sie belegen ihre Rasterposition weiterhin, werden aber leer gedruckt
/// (gleiche Logik wie SeatingPlanPanel/SeatCellViewModel.ShowSeat).</param>
public sealed record SeatingPlanPrintData(
string PlanTitle,
string PlanSubtitle,
int Columns,
bool IsBoardAtBottom,
IReadOnlyList<double> ColumnGapWidths,
IReadOnlyList<SeatPrintCell> Seats);
public sealed record SeatPrintCell(int Row, int Column, string? StudentName, bool IsHidden);
public sealed record GradeListPrintData(
string GroupLabel,
string SchoolYear,
string PeriodLabel,
IReadOnlyList<string> ColumnHeaders,
IReadOnlyList<GradeListPrintRow> Rows);
public sealed record GradeListPrintRow(string Name, IReadOnlyList<string> Cells, string Total);
public sealed record LabelValue(string Label, string Value);
public sealed record ExamStatisticsPrintData(
string ExamTitle,
string DateLabel,
string NiveauLabel,
IReadOnlyList<LabelValue> Stats,
IReadOnlyList<GradeDistributionPrintRow> Distribution,
IReadOnlyList<TaskAnalysisPrintRow> TaskAnalysis);
public sealed record GradeDistributionPrintRow(string Grade, int Count, double Percent);
public sealed record TaskAnalysisPrintRow(string Label, double AvgPercent, bool IsWeak);
public sealed record CompetencyReportPrintData(
string GroupLabel,
string StudentName,
string Summary,
string ThresholdLabel,
IReadOnlyList<CompetencyReportPrintRow> Rows);
public sealed record CompetencyReportPrintRow(
string Domain,
string Code,
string Description,
string InstructionCoverage,
string ExamCoverage,
string GroupResult,
string StudentResult,
string Recommendation);
public sealed record StudentDocumentationPrintData(
string StudentName,
IReadOnlyList<DocumentationPrintEntry> Entries);
public sealed record DocumentationPrintEntry(
string DateDisplay,
string TypeLabel,
string Title,
string Content,
IReadOnlyList<string> Tags,
bool IsConfidential,
bool IsDraft);
/// <summary>
/// PDF-Erzeugung über QuestPDF (11.3). Liefert fertige Bytes - der Speicherdialog läuft wie bei
/// allen Exporten über <see cref="ExportService"/>. Lebt bewusst in LehrerApp.Desktop statt Core:
/// QuestPDF zieht SkiaSharp-Native-Binaries mit, die im Server-Image (LehrerApp.Api, referenziert
/// Core transitiv über Sync) nichts verloren haben.
/// </summary>
public sealed class PdfExportService
{
static PdfExportService() => QuestPDF.Settings.License = LicenseType.Community;
// Bildschirm-Pixel (Tischabstände, 0-300) → Druckpunkte. 0.4 hält auch den Maximalabstand
// (120pt ≈ 4cm) auf einer A4-Querseite praktikabel.
private const float GapPixelToPoint = 0.4f;
public byte[] BuildSeatingPlanPdf(SeatingPlanPrintData data) =>
Document.Create(container => container.Page(page =>
{
page.Size(PageSizes.A4.Landscape());
page.Margin(36);
page.DefaultTextStyle(style => style.FontSize(10));
page.Header().Element(header => Header(header, data.PlanTitle, data.PlanSubtitle));
page.Content().PaddingVertical(10).Column(column =>
{
column.Spacing(10);
if (!data.IsBoardAtBottom) column.Item().Element(BoardBanner);
column.Item().Element(grid => SeatGrid(grid, data));
if (data.IsBoardAtBottom) column.Item().Element(BoardBanner);
});
page.Footer().Element(Footer);
})).GeneratePdf();
public byte[] BuildGradeListPdf(GradeListPrintData data) =>
Document.Create(container => container.Page(page =>
{
page.Size(PageSizes.A4.Landscape());
page.Margin(36);
page.DefaultTextStyle(style => style.FontSize(8));
page.Header().Element(header => Header(header,
$"Notenliste {data.GroupLabel}",
$"{data.SchoolYear} · {data.PeriodLabel}"));
page.Content().PaddingVertical(10).Table(table =>
{
table.ColumnsDefinition(columns =>
{
columns.RelativeColumn(2.4f);
foreach (var _ in data.ColumnHeaders) columns.RelativeColumn();
columns.RelativeColumn(0.8f);
});
table.Header(header =>
{
header.Cell().Element(HeaderCell).Text("Name").SemiBold();
foreach (var columnHeader in data.ColumnHeaders)
header.Cell().Element(HeaderCell).Text(columnHeader).SemiBold();
header.Cell().Element(HeaderCell).AlignRight().Text("Schnitt").SemiBold();
});
foreach (var row in data.Rows)
{
table.Cell().Element(BodyCell).Text(row.Name);
foreach (var cell in row.Cells)
table.Cell().Element(BodyCell).AlignCenter().Text(cell);
table.Cell().Element(BodyCell).AlignRight().Text(row.Total).SemiBold();
}
});
page.Footer().Element(Footer);
})).GeneratePdf();
public byte[] BuildExamStatisticsPdf(ExamStatisticsPrintData data) =>
Document.Create(container => container.Page(page =>
{
page.Size(PageSizes.A4);
page.Margin(36);
page.DefaultTextStyle(style => style.FontSize(10));
var subtitle = string.IsNullOrWhiteSpace(data.NiveauLabel)
? data.DateLabel
: $"{data.DateLabel} · Niveau {data.NiveauLabel}";
page.Header().Element(header => Header(header, data.ExamTitle, subtitle));
page.Content().PaddingVertical(10).Column(column =>
{
column.Spacing(14);
column.Item().Table(table =>
{
table.ColumnsDefinition(columns =>
{
columns.RelativeColumn(2);
columns.RelativeColumn();
});
foreach (var stat in data.Stats)
{
table.Cell().Element(BodyCell).Text(stat.Label);
table.Cell().Element(BodyCell).AlignRight().Text(stat.Value).SemiBold();
}
});
column.Item().Text("Notenspiegel").FontSize(12).SemiBold();
column.Item().Table(table =>
{
table.ColumnsDefinition(columns =>
{
columns.ConstantColumn(60);
columns.ConstantColumn(60);
columns.ConstantColumn(60);
columns.RelativeColumn();
});
table.Header(header =>
{
header.Cell().Element(HeaderCell).Text("Note").SemiBold();
header.Cell().Element(HeaderCell).AlignRight().Text("Anzahl").SemiBold();
header.Cell().Element(HeaderCell).AlignRight().Text("Anteil").SemiBold();
header.Cell().Element(HeaderCell);
});
var maxCount = data.Distribution.Count == 0 ? 0 : data.Distribution.Max(d => d.Count);
foreach (var entry in data.Distribution)
{
table.Cell().Element(BodyCell).Text(entry.Grade);
table.Cell().Element(BodyCell).AlignRight().Text(entry.Count.ToString());
table.Cell().Element(BodyCell).AlignRight().Text($"{entry.Percent:0.#} %");
table.Cell().Element(BodyCell).AlignMiddle().Element(bar =>
Bar(bar, maxCount == 0 ? 0 : entry.Count * 100.0 / maxCount, Colors.Blue.Medium));
}
});
if (data.TaskAnalysis.Count > 0)
{
column.Item().Text("Aufgabenanalyse").FontSize(12).SemiBold();
column.Item().Table(table =>
{
table.ColumnsDefinition(columns =>
{
columns.RelativeColumn(2);
columns.ConstantColumn(80);
columns.RelativeColumn();
});
table.Header(header =>
{
header.Cell().Element(HeaderCell).Text("Aufgabe").SemiBold();
header.Cell().Element(HeaderCell).AlignRight().Text("Ø Erfüllung").SemiBold();
header.Cell().Element(HeaderCell);
});
foreach (var task in data.TaskAnalysis)
{
table.Cell().Element(BodyCell).Text(text =>
{
text.Span(task.Label);
if (task.IsWeak) text.Span(" auffällig schwach")
.FontColor(Colors.Red.Darken1).FontSize(8);
});
table.Cell().Element(BodyCell).AlignRight().Text($"{task.AvgPercent:0.#} %");
table.Cell().Element(BodyCell).AlignMiddle().Element(bar =>
Bar(bar, task.AvgPercent, task.IsWeak ? Colors.Red.Medium : Colors.Green.Medium));
}
});
}
});
page.Footer().Element(Footer);
})).GeneratePdf();
public byte[] BuildCompetencyReportPdf(CompetencyReportPrintData data) =>
Document.Create(container => container.Page(page =>
{
page.Size(PageSizes.A4.Landscape());
page.Margin(36);
page.DefaultTextStyle(style => style.FontSize(8));
page.Header().Element(header => Header(header,
$"Kompetenzbericht · {data.StudentName}", $"{data.GroupLabel} · {data.Summary}"));
page.Content().PaddingVertical(10).Column(column =>
{
column.Spacing(8);
column.Item().Text(data.ThresholdLabel).FontSize(8).FontColor(Colors.Grey.Darken1);
column.Item().Table(table =>
{
table.ColumnsDefinition(columns =>
{
columns.RelativeColumn(1.4f); // Bereich
columns.RelativeColumn(0.5f); // Code
columns.RelativeColumn(2.3f); // Beschreibung
columns.RelativeColumn(1.1f); // Unterricht
columns.RelativeColumn(1.0f); // Klausuren
columns.RelativeColumn(1.4f); // Gruppe
columns.RelativeColumn(1.1f); // Schüler
// "Wiederholungsbedarf" (längster Wert) muss ohne Wortumbruch passen.
columns.ConstantColumn(88); // Empfehlung
});
table.Header(header =>
{
header.Cell().Element(HeaderCell).Text("Bereich").SemiBold();
header.Cell().Element(HeaderCell).Text("Code").SemiBold();
header.Cell().Element(HeaderCell).Text("Kompetenz").SemiBold();
header.Cell().Element(HeaderCell).Text("Unterricht").SemiBold();
header.Cell().Element(HeaderCell).Text("Klausuren").SemiBold();
header.Cell().Element(HeaderCell).Text("Gruppenergebnis").SemiBold();
header.Cell().Element(HeaderCell).Text("Schüler").SemiBold();
header.Cell().Element(HeaderCell).Text("Empfehlung").SemiBold();
});
foreach (var row in data.Rows)
{
table.Cell().Element(BodyCell).Text(row.Domain);
table.Cell().Element(BodyCell).Text(row.Code);
table.Cell().Element(BodyCell).Text(row.Description);
table.Cell().Element(BodyCell).Text(row.InstructionCoverage);
table.Cell().Element(BodyCell).Text(row.ExamCoverage);
table.Cell().Element(BodyCell).Text(row.GroupResult);
table.Cell().Element(BodyCell).Text(row.StudentResult).SemiBold();
table.Cell().Element(BodyCell).Text(row.Recommendation)
.FontColor(row.Recommendation == "Wiederholungsbedarf"
? Colors.Red.Darken1
: row.Recommendation == "Stand solide"
? Colors.Green.Darken1
: Colors.Grey.Darken1);
}
});
});
page.Footer().Element(Footer);
})).GeneratePdf();
public byte[] BuildStudentDocumentationPdf(StudentDocumentationPrintData data) =>
Document.Create(container => container.Page(page =>
{
page.Size(PageSizes.A4);
page.Margin(36);
page.DefaultTextStyle(style => style.FontSize(9));
page.Header().Element(header => Header(header,
$"Dokumentation · {data.StudentName}",
data.Entries.Count == 1 ? "1 Eintrag" : $"{data.Entries.Count} Einträge"));
page.Content().PaddingVertical(10).Column(column =>
{
column.Spacing(10);
foreach (var entry in data.Entries)
column.Item().Border(0.8f).BorderColor(Colors.Grey.Lighten2).Padding(8)
.Column(block =>
{
block.Spacing(3);
block.Item().Row(row =>
{
row.RelativeItem().Text(text =>
{
text.Span(entry.Title).FontSize(10).SemiBold();
if (entry.IsConfidential)
text.Span(" vertraulich").FontSize(8).FontColor(Colors.Red.Darken1);
if (entry.IsDraft)
text.Span(" Entwurf").FontSize(8).FontColor(Colors.Orange.Darken2);
});
row.ConstantItem(130).AlignRight()
.Text($"{entry.TypeLabel} · {entry.DateDisplay}")
.FontSize(8).FontColor(Colors.Grey.Darken1);
});
if (!string.IsNullOrWhiteSpace(entry.Content))
block.Item().Text(entry.Content);
if (entry.Tags.Count > 0)
block.Item().Text(string.Join(" · ", entry.Tags))
.FontSize(8).FontColor(Colors.Grey.Darken1);
});
});
page.Footer().Element(Footer);
})).GeneratePdf();
// ── Bausteine ───────────────────────────────────────────────────────────────
/// <summary>Horizontaler Balken, 0-100 % der verfügbaren Zellenbreite (max. 150pt).</summary>
private static void Bar(IContainer container, double percent, string color) =>
container.MaxWidth(150).Height(7).Background(Colors.Grey.Lighten3)
.AlignLeft().Width((float)(Math.Clamp(percent, 0, 100) * 1.5)).Background(color);
private static void Header(IContainer container, string title, string subtitle) =>
container.BorderBottom(1).BorderColor(Colors.Grey.Lighten2).PaddingBottom(6).Row(row =>
{
row.RelativeItem().Column(column =>
{
column.Item().Text(title).FontSize(16).SemiBold();
if (!string.IsNullOrWhiteSpace(subtitle))
column.Item().Text(subtitle).FontSize(10).FontColor(Colors.Grey.Darken1);
});
row.ConstantItem(110).AlignRight().AlignBottom()
.Text($"Stand: {DateTime.Now:dd.MM.yyyy}").FontSize(9).FontColor(Colors.Grey.Darken1);
});
private static void Footer(IContainer container) =>
container.AlignCenter().Text(text =>
{
text.DefaultTextStyle(style => style.FontSize(8).FontColor(Colors.Grey.Darken1));
text.Span("Seite ");
text.CurrentPageNumber();
text.Span(" von ");
text.TotalPages();
});
private static void BoardBanner(IContainer container) =>
container.AlignCenter().Background(Colors.Grey.Lighten3).PaddingVertical(4)
.PaddingHorizontal(40).Text("Tafel / Vorderseite").FontSize(9).SemiBold()
.FontColor(Colors.Grey.Darken2);
private static void SeatGrid(IContainer container, SeatingPlanPrintData data) =>
container.Table(table =>
{
// Tischabstände als eigene, schmale Leerspalten zwischen den Sitzspalten - dieselbe
// Idee wie SeatingPlanPanel, nur mit Tabellenspalten statt Panel-Arithmetik. Merkt
// sich je Sitzspalte den 1-basierten Tabellenspalten-Index.
var tableColumnOfSeatColumn = new int[Math.Max(1, data.Columns)];
table.ColumnsDefinition(columns =>
{
var next = 1;
for (var c = 0; c < data.Columns; c++)
{
columns.RelativeColumn();
tableColumnOfSeatColumn[c] = next++;
var gap = c < data.ColumnGapWidths.Count ? data.ColumnGapWidths[c] : 0;
if (c < data.Columns - 1 && gap > 0)
{
columns.ConstantColumn((float)gap * GapPixelToPoint);
next++;
}
}
});
foreach (var seat in data.Seats)
{
var cell = table.Cell()
.Row((uint)(seat.Row + 1))
.Column((uint)tableColumnOfSeatColumn[seat.Column])
.Padding(3);
// Ausgeblendete Plätze (kein Tisch im Raum) bleiben als leere Rasterposition
// erhalten, damit die übrigen Plätze der Reihe nicht verrutschen.
if (seat.IsHidden) { cell.MinHeight(38); continue; }
var box = cell.MinHeight(38).Border(0.8f)
.BorderColor(seat.StudentName is null ? Colors.Grey.Lighten2 : Colors.Grey.Darken1)
.Padding(4).AlignCenter().AlignMiddle();
if (seat.StudentName is null)
box.Text("frei").FontSize(8).FontColor(Colors.Grey.Lighten1);
else
box.Text(seat.StudentName).FontSize(9).SemiBold();
}
});
private static IContainer HeaderCell(IContainer container) => container
.BorderBottom(1).BorderColor(Colors.Grey.Darken1).PaddingVertical(3).PaddingHorizontal(2);
private static IContainer BodyCell(IContainer container) => container
.BorderBottom(0.5f).BorderColor(Colors.Grey.Lighten2).PaddingVertical(2.5f).PaddingHorizontal(2);
}
@@ -22,6 +22,8 @@ public partial class CompetencyOverviewTabViewModel : ObservableObject
[ObservableProperty] private string _summary = "";
[ObservableProperty] private string _emptyMessage = "";
public string GroupLabel => _group?.Name ?? "";
public ObservableCollection<CompetencyStudentOption> StudentOptions { get; } = [];
public ObservableCollection<CompetencyOverviewRow> Rows { get; } = [];
public bool HasRows => Rows.Count > 0;
@@ -10,7 +10,10 @@
<TextBlock Text="Kompetenzübersicht" FontSize="18" FontWeight="SemiBold"/>
<TextBlock Text="{Binding Summary}" FontSize="12" Opacity="0.65"/>
</StackPanel>
<Button Grid.Column="1" Content="Aktualisieren" Command="{Binding RefreshCommand}"/>
<StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="8" VerticalAlignment="Top">
<Button Content="Als PDF" Click="OnExportPdfClick" IsEnabled="{Binding HasRows}"/>
<Button Content="Aktualisieren" Command="{Binding RefreshCommand}"/>
</StackPanel>
</Grid>
<Grid ColumnDefinitions="*,16,210" IsVisible="{Binding HasRows}">
@@ -1,8 +1,30 @@
using Avalonia.Controls;
using Avalonia.Interactivity;
using LehrerApp.Desktop.Services;
using LehrerApp.Desktop.ViewModels.Groups;
using Microsoft.Extensions.DependencyInjection;
namespace LehrerApp.Desktop.Views.Groups;
public partial class CompetencyOverviewTabView : UserControl
{
public CompetencyOverviewTabView() => InitializeComponent();
private async void OnExportPdfClick(object? sender, RoutedEventArgs e)
{
var topLevel = TopLevel.GetTopLevel(this);
if (topLevel is null || DataContext is not CompetencyOverviewTabViewModel { HasRows: true } vm) return;
var studentName = vm.SelectedStudent?.DisplayName ?? "Kein Schüler ausgewählt";
var data = new CompetencyReportPrintData(vm.GroupLabel, studentName, vm.Summary,
vm.ThresholdLabel,
vm.Rows.Select(r => new CompetencyReportPrintRow(r.Domain, r.Code, r.Description,
r.InstructionCoverage, r.ExamCoverage, r.GroupResult, r.StudentResult,
r.Recommendation)).ToList());
var pdf = App.Services.GetRequiredService<PdfExportService>().BuildCompetencyReportPdf(data);
await App.Services.GetRequiredService<ExportService>().SaveAsync(topLevel.StorageProvider,
ExportFile.Pdf("Kompetenzbericht als PDF speichern",
$"Kompetenzbericht_{vm.GroupLabel}_{studentName}", pdf));
}
}
@@ -147,9 +147,10 @@
</StackPanel>
</ScrollViewer>
<Grid Grid.Row="1" ColumnDefinitions="Auto,*,Auto" Margin="0,16,0,0">
<Grid Grid.Row="1" ColumnDefinitions="Auto,8,Auto,*,Auto" Margin="0,16,0,0">
<Button Grid.Column="0" Content="Als CSV exportieren" Click="OnExportClick"/>
<Button Grid.Column="2" Content="Fertig" Click="OnClose"/>
<Button Grid.Column="2" Content="Als PDF" Click="OnExportPdfClick"/>
<Button Grid.Column="4" Content="Fertig" Click="OnClose"/>
</Grid>
</Grid>
</Window>
@@ -21,5 +21,33 @@ public partial class ExamEvaluationDialog : Window
await App.Services.GetRequiredService<ExportService>().SaveAsync(topLevel.StorageProvider, export);
}
private async void OnExportPdfClick(object? sender, RoutedEventArgs e)
{
if (DataContext is not ExamEvaluationDialogViewModel vm) return;
var topLevel = TopLevel.GetTopLevel(this);
if (topLevel is null) return;
var stats = new List<LabelValue>
{
new("Bewertet", vm.GradedCount.ToString()),
new("Abwesend", vm.AbsentCount.ToString()),
new("Durchschnitt", vm.AverageDisplay),
new("Median", vm.MedianDisplay),
};
if (!string.IsNullOrEmpty(vm.BelowThresholdLabel1))
stats.Add(new(vm.BelowThresholdLabel1, vm.BelowThresholdDisplay1));
if (!string.IsNullOrEmpty(vm.BelowThresholdLabel2))
stats.Add(new(vm.BelowThresholdLabel2, vm.BelowThresholdDisplay2));
var data = new ExamStatisticsPrintData(vm.ExamTitle, vm.ExamDateLabel, vm.NiveauLabel, stats,
vm.GradeDistribution.Select(g => new GradeDistributionPrintRow(g.Grade, g.Count,
vm.GradedCount == 0 ? 0 : g.Count * 100.0 / vm.GradedCount)).ToList(),
vm.TaskAnalysis.Select(t => new TaskAnalysisPrintRow(t.Label, t.AvgPercent, t.IsWeak)).ToList());
var pdf = App.Services.GetRequiredService<PdfExportService>().BuildExamStatisticsPdf(data);
await App.Services.GetRequiredService<ExportService>().SaveAsync(topLevel.StorageProvider,
ExportFile.Pdf("Klausurauswertung als PDF speichern", $"Auswertung_{vm.ExamTitle}", pdf));
}
private void OnClose(object? sender, RoutedEventArgs e) => Close();
}
@@ -21,6 +21,8 @@
IsEnabled="{Binding !IsReadOnly}"/>
<Button Content=" Sammelnote" Command="{Binding CollectiveGradeCommand}" IsEnabled="{Binding !IsReadOnly}"/>
<Button Content="Zeugnisnoten" Command="{Binding ReportGradesCommand}" IsEnabled="{Binding !IsReadOnly}"/>
<Button Content="Als PDF" Click="OnExportPdfClick" Margin="12,0,0,0"
IsEnabled="{Binding !!Rows.Count}"/>
</StackPanel>
<DataGrid Grid.Row="1"
@@ -1,7 +1,9 @@
using Avalonia.Controls;
using Avalonia.Data;
using Avalonia.Interactivity;
using LehrerApp.Core.Interfaces;
using LehrerApp.Core.Services;
using LehrerApp.Desktop.Services;
using LehrerApp.Desktop.ViewModels.Groups;
using Microsoft.Extensions.DependencyInjection;
@@ -77,6 +79,22 @@ public partial class GradeOverviewTabView : UserControl
if (owner is not null) await dialog.ShowDialog(owner);
}
private async void OnExportPdfClick(object? sender, RoutedEventArgs e)
{
var topLevel = TopLevel.GetTopLevel(this);
if (topLevel is null || _vm is null) return;
var data = new GradeListPrintData(
_vm.GroupLabel, _vm.SchoolYear, _vm.SelectedPeriod.Label,
_vm.Columns.Select(c => c.Header).ToList(),
_vm.Rows.Select(r => new GradeListPrintRow(r.Name, r.Cells, r.TotalDisplay)).ToList());
var pdf = App.Services.GetRequiredService<PdfExportService>().BuildGradeListPdf(data);
await App.Services.GetRequiredService<ExportService>().SaveAsync(topLevel.StorageProvider,
ExportFile.Pdf("Notenliste als PDF speichern",
$"Notenliste_{_vm.GroupLabel}_{_vm.SchoolYear.Replace('/', '-')}", pdf));
}
private void BuildColumns()
{
var grid = this.FindControl<DataGrid>("GradesGrid");
@@ -79,8 +79,11 @@
</StackPanel>
</StackPanel>
<StackPanel Grid.Column="1" VerticalAlignment="Bottom" Spacing="4">
<ToggleSwitch Content="Bearbeitungsmodus" IsChecked="{Binding IsEditMode}"
IsVisible="{Binding IsEditable}" HorizontalAlignment="Right"/>
<StackPanel Orientation="Horizontal" Spacing="10" 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="Ziehen: Platz ändern · Klicken: bewerten" HorizontalAlignment="Right"
FontSize="11" Opacity="0.5" IsVisible="{Binding IsEditMode}"/>
@@ -5,8 +5,10 @@ using Avalonia.Interactivity;
using Avalonia.Threading;
using Avalonia.VisualTree;
using LehrerApp.Core.Models;
using LehrerApp.Desktop.Services;
using LehrerApp.Desktop.ViewModels.Groups;
using LehrerApp.Desktop.Views.Shared;
using Microsoft.Extensions.DependencyInjection;
namespace LehrerApp.Desktop.Views.Groups;
@@ -205,6 +207,21 @@ public partial class SeatingPlanTabView : UserControl
await vm.AssessStudent(seat);
}
private async void OnExportPdfClick(object? sender, RoutedEventArgs e)
{
var topLevel = TopLevel.GetTopLevel(this);
if (topLevel is null || DataContext is not SeatingPlanTabViewModel { HasSelectedPlan: true } vm) return;
var data = new SeatingPlanPrintData(
vm.PlanTitle, vm.PlanSubtitle, vm.PlanColumns, vm.IsBoardAtBottom, vm.ColumnGapWidths,
vm.Seats.Select(s => new SeatPrintCell(s.Row, s.Column,
s.IsOccupied ? s.SelectedOption.DisplayName : null, s.IsHidden)).ToList());
var pdf = App.Services.GetRequiredService<PdfExportService>().BuildSeatingPlanPdf(data);
await App.Services.GetRequiredService<ExportService>().SaveAsync(topLevel.StorageProvider,
ExportFile.Pdf("Sitzplan als PDF speichern", $"Sitzplan_{vm.PlanTitle}", pdf));
}
private async Task ShowAssessmentDialog(SeatAssessmentViewModel vm)
{
var dialog = new SeatAssessmentDialog { DataContext = vm };
@@ -245,12 +245,16 @@
</StackPanel>
</Border>
<Grid ColumnDefinitions="*,Auto,Auto">
<Grid ColumnDefinitions="*,Auto,Auto,Auto">
<TextBlock Grid.Column="0" Text="Einträge" FontSize="14" FontWeight="SemiBold" VerticalAlignment="Center"/>
<Button Grid.Column="1" Content="Datenauskunft exportieren" FontSize="12" Padding="10,4"
<Button Grid.Column="1" Content="Als PDF" FontSize="12" Padding="10,4"
Margin="0,0,8,0" Click="OnExportDocumentationPdfClick"
IsEnabled="{Binding !!Documentation.Count}"
ToolTip.Tip="Alle sichtbaren Dokumentationseinträge dieses Schülers als PDF drucken"/>
<Button Grid.Column="2" Content="Datenauskunft exportieren" FontSize="12" Padding="10,4"
Margin="0,0,8,0" Command="{Binding ExportPersonalDataCommand}"
ToolTip.Tip="Alle gespeicherten Daten dieses Schülers als JSON-Datei exportieren (Art. 15 DSGVO)"/>
<Button Grid.Column="2" Content=" Eintrag" Command="{Binding AddDocumentationCommand}"/>
<Button Grid.Column="3" Content=" Eintrag" Command="{Binding AddDocumentationCommand}"/>
</Grid>
<TextBlock Text="{Binding ExportStatus}" Foreground="Green" FontSize="12"
IsVisible="{Binding ExportStatus, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
@@ -1,8 +1,10 @@
using Avalonia.Controls;
using Avalonia.Input;
using Avalonia.Interactivity;
using Avalonia.Platform.Storage;
using LehrerApp.Core.Interfaces;
using LehrerApp.Core.Models;
using LehrerApp.Desktop.Services;
using LehrerApp.Desktop.ViewModels.Students;
using LehrerApp.Desktop.Views.Shared;
using Microsoft.Extensions.DependencyInjection;
@@ -119,6 +121,23 @@ public partial class StudentDetailView : UserControl
return owner is not null && await dialog.ShowDialog<bool>(owner);
}
private async void OnExportDocumentationPdfClick(object? sender, RoutedEventArgs e)
{
var topLevel = TopLevel.GetTopLevel(this);
if (topLevel is null || DataContext is not StudentDetailViewModel { Student: not null } vm
|| vm.Documentation.Count == 0) return;
var data = new StudentDocumentationPrintData(vm.Student.FullName,
vm.Documentation.Select(d => new DocumentationPrintEntry(
d.DateDisplay, d.TypeLabel, d.Model.Title, d.Model.Content,
d.Model.Tags, d.IsConfidential, d.IsDraft)).ToList());
var pdf = App.Services.GetRequiredService<PdfExportService>().BuildStudentDocumentationPdf(data);
await App.Services.GetRequiredService<ExportService>().SaveAsync(topLevel.StorageProvider,
ExportFile.Pdf("Dokumentation als PDF speichern",
$"Dokumentation_{vm.Student.LastName}_{vm.Student.FirstName}", pdf));
}
private async Task SaveExportFile(string json)
{
var topLevel = TopLevel.GetTopLevel(this);