/** * Berichte. * * Die Seite, die in Phase 12 als „noch nicht verfuegbar" im Menue stand. * * Zwei Dinge unterscheidet sie von einer gewoehnlichen Berichtsmaske: * * Sie zeigt die Vorschau **im selben Modell**, in dem der Bericht auch als CSV * und PDF herausgeht — was hier steht, steht auch in der Datei. Und sie stellt * eine ungemessene Kennzahl als solche dar, nicht als Null. Genau dieser * Unterschied entscheidet, ob ein Bericht die Wahrheit sagt: „Erfolgsquote 0 %" * meldet eine ausgefallene Sicherung, „nicht bestimmbar" meldet, dass es nichts * zu bewerten gab. */ import { useCallback, useState } from 'react'; import { ApiError, downloadApiFile, requestApi } from '../../api/client'; import { useApiResource } from '../../api/useApiResource'; import { ErrorState, LoadingState } from '../../components/PageState'; /** Art des Zeitbezugs eines Berichts. */ type PeriodKind = 'range' | 'point_in_time'; /** Ausgabeformat. */ type ReportFormat = 'json' | 'csv' | 'pdf'; /** Eintrag des Berichtskatalogs. */ interface ReportCatalogEntry { /** Maschinenlesbarer Bezeichner. */ readonly type: string; /** Bezeichnung. */ readonly title: string; /** Erklaert, welche Frage der Bericht beantwortet. */ readonly description: string; /** Art des Zeitbezugs. */ readonly period_kind: PeriodKind; /** Beschreibung des Standardzeitraums. */ readonly default_period?: string; /** Verfuegbare Ausgabeformate. */ readonly formats: readonly ReportFormat[]; } /** Eine Kennzahl eines Berichts. */ interface ReportMetric { /** Beschriftung. */ readonly label: string; /** Gemessener Wert; nur gueltig, wenn is_known gilt. */ readonly value?: number; /** Einheit. */ readonly unit?: string; /** Nicht numerische Angabe. */ readonly text?: string; /** Meldet, ob der Wert gemessen wurde. */ readonly is_known: boolean; /** Erklaert einen fehlenden Wert. */ readonly unknown_reason?: string; } /** Eine Tabelle eines Berichts. */ interface ReportTable { /** Ueberschrift. */ readonly title: string; /** Spaltenbeschriftungen. */ readonly columns: readonly string[]; /** Zeilen. */ readonly rows: readonly (readonly string[])[]; /** Hinweis anstelle einer leeren Tabelle. */ readonly empty_notice?: string; } /** Ein Abschnitt eines Berichts. */ interface ReportSection { /** Ueberschrift. */ readonly title: string; /** Erklaerung. */ readonly description?: string; /** Kennzahlen. */ readonly metrics?: readonly ReportMetric[]; /** Tabellen. */ readonly tables?: readonly ReportTable[]; } /** Ein fertiger Bericht. */ interface GeneratedReport { /** Reportart. */ readonly type: string; /** Bezeichnung. */ readonly title: string; /** Erklaerung. */ readonly description: string; /** Beginn des Zeitraums. */ readonly period_from: string; /** Ende des Zeitraums. */ readonly period_to: string; /** Erzeugungszeitpunkt. */ readonly generated_at: string; /** Anmeldename des Anfordernden. */ readonly generated_by?: string; /** Abschnitte. */ readonly sections: readonly ReportSection[]; /** Benannte Luecken und Einschraenkungen. */ readonly notes?: readonly string[]; } /** Zustand einer laufenden Ausgabe. */ interface ExportState { /** Format, das gerade erzeugt wird; null, wenn nichts laeuft. */ readonly runningFormat: ReportFormat | null; /** Fehler der letzten Ausgabe. */ readonly exportError: ApiError | null; } /** Laedt den Berichtskatalog. */ function loadReportCatalog(abortSignal: AbortSignal): Promise { return requestApi('/reports', { signal: abortSignal }); } /** Die Berichtsseite. */ export function ReportsPage(): React.JSX.Element { const catalogResource = useApiResource(loadReportCatalog); const [selectedType, setSelectedType] = useState(null); const [previewReport, setPreviewReport] = useState(null); const [exportState, setExportState] = useState({ runningFormat: null, exportError: null, }); const selectedEntry = catalogResource.data?.find((entry) => entry.type === selectedType) ?? null; /** * Fordert einen Bericht an. * * JSON wandert in die Vorschau, CSV und PDF in den Download-Ordner. Beide Wege * erzeugen **denselben** Bericht — die Vorschau ist keine eigene Ansicht der * Daten, sondern dieselbe Struktur in anderer Darstellung. */ const generateReport = useCallback( async (reportType: string, outputFormat: ReportFormat): Promise => { setExportState({ runningFormat: outputFormat, exportError: null }); try { if (outputFormat === 'json') { const generatedReport = await requestApi('/reports/generate', { method: 'POST', body: { type: reportType, format: 'json' }, }); setPreviewReport(generatedReport); setExportState({ runningFormat: null, exportError: null }); return; } const downloadedFile = await downloadApiFile('/reports/generate', { method: 'POST', body: { type: reportType, format: outputFormat }, }); triggerBrowserDownload(downloadedFile.blob, downloadedFile.fileName || `bericht.${outputFormat}`); setExportState({ runningFormat: null, exportError: null }); } catch (caughtError) { setExportState({ runningFormat: null, exportError: caughtError instanceof ApiError ? caughtError : null, }); } }, [], ); const selectReport = useCallback( (reportType: string): void => { setSelectedType(reportType); setPreviewReport(null); setExportState({ runningFormat: null, exportError: null }); void generateReport(reportType, 'json'); }, [generateReport], ); if (catalogResource.loadState === 'loading') { return ; } if (catalogResource.loadState === 'failed' || catalogResource.data === null) { // loadError kann in diesem Zweig nur dann fehlen, wenn die Nutzlast leer // blieb — dann steht der Katalog trotzdem nicht zur Verfuegung. return catalogResource.loadError === null ? (

Der Berichtskatalog steht nicht zur Verfügung.

) : ( ); } return (

Berichte

Neun Berichte in drei Formaten. Was in der Vorschau steht, steht auch in der Datei — einschließlich der Angaben, die sich nicht messen ließen.

{catalogResource.data.map((catalogEntry) => ( ))}
{selectedEntry !== null && ( void generateReport(selectedEntry.type, outputFormat)} /> )}
); } /** Zeigt einen erzeugten Bericht mit seinen Ausgabemoeglichkeiten. */ function ReportPanel(properties: { readonly catalogEntry: ReportCatalogEntry; readonly previewReport: GeneratedReport | null; readonly exportState: ExportState; readonly onExport: (outputFormat: ReportFormat) => void; }): React.JSX.Element { const { catalogEntry, previewReport, exportState, onExport } = properties; return (

{catalogEntry.title}

{exportState.exportError !== null && (

{exportState.exportError.message} Vorgang {exportState.exportError.requestId}

)} {previewReport === null ? ( ) : ( )}
); } /** Stellt einen Bericht dar. */ function ReportPreview(properties: { readonly report: GeneratedReport }): React.JSX.Element { const { report } = properties; // Ein Zustandsbericht hat keinen Zeitraum. Zwei gleiche Zeitpunkte // auszuweisen sähe nach einem Fehler aus. const isPointInTime = report.period_from === report.period_to; return (

{isPointInTime ? `Zustand zum ${formatTimestamp(report.period_to)}` : `Zeitraum ${formatTimestamp(report.period_from)} bis ${formatTimestamp(report.period_to)}`} {' · '} Erzeugt am {formatTimestamp(report.generated_at)} {report.generated_by !== undefined && report.generated_by !== '' && ` von ${report.generated_by}`}

{report.sections.map((reportSection) => (

{reportSection.title}

{reportSection.description !== undefined && (

{reportSection.description}

)} {reportSection.metrics !== undefined && reportSection.metrics.length > 0 && (
{reportSection.metrics.map((reportMetric) => (
{reportMetric.label}
{formatMetricValue(reportMetric)} {!reportMetric.is_known && reportMetric.unknown_reason !== undefined && ( {reportMetric.unknown_reason} )}
))}
)} {reportSection.tables?.map((reportTable) => ( ))}
))} {report.notes !== undefined && report.notes.length > 0 && (

Hinweise

    {report.notes.map((note) => (
  • {note}
  • ))}
)}
); } /** Zahl der Zeilen, die in der Vorschau gezeigt werden. */ const previewRowLimit = 25; /** Stellt eine Tabelle dar. */ function ReportTableView(properties: { readonly table: ReportTable }): React.JSX.Element { const { table } = properties; if (table.rows.length === 0) { return (

{table.title}

{table.empty_notice ?? 'Keine Einträge.'}

); } const visibleRows = table.rows.slice(0, previewRowLimit); return (

{table.title}

{table.columns.map((columnLabel) => ( ))} {/* Die Position ist der Schluessel: Eine Berichtszeile traegt keine Kennung, und zwei Zeilen koennen Zeichen fuer Zeichen gleich sein. Unbedenklich, weil die Tabelle statisch ist — sie wird weder umsortiert noch ergaenzt, sondern mit dem Bericht neu aufgebaut. */} {visibleRows.map((tableRow, rowIndex) => ( {tableRow.map((cellValue, cellIndex) => ( ))} ))}
{columnLabel}
{cellValue}
{table.rows.length > visibleRows.length && (

Die Vorschau zeigt {visibleRows.length} von {table.rows.length} Zeilen. Die heruntergeladene Datei enthält alle.

)}
); } /** * Stellt den Wert einer Kennzahl dar. * * Eine ungemessene Kennzahl zeigt „nicht gemessen", keine Null. Der Unterschied * ist der Kern des ganzen Berichtswesens. */ function formatMetricValue(reportMetric: ReportMetric): string { if (!reportMetric.is_known) { return 'nicht gemessen'; } if (reportMetric.text !== undefined && reportMetric.text !== '') { return reportMetric.text; } const numericValue = reportMetric.value ?? 0; switch (reportMetric.unit) { case 'bytes': return formatBytes(numericValue); case 'seconds': return formatDuration(numericValue); case 'percent': return formatPercent(numericValue); case 'count': return numericValue.toLocaleString('de-DE'); default: return numericValue.toLocaleString('de-DE'); } } /** Stellt eine Datenmenge lesbar dar. */ function formatBytes(byteCount: number): string { const unitNames = ['B', 'KiB', 'MiB', 'GiB', 'TiB', 'PiB']; let scaledValue = byteCount; let unitIndex = 0; while (scaledValue >= 1024 && unitIndex < unitNames.length - 1) { scaledValue /= 1024; unitIndex += 1; } return `${scaledValue.toLocaleString('de-DE', { maximumFractionDigits: 1 })} ${unitNames[unitIndex]}`; } /** Stellt eine Dauer lesbar dar. */ function formatDuration(seconds: number): string { if (seconds < 1) { return `${Math.round(seconds * 1000)} ms`; } if (seconds < 60) { return `${seconds.toLocaleString('de-DE', { maximumFractionDigits: 1 })} s`; } if (seconds < 3600) { return `${Math.floor(seconds / 60)} min ${Math.round(seconds % 60)} s`; } if (seconds < 86400) { return `${Math.floor(seconds / 3600)} h ${Math.round((seconds % 3600) / 60)} min`; } return `${Math.floor(seconds / 86400)} Tage ${Math.round((seconds % 86400) / 3600)} h`; } /** * Stellt einen Anteil dar. * * Ein kleiner Wert erscheint als „< 0,1 %", nicht als „0,0 %": Null Prozent * liest sich wie „nichts vorhanden" (dieselbe Regel wie in der Uebersicht). */ function formatPercent(percentValue: number): string { if (percentValue > 0 && percentValue < 0.1) { return '< 0,1 %'; } return `${percentValue.toLocaleString('de-DE', { maximumFractionDigits: 1 })} %`; } /** Stellt einen Zeitpunkt in Ortszeit dar. */ function formatTimestamp(isoTimestamp: string): string { return new Date(isoTimestamp).toLocaleString('de-DE'); } /** * Loest den Browser-Download einer Datei aus. * * Die Objekt-URL wird unmittelbar wieder freigegeben. Ohne das haelt der Browser * jeden erzeugten Bericht im Speicher, bis die Seite neu geladen wird — bei * einem Monatsbericht ueber eine grosse Anlage sind das schnell einige Megabyte * je Klick. */ function triggerBrowserDownload(fileBlob: Blob, fileName: string): void { const objectUrl = URL.createObjectURL(fileBlob); const downloadLink = document.createElement('a'); downloadLink.href = objectUrl; downloadLink.download = fileName; document.body.appendChild(downloadLink); downloadLink.click(); document.body.removeChild(downloadLink); URL.revokeObjectURL(objectUrl); }