Basis: Certvia dev@a48c5fb als Fundament für Craftvia
CI / build-and-check (push) Canceled after 0s
CI / audit (push) Canceled after 0s
CI / sbom (push) Canceled after 0s

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:
2026-09-14 11:05:39 +02:00
co-authored by Claude Opus 5
commit c8e6f30a27
720 changed files with 140143 additions and 0 deletions
+199
View File
@@ -0,0 +1,199 @@
import Link from "next/link";
import { AlertTriangle, Compass, ListChecks } from "lucide-react";
import { getFormatter, getTranslations } from "next-intl/server";
import { requireSession } from "@/server/auth";
import { dbForTenant } from "@/server/db";
import { hasPermission } from "@/server/rbac";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { MODULE_KEYS } from "@/lib/modules";
import { openReportDeadlines } from "@/lib/incident-deadlines";
import { getVisibleSteps, type StepKey } from "@/lib/onboarding/registry";
import { firstIncompleteIndex, isValidated, progressPercent } from "@/lib/onboarding/state";
export default async function DashboardPage() {
const session = await requireSession();
const t = await getTranslations("dashboard");
const format = await getFormatter();
const db = dbForTenant(session.user.tenantId);
const [assetCount, processCount, criticalCount, openRisks, myOpenTasks, activities, moduleRows, progressRows] = await Promise.all([
db.asset.count(),
db.process.count(),
db.biaEntry.count({ where: { criticality: { gte: 3 } } }),
db.risk.count({ where: { status: { in: ["OPEN", "IN_TREATMENT"] } } }),
db.task.count({ where: { assigneeId: session.user.id, status: "OPEN" } }),
db.auditLog.findMany({
orderBy: { createdAt: "desc" },
take: 6,
}),
db.tenantModule.findMany(),
db.onboardingProgress.findMany(),
]);
// Onboarding-Fortschritt (Story A1-2): Kachel nur, wenn Modul aktiv und noch nicht fertig.
const disabledModules = new Set(moduleRows.filter((m) => !m.enabled).map((m) => m.moduleKey));
const to = await getTranslations("onboarding");
let onboardingTile: { percent: number; done: number; total: number; nextKey: StepKey; nextTitle: string } | null = null;
if (!disabledModules.has("onboarding")) {
const steps = getVisibleSteps({ enabledModules: new Set(MODULE_KEYS.filter((k) => !disabledModules.has(k))) });
const statusOf = (k: StepKey) => progressRows.find((r) => r.stepKey === k)?.status ?? "offen";
const percent = progressPercent(steps, statusOf);
if (steps.length > 0 && percent < 100) {
const next = steps[firstIncompleteIndex(steps, statusOf)];
onboardingTile = {
percent,
done: steps.filter((s) => isValidated(statusOf(s.key))).length,
total: steps.length,
nextKey: next.key,
nextTitle: to(next.title),
};
}
}
// Vorfälle/Fristen-Kachel (IM-B, §6): offene meldepflichtige Fristen + überfällige.
// Nur wenn Modul „incidents" aktiv und der Nutzer Vorfälle sehen darf.
let incidentTile: { open: number; overdue: number } | null = null;
if (!disabledModules.has("incidents") && hasPermission(session, "incident:read")) {
const incs = await db.incident.findMany({
where: {
status: { not: "abgeschlossen" },
reportStatus: { not: "abschluss" },
OR: [
{ erstmeldungDueAt: { not: null } },
{ folgemeldungDueAt: { not: null } },
{ abschlussDueAt: { not: null } },
{ dsgvoDueAt: { not: null } },
],
},
select: {
severity: true,
reportStatus: true,
detectedAt: true,
reportedAt: true,
occurredAt: true,
createdAt: true,
erstmeldungDueAt: true,
folgemeldungDueAt: true,
abschlussDueAt: true,
dsgvoDueAt: true,
},
take: 500,
});
const now = new Date();
let open = 0;
let overdue = 0;
for (const inc of incs) {
const items = openReportDeadlines(inc, now);
if (items.length === 0) continue;
open++;
if (items.some((i) => i.overdue)) overdue++;
}
if (open > 0) incidentTile = { open, overdue };
}
const actorNames = new Map<string, string>();
const actorIds = [...new Set(activities.map((a) => a.actorId).filter(Boolean))] as string[];
if (actorIds.length) {
const users = await db.user.findMany({
where: { id: { in: actorIds } },
select: { id: true, name: true },
});
users.forEach((u) => actorNames.set(u.id, u.name));
}
const kpis = [
{ label: t("kpiAssets"), value: String(assetCount), hint: null },
{ label: t("kpiProcesses"), value: String(processCount), hint: null },
{ label: t("kpiCritical"), value: String(criticalCount), hint: t("kpiCriticalHint") },
{ label: t("kpiRisks"), value: String(openRisks), hint: null },
];
return (
<main className="flex-1 p-6">
<h1 className="text-xl">{t("title")}</h1>
<p className="mt-1 text-sm text-muted-foreground">
{t("subtitle", { name: session.user.name ?? "", tenant: session.user.tenantSlug })}
</p>
{onboardingTile && (
<Link href={`/onboarding?step=${onboardingTile.nextKey}`} className="shadow-card mt-6 flex items-center justify-between rounded-xl border bg-card p-4 transition-colors hover:bg-muted/40">
<div className="flex items-center gap-3">
<div className="grid size-10 place-items-center rounded-lg bg-[rgba(124,92,209,0.16)] text-[var(--primary)]"><Compass className="size-5" /></div>
<div>
<p className="font-heading text-sm font-semibold">{t("onboardingTile", { percent: onboardingTile.percent })}</p>
<p className="text-[12px] text-muted-foreground">{t("onboardingTileSub", { done: onboardingTile.done, total: onboardingTile.total, step: onboardingTile.nextTitle })}</p>
</div>
</div>
<span className="text-[13px] font-semibold text-[var(--primary)]">{t("onboardingTileCta")}</span>
</Link>
)}
{myOpenTasks > 0 && (
<Link href="/tasks" className={`shadow-card ${onboardingTile ? "mt-4" : "mt-6"} flex items-center justify-between rounded-xl border bg-card p-4 transition-colors hover:bg-muted/40`}>
<div className="flex items-center gap-3">
<div className="grid size-10 place-items-center rounded-lg bg-[rgba(90,169,230,0.16)] text-[var(--info)]"><ListChecks className="size-5" /></div>
<div>
<p className="font-heading text-sm font-semibold">{myOpenTasks} offene Aufgabe{myOpenTasks === 1 ? "" : "n"}</p>
<p className="text-[12px] text-muted-foreground">Freigaben, die auf Sie warten.</p>
</div>
</div>
<span className="text-[13px] font-semibold text-[var(--primary)]">Zu den Aufgaben →</span>
</Link>
)}
{incidentTile && (
<Link href="/incidents" className="shadow-card mt-4 flex items-center justify-between rounded-xl border bg-card p-4 transition-colors hover:bg-muted/40">
<div className="flex items-center gap-3">
<div className={`grid size-10 place-items-center rounded-lg ${incidentTile.overdue > 0 ? "bg-[rgba(255,107,107,0.16)] text-[var(--risk)]" : "bg-[rgba(240,173,78,0.16)] text-[var(--warn)]"}`}>
<AlertTriangle className="size-5" />
</div>
<div>
<p className="font-heading text-sm font-semibold">{t("incidentTile")}</p>
<p className="text-[12px] text-muted-foreground">{t("incidentTileSub", { open: incidentTile.open, overdue: incidentTile.overdue })}</p>
</div>
</div>
<span className="text-[13px] font-semibold text-[var(--primary)]">{t("incidentTileCta")}</span>
</Link>
)}
<div className="mt-6 grid gap-4 sm:grid-cols-2 xl:grid-cols-4">
{kpis.map((kpi) => (
<Card key={kpi.label}>
<CardContent>
<p className="text-[12.5px] font-semibold text-muted-foreground">{kpi.label}</p>
<p className="mt-1.5 font-heading text-3xl font-bold leading-none">{kpi.value}</p>
{kpi.hint && <p className="mt-1.5 text-xs text-muted-foreground">{kpi.hint}</p>}
</CardContent>
</Card>
))}
</div>
<Card className="mt-6 max-w-3xl">
<CardHeader>
<CardTitle>{t("activity")}</CardTitle>
<CardDescription>{t("activitySub")}</CardDescription>
</CardHeader>
<CardContent>
{activities.length === 0 && (
<p className="text-sm text-muted-foreground">{t("activityEmpty")}</p>
)}
<ul className="space-y-2 text-sm">
{activities.map((a) => (
<li key={a.id} className="flex items-baseline gap-3">
<span className="shrink-0 text-xs text-muted-foreground">
{format.relativeTime(a.createdAt)}
</span>
<span>
<strong className="font-semibold">
{a.actorId ? actorNames.get(a.actorId) ?? "System" : "System"}
</strong>{" "}
· {a.action} · {a.entity}
</span>
</li>
))}
</ul>
</CardContent>
</Card>
</main>
);
}