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