Files
vfxreview/app/(dashboard)/projects/[id]/import-edl/EdlImportClient.tsx
T
twotalesanimation 98120f3ca8
Deploy / deploy (push) Successful in 2m45s
CSV Group updates
2026-08-06 09:13:16 +02:00

1084 lines
52 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import { useState, useCallback, useRef } from "react";
import { useRouter } from "next/navigation";
import Link from "next/link";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import { useToast } from "@/components/ui/use-toast";
import { cn } from "@/lib/utils";
import {
ArrowLeft,
Upload,
AlertCircle,
CheckCircle2,
RefreshCw,
SkipForward,
FilePlus2,
Pencil,
Film,
FileSpreadsheet,
} from "lucide-react";
import { parseEdlCsv, parsePictureTrackerCsv, parseSimpleCsv, parseGroupAssignCsv } from "@/lib/edl-utils";
import type { EdlImportRow, PictureTrackerRow, SimpleCsvRow, GroupAssignRow } from "@/lib/edl-utils";
import { importShotsFromEdl, updateShotsSeqTimecodes, importShotsFromSimpleCsv, assignShotGroups } from "@/actions/shots";
interface EdlImportClientProps {
projectId: string;
projectName: string;
existingShotCodes: Set<string>;
}
type Step = "input" | "preview" | "result";
const ACTION_STYLES = {
create: "bg-emerald-500/10 text-emerald-400 border-emerald-500/20",
update: "bg-blue-500/10 text-blue-400 border-blue-500/20",
skip: "bg-zinc-500/10 text-zinc-400 border-zinc-500/20",
};
const ACTION_ICONS = {
create: FilePlus2,
update: Pencil,
skip: SkipForward,
};
export function EdlImportClient({ projectId, projectName, existingShotCodes }: EdlImportClientProps) {
const router = useRouter();
const { toast } = useToast();
const fileInputRef = useRef<HTMLInputElement>(null);
const [step, setStep] = useState<Step>("input");
const [csvText, setCsvText] = useState("");
const [rows, setRows] = useState<EdlImportRow[]>([]);
const [parseErrors, setParseErrors] = useState<string[]>([]);
const [importing, setImporting] = useState(false);
const [result, setResult] = useState<{ created: string[]; updated: string[]; skipped: string[]; errors: string[] } | null>(null);
// ── Simple CSV state ────────────────────────────────────────────────────────
const scFileInputRef = useRef<HTMLInputElement>(null);
const [scStep, setScStep] = useState<"input" | "preview" | "result">("input");
const [scCsvText, setScCsvText] = useState("");
const [scRows, setScRows] = useState<SimpleCsvRow[]>([]);
const [scErrors, setScErrors] = useState<string[]>([]);
const [scImporting, setScImporting] = useState(false);
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 ─────────────────────────────────────────────────
const ptFileInputRef = useRef<HTMLInputElement>(null);
const [ptStep, setPtStep] = useState<"input" | "preview" | "result">("input");
const [ptCsvText, setPtCsvText] = useState("");
const [ptRows, setPtRows] = useState<PictureTrackerRow[]>([]);
const [ptErrors, setPtErrors] = useState<string[]>([]);
const [ptImporting, setPtImporting] = useState(false);
const [ptResult, setPtResult] = useState<{ updated: string[]; skipped: string[]; errors: string[] } | null>(null);
const handleFileUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
const reader = new FileReader();
reader.onload = (ev) => setCsvText(ev.target?.result as string ?? "");
reader.readAsText(file);
e.target.value = "";
};
const handleParse = useCallback(() => {
const { rows: parsed, errors } = parseEdlCsv(csvText);
setParseErrors(errors);
// Determine action for each row based on existing shots
const withActions: EdlImportRow[] = parsed.map((row) => ({
...row,
action: existingShotCodes.has(row.shotCode) ? "update" : "create",
}));
setRows(withActions);
if (withActions.length > 0) setStep("preview");
}, [csvText, existingShotCodes]);
const toggleAction = (idx: number) => {
setRows((prev) =>
prev.map((r, i) => {
if (i !== idx) return r;
const cycle: EdlImportRow["action"][] = ["create", "update", "skip"];
const next = cycle[(cycle.indexOf(r.action) + 1) % cycle.length];
return { ...r, action: next };
})
);
};
const setAllAction = (action: EdlImportRow["action"]) => {
setRows((prev) => prev.map((r) => ({ ...r, action })));
};
const handleImport = async () => {
setImporting(true);
try {
const res = await importShotsFromEdl(projectId, rows);
setResult(res);
setStep("result");
if (res.created.length + res.updated.length > 0) {
toast({
title: `Import complete`,
description: `${res.created.length} created, ${res.updated.length} updated`,
});
}
} catch (e) {
toast({ title: "Import failed", description: e instanceof Error ? e.message : undefined, variant: "destructive" });
} finally {
setImporting(false);
}
};
const handleReset = () => {
setStep("input");
setCsvText("");
setRows([]);
setParseErrors([]);
setResult(null);
};
// ── Simple CSV handlers ─────────────────────────────────────────────────────
const handleScFileUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
const reader = new FileReader();
reader.onload = (ev) => setScCsvText(ev.target?.result as string ?? "");
reader.readAsText(file);
e.target.value = "";
};
const handleScParse = useCallback(() => {
const { rows: parsed, errors } = parseSimpleCsv(scCsvText, existingShotCodes);
setScErrors(errors);
setScRows(parsed);
if (parsed.length > 0) setScStep("preview");
}, [scCsvText, existingShotCodes]);
const toggleScAction = (idx: number) => {
setScRows((prev) =>
prev.map((r, i) => {
if (i !== idx) return r;
const cycle: SimpleCsvRow["action"][] = ["create", "update", "skip"];
const next = cycle[(cycle.indexOf(r.action) + 1) % cycle.length];
return { ...r, action: next };
})
);
};
const handleScImport = async () => {
setScImporting(true);
try {
const res = await importShotsFromSimpleCsv(projectId, scRows);
setScResult(res);
setScStep("result");
if (res.created.length + res.updated.length > 0) {
toast({
title: "Import complete",
description: `${res.created.length} created, ${res.updated.length} updated`,
});
}
} catch (e) {
toast({ title: "Import failed", description: e instanceof Error ? e.message : undefined, variant: "destructive" });
} finally {
setScImporting(false);
}
};
const handleScReset = () => {
setScStep("input");
setScCsvText("");
setScRows([]);
setScErrors([]);
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 ──────────────────────────────────────────────
const handlePtFileUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
const reader = new FileReader();
reader.onload = (ev) => setPtCsvText(ev.target?.result as string ?? "");
reader.readAsText(file);
e.target.value = "";
};
const handlePtParse = useCallback(() => {
const { rows: parsed, errors } = parsePictureTrackerCsv(ptCsvText, existingShotCodes);
setPtErrors(errors);
setPtRows(parsed);
if (parsed.length > 0) setPtStep("preview");
}, [ptCsvText, existingShotCodes]);
const handlePtImport = async () => {
setPtImporting(true);
try {
const toUpdate = ptRows.filter((r) => r.exists);
const res = await updateShotsSeqTimecodes(projectId, toUpdate);
// Rows that parsed but didn't exist are also skipped
const notFound = ptRows.filter((r) => !r.exists).map((r) => r.shotCode);
const combined = { ...res, skipped: [...notFound, ...res.skipped] };
setPtResult(combined);
setPtStep("result");
if (res.updated.length > 0) {
toast({
title: "Timecodes updated",
description: `${res.updated.length} shot${res.updated.length !== 1 ? "s" : ""} updated`,
});
}
} catch (e) {
toast({ title: "Update failed", description: e instanceof Error ? e.message : undefined, variant: "destructive" });
} finally {
setPtImporting(false);
}
};
const handlePtReset = () => {
setPtStep("input");
setPtCsvText("");
setPtRows([]);
setPtErrors([]);
setPtResult(null);
};
const counts = {
create: rows.filter((r) => r.action === "create").length,
update: rows.filter((r) => r.action === "update").length,
skip: rows.filter((r) => r.action === "skip").length,
};
return (
<div className="min-h-screen bg-background">
<div className="max-w-5xl mx-auto p-6 space-y-6">
{/* Breadcrumb */}
<div className="flex items-center gap-2 text-sm text-zinc-500">
<Link href="/projects" className="hover:text-white transition-colors">Projects</Link>
<span>/</span>
<Link href={`/projects/${projectId}`} className="hover:text-white transition-colors">{projectName}</Link>
<span>/</span>
<span className="text-zinc-300">Import VFX Pull</span>
</div>
{/* Header */}
<div className="flex items-center gap-3">
<Button variant="ghost" size="icon" className="h-8 w-8 -ml-2" asChild>
<Link href={`/projects/${projectId}`}><ArrowLeft className="h-4 w-4" /></Link>
</Button>
<div>
<h1 className="text-xl font-bold text-white">Import VFX Pull CSV</h1>
<p className="text-sm text-zinc-500 mt-0.5">Paste or upload a Colorfront VFX pull CSV to create or update shots</p>
</div>
</div>
{/* ── STEP 1: Input ─────────────────────────────────────────────────── */}
{step === "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={fileInputRef}
type="file"
accept=".csv,text/csv"
className="hidden"
onChange={handleFileUpload}
/>
<Button
variant="outline"
size="sm"
className="gap-1.5 h-7"
onClick={() => fileInputRef.current?.click()}
>
<Upload className="h-3.5 w-3.5" />
Upload .csv
</Button>
</div>
</div>
<Textarea
value={csvText}
onChange={(e) => setCsvText(e.target.value)}
placeholder={`Events,"Deliverable Name","Output Name","Source Clip","Timecode Start","Timecode End",Duration,CDL,Status,Trimmed\n000001,VFX_Pull_Default_EXR,"UNG_108_001_010_BG01_TT_v001",A315C001_260313H1,...`}
className="font-mono text-xs min-h-[280px] 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>Columns required: <span className="font-mono text-amber-400">Output Name</span>, <span className="font-mono text-amber-400">Source Clip</span></p>
<p>Optional: <span className="font-mono text-zinc-400">Timecode Start, Timecode End, Duration</span></p>
<p className="mt-1.5">Shot code is derived from the first 4 segments of Output Name, e.g. <span className="font-mono text-zinc-300">UNG_108_030_060_BG01_TT_v001</span> <span className="font-mono text-emerald-400">UNG_108_030_060</span></p>
<p>EXR Output = <span className="font-mono text-zinc-300">{"{shot_code}_{source_clip}"}</span></p>
</div>
</div>
<div className="flex justify-end">
<Button onClick={handleParse} disabled={!csvText.trim()} className="gap-2">
<Film className="h-4 w-4" />
Parse &amp; Preview
</Button>
</div>
</div>
)}
{/* ── STEP 2: Preview ───────────────────────────────────────────────── */}
{step === "preview" && (
<div className="space-y-4">
{/* Summary bar */}
<div className="flex flex-wrap items-center gap-3">
<div className="flex items-center gap-2 px-3 py-1.5 rounded-lg bg-emerald-500/10 border border-emerald-500/20 text-emerald-400 text-sm">
<FilePlus2 className="h-3.5 w-3.5" />
{counts.create} to create
</div>
<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" />
{counts.update} to update
</div>
{counts.skip > 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" />
{counts.skip} to skip
</div>
)}
<div className="ml-auto flex items-center gap-2">
<span className="text-xs text-zinc-500">Set all:</span>
<Button variant="outline" size="sm" className="h-7 text-xs gap-1" onClick={() => setAllAction("create")}>Create</Button>
<Button variant="outline" size="sm" className="h-7 text-xs gap-1" onClick={() => setAllAction("update")}>Update</Button>
<Button variant="outline" size="sm" className="h-7 text-xs gap-1" onClick={() => setAllAction("skip")}>Skip</Button>
</div>
</div>
{parseErrors.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" /> {parseErrors.length} parse warning{parseErrors.length !== 1 ? "s" : ""}
</p>
{parseErrors.map((e, i) => <p key={i} className="text-xs text-red-300 pl-5">{e}</p>)}
</div>
)}
{/* Table */}
<div className="rounded-xl border border-zinc-800 overflow-hidden">
<div className="grid grid-cols-[auto_1fr_1fr_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>Action</span>
<span>Shot Code</span>
<span>EXR Output</span>
<span>TC In Out</span>
<span>Duration</span>
<span>Source Clip</span>
</div>
<div className="divide-y divide-zinc-800/60 bg-zinc-950/40 max-h-[480px] overflow-y-auto">
{rows.map((row, i) => {
const ActionIcon = ACTION_ICONS[row.action];
return (
<div
key={i}
className="grid grid-cols-[auto_1fr_1fr_1fr_1fr_auto] items-center gap-4 px-4 py-3 text-sm"
>
{/* Action badge — click to cycle */}
<button
onClick={() => toggleAction(i)}
title="Click to cycle: create → update → skip"
className={cn(
"inline-flex items-center gap-1.5 px-2 py-0.5 rounded border text-xs font-medium transition-colors shrink-0",
ACTION_STYLES[row.action]
)}
>
<ActionIcon className="h-3 w-3" />
{row.action}
</button>
<span className="font-mono text-xs text-zinc-200 truncate">{row.shotCode}</span>
<span className="font-mono text-xs text-zinc-400 truncate">{row.exrOutput}</span>
<span className="font-mono text-xs text-zinc-500 truncate">
{row.timecodeStart && row.timecodeEnd
? `${row.timecodeStart}${row.timecodeEnd}`
: "—"}
</span>
<span className="font-mono text-xs text-zinc-500">{row.clipDuration || "—"}</span>
<span className="font-mono text-xs text-zinc-500 truncate">{row.sourceClip || "—"}</span>
</div>
);
})}
</div>
</div>
<div className="flex items-center justify-between">
<Button variant="outline" onClick={handleReset}>
<ArrowLeft className="h-4 w-4 mr-1.5" /> Back
</Button>
<Button
onClick={handleImport}
disabled={importing || (counts.create + counts.update === 0)}
className="gap-2"
>
{importing ? <RefreshCw className="h-4 w-4 animate-spin" /> : <Upload className="h-4 w-4" />}
{importing ? "Importing…" : `Import ${counts.create + counts.update} shot${counts.create + counts.update !== 1 ? "s" : ""}`}
</Button>
</div>
</div>
)}
{/* ── STEP 3: Result ───────────────────────────────────────────────── */}
{step === "result" && result && (
<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" />
Import complete
</h2>
{result.created.length > 0 && (
<div className="space-y-2">
<p className="text-xs font-medium text-emerald-400 uppercase tracking-wide">
Created ({result.created.length})
</p>
<div className="flex flex-wrap gap-1.5">
{result.created.map((c) => (
<span key={c} className="font-mono text-xs px-2 py-0.5 rounded bg-emerald-500/10 border border-emerald-500/20 text-emerald-300">{c}</span>
))}
</div>
</div>
)}
{result.updated.length > 0 && (
<div className="space-y-2">
<p className="text-xs font-medium text-blue-400 uppercase tracking-wide">
Updated ({result.updated.length})
</p>
<div className="flex flex-wrap gap-1.5">
{result.updated.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>
)}
{result.skipped.length > 0 && (
<div className="space-y-2">
<p className="text-xs font-medium text-zinc-500 uppercase tracking-wide">
Skipped ({result.skipped.length})
</p>
<div className="flex flex-wrap gap-1.5">
{result.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>
)}
{result.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 ({result.errors.length})
</p>
{result.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={handleReset}>
Import Another
</Button>
<Button onClick={() => router.push(`/projects/${projectId}`)}>
Back to Project
</Button>
</div>
</div>
)}
{/* ── SIMPLE CSV PANEL ──────────────────────────────────────────── */}
<div className="pt-6 border-t border-zinc-800 space-y-4">
<div>
<h2 className="text-base font-semibold text-white">Import Shots from Simple CSV</h2>
<p className="text-sm text-zinc-500 mt-0.5">
Create or update shots from a CSV with <span className="font-mono text-zinc-300">Shot Name</span>, <span className="font-mono text-zinc-300">Time Code In</span>, <span className="font-mono text-zinc-300">Time Code Out</span>, <span className="font-mono text-zinc-300">Description</span>.
</p>
</div>
{scStep === "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={scFileInputRef} type="file" accept=".csv,text/csv" className="hidden" onChange={handleScFileUpload} />
<Button variant="outline" size="sm" className="gap-1.5 h-7" onClick={() => scFileInputRef.current?.click()}>
<Upload className="h-3.5 w-3.5" />
Upload .csv
</Button>
</div>
</div>
<Textarea
value={scCsvText}
onChange={(e) => setScCsvText(e.target.value)}
placeholder={`Shot Name,Time Code In,Time Code Out,Description\nUNG_108_001_010,01:00:10:00,01:00:20:00,Hero wide shot`}
className="font-mono text-xs min-h-[200px] 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></p>
<p>Optional: <span className="font-mono text-zinc-400">Time Code In, Time Code Out, Description</span></p>
<p>Existing shots will be set to <span className="font-mono text-blue-400">update</span>; new shots to <span className="font-mono text-emerald-400">create</span>. Toggle per row in the preview.</p>
</div>
</div>
<div className="flex justify-end">
<Button onClick={handleScParse} disabled={!scCsvText.trim()} className="gap-2">
<FileSpreadsheet className="h-4 w-4" />
Parse &amp; Preview
</Button>
</div>
</div>
)}
{scStep === "preview" && (() => {
const scCreate = scRows.filter((r) => r.action === "create").length;
const scUpdate = scRows.filter((r) => r.action === "update").length;
const scSkip = scRows.filter((r) => r.action === "skip").length;
return (
<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-emerald-500/10 border border-emerald-500/20 text-emerald-400 text-sm">
<FilePlus2 className="h-3.5 w-3.5" />
{scCreate} to create
</div>
<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" />
{scUpdate} to update
</div>
{scSkip > 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" />
{scSkip} to skip
</div>
)}
</div>
{scErrors.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" /> {scErrors.length} parse warning{scErrors.length !== 1 ? "s" : ""}
</p>
{scErrors.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-[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>Shot Code</span>
<span>Seq TC In</span>
<span>Seq TC Out</span>
<span>Group</span>
<span>Description</span>
</div>
<div className="divide-y divide-zinc-800/60 bg-zinc-950/40 max-h-[400px] overflow-y-auto">
{scRows.map((row, i) => {
const ActionIcon = ACTION_ICONS[row.action];
return (
<div key={i} className="grid grid-cols-[auto_1fr_1fr_1fr_1fr_1fr] items-center gap-4 px-4 py-3 text-sm">
<button
onClick={() => toggleScAction(i)}
title="Click to cycle: create → update → skip"
className={cn(
"inline-flex items-center gap-1.5 px-2 py-0.5 rounded border text-xs font-medium transition-colors shrink-0",
ACTION_STYLES[row.action]
)}
>
<ActionIcon className="h-3 w-3" />
{row.action}
</button>
<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.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>
</div>
);
})}
</div>
</div>
<div className="flex items-center justify-between">
<Button variant="outline" onClick={handleScReset}>
<ArrowLeft className="h-4 w-4 mr-1.5" /> Back
</Button>
<Button
onClick={handleScImport}
disabled={scImporting || (scCreate + scUpdate === 0)}
className="gap-2"
>
{scImporting ? <RefreshCw className="h-4 w-4 animate-spin" /> : <Upload className="h-4 w-4" />}
{scImporting ? "Importing…" : `Import ${scCreate + scUpdate} shot${scCreate + scUpdate !== 1 ? "s" : ""}`}
</Button>
</div>
</div>
);
})()}
{scStep === "result" && scResult && (
<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" />
Import complete
</h2>
{scResult.created.length > 0 && (
<div className="space-y-2">
<p className="text-xs font-medium text-emerald-400 uppercase tracking-wide">Created ({scResult.created.length})</p>
<div className="flex flex-wrap gap-1.5">
{scResult.created.map((c) => (
<span key={c} className="font-mono text-xs px-2 py-0.5 rounded bg-emerald-500/10 border border-emerald-500/20 text-emerald-300">{c}</span>
))}
</div>
</div>
)}
{scResult.updated.length > 0 && (
<div className="space-y-2">
<p className="text-xs font-medium text-blue-400 uppercase tracking-wide">Updated ({scResult.updated.length})</p>
<div className="flex flex-wrap gap-1.5">
{scResult.updated.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>
)}
{scResult.skipped.length > 0 && (
<div className="space-y-2">
<p className="text-xs font-medium text-zinc-500 uppercase tracking-wide">Skipped ({scResult.skipped.length})</p>
<div className="flex flex-wrap gap-1.5">
{scResult.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>
)}
{scResult.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 ({scResult.errors.length})
</p>
{scResult.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={handleScReset}>Import Another</Button>
<Button onClick={() => router.push(`/projects/${projectId}`)}>Back to Project</Button>
</div>
</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 ─────────────────────────────────────── */}
<div className="pt-6 border-t border-zinc-800 space-y-4">
<div>
<h2 className="text-base font-semibold text-white">Import Picture Tracker Timecodes</h2>
<p className="text-sm text-zinc-500 mt-0.5">
Paste a picture tracker CSV to update <span className="font-mono text-zinc-300">seqTimecodeStart</span> / <span className="font-mono text-zinc-300">seqTimecodeEnd</span> on existing shots only.
</p>
</div>
{/* PT STEP 1: Input */}
{ptStep === "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={ptFileInputRef}
type="file"
accept=".csv,text/csv"
className="hidden"
onChange={handlePtFileUpload}
/>
<Button
variant="outline"
size="sm"
className="gap-1.5 h-7"
onClick={() => ptFileInputRef.current?.click()}
>
<Upload className="h-3.5 w-3.5" />
Upload .csv
</Button>
</div>
</div>
<Textarea
value={ptCsvText}
onChange={(e) => setPtCsvText(e.target.value)}
placeholder={`#,Thumbnail,Shot Name,TC In,TC Out,Duration,...\n1,,UNG_106_004_010,01:00:37:16,01:00:53:01,00:00:15:09,...`}
className="font-mono text-xs min-h-[200px] 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>Columns required: <span className="font-mono text-amber-400">Shot Name</span>, <span className="font-mono text-amber-400">TC In</span>, <span className="font-mono text-amber-400">TC Out</span></p>
<p>Only shots that already exist in this project will be updated. Duplicate shot rows are ignored (first TC In/Out wins).</p>
</div>
</div>
<div className="flex justify-end">
<Button onClick={handlePtParse} disabled={!ptCsvText.trim()} className="gap-2">
<Film className="h-4 w-4" />
Parse &amp; Preview
</Button>
</div>
</div>
)}
{/* PT STEP 2: Preview */}
{ptStep === "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" />
{ptRows.filter((r) => r.exists).length} to update
</div>
{ptRows.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" />
{ptRows.filter((r) => !r.exists).length} not found (will skip)
</div>
)}
</div>
{ptErrors.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" /> {ptErrors.length} parse warning{ptErrors.length !== 1 ? "s" : ""}
</p>
{ptErrors.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_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>Seq TC In</span>
<span>Seq TC Out</span>
<span>Status</span>
</div>
<div className="divide-y divide-zinc-800/60 bg-zinc-950/40 max-h-[400px] overflow-y-auto">
{ptRows.map((row, i) => (
<div key={i} className="grid grid-cols-[1fr_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="font-mono text-xs text-zinc-300">{row.seqTimecodeStart}</span>
<span className="font-mono text-xs text-zinc-300">{row.seqTimecodeEnd}</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" /> update
</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={handlePtReset}>
<ArrowLeft className="h-4 w-4 mr-1.5" /> Back
</Button>
<Button
onClick={handlePtImport}
disabled={ptImporting || ptRows.filter((r) => r.exists).length === 0}
className="gap-2"
>
{ptImporting ? <RefreshCw className="h-4 w-4 animate-spin" /> : <Upload className="h-4 w-4" />}
{ptImporting
? "Updating…"
: `Update ${ptRows.filter((r) => r.exists).length} shot${ptRows.filter((r) => r.exists).length !== 1 ? "s" : ""}`}
</Button>
</div>
</div>
)}
{/* PT STEP 3: Result */}
{ptStep === "result" && ptResult && (
<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" />
Timecode update complete
</h2>
{ptResult.updated.length > 0 && (
<div className="space-y-2">
<p className="text-xs font-medium text-blue-400 uppercase tracking-wide">
Updated ({ptResult.updated.length})
</p>
<div className="flex flex-wrap gap-1.5">
{ptResult.updated.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>
)}
{ptResult.skipped.length > 0 && (
<div className="space-y-2">
<p className="text-xs font-medium text-zinc-500 uppercase tracking-wide">
Skipped / not found ({ptResult.skipped.length})
</p>
<div className="flex flex-wrap gap-1.5">
{ptResult.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>
)}
{ptResult.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 ({ptResult.errors.length})
</p>
{ptResult.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={handlePtReset}>
Import Another
</Button>
<Button onClick={() => router.push(`/projects/${projectId}`)}>
Back to Project
</Button>
</div>
</div>
)}
</div>
</div>
</div>
);
}