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**.
|
18 shots"*, click **Build delivery**.
|
||||||
3. A render worker builds the folder on the delivery volume, verifies every
|
3. A render worker builds the folder on the delivery volume, verifies every
|
||||||
file, and writes a manifest and CSV report.
|
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 |
|
| `QUEUED` | Waiting for a worker to pick it up |
|
||||||
| `BUILDING` | A worker is creating folders and linking files |
|
| `BUILDING` | A worker is creating folders and linking files |
|
||||||
| `READY` | Built and verified — upload it |
|
| `READY` | Built and verified. Shown as *Built — not uploaded*, then *Uploaded — link not shared* |
|
||||||
| `DELIVERED` | You confirmed the client has it |
|
| `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 |
|
| `FAILED` | Something did not verify — see the error and the per-shot list |
|
||||||
| `CANCELLED` | Abandoned before delivery |
|
| `CANCELLED` | Abandoned before delivery |
|
||||||
|
|
||||||
@@ -166,11 +171,59 @@ so it is logged and the package continues without it.
|
|||||||
|
|
||||||
## Marking delivered
|
## Marking delivered
|
||||||
|
|
||||||
Once you have uploaded the folder, **Mark delivered** moves every packaged
|
A built package is not a delivered one, so delivery is two recorded steps. Both
|
||||||
export to `DELIVERED`. Requires admin, producer or supervisor.
|
require admin, producer or supervisor.
|
||||||
|
|
||||||
This is the end of the pipeline, and it is a human statement of fact — the app
|
1. **Record upload** — paste the link to the folder on the client's portal and
|
||||||
has no way to know the client received anything.
|
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 |
|
| E16 | `POST` | `/api/deliveries` | Create a package and queue the build |
|
||||||
| E17 | `GET` | `/api/deliveries` | List packages / history |
|
| E17 | `GET` | `/api/deliveries` | List packages / history |
|
||||||
| E18 | `GET` | `/api/deliveries/ready` | Ready exports grouped by episode |
|
| 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 |
|
| — | `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/ext/render/jobs/{jobId}/delivery` | Worker reports a build (API key) |
|
||||||
|
|
||||||
`POST /api/deliveries` takes `{ projectId, episode?, exportIds?, options? }`.
|
`POST /api/deliveries` takes `{ projectId, episode?, exportIds?, options? }`.
|
||||||
|
|||||||
+35
-3
@@ -6,6 +6,7 @@ import { revalidatePath } from "next/cache";
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { ShotStatus, ShotPriority, ShotApprovalStatus, Prisma } from "@prisma/client";
|
import { ShotStatus, ShotPriority, ShotApprovalStatus, Prisma } from "@prisma/client";
|
||||||
import { recalcShotStatus, markShotForRevisions } from "@/lib/shot-status";
|
import { recalcShotStatus, markShotForRevisions } from "@/lib/shot-status";
|
||||||
|
import { countShotsOnOpenRequests } from "@/lib/render-pipeline/delivery-requests";
|
||||||
|
|
||||||
const createShotSchema = z.object({
|
const createShotSchema = z.object({
|
||||||
scene: z.string().min(1).max(50).regex(/^[A-Z0-9_]+$/i, "Alphanumeric and underscore only"),
|
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[],
|
shotIds: string[],
|
||||||
deliveredOn: string | null,
|
deliveredOn: string | null,
|
||||||
note?: string,
|
note?: string,
|
||||||
|
/** Where it was uploaded and who was sent the link. */
|
||||||
|
evidence?: { uploadUrl?: string; sharedWith?: string },
|
||||||
) {
|
) {
|
||||||
const session = await auth();
|
const session = await auth();
|
||||||
if (!session?.user) throw new Error("Unauthorized");
|
if (!session?.user) throw new Error("Unauthorized");
|
||||||
@@ -726,7 +729,7 @@ export async function bulkSetManualDelivery(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const ids = z.array(z.string().cuid()).parse(shotIds);
|
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;
|
let deliveredAt: Date | null = null;
|
||||||
if (deliveredOn) {
|
if (deliveredOn) {
|
||||||
@@ -745,6 +748,27 @@ export async function bulkSetManualDelivery(
|
|||||||
if (shots.length !== ids.length) throw new Error("Some shots not found");
|
if (shots.length !== ids.length) throw new Error("Some shots not found");
|
||||||
|
|
||||||
const trimmedNote = note?.trim() || null;
|
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({
|
await db.shot.updateMany({
|
||||||
where: { id: { in: ids } },
|
where: { id: { in: ids } },
|
||||||
data: deliveredAt
|
data: deliveredAt
|
||||||
@@ -752,8 +776,16 @@ export async function bulkSetManualDelivery(
|
|||||||
manualDeliveredAt: deliveredAt,
|
manualDeliveredAt: deliveredAt,
|
||||||
manualDeliveredById: session.user.id,
|
manualDeliveredById: session.user.id,
|
||||||
manualDeliveryNote: trimmedNote,
|
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))]) {
|
for (const pid of [...new Set(shots.map((s) => s.projectId))]) {
|
||||||
@@ -762,7 +794,7 @@ export async function bulkSetManualDelivery(
|
|||||||
revalidatePath(`/shot-status`);
|
revalidatePath(`/shot-status`);
|
||||||
revalidatePath(`/dashboard`);
|
revalidatePath(`/dashboard`);
|
||||||
|
|
||||||
return { success: true, updated: shots.length };
|
return { success: true as const, updated: shots.length };
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Bulk Delete Shots ─────────────────────────────────────────────────────────
|
// ── Bulk Delete Shots ─────────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -5,12 +5,15 @@ import { BlockedQueue } from "@/components/dashboard/BlockedQueue";
|
|||||||
import { FarmStrip } from "@/components/dashboard/FarmStrip";
|
import { FarmStrip } from "@/components/dashboard/FarmStrip";
|
||||||
import { DeliveryCountdown } from "@/components/dashboard/DeliveryCountdown";
|
import { DeliveryCountdown } from "@/components/dashboard/DeliveryCountdown";
|
||||||
import { QcQueue } from "@/components/dashboard/QcQueue";
|
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 { RecentDeliveries } from "@/components/dashboard/RecentDeliveries";
|
||||||
import { WhoseCourt } from "@/components/dashboard/WhoseCourt";
|
import { WhoseCourt } from "@/components/dashboard/WhoseCourt";
|
||||||
import { ShotQueue } from "@/components/dashboard/ShotQueue";
|
import { ShotQueue } from "@/components/dashboard/ShotQueue";
|
||||||
import { TaskWidgets } from "@/components/dashboard/TaskWidgets";
|
import { TaskWidgets } from "@/components/dashboard/TaskWidgets";
|
||||||
import { ScheduleWidgets } from "@/components/dashboard/ScheduleWidgets";
|
import { ScheduleWidgets } from "@/components/dashboard/ScheduleWidgets";
|
||||||
import {
|
import {
|
||||||
|
getAwaitingDelivery,
|
||||||
getBlockedExports,
|
getBlockedExports,
|
||||||
getDeliveryCountdown,
|
getDeliveryCountdown,
|
||||||
getFarmStatus,
|
getFarmStatus,
|
||||||
@@ -20,6 +23,7 @@ import {
|
|||||||
getRecentDeliveries,
|
getRecentDeliveries,
|
||||||
getWhoseCourt,
|
getWhoseCourt,
|
||||||
} from "@/lib/dashboard/ops";
|
} from "@/lib/dashboard/ops";
|
||||||
|
import { listDeliveryRequests } from "@/lib/render-pipeline/delivery-requests";
|
||||||
import type { DashboardStats } from "@/types";
|
import type { DashboardStats } from "@/types";
|
||||||
|
|
||||||
export const metadata = { title: "Dashboard" };
|
export const metadata = { title: "Dashboard" };
|
||||||
@@ -37,7 +41,7 @@ async function getDashboardData(userId: string, role: string) {
|
|||||||
const isClient = role === "CLIENT";
|
const isClient = role === "CLIENT";
|
||||||
|
|
||||||
// Producers see the floor; an artist sees the failures that are theirs.
|
// 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([
|
await Promise.all([
|
||||||
getOpsCounts(isArtist ? userId : undefined),
|
getOpsCounts(isArtist ? userId : undefined),
|
||||||
getBlockedExports(isArtist ? { artistId: userId } : {}),
|
getBlockedExports(isArtist ? { artistId: userId } : {}),
|
||||||
@@ -46,6 +50,8 @@ async function getDashboardData(userId: string, role: string) {
|
|||||||
isArtist || isClient ? [] : getQcQueue(),
|
isArtist || isClient ? [] : getQcQueue(),
|
||||||
getQcStaleHours(),
|
getQcStaleHours(),
|
||||||
isArtist || isClient ? [] : getRecentDeliveries(),
|
isArtist || isClient ? [] : getRecentDeliveries(),
|
||||||
|
isArtist || isClient ? [] : getAwaitingDelivery(),
|
||||||
|
isArtist || isClient ? [] : listDeliveryRequests({ openOnly: true }),
|
||||||
isArtist || isClient ? null : getWhoseCourt(),
|
isArtist || isClient ? null : getWhoseCourt(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -128,6 +134,8 @@ async function getDashboardData(userId: string, role: string) {
|
|||||||
qcQueue,
|
qcQueue,
|
||||||
qcStaleHours,
|
qcStaleHours,
|
||||||
deliveries,
|
deliveries,
|
||||||
|
awaitingDelivery,
|
||||||
|
openRequests,
|
||||||
court,
|
court,
|
||||||
shots,
|
shots,
|
||||||
myTasks,
|
myTasks,
|
||||||
@@ -152,6 +160,8 @@ export default async function DashboardPage() {
|
|||||||
qcQueue,
|
qcQueue,
|
||||||
qcStaleHours,
|
qcStaleHours,
|
||||||
deliveries,
|
deliveries,
|
||||||
|
awaitingDelivery,
|
||||||
|
openRequests,
|
||||||
court,
|
court,
|
||||||
shots,
|
shots,
|
||||||
myTasks,
|
myTasks,
|
||||||
@@ -171,6 +181,26 @@ export default async function DashboardPage() {
|
|||||||
|
|
||||||
{!isClient && <StatsCards stats={stats} />}
|
{!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 && (
|
{!isClient && (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<h2 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide">
|
<h2 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide">
|
||||||
|
|||||||
@@ -3,11 +3,14 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
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 { Button } from "@/components/ui/button";
|
||||||
import { useToast } from "@/components/ui/use-toast";
|
import { useToast } from "@/components/ui/use-toast";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
import { handoffStage } from "@/lib/render-pipeline/delivery-handoff";
|
||||||
import { DELIVERY_STATUS_STYLES, statusLabel } from "../status-colors";
|
import { DELIVERY_STATUS_STYLES, statusLabel } from "../status-colors";
|
||||||
|
import { RecordShareDialog, RecordUploadDialog } from "./HandoffDialogs";
|
||||||
|
import { RequestsPanel } from "./RequestsPanel";
|
||||||
|
|
||||||
interface ReadyExport {
|
interface ReadyExport {
|
||||||
exportId: string;
|
exportId: string;
|
||||||
@@ -35,14 +38,25 @@ interface PackageRow {
|
|||||||
rootPath: string | null;
|
rootPath: string | null;
|
||||||
errorMessage: string | null;
|
errorMessage: string | null;
|
||||||
projectName: string;
|
projectName: string;
|
||||||
|
projectCode: string;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
builtAt: string | null;
|
builtAt: string | null;
|
||||||
|
uploadUrl: string | null;
|
||||||
|
uploadedAt: string | null;
|
||||||
|
uploadedByName: string | null;
|
||||||
|
sharedWith: string | null;
|
||||||
|
sharedNote: string | null;
|
||||||
deliveredAt: string | null;
|
deliveredAt: string | null;
|
||||||
|
deliveredByName: string | null;
|
||||||
itemCount: number;
|
itemCount: number;
|
||||||
verifiedCount: number;
|
verifiedCount: number;
|
||||||
|
exrFileCount: number;
|
||||||
|
movCount: number;
|
||||||
|
totalBytes: number;
|
||||||
items: {
|
items: {
|
||||||
id: string;
|
id: string;
|
||||||
shotCode: string;
|
shotCode: string;
|
||||||
|
clientCode: string | null;
|
||||||
versionString: string;
|
versionString: string;
|
||||||
status: string;
|
status: string;
|
||||||
fileCount: number | null;
|
fileCount: number | null;
|
||||||
@@ -69,6 +83,7 @@ export function DeliveriesClient({ canBuild }: { canBuild: boolean }) {
|
|||||||
const [projectId, setProjectId] = useState<string>("");
|
const [projectId, setProjectId] = useState<string>("");
|
||||||
const [episode, setEpisode] = useState<string>("");
|
const [episode, setEpisode] = useState<string>("");
|
||||||
const [building, setBuilding] = useState(false);
|
const [building, setBuilding] = useState(false);
|
||||||
|
const [handoff, setHandoff] = useState<{ step: "upload" | "share"; packageId: string } | null>(null);
|
||||||
|
|
||||||
const { data: projects } = useQuery({
|
const { data: projects } = useQuery({
|
||||||
queryKey: ["pipeline-projects"],
|
queryKey: ["pipeline-projects"],
|
||||||
@@ -126,16 +141,23 @@ export function DeliveriesClient({ canBuild }: { canBuild: boolean }) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function packageAction(id: string, verb: "delivered" | "cancel") {
|
function refreshPackages() {
|
||||||
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" });
|
|
||||||
}
|
|
||||||
queryClient.invalidateQueries({ queryKey: ["delivery-packages"] });
|
queryClient.invalidateQueries({ queryKey: ["delivery-packages"] });
|
||||||
queryClient.invalidateQueries({ queryKey: ["delivery-ready"] });
|
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 episodes = ready?.episodes ?? [];
|
||||||
const shown = episode ? episodes.filter((e) => e.episode === episode) : episodes;
|
const shown = episode ? episodes.filter((e) => e.episode === episode) : episodes;
|
||||||
|
|
||||||
@@ -183,6 +205,8 @@ export function DeliveriesClient({ canBuild }: { canBuild: boolean }) {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<RequestsPanel projectId={projectId} canBuild={canBuild} />
|
||||||
|
|
||||||
{/* Ready to build */}
|
{/* Ready to build */}
|
||||||
{projectId && (
|
{projectId && (
|
||||||
<div className="rounded-lg border border-zinc-800 bg-zinc-900/40">
|
<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="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="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">
|
<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">
|
<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">
|
<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>
|
<span className="text-sm text-white font-medium">#{p.packageNumber}</span>
|
||||||
@@ -251,10 +277,16 @@ export function DeliveriesClient({ canBuild }: { canBuild: boolean }) {
|
|||||||
<span
|
<span
|
||||||
className={cn(
|
className={cn(
|
||||||
"rounded-full border px-2 py-0.5 text-xs",
|
"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>
|
||||||
<span className="text-xs text-zinc-500">
|
<span className="text-xs text-zinc-500">
|
||||||
{p.episode ? `episode ${p.episode} · ` : ""}
|
{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>
|
<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" />}
|
{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">
|
<span className="ml-auto flex items-center gap-2">
|
||||||
{canBuild && p.status === "READY" && p.kind === "EXR_DELIVERY" && (
|
{canBuild && stage === "AWAITING_UPLOAD" && (
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.preventDefault();
|
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" />
|
<CheckCircle2 className="h-4 w-4 mr-2 text-emerald-400" />
|
||||||
Mark delivered
|
Share link
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
{canBuild && ["QUEUED", "FAILED", "DRAFT"].includes(p.status) && (
|
{canBuild && ["QUEUED", "FAILED", "DRAFT"].includes(p.status) && (
|
||||||
@@ -282,7 +327,7 @@ export function DeliveriesClient({ canBuild }: { canBuild: boolean }) {
|
|||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
packageAction(p.id, "cancel");
|
cancelPackage(p.id);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<XCircle className="h-4 w-4 mr-2 text-red-400" />
|
<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">
|
<div className="px-4 pb-4 space-y-2">
|
||||||
{p.rootPath && <div className="text-xs text-zinc-500 break-all">{p.rootPath}</div>}
|
{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.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 && (
|
{p.deliveredAt && (
|
||||||
<div className="text-xs text-emerald-400">
|
<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>
|
||||||
)}
|
)}
|
||||||
<div className="grid sm:grid-cols-2 lg:grid-cols-3 gap-x-6 gap-y-1">
|
<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>
|
||||||
</div>
|
</div>
|
||||||
</details>
|
</details>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
{(packages?.packages ?? []).length === 0 && (
|
{(packages?.packages ?? []).length === 0 && (
|
||||||
<div className="p-4 text-sm text-zinc-500">No packages built yet</div>
|
<div className="p-4 text-sm text-zinc-500">No packages built yet</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</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>
|
</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 { updateShotNotes, bulkUpdateDueDates, bulkAssignArtist, bulkDeleteShots, bulkSetManualDelivery, toggleKeyShot, toggleScaledShot } from "@/actions/shots";
|
||||||
import { previewMovReview, buildMovReview } from "@/actions/deliveries";
|
import { previewMovReview, buildMovReview } from "@/actions/deliveries";
|
||||||
import { useToast } from "@/components/ui/use-toast";
|
import { useToast } from "@/components/ui/use-toast";
|
||||||
|
import { LogRequestDialog } from "@/components/delivery/LogRequestDialog";
|
||||||
import {
|
import {
|
||||||
Film,
|
Film,
|
||||||
Clock,
|
Clock,
|
||||||
@@ -55,6 +56,7 @@ import {
|
|||||||
Scaling,
|
Scaling,
|
||||||
UserPlus,
|
UserPlus,
|
||||||
PackageCheck,
|
PackageCheck,
|
||||||
|
ClipboardList,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { format } from "date-fns";
|
import { format } from "date-fns";
|
||||||
import { shotThumbnail } from "@/lib/shot-thumbnail";
|
import { shotThumbnail } from "@/lib/shot-thumbnail";
|
||||||
@@ -373,11 +375,17 @@ function ManualDeliveryDialog({
|
|||||||
}: {
|
}: {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
onClose: () => void;
|
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;
|
count: number;
|
||||||
}) {
|
}) {
|
||||||
const [date, setDate] = useState("");
|
const [date, setDate] = useState("");
|
||||||
const [note, setNote] = useState("");
|
const [note, setNote] = useState("");
|
||||||
|
const [uploadUrl, setUploadUrl] = useState("");
|
||||||
|
const [sharedWith, setSharedWith] = useState("");
|
||||||
const [clearing, setClearing] = useState(false);
|
const [clearing, setClearing] = useState(false);
|
||||||
const [isPending, startTransition] = useTransition();
|
const [isPending, startTransition] = useTransition();
|
||||||
|
|
||||||
@@ -385,6 +393,8 @@ function ManualDeliveryDialog({
|
|||||||
if (open) {
|
if (open) {
|
||||||
setDate("");
|
setDate("");
|
||||||
setNote("");
|
setNote("");
|
||||||
|
setUploadUrl("");
|
||||||
|
setSharedWith("");
|
||||||
setClearing(false);
|
setClearing(false);
|
||||||
}
|
}
|
||||||
}, [open]);
|
}, [open]);
|
||||||
@@ -392,7 +402,7 @@ function ManualDeliveryDialog({
|
|||||||
const handleApply = () => {
|
const handleApply = () => {
|
||||||
if (!clearing && !date) return;
|
if (!clearing && !date) return;
|
||||||
startTransition(async () => {
|
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"
|
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>
|
||||||
|
<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">
|
<div className="space-y-1.5">
|
||||||
<label className="text-xs font-medium text-zinc-400">Note (optional)</label>
|
<label className="text-xs font-medium text-zinc-400">Note (optional)</label>
|
||||||
<Textarea
|
<Textarea
|
||||||
@@ -1086,6 +1120,7 @@ export function ShotStatusClient({
|
|||||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||||
const [movReviewDialogOpen, setMovReviewDialogOpen] = useState(false);
|
const [movReviewDialogOpen, setMovReviewDialogOpen] = useState(false);
|
||||||
const [manualDeliveryDialogOpen, setManualDeliveryDialogOpen] = useState(false);
|
const [manualDeliveryDialogOpen, setManualDeliveryDialogOpen] = useState(false);
|
||||||
|
const [requestDialogOpen, setRequestDialogOpen] = useState(false);
|
||||||
|
|
||||||
const toggleEpisode = (ep: string) =>
|
const toggleEpisode = (ep: string) =>
|
||||||
setExpandedEpisodes((prev) => {
|
setExpandedEpisodes((prev) => {
|
||||||
@@ -1185,10 +1220,19 @@ 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 {
|
try {
|
||||||
const result = await bulkSetManualDelivery([...selectedIds], deliveredOn, note);
|
const result = await bulkSetManualDelivery([...selectedIds], deliveredOn, note, evidence);
|
||||||
const n = `${result.updated} shot${result.updated !== 1 ? "s" : ""}`;
|
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}` });
|
toast({ title: deliveredOn ? `${n} marked delivered` : `Manual delivery cleared on ${n}` });
|
||||||
setManualDeliveryDialogOpen(false);
|
setManualDeliveryDialogOpen(false);
|
||||||
clearSelection();
|
clearSelection();
|
||||||
@@ -1384,6 +1428,10 @@ export function ShotStatusClient({
|
|||||||
<Clapperboard className="h-3.5 w-3.5 mr-2" />
|
<Clapperboard className="h-3.5 w-3.5 mr-2" />
|
||||||
Build MOV Review Package
|
Build MOV Review Package
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem onClick={() => setRequestDialogOpen(true)}>
|
||||||
|
<ClipboardList className="h-3.5 w-3.5 mr-2" />
|
||||||
|
Log Delivery Request
|
||||||
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem onClick={() => setManualDeliveryDialogOpen(true)}>
|
<DropdownMenuItem onClick={() => setManualDeliveryDialogOpen(true)}>
|
||||||
<PackageCheck className="h-3.5 w-3.5 mr-2" />
|
<PackageCheck className="h-3.5 w-3.5 mr-2" />
|
||||||
Delivered Outside Pipeline
|
Delivered Outside Pipeline
|
||||||
@@ -1610,6 +1658,16 @@ export function ShotStatusClient({
|
|||||||
count={selectionCount}
|
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 */}
|
{/* Bulk delete dialog */}
|
||||||
<BulkDeleteDialog
|
<BulkDeleteDialog
|
||||||
open={deleteDialogOpen}
|
open={deleteDialogOpen}
|
||||||
|
|||||||
@@ -2,20 +2,30 @@ import { NextRequest, NextResponse } from "next/server";
|
|||||||
import { toErrorResponse } from "@/lib/render-pipeline/errors";
|
import { toErrorResponse } from "@/lib/render-pipeline/errors";
|
||||||
import { requirePipelineAdmin } from "@/lib/render-pipeline/session-auth";
|
import { requirePipelineAdmin } from "@/lib/render-pipeline/session-auth";
|
||||||
import { markDelivered } from "@/lib/render-pipeline/deliveries";
|
import { markDelivered } from "@/lib/render-pipeline/deliveries";
|
||||||
|
import { recordShareSchema } from "@/lib/render-pipeline/delivery-handoff";
|
||||||
|
|
||||||
// ── POST /api/deliveries/{id}/delivered (E19) ────────────────────────────────
|
// ── POST /api/deliveries/{id}/delivered (E19) ────────────────────────────────
|
||||||
//
|
//
|
||||||
// The folder has been uploaded to the client. Moves every packaged export to
|
// The upload link has been sent to the client. Takes who it was sent to, and
|
||||||
// DELIVERED — the end of the pipeline.
|
// moves every packaged export to DELIVERED — the end of the pipeline.
|
||||||
|
|
||||||
export async function POST(
|
export async function POST(
|
||||||
_req: NextRequest,
|
req: NextRequest,
|
||||||
{ params }: { params: Promise<{ packageId: string }> }
|
{ params }: { params: Promise<{ packageId: string }> }
|
||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
const user = await requirePipelineAdmin();
|
const user = await requirePipelineAdmin();
|
||||||
const { packageId } = await params;
|
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);
|
return NextResponse.json(result);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const { body, status } = toErrorResponse(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 { db } from "@/lib/db";
|
||||||
import { getConfigNumber } from "@/lib/render-pipeline/config";
|
import { getConfigNumber } from "@/lib/render-pipeline/config";
|
||||||
import { listMachines } from "@/lib/render-pipeline/machines";
|
import { listMachines } from "@/lib/render-pipeline/machines";
|
||||||
|
import { handoffStage, type HandoffStage } from "@/lib/render-pipeline/delivery-handoff";
|
||||||
import type { ExportStatus, Prisma } from "@prisma/client";
|
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 {
|
export interface CourtSide {
|
||||||
count: number;
|
count: number;
|
||||||
items: { id: string; shotCode: string; label: string; href: string }[];
|
items: { id: string; shotCode: string; label: string; href: string }[];
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { PipelineError } from "./errors";
|
|||||||
import { applyTransition } from "./transitions";
|
import { applyTransition } from "./transitions";
|
||||||
import { getConfigNumber, getConfigValue } from "./config";
|
import { getConfigNumber, getConfigValue } from "./config";
|
||||||
import { clientShotCode } from "@/lib/shot-code";
|
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
|
* Delivery builder (RenderPipeline2 §11). The web app decides *what* goes in a
|
||||||
@@ -640,11 +641,33 @@ export async function listDeliveryPackages(filter: {
|
|||||||
},
|
},
|
||||||
orderBy: { createdAt: "desc" },
|
orderBy: { createdAt: "desc" },
|
||||||
include: {
|
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 } },
|
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 {
|
return {
|
||||||
packages: packages.map((p) => ({
|
packages: packages.map((p) => ({
|
||||||
id: p.id,
|
id: p.id,
|
||||||
@@ -659,30 +682,71 @@ export async function listDeliveryPackages(filter: {
|
|||||||
projectCode: p.project.code,
|
projectCode: p.project.code,
|
||||||
createdAt: p.createdAt,
|
createdAt: p.createdAt,
|
||||||
builtAt: p.builtAt,
|
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,
|
deliveredAt: p.deliveredAt,
|
||||||
|
deliveredByName: p.deliveredById ? (userName.get(p.deliveredById) ?? null) : null,
|
||||||
itemCount: p.items.length,
|
itemCount: p.items.length,
|
||||||
verifiedCount: p.items.filter((i) => i.status === "VERIFIED").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. */
|
function assertHandoffAllowed(pkg: { kind: DeliveryKind; status: DeliveryStatus }, verb: string) {
|
||||||
export async function markDelivered(packageId: string, userId: 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) => {
|
return db.$transaction(async (tx) => {
|
||||||
const pkg = await tx.deliveryPackage.findUnique({ where: { id: packageId }, include: { items: true } });
|
const pkg = await tx.deliveryPackage.findUnique({ where: { id: packageId }, include: { items: true } });
|
||||||
if (!pkg) throw new PipelineError(404, "Delivery package not found");
|
if (!pkg) throw new PipelineError(404, "Delivery package not found");
|
||||||
if (pkg.status === "DELIVERED") {
|
if (pkg.status === "DELIVERED") {
|
||||||
return { package: { id: pkg.id, status: pkg.status }, alreadyApplied: true };
|
return { package: { id: pkg.id, status: pkg.status }, alreadyApplied: true };
|
||||||
}
|
}
|
||||||
if (pkg.status !== "READY") {
|
assertHandoffAllowed(pkg, "marked delivered");
|
||||||
throw new PipelineError(409, `Package is ${pkg.status} — only a READY package can be marked delivered`);
|
if (!pkg.uploadedAt || !pkg.uploadUrl) {
|
||||||
}
|
throw new PipelineError(409, "Record where the package was uploaded before marking it 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"
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const item of pkg.items) {
|
for (const item of pkg.items) {
|
||||||
@@ -698,7 +762,13 @@ export async function markDelivered(packageId: string, userId: string) {
|
|||||||
|
|
||||||
const updated = await tx.deliveryPackage.update({
|
const updated = await tx.deliveryPackage.update({
|
||||||
where: { id: pkg.id },
|
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 };
|
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.
|
/// is why it lives here rather than in per-machine panel settings.
|
||||||
pipelineConfig Json?
|
pipelineConfig Json?
|
||||||
deliveryPackages DeliveryPackage[]
|
deliveryPackages DeliveryPackage[]
|
||||||
|
deliveryRequests DeliveryRequest[]
|
||||||
|
|
||||||
@@map("projects")
|
@@map("projects")
|
||||||
}
|
}
|
||||||
@@ -463,6 +464,11 @@ model Shot {
|
|||||||
manualDeliveredAt DateTime?
|
manualDeliveredAt DateTime?
|
||||||
manualDeliveredById String?
|
manualDeliveredById String?
|
||||||
manualDeliveryNote String? @db.Text
|
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
|
// Netflix slate fields — managed via the Slate tab, used by the preview pipeline
|
||||||
slateType String?
|
slateType String?
|
||||||
slateDescription String? @db.Text
|
slateDescription String? @db.Text
|
||||||
@@ -480,6 +486,7 @@ model Shot {
|
|||||||
references ShotReference[]
|
references ShotReference[]
|
||||||
loggedTakes Take[] @relation("TakeToShot")
|
loggedTakes Take[] @relation("TakeToShot")
|
||||||
exports Export[]
|
exports Export[]
|
||||||
|
deliveryRequestLines DeliveryRequestLine[]
|
||||||
|
|
||||||
@@unique([projectId, shotCode])
|
@@unique([projectId, shotCode])
|
||||||
@@map("shots")
|
@@map("shots")
|
||||||
@@ -1155,7 +1162,16 @@ model DeliveryPackage {
|
|||||||
createdById String
|
createdById String
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
builtAt DateTime?
|
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?
|
deliveredById String?
|
||||||
|
|
||||||
items DeliveryItem[]
|
items DeliveryItem[]
|
||||||
@@ -1196,6 +1212,49 @@ model DeliveryItem {
|
|||||||
@@map("delivery_items")
|
@@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 {
|
model SketchTemplate {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
name String
|
name String
|
||||||
|
|||||||
Reference in New Issue
Block a user