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:
co-authored by
Claude Sonnet 5
parent
b286352d07
commit
be93614c9f
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
Reference in New Issue
Block a user