Files
vfxreview/app/(dashboard)/board/page.tsx
T
twotalesanimation 842a8fe036
Deploy / deploy (push) Successful in 3m34s
feat(board): paste client feedback from WhatsApp
"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>
2026-10-07 17:31:34 +02:00

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} />;
}