feat(PROJ-52): Vollständigkeits-Reconciliation (Zähl-Report Mailserver vs. Archiv)

Täglicher Cron-Job (archivmail reconcile) berechnet pro Tenant/Quelle
(SMTP-Journal, IMAP-Konto, POP3-Konto, Datei-Import) archivierte Mail-Zahlen,
für IMAP zusätzlich einen Soll/Ist-Vergleich via UID-Tracking. Abweichungen
über Schwellenwert erzeugen Audit-Log-Warnung. Neue Admin-Dashboard-Kachel
"Vollständigkeits-Check" (letzte 7 Tage, Warn-Badge, CSV-Export).

Schließt die "teilweise erfüllt"-Lücke bei Vollständigkeit im
GoBD/DSGVO-Compliance-Check (VOI-Grundsatz 2).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
sysops
2026-07-03 22:48:42 +02:00
co-authored by Claude Sonnet 5
parent b286352d07
commit be93614c9f
24 changed files with 1577 additions and 10 deletions
@@ -15,6 +15,7 @@ import { Badge } from "@/components/ui/badge";
import { Skeleton } from "@/components/ui/skeleton";
import { Separator } from "@/components/ui/separator";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { ReconciliationCard } from "@/components/admin/tabs/ReconciliationCard";
function formatBytes(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
@@ -491,6 +492,9 @@ export function DashboardTab({
</Card>
)}
{/* Vollständigkeits-Check (PROJ-52) — tenant-gescoped, domain_admin+ */}
<ReconciliationCard />
{/* Benutzerübersicht */}
<Card>
<CardContent className="pt-6 space-y-2">
@@ -0,0 +1,271 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import {
exportReconciliationCSV,
getReconciliation,
type ReconciliationResponse,
type ReconciliationSource,
} from "@/lib/api";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Skeleton } from "@/components/ui/skeleton";
import { Separator } from "@/components/ui/separator";
import { Alert, AlertDescription } from "@/components/ui/alert";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
/** Formatiert einen source_key zu einem lesbaren Label. */
function formatSourceLabel(source: ReconciliationSource): string {
switch (source.source_type) {
case "smtp":
return "SMTP-Journal";
case "import":
return "Datei-Import";
case "imap":
return `IMAP-Konto #${source.source_id ?? "?"}`;
case "pop3":
return `POP3-Konto #${source.source_id ?? "?"}`;
default:
return source.source_key;
}
}
function formatDayLabel(iso: string): string {
const d = new Date(iso);
return d.toLocaleDateString("de-DE", { day: "2-digit", month: "2-digit" });
}
/** Zellinhalt für einen Tagespunkt: "—" bei fehlendem Datensatz, sonst Werte. */
function DayCell({
archived,
expected,
delta,
missing,
isImap,
}: {
archived: number | null;
expected: number | null;
delta: number | null;
missing: boolean;
isImap: boolean;
}) {
if (missing) {
return (
<span
className="text-muted-foreground"
title="Kein Report-Datensatz für diesen Tag (Cron nicht gelaufen)"
>
</span>
);
}
return (
<span className="inline-flex flex-col items-end leading-tight">
<span className="font-medium tabular-nums">
{(archived ?? 0).toLocaleString("de-DE")}
</span>
{isImap && expected != null && (
<span className="text-[10px] text-muted-foreground tabular-nums">
Soll {expected.toLocaleString("de-DE")}
{delta != null && (
<span
className={
delta < 0 ? "ml-1 text-destructive" : "ml-1 text-green-600"
}
>
({delta > 0 ? "+" : ""}
{delta.toLocaleString("de-DE")})
</span>
)}
</span>
)}
</span>
);
}
export function ReconciliationCard() {
const [data, setData] = useState<ReconciliationResponse | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [exporting, setExporting] = useState(false);
const load = useCallback(async () => {
setLoading(true);
setError(null);
try {
const res = await getReconciliation(7);
setData(res);
} catch (e: unknown) {
setError(e instanceof Error ? e.message : "Laden fehlgeschlagen");
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
void load();
}, [load]);
const handleExport = async () => {
setExporting(true);
try {
const { blob, filename } = await exportReconciliationCSV(30);
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(url);
} catch (e: unknown) {
setError(e instanceof Error ? e.message : "Export fehlgeschlagen");
} finally {
setExporting(false);
}
};
// Tages-Header aus dem ersten Quell-Eintrag ableiten (alle Quellen haben
// dieselben Tage in gleicher Reihenfolge).
const dayHeaders = data?.sources[0]?.points.map((p) => p.date) ?? [];
return (
<Card>
<CardContent className="pt-6 space-y-3">
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-muted-foreground">
Vollständigkeits-Check
</span>
{data && (
<span className="text-xs text-muted-foreground">
letzte {data.days} Tage · Schwelle {data.threshold_pct}%
</span>
)}
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={load}
disabled={loading}
>
{loading ? "..." : "Aktualisieren"}
</Button>
<Button
variant="outline"
size="sm"
onClick={handleExport}
disabled={exporting}
>
{exporting ? "Export..." : "CSV-Export (30 Tage)"}
</Button>
</div>
</div>
<Separator />
{loading ? (
<div className="space-y-2">
{Array.from({ length: 3 }).map((_, i) => (
<Skeleton key={i} className="h-8 w-full" />
))}
</div>
) : error ? (
<Alert variant="destructive">
<AlertDescription>
Vollständigkeits-Report konnte nicht geladen werden: {error}
</AlertDescription>
</Alert>
) : !data || data.sources.length === 0 ? (
<p className="text-sm text-muted-foreground">
Noch keine Reconciliation-Daten vorhanden. Der tägliche Zähl-Job
(<code className="font-mono">archivmail reconcile</code>) hat noch
keine Datensätze erzeugt.
</p>
) : (
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead className="whitespace-nowrap">Quelle</TableHead>
{dayHeaders.map((d) => (
<TableHead
key={d}
className="text-right whitespace-nowrap"
>
{formatDayLabel(d)}
</TableHead>
))}
<TableHead className="text-right whitespace-nowrap">
Ø 7 Tage
</TableHead>
<TableHead className="text-right whitespace-nowrap">
Status
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{data.sources.map((s) => {
const isImap = s.source_type === "imap";
return (
<TableRow key={s.source_key}>
<TableCell className="font-medium whitespace-nowrap">
{formatSourceLabel(s)}
</TableCell>
{!s.enough_data ? (
<TableCell
colSpan={dayHeaders.length + 1}
className="text-center text-sm text-muted-foreground"
>
Noch nicht genug Daten
</TableCell>
) : (
<>
{s.points.map((p) => (
<TableCell key={p.date} className="text-right">
<DayCell
archived={p.archived_count}
expected={p.expected_count}
delta={p.delta}
missing={p.missing}
isImap={isImap}
/>
</TableCell>
))}
<TableCell className="text-right tabular-nums text-muted-foreground">
{s.avg_7d != null
? s.avg_7d.toLocaleString("de-DE", {
maximumFractionDigits: 1,
})
: "—"}
</TableCell>
</>
)}
<TableCell className="text-right">
{s.alert ? (
<Badge variant="destructive">Auffällig</Badge>
) : s.enough_data ? (
<Badge variant="secondary">OK</Badge>
) : (
<Badge variant="outline"></Badge>
)}
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</div>
)}
</CardContent>
</Card>
);
}
+10
View File
@@ -178,6 +178,16 @@ export {
deleteArchivingRule,
} from "./archiving_rules";
export type {
ReconciliationPoint,
ReconciliationSource,
ReconciliationResponse,
} from "./reconciliation";
export {
getReconciliation,
exportReconciliationCSV,
} from "./reconciliation";
export type { SavedSearch } from "./saved_searches";
export {
listSavedSearches,
+51
View File
@@ -0,0 +1,51 @@
import { API_BASE, request } from "./core";
// ── Types ────────────────────────────────────────────────────────────────────
export interface ReconciliationPoint {
date: string; // "2026-06-27"
archived_count: number | null;
expected_count: number | null;
delta: number | null;
missing: boolean; // true = kein Report-Datensatz (Cron nicht gelaufen) ≠ archived_count:0
}
export interface ReconciliationSource {
source_type: string; // "smtp" | "imap" | "pop3" | "import"
source_id: number | null;
source_key: string; // "smtp" | "import" | "imap:<id>" | "pop3:<id>"
tenant_id: number | null;
points: ReconciliationPoint[];
avg_7d: number | null;
enough_data: boolean;
alert: boolean;
}
export interface ReconciliationResponse {
days: number;
threshold_pct: number;
sources: ReconciliationSource[];
}
// ── API ──────────────────────────────────────────────────────────────────────
export async function getReconciliation(days = 7): Promise<ReconciliationResponse> {
return request<ReconciliationResponse>(`/api/admin/reconciliation?days=${days}`);
}
/** Lädt den Reconciliation-Report als CSV-Datei herunter. */
export async function exportReconciliationCSV(
days = 30
): Promise<{ blob: Blob; filename: string }> {
const res = await fetch(
`${API_BASE}/api/admin/reconciliation/export.csv?days=${days}`,
{ credentials: "include" }
);
if (!res.ok) throw new Error(`Export fehlgeschlagen: ${res.status}`);
const disposition = res.headers.get("Content-Disposition") || "";
const match = disposition.match(/filename[^;=\n]*=((['"]).*?\2|[^;\n]*)/);
const filename = match
? match[1].replace(/['"]/g, "")
: `reconciliation-${days}d.csv`;
return { blob: await res.blob(), filename };
}