diff --git a/TrafagSalesExporter/Components/HrKpi/HrKpiDashboardTabs.razor b/TrafagSalesExporter/Components/HrKpi/HrKpiDashboardTabs.razor
index b0a8c6e..992508f 100644
--- a/TrafagSalesExporter/Components/HrKpi/HrKpiDashboardTabs.razor
+++ b/TrafagSalesExporter/Components/HrKpi/HrKpiDashboardTabs.razor
@@ -7,6 +7,15 @@
@MetricGrid(Result.Metrics)
+
+
+ @TrafficLightPanel(Result.TrafficLights)
+
+
+ @MetricGrid(Result.PeriodComparisonMetrics)
+
+
+
@HeadcountByOrganisationTable(Result.HeadcountByOrganisation)
@@ -29,6 +38,15 @@
+
+
+ @GroupValueTable(T("Austritte nach Austrittsart", "Leavers by exit type"), Result.LeaversByType, T("Austritte", "Leavers"))
+
+
+ @GroupValueTable(T("Austritte nach Organisation", "Leavers by organisation"), Result.LeaversByOrganisation, T("Austritte", "Leavers"))
+
+
+
@TurnoverGauge(Result.TurnoverVisuals)
@@ -51,8 +69,21 @@
+
+ @TrafficLightPanel(Result.TrafficLights)
+ @MetricGrid(Result.PeriodComparisonMetrics)
+
+
@MetricGrid(Result.AbsenceMetrics)
+
+
+ @GroupValueTable(T("Absenzen nach Organisation", "Absences by organisation"), Result.AbsenceByOrganisation, T("Krankheitstage", "Sick days"))
+
+
+ @TopAbsencesTable(Result.Absences)
+
+
@T("Absenzen je Mitarbeiter", "Absences by employee")
@@ -67,7 +98,7 @@
@context.Personalnummer
- @context.Name
+ @DisplayPersonName(context.Name, context.Personalnummer, Result.Options.ManagementView)
@context.Organisationseinheit
@context.KrankheitstageKurz.ToString("N1")
@context.KrankheitstageLang.ToString("N1")
@@ -100,7 +131,7 @@
@T("Ampel", "Status")
- @context.NameVoll
+ @DisplayPersonName(context.NameVoll, context.Personalnummer, Result.Options.ManagementView)
@context.Organisationseinheit
@context.UrlaubRest.ToString("N1")
@context.FerienAusstehend.ToString("N1")
@@ -122,6 +153,11 @@
@FileStatusTable(Result.FileStatuses)
+
+
+ @DataQualityTable(Result.DataQualityIssues)
+
+
@@ -145,6 +181,19 @@
_ => Color.Success
};
+ private static Color MapQualityColor(string severity)
+ => severity switch
+ {
+ "Error" => Color.Error,
+ "Warning" => Color.Warning,
+ _ => Color.Info
+ };
+
+ private static string DisplayPersonName(string name, int? personalnummer, bool managementView)
+ => managementView
+ ? (personalnummer.HasValue ? $"Personalnr. {personalnummer.Value}" : "Person anonymisiert")
+ : name;
+
private static string FormatDate(DateTime? value)
=> value?.ToString("dd.MM.yyyy") ?? "-";
@@ -177,6 +226,93 @@
;
+ private RenderFragment> TrafficLightPanel => items => @
+ @T("HR-Ampel", "HR status")
+
+ @foreach (var item in items)
+ {
+
+
+
+
+ @item.Status
+
+ @item.Area
+
+ @item.Value
+ @item.Detail
+
+
+ }
+
+ ;
+
+ private RenderFragment> DataQualityTable => items => @
+ @T("Datenqualitaet", "Data quality")
+
+
+ @T("Schwere", "Severity")
+ @T("Bereich", "Area")
+ @T("Pruefpunkt", "Check")
+ @T("Anzahl", "Count")
+ @T("Hinweis", "Note")
+
+
+
+
+ @context.Severity
+
+
+ @context.Area
+ @context.Issue
+ @context.Count.ToString("N0")
+ @context.Detail
+
+
+ @T("Keine Datenqualitaetswarnungen.", "No data quality warnings.")
+
+
+ ;
+
+ private RenderFragment<(string Title, IReadOnlyList Items, string ValueLabel)> GroupValueTableTuple => data => @
+ @data.Title
+
+
+ @T("Gruppe", "Group")
+ @data.ValueLabel
+ %
+
+
+ @context.Label
+ @(context.Value != 0 ? context.Value.ToString("N1") : context.Count.ToString("N0"))
+ @context.Percent.ToString("N1")
+
+
+ ;
+
+ private RenderFragment GroupValueTable(string title, IReadOnlyList items, string valueLabel)
+ => GroupValueTableTuple((title, items, valueLabel));
+
+ private RenderFragment> TopAbsencesTable => items => @
+ @T("Hoechste Absenzen", "Highest absences")
+
+
+ @T("Name", "Name")
+ @T("Organisation", "Organisation")
+ @T("Kurz", "Short")
+ @T("Lang", "Long")
+ @T("Gesamt", "Total")
+
+
+ @DisplayPersonName(context.Name, context.Personalnummer, Result.Options.ManagementView)
+ @context.Organisationseinheit
+ @context.KrankheitstageKurz.ToString("N1")
+ @context.KrankheitstageLang.ToString("N1")
+ @context.KrankheitstageGesamt.ToString("N1")
+
+
+ ;
+
private RenderFragment> CriticalBalancesTable => items => @
@T("Kritische GLZ-Saldi", "Critical time balances")
@@ -187,7 +323,7 @@
@T("Ampel", "Status")
- @context.NameVoll
+ @DisplayPersonName(context.NameVoll, context.Personalnummer, Result.Options.ManagementView)
@context.Organisationseinheit
@context.StundenSaldo.ToString("N1")
@@ -209,7 +345,7 @@
@T("Austrittsart", "Exit type")
- @context.NameVoll
+ @DisplayPersonName(context.NameVoll, context.Personalnummer, Result.Options.ManagementView)
@FormatDate(context.Austrittsdatum)
@context.Organisationseinheit
@context.Austrittsart
@@ -246,7 +382,7 @@
@context.Personalnummer
- @context.NameVoll
+ @DisplayPersonName(context.NameVoll, context.Personalnummer, Result.Options.ManagementView)
@context.Organisationseinheit
@context.KostenstelleText
@context.Fte.ToString("N2")
@@ -266,6 +402,8 @@
@T("Quelle", "Source")
@T("Status", "Status")
+ @T("Stand", "Modified")
+ @T("Alter", "Age")
@T("Zeilen", "Rows")
@@ -278,6 +416,8 @@
@(context.Message ?? "-")
+ @FormatDate(context.LastModified)
+ @(context.AgeDays.HasValue ? $"{context.AgeDays:N0} Tage / {context.FreshnessStatus}" : "-")
@context.RowCount.ToString("N0")
diff --git a/TrafagSalesExporter/Components/Pages/HrKpi.razor b/TrafagSalesExporter/Components/Pages/HrKpi.razor
index 4a212f0..500de21 100644
--- a/TrafagSalesExporter/Components/Pages/HrKpi.razor
+++ b/TrafagSalesExporter/Components/Pages/HrKpi.razor
@@ -7,6 +7,7 @@
@inject IHrKpiAccessService HrKpiAccess
@inject ISnackbar Snackbar
@inject IUiTextService UiText
+@inject IJSRuntime JsRuntime
@T("HR KPI", "HR KPI")
@@ -65,6 +66,10 @@ else
@(_loading ? T("Lade...", "Loading...") : T("Laden", "Load"))
+
+
+
@@ -128,6 +133,12 @@ else
@T("Sperren", "Lock")
+
+
+ @T("Drucken/PDF", "Print/PDF")
+
+
}
@@ -161,6 +172,7 @@ else
private string? _glzAmpel;
private string? _restferienAmpel;
private string? _searchText;
+ private bool _managementView;
private string? _hrUsername;
private string? _hrPassword;
private bool _loading;
@@ -207,7 +219,8 @@ else
FluktuationFilter = _fluktuationFilter,
GlzAmpel = _glzAmpel,
RestferienAmpel = _restferienAmpel,
- SearchText = _searchText
+ SearchText = _searchText,
+ ManagementView = _managementView
});
}
catch (Exception ex)
@@ -239,6 +252,11 @@ else
_hrPassword = string.Empty;
}
+ private async Task PrintAsync()
+ {
+ await JsRuntime.InvokeVoidAsync("print");
+ }
+
private bool CanShowHrKpi => !HrKpiAccess.IsEnabled || HrKpiAccess.IsUnlocked;
private string T(string german, string english) => UiText.Text(german, english);
diff --git a/TrafagSalesExporter/Models/HrKpiModels.cs b/TrafagSalesExporter/Models/HrKpiModels.cs
index 2e689da..8a7ae19 100644
--- a/TrafagSalesExporter/Models/HrKpiModels.cs
+++ b/TrafagSalesExporter/Models/HrKpiModels.cs
@@ -14,6 +14,7 @@ public sealed class HrKpiOptions
public string? GlzAmpel { get; set; }
public string? RestferienAmpel { get; set; }
public string? SearchText { get; set; }
+ public bool ManagementView { get; set; }
}
public sealed class HrKpiDataSourceOptions
@@ -57,6 +58,13 @@ public sealed class HrKpiResult
public List TurnoverMetrics { get; set; } = [];
public List AbsenceMetrics { get; set; } = [];
public List TimeVacationMetrics { get; set; } = [];
+ public List PeriodComparisonMetrics { get; set; } = [];
+ public List TrafficLights { get; set; } = [];
+ public List DataQualityIssues { get; set; } = [];
+ public List LeaversByType { get; set; } = [];
+ public List LeaversByOrganisation { get; set; } = [];
+ public List AbsenceByOrganisation { get; set; } = [];
+ public List CriticalAbsences { get; set; } = [];
public List Employees { get; set; } = [];
public List Absences { get; set; } = [];
public List Leavers { get; set; } = [];
@@ -73,6 +81,26 @@ public sealed class HrKpiFileStatus
public bool Exists { get; set; }
public int RowCount { get; set; }
public string? Message { get; set; }
+ public DateTime? LastModified { get; set; }
+ public int? AgeDays { get; set; }
+ public string FreshnessStatus { get; set; } = "Unbekannt";
+}
+
+public sealed class HrKpiTrafficLight
+{
+ public string Area { get; set; } = string.Empty;
+ public string Status { get; set; } = "Gruen";
+ public string Value { get; set; } = string.Empty;
+ public string Detail { get; set; } = string.Empty;
+}
+
+public sealed class HrKpiDataQualityIssue
+{
+ public string Severity { get; set; } = "Info";
+ public string Area { get; set; } = string.Empty;
+ public string Issue { get; set; } = string.Empty;
+ public int Count { get; set; }
+ public string Detail { get; set; } = string.Empty;
}
public sealed class HrKpiMetric
diff --git a/TrafagSalesExporter/Services/HrKpi/HrKpiDashboardBuilder.cs b/TrafagSalesExporter/Services/HrKpi/HrKpiDashboardBuilder.cs
index 39c3537..7a21ebb 100644
--- a/TrafagSalesExporter/Services/HrKpi/HrKpiDashboardBuilder.cs
+++ b/TrafagSalesExporter/Services/HrKpi/HrKpiDashboardBuilder.cs
@@ -38,7 +38,8 @@ internal sealed class HrKpiDashboardBuilder
FluktuationFilter = NormalizeFilter(options.FluktuationFilter),
GlzAmpel = NormalizeFilter(options.GlzAmpel),
RestferienAmpel = NormalizeFilter(options.RestferienAmpel),
- SearchText = NormalizeFilter(options.SearchText)
+ SearchText = NormalizeFilter(options.SearchText),
+ ManagementView = options.ManagementView
};
var result = new HrKpiResult { Options = normalizedOptions };
@@ -107,6 +108,23 @@ internal sealed class HrKpiDashboardBuilder
result.TurnoverMetrics = BuildTurnoverMetrics(turnoverEmployees, turnoverHeadcountLeavers, leavers, turnoverPeriod);
result.AbsenceMetrics = BuildAbsenceMetrics(employees, absences);
result.TimeVacationMetrics = BuildTimeVacationMetrics(employees);
+ result.PeriodComparisonMetrics = BuildPeriodComparisonMetrics(turnoverEmployees, turnoverHeadcountLeavers, leavers, turnoverPeriod);
+ result.TrafficLights = BuildTrafficLights(result.Metrics, result.TurnoverMetrics, result.AbsenceMetrics, result.TimeVacationMetrics, context);
+ result.DataQualityIssues = BuildDataQualityIssues(employees, absences, leavers, sapRows, context);
+ result.LeaversByType = BuildLeaverTypeGroups(leavers);
+ result.LeaversByOrganisation = BuildLeaverOrganisationGroups(leavers);
+ result.AbsenceByOrganisation = BuildAbsenceOrganisationGroups(absences);
+ result.CriticalAbsences = absences
+ .Where(x => x.KrankheitstageGesamt > 0)
+ .OrderByDescending(x => x.KrankheitstageGesamt)
+ .Select(absence => employees.FirstOrDefault(employee => employee.Personalnummer == absence.Personalnummer) ?? new HrKpiEmployeeRow
+ {
+ Personalnummer = absence.Personalnummer,
+ NameVoll = absence.Name,
+ Organisationseinheit = absence.Organisationseinheit
+ })
+ .Take(25)
+ .ToList();
result.TurnoverVisuals = BuildTurnoverVisuals(turnoverEmployees, turnoverHeadcountLeavers, leavers, turnoverPeriod);
result.HeadcountByOrganisation = employees
.GroupBy(x => BlankAsUnknown(x.Organisationseinheit), StringComparer.OrdinalIgnoreCase)
@@ -587,6 +605,171 @@ internal sealed class HrKpiDashboardBuilder
];
}
+ private static List BuildPeriodComparisonMetrics(
+ IReadOnlyCollection employees,
+ IReadOnlyCollection turnoverHeadcountLeavers,
+ IReadOnlyCollection leavers,
+ TurnoverPeriodScope period)
+ {
+ var selectedYear = period.BreakdownYear ?? leavers
+ .Where(x => x.Austrittsjahr.HasValue)
+ .Select(x => x.Austrittsjahr!.Value)
+ .DefaultIfEmpty(DateTime.Today.Year)
+ .Max();
+ var previousYear = selectedYear - 1;
+ var intervals = BuildTurnoverIntervals(employees, turnoverHeadcountLeavers);
+ var selectedHeadcount = CalculateAverageFixedHeadcount(intervals, Enumerable.Range(1, 12).Select(month => (selectedYear, month)));
+ var previousHeadcount = CalculateAverageFixedHeadcount(intervals, Enumerable.Range(1, 12).Select(month => (previousYear, month)));
+ var selectedLeavers = CountDistinctPersons(leavers
+ .Where(x => x.IstFluktuationsrelevant && x.Austrittsjahr == selectedYear)
+ .Select(x => x.Personalnummer));
+ var previousLeavers = CountDistinctPersons(leavers
+ .Where(x => x.IstFluktuationsrelevant && x.Austrittsjahr == previousYear)
+ .Select(x => x.Personalnummer));
+ var selectedRate = selectedHeadcount == 0 ? 0 : selectedLeavers / selectedHeadcount;
+ var previousRate = previousHeadcount == 0 ? 0 : previousLeavers / previousHeadcount;
+ var deltaRate = selectedRate - previousRate;
+ var selectedAbs = leavers.Count(x => x.Austrittsjahr == selectedYear);
+ var previousAbs = leavers.Count(x => x.Austrittsjahr == previousYear);
+
+ return
+ [
+ new() { Label = $"Headcount {selectedYear}", Value = FormatHeadcount(selectedHeadcount), Detail = $"Vorjahr {FormatHeadcount(previousHeadcount)}", Severity = "Normal" },
+ new() { Label = $"Austritte {selectedYear}", Value = selectedAbs.ToString("N0"), Detail = $"Vorjahr {previousAbs:N0}", Severity = selectedAbs > previousAbs ? "Warning" : "Normal" },
+ new() { Label = $"Fluktuation {selectedYear}", Value = selectedRate.ToString("P1"), Detail = $"Vorjahr {previousRate:P1}", Severity = selectedRate > 0.12m ? "Warning" : "Normal" },
+ new() { Label = "Delta Fluktuation", Value = deltaRate.ToString("+0.0%;-0.0%;0.0%"), Detail = $"{selectedYear} gegen {previousYear}", Severity = deltaRate > 0.02m ? "Warning" : "Normal" }
+ ];
+ }
+
+ private static List BuildTrafficLights(
+ IReadOnlyList overviewMetrics,
+ IReadOnlyList turnoverMetrics,
+ IReadOnlyList absenceMetrics,
+ IReadOnlyList timeVacationMetrics,
+ ImportContext context)
+ {
+ var turnover = FindMetric(turnoverMetrics, "Fluktuation Jahr Effektiv %") ?? FindMetric(overviewMetrics, "Fluktuation");
+ var absence = FindMetric(absenceMetrics, "Krankenquote");
+ var glzRed = FindMetric(timeVacationMetrics, "GLZ Rot");
+ var vacationRed = FindMetric(timeVacationMetrics, "Restferien Rot");
+ var missingFiles = context.FileStatuses.Count(x => !x.Exists);
+
+ return
+ [
+ BuildTrafficLight("Fluktuation", turnover?.Value ?? "-", turnover?.Detail ?? string.Empty, turnover?.Severity == "Warning"),
+ BuildTrafficLight("Krankenquote", absence?.Value ?? "-", absence?.Detail ?? string.Empty, absence?.Severity == "Warning"),
+ BuildTrafficLight("GLZ-Saldi", glzRed?.Value ?? "0", glzRed?.Detail ?? string.Empty, ParseInt(glzRed?.Value) > 0),
+ BuildTrafficLight("Restferien", vacationRed?.Value ?? "0", vacationRed?.Detail ?? string.Empty, ParseInt(vacationRed?.Value) > 0),
+ new()
+ {
+ Area = "Datenqualitaet",
+ Status = missingFiles == 0 ? "Gruen" : "Rot",
+ Value = missingFiles.ToString("N0"),
+ Detail = missingFiles == 0 ? "Alle erwarteten Dateien gefunden" : "Erwartete Dateien fehlen"
+ }
+ ];
+ }
+
+ private static List BuildDataQualityIssues(
+ IReadOnlyCollection employees,
+ IReadOnlyCollection absences,
+ IReadOnlyCollection leavers,
+ IReadOnlyDictionary sapRows,
+ ImportContext context)
+ {
+ var employeeNumbers = employees
+ .Where(x => x.Personalnummer.HasValue)
+ .Select(x => x.Personalnummer!.Value)
+ .ToHashSet();
+ var duplicateEmployeeNumbers = employees
+ .Where(x => x.Personalnummer.HasValue)
+ .GroupBy(x => x.Personalnummer!.Value)
+ .Count(g => g.Count() > 1);
+ var sapNumbers = sapRows.Keys
+ .Select(key => int.TryParse(key, NumberStyles.Any, CultureInfo.InvariantCulture, out var parsed) ? parsed : (int?)null)
+ .Where(x => x.HasValue)
+ .Select(x => x!.Value)
+ .ToHashSet();
+
+ return new[]
+ {
+ CreateQualityIssue("Error", "Dateien", "Fehlende Dateien", context.FileStatuses.Count(x => !x.Exists), "Erwartete HR-KPI-Datei wurde im Datenordner nicht gefunden."),
+ CreateQualityIssue("Warning", "Mitarbeitende", "Fehlende Personalnummer", employees.Count(x => !x.Personalnummer.HasValue), "Diese Zeilen zaehlen nicht in Distinct-Headcount-Kennzahlen."),
+ CreateQualityIssue("Warning", "Mitarbeitende", "Doppelte Personalnummer", duplicateEmployeeNumbers, "Mehrere aktive Zeilen mit gleicher Personalnummer."),
+ CreateQualityIssue("Warning", "Rexx/SAP", "Rexx ohne SAP", employeeNumbers.Count(number => !sapNumbers.Contains(number)), "Aktive Mitarbeitende ohne passende SAP-Zusatzzeile."),
+ CreateQualityIssue("Info", "Rexx/SAP", "SAP ohne Rexx", sapNumbers.Count(number => !employeeNumbers.Contains(number)), "SAP-Zeile ohne aktive Rexx-Mitarbeiterzeile."),
+ CreateQualityIssue("Warning", "Mitarbeitende", "Fehlende Organisation", employees.Count(x => string.IsNullOrWhiteSpace(x.Organisationseinheit)), "Organisationseinheit fehlt."),
+ CreateQualityIssue("Warning", "Mitarbeitende", "Fehlende Kostenstelle", employees.Count(x => string.IsNullOrWhiteSpace(x.KostenstelleText)), "Kostenstelle fehlt."),
+ CreateQualityIssue("Warning", "Mitarbeitende", "Fehlender Beschaeftigungsgrad", employees.Count(x => !x.BeschaeftigungsgradProzent.HasValue), "FTE verwendet Rexx-Fallback."),
+ CreateQualityIssue("Info", "Absenzen", "Absenzen ohne aktive Person", absences.Count(x => x.Personalnummer.HasValue && !employeeNumbers.Contains(x.Personalnummer.Value)), "Absenzzeile passt nicht auf aktuell aktive Mitarbeitendenfilter."),
+ CreateQualityIssue("Info", "Austritte", "Austritte ohne Personalnummer", leavers.Count(x => !x.Personalnummer.HasValue), "Austritt kann nicht eindeutig per Personalnummer gruppiert werden.")
+ }.Where(x => x.Count > 0).ToList();
+ }
+
+ private static List BuildLeaverTypeGroups(IReadOnlyCollection leavers)
+ => leavers
+ .GroupBy(x => BlankAsUnknown(string.IsNullOrWhiteSpace(x.AustrittsartNormalisiert) ? x.Austrittsart : x.AustrittsartNormalisiert), StringComparer.OrdinalIgnoreCase)
+ .Select(g => new HrKpiGroupValue { Label = g.Key, Count = CountDistinctPersons(g.Select(x => x.Personalnummer)), Value = CountDistinctPersons(g.Select(x => x.Personalnummer)) })
+ .OrderByDescending(x => x.Count)
+ .ThenBy(x => x.Label, StringComparer.OrdinalIgnoreCase)
+ .ToList();
+
+ private static List BuildLeaverOrganisationGroups(IReadOnlyCollection leavers)
+ => leavers
+ .GroupBy(x => BlankAsUnknown(x.Organisationseinheit), StringComparer.OrdinalIgnoreCase)
+ .Select(g => new HrKpiGroupValue { Label = g.Key, Count = CountDistinctPersons(g.Select(x => x.Personalnummer)), Value = CountDistinctPersons(g.Select(x => x.Personalnummer)) })
+ .OrderByDescending(x => x.Count)
+ .ThenBy(x => x.Label, StringComparer.OrdinalIgnoreCase)
+ .ToList();
+
+ private static List BuildAbsenceOrganisationGroups(IReadOnlyCollection absences)
+ {
+ var total = absences.Sum(x => x.KrankheitstageGesamt);
+ return absences
+ .GroupBy(x => BlankAsUnknown(x.Organisationseinheit), StringComparer.OrdinalIgnoreCase)
+ .Select(g =>
+ {
+ var value = g.Sum(x => x.KrankheitstageGesamt);
+ return new HrKpiGroupValue
+ {
+ Label = g.Key,
+ Count = g.Count(),
+ Value = value,
+ Percent = total == 0 ? 0 : value / total * 100m
+ };
+ })
+ .OrderByDescending(x => x.Value)
+ .ThenBy(x => x.Label, StringComparer.OrdinalIgnoreCase)
+ .ToList();
+ }
+
+ private static HrKpiMetric? FindMetric(IEnumerable metrics, string labelPart)
+ => metrics.FirstOrDefault(x => x.Label.Contains(labelPart, StringComparison.OrdinalIgnoreCase));
+
+ private static HrKpiTrafficLight BuildTrafficLight(string area, string value, string detail, bool warning)
+ => new()
+ {
+ Area = area,
+ Status = warning ? "Gelb" : "Gruen",
+ Value = value,
+ Detail = detail
+ };
+
+ private static HrKpiDataQualityIssue CreateQualityIssue(string severity, string area, string issue, int count, string detail)
+ => new()
+ {
+ Severity = severity,
+ Area = area,
+ Issue = issue,
+ Count = count,
+ Detail = detail
+ };
+
+ private static int ParseInt(string? value)
+ => int.TryParse((value ?? string.Empty).Replace("'", string.Empty), NumberStyles.Any, CultureInfo.CurrentCulture, out var parsed)
+ ? parsed
+ : 0;
+
private static HrTurnoverVisuals BuildTurnoverVisuals(
IReadOnlyCollection employees,
IReadOnlyCollection turnoverHeadcountLeavers,
@@ -1219,6 +1402,8 @@ internal sealed class HrKpiDashboardBuilder
public bool HasFile(string fileName)
=> File.Exists(BuildPath(fileName));
+ public IReadOnlyList FileStatuses => _result.FileStatuses;
+
public List ReadRows(string fileName, string label, Func, T> map)
{
var path = BuildPath(fileName);
@@ -1228,6 +1413,12 @@ internal sealed class HrKpiDashboardBuilder
Path = path,
Exists = File.Exists(path)
};
+ if (status.Exists)
+ {
+ status.LastModified = File.GetLastWriteTime(path);
+ status.AgeDays = Math.Max(0, (DateTime.Today - status.LastModified.Value.Date).Days);
+ status.FreshnessStatus = status.AgeDays <= 7 ? "Aktuell" : status.AgeDays <= 31 ? "Aelter" : "Alt";
+ }
_result.FileStatuses.Add(status);
if (!status.Exists)
diff --git a/TrafagSalesExporter/docs/FINANCE_COCKPIT_ANLEITUNG_FINANZ_2026-05-20.docx b/TrafagSalesExporter/docs/FINANCE_COCKPIT_ANLEITUNG_FINANZ_2026-05-20.docx
new file mode 100644
index 0000000..f6b076e
Binary files /dev/null and b/TrafagSalesExporter/docs/FINANCE_COCKPIT_ANLEITUNG_FINANZ_2026-05-20.docx differ
diff --git a/TrafagSalesExporter/docs/HR_KPI_ANLEITUNG_HR_2026-05-20.docx b/TrafagSalesExporter/docs/HR_KPI_ANLEITUNG_HR_2026-05-20.docx
new file mode 100644
index 0000000..428400e
Binary files /dev/null and b/TrafagSalesExporter/docs/HR_KPI_ANLEITUNG_HR_2026-05-20.docx differ