From f30acef101afc98ec284f1f4febc92c1c0e49374 Mon Sep 17 00:00:00 2001 From: twotalesanimation <80506065+twotalesanimation@users.noreply.github.com> Date: Wed, 30 Sep 2026 14:21:24 +0200 Subject: [PATCH] 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 --- DELIVERY_WORKFLOW.md | 75 ++++- actions/shots.ts | 38 ++- app/(dashboard)/dashboard/page.tsx | 32 +- .../pipeline/deliveries/DeliveriesClient.tsx | 111 ++++++- .../pipeline/deliveries/HandoffDialogs.tsx | 218 ++++++++++++++ .../pipeline/deliveries/RequestsPanel.tsx | 273 ++++++++++++++++++ .../shot-status/ShotStatusClient.tsx | 68 ++++- .../deliveries/[packageId]/delivered/route.ts | 18 +- .../deliveries/[packageId]/uploaded/route.ts | 34 +++ .../[requestId]/cancel/route.ts | 34 +++ app/api/delivery-requests/options/route.ts | 22 ++ app/api/delivery-requests/route.ts | 44 +++ components/dashboard/AwaitingDelivery.tsx | 88 ++++++ components/dashboard/LogRequestButton.tsx | 23 ++ components/delivery/LogRequestDialog.tsx | 262 +++++++++++++++++ lib/dashboard/ops.ts | 67 +++++ lib/render-pipeline/deliveries.ts | 96 +++++- lib/render-pipeline/delivery-handoff.test.ts | 72 +++++ lib/render-pipeline/delivery-handoff.ts | 73 +++++ .../delivery-request-state.test.ts | 113 ++++++++ lib/render-pipeline/delivery-request-state.ts | 163 +++++++++++ lib/render-pipeline/delivery-requests.ts | 256 ++++++++++++++++ .../migration.sql | 8 + .../migration.sql | 44 +++ prisma/schema.prisma | 61 +++- 25 files changed, 2241 insertions(+), 52 deletions(-) create mode 100644 app/(dashboard)/pipeline/deliveries/HandoffDialogs.tsx create mode 100644 app/(dashboard)/pipeline/deliveries/RequestsPanel.tsx create mode 100644 app/api/deliveries/[packageId]/uploaded/route.ts create mode 100644 app/api/delivery-requests/[requestId]/cancel/route.ts create mode 100644 app/api/delivery-requests/options/route.ts create mode 100644 app/api/delivery-requests/route.ts create mode 100644 components/dashboard/AwaitingDelivery.tsx create mode 100644 components/dashboard/LogRequestButton.tsx create mode 100644 components/delivery/LogRequestDialog.tsx create mode 100644 lib/render-pipeline/delivery-handoff.test.ts create mode 100644 lib/render-pipeline/delivery-handoff.ts create mode 100644 lib/render-pipeline/delivery-request-state.test.ts create mode 100644 lib/render-pipeline/delivery-request-state.ts create mode 100644 lib/render-pipeline/delivery-requests.ts create mode 100644 prisma/migrations/20260930000000_delivery_handoff_evidence/migration.sql create mode 100644 prisma/migrations/20260930010000_delivery_requests/migration.sql diff --git a/DELIVERY_WORKFLOW.md b/DELIVERY_WORKFLOW.md index 8f180df..66b94f6 100644 --- a/DELIVERY_WORKFLOW.md +++ b/DELIVERY_WORKFLOW.md @@ -20,9 +20,14 @@ This is the operational guide. The design rationale and normative rules live in 18 shots"*, click **Build delivery**. 3. A render worker builds the folder on the delivery volume, verifies every file, and writes a manifest and CSV report. -4. A human uploads that folder to the client, then clicks **Mark delivered**. +4. A human uploads that folder to the client and clicks **Record upload**, + pasting the link to where it now lives. +5. They send the client that link and click **Share link**, recording who it + went to. Only then is the package `DELIVERED`. -Nothing is ever sent to the client automatically. Step 4 is deliberately manual. +Nothing is ever sent to the client automatically. Steps 4 and 5 are deliberately +manual — but until both are recorded the package sits in **Awaiting delivery** +at the top of the dashboard. --- @@ -69,8 +74,8 @@ Watch the package status on the same page: |---|---| | `QUEUED` | Waiting for a worker to pick it up | | `BUILDING` | A worker is creating folders and linking files | -| `READY` | Built and verified — upload it | -| `DELIVERED` | You confirmed the client has it | +| `READY` | Built and verified. Shown as *Built — not uploaded*, then *Uploaded — link not shared* | +| `DELIVERED` | The upload location and who the link was sent to are both on record | | `FAILED` | Something did not verify — see the error and the per-shot list | | `CANCELLED` | Abandoned before delivery | @@ -166,11 +171,59 @@ so it is logged and the package continues without it. ## Marking delivered -Once you have uploaded the folder, **Mark delivered** moves every packaged -export to `DELIVERED`. Requires admin, producer or supervisor. +A built package is not a delivered one, so delivery is two recorded steps. Both +require admin, producer or supervisor. -This is the end of the pipeline, and it is a human statement of fact — the app -has no way to know the client received anything. +1. **Record upload** — paste the link to the folder on the client's portal and + confirm it matches the shot, frame and MOV counts shown. The link can be + corrected until it is shared. +2. **Share link** — copy the generated message (link, shots, versions), send it, + and record who it went to. This moves every packaged export to `DELIVERED`. + +The second step is refused until the first is on record. Meanwhile the package +is listed under **Awaiting delivery** on the dashboard, amber after a day and +red after two. + +This is still a human statement of fact — the client's portal has no API, so +the app cannot see the upload itself. What it guarantees is that nothing reads +as delivered without a link, a recipient, a name and a time behind it. Packages +delivered before this existed show as delivered with no evidence recorded. + +--- + +## Delivery requests + +When a client asks for something ("EXRs for 116"), log it so it cannot be +forgotten. Requires admin, producer or supervisor. + +- **Dashboard → Awaiting delivery → Log request**, or the same button on + **Pipeline → Deliveries**: pick the project, then a whole episode or a pasted + list of shot codes (ours or the client's; a code that matches nothing is + refused rather than skipped). +- **Shot Status → Bulk Actions → Log Delivery Request** for a ticked selection. + +The shot list is a snapshot taken when the request is logged. The request then +sits in **Awaiting delivery** showing *"3 of 9 delivered"*, and on the +deliveries page every shot shows where it stands: not through QC, in QC, ready +to package, building, built, uploaded, delivered. **Build N ready** packages +the shots that have passed QC. + +A request has no "done" button. A shot's line closes only when: + +- a package containing it is shared (step 2 of marking delivered) after the + request was logged, or +- it is marked **Delivered Outside Pipeline** with the upload link and who the + link was sent to, dated on or after the day the request was logged. For a + shot on an open request those two fields are required. + +The request closes when every line has. A delivery made *before* the request +was logged does not count — a client asking again is asking for it again. The +only other way out is **Cancel request**, which needs a reason and stays in the +history. + +The *Wanted* field (EXRs, MOVs, both) records what was asked for; it does not +change what closes a line. A MOV-only ask is closed through Delivered Outside +Pipeline, since a MOV review package has no delivered state. --- @@ -233,8 +286,12 @@ a different spec is a config change rather than a code change. | E16 | `POST` | `/api/deliveries` | Create a package and queue the build | | E17 | `GET` | `/api/deliveries` | List packages / history | | E18 | `GET` | `/api/deliveries/ready` | Ready exports grouped by episode | -| E19 | `POST` | `/api/deliveries/{id}/delivered` | Mark delivered | +| — | `POST` | `/api/deliveries/{id}/uploaded` | Record the upload: `{ uploadUrl, contentsConfirmed: true }` | +| E19 | `POST` | `/api/deliveries/{id}/delivered` | Record the share and mark delivered: `{ sharedWith, note? }` | | — | `POST` | `/api/deliveries/{id}/cancel` | Cancel a package | +| — | `GET` | `/api/delivery-requests` | List requests with per-shot stages (`?projectId=`, `?open=1`) | +| — | `POST` | `/api/delivery-requests` | Log a request: `{ projectId, episode \| shotIds \| shotCodes, deliverable, requestedBy, dueDate?, note? }` | +| — | `POST` | `/api/delivery-requests/{id}/cancel` | Cancel a request: `{ reason }` | | — | `POST` | `/api/ext/render/jobs/{jobId}/delivery` | Worker reports a build (API key) | `POST /api/deliveries` takes `{ projectId, episode?, exportIds?, options? }`. diff --git a/actions/shots.ts b/actions/shots.ts index 053f6f9..94da6f9 100644 --- a/actions/shots.ts +++ b/actions/shots.ts @@ -6,6 +6,7 @@ import { revalidatePath } from "next/cache"; import { z } from "zod"; import { ShotStatus, ShotPriority, ShotApprovalStatus, Prisma } from "@prisma/client"; import { recalcShotStatus, markShotForRevisions } from "@/lib/shot-status"; +import { countShotsOnOpenRequests } from "@/lib/render-pipeline/delivery-requests"; const createShotSchema = z.object({ scene: z.string().min(1).max(50).regex(/^[A-Z0-9_]+$/i, "Alphanumeric and underscore only"), @@ -718,6 +719,8 @@ export async function bulkSetManualDelivery( shotIds: string[], deliveredOn: string | null, note?: string, + /** Where it was uploaded and who was sent the link. */ + evidence?: { uploadUrl?: string; sharedWith?: string }, ) { const session = await auth(); if (!session?.user) throw new Error("Unauthorized"); @@ -726,7 +729,7 @@ export async function bulkSetManualDelivery( } const ids = z.array(z.string().cuid()).parse(shotIds); - if (!ids.length) return { success: true, updated: 0 }; + if (!ids.length) return { success: true as const, updated: 0 }; let deliveredAt: Date | null = null; if (deliveredOn) { @@ -745,6 +748,27 @@ export async function bulkSetManualDelivery( if (shots.length !== ids.length) throw new Error("Some shots not found"); const trimmedNote = note?.trim() || null; + const uploadUrl = evidence?.uploadUrl?.trim() || null; + const sharedWith = evidence?.sharedWith?.trim() || null; + if (uploadUrl && !/^https?:\/\/\S+$/i.test(uploadUrl)) { + return { success: false as const, error: "The upload link must be a full http(s) link" }; + } + + // A backfill of old deliveries may have no link to give. A shot a client is + // waiting on is different: without the link and recipient the request it + // answers would stay open, so say so now rather than leave it unexplained. + if (deliveredAt && (!uploadUrl || !sharedWith)) { + const owed = await countShotsOnOpenRequests(ids); + if (owed > 0) { + // Returned, not thrown: a production build replaces a thrown action + // error with a generic message, and this one has to reach the person. + return { + success: false as const, + error: `${owed} of these shots ${owed === 1 ? "is" : "are"} on an open delivery request — add the upload link and who it was sent to`, + }; + } + } + await db.shot.updateMany({ where: { id: { in: ids } }, data: deliveredAt @@ -752,8 +776,16 @@ export async function bulkSetManualDelivery( manualDeliveredAt: deliveredAt, manualDeliveredById: session.user.id, manualDeliveryNote: trimmedNote, + manualDeliveryUrl: uploadUrl, + manualDeliverySharedWith: sharedWith, } - : { manualDeliveredAt: null, manualDeliveredById: null, manualDeliveryNote: null }, + : { + manualDeliveredAt: null, + manualDeliveredById: null, + manualDeliveryNote: null, + manualDeliveryUrl: null, + manualDeliverySharedWith: null, + }, }); for (const pid of [...new Set(shots.map((s) => s.projectId))]) { @@ -762,7 +794,7 @@ export async function bulkSetManualDelivery( revalidatePath(`/shot-status`); revalidatePath(`/dashboard`); - return { success: true, updated: shots.length }; + return { success: true as const, updated: shots.length }; } // ── Bulk Delete Shots ───────────────────────────────────────────────────────── diff --git a/app/(dashboard)/dashboard/page.tsx b/app/(dashboard)/dashboard/page.tsx index 59e8cec..5a56f99 100644 --- a/app/(dashboard)/dashboard/page.tsx +++ b/app/(dashboard)/dashboard/page.tsx @@ -5,12 +5,15 @@ import { BlockedQueue } from "@/components/dashboard/BlockedQueue"; import { FarmStrip } from "@/components/dashboard/FarmStrip"; import { DeliveryCountdown } from "@/components/dashboard/DeliveryCountdown"; import { QcQueue } from "@/components/dashboard/QcQueue"; +import { AwaitingDelivery } from "@/components/dashboard/AwaitingDelivery"; +import { LogRequestButton } from "@/components/dashboard/LogRequestButton"; import { RecentDeliveries } from "@/components/dashboard/RecentDeliveries"; import { WhoseCourt } from "@/components/dashboard/WhoseCourt"; import { ShotQueue } from "@/components/dashboard/ShotQueue"; import { TaskWidgets } from "@/components/dashboard/TaskWidgets"; import { ScheduleWidgets } from "@/components/dashboard/ScheduleWidgets"; import { + getAwaitingDelivery, getBlockedExports, getDeliveryCountdown, getFarmStatus, @@ -20,6 +23,7 @@ import { getRecentDeliveries, getWhoseCourt, } from "@/lib/dashboard/ops"; +import { listDeliveryRequests } from "@/lib/render-pipeline/delivery-requests"; import type { DashboardStats } from "@/types"; export const metadata = { title: "Dashboard" }; @@ -37,7 +41,7 @@ async function getDashboardData(userId: string, role: string) { const isClient = role === "CLIENT"; // Producers see the floor; an artist sees the failures that are theirs. - const [counts, blocked, farm, episodes, qcQueue, qcStaleHours, deliveries, court] = + const [counts, blocked, farm, episodes, qcQueue, qcStaleHours, deliveries, awaitingDelivery, openRequests, court] = await Promise.all([ getOpsCounts(isArtist ? userId : undefined), getBlockedExports(isArtist ? { artistId: userId } : {}), @@ -46,6 +50,8 @@ async function getDashboardData(userId: string, role: string) { isArtist || isClient ? [] : getQcQueue(), getQcStaleHours(), isArtist || isClient ? [] : getRecentDeliveries(), + isArtist || isClient ? [] : getAwaitingDelivery(), + isArtist || isClient ? [] : listDeliveryRequests({ openOnly: true }), isArtist || isClient ? null : getWhoseCourt(), ]); @@ -128,6 +134,8 @@ async function getDashboardData(userId: string, role: string) { qcQueue, qcStaleHours, deliveries, + awaitingDelivery, + openRequests, court, shots, myTasks, @@ -152,6 +160,8 @@ export default async function DashboardPage() { qcQueue, qcStaleHours, deliveries, + awaitingDelivery, + openRequests, court, shots, myTasks, @@ -171,6 +181,26 @@ export default async function DashboardPage() { {!isClient && } + {/* Rows only clear by being dealt with; the heading stays so there is + always somewhere to log what a client just asked for. */} + {!isClient && !isArtistRole && ( +
+
+

