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>
285 lines
11 KiB
TypeScript
285 lines
11 KiB
TypeScript
import Link from "next/link";
|
|
import { getTranslations } from "next-intl/server";
|
|
import { Plus } from "lucide-react";
|
|
import type { Prisma } from "@prisma/client";
|
|
import { requireSession } from "@/server/auth";
|
|
import { dbForTenant } from "@/server/db";
|
|
import { hasPermission, requirePermission } from "@/server/rbac";
|
|
import { Button } from "@/components/ui/button";
|
|
import { PageHead, Pill, SectTitle, Tag } from "@/components/mockup-ui";
|
|
import {
|
|
Table,
|
|
TableBody,
|
|
TableCell,
|
|
TableHead,
|
|
TableHeader,
|
|
TableRow,
|
|
} from "@/components/ui/table";
|
|
import {
|
|
INCIDENT_INCLUDE,
|
|
IncidentCreateModal,
|
|
IncidentDetailModal,
|
|
IncidentEditModal,
|
|
} from "@/components/incident-modals";
|
|
import {
|
|
INCIDENT_CATEGORIES,
|
|
INCIDENT_STATUSES,
|
|
SEVERITY_TONE,
|
|
STATUS_TONE,
|
|
canSeeRestricted,
|
|
type IncidentStatus,
|
|
} from "@/lib/incident";
|
|
import { INCIDENT_SEVERITIES, type IncidentSeverity } from "@/lib/incident-severity";
|
|
|
|
export default async function IncidentsPage({
|
|
searchParams,
|
|
}: {
|
|
searchParams: Promise<{
|
|
detail?: string;
|
|
edit?: string;
|
|
new?: string;
|
|
status?: string;
|
|
severity?: string;
|
|
category?: string;
|
|
}>;
|
|
}) {
|
|
const session = await requireSession();
|
|
requirePermission(session, "incident:read");
|
|
const t = await getTranslations("incidents");
|
|
const tCat = await getTranslations("incidentCategory");
|
|
const tStatus = await getTranslations("incidentStatus");
|
|
const tSev = await getTranslations("incidentSeverity");
|
|
|
|
const params = await searchParams;
|
|
const db = dbForTenant(session.user.tenantId);
|
|
const canManage = hasPermission(session, "incident:manage");
|
|
const canClose = hasPermission(session, "incident:close");
|
|
const canReport = hasPermission(session, "incident:report");
|
|
|
|
// §11 Vertraulichkeit: wer nicht manage/close hat, sieht nur unrestricted +
|
|
// seine eigenen (owner) Vorfälle — serverseitiger Filter.
|
|
const restrictedWhere: Prisma.IncidentWhereInput = canSeeRestricted(session)
|
|
? {}
|
|
: { OR: [{ restricted: false }, { ownerId: session.user.id }] };
|
|
|
|
const filterWhere: Prisma.IncidentWhereInput = {
|
|
...(params.status && INCIDENT_STATUSES.includes(params.status as IncidentStatus)
|
|
? { status: params.status as IncidentStatus }
|
|
: {}),
|
|
...(params.severity && INCIDENT_SEVERITIES.includes(params.severity as IncidentSeverity)
|
|
? { severity: params.severity }
|
|
: {}),
|
|
...(params.category &&
|
|
INCIDENT_CATEGORIES.includes(params.category as (typeof INCIDENT_CATEGORIES)[number])
|
|
? { category: params.category as (typeof INCIDENT_CATEGORIES)[number] }
|
|
: {}),
|
|
};
|
|
|
|
const incidents = await db.incident.findMany({
|
|
where: { AND: [restrictedWhere, filterWhere] },
|
|
include: { owner: { select: { name: true } } },
|
|
orderBy: { createdAt: "desc" },
|
|
take: 200,
|
|
});
|
|
|
|
// Detail/Bearbeiten-Popup
|
|
const modalId = params.edit && canManage ? params.edit : params.detail;
|
|
const modalIncident = modalId
|
|
? await db.incident.findFirst({
|
|
where: { AND: [{ id: modalId }, restrictedWhere] },
|
|
include: INCIDENT_INCLUDE,
|
|
})
|
|
: null;
|
|
|
|
// Für die Detail-Timeline: Audit-Einträge dieses Vorfalls + Akteursnamen.
|
|
let auditRows: {
|
|
id: string;
|
|
createdAt: Date;
|
|
actorId: string | null;
|
|
action: string;
|
|
after: unknown;
|
|
}[] = [];
|
|
let actorNames: Record<string, string> = {};
|
|
if (modalIncident && params.detail) {
|
|
const rows = await db.auditLog.findMany({
|
|
where: { entity: "incident", entityId: modalIncident.id },
|
|
orderBy: { createdAt: "asc" },
|
|
take: 200,
|
|
});
|
|
auditRows = rows.map((r) => ({
|
|
id: r.id,
|
|
createdAt: r.createdAt,
|
|
actorId: r.actorId,
|
|
action: r.action,
|
|
after: r.after,
|
|
}));
|
|
const actorIds = new Set<string>();
|
|
rows.forEach((r) => r.actorId && actorIds.add(r.actorId));
|
|
modalIncident.comments.forEach((c) => c.authorId && actorIds.add(c.authorId));
|
|
const actors = await db.user.findMany({
|
|
where: { id: { in: [...actorIds] } },
|
|
select: { id: true, name: true },
|
|
});
|
|
actorNames = Object.fromEntries(actors.map((a) => [a.id, a.name]));
|
|
}
|
|
|
|
// Für das Bearbeiten-Popup: Auswahl-Listen (nur noch nicht verknüpfte).
|
|
const needsEdit = canManage && params.edit && modalIncident;
|
|
const [availableAssets, availableProcesses, availableRisks, availableMeasures, availableEvidence] = needsEdit
|
|
? await Promise.all([
|
|
db.asset.findMany({
|
|
where: { id: { notIn: modalIncident.incidentAssets.map((x) => x.assetId) } },
|
|
select: { id: true, name: true },
|
|
orderBy: { name: "asc" },
|
|
}),
|
|
db.process.findMany({
|
|
where: { id: { notIn: modalIncident.incidentProcesses.map((x) => x.processId) } },
|
|
select: { id: true, name: true },
|
|
orderBy: { name: "asc" },
|
|
}),
|
|
db.risk.findMany({
|
|
where: { id: { notIn: modalIncident.incidentRisks.map((x) => x.riskId) } },
|
|
select: { id: true, refNo: true, title: true },
|
|
orderBy: { refNo: "asc" },
|
|
}),
|
|
db.measure.findMany({
|
|
where: { id: { notIn: modalIncident.incidentMeasures.map((x) => x.measureId) } },
|
|
select: { id: true, refNo: true, title: true },
|
|
orderBy: { refNo: "asc" },
|
|
}),
|
|
db.evidence.findMany({
|
|
where: { id: { notIn: modalIncident.incidentEvidence.map((x) => x.evidenceId) } },
|
|
select: { id: true, title: true },
|
|
orderBy: { createdAt: "desc" },
|
|
take: 200,
|
|
}),
|
|
])
|
|
: [[], [], [], [], []];
|
|
|
|
// Aktive Nutzer für Owner-/Bearbeiter-/Maßnahmen-Auswahl (Detail + Bearbeiten).
|
|
const users =
|
|
modalIncident && canManage
|
|
? await db.user.findMany({ where: { status: "ACTIVE" }, select: { id: true, name: true }, orderBy: { name: "asc" } })
|
|
: [];
|
|
|
|
// NIS2-Betroffenheit des Mandanten steuert die Anzeige/Timer (§6) im Detail.
|
|
const nis2Category =
|
|
modalIncident && params.detail
|
|
? (await db.tenantSettings.findUnique({ where: { tenantId: session.user.tenantId }, select: { nis2Category: true } }))?.nis2Category ?? "keine"
|
|
: "keine";
|
|
|
|
const filterLink = (patch: Record<string, string | undefined>) => {
|
|
const sp = new URLSearchParams();
|
|
const merged = { status: params.status, severity: params.severity, category: params.category, ...patch };
|
|
for (const [k, v] of Object.entries(merged)) if (v) sp.set(k, v);
|
|
const qs = sp.toString();
|
|
return `/incidents${qs ? `?${qs}` : ""}`;
|
|
};
|
|
|
|
return (
|
|
<main className="flex-1 p-6">
|
|
<PageHead
|
|
crumb={t("crumb")}
|
|
title={t("title")}
|
|
sub={t("sub")}
|
|
actions={
|
|
<div className="flex flex-wrap items-center gap-2">
|
|
<a href="/incidents/export?format=register-csv" className="rounded-md border px-3 py-1.5 text-[12px] hover:bg-muted">
|
|
{t("exportRegisterCsv")}
|
|
</a>
|
|
<a href="/incidents/export?format=register-xlsx" className="rounded-md border px-3 py-1.5 text-[12px] hover:bg-muted">
|
|
{t("exportRegisterXlsx")}
|
|
</a>
|
|
{canReport ? (
|
|
<Button nativeButton={false} render={<Link href="/incidents?new=1" />}>
|
|
<Plus className="size-4" /> {t("newIncident")}
|
|
</Button>
|
|
) : null}
|
|
</div>
|
|
}
|
|
/>
|
|
|
|
<div className="grid gap-4">
|
|
{/* Filterleiste */}
|
|
<div className="shadow-card flex flex-wrap items-center gap-2 rounded-xl border bg-card p-3 text-[12px]">
|
|
<span className="text-muted-foreground">{t("filter")}:</span>
|
|
<Link href={filterLink({ status: undefined, severity: undefined, category: undefined })} className="rounded-md border px-2 py-1 hover:bg-muted">{t("filterAll")}</Link>
|
|
<span className="mx-1 h-4 w-px bg-border" />
|
|
{INCIDENT_STATUSES.map((s) => (
|
|
<Link key={s} href={filterLink({ status: params.status === s ? undefined : s })} className={`rounded-md border px-2 py-1 hover:bg-muted ${params.status === s ? "bg-muted font-semibold" : ""}`}>{tStatus(s)}</Link>
|
|
))}
|
|
<span className="mx-1 h-4 w-px bg-border" />
|
|
{INCIDENT_SEVERITIES.map((s) => (
|
|
<Link key={s} href={filterLink({ severity: params.severity === s ? undefined : s })} className={`rounded-md border px-2 py-1 hover:bg-muted ${params.severity === s ? "bg-muted font-semibold" : ""}`}>{tSev(s)}</Link>
|
|
))}
|
|
</div>
|
|
|
|
{/* Register */}
|
|
<div className="shadow-card rounded-xl border bg-card">
|
|
<div className="p-4 pb-0">
|
|
<SectTitle title={t("register")} sub={t("registerSub")} />
|
|
</div>
|
|
<Table className="mt-1">
|
|
<TableHeader>
|
|
<TableRow>
|
|
<TableHead>{t("id")}</TableHead>
|
|
<TableHead>{t("incident")}</TableHead>
|
|
<TableHead>{t("category")}</TableHead>
|
|
<TableHead>{t("severity")}</TableHead>
|
|
<TableHead>{t("status")}</TableHead>
|
|
<TableHead>{t("owner")}</TableHead>
|
|
</TableRow>
|
|
</TableHeader>
|
|
<TableBody>
|
|
{incidents.length === 0 && (
|
|
<TableRow>
|
|
<TableCell colSpan={6} className="py-8 text-center text-muted-foreground">{t("empty")}</TableCell>
|
|
</TableRow>
|
|
)}
|
|
{incidents.map((inc) => (
|
|
<TableRow key={inc.id}>
|
|
<TableCell className="text-muted-foreground">{inc.refNo}</TableCell>
|
|
<TableCell>
|
|
<Link href={`/incidents?detail=${inc.id}`} className="font-bold hover:underline">
|
|
{inc.restricted ? "🔒 " : ""}{inc.title}
|
|
</Link>
|
|
</TableCell>
|
|
<TableCell><Tag>{tCat(inc.category)}</Tag></TableCell>
|
|
<TableCell><Pill tone={SEVERITY_TONE[inc.severity as keyof typeof SEVERITY_TONE] ?? "mut"}>{tSev(inc.severity)}</Pill></TableCell>
|
|
<TableCell><Pill tone={STATUS_TONE[inc.status as IncidentStatus] ?? "mut"}>{tStatus(inc.status)}</Pill></TableCell>
|
|
<TableCell className="text-muted-foreground">{inc.owner?.name ?? "—"}</TableCell>
|
|
</TableRow>
|
|
))}
|
|
</TableBody>
|
|
</Table>
|
|
</div>
|
|
</div>
|
|
|
|
{modalIncident && params.edit && canManage ? (
|
|
<IncidentEditModal
|
|
incident={modalIncident}
|
|
availableAssets={availableAssets}
|
|
availableProcesses={availableProcesses}
|
|
availableRisks={availableRisks}
|
|
availableMeasures={availableMeasures}
|
|
availableEvidence={availableEvidence}
|
|
users={users}
|
|
/>
|
|
) : modalIncident ? (
|
|
<IncidentDetailModal
|
|
incident={modalIncident}
|
|
audit={auditRows}
|
|
actorNames={actorNames}
|
|
users={users}
|
|
canManage={canManage}
|
|
canClose={canClose}
|
|
canReport={canReport}
|
|
nis2Category={nis2Category}
|
|
/>
|
|
) : params.new && canReport ? (
|
|
<IncidentCreateModal />
|
|
) : null}
|
|
</main>
|
|
);
|
|
}
|