CSV Group updates
Deploy / deploy (push) Successful in 2m45s

This commit is contained in:
twotalesanimation
2026-08-06 09:13:16 +02:00
parent 5f3c89119a
commit 98120f3ca8
3 changed files with 350 additions and 5 deletions
+68
View File
@@ -958,6 +958,7 @@ export interface SimpleCsvRow {
seqTimecodeStart: string; seqTimecodeStart: string;
seqTimecodeEnd: string; seqTimecodeEnd: string;
description: string; description: string;
group: string;
action: "create" | "update" | "skip"; action: "create" | "update" | "skip";
} }
@@ -985,12 +986,20 @@ export async function importShotsFromSimpleCsv(
}); });
if (existing) { if (existing) {
const shotGroupId = row.group?.trim()
? (await db.shotGroup.upsert({
where: { projectId_name: { projectId, name: row.group.trim() } },
create: { projectId, name: row.group.trim() },
update: {},
})).id
: undefined;
await db.shot.update({ await db.shot.update({
where: { id: existing.id }, where: { id: existing.id },
data: { data: {
description: row.description || null, description: row.description || null,
seqTimecodeStart: row.seqTimecodeStart || null, seqTimecodeStart: row.seqTimecodeStart || null,
seqTimecodeEnd: row.seqTimecodeEnd || null, seqTimecodeEnd: row.seqTimecodeEnd || null,
...(shotGroupId ? { shotGroupId } : {}),
}, },
}); });
updated.push(row.shotCode); updated.push(row.shotCode);
@@ -1004,6 +1013,13 @@ export async function importShotsFromSimpleCsv(
select: { shotNumber: true }, select: { shotNumber: true },
}); });
const shotNumber = (maxNum?.shotNumber ?? 0) + 10; const shotNumber = (maxNum?.shotNumber ?? 0) + 10;
const shotGroupId = row.group?.trim()
? (await db.shotGroup.upsert({
where: { projectId_name: { projectId, name: row.group.trim() } },
create: { projectId, name: row.group.trim() },
update: {},
})).id
: undefined;
await db.shot.create({ await db.shot.create({
data: { data: {
@@ -1015,6 +1031,7 @@ export async function importShotsFromSimpleCsv(
description: row.description || null, description: row.description || null,
seqTimecodeStart: row.seqTimecodeStart || null, seqTimecodeStart: row.seqTimecodeStart || null,
seqTimecodeEnd: row.seqTimecodeEnd || null, seqTimecodeEnd: row.seqTimecodeEnd || null,
shotGroupId,
}, },
}); });
created.push(row.shotCode); created.push(row.shotCode);
@@ -1031,6 +1048,57 @@ export async function importShotsFromSimpleCsv(
return { created, updated, skipped, errors }; return { created, updated, skipped, errors };
} }
// ── Group Assignment ──────────────────────────────────────────────────────────────────
export interface GroupAssignRow {
shotCode: string;
groupName: string;
exists: boolean;
}
export async function assignShotGroups(
projectId: string,
rows: GroupAssignRow[]
): Promise<{ assigned: string[]; skipped: string[]; errors: string[] }> {
const session = await auth();
if (!session?.user) throw new Error("Unauthorized");
if (!["ADMIN", "PRODUCER", "SUPERVISOR"].includes(session.user.role)) {
throw new Error("Insufficient permissions");
}
const assigned: string[] = [];
const skipped: string[] = [];
const errors: string[] = [];
for (const row of rows) {
if (!row.exists) { skipped.push(row.shotCode); continue; }
try {
const shot = await db.shot.findFirst({
where: { projectId, shotCode: row.shotCode },
select: { id: true },
});
if (!shot) { skipped.push(row.shotCode); continue; }
const group = await db.shotGroup.upsert({
where: { projectId_name: { projectId, name: row.groupName.trim() } },
create: { projectId, name: row.groupName.trim() },
update: {},
});
await db.shot.update({
where: { id: shot.id },
data: { shotGroupId: group.id },
});
assigned.push(row.shotCode);
} catch (e) {
errors.push(`${row.shotCode}: ${e instanceof Error ? e.message : "Unknown error"}`);
}
}
revalidatePath(`/projects/${projectId}`);
return { assigned, skipped, errors };
}
/** /**
* Internal: handle client requesting changes on a shot. * Internal: handle client requesting changes on a shot.
* Resets shotApprovalStatus = PENDING, sharedWithClient = false. * Resets shotApprovalStatus = PENDING, sharedWithClient = false.
@@ -19,9 +19,9 @@ import {
Film, Film,
FileSpreadsheet, FileSpreadsheet,
} from "lucide-react"; } from "lucide-react";
import { parseEdlCsv, parsePictureTrackerCsv, parseSimpleCsv } from "@/lib/edl-utils"; import { parseEdlCsv, parsePictureTrackerCsv, parseSimpleCsv, parseGroupAssignCsv } from "@/lib/edl-utils";
import type { EdlImportRow, PictureTrackerRow, SimpleCsvRow } from "@/lib/edl-utils"; import type { EdlImportRow, PictureTrackerRow, SimpleCsvRow, GroupAssignRow } from "@/lib/edl-utils";
import { importShotsFromEdl, updateShotsSeqTimecodes, importShotsFromSimpleCsv } from "@/actions/shots"; import { importShotsFromEdl, updateShotsSeqTimecodes, importShotsFromSimpleCsv, assignShotGroups } from "@/actions/shots";
interface EdlImportClientProps { interface EdlImportClientProps {
projectId: string; projectId: string;
@@ -64,6 +64,15 @@ export function EdlImportClient({ projectId, projectName, existingShotCodes }: E
const [scImporting, setScImporting] = useState(false); const [scImporting, setScImporting] = useState(false);
const [scResult, setScResult] = useState<{ created: string[]; updated: string[]; skipped: string[]; errors: string[] } | null>(null); const [scResult, setScResult] = useState<{ created: string[]; updated: string[]; skipped: string[]; errors: string[] } | null>(null);
// ── Group Assignment state ───────────────────────────────────────────────
const gaFileInputRef = useRef<HTMLInputElement>(null);
const [gaStep, setGaStep] = useState<"input" | "preview" | "result">("input");
const [gaCsvText, setGaCsvText] = useState("");
const [gaRows, setGaRows] = useState<GroupAssignRow[]>([]);
const [gaErrors, setGaErrors] = useState<string[]>([]);
const [gaAssigning, setGaAssigning] = useState(false);
const [gaResult, setGaResult] = useState<{ assigned: string[]; skipped: string[]; errors: string[] } | null>(null);
// ── Picture Tracker state ───────────────────────────────────────────────── // ── Picture Tracker state ─────────────────────────────────────────────────
const ptFileInputRef = useRef<HTMLInputElement>(null); const ptFileInputRef = useRef<HTMLInputElement>(null);
const [ptStep, setPtStep] = useState<"input" | "preview" | "result">("input"); const [ptStep, setPtStep] = useState<"input" | "preview" | "result">("input");
@@ -193,6 +202,47 @@ export function EdlImportClient({ projectId, projectName, existingShotCodes }: E
setScResult(null); setScResult(null);
}; };
// ── Group Assignment handlers ────────────────────────────────────────────
const handleGaFileUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
const reader = new FileReader();
reader.onload = (ev) => setGaCsvText(ev.target?.result as string ?? "");
reader.readAsText(file);
e.target.value = "";
};
const handleGaParse = useCallback(() => {
const { rows: parsed, errors } = parseGroupAssignCsv(gaCsvText, existingShotCodes);
setGaErrors(errors);
setGaRows(parsed);
if (parsed.length > 0) setGaStep("preview");
}, [gaCsvText, existingShotCodes]);
const handleGaAssign = async () => {
setGaAssigning(true);
try {
const res = await assignShotGroups(projectId, gaRows);
setGaResult(res);
setGaStep("result");
if (res.assigned.length > 0) {
toast({ title: "Groups assigned", description: `${res.assigned.length} shot${res.assigned.length !== 1 ? "s" : ""} updated` });
}
} catch (e) {
toast({ title: "Assignment failed", description: e instanceof Error ? e.message : undefined, variant: "destructive" });
} finally {
setGaAssigning(false);
}
};
const handleGaReset = () => {
setGaStep("input");
setGaCsvText("");
setGaRows([]);
setGaErrors([]);
setGaResult(null);
};
// ── Picture Tracker handlers ────────────────────────────────────────────── // ── Picture Tracker handlers ──────────────────────────────────────────────
const handlePtFileUpload = (e: React.ChangeEvent<HTMLInputElement>) => { const handlePtFileUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]; const file = e.target.files?.[0];
@@ -572,18 +622,19 @@ export function EdlImportClient({ projectId, projectName, existingShotCodes }: E
)} )}
<div className="rounded-xl border border-zinc-800 overflow-hidden"> <div className="rounded-xl border border-zinc-800 overflow-hidden">
<div className="grid grid-cols-[auto_1fr_1fr_1fr_2fr] text-xs font-medium text-zinc-500 uppercase tracking-wider bg-zinc-900 px-4 py-2.5 gap-4 border-b border-zinc-800"> <div className="grid grid-cols-[auto_1fr_1fr_1fr_1fr_1fr] text-xs font-medium text-zinc-500 uppercase tracking-wider bg-zinc-900 px-4 py-2.5 gap-4 border-b border-zinc-800">
<span>Action</span> <span>Action</span>
<span>Shot Code</span> <span>Shot Code</span>
<span>Seq TC In</span> <span>Seq TC In</span>
<span>Seq TC Out</span> <span>Seq TC Out</span>
<span>Group</span>
<span>Description</span> <span>Description</span>
</div> </div>
<div className="divide-y divide-zinc-800/60 bg-zinc-950/40 max-h-[400px] overflow-y-auto"> <div className="divide-y divide-zinc-800/60 bg-zinc-950/40 max-h-[400px] overflow-y-auto">
{scRows.map((row, i) => { {scRows.map((row, i) => {
const ActionIcon = ACTION_ICONS[row.action]; const ActionIcon = ACTION_ICONS[row.action];
return ( return (
<div key={i} className="grid grid-cols-[auto_1fr_1fr_1fr_2fr] items-center gap-4 px-4 py-3 text-sm"> <div key={i} className="grid grid-cols-[auto_1fr_1fr_1fr_1fr_1fr] items-center gap-4 px-4 py-3 text-sm">
<button <button
onClick={() => toggleScAction(i)} onClick={() => toggleScAction(i)}
title="Click to cycle: create → update → skip" title="Click to cycle: create → update → skip"
@@ -598,6 +649,7 @@ export function EdlImportClient({ projectId, projectName, existingShotCodes }: E
<span className="font-mono text-xs text-zinc-200 truncate">{row.shotCode}</span> <span className="font-mono text-xs text-zinc-200 truncate">{row.shotCode}</span>
<span className="font-mono text-xs text-zinc-400">{row.seqTimecodeStart || "—"}</span> <span className="font-mono text-xs text-zinc-400">{row.seqTimecodeStart || "—"}</span>
<span className="font-mono text-xs text-zinc-400">{row.seqTimecodeEnd || "—"}</span> <span className="font-mono text-xs text-zinc-400">{row.seqTimecodeEnd || "—"}</span>
<span className="text-xs text-zinc-300 truncate">{row.group || <span className="text-zinc-600">none</span>}</span>
<span className="text-xs text-zinc-500 truncate">{row.description || "—"}</span> <span className="text-xs text-zinc-500 truncate">{row.description || "—"}</span>
</div> </div>
); );
@@ -678,6 +730,166 @@ export function EdlImportClient({ projectId, projectName, existingShotCodes }: E
)} )}
</div> </div>
{/* ── GROUP ASSIGNMENT PANEL ─────────────────────────────────────── */}
<div className="pt-6 border-t border-zinc-800 space-y-4">
<div>
<h2 className="text-base font-semibold text-white">Assign Shots to Groups</h2>
<p className="text-sm text-zinc-500 mt-0.5">
Bulk-assign existing shots to groups from a CSV with <span className="font-mono text-zinc-300">Shot Name</span> and <span className="font-mono text-zinc-300">Group</span> columns. Groups are created automatically if they dont exist.
</p>
</div>
{gaStep === "input" && (
<div className="space-y-4">
<div className="rounded-xl border border-zinc-800 bg-zinc-900 p-5 space-y-4">
<div className="flex items-center justify-between">
<p className="text-sm font-medium text-zinc-300">Paste CSV or upload file</p>
<div className="flex items-center gap-2">
<input ref={gaFileInputRef} type="file" accept=".csv,text/csv" className="hidden" onChange={handleGaFileUpload} />
<Button variant="outline" size="sm" className="gap-1.5 h-7" onClick={() => gaFileInputRef.current?.click()}>
<Upload className="h-3.5 w-3.5" />
Upload .csv
</Button>
</div>
</div>
<Textarea
value={gaCsvText}
onChange={(e) => setGaCsvText(e.target.value)}
placeholder={`Shot Name,Group\nUNG_108_001_010,Action Sequences\nUNG_108_002_020,Compositing`}
className="font-mono text-xs min-h-[180px] bg-zinc-950 border-zinc-700 resize-y"
spellCheck={false}
/>
<div className="rounded-lg bg-zinc-950 border border-zinc-800 p-3 text-xs text-zinc-400 space-y-1">
<p className="font-medium text-zinc-300">Expected format</p>
<p>Required: <span className="font-mono text-amber-400">Shot Name</span>, <span className="font-mono text-amber-400">Group</span></p>
<p>Only shots that already exist in this project are updated. Unknown shots are skipped.</p>
</div>
</div>
<div className="flex justify-end">
<Button onClick={handleGaParse} disabled={!gaCsvText.trim()} className="gap-2">
<Film className="h-4 w-4" />
Parse &amp; Preview
</Button>
</div>
</div>
)}
{gaStep === "preview" && (
<div className="space-y-4">
<div className="flex flex-wrap items-center gap-3">
<div className="flex items-center gap-2 px-3 py-1.5 rounded-lg bg-blue-500/10 border border-blue-500/20 text-blue-400 text-sm">
<Pencil className="h-3.5 w-3.5" />
{gaRows.filter((r) => r.exists).length} to assign
</div>
{gaRows.filter((r) => !r.exists).length > 0 && (
<div className="flex items-center gap-2 px-3 py-1.5 rounded-lg bg-zinc-500/10 border border-zinc-500/20 text-zinc-400 text-sm">
<SkipForward className="h-3.5 w-3.5" />
{gaRows.filter((r) => !r.exists).length} not found (will skip)
</div>
)}
</div>
{gaErrors.length > 0 && (
<div className="rounded-lg bg-red-500/10 border border-red-500/20 p-3 space-y-1">
<p className="text-xs font-medium text-red-400 flex items-center gap-1.5">
<AlertCircle className="h-3.5 w-3.5" /> {gaErrors.length} parse warning{gaErrors.length !== 1 ? "s" : ""}
</p>
{gaErrors.map((e, i) => <p key={i} className="text-xs text-red-300 pl-5">{e}</p>)}
</div>
)}
<div className="rounded-xl border border-zinc-800 overflow-hidden">
<div className="grid grid-cols-[1fr_1fr_auto] text-xs font-medium text-zinc-500 uppercase tracking-wider bg-zinc-900 px-4 py-2.5 gap-4 border-b border-zinc-800">
<span>Shot Code</span>
<span>Group</span>
<span>Status</span>
</div>
<div className="divide-y divide-zinc-800/60 bg-zinc-950/40 max-h-[400px] overflow-y-auto">
{gaRows.map((row, i) => (
<div key={i} className="grid grid-cols-[1fr_1fr_auto] items-center gap-4 px-4 py-3 text-sm">
<span className="font-mono text-xs text-zinc-200">{row.shotCode}</span>
<span className="text-xs text-zinc-300">{row.groupName}</span>
{row.exists ? (
<span className="inline-flex items-center gap-1 text-xs px-2 py-0.5 rounded border bg-blue-500/10 text-blue-400 border-blue-500/20">
<Pencil className="h-3 w-3" /> assign
</span>
) : (
<span className="inline-flex items-center gap-1 text-xs px-2 py-0.5 rounded border bg-zinc-500/10 text-zinc-400 border-zinc-500/20">
<SkipForward className="h-3 w-3" /> skip
</span>
)}
</div>
))}
</div>
</div>
<div className="flex items-center justify-between">
<Button variant="outline" onClick={handleGaReset}>
<ArrowLeft className="h-4 w-4 mr-1.5" /> Back
</Button>
<Button
onClick={handleGaAssign}
disabled={gaAssigning || gaRows.filter((r) => r.exists).length === 0}
className="gap-2"
>
{gaAssigning ? <RefreshCw className="h-4 w-4 animate-spin" /> : <Upload className="h-4 w-4" />}
{gaAssigning
? "Assigning…"
: `Assign ${gaRows.filter((r) => r.exists).length} shot${gaRows.filter((r) => r.exists).length !== 1 ? "s" : ""}`}
</Button>
</div>
</div>
)}
{gaStep === "result" && gaResult && (
<div className="space-y-4">
<div className="rounded-xl border border-zinc-800 bg-zinc-900 p-6 space-y-5">
<h2 className="text-base font-semibold text-white flex items-center gap-2">
<CheckCircle2 className="h-5 w-5 text-emerald-400" />
Group assignment complete
</h2>
{gaResult.assigned.length > 0 && (
<div className="space-y-2">
<p className="text-xs font-medium text-blue-400 uppercase tracking-wide">Assigned ({gaResult.assigned.length})</p>
<div className="flex flex-wrap gap-1.5">
{gaResult.assigned.map((c) => (
<span key={c} className="font-mono text-xs px-2 py-0.5 rounded bg-blue-500/10 border border-blue-500/20 text-blue-300">{c}</span>
))}
</div>
</div>
)}
{gaResult.skipped.length > 0 && (
<div className="space-y-2">
<p className="text-xs font-medium text-zinc-500 uppercase tracking-wide">Skipped / not found ({gaResult.skipped.length})</p>
<div className="flex flex-wrap gap-1.5">
{gaResult.skipped.map((c) => (
<span key={c} className="font-mono text-xs px-2 py-0.5 rounded bg-zinc-800 border border-zinc-700 text-zinc-400">{c}</span>
))}
</div>
</div>
)}
{gaResult.errors.length > 0 && (
<div className="space-y-2">
<p className="text-xs font-medium text-red-400 uppercase tracking-wide flex items-center gap-1.5">
<AlertCircle className="h-3.5 w-3.5" /> Errors ({gaResult.errors.length})
</p>
{gaResult.errors.map((e, i) => (
<p key={i} className="text-xs text-red-300 pl-5">{e}</p>
))}
</div>
)}
</div>
<div className="flex items-center gap-3">
<Button variant="outline" onClick={handleGaReset}>Import Another</Button>
<Button onClick={() => router.push(`/projects/${projectId}`)}>Back to Project</Button>
</div>
</div>
)}
</div>
{/* ── PICTURE TRACKER PANEL ─────────────────────────────────────── */} {/* ── PICTURE TRACKER PANEL ─────────────────────────────────────── */}
<div className="pt-6 border-t border-zinc-800 space-y-4"> <div className="pt-6 border-t border-zinc-800 space-y-4">
<div> <div>
+65
View File
@@ -192,6 +192,7 @@ export interface SimpleCsvRow {
seqTimecodeStart: string; seqTimecodeStart: string;
seqTimecodeEnd: string; seqTimecodeEnd: string;
description: string; description: string;
group: string;
action: "create" | "update" | "skip"; action: "create" | "update" | "skip";
} }
@@ -222,6 +223,7 @@ export function parseSimpleCsv(
const tcInIdx = col(["time code in", "timecode in", "tc in", "tcin", "timecode start", "tc start"]); const tcInIdx = col(["time code in", "timecode in", "tc in", "tcin", "timecode start", "tc start"]);
const tcOutIdx = col(["time code out", "timecode out", "tc out", "tcout", "timecode end", "tc end"]); const tcOutIdx = col(["time code out", "timecode out", "tc out", "tcout", "timecode end", "tc end"]);
const descIdx = col(["description", "desc", "notes", "note"]); const descIdx = col(["description", "desc", "notes", "note"]);
const groupIdx = col(["group", "shot group", "shotgroup", "group name"]);
if (shotNameIdx === -1) { if (shotNameIdx === -1) {
return { rows: [], errors: ['CSV must contain a "Shot Name" (or "Shot Code") column'] }; return { rows: [], errors: ['CSV must contain a "Shot Name" (or "Shot Code") column'] };
@@ -255,9 +257,72 @@ export function parseSimpleCsv(
seqTimecodeStart: tcIn, seqTimecodeStart: tcIn,
seqTimecodeEnd: tcOut, seqTimecodeEnd: tcOut,
description: get(descIdx), description: get(descIdx),
group: get(groupIdx),
action: existingCodes.has(shotCode) ? "update" : "create", action: existingCodes.has(shotCode) ? "update" : "create",
}); });
} }
return { rows, errors }; return { rows, errors };
} }
// ── Group Assignment CSV ──────────────────────────────────────────────────────
export interface GroupAssignRow {
shotCode: string;
groupName: string;
exists: boolean;
}
/**
* Parse a group-assignment CSV: Shot Name, Group.
* Only shots that already exist in the project will be updated.
*/
export function parseGroupAssignCsv(
csvText: string,
existingCodes: Set<string>
): { rows: GroupAssignRow[]; errors: string[] } {
const lines = csvText.split(/\r?\n/).map((l) => l.trim()).filter(Boolean);
if (lines.length < 2) {
return { rows: [], errors: ["CSV must have a header row and at least one data row"] };
}
const rawHeaders = parseCsvLine(lines[0]).map((h) => h.toLowerCase().trim());
const col = (names: string[]) => {
for (const name of names) {
const idx = rawHeaders.indexOf(name);
if (idx !== -1) return idx;
}
return -1;
};
const shotNameIdx = col(["shot name", "shot code", "shotcode", "shot", "name"]);
const groupIdx = col(["group", "shot group", "shotgroup", "group name"]);
if (shotNameIdx === -1) {
return { rows: [], errors: ['CSV must contain a "Shot Name" (or "Shot Code") column'] };
}
if (groupIdx === -1) {
return { rows: [], errors: ['CSV must contain a "Group" column'] };
}
const rows: GroupAssignRow[] = [];
const errors: string[] = [];
const seen = new Set<string>();
for (let i = 1; i < lines.length; i++) {
const cells = parseCsvLine(lines[i]);
const get = (idx: number) =>
idx !== -1 && idx < cells.length ? (cells[idx] ?? "").trim() : "";
const shotCode = get(shotNameIdx);
const groupName = get(groupIdx);
if (!shotCode) { errors.push(`Row ${i + 1}: empty Shot Name — skipped`); continue; }
if (!groupName) { errors.push(`Row ${i + 1} (${shotCode}): empty Group — skipped`); continue; }
if (seen.has(shotCode)) continue;
seen.add(shotCode);
rows.push({ shotCode, groupName, exists: existingCodes.has(shotCode) });
}
return { rows, errors };
}