diff --git a/LehrerApp.Desktop.Tests/ExportServiceTests.cs b/LehrerApp.Desktop.Tests/ExportServiceTests.cs new file mode 100644 index 0000000..7720260 --- /dev/null +++ b/LehrerApp.Desktop.Tests/ExportServiceTests.cs @@ -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..])); + } +} diff --git a/LehrerApp.Desktop.Tests/WorkloadViewModelTests.cs b/LehrerApp.Desktop.Tests/WorkloadViewModelTests.cs index 7be2b94..e1b6ce8 100644 --- a/LehrerApp.Desktop.Tests/WorkloadViewModelTests.cs +++ b/LehrerApp.Desktop.Tests/WorkloadViewModelTests.cs @@ -597,4 +597,53 @@ public sealed class WorkloadEvaluationViewModelTests Assert.Contains("Soll:", 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); + } } diff --git a/LehrerApp.Desktop/AppBootstrapper.cs b/LehrerApp.Desktop/AppBootstrapper.cs index efeccbd..9ef4713 100644 --- a/LehrerApp.Desktop/AppBootstrapper.cs +++ b/LehrerApp.Desktop/AppBootstrapper.cs @@ -110,6 +110,7 @@ public static class AppBootstrapper EnsureLogger(); services.AddSingleton(Logger); services.AddSingleton(); + services.AddSingleton(); // ── Datensicherheit (13.3) ─────────────────────────────────────────── // Backup läuft vor dem Öffnen der Datenbank, rein dateibasiert (unabhängig von diff --git a/LehrerApp.Desktop/Services/CsvBuilder.cs b/LehrerApp.Desktop/Services/CsvBuilder.cs new file mode 100644 index 0000000..c7ae5a6 --- /dev/null +++ b/LehrerApp.Desktop/Services/CsvBuilder.cs @@ -0,0 +1,45 @@ +using System.Text; + +namespace LehrerApp.Desktop.Services; + +/// +/// 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. +/// +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('"'); + } +} diff --git a/LehrerApp.Desktop/Services/ExportService.cs b/LehrerApp.Desktop/Services/ExportService.cs new file mode 100644 index 0000000..b1b3f2e --- /dev/null +++ b/LehrerApp.Desktop/Services/ExportService.cs @@ -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 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)); + } +} + +/// Zentrale Dateiauswahl und Stream-Ausgabe für alle Exportformate. +public sealed class ExportService +{ + public async Task 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; + } +} diff --git a/LehrerApp.Desktop/ViewModels/Groups/ExamEvaluationViewModels.cs b/LehrerApp.Desktop/ViewModels/Groups/ExamEvaluationViewModels.cs index d229ed4..b414d60 100644 --- a/LehrerApp.Desktop/ViewModels/Groups/ExamEvaluationViewModels.cs +++ b/LehrerApp.Desktop/ViewModels/Groups/ExamEvaluationViewModels.cs @@ -3,9 +3,9 @@ using CommunityToolkit.Mvvm.Input; using LehrerApp.Core.Interfaces; using LehrerApp.Core.Models; using LehrerApp.Core.Services; +using LehrerApp.Desktop.Services; using System.Collections.ObjectModel; using System.Globalization; -using System.Text; namespace LehrerApp.Desktop.ViewModels.Groups; @@ -195,27 +195,27 @@ public partial class ExamEvaluationDialogViewModel : ObservableObject public string ExportCsv() { - var sb = new StringBuilder(); - sb.AppendLine($"Klausur;{_exam.Title}"); - sb.AppendLine($"Datum;{_exam.Date:dd.MM.yyyy}"); - sb.AppendLine(); - sb.AppendLine("Notenspiegel"); - sb.AppendLine("Note;Anzahl;Anteil"); + var csv = new CsvBuilder() + .AddRow("Klausur", _exam.Title) + .AddRow("Datum", _exam.Date.ToString("dd.MM.yyyy", CultureInfo.InvariantCulture)) + .AddBlankRow() + .AddRow("Notenspiegel") + .AddRow("Note", "Anzahl", "Anteil"); foreach (var g in GradeDistribution) - sb.AppendLine($"{g.Grade};{g.Count};{Percent(g.Count, GradedCount)}"); - sb.AppendLine(); - sb.AppendLine($"Durchschnitt;{AverageDisplay}"); - sb.AppendLine($"Median;{MedianDisplay}"); - if (!string.IsNullOrEmpty(BelowThresholdLabel1)) sb.AppendLine($"{BelowThresholdLabel1};{BelowThresholdDisplay1}"); - if (!string.IsNullOrEmpty(BelowThresholdLabel2)) sb.AppendLine($"{BelowThresholdLabel2};{BelowThresholdDisplay2}"); - sb.AppendLine($"Bewertet;{GradedCount}"); - sb.AppendLine($"Abwesend;{AbsentCount}"); - sb.AppendLine(); - sb.AppendLine("Aufgabenanalyse"); - sb.AppendLine("Aufgabe;Ø Erfüllungsgrad;Auffällig schwach"); + csv.AddRow(g.Grade, g.Count, Percent(g.Count, GradedCount)); + csv.AddBlankRow() + .AddRow("Durchschnitt", AverageDisplay) + .AddRow("Median", MedianDisplay); + if (!string.IsNullOrEmpty(BelowThresholdLabel1)) csv.AddRow(BelowThresholdLabel1, BelowThresholdDisplay1); + if (!string.IsNullOrEmpty(BelowThresholdLabel2)) csv.AddRow(BelowThresholdLabel2, BelowThresholdDisplay2); + csv.AddRow("Bewertet", GradedCount) + .AddRow("Abwesend", AbsentCount) + .AddBlankRow() + .AddRow("Aufgabenanalyse") + .AddRow("Aufgabe", "Ø Erfüllungsgrad", "Auffällig schwach"); foreach (var t in TaskAnalysis) - sb.AppendLine($"{t.Label};{t.AvgPercentDisplay};{(t.IsWeak ? "ja" : "")}"); - return sb.ToString(); + csv.AddRow(t.Label, t.AvgPercentDisplay, t.IsWeak ? "ja" : ""); + return csv.ToString(); } } diff --git a/LehrerApp.Desktop/ViewModels/Groups/ReportGradeViewModels.cs b/LehrerApp.Desktop/ViewModels/Groups/ReportGradeViewModels.cs index a98c7c2..4daddfd 100644 --- a/LehrerApp.Desktop/ViewModels/Groups/ReportGradeViewModels.cs +++ b/LehrerApp.Desktop/ViewModels/Groups/ReportGradeViewModels.cs @@ -3,9 +3,9 @@ using CommunityToolkit.Mvvm.Input; using LehrerApp.Core.Interfaces; using LehrerApp.Core.Models; using LehrerApp.Core.Services; +using LehrerApp.Desktop.Services; using System.Collections.ObjectModel; using System.Globalization; -using System.Text; namespace LehrerApp.Desktop.ViewModels.Groups; @@ -197,12 +197,13 @@ public partial class ReportGradeDialogViewModel : ObservableObject public string ExportCsv() { - var sb = new StringBuilder(); - sb.AppendLine($"Zeugnisnoten;{_groupLabel};{SelectedPeriod.Label}"); - sb.AppendLine("Schüler;Berechnet;Übersteuert;Begründung;Endnote;Gesperrt"); + var csv = new CsvBuilder() + .AddRow("Zeugnisnoten", _groupLabel, SelectedPeriod.Label) + .AddRow("Schüler", "Berechnet", "Übersteuert", "Begründung", "Endnote", "Gesperrt"); foreach (var r in Rows) - sb.AppendLine($"{r.Name};{r.CalculatedValue};{r.OverrideValue};{r.OverrideReason};{r.FinalDisplay};{(r.IsLocked ? "ja" : "")}"); - return sb.ToString(); + csv.AddRow(r.Name, r.CalculatedValue, r.OverrideValue, r.OverrideReason, + r.FinalDisplay, r.IsLocked ? "ja" : ""); + return csv.ToString(); } } diff --git a/LehrerApp.Desktop/ViewModels/Workload/WorkloadViewModels.cs b/LehrerApp.Desktop/ViewModels/Workload/WorkloadViewModels.cs index 32cd5cb..592ff90 100644 --- a/LehrerApp.Desktop/ViewModels/Workload/WorkloadViewModels.cs +++ b/LehrerApp.Desktop/ViewModels/Workload/WorkloadViewModels.cs @@ -3,6 +3,7 @@ using CommunityToolkit.Mvvm.Input; using LehrerApp.Core.Interfaces; using LehrerApp.Core.Models; using LehrerApp.Core.Services; +using LehrerApp.Desktop.Services; using System.Collections.ObjectModel; using System.Globalization; @@ -519,6 +520,7 @@ public class TimeEntryListItem(TimeEntry model, string? taskTitle) public class CategoryTimeSummary(string category, int minutes, double barFraction) { public string Category { get; } = category; + public int Minutes { get; } = minutes; public string MinutesDisplay { get; } = $"{minutes} min"; public double BarFraction { get; } = barFraction; } @@ -619,6 +621,20 @@ public partial class WorkloadEvaluationViewModel : ObservableObject public ObservableCollection GroupSummaries { get; } = []; public string TotalMinutesDisplay { get; private set; } = "0 min"; 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, WorkloadSettingsService workloadSettings, SchoolYearService schoolYear) @@ -671,6 +687,8 @@ public partial class WorkloadEvaluationViewModel : ObservableObject private void Refresh() { var (from, to) = CurrentPeriod(); + _periodFrom = from; + _periodTo = to; var periodEntries = _entries.GetByDateRange(from, to); 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); + _totalMinutes = totalMinutes; TotalMinutesDisplay = $"{totalMinutes} min ({(totalMinutes / 60.0).ToString("0.#", CultureInfo.InvariantCulture)} h)"; // 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(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 string GroupName { get; } = groupName; + public int Minutes { get; } = minutes; public string MinutesDisplay { get; } = $"{minutes} min"; public double BarFraction { get; } = barFraction; } diff --git a/LehrerApp.Desktop/Views/Groups/ExamEvaluationDialog.axaml.cs b/LehrerApp.Desktop/Views/Groups/ExamEvaluationDialog.axaml.cs index 3cc174b..2ebda88 100644 --- a/LehrerApp.Desktop/Views/Groups/ExamEvaluationDialog.axaml.cs +++ b/LehrerApp.Desktop/Views/Groups/ExamEvaluationDialog.axaml.cs @@ -1,7 +1,8 @@ using Avalonia.Controls; using Avalonia.Interactivity; -using Avalonia.Platform.Storage; +using LehrerApp.Desktop.Services; using LehrerApp.Desktop.ViewModels.Groups; +using Microsoft.Extensions.DependencyInjection; namespace LehrerApp.Desktop.Views.Groups; @@ -15,15 +16,9 @@ public partial class ExamEvaluationDialog : Window var topLevel = TopLevel.GetTopLevel(this); if (topLevel is null) return; - var file = await topLevel.StorageProvider.SaveFilePickerAsync(new FilePickerSaveOptions - { - Title = "Klausurauswertung exportieren", - 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()); + var export = ExportFile.Csv("Klausurauswertung exportieren", + $"Auswertung_{vm.ExamTitle}.csv", vm.ExportCsv()); + await App.Services.GetRequiredService().SaveAsync(topLevel.StorageProvider, export); } private void OnClose(object? sender, RoutedEventArgs e) => Close(); diff --git a/LehrerApp.Desktop/Views/Groups/ReportGradeDialog.axaml.cs b/LehrerApp.Desktop/Views/Groups/ReportGradeDialog.axaml.cs index e3d3468..87d5b88 100644 --- a/LehrerApp.Desktop/Views/Groups/ReportGradeDialog.axaml.cs +++ b/LehrerApp.Desktop/Views/Groups/ReportGradeDialog.axaml.cs @@ -1,7 +1,8 @@ using Avalonia.Controls; using Avalonia.Interactivity; -using Avalonia.Platform.Storage; +using LehrerApp.Desktop.Services; using LehrerApp.Desktop.ViewModels.Groups; +using Microsoft.Extensions.DependencyInjection; namespace LehrerApp.Desktop.Views.Groups; @@ -15,15 +16,9 @@ public partial class ReportGradeDialog : Window var topLevel = TopLevel.GetTopLevel(this); if (topLevel is null) return; - var file = await topLevel.StorageProvider.SaveFilePickerAsync(new FilePickerSaveOptions - { - Title = "Zeugnisnoten exportieren", - 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()); + var export = ExportFile.Csv("Zeugnisnoten exportieren", + $"Zeugnisnoten_{vm.GroupLabel}.csv", vm.ExportCsv()); + await App.Services.GetRequiredService().SaveAsync(topLevel.StorageProvider, export); } private void OnClose(object? sender, RoutedEventArgs e) => Close(); diff --git a/LehrerApp.Desktop/Views/Workload/WorkloadEvaluationView.axaml b/LehrerApp.Desktop/Views/Workload/WorkloadEvaluationView.axaml index f5b21b3..201c4d4 100644 --- a/LehrerApp.Desktop/Views/Workload/WorkloadEvaluationView.axaml +++ b/LehrerApp.Desktop/Views/Workload/WorkloadEvaluationView.axaml @@ -7,12 +7,14 @@ - + +