842a8fe036
Deploy / deploy (push) Successful in 3m34s
"Paste feedback" on each board card takes a client's message as it arrived
and splits it into rounds ("FEEDBACK 113 DUE Oct 7 12:00 PM") and per-shot
notes, tolerating WhatsApp copy prefixes, bold markers, wrapped lines,
several codes per note and codes without the show prefix.
A review step shows each note's shot, the version the client was sent,
the player frame and a "move task to Changes" tick (on by default).
Saving creates a FeedbackRound, a player comment per note tagged
source=WHATSAPP (shown as "Client feedback") and a board line under the
round's due time; ticking the line off resolves the comment.
Quoted frames are read as comp frames: shot frameStart or the house 1001,
plus the slate (recorded, else 1 for pipeline previews). Version.duration
is never stored on live, so the length-based slate inference can't be used.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
170 lines
6.1 KiB
TypeScript
170 lines
6.1 KiB
TypeScript
import { auth } from "@/auth";
|
|
import { db } from "@/lib/db";
|
|
import { redirect } from "next/navigation";
|
|
import { suggestShot } from "@/lib/board-parse";
|
|
import { BoardClient, type BoardProject } from "./BoardClient";
|
|
|
|
export const metadata = { title: "Board" };
|
|
|
|
// Done lines stay visible this long so "what did we get through" is answerable
|
|
const DONE_WINDOW_MS = 7 * 86400000;
|
|
|
|
export default async function BoardPage({
|
|
searchParams,
|
|
}: {
|
|
searchParams: Promise<{ who?: string }>;
|
|
}) {
|
|
const session = await auth();
|
|
if (!session?.user) redirect("/login");
|
|
if (session.user.role === "CLIENT") redirect("/dashboard");
|
|
|
|
const { who } = await searchParams;
|
|
const doneSince = new Date(Date.now() - DONE_WINDOW_MS);
|
|
|
|
const [projects, users] = await Promise.all([
|
|
db.project.findMany({
|
|
where: { status: { in: ["ACTIVE", "ON_HOLD"] } },
|
|
orderBy: [{ status: "asc" }, { name: "asc" }],
|
|
select: {
|
|
id: true,
|
|
name: true,
|
|
code: true,
|
|
status: true,
|
|
boardStatus: true,
|
|
boardStatusAt: true,
|
|
milestones: { orderBy: { date: "asc" } },
|
|
boardItems: {
|
|
where: { OR: [{ doneAt: null }, { doneAt: { gte: doneSince } }] },
|
|
orderBy: { sortOrder: "asc" },
|
|
include: {
|
|
assignee: { select: { id: true, name: true, image: true } },
|
|
round: { select: { id: true, title: true, dueAt: true } },
|
|
shot: {
|
|
select: {
|
|
id: true,
|
|
shotCode: true,
|
|
status: true,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
}),
|
|
db.user.findMany({
|
|
where: { isActive: true, role: { not: "CLIENT" } },
|
|
select: { id: true, name: true, email: true, image: true },
|
|
orderBy: { name: "asc" },
|
|
}),
|
|
]);
|
|
|
|
const projectIds = projects.map((p) => p.id);
|
|
|
|
// Latest version per linked shot — versions hang off the shot or its task
|
|
const linkedShotIds = [...new Set(projects.flatMap((p) => p.boardItems.map((i) => i.shotId).filter(Boolean) as string[]))];
|
|
const latestByShot = new Map<string, { id: string; versionNumber: number; approvalStatus: string; isClientVisible: boolean }>();
|
|
if (linkedShotIds.length) {
|
|
const versions = await db.version.findMany({
|
|
where: { OR: [{ shotId: { in: linkedShotIds } }, { task: { shotId: { in: linkedShotIds } } }] },
|
|
orderBy: { createdAt: "desc" },
|
|
select: {
|
|
id: true, versionNumber: true, approvalStatus: true, isClientVisible: true,
|
|
shotId: true, task: { select: { shotId: true } },
|
|
},
|
|
});
|
|
for (const v of versions) {
|
|
const sid = v.shotId ?? v.task?.shotId;
|
|
if (sid && !latestByShot.has(sid)) latestByShot.set(sid, v);
|
|
}
|
|
}
|
|
|
|
// Pasted-feedback lines link to the comment they were saved as
|
|
const commentIds = projects.flatMap((p) => p.boardItems.map((i) => i.commentId).filter(Boolean) as string[]);
|
|
const commentVersion = new Map(
|
|
(commentIds.length
|
|
? await db.comment.findMany({ where: { id: { in: commentIds } }, select: { id: true, versionId: true, frameNumber: true } })
|
|
: []
|
|
).map((c) => [c.id, c]),
|
|
);
|
|
const shotCounts = await db.shot.groupBy({
|
|
by: ["projectId", "status"],
|
|
where: { projectId: { in: projectIds } },
|
|
_count: { _all: true },
|
|
});
|
|
|
|
// Lines whose code did not match a shot when typed: the shot may exist now,
|
|
// or there may be an obvious near-miss (DH2_004_100 → DH2_VFX_004_100).
|
|
const flaggedProjects = projects.filter((p) => p.boardItems.some((i) => i.code && !i.shotId));
|
|
const shotsByProject = new Map(
|
|
await Promise.all(
|
|
flaggedProjects.map(async (p) => [
|
|
p.id,
|
|
await db.shot.findMany({
|
|
where: { projectId: p.id },
|
|
select: { id: true, shotCode: true, clientCode: true },
|
|
}),
|
|
] as const),
|
|
),
|
|
);
|
|
|
|
const data: BoardProject[] = projects.map((p) => {
|
|
const counts = shotCounts.filter((c) => c.projectId === p.id);
|
|
const shots = shotsByProject.get(p.id) ?? [];
|
|
return {
|
|
id: p.id,
|
|
name: p.name,
|
|
code: p.code,
|
|
status: p.status,
|
|
boardStatus: p.boardStatus,
|
|
boardStatusAt: p.boardStatusAt?.toISOString() ?? null,
|
|
shotTotal: counts.reduce((n, c) => n + c._count._all, 0),
|
|
shotComplete: counts.find((c) => c.status === "COMPLETE")?._count._all ?? 0,
|
|
milestones: p.milestones.map((m) => ({
|
|
id: m.id,
|
|
label: m.label,
|
|
date: m.date.toISOString(),
|
|
doneAt: m.doneAt?.toISOString() ?? null,
|
|
})),
|
|
items: p.boardItems.map((i) => {
|
|
const v = i.shot ? latestByShot.get(i.shot.id) : undefined;
|
|
const nowMatches = !i.shotId && i.code
|
|
? shots.find((s) => s.shotCode.toUpperCase() === i.code!.toUpperCase())
|
|
: undefined;
|
|
const suggestion = !i.shotId && i.code && !nowMatches ? suggestShot(i.code, shots) : null;
|
|
return {
|
|
id: i.id,
|
|
text: i.text,
|
|
code: i.code,
|
|
group: i.group,
|
|
waitingOn: i.waitingOn,
|
|
round: i.round ? { id: i.round.id, title: i.round.title, dueAt: i.round.dueAt?.toISOString() ?? null } : null,
|
|
comment: i.commentId && commentVersion.has(i.commentId)
|
|
? { versionId: commentVersion.get(i.commentId)!.versionId, frame: commentVersion.get(i.commentId)!.frameNumber }
|
|
: null,
|
|
doneAt: i.doneAt?.toISOString() ?? null,
|
|
assignee: i.assignee,
|
|
shot: i.shot
|
|
? {
|
|
id: i.shot.id,
|
|
shotCode: i.shot.shotCode,
|
|
status: i.shot.status,
|
|
latest: v
|
|
? {
|
|
id: v.id,
|
|
versionNumber: v.versionNumber,
|
|
approvalStatus: v.approvalStatus,
|
|
isClientVisible: v.isClientVisible,
|
|
}
|
|
: null,
|
|
}
|
|
: null,
|
|
// Shot created since the line was typed: offer to link it
|
|
relinkTo: nowMatches?.shotCode ?? suggestion?.shotCode ?? null,
|
|
relinkIsExact: !!nowMatches,
|
|
};
|
|
}),
|
|
};
|
|
});
|
|
|
|
return <BoardClient projects={data} users={users} who={who ?? null} />;
|
|
}
|