Files
vfxreview/app/(dashboard)/pipeline/deliveries/RequestsPanel.tsx
T
twotalesanimation f30acef101
Deploy / deploy (push) Successful in 5m17s
feat(deliveries): delivery requests and upload/share evidence
A built package used to become DELIVERED on one click with nothing behind
it, and nothing recorded what a client had asked for.

- Marking delivered is now two recorded steps: Record upload (the link on
  the client's portal, contents confirmed) then Share link (who it was sent
  to). The second is refused until the first is on record.
- Delivery requests log a client's ask by episode, pasted shot codes or a
  Shot Status selection. A line closes only when its shot goes out with
  evidence after the request was logged; a request can otherwise only be
  cancelled with a reason.
- Delivered Outside Pipeline takes an upload link and recipient, required
  for shots on an open request.
- The dashboard gains an Awaiting delivery section listing open requests
  and built packages the client does not have yet.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 14:21:24 +02:00

274 lines
10 KiB
TypeScript

"use client";
import { useState } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { Package, Plus, XCircle } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { useToast } from "@/components/ui/use-toast";
import { cn } from "@/lib/utils";
import { LogRequestDialog } from "@/components/delivery/LogRequestDialog";
import {
DELIVERABLE_LABEL,
LINE_STAGE_LABEL,
type Deliverable,
type RequestLineStage,
} from "@/lib/render-pipeline/delivery-request-state";
interface RequestRow {
id: string;
requestNumber: number;
projectId: string;
projectCode: string;
episode: string;
deliverable: string;
requestedBy: string;
note: string | null;
dueDate: string | null;
createdAt: string;
cancelReason: string | null;
state: "OPEN" | "CLOSED" | "CANCELLED";
lineCount: number;
deliveredCount: number;
severity: "normal" | "warning" | "critical";
lines: {
id: string;
shotCode: string;
clientCode: string | null;
stage: RequestLineStage;
readyExportId: string | null;
}[];
}
const SEVERITY_STYLES: Record<RequestRow["severity"], string> = {
normal: "bg-zinc-500/15 text-zinc-300 border-zinc-500/30",
warning: "bg-amber-500/15 text-amber-400 border-amber-500/30",
critical: "bg-red-500/15 text-red-400 border-red-500/30",
};
const STAGE_STYLES: Record<RequestLineStage, string> = {
DELIVERED: "text-emerald-400",
UPLOADED: "text-amber-400",
BUILT: "text-amber-400",
BUILDING: "text-blue-400",
READY: "text-green-400",
IN_QC: "text-zinc-400",
NOT_READY: "text-zinc-500",
};
function requestTitle(r: RequestRow): string {
const what = DELIVERABLE_LABEL[r.deliverable as Deliverable] ?? r.deliverable;
return r.episode ? `${what} for ${r.episode}` : `${what} · ${r.lineCount} shot${r.lineCount === 1 ? "" : "s"}`;
}
/**
* What clients have asked for. An open request lists every shot with where it
* stands, so "did 116 go out?" is answered by the page rather than by memory.
*/
export function RequestsPanel({ projectId, canBuild }: { projectId: string; canBuild: boolean }) {
const { toast } = useToast();
const queryClient = useQueryClient();
const [logging, setLogging] = useState(false);
const [cancelling, setCancelling] = useState<RequestRow | null>(null);
const [reason, setReason] = useState("");
const [busy, setBusy] = useState(false);
const { data } = useQuery({
queryKey: ["delivery-requests", projectId],
queryFn: async () => {
const res = await fetch(`/api/delivery-requests${projectId ? `?projectId=${encodeURIComponent(projectId)}` : ""}`);
if (!res.ok) throw new Error("Failed to load delivery requests");
return res.json() as Promise<{ requests: RequestRow[] }>;
},
// Lines move as packages build and get shared on this same page.
refetchInterval: 5000,
});
const refresh = () => {
queryClient.invalidateQueries({ queryKey: ["delivery-requests"] });
queryClient.invalidateQueries({ queryKey: ["delivery-packages"] });
queryClient.invalidateQueries({ queryKey: ["delivery-ready"] });
};
async function build(request: RequestRow) {
const exportIds = request.lines.flatMap((l) => (l.readyExportId ? [l.readyExportId] : []));
if (!exportIds.length) return;
setBusy(true);
const res = await fetch("/api/deliveries", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ projectId: request.projectId, episode: request.episode || null, exportIds }),
});
const body = await res.json().catch(() => ({}));
setBusy(false);
if (!res.ok) {
toast({ title: "Build failed", description: body.error ?? res.statusText, variant: "destructive" });
} else {
toast({
title: `Package #${body.package.packageNumber} queued`,
description: `${body.package.itemCount} shots for request #${request.requestNumber}`,
});
}
refresh();
}
async function cancel() {
if (!cancelling) return;
setBusy(true);
const res = await fetch(`/api/delivery-requests/${cancelling.id}/cancel`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ reason }),
});
const body = await res.json().catch(() => ({}));
setBusy(false);
if (!res.ok) {
toast({ title: "Not cancelled", description: body.error ?? res.statusText, variant: "destructive" });
return;
}
setCancelling(null);
refresh();
}
const requests = data?.requests ?? [];
const open = requests.filter((r) => r.state === "OPEN");
const past = requests.filter((r) => r.state !== "OPEN");
return (
<div className="rounded-lg border border-zinc-800 bg-zinc-900/40">
<div className="flex items-center justify-between gap-3 px-4 py-2.5 border-b border-zinc-800">
<span className="text-sm font-medium text-white">Delivery requests</span>
{canBuild && (
<Button size="sm" variant="outline" onClick={() => setLogging(true)}>
<Plus className="h-3.5 w-3.5 mr-1.5" />
Log request
</Button>
)}
</div>
{open.length === 0 && <div className="p-4 text-sm text-zinc-500">No open requests.</div>}
<div className="divide-y divide-zinc-800/60">
{open.map((r) => {
const readyCount = r.lines.filter((l) => l.readyExportId).length;
return (
<details key={r.id} open className="group">
<summary className="flex items-center gap-3 px-4 py-3 cursor-pointer hover:bg-zinc-900/60 flex-wrap">
<span className="text-sm text-white font-medium">Request #{r.requestNumber}</span>
<span className="text-sm text-zinc-300">{requestTitle(r)}</span>
<span className={cn("rounded-full border px-2 py-0.5 text-xs", SEVERITY_STYLES[r.severity])}>
{r.deliveredCount} of {r.lineCount} delivered
</span>
<span className="text-xs text-zinc-500">
{r.projectCode} · asked by {r.requestedBy} · {new Date(r.createdAt).toLocaleDateString()}
{r.dueDate ? ` · due ${new Date(r.dueDate).toLocaleDateString()}` : ""}
</span>
<span className="ml-auto flex items-center gap-2">
{canBuild && readyCount > 0 && (
<Button
size="sm"
disabled={busy}
onClick={(e) => {
e.preventDefault();
build(r);
}}
>
<Package className="h-4 w-4 mr-2" />
Build {readyCount} ready
</Button>
)}
{canBuild && (
<Button
size="sm"
variant="outline"
onClick={(e) => {
e.preventDefault();
setReason("");
setCancelling(r);
}}
>
<XCircle className="h-4 w-4 mr-2 text-red-400" />
Cancel request
</Button>
)}
</span>
</summary>
<div className="px-4 pb-4 space-y-2">
{r.note && <div className="text-xs text-zinc-400">{r.note}</div>}
<div className="grid sm:grid-cols-2 lg:grid-cols-3 gap-x-6 gap-y-1">
{r.lines.map((line) => (
<div key={line.id} className="flex items-center gap-2 text-xs">
<span className="text-zinc-300">{line.shotCode}</span>
{line.clientCode && <span className="text-zinc-600">{line.clientCode}</span>}
<span className={STAGE_STYLES[line.stage]}>{LINE_STAGE_LABEL[line.stage]}</span>
</div>
))}
</div>
</div>
</details>
);
})}
</div>
{past.length > 0 && (
<details className="border-t border-zinc-800">
<summary className="px-4 py-2.5 cursor-pointer text-xs text-zinc-500 hover:text-zinc-300">
Closed and cancelled ({past.length})
</summary>
<div className="px-4 pb-3 space-y-1">
{past.map((r) => (
<div key={r.id} className="text-xs text-zinc-500">
<span className="text-zinc-300">Request #{r.requestNumber}</span> {requestTitle(r)} · {r.projectCode} ·
asked by {r.requestedBy} ·{" "}
{r.state === "CANCELLED" ? (
<span className="text-zinc-400">cancelled — {r.cancelReason}</span>
) : (
<span className="text-emerald-400">all {r.lineCount} delivered</span>
)}
</div>
))}
</div>
</details>
)}
<LogRequestDialog
open={logging}
onClose={() => setLogging(false)}
onLogged={refresh}
defaultProjectId={projectId || undefined}
/>
{cancelling && (
<Dialog open onOpenChange={(o) => !o && !busy && setCancelling(null)}>
<DialogContent>
<DialogHeader>
<DialogTitle>Cancel request #{cancelling.requestNumber}</DialogTitle>
<DialogDescription>
{cancelling.lineCount - cancelling.deliveredCount} of {cancelling.lineCount} shots have not been
delivered. Cancelling removes the request from Awaiting delivery; it stays in the history with the
reason.
</DialogDescription>
</DialogHeader>
<Input
aria-label="Reason"
placeholder="Why — e.g. client no longer needs 116"
value={reason}
onChange={(e) => setReason(e.target.value)}
autoFocus
/>
<DialogFooter>
<Button variant="outline" onClick={() => setCancelling(null)} disabled={busy}>
Keep request
</Button>
<Button variant="destructive" onClick={cancel} disabled={busy || !reason.trim()}>
Cancel request
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)}
</div>
);
}