Files
vfxreview/app/(dashboard)/pipeline/deliveries/HandoffDialogs.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

219 lines
7.1 KiB
TypeScript

"use client";
import { useState } from "react";
import { Copy } 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 { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { useToast } from "@/components/ui/use-toast";
import { buildShareMessage } from "@/lib/render-pipeline/delivery-handoff";
export interface HandoffPackage {
id: string;
packageNumber: number;
name: string;
projectCode: string;
episode: string;
rootPath: string | null;
uploadUrl: string | null;
itemCount: number;
exrFileCount: number;
movCount: number;
totalBytes: number;
items: { shotCode: string; clientCode: string | null; versionString: string }[];
}
function formatSize(bytes: number): string {
if (!bytes) return "size unknown";
const gb = bytes / 1_073_741_824;
return gb >= 1 ? `${gb.toFixed(1)} GB` : `${(bytes / 1_048_576).toFixed(0)} MB`;
}
async function post(path: string, body: unknown): Promise<string | null> {
const res = await fetch(path, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
if (res.ok) return null;
const data = await res.json().catch(() => ({}));
return data.error ?? res.statusText;
}
/** Step one: where the folder now lives on the client's side. */
export function RecordUploadDialog({
pkg,
onClose,
onSaved,
}: {
pkg: HandoffPackage;
onClose: () => void;
onSaved: () => void;
}) {
const { toast } = useToast();
const [uploadUrl, setUploadUrl] = useState(pkg.uploadUrl ?? "");
const [confirmed, setConfirmed] = useState(false);
const [saving, setSaving] = useState(false);
async function save() {
setSaving(true);
const error = await post(`/api/deliveries/${pkg.id}/uploaded`, { uploadUrl, contentsConfirmed: confirmed });
setSaving(false);
if (error) {
toast({ title: "Upload not recorded", description: error, variant: "destructive" });
return;
}
onSaved();
onClose();
}
return (
<Dialog open onOpenChange={(open) => !open && onClose()}>
<DialogContent>
<DialogHeader>
<DialogTitle>
Record upload — #{pkg.packageNumber} {pkg.name}
</DialogTitle>
<DialogDescription>
Paste the link to the folder on the client&apos;s portal. The package stays on the dashboard as
awaiting delivery until the link has also been shared.
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="rounded-md border border-zinc-800 bg-zinc-900/60 p-3 text-xs text-zinc-400 space-y-1">
<div className="text-zinc-200">
The upload should contain {pkg.itemCount} shot{pkg.itemCount === 1 ? "" : "s"} · {pkg.exrFileCount} EXR
frames · {pkg.movCount} MOV{pkg.movCount === 1 ? "" : "s"} · {formatSize(pkg.totalBytes)}
</div>
{pkg.rootPath && <div className="break-all">Source: {pkg.rootPath}</div>}
</div>
<div className="space-y-1.5">
<Label htmlFor="upload-url">Upload link</Label>
<Input
id="upload-url"
type="url"
placeholder="https://…"
value={uploadUrl}
onChange={(e) => setUploadUrl(e.target.value)}
autoFocus
/>
</div>
<label className="flex items-start gap-2 text-sm text-zinc-300">
<input
type="checkbox"
className="mt-1"
checked={confirmed}
onChange={(e) => setConfirmed(e.target.checked)}
/>
<span>I opened this link and the upload has finished and matches the counts above.</span>
</label>
</div>
<DialogFooter>
<Button variant="outline" onClick={onClose}>
Cancel
</Button>
<Button onClick={save} disabled={saving || !uploadUrl.trim() || !confirmed}>
Record upload
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
/** Step two: the link went to a person. This is what makes it delivered. */
export function RecordShareDialog({
pkg,
onClose,
onSaved,
}: {
pkg: HandoffPackage & { uploadUrl: string };
onClose: () => void;
onSaved: () => void;
}) {
const { toast } = useToast();
const [sharedWith, setSharedWith] = useState("");
const [note, setNote] = useState("");
const [saving, setSaving] = useState(false);
const message = buildShareMessage(pkg);
async function copy() {
try {
await navigator.clipboard.writeText(message);
toast({ title: "Message copied" });
} catch {
toast({ title: "Could not copy", description: "Select the text and copy it by hand.", variant: "destructive" });
}
}
async function save() {
setSaving(true);
const error = await post(`/api/deliveries/${pkg.id}/delivered`, { sharedWith, note: note || null });
setSaving(false);
if (error) {
toast({ title: "Not marked delivered", description: error, variant: "destructive" });
return;
}
onSaved();
onClose();
}
return (
<Dialog open onOpenChange={(open) => !open && onClose()}>
<DialogContent>
<DialogHeader>
<DialogTitle>
Share link — #{pkg.packageNumber} {pkg.name}
</DialogTitle>
<DialogDescription>
Send this message to the client, then record who it went to. That marks the package delivered.
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-1.5">
<div className="flex items-center justify-between">
<Label htmlFor="share-message">Message</Label>
<Button size="sm" variant="outline" onClick={copy}>
<Copy className="h-3.5 w-3.5 mr-2" />
Copy message
</Button>
</div>
<Textarea id="share-message" readOnly value={message} rows={8} className="font-mono text-xs" />
</div>
<div className="space-y-1.5">
<Label htmlFor="shared-with">Sent to</Label>
<Input
id="shared-with"
placeholder="Name, and where — e.g. Sam, production WhatsApp group"
value={sharedWith}
onChange={(e) => setSharedWith(e.target.value)}
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="share-note">Note (optional)</Label>
<Input id="share-note" value={note} onChange={(e) => setNote(e.target.value)} />
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={onClose}>
Cancel
</Button>
<Button onClick={save} disabled={saving || !sharedWith.trim()}>
Link sent — mark delivered
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}