feat(dashboard): QC queue, deliveries and whose-court panels
Deploy / deploy (push) Successful in 6m51s
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:
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user