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>
219 lines
7.1 KiB
TypeScript
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'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>
|
|
);
|
|
}
|