Files
patchpass/UI/src/components/DecisionModal.tsx
T
Space-Banane 7e05dd918c
Deploy / Build (push) Successful in 28s
Deploy / Test & Lint (push) Failing after 29s
Deploy / Build and Push Docker Image (push) Has been skipped
Patchpass V1
2026-07-18 20:14:44 +02:00

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