"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>
This commit is contained in:
+14
-1
@@ -115,10 +115,23 @@ export async function updateBoardItem(id: string, data: z.infer<typeof updateSch
|
|||||||
|
|
||||||
export async function setBoardItemDone(id: string, done: boolean) {
|
export async function setBoardItemDone(id: string, done: boolean) {
|
||||||
const user = await requireStaff();
|
const user = await requireStaff();
|
||||||
await db.boardItem.update({
|
const item = await db.boardItem.update({
|
||||||
where: { id },
|
where: { id },
|
||||||
data: done ? { doneAt: new Date(), doneById: user.id } : { doneAt: null, doneById: null },
|
data: done ? { doneAt: new Date(), doneById: user.id } : { doneAt: null, doneById: null },
|
||||||
|
select: { commentId: true },
|
||||||
});
|
});
|
||||||
|
// A line from pasted feedback is that client note: ticking it off resolves
|
||||||
|
// the comment in the player too. (The comment may have been deleted there.)
|
||||||
|
if (item.commentId) {
|
||||||
|
const comment = await db.comment.updateMany({
|
||||||
|
where: { id: item.commentId },
|
||||||
|
data: { isResolved: done },
|
||||||
|
});
|
||||||
|
if (comment.count) {
|
||||||
|
const c = await db.comment.findUnique({ where: { id: item.commentId }, select: { versionId: true } });
|
||||||
|
if (c) revalidatePath(`/review/${c.versionId}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
revalidatePath("/board");
|
revalidatePath("/board");
|
||||||
return { success: true };
|
return { success: true };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,253 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import { auth } from "@/auth";
|
||||||
|
import { db } from "@/lib/db";
|
||||||
|
import { revalidatePath } from "next/cache";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { findShot, suggestShot, type BoardShot } from "@/lib/board-parse";
|
||||||
|
import { parseFeedback, slateFrames, toPlayerFrame } from "@/lib/feedback-parse";
|
||||||
|
import { updateTaskStatus } from "@/actions/tasks";
|
||||||
|
|
||||||
|
/** Comment.source for notes pasted from a client message */
|
||||||
|
const SOURCE = "WHATSAPP";
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The shot a pasted code means: exact code or client alias first, then a code
|
||||||
|
* the client shortened by dropping the show prefix ("113_018_040" for
|
||||||
|
* "UNG_113_018_040") when exactly one shot ends with it.
|
||||||
|
*/
|
||||||
|
function resolveShot<T extends BoardShot>(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<FeedbackPreview> {
|
||||||
|
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<string, (typeof shots)[number] | null>();
|
||||||
|
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<string, typeof versions>();
|
||||||
|
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<typeof commitSchema>) {
|
||||||
|
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<string>();
|
||||||
|
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 };
|
||||||
|
}
|
||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
Clock,
|
Clock,
|
||||||
Flag,
|
Flag,
|
||||||
Link2,
|
Link2,
|
||||||
|
MessageSquareText,
|
||||||
Pencil,
|
Pencil,
|
||||||
Plus,
|
Plus,
|
||||||
Trash2,
|
Trash2,
|
||||||
@@ -31,6 +32,7 @@ import {
|
|||||||
setProjectBoardStatus,
|
setProjectBoardStatus,
|
||||||
updateBoardItem,
|
updateBoardItem,
|
||||||
} from "@/actions/board";
|
} from "@/actions/board";
|
||||||
|
import { FeedbackPasteDialog } from "./FeedbackPasteDialog";
|
||||||
|
|
||||||
type User = { id: string; name: string | null; email: string; image: string | null };
|
type User = { id: string; name: string | null; email: string; image: string | null };
|
||||||
|
|
||||||
@@ -40,6 +42,9 @@ type BoardItem = {
|
|||||||
code: string | null;
|
code: string | null;
|
||||||
group: string;
|
group: string;
|
||||||
waitingOn: string | null;
|
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;
|
doneAt: string | null;
|
||||||
assignee: { id: string; name: string | null; image: string | null } | null;
|
assignee: { id: string; name: string | null; image: string | null } | null;
|
||||||
shot: {
|
shot: {
|
||||||
@@ -138,15 +143,26 @@ export function BoardClient({
|
|||||||
|
|
||||||
function ProjectSection({ project, users, who }: { project: BoardProject; users: User[]; who: string | null }) {
|
function ProjectSection({ project, users, who }: { project: BoardProject; users: User[]; who: string | null }) {
|
||||||
const [showDone, setShowDone] = useState(false);
|
const [showDone, setShowDone] = useState(false);
|
||||||
|
const [pasting, setPasting] = useState(false);
|
||||||
|
|
||||||
const visible = project.items.filter((i) =>
|
const visible = project.items.filter((i) =>
|
||||||
who === null ? true : who === "none" ? !i.assignee : i.assignee?.id === who,
|
who === null ? true : who === "none" ? !i.assignee : i.assignee?.id === who,
|
||||||
);
|
);
|
||||||
const open = visible.filter((i) => !i.doneAt);
|
const open = visible.filter((i) => !i.doneAt);
|
||||||
const done = visible.filter((i) => i.doneAt);
|
const done = visible.filter((i) => i.doneAt);
|
||||||
const flagged = open.filter((i) => i.code && !i.shot);
|
// Pasted feedback stays together under its round, flags and all
|
||||||
const waiting = open.filter((i) => i.waitingOn && !(i.code && !i.shot));
|
const rounds: { round: NonNullable<BoardItem["round"]>; items: BoardItem[] }[] = [];
|
||||||
const rest = open.filter((i) => !flagged.includes(i) && !waiting.includes(i));
|
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
|
// Groups keep the order they were first typed in
|
||||||
const groups: { name: string; items: BoardItem[] }[] = [];
|
const groups: { name: string; items: BoardItem[] }[] = [];
|
||||||
@@ -171,8 +187,17 @@ function ProjectSection({ project, users, who }: { project: BoardProject; users:
|
|||||||
On hold
|
On hold
|
||||||
</span>
|
</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 && (
|
{project.shotTotal > 0 && (
|
||||||
<div className="ml-auto flex items-center gap-2 text-xs text-zinc-400">
|
<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-1.5 w-24 overflow-hidden rounded-full bg-zinc-800">
|
||||||
<div className="h-full bg-green-500/70" style={{ width: `${pct}%` }} />
|
<div className="h-full bg-green-500/70" style={{ width: `${pct}%` }} />
|
||||||
</div>
|
</div>
|
||||||
@@ -185,6 +210,11 @@ function ProjectSection({ project, users, who }: { project: BoardProject; users:
|
|||||||
<StatusLine project={project} />
|
<StatusLine project={project} />
|
||||||
<Milestones 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 && (
|
{flagged.length > 0 && (
|
||||||
<ItemBlock title="Flags" icon={<Flag className="h-3.5 w-3.5 text-red-400" />}>
|
<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} />)}
|
{flagged.map((i) => <ItemRow key={i.id} item={i} users={users} projectId={project.id} />)}
|
||||||
@@ -221,10 +251,54 @@ function ProjectSection({ project, users, who }: { project: BoardProject; users:
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
<FeedbackPasteDialog
|
||||||
|
projectId={project.id}
|
||||||
|
projectName={project.name}
|
||||||
|
open={pasting}
|
||||||
|
onOpenChange={setPasting}
|
||||||
|
/>
|
||||||
</section>
|
</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 }) {
|
function ItemBlock({ title, icon, children }: { title: string; icon?: React.ReactNode; children: React.ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
@@ -466,7 +540,15 @@ function ItemRow({ item, users, projectId }: { item: BoardItem; users: User[]; p
|
|||||||
</Link>
|
</Link>
|
||||||
)}
|
)}
|
||||||
<span className={cn("text-zinc-200", done && "text-zinc-600 line-through")}>{item.text}</span>
|
<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>
|
</div>
|
||||||
|
|
||||||
{(item.waitingOn || (flagged && !done)) && (
|
{(item.waitingOn || (flagged && !done)) && (
|
||||||
|
|||||||
@@ -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<string, NoteState> };
|
||||||
|
|
||||||
|
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<FeedbackPreview | null>(null);
|
||||||
|
const [rounds, setRounds] = useState<RoundState[]>([]);
|
||||||
|
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<NoteState>) =>
|
||||||
|
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 (
|
||||||
|
<Dialog open={open} onOpenChange={(o) => { if (!o) reset(); onOpenChange(o); }}>
|
||||||
|
<DialogContent className="max-h-[90vh] max-w-4xl overflow-y-auto">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="flex items-center gap-2">
|
||||||
|
<MessageSquareText className="h-4 w-4 text-amber-400" />
|
||||||
|
Paste client feedback · {projectName}
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
{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."}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
{!preview ? (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<textarea
|
||||||
|
autoFocus
|
||||||
|
value={input}
|
||||||
|
onChange={(e) => setInput(e.target.value)}
|
||||||
|
rows={12}
|
||||||
|
placeholder={"FEEDBACK 113 DUE Oct 7, 2026 12:00 PM\nUNG_113_008_040 The screen is partially peeking under the mask\nUNG_113_018_050 At frame 1018, the mask is still slipping"}
|
||||||
|
className={cn(field, "w-full resize-y py-2 font-mono text-[13px] leading-relaxed")}
|
||||||
|
/>
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<Button onClick={() => read(input)} disabled={isPending || !input.trim()}>
|
||||||
|
{isPending ? "Reading…" : "Read feedback"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-5">
|
||||||
|
{preview.ignored.length > 0 && (
|
||||||
|
<p className="text-xs text-zinc-500">
|
||||||
|
Skipped (no shot code): {preview.ignored.map((l) => `“${l}”`).join(", ")}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{preview.rounds.map((r, ri) => {
|
||||||
|
const rs = rounds[ri];
|
||||||
|
if (!rs) return null;
|
||||||
|
return (
|
||||||
|
<div key={ri} className="space-y-2">
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<input
|
||||||
|
value={rs.title}
|
||||||
|
onChange={(e) => setRounds((all) => all.map((x, i) => (i === ri ? { ...x, title: e.target.value } : x)))}
|
||||||
|
className={cn(field, "h-8 w-56 text-sm font-medium")}
|
||||||
|
/>
|
||||||
|
<span className="text-xs text-zinc-500">due</span>
|
||||||
|
<input
|
||||||
|
type="datetime-local"
|
||||||
|
value={rs.due}
|
||||||
|
onChange={(e) => setRounds((all) => all.map((x, i) => (i === ri ? { ...x, due: e.target.value } : x)))}
|
||||||
|
className={cn(field, "h-8 [color-scheme:dark]")}
|
||||||
|
/>
|
||||||
|
{r.dueText && !rs.due && (
|
||||||
|
<span className="text-xs text-amber-400">Couldn’t read “{r.dueText}”, set it by hand</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="overflow-x-auto rounded-lg border border-zinc-800">
|
||||||
|
<table className="w-full text-xs">
|
||||||
|
<thead className="bg-zinc-950 text-left uppercase tracking-wider text-zinc-500">
|
||||||
|
<tr>
|
||||||
|
<th className="w-8 px-2 py-2" />
|
||||||
|
<th className="px-2 py-2">Shot</th>
|
||||||
|
<th className="px-2 py-2">Note</th>
|
||||||
|
<th className="px-2 py-2">Version</th>
|
||||||
|
<th className="px-2 py-2">Frame</th>
|
||||||
|
<th className="px-2 py-2" title="Move the task to Changes">Changes</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-zinc-800">
|
||||||
|
{r.notes.map((n) => {
|
||||||
|
const s = rs.notes[n.key];
|
||||||
|
const v = n.versions.find((x) => x.id === s.versionId) ?? null;
|
||||||
|
const frame = frameFor(n, s);
|
||||||
|
return (
|
||||||
|
<tr key={n.key} className={cn("align-top", !s.include && "opacity-40")}>
|
||||||
|
<td className="px-2 py-2">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={s.include}
|
||||||
|
onChange={(e) => updateNote(ri, n.key, { include: e.target.checked })}
|
||||||
|
className="mt-1 accent-amber-500"
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td className="whitespace-nowrap px-2 py-2 font-mono">
|
||||||
|
{n.shot ? (
|
||||||
|
<span className="text-amber-400">{n.shot.shotCode}</span>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-1">
|
||||||
|
<span className="flex items-center gap-1 text-red-400">
|
||||||
|
<AlertTriangle className="h-3 w-3" /> {n.code}
|
||||||
|
</span>
|
||||||
|
{n.suggestion ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => applySuggestion(n.code, n.suggestion!)}
|
||||||
|
className="flex items-center gap-1 font-sans text-zinc-400 hover:text-amber-400"
|
||||||
|
>
|
||||||
|
<Link2 className="h-3 w-3" /> Use {n.suggestion}
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<span className="block font-sans text-zinc-500">Not in project; flag only</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="min-w-[16rem] px-2 py-2">
|
||||||
|
<textarea
|
||||||
|
value={s.text}
|
||||||
|
onChange={(e) => updateNote(ri, n.key, { text: e.target.value })}
|
||||||
|
rows={Math.min(5, Math.max(1, Math.ceil(s.text.length / 60)))}
|
||||||
|
className={cn(field, "w-full resize-y py-1 leading-snug")}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td className="px-2 py-2">
|
||||||
|
{n.versions.length > 0 ? (
|
||||||
|
<select
|
||||||
|
value={s.versionId ?? ""}
|
||||||
|
onChange={(e) => updateNote(ri, n.key, { versionId: e.target.value || null, frame: null })}
|
||||||
|
className={cn(field, "h-7")}
|
||||||
|
>
|
||||||
|
{n.versions.map((o) => (
|
||||||
|
<option key={o.id} value={o.id}>
|
||||||
|
{o.label}
|
||||||
|
{o.sent ? ` · sent${o.sharedAt ? " " + format(new Date(o.sharedAt), "d MMM") : ""}` : " · not sent"}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
<option value="">Board only</option>
|
||||||
|
</select>
|
||||||
|
) : (
|
||||||
|
<span className="text-zinc-500">{n.shot ? "No versions; board only" : "—"}</span>
|
||||||
|
)}
|
||||||
|
{v && !v.sent && <span className="mt-1 block text-amber-400/80">Not sent to client</span>}
|
||||||
|
</td>
|
||||||
|
<td className="px-2 py-2">
|
||||||
|
{s.versionId ? (
|
||||||
|
<>
|
||||||
|
<input
|
||||||
|
inputMode="numeric"
|
||||||
|
value={s.frame ?? (frame != null ? String(frame) : "")}
|
||||||
|
onChange={(e) => updateNote(ri, n.key, { frame: e.target.value.replace(/\D/g, "") })}
|
||||||
|
placeholder="0"
|
||||||
|
className={cn(field, "h-7 w-16 font-mono")}
|
||||||
|
/>
|
||||||
|
{n.quotedFrame != null && s.frame === null && v && (
|
||||||
|
<span className="mt-1 block whitespace-nowrap text-zinc-500">
|
||||||
|
{v.fromComp ? `comp ${n.quotedFrame}` : `as quoted`}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<span className="text-zinc-600">—</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="px-2 py-2 text-center">
|
||||||
|
{v?.taskId ? (
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={s.setChanges}
|
||||||
|
disabled={v.taskStatus === "CHANGES"}
|
||||||
|
title={v.taskStatus === "CHANGES" ? "Already in Changes" : "Move the task to Changes"}
|
||||||
|
onChange={(e) => updateNote(ri, n.key, { setChanges: e.target.checked })}
|
||||||
|
className="mt-1 accent-amber-500"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<span className="text-zinc-600">—</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||||
|
<Button variant="ghost" onClick={() => setPreview(null)} disabled={isPending}>
|
||||||
|
<ArrowLeft /> Edit paste
|
||||||
|
</Button>
|
||||||
|
<Button onClick={save} disabled={isPending || included.length === 0}>
|
||||||
|
{isPending ? "Saving…" : `Save ${included.length} note${included.length === 1 ? "" : "s"}`}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -38,6 +38,7 @@ export default async function BoardPage({
|
|||||||
orderBy: { sortOrder: "asc" },
|
orderBy: { sortOrder: "asc" },
|
||||||
include: {
|
include: {
|
||||||
assignee: { select: { id: true, name: true, image: true } },
|
assignee: { select: { id: true, name: true, image: true } },
|
||||||
|
round: { select: { id: true, title: true, dueAt: true } },
|
||||||
shot: {
|
shot: {
|
||||||
select: {
|
select: {
|
||||||
id: true,
|
id: true,
|
||||||
@@ -76,6 +77,14 @@ export default async function BoardPage({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Pasted-feedback lines link to the comment they were saved as
|
||||||
|
const commentIds = projects.flatMap((p) => p.boardItems.map((i) => i.commentId).filter(Boolean) as string[]);
|
||||||
|
const commentVersion = new Map(
|
||||||
|
(commentIds.length
|
||||||
|
? await db.comment.findMany({ where: { id: { in: commentIds } }, select: { id: true, versionId: true, frameNumber: true } })
|
||||||
|
: []
|
||||||
|
).map((c) => [c.id, c]),
|
||||||
|
);
|
||||||
const shotCounts = await db.shot.groupBy({
|
const shotCounts = await db.shot.groupBy({
|
||||||
by: ["projectId", "status"],
|
by: ["projectId", "status"],
|
||||||
where: { projectId: { in: projectIds } },
|
where: { projectId: { in: projectIds } },
|
||||||
@@ -127,6 +136,10 @@ export default async function BoardPage({
|
|||||||
code: i.code,
|
code: i.code,
|
||||||
group: i.group,
|
group: i.group,
|
||||||
waitingOn: i.waitingOn,
|
waitingOn: i.waitingOn,
|
||||||
|
round: i.round ? { id: i.round.id, title: i.round.title, dueAt: i.round.dueAt?.toISOString() ?? null } : null,
|
||||||
|
comment: i.commentId && commentVersion.has(i.commentId)
|
||||||
|
? { versionId: commentVersion.get(i.commentId)!.versionId, frame: commentVersion.get(i.commentId)!.frameNumber }
|
||||||
|
: null,
|
||||||
doneAt: i.doneAt?.toISOString() ?? null,
|
doneAt: i.doneAt?.toISOString() ?? null,
|
||||||
assignee: i.assignee,
|
assignee: i.assignee,
|
||||||
shot: i.shot
|
shot: i.shot
|
||||||
|
|||||||
@@ -46,6 +46,8 @@ interface Comment {
|
|||||||
timestamp: number;
|
timestamp: number;
|
||||||
text: string;
|
text: string;
|
||||||
isResolved: boolean;
|
isResolved: boolean;
|
||||||
|
/** "WHATSAPP" = client feedback sent by message and logged by the studio */
|
||||||
|
source?: string | null;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
/** This reviewer wrote it, so they can change or remove it. Set by the API. */
|
/** This reviewer wrote it, so they can change or remove it. Set by the API. */
|
||||||
canEdit?: boolean;
|
canEdit?: boolean;
|
||||||
@@ -660,7 +662,9 @@ export default function ClientReviewPage({
|
|||||||
</AvatarFallback>
|
</AvatarFallback>
|
||||||
</Avatar>
|
</Avatar>
|
||||||
<span className="text-xs text-zinc-300 font-medium">
|
<span className="text-xs text-zinc-300 font-medium">
|
||||||
{comment.author.name ?? comment.author.email.split("@")[0]}
|
{comment.source === "WHATSAPP"
|
||||||
|
? "Client feedback"
|
||||||
|
: comment.author.name ?? comment.author.email.split("@")[0]}
|
||||||
</span>
|
</span>
|
||||||
<span className="ml-auto font-mono text-xs text-amber-400/80">
|
<span className="ml-auto font-mono text-xs text-amber-400/80">
|
||||||
{frameToTimecode(comment.frameNumber, version.fps)}
|
{frameToTimecode(comment.frameNumber, version.fps)}
|
||||||
|
|||||||
@@ -342,8 +342,18 @@ function CommentThread({
|
|||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<div className="flex items-center gap-2 flex-wrap">
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
<span className="text-xs font-medium">
|
<span className="text-xs font-medium">
|
||||||
{comment.author?.name ?? comment.author?.email ?? "Deleted User"}
|
{comment.source === "WHATSAPP"
|
||||||
|
? "Client feedback"
|
||||||
|
: comment.author?.name ?? comment.author?.email ?? "Deleted User"}
|
||||||
</span>
|
</span>
|
||||||
|
{comment.source === "WHATSAPP" && (
|
||||||
|
<span
|
||||||
|
className="rounded border border-indigo-500/20 bg-indigo-500/10 px-1 py-px text-[10px] text-indigo-400"
|
||||||
|
title={`Pasted from WhatsApp by ${comment.author?.name ?? comment.author?.email ?? "someone"}`}
|
||||||
|
>
|
||||||
|
via {comment.author?.name?.split(" ")[0] ?? "WhatsApp"}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
<button
|
<button
|
||||||
className="font-mono text-xs text-primary/80 hover:text-primary hover:underline"
|
className="font-mono text-xs text-primary/80 hover:text-primary hover:underline"
|
||||||
onClick={() => onJumpToFrame(comment.frameNumber)}
|
onClick={() => onJumpToFrame(comment.frameNumber)}
|
||||||
|
|||||||
@@ -0,0 +1,116 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { parseDue, parseFeedback, slateFrames, toPlayerFrame } from "./feedback-parse";
|
||||||
|
|
||||||
|
describe("parseFeedback", () => {
|
||||||
|
it("splits rounds and notes from the doc's own format", () => {
|
||||||
|
const { rounds, ignored } = parseFeedback(`FEEDBACK 117 DUE Oct 7, 2026 12:00 PM
|
||||||
|
UNG_117_023_010 The screen comp starts warping, slightly expanding and contracting in the second half of the shot.
|
||||||
|
UNG_117_024_010 48fps @ 200% Done Dineo seems slightly too small
|
||||||
|
|
||||||
|
FEEDBACK 113 DUE Oct 7, 2026 12:00 PM
|
||||||
|
UNG_113_008_040 The screen is partially peeking under the mask on OZ
|
||||||
|
UNG_113_018_050 At frame 1018, the mask is still slipping`);
|
||||||
|
expect(ignored).toEqual([]);
|
||||||
|
expect(rounds.map((r) => [r.title, r.dueText, r.notes.length])).toEqual([
|
||||||
|
["FEEDBACK 117", "Oct 7, 2026 12:00 PM", 2],
|
||||||
|
["FEEDBACK 113", "Oct 7, 2026 12:00 PM", 2],
|
||||||
|
]);
|
||||||
|
expect(rounds[1].notes[1]).toEqual({
|
||||||
|
codes: ["UNG_113_018_050"],
|
||||||
|
text: "At frame 1018, the mask is still slipping",
|
||||||
|
frame: 1018,
|
||||||
|
});
|
||||||
|
expect(rounds[0].notes[1].frame).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("joins wrapped lines onto the note above, even ones mentioning changes or due", () => {
|
||||||
|
const { rounds } = parseFeedback(`UNG_108_004_030 Comp needs work.
|
||||||
|
Visible matte edge on left of "Top Story" banner.
|
||||||
|
lighting changes, due to the grade`);
|
||||||
|
expect(rounds).toHaveLength(1);
|
||||||
|
expect(rounds[0].title).toBe("");
|
||||||
|
expect(rounds[0].notes[0].text).toBe(
|
||||||
|
'Comp needs work. Visible matte edge on left of "Top Story" banner. lighting changes, due to the grade',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("strips WhatsApp prefixes and formatting, and keeps preamble aside", () => {
|
||||||
|
const { rounds, ignored } = parseFeedback(`[07/10/2026, 09:12] Stephen Abbott: Hi team, notes below
|
||||||
|
[07/10/2026, 09:13] Stephen Abbott: *UNG_113_031_010* As the camera pans the screen comp seems to shrink`);
|
||||||
|
expect(rounds[0].title).toBe("");
|
||||||
|
expect(rounds[0].notes[0].codes).toEqual(["UNG_113_031_010"]);
|
||||||
|
expect(rounds[0].notes[0].text).toBe("As the camera pans the screen comp seems to shrink");
|
||||||
|
expect(ignored).toEqual(["Hi team, notes below"]);
|
||||||
|
|
||||||
|
const plain = parseFeedback("Morning!\nUNG_113_031_010 shrink");
|
||||||
|
expect(plain.ignored).toEqual(["Morning!"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("gives one note per shot when several codes lead the line", () => {
|
||||||
|
const { rounds } = parseFeedback("UNG_113_032_034 / UNG_113_032_036, UNG_113_032_038 same matte fix");
|
||||||
|
expect(rounds[0].notes.map((n) => [n.codes[0], n.text])).toEqual([
|
||||||
|
["UNG_113_032_034", "same matte fix"],
|
||||||
|
["UNG_113_032_036", "same matte fix"],
|
||||||
|
["UNG_113_032_038", "same matte fix"],
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts codes without the show prefix and upper-cases them", () => {
|
||||||
|
const { rounds } = parseFeedback("- 113_018_040: hard blue edge, fr 1004");
|
||||||
|
expect(rounds[0].notes[0]).toMatchObject({ codes: ["113_018_040"], text: "hard blue edge, fr 1004", frame: 1004 });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("parseDue", () => {
|
||||||
|
const now = new Date(2026, 9, 7, 8, 0);
|
||||||
|
|
||||||
|
it("reads a full date and time", () => {
|
||||||
|
const d = parseDue("Oct 7, 2026 12:00 PM", now)!;
|
||||||
|
expect([d.getFullYear(), d.getMonth(), d.getDate(), d.getHours()]).toEqual([2026, 9, 7, 12]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fills in the year", () => {
|
||||||
|
const d = parseDue("7 Oct 16:00", now)!;
|
||||||
|
expect([d.getFullYear(), d.getMonth(), d.getDate(), d.getHours()]).toEqual([2026, 9, 7, 16]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null for what it cannot read", () => {
|
||||||
|
expect(parseDue("end of day", now)).toBeNull();
|
||||||
|
expect(parseDue(null, now)).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("slateFrames", () => {
|
||||||
|
it("uses the recorded slate, else 1 for pipeline previews, else 0", () => {
|
||||||
|
expect(slateFrames({ pictureStartFrame: 2, frameCount: 120 })).toBe(2);
|
||||||
|
expect(slateFrames({ pictureStartFrame: 0, frameCount: 120 })).toBe(0);
|
||||||
|
expect(slateFrames({ pictureStartFrame: null, frameCount: 120 })).toBe(1);
|
||||||
|
expect(slateFrames({ pictureStartFrame: null, frameCount: null })).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("toPlayerFrame", () => {
|
||||||
|
it("maps a comp frame through the shot start and slate", () => {
|
||||||
|
expect(toPlayerFrame(1018, { shotFrameStart: 1001, pictureStart: 1, frameCount: 120 })).toEqual({
|
||||||
|
frame: 18,
|
||||||
|
fromComp: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("assumes the house 1001 start when the shot has none", () => {
|
||||||
|
expect(toPlayerFrame(1018, { shotFrameStart: null, pictureStart: 1, frameCount: 120 }).frame).toBe(18);
|
||||||
|
expect(toPlayerFrame(1018, { shotFrameStart: 0, pictureStart: 0, frameCount: null }).frame).toBe(17);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("takes a small number as the player's own frame", () => {
|
||||||
|
expect(toPlayerFrame(40, { shotFrameStart: 1001, pictureStart: 1, frameCount: 120 })).toEqual({
|
||||||
|
frame: 40,
|
||||||
|
fromComp: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the frame inside the picture", () => {
|
||||||
|
expect(toPlayerFrame(1500, { shotFrameStart: 1001, pictureStart: 1, frameCount: 100 }).frame).toBe(100);
|
||||||
|
expect(toPlayerFrame(500, { shotFrameStart: null, pictureStart: 0, frameCount: 100 }).frame).toBe(99);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,180 @@
|
|||||||
|
/**
|
||||||
|
* Client feedback pasted from WhatsApp (or an email, or the old doc) split
|
||||||
|
* into rounds and per-shot notes.
|
||||||
|
*
|
||||||
|
* [07/10/2026, 09:12] Stephen: FEEDBACK 113 DUE Oct 7, 2026 12:00 PM
|
||||||
|
* UNG_113_018_040 there is a hard blue edge on her finger,
|
||||||
|
* along her thumb, which is not in the OCF
|
||||||
|
* UNG_113_018_050 At frame 1018, the mask is still slipping
|
||||||
|
*
|
||||||
|
* - A line that starts with a shot code starts a note. Several codes before
|
||||||
|
* the text ("UNG_113_032_034 / UNG_113_032_036 same fix") give one note per
|
||||||
|
* shot. Lines without a code continue the note above them — WhatsApp
|
||||||
|
* messages wrap.
|
||||||
|
* - A line with no code that names feedback/notes/a round, mentions a due
|
||||||
|
* date, or ends in ":" starts a new round. "DUE …" is split off as the
|
||||||
|
* round's due text; turning it into a date is left to the browser
|
||||||
|
* (parseDue) so "12:00 PM" means the studio's noon, not the server's.
|
||||||
|
* - WhatsApp's copy prefix ("[07/10, 09:12] Name: ") and bold, italic
|
||||||
|
* and strike markers are stripped.
|
||||||
|
*
|
||||||
|
* Codes are kept as typed; matching them to shots is the server's job.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface FeedbackNote {
|
||||||
|
codes: string[];
|
||||||
|
text: string;
|
||||||
|
/** Frame number as the client quoted it ("at frame 1018"); null = none given */
|
||||||
|
frame: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FeedbackRoundDraft {
|
||||||
|
title: string;
|
||||||
|
dueText: string | null;
|
||||||
|
notes: FeedbackNote[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ParsedFeedback {
|
||||||
|
rounds: FeedbackRoundDraft[];
|
||||||
|
/** Lines before any note that were not a round header ("Hi team") */
|
||||||
|
ignored: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// "[07/10/2026, 09:12]" / "[9:12 AM, 10/7/2026]" then "Sender Name: "
|
||||||
|
const WHATSAPP_PREFIX = /^\[[^\]]*\d[^\]]*\]\s*(?:[^:]{1,60}:\s)?/;
|
||||||
|
const BULLET = /^(?:[-*•–]|\d+[.)])\s+/;
|
||||||
|
// One code: underscore-joined segments. Clients sometimes drop the show
|
||||||
|
// prefix ("113_018_050"), so two segments are enough here.
|
||||||
|
const CODE = "[A-Za-z0-9]+(?:_[A-Za-z0-9]+)+";
|
||||||
|
const LEADING_CODES = new RegExp(`^(${CODE})((?:\\s*(?:,|/|&|\\+|and)?\\s*${CODE})*)`, "i");
|
||||||
|
const HEADER_START = /^(feedback|notes|round|comments)\b/i;
|
||||||
|
const DUE = /\bdue\b[:\s]*(.*)$/i;
|
||||||
|
const DUE_WITH_DATE = /\bdue\b[:\s]+\S*\d/i;
|
||||||
|
const FRAME = /\b(?:frames?|fr\.?|frm)\s*#?\s*(\d{1,6})\b/i;
|
||||||
|
|
||||||
|
function clean(line: string): string {
|
||||||
|
return line
|
||||||
|
.replace(WHATSAPP_PREFIX, "")
|
||||||
|
.replace(/[*~]/g, "") // *bold*, ~strike~
|
||||||
|
// _italic_ only around whole words: shot codes are full of underscores
|
||||||
|
.replace(/(^|\s)_([^_\s][^_]*?)_(?=[\s.,!?]|$)/g, "$1$2")
|
||||||
|
.replace(BULLET, "")
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Strict on purpose: a wrapped note line ("lighting changes, due to the
|
||||||
|
// grade") must continue the note, not start a round.
|
||||||
|
function isHeader(line: string): boolean {
|
||||||
|
if (HEADER_START.test(line)) return true;
|
||||||
|
return line.length <= 60 && (line.endsWith(":") || DUE_WITH_DATE.test(line));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseFeedback(input: string): ParsedFeedback {
|
||||||
|
const rounds: FeedbackRoundDraft[] = [];
|
||||||
|
const ignored: string[] = [];
|
||||||
|
let round: FeedbackRoundDraft | null = null;
|
||||||
|
let note: FeedbackNote[] | null = null; // the note (one per code) being continued
|
||||||
|
|
||||||
|
const startRound = (title: string, dueText: string | null) => {
|
||||||
|
round = { title, dueText, notes: [] };
|
||||||
|
rounds.push(round);
|
||||||
|
note = null;
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const raw of input.split(/\r?\n/)) {
|
||||||
|
const line = clean(raw);
|
||||||
|
if (!line) continue;
|
||||||
|
|
||||||
|
const lead = LEADING_CODES.exec(line);
|
||||||
|
if (lead) {
|
||||||
|
const codes = (lead[0].match(new RegExp(CODE, "g")) ?? []).map((c) => c.toUpperCase());
|
||||||
|
const text = line.slice(lead[0].length).replace(/^[\s:\-–,.]+/, "").trim();
|
||||||
|
if (!round) startRound("", null);
|
||||||
|
note = codes.map((code) => ({ codes: [code], text, frame: null }));
|
||||||
|
round!.notes.push(...note);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isHeader(line)) {
|
||||||
|
const due = DUE.exec(line);
|
||||||
|
const title = (due ? line.slice(0, due.index) : line).replace(/[\s:\-–]+$/, "").trim();
|
||||||
|
startRound(title, due?.[1].trim() || null);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (note) {
|
||||||
|
for (const n of note as FeedbackNote[]) n.text = n.text ? `${n.text} ${line}` : line;
|
||||||
|
} else {
|
||||||
|
ignored.push(line);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const r of rounds as FeedbackRoundDraft[]) {
|
||||||
|
for (const n of r.notes) {
|
||||||
|
const f = FRAME.exec(n.text);
|
||||||
|
n.frame = f ? Number(f[1]) : null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { rounds: rounds.filter((r) => r.notes.length > 0), ignored };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The round's due time from the text the client wrote ("Oct 7, 2026 12:00 PM",
|
||||||
|
* "7 Oct 12:00", "Friday" is not attempted). Run in the browser so the time is
|
||||||
|
* read in the studio's zone. A date without a year takes `now`'s year.
|
||||||
|
*/
|
||||||
|
export function parseDue(dueText: string | null, now = new Date()): Date | null {
|
||||||
|
if (!dueText) return null;
|
||||||
|
const text = dueText.replace(/\b(at|@)\b/gi, " ").replace(/\s+/g, " ").trim();
|
||||||
|
// V8's Date parser accepts almost anything ("end of day 2026"); insist on
|
||||||
|
// a month name or a numeric date before handing it over.
|
||||||
|
if (!/\b(jan|feb|mar|apr|may|jun|jul|aug|sep|oct|nov|dec)[a-z]*\b|\d{1,2}[/.-]\d{1,2}/i.test(text)) return null;
|
||||||
|
const direct = new Date(text);
|
||||||
|
if (!isNaN(direct.getTime()) && /\d{4}/.test(text)) return direct;
|
||||||
|
const withYear = new Date(`${text} ${now.getFullYear()}`);
|
||||||
|
if (!isNaN(withYear.getTime())) return withYear;
|
||||||
|
// "7 Oct 12:00" → move the year before the time so Date can read it
|
||||||
|
const m = /^(.*?)(\d{1,2}:\d{2}\s*(?:am|pm)?)$/i.exec(text);
|
||||||
|
if (m) {
|
||||||
|
const d = new Date(`${m[1].trim()} ${now.getFullYear()} ${m[2]}`);
|
||||||
|
if (!isNaN(d.getTime())) return d;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** First comp frame by house convention, for shots with no frameStart recorded */
|
||||||
|
export const HOUSE_FRAME_START = 1001;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Slate frames before the picture in a version's review media. Recorded on
|
||||||
|
* newer versions; otherwise pipeline previews (the ones that record an EXR
|
||||||
|
* frameCount) open with a 1-frame slate and other uploads with none.
|
||||||
|
* Version.duration is not stored, so the length-based inference in
|
||||||
|
* lib/handles.ts is not available here.
|
||||||
|
*/
|
||||||
|
export function slateFrames(v: { pictureStartFrame: number | null; frameCount: number | null }): number {
|
||||||
|
if (v.pictureStartFrame != null && v.pictureStartFrame >= 0) return v.pictureStartFrame;
|
||||||
|
return v.frameCount != null ? 1 : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The player frame (0-based, in the review media) for a frame the client
|
||||||
|
* quoted. Clients read the burned-in comp frame (1001-based), so a quote at
|
||||||
|
* or past the shot's first frame is mapped through it and the slate; a
|
||||||
|
* smaller number is taken as the player's own counter.
|
||||||
|
*/
|
||||||
|
export function toPlayerFrame(
|
||||||
|
quoted: number,
|
||||||
|
opts: { shotFrameStart: number | null; pictureStart: number; frameCount: number | null },
|
||||||
|
): { frame: number; fromComp: boolean } {
|
||||||
|
const { pictureStart, frameCount } = opts;
|
||||||
|
const compStart = opts.shotFrameStart && opts.shotFrameStart > 0 ? opts.shotFrameStart : HOUSE_FRAME_START;
|
||||||
|
const last = frameCount && frameCount > 0 ? pictureStart + frameCount - 1 : null;
|
||||||
|
const clamp = (f: number, min: number) => Math.max(min, last != null ? Math.min(last, f) : f);
|
||||||
|
|
||||||
|
if (quoted >= compStart) {
|
||||||
|
return { frame: clamp(quoted - compStart + pictureStart, pictureStart), fromComp: true };
|
||||||
|
}
|
||||||
|
return { frame: clamp(quoted, 0), fromComp: false };
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
-- Client feedback pasted from WhatsApp: rounds group the notes on the board,
|
||||||
|
-- and each note is saved as a player comment tagged with its source.
|
||||||
|
ALTER TABLE "comments" ADD COLUMN "source" TEXT;
|
||||||
|
|
||||||
|
CREATE TABLE "feedback_rounds" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"projectId" TEXT NOT NULL,
|
||||||
|
"title" TEXT NOT NULL,
|
||||||
|
"dueAt" TIMESTAMP(3),
|
||||||
|
"createdById" TEXT NOT NULL,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "feedback_rounds_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
ALTER TABLE "board_items" ADD COLUMN "roundId" TEXT;
|
||||||
|
ALTER TABLE "board_items" ADD COLUMN "commentId" TEXT;
|
||||||
|
|
||||||
|
CREATE INDEX "feedback_rounds_projectId_idx" ON "feedback_rounds"("projectId");
|
||||||
|
|
||||||
|
ALTER TABLE "feedback_rounds" ADD CONSTRAINT "feedback_rounds_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "projects"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
ALTER TABLE "board_items" ADD CONSTRAINT "board_items_roundId_fkey" FOREIGN KEY ("roundId") REFERENCES "feedback_rounds"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
@@ -390,6 +390,7 @@ model Project {
|
|||||||
boardStatusAt DateTime?
|
boardStatusAt DateTime?
|
||||||
boardItems BoardItem[]
|
boardItems BoardItem[]
|
||||||
milestones ProjectMilestone[]
|
milestones ProjectMilestone[]
|
||||||
|
feedbackRounds FeedbackRound[]
|
||||||
|
|
||||||
@@map("projects")
|
@@map("projects")
|
||||||
}
|
}
|
||||||
@@ -612,6 +613,10 @@ model Comment {
|
|||||||
timestamp Float
|
timestamp Float
|
||||||
text String @db.Text
|
text String @db.Text
|
||||||
isResolved Boolean @default(false)
|
isResolved Boolean @default(false)
|
||||||
|
/// Where the note came from when it was not typed in the player. "WHATSAPP"
|
||||||
|
/// = client feedback pasted from a message; `authorId` is then whoever
|
||||||
|
/// logged it, not who said it, and the player labels it client feedback.
|
||||||
|
source String?
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
@@ -1288,6 +1293,12 @@ model BoardItem {
|
|||||||
assignee User? @relation("BoardItemAssignee", fields: [assigneeId], references: [id], onDelete: SetNull)
|
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
|
/// What the line is blocked on ("client GFX ADH_GFX_013_010"); null = not blocked
|
||||||
waitingOn String?
|
waitingOn String?
|
||||||
|
/// Set when the line came from pasted client feedback: the round it belongs
|
||||||
|
/// to, and the player comment it was saved as. Ticking the line off
|
||||||
|
/// resolves that comment.
|
||||||
|
roundId String?
|
||||||
|
round FeedbackRound? @relation(fields: [roundId], references: [id], onDelete: SetNull)
|
||||||
|
commentId String?
|
||||||
sortOrder Int @default(0)
|
sortOrder Int @default(0)
|
||||||
doneAt DateTime?
|
doneAt DateTime?
|
||||||
doneById String?
|
doneById String?
|
||||||
@@ -1300,6 +1311,23 @@ model BoardItem {
|
|||||||
@@map("board_items")
|
@@map("board_items")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// One batch of client feedback as it arrived ("FEEDBACK 113 DUE Oct 7 12:00
|
||||||
|
/// PM"). Its notes are saved as player comments and as board lines; the round
|
||||||
|
/// is what groups them on the board under one due time.
|
||||||
|
model FeedbackRound {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
projectId String
|
||||||
|
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
|
||||||
|
title String
|
||||||
|
dueAt DateTime?
|
||||||
|
createdById String
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
items BoardItem[]
|
||||||
|
|
||||||
|
@@index([projectId])
|
||||||
|
@@map("feedback_rounds")
|
||||||
|
}
|
||||||
|
|
||||||
/// A dated project milestone — batch deliveries, picture locks, shoot start,
|
/// A dated project milestone — batch deliveries, picture locks, shoot start,
|
||||||
/// colour & mix review. Episode due dates stay in EpisodeDueDate because the
|
/// colour & mix review. Episode due dates stay in EpisodeDueDate because the
|
||||||
/// shot-status board keys off them; these are the dates around the work.
|
/// shot-status board keys off them; these are the dates around the work.
|
||||||
|
|||||||
@@ -64,6 +64,8 @@ export interface CommentWithReplies {
|
|||||||
timestamp: number;
|
timestamp: number;
|
||||||
text: string;
|
text: string;
|
||||||
isResolved: boolean;
|
isResolved: boolean;
|
||||||
|
/** "WHATSAPP" = client feedback pasted from a message; author is who logged it */
|
||||||
|
source?: string | null;
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
updatedAt: Date;
|
updatedAt: Date;
|
||||||
author: {
|
author: {
|
||||||
|
|||||||
Reference in New Issue
Block a user