842a8fe036
Deploy / deploy (push) Successful in 3m34s
"Paste feedback" on each board card takes a client's message as it arrived
and splits it into rounds ("FEEDBACK 113 DUE Oct 7 12:00 PM") and per-shot
notes, tolerating WhatsApp copy prefixes, bold markers, wrapped lines,
several codes per note and codes without the show prefix.
A review step shows each note's shot, the version the client was sent,
the player frame and a "move task to Changes" tick (on by default).
Saving creates a FeedbackRound, a player comment per note tagged
source=WHATSAPP (shown as "Client feedback") and a board line under the
round's due time; ticking the line off resolves the comment.
Quoted frames are read as comp frames: shot frameStart or the house 1001,
plus the slate (recorded, else 1 for pipeline previews). Version.duration
is never stored on live, so the length-based slate inference can't be used.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
690 lines
26 KiB
TypeScript
690 lines
26 KiB
TypeScript
"use client";
|
|
|
|
import { useEffect, useRef, useState, useTransition } from "react";
|
|
import { useRouter } from "next/navigation";
|
|
import Link from "next/link";
|
|
import { differenceInCalendarDays, format, formatDistanceToNowStrict } from "date-fns";
|
|
import {
|
|
AlertTriangle,
|
|
Check,
|
|
ChevronDown,
|
|
ChevronRight,
|
|
Clock,
|
|
Flag,
|
|
Link2,
|
|
MessageSquareText,
|
|
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";
|
|
import { FeedbackPasteDialog } from "./FeedbackPasteDialog";
|
|
|
|
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;
|
|
round: { id: string; title: string; dueAt: string | null } | null;
|
|
/** The player comment a pasted-feedback line was saved as */
|
|
comment: { versionId: string; frame: number } | 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 [pasting, setPasting] = 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);
|
|
// Pasted feedback stays together under its round, flags and all
|
|
const rounds: { round: NonNullable<BoardItem["round"]>; items: BoardItem[] }[] = [];
|
|
for (const item of open) {
|
|
if (!item.round) continue;
|
|
let r = rounds.find((x) => x.round.id === item.round!.id);
|
|
if (!r) rounds.push((r = { round: item.round, items: [] }));
|
|
r.items.push(item);
|
|
}
|
|
rounds.sort((a, b) => (a.round.dueAt ?? "9").localeCompare(b.round.dueAt ?? "9"));
|
|
const loose = open.filter((i) => !i.round);
|
|
const flagged = loose.filter((i) => i.code && !i.shot);
|
|
const waiting = loose.filter((i) => i.waitingOn && !(i.code && !i.shot));
|
|
const rest = loose.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>
|
|
)}
|
|
<Button
|
|
size="sm"
|
|
variant="outline"
|
|
className="ml-auto h-7"
|
|
onClick={() => setPasting(true)}
|
|
title="Paste a client's feedback message"
|
|
>
|
|
<MessageSquareText /> Paste feedback
|
|
</Button>
|
|
{project.shotTotal > 0 && (
|
|
<div className="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} />
|
|
|
|
{rounds.map(({ round, items }) => (
|
|
<RoundBlock key={round.id} round={round} count={items.length}>
|
|
{items.map((i) => <ItemRow key={i.id} item={i} users={users} projectId={project.id} />)}
|
|
</RoundBlock>
|
|
))}
|
|
{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>
|
|
<FeedbackPasteDialog
|
|
projectId={project.id}
|
|
projectName={project.name}
|
|
open={pasting}
|
|
onOpenChange={setPasting}
|
|
/>
|
|
</section>
|
|
);
|
|
}
|
|
|
|
function RoundBlock({
|
|
round,
|
|
count,
|
|
children,
|
|
}: {
|
|
round: NonNullable<BoardItem["round"]>;
|
|
count: number;
|
|
children: React.ReactNode;
|
|
}) {
|
|
const due = round.dueAt ? new Date(round.dueAt) : null;
|
|
const late = due && due.getTime() < Date.now();
|
|
const days = due ? differenceInCalendarDays(due, new Date()) : null;
|
|
const when = due
|
|
? days === 0 ? `today ${format(due, "HH:mm")}` : days === 1 ? `tomorrow ${format(due, "HH:mm")}` : format(due, "d MMM HH:mm")
|
|
: null;
|
|
|
|
return (
|
|
<div>
|
|
<div className="mb-1 flex flex-wrap items-center gap-1.5 text-xs uppercase tracking-wider text-zinc-500">
|
|
<MessageSquareText className="h-3.5 w-3.5 text-indigo-400" />
|
|
{round.title}
|
|
{when && (
|
|
<span
|
|
className={cn(
|
|
"rounded px-1.5 py-px normal-case tracking-normal",
|
|
late ? "bg-red-500/10 text-red-300" : days !== null && days <= 1 ? "bg-amber-500/10 text-amber-300" : "text-zinc-400",
|
|
)}
|
|
>
|
|
{late ? "overdue · " : "due "}{when}
|
|
</span>
|
|
)}
|
|
<span className="normal-case tracking-normal text-zinc-600">{count} open</span>
|
|
</div>
|
|
<div className="divide-y divide-zinc-800/60">{children}</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
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>
|
|
{item.comment && (
|
|
<Link
|
|
href={`/review/${item.comment.versionId}`}
|
|
className="text-[11px] text-indigo-400/80 hover:text-indigo-300"
|
|
title="Open the note in the player"
|
|
>
|
|
note @ F{item.comment.frame}
|
|
</Link>
|
|
)}
|
|
</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>
|
|
);
|
|
}
|