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>
This commit is contained in:
twotalesanimation
2026-10-07 17:30:40 +02:00
parent 27d9618995
commit 34af26dfbf
8 changed files with 1331 additions and 0 deletions
+183
View File
@@ -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<typeof addSchema>) {
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<typeof updateSchema>) {
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<typeof statusSchema>) {
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<typeof milestoneSchema>) {
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 };
}
+607
View File
@@ -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<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>
);
}
+156
View File
@@ -0,0 +1,156 @@
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 } },
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);
}
}
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,
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} />;
}
+2
View File
@@ -15,6 +15,7 @@ import {
ChevronLeft,
ChevronRight,
ListTodo,
NotebookPen,
CalendarRange,
BarChart2,
ListVideo,
@@ -32,6 +33,7 @@ import { useMobileNav } from '@/components/layout/MobileNav';
const navItems = [
{ href: '/dashboard', label: 'Dashboard', icon: LayoutDashboard },
{ href: '/projects', label: 'Projects', icon: FolderOpen },
{ href: '/board', label: 'Board', icon: NotebookPen, hideForClient: true },
{ href: '/shot-status', label: 'Shot Status', icon: BarChart2, hideForClient: true },
{ href: '/playlist', label: 'Playlist', icon: ListVideo, hideForClient: true },
{ href: '/tasks', label: 'My Tasks', icon: ListTodo, hideForClient: true },
+107
View File
@@ -0,0 +1,107 @@
import { describe, it, expect } from "vitest";
import { parseBoardLine, parseBoardText, suggestShot } from "./board-parse";
const shots = [
{ id: "s1", shotCode: "DH2_VFX_027_090" },
{ id: "s2", shotCode: "DH2_VFX_005_010" },
{ id: "s3", shotCode: "DH2_VFX_004_100" },
{ id: "s4", shotCode: "UNG_117_023_010", clientCode: "EP17_VFX_003" },
];
const users = [
{ id: "u1", name: "Chris Pinto", email: "chris@example.com" },
{ id: "u2", name: "Michelle Pinto", email: "michelle@example.com" },
];
const ctx = { shots, users };
describe("parseBoardLine", () => {
it("links the shot and assigns from (NAME)", () => {
expect(parseBoardLine("DH2_VFX_027_090 finalise (CHRIS)", ctx)).toEqual({
text: "finalise",
code: "DH2_VFX_027_090",
shotId: "s1",
assigneeId: "u1",
waitingOn: null,
group: "",
});
});
it("matches codes case-insensitively and by client alias", () => {
expect(parseBoardLine("dh2_vfx_027_090 x", ctx).shotId).toBe("s1");
const alias = parseBoardLine("EP17_VFX_003 warp fix", ctx);
expect(alias.shotId).toBe("s4");
expect(alias.code).toBe("UNG_117_023_010");
});
it("keeps an unknown code as typed for flagging", () => {
const r = parseBoardLine("DH2_004_100 not on our tracker?", ctx);
expect(r.code).toBe("DH2_004_100");
expect(r.shotId).toBeNull();
expect(r.text).toBe("not on our tracker?");
});
it("assigns from @name and keeps the rest of a parenthesised group", () => {
expect(parseBoardLine("DH2_VFX_005_010 gfx @michelle", ctx).assigneeId).toBe("u2");
const r = parseBoardLine("(MICHELLE ROTO) Maagie shot", ctx);
expect(r.assigneeId).toBe("u2");
expect(r.text).toBe("ROTO Maagie shot");
});
it("leaves parentheses that are not a user alone", () => {
const r = parseBoardLine("DH2_VFX_005_010 track (90% done)", ctx);
expect(r.assigneeId).toBeNull();
expect(r.text).toBe("track (90% done)");
});
it("splits off what the line is waiting on", () => {
const r = parseBoardLine(
"DH2_VFX_005_010 gfx required for update (MICHELLE) wait: client GFX ADH_GFX_005_020",
ctx,
);
expect(r.shotId).toBe("s2");
expect(r.waitingOn).toBe("client GFX ADH_GFX_005_020");
expect(r.text).toBe("gfx required for update");
expect(parseBoardLine("x waiting on: grade", ctx).waitingOn).toBe("grade");
// no colon → prose
expect(parseBoardLine("awaiting gfx", ctx).waitingOn).toBeNull();
});
it("takes a note with no shot code", () => {
const r = parseBoardLine("- Deposit received", ctx);
expect(r).toMatchObject({ text: "Deposit received", code: null, shotId: null });
});
});
describe("parseBoardText", () => {
it("groups lines under headings and skips blanks", () => {
const items = parseBoardText(
"DH2_VFX_027_090 finalise (CHRIS)\n\nWATER DROPS:\nDH2_VFX_005_010 still do (CHRIS)\nDH2_VFX_004_100\n",
ctx,
);
expect(items.map((i) => [i.code, i.group, i.text])).toEqual([
["DH2_VFX_027_090", "", "finalise"],
["DH2_VFX_005_010", "WATER DROPS", "still do"],
["DH2_VFX_004_100", "WATER DROPS", ""],
]);
});
it("treats an all-caps line without a code as a heading", () => {
const items = parseBoardText("FLAGS\nDH2_004_100 not on our tracker?", ctx);
expect(items).toHaveLength(1);
expect(items[0].group).toBe("FLAGS");
});
it("never treats a single typed line as a heading", () => {
expect(parseBoardText("CALL CLIENT", ctx)).toHaveLength(1);
});
});
describe("suggestShot", () => {
it("suggests the shot with the same prefix and numbers", () => {
expect(suggestShot("DH2_004_100", shots)?.id).toBe("s3");
});
it("does not guess between several candidates", () => {
const dup = [...shots, { id: "s5", shotCode: "DH2_GFX_004_100" }];
expect(suggestShot("DH2_004_100", dup)).toBeNull();
});
});
+171
View File
@@ -0,0 +1,171 @@
/**
* Turns lines typed (or pasted) onto the daily board into board items.
*
* The board replaces a shared doc, so the input is whatever someone would
* have typed there:
*
* WATER DROPS:
* DH2_VFX_027_210 still do (CHRIS)
* DH2_VFX_005_010 gfx required for update @michelle wait: client GFX ADH_GFX_005_020
*
* - The first shot-code-shaped token is the line's shot. It is matched against
* the project's shot codes and client aliases; a code that matches nothing
* is kept as typed so the board can flag it.
* - `(NAME)` or `@name` assigns the line when NAME is a user's first or full
* name. A parenthesised group can carry more than the name — "(MICHELLE
* ROTO)" assigns Michelle and keeps "ROTO" in the text.
* - `wait: …` (also `waiting on:`, `blocked by:`) marks what the line is
* blocked on. The colon is required so prose like "awaiting gfx" stays text.
* - A line with no shot code that ends in ":" or is all capitals is a heading;
* the lines after it are grouped under it.
*/
export interface BoardShot {
id: string;
shotCode: string;
clientCode?: string | null;
}
export interface BoardUser {
id: string;
name: string | null;
email: string;
}
export interface ParsedBoardLine {
text: string;
/** The shot code as typed (canonical shotCode when it matched); null = no code on the line */
code: string | null;
shotId: string | null;
assigneeId: string | null;
waitingOn: string | null;
group: string;
}
// Three or more underscore-separated segments, starting with a letter:
// DH2_VFX_027_090, UNG_117_023_010, ADH_GFX_005_020.
export const CODE_RE = /\b[A-Za-z][A-Za-z0-9]*(?:_[A-Za-z0-9]+){2,}\b/;
const WAIT_RE = /\b(?:wait(?:ing)?(?:\s+(?:on|for))?|blocked(?:\s+(?:on|by))?)\s*:\s*(.*)$/i;
const BULLET_RE = /^(?:[-*•–]|\d+[.)])\s+/;
export function findShot(code: string, shots: BoardShot[]): BoardShot | null {
const c = code.toUpperCase();
return (
shots.find((s) => s.shotCode.toUpperCase() === c) ??
shots.find((s) => s.clientCode?.trim().toUpperCase() === c) ??
null
);
}
/**
* A likely intended shot for a code that matched nothing — same leading
* segment and same numeric segments, so "DH2_004_100" suggests
* "DH2_VFX_004_100". Only a unique candidate is suggested.
*/
export function suggestShot(code: string, shots: BoardShot[]): BoardShot | null {
const sig = (c: string) => {
const parts = c.toUpperCase().split("_");
return `${parts[0]}|${parts.filter((p) => /^\d+$/.test(p)).join("_")}`;
};
const want = sig(code);
if (want.endsWith("|")) return null; // no numeric segments to go on
const hits = shots.filter((s) => sig(s.shotCode) === want);
return hits.length === 1 ? hits[0] : null;
}
function userNames(u: BoardUser): string[] {
const names = [u.email.split("@")[0]];
if (u.name) {
names.push(u.name);
names.push(u.name.split(/\s+/)[0]);
}
return names.map((n) => n.toLowerCase());
}
function findUser(word: string, users: BoardUser[]): BoardUser | null {
const w = word.toLowerCase();
const hits = users.filter((u) => userNames(u).includes(w));
return hits.length === 1 ? hits[0] : null;
}
function isHeading(line: string): boolean {
if (CODE_RE.test(line)) return false;
if (line.endsWith(":")) return true;
const letters = line.replace(/[^A-Za-z]/g, "");
return letters.length >= 3 && letters === letters.toUpperCase();
}
export function parseBoardLine(
raw: string,
ctx: { shots: BoardShot[]; users: BoardUser[] },
group = "",
): ParsedBoardLine {
let line = raw.trim().replace(BULLET_RE, "");
let waitingOn: string | null = null;
const wait = WAIT_RE.exec(line);
if (wait) {
waitingOn = wait[1].trim() || null;
line = line.slice(0, wait.index);
}
let code: string | null = null;
let shotId: string | null = null;
const codeMatch = CODE_RE.exec(line);
if (codeMatch) {
const shot = findShot(codeMatch[0], ctx.shots);
code = shot?.shotCode ?? codeMatch[0].toUpperCase();
shotId = shot?.id ?? null;
line = line.slice(0, codeMatch.index) + line.slice(codeMatch.index + codeMatch[0].length);
}
let assigneeId: string | null = null;
// (NAME …) — first word a user's name; the rest of the group stays as text
line = line.replace(/\(([^)]*)\)/g, (whole, inner: string) => {
if (assigneeId) return whole;
const words = inner.trim().split(/\s+/);
// Full name first ("Chris Pinto"), then first word
const full = findUser(words.slice(0, 2).join(" "), ctx.users);
const user = full ?? findUser(words[0] ?? "", ctx.users);
if (!user) return whole;
assigneeId = user.id;
const rest = words.slice(full ? 2 : 1).join(" ");
return rest ? ` ${rest} ` : " ";
});
line = line.replace(/@([A-Za-z][\w.-]*)/g, (whole, name: string) => {
if (assigneeId) return whole;
const user = findUser(name, ctx.users);
if (!user) return whole;
assigneeId = user.id;
return " ";
});
const text = line
.replace(/\s+/g, " ")
.replace(/^[\s:\-–,.]+|[\s:\-–,]+$/g, "")
.trim();
return { text, code, shotId, assigneeId, waitingOn, group };
}
/** Every non-blank line of `input` as a board item, headings applied as groups. */
export function parseBoardText(
input: string,
ctx: { shots: BoardShot[]; users: BoardUser[]; group?: string },
): ParsedBoardLine[] {
const out: ParsedBoardLine[] = [];
let group = ctx.group ?? "";
const lines = input.split(/\r?\n/).map((l) => l.trim()).filter(Boolean);
for (const raw of lines) {
const line = raw.replace(BULLET_RE, "");
// A single line is always an item — someone typing "Deposit received"
// into the quick-add box means a note, not a heading.
if (lines.length > 1 && isHeading(line)) {
group = line.replace(/:$/, "").trim();
continue;
}
const parsed = parseBoardLine(line, ctx, group);
if (parsed.text || parsed.code || parsed.waitingOn) out.push(parsed);
}
return out;
}
@@ -0,0 +1,44 @@
-- Daily board: the day-to-day tracker that used to live in a shared doc,
-- now linked to shots. Board items are notes, not tasks, so nothing here
-- feeds shot status.
ALTER TABLE "projects" ADD COLUMN "boardStatus" TEXT;
ALTER TABLE "projects" ADD COLUMN "boardStatusAt" TIMESTAMP(3);
CREATE TABLE "board_items" (
"id" TEXT NOT NULL,
"projectId" TEXT NOT NULL,
"shotId" TEXT,
"code" TEXT,
"text" TEXT NOT NULL,
"group" TEXT NOT NULL DEFAULT '',
"assigneeId" TEXT,
"waitingOn" TEXT,
"sortOrder" INTEGER NOT NULL DEFAULT 0,
"doneAt" TIMESTAMP(3),
"doneById" TEXT,
"createdById" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "board_items_pkey" PRIMARY KEY ("id")
);
CREATE TABLE "project_milestones" (
"id" TEXT NOT NULL,
"projectId" TEXT NOT NULL,
"label" TEXT NOT NULL,
"date" TIMESTAMP(3) NOT NULL,
"doneAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "project_milestones_pkey" PRIMARY KEY ("id")
);
CREATE INDEX "board_items_projectId_doneAt_idx" ON "board_items"("projectId", "doneAt");
CREATE INDEX "board_items_shotId_idx" ON "board_items"("shotId");
CREATE INDEX "project_milestones_projectId_date_idx" ON "project_milestones"("projectId", "date");
ALTER TABLE "board_items" ADD CONSTRAINT "board_items_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "projects"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "board_items" ADD CONSTRAINT "board_items_shotId_fkey" FOREIGN KEY ("shotId") REFERENCES "shots"("id") ON DELETE SET NULL ON UPDATE CASCADE;
ALTER TABLE "board_items" ADD CONSTRAINT "board_items_assigneeId_fkey" FOREIGN KEY ("assigneeId") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE;
ALTER TABLE "project_milestones" ADD CONSTRAINT "project_milestones_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "projects"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+61
View File
@@ -262,6 +262,7 @@ model User {
createdShootDays ShootDay[] @relation("ShootDayCreator")
createdTakes Take[] @relation("TakeCreator")
uploadedTakeAttachments TakeAttachment[] @relation("TakeAttachmentUploader")
assignedBoardItems BoardItem[] @relation("BoardItemAssignee")
@@map("users")
}
@@ -382,6 +383,14 @@ model Project {
deliveryPackages DeliveryPackage[]
deliveryRequests DeliveryRequest[]
/// The one-line "where are we" on the daily board ("Official bid sent",
/// "Awaiting feedback on pick-ups"). Free text on purpose: it is what the
/// producer would have typed at the top of the project's section in the doc.
boardStatus String? @db.Text
boardStatusAt DateTime?
boardItems BoardItem[]
milestones ProjectMilestone[]
@@map("projects")
}
@@ -487,6 +496,7 @@ model Shot {
loggedTakes Take[] @relation("TakeToShot")
exports Export[]
deliveryRequestLines DeliveryRequestLine[]
boardItems BoardItem[]
@@unique([projectId, shotCode])
@@map("shots")
@@ -1255,6 +1265,57 @@ model DeliveryRequestLine {
@@map("delivery_request_lines")
}
/// A line on the daily board — the day-to-day to-do the team used to keep in
/// a shared doc ("DH2_VFX_027_090 finalise (CHRIS)"). Deliberately not a Task:
/// tasks are the pipeline's one-per-shot work items whose state drives shot
/// status, and a note like "finalise" or "awaiting gfx" must not move a shot
/// back to IN_PROGRESS or notify anyone.
///
/// `code` is the shot code as typed. When it matches a shot, `shotId` links it
/// and the board shows the shot's live status; when it does not, the line is a
/// flag ("not on our tracker?") until the shot exists or the code is fixed.
model BoardItem {
id String @id @default(cuid())
projectId String
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
shotId String?
shot Shot? @relation(fields: [shotId], references: [id], onDelete: SetNull)
code String?
text String @db.Text
/// Heading the line sits under on the board ("WATER DROPS"); "" = none
group String @default("")
assigneeId String?
assignee User? @relation("BoardItemAssignee", fields: [assigneeId], references: [id], onDelete: SetNull)
/// What the line is blocked on ("client GFX ADH_GFX_013_010"); null = not blocked
waitingOn String?
sortOrder Int @default(0)
doneAt DateTime?
doneById String?
createdById String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([projectId, doneAt])
@@index([shotId])
@@map("board_items")
}
/// A dated project milestone — batch deliveries, picture locks, shoot start,
/// colour & mix review. Episode due dates stay in EpisodeDueDate because the
/// shot-status board keys off them; these are the dates around the work.
model ProjectMilestone {
id String @id @default(cuid())
projectId String
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
label String
date DateTime
doneAt DateTime?
createdAt DateTime @default(now())
@@index([projectId, date])
@@map("project_milestones")
}
model SketchTemplate {
id String @id @default(cuid())
name String