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:
@@ -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 };
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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} />;
|
||||
}
|
||||
@@ -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 },
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user