Files
craftvia/src/app/(app)/review/page.tsx
T
msolarczekandClaude Opus 5 c8e6f30a27
CI / build-and-check (push) Canceled after 0s
CI / audit (push) Canceled after 0s
CI / sbom (push) Canceled after 0s
Basis: Certvia dev@a48c5fb als Fundament für Craftvia
Unveränderter Stand von certvia/dev (a48c5fb) plus Craftvia-Spezifikation
und Brandbook unter docs/craftvia/. ISMS-Module werden im Folgecommit entfernt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 11:05:39 +02:00

235 lines
18 KiB
TypeScript

import { redirect } from "next/navigation";
import { Plus, Gauge, ClipboardList, AlertTriangle, CheckCircle2 } from "lucide-react";
import { requireSession } from "@/server/auth";
import { dbForTenant } from "@/server/db";
import { hasPermission } from "@/server/rbac";
import { PageHead, Pill } from "@/components/mockup-ui";
import { Button } from "@/components/ui/button";
import {
createKpi, recordKpiValue,
createManagementReview, updateManagementReview, addReviewDecision, setReviewDecisionStatus,
createNonconformity, addCorrectiveAction, updateCorrectiveAction, updateNonconformity,
} from "@/server/actions/review";
/**
* Managementklauseln (AP4 · ISO/IEC 27001 9.1, 9.3, 10.2). Kennzahlen mit Zielwerten
* und Messwerten je Periode, Managementbewertung entlang der 9.3.2-Agenda und
* Nichtkonformitäten mit Korrekturmaßnahme inkl. dokumentierter Wirksamkeitsprüfung.
*/
const inp = "h-8 w-full rounded-md border border-input bg-transparent px-2 text-[12.5px]";
const ta = "w-full rounded-md border border-input bg-transparent px-2 py-1 text-[12.5px]";
const AGENDA_9_3_2 = "Status Vormaßnahmen · Änderungen (intern/extern) · Rückmeldungen zur Leistung (Nichtkonformitäten, Kennzahlen, Auditergebnisse, Zielerreichung) · Rückmeldungen interessierter Parteien · Risikobewertungsergebnisse & Risikobehandlungsplan · Verbesserungsmöglichkeiten";
const fmtDate = (d: Date | null) => (d ? new Date(d).toLocaleDateString("de-DE") : "—");
export default async function ReviewPage() {
const session = await requireSession();
const canRead = hasPermission(session, "review:manage") || hasPermission(session, "report:read");
if (!canRead) redirect("/dashboard");
const canWrite = hasPermission(session, "review:manage");
const db = dbForTenant(session.user.tenantId);
const [kpis, reviews, ncs, users] = await Promise.all([
db.kpi.findMany({ orderBy: { createdAt: "asc" }, include: { values: { orderBy: { period: "desc" }, take: 6 } } }),
db.managementReview.findMany({ orderBy: { reviewDate: "desc" }, include: { decisions: true } }),
db.nonconformity.findMany({ orderBy: { detectedAt: "desc" }, include: { actions: true } }),
db.user.findMany({ where: { status: "ACTIVE" }, select: { id: true, name: true }, orderBy: { name: "asc" } }),
]);
const ownerSel = (name: string, val?: string | null) => (
<select name={name} defaultValue={val ?? ""} className={inp}><option value="">—</option>{users.map((u) => <option key={u.id} value={u.id}>{u.name}</option>)}</select>
);
return (
<main className="flex-1 space-y-6 p-6">
<PageHead crumb="ISO 27001" title="Management-Review & Kennzahlen" sub="9.1 Kennzahlen · 9.3 Managementbewertung · 10.2 Korrekturmaßnahmen — die drei Managementklauseln." />
{/* ── 9.1 Kennzahlen ─────────────────────────────────────────────── */}
<section className="shadow-card rounded-xl border bg-card p-4">
<p className="mb-3 flex items-center gap-2 font-heading text-sm font-semibold"><Gauge className="size-4 text-[var(--primary)]" />9.1 Kennzahlen</p>
{kpis.length === 0 && <p className="text-[13px] text-muted-foreground">Noch keine Kennzahlen.</p>}
<div className="space-y-1.5">
{kpis.map((k) => (
<details key={k.id} className="rounded-lg border">
<summary className="flex cursor-pointer flex-wrap items-center gap-3 px-3 py-2 text-[13px]">
<span className="font-semibold">{k.name}</span>
{k.target && <Pill tone="info">Ziel: {k.target}{k.unit ? ` ${k.unit}` : ""}</Pill>}
<span className="text-muted-foreground">{k.cadence}</span>
<span className="ml-auto flex flex-wrap gap-1">
{k.values.map((v) => <Pill key={v.id} tone="mut">{v.period}: {v.value}</Pill>)}
</span>
</summary>
<div className="border-t p-3 text-[12.5px]">
{k.description && <p className="mb-2 text-muted-foreground">{k.description}</p>}
{k.dataSource && <p className="mb-2"><span className="text-muted-foreground">Datenquelle:</span> {k.dataSource}</p>}
{canWrite && (
<form action={recordKpiValue.bind(null, k.id)} className="flex flex-wrap items-end gap-2">
<label className="text-[11.5px] text-muted-foreground">Periode<input name="period" required placeholder="2026-Q1" className={inp} /></label>
<label className="text-[11.5px] text-muted-foreground">Wert<input name="value" required placeholder="97%" className={inp} /></label>
<label className="flex-1 text-[11.5px] text-muted-foreground">Notiz<input name="note" className={inp} /></label>
<Button type="submit" size="sm">Messwert erfassen</Button>
</form>
)}
</div>
</details>
))}
</div>
{canWrite && (
<form action={createKpi} className="mt-4 flex flex-wrap items-end gap-2 border-t pt-4">
<label className="text-[11.5px] text-muted-foreground">Name<input name="name" required placeholder="Offene Maßnahmen überfällig" className={inp} /></label>
<label className="text-[11.5px] text-muted-foreground">Zielwert<input name="target" placeholder="< 5%" className={inp} /></label>
<label className="text-[11.5px] text-muted-foreground">Einheit<input name="unit" placeholder="%" className={`${inp} w-20`} /></label>
<label className="text-[11.5px] text-muted-foreground">Turnus
<select name="cadence" className={inp}><option>monatlich</option><option>quartalsweise</option><option>jährlich</option></select>
</label>
<label className="flex-1 text-[11.5px] text-muted-foreground">Datenquelle<input name="dataSource" placeholder="Aufgaben-Modul / Vorfall-SLA" className={inp} /></label>
<label className="text-[11.5px] text-muted-foreground">Verantwortlich{ownerSel("ownerId")}</label>
<Button type="submit" size="sm"><Plus className="mr-1 size-4" />Kennzahl</Button>
</form>
)}
</section>
{/* ── 9.3 Managementbewertung ────────────────────────────────────── */}
<section className="shadow-card rounded-xl border bg-card p-4">
<p className="mb-3 flex items-center gap-2 font-heading text-sm font-semibold"><ClipboardList className="size-4 text-[var(--primary)]" />9.3 Managementbewertung</p>
{reviews.length === 0 && <p className="text-[13px] text-muted-foreground">Noch keine Managementbewertung.</p>}
<div className="space-y-1.5">
{reviews.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-semibold">{fmtDate(r.reviewDate)}</span>
<Pill tone={r.status === "abgeschlossen" ? "ok" : "warn"}>{r.status}</Pill>
<span className="ml-auto text-muted-foreground">{r.decisions.length} Beschluss/-e ({r.decisions.filter((d) => d.status === "offen").length} offen)</span>
</summary>
<div className="space-y-3 border-t p-3">
{canWrite ? (
<form action={updateManagementReview.bind(null, r.id)} className="space-y-2">
<div className="flex flex-wrap gap-2">
<label className="text-[11.5px] text-muted-foreground">Datum<input type="date" name="reviewDate" defaultValue={new Date(r.reviewDate).toISOString().slice(0, 10)} className={inp} /></label>
<label className="text-[11.5px] text-muted-foreground">Status<select name="status" defaultValue={r.status} className={inp}><option value="entwurf">entwurf</option><option value="abgeschlossen">abgeschlossen</option></select></label>
<label className="text-[11.5px] text-muted-foreground">Leitung{ownerSel("ownerId", r.ownerId)}</label>
</div>
<label className="block text-[11.5px] text-muted-foreground">Eingaben (9.3.2)<textarea name="inputs" defaultValue={r.inputs ?? ""} rows={4} placeholder={AGENDA_9_3_2} className={ta} /></label>
<label className="block text-[11.5px] text-muted-foreground">Ergebnisse (9.3.3)<textarea name="results" defaultValue={r.results ?? ""} rows={3} className={ta} /></label>
<Button type="submit" size="sm">Speichern</Button>
</form>
) : (
<div className="text-[12.5px]"><p className="whitespace-pre-wrap"><span className="text-muted-foreground">Eingaben:</span> {r.inputs || "—"}</p><p className="mt-1 whitespace-pre-wrap"><span className="text-muted-foreground">Ergebnisse:</span> {r.results || "—"}</p></div>
)}
<div className="border-t pt-2">
<p className="mb-1 text-[12px] font-semibold">Beschlüsse (mit Verantwortlichem &amp; Termin)</p>
<ul className="space-y-1">
{r.decisions.map((d) => (
<li key={d.id} className="flex flex-wrap items-center gap-2 text-[12.5px]">
<Pill tone={d.status === "erledigt" ? "ok" : "warn"}>{d.status}</Pill>
<span className="flex-1">{d.decision}</span>
<span className="text-muted-foreground">Fällig: {fmtDate(d.dueDate)}</span>
{canWrite && (
<form action={setReviewDecisionStatus.bind(null, d.id, d.status !== "erledigt")}>
<Button type="submit" variant="outline" size="sm">{d.status === "erledigt" ? "wieder offen" : "erledigt"}</Button>
</form>
)}
</li>
))}
</ul>
{canWrite && (
<form action={addReviewDecision.bind(null, r.id)} className="mt-2 flex flex-wrap items-end gap-2">
<label className="flex-1 text-[11.5px] text-muted-foreground">Beschluss<input name="decision" required className={inp} /></label>
<label className="text-[11.5px] text-muted-foreground">Verantwortlich{ownerSel("ownerId")}</label>
<label className="text-[11.5px] text-muted-foreground">Termin<input type="date" name="dueDate" className={inp} /></label>
<Button type="submit" size="sm">Beschluss</Button>
</form>
)}
</div>
</div>
</details>
))}
</div>
{canWrite && (
<form action={createManagementReview} className="mt-4 flex flex-wrap items-end gap-2 border-t pt-4">
<label className="text-[11.5px] text-muted-foreground">Datum<input type="date" name="reviewDate" className={inp} /></label>
<label className="text-[11.5px] text-muted-foreground">Leitung{ownerSel("ownerId")}</label>
<Button type="submit" size="sm"><Plus className="mr-1 size-4" />Managementbewertung anlegen</Button>
</form>
)}
</section>
{/* ── 10.2 Nichtkonformität & Korrekturmaßnahme ──────────────────── */}
<section className="shadow-card rounded-xl border bg-card p-4">
<p className="mb-3 flex items-center gap-2 font-heading text-sm font-semibold"><AlertTriangle className="size-4 text-[var(--primary)]" />10.2 Nichtkonformität &amp; Korrekturmaßnahme</p>
{ncs.length === 0 && <p className="text-[13px] text-muted-foreground">Noch keine Nichtkonformitäten.</p>}
<div className="space-y-1.5">
{ncs.map((nc) => (
<details key={nc.id} className="rounded-lg border">
<summary className="flex cursor-pointer flex-wrap items-center gap-3 px-3 py-2 text-[13px]">
<span className="font-mono text-[12px]">{nc.refNo}</span>
<Pill tone={nc.status === "abgeschlossen" ? "ok" : nc.status === "in_bearbeitung" ? "warn" : "mut"}>{nc.status}</Pill>
<span className="min-w-0 flex-1 truncate">{nc.description}</span>
<span className="text-muted-foreground">{nc.actions.length} Maßnahme(n)</span>
</summary>
<div className="space-y-3 border-t p-3">
{canWrite ? (
<form action={updateNonconformity.bind(null, nc.id)} className="space-y-2">
<div className="flex flex-wrap gap-2">
<label className="text-[11.5px] text-muted-foreground">Herkunft<input name="source" defaultValue={nc.source} className={inp} /></label>
<label className="text-[11.5px] text-muted-foreground">Status<select name="status" defaultValue={nc.status} className={inp}><option value="offen">offen</option><option value="in_bearbeitung">in Bearbeitung</option><option value="abgeschlossen">abgeschlossen</option></select></label>
<label className="text-[11.5px] text-muted-foreground">Verantwortlich{ownerSel("ownerId", nc.ownerId)}</label>
</div>
<label className="block text-[11.5px] text-muted-foreground">Beschreibung<textarea name="description" defaultValue={nc.description} rows={2} className={ta} /></label>
<label className="block text-[11.5px] text-muted-foreground">Sofortkorrektur (10.2 a)<textarea name="immediateCorrection" defaultValue={nc.immediateCorrection ?? ""} rows={2} className={ta} /></label>
<Button type="submit" size="sm">Speichern</Button>
</form>
) : (
<p className="text-[12.5px]">{nc.description}</p>
)}
<div className="border-t pt-2">
<p className="mb-1 text-[12px] font-semibold">Korrekturmaßnahmen (Ursache → Maßnahme → Wirksamkeit)</p>
<ul className="space-y-2">
{nc.actions.map((a) => (
<li key={a.id} className="rounded-lg border bg-muted/30 p-2">
{canWrite ? (
<form action={updateCorrectiveAction.bind(null, a.id)} className="space-y-1.5">
<label className="block text-[11.5px] text-muted-foreground">Maßnahme<input name="action" defaultValue={a.action} className={inp} /></label>
<label className="block text-[11.5px] text-muted-foreground">Ursachenanalyse (10.2 b)<textarea name="rootCause" defaultValue={a.rootCause ?? ""} rows={2} className={ta} /></label>
<label className="block text-[11.5px] text-muted-foreground">Wirksamkeitsbewertung (10.2 d/e)<textarea name="effectivenessCheck" defaultValue={a.effectivenessCheck ?? ""} rows={2} className={ta} /></label>
<div className="flex flex-wrap items-center gap-2">
<label className="text-[11.5px] text-muted-foreground">Status<select name="status" defaultValue={a.status} className={inp}><option value="geplant">geplant</option><option value="umgesetzt">umgesetzt</option></select></label>
<label className="text-[11.5px] text-muted-foreground">Fällig<input type="date" name="dueDate" defaultValue={a.dueDate ? new Date(a.dueDate).toISOString().slice(0, 10) : ""} className={inp} /></label>
<label className="text-[11.5px] text-muted-foreground">Verantwortlich{ownerSel("ownerId", a.ownerId)}</label>
<label className="flex items-center gap-1.5 text-[12px]"><input type="checkbox" name="effectivenessConfirmed" defaultChecked={!!a.effectivenessConfirmedAt} /> Wirksamkeit bestätigt</label>
<Button type="submit" size="sm">Speichern</Button>
{a.effectivenessConfirmedAt && <Pill tone="ok"><CheckCircle2 className="mr-1 size-3" />wirksam ({fmtDate(a.effectivenessConfirmedAt)})</Pill>}
</div>
</form>
) : (
<div className="text-[12.5px]"><p>{a.action}</p>{a.effectivenessConfirmedAt && <Pill tone="ok">wirksam bestätigt</Pill>}</div>
)}
</li>
))}
</ul>
{canWrite && (
<form action={addCorrectiveAction.bind(null, nc.id)} className="mt-2 flex flex-wrap items-end gap-2">
<label className="flex-1 text-[11.5px] text-muted-foreground">Neue Maßnahme<input name="action" required className={inp} /></label>
<label className="text-[11.5px] text-muted-foreground">Verantwortlich{ownerSel("ownerId")}</label>
<label className="text-[11.5px] text-muted-foreground">Fällig<input type="date" name="dueDate" className={inp} /></label>
<Button type="submit" size="sm">Maßnahme</Button>
</form>
)}
</div>
</div>
</details>
))}
</div>
{canWrite && (
<form action={createNonconformity} className="mt-4 flex flex-wrap items-end gap-2 border-t pt-4">
<label className="text-[11.5px] text-muted-foreground">Herkunft<input name="source" required placeholder="Internes Audit / Vorfall / Beschwerde" className={inp} /></label>
<label className="flex-1 text-[11.5px] text-muted-foreground">Beschreibung<input name="description" required className={inp} /></label>
<label className="text-[11.5px] text-muted-foreground">Verantwortlich{ownerSel("ownerId")}</label>
<Button type="submit" size="sm"><Plus className="mr-1 size-4" />Nichtkonformität</Button>
</form>
)}
</section>
</main>
);
}