src/features/reviews/workspace-finish-actions.tsx
Linked repository file from this build.
"use client";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { fieldMultilineClass } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { validateRequiredInput } from "@/programs/snap-negative/validation";
import type { SnapReview } from "@/programs/snap-negative/types";
import { finalCurrent, openFindingsBlockApproval, requiredInputCurrent } from "./commands";
import type { Actor, PolicyDecision, ReviewOutcome } from "./types";
import { PEC_ROUTING_PENDING } from "./workflow";
import { can } from "@/lib/auth/scope";
export type FinishKind =
| { type: "submit" }
| { type: "transition"; action: "approve" | "request_changes" }
| { type: "policy"; decision: PolicyDecision };
export interface PendingFinish {
kind: FinishKind;
}
type FinishAction = { key: string; label: string; kind: FinishKind };
function actionKey(kind: FinishKind): string {
return kind.type === "submit" ? "submit" : kind.type === "policy" ? kind.decision : kind.action;
}
function approvalBlockers(review: SnapReview): string[] {
const messages: string[] = [];
if (review.outcome === "drop") return messages;
if (openFindingsBlockApproval(review))
messages.push("Resolve or refuse open edit-check findings before approval.");
if (!review.outcome) messages.push("Review outcome is unset.");
if (!requiredInputCurrent(review) || !finalCurrent(review))
messages.push("Complete required fields and apply current Final Results.");
return messages;
}
export function WorkspaceFinishActions({
review,
actor,
pendingFinish,
onPendingFinishChange,
onExecute,
}: {
review: SnapReview;
actor: Actor;
pendingFinish: PendingFinish | null;
onPendingFinishChange(pending: PendingFinish | null): void;
onExecute(payload: {
kind: FinishKind;
note: string;
skipPec: boolean;
policyNote: string;
}): Promise<boolean>;
}) {
const [busy, setBusy] = useState(false);
const [note, setNote] = useState("");
const skipPec = true;
const actions: FinishAction[] = [];
if (actor.role === "examiner" && ["new_active", "needs_changes"].includes(review.state)) {
actions.push({
key: "submit",
label: review.state === "needs_changes" ? "Save & Resubmit" : "Save & Submit",
kind: { type: "submit" },
});
} else if (
can(actor, "seniorReview") &&
(review.state === "awaiting_review" ||
(actor.role === "program_manager" &&
review.state === "awaiting_manager" &&
review.outcome === "drop"))
) {
actions.push(
{
key: "request_changes",
label: "Return to examiner",
kind: { type: "transition", action: "request_changes" },
},
{ key: "approve", label: "Approve", kind: { type: "transition", action: "approve" } },
);
} else if (actor.role === "pec" && review.state === "awaiting_pec_approval") {
actions.push(
{ key: "deny", label: "Deny", kind: { type: "policy", decision: "deny" } },
{
key: "conditional",
label: "Conditional approval",
kind: { type: "policy", decision: "conditional" },
},
{ key: "approve", label: "Approve", kind: { type: "policy", decision: "approve" } },
);
}
function close() {
onPendingFinishChange(null);
setNote("");
}
async function confirm() {
if (!pendingFinish || busy) return;
setBusy(true);
try {
const policy = pendingFinish.kind.type === "policy";
if (
await onExecute({
kind: pendingFinish.kind,
note: policy ? "" : note,
policyNote: policy ? note : "",
skipPec,
})
)
close();
} finally {
setBusy(false);
}
}
return (
<div className="flex flex-wrap items-start gap-2" aria-busy={busy}>
{actions.map(({ key, label, kind }) => {
const isOpen =
pendingFinish?.kind.type === kind.type && actionKey(pendingFinish.kind) === key;
const submit = kind.type === "submit";
const policy = kind.type === "policy";
const deny = key === "deny" || key === "request_changes";
const routingPending = kind.type === "transition" && !deny && review.outcome !== "drop";
const showNote = submit || deny || policy;
const noteRequired = deny || key === "conditional";
let noteLabel = "Optional note";
if (policy) noteLabel = "Policy / conditions note";
else if (deny) noteLabel = "Reason for return";
else if (review.outcome === "drop") noteLabel = "Drop explanation";
const blockers = deny
? []
: submit
? review.outcome === "drop"
? []
: validateRequiredInput(review).map((item) => item.message)
: approvalBlockers(review);
if (submit) {
if (!review.outcome)
blockers.unshift("Choose a review outcome in the review general section.");
if (review.outcome === "drop" && !review.dropKind) blockers.push("Choose a drop kind.");
if (review.outcome !== "drop" && !finalCurrent(review))
blockers.push("Apply Final Results on the Computation tab.");
}
if (routingPending) blockers.push(PEC_ROUTING_PENDING);
let confirmLabel = "Confirm approve";
if (submit) confirmLabel = "Confirm submit";
else if (deny) confirmLabel = "Confirm deny";
else if (key === "conditional") confirmLabel = "Confirm conditional approval";
else if (kind.type === "transition" && review.outcome === "drop")
confirmLabel = review.state === "awaiting_manager" ? "Approve drop" : "Send to manager";
return (
<div key={key} className="relative max-w-xs">
<Button
type="button"
size="sm"
variant={submit || deny || key === "conditional" ? "outline" : "default"}
className={submit ? "border-primary/50 text-primary hover:bg-primary/10" : undefined}
disabled={busy}
aria-expanded={isOpen}
onClick={() => {
if (isOpen) close();
else {
setNote("");
onPendingFinishChange({ kind });
}
}}
>
{label}
</Button>
{isOpen && (
<div
role="region"
aria-label="Confirm finish action"
className="fixed right-4 bottom-4 z-20 max-h-[45dvh] w-[min(22rem,calc(100vw-2rem))] overflow-auto rounded-md border bg-popover p-3 text-sm shadow-md"
>
{blockers.length > 0 && (
<ul className="mb-2 list-inside list-disc space-y-0.5 text-destructive">
{blockers.map((message) => (
<li key={message}>{message}</li>
))}
</ul>
)}
{showNote && (
<div className="mb-2 space-y-1">
<Label htmlFor={policy ? "policy-note" : "finish-note"} className="text-xs">
{noteLabel}
{noteRequired && <span className="text-destructive"> *</span>}
</Label>
<textarea
id={policy ? "policy-note" : "finish-note"}
rows={3}
value={note}
disabled={busy}
onChange={(event) => setNote(event.target.value)}
className={fieldMultilineClass}
/>
</div>
)}
<div className="flex justify-end gap-2">
<Button type="button" size="sm" variant="ghost" disabled={busy} onClick={close}>
Cancel
</Button>
<Button
type="button"
size="sm"
disabled={busy || blockers.length > 0 || (noteRequired && !note.trim())}
onClick={() => void confirm()}
>
{confirmLabel}
</Button>
</div>
</div>
)}
</div>
);
})}
</div>
);
}
export function outcomeLabel(outcome: ReviewOutcome): string {
return { clean: "Valid", error: "Invalid", drop: "Drop", "": "Unset" }[outcome];
}