Add shared export infrastructure
This commit is contained in:
@@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.9–4.5.19** (KI-gestützte Planungsunterstützung)~~ — in mehreren
|
9. ~~**Kapitel 4.5.9–4.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),
|
||||||
|
|||||||
Reference in New Issue
Block a user