34af26dfbf
A /board page that replaces the shared doc used as the day-to-day tracker. One card per active/on-hold project with a status line, dated milestones and to-do lines typed or pasted in the doc's own format: DH2_VFX_027_090 finalise (CHRIS) DH2_VFX_005_010 gfx update @michelle wait: client GFX ADH_GFX_005_020 Shot codes link to the shot (live status + latest version), (NAME)/@name assigns, wait: marks a blocker, headings group lines. Codes that match no shot are flagged, with a relink when there is an obvious near miss. Board lines are their own model, not Tasks: tasks drive shot status and notifications, and a note like "finalise" must not touch either. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
184 lines
5.7 KiB
TypeScript
184 lines
5.7 KiB
TypeScript
"use server";
|
|
|
|
import { auth } from "@/auth";
|
|
import { db } from "@/lib/db";
|
|
import { revalidatePath } from "next/cache";
|
|
import { z } from "zod";
|
|
import { findShot, parseBoardText } from "@/lib/board-parse";
|
|
|
|
// The board is studio-internal. Anyone on staff can add and tick off lines;
|
|
// it is a shared to-do list, not an approval surface.
|
|
async function requireStaff() {
|
|
const session = await auth();
|
|
if (!session?.user) throw new Error("Unauthorized");
|
|
if (session.user.role === "CLIENT") throw new Error("Insufficient permissions");
|
|
return session.user;
|
|
}
|
|
|
|
async function boardContext(projectId: string) {
|
|
const [shots, users] = await Promise.all([
|
|
db.shot.findMany({
|
|
where: { projectId },
|
|
select: { id: true, shotCode: true, clientCode: true },
|
|
}),
|
|
db.user.findMany({
|
|
where: { isActive: true, role: { not: "CLIENT" } },
|
|
select: { id: true, name: true, email: true },
|
|
}),
|
|
]);
|
|
return { shots, users };
|
|
}
|
|
|
|
const addSchema = z.object({
|
|
projectId: z.string().cuid(),
|
|
input: z.string().min(1).max(20000),
|
|
group: z.string().max(100).optional(),
|
|
});
|
|
|
|
/** Adds one line, or a pasted block of lines, to a project's board. */
|
|
export async function addBoardItems(data: z.infer<typeof addSchema>) {
|
|
const user = await requireStaff();
|
|
const parsed = addSchema.parse(data);
|
|
|
|
const items = parseBoardText(parsed.input, {
|
|
...(await boardContext(parsed.projectId)),
|
|
group: parsed.group,
|
|
});
|
|
if (items.length === 0) return { success: true, added: 0, unmatched: [] as string[] };
|
|
|
|
const last = await db.boardItem.findFirst({
|
|
where: { projectId: parsed.projectId },
|
|
orderBy: { sortOrder: "desc" },
|
|
select: { sortOrder: true },
|
|
});
|
|
const base = (last?.sortOrder ?? -1) + 1;
|
|
|
|
await db.boardItem.createMany({
|
|
data: items.map((item, i) => ({
|
|
projectId: parsed.projectId,
|
|
shotId: item.shotId,
|
|
code: item.code,
|
|
text: item.text,
|
|
group: item.group,
|
|
assigneeId: item.assigneeId,
|
|
waitingOn: item.waitingOn,
|
|
sortOrder: base + i,
|
|
createdById: user.id,
|
|
})),
|
|
});
|
|
|
|
revalidatePath("/board");
|
|
return {
|
|
success: true,
|
|
added: items.length,
|
|
unmatched: items.filter((i) => i.code && !i.shotId).map((i) => i.code as string),
|
|
};
|
|
}
|
|
|
|
const updateSchema = z.object({
|
|
text: z.string().max(5000).optional(),
|
|
code: z.string().max(100).nullable().optional(),
|
|
group: z.string().max(100).optional(),
|
|
assigneeId: z.string().cuid().nullable().optional(),
|
|
waitingOn: z.string().max(500).nullable().optional(),
|
|
});
|
|
|
|
export async function updateBoardItem(id: string, data: z.infer<typeof updateSchema>) {
|
|
await requireStaff();
|
|
const parsed = updateSchema.parse(data);
|
|
const item = await db.boardItem.findUnique({ where: { id }, select: { projectId: true } });
|
|
if (!item) throw new Error("Board item not found");
|
|
|
|
// Changing the code re-links the line; a code that matches nothing is kept
|
|
// as a flag, exactly as when it was first typed.
|
|
let link: { code: string | null; shotId: string | null } | undefined;
|
|
if (parsed.code !== undefined) {
|
|
const code = parsed.code?.trim() || null;
|
|
const shot = code ? findShot(code, (await boardContext(item.projectId)).shots) : null;
|
|
link = { code: shot?.shotCode ?? code?.toUpperCase() ?? null, shotId: shot?.id ?? null };
|
|
}
|
|
|
|
await db.boardItem.update({
|
|
where: { id },
|
|
data: {
|
|
...(parsed.text !== undefined && { text: parsed.text.trim() }),
|
|
...(parsed.group !== undefined && { group: parsed.group.trim() }),
|
|
...(parsed.assigneeId !== undefined && { assigneeId: parsed.assigneeId }),
|
|
...(parsed.waitingOn !== undefined && { waitingOn: parsed.waitingOn?.trim() || null }),
|
|
...link,
|
|
},
|
|
});
|
|
|
|
revalidatePath("/board");
|
|
return { success: true };
|
|
}
|
|
|
|
export async function setBoardItemDone(id: string, done: boolean) {
|
|
const user = await requireStaff();
|
|
await db.boardItem.update({
|
|
where: { id },
|
|
data: done ? { doneAt: new Date(), doneById: user.id } : { doneAt: null, doneById: null },
|
|
});
|
|
revalidatePath("/board");
|
|
return { success: true };
|
|
}
|
|
|
|
export async function deleteBoardItem(id: string) {
|
|
await requireStaff();
|
|
await db.boardItem.delete({ where: { id } });
|
|
revalidatePath("/board");
|
|
return { success: true };
|
|
}
|
|
|
|
const statusSchema = z.object({
|
|
projectId: z.string().cuid(),
|
|
status: z.string().max(2000),
|
|
});
|
|
|
|
export async function setProjectBoardStatus(data: z.infer<typeof statusSchema>) {
|
|
await requireStaff();
|
|
const parsed = statusSchema.parse(data);
|
|
const status = parsed.status.trim() || null;
|
|
await db.project.update({
|
|
where: { id: parsed.projectId },
|
|
data: { boardStatus: status, boardStatusAt: status ? new Date() : null },
|
|
});
|
|
revalidatePath("/board");
|
|
return { success: true };
|
|
}
|
|
|
|
const milestoneSchema = z.object({
|
|
projectId: z.string().cuid(),
|
|
label: z.string().min(1).max(200),
|
|
date: z.string().min(1),
|
|
});
|
|
|
|
export async function addMilestone(data: z.infer<typeof milestoneSchema>) {
|
|
await requireStaff();
|
|
const parsed = milestoneSchema.parse(data);
|
|
const date = new Date(parsed.date);
|
|
if (isNaN(date.getTime())) throw new Error("Invalid date");
|
|
await db.projectMilestone.create({
|
|
data: { projectId: parsed.projectId, label: parsed.label.trim(), date },
|
|
});
|
|
revalidatePath("/board");
|
|
return { success: true };
|
|
}
|
|
|
|
export async function setMilestoneDone(id: string, done: boolean) {
|
|
await requireStaff();
|
|
await db.projectMilestone.update({
|
|
where: { id },
|
|
data: { doneAt: done ? new Date() : null },
|
|
});
|
|
revalidatePath("/board");
|
|
return { success: true };
|
|
}
|
|
|
|
export async function deleteMilestone(id: string) {
|
|
await requireStaff();
|
|
await db.projectMilestone.delete({ where: { id } });
|
|
revalidatePath("/board");
|
|
return { success: true };
|
|
}
|