diff --git a/app/(dashboard)/dashboard/page.tsx b/app/(dashboard)/dashboard/page.tsx
index 67d61b6..2b17342 100644
--- a/app/(dashboard)/dashboard/page.tsx
+++ b/app/(dashboard)/dashboard/page.tsx
@@ -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() {
- Blocked — needs a human
+ {isArtistRole ? "Your blocked shots" : "Blocked — needs a human"}
@@ -176,6 +198,34 @@ export default async function DashboardPage() {
)}
+ {!isClient && !isArtistRole && (
+
+
+
+ QC queue
+
+
+
+
+
+
+
+ Deliveries
+
+
+
+ {court && (
+
+
+ Whose court
+
+
+
+ )}
+
+
+ )}
+
{!isClient && (
+
+
+ {entries.length === 0
+ ? "QC queue empty"
+ : `${entries.length} waiting on QC`}
+ {oldest && (
+
+ oldest {formatDistanceToNowStrict(oldest.since)}
+
+ )}
+
+
+ Open QC →
+
+
+
+ {entries.length === 0 ? (
+
+ Nothing is waiting on a QC pass.
+
+ ) : (
+
+
+
+
+ Shot
+ Version
+ Waiting
+ Frames
+ Submitted by
+
+
+
+ {entries.map((e) => {
+ const stale = Date.now() - e.since.getTime() > staleHours * 3600_000;
+ return (
+
+
+
+ {e.shotCode}
+
+
+ {e.projectCode}
+ {e.episode ? ` · ep ${e.episode}` : ""}
+
+
+
+ {e.versionString}
+
+
+ {formatDistanceToNowStrict(e.since)}
+
+
+ {e.frameStart}–{e.frameEnd}
+
+
+ {e.submittedBy ?? "—"}
+
+
+ );
+ })}
+
+
+
+ )}
+
+ );
+}
diff --git a/components/dashboard/RecentDeliveries.tsx b/components/dashboard/RecentDeliveries.tsx
new file mode 100644
index 0000000..ee8284b
--- /dev/null
+++ b/components/dashboard/RecentDeliveries.tsx
@@ -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 (
+
+
+
+ Recent packages
+ {problems > 0 && (
+
+ {problems} need{problems === 1 ? "s" : ""} attention
+
+ )}
+
+
+ All deliveries →
+
+
+
+ {deliveries.length === 0 ? (
+
No packages built yet.
+ ) : (
+ deliveries.map((d) => (
+
+
+
+ #{d.packageNumber} {" "}
+
+ {d.projectCode}
+ {d.episode ? ` · ${d.episode}` : ""}
+ {d.kind === "MOV_REVIEW" ? " · review" : ""}
+
+
+
+ {d.status === "FAILED" && d.errorMessage
+ ? d.errorMessage
+ : `${d.itemCount} shot${d.itemCount === 1 ? "" : "s"} · ${d.atLabel} ${formatDistanceToNowStrict(d.at)} ago`}
+
+
+
+ {d.stuck ? "STUCK" : d.status}
+
+
+ ))
+ )}
+
+ );
+}
diff --git a/components/dashboard/WhoseCourt.tsx b/components/dashboard/WhoseCourt.tsx
new file mode 100644
index 0000000..3f7625e
--- /dev/null
+++ b/components/dashboard/WhoseCourt.tsx
@@ -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 (
+
+
+
+
+ );
+}
+
+function Side({
+ title,
+ subtitle,
+ side,
+ empty,
+ className,
+}: {
+ title: string;
+ subtitle: string;
+ side: WhoseCourtData["client"];
+ empty: string;
+ className?: string;
+}) {
+ return (
+
+
{title}
+
{subtitle}
+
+ {side.count}
+
+ {side.items.length === 0 ? (
+
{empty}
+ ) : (
+
+ {side.items.map((item) => (
+
+
+ {item.shotCode}
+
+ {item.label}
+
+ ))}
+
+ )}
+
+ );
+}
diff --git a/lib/dashboard/ops.ts b/lib/dashboard/ops.ts
index fc49f47..bdccce2 100644
--- a/lib/dashboard/ops.ts
+++ b/lib/dashboard/ops.ts
@@ -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 {
+export async function getBlockedExports(
+ opts: { artistId?: string; limit?: number } = {}
+): Promise {
+ 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 {
// 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 {
+export async function getOpsCounts(artistId?: string): Promise {
// 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 {
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 {
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 {
+ 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 {
+ 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 {
+ 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 {
+ 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();
+ 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>;
online: number;