feat(dashboard): QC queue, deliveries and whose-court panels
Deploy / deploy (push) Successful in 6m51s

Completes the operations dashboard.

QC queue: what is waiting on a person to watch the frames, oldest first.
Every stage before QC is worked by the farm, so this is the only one that
cannot drain itself - depth here is the best early warning that a delivery
date is going to move, and it had no home outside a filter on the queue page.

Deliveries: the last few packages, flagging FAILED and builds stuck in
BUILDING. A failed build is otherwise silent - the exports stay
READY_FOR_DELIVERY, nothing reaches the delivery volume, and nobody finds out
until the client asks where the shots are.

Whose court: versions sent to the client and unanswered, against shots with
unresolved notes. Two numbers rather than one because they call for opposite
responses - a fortnight-old review is a phone call, unactioned notes are a
scheduling problem.

The artist view now scopes to the artist throughout: the blocked list, and
the counters above it, which otherwise quoted a studio-wide figure next to a
personal list.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
twotalesanimation
2026-09-10 05:58:08 +02:00
parent 6bfc013e35
commit 6afbe82a29
5 changed files with 532 additions and 13 deletions
+57 -7
View File
@@ -4,6 +4,9 @@ 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 { 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";
@@ -12,6 +15,10 @@ import {
getDeliveryCountdown,
getFarmStatus,
getOpsCounts,
getQcQueue,
getQcStaleHours,
getRecentDeliveries,
getWhoseCourt,
} from "@/lib/dashboard/ops";
import type { DashboardStats } from "@/types";
@@ -29,12 +36,18 @@ async function getDashboardData(userId: string, role: string) {
const isArtist = role === "ARTIST";
const isClient = role === "CLIENT";
const [counts, blocked, farm, episodes] = await Promise.all([
getOpsCounts(),
getBlockedExports(),
getFarmStatus(),
getDeliveryCountdown(),
]);
// Producers see the floor; an artist sees the failures that are theirs.
const [counts, blocked, farm, episodes, qcQueue, qcStaleHours, deliveries, court] =
await Promise.all([
getOpsCounts(isArtist ? userId : undefined),
getBlockedExports(isArtist ? { artistId: userId } : {}),
getFarmStatus(),
getDeliveryCountdown(),
isArtist || isClient ? [] : getQcQueue(),
getQcStaleHours(),
isArtist || isClient ? [] : getRecentDeliveries(),
isArtist || isClient ? null : getWhoseCourt(),
]);
// An artist's own shots come first; everyone else sees the floor.
const shots = await db.shot.findMany({
@@ -112,6 +125,10 @@ async function getDashboardData(userId: string, role: string) {
blocked,
farm,
episodes,
qcQueue,
qcStaleHours,
deliveries,
court,
shots,
myTasks,
reviewTasks,
@@ -125,12 +142,17 @@ export default async function DashboardPage() {
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,
court,
shots,
myTasks,
reviewTasks,
@@ -162,7 +184,7 @@ export default async function DashboardPage() {
<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">
Blocked — needs a human
{isArtistRole ? "Your blocked shots" : "Blocked — needs a human"}
</h2>
<BlockedQueue blocked={blocked} />
</div>
@@ -176,6 +198,34 @@ export default async function DashboardPage() {
</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}
+96
View File
@@ -0,0 +1,96 @@
import Link from "next/link";
import { formatDistanceToNowStrict } from "date-fns";
import { cn } from "@/lib/utils";
import type { QcQueueEntry } from "@/lib/dashboard/ops";
/**
* What is waiting on someone to watch the frames, oldest first.
*
* Everything upstream of QC is worked by the farm; this is the one stage that
* only drains when a person sits down to it, which is why depth here is worth
* a panel of its own rather than a filter on the queue page.
*/
export function QcQueue({ entries, staleHours }: { entries: QcQueueEntry[]; staleHours: number }) {
const oldest = entries[0];
return (
<div className="rounded-lg border border-zinc-800 bg-card overflow-hidden">
<div className="flex items-center justify-between gap-3 border-b border-zinc-800/60 px-4 py-3">
<h3 className="text-sm font-semibold text-white">
{entries.length === 0
? "QC queue empty"
: `${entries.length} waiting on QC`}
{oldest && (
<span className="ml-2 text-xs font-normal text-zinc-500">
oldest {formatDistanceToNowStrict(oldest.since)}
</span>
)}
</h3>
<Link
href="/pipeline?status=READY_FOR_QC"
className="text-xs text-zinc-400 hover:text-amber-400"
>
Open QC →
</Link>
</div>
{entries.length === 0 ? (
<p className="px-4 py-10 text-center text-sm text-zinc-500">
Nothing is waiting on a QC pass.
</p>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-zinc-800 bg-zinc-900/60 text-left text-xs text-zinc-400">
<th className="px-4 py-2.5 font-medium">Shot</th>
<th className="px-4 py-2.5 font-medium">Version</th>
<th className="px-4 py-2.5 font-medium">Waiting</th>
<th className="px-4 py-2.5 font-medium">Frames</th>
<th className="px-4 py-2.5 font-medium">Submitted by</th>
</tr>
</thead>
<tbody>
{entries.map((e) => {
const stale = Date.now() - e.since.getTime() > staleHours * 3600_000;
return (
<tr key={e.exportId} className="border-b border-zinc-800/60 hover:bg-zinc-900/40">
<td className="px-4 py-2.5">
<Link
href={`/pipeline/exports/${e.exportId}`}
className="font-mono text-[13px] font-medium text-white hover:text-amber-400"
>
{e.shotCode}
</Link>
<div className="text-xs text-zinc-500">
{e.projectCode}
{e.episode ? ` · ep ${e.episode}` : ""}
</div>
</td>
<td className="px-4 py-2.5 font-mono text-[13px] text-zinc-300">
{e.versionString}
</td>
<td
className={cn(
"px-4 py-2.5 tabular-nums whitespace-nowrap",
stale ? "text-amber-400" : "text-zinc-300"
)}
>
{formatDistanceToNowStrict(e.since)}
</td>
<td className="px-4 py-2.5 font-mono text-[13px] tabular-nums text-zinc-400 whitespace-nowrap">
{e.frameStart}–{e.frameEnd}
</td>
<td className="px-4 py-2.5 text-zinc-300 whitespace-nowrap">
{e.submittedBy ?? "—"}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</div>
);
}
+74
View File
@@ -0,0 +1,74 @@
import Link from "next/link";
import { formatDistanceToNowStrict } from "date-fns";
import { cn } from "@/lib/utils";
import { DELIVERY_STATUS_STYLES } from "@/app/(dashboard)/pipeline/status-colors";
import type { DeliverySummary } from "@/lib/dashboard/ops";
/**
* The last few packages, and whether any of them went wrong.
*
* A failed build is otherwise silent — the exports stay READY_FOR_DELIVERY and
* nothing reaches the delivery volume, but no one finds out until the client
* asks where the shots are.
*/
export function RecentDeliveries({ deliveries }: { deliveries: DeliverySummary[] }) {
const problems = deliveries.filter((d) => d.status === "FAILED" || d.stuck).length;
return (
<div className="rounded-lg border border-zinc-800 bg-card overflow-hidden">
<div className="flex items-center justify-between gap-3 border-b border-zinc-800/60 px-4 py-3">
<h3 className="text-sm font-semibold text-white">
Recent packages
{problems > 0 && (
<span className="ml-2 text-xs font-normal text-red-400">
{problems} need{problems === 1 ? "s" : ""} attention
</span>
)}
</h3>
<Link href="/pipeline/deliveries" className="text-xs text-zinc-400 hover:text-amber-400">
All deliveries →
</Link>
</div>
{deliveries.length === 0 ? (
<p className="px-4 py-8 text-center text-sm text-zinc-500">No packages built yet.</p>
) : (
deliveries.map((d) => (
<Link
key={d.id}
href="/pipeline/deliveries"
className="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"
>
<div className="min-w-0 flex-1">
<div className="truncate text-[13px] text-white">
<span className="font-mono font-medium">#{d.packageNumber}</span>{" "}
<span className="text-zinc-500">
{d.projectCode}
{d.episode ? ` · ${d.episode}` : ""}
{d.kind === "MOV_REVIEW" ? " · review" : ""}
</span>
</div>
<div className="truncate text-xs text-zinc-500" title={d.errorMessage ?? undefined}>
{d.status === "FAILED" && d.errorMessage
? d.errorMessage
: `${d.itemCount} shot${d.itemCount === 1 ? "" : "s"} · ${d.atLabel} ${formatDistanceToNowStrict(d.at)} ago`}
</div>
</div>
<span
className={cn(
"shrink-0 rounded-full border px-2 py-0.5 text-xs whitespace-nowrap",
// A build that never finished reads as a problem, not as work
// still in progress.
d.stuck
? "bg-red-500/15 text-red-400 border-red-500/30"
: DELIVERY_STATUS_STYLES[d.status] ?? "bg-zinc-500/15 text-zinc-300 border-zinc-500/30"
)}
>
{d.stuck ? "STUCK" : d.status}
</span>
</Link>
))
)}
</div>
);
}
+70
View File
@@ -0,0 +1,70 @@
import Link from "next/link";
import type { WhoseCourt as WhoseCourtData } from "@/lib/dashboard/ops";
/**
* Which waits are the client's and which are ours.
*
* Two numbers rather than one, because they call for opposite responses: a
* version unanswered for a fortnight is a phone call, while notes nobody has
* picked up is a scheduling problem.
*/
export function WhoseCourt({ court }: { court: WhoseCourtData }) {
return (
<div className="grid grid-cols-1 sm:grid-cols-2 rounded-lg border border-zinc-800 bg-card overflow-hidden">
<Side
title="Waiting on the client"
subtitle="Sent for review, no verdict yet"
side={court.client}
empty="Nothing is with the client."
className="border-b border-zinc-800/60 sm:border-b-0 sm:border-r"
/>
<Side
title="Waiting on us"
subtitle="Notes in, still unresolved"
side={court.us}
empty="No open notes."
/>
</div>
);
}
function Side({
title,
subtitle,
side,
empty,
className,
}: {
title: string;
subtitle: string;
side: WhoseCourtData["client"];
empty: string;
className?: string;
}) {
return (
<div className={`px-4 py-3 ${className ?? ""}`}>
<h4 className="text-xs font-semibold text-white">{title}</h4>
<p className="text-[11px] text-zinc-500">{subtitle}</p>
<b className="mt-2 mb-2 block font-mono text-2xl font-bold leading-none tabular-nums text-white">
{side.count}
</b>
{side.items.length === 0 ? (
<p className="text-xs text-zinc-500">{empty}</p>
) : (
<ul className="flex flex-col gap-1.5">
{side.items.map((item) => (
<li key={item.id} className="flex items-baseline justify-between gap-3 text-xs">
<Link
href={item.href}
className="truncate font-mono text-[11.5px] text-zinc-200 hover:text-amber-400"
>
{item.shotCode}
</Link>
<span className="shrink-0 tabular-nums text-zinc-500">{item.label}</span>
</li>
))}
</ul>
)}
</div>
);
}
+235 -6
View File
@@ -56,7 +56,10 @@ export interface BlockedExport {
* forgotten QC pass shows up. A failed export is currently only visible if
* someone thinks to open the render queue and filter for it.
*/
export async function getBlockedExports(limit = 25): Promise<BlockedExport[]> {
export async function getBlockedExports(
opts: { artistId?: string; limit?: number } = {}
): Promise<BlockedExport[]> {
const { artistId, limit = 25 } = opts;
const [qcStaleHours, stalledHours] = await Promise.all([
getConfigNumber("dashboard.qcStaleHours"),
getConfigNumber("dashboard.stalledJobHours"),
@@ -70,7 +73,11 @@ export async function getBlockedExports(limit = 25): Promise<BlockedExport[]> {
// Finished shows would otherwise fill the list with failures nobody
// intends to fix. Export carries a projectId but no project relation, so
// the scope is reached through the shot.
shot: { project: { status: { in: ["ACTIVE", "ON_HOLD"] } } },
shot: {
project: { status: { in: ["ACTIVE", "ON_HOLD"] } },
// An artist's dashboard shows the failures that are theirs to fix.
...(artistId ? { artistId } : {}),
},
OR: [
{ status: { in: FAILED_STATUSES } },
{ status: "READY_FOR_QC", statusChangedAt: { lt: qcCutoff } },
@@ -401,11 +408,16 @@ export interface OpsCounts {
* told anyone anything. Each of these can go down, which is what makes it
* worth looking at twice in a day.
*/
export async function getOpsCounts(): Promise<OpsCounts> {
export async function getOpsCounts(artistId?: string): Promise<OpsCounts> {
// Export has a projectId column but no project relation, so live-production
// scope is reached through the shot.
// scope is reached through the shot. An artist gets their own figures, so the
// cards agree with the blocked list underneath them rather than quoting a
// studio-wide number beside a personal one.
const liveProject: Prisma.ExportWhereInput = {
shot: { project: { status: { in: ["ACTIVE", "ON_HOLD"] } } },
shot: {
project: { status: { in: ["ACTIVE", "ON_HOLD"] } },
...(artistId ? { artistId } : {}),
},
};
const [qcStaleHours, stalledHours] = await Promise.all([
getConfigNumber("dashboard.qcStaleHours"),
@@ -436,7 +448,10 @@ export async function getOpsCounts(): Promise<OpsCounts> {
approvalStatus: "PENDING_REVIEW",
isClientVisible: true,
isLatest: true,
shot: { project: { status: { in: ["ACTIVE", "ON_HOLD"] } } },
shot: {
project: { status: { in: ["ACTIVE", "ON_HOLD"] } },
...(artistId ? { artistId } : {}),
},
},
}),
db.project.count({ where: { status: "ACTIVE" } }),
@@ -445,6 +460,220 @@ export async function getOpsCounts(): Promise<OpsCounts> {
return { blocked, inQc, readyToDeliver, awaitingClientReview, activeProjects };
}
/** How long an export may sit in READY_FOR_QC before the UI calls it stale. */
export function getQcStaleHours(): Promise<number> {
return getConfigNumber("dashboard.qcStaleHours");
}
export interface QcQueueEntry {
exportId: string;
shotCode: string;
versionString: string;
projectCode: string;
episode: string | null;
since: Date;
frameStart: number;
frameEnd: number;
submittedBy: string | null;
}
/**
* What is waiting on a human to look at the frames.
*
* QC is the last gate before anything can be packaged, and it is the only
* stage in the pipeline that cannot drain itself — everything before it is
* worked by the farm. Depth here is the single best predictor of a delivery
* slipping, and until now it had no home outside the render queue's filter.
*/
export async function getQcQueue(limit = 8): Promise<QcQueueEntry[]> {
const rows = await db.export.findMany({
where: {
status: "READY_FOR_QC",
shot: { project: { status: { in: ["ACTIVE", "ON_HOLD"] } } },
},
orderBy: { statusChangedAt: "asc" },
take: limit,
select: {
id: true,
versionString: true,
statusChangedAt: true,
frameStart: true,
frameEnd: true,
submittedByName: true,
shot: {
select: { shotCode: true, episode: true, project: { select: { code: true } } },
},
},
});
return rows.map((row) => ({
exportId: row.id,
shotCode: row.shot.shotCode,
versionString: row.versionString,
projectCode: row.shot.project.code,
episode: row.shot.episode,
since: row.statusChangedAt,
frameStart: row.frameStart,
frameEnd: row.frameEnd,
// Free text from the panel's config: Export keeps submittedById for the
// resolved account but carries no relation to it.
submittedBy: row.submittedByName,
}));
}
export interface DeliverySummary {
id: string;
packageNumber: number;
name: string;
projectCode: string;
episode: string;
kind: string;
status: string;
itemCount: number;
errorMessage: string | null;
/** Most recent of delivered/built/created — what the timestamp column means. */
at: Date;
atLabel: "delivered" | "built" | "created";
/** BUILDING for longer than a build plausibly takes. */
stuck: boolean;
}
/**
* Recent packages, and whether any of them went wrong.
*
* A failed delivery build is otherwise silent: the export stays
* READY_FOR_DELIVERY, nothing is on the delivery volume, and the only trace is
* a status on a page nobody opens between deliveries.
*/
export async function getRecentDeliveries(limit = 5): Promise<DeliverySummary[]> {
const rows = await db.deliveryPackage.findMany({
where: { project: { status: { in: ["ACTIVE", "ON_HOLD"] } } },
orderBy: { createdAt: "desc" },
take: limit,
select: {
id: true,
packageNumber: true,
name: true,
episode: true,
kind: true,
status: true,
errorMessage: true,
createdAt: true,
builtAt: true,
deliveredAt: true,
project: { select: { code: true } },
_count: { select: { items: true } },
},
});
const buildStuckAfter = 2 * 3600_000;
return rows.map((row) => {
const at = row.deliveredAt ?? row.builtAt ?? row.createdAt;
return {
id: row.id,
packageNumber: row.packageNumber,
name: row.name,
projectCode: row.project.code,
episode: row.episode,
kind: row.kind,
status: row.status,
itemCount: row._count.items,
errorMessage: row.errorMessage,
at,
atLabel: row.deliveredAt ? "delivered" : row.builtAt ? "built" : "created",
// A build that never finished leaves the package sitting in BUILDING
// forever; nothing times it out.
stuck: row.status === "BUILDING" && Date.now() - row.createdAt.getTime() > buildStuckAfter,
};
});
}
export interface CourtSide {
count: number;
items: { id: string; shotCode: string; label: string; href: string }[];
}
export interface WhoseCourt {
client: CourtSide;
us: CourtSide;
}
/**
* Which waits are ours and which are the client's.
*
* The distinction is the useful part: a version sitting unanswered for eleven
* days is a phone call, while notes nobody has actioned are a scheduling
* problem. Lumping both into "needs revisions" hid which was which.
*/
export async function getWhoseCourt(perSide = 4): Promise<WhoseCourt> {
const liveShot: Prisma.ShotWhereInput = {
project: { status: { in: ["ACTIVE", "ON_HOLD"] } },
};
const [withClient, openNotes] = await Promise.all([
db.version.findMany({
where: {
approvalStatus: "PENDING_REVIEW",
isClientVisible: true,
isLatest: true,
shot: liveShot,
},
// Longest wait first — that is the one worth chasing.
orderBy: [{ sharedAt: { sort: "asc", nulls: "last" } }, { createdAt: "asc" }],
take: perSide,
select: {
id: true,
versionNumber: true,
sharedAt: true,
createdAt: true,
shot: { select: { id: true, shotCode: true } },
},
}),
// Unresolved notes on the version currently in play. Grouped in memory
// because the interesting unit is the shot, not the comment.
db.comment.findMany({
where: {
isResolved: false,
version: { isLatest: true, shot: liveShot },
},
select: { id: true, version: { select: { shot: { select: { id: true, shotCode: true } } } } },
}),
]);
const byShot = new Map<string, { shotCode: string; count: number }>();
for (const note of openNotes) {
const shot = note.version?.shot;
if (!shot) continue;
const entry = byShot.get(shot.id) ?? { shotCode: shot.shotCode, count: 0 };
entry.count++;
byShot.set(shot.id, entry);
}
const ours = [...byShot.entries()].sort((a, b) => b[1].count - a[1].count);
const days = (from: Date) => Math.max(0, Math.floor((Date.now() - from.getTime()) / DAY_MS));
return {
client: {
count: withClient.length,
items: withClient.map((v) => ({
id: v.id,
shotCode: v.shot?.shotCode ?? "—",
label: `${days(v.sharedAt ?? v.createdAt)}d`,
href: `/review/${v.id}`,
})),
},
us: {
count: ours.length,
items: ours.slice(0, perSide).map(([shotId, entry]) => ({
id: shotId,
shotCode: entry.shotCode,
label: `${entry.count} note${entry.count === 1 ? "" : "s"}`,
href: `/shot-status`,
})),
},
};
}
export interface FarmStatus {
machines: Awaited<ReturnType<typeof listMachines>>;
online: number;