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,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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user