feat(PROJ-51): Aufbewahrungsfristen nach Dokumentenart (Retention-Kategorien)

Fuehrt archiving_rules ein (PROJ-43-Basis: Tabelle + CRUD-API + Admin-UI) und
erweitert die Retention-Logik (PROJ-34) um Regel-basierte Fristen, eine
globale Mindestfrist (min_retention_days) sowie Nachvollziehbarkeit der
Frist-Quelle (retain_until_source) in API und Mail-Detailansicht.
This commit is contained in:
sysops
2026-06-13 20:48:16 +02:00
parent 7c08ebe1b7
commit 507dee6431
16 changed files with 1175 additions and 21 deletions
@@ -0,0 +1,459 @@
"use client";
import { useState, useEffect, useCallback } from "react";
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Badge } from "@/components/ui/badge";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Skeleton } from "@/components/ui/skeleton";
import {
getArchivingRules,
createArchivingRule,
updateArchivingRule,
deleteArchivingRule,
getTenants,
type ArchivingRule,
type ArchivingRuleInput,
type RuleConditionType,
type Tenant,
} from "@/lib/api";
const COND_LABELS: Record<RuleConditionType, string> = {
sender_domain: "Absender-Domain",
recipient_domain: "Empfänger-Domain",
sender_address: "Absender-Adresse",
recipient_address: "Empfänger-Adresse",
};
// PROJ-51 retention suggestion values.
const RETENTION_PRESETS = [
{ days: 2190, label: "6 Jahre (Handels-/Geschäftsbriefe)" },
{ days: 3650, label: "10 Jahre (Rechnungen, Belege)" },
{ days: 36500, label: "Dauerhaft (100 Jahre)" },
];
function retentionLabel(days: number | null): React.ReactNode {
if (days === null) {
return <Badge variant="secondary">kein Regel-Wert</Badge>;
}
if (days >= 36500) {
return <Badge>Dauerhaft</Badge>;
}
return <Badge>{days} Tage ({(days / 365).toFixed(1)} Jahre)</Badge>;
}
interface EditState {
id: number | null; // null = create
tenant_id: string; // "" = alle Mandanten
condition_type: RuleConditionType;
pattern: string;
priority: string;
retention_days: string; // "" = kein Regel-Wert
}
const EMPTY_EDIT: EditState = {
id: null,
tenant_id: "",
condition_type: "sender_domain",
pattern: "",
priority: "0",
retention_days: "",
};
export function ArchivingRulesTab() {
const [rules, setRules] = useState<ArchivingRule[]>([]);
const [minRetentionDays, setMinRetentionDays] = useState(0);
const [tenants, setTenants] = useState<Tenant[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
const [edit, setEdit] = useState<EditState | null>(null);
const [saving, setSaving] = useState(false);
const [formError, setFormError] = useState("");
const [deleteRule, setDeleteRule] = useState<ArchivingRule | null>(null);
const [deleting, setDeleting] = useState(false);
const load = useCallback(() => {
setLoading(true);
setError("");
Promise.all([getArchivingRules(), getTenants()])
.then(([data, ts]) => {
setRules(data.rules);
setMinRetentionDays(data.min_retention_days);
setTenants(ts);
})
.catch(() => setError("Archivierungsregeln konnten nicht geladen werden"))
.finally(() => setLoading(false));
}, []);
useEffect(() => {
load();
}, [load]);
const tenantName = (id: number | null): React.ReactNode => {
if (id === null) return <span className="text-muted-foreground">Alle Mandanten</span>;
const t = tenants.find((x) => x.id === id);
return t ? t.name : `#${id}`;
};
const openCreate = () => {
setEdit({ ...EMPTY_EDIT });
setFormError("");
};
const openEdit = (r: ArchivingRule) => {
setEdit({
id: r.id,
tenant_id: r.tenant_id === null ? "" : String(r.tenant_id),
condition_type: r.condition_type,
pattern: r.pattern,
priority: String(r.priority),
retention_days: r.retention_days === null ? "" : String(r.retention_days),
});
setFormError("");
};
const handleSave = async () => {
if (!edit) return;
const pattern = edit.pattern.trim();
if (!pattern) {
setFormError("Muster darf nicht leer sein");
return;
}
const priority = parseInt(edit.priority, 10);
if (isNaN(priority)) {
setFormError("Priorität muss eine Zahl sein");
return;
}
let retention_days: number | null = null;
if (edit.retention_days.trim() !== "") {
retention_days = parseInt(edit.retention_days, 10);
if (isNaN(retention_days) || retention_days < 0) {
setFormError("Aufbewahrung muss eine positive Zahl sein");
return;
}
}
const input: ArchivingRuleInput = {
tenant_id: edit.tenant_id === "" ? null : parseInt(edit.tenant_id, 10),
condition_type: edit.condition_type,
pattern,
priority,
retention_days,
};
setSaving(true);
setFormError("");
try {
if (edit.id === null) {
await createArchivingRule(input);
} else {
await updateArchivingRule(edit.id, input);
}
setEdit(null);
load();
} catch (e: unknown) {
setFormError(e instanceof Error ? e.message : "Speichern fehlgeschlagen");
} finally {
setSaving(false);
}
};
const handleDelete = async () => {
if (!deleteRule) return;
setDeleting(true);
try {
await deleteArchivingRule(deleteRule.id);
setDeleteRule(null);
load();
} catch (e: unknown) {
setError(e instanceof Error ? e.message : "Löschen fehlgeschlagen");
setDeleteRule(null);
} finally {
setDeleting(false);
}
};
return (
<div className="mt-4 space-y-4">
<Card>
<CardHeader>
<CardTitle>Globale Mindest-Aufbewahrungsfrist</CardTitle>
<CardDescription>
Gilt systemweit als unterste Grenze. Regeln und Mandanten-Einstellungen dürfen die
Frist nur verlängern, nie verkürzen. Konfiguration über{" "}
<code className="text-xs">storage.min_retention_days</code> in der{" "}
<code className="text-xs">config.yml</code>.
</CardDescription>
</CardHeader>
<CardContent>
{loading ? (
<Skeleton className="h-6 w-48" />
) : minRetentionDays > 0 ? (
<Badge>
{minRetentionDays} Tage ({(minRetentionDays / 365).toFixed(1)} Jahre)
</Badge>
) : (
<Badge variant="secondary">Keine Mindestfrist (0)</Badge>
)}
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between gap-2">
<div>
<CardTitle>Archivierungsregeln</CardTitle>
<CardDescription>
Regeln mit Aufbewahrungsfrist bestimmen bei Treffer die Mindestaufbewahrung einer
Mail (höchste Priorität gewinnt).
</CardDescription>
</div>
<Button size="sm" onClick={openCreate}>
Regel hinzufügen
</Button>
</CardHeader>
<CardContent>
{error && <p className="mb-3 text-sm text-destructive">{error}</p>}
{loading ? (
<div className="space-y-2">
<Skeleton className="h-10 w-full" />
<Skeleton className="h-10 w-full" />
</div>
) : rules.length === 0 ? (
<p className="py-6 text-center text-sm text-muted-foreground">
Noch keine Archivierungsregeln definiert.
</p>
) : (
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-16">Prio</TableHead>
<TableHead>Bedingung</TableHead>
<TableHead>Muster</TableHead>
<TableHead>Mandant</TableHead>
<TableHead>Aufbewahrung</TableHead>
<TableHead className="w-32"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{rules.map((r) => (
<TableRow key={r.id}>
<TableCell>{r.priority}</TableCell>
<TableCell>{COND_LABELS[r.condition_type] ?? r.condition_type}</TableCell>
<TableCell className="font-mono text-xs break-all">{r.pattern}</TableCell>
<TableCell>{tenantName(r.tenant_id)}</TableCell>
<TableCell>{retentionLabel(r.retention_days)}</TableCell>
<TableCell>
<div className="flex gap-1">
<Button size="sm" variant="outline" onClick={() => openEdit(r)}>
Bearbeiten
</Button>
<Button
size="sm"
variant="ghost"
className="text-destructive"
onClick={() => setDeleteRule(r)}
>
Löschen
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
</CardContent>
</Card>
{/* Create / Edit Dialog */}
<Dialog open={!!edit} onOpenChange={(o) => { if (!o) setEdit(null); }}>
<DialogContent>
<DialogHeader>
<DialogTitle>
{edit?.id === null ? "Regel hinzufügen" : "Regel bearbeiten"}
</DialogTitle>
<DialogDescription>
Legt fest, welche Mails von dieser Regel erfasst werden und optional wie lange
sie aufbewahrt werden.
</DialogDescription>
</DialogHeader>
{edit && (
<div className="space-y-4 py-2">
<div className="space-y-2">
<Label>Bedingungstyp</Label>
<Select
value={edit.condition_type}
onValueChange={(v) =>
setEdit({ ...edit, condition_type: v as RuleConditionType })
}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{(Object.keys(COND_LABELS) as RuleConditionType[]).map((k) => (
<SelectItem key={k} value={k}>
{COND_LABELS[k]}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="rule-pattern">Muster</Label>
<Input
id="rule-pattern"
placeholder="z.B. firma.de oder rechnung@firma.de"
value={edit.pattern}
onChange={(e) => setEdit({ ...edit, pattern: e.target.value })}
/>
</div>
<div className="space-y-2">
<Label>Mandant</Label>
<Select
value={edit.tenant_id === "" ? "__all__" : edit.tenant_id}
onValueChange={(v) =>
setEdit({ ...edit, tenant_id: v === "__all__" ? "" : v })
}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="__all__">Alle Mandanten</SelectItem>
{tenants.map((t) => (
<SelectItem key={t.id} value={String(t.id)}>
{t.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="rule-priority">Priorität</Label>
<Input
id="rule-priority"
type="number"
value={edit.priority}
onChange={(e) => setEdit({ ...edit, priority: e.target.value })}
/>
<p className="text-xs text-muted-foreground">
Höher = wird zuerst geprüft. Bei mehreren Treffern gewinnt die höchste Priorität.
</p>
</div>
<div className="space-y-2">
<Label htmlFor="rule-retention">Aufbewahrung (Tage)</Label>
<Input
id="rule-retention"
type="number"
min={0}
placeholder="leer = kein Regel-spezifischer Wert"
value={edit.retention_days}
onChange={(e) => setEdit({ ...edit, retention_days: e.target.value })}
/>
<div className="flex flex-wrap gap-1">
{RETENTION_PRESETS.map((p) => (
<Button
key={p.days}
type="button"
size="sm"
variant="outline"
onClick={() => setEdit({ ...edit, retention_days: String(p.days) })}
>
{p.label}
</Button>
))}
</div>
{edit.retention_days.trim() !== "" &&
parseInt(edit.retention_days, 10) > 0 && (
<p className="text-xs text-muted-foreground">
{(parseInt(edit.retention_days, 10) / 365).toFixed(1)} Jahre
{minRetentionDays > 0 &&
parseInt(edit.retention_days, 10) < minRetentionDays && (
<span className="text-amber-600">
{" "}
wird durch globale Mindestfrist ({minRetentionDays} Tage) angehoben
</span>
)}
</p>
)}
<p className="text-xs text-muted-foreground">
Leer = diese Regel setzt keine eigene Frist (Mandanten-/globaler Standard gilt).
</p>
</div>
{formError && <p className="text-sm text-destructive">{formError}</p>}
</div>
)}
<DialogFooter>
<Button variant="outline" onClick={() => setEdit(null)}>
Abbrechen
</Button>
<Button disabled={saving} onClick={handleSave}>
{saving ? "Speichern..." : "Speichern"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Delete Dialog */}
<Dialog open={!!deleteRule} onOpenChange={(o) => { if (!o) setDeleteRule(null); }}>
<DialogContent>
<DialogHeader>
<DialogTitle>Regel löschen</DialogTitle>
<DialogDescription>
Die Regel wird gelöscht. Bereits gesetzte Aufbewahrungsfristen bestehender Mails
bleiben unverändert.
</DialogDescription>
</DialogHeader>
{deleteRule && (
<p className="py-2 text-sm">
<span className="font-mono">{deleteRule.pattern}</span> (
{COND_LABELS[deleteRule.condition_type]})
</p>
)}
<DialogFooter>
<Button variant="outline" onClick={() => setDeleteRule(null)}>
Abbrechen
</Button>
<Button variant="destructive" disabled={deleting} onClick={handleDelete}>
{deleting ? "Lösche..." : "Löschen"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}