0 + ? "text-sm font-semibold text-amber-400 uppercase tracking-wide" + : "text-sm font-semibold text-muted-foreground uppercase tracking-wide" + } + > + Awaiting delivery +

+ {canSeeSchedule && } +
+ +
+ )} + {!isClient && (

diff --git a/app/(dashboard)/pipeline/deliveries/DeliveriesClient.tsx b/app/(dashboard)/pipeline/deliveries/DeliveriesClient.tsx index 4cf8e71..8152b2b 100644 --- a/app/(dashboard)/pipeline/deliveries/DeliveriesClient.tsx +++ b/app/(dashboard)/pipeline/deliveries/DeliveriesClient.tsx @@ -3,11 +3,14 @@ import { useState } from "react"; import Link from "next/link"; import { useQuery, useQueryClient } from "@tanstack/react-query"; -import { ArrowLeft, Package, CheckCircle2, XCircle, RefreshCw } from "lucide-react"; +import { ArrowLeft, Package, CheckCircle2, XCircle, RefreshCw, Upload } from "lucide-react"; import { Button } from "@/components/ui/button"; import { useToast } from "@/components/ui/use-toast"; import { cn } from "@/lib/utils"; +import { handoffStage } from "@/lib/render-pipeline/delivery-handoff"; import { DELIVERY_STATUS_STYLES, statusLabel } from "../status-colors"; +import { RecordShareDialog, RecordUploadDialog } from "./HandoffDialogs"; +import { RequestsPanel } from "./RequestsPanel"; interface ReadyExport { exportId: string; @@ -35,14 +38,25 @@ interface PackageRow { rootPath: string | null; errorMessage: string | null; projectName: string; + projectCode: string; createdAt: string; builtAt: string | null; + uploadUrl: string | null; + uploadedAt: string | null; + uploadedByName: string | null; + sharedWith: string | null; + sharedNote: string | null; deliveredAt: string | null; + deliveredByName: string | null; itemCount: number; verifiedCount: number; + exrFileCount: number; + movCount: number; + totalBytes: number; items: { id: string; shotCode: string; + clientCode: string | null; versionString: string; status: string; fileCount: number | null; @@ -69,6 +83,7 @@ export function DeliveriesClient({ canBuild }: { canBuild: boolean }) { const [projectId, setProjectId] = useState(""); const [episode, setEpisode] = useState(""); const [building, setBuilding] = useState(false); + const [handoff, setHandoff] = useState<{ step: "upload" | "share"; packageId: string } | null>(null); const { data: projects } = useQuery({ queryKey: ["pipeline-projects"], @@ -126,16 +141,23 @@ export function DeliveriesClient({ canBuild }: { canBuild: boolean }) { } } - async function packageAction(id: string, verb: "delivered" | "cancel") { - const res = await fetch(`/api/deliveries/${id}/${verb}`, { method: "POST" }); - const body = await res.json().catch(() => ({})); - if (!res.ok) { - toast({ title: `${verb} failed`, description: body.error ?? res.statusText, variant: "destructive" }); - } + function refreshPackages() { queryClient.invalidateQueries({ queryKey: ["delivery-packages"] }); queryClient.invalidateQueries({ queryKey: ["delivery-ready"] }); } + async function cancelPackage(id: string) { + const res = await fetch(`/api/deliveries/${id}/cancel`, { method: "POST" }); + const body = await res.json().catch(() => ({})); + if (!res.ok) { + toast({ title: "Cancel failed", description: body.error ?? res.statusText, variant: "destructive" }); + } + refreshPackages(); + } + + // Looked up from the live list so the dialog never acts on a stale row. + const handoffPackage = handoff ? packages?.packages.find((p) => p.id === handoff.packageId) : undefined; + const episodes = ready?.episodes ?? []; const shown = episode ? episodes.filter((e) => e.episode === episode) : episodes; @@ -183,6 +205,8 @@ export function DeliveriesClient({ canBuild }: { canBuild: boolean }) { )}

+ + {/* Ready to build */} {projectId && (
@@ -238,7 +262,9 @@ export function DeliveriesClient({ canBuild }: { canBuild: boolean }) {
Packages
- {(packages?.packages ?? []).map((p) => ( + {(packages?.packages ?? []).map((p) => { + const stage = handoffStage(p); + return (
#{p.packageNumber} @@ -251,10 +277,16 @@ export function DeliveriesClient({ canBuild }: { canBuild: boolean }) { - {statusLabel(p.status)} + {stage === "AWAITING_UPLOAD" + ? "BUILT — NOT UPLOADED" + : stage === "AWAITING_SHARE" + ? "UPLOADED — LINK NOT SHARED" + : statusLabel(p.status)} {p.episode ? `episode ${p.episode} · ` : ""} @@ -263,17 +295,30 @@ export function DeliveriesClient({ canBuild }: { canBuild: boolean }) { {new Date(p.createdAt).toLocaleDateString()} {p.status === "BUILDING" && } - {canBuild && p.status === "READY" && p.kind === "EXR_DELIVERY" && ( + {canBuild && stage === "AWAITING_UPLOAD" && ( + )} + {canBuild && stage === "AWAITING_SHARE" && ( + )} {canBuild && ["QUEUED", "FAILED", "DRAFT"].includes(p.status) && ( @@ -282,7 +327,7 @@ export function DeliveriesClient({ canBuild }: { canBuild: boolean }) { variant="outline" onClick={(e) => { e.preventDefault(); - packageAction(p.id, "cancel"); + cancelPackage(p.id); }} > @@ -294,9 +339,31 @@ export function DeliveriesClient({ canBuild }: { canBuild: boolean }) {
{p.rootPath &&
{p.rootPath}
} {p.errorMessage &&
{p.errorMessage}
} + {p.uploadedAt && p.uploadUrl && ( +
+ Uploaded {new Date(p.uploadedAt).toLocaleString()} + {p.uploadedByName ? ` by ${p.uploadedByName}` : ""} —{" "} + + {p.uploadUrl} + + {canBuild && stage === "AWAITING_SHARE" && ( + + )} +
+ )} {p.deliveredAt && (
- Delivered {new Date(p.deliveredAt).toLocaleString()} + {p.sharedWith + ? `Link shared with ${p.sharedWith} ${new Date(p.deliveredAt).toLocaleString()}` + : `Delivered ${new Date(p.deliveredAt).toLocaleString()} — before uploads and shares were recorded`} + {p.deliveredByName ? ` by ${p.deliveredByName}` : ""} + {p.sharedNote ? ` — ${p.sharedNote}` : ""}
)}
@@ -325,12 +392,24 @@ export function DeliveriesClient({ canBuild }: { canBuild: boolean }) {
- ))} + ); + })} {(packages?.packages ?? []).length === 0 && (
No packages built yet
)}
+ + {handoff?.step === "upload" && handoffPackage && ( + setHandoff(null)} onSaved={refreshPackages} /> + )} + {handoff?.step === "share" && handoffPackage?.uploadUrl && ( + setHandoff(null)} + onSaved={refreshPackages} + /> + )}
); } diff --git a/app/(dashboard)/pipeline/deliveries/HandoffDialogs.tsx b/app/(dashboard)/pipeline/deliveries/HandoffDialogs.tsx new file mode 100644 index 0000000..31e3ab0 --- /dev/null +++ b/app/(dashboard)/pipeline/deliveries/HandoffDialogs.tsx @@ -0,0 +1,218 @@ +"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 { + 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 ( + !open && onClose()}> + + + + Record upload — #{pkg.packageNumber} {pkg.name} + + + 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. + + + +
+
+
+ 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)} +
+ {pkg.rootPath &&
Source: {pkg.rootPath}
} +
+ +
+ + setUploadUrl(e.target.value)} + autoFocus + /> +
+ + +
+ + + + + +
+
+ ); +} + +/** 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 ( + !open && onClose()}> + + + + Share link — #{pkg.packageNumber} {pkg.name} + + + Send this message to the client, then record who it went to. That marks the package delivered. + + + +
+
+
+ + +
+