From 34af26dfbf9edca6e1fe3d61ba777b80dc00f7ab Mon Sep 17 00:00:00 2001 From: twotalesanimation <80506065+twotalesanimation@users.noreply.github.com> Date: Wed, 7 Oct 2026 17:30:40 +0200 Subject: [PATCH] feat(board): daily board linked to shots A /board page that replaces the shared doc used as the day-to-day tracker. One card per active/on-hold project with a status line, dated milestones and to-do lines typed or pasted in the doc's own format: DH2_VFX_027_090 finalise (CHRIS) DH2_VFX_005_010 gfx update @michelle wait: client GFX ADH_GFX_005_020 Shot codes link to the shot (live status + latest version), (NAME)/@name assigns, wait: marks a blocker, headings group lines. Codes that match no shot are flagged, with a relink when there is an obvious near miss. Board lines are their own model, not Tasks: tasks drive shot status and notifications, and a note like "finalise" must not touch either. Co-Authored-By: Claude Opus 5.5 --- actions/board.ts | 183 ++++++ app/(dashboard)/board/BoardClient.tsx | 607 ++++++++++++++++++ app/(dashboard)/board/page.tsx | 156 +++++ components/layout/Sidebar.tsx | 2 + lib/board-parse.test.ts | 107 +++ lib/board-parse.ts | 171 +++++ .../20261007000000_daily_board/migration.sql | 44 ++ prisma/schema.prisma | 61 ++ 8 files changed, 1331 insertions(+) create mode 100644 actions/board.ts create mode 100644 app/(dashboard)/board/BoardClient.tsx create mode 100644 app/(dashboard)/board/page.tsx create mode 100644 lib/board-parse.test.ts create mode 100644 lib/board-parse.ts create mode 100644 prisma/migrations/20261007000000_daily_board/migration.sql diff --git a/actions/board.ts b/actions/board.ts new file mode 100644 index 0000000..38ded3a --- /dev/null +++ b/actions/board.ts @@ -0,0 +1,183 @@ +"use server"; + +import { auth } from "@/auth"; +import { db } from "@/lib/db"; +import { revalidatePath } from "next/cache"; +import { z } from "zod"; +import { findShot, parseBoardText } from "@/lib/board-parse"; + +// The board is studio-internal. Anyone on staff can add and tick off lines; +// it is a shared to-do list, not an approval surface. +async function requireStaff() { + const session = await auth(); + if (!session?.user) throw new Error("Unauthorized"); + if (session.user.role === "CLIENT") throw new Error("Insufficient permissions"); + return session.user; +} + +async function boardContext(projectId: string) { + const [shots, users] = await Promise.all([ + db.shot.findMany({ + where: { projectId }, + select: { id: true, shotCode: true, clientCode: true }, + }), + db.user.findMany({ + where: { isActive: true, role: { not: "CLIENT" } }, + select: { id: true, name: true, email: true }, + }), + ]); + return { shots, users }; +} + +const addSchema = z.object({ + projectId: z.string().cuid(), + input: z.string().min(1).max(20000), + group: z.string().max(100).optional(), +}); + +/** Adds one line, or a pasted block of lines, to a project's board. */ +export async function addBoardItems(data: z.infer) { + const user = await requireStaff(); + const parsed = addSchema.parse(data); + + const items = parseBoardText(parsed.input, { + ...(await boardContext(parsed.projectId)), + group: parsed.group, + }); + if (items.length === 0) return { success: true, added: 0, unmatched: [] as string[] }; + + const last = await db.boardItem.findFirst({ + where: { projectId: parsed.projectId }, + orderBy: { sortOrder: "desc" }, + select: { sortOrder: true }, + }); + const base = (last?.sortOrder ?? -1) + 1; + + await db.boardItem.createMany({ + data: items.map((item, i) => ({ + projectId: parsed.projectId, + shotId: item.shotId, + code: item.code, + text: item.text, + group: item.group, + assigneeId: item.assigneeId, + waitingOn: item.waitingOn, + sortOrder: base + i, + createdById: user.id, + })), + }); + + revalidatePath("/board"); + return { + success: true, + added: items.length, + unmatched: items.filter((i) => i.code && !i.shotId).map((i) => i.code as string), + }; +} + +const updateSchema = z.object({ + text: z.string().max(5000).optional(), + code: z.string().max(100).nullable().optional(), + group: z.string().max(100).optional(), + assigneeId: z.string().cuid().nullable().optional(), + waitingOn: z.string().max(500).nullable().optional(), +}); + +export async function updateBoardItem(id: string, data: z.infer) { + await requireStaff(); + const parsed = updateSchema.parse(data); + const item = await db.boardItem.findUnique({ where: { id }, select: { projectId: true } }); + if (!item) throw new Error("Board item not found"); + + // Changing the code re-links the line; a code that matches nothing is kept + // as a flag, exactly as when it was first typed. + let link: { code: string | null; shotId: string | null } | undefined; + if (parsed.code !== undefined) { + const code = parsed.code?.trim() || null; + const shot = code ? findShot(code, (await boardContext(item.projectId)).shots) : null; + link = { code: shot?.shotCode ?? code?.toUpperCase() ?? null, shotId: shot?.id ?? null }; + } + + await db.boardItem.update({ + where: { id }, + data: { + ...(parsed.text !== undefined && { text: parsed.text.trim() }), + ...(parsed.group !== undefined && { group: parsed.group.trim() }), + ...(parsed.assigneeId !== undefined && { assigneeId: parsed.assigneeId }), + ...(parsed.waitingOn !== undefined && { waitingOn: parsed.waitingOn?.trim() || null }), + ...link, + }, + }); + + revalidatePath("/board"); + return { success: true }; +} + +export async function setBoardItemDone(id: string, done: boolean) { + const user = await requireStaff(); + await db.boardItem.update({ + where: { id }, + data: done ? { doneAt: new Date(), doneById: user.id } : { doneAt: null, doneById: null }, + }); + revalidatePath("/board"); + return { success: true }; +} + +export async function deleteBoardItem(id: string) { + await requireStaff(); + await db.boardItem.delete({ where: { id } }); + revalidatePath("/board"); + return { success: true }; +} + +const statusSchema = z.object({ + projectId: z.string().cuid(), + status: z.string().max(2000), +}); + +export async function setProjectBoardStatus(data: z.infer) { + await requireStaff(); + const parsed = statusSchema.parse(data); + const status = parsed.status.trim() || null; + await db.project.update({ + where: { id: parsed.projectId }, + data: { boardStatus: status, boardStatusAt: status ? new Date() : null }, + }); + revalidatePath("/board"); + return { success: true }; +} + +const milestoneSchema = z.object({ + projectId: z.string().cuid(), + label: z.string().min(1).max(200), + date: z.string().min(1), +}); + +export async function addMilestone(data: z.infer) { + await requireStaff(); + const parsed = milestoneSchema.parse(data); + const date = new Date(parsed.date); + if (isNaN(date.getTime())) throw new Error("Invalid date"); + await db.projectMilestone.create({ + data: { projectId: parsed.projectId, label: parsed.label.trim(), date }, + }); + revalidatePath("/board"); + return { success: true }; +} + +export async function setMilestoneDone(id: string, done: boolean) { + await requireStaff(); + await db.projectMilestone.update({ + where: { id }, + data: { doneAt: done ? new Date() : null }, + }); + revalidatePath("/board"); + return { success: true }; +} + +export async function deleteMilestone(id: string) { + await requireStaff(); + await db.projectMilestone.delete({ where: { id } }); + revalidatePath("/board"); + return { success: true }; +} diff --git a/app/(dashboard)/board/BoardClient.tsx b/app/(dashboard)/board/BoardClient.tsx new file mode 100644 index 0000000..4ac6345 --- /dev/null +++ b/app/(dashboard)/board/BoardClient.tsx @@ -0,0 +1,607 @@ +"use client"; + +import { useEffect, useRef, useState, useTransition } from "react"; +import { useRouter } from "next/navigation"; +import Link from "next/link"; +import { differenceInCalendarDays, format, formatDistanceToNowStrict } from "date-fns"; +import { + AlertTriangle, + Check, + ChevronDown, + ChevronRight, + Clock, + Flag, + Link2, + Pencil, + Plus, + Trash2, + X, +} from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; +import { useToast } from "@/components/ui/use-toast"; +import { cn, getInitials } from "@/lib/utils"; +import { + addBoardItems, + addMilestone, + deleteBoardItem, + deleteMilestone, + setBoardItemDone, + setMilestoneDone, + setProjectBoardStatus, + updateBoardItem, +} from "@/actions/board"; + +type User = { id: string; name: string | null; email: string; image: string | null }; + +type BoardItem = { + id: string; + text: string; + code: string | null; + group: string; + waitingOn: string | null; + doneAt: string | null; + assignee: { id: string; name: string | null; image: string | null } | null; + shot: { + id: string; + shotCode: string; + status: string; + latest: { id: string; versionNumber: number; approvalStatus: string; isClientVisible: boolean } | null; + } | null; + relinkTo: string | null; + relinkIsExact: boolean; +}; + +export type BoardProject = { + id: string; + name: string; + code: string; + status: string; + boardStatus: string | null; + boardStatusAt: string | null; + shotTotal: number; + shotComplete: number; + milestones: { id: string; label: string; date: string; doneAt: string | null }[]; + items: BoardItem[]; +}; + +const SHOT_STATUS: Record = { + WAITING: { label: "Waiting", color: "bg-zinc-500/10 text-zinc-400 border-zinc-500/20" }, + IN_PROGRESS: { label: "In Progress", color: "bg-blue-500/10 text-blue-400 border-blue-500/20" }, + INTERNAL_REVIEW: { label: "Internal Review", color: "bg-purple-500/10 text-purple-400 border-purple-500/20" }, + READY_FOR_CLIENT: { label: "Ready for Client", color: "bg-cyan-500/10 text-cyan-400 border-cyan-500/20" }, + CLIENT_REVIEW: { label: "Client Review", color: "bg-indigo-500/10 text-indigo-400 border-indigo-500/20" }, + REVISIONS: { label: "Revisions", color: "bg-orange-500/10 text-orange-400 border-orange-500/20" }, + COMPLETE: { label: "Complete", color: "bg-green-500/10 text-green-400 border-green-500/20" }, +}; + +const APPROVAL: Record = { + PENDING_REVIEW: { label: "pending", color: "text-zinc-500" }, + APPROVED: { label: "approved", color: "text-green-400" }, + REJECTED: { label: "rejected", color: "text-red-400" }, + NEEDS_CHANGES: { label: "changes", color: "text-orange-400" }, +}; + +export function BoardClient({ + projects, + users, + who, +}: { + projects: BoardProject[]; + users: User[]; + who: string | null; +}) { + const router = useRouter(); + + const filters = [ + { key: null, label: "Everyone" }, + ...users.map((u) => ({ key: u.id, label: u.name?.split(" ")[0] ?? u.email })), + { key: "none", label: "Unassigned" }, + ]; + + return ( +
+
+
+

Board

+

{format(new Date(), "EEEE, d MMMM yyyy")}

+
+
+ {filters.map((f) => ( + + ))} +
+
+ + {projects.length === 0 && ( +

No active or on-hold projects.

+ )} + +
+ {projects.map((p) => ( + + ))} +
+
+ ); +} + +function ProjectSection({ project, users, who }: { project: BoardProject; users: User[]; who: string | null }) { + const [showDone, setShowDone] = useState(false); + + const visible = project.items.filter((i) => + who === null ? true : who === "none" ? !i.assignee : i.assignee?.id === who, + ); + const open = visible.filter((i) => !i.doneAt); + const done = visible.filter((i) => i.doneAt); + const flagged = open.filter((i) => i.code && !i.shot); + const waiting = open.filter((i) => i.waitingOn && !(i.code && !i.shot)); + const rest = open.filter((i) => !flagged.includes(i) && !waiting.includes(i)); + + // Groups keep the order they were first typed in + const groups: { name: string; items: BoardItem[] }[] = []; + for (const item of rest) { + let g = groups.find((x) => x.name === item.group); + if (!g) groups.push((g = { name: item.group, items: [] })); + g.items.push(item); + } + groups.sort((a, b) => (a.name === "" ? -1 : b.name === "" ? 1 : 0)); + + const pct = project.shotTotal ? Math.round((project.shotComplete / project.shotTotal) * 100) : 0; + + return ( +
+
+ + {project.name} + + {project.code} + {project.status === "ON_HOLD" && ( + + On hold + + )} + {project.shotTotal > 0 && ( +
+
+
+
+ {project.shotComplete}/{project.shotTotal} shots complete +
+ )} +
+ +
+ + + + {flagged.length > 0 && ( + }> + {flagged.map((i) => )} + + )} + {waiting.length > 0 && ( + }> + {waiting.map((i) => )} + + )} + {groups.map((g) => ( + + {g.items.map((i) => )} + + ))} + + + + {done.length > 0 && ( +
+ + {showDone && ( +
+ {done.map((i) => )} +
+ )} +
+ )} +
+
+ ); +} + +function ItemBlock({ title, icon, children }: { title: string; icon?: React.ReactNode; children: React.ReactNode }) { + return ( +
+ {title && ( +
+ {icon} + {title} +
+ )} +
{children}
+
+ ); +} + +function useAction() { + const router = useRouter(); + const { toast } = useToast(); + const [isPending, startTransition] = useTransition(); + const run = (fn: () => Promise, after?: () => void) => + startTransition(async () => { + try { + await fn(); + after?.(); + router.refresh(); + } catch (e) { + toast({ title: "Couldn't save", description: (e as Error).message, variant: "destructive" }); + } + }); + return { run, isPending }; +} + +// ── Status line ────────────────────────────────────────────────────────────── + +function StatusLine({ project }: { project: BoardProject }) { + const [editing, setEditing] = useState(false); + const [value, setValue] = useState(project.boardStatus ?? ""); + const { run, isPending } = useAction(); + + const save = () => + run(() => setProjectBoardStatus({ projectId: project.id, status: value }), () => setEditing(false)); + + if (editing) { + return ( +
+