f30acef101
Deploy / deploy (push) Successful in 5m17s
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>
274 lines
10 KiB
TypeScript
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>
|
|
);
|
|
}
|