f30acef101
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>
287 lines
9.7 KiB
TypeScript
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>
|
|
);
|
|
}
|