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>
235 lines
18 KiB
TypeScript
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 & 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 & 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>
|
|
);
|
|
}
|