Fundament: ISMS-Module entfernt; Craftvia-Rollen, Module, Navigation, i18n-Split

- ISMS-Routen, Actions, Server-/Lib-Code, Komponenten, Prisma-Modelle, Seeds,
  Importer, Skripte und ISMS-Tests entfernt (Fundament bleibt: Auth, Identity,
  MFA/WebAuthn, RBAC, Audit, Mail, Storage, Backup/DSGVO, Plattform-Admin)
- Schema auf Fundament-Modelle reduziert; TenantSettings generisch (+phone/email)
- TENANT_MODELS (db.ts, backup/topology.ts) und PII-Felder ausgedünnt
- RBAC: Rollen tenant-admin/backoffice/team-lead/technician + Craftvia-Permissions
- Modul-Katalog (customers, sites, teams, work_orders, imports, field, reports,
  emergency, documents, notifications, lotse) + Navigation aus src/lib/nav.ts
- Modul-Routen mit requireModule-Layout und Platzhalterseite
- Message-Katalog je Namespace (messages/<locale>/<namespace>.json), fs-Loader
- check-module-guards: Modul-Key aus src/server/actions/<moduleKey>/
- Provisionierung, Admin-Konsole, Einstellungen, Files-Route, Mail entkoppelt
- Seed minimal (demo/demo2, Nutzer je Rolle); Fundament-Tests auf Role/
  NotificationPreference-Fixtures umgestellt

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-14 11:35:44 +02:00
co-authored by Claude Opus 5
parent c8e6f30a27
commit 8491c7f173
443 changed files with 1325 additions and 87773 deletions
+12 -168
View File
@@ -7,8 +7,7 @@ import { platformAuth } from "@/server/platform-auth";
import { Button } from "@/components/ui/button";
import { PageHead, Pill } from "@/components/mockup-ui";
import { MODULES } from "@/lib/modules";
import { setTenantStatus, toggleTenantModule, setTenantTisaxLevel, setTenantMfaRequired, setTenantLocale, importPolicyPackageForTenant, setTenantFrameworks } from "@/server/actions/admin";
import { getTenantFrameworks } from "../../../../../prisma/template-store";
import { setTenantStatus, toggleTenantModule, setTenantMfaRequired, setTenantLocale } from "@/server/actions/admin";
import { resolveMfaRequired } from "@/lib/mfa-policy";
import { createTenantUser, updateTenantUser, setTenantUserRoles, setTenantUserStatus } from "@/server/actions/platform-users";
import { enqueueRestore, enqueueExport, enqueueDsgvoExport } from "@/server/actions/backup-admin";
@@ -20,9 +19,6 @@ import { UserTable } from "@/components/user-table";
import { UserCreateForm, UserEditForm } from "@/components/user-forms";
import { AuditTrailModal, type AuditRow } from "@/components/audit-trail";
import { RestoreModalBody, ExportModalBody, DsgvoModalBody, type SnapshotOption, type SubjectOption } from "@/components/backup-admin-panel";
import { provisionIncidentIntake, setIncidentIntakeStatus } from "@/server/actions/incident-intake-admin";
import { intakeAddress } from "@/server/incident-inbound/parse";
import { Siren } from "lucide-react";
const STATUS_TONE: Record<string, "ok" | "warn" | "mut"> = { ACTIVE: "ok", SUSPENDED: "warn", ARCHIVED: "mut" };
@@ -31,7 +27,7 @@ export default async function AdminTenantPage({
searchParams,
}: {
params: Promise<{ id: string }>;
searchParams: Promise<{ new?: string; edit?: string; audit?: string; modules?: string; users?: string; restore?: string; export?: string; dsgvo?: string; intake?: string }>;
searchParams: Promise<{ new?: string; edit?: string; audit?: string; modules?: string; users?: string; restore?: string; export?: string; dsgvo?: string }>;
}) {
// Zugriff (Plattform-Session + MFA) wird im (platform)/layout.tsx erzwungen.
const { id } = await params;
@@ -52,10 +48,6 @@ export default async function AdminTenantPage({
});
if (!tenant) notFound();
const frameworks = await getTenantFrameworks(prisma, tenant.id);
const runsTisax = frameworks.includes("TISAX");
const runsIso = frameworks.includes("ISO_27001");
const STATUS_LABEL: Record<string, string> = { ACTIVE: t("statusActive"), SUSPENDED: t("statusSuspended"), ARCHIVED: t("statusArchived") };
const roleOptions = tenant.roles.map((r) => ({ id: r.id, name: r.name }));
@@ -68,12 +60,6 @@ export default async function AdminTenantPage({
const moduleState = new Map(tenant.modules.map((m) => [m.moduleKey, m.enabled]));
const isOn = (key: string) => moduleState.get(key) ?? true;
// IM-D: Intake-Konfiguration (E-Mail-Eingang für Vorfälle). Nur relevant bei aktivem Modul.
const incidentsEnabled = isOn("incidents");
const intakeConfig = incidentsEnabled
? await prisma.incidentIntakeConfig.findUnique({ where: { tenantId: id } })
: null;
const s = tenant.settings;
// Hauptkontakt (bestätigte Entscheidung): AUS den tenant-admin-Usern ABLEITEN —
@@ -115,7 +101,7 @@ export default async function AdminTenantPage({
const adminMfaEnrolled = currentAdmin?.mfaEnrolledAt != null;
// Sicherungspunkte + Dry-run-Vorschau (Manifest je Snapshot, nur wenn Popup offen).
// FAIL-SAFE: Ein nicht erreichbarer Sicherungsspeicher (S3/MinIO nicht konfiguriert,
// FAIL-SAFE: Ein nicht erreichbarer Sicherungsspeicher (S3/Garage nicht konfiguriert,
// Bucket fehlt, Creds falsch) darf die Betreiber-Konsole NICHT auf eine Fehlerseite
// werfen — die Liste bleibt dann leer und das Popup zeigt einen Hinweis.
const snapshotOptions: SnapshotOption[] = [];
@@ -170,7 +156,7 @@ export default async function AdminTenantPage({
<PageHead
crumb={t("crumb")}
title={tenant.name}
sub={t("sub", { slug: tenant.slug, sector: tenant.sector ? ` · ${tenant.sector}` : "", level: s?.tisaxLevel ?? "AL2" })}
sub={t("sub", { slug: tenant.slug, sector: tenant.sector ? ` · ${tenant.sector}` : "" })}
actions={<Pill tone={STATUS_TONE[tenant.status]}>{STATUS_LABEL[tenant.status]}</Pill>}
/>
</div>
@@ -188,9 +174,8 @@ export default async function AdminTenantPage({
<Field label={t("slug")} value={tenant.slug} />
<Field label={t("sector")} value={s?.sector ?? tenant.sector} empty={t("notSet")} />
<Field label={t("address")} value={s?.address} empty={t("notSet")} />
<Field label={t("duns")} value={s?.duns} empty={t("notSet")} />
<Field label={t("ismsScope")} value={s?.ismsScope} empty={t("notSet")} />
<Field label={t("tisaxLevel")} value={s?.tisaxLevel ?? "AL2"} />
<Field label={t("phone")} value={s?.phone} empty={t("notSet")} />
<Field label={t("email")} value={s?.email} empty={t("notSet")} />
<Field label={t("status")} value={STATUS_LABEL[tenant.status]} />
</dl>
</div>
@@ -226,20 +211,6 @@ export default async function AdminTenantPage({
<Link href={usersBase} scroll={false}>
<Button variant="outline" size="sm" className="gap-2"><Users className="size-4" /> {t("manageUsers")} · {t("usersCount", { count: tenant.users.length })}</Button>
</Link>
{incidentsEnabled && (
<Link href={`${base}?intake=1`} scroll={false}>
<Button variant="outline" size="sm" className="gap-2">
<Siren className="size-4" /> E-Mail-Eingang
{intakeConfig ? (
<Pill tone={intakeConfig.status === "verifiziert" ? "ok" : "warn"}>
{intakeConfig.status === "verifiziert" ? "verifiziert" : "ausstehend"}
</Pill>
) : (
<Pill tone="mut">nicht eingerichtet</Pill>
)}
</Button>
</Link>
)}
</div>
</div>
</div>
@@ -262,55 +233,6 @@ export default async function AdminTenantPage({
<p className="mt-3 text-[11px] text-muted-foreground">{t("lifecycleNote")}</p>
</div>
{/* Normen/Rahmenwerke je Mandant — nachträglich aktivierbar/deaktivierbar */}
<div className="shadow-card rounded-xl border bg-card p-5">
<p className="mb-1 font-heading text-sm font-semibold">{t("frameworksTitle")}</p>
<p className="mb-3 text-[12px] text-muted-foreground">
{t("frameworksHint", { list: frameworks.map((f) => (f === "TISAX" ? t("frameworksTisax") : t("frameworksIso"))).join(" + ") })}
</p>
<div className="space-y-2">
<div className="flex items-center justify-between gap-2">
<span className="text-[13px]">{t("frameworksTisax")}</span>
{runsTisax ? (
<form action={setTenantFrameworks.bind(null, tenant.id, frameworks.filter((f) => f !== "TISAX"))}>
<Button type="submit" variant="outline" size="sm" disabled={!runsIso}>{t("frameworksDeactivate")}</Button>
</form>
) : (
<form action={setTenantFrameworks.bind(null, tenant.id, [...frameworks, "TISAX" as const])}>
<Button type="submit" variant="secondary" size="sm">{t("frameworksActivate")}</Button>
</form>
)}
</div>
<div className="flex items-center justify-between gap-2">
<span className="text-[13px]">{t("frameworksIso")}</span>
{runsIso ? (
<form action={setTenantFrameworks.bind(null, tenant.id, frameworks.filter((f) => f !== "ISO_27001"))}>
<Button type="submit" variant="outline" size="sm" disabled={!runsTisax}>{t("frameworksDeactivate")}</Button>
</form>
) : (
<form action={setTenantFrameworks.bind(null, tenant.id, [...frameworks, "ISO_27001" as const])}>
<Button type="submit" variant="secondary" size="sm">{t("frameworksActivate")}</Button>
</form>
)}
</div>
</div>
<p className="mt-3 text-[11.5px] text-muted-foreground">{t("frameworksNote")}</p>
</div>
{/* Kern-Einstellung: Assessment-Level / Schutzbedarf (nur Superadmin) */}
<div className="shadow-card rounded-xl border bg-card p-5">
<p className="mb-1 font-heading text-sm font-semibold">{t("assessmentTitle")}</p>
<p className="mb-3 text-[12px] text-muted-foreground">{t("assessmentHint", { level: s?.tisaxLevel ?? "AL2" })}</p>
<div className="space-y-2">
<form action={setTenantTisaxLevel.bind(null, tenant.id, "AL2")}>
<Button type="submit" variant={(s?.tisaxLevel ?? "AL2") === "AL2" ? "secondary" : "outline"} size="sm" className="w-full justify-center" disabled={(s?.tisaxLevel ?? "AL2") === "AL2"}>{t("assessmentAl2")}</Button>
</form>
<form action={setTenantTisaxLevel.bind(null, tenant.id, "AL3")}>
<Button type="submit" variant={s?.tisaxLevel === "AL3" ? "secondary" : "outline"} size="sm" className="w-full justify-center" disabled={s?.tisaxLevel === "AL3"}>{t("assessmentAl3")}</Button>
</form>
</div>
</div>
{/* SEC3-a: MFA-Pflicht je Mandant (nur Superadmin) */}
<div className="shadow-card rounded-xl border bg-card p-5">
<p className="mb-1 font-heading text-sm font-semibold">{t("mfaTitle")}</p>
@@ -325,16 +247,16 @@ export default async function AdminTenantPage({
</div>
</div>
{/* Sprachwahl je Mandant (steuert die Import-Sprache des Vorlagenpakets) */}
{/* Standardsprache je Mandant (Benachrichtigungen/Dokumente) */}
<div className="shadow-card rounded-xl border bg-card p-5">
<p className="mb-1 font-heading text-sm font-semibold">{t("policyLangTitle")}</p>
<p className="mb-3 text-[12px] text-muted-foreground">{t("policyLangHint", { lang: s?.locale === "en" ? t("policyLangEn") : t("policyLangDe") })}</p>
<p className="mb-1 font-heading text-sm font-semibold">{t("localeTitle")}</p>
<p className="mb-3 text-[12px] text-muted-foreground">{t("localeHint", { lang: s?.locale === "en" ? t("localeEn") : t("localeDe") })}</p>
<div className="space-y-2">
<form action={setTenantLocale.bind(null, tenant.id, "de")}>
<Button type="submit" variant={(s?.locale ?? "de") === "de" ? "secondary" : "outline"} size="sm" className="w-full justify-center" disabled={(s?.locale ?? "de") === "de"}>{t("policyLangDe")}</Button>
<Button type="submit" variant={(s?.locale ?? "de") === "de" ? "secondary" : "outline"} size="sm" className="w-full justify-center" disabled={(s?.locale ?? "de") === "de"}>{t("localeDe")}</Button>
</form>
<form action={setTenantLocale.bind(null, tenant.id, "en")}>
<Button type="submit" variant={s?.locale === "en" ? "secondary" : "outline"} size="sm" className="w-full justify-center" disabled={s?.locale === "en"}>{t("policyLangEn")}</Button>
<Button type="submit" variant={s?.locale === "en" ? "secondary" : "outline"} size="sm" className="w-full justify-center" disabled={s?.locale === "en"}>{t("localeEn")}</Button>
</form>
</div>
</div>
@@ -407,16 +329,9 @@ export default async function AdminTenantPage({
<div key={m.key} className="flex items-center justify-between gap-3 py-2.5">
<div>
<p className="text-sm font-medium">{m.name}</p>
<p className="text-[11px] text-muted-foreground">{m.href}</p>
<p className="text-[11px] text-muted-foreground">{m.href ?? m.key}</p>
</div>
<div className="flex items-center gap-2">
{m.key === "policies" && on && (
<form action={importPolicyPackageForTenant.bind(null, tenant.id)}>
<button type="submit" className="rounded-full border px-3 py-1 text-[12px] font-medium hover:bg-muted" title={t("importTemplatesTitle")}>
{t("importTemplates")}
</button>
</form>
)}
<Pill tone={on ? "ok" : "mut"}>{on ? t("moduleActive") : t("moduleInactive")}</Pill>
<form action={toggleTenantModule.bind(null, tenant.id, m.key, !on)}>
<Button type="submit" size="sm" variant="outline">{on ? t("moduleDeactivate") : t("moduleActivate")}</Button>
@@ -484,77 +399,6 @@ export default async function AdminTenantPage({
<DsgvoModalBody tenantId={tenant.id} action={enqueueDsgvoExport} subjects={subjectOptions} mfaEnrolled={adminMfaEnrolled} />
</Modal>
)}
{/* IM-D: E-Mail-Eingang provisionieren → Popup (?intake=1) */}
{incidentsEnabled && sp.intake && (
<Modal title="E-Mail-Eingang für Vorfälle" sub={`Intake-Provisionierung — ${tenant.name}`} closeHref={base} closeLabel={t("close")}>
<div className="space-y-5 p-5">
<div className="space-y-1.5">
<p className="text-[11px] font-medium uppercase tracking-wide text-muted-foreground">Intake-Adresse</p>
{intakeConfig ? (
<div className="flex flex-wrap items-center gap-2">
<code className="rounded-md border bg-muted px-2.5 py-1.5 text-[13px] font-mono select-all">{intakeAddress(intakeConfig.token)}</code>
<Pill tone={intakeConfig.status === "verifiziert" ? "ok" : "warn"}>
{intakeConfig.status === "verifiziert" ? "verifiziert" : "Weiterleitung ausstehend"}
</Pill>
</div>
) : (
<p className="text-[12.5px] text-muted-foreground">Wird beim Speichern erzeugt (global eindeutiger Token).</p>
)}
<p className="text-[11.5px] text-muted-foreground">
Der Kunde richtet eine Weiterleitung von seiner Adresse auf diese Intake-Adresse ein. Aus jeder
eingehenden Mail (erlaubte Domäne + DKIM) wird automatisch ein Vorfall.
</p>
</div>
<form action={provisionIncidentIntake.bind(null, tenant.id)} className="space-y-3 border-t pt-4">
<div className="space-y-1.5">
<label htmlFor="intake-domains" className="text-[12.5px] font-medium">Erlaubte Absender-Domänen</label>
<textarea
id="intake-domains"
name="allowlistDomains"
rows={3}
className="w-full rounded-md border border-input bg-transparent px-3 py-2 text-sm"
placeholder={"kunde.de\nit.kunde.de"}
defaultValue={(intakeConfig?.allowlistDomains ?? []).join("\n")}
/>
<p className="text-[11px] text-muted-foreground">Eine je Zeile. Pflicht für den Automatikbetrieb.</p>
</div>
<div className="space-y-1.5">
<label htmlFor="intake-source" className="text-[12.5px] font-medium">Quelladresse (optional)</label>
<input
id="intake-source"
name="sourceAddress"
type="email"
className="h-9 w-full rounded-md border border-input bg-transparent px-3 text-sm"
placeholder="vorfall@kunde.de"
defaultValue={intakeConfig?.sourceAddress ?? ""}
/>
</div>
<div className="flex justify-end">
<Button type="submit" size="sm">{intakeConfig ? "Speichern" : "Intake anlegen"}</Button>
</div>
</form>
{intakeConfig && (
<div className="flex items-center justify-between border-t pt-4">
<p className="text-[12px] text-muted-foreground">
Status manuell setzen (i. d. R. automatisch bei erster Test-Mail).
</p>
{intakeConfig.status === "verifiziert" ? (
<form action={setIncidentIntakeStatus.bind(null, tenant.id, false)}>
<Button type="submit" variant="ghost" size="sm">Auf „ausstehend“ zurücksetzen</Button>
</form>
) : (
<form action={setIncidentIntakeStatus.bind(null, tenant.id, true)}>
<Button type="submit" variant="outline" size="sm">Als verifiziert markieren</Button>
</form>
)}
</div>
)}
</div>
</Modal>
)}
</main>
);
}
+6 -115
View File
@@ -16,49 +16,22 @@ import {
import { createTenant } from "@/server/actions/admin";
import { getMailStatus } from "@/server/actions/mail";
import { MailStatusPanel } from "@/components/mail-status-panel";
import { resolveInboundReview } from "@/server/actions/incident-intake-admin";
import { intakeAddress } from "@/server/incident-inbound/parse";
import { Siren } from "lucide-react";
const REVIEW_REASON_LABEL: Record<string, string> = {
no_token: "kein Token",
unknown_token: "unbekannter Token",
allowlist_failed: "Absender nicht in Allowlist",
dkim_failed: "DKIM fehlgeschlagen",
};
const STATUS_TONE: Record<string, "ok" | "warn" | "mut"> = { ACTIVE: "ok", SUSPENDED: "warn", ARCHIVED: "mut" };
const STATUS_LABEL: Record<string, string> = { ACTIVE: "Aktiv", SUSPENDED: "Gesperrt", ARCHIVED: "Archiviert" };
const inputCls = "h-9 w-full rounded-md border border-input bg-transparent px-3 text-sm";
export default async function AdminPage({ searchParams }: { searchParams: Promise<{ new?: string }> }) {
// Zugriff (Plattform-Session + MFA) wird im (platform)/layout.tsx erzwungen.
const params = await searchParams;
const [tenants, mailStatus, pendingIntake, openReviews] = await Promise.all([
const [tenants, mailStatus] = await Promise.all([
prisma.tenant.findMany({
include: { _count: { select: { users: true } }, modules: true },
orderBy: { createdAt: "asc" },
}),
// SEC1: Betriebszustand der Mail-Strecke + Testversand.
getMailStatus(),
// IM-D: Kunden, deren E-Mail-Weiterleitung noch nicht verifiziert ist.
prisma.incidentIntakeConfig.findMany({
where: { status: "weiterleitung_ausstehend" },
select: { tenantId: true, token: true },
}),
// IM-D: Inbound-Mails ohne/mit unbekanntem Token → Betreiber-Sichtung.
prisma.incidentInboundReview.findMany({
where: { status: "offen" },
orderBy: { receivedAt: "desc" },
take: 50,
select: { id: true, sender: true, subject: true, recipient: true, reason: true, receivedAt: true },
}),
]);
// Mandantennamen zu den offenen Verifizierungen (mandantenübergreifende Betreiber-Sicht).
const pendingTenantNames = new Map(
tenants.filter((t) => pendingIntake.some((p) => p.tenantId === t.id)).map((t) => [t.id, t.name]),
);
return (
<main className="flex-1 p-6">
@@ -82,7 +55,7 @@ export default async function AdminPage({ searchParams }: { searchParams: Promis
<form action={createTenant} className="grid gap-4 md:grid-cols-2">
<div>
<Label htmlFor="name">Firmenname *</Label>
<Input id="name" name="name" required className="mt-1" placeholder="Muster GmbH" />
<Input id="name" name="name" required className="mt-1" placeholder="Musterbau GmbH" />
</div>
<div>
<Label htmlFor="short">Kurzname</Label>
@@ -93,12 +66,12 @@ export default async function AdminPage({ searchParams }: { searchParams: Promis
<Input id="slug" name="slug" className="mt-1" placeholder="wird aus Name erzeugt" />
</div>
<div>
<Label htmlFor="sector">Sektor</Label>
<Input id="sector" name="sector" className="mt-1" placeholder="z. B. Automotive-Zulieferer" />
<Label htmlFor="sector">Gewerk / Branche</Label>
<Input id="sector" name="sector" className="mt-1" placeholder="z. B. Sanitär, Heizung, Klima" />
</div>
<div>
<Label htmlFor="adminEmail">Admin-E-Mail *</Label>
<Input id="adminEmail" name="adminEmail" type="email" required className="mt-1" placeholder="admin@muster.example" />
<Input id="adminEmail" name="adminEmail" type="email" required className="mt-1" placeholder="admin@betrieb.example" />
</div>
<div>
<Label htmlFor="adminName">Admin-Name</Label>
@@ -108,37 +81,13 @@ export default async function AdminPage({ searchParams }: { searchParams: Promis
<Label htmlFor="adminPassword">Initial-Passwort * (min. 8)</Label>
<Input id="adminPassword" name="adminPassword" type="text" required className="mt-1" placeholder="wird dem Admin mitgeteilt" />
</div>
<div>
<Label htmlFor="tisaxLevel">TISAX-Level</Label>
<select id="tisaxLevel" name="tisaxLevel" defaultValue="AL2" className={`${inputCls} mt-1`}>
<option value="AL2">AL2 (MUSS · SOLL · HOCH)</option>
<option value="AL3">AL3 (+ SEHR HOCH)</option>
</select>
</div>
<div className="md:col-span-2">
<Label>Rahmenwerk(e)</Label>
<div className="mt-1 flex flex-wrap gap-4 text-sm">
<label className="flex items-center gap-2">
<input type="checkbox" name="fw_tisax" defaultChecked /> TISAX / VDA ISA
</label>
<label className="flex items-center gap-2">
<input type="checkbox" name="fw_iso" /> ISO/IEC 27001
</label>
</div>
<p className="mt-1 text-[11.5px] text-muted-foreground">
Mind. eines wählen (nichts gewählt = TISAX). Bei beiden ist TISAX das Primär-Framework; die Anforderungssichten koexistieren je Dokument.
</p>
</div>
<label className="flex items-center gap-2 text-sm md:col-span-2">
<input type="checkbox" name="seedPolicies" defaultChecked /> Richtlinienpaket beim Anlegen ausrollen (je gewähltem Rahmenwerk)
</label>
<div className="flex gap-2 md:col-span-2">
<Button type="submit">Kunde anlegen & provisionieren</Button>
<Button variant="outline" nativeButton={false} render={<Link href="/admin" />}>Abbrechen</Button>
</div>
</form>
<p className="mt-3 text-[11.5px] text-muted-foreground">
Beim Anlegen werden automatisch ausgerollt: Standard-Rollen, erster Admin-User, alle Module, Mandanten-Einstellungen und der TISAX-Default (idempotent, protokolliert).
Beim Anlegen werden automatisch ausgerollt: Standard-Rollen (Mandantenadministrator, Backoffice, Teamleiter, Monteur), erster Admin-User, alle Module und Mandanten-Einstellungen (idempotent, protokolliert).
</p>
</div>
)}
@@ -174,64 +123,6 @@ export default async function AdminPage({ searchParams }: { searchParams: Promis
</Table>
</div>
{/* IM-D: E-Mail-Eingang für Vorfälle — Provisionierungsstatus + Review-Queue. */}
{(pendingIntake.length > 0 || openReviews.length > 0) && (
<div className="shadow-card mt-4 rounded-xl border bg-card p-5">
<div className="mb-3 flex items-center gap-2">
<Siren className="size-4 text-[var(--primary)]" />
<p className="font-heading text-sm font-semibold">E-Mail-Eingang für Vorfälle</p>
</div>
{pendingIntake.length > 0 && (
<div className="mb-4">
<p className="mb-1.5 text-[12.5px] font-semibold text-muted-foreground">
Weiterleitung ausstehend ({pendingIntake.length})
</p>
<ul className="space-y-1.5">
{pendingIntake.map((p) => (
<li key={p.tenantId} className="flex flex-wrap items-center gap-2 text-[12.5px]">
<Pill tone="warn">ausstehend</Pill>
<Link href={`/admin/${p.tenantId}`} className="font-semibold hover:underline">
{pendingTenantNames.get(p.tenantId) ?? p.tenantId}
</Link>
<code className="font-mono text-muted-foreground">{intakeAddress(p.token)}</code>
</li>
))}
</ul>
<p className="mt-1.5 text-[11px] text-muted-foreground">
Der Status wechselt automatisch auf „verifiziert“, sobald die erste weitergeleitete Test-Mail als Vorfall ankommt.
</p>
</div>
)}
{openReviews.length > 0 && (
<div className="border-t pt-3">
<p className="mb-1.5 text-[12.5px] font-semibold text-muted-foreground">
Zu prüfende Eingänge ({openReviews.length})
</p>
<ul className="space-y-2">
{openReviews.map((r) => (
<li key={r.id} className="flex flex-wrap items-center gap-2 rounded-lg border bg-muted/30 px-3 py-2 text-[12px]">
<Pill tone="mut">{REVIEW_REASON_LABEL[r.reason] ?? r.reason}</Pill>
<span className="font-mono">{r.sender}</span>
<span className="text-muted-foreground">{r.subject || "(ohne Betreff)"}</span>
{r.recipient && <span className="text-muted-foreground">→ {r.recipient}</span>}
<span className="ml-auto flex gap-1.5">
<form action={resolveInboundReview.bind(null, r.id, "zugeordnet")}>
<Button type="submit" variant="outline" size="sm">Zugeordnet</Button>
</form>
<form action={resolveInboundReview.bind(null, r.id, "erledigt")}>
<Button type="submit" variant="ghost" size="sm">Erledigt</Button>
</form>
</span>
</li>
))}
</ul>
</div>
)}
</div>
)}
<MailStatusPanel status={mailStatus} />
</main>
);
+2 -3
View File
@@ -5,7 +5,7 @@ import { prisma } from "@/server/db";
import { getPlatformSettings } from "@/server/platform-settings";
import { platformSignOutAction } from "@/server/actions/platform";
import { Button } from "@/components/ui/button";
import { CertviaLogo } from "@/components/brand/certvia-logo";
import { CraftviaLogo } from "@/components/brand/craftvia-logo";
import { BRAND } from "@/lib/brand";
/**
@@ -30,11 +30,10 @@ export default async function PlatformLayout({ children }: Readonly<{ children:
<div className="flex min-h-screen flex-col">
<header className="flex items-center justify-between border-b bg-[var(--panel)] px-6 py-3">
<Link href="/admin" className="flex items-center gap-2.5 font-heading text-sm font-semibold">
<CertviaLogo variant="mark" theme="dark" height={22} />
<CraftviaLogo variant="signet" height={22} />
{BRAND.name} · Plattform-Administration
</Link>
<div className="flex items-center gap-4 text-sm">
<Link href="/templates" className="text-muted-foreground hover:text-foreground">Vorlagen</Link>
<Link href="/admin/backup" className="text-muted-foreground hover:text-foreground">Backup-Ziel</Link>
<span className="text-muted-foreground">
{admin.name} · {admin.email}
@@ -1,73 +0,0 @@
import Link from "next/link";
import { notFound } from "next/navigation";
import { ArrowLeft } from "lucide-react";
import { prisma } from "@/server/db";
import { PageHead, Pill } from "@/components/mockup-ui";
import { Button } from "@/components/ui/button";
import { PolicyExpertEditor } from "@/components/policy-expert-editor";
import { updateTemplateDoc } from "@/server/actions/policy-templates";
/**
* Vorlagen-Dokument-Editor (Plattform, Phase 2). Bearbeitet ein Dokument im ENTWURF
* (rawMarkdown, wie im Mandanten-Experten-Editor). Veröffentlichte Versionen: nur Lesen.
*/
export default async function TemplateDocEditor({ params, searchParams }: { params: Promise<{ locale: string; code: string }>; searchParams: Promise<{ framework?: string }> }) {
const p = await params;
const locale = p.locale === "en" ? "en" : "de";
const code = decodeURIComponent(p.code);
const framework = (await searchParams).framework === "ISO_27001" ? "ISO_27001" : "TISAX";
const [draft, published] = await Promise.all([
prisma.policyTemplateVersion.findFirst({ where: { status: "DRAFT", framework }, orderBy: { createdAt: "desc" } }),
prisma.policyTemplateVersion.findFirst({ where: { status: "PUBLISHED", framework }, orderBy: [{ publishedAt: "desc" }, { createdAt: "desc" }] }),
]);
const version = draft ?? published;
if (!version) notFound();
const doc = await prisma.policyTemplateDoc.findUnique({ where: { versionId_locale_code: { versionId: version.id, locale, code } } });
if (!doc) notFound();
const editable = version.status === "DRAFT";
const [vars, docs] = await Promise.all([
prisma.policyTemplateVariable.findMany({ where: { versionId: version.id, locale }, orderBy: { orderIdx: "asc" }, select: { key: true, title: true } }),
prisma.policyTemplateDoc.findMany({ where: { versionId: version.id, locale }, orderBy: { orderIdx: "asc" }, select: { code: true, title: true } }),
]);
return (
<main className="flex-1 p-6">
<PageHead
crumb={`Vorlagen · ${locale.toUpperCase()}`}
title={`${doc.code} — ${doc.title}`}
sub={editable
? "Entwurf bearbeiten. Fehlende {{Variablen}} werden beim Speichern angelegt."
: "Veröffentlichte Version — nur Lesen. Zum Bearbeiten einen Entwurf anlegen."}
actions={
<div className="flex items-center gap-2">
{editable ? <Pill tone="warn">Entwurf</Pill> : <Pill tone="ok">Veröffentlicht</Pill>}
<Button variant="outline" size="sm" nativeButton={false} render={<Link href={`/templates?locale=${locale}&framework=${framework}`} />}>
<ArrowLeft className="mr-1 size-3.5" />Zurück
</Button>
{editable && <Button type="submit" form="tpl-doc" size="sm">Speichern</Button>}
</div>
}
/>
<div className="shadow-card mt-4 rounded-xl border bg-card p-4">
{editable ? (
<PolicyExpertEditor
formId="tpl-doc"
saveAction={updateTemplateDoc.bind(null, doc.id)}
initialMarkdown={doc.rawMarkdown}
variables={vars}
docs={docs}
/>
) : (
<textarea
readOnly
value={doc.rawMarkdown}
rows={32}
className="w-full rounded-xl border border-input bg-transparent p-3 font-mono text-[12px] leading-relaxed outline-none"
/>
)}
</div>
</main>
);
}
@@ -1,106 +0,0 @@
import Link from "next/link";
import { notFound } from "next/navigation";
import { ArrowLeft, Trash2 } from "lucide-react";
import { prisma } from "@/server/db";
import { PageHead, Pill } from "@/components/mockup-ui";
import { Button } from "@/components/ui/button";
import { upsertTemplateRequirement, deleteTemplateRequirement } from "@/server/actions/policy-templates";
/**
* Vorlagen-Anforderungen (Control-Mapping) je Sprache. Bearbeitbar nur im Entwurf.
*/
const inp = "h-8 w-full rounded-md border border-input bg-transparent px-2 text-[12.5px]";
export default async function TemplateRequirementsPage({ params, searchParams }: { params: Promise<{ locale: string }>; searchParams: Promise<{ framework?: string }> }) {
const p = await params;
const locale = p.locale === "en" ? "en" : "de";
const framework = (await searchParams).framework === "ISO_27001" ? "ISO_27001" : "TISAX";
const [draft, published] = await Promise.all([
prisma.policyTemplateVersion.findFirst({ where: { status: "DRAFT", framework }, orderBy: { createdAt: "desc" } }),
prisma.policyTemplateVersion.findFirst({ where: { status: "PUBLISHED", framework }, orderBy: [{ publishedAt: "desc" }, { createdAt: "desc" }] }),
]);
const version = draft ?? published;
if (!version) notFound();
const editable = version.status === "DRAFT";
const reqs = await prisma.policyTemplateRequirement.findMany({ where: { versionId: version.id, locale }, orderBy: { orderIdx: "asc" } });
return (
<main className="flex-1 p-6">
<PageHead
crumb={`Vorlagen · ${locale.toUpperCase()}`}
title="Anforderungen (Control-Mapping)"
sub={editable ? "Entwurf bearbeiten — Anforderungen je Control." : "Veröffentlichte Version — nur Lesen."}
actions={
<div className="flex items-center gap-2">
{editable ? <Pill tone="warn">Entwurf</Pill> : <Pill tone="ok">Veröffentlicht</Pill>}
<Button variant="outline" size="sm" nativeButton={false} render={<Link href={`/templates?locale=${locale}&framework=${framework}`} />}>
<ArrowLeft className="mr-1 size-3.5" />Zurück
</Button>
</div>
}
/>
<div className="shadow-card mt-4 rounded-xl border bg-card p-4">
<p className="mb-3 font-heading text-sm font-semibold">{reqs.length} Anforderungen</p>
{reqs.length === 0 && <p className="text-[13px] text-muted-foreground">Keine Anforderungen in dieser Sprache.</p>}
<div className="space-y-1.5">
{reqs.map((r) => (
<details key={r.id} className="rounded-lg border">
<summary className="flex cursor-pointer items-center gap-3 px-3 py-2 text-[13px]">
<span className="font-mono text-[12px]">{r.reqId}</span>
<span className="text-muted-foreground">{r.control}</span>
<span className="truncate">{r.requirement}</span>
<Pill tone={r.obligation === "MUSS" ? "warn" : "mut"}>{r.obligation}</Pill>
</summary>
{editable ? (
<div className="border-t p-3">
<form action={upsertTemplateRequirement} className="grid grid-cols-2 gap-2 lg:grid-cols-4">
<input type="hidden" name="id" value={r.id} />
<label className="text-[11.5px] text-muted-foreground">Control<input name="control" defaultValue={r.control} className={inp} /></label>
<label className="text-[11.5px] text-muted-foreground">Pflicht
<select name="obligation" defaultValue={r.obligation} className={inp}><option>MUSS</option><option>SOLL</option></select>
</label>
<label className="text-[11.5px] text-muted-foreground">Richtlinie (policyCode)<input name="policyCode" defaultValue={r.policyCode} className={inp} /></label>
<label className="text-[11.5px] text-muted-foreground">Bedingung (Flag)<input name="condition" defaultValue={r.condition ?? ""} className={inp} /></label>
<label className="col-span-2 text-[11.5px] text-muted-foreground lg:col-span-4">Anforderung<textarea name="requirement" defaultValue={r.requirement} rows={2} className="w-full rounded-md border border-input bg-transparent px-2 py-1 text-[12.5px]" /></label>
<label className="col-span-2 text-[11.5px] text-muted-foreground lg:col-span-4">Umsetzung<textarea name="implementation" defaultValue={r.implementation} rows={2} className="w-full rounded-md border border-input bg-transparent px-2 py-1 text-[12.5px]" /></label>
<label className="col-span-2 text-[11.5px] text-muted-foreground">Verfahren (VA, Komma)<input name="vaCodes" defaultValue={r.vaCodes.join(", ")} className={inp} /></label>
<label className="col-span-2 text-[11.5px] text-muted-foreground">Nachweis-Link<input name="nachweisLink" defaultValue={r.nachweisLink ?? ""} className={inp} /></label>
<input type="hidden" name="reqId" value={r.reqId} />
<div className="col-span-2 mt-1 flex items-center gap-2 lg:col-span-4">
<Button type="submit" size="sm">Speichern</Button>
</div>
</form>
<form action={deleteTemplateRequirement.bind(null, r.id)} className="mt-2 border-t pt-2">
<Button type="submit" variant="destructive" size="sm"><Trash2 className="mr-1 size-3.5" />Anforderung löschen</Button>
</form>
</div>
) : (
<div className="border-t p-3 text-[12.5px]">
<p className="mb-1"><span className="text-muted-foreground">Anforderung:</span> {r.requirement}</p>
<p><span className="text-muted-foreground">Umsetzung:</span> {r.implementation}</p>
</div>
)}
</details>
))}
</div>
{editable && (
<form action={upsertTemplateRequirement} className="mt-4 grid grid-cols-2 gap-2 border-t pt-4 lg:grid-cols-4">
<p className="col-span-2 font-heading text-[13px] font-semibold lg:col-span-4">Neue Anforderung</p>
<input type="hidden" name="locale" value={locale} />
<input type="hidden" name="framework" value={framework} />
<label className="text-[11.5px] text-muted-foreground">Anforderungs-ID<input name="reqId" required placeholder="4.1.2-M1" className={inp} /></label>
<label className="text-[11.5px] text-muted-foreground">Control<input name="control" placeholder="4.1.2" className={inp} /></label>
<label className="text-[11.5px] text-muted-foreground">Pflicht<select name="obligation" className={inp}><option>MUSS</option><option>SOLL</option></select></label>
<label className="text-[11.5px] text-muted-foreground">Richtlinie (policyCode)<input name="policyCode" placeholder="R08" className={inp} /></label>
<label className="col-span-2 text-[11.5px] text-muted-foreground lg:col-span-4">Anforderung<textarea name="requirement" rows={2} className="w-full rounded-md border border-input bg-transparent px-2 py-1 text-[12.5px]" /></label>
<label className="col-span-2 text-[11.5px] text-muted-foreground lg:col-span-4">Umsetzung<textarea name="implementation" rows={2} className="w-full rounded-md border border-input bg-transparent px-2 py-1 text-[12.5px]" /></label>
<div className="col-span-2 lg:col-span-4"><Button type="submit" size="sm">Anforderung anlegen</Button></div>
</form>
)}
</div>
</main>
);
}
@@ -1,114 +0,0 @@
import Link from "next/link";
import { notFound } from "next/navigation";
import { ArrowLeft, Trash2 } from "lucide-react";
import { prisma } from "@/server/db";
import { PageHead, Pill } from "@/components/mockup-ui";
import { Button } from "@/components/ui/button";
import { upsertTemplateVariable, deleteTemplateVariable } from "@/server/actions/policy-templates";
/**
* Vorlagen-Variablen ({{Platzhalter}} / Feature-Flags) je Sprache. Bearbeitbar nur im Entwurf.
*/
const inp = "h-8 w-full rounded-md border border-input bg-transparent px-2 text-[12.5px]";
export default async function TemplateVariablesPage({ params, searchParams }: { params: Promise<{ locale: string }>; searchParams: Promise<{ framework?: string }> }) {
const p = await params;
const locale = p.locale === "en" ? "en" : "de";
const framework = (await searchParams).framework === "ISO_27001" ? "ISO_27001" : "TISAX";
const [draft, published] = await Promise.all([
prisma.policyTemplateVersion.findFirst({ where: { status: "DRAFT", framework }, orderBy: { createdAt: "desc" } }),
prisma.policyTemplateVersion.findFirst({ where: { status: "PUBLISHED", framework }, orderBy: [{ publishedAt: "desc" }, { createdAt: "desc" }] }),
]);
const version = draft ?? published;
if (!version) notFound();
const editable = version.status === "DRAFT";
const vars = await prisma.policyTemplateVariable.findMany({ where: { versionId: version.id, locale }, orderBy: [{ groupName: "asc" }, { orderIdx: "asc" }] });
return (
<main className="flex-1 p-6">
<PageHead
crumb={`Vorlagen · ${locale.toUpperCase()}`}
title="Variablen"
sub={editable ? "Entwurf bearbeiten — Platzhalter und Feature-Flags." : "Veröffentlichte Version — nur Lesen."}
actions={
<div className="flex items-center gap-2">
{editable ? <Pill tone="warn">Entwurf</Pill> : <Pill tone="ok">Veröffentlicht</Pill>}
<Button variant="outline" size="sm" nativeButton={false} render={<Link href={`/templates?locale=${locale}&framework=${framework}`} />}>
<ArrowLeft className="mr-1 size-3.5" />Zurück
</Button>
</div>
}
/>
<div className="shadow-card mt-4 rounded-xl border bg-card p-4">
<p className="mb-3 font-heading text-sm font-semibold">{vars.length} Variablen</p>
{vars.length === 0 && <p className="text-[13px] text-muted-foreground">Keine Variablen in dieser Sprache.</p>}
<div className="overflow-x-auto">
<table className="w-full text-[12.5px]">
<thead className="text-muted-foreground">
<tr className="border-b">
<th className="py-1.5 pr-2 text-left font-medium">Key</th>
<th className="py-1.5 pr-2 text-left font-medium">Titel</th>
<th className="py-1.5 pr-2 text-left font-medium">Typ</th>
<th className="py-1.5 pr-2 text-left font-medium">Gruppe</th>
<th className="py-1.5 pr-2 text-left font-medium">Default</th>
{editable && <th className="py-1.5 pr-2"></th>}
</tr>
</thead>
<tbody className="divide-y">
{vars.map((v) => (
<tr key={v.id}>
{editable ? (
<>
<td className="py-1 pr-2 font-mono text-[12px] align-middle">{v.key}</td>
<td className="py-1 pr-2" colSpan={4}>
<form action={upsertTemplateVariable} className="flex flex-wrap items-center gap-1.5">
<input type="hidden" name="id" value={v.id} />
<input name="title" defaultValue={v.title} className={inp + " w-40"} />
<select name="kind" defaultValue={v.kind} className={inp + " w-24"}><option value="string">string</option><option value="boolean">boolean</option></select>
<input name="groupName" defaultValue={v.groupName ?? ""} className={inp + " w-32"} placeholder="Gruppe" />
<input name="value" defaultValue={v.value} className={inp + " w-32"} placeholder="Default" />
<label className="flex items-center gap-1 text-[11.5px] text-muted-foreground"><input type="checkbox" name="required" defaultChecked={v.required} />Pflicht</label>
<Button type="submit" size="xs">Speichern</Button>
</form>
</td>
<td className="py-1 pr-2 text-right align-middle">
<form action={deleteTemplateVariable.bind(null, v.id)}>
<Button type="submit" variant="ghost" size="icon-xs" title="Löschen"><Trash2 className="size-3.5" /></Button>
</form>
</td>
</>
) : (
<>
<td className="py-1.5 pr-2 font-mono text-[12px]">{v.key}</td>
<td className="py-1.5 pr-2">{v.title}</td>
<td className="py-1.5 pr-2 text-muted-foreground">{v.kind}</td>
<td className="py-1.5 pr-2 text-muted-foreground">{v.groupName ?? "—"}</td>
<td className="py-1.5 pr-2 font-mono text-[12px]">{v.value || "—"}</td>
</>
)}
</tr>
))}
</tbody>
</table>
</div>
{editable && (
<form action={upsertTemplateVariable} className="mt-4 flex flex-wrap items-end gap-2 border-t pt-4">
<p className="w-full font-heading text-[13px] font-semibold">Neue Variable</p>
<input type="hidden" name="locale" value={locale} />
<input type="hidden" name="framework" value={framework} />
<label className="flex flex-col gap-1 text-[11.5px] text-muted-foreground">Key<input name="key" required placeholder="PW_MIN_LENGTH" className={inp + " w-44"} /></label>
<label className="flex flex-col gap-1 text-[11.5px] text-muted-foreground">Titel<input name="title" className={inp + " w-44"} /></label>
<label className="flex flex-col gap-1 text-[11.5px] text-muted-foreground">Typ<select name="kind" className={inp + " w-28"}><option value="string">string</option><option value="boolean">boolean</option></select></label>
<label className="flex flex-col gap-1 text-[11.5px] text-muted-foreground">Gruppe<input name="groupName" className={inp + " w-36"} /></label>
<label className="flex flex-col gap-1 text-[11.5px] text-muted-foreground">Default<input name="value" className={inp + " w-32"} /></label>
<label className="flex items-center gap-1 pb-1.5 text-[11.5px] text-muted-foreground"><input type="checkbox" name="required" />Pflicht</label>
<Button type="submit" size="sm">Variable anlegen</Button>
</form>
)}
</div>
</main>
);
}
-195
View File
@@ -1,195 +0,0 @@
import Link from "next/link";
import { FileText, Plus, Trash2, Pencil, Rocket } from "lucide-react";
import { prisma } from "@/server/db";
import { PageHead, Pill } from "@/components/mockup-ui";
import { Button } from "@/components/ui/button";
import { startDraftAction, discardDraft, createTemplateDoc, publishDraft } from "@/server/actions/policy-templates";
/**
* Richtlinien-Vorlagen (Plattform, Phase 2). Bearbeitung des aktuellen ENTWURFS
* (DRAFT); veröffentlichte Versionen sind unveränderlich. Sprache (de|en) per ?locale.
* Zugriff (Plattform-Session + MFA) wird im (platform)/layout.tsx erzwungen.
*/
const TYPE_LABEL: Record<string, string> = {
LEITLINIE: "Leitlinie", RICHTLINIE: "Richtlinie", VERFAHREN: "Verfahren",
REGISTER: "Register", HANDBUCH: "Handbuch", EIGENES: "Eigenes",
};
const LOCALE_LABEL: Record<string, string> = { de: "Deutsch", en: "English" };
const FRAMEWORK_LABEL: Record<string, string> = { TISAX: "TISAX / VDA ISA", ISO_27001: "ISO/IEC 27001" };
export default async function TemplatesPage({ searchParams }: { searchParams: Promise<{ locale?: string; framework?: string }> }) {
const sp = await searchParams;
const locale = sp.locale === "en" ? "en" : "de";
// Editor-Framework (Default TISAX). ISO-Vorlagen sind jetzt genauso editier-/
// versionierbar wie TISAX — nur im jeweils gewählten Namensraum.
const framework = sp.framework === "ISO_27001" ? "ISO_27001" : "TISAX";
const [draft, published] = await Promise.all([
prisma.policyTemplateVersion.findFirst({ where: { status: "DRAFT", framework }, orderBy: { createdAt: "desc" } }),
prisma.policyTemplateVersion.findFirst({ where: { status: "PUBLISHED", framework }, orderBy: [{ publishedAt: "desc" }, { createdAt: "desc" }] }),
]);
const activeVersion = draft ?? published;
const editable = !!draft;
const docs = activeVersion
? await prisma.policyTemplateDoc.findMany({ where: { versionId: activeVersion.id, locale }, orderBy: { orderIdx: "asc" } })
: [];
const tab = (l: string) => (
<Button
key={l}
variant={l === locale ? "default" : "outline"}
size="sm"
nativeButton={false}
render={<Link href={`/templates?locale=${l}&framework=${framework}`} />}
>
{LOCALE_LABEL[l]}
</Button>
);
const fwTab = (f: "TISAX" | "ISO_27001") => (
<Button
key={f}
variant={f === framework ? "default" : "outline"}
size="sm"
nativeButton={false}
render={<Link href={`/templates?locale=${locale}&framework=${f}`} />}
>
{FRAMEWORK_LABEL[f]}
</Button>
);
return (
<main className="flex-1 p-6">
<PageHead
crumb="Plattform"
title="Richtlinien-Vorlagen"
sub="Master-Vorlagen bearbeiten und versionieren. Neue Mandanten erhalten die veröffentlichte Version automatisch; bestehende werden informiert und übernehmen selbst."
actions={
<div className="flex flex-wrap items-center gap-2">
<div className="flex gap-1">{(["TISAX", "ISO_27001"] as const).map(fwTab)}</div>
<span className="mx-1 h-5 w-px bg-border" />
<div className="flex gap-1">{["de", "en"].map(tab)}</div>
</div>
}
/>
{!activeVersion ? (
<div className="shadow-card mt-4 rounded-xl border bg-card p-6 text-[13px] text-muted-foreground">
Es sind noch keine Vorlagen in der Datenbank. Bitte einmalig den Sync ausführen:
<code className="mx-1 rounded bg-[var(--elevated)] px-1.5 py-0.5">npx tsx scripts/sync-policy-templates.ts</code>
(überführt das Datei-Paket als veröffentlichte Version).
</div>
) : (
<div className="mt-4 grid gap-4 lg:grid-cols-[320px_1fr]">
{/* Version / Entwurf */}
<div className="shadow-card h-fit rounded-xl border bg-card p-4 text-[13px]">
<p className="mb-2 font-heading text-sm font-semibold">Version</p>
<dl className="space-y-1.5">
<div className="flex justify-between gap-2"><dt className="text-muted-foreground">Rahmenwerk</dt><dd className="font-medium">{FRAMEWORK_LABEL[framework]}</dd></div>
<div className="flex justify-between gap-2"><dt className="text-muted-foreground">Veröffentlicht</dt><dd className="font-medium">{published?.version ?? "—"}</dd></div>
<div className="flex justify-between gap-2"><dt className="text-muted-foreground">Entwurf</dt><dd className="font-medium">{draft?.version ?? "—"}</dd></div>
<div className="flex justify-between gap-2"><dt className="text-muted-foreground">Sprache</dt><dd>{LOCALE_LABEL[locale]}</dd></div>
</dl>
<div className="mt-3">
{editable
? <Pill tone="warn">Entwurf bearbeitbar</Pill>
: <Pill tone="ok">Nur Lesen (veröffentlicht)</Pill>}
</div>
<div className="mt-4 flex flex-col gap-2 border-t pt-3">
{!draft ? (
<form action={startDraftAction.bind(null, framework)}>
<Button type="submit" className="w-full"><Pencil className="mr-1.5 size-4" />Entwurf anlegen</Button>
</form>
) : (
<>
<form action={publishDraft} className="flex flex-col gap-2">
<input type="hidden" name="framework" value={framework} />
<label className="flex flex-col gap-1 text-[11.5px] text-muted-foreground">
Versionsnummer
<input name="version" defaultValue={draft.version} className="h-8 rounded-md border border-input bg-transparent px-2 text-[13px]" />
</label>
<label className="flex flex-col gap-1 text-[11.5px] text-muted-foreground">
Änderungshinweis (für Mandanten)
<textarea name="notes" rows={2} placeholder="Was ändert sich?" className="rounded-md border border-input bg-transparent px-2 py-1 text-[12.5px]" />
</label>
<Button type="submit" className="w-full"><Rocket className="mr-1.5 size-4" />Veröffentlichen</Button>
</form>
<p className="text-[11.5px] text-muted-foreground">Neue Mandanten erhalten die Version automatisch; bestehende sehen ein Update und übernehmen selbst (nicht-destruktiv).</p>
<form action={discardDraft.bind(null, framework)}>
<Button type="submit" variant="outline" size="sm" className="w-full"><Trash2 className="mr-1.5 size-4" />Entwurf verwerfen</Button>
</form>
</>
)}
</div>
<div className="mt-3 flex flex-col gap-1.5 border-t pt-3 text-[12.5px]">
<Link className="text-muted-foreground hover:text-foreground" href={`/templates/${locale}/requirements?framework=${framework}`}>Anforderungen (Control-Mapping) →</Link>
<Link className="text-muted-foreground hover:text-foreground" href={`/templates/${locale}/variables?framework=${framework}`}>Variablen →</Link>
</div>
</div>
{/* Dokumente */}
<div className="shadow-card rounded-xl border bg-card p-4">
<div className="mb-3 flex items-center justify-between">
<p className="font-heading text-sm font-semibold">Dokumente ({docs.length})</p>
</div>
{docs.length === 0 ? (
<p className="text-[13px] text-muted-foreground">
Keine Dokumente in dieser Sprache. {locale === "en" && "Englische Inhalte müssen angelegt werden — "}
{editable ? "Neues Dokument unten anlegen." : "Zum Bearbeiten zuerst einen Entwurf anlegen."}
</p>
) : (
<div className="overflow-x-auto">
<table className="w-full text-[13px]">
<thead className="text-muted-foreground">
<tr className="border-b"><th className="py-1.5 pr-3 text-left font-medium">Code</th><th className="py-1.5 pr-3 text-left font-medium">Titel</th><th className="py-1.5 pr-3 text-left font-medium">Typ</th><th className="py-1.5 pr-3"></th></tr>
</thead>
<tbody className="divide-y">
{docs.map((d) => (
<tr key={d.id}>
<td className="py-1.5 pr-3 font-mono text-[12px]">{d.code}</td>
<td className="py-1.5 pr-3">{d.title}</td>
<td className="py-1.5 pr-3 text-muted-foreground">{TYPE_LABEL[d.type] ?? d.type}</td>
<td className="py-1.5 pr-3 text-right">
<Button variant="outline" size="sm" nativeButton={false} render={<Link href={`/templates/${locale}/${encodeURIComponent(d.code)}?framework=${framework}`} />}>
<FileText className="mr-1 size-3.5" />{editable ? "Bearbeiten" : "Ansehen"}
</Button>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{editable && (
<form action={createTemplateDoc} className="mt-4 flex flex-wrap items-end gap-2 border-t pt-4">
<input type="hidden" name="locale" value={locale} />
<input type="hidden" name="framework" value={framework} />
<label className="flex flex-col gap-1 text-[12px]">
<span className="text-muted-foreground">Code</span>
<input name="code" required placeholder="R15 / VA-21 / EIG-1" className="h-9 w-36 rounded-md border border-input bg-transparent px-2 text-[13px]" />
</label>
<label className="flex flex-col gap-1 text-[12px]">
<span className="text-muted-foreground">Typ</span>
<select name="type" className="h-9 rounded-md border border-input bg-transparent px-2 text-[13px]">
<option value="RICHTLINIE">Richtlinie</option>
<option value="VERFAHREN">Verfahren</option>
<option value="LEITLINIE">Leitlinie</option>
<option value="REGISTER">Register</option>
<option value="EIGENES">Eigenes</option>
</select>
</label>
<label className="flex flex-1 flex-col gap-1 text-[12px]">
<span className="text-muted-foreground">Titel</span>
<input name="title" required placeholder="Titel des Dokuments" className="h-9 w-full min-w-40 rounded-md border border-input bg-transparent px-2 text-[13px]" />
</label>
<Button type="submit" size="sm"><Plus className="mr-1 size-4" />Dokument anlegen</Button>
</form>
)}
</div>
</div>
)}
</main>
);
}