diff --git a/actions/board.ts b/actions/board.ts index 38ded3a..add5a7f 100644 --- a/actions/board.ts +++ b/actions/board.ts @@ -115,10 +115,23 @@ export async function updateBoardItem(id: string, data: z.infer(code: string, shots: T[]): T | null { + const exact = findShot(code, shots) as T | null; + if (exact) return exact; + const tail = `_${code.toUpperCase()}`; + const hits = shots.filter((s) => s.shotCode.toUpperCase().endsWith(tail)); + return hits.length === 1 ? hits[0] : null; +} + +export interface FeedbackVersionOption { + id: string; + label: string; + sent: boolean; + sharedAt: string | null; + taskId: string | null; + taskStatus: string | null; + /** The note's quoted frame converted for this version's media; null = no frame quoted */ + playerFrame: number | null; + fromComp: boolean; +} + +export interface FeedbackPreviewNote { + key: string; + code: string; + text: string; + quotedFrame: number | null; + shot: { id: string; shotCode: string } | null; + suggestion: string | null; + versions: FeedbackVersionOption[]; + defaultVersionId: string | null; +} + +export interface FeedbackPreview { + rounds: { title: string; dueText: string | null; notes: FeedbackPreviewNote[] }[]; + ignored: string[]; +} + +export async function previewFeedback(projectId: string, input: string): Promise { + await requireStaff(); + z.string().cuid().parse(projectId); + const parsed = parseFeedback(z.string().max(50000).parse(input)); + + const shots = await db.shot.findMany({ + where: { projectId }, + select: { id: true, shotCode: true, clientCode: true, frameStart: true }, + }); + + const matched = new Map(); + for (const r of parsed.rounds) for (const n of r.notes) { + const code = n.codes[0]; + if (!matched.has(code)) matched.set(code, resolveShot(code, shots)); + } + const shotIds = [...new Set([...matched.values()].filter(Boolean).map((s) => s!.id))]; + + // Recent versions per shot, whether hung off the shot or its task + const versions = shotIds.length + ? await db.version.findMany({ + where: { OR: [{ shotId: { in: shotIds } }, { task: { shotId: { in: shotIds } } }] }, + orderBy: { createdAt: "desc" }, + select: { + id: true, + versionNumber: true, + shotId: true, + isClientVisible: true, + sharedAt: true, + frameCount: true, + pictureStartFrame: true, + task: { select: { id: true, status: true, shotId: true } }, + }, + }) + : []; + const byShot = new Map(); + for (const v of versions) { + const sid = v.shotId ?? v.task?.shotId; + if (!sid) continue; + const list = byShot.get(sid) ?? []; + if (list.length < 6) list.push(v); + byShot.set(sid, list); + } + + let k = 0; + return { + ignored: parsed.ignored, + rounds: parsed.rounds.map((r) => ({ + title: r.title, + dueText: r.dueText, + notes: r.notes.map((n) => { + const code = n.codes[0]; + const shot = matched.get(code) ?? null; + const options: FeedbackVersionOption[] = (shot ? byShot.get(shot.id) ?? [] : []).map((v) => { + const conv = n.frame != null + ? toPlayerFrame(n.frame, { + shotFrameStart: shot!.frameStart, + pictureStart: slateFrames(v), + frameCount: v.frameCount, + }) + : null; + return { + id: v.id, + label: `v${String(v.versionNumber).padStart(3, "0")}`, + sent: v.isClientVisible, + sharedAt: v.sharedAt?.toISOString() ?? null, + taskId: v.task?.id ?? null, + taskStatus: v.task?.status ?? null, + playerFrame: conv?.frame ?? null, + fromComp: conv?.fromComp ?? false, + }; + }); + // The client is commenting on what they were sent + const def = options.find((o) => o.sent) ?? options[0] ?? null; + return { + key: `n${k++}`, + code: shot?.shotCode ?? code, + text: n.text, + quotedFrame: n.frame, + shot: shot ? { id: shot.id, shotCode: shot.shotCode } : null, + suggestion: shot ? null : suggestShot(code, shots)?.shotCode ?? null, + versions: options, + defaultVersionId: def?.id ?? null, + }; + }), + })), + }; +} + +const commitSchema = z.object({ + projectId: z.string().cuid(), + rounds: z.array(z.object({ + title: z.string().max(200), + dueAt: z.string().datetime().nullable(), + notes: z.array(z.object({ + code: z.string().min(1).max(100), + shotId: z.string().cuid().nullable(), + text: z.string().min(1).max(5000), + versionId: z.string().cuid().nullable(), + frame: z.number().int().min(0).nullable(), + setChanges: z.boolean(), + })).min(1), + })).min(1), +}); + +/** + * Saves pasted client feedback: one FeedbackRound per round, each note as a + * player comment (tagged as client feedback) and a board line, and — where + * ticked — the version's task moved to Changes. + */ +export async function commitFeedback(data: z.infer) { + const user = await requireStaff(); + const parsed = commitSchema.parse(data); + + const notes = parsed.rounds.flatMap((r) => r.notes); + const versionIds = [...new Set(notes.map((n) => n.versionId).filter(Boolean) as string[])]; + const versions = await db.version.findMany({ + where: { id: { in: versionIds } }, + select: { id: true, fps: true, shotId: true, task: { select: { id: true, shotId: true, status: true, projectId: true } }, shot: { select: { projectId: true } } }, + }); + const vById = new Map(versions.map((v) => [v.id, v])); + for (const n of notes) { + if (!n.versionId) continue; + const v = vById.get(n.versionId); + const projectOk = v && (v.shot?.projectId ?? v.task?.projectId) === parsed.projectId; + const shotOk = v && (v.shotId ?? v.task?.shotId) === n.shotId; + if (!projectOk || !shotOk) throw new Error(`Version does not belong to ${n.code}`); + } + + const last = await db.boardItem.findFirst({ + where: { projectId: parsed.projectId }, + orderBy: { sortOrder: "desc" }, + select: { sortOrder: true }, + }); + let sort = (last?.sortOrder ?? -1) + 1; + + let comments = 0; + await db.$transaction(async (tx) => { + for (const r of parsed.rounds) { + const round = await tx.feedbackRound.create({ + data: { + projectId: parsed.projectId, + title: r.title.trim() || "Client feedback", + dueAt: r.dueAt ? new Date(r.dueAt) : null, + createdById: user.id, + }, + }); + for (const n of r.notes) { + const v = n.versionId ? vById.get(n.versionId)! : null; + const frame = n.frame ?? 0; + const comment = v + ? await tx.comment.create({ + data: { + versionId: v.id, + authorId: user.id, + frameNumber: frame, + timestamp: frame / v.fps, + text: n.text.trim(), + source: SOURCE, + }, + select: { id: true }, + }) + : null; + if (comment) comments++; + await tx.boardItem.create({ + data: { + projectId: parsed.projectId, + shotId: n.shotId, + code: n.code.toUpperCase(), + text: n.text.trim(), + roundId: round.id, + commentId: comment?.id ?? null, + sortOrder: sort++, + createdById: user.id, + }, + }); + } + } + }); + + // Outside the transaction: updateTaskStatus notifies the artist and + // recalculates shot status, exactly as moving the task by hand does. + const tasks = new Set(); + for (const n of notes) { + const task = n.versionId ? vById.get(n.versionId)?.task : null; + if (n.setChanges && task && task.status !== "CHANGES") tasks.add(task.id); + } + for (const taskId of tasks) await updateTaskStatus(taskId, "CHANGES"); + + for (const id of versionIds) revalidatePath(`/review/${id}`); + revalidatePath("/board"); + return { success: true, notes: notes.length, comments, tasksChanged: tasks.size }; +} diff --git a/app/(dashboard)/board/BoardClient.tsx b/app/(dashboard)/board/BoardClient.tsx index 4ac6345..a91b108 100644 --- a/app/(dashboard)/board/BoardClient.tsx +++ b/app/(dashboard)/board/BoardClient.tsx @@ -12,6 +12,7 @@ import { Clock, Flag, Link2, + MessageSquareText, Pencil, Plus, Trash2, @@ -31,6 +32,7 @@ import { setProjectBoardStatus, updateBoardItem, } from "@/actions/board"; +import { FeedbackPasteDialog } from "./FeedbackPasteDialog"; type User = { id: string; name: string | null; email: string; image: string | null }; @@ -40,6 +42,9 @@ type BoardItem = { 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: { @@ -138,15 +143,26 @@ export function BoardClient({ 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); - 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)); + // Pasted feedback stays together under its round, flags and all + const rounds: { round: NonNullable; 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[] }[] = []; @@ -171,8 +187,17 @@ function ProjectSection({ project, users, who }: { project: BoardProject; users: On hold )} + {project.shotTotal > 0 && ( -
+
@@ -185,6 +210,11 @@ function ProjectSection({ project, users, who }: { project: BoardProject; users: + {rounds.map(({ round, items }) => ( + + {items.map((i) => )} + + ))} {flagged.length > 0 && ( }> {flagged.map((i) => )} @@ -221,10 +251,54 @@ function ProjectSection({ project, users, who }: { project: BoardProject; users:
)}
+ ); } +function RoundBlock({ + round, + count, + children, +}: { + round: NonNullable; + 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 ( +
+
+ + {round.title} + {when && ( + + {late ? "overdue · " : "due "}{when} + + )} + {count} open +
+
{children}
+
+ ); +} + function ItemBlock({ title, icon, children }: { title: string; icon?: React.ReactNode; children: React.ReactNode }) { return (
@@ -466,7 +540,15 @@ function ItemRow({ item, users, projectId }: { item: BoardItem; users: User[]; p )} {item.text} - + {item.comment && ( + + note @ F{item.comment.frame} + + )}
{(item.waitingOn || (flagged && !done)) && ( diff --git a/app/(dashboard)/board/FeedbackPasteDialog.tsx b/app/(dashboard)/board/FeedbackPasteDialog.tsx new file mode 100644 index 0000000..d646aa4 --- /dev/null +++ b/app/(dashboard)/board/FeedbackPasteDialog.tsx @@ -0,0 +1,342 @@ +"use client"; + +import { useState, useTransition } from "react"; +import { useRouter } from "next/navigation"; +import { format } from "date-fns"; +import { AlertTriangle, ArrowLeft, Link2, MessageSquareText } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog"; +import { useToast } from "@/components/ui/use-toast"; +import { cn } from "@/lib/utils"; +import { parseDue } from "@/lib/feedback-parse"; +import { commitFeedback, previewFeedback, type FeedbackPreview, type FeedbackPreviewNote } from "@/actions/feedback"; + +type NoteState = { + include: boolean; + text: string; + versionId: string | null; + /** Typed frame; null = use the selected version's converted frame */ + frame: string | null; + setChanges: boolean; +}; +type RoundState = { title: string; due: string; notes: Record }; + +const LOCAL_DT = "yyyy-MM-dd'T'HH:mm"; + +function initialState(preview: FeedbackPreview): RoundState[] { + return preview.rounds.map((r) => { + const due = parseDue(r.dueText); + return { + title: r.title || `Feedback ${format(new Date(), "d MMM")}`, + due: due ? format(due, LOCAL_DT) : "", + notes: Object.fromEntries( + r.notes.map((n) => [ + n.key, + { include: true, text: n.text, versionId: n.defaultVersionId, frame: null, setChanges: true }, + ]), + ), + }; + }); +} + +export function FeedbackPasteDialog({ + projectId, + projectName, + open, + onOpenChange, +}: { + projectId: string; + projectName: string; + open: boolean; + onOpenChange: (open: boolean) => void; +}) { + const router = useRouter(); + const { toast } = useToast(); + const [input, setInput] = useState(""); + const [preview, setPreview] = useState(null); + const [rounds, setRounds] = useState([]); + const [isPending, startTransition] = useTransition(); + + const reset = () => { + setInput(""); + setPreview(null); + setRounds([]); + }; + + const read = (text: string) => + startTransition(async () => { + try { + const p = await previewFeedback(projectId, text); + if (p.rounds.length === 0) { + toast({ title: "No shot notes found", description: "Each note needs to start with a shot code.", variant: "destructive" }); + return; + } + setPreview(p); + setRounds(initialState(p)); + } catch (e) { + toast({ title: "Couldn't read feedback", description: (e as Error).message, variant: "destructive" }); + } + }); + + // Accepting a suggested code rewrites the paste and reads it again, so + // everything downstream (versions, frames) comes from the real shot. + const applySuggestion = (code: string, suggestion: string) => { + const next = input.split(code).join(suggestion); + setInput(next); + read(next); + }; + + const updateNote = (ri: number, key: string, patch: Partial) => + setRounds((rs) => rs.map((r, i) => (i === ri ? { ...r, notes: { ...r.notes, [key]: { ...r.notes[key], ...patch } } } : r))); + + const frameFor = (n: FeedbackPreviewNote, s: NoteState): number | null => { + if (s.frame !== null) return s.frame.trim() === "" ? null : Number(s.frame); + return n.versions.find((v) => v.id === s.versionId)?.playerFrame ?? null; + }; + + const included = preview + ? preview.rounds.flatMap((r, ri) => r.notes.filter((n) => rounds[ri]?.notes[n.key]?.include && rounds[ri].notes[n.key].text.trim())) + : []; + + const save = () => { + if (!preview) return; + const payload = preview.rounds + .map((r, ri) => { + const rs = rounds[ri]; + return { + title: rs.title, + dueAt: rs.due ? new Date(rs.due).toISOString() : null, + notes: r.notes + .filter((n) => rs.notes[n.key].include && rs.notes[n.key].text.trim()) + .map((n) => { + const s = rs.notes[n.key]; + const frame = frameFor(n, s); + return { + code: n.code, + shotId: n.shot?.id ?? null, + text: s.text.trim(), + versionId: n.shot ? s.versionId : null, + frame: frame != null && Number.isFinite(frame) && frame >= 0 ? Math.floor(frame) : null, + setChanges: !!s.versionId && s.setChanges, + }; + }), + }; + }) + .filter((r) => r.notes.length > 0); + if (payload.length === 0) return; + + startTransition(async () => { + try { + const res = await commitFeedback({ projectId, rounds: payload }); + toast({ + title: `${res.notes} note${res.notes === 1 ? "" : "s"} logged`, + description: `${res.comments} in the player · ${res.tasksChanged} task${res.tasksChanged === 1 ? "" : "s"} moved to Changes`, + }); + reset(); + onOpenChange(false); + router.refresh(); + } catch (e) { + toast({ title: "Couldn't save feedback", description: (e as Error).message, variant: "destructive" }); + } + }); + }; + + const field = "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 ( + { if (!o) reset(); onOpenChange(o); }}> + + + + + Paste client feedback · {projectName} + + + {preview + ? "Check where each note will go. Notes are saved in the player as client feedback and added to the board." + : "Paste the message as it arrived. Each note should start with a shot code; a line like “FEEDBACK 113 DUE Oct 7 12:00 PM” starts a round."} + + + + {!preview ? ( +
+