Add shared export infrastructure

This commit is contained in:
2026-08-18 21:21:56 +02:00
parent 41bf2c0756
commit 26461bd2f2
13 changed files with 336 additions and 63 deletions
@@ -0,0 +1,32 @@
using LehrerApp.Desktop.Services;
using System.Text;
using Xunit;
namespace LehrerApp.Desktop.Tests;
public sealed class CsvBuilderTests
{
[Fact]
public void AddRow_MaskiertTrennzeichen_AnfuehrungszeichenUndZeilenumbrueche()
{
var csv = new CsvBuilder()
.AddRow("Normal", "A;B", "Zitat \"wichtig\"", "Zeile 1\nZeile 2")
.ToString();
Assert.Contains("Normal;\"A;B\"", csv);
Assert.Contains("\"Zitat \"\"wichtig\"\"\"", csv);
Assert.Contains("\"Zeile 1\nZeile 2\"", csv);
}
[Fact]
public void CsvExportfile_BereinigtDateinameUndErgaenztEndungUndUtf8Bom()
{
var export = ExportFile.Csv("Export", "Auswertung/Chemie", "Änderung");
var bytes = export.Content.ToArray();
var preamble = new UTF8Encoding(encoderShouldEmitUTF8Identifier: true).GetPreamble();
Assert.Equal("Auswertung_Chemie.csv", export.SuggestedFileName);
Assert.Equal(preamble, bytes[..preamble.Length]);
Assert.Equal("Änderung", Encoding.UTF8.GetString(bytes[preamble.Length..]));
}
}
@@ -597,4 +597,53 @@ public sealed class WorkloadEvaluationViewModelTests
Assert.Contains("Soll:", vm.RequiredVsActualDisplay); Assert.Contains("Soll:", vm.RequiredVsActualDisplay);
Assert.Contains("Ist: 1 h", vm.RequiredVsActualDisplay); Assert.Contains("Ist: 1 h", vm.RequiredVsActualDisplay);
} }
[Fact]
public void ExportCsv_EnthaeltZeitraumSummenUndKorrektMaskierteAufschluesselung()
{
var group = new LearningGroup { Name = "8a \"Chemie\"" };
var entries = new FakeTimeEntries();
entries.Add(new TimeEntry
{
Category = "Korrektur; schriftlich",
GroupId = group.Id,
Date = new DateOnly(2026, 3, 5),
DurationMinutes = 90,
});
var vm = new WorkloadEvaluationViewModel(entries, new FakeGroups([group]),
BuildSettingsService(), new SchoolYearService())
{
SelectedYear = 2026,
};
vm.SelectedMonth = vm.MonthOptions[2];
var csv = vm.ExportCsv();
Assert.Contains("Zeitraum;01.03.2026;31.03.2026", csv);
Assert.Contains("Gesamtzeit (Minuten);90", csv);
Assert.Contains("\"Korrektur; schriftlich\";90;1,5", csv);
Assert.Contains("\"8a \"\"Chemie\"\"\";90;1,5", csv);
Assert.Equal("Arbeitszeitauswertung_2026-03.csv", vm.ExportSuggestedFileName);
}
[Fact]
public void ExportCsv_MitPflichtstunden_EnthaeltSollzeitUndAbweichung()
{
var entries = new FakeTimeEntries();
entries.Add(new TimeEntry { Category = "Unterricht", Date = new DateOnly(2026, 3, 2), DurationMinutes = 60 });
var vm = new WorkloadEvaluationViewModel(entries, new FakeGroups([]),
BuildSettingsService(), new SchoolYearService())
{
SelectedYear = 2026,
RequiredWeeklyHoursText = "10",
};
vm.SelectedMonth = vm.MonthOptions[2];
vm.SaveRequiredWeeklyHoursCommand.Execute(null);
var csv = vm.ExportCsv();
Assert.Contains("Pflichtstunden pro Woche;10", csv);
Assert.Contains("Sollzeit (Stunden);", csv);
Assert.Contains("Abweichung (Stunden);", csv);
}
} }
+1
View File
@@ -110,6 +110,7 @@ public static class AppBootstrapper
EnsureLogger(); EnsureLogger();
services.AddSingleton(Logger); services.AddSingleton(Logger);
services.AddSingleton<NotificationService>(); services.AddSingleton<NotificationService>();
services.AddSingleton<ExportService>();
// ── 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
+45
View File
@@ -0,0 +1,45 @@
using System.Text;
namespace LehrerApp.Desktop.Services;
/// <summary>
/// Erzeugt semikolongetrennte CSV-Daten für die deutsche Excel-Umgebung und maskiert Felder
/// nach RFC-4180-Regeln. Das Trennzeichen bleibt bewusst konfigurierbar für weitere Exportformate.
/// </summary>
public sealed class CsvBuilder(char separator = ';')
{
private readonly StringBuilder _content = new();
public CsvBuilder AddRow(params object?[] values)
{
for (var index = 0; index < values.Length; index++)
{
if (index > 0) _content.Append(separator);
AppendEscaped(values[index]?.ToString() ?? "");
}
_content.AppendLine();
return this;
}
public CsvBuilder AddBlankRow()
{
_content.AppendLine();
return this;
}
public override string ToString() => _content.ToString();
private void AppendEscaped(string value)
{
if (!value.Contains(separator) && !value.Contains('"') &&
!value.Contains('\r') && !value.Contains('\n'))
{
_content.Append(value);
return;
}
_content.Append('"');
_content.Append(value.Replace("\"", "\"\"", StringComparison.Ordinal));
_content.Append('"');
}
}
@@ -0,0 +1,75 @@
using Avalonia.Platform.Storage;
using System.Text;
namespace LehrerApp.Desktop.Services;
public sealed record ExportFormat(string Label, string Extension, string MimeType)
{
public static ExportFormat Csv { get; } = new("CSV-Dateien", ".csv", "text/csv");
public static ExportFormat Json { get; } = new("JSON-Dateien", ".json", "application/json");
}
public sealed record ExportFile(
string DialogTitle,
string SuggestedFileName,
ExportFormat Format,
ReadOnlyMemory<byte> Content)
{
public static ExportFile Csv(string dialogTitle, string suggestedFileName, string content) =>
FromText(dialogTitle, suggestedFileName, ExportFormat.Csv, content, includeUtf8Bom: true);
public static ExportFile Json(string dialogTitle, string suggestedFileName, string content) =>
FromText(dialogTitle, suggestedFileName, ExportFormat.Json, content);
private static ExportFile FromText(string dialogTitle, string suggestedFileName,
ExportFormat format, string content, bool includeUtf8Bom = false)
{
var encoding = new UTF8Encoding(encoderShouldEmitUTF8Identifier: includeUtf8Bom);
var preamble = encoding.GetPreamble();
var text = encoding.GetBytes(content);
var bytes = new byte[preamble.Length + text.Length];
preamble.CopyTo(bytes, 0);
text.CopyTo(bytes, preamble.Length);
return new ExportFile(dialogTitle,
EnsureExtension(SanitizeFileName(suggestedFileName), format.Extension), format, bytes);
}
private static string EnsureExtension(string fileName, string extension) =>
fileName.EndsWith(extension, StringComparison.OrdinalIgnoreCase) ? fileName : fileName + extension;
private static string SanitizeFileName(string fileName)
{
var invalidCharacters = Path.GetInvalidFileNameChars();
return string.Concat(fileName.Select(character =>
invalidCharacters.Contains(character) ? '_' : character));
}
}
/// <summary>Zentrale Dateiauswahl und Stream-Ausgabe für alle Exportformate.</summary>
public sealed class ExportService
{
public async Task<bool> SaveAsync(IStorageProvider storageProvider, ExportFile export)
{
var file = await storageProvider.SaveFilePickerAsync(new FilePickerSaveOptions
{
Title = export.DialogTitle,
SuggestedFileName = export.SuggestedFileName,
DefaultExtension = export.Format.Extension.TrimStart('.'),
FileTypeChoices =
[
new FilePickerFileType(export.Format.Label)
{
Patterns = [$"*{export.Format.Extension}"],
MimeTypes = [export.Format.MimeType],
},
],
});
if (file is null) return false;
await using var stream = await file.OpenWriteAsync();
if (stream.CanSeek) stream.SetLength(0);
await stream.WriteAsync(export.Content);
return true;
}
}
@@ -3,9 +3,9 @@ using CommunityToolkit.Mvvm.Input;
using LehrerApp.Core.Interfaces; using LehrerApp.Core.Interfaces;
using LehrerApp.Core.Models; using LehrerApp.Core.Models;
using LehrerApp.Core.Services; using LehrerApp.Core.Services;
using LehrerApp.Desktop.Services;
using System.Collections.ObjectModel; using System.Collections.ObjectModel;
using System.Globalization; using System.Globalization;
using System.Text;
namespace LehrerApp.Desktop.ViewModels.Groups; namespace LehrerApp.Desktop.ViewModels.Groups;
@@ -195,27 +195,27 @@ public partial class ExamEvaluationDialogViewModel : ObservableObject
public string ExportCsv() public string ExportCsv()
{ {
var sb = new StringBuilder(); var csv = new CsvBuilder()
sb.AppendLine($"Klausur;{_exam.Title}"); .AddRow("Klausur", _exam.Title)
sb.AppendLine($"Datum;{_exam.Date:dd.MM.yyyy}"); .AddRow("Datum", _exam.Date.ToString("dd.MM.yyyy", CultureInfo.InvariantCulture))
sb.AppendLine(); .AddBlankRow()
sb.AppendLine("Notenspiegel"); .AddRow("Notenspiegel")
sb.AppendLine("Note;Anzahl;Anteil"); .AddRow("Note", "Anzahl", "Anteil");
foreach (var g in GradeDistribution) foreach (var g in GradeDistribution)
sb.AppendLine($"{g.Grade};{g.Count};{Percent(g.Count, GradedCount)}"); csv.AddRow(g.Grade, g.Count, Percent(g.Count, GradedCount));
sb.AppendLine(); csv.AddBlankRow()
sb.AppendLine($"Durchschnitt;{AverageDisplay}"); .AddRow("Durchschnitt", AverageDisplay)
sb.AppendLine($"Median;{MedianDisplay}"); .AddRow("Median", MedianDisplay);
if (!string.IsNullOrEmpty(BelowThresholdLabel1)) sb.AppendLine($"{BelowThresholdLabel1};{BelowThresholdDisplay1}"); if (!string.IsNullOrEmpty(BelowThresholdLabel1)) csv.AddRow(BelowThresholdLabel1, BelowThresholdDisplay1);
if (!string.IsNullOrEmpty(BelowThresholdLabel2)) sb.AppendLine($"{BelowThresholdLabel2};{BelowThresholdDisplay2}"); if (!string.IsNullOrEmpty(BelowThresholdLabel2)) csv.AddRow(BelowThresholdLabel2, BelowThresholdDisplay2);
sb.AppendLine($"Bewertet;{GradedCount}"); csv.AddRow("Bewertet", GradedCount)
sb.AppendLine($"Abwesend;{AbsentCount}"); .AddRow("Abwesend", AbsentCount)
sb.AppendLine(); .AddBlankRow()
sb.AppendLine("Aufgabenanalyse"); .AddRow("Aufgabenanalyse")
sb.AppendLine("Aufgabe;Ø Erfüllungsgrad;Auffällig schwach"); .AddRow("Aufgabe", "Ø Erfüllungsgrad", "Auffällig schwach");
foreach (var t in TaskAnalysis) foreach (var t in TaskAnalysis)
sb.AppendLine($"{t.Label};{t.AvgPercentDisplay};{(t.IsWeak ? "ja" : "")}"); csv.AddRow(t.Label, t.AvgPercentDisplay, t.IsWeak ? "ja" : "");
return sb.ToString(); return csv.ToString();
} }
} }
@@ -3,9 +3,9 @@ using CommunityToolkit.Mvvm.Input;
using LehrerApp.Core.Interfaces; using LehrerApp.Core.Interfaces;
using LehrerApp.Core.Models; using LehrerApp.Core.Models;
using LehrerApp.Core.Services; using LehrerApp.Core.Services;
using LehrerApp.Desktop.Services;
using System.Collections.ObjectModel; using System.Collections.ObjectModel;
using System.Globalization; using System.Globalization;
using System.Text;
namespace LehrerApp.Desktop.ViewModels.Groups; namespace LehrerApp.Desktop.ViewModels.Groups;
@@ -197,12 +197,13 @@ public partial class ReportGradeDialogViewModel : ObservableObject
public string ExportCsv() public string ExportCsv()
{ {
var sb = new StringBuilder(); var csv = new CsvBuilder()
sb.AppendLine($"Zeugnisnoten;{_groupLabel};{SelectedPeriod.Label}"); .AddRow("Zeugnisnoten", _groupLabel, SelectedPeriod.Label)
sb.AppendLine("Schüler;Berechnet;Übersteuert;Begründung;Endnote;Gesperrt"); .AddRow("Schüler", "Berechnet", "Übersteuert", "Begründung", "Endnote", "Gesperrt");
foreach (var r in Rows) foreach (var r in Rows)
sb.AppendLine($"{r.Name};{r.CalculatedValue};{r.OverrideValue};{r.OverrideReason};{r.FinalDisplay};{(r.IsLocked ? "ja" : "")}"); csv.AddRow(r.Name, r.CalculatedValue, r.OverrideValue, r.OverrideReason,
return sb.ToString(); r.FinalDisplay, r.IsLocked ? "ja" : "");
return csv.ToString();
} }
} }
@@ -3,6 +3,7 @@ using CommunityToolkit.Mvvm.Input;
using LehrerApp.Core.Interfaces; using LehrerApp.Core.Interfaces;
using LehrerApp.Core.Models; using LehrerApp.Core.Models;
using LehrerApp.Core.Services; using LehrerApp.Core.Services;
using LehrerApp.Desktop.Services;
using System.Collections.ObjectModel; using System.Collections.ObjectModel;
using System.Globalization; using System.Globalization;
@@ -519,6 +520,7 @@ public class TimeEntryListItem(TimeEntry model, string? taskTitle)
public class CategoryTimeSummary(string category, int minutes, double barFraction) public class CategoryTimeSummary(string category, int minutes, double barFraction)
{ {
public string Category { get; } = category; public string Category { get; } = category;
public int Minutes { get; } = minutes;
public string MinutesDisplay { get; } = $"{minutes} min"; public string MinutesDisplay { get; } = $"{minutes} min";
public double BarFraction { get; } = barFraction; public double BarFraction { get; } = barFraction;
} }
@@ -619,6 +621,20 @@ public partial class WorkloadEvaluationViewModel : ObservableObject
public ObservableCollection<GroupTimeSummary> GroupSummaries { get; } = []; public ObservableCollection<GroupTimeSummary> GroupSummaries { get; } = [];
public string TotalMinutesDisplay { get; private set; } = "0 min"; public string TotalMinutesDisplay { get; private set; } = "0 min";
public string RequiredVsActualDisplay { get; private set; } = ""; public string RequiredVsActualDisplay { get; private set; } = "";
public string ExportSuggestedFileName
{
get
{
if (PeriodMode == SchoolYearMode)
return $"Arbeitszeitauswertung_Schuljahr_{SelectedYear}-{SelectedYear + 1}.csv";
var month = MonthOptions.IndexOf(SelectedMonth) + 1;
return $"Arbeitszeitauswertung_{SelectedYear}-{month:00}.csv";
}
}
private DateOnly _periodFrom;
private DateOnly _periodTo;
private int _totalMinutes;
public WorkloadEvaluationViewModel(ITimeEntryRepository entries, IGroupRepository groups, public WorkloadEvaluationViewModel(ITimeEntryRepository entries, IGroupRepository groups,
WorkloadSettingsService workloadSettings, SchoolYearService schoolYear) WorkloadSettingsService workloadSettings, SchoolYearService schoolYear)
@@ -671,6 +687,8 @@ public partial class WorkloadEvaluationViewModel : ObservableObject
private void Refresh() private void Refresh()
{ {
var (from, to) = CurrentPeriod(); var (from, to) = CurrentPeriod();
_periodFrom = from;
_periodTo = to;
var periodEntries = _entries.GetByDateRange(from, to); var periodEntries = _entries.GetByDateRange(from, to);
var groupNames = _groups.GetAll(includeInactive: true).ToDictionary(g => g.Id, g => g.Name); var groupNames = _groups.GetAll(includeInactive: true).ToDictionary(g => g.Id, g => g.Name);
@@ -698,6 +716,7 @@ public partial class WorkloadEvaluationViewModel : ObservableObject
} }
var totalMinutes = periodEntries.Sum(e => e.DurationMinutes); var totalMinutes = periodEntries.Sum(e => e.DurationMinutes);
_totalMinutes = totalMinutes;
TotalMinutesDisplay = $"{totalMinutes} min ({(totalMinutes / 60.0).ToString("0.#", CultureInfo.InvariantCulture)} h)"; TotalMinutesDisplay = $"{totalMinutes} min ({(totalMinutes / 60.0).ToString("0.#", CultureInfo.InvariantCulture)} h)";
// Pflichtstunden-Abgleich (6.3.2): auf die Anzahl Wochen im Zeitraum hochgerechnet. // Pflichtstunden-Abgleich (6.3.2): auf die Anzahl Wochen im Zeitraum hochgerechnet.
@@ -721,12 +740,52 @@ public partial class WorkloadEvaluationViewModel : ObservableObject
OnPropertyChanged(nameof(TotalMinutesDisplay)); OnPropertyChanged(nameof(TotalMinutesDisplay));
OnPropertyChanged(nameof(RequiredVsActualDisplay)); OnPropertyChanged(nameof(RequiredVsActualDisplay));
OnPropertyChanged(nameof(ExportSuggestedFileName));
}
public string ExportCsv()
{
var germanCulture = CultureInfo.GetCultureInfo("de-DE");
var csv = new CsvBuilder()
.AddRow("Arbeitszeitauswertung")
.AddRow("Zeitraum", _periodFrom.ToString("dd.MM.yyyy", germanCulture),
_periodTo.ToString("dd.MM.yyyy", germanCulture))
.AddRow("Gesamtzeit (Minuten)", _totalMinutes)
.AddRow("Gesamtzeit (Stunden)", (_totalMinutes / 60.0).ToString("0.##", germanCulture));
if (_workloadSettings.RequiredWeeklyHours > 0)
{
var weeks = (_periodTo.DayNumber - _periodFrom.DayNumber + 1) / 7.0;
var requiredHours = _workloadSettings.RequiredWeeklyHours * weeks;
var actualHours = _totalMinutes / 60.0;
csv.AddRow("Pflichtstunden pro Woche",
_workloadSettings.RequiredWeeklyHours.ToString("0.##", germanCulture))
.AddRow("Sollzeit (Stunden)", requiredHours.ToString("0.##", germanCulture))
.AddRow("Abweichung (Stunden)", (actualHours - requiredHours).ToString("0.##", germanCulture));
}
csv.AddBlankRow()
.AddRow("Nach Kategorie")
.AddRow("Kategorie", "Minuten", "Stunden");
foreach (var summary in CategorySummaries)
csv.AddRow(summary.Category, summary.Minutes,
(summary.Minutes / 60.0).ToString("0.##", germanCulture));
csv.AddBlankRow()
.AddRow("Nach Gruppe")
.AddRow("Gruppe", "Minuten", "Stunden");
foreach (var summary in GroupSummaries)
csv.AddRow(summary.GroupName, summary.Minutes,
(summary.Minutes / 60.0).ToString("0.##", germanCulture));
return csv.ToString();
} }
} }
public class GroupTimeSummary(string groupName, int minutes, double barFraction) public class GroupTimeSummary(string groupName, int minutes, double barFraction)
{ {
public string GroupName { get; } = groupName; public string GroupName { get; } = groupName;
public int Minutes { get; } = minutes;
public string MinutesDisplay { get; } = $"{minutes} min"; public string MinutesDisplay { get; } = $"{minutes} min";
public double BarFraction { get; } = barFraction; public double BarFraction { get; } = barFraction;
} }
@@ -1,7 +1,8 @@
using Avalonia.Controls; using Avalonia.Controls;
using Avalonia.Interactivity; using Avalonia.Interactivity;
using Avalonia.Platform.Storage; using LehrerApp.Desktop.Services;
using LehrerApp.Desktop.ViewModels.Groups; using LehrerApp.Desktop.ViewModels.Groups;
using Microsoft.Extensions.DependencyInjection;
namespace LehrerApp.Desktop.Views.Groups; namespace LehrerApp.Desktop.Views.Groups;
@@ -15,15 +16,9 @@ public partial class ExamEvaluationDialog : Window
var topLevel = TopLevel.GetTopLevel(this); var topLevel = TopLevel.GetTopLevel(this);
if (topLevel is null) return; if (topLevel is null) return;
var file = await topLevel.StorageProvider.SaveFilePickerAsync(new FilePickerSaveOptions var export = ExportFile.Csv("Klausurauswertung exportieren",
{ $"Auswertung_{vm.ExamTitle}.csv", vm.ExportCsv());
Title = "Klausurauswertung exportieren", await App.Services.GetRequiredService<ExportService>().SaveAsync(topLevel.StorageProvider, export);
SuggestedFileName = $"Auswertung_{vm.ExamTitle}.csv",
FileTypeChoices = [new FilePickerFileType("CSV-Dateien") { Patterns = ["*.csv"] }],
});
if (file is null) return;
await File.WriteAllTextAsync(file.Path.LocalPath, vm.ExportCsv());
} }
private void OnClose(object? sender, RoutedEventArgs e) => Close(); private void OnClose(object? sender, RoutedEventArgs e) => Close();
@@ -1,7 +1,8 @@
using Avalonia.Controls; using Avalonia.Controls;
using Avalonia.Interactivity; using Avalonia.Interactivity;
using Avalonia.Platform.Storage; using LehrerApp.Desktop.Services;
using LehrerApp.Desktop.ViewModels.Groups; using LehrerApp.Desktop.ViewModels.Groups;
using Microsoft.Extensions.DependencyInjection;
namespace LehrerApp.Desktop.Views.Groups; namespace LehrerApp.Desktop.Views.Groups;
@@ -15,15 +16,9 @@ public partial class ReportGradeDialog : Window
var topLevel = TopLevel.GetTopLevel(this); var topLevel = TopLevel.GetTopLevel(this);
if (topLevel is null) return; if (topLevel is null) return;
var file = await topLevel.StorageProvider.SaveFilePickerAsync(new FilePickerSaveOptions var export = ExportFile.Csv("Zeugnisnoten exportieren",
{ $"Zeugnisnoten_{vm.GroupLabel}.csv", vm.ExportCsv());
Title = "Zeugnisnoten exportieren", await App.Services.GetRequiredService<ExportService>().SaveAsync(topLevel.StorageProvider, export);
SuggestedFileName = $"Zeugnisnoten_{vm.GroupLabel}.csv",
FileTypeChoices = [new FilePickerFileType("CSV-Dateien") { Patterns = ["*.csv"] }],
});
if (file is null) return;
await File.WriteAllTextAsync(file.Path.LocalPath, vm.ExportCsv());
} }
private void OnClose(object? sender, RoutedEventArgs e) => Close(); private void OnClose(object? sender, RoutedEventArgs e) => Close();
@@ -7,12 +7,14 @@
<StackPanel Margin="20,16" Spacing="16"> <StackPanel Margin="20,16" Spacing="16">
<!-- Zeitraum --> <!-- Zeitraum -->
<Grid ColumnDefinitions="Auto,Auto,Auto" HorizontalAlignment="Left"> <Grid ColumnDefinitions="Auto,Auto,Auto,Auto" HorizontalAlignment="Left">
<ComboBox Grid.Column="0" ItemsSource="{Binding PeriodModeOptions}" <ComboBox Grid.Column="0" ItemsSource="{Binding PeriodModeOptions}"
SelectedItem="{Binding PeriodMode}" Margin="0,0,8,0"/> SelectedItem="{Binding PeriodMode}" Margin="0,0,8,0"/>
<ComboBox Grid.Column="1" ItemsSource="{Binding MonthOptions}" SelectedItem="{Binding SelectedMonth}" <ComboBox Grid.Column="1" ItemsSource="{Binding MonthOptions}" SelectedItem="{Binding SelectedMonth}"
Margin="0,0,8,0" IsVisible="{Binding IsMonthMode}"/> Margin="0,0,8,0" IsVisible="{Binding IsMonthMode}"/>
<ComboBox Grid.Column="2" ItemsSource="{Binding YearOptions}" SelectedItem="{Binding SelectedYear}"/> <ComboBox Grid.Column="2" ItemsSource="{Binding YearOptions}" SelectedItem="{Binding SelectedYear}"/>
<Button Grid.Column="3" Content="Als CSV exportieren" Margin="16,0,0,0"
Click="OnExportClick"/>
</Grid> </Grid>
<!-- Pflichtstunden (6.3.2) --> <!-- Pflichtstunden (6.3.2) -->
@@ -1,8 +1,23 @@
using Avalonia.Controls; using Avalonia.Controls;
using Avalonia.Interactivity;
using LehrerApp.Desktop.Services;
using LehrerApp.Desktop.ViewModels.Workload;
using Microsoft.Extensions.DependencyInjection;
namespace LehrerApp.Desktop.Views.Workload; namespace LehrerApp.Desktop.Views.Workload;
public partial class WorkloadEvaluationView : UserControl public partial class WorkloadEvaluationView : UserControl
{ {
public WorkloadEvaluationView() => InitializeComponent(); public WorkloadEvaluationView() => InitializeComponent();
private async void OnExportClick(object? sender, RoutedEventArgs e)
{
if (DataContext is not WorkloadEvaluationViewModel vm) return;
var topLevel = TopLevel.GetTopLevel(this);
if (topLevel is null) return;
var export = ExportFile.Csv("Arbeitszeitauswertung exportieren",
vm.ExportSuggestedFileName, vm.ExportCsv());
await App.Services.GetRequiredService<ExportService>().SaveAsync(topLevel.StorageProvider, export);
}
} }
+20 -16
View File
@@ -3,7 +3,7 @@
Strukturierte ToDo-Liste aller offenen Entwicklungsschritte. Strukturierte ToDo-Liste aller offenen Entwicklungsschritte.
Jeder Punkt ist so formuliert, dass er einzeln an einen Agenten übergeben werden kann. Jeder Punkt ist so formuliert, dass er einzeln an einen Agenten übergeben werden kann.
**Stand:** 2026-08-16 **Stand:** 2026-08-18
**Legende:** `[ ]` offen · `[~]` teilweise umgesetzt · `[x]` fertig **Legende:** `[ ]` offen · `[~]` teilweise umgesetzt · `[x]` fertig
--- ---
@@ -93,9 +93,8 @@ pragmatisch über "Abwesend" bei den übrigen Schülern statt über eine eigene
Kennzeichnung auffällig schwacher Aufgaben (Ø < 50 %, rot markiert). Kennzeichnung auffällig schwacher Aufgaben (Ø < 50 %, rot markiert).
- [x] **1.5.3** Notenschlüssel nachträglich verschieben und Auswirkung sofort im Notenspiegel sehen — - [x] **1.5.3** Notenschlüssel nachträglich verschieben und Auswirkung sofort im Notenspiegel sehen —
Änderungen wirken sich live aus, erst "Übernehmen" schreibt sie in die Klausur zurück. Änderungen wirken sich live aus, erst "Übernehmen" schreibt sie in die Klausur zurück.
- [x] **1.5.4** Export der Auswertung — als eigenständiger CSV-Export direkt im Dialog umgesetzt - [x] **1.5.4** Export der Auswertung — CSV-Export direkt im Dialog; inzwischen auf die gemeinsame
(analog zum bestehenden JSON-Export der Kompetenzkataloge), nicht über eine gemeinsame Export-Infrastruktur aus 11.1 und den zentralen `CsvBuilder` umgestellt.
Export-Infrastruktur, da Kapitel 11 ("Bisher nicht vorhanden — komplett neu") noch aussteht.
--- ---
@@ -1123,8 +1122,8 @@ dadurch faktisch schon (Quick-Input-Dialog bzw. "Offene Entschuldigungen" im Das
Modelle `WorkTask` und `TimeEntry` existieren, Repositories ebenfalls. Modelle `WorkTask` und `TimeEntry` existieren, Repositories ebenfalls.
Navigationspunkt "Arbeitszeit" zeigt Aufgabenverwaltung (6.1), Zeiterfassung (6.2) und Auswertung Navigationspunkt "Arbeitszeit" zeigt Aufgabenverwaltung (6.1), Zeiterfassung (6.2) und Auswertung
(6.3) als drei Tabs (`WorkloadViewModel`/`WorkloadView`, gleiches Container-Tab-Muster wie (6.3) als drei Tabs (`WorkloadViewModel`/`WorkloadView`, gleiches Container-Tab-Muster wie
`GroupDetailViewModel`). **Kapitel 6 ist damit vollständig abgeschlossen** bis auf den Export der `GroupDetailViewModel`). **Kapitel 6 ist damit vollständig abgeschlossen**, einschließlich des
Auswertung (6.3.3), der am noch nicht existierenden Kapitel 11 (Export-Infrastruktur) hängt. CSV-Exports der Auswertung (6.3.3) über die gemeinsame Export-Infrastruktur aus 11.1.
### 6.1 Aufgabenverwaltung ### 6.1 Aufgabenverwaltung
- [x] **6.1.1** Aufgabenliste mit Filter nach Status, Kategorie, Gruppe und Fälligkeit — - [x] **6.1.1** Aufgabenliste mit Filter nach Status, Kategorie, Gruppe und Fälligkeit —
@@ -1201,8 +1200,9 @@ verschieben.
Indirektion (siehe auch das Nutzer-Feedback zum "Stundenplan bearbeiten"-Knopf oben). Die Indirektion (siehe auch das Nutzer-Feedback zum "Stundenplan bearbeiten"-Knopf oben). Die
Pflichtstunden pro Woche werden auf die Wochenzahl des gewählten Zeitraums hochgerechnet und Pflichtstunden pro Woche werden auf die Wochenzahl des gewählten Zeitraums hochgerechnet und
der erfassten Ist-Zeit gegenübergestellt. der erfassten Ist-Zeit gegenübergestellt.
- [ ] **6.3.3** Export der Arbeitszeitauswertung (siehe 11.2) — noch offen, da Kapitel 11 - [x] **6.3.3** Export der Arbeitszeitauswertung (siehe 11.2) — CSV enthält Zeitraum,
(Export-Infrastruktur) noch nicht existiert. Gesamtzeit, optionalen Soll-/Ist-Abgleich sowie die Aufschlüsselung nach Kategorie und
Lerngruppe. Deutsche Dezimaldarstellung und Excel-kompatible UTF-8-Ausgabe mit BOM.
--- ---
@@ -1647,10 +1647,14 @@ beides vor dem ersten produktiven Zwei-Geräte-Einsatz empfehlenswert nachzuhole
## 11. Export, Druck & Berichte ## 11. Export, Druck & Berichte
Bisher nicht vorhanden — komplett neu. - [x] **11.1** Basisinfrastruktur: zentraler `ExportService` mit Formatdefinition,
Dateityp/Speicherdialog und plattformneutraler Ausgabe über Avalonia-Storage-Streams.
- [ ] **11.1** Basisinfrastruktur: Export-Service mit Formatwahl und Speicherdialog. `CsvBuilder` erzeugt semikolongetrennte Dateien mit gemeinsamem Escaping; CSV wird mit
- [ ] **11.2** CSV-Export für Notenlisten, Klausurauswertung, Arbeitszeit, Fehlzeiten. UTF-8-BOM geschrieben, damit Umlaute in Excel zuverlässig erkannt werden. Klausurauswertung
und Zeugnisnoten verwenden die Infrastruktur bereits statt eigener Dateidialog-Logik.
- [~] **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.3** PDF-Erzeugung (Bibliothek auswählen — z.B. QuestPDF) mit einheitlichem Layout.
- [ ] **11.4** Druckvorlagen: Notenliste, Klausur-Notenspiegel, Sitzplan, Kompetenzbericht, - [ ] **11.4** Druckvorlagen: Notenliste, Klausur-Notenspiegel, Sitzplan, Kompetenzbericht,
Schülerdokumentation. Schülerdokumentation.
@@ -1926,10 +1930,10 @@ Die Abschnitte sind thematisch, nicht chronologisch nummeriert. Sinnvolle Bearbe
möglicher Companion-Client bleibt bewusst minimal). möglicher Companion-Client bleibt bewusst minimal).
**→ nächster sinnvoller Schritt: Kapitel 6, 10 oder 11.** **→ nächster sinnvoller Schritt: Kapitel 6, 10 oder 11.**
7. ~~**Kapitel 6** (Arbeitszeit & Aufgaben)~~ — vollständig erledigt (6.1 Aufgabenverwaltung 7. ~~**Kapitel 6** (Arbeitszeit & Aufgaben)~~ — vollständig erledigt (6.1 Aufgabenverwaltung
inkl. wiederkehrender Aufgaben, 6.2 Zeiterfassung, 6.3 Auswertung). Offen bleibt nur **6.3.3** inkl. wiederkehrender Aufgaben, 6.2 Zeiterfassung, 6.3 Auswertung und CSV-Export).
(Export der Auswertung), das am noch fehlenden Kapitel 11 hängt. **→ nächster sinnvoller Schritt: restliches Kapitel 11 (Export) oder 10 (Sync).**
**→ nächster sinnvoller Schritt: Kapitel 10 (Sync) oder 11 (Export).** 8. **Kapitel 11** (Export, Basisinfrastruktur und erste CSV-Exporte erledigt), **10** (Sync) —
8. **Kapitel 11** (Export), **10** (Sync) — danach. danach die noch offenen Berichte und produktiven Integrationsprüfungen.
9. ~~**Kapitel 4.5.94.5.19** (KI-gestützte Planungsunterstützung)~~ — in mehreren 9. ~~**Kapitel 4.5.94.5.19** (KI-gestützte Planungsunterstützung)~~ — in mehreren
Nutzer-Feedback-Iterationen weit über den ursprünglichen Punkt 4.5.9 hinaus ausgebaut: Nutzer-Feedback-Iterationen weit über den ursprünglichen Punkt 4.5.9 hinaus ausgebaut:
Eingabeschema im Systemprompt (4.5.13), Umfangs-Umschalter (4.5.15), Prompt Caching (4.5.16), Eingabeschema im Systemprompt (4.5.13), Umfangs-Umschalter (4.5.15), Prompt Caching (4.5.16),