Files
craftvia/src/components/object-review.tsx
T
msolarczekandClaude Opus 5 c8e6f30a27
CI / build-and-check (push) Canceled after 0s
CI / audit (push) Canceled after 0s
CI / sbom (push) Canceled after 0s
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>
2026-09-14 11:05:39 +02:00

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>
);
}