Files
vfxreview/app/(dashboard)/board/BoardClient.tsx
T
twotalesanimation 34af26dfbf 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 <noreply@anthropic.com>
2026-10-07 17:30:40 +02:00

608 lines
23 KiB
TypeScript

"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<string, { label: string; color: string }> = {
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<string, { label: string; color: string }> = {
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 (
<div className="p-4 md:p-8 max-w-5xl">
<div className="mb-6 flex flex-wrap items-end justify-between gap-4">
<div>
<h1 className="text-3xl font-bold text-white">Board</h1>
<p className="text-zinc-400 mt-1">{format(new Date(), "EEEE, d MMMM yyyy")}</p>
</div>
<div className="flex flex-wrap gap-1 rounded-lg border border-zinc-800 bg-zinc-900 p-1">
{filters.map((f) => (
<button
key={f.key ?? "all"}
type="button"
onClick={() => router.push(f.key ? `/board?who=${f.key}` : "/board")}
className={cn(
"rounded-md px-3 py-1 text-xs font-medium transition-colors",
who === f.key ? "bg-amber-500/10 text-amber-400" : "text-zinc-400 hover:text-white",
)}
>
{f.label}
</button>
))}
</div>
</div>
{projects.length === 0 && (
<p className="text-sm text-zinc-500">No active or on-hold projects.</p>
)}
<div className="space-y-6">
{projects.map((p) => (
<ProjectSection key={p.id} project={p} users={users} who={who} />
))}
</div>
</div>
);
}
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 (
<section className="rounded-xl border border-zinc-800 bg-zinc-900">
<header className="flex flex-wrap items-center gap-x-3 gap-y-1 border-b border-zinc-800 px-4 py-3">
<Link href={`/projects/${project.id}`} className="text-lg font-semibold text-white hover:text-amber-400">
{project.name}
</Link>
<span className="text-xs text-zinc-500">{project.code}</span>
{project.status === "ON_HOLD" && (
<span className="rounded border border-yellow-500/20 bg-yellow-500/10 px-1.5 py-0.5 text-[10px] font-medium text-yellow-400">
On hold
</span>
)}
{project.shotTotal > 0 && (
<div className="ml-auto flex items-center gap-2 text-xs text-zinc-400">
<div className="h-1.5 w-24 overflow-hidden rounded-full bg-zinc-800">
<div className="h-full bg-green-500/70" style={{ width: `${pct}%` }} />
</div>
{project.shotComplete}/{project.shotTotal} shots complete
</div>
)}
</header>
<div className="space-y-4 px-4 py-3">
<StatusLine project={project} />
<Milestones project={project} />
{flagged.length > 0 && (
<ItemBlock title="Flags" icon={<Flag className="h-3.5 w-3.5 text-red-400" />}>
{flagged.map((i) => <ItemRow key={i.id} item={i} users={users} projectId={project.id} />)}
</ItemBlock>
)}
{waiting.length > 0 && (
<ItemBlock title="Waiting on" icon={<Clock className="h-3.5 w-3.5 text-amber-400" />}>
{waiting.map((i) => <ItemRow key={i.id} item={i} users={users} projectId={project.id} />)}
</ItemBlock>
)}
{groups.map((g) => (
<ItemBlock key={g.name || "_"} title={g.name}>
{g.items.map((i) => <ItemRow key={i.id} item={i} users={users} projectId={project.id} />)}
</ItemBlock>
))}
<QuickAdd projectId={project.id} />
{done.length > 0 && (
<div>
<button
type="button"
onClick={() => setShowDone(!showDone)}
className="flex items-center gap-1 text-xs text-zinc-500 hover:text-zinc-300"
>
{showDone ? <ChevronDown className="h-3.5 w-3.5" /> : <ChevronRight className="h-3.5 w-3.5" />}
Done this week ({done.length})
</button>
{showDone && (
<div className="mt-1">
{done.map((i) => <ItemRow key={i.id} item={i} users={users} projectId={project.id} />)}
</div>
)}
</div>
)}
</div>
</section>
);
}
function ItemBlock({ title, icon, children }: { title: string; icon?: React.ReactNode; children: React.ReactNode }) {
return (
<div>
{title && (
<div className="mb-1 flex items-center gap-1.5 text-xs uppercase tracking-wider text-zinc-500">
{icon}
{title}
</div>
)}
<div className="divide-y divide-zinc-800/60">{children}</div>
</div>
);
}
function useAction() {
const router = useRouter();
const { toast } = useToast();
const [isPending, startTransition] = useTransition();
const run = (fn: () => Promise<unknown>, 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 (
<div className="space-y-1.5">
<textarea
autoFocus
value={value}
onChange={(e) => setValue(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); save(); }
if (e.key === "Escape") { setValue(project.boardStatus ?? ""); setEditing(false); }
}}
rows={2}
placeholder="Where things stand — e.g. Awaiting feedback on pick-ups"
className="w-full resize-y rounded-lg border border-zinc-700 bg-zinc-950 px-3 py-2 text-sm text-white placeholder:text-zinc-600 focus:border-amber-500/50 focus:outline-none"
/>
<div className="flex gap-2">
<Button size="sm" onClick={save} disabled={isPending}>Save</Button>
<Button size="sm" variant="ghost" onClick={() => { setValue(project.boardStatus ?? ""); setEditing(false); }}>
Cancel
</Button>
</div>
</div>
);
}
return (
<button
type="button"
onClick={() => setEditing(true)}
className="group block w-full text-left"
>
{project.boardStatus ? (
<p className="whitespace-pre-wrap text-sm text-zinc-200">
{project.boardStatus}
{project.boardStatusAt && (
<span className="ml-2 text-xs text-zinc-600">
{formatDistanceToNowStrict(new Date(project.boardStatusAt), { addSuffix: true })}
</span>
)}
<Pencil className="ml-2 inline h-3 w-3 text-zinc-600 opacity-0 group-hover:opacity-100" />
</p>
) : (
<p className="text-sm text-zinc-600 group-hover:text-zinc-400">+ Add a status line</p>
)}
</button>
);
}
// ── Milestones ───────────────────────────────────────────────────────────────
function Milestones({ project }: { project: BoardProject }) {
const [adding, setAdding] = useState(false);
const [label, setLabel] = useState("");
const [date, setDate] = useState("");
const { run, isPending } = useAction();
const add = () => {
if (!label.trim() || !date) return;
// Noon keeps the calendar day stable across the studio/server time zones
run(() => addMilestone({ projectId: project.id, label, date: `${date}T12:00:00` }), () => {
setLabel("");
setDate("");
setAdding(false);
});
};
return (
<div className="flex flex-wrap items-center gap-1.5">
{project.milestones.map((m) => <MilestoneChip key={m.id} m={m} />)}
{adding ? (
<div className="flex flex-wrap items-center gap-1.5">
<input
autoFocus
value={label}
onChange={(e) => setLabel(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && add()}
placeholder="e.g. Final delivery"
className="h-7 w-40 rounded-md border border-zinc-700 bg-zinc-950 px-2 text-xs text-white placeholder:text-zinc-600 focus:outline-none"
/>
<input
type="date"
value={date}
onChange={(e) => setDate(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && add()}
className="h-7 rounded-md border border-zinc-700 bg-zinc-950 px-2 text-xs text-white [color-scheme:dark] focus:outline-none"
/>
<Button size="sm" className="h-7" onClick={add} disabled={isPending || !label.trim() || !date}>Add</Button>
<Button size="sm" variant="ghost" className="h-7" onClick={() => setAdding(false)}>Cancel</Button>
</div>
) : (
<button
type="button"
onClick={() => setAdding(true)}
className="flex h-7 items-center gap-1 rounded-md px-2 text-xs text-zinc-600 hover:bg-zinc-800 hover:text-zinc-300"
>
<Plus className="h-3 w-3" /> Date
</button>
)}
</div>
);
}
function MilestoneChip({ m }: { m: BoardProject["milestones"][number] }) {
const { run } = useAction();
const days = differenceInCalendarDays(new Date(m.date), new Date());
const done = !!m.doneAt;
const late = !done && days < 0;
const soon = !done && days >= 0 && days <= 7;
const when = done ? "done" : days === 0 ? "today" : days === 1 ? "tomorrow" : days > 0 ? `in ${days}d` : `${-days}d late`;
return (
<div
className={cn(
"group flex h-7 items-center gap-1.5 rounded-md border px-2 text-xs",
done ? "border-zinc-800 text-zinc-600" :
late ? "border-red-500/30 bg-red-500/10 text-red-300" :
soon ? "border-amber-500/30 bg-amber-500/10 text-amber-300" :
"border-zinc-700 text-zinc-300",
)}
>
<button
type="button"
title={done ? "Mark not done" : "Mark done"}
onClick={() => run(() => setMilestoneDone(m.id, !done))}
className={cn(
"flex h-3.5 w-3.5 items-center justify-center rounded-sm border",
done ? "border-green-500/50 bg-green-500/20 text-green-400" : "border-current opacity-60 hover:opacity-100",
)}
>
{done && <Check className="h-2.5 w-2.5" />}
</button>
<span className={cn(done && "line-through")}>{m.label}</span>
<span className="opacity-70">{format(new Date(m.date), "d MMM")}</span>
<span className="font-medium">· {when}</span>
<button
type="button"
title="Remove"
onClick={() => { if (confirm(`Remove "${m.label}"?`)) run(() => deleteMilestone(m.id)); }}
className="hidden text-zinc-500 hover:text-red-400 group-hover:block"
>
<X className="h-3 w-3" />
</button>
</div>
);
}
// ── Items ────────────────────────────────────────────────────────────────────
function ItemRow({ item, users, projectId }: { item: BoardItem; users: User[]; projectId: string }) {
const [editing, setEditing] = useState(false);
const { run, isPending } = useAction();
const done = !!item.doneAt;
const flagged = !!item.code && !item.shot;
if (editing) return <ItemEditor item={item} users={users} onClose={() => setEditing(false)} />;
const st = item.shot ? SHOT_STATUS[item.shot.status] : null;
const v = item.shot?.latest;
return (
<div className={cn("group flex items-start gap-2.5 py-1.5", isPending && "opacity-50")}>
<button
type="button"
title={done ? "Mark not done" : "Mark done"}
onClick={() => run(() => setBoardItemDone(item.id, !done))}
className={cn(
"mt-0.5 flex h-4 w-4 shrink-0 items-center justify-center rounded border",
done ? "border-green-500/50 bg-green-500/20 text-green-400" : "border-zinc-600 hover:border-zinc-400",
)}
>
{done && <Check className="h-3 w-3" />}
</button>
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-x-2 gap-y-1 text-sm">
{item.shot && (
<Link
href={`/projects/${projectId}/shots/${item.shot.id}`}
className="font-mono text-xs text-amber-400 hover:underline"
>
{item.shot.shotCode}
</Link>
)}
{flagged && (
<span className="flex items-center gap-1 font-mono text-xs text-red-400" title="Not a shot in this project">
<AlertTriangle className="h-3 w-3" />
{item.code}
</span>
)}
{st && !done && (
<span className={cn("rounded border px-1.5 py-px text-[10px] font-medium", st.color)}>{st.label}</span>
)}
{v && !done && (
<Link href={`/review/${v.id}`} className="text-[11px] text-zinc-500 hover:text-zinc-300">
v{String(v.versionNumber).padStart(3, "0")}{" "}
<span className={APPROVAL[v.approvalStatus]?.color}>{APPROVAL[v.approvalStatus]?.label}</span>
{v.isClientVisible && <span className="text-indigo-400"> · sent</span>}
</Link>
)}
<span className={cn("text-zinc-200", done && "text-zinc-600 line-through")}>{item.text}</span>
</div>
{(item.waitingOn || (flagged && !done)) && (
<div className="mt-1 flex flex-wrap items-center gap-2 text-xs">
{item.waitingOn && (
<span className="flex items-center gap-1 rounded bg-amber-500/10 px-1.5 py-0.5 text-amber-300">
<Clock className="h-3 w-3" /> Waiting on {item.waitingOn}
</span>
)}
{flagged && !done && (
item.relinkTo ? (
<button
type="button"
onClick={() => run(() => updateBoardItem(item.id, { code: item.relinkTo }))}
className="flex items-center gap-1 text-zinc-400 hover:text-amber-400"
>
<Link2 className="h-3 w-3" />
{item.relinkIsExact ? `${item.relinkTo} exists now. Link it` : `Did you mean ${item.relinkTo}?`}
</button>
) : (
<span className="text-zinc-500">Not a shot in this project</span>
)
)}
</div>
)}
</div>
{item.assignee && (
<span className="flex shrink-0 items-center gap-1.5 text-xs text-zinc-400" title={item.assignee.name ?? ""}>
<Avatar className="h-5 w-5">
{item.assignee.image && <AvatarImage src={item.assignee.image} />}
<AvatarFallback className="text-[9px]">{getInitials(item.assignee.name)}</AvatarFallback>
</Avatar>
<span className="hidden sm:inline">{item.assignee.name?.split(" ")[0]}</span>
</span>
)}
<div className="flex shrink-0 gap-0.5 opacity-100 md:opacity-0 md:group-hover:opacity-100">
<button type="button" title="Edit" onClick={() => setEditing(true)} className="rounded p-1 text-zinc-500 hover:bg-zinc-800 hover:text-white">
<Pencil className="h-3.5 w-3.5" />
</button>
<button
type="button"
title="Delete"
onClick={() => { if (confirm("Delete this line?")) run(() => deleteBoardItem(item.id)); }}
className="rounded p-1 text-zinc-500 hover:bg-zinc-800 hover:text-red-400"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
</div>
</div>
);
}
function ItemEditor({ item, users, onClose }: { item: BoardItem; users: User[]; onClose: () => void }) {
const [code, setCode] = useState(item.shot?.shotCode ?? item.code ?? "");
const [text, setText] = useState(item.text);
const [assigneeId, setAssigneeId] = useState(item.assignee?.id ?? "");
const [waitingOn, setWaitingOn] = useState(item.waitingOn ?? "");
const [group, setGroup] = useState(item.group);
const { run, isPending } = useAction();
const save = () =>
run(
() => updateBoardItem(item.id, { code, text, assigneeId: assigneeId || null, waitingOn, group }),
onClose,
);
const field = "h-8 rounded-md border border-zinc-700 bg-zinc-950 px-2 text-xs text-white placeholder:text-zinc-600 focus:border-amber-500/50 focus:outline-none";
return (
<div
className="my-1 grid grid-cols-1 gap-2 rounded-lg border border-zinc-700 bg-zinc-950/50 p-2 sm:grid-cols-[10rem_1fr]"
onKeyDown={(e) => {
if (e.key === "Enter") { e.preventDefault(); save(); }
if (e.key === "Escape") onClose();
}}
>
<input value={code} onChange={(e) => setCode(e.target.value)} placeholder="Shot code" className={cn(field, "font-mono")} />
<input autoFocus value={text} onChange={(e) => setText(e.target.value)} placeholder="Note" className={field} />
<select value={assigneeId} onChange={(e) => setAssigneeId(e.target.value)} className={field}>
<option value="">Unassigned</option>
{users.map((u) => <option key={u.id} value={u.id}>{u.name ?? u.email}</option>)}
</select>
<input value={waitingOn} onChange={(e) => setWaitingOn(e.target.value)} placeholder="Waiting on (blank = not blocked)" className={field} />
<input value={group} onChange={(e) => setGroup(e.target.value)} placeholder="Heading (optional)" className={field} />
<div className="flex justify-end gap-2">
<Button size="sm" variant="ghost" onClick={onClose}>Cancel</Button>
<Button size="sm" onClick={save} disabled={isPending}>Save</Button>
</div>
</div>
);
}
function QuickAdd({ projectId }: { projectId: string }) {
const [value, setValue] = useState("");
const ref = useRef<HTMLTextAreaElement>(null);
const { toast } = useToast();
const { run, isPending } = useAction();
// Grow with pasted blocks
useEffect(() => {
const el = ref.current;
if (!el) return;
el.style.height = "auto";
el.style.height = `${Math.min(el.scrollHeight, 320)}px`;
}, [value]);
const submit = () => {
if (!value.trim()) return;
run(async () => {
const res = await addBoardItems({ projectId, input: value });
if (res.unmatched.length) {
toast({
title: `${res.unmatched.length} code${res.unmatched.length === 1 ? "" : "s"} not in this project`,
description: `${res.unmatched.join(", ")}: flagged on the board.`,
});
}
}, () => setValue(""));
};
return (
<div>
<textarea
ref={ref}
value={value}
onChange={(e) => setValue(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); submit(); }
}}
rows={1}
disabled={isPending}
placeholder="Add a line, e.g. SHOT_CODE finalise (Chris) wait: client GFX, or paste a list"
className="w-full resize-none rounded-lg border border-dashed border-zinc-700 bg-transparent px-3 py-2 text-sm text-white placeholder:text-zinc-600 focus:border-solid focus:border-amber-500/50 focus:outline-none"
/>
</div>
);
}