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>
This commit is contained in:
@@ -0,0 +1,284 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user