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>
115 lines
5.5 KiB
TypeScript
115 lines
5.5 KiB
TypeScript
import { requireSession } from "@/server/auth";
|
|
import { dbForTenant } from "@/server/db";
|
|
import { hasPermission } from "@/server/rbac";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Textarea } from "@/components/ui/textarea";
|
|
import { Pill } from "@/components/mockup-ui";
|
|
import { eligibleValidators, latestReviewTask } from "@/server/object-review";
|
|
import { REVIEW_LABEL, REVIEW_TONE, reviewState } from "@/lib/object-review";
|
|
import { approveTask, rejectTask } from "@/server/actions/tasks";
|
|
|
|
const inputCls = "h-9 w-full rounded-md border border-input bg-transparent px-3 text-sm";
|
|
|
|
/**
|
|
* Generisches Objekt-Review (Story A3-1). Zeigt den aus der jüngsten Review-Aufgabe
|
|
* abgeleiteten Status eines beliebigen Objekts und bietet — je nach Rolle —
|
|
* Einreichen zur Validierung (Vier-Augen) bzw. Validieren/Zurückweisen an.
|
|
*
|
|
* `submit` ist die objekt-modulspezifische Einreich-Action (z. B. `submitRiskForReview`
|
|
* gebunden an die Objekt-ID); sie erwartet `approverId` und optional `note` im FormData.
|
|
*/
|
|
export async function ObjectReview({
|
|
entityType,
|
|
entityId,
|
|
title,
|
|
submit,
|
|
canSubmit,
|
|
}: {
|
|
entityType: string;
|
|
entityId: string;
|
|
entityRef?: string | null;
|
|
title: string;
|
|
submit: (formData: FormData) => Promise<void>;
|
|
canSubmit: boolean;
|
|
}) {
|
|
const session = await requireSession();
|
|
const db = dbForTenant(session.user.tenantId);
|
|
const task = await latestReviewTask(db, entityType, entityId);
|
|
const state = reviewState(task);
|
|
|
|
const isOpen = task?.status === "OPEN";
|
|
const isAssignee = task?.assigneeId === session.user.id;
|
|
const canValidate = hasPermission(session, "validate_objects");
|
|
const validators = !isOpen && canSubmit ? await eligibleValidators(db, session.user.id) : [];
|
|
|
|
const names = new Map<string, string>();
|
|
const ids = [task?.assigneeId, task?.createdById, task?.resolvedById].filter(Boolean) as string[];
|
|
if (ids.length) {
|
|
(await db.user.findMany({ where: { id: { in: ids } }, select: { id: true, name: true } })).forEach((u) => names.set(u.id, u.name));
|
|
}
|
|
const lastReviewComment = [...(task?.comments ?? [])].reverse().find((c) => c.kind === "reject" || c.kind === "approve");
|
|
|
|
return (
|
|
<section className="rounded-xl border bg-card p-4">
|
|
<div className="flex items-center justify-between gap-2">
|
|
<div className="flex items-center gap-2">
|
|
<span className="font-heading text-sm font-semibold">Validierung</span>
|
|
<Pill tone={REVIEW_TONE[state]}>{REVIEW_LABEL[state]}</Pill>
|
|
</div>
|
|
{task && <span className="text-[11.5px] text-muted-foreground">nur „Bestätigt“ zählt als abgesichert</span>}
|
|
</div>
|
|
|
|
{/* Laufende Validierung */}
|
|
{isOpen && (
|
|
<div className="mt-3 text-[12.5px]">
|
|
<p className="text-muted-foreground">Zur Validierung bei <b>{names.get(task!.assigneeId ?? "") ?? "—"}</b>{task!.createdById ? <> · eingereicht von {names.get(task!.createdById) ?? "—"}</> : null}.</p>
|
|
{isAssignee && canValidate ? (
|
|
<div className="mt-2 flex flex-wrap items-center gap-2">
|
|
<form action={approveTask.bind(null, task!.id)}>
|
|
<Button type="submit" size="sm">Validieren</Button>
|
|
</form>
|
|
<details className="relative">
|
|
<summary className="inline-flex h-8 cursor-pointer list-none items-center rounded-md border border-input px-2.5 text-sm font-medium select-none hover:bg-muted [&::-webkit-details-marker]:hidden">Zurückweisen</summary>
|
|
<form action={rejectTask.bind(null, task!.id)} className="shadow-card absolute left-0 z-10 mt-2 w-80 space-y-2 rounded-xl border bg-card p-3">
|
|
<Textarea name="note" rows={3} placeholder="Grund der Rückweisung" required />
|
|
<Button type="submit" variant="secondary" size="sm">Zurückweisen</Button>
|
|
</form>
|
|
</details>
|
|
</div>
|
|
) : (
|
|
<p className="mt-1 text-muted-foreground">Wartet auf Entscheidung des Validators.</p>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* Abgeschlossenes Review + Begründung */}
|
|
{!isOpen && lastReviewComment && (
|
|
<p className="mt-2 text-[12.5px] text-muted-foreground">
|
|
{state === "bestaetigt" ? "Bestätigt" : "Zurückgewiesen"} von {names.get(task!.resolvedById ?? "") ?? "—"}: „{lastReviewComment.body}“
|
|
</p>
|
|
)}
|
|
|
|
{/* Einreichen zur Validierung */}
|
|
{!isOpen && canSubmit && (
|
|
validators.length === 0 ? (
|
|
<p className="mt-3 text-[12.5px] text-muted-foreground">Kein aktiver Validator (Recht „validate_objects“) verfügbar.</p>
|
|
) : (
|
|
<form action={submit} className="mt-3 space-y-2">
|
|
<p className="text-[12.5px] text-muted-foreground">
|
|
{state === "zurueckgewiesen" ? "Nach Nacharbeit erneut zur Validierung einreichen:" : "Zur Validierung einreichen (Vier-Augen):"}
|
|
</p>
|
|
<div className="grid gap-2 sm:grid-cols-[minmax(0,16rem)_1fr] sm:items-center">
|
|
<select name="approverId" required className={inputCls} defaultValue="">
|
|
<option value="" disabled>Validator wählen…</option>
|
|
{validators.map((v) => <option key={v.id} value={v.id}>{v.name}</option>)}
|
|
</select>
|
|
<input name="note" placeholder="Notiz (optional)" className={inputCls} />
|
|
</div>
|
|
<Button type="submit" size="sm" title={title}>Zur Validierung einreichen</Button>
|
|
</form>
|
|
)
|
|
)}
|
|
</section>
|
|
);
|
|
}
|