feat(deliveries): delivery requests and upload/share evidence
Deploy / deploy (push) Successful in 5m17s
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>
This commit is contained in:
+66
-9
@@ -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? }`.
|
||||
|
||||
+35
-3
@@ -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 ─────────────────────────────────────────────────────────
|
||||
|
||||
@@ -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 && <StatsCards stats={stats} />}
|
||||
|
||||
{/* 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 && (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<h2
|
||||
className={
|
||||
openRequests.length + awaitingDelivery.length > 0
|
||||
? "text-sm font-semibold text-amber-400 uppercase tracking-wide"
|
||||
: "text-sm font-semibold text-muted-foreground uppercase tracking-wide"
|
||||
}
|
||||
>
|
||||
Awaiting delivery
|
||||
</h2>
|
||||
{canSeeSchedule && <LogRequestButton />}
|
||||
</div>
|
||||
<AwaitingDelivery requests={openRequests} packages={awaitingDelivery} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isClient && (
|
||||
<div className="space-y-3">
|
||||
<h2 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide">
|
||||
|
||||
@@ -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<string>("");
|
||||
const [episode, setEpisode] = useState<string>("");
|
||||
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 }) {
|
||||
)}
|
||||
</div>
|
||||
|
||||
<RequestsPanel projectId={projectId} canBuild={canBuild} />
|
||||
|
||||
{/* Ready to build */}
|
||||
{projectId && (
|
||||
<div className="rounded-lg border border-zinc-800 bg-zinc-900/40">
|
||||
@@ -238,7 +262,9 @@ export function DeliveriesClient({ canBuild }: { canBuild: boolean }) {
|
||||
<div className="rounded-lg border border-zinc-800 bg-zinc-900/40">
|
||||
<div className="px-4 py-2.5 border-b border-zinc-800 text-sm font-medium text-white">Packages</div>
|
||||
<div className="divide-y divide-zinc-800/60">
|
||||
{(packages?.packages ?? []).map((p) => (
|
||||
{(packages?.packages ?? []).map((p) => {
|
||||
const stage = handoffStage(p);
|
||||
return (
|
||||
<details key={p.id} className="group">
|
||||
<summary className="flex items-center gap-3 px-4 py-3 cursor-pointer hover:bg-zinc-900/60 flex-wrap">
|
||||
<span className="text-sm text-white font-medium">#{p.packageNumber}</span>
|
||||
@@ -251,10 +277,16 @@ export function DeliveriesClient({ canBuild }: { canBuild: boolean }) {
|
||||
<span
|
||||
className={cn(
|
||||
"rounded-full border px-2 py-0.5 text-xs",
|
||||
DELIVERY_STATUS_STYLES[p.status] ?? ""
|
||||
// Built is not delivered: a package still owed to the
|
||||
// client must not read as a green "READY".
|
||||
stage ? "bg-amber-500/15 text-amber-400 border-amber-500/30" : (DELIVERY_STATUS_STYLES[p.status] ?? "")
|
||||
)}
|
||||
>
|
||||
{statusLabel(p.status)}
|
||||
{stage === "AWAITING_UPLOAD"
|
||||
? "BUILT — NOT UPLOADED"
|
||||
: stage === "AWAITING_SHARE"
|
||||
? "UPLOADED — LINK NOT SHARED"
|
||||
: statusLabel(p.status)}
|
||||
</span>
|
||||
<span className="text-xs text-zinc-500">
|
||||
{p.episode ? `episode ${p.episode} · ` : ""}
|
||||
@@ -263,17 +295,30 @@ export function DeliveriesClient({ canBuild }: { canBuild: boolean }) {
|
||||
<span className="text-xs text-zinc-600">{new Date(p.createdAt).toLocaleDateString()}</span>
|
||||
{p.status === "BUILDING" && <RefreshCw className="h-3.5 w-3.5 text-blue-400 animate-spin" />}
|
||||
<span className="ml-auto flex items-center gap-2">
|
||||
{canBuild && p.status === "READY" && p.kind === "EXR_DELIVERY" && (
|
||||
{canBuild && stage === "AWAITING_UPLOAD" && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
packageAction(p.id, "delivered");
|
||||
setHandoff({ step: "upload", packageId: p.id });
|
||||
}}
|
||||
>
|
||||
<Upload className="h-4 w-4 mr-2 text-amber-400" />
|
||||
Record upload
|
||||
</Button>
|
||||
)}
|
||||
{canBuild && stage === "AWAITING_SHARE" && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
setHandoff({ step: "share", packageId: p.id });
|
||||
}}
|
||||
>
|
||||
<CheckCircle2 className="h-4 w-4 mr-2 text-emerald-400" />
|
||||
Mark delivered
|
||||
Share link
|
||||
</Button>
|
||||
)}
|
||||
{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);
|
||||
}}
|
||||
>
|
||||
<XCircle className="h-4 w-4 mr-2 text-red-400" />
|
||||
@@ -294,9 +339,31 @@ export function DeliveriesClient({ canBuild }: { canBuild: boolean }) {
|
||||
<div className="px-4 pb-4 space-y-2">
|
||||
{p.rootPath && <div className="text-xs text-zinc-500 break-all">{p.rootPath}</div>}
|
||||
{p.errorMessage && <div className="text-xs text-red-400">{p.errorMessage}</div>}
|
||||
{p.uploadedAt && p.uploadUrl && (
|
||||
<div className="text-xs text-zinc-400 break-all">
|
||||
Uploaded {new Date(p.uploadedAt).toLocaleString()}
|
||||
{p.uploadedByName ? ` by ${p.uploadedByName}` : ""} —{" "}
|
||||
<a href={p.uploadUrl} target="_blank" rel="noreferrer" className="text-sky-400 hover:underline">
|
||||
{p.uploadUrl}
|
||||
</a>
|
||||
{canBuild && stage === "AWAITING_SHARE" && (
|
||||
<button
|
||||
type="button"
|
||||
className="ml-2 text-zinc-500 hover:text-white underline"
|
||||
onClick={() => setHandoff({ step: "upload", packageId: p.id })}
|
||||
>
|
||||
change
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{p.deliveredAt && (
|
||||
<div className="text-xs text-emerald-400">
|
||||
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}` : ""}
|
||||
</div>
|
||||
)}
|
||||
<div className="grid sm:grid-cols-2 lg:grid-cols-3 gap-x-6 gap-y-1">
|
||||
@@ -325,12 +392,24 @@ export function DeliveriesClient({ canBuild }: { canBuild: boolean }) {
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
{(packages?.packages ?? []).length === 0 && (
|
||||
<div className="p-4 text-sm text-zinc-500">No packages built yet</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{handoff?.step === "upload" && handoffPackage && (
|
||||
<RecordUploadDialog pkg={handoffPackage} onClose={() => setHandoff(null)} onSaved={refreshPackages} />
|
||||
)}
|
||||
{handoff?.step === "share" && handoffPackage?.uploadUrl && (
|
||||
<RecordShareDialog
|
||||
pkg={{ ...handoffPackage, uploadUrl: handoffPackage.uploadUrl }}
|
||||
onClose={() => setHandoff(null)}
|
||||
onSaved={refreshPackages}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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<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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,273 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Package, Plus, XCircle } 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 { useToast } from "@/components/ui/use-toast";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { LogRequestDialog } from "@/components/delivery/LogRequestDialog";
|
||||
import {
|
||||
DELIVERABLE_LABEL,
|
||||
LINE_STAGE_LABEL,
|
||||
type Deliverable,
|
||||
type RequestLineStage,
|
||||
} from "@/lib/render-pipeline/delivery-request-state";
|
||||
|
||||
interface RequestRow {
|
||||
id: string;
|
||||
requestNumber: number;
|
||||
projectId: string;
|
||||
projectCode: string;
|
||||
episode: string;
|
||||
deliverable: string;
|
||||
requestedBy: string;
|
||||
note: string | null;
|
||||
dueDate: string | null;
|
||||
createdAt: string;
|
||||
cancelReason: string | null;
|
||||
state: "OPEN" | "CLOSED" | "CANCELLED";
|
||||
lineCount: number;
|
||||
deliveredCount: number;
|
||||
severity: "normal" | "warning" | "critical";
|
||||
lines: {
|
||||
id: string;
|
||||
shotCode: string;
|
||||
clientCode: string | null;
|
||||
stage: RequestLineStage;
|
||||
readyExportId: string | null;
|
||||
}[];
|
||||
}
|
||||
|
||||
const SEVERITY_STYLES: Record<RequestRow["severity"], string> = {
|
||||
normal: "bg-zinc-500/15 text-zinc-300 border-zinc-500/30",
|
||||
warning: "bg-amber-500/15 text-amber-400 border-amber-500/30",
|
||||
critical: "bg-red-500/15 text-red-400 border-red-500/30",
|
||||
};
|
||||
|
||||
const STAGE_STYLES: Record<RequestLineStage, string> = {
|
||||
DELIVERED: "text-emerald-400",
|
||||
UPLOADED: "text-amber-400",
|
||||
BUILT: "text-amber-400",
|
||||
BUILDING: "text-blue-400",
|
||||
READY: "text-green-400",
|
||||
IN_QC: "text-zinc-400",
|
||||
NOT_READY: "text-zinc-500",
|
||||
};
|
||||
|
||||
function requestTitle(r: RequestRow): string {
|
||||
const what = DELIVERABLE_LABEL[r.deliverable as Deliverable] ?? r.deliverable;
|
||||
return r.episode ? `${what} for ${r.episode}` : `${what} · ${r.lineCount} shot${r.lineCount === 1 ? "" : "s"}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* What clients have asked for. An open request lists every shot with where it
|
||||
* stands, so "did 116 go out?" is answered by the page rather than by memory.
|
||||
*/
|
||||
export function RequestsPanel({ projectId, canBuild }: { projectId: string; canBuild: boolean }) {
|
||||
const { toast } = useToast();
|
||||
const queryClient = useQueryClient();
|
||||
const [logging, setLogging] = useState(false);
|
||||
const [cancelling, setCancelling] = useState<RequestRow | null>(null);
|
||||
const [reason, setReason] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const { data } = useQuery({
|
||||
queryKey: ["delivery-requests", projectId],
|
||||
queryFn: async () => {
|
||||
const res = await fetch(`/api/delivery-requests${projectId ? `?projectId=${encodeURIComponent(projectId)}` : ""}`);
|
||||
if (!res.ok) throw new Error("Failed to load delivery requests");
|
||||
return res.json() as Promise<{ requests: RequestRow[] }>;
|
||||
},
|
||||
// Lines move as packages build and get shared on this same page.
|
||||
refetchInterval: 5000,
|
||||
});
|
||||
|
||||
const refresh = () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["delivery-requests"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["delivery-packages"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["delivery-ready"] });
|
||||
};
|
||||
|
||||
async function build(request: RequestRow) {
|
||||
const exportIds = request.lines.flatMap((l) => (l.readyExportId ? [l.readyExportId] : []));
|
||||
if (!exportIds.length) return;
|
||||
setBusy(true);
|
||||
const res = await fetch("/api/deliveries", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ projectId: request.projectId, episode: request.episode || null, exportIds }),
|
||||
});
|
||||
const body = await res.json().catch(() => ({}));
|
||||
setBusy(false);
|
||||
if (!res.ok) {
|
||||
toast({ title: "Build failed", description: body.error ?? res.statusText, variant: "destructive" });
|
||||
} else {
|
||||
toast({
|
||||
title: `Package #${body.package.packageNumber} queued`,
|
||||
description: `${body.package.itemCount} shots for request #${request.requestNumber}`,
|
||||
});
|
||||
}
|
||||
refresh();
|
||||
}
|
||||
|
||||
async function cancel() {
|
||||
if (!cancelling) return;
|
||||
setBusy(true);
|
||||
const res = await fetch(`/api/delivery-requests/${cancelling.id}/cancel`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ reason }),
|
||||
});
|
||||
const body = await res.json().catch(() => ({}));
|
||||
setBusy(false);
|
||||
if (!res.ok) {
|
||||
toast({ title: "Not cancelled", description: body.error ?? res.statusText, variant: "destructive" });
|
||||
return;
|
||||
}
|
||||
setCancelling(null);
|
||||
refresh();
|
||||
}
|
||||
|
||||
const requests = data?.requests ?? [];
|
||||
const open = requests.filter((r) => r.state === "OPEN");
|
||||
const past = requests.filter((r) => r.state !== "OPEN");
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border border-zinc-800 bg-zinc-900/40">
|
||||
<div className="flex items-center justify-between gap-3 px-4 py-2.5 border-b border-zinc-800">
|
||||
<span className="text-sm font-medium text-white">Delivery requests</span>
|
||||
{canBuild && (
|
||||
<Button size="sm" variant="outline" onClick={() => setLogging(true)}>
|
||||
<Plus className="h-3.5 w-3.5 mr-1.5" />
|
||||
Log request
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{open.length === 0 && <div className="p-4 text-sm text-zinc-500">No open requests.</div>}
|
||||
|
||||
<div className="divide-y divide-zinc-800/60">
|
||||
{open.map((r) => {
|
||||
const readyCount = r.lines.filter((l) => l.readyExportId).length;
|
||||
return (
|
||||
<details key={r.id} open className="group">
|
||||
<summary className="flex items-center gap-3 px-4 py-3 cursor-pointer hover:bg-zinc-900/60 flex-wrap">
|
||||
<span className="text-sm text-white font-medium">Request #{r.requestNumber}</span>
|
||||
<span className="text-sm text-zinc-300">{requestTitle(r)}</span>
|
||||
<span className={cn("rounded-full border px-2 py-0.5 text-xs", SEVERITY_STYLES[r.severity])}>
|
||||
{r.deliveredCount} of {r.lineCount} delivered
|
||||
</span>
|
||||
<span className="text-xs text-zinc-500">
|
||||
{r.projectCode} · asked by {r.requestedBy} · {new Date(r.createdAt).toLocaleDateString()}
|
||||
{r.dueDate ? ` · due ${new Date(r.dueDate).toLocaleDateString()}` : ""}
|
||||
</span>
|
||||
<span className="ml-auto flex items-center gap-2">
|
||||
{canBuild && readyCount > 0 && (
|
||||
<Button
|
||||
size="sm"
|
||||
disabled={busy}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
build(r);
|
||||
}}
|
||||
>
|
||||
<Package className="h-4 w-4 mr-2" />
|
||||
Build {readyCount} ready
|
||||
</Button>
|
||||
)}
|
||||
{canBuild && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
setReason("");
|
||||
setCancelling(r);
|
||||
}}
|
||||
>
|
||||
<XCircle className="h-4 w-4 mr-2 text-red-400" />
|
||||
Cancel request
|
||||
</Button>
|
||||
)}
|
||||
</span>
|
||||
</summary>
|
||||
<div className="px-4 pb-4 space-y-2">
|
||||
{r.note && <div className="text-xs text-zinc-400">{r.note}</div>}
|
||||
<div className="grid sm:grid-cols-2 lg:grid-cols-3 gap-x-6 gap-y-1">
|
||||
{r.lines.map((line) => (
|
||||
<div key={line.id} className="flex items-center gap-2 text-xs">
|
||||
<span className="text-zinc-300">{line.shotCode}</span>
|
||||
{line.clientCode && <span className="text-zinc-600">{line.clientCode}</span>}
|
||||
<span className={STAGE_STYLES[line.stage]}>{LINE_STAGE_LABEL[line.stage]}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{past.length > 0 && (
|
||||
<details className="border-t border-zinc-800">
|
||||
<summary className="px-4 py-2.5 cursor-pointer text-xs text-zinc-500 hover:text-zinc-300">
|
||||
Closed and cancelled ({past.length})
|
||||
</summary>
|
||||
<div className="px-4 pb-3 space-y-1">
|
||||
{past.map((r) => (
|
||||
<div key={r.id} className="text-xs text-zinc-500">
|
||||
<span className="text-zinc-300">Request #{r.requestNumber}</span> {requestTitle(r)} · {r.projectCode} ·
|
||||
asked by {r.requestedBy} ·{" "}
|
||||
{r.state === "CANCELLED" ? (
|
||||
<span className="text-zinc-400">cancelled — {r.cancelReason}</span>
|
||||
) : (
|
||||
<span className="text-emerald-400">all {r.lineCount} delivered</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</details>
|
||||
)}
|
||||
|
||||
<LogRequestDialog
|
||||
open={logging}
|
||||
onClose={() => setLogging(false)}
|
||||
onLogged={refresh}
|
||||
defaultProjectId={projectId || undefined}
|
||||
/>
|
||||
|
||||
{cancelling && (
|
||||
<Dialog open onOpenChange={(o) => !o && !busy && setCancelling(null)}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Cancel request #{cancelling.requestNumber}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{cancelling.lineCount - cancelling.deliveredCount} of {cancelling.lineCount} shots have not been
|
||||
delivered. Cancelling removes the request from Awaiting delivery; it stays in the history with the
|
||||
reason.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<Input
|
||||
aria-label="Reason"
|
||||
placeholder="Why — e.g. client no longer needs 116"
|
||||
value={reason}
|
||||
onChange={(e) => setReason(e.target.value)}
|
||||
autoFocus
|
||||
/>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setCancelling(null)} disabled={busy}>
|
||||
Keep request
|
||||
</Button>
|
||||
<Button variant="destructive" onClick={cancel} disabled={busy || !reason.trim()}>
|
||||
Cancel request
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -35,6 +35,7 @@ import { cn, getInitials } from "@/lib/utils";
|
||||
import { updateShotNotes, bulkUpdateDueDates, bulkAssignArtist, bulkDeleteShots, bulkSetManualDelivery, toggleKeyShot, toggleScaledShot } from "@/actions/shots";
|
||||
import { previewMovReview, buildMovReview } from "@/actions/deliveries";
|
||||
import { useToast } from "@/components/ui/use-toast";
|
||||
import { LogRequestDialog } from "@/components/delivery/LogRequestDialog";
|
||||
import {
|
||||
Film,
|
||||
Clock,
|
||||
@@ -55,6 +56,7 @@ import {
|
||||
Scaling,
|
||||
UserPlus,
|
||||
PackageCheck,
|
||||
ClipboardList,
|
||||
} from "lucide-react";
|
||||
import { format } from "date-fns";
|
||||
import { shotThumbnail } from "@/lib/shot-thumbnail";
|
||||
@@ -373,11 +375,17 @@ function ManualDeliveryDialog({
|
||||
}: {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onApply: (deliveredOn: string | null, note: string) => Promise<void>;
|
||||
onApply: (
|
||||
deliveredOn: string | null,
|
||||
note: string,
|
||||
evidence: { uploadUrl: string; sharedWith: string },
|
||||
) => Promise<void>;
|
||||
count: number;
|
||||
}) {
|
||||
const [date, setDate] = useState("");
|
||||
const [note, setNote] = useState("");
|
||||
const [uploadUrl, setUploadUrl] = useState("");
|
||||
const [sharedWith, setSharedWith] = useState("");
|
||||
const [clearing, setClearing] = useState(false);
|
||||
const [isPending, startTransition] = useTransition();
|
||||
|
||||
@@ -385,6 +393,8 @@ function ManualDeliveryDialog({
|
||||
if (open) {
|
||||
setDate("");
|
||||
setNote("");
|
||||
setUploadUrl("");
|
||||
setSharedWith("");
|
||||
setClearing(false);
|
||||
}
|
||||
}, [open]);
|
||||
@@ -392,7 +402,7 @@ function ManualDeliveryDialog({
|
||||
const handleApply = () => {
|
||||
if (!clearing && !date) return;
|
||||
startTransition(async () => {
|
||||
await onApply(clearing ? null : date, note);
|
||||
await onApply(clearing ? null : date, note, { uploadUrl, sharedWith });
|
||||
});
|
||||
};
|
||||
|
||||
@@ -444,6 +454,30 @@ function ManualDeliveryDialog({
|
||||
className="w-full rounded-md border border-zinc-700 bg-zinc-900 px-3 py-2 text-sm text-zinc-100 [color-scheme:dark] focus:border-zinc-500 focus:outline-none focus:ring-1 focus:ring-zinc-500"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-medium text-zinc-400">Upload link</label>
|
||||
<input
|
||||
type="url"
|
||||
value={uploadUrl}
|
||||
onChange={(e) => setUploadUrl(e.target.value)}
|
||||
placeholder="https://…"
|
||||
className="w-full rounded-md border border-zinc-700 bg-zinc-900 px-3 py-2 text-sm text-zinc-100 placeholder:text-zinc-600 focus:border-zinc-500 focus:outline-none focus:ring-1 focus:ring-zinc-500"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-medium text-zinc-400">Link sent to</label>
|
||||
<input
|
||||
type="text"
|
||||
value={sharedWith}
|
||||
onChange={(e) => setSharedWith(e.target.value)}
|
||||
placeholder="Name, and where it was sent"
|
||||
className="w-full rounded-md border border-zinc-700 bg-zinc-900 px-3 py-2 text-sm text-zinc-100 placeholder:text-zinc-600 focus:border-zinc-500 focus:outline-none focus:ring-1 focus:ring-zinc-500"
|
||||
/>
|
||||
<p className="text-xs text-zinc-500">
|
||||
Both are needed for shots on an open delivery request; a backfill of old deliveries can leave
|
||||
them blank.
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-medium text-zinc-400">Note (optional)</label>
|
||||
<Textarea
|
||||
@@ -1086,6 +1120,7 @@ export function ShotStatusClient({
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||
const [movReviewDialogOpen, setMovReviewDialogOpen] = useState(false);
|
||||
const [manualDeliveryDialogOpen, setManualDeliveryDialogOpen] = useState(false);
|
||||
const [requestDialogOpen, setRequestDialogOpen] = useState(false);
|
||||
|
||||
const toggleEpisode = (ep: string) =>
|
||||
setExpandedEpisodes((prev) => {
|
||||
@@ -1185,9 +1220,18 @@ export function ShotStatusClient({
|
||||
}
|
||||
};
|
||||
|
||||
const handleManualDelivery = async (deliveredOn: string | null, note: string) => {
|
||||
const handleManualDelivery = async (
|
||||
deliveredOn: string | null,
|
||||
note: string,
|
||||
evidence: { uploadUrl: string; sharedWith: string },
|
||||
) => {
|
||||
try {
|
||||
const result = await bulkSetManualDelivery([...selectedIds], deliveredOn, note);
|
||||
const result = await bulkSetManualDelivery([...selectedIds], deliveredOn, note, evidence);
|
||||
if (!result.success) {
|
||||
// The dialog stays open so the missing link and recipient can be added.
|
||||
toast({ title: "Not marked delivered", description: result.error, variant: "destructive" });
|
||||
return;
|
||||
}
|
||||
const n =`${result.updated} shot${result.updated !== 1 ? "s" : ""}`;
|
||||
toast({ title: deliveredOn ? `${n} marked delivered` : `Manual delivery cleared on ${n}` });
|
||||
setManualDeliveryDialogOpen(false);
|
||||
@@ -1384,6 +1428,10 @@ export function ShotStatusClient({
|
||||
<Clapperboard className="h-3.5 w-3.5 mr-2" />
|
||||
Build MOV Review Package
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => setRequestDialogOpen(true)}>
|
||||
<ClipboardList className="h-3.5 w-3.5 mr-2" />
|
||||
Log Delivery Request
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => setManualDeliveryDialogOpen(true)}>
|
||||
<PackageCheck className="h-3.5 w-3.5 mr-2" />
|
||||
Delivered Outside Pipeline
|
||||
@@ -1610,6 +1658,16 @@ export function ShotStatusClient({
|
||||
count={selectionCount}
|
||||
/>
|
||||
|
||||
{/* A client asked for the selected shots */}
|
||||
{selectedProjectId && (
|
||||
<LogRequestDialog
|
||||
open={requestDialogOpen}
|
||||
onClose={() => setRequestDialogOpen(false)}
|
||||
onLogged={clearSelection}
|
||||
preset={{ projectId: selectedProjectId, shotIds: [...selectedIds] }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Bulk delete dialog */}
|
||||
<BulkDeleteDialog
|
||||
open={deleteDialogOpen}
|
||||
|
||||
@@ -2,20 +2,30 @@ import { NextRequest, NextResponse } from "next/server";
|
||||
import { toErrorResponse } from "@/lib/render-pipeline/errors";
|
||||
import { requirePipelineAdmin } from "@/lib/render-pipeline/session-auth";
|
||||
import { markDelivered } from "@/lib/render-pipeline/deliveries";
|
||||
import { recordShareSchema } from "@/lib/render-pipeline/delivery-handoff";
|
||||
|
||||
// ── POST /api/deliveries/{id}/delivered (E19) ────────────────────────────────
|
||||
//
|
||||
// The folder has been uploaded to the client. Moves every packaged export to
|
||||
// DELIVERED — the end of the pipeline.
|
||||
// The upload link has been sent to the client. Takes who it was sent to, and
|
||||
// moves every packaged export to DELIVERED — the end of the pipeline.
|
||||
|
||||
export async function POST(
|
||||
_req: NextRequest,
|
||||
req: NextRequest,
|
||||
{ params }: { params: Promise<{ packageId: string }> }
|
||||
) {
|
||||
try {
|
||||
const user = await requirePipelineAdmin();
|
||||
const { packageId } = await params;
|
||||
const result = await markDelivered(packageId, user.id);
|
||||
|
||||
const parsed = recordShareSchema.safeParse(await req.json().catch(() => null));
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json(
|
||||
{ error: parsed.error.issues[0]?.message ?? "Invalid request", details: parsed.error.issues },
|
||||
{ status: 422 }
|
||||
);
|
||||
}
|
||||
|
||||
const result = await markDelivered(packageId, parsed.data, user.id);
|
||||
return NextResponse.json(result);
|
||||
} catch (err) {
|
||||
const { body, status } = toErrorResponse(err);
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { toErrorResponse } from "@/lib/render-pipeline/errors";
|
||||
import { requirePipelineAdmin } from "@/lib/render-pipeline/session-auth";
|
||||
import { markUploaded } from "@/lib/render-pipeline/deliveries";
|
||||
import { recordUploadSchema } from "@/lib/render-pipeline/delivery-handoff";
|
||||
|
||||
// ── POST /api/deliveries/{id}/uploaded ───────────────────────────────────────
|
||||
//
|
||||
// The folder is on the client's portal. Records where, so the package shows as
|
||||
// waiting to be shared rather than waiting to be uploaded.
|
||||
|
||||
export async function POST(
|
||||
req: NextRequest,
|
||||
{ params }: { params: Promise<{ packageId: string }> }
|
||||
) {
|
||||
try {
|
||||
const user = await requirePipelineAdmin();
|
||||
const { packageId } = await params;
|
||||
|
||||
const parsed = recordUploadSchema.safeParse(await req.json().catch(() => null));
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json(
|
||||
{ error: parsed.error.issues[0]?.message ?? "Invalid request", details: parsed.error.issues },
|
||||
{ status: 422 }
|
||||
);
|
||||
}
|
||||
|
||||
const result = await markUploaded(packageId, parsed.data, user.id);
|
||||
return NextResponse.json(result);
|
||||
} catch (err) {
|
||||
const { body, status } = toErrorResponse(err);
|
||||
return NextResponse.json(body, { status });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { toErrorResponse } from "@/lib/render-pipeline/errors";
|
||||
import { requirePipelineAdmin } from "@/lib/render-pipeline/session-auth";
|
||||
import { cancelDeliveryRequest } from "@/lib/render-pipeline/delivery-requests";
|
||||
import { cancelRequestSchema } from "@/lib/render-pipeline/delivery-request-state";
|
||||
|
||||
// ── POST /api/delivery-requests/{id}/cancel ──────────────────────────────────
|
||||
//
|
||||
// The only way a request leaves the awaiting list without being delivered, and
|
||||
// it has to say why.
|
||||
|
||||
export async function POST(
|
||||
req: NextRequest,
|
||||
{ params }: { params: Promise<{ requestId: string }> }
|
||||
) {
|
||||
try {
|
||||
const user = await requirePipelineAdmin();
|
||||
const { requestId } = await params;
|
||||
|
||||
const parsed = cancelRequestSchema.safeParse(await req.json().catch(() => null));
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json(
|
||||
{ error: parsed.error.issues[0]?.message ?? "Invalid request", details: parsed.error.issues },
|
||||
{ status: 422 }
|
||||
);
|
||||
}
|
||||
|
||||
const result = await cancelDeliveryRequest(requestId, parsed.data.reason, user.id);
|
||||
return NextResponse.json(result);
|
||||
} catch (err) {
|
||||
const { body, status } = toErrorResponse(err);
|
||||
return NextResponse.json(body, { status });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { toErrorResponse } from "@/lib/render-pipeline/errors";
|
||||
import { requirePipelineUser } from "@/lib/render-pipeline/session-auth";
|
||||
import { getRequestOptions } from "@/lib/render-pipeline/delivery-requests";
|
||||
|
||||
// ── GET /api/delivery-requests/options?projectId= ────────────────────────────
|
||||
//
|
||||
// The episodes a request can be logged against.
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
try {
|
||||
await requirePipelineUser();
|
||||
const projectId = new URL(req.url).searchParams.get("projectId");
|
||||
if (!projectId) {
|
||||
return NextResponse.json({ error: "projectId is required" }, { status: 400 });
|
||||
}
|
||||
return NextResponse.json(await getRequestOptions(projectId));
|
||||
} catch (err) {
|
||||
const { body, status } = toErrorResponse(err);
|
||||
return NextResponse.json(body, { status });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { toErrorResponse } from "@/lib/render-pipeline/errors";
|
||||
import { requirePipelineAdmin, requirePipelineUser } from "@/lib/render-pipeline/session-auth";
|
||||
import { createDeliveryRequest, listDeliveryRequests } from "@/lib/render-pipeline/delivery-requests";
|
||||
import { createRequestSchema } from "@/lib/render-pipeline/delivery-request-state";
|
||||
|
||||
// ── GET /api/delivery-requests · POST /api/delivery-requests ─────────────────
|
||||
//
|
||||
// What clients have asked for, and how much of each ask has gone out.
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
try {
|
||||
await requirePipelineUser();
|
||||
const { searchParams } = new URL(req.url);
|
||||
const requests = await listDeliveryRequests({
|
||||
projectId: searchParams.get("projectId") ?? undefined,
|
||||
openOnly: searchParams.get("open") === "1",
|
||||
});
|
||||
return NextResponse.json({ requests });
|
||||
} catch (err) {
|
||||
const { body, status } = toErrorResponse(err);
|
||||
return NextResponse.json(body, { status });
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const user = await requirePipelineAdmin();
|
||||
|
||||
const parsed = createRequestSchema.safeParse(await req.json().catch(() => null));
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json(
|
||||
{ error: parsed.error.issues[0]?.message ?? "Invalid request", details: parsed.error.issues },
|
||||
{ status: 422 }
|
||||
);
|
||||
}
|
||||
|
||||
const result = await createDeliveryRequest(parsed.data, user.id);
|
||||
return NextResponse.json(result, { status: 201 });
|
||||
} catch (err) {
|
||||
const { body, status } = toErrorResponse(err);
|
||||
return NextResponse.json(body, { status });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import Link from "next/link";
|
||||
import { format, formatDistanceToNowStrict } from "date-fns";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { AwaitingDelivery as AwaitingDeliveryRow } from "@/lib/dashboard/ops";
|
||||
import type { DeliveryRequestView } from "@/lib/render-pipeline/delivery-requests";
|
||||
import { DELIVERABLE_LABEL, type Deliverable } from "@/lib/render-pipeline/delivery-request-state";
|
||||
|
||||
const STAGE_LABEL: Record<AwaitingDeliveryRow["stage"], string> = {
|
||||
AWAITING_UPLOAD: "Not uploaded",
|
||||
AWAITING_SHARE: "Uploaded — link not shared",
|
||||
};
|
||||
|
||||
const SEVERITY_STYLES: Record<AwaitingDeliveryRow["severity"], string> = {
|
||||
normal: "bg-zinc-500/15 text-zinc-300 border-zinc-500/30",
|
||||
warning: "bg-amber-500/15 text-amber-400 border-amber-500/30",
|
||||
critical: "bg-red-500/15 text-red-400 border-red-500/30",
|
||||
};
|
||||
|
||||
const ROW_CLASS =
|
||||
"flex items-center gap-3 border-b border-zinc-800/60 px-4 py-2.5 last:border-b-0 hover:bg-zinc-900/40";
|
||||
const PILL_CLASS = "shrink-0 rounded-full border px-2 py-0.5 text-xs whitespace-nowrap";
|
||||
|
||||
/**
|
||||
* Everything a client is owed: what they asked for that has not fully gone
|
||||
* out, and built packages they do not have yet. A row only leaves this list
|
||||
* when the upload location and who the link went to are on record (or a
|
||||
* request is cancelled with a reason) — never because time passed or someone
|
||||
* remembers doing it.
|
||||
*/
|
||||
export function AwaitingDelivery({
|
||||
requests,
|
||||
packages,
|
||||
}: {
|
||||
requests: DeliveryRequestView[];
|
||||
packages: AwaitingDeliveryRow[];
|
||||
}) {
|
||||
if (requests.length === 0 && packages.length === 0) {
|
||||
return (
|
||||
<div className="rounded-lg border border-zinc-800 bg-card px-4 py-4 text-sm text-zinc-500">
|
||||
Nothing is owed to a client.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border border-amber-500/30 bg-card overflow-hidden">
|
||||
{requests.map((r) => (
|
||||
<Link key={r.id} href="/pipeline/deliveries" className={ROW_CLASS}>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-[13px] text-white">
|
||||
<span className="font-medium">Request #{r.requestNumber}</span>{" "}
|
||||
<span className="text-zinc-300">
|
||||
{DELIVERABLE_LABEL[r.deliverable as Deliverable] ?? r.deliverable}
|
||||
{r.episode ? ` for ${r.episode}` : ""}
|
||||
</span>{" "}
|
||||
<span className="text-zinc-500">{r.projectCode}</span>
|
||||
</div>
|
||||
<div className="truncate text-xs text-zinc-500">
|
||||
asked by {r.requestedBy} {formatDistanceToNowStrict(r.createdAt)} ago
|
||||
{r.dueDate ? ` · due ${format(r.dueDate, "d MMM")}` : ""}
|
||||
</div>
|
||||
</div>
|
||||
<span className={cn(PILL_CLASS, SEVERITY_STYLES[r.severity])}>
|
||||
{r.deliveredCount} of {r.lineCount} delivered
|
||||
</span>
|
||||
</Link>
|
||||
))}
|
||||
{packages.map((p) => (
|
||||
<Link key={p.id} href="/pipeline/deliveries" className={ROW_CLASS}>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-[13px] text-white">
|
||||
<span className="font-mono font-medium">#{p.packageNumber}</span>{" "}
|
||||
<span className="text-zinc-300">{p.name}</span>{" "}
|
||||
<span className="text-zinc-500">
|
||||
{p.projectCode}
|
||||
{p.episode ? ` · ${p.episode}` : ""}
|
||||
</span>
|
||||
</div>
|
||||
<div className="truncate text-xs text-zinc-500">
|
||||
{p.itemCount} shot{p.itemCount === 1 ? "" : "s"} · built {formatDistanceToNowStrict(p.builtAt)} ago
|
||||
</div>
|
||||
</div>
|
||||
<span className={cn(PILL_CLASS, SEVERITY_STYLES[p.severity])}>{STAGE_LABEL[p.stage]}</span>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Plus } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { LogRequestDialog } from "@/components/delivery/LogRequestDialog";
|
||||
|
||||
/** The dashboard's way in to logging what a client just asked for. */
|
||||
export function LogRequestButton() {
|
||||
const router = useRouter();
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button size="sm" variant="outline" onClick={() => setOpen(true)}>
|
||||
<Plus className="h-3.5 w-3.5 mr-1.5" />
|
||||
Log request
|
||||
</Button>
|
||||
<LogRequestDialog open={open} onClose={() => setOpen(false)} onLogged={() => router.refresh()} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,262 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { db } from "@/lib/db";
|
||||
import { getConfigNumber } from "@/lib/render-pipeline/config";
|
||||
import { listMachines } from "@/lib/render-pipeline/machines";
|
||||
import { handoffStage, type HandoffStage } from "@/lib/render-pipeline/delivery-handoff";
|
||||
import type { ExportStatus, Prisma } from "@prisma/client";
|
||||
|
||||
/**
|
||||
@@ -596,6 +597,72 @@ export async function getRecentDeliveries(limit = 5): Promise<DeliverySummary[]>
|
||||
});
|
||||
}
|
||||
|
||||
export interface AwaitingDelivery {
|
||||
id: string;
|
||||
packageNumber: number;
|
||||
name: string;
|
||||
projectCode: string;
|
||||
episode: string;
|
||||
itemCount: number;
|
||||
stage: HandoffStage;
|
||||
/** When the folder was ready to go — the clock the client is waiting on. */
|
||||
builtAt: Date;
|
||||
severity: "normal" | "warning" | "critical";
|
||||
}
|
||||
|
||||
/**
|
||||
* Built packages the client does not have yet.
|
||||
*
|
||||
* A READY package is the easiest thing in the pipeline to believe is done: the
|
||||
* folder exists and every file verified, but nothing has left the building.
|
||||
* It stays listed until the upload location and the person it was shared with
|
||||
* are both on record, and gets louder the longer that takes.
|
||||
*/
|
||||
export async function getAwaitingDelivery(): Promise<AwaitingDelivery[]> {
|
||||
const rows = await db.deliveryPackage.findMany({
|
||||
where: {
|
||||
kind: "EXR_DELIVERY",
|
||||
status: "READY",
|
||||
project: { status: { in: ["ACTIVE", "ON_HOLD"] } },
|
||||
},
|
||||
orderBy: { builtAt: "asc" },
|
||||
select: {
|
||||
id: true,
|
||||
packageNumber: true,
|
||||
name: true,
|
||||
kind: true,
|
||||
status: true,
|
||||
episode: true,
|
||||
createdAt: true,
|
||||
builtAt: true,
|
||||
uploadedAt: true,
|
||||
project: { select: { code: true } },
|
||||
_count: { select: { items: true } },
|
||||
},
|
||||
});
|
||||
|
||||
const day = 24 * 3600_000;
|
||||
return rows.flatMap((row) => {
|
||||
const stage = handoffStage(row);
|
||||
if (!stage) return [];
|
||||
const builtAt = row.builtAt ?? row.createdAt;
|
||||
const age = Date.now() - builtAt.getTime();
|
||||
return [
|
||||
{
|
||||
id: row.id,
|
||||
packageNumber: row.packageNumber,
|
||||
name: row.name,
|
||||
projectCode: row.project.code,
|
||||
episode: row.episode,
|
||||
itemCount: row._count.items,
|
||||
stage,
|
||||
builtAt,
|
||||
severity: age >= 2 * day ? "critical" : age >= day ? "warning" : "normal",
|
||||
},
|
||||
];
|
||||
});
|
||||
}
|
||||
|
||||
export interface CourtSide {
|
||||
count: number;
|
||||
items: { id: string; shotCode: string; label: string; href: string }[];
|
||||
|
||||
@@ -5,6 +5,7 @@ import { PipelineError } from "./errors";
|
||||
import { applyTransition } from "./transitions";
|
||||
import { getConfigNumber, getConfigValue } from "./config";
|
||||
import { clientShotCode } from "@/lib/shot-code";
|
||||
import type { RecordShareInput, RecordUploadInput } from "./delivery-handoff";
|
||||
|
||||
/**
|
||||
* Delivery builder (RenderPipeline2 §11). The web app decides *what* goes in a
|
||||
@@ -640,11 +641,33 @@ export async function listDeliveryPackages(filter: {
|
||||
},
|
||||
orderBy: { createdAt: "desc" },
|
||||
include: {
|
||||
items: { select: { id: true, shotCode: true, versionString: true, status: true, fileCount: true, linkMode: true, errorMessage: true } },
|
||||
items: {
|
||||
select: {
|
||||
id: true,
|
||||
shotCode: true,
|
||||
clientCode: true,
|
||||
versionString: true,
|
||||
status: true,
|
||||
fileCount: true,
|
||||
totalBytes: true,
|
||||
movPath: true,
|
||||
linkMode: true,
|
||||
errorMessage: true,
|
||||
},
|
||||
},
|
||||
project: { select: { id: true, name: true, code: true } },
|
||||
},
|
||||
});
|
||||
|
||||
// Who recorded each handoff step — the ids are plain columns, not relations.
|
||||
const userIds = [
|
||||
...new Set(packages.flatMap((p) => [p.uploadedById, p.deliveredById]).filter((id): id is string => !!id)),
|
||||
];
|
||||
const users = userIds.length
|
||||
? await db.user.findMany({ where: { id: { in: userIds } }, select: { id: true, name: true, email: true } })
|
||||
: [];
|
||||
const userName = new Map(users.map((u) => [u.id, u.name ?? u.email]));
|
||||
|
||||
return {
|
||||
packages: packages.map((p) => ({
|
||||
id: p.id,
|
||||
@@ -659,30 +682,71 @@ export async function listDeliveryPackages(filter: {
|
||||
projectCode: p.project.code,
|
||||
createdAt: p.createdAt,
|
||||
builtAt: p.builtAt,
|
||||
uploadUrl: p.uploadUrl,
|
||||
uploadedAt: p.uploadedAt,
|
||||
uploadedByName: p.uploadedById ? (userName.get(p.uploadedById) ?? null) : null,
|
||||
sharedWith: p.sharedWith,
|
||||
sharedNote: p.sharedNote,
|
||||
deliveredAt: p.deliveredAt,
|
||||
deliveredByName: p.deliveredById ? (userName.get(p.deliveredById) ?? null) : null,
|
||||
itemCount: p.items.length,
|
||||
verifiedCount: p.items.filter((i) => i.status === "VERIFIED").length,
|
||||
items: p.items,
|
||||
// What the upload should contain, for the person comparing it against
|
||||
// the client's portal.
|
||||
exrFileCount: p.items.reduce((sum, i) => sum + (i.fileCount ?? 0), 0),
|
||||
movCount: p.items.filter((i) => i.movPath).length,
|
||||
totalBytes: p.items.reduce((sum, i) => sum + Number(i.totalBytes ?? 0), 0),
|
||||
items: p.items.map(({ totalBytes: _bytes, movPath: _mov, ...item }) => item),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
/** E19 — the human uploaded the folder to the client and is confirming it. */
|
||||
export async function markDelivered(packageId: string, userId: string) {
|
||||
function assertHandoffAllowed(pkg: { kind: DeliveryKind; status: DeliveryStatus }, verb: string) {
|
||||
if (pkg.kind !== "EXR_DELIVERY") {
|
||||
throw new PipelineError(
|
||||
409,
|
||||
"A MOV review package is shared for review, not delivered — it has no delivered state"
|
||||
);
|
||||
}
|
||||
if (pkg.status !== "READY") {
|
||||
throw new PipelineError(409, `Package is ${pkg.status} — only a READY package can be ${verb}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Step one of the handoff: the folder is on the client's portal, and this is
|
||||
* where. Repeatable while the package is READY, so a wrong link can be
|
||||
* corrected before it is shared.
|
||||
*/
|
||||
export async function markUploaded(packageId: string, input: RecordUploadInput, userId: string) {
|
||||
return db.$transaction(async (tx) => {
|
||||
const pkg = await tx.deliveryPackage.findUnique({ where: { id: packageId } });
|
||||
if (!pkg) throw new PipelineError(404, "Delivery package not found");
|
||||
assertHandoffAllowed(pkg, "recorded as uploaded");
|
||||
|
||||
const updated = await tx.deliveryPackage.update({
|
||||
where: { id: pkg.id },
|
||||
data: { uploadUrl: input.uploadUrl, uploadedAt: new Date(), uploadedById: userId },
|
||||
});
|
||||
return { package: { id: updated.id, status: updated.status, uploadUrl: updated.uploadUrl } };
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* E19 — step two: the link was sent to a named person, which is what makes the
|
||||
* package DELIVERED. Refused until the upload location is on record, so
|
||||
* "delivered" always has somewhere to point.
|
||||
*/
|
||||
export async function markDelivered(packageId: string, input: RecordShareInput, userId: string) {
|
||||
return db.$transaction(async (tx) => {
|
||||
const pkg = await tx.deliveryPackage.findUnique({ where: { id: packageId }, include: { items: true } });
|
||||
if (!pkg) throw new PipelineError(404, "Delivery package not found");
|
||||
if (pkg.status === "DELIVERED") {
|
||||
return { package: { id: pkg.id, status: pkg.status }, alreadyApplied: true };
|
||||
}
|
||||
if (pkg.status !== "READY") {
|
||||
throw new PipelineError(409, `Package is ${pkg.status} — only a READY package can be marked delivered`);
|
||||
}
|
||||
if (pkg.kind !== "EXR_DELIVERY") {
|
||||
throw new PipelineError(
|
||||
409,
|
||||
"A MOV review package is shared for review, not delivered — it has no delivered state"
|
||||
);
|
||||
assertHandoffAllowed(pkg, "marked delivered");
|
||||
if (!pkg.uploadedAt || !pkg.uploadUrl) {
|
||||
throw new PipelineError(409, "Record where the package was uploaded before marking it delivered");
|
||||
}
|
||||
|
||||
for (const item of pkg.items) {
|
||||
@@ -698,7 +762,13 @@ export async function markDelivered(packageId: string, userId: string) {
|
||||
|
||||
const updated = await tx.deliveryPackage.update({
|
||||
where: { id: pkg.id },
|
||||
data: { status: "DELIVERED", deliveredAt: new Date(), deliveredById: userId },
|
||||
data: {
|
||||
status: "DELIVERED",
|
||||
sharedWith: input.sharedWith,
|
||||
sharedNote: input.note || null,
|
||||
deliveredAt: new Date(),
|
||||
deliveredById: userId,
|
||||
},
|
||||
});
|
||||
return { package: { id: updated.id, status: updated.status }, alreadyApplied: false };
|
||||
});
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { buildShareMessage, handoffStage, recordShareSchema, recordUploadSchema } from "./delivery-handoff";
|
||||
|
||||
describe("handoffStage", () => {
|
||||
const ready = { kind: "EXR_DELIVERY", status: "READY", uploadedAt: null };
|
||||
|
||||
it("waits on the upload first, then on the share", () => {
|
||||
expect(handoffStage(ready)).toBe("AWAITING_UPLOAD");
|
||||
expect(handoffStage({ ...ready, uploadedAt: new Date() })).toBe("AWAITING_SHARE");
|
||||
});
|
||||
|
||||
it("waits on nothing before the build or after delivery", () => {
|
||||
expect(handoffStage({ ...ready, status: "BUILDING" })).toBeNull();
|
||||
expect(handoffStage({ ...ready, status: "FAILED" })).toBeNull();
|
||||
expect(handoffStage({ ...ready, status: "DELIVERED", uploadedAt: new Date() })).toBeNull();
|
||||
});
|
||||
|
||||
it("never asks for a review package to be delivered", () => {
|
||||
expect(handoffStage({ ...ready, kind: "MOV_REVIEW" })).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("recordUploadSchema", () => {
|
||||
const url = "https://contenthub.example.com/folders/abc";
|
||||
|
||||
it("accepts a link with the contents confirmed", () => {
|
||||
expect(recordUploadSchema.safeParse({ uploadUrl: url, contentsConfirmed: true }).success).toBe(true);
|
||||
});
|
||||
|
||||
it("refuses an unconfirmed upload", () => {
|
||||
expect(recordUploadSchema.safeParse({ uploadUrl: url, contentsConfirmed: false }).success).toBe(false);
|
||||
expect(recordUploadSchema.safeParse({ uploadUrl: url }).success).toBe(false);
|
||||
});
|
||||
|
||||
it("refuses anything that is not a web link", () => {
|
||||
for (const uploadUrl of ["", "uploaded", "//SAN/DELIVERY/ACME/101", "javascript:alert(1)"]) {
|
||||
expect(recordUploadSchema.safeParse({ uploadUrl, contentsConfirmed: true }).success).toBe(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("recordShareSchema", () => {
|
||||
it("needs a recipient", () => {
|
||||
expect(recordShareSchema.safeParse({ sharedWith: " " }).success).toBe(false);
|
||||
expect(recordShareSchema.safeParse({ sharedWith: "Sam (post super)" }).success).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildShareMessage", () => {
|
||||
it("lists the packaged versions under the client's own shot codes", () => {
|
||||
const message = buildShareMessage({
|
||||
projectCode: "UNG",
|
||||
episode: "116",
|
||||
name: "20260930_Delivery",
|
||||
uploadUrl: "https://contenthub.example.com/folders/abc",
|
||||
items: [
|
||||
{ shotCode: "UNG_116_020", clientCode: null, versionString: "v002" },
|
||||
{ shotCode: "UNG_116_010", clientCode: "116_A_0010", versionString: "v004" },
|
||||
],
|
||||
});
|
||||
expect(message).toBe(
|
||||
[
|
||||
"Delivery: UNG · episode 116 · 20260930_Delivery",
|
||||
"2 shots",
|
||||
"https://contenthub.example.com/folders/abc",
|
||||
"",
|
||||
"116_A_0010 v004",
|
||||
"UNG_116_020 v002",
|
||||
].join("\n")
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,73 @@
|
||||
import { z } from "zod";
|
||||
import { clientShotCode } from "@/lib/shot-code";
|
||||
|
||||
/**
|
||||
* The handoff after a package is built: upload it, then share the link.
|
||||
*
|
||||
* Kept free of database imports so the deliveries page can use the same stage
|
||||
* logic and share message the server does.
|
||||
*/
|
||||
|
||||
export type HandoffStage = "AWAITING_UPLOAD" | "AWAITING_SHARE";
|
||||
|
||||
/**
|
||||
* What a built EXR package is still waiting on, or null when it is waiting on
|
||||
* nothing — not built yet, already delivered, or a review (which has no
|
||||
* delivered state at all).
|
||||
*/
|
||||
export function handoffStage(pkg: {
|
||||
kind: string;
|
||||
status: string;
|
||||
uploadedAt: Date | string | null;
|
||||
}): HandoffStage | null {
|
||||
if (pkg.kind !== "EXR_DELIVERY" || pkg.status !== "READY") return null;
|
||||
return pkg.uploadedAt ? "AWAITING_SHARE" : "AWAITING_UPLOAD";
|
||||
}
|
||||
|
||||
export const recordUploadSchema = z.object({
|
||||
uploadUrl: z
|
||||
.string()
|
||||
.trim()
|
||||
.max(2000)
|
||||
.url("Paste the full link to the upload folder")
|
||||
.refine((value) => /^https?:\/\//i.test(value), "The upload link must start with http:// or https://"),
|
||||
/** The person looked at the upload and its contents match the package. */
|
||||
contentsConfirmed: z.literal(true, {
|
||||
errorMap: () => ({ message: "Confirm the uploaded files match the package" }),
|
||||
}),
|
||||
});
|
||||
|
||||
export type RecordUploadInput = z.infer<typeof recordUploadSchema>;
|
||||
|
||||
export const recordShareSchema = z.object({
|
||||
sharedWith: z.string().trim().min(1, "Say who the link was sent to").max(200),
|
||||
note: z.string().trim().max(1000).optional().nullable(),
|
||||
});
|
||||
|
||||
export type RecordShareInput = z.infer<typeof recordShareSchema>;
|
||||
|
||||
/**
|
||||
* The message sent to the client with the link. Generated rather than typed so
|
||||
* the shots and versions the client is told about are the ones in the package.
|
||||
*/
|
||||
export function buildShareMessage(pkg: {
|
||||
projectCode: string;
|
||||
episode: string;
|
||||
name: string;
|
||||
uploadUrl: string;
|
||||
items: { shotCode: string; clientCode?: string | null; versionString: string }[];
|
||||
}): string {
|
||||
const shots = pkg.items
|
||||
.map((item) => `${clientShotCode(item)} ${item.versionString}`)
|
||||
.sort((a, b) => a.localeCompare(b));
|
||||
const heading = [pkg.projectCode, pkg.episode ? `episode ${pkg.episode}` : "", pkg.name]
|
||||
.filter(Boolean)
|
||||
.join(" · ");
|
||||
return [
|
||||
`Delivery: ${heading}`,
|
||||
`${shots.length} shot${shots.length === 1 ? "" : "s"}`,
|
||||
pkg.uploadUrl,
|
||||
"",
|
||||
...shots,
|
||||
].join("\n");
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
createRequestSchema,
|
||||
matchShotCodes,
|
||||
parseShotCodes,
|
||||
resolveLineStage,
|
||||
waitingSeverity,
|
||||
} from "./delivery-request-state";
|
||||
|
||||
describe("parseShotCodes", () => {
|
||||
it("takes codes out of a pasted message whatever separates them", () => {
|
||||
expect(parseShotCodes("UNG_116_010, UNG_116_020\nUNG_116_030; 116-A-0040")).toEqual([
|
||||
"UNG_116_010",
|
||||
"UNG_116_020",
|
||||
"UNG_116_030",
|
||||
"116-A-0040",
|
||||
]);
|
||||
});
|
||||
|
||||
it("drops repeats regardless of case", () => {
|
||||
expect(parseShotCodes("ung_116_010 UNG_116_010")).toEqual(["ung_116_010"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("matchShotCodes", () => {
|
||||
const shots = [
|
||||
{ id: "a", shotCode: "UNG_116_010", clientCode: "116_A_0010" },
|
||||
{ id: "b", shotCode: "UNG_116_020", clientCode: null },
|
||||
];
|
||||
|
||||
it("matches our code or the client's, in any case", () => {
|
||||
const { matched, unmatched } = matchShotCodes(["116_a_0010", "ung_116_020"], shots);
|
||||
expect(matched.map((s) => s.id)).toEqual(["a", "b"]);
|
||||
expect(unmatched).toEqual([]);
|
||||
});
|
||||
|
||||
it("reports what it could not match instead of dropping it", () => {
|
||||
expect(matchShotCodes(["UNG_116_010", "UNG_116_999"], shots).unmatched).toEqual(["UNG_116_999"]);
|
||||
});
|
||||
|
||||
it("counts a shot once when both of its codes are given", () => {
|
||||
expect(matchShotCodes(["UNG_116_010", "116_A_0010"], shots).matched).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveLineStage", () => {
|
||||
const logged = new Date("2026-09-30T14:00:00Z");
|
||||
const before = new Date("2026-09-20T10:00:00Z");
|
||||
const after = new Date("2026-10-01T10:00:00Z");
|
||||
const base = {
|
||||
requestCreatedAt: logged,
|
||||
packages: [],
|
||||
manual: { deliveredAt: null, url: null, sharedWith: null },
|
||||
exportStatuses: [],
|
||||
};
|
||||
|
||||
it("walks a shot from not ready through to delivered", () => {
|
||||
expect(resolveLineStage(base)).toBe("NOT_READY");
|
||||
expect(resolveLineStage({ ...base, exportStatuses: ["READY_FOR_QC"] })).toBe("IN_QC");
|
||||
expect(resolveLineStage({ ...base, exportStatuses: ["READY_FOR_QC", "READY_FOR_DELIVERY"] })).toBe("READY");
|
||||
const pkg = { status: "BUILDING", uploadedAt: null, deliveredAt: null };
|
||||
expect(resolveLineStage({ ...base, packages: [pkg] })).toBe("BUILDING");
|
||||
expect(resolveLineStage({ ...base, packages: [{ ...pkg, status: "READY" }] })).toBe("BUILT");
|
||||
expect(resolveLineStage({ ...base, packages: [{ ...pkg, status: "READY", uploadedAt: after }] })).toBe("UPLOADED");
|
||||
expect(
|
||||
resolveLineStage({ ...base, packages: [{ status: "DELIVERED", uploadedAt: after, deliveredAt: after }] })
|
||||
).toBe("DELIVERED");
|
||||
});
|
||||
|
||||
it("does not let a delivery from before the request close it", () => {
|
||||
expect(
|
||||
resolveLineStage({ ...base, packages: [{ status: "DELIVERED", uploadedAt: before, deliveredAt: before }] })
|
||||
).toBe("NOT_READY");
|
||||
});
|
||||
|
||||
it("closes on a manual delivery only with a link and a recipient", () => {
|
||||
const sameDay = new Date("2026-09-30T12:00:00Z");
|
||||
const manual = { deliveredAt: sameDay, url: "https://x.example/f", sharedWith: "Sam" };
|
||||
expect(resolveLineStage({ ...base, manual })).toBe("DELIVERED");
|
||||
expect(resolveLineStage({ ...base, manual: { ...manual, url: null } })).toBe("NOT_READY");
|
||||
expect(resolveLineStage({ ...base, manual: { ...manual, sharedWith: null } })).toBe("NOT_READY");
|
||||
expect(resolveLineStage({ ...base, manual: { ...manual, deliveredAt: before } })).toBe("NOT_READY");
|
||||
});
|
||||
});
|
||||
|
||||
describe("waitingSeverity", () => {
|
||||
const now = new Date("2026-09-30T12:00:00Z").getTime();
|
||||
const hoursAgo = (h: number) => new Date(now - h * 3600_000);
|
||||
|
||||
it("escalates with age", () => {
|
||||
expect(waitingSeverity(hoursAgo(2), null, now)).toBe("normal");
|
||||
expect(waitingSeverity(hoursAgo(30), null, now)).toBe("warning");
|
||||
expect(waitingSeverity(hoursAgo(50), null, now)).toBe("critical");
|
||||
});
|
||||
|
||||
it("is critical once past the due date, however new", () => {
|
||||
expect(waitingSeverity(hoursAgo(2), hoursAgo(1), now)).toBe("critical");
|
||||
});
|
||||
});
|
||||
|
||||
describe("createRequestSchema", () => {
|
||||
const base = { projectId: "p", deliverable: "EXR", requestedBy: "Sam" };
|
||||
|
||||
it("needs exactly one way of naming the shots", () => {
|
||||
expect(createRequestSchema.safeParse(base).success).toBe(false);
|
||||
expect(createRequestSchema.safeParse({ ...base, episode: "116" }).success).toBe(true);
|
||||
expect(createRequestSchema.safeParse({ ...base, episode: "116", shotCodes: "UNG_116_010" }).success).toBe(false);
|
||||
});
|
||||
|
||||
it("needs to know who asked", () => {
|
||||
expect(createRequestSchema.safeParse({ ...base, episode: "116", requestedBy: " " }).success).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,163 @@
|
||||
import { z } from "zod";
|
||||
|
||||
/**
|
||||
* Delivery request rules with no database behind them, shared by the server
|
||||
* and the pages that show requests.
|
||||
*/
|
||||
|
||||
export const DELIVERABLES = ["EXR", "MOV", "EXR_AND_MOV"] as const;
|
||||
export type Deliverable = (typeof DELIVERABLES)[number];
|
||||
|
||||
export const DELIVERABLE_LABEL: Record<Deliverable, string> = {
|
||||
EXR: "EXRs",
|
||||
MOV: "MOVs",
|
||||
EXR_AND_MOV: "EXRs + MOVs",
|
||||
};
|
||||
|
||||
/** Where one requested shot stands, from furthest along to not started. */
|
||||
export type RequestLineStage =
|
||||
| "DELIVERED"
|
||||
| "UPLOADED"
|
||||
| "BUILT"
|
||||
| "BUILDING"
|
||||
| "READY"
|
||||
| "IN_QC"
|
||||
| "NOT_READY";
|
||||
|
||||
export const LINE_STAGE_LABEL: Record<RequestLineStage, string> = {
|
||||
DELIVERED: "delivered",
|
||||
UPLOADED: "uploaded, link not shared",
|
||||
BUILT: "built, not uploaded",
|
||||
BUILDING: "package building",
|
||||
READY: "ready to package",
|
||||
IN_QC: "in QC",
|
||||
NOT_READY: "not through QC",
|
||||
};
|
||||
|
||||
export const createRequestSchema = z
|
||||
.object({
|
||||
projectId: z.string().min(1),
|
||||
/** Exactly one of these three says which shots were asked for. */
|
||||
episode: z.string().trim().min(1).max(50).optional().nullable(),
|
||||
shotIds: z.array(z.string().min(1)).min(1).max(1000).optional().nullable(),
|
||||
shotCodes: z.string().trim().min(1).max(20000).optional().nullable(),
|
||||
deliverable: z.enum(DELIVERABLES),
|
||||
requestedBy: z.string().trim().min(1, "Say who asked for it").max(200),
|
||||
dueDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional().nullable(),
|
||||
note: z.string().trim().max(2000).optional().nullable(),
|
||||
})
|
||||
.refine((v) => [v.episode, v.shotIds, v.shotCodes].filter(Boolean).length === 1, {
|
||||
message: "Choose an episode, a shot selection or a list of shot codes",
|
||||
});
|
||||
|
||||
export type CreateRequestInput = z.infer<typeof createRequestSchema>;
|
||||
|
||||
export const cancelRequestSchema = z.object({
|
||||
reason: z.string().trim().min(1, "Say why the request is being cancelled").max(500),
|
||||
});
|
||||
|
||||
/**
|
||||
* Shot codes out of a pasted message: split on anything that is not part of a
|
||||
* code, so "116_010, 116_020\n116_030" and a column copied from a sheet both
|
||||
* work. Order is kept and repeats dropped.
|
||||
*/
|
||||
export function parseShotCodes(text: string): string[] {
|
||||
const seen = new Set<string>();
|
||||
const codes: string[] = [];
|
||||
for (const raw of text.split(/[^A-Za-z0-9_\-.]+/)) {
|
||||
const code = raw.trim();
|
||||
const key = code.toLowerCase();
|
||||
if (!code || seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
codes.push(code);
|
||||
}
|
||||
return codes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pasted codes against a project's shots. The client writes their own codes
|
||||
* and we write ours, so either matches; anything that matches neither is
|
||||
* returned rather than dropped, because a silently skipped shot is exactly the
|
||||
* kind of miss a request exists to prevent.
|
||||
*/
|
||||
export function matchShotCodes<T extends { shotCode: string; clientCode?: string | null }>(
|
||||
codes: string[],
|
||||
shots: T[]
|
||||
): { matched: T[]; unmatched: string[] } {
|
||||
const byCode = new Map<string, T>();
|
||||
for (const shot of shots) {
|
||||
byCode.set(shot.shotCode.toLowerCase(), shot);
|
||||
const alias = shot.clientCode?.trim().toLowerCase();
|
||||
if (alias && !byCode.has(alias)) byCode.set(alias, shot);
|
||||
}
|
||||
const matched = new Set<T>();
|
||||
const unmatched: string[] = [];
|
||||
for (const code of codes) {
|
||||
const shot = byCode.get(code.toLowerCase());
|
||||
if (shot) matched.add(shot);
|
||||
else unmatched.push(code);
|
||||
}
|
||||
return { matched: [...matched], unmatched };
|
||||
}
|
||||
|
||||
/**
|
||||
* Where a requested shot stands. Only a delivery made after the request was
|
||||
* logged closes the line — a client asking again for something already sent
|
||||
* is asking for it to be sent again.
|
||||
*/
|
||||
export function resolveLineStage(input: {
|
||||
requestCreatedAt: Date;
|
||||
/** EXR delivery packages containing the shot. */
|
||||
packages: { status: string; uploadedAt: Date | null; deliveredAt: Date | null }[];
|
||||
manual: { deliveredAt: Date | null; url: string | null; sharedWith: string | null };
|
||||
/** Statuses of the shot's exports. */
|
||||
exportStatuses: string[];
|
||||
}): RequestLineStage {
|
||||
const { requestCreatedAt, packages, manual, exportStatuses } = input;
|
||||
if (
|
||||
packages.some((p) => p.status === "DELIVERED" && p.deliveredAt && p.deliveredAt >= requestCreatedAt)
|
||||
) {
|
||||
return "DELIVERED";
|
||||
}
|
||||
if (
|
||||
manual.deliveredAt &&
|
||||
manual.url &&
|
||||
manual.sharedWith &&
|
||||
manualDeliveryCounts(manual.deliveredAt, requestCreatedAt)
|
||||
) {
|
||||
return "DELIVERED";
|
||||
}
|
||||
const ready = packages.filter((p) => p.status === "READY");
|
||||
if (ready.some((p) => p.uploadedAt)) return "UPLOADED";
|
||||
if (ready.length) return "BUILT";
|
||||
if (packages.some((p) => p.status === "QUEUED" || p.status === "BUILDING")) return "BUILDING";
|
||||
if (exportStatuses.includes("READY_FOR_DELIVERY")) return "READY";
|
||||
if (exportStatuses.includes("READY_FOR_QC")) return "IN_QC";
|
||||
return "NOT_READY";
|
||||
}
|
||||
|
||||
const DAY_MS = 24 * 3600_000;
|
||||
|
||||
/** Past its due date or two days old is critical; a day old is a warning. */
|
||||
export function waitingSeverity(
|
||||
since: Date,
|
||||
dueDate: Date | null,
|
||||
now = Date.now()
|
||||
): "normal" | "warning" | "critical" {
|
||||
if (dueDate && dueDate.getTime() < now) return "critical";
|
||||
const age = now - since.getTime();
|
||||
return age >= 2 * DAY_MS ? "critical" : age >= DAY_MS ? "warning" : "normal";
|
||||
}
|
||||
|
||||
/**
|
||||
* A manual delivery is recorded as a calendar day (noon UTC), so it counts for
|
||||
* a request logged any time that same day.
|
||||
*/
|
||||
export function manualDeliveryCounts(manualDeliveredAt: Date, requestCreatedAt: Date): boolean {
|
||||
const dayStart = Date.UTC(
|
||||
requestCreatedAt.getUTCFullYear(),
|
||||
requestCreatedAt.getUTCMonth(),
|
||||
requestCreatedAt.getUTCDate()
|
||||
);
|
||||
return manualDeliveredAt.getTime() >= dayStart;
|
||||
}
|
||||
@@ -0,0 +1,256 @@
|
||||
import { db } from "@/lib/db";
|
||||
import { PipelineError } from "./errors";
|
||||
import {
|
||||
matchShotCodes,
|
||||
parseShotCodes,
|
||||
resolveLineStage,
|
||||
waitingSeverity,
|
||||
type CreateRequestInput,
|
||||
type RequestLineStage,
|
||||
} from "./delivery-request-state";
|
||||
|
||||
/**
|
||||
* Delivery requests: what a client asked for, held open until it has gone out.
|
||||
*
|
||||
* A request stores only the ask and its shot list. Whether each shot has been
|
||||
* delivered is read from the packages and manual deliveries themselves, so a
|
||||
* request cannot say "done" when the delivery record does not.
|
||||
*/
|
||||
|
||||
export async function createDeliveryRequest(input: CreateRequestInput, userId: string) {
|
||||
const project = await db.project.findUnique({ where: { id: input.projectId }, select: { id: true } });
|
||||
if (!project) throw new PipelineError(404, "Project not found");
|
||||
|
||||
const select = { id: true, shotCode: true, clientCode: true } as const;
|
||||
let shots: { id: string; shotCode: string; clientCode: string | null }[];
|
||||
|
||||
if (input.episode) {
|
||||
// Snapshot: the request is for the shots in the episode today. A shot
|
||||
// added to the episode later is a new ask, not a silent change to this one.
|
||||
shots = await db.shot.findMany({ where: { projectId: project.id, episode: input.episode }, select });
|
||||
if (!shots.length) throw new PipelineError(422, `No shots in episode ${input.episode}`);
|
||||
} else if (input.shotIds) {
|
||||
const ids = [...new Set(input.shotIds)];
|
||||
shots = await db.shot.findMany({ where: { projectId: project.id, id: { in: ids } }, select });
|
||||
if (shots.length !== ids.length) {
|
||||
throw new PipelineError(422, "Some of the selected shots are not in this project");
|
||||
}
|
||||
} else {
|
||||
const codes = parseShotCodes(input.shotCodes ?? "");
|
||||
const all = await db.shot.findMany({ where: { projectId: project.id }, select });
|
||||
const { matched, unmatched } = matchShotCodes(codes, all);
|
||||
if (unmatched.length) {
|
||||
throw new PipelineError(
|
||||
422,
|
||||
`${unmatched.length} code${unmatched.length === 1 ? "" : "s"} did not match a shot: ${unmatched.slice(0, 10).join(", ")}${unmatched.length > 10 ? "…" : ""}`,
|
||||
{ unmatched }
|
||||
);
|
||||
}
|
||||
shots = matched;
|
||||
if (!shots.length) throw new PipelineError(422, "No shot codes found in the text");
|
||||
}
|
||||
|
||||
return db.$transaction(async (tx) => {
|
||||
const last = await tx.deliveryRequest.aggregate({
|
||||
where: { projectId: project.id },
|
||||
_max: { requestNumber: true },
|
||||
});
|
||||
const request = await tx.deliveryRequest.create({
|
||||
data: {
|
||||
projectId: project.id,
|
||||
requestNumber: (last._max.requestNumber ?? 0) + 1,
|
||||
episode: input.episode ?? "",
|
||||
deliverable: input.deliverable,
|
||||
requestedBy: input.requestedBy,
|
||||
note: input.note || null,
|
||||
// Noon UTC so the calendar day survives any timezone it is shown in.
|
||||
dueDate: input.dueDate ? new Date(`${input.dueDate}T12:00:00.000Z`) : null,
|
||||
createdById: userId,
|
||||
lines: {
|
||||
create: shots.map((s) => ({ shotId: s.id, shotCode: s.shotCode, clientCode: s.clientCode })),
|
||||
},
|
||||
},
|
||||
});
|
||||
return { request: { id: request.id, requestNumber: request.requestNumber, lineCount: shots.length } };
|
||||
});
|
||||
}
|
||||
|
||||
export async function cancelDeliveryRequest(requestId: string, reason: string, userId: string) {
|
||||
const request = await db.deliveryRequest.findUnique({ where: { id: requestId } });
|
||||
if (!request) throw new PipelineError(404, "Delivery request not found");
|
||||
if (request.cancelledAt) return { request: { id: request.id }, alreadyApplied: true };
|
||||
await db.deliveryRequest.update({
|
||||
where: { id: request.id },
|
||||
data: { cancelledAt: new Date(), cancelledById: userId, cancelReason: reason },
|
||||
});
|
||||
return { request: { id: request.id }, alreadyApplied: false };
|
||||
}
|
||||
|
||||
export interface DeliveryRequestLineView {
|
||||
id: string;
|
||||
shotId: string;
|
||||
shotCode: string;
|
||||
clientCode: string | null;
|
||||
stage: RequestLineStage;
|
||||
/** The export to package when the stage is READY. */
|
||||
readyExportId: string | null;
|
||||
}
|
||||
|
||||
export interface DeliveryRequestView {
|
||||
id: string;
|
||||
requestNumber: number;
|
||||
projectId: string;
|
||||
projectCode: string;
|
||||
episode: string;
|
||||
deliverable: string;
|
||||
requestedBy: string;
|
||||
note: string | null;
|
||||
dueDate: Date | null;
|
||||
createdAt: Date;
|
||||
cancelledAt: Date | null;
|
||||
cancelReason: string | null;
|
||||
state: "OPEN" | "CLOSED" | "CANCELLED";
|
||||
lineCount: number;
|
||||
deliveredCount: number;
|
||||
severity: "normal" | "warning" | "critical";
|
||||
lines: DeliveryRequestLineView[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Requests with each line's stage worked out from the delivery record.
|
||||
* `openOnly` is what the dashboard wants; the deliveries page takes the recent
|
||||
* history too.
|
||||
*/
|
||||
export async function listDeliveryRequests(
|
||||
filter: { projectId?: string; openOnly?: boolean } = {}
|
||||
): Promise<DeliveryRequestView[]> {
|
||||
const requests = await db.deliveryRequest.findMany({
|
||||
where: {
|
||||
...(filter.projectId ? { projectId: filter.projectId } : {}),
|
||||
...(filter.openOnly ? { cancelledAt: null } : {}),
|
||||
project: { status: { in: ["ACTIVE", "ON_HOLD"] } },
|
||||
},
|
||||
orderBy: { createdAt: "desc" },
|
||||
take: 200,
|
||||
include: {
|
||||
project: { select: { code: true } },
|
||||
lines: { orderBy: { shotCode: "asc" } },
|
||||
},
|
||||
});
|
||||
if (!requests.length) return [];
|
||||
|
||||
const shotIds = [...new Set(requests.flatMap((r) => r.lines.map((l) => l.shotId)))];
|
||||
const [items, shots, exports] = await Promise.all([
|
||||
db.deliveryItem.findMany({
|
||||
where: {
|
||||
shotId: { in: shotIds },
|
||||
package: { kind: "EXR_DELIVERY", status: { in: ["QUEUED", "BUILDING", "READY", "DELIVERED"] } },
|
||||
},
|
||||
select: { shotId: true, package: { select: { status: true, uploadedAt: true, deliveredAt: true } } },
|
||||
}),
|
||||
db.shot.findMany({
|
||||
where: { id: { in: shotIds } },
|
||||
select: { id: true, manualDeliveredAt: true, manualDeliveryUrl: true, manualDeliverySharedWith: true },
|
||||
}),
|
||||
db.export.findMany({
|
||||
where: { shotId: { in: shotIds }, status: { in: ["READY_FOR_DELIVERY", "READY_FOR_QC"] } },
|
||||
orderBy: { versionNumber: "desc" },
|
||||
select: { id: true, shotId: true, status: true },
|
||||
}),
|
||||
]);
|
||||
|
||||
const packagesByShot = new Map<string, (typeof items)[number]["package"][]>();
|
||||
for (const item of items) {
|
||||
if (!packagesByShot.has(item.shotId)) packagesByShot.set(item.shotId, []);
|
||||
packagesByShot.get(item.shotId)!.push(item.package);
|
||||
}
|
||||
const shotById = new Map(shots.map((s) => [s.id, s]));
|
||||
const exportsByShot = new Map<string, typeof exports>();
|
||||
for (const row of exports) {
|
||||
if (!exportsByShot.has(row.shotId)) exportsByShot.set(row.shotId, []);
|
||||
exportsByShot.get(row.shotId)!.push(row);
|
||||
}
|
||||
|
||||
const views = requests.map((request): DeliveryRequestView => {
|
||||
const lines = request.lines.map((line): DeliveryRequestLineView => {
|
||||
const shot = shotById.get(line.shotId);
|
||||
const shotExports = exportsByShot.get(line.shotId) ?? [];
|
||||
const stage = resolveLineStage({
|
||||
requestCreatedAt: request.createdAt,
|
||||
packages: packagesByShot.get(line.shotId) ?? [],
|
||||
manual: {
|
||||
deliveredAt: shot?.manualDeliveredAt ?? null,
|
||||
url: shot?.manualDeliveryUrl ?? null,
|
||||
sharedWith: shot?.manualDeliverySharedWith ?? null,
|
||||
},
|
||||
exportStatuses: shotExports.map((e) => e.status),
|
||||
});
|
||||
return {
|
||||
id: line.id,
|
||||
shotId: line.shotId,
|
||||
shotCode: line.shotCode,
|
||||
clientCode: line.clientCode,
|
||||
stage,
|
||||
// Newest first, so this is the version a build would take.
|
||||
readyExportId:
|
||||
stage === "READY" ? (shotExports.find((e) => e.status === "READY_FOR_DELIVERY")?.id ?? null) : null,
|
||||
};
|
||||
});
|
||||
const deliveredCount = lines.filter((l) => l.stage === "DELIVERED").length;
|
||||
const state = request.cancelledAt ? "CANCELLED" : deliveredCount === lines.length ? "CLOSED" : "OPEN";
|
||||
return {
|
||||
id: request.id,
|
||||
requestNumber: request.requestNumber,
|
||||
projectId: request.projectId,
|
||||
projectCode: request.project.code,
|
||||
episode: request.episode,
|
||||
deliverable: request.deliverable,
|
||||
requestedBy: request.requestedBy,
|
||||
note: request.note,
|
||||
dueDate: request.dueDate,
|
||||
createdAt: request.createdAt,
|
||||
cancelledAt: request.cancelledAt,
|
||||
cancelReason: request.cancelReason,
|
||||
state,
|
||||
lineCount: lines.length,
|
||||
deliveredCount,
|
||||
severity: state === "OPEN" ? waitingSeverity(request.createdAt, request.dueDate) : "normal",
|
||||
lines,
|
||||
};
|
||||
});
|
||||
|
||||
return filter.openOnly ? views.filter((v) => v.state === "OPEN") : views;
|
||||
}
|
||||
|
||||
/** Episodes a request can be logged against, with how many shots each holds. */
|
||||
export async function getRequestOptions(projectId: string) {
|
||||
const groups = await db.shot.groupBy({
|
||||
by: ["episode"],
|
||||
where: { projectId, episode: { not: null } },
|
||||
_count: { _all: true },
|
||||
});
|
||||
return {
|
||||
episodes: groups
|
||||
.filter((g) => g.episode)
|
||||
.map((g) => ({ episode: g.episode as string, shotCount: g._count._all }))
|
||||
.sort((a, b) => a.episode.localeCompare(b.episode)),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* How many of these shots are still owed on a request. A manual delivery of
|
||||
* such a shot has to carry the upload link and recipient, or the request it
|
||||
* answers would stay open with nothing to say why.
|
||||
*/
|
||||
export async function countShotsOnOpenRequests(shotIds: string[]): Promise<number> {
|
||||
if (!shotIds.length) return 0;
|
||||
const open = await listDeliveryRequests({ openOnly: true });
|
||||
const wanted = new Set(shotIds);
|
||||
const owed = new Set<string>();
|
||||
for (const request of open) {
|
||||
for (const line of request.lines) {
|
||||
if (line.stage !== "DELIVERED" && wanted.has(line.shotId)) owed.add(line.shotId);
|
||||
}
|
||||
}
|
||||
return owed.size;
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
-- "Delivered" used to be one click with nothing behind it. Record the upload
|
||||
-- location and who was sent the link, so a package only becomes DELIVERED once
|
||||
-- both have actually happened. Packages delivered before this keep NULLs here.
|
||||
ALTER TABLE "delivery_packages" ADD COLUMN "uploadUrl" TEXT;
|
||||
ALTER TABLE "delivery_packages" ADD COLUMN "uploadedAt" TIMESTAMP(3);
|
||||
ALTER TABLE "delivery_packages" ADD COLUMN "uploadedById" TEXT;
|
||||
ALTER TABLE "delivery_packages" ADD COLUMN "sharedWith" TEXT;
|
||||
ALTER TABLE "delivery_packages" ADD COLUMN "sharedNote" TEXT;
|
||||
@@ -0,0 +1,44 @@
|
||||
-- Delivery requests: what a client asked for, tracked until it has gone out
|
||||
-- with evidence. Closure is derived from packages and manual deliveries, so
|
||||
-- there is no status column to fall out of step.
|
||||
CREATE TABLE "delivery_requests" (
|
||||
"id" TEXT NOT NULL,
|
||||
"projectId" TEXT NOT NULL,
|
||||
"requestNumber" INTEGER NOT NULL,
|
||||
"episode" TEXT NOT NULL DEFAULT '',
|
||||
"deliverable" TEXT NOT NULL DEFAULT 'EXR',
|
||||
"requestedBy" TEXT NOT NULL,
|
||||
"note" TEXT,
|
||||
"dueDate" TIMESTAMP(3),
|
||||
"createdById" TEXT NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"cancelledAt" TIMESTAMP(3),
|
||||
"cancelledById" TEXT,
|
||||
"cancelReason" TEXT,
|
||||
|
||||
CONSTRAINT "delivery_requests_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
CREATE TABLE "delivery_request_lines" (
|
||||
"id" TEXT NOT NULL,
|
||||
"requestId" TEXT NOT NULL,
|
||||
"shotId" TEXT NOT NULL,
|
||||
"shotCode" TEXT NOT NULL,
|
||||
"clientCode" TEXT,
|
||||
|
||||
CONSTRAINT "delivery_request_lines_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX "delivery_requests_projectId_requestNumber_key" ON "delivery_requests"("projectId", "requestNumber");
|
||||
CREATE INDEX "delivery_requests_projectId_cancelledAt_idx" ON "delivery_requests"("projectId", "cancelledAt");
|
||||
CREATE UNIQUE INDEX "delivery_request_lines_requestId_shotId_key" ON "delivery_request_lines"("requestId", "shotId");
|
||||
CREATE INDEX "delivery_request_lines_shotId_idx" ON "delivery_request_lines"("shotId");
|
||||
|
||||
ALTER TABLE "delivery_requests" ADD CONSTRAINT "delivery_requests_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "projects"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
ALTER TABLE "delivery_request_lines" ADD CONSTRAINT "delivery_request_lines_requestId_fkey" FOREIGN KEY ("requestId") REFERENCES "delivery_requests"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
ALTER TABLE "delivery_request_lines" ADD CONSTRAINT "delivery_request_lines_shotId_fkey" FOREIGN KEY ("shotId") REFERENCES "shots"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- A manual delivery closes a request line only with the same evidence a
|
||||
-- package carries: where it was uploaded and who was sent the link.
|
||||
ALTER TABLE "shots" ADD COLUMN "manualDeliveryUrl" TEXT;
|
||||
ALTER TABLE "shots" ADD COLUMN "manualDeliverySharedWith" TEXT;
|
||||
+60
-1
@@ -380,6 +380,7 @@ model Project {
|
||||
/// is why it lives here rather than in per-machine panel settings.
|
||||
pipelineConfig Json?
|
||||
deliveryPackages DeliveryPackage[]
|
||||
deliveryRequests DeliveryRequest[]
|
||||
|
||||
@@map("projects")
|
||||
}
|
||||
@@ -463,6 +464,11 @@ model Shot {
|
||||
manualDeliveredAt DateTime?
|
||||
manualDeliveredById String?
|
||||
manualDeliveryNote String? @db.Text
|
||||
/// Where a manual delivery was uploaded and who was sent the link. Optional
|
||||
/// for a backfill, but a manual delivery only closes a DeliveryRequest line
|
||||
/// when both are present — the same evidence a package needs.
|
||||
manualDeliveryUrl String?
|
||||
manualDeliverySharedWith String?
|
||||
// Netflix slate fields — managed via the Slate tab, used by the preview pipeline
|
||||
slateType String?
|
||||
slateDescription String? @db.Text
|
||||
@@ -480,6 +486,7 @@ model Shot {
|
||||
references ShotReference[]
|
||||
loggedTakes Take[] @relation("TakeToShot")
|
||||
exports Export[]
|
||||
deliveryRequestLines DeliveryRequestLine[]
|
||||
|
||||
@@unique([projectId, shotCode])
|
||||
@@map("shots")
|
||||
@@ -1155,7 +1162,16 @@ model DeliveryPackage {
|
||||
createdById String
|
||||
createdAt DateTime @default(now())
|
||||
builtAt DateTime?
|
||||
deliveredAt DateTime?
|
||||
|
||||
// Handoff evidence. Built is not delivered: the folder still has to be
|
||||
// uploaded to the client's portal and the link sent to a person, and each of
|
||||
// those is recorded separately so a package cannot be "delivered" on a hunch.
|
||||
uploadUrl String? // where it lives on the client's side (Content Hub folder)
|
||||
uploadedAt DateTime?
|
||||
uploadedById String?
|
||||
sharedWith String? // who was sent the link
|
||||
sharedNote String?
|
||||
deliveredAt DateTime? // when the link was shared — the package is DELIVERED from here
|
||||
deliveredById String?
|
||||
|
||||
items DeliveryItem[]
|
||||
@@ -1196,6 +1212,49 @@ model DeliveryItem {
|
||||
@@map("delivery_items")
|
||||
}
|
||||
|
||||
/// Something a client asked for ("EXRs for 116"), logged by hand because the
|
||||
/// ask arrives over chat. It has no status of its own: a line is closed when
|
||||
/// its shot has gone out with evidence after the request was logged, and the
|
||||
/// request is closed when every line is — so it cannot be ticked off by hand.
|
||||
model DeliveryRequest {
|
||||
id String @id @default(cuid())
|
||||
projectId String
|
||||
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
|
||||
requestNumber Int // sequential per project → "Request #4"
|
||||
episode String @default("") // set when a whole episode was asked for
|
||||
deliverable String @default("EXR") // EXR | MOV | EXR_AND_MOV — what the client asked for
|
||||
requestedBy String // who at the client asked
|
||||
note String? @db.Text
|
||||
dueDate DateTime?
|
||||
createdById String
|
||||
createdAt DateTime @default(now())
|
||||
cancelledAt DateTime?
|
||||
cancelledById String?
|
||||
cancelReason String?
|
||||
|
||||
lines DeliveryRequestLine[]
|
||||
|
||||
@@unique([projectId, requestNumber])
|
||||
@@index([projectId, cancelledAt])
|
||||
@@map("delivery_requests")
|
||||
}
|
||||
|
||||
/// One requested shot. The shot list is a snapshot taken when the request was
|
||||
/// logged; codes are denormalised for the same reason DeliveryItem's are.
|
||||
model DeliveryRequestLine {
|
||||
id String @id @default(cuid())
|
||||
requestId String
|
||||
request DeliveryRequest @relation(fields: [requestId], references: [id], onDelete: Cascade)
|
||||
shotId String
|
||||
shot Shot @relation(fields: [shotId], references: [id], onDelete: Cascade)
|
||||
shotCode String
|
||||
clientCode String?
|
||||
|
||||
@@unique([requestId, shotId])
|
||||
@@index([shotId])
|
||||
@@map("delivery_request_lines")
|
||||
}
|
||||
|
||||
model SketchTemplate {
|
||||
id String @id @default(cuid())
|
||||
name String
|
||||
|
||||
Reference in New Issue
Block a user