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>
263 lines
9.4 KiB
TypeScript
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>
|
|
);
|
|
}
|