diff --git a/admin-compliance/app/sdk/template-rule-editor/_components/TenantOverrideList.tsx b/admin-compliance/app/sdk/template-rule-editor/_components/TenantOverrideList.tsx new file mode 100644 index 00000000..fff57d69 --- /dev/null +++ b/admin-compliance/app/sdk/template-rule-editor/_components/TenantOverrideList.tsx @@ -0,0 +1,398 @@ +'use client' + +/** + * Pro-Tenant Override-Liste: zeigt alle Overrides der eigenen Kanzlei + * + Add/Edit/Delete. + * + * Reuse: Backend /tenant-rule-overrides (upsert via POST, delete via DELETE). + * Read-only Klassifikation wird aus der live_version der Regel gezogen. + */ + +import { useMemo, useState } from 'react' +import type { + Classification, Rule, RuleVersion, TenantRuleOverride, +} from '../_types' +import { CLASSIFICATION_LABELS } from '../_types' + +interface Props { + rules: Rule[] + liveVersionsByRule: Record + overrides: TenantRuleOverride[] + onUpsert: (payload: { + rule_id: string + override_classification: Classification | null + reason: string + }) => Promise + onDelete: (overrideId: string) => Promise +} + +export default function TenantOverrideList({ + rules, liveVersionsByRule, overrides, onUpsert, onDelete, +}: Props) { + const [filter, setFilter] = useState('') + const [showAdd, setShowAdd] = useState(false) + const [editing, setEditing] = useState(null) + const [confirmDelete, setConfirmDelete] = useState(null) + + const rulesById = useMemo( + () => Object.fromEntries(rules.map((r) => [r.id, r])), + [rules], + ) + + const rows = useMemo(() => { + return overrides + .map((o) => { + const rule = rulesById[o.rule_id] + const live = liveVersionsByRule[o.rule_id] + return { override: o, rule, live } + }) + .filter(({ rule }) => { + if (!filter.trim()) return true + const q = filter.toLowerCase() + return ( + (rule?.title || '').toLowerCase().includes(q) || + (rule?.document_type || '').toLowerCase().includes(q) || + (rule?.rule_key || '').toLowerCase().includes(q) + ) + }) + }, [overrides, rulesById, liveVersionsByRule, filter]) + + return ( +
+
+
+

Meine Overrides

+

+ Globale Regeln, die für meine Mandanten abweichend gelten. + {overrides.length > 0 && ` ${overrides.length} aktiv.`} +

+
+
+ setFilter(e.target.value)} + className="text-sm px-2 py-1.5 border border-gray-300 rounded" + /> + +
+
+ +
+ {rows.length === 0 ? ( +
+ {overrides.length === 0 + ? 'Noch keine Overrides angelegt. Klicke oben rechts „+ Override hinzufügen“, um die globale Klassifikation einer Regel für deine Kanzlei abweichend zu setzen.' + : 'Keine Treffer für den Filter.'} +
+ ) : ( + + + + + + + + + + + + + {rows.map(({ override, rule, live }) => ( + + + + + + + + + ))} + +
RegelOriginalMein OverrideGrundErstelltAktionen
+
{rule?.title ?? '(unbekannt)'}
+
+ {rule?.document_type ?? '?'} · {rule?.rule_key ?? '?'} +
+
+ {live ? ( + + ) : ( + + )} + + {override.override_classification ? ( + + ) : ( + + deaktiviert + + )} + + {override.reason} + + {new Date(override.created_at).toLocaleDateString('de-DE')} + {override.created_by && ( +
{override.created_by}
+ )} +
+
+ + +
+
+ )} +
+ + {showAdd && ( + o.rule_id))} + onCancel={() => setShowAdd(false)} + onSubmit={async (payload) => { + await onUpsert(payload) + setShowAdd(false) + }} + /> + )} + + {editing && ( + setEditing(null)} + onSubmit={async (payload) => { + await onUpsert(payload) + setEditing(null) + }} + /> + )} + + {confirmDelete && ( + setConfirmDelete(null)} + onConfirm={async () => { + await onDelete(confirmDelete.id) + setConfirmDelete(null) + }} + /> + )} +
+ ) +} + +function ClassChip({ classification }: { classification: Classification }) { + const colorMap = { + required: 'bg-rose-100 text-rose-800 border-rose-300', + recommended: 'bg-amber-100 text-amber-800 border-amber-300', + optional: 'bg-slate-100 text-slate-700 border-slate-300', + } as const + return ( + + {CLASSIFICATION_LABELS[classification]} + + ) +} + +interface OverrideDialogProps { + title: string + rules: Rule[] + liveVersionsByRule: Record + existingOverrideRuleIds: Set + initial?: TenantRuleOverride + fixedRuleId?: string + onCancel: () => void + onSubmit: (payload: { + rule_id: string + override_classification: Classification | null + reason: string + }) => Promise +} + +function OverrideDialog({ + title, rules, liveVersionsByRule, existingOverrideRuleIds, + initial, fixedRuleId, onCancel, onSubmit, +}: OverrideDialogProps) { + const [ruleId, setRuleId] = useState( + fixedRuleId ?? initial?.rule_id ?? '', + ) + const [classification, setClassification] = useState( + initial?.override_classification ?? 'optional', + ) + const [reason, setReason] = useState(initial?.reason ?? '') + const [submitting, setSubmitting] = useState(false) + const [error, setError] = useState(null) + + const availableRules = useMemo(() => { + if (fixedRuleId) { + // Edit-Mode: nur die eine Regel zeigen + return rules.filter((r) => r.id === fixedRuleId) + } + return rules.filter((r) => !existingOverrideRuleIds.has(r.id)) + }, [rules, existingOverrideRuleIds, fixedRuleId]) + + const selectedRule = rules.find((r) => r.id === ruleId) + const selectedLive = ruleId ? liveVersionsByRule[ruleId] : undefined + + const canSubmit = !!ruleId && reason.trim().length > 0 && !submitting + + const handleSubmit = async () => { + setSubmitting(true) + setError(null) + try { + await onSubmit({ + rule_id: ruleId, + override_classification: classification === 'disabled' ? null : classification, + reason: reason.trim(), + }) + } catch (e) { + setError((e as Error).message) + } finally { + setSubmitting(false) + } + } + + return ( +
+
e.stopPropagation()} + > +
+

{title}

+
+
+
+ + + {selectedRule && selectedLive && ( +
+ Original-Klassifikation: {' '} + · Quelle: {selectedLive.source_citation} +
+ )} +
+ +
+ +
+ {(['required', 'recommended', 'optional', 'disabled'] as const).map((c) => ( + + ))} +
+
+ +
+ +