Files
vfxreview/app/(dashboard)/dashboard/page.tsx
T
twotalesanimation f30acef101
Deploy / deploy (push) Successful in 5m17s
feat(deliveries): delivery requests and upload/share evidence
A built package used to become DELIVERED on one click with nothing behind
it, and nothing recorded what a client had asked for.

- Marking delivered is now two recorded steps: Record upload (the link on
  the client's portal, contents confirmed) then Share link (who it was sent
  to). The second is refused until the first is on record.
- Delivery requests log a client's ask by episode, pasted shot codes or a
  Shot Status selection. A line closes only when its shot goes out with
  evidence after the request was logged; a request can otherwise only be
  cancelled with a reason.
- Delivered Outside Pipeline takes an upload link and recipient, required
  for shots on an open request.
- The dashboard gains an Awaiting delivery section listing open requests
  and built packages the client does not have yet.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 14:21:24 +02:00

287 lines
9.7 KiB
TypeScript

import { auth } from "@/auth";
import { db } from "@/lib/db";
import { StatsCards } from "@/components/dashboard/StatsCards";
import { BlockedQueue } from "@/components/dashboard/BlockedQueue";
import { FarmStrip } from "@/components/dashboard/FarmStrip";
import { DeliveryCountdown } from "@/components/dashboard/DeliveryCountdown";
import { QcQueue } from "@/components/dashboard/QcQueue";
import { AwaitingDelivery } from "@/components/dashboard/AwaitingDelivery";
import { LogRequestButton } from "@/components/dashboard/LogRequestButton";
import { RecentDeliveries } from "@/components/dashboard/RecentDeliveries";
import { WhoseCourt } from "@/components/dashboard/WhoseCourt";
import { ShotQueue } from "@/components/dashboard/ShotQueue";
import { TaskWidgets } from "@/components/dashboard/TaskWidgets";
import { ScheduleWidgets } from "@/components/dashboard/ScheduleWidgets";
import {
getAwaitingDelivery,
getBlockedExports,
getDeliveryCountdown,
getFarmStatus,
getOpsCounts,
getQcQueue,
getQcStaleHours,
getRecentDeliveries,
getWhoseCourt,
} from "@/lib/dashboard/ops";
import { listDeliveryRequests } from "@/lib/render-pipeline/delivery-requests";
import type { DashboardStats } from "@/types";
export const metadata = { title: "Dashboard" };
// Queue depth and machine state are stale the moment they are cached.
export const dynamic = "force-dynamic";
/**
* The dashboard answers one question: can we ship what is due, and what is
* stopping us? Everything on it is pipeline state — exports, QC, deliveries,
* worker heartbeats — rather than anything a person has to maintain by hand.
*/
async function getDashboardData(userId: string, role: string) {
const isArtist = role === "ARTIST";
const isClient = role === "CLIENT";
// Producers see the floor; an artist sees the failures that are theirs.
const [counts, blocked, farm, episodes, qcQueue, qcStaleHours, deliveries, awaitingDelivery, openRequests, court] =
await Promise.all([
getOpsCounts(isArtist ? userId : undefined),
getBlockedExports(isArtist ? { artistId: userId } : {}),
getFarmStatus(),
getDeliveryCountdown(),
isArtist || isClient ? [] : getQcQueue(),
getQcStaleHours(),
isArtist || isClient ? [] : getRecentDeliveries(),
isArtist || isClient ? [] : getAwaitingDelivery(),
isArtist || isClient ? [] : listDeliveryRequests({ openOnly: true }),
isArtist || isClient ? null : getWhoseCourt(),
]);
// An artist's own shots come first; everyone else sees the floor.
const shots = await db.shot.findMany({
where: {
...(isArtist ? { artistId: userId } : {}),
status: { not: "COMPLETE" },
project: { status: { in: ["ACTIVE", "ON_HOLD"] } },
},
take: 15,
orderBy: [{ dueDate: { sort: "asc", nulls: "last" } }, { updatedAt: "desc" }],
include: {
artist: { select: { id: true, name: true, image: true, email: true } },
versions: {
take: 1,
orderBy: { versionNumber: "desc" },
include: { comments: { select: { id: true, isResolved: true } } },
},
},
});
const myTasks = await db.task.findMany({
where: { assignedArtistId: userId, status: { not: "DONE" } },
take: 8,
orderBy: [{ dueDate: "asc" }, { priority: "desc" }],
include: {
shot: { select: { shotCode: true } },
asset: { select: { assetCode: true } },
project: { select: { id: true, name: true, code: true } },
},
});
const reviewTasks = isArtist || isClient ? [] : await db.task.findMany({
where: { status: { in: ["INTERNAL_REVIEW", "CLIENT_REVIEW"] } },
take: 8,
orderBy: { updatedAt: "desc" },
include: {
shot: { select: { shotCode: true } },
asset: { select: { assetCode: true } },
project: { select: { id: true, name: true, code: true } },
assignedArtist: { select: { id: true, name: true, image: true, email: true } },
},
});
// Schedule overview (producers/supervisors/admins) — unchanged; it reads the
// schedule page's own data rather than anything the pipeline owns.
const scheduledTasks =
!isArtist && !isClient
? await db.task.findMany({
where: { scheduledStartDate: { not: null }, status: { not: "DONE" } },
include: {
shot: { select: { shotCode: true } },
asset: { select: { assetCode: true } },
project: { select: { id: true, name: true, code: true } },
assignedArtist: {
select: { id: true, name: true, email: true, image: true },
},
},
orderBy: { scheduledStartDate: "asc" },
})
: [];
const scheduleArtists =
!isArtist && !isClient
? await db.user.findMany({
where: { isActive: true, role: { not: "CLIENT" } },
select: { id: true, name: true, email: true, image: true, role: true },
orderBy: { name: "asc" },
})
: [];
const stats: DashboardStats = counts;
return {
stats,
blocked,
farm,
episodes,
qcQueue,
qcStaleHours,
deliveries,
awaitingDelivery,
openRequests,
court,
shots,
myTasks,
reviewTasks,
scheduledTasks,
scheduleArtists,
};
}
export default async function DashboardPage() {
const session = await auth();
if (!session?.user) return null;
const isClient = session.user.role === "CLIENT";
const isArtistRole = session.user.role === "ARTIST";
const canSeeSchedule = ["ADMIN", "PRODUCER", "SUPERVISOR"].includes(session.user.role);
const {
stats,
blocked,
farm,
episodes,
qcQueue,
qcStaleHours,
deliveries,
awaitingDelivery,
openRequests,
court,
shots,
myTasks,
reviewTasks,
scheduledTasks,
scheduleArtists,
} = await getDashboardData(session.user.id, session.user.role);
return (
<div className="p-4 md:p-8 space-y-6 max-w-[1600px] mx-auto">
<div className="mb-4">
<h1 className="text-3xl font-bold text-white">Dashboard</h1>
<p className="text-zinc-400 mt-1">
Welcome back, {session.user.name ?? session.user.email}
</p>
</div>
{!isClient && <StatsCards stats={stats} />}
{/* Rows only clear by being dealt with; the heading stays so there is
always somewhere to log what a client just asked for. */}
{!isClient && !isArtistRole && (
<div className="space-y-3">
<div className="flex items-center justify-between gap-3">
<h2
className={
openRequests.length + awaitingDelivery.length > 0
? "text-sm font-semibold text-amber-400 uppercase tracking-wide"
: "text-sm font-semibold text-muted-foreground uppercase tracking-wide"
}
>
Awaiting delivery
</h2>
{canSeeSchedule && <LogRequestButton />}
</div>
<AwaitingDelivery requests={openRequests} packages={awaitingDelivery} />
</div>
)}
{!isClient && (
<div className="space-y-3">
<h2 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide">
Delivery countdown
</h2>
<DeliveryCountdown episodes={episodes} />
</div>
)}
{!isClient && (
<div id="blocked" className="grid grid-cols-1 xl:grid-cols-3 gap-6 items-start scroll-mt-6">
<div className="xl:col-span-2 space-y-3">
<h2 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide">
{isArtistRole ? "Your blocked shots" : "Blocked — needs a human"}
</h2>
<BlockedQueue blocked={blocked} />
</div>
<div className="space-y-3">
<h2 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide">
Render farm
</h2>
<FarmStrip farm={farm} />
</div>
</div>
)}
{!isClient && !isArtistRole && (
<div className="grid grid-cols-1 xl:grid-cols-3 gap-6 items-start">
<div className="xl:col-span-2 space-y-3">
<h2 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide">
QC queue
</h2>
<QcQueue entries={qcQueue} staleHours={qcStaleHours} />
</div>
<div className="space-y-6">
<div className="space-y-3">
<h2 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide">
Deliveries
</h2>
<RecentDeliveries deliveries={deliveries} />
</div>
{court && (
<div className="space-y-3">
<h2 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide">
Whose court
</h2>
<WhoseCourt court={court} />
</div>
)}
</div>
</div>
)}
{!isClient && (
<TaskWidgets
myTasks={myTasks as any}
reviewTasks={reviewTasks as any}
role={session.user.role}
/>
)}
{canSeeSchedule && (
<div className="space-y-3">
<h2 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide">
Schedule Overview
</h2>
<ScheduleWidgets scheduledTasks={scheduledTasks as any} artists={scheduleArtists} />
</div>
)}
<div className="space-y-3">
<h2 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide">
{session.user.role === "ARTIST" ? "My shots" : "Shots in flight"}
</h2>
<div className="rounded-lg border border-border bg-card p-1">
<ShotQueue shots={shots as any} />
</div>
</div>
</div>
);
}