Files
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

263 lines
9.4 KiB
TypeScript

"use client";
import { useEffect, useState } from "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 { DELIVERABLES, DELIVERABLE_LABEL, parseShotCodes, type Deliverable } from "@/lib/render-pipeline/delivery-request-state";
const SELECT_CLASS = "w-full rounded-md border border-zinc-800 bg-zinc-900 px-3 py-2 text-sm text-zinc-200";
interface ProjectOption {
id: string;
name: string;
code: string;
}
/**
* Log what a client asked for. The ask arrives over chat, so this has to be
* quicker than deciding to do it later: a project, the shots, who asked.
*
* With `preset` the shots are already chosen (the shot-status selection);
* otherwise they come from an episode or a pasted list of codes.
*/
export function LogRequestDialog({
open,
onClose,
onLogged,
preset,
defaultProjectId,
}: {
open: boolean;
onClose: () => void;
onLogged: () => void;
preset?: { projectId: string; shotIds: string[] };
defaultProjectId?: string;
}) {
const { toast } = useToast();
const [projects, setProjects] = useState<ProjectOption[]>([]);
const [projectId, setProjectId] = useState("");
const [episodes, setEpisodes] = useState<{ episode: string; shotCount: number }[]>([]);
const [mode, setMode] = useState<"episode" | "codes">("episode");
const [episode, setEpisode] = useState("");
const [codes, setCodes] = useState("");
const [deliverable, setDeliverable] = useState<Deliverable>("EXR");
const [requestedBy, setRequestedBy] = useState("");
const [dueDate, setDueDate] = useState("");
const [note, setNote] = useState("");
const [error, setError] = useState<string | null>(null);
const [saving, setSaving] = useState(false);
useEffect(() => {
if (!open) return;
setProjectId(preset?.projectId ?? defaultProjectId ?? "");
setMode("episode");
setEpisode("");
setCodes("");
setDeliverable("EXR");
setRequestedBy("");
setDueDate("");
setNote("");
setError(null);
if (preset) return;
fetch("/api/projects")
.then((res) => (res.ok ? res.json() : { projects: [] }))
.then((body) => setProjects((body.projects ?? body) as ProjectOption[]))
.catch(() => setProjects([]));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open]);
useEffect(() => {
setEpisodes([]);
setEpisode("");
if (!open || preset || !projectId) return;
let stale = false;
fetch(`/api/delivery-requests/options?projectId=${encodeURIComponent(projectId)}`)
.then((res) => (res.ok ? res.json() : { episodes: [] }))
.then((body) => {
if (!stale) setEpisodes(body.episodes ?? []);
})
.catch(() => {});
return () => {
stale = true;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open, projectId]);
const codeCount = parseShotCodes(codes).length;
const hasShots = preset ? preset.shotIds.length > 0 : mode === "episode" ? Boolean(episode) : codeCount > 0;
const canSave = Boolean(projectId) && hasShots && requestedBy.trim().length > 0 && !saving;
async function save() {
setSaving(true);
setError(null);
const res = await fetch("/api/delivery-requests", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
projectId,
...(preset ? { shotIds: preset.shotIds } : mode === "episode" ? { episode } : { shotCodes: codes }),
deliverable,
requestedBy,
dueDate: dueDate || null,
note: note || null,
}),
});
const body = await res.json().catch(() => ({}));
setSaving(false);
if (!res.ok) {
// Shown in place rather than as a toast: an unmatched code has to be
// fixed in the text right above it.
setError(body.error ?? res.statusText);
return;
}
toast({
title: `Request #${body.request.requestNumber} logged`,
description: `${body.request.lineCount} shot${body.request.lineCount === 1 ? "" : "s"} awaiting delivery`,
});
onLogged();
onClose();
}
return (
<Dialog open={open} onOpenChange={(o) => !o && !saving && onClose()}>
<DialogContent>
<DialogHeader>
<DialogTitle>Log delivery request</DialogTitle>
<DialogDescription>
What the client asked for. It stays under Awaiting delivery until every shot has been uploaded and the
link shared.
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
{preset ? (
<p className="text-sm text-zinc-300">
{preset.shotIds.length} selected shot{preset.shotIds.length === 1 ? "" : "s"}
</p>
) : (
<>
<div className="space-y-1.5">
<Label htmlFor="request-project">Project</Label>
<select
id="request-project"
value={projectId}
onChange={(e) => setProjectId(e.target.value)}
className={SELECT_CLASS}
>
<option value="">Select a project…</option>
{projects.map((p) => (
<option key={p.id} value={p.id}>
{p.name} ({p.code})
</option>
))}
</select>
</div>
<div className="space-y-2">
<div className="flex items-center gap-4 text-sm text-zinc-200">
<label className="flex items-center gap-2 cursor-pointer">
<input type="radio" checked={mode === "episode"} onChange={() => setMode("episode")} />
Whole episode
</label>
<label className="flex items-center gap-2 cursor-pointer">
<input type="radio" checked={mode === "codes"} onChange={() => setMode("codes")} />
Paste shot codes
</label>
</div>
{mode === "episode" ? (
<select
aria-label="Episode"
value={episode}
onChange={(e) => setEpisode(e.target.value)}
disabled={!projectId}
className={SELECT_CLASS}
>
<option value="">{projectId ? "Select an episode…" : "Pick a project first"}</option>
{episodes.map((e) => (
<option key={e.episode} value={e.episode}>
{e.episode} — {e.shotCount} shots
</option>
))}
</select>
) : (
<div className="space-y-1">
<Textarea
aria-label="Shot codes"
value={codes}
onChange={(e) => setCodes(e.target.value)}
rows={4}
placeholder="Paste from the client's message — our codes or theirs, any separator"
className="font-mono text-xs"
/>
<p className="text-xs text-zinc-500">
{codeCount} code{codeCount === 1 ? "" : "s"} found
</p>
</div>
)}
</div>
</>
)}
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5">
<Label htmlFor="request-deliverable">Wanted</Label>
<select
id="request-deliverable"
value={deliverable}
onChange={(e) => setDeliverable(e.target.value as Deliverable)}
className={SELECT_CLASS}
>
{DELIVERABLES.map((d) => (
<option key={d} value={d}>
{DELIVERABLE_LABEL[d]}
</option>
))}
</select>
</div>
<div className="space-y-1.5">
<Label htmlFor="request-due">Due (optional)</Label>
<Input
id="request-due"
type="date"
value={dueDate}
onChange={(e) => setDueDate(e.target.value)}
className="[color-scheme:dark]"
/>
</div>
</div>
<div className="space-y-1.5">
<Label htmlFor="request-by">Asked by</Label>
<Input
id="request-by"
placeholder="Who at the client asked"
value={requestedBy}
onChange={(e) => setRequestedBy(e.target.value)}
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="request-note">Note (optional)</Label>
<Input id="request-note" value={note} onChange={(e) => setNote(e.target.value)} />
</div>
{error && <p className="text-sm text-red-400 break-words">{error}</p>}
</div>
<DialogFooter>
<Button variant="outline" onClick={onClose} disabled={saving}>
Cancel
</Button>
<Button onClick={save} disabled={!canSave}>
Log request
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}