127 lines
3.7 KiB
TypeScript
127 lines
3.7 KiB
TypeScript
import { useEffect, useState } from "react";
|
|
import { toast } from "react-toastify";
|
|
import { requests } from "../api/client";
|
|
import type { ChangeRequest } from "../api/types";
|
|
import { Modal } from "./Modal";
|
|
import { Button } from "./ui";
|
|
|
|
type Decision = "APPROVE" | "REJECT" | "REQUEST_CHANGES";
|
|
|
|
const MAX = 500;
|
|
|
|
const meta: Record<Decision, { title: string; verb: string; variant: "success" | "danger" | "primary"; blurb: string; commentRequired: boolean }> = {
|
|
APPROVE: {
|
|
title: "Approve request",
|
|
verb: "Approve",
|
|
variant: "success",
|
|
blurb: "The agent will be allowed to proceed. A platform-signed receipt will be issued.",
|
|
commentRequired: false,
|
|
},
|
|
REJECT: {
|
|
title: "Reject request",
|
|
verb: "Reject",
|
|
variant: "danger",
|
|
blurb: "This is a hard blocker. The agent must submit a new request to try again.",
|
|
commentRequired: false,
|
|
},
|
|
REQUEST_CHANGES: {
|
|
title: "Request changes",
|
|
verb: "Request changes",
|
|
variant: "primary",
|
|
blurb: "The agent will see your notes and can update the request for re-review.",
|
|
commentRequired: true,
|
|
},
|
|
};
|
|
|
|
export function DecisionModal({
|
|
request,
|
|
decision,
|
|
onClose,
|
|
onDone,
|
|
}: {
|
|
request: ChangeRequest;
|
|
decision: Decision | null;
|
|
onClose: () => void;
|
|
onDone: (updated: ChangeRequest) => void;
|
|
}) {
|
|
const [comment, setComment] = useState("");
|
|
const [loading, setLoading] = useState(false);
|
|
|
|
useEffect(() => {
|
|
setComment("");
|
|
}, [decision]);
|
|
|
|
if (!decision) return null;
|
|
const m = meta[decision];
|
|
const tooLong = comment.length > MAX;
|
|
const missingRequired = m.commentRequired && comment.trim().length === 0;
|
|
|
|
const submit = async () => {
|
|
if (tooLong || missingRequired) return;
|
|
setLoading(true);
|
|
try {
|
|
const updated = await requests.decide(request.request_id, decision, comment.trim() || undefined);
|
|
toast.success(`${m.verb}d`);
|
|
onDone(updated);
|
|
onClose();
|
|
} catch (err) {
|
|
toast.error(err instanceof Error ? err.message : "Failed");
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<Modal
|
|
open={!!decision}
|
|
onClose={onClose}
|
|
title={m.title}
|
|
footer={
|
|
<>
|
|
<Button variant="ghost" onClick={onClose}>
|
|
Cancel
|
|
</Button>
|
|
<Button variant={m.variant} onClick={submit} loading={loading} disabled={tooLong || missingRequired}>
|
|
{m.verb}
|
|
</Button>
|
|
</>
|
|
}
|
|
>
|
|
<div className="space-y-4">
|
|
<div className="rounded-lg border border-border bg-surface-raised/50 p-3">
|
|
<p className="text-sm font-medium text-text">{request.title}</p>
|
|
<p className="mt-1 text-xs text-muted">
|
|
{request.changes.length} change{request.changes.length === 1 ? "" : "s"} ·{" "}
|
|
{request.agent?.name}
|
|
</p>
|
|
</div>
|
|
<p className="text-sm text-muted">{m.blurb}</p>
|
|
<div>
|
|
<div className="mb-1 flex items-center justify-between">
|
|
<span className="text-sm text-muted">
|
|
Comment {m.commentRequired ? <span className="text-changes">(required)</span> : "(optional)"}
|
|
</span>
|
|
<span className={`text-xs ${tooLong ? "text-rejected" : comment.length > MAX * 0.8 ? "text-pending" : "text-faint"}`}>
|
|
{comment.length}/{MAX}
|
|
</span>
|
|
</div>
|
|
<textarea
|
|
value={comment}
|
|
onChange={(e) => setComment(e.target.value)}
|
|
rows={4}
|
|
autoFocus
|
|
placeholder={m.commentRequired ? "Explain what needs to change…" : "Add an optional note…"}
|
|
className={`w-full rounded-lg border bg-bg px-3 py-2 text-sm text-text outline-none focus:border-primary ${
|
|
tooLong ? "border-rejected" : "border-border-strong"
|
|
}`}
|
|
/>
|
|
{tooLong && <p className="mt-1 text-xs text-rejected">Comment is too long (max {MAX}).</p>}
|
|
{missingRequired && (
|
|
<p className="mt-1 text-xs text-changes">A comment is required when requesting changes.</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</Modal>
|
|
);
|
|
}
|