bada122f42
Deploy / deploy (push) Successful in 7m4s
Initialise Workspace built UNG_101..UNG_117 from a hardcoded range and
imported one hardcoded AEP, and fourteen findComp calls named UNG's comps
literally. None of it worked for a second show.
Comp names now follow a convention derived from the show id —
{showId}_VFX_OVERLAY, {showId}_EXPORT_TEMPLATE, _SHOW LUT — which
reproduces UNG's existing names exactly, so nothing changes for the show
it was written for. A project whose template departs from the pattern can
override any of the three.
Paths cannot be derived: UNG's show id is UNG while its folder is
UNGO_VFX. Those live on Project.pipelineConfig and are edited under
Project → Settings → Pipeline. They are resolved server-side and handed
to the panel with the project list, and read by buildPreviewManifest for
the headless build, so the panel and the farm cannot disagree about which
template — or which show LUT — a shot was built with. That mattered more
than the folder names: a preview rendered through another show's LUT is
quietly the wrong colour rather than obviously broken.
Episode folders come from the project's episode list, falling back to the
folders under its footage root for a show whose episodes are not entered
yet.
Panel 2.5.0. Adds migration 20260814010000_project_pipeline_config.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
442 lines
16 KiB
TypeScript
442 lines
16 KiB
TypeScript
"use client";
|
|
|
|
import { useState } from "react";
|
|
import { useRouter } from "next/navigation";
|
|
import { useForm } from "react-hook-form";
|
|
import { zodResolver } from "@hookform/resolvers/zod";
|
|
import { z } from "zod";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Input } from "@/components/ui/input";
|
|
import { Label } from "@/components/ui/label";
|
|
import { Textarea } from "@/components/ui/textarea";
|
|
import { Separator } from "@/components/ui/separator";
|
|
import {
|
|
Select,
|
|
SelectContent,
|
|
SelectItem,
|
|
SelectTrigger,
|
|
SelectValue,
|
|
} from "@/components/ui/select";
|
|
import { updateProject } from "@/actions/projects";
|
|
import { useToast } from "@/components/ui/use-toast";
|
|
import { Settings, Building2, Users, Webhook, Clapperboard } from "lucide-react";
|
|
|
|
const settingsSchema = z.object({
|
|
name: z.string().min(1, "Name is required").max(100),
|
|
code: z.string().min(1, "Code is required").max(20).regex(/^[A-Z0-9_\-]+$/i, "Alphanumeric, dash, underscore"),
|
|
showId: z.string().min(1, "Show ID is required").max(10).regex(/^[A-Z0-9_]+$/i, "Letters, numbers, underscore only"),
|
|
projectType: z.enum(["STANDARD", "EPISODIC"]),
|
|
description: z.string().optional(),
|
|
status: z.enum(["ACTIVE", "ON_HOLD", "COMPLETED", "ARCHIVED"]),
|
|
clientId: z.string().optional(),
|
|
producerId: z.string().optional(),
|
|
supervisorId: z.string().optional(),
|
|
dueDate: z.string().optional(),
|
|
startDate: z.string().optional(),
|
|
slackWebhook: z.string().optional(),
|
|
slackChannel: z.string().optional(),
|
|
// Pipeline — see the Pipeline section for why only paths are required here.
|
|
templateAep: z.string().optional(),
|
|
rendersRoot: z.string().optional(),
|
|
overlayComp: z.string().optional(),
|
|
previewComp: z.string().optional(),
|
|
lutComp: z.string().optional(),
|
|
});
|
|
|
|
type SettingsFormValues = z.infer<typeof settingsSchema>;
|
|
|
|
interface Client {
|
|
id: string;
|
|
company: string;
|
|
}
|
|
|
|
interface TeamMember {
|
|
id: string;
|
|
name: string | null;
|
|
email: string;
|
|
role: string;
|
|
}
|
|
|
|
interface ProjectSettingsTabProps {
|
|
project: {
|
|
id: string;
|
|
name: string;
|
|
code: string;
|
|
showId: string;
|
|
projectType: "STANDARD" | "EPISODIC";
|
|
description: string | null;
|
|
status: string;
|
|
clientId: string | null;
|
|
producerId: string | null;
|
|
supervisorId: string | null;
|
|
dueDate: Date | null;
|
|
startDate: Date | null;
|
|
slackWebhook: string | null;
|
|
slackChannel: string | null;
|
|
pipelineConfig: {
|
|
templateAep?: string | null;
|
|
rendersRoot?: string | null;
|
|
overlayComp?: string | null;
|
|
previewComp?: string | null;
|
|
lutComp?: string | null;
|
|
} | null;
|
|
};
|
|
clients: Client[];
|
|
teamMembers: TeamMember[];
|
|
}
|
|
|
|
const NONE = "__none__";
|
|
|
|
function toDateInput(d: Date | null | undefined) {
|
|
if (!d) return "";
|
|
return new Date(d).toISOString().substring(0, 10);
|
|
}
|
|
|
|
export function ProjectSettingsTab({ project, clients, teamMembers }: ProjectSettingsTabProps) {
|
|
const { toast } = useToast();
|
|
const router = useRouter();
|
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
|
|
|
const producers = teamMembers.filter((m) => ["ADMIN", "PRODUCER"].includes(m.role));
|
|
const supervisors = teamMembers.filter((m) => ["ADMIN", "PRODUCER", "SUPERVISOR"].includes(m.role));
|
|
|
|
const {
|
|
register,
|
|
handleSubmit,
|
|
setValue,
|
|
watch,
|
|
formState: { errors, isDirty },
|
|
} = useForm<SettingsFormValues>({
|
|
resolver: zodResolver(settingsSchema),
|
|
defaultValues: {
|
|
name: project.name,
|
|
code: project.code,
|
|
showId: project.showId,
|
|
projectType: project.projectType,
|
|
description: project.description ?? "",
|
|
status: project.status as SettingsFormValues["status"],
|
|
clientId: project.clientId ?? NONE,
|
|
producerId: project.producerId ?? NONE,
|
|
supervisorId: project.supervisorId ?? NONE,
|
|
dueDate: toDateInput(project.dueDate),
|
|
startDate: toDateInput(project.startDate),
|
|
slackWebhook: project.slackWebhook ?? "",
|
|
slackChannel: project.slackChannel ?? "",
|
|
templateAep: project.pipelineConfig?.templateAep ?? "",
|
|
rendersRoot: project.pipelineConfig?.rendersRoot ?? "",
|
|
overlayComp: project.pipelineConfig?.overlayComp ?? "",
|
|
previewComp: project.pipelineConfig?.previewComp ?? "",
|
|
lutComp: project.pipelineConfig?.lutComp ?? "",
|
|
},
|
|
});
|
|
|
|
// Blank comp-name fields mean "use the convention", so they are shown as
|
|
// placeholders rather than pre-filled: a project that follows the pattern
|
|
// should have nothing stored, and inherit any future change to it.
|
|
const showId = watch("showId") || project.showId;
|
|
|
|
const onSubmit = async (data: SettingsFormValues) => {
|
|
setIsSubmitting(true);
|
|
try {
|
|
await updateProject({
|
|
id: project.id,
|
|
name: data.name,
|
|
code: data.code,
|
|
showId: data.showId,
|
|
projectType: data.projectType,
|
|
description: data.description || null,
|
|
status: data.status as any,
|
|
clientId: data.clientId === NONE ? null : data.clientId || null,
|
|
producerId: data.producerId === NONE ? null : data.producerId || null,
|
|
supervisorId: data.supervisorId === NONE ? null : data.supervisorId || null,
|
|
dueDate: data.dueDate || null,
|
|
startDate: data.startDate || null,
|
|
slackWebhook: data.slackWebhook || null,
|
|
slackChannel: data.slackChannel || null,
|
|
pipelineConfig: {
|
|
templateAep: data.templateAep?.trim() || null,
|
|
rendersRoot: data.rendersRoot?.trim() || null,
|
|
overlayComp: data.overlayComp?.trim() || null,
|
|
previewComp: data.previewComp?.trim() || null,
|
|
lutComp: data.lutComp?.trim() || null,
|
|
},
|
|
});
|
|
toast({ title: "Project settings saved" });
|
|
router.refresh();
|
|
} catch (err) {
|
|
toast({
|
|
title: "Failed to save settings",
|
|
description: (err as Error).message,
|
|
variant: "destructive",
|
|
});
|
|
} finally {
|
|
setIsSubmitting(false);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<form onSubmit={handleSubmit(onSubmit)} className="space-y-8 max-w-2xl">
|
|
|
|
{/* ── General ── */}
|
|
<section className="space-y-4">
|
|
<div className="flex items-center gap-2">
|
|
<Settings className="h-4 w-4 text-zinc-400" />
|
|
<h2 className="text-sm font-semibold text-zinc-200">General</h2>
|
|
</div>
|
|
<Separator />
|
|
|
|
<div className="grid grid-cols-2 gap-4">
|
|
<div className="space-y-1.5 col-span-2">
|
|
<Label htmlFor="name">Project Name</Label>
|
|
<Input id="name" {...register("name")} />
|
|
{errors.name && <p className="text-xs text-destructive">{errors.name.message}</p>}
|
|
</div>
|
|
|
|
<div className="space-y-1.5">
|
|
<Label htmlFor="code">Project Code</Label>
|
|
<Input id="code" className="uppercase font-mono" {...register("code")} />
|
|
{errors.code && <p className="text-xs text-destructive">{errors.code.message}</p>}
|
|
</div>
|
|
|
|
<div className="space-y-1.5">
|
|
<Label>Status</Label>
|
|
<Select
|
|
defaultValue={project.status}
|
|
onValueChange={(v) => setValue("status", v as SettingsFormValues["status"], { shouldDirty: true })}
|
|
>
|
|
<SelectTrigger>
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="ACTIVE">Active</SelectItem>
|
|
<SelectItem value="ON_HOLD">On Hold</SelectItem>
|
|
<SelectItem value="COMPLETED">Completed</SelectItem>
|
|
<SelectItem value="ARCHIVED">Archived</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
|
|
<div className="space-y-1.5 col-span-2">
|
|
<Label htmlFor="description">Description</Label>
|
|
<Textarea id="description" className="min-h-[80px]" {...register("description")} />
|
|
</div>
|
|
|
|
<div className="space-y-1.5">
|
|
<Label htmlFor="startDate">Start Date</Label>
|
|
<Input id="startDate" type="date" {...register("startDate")} />
|
|
</div>
|
|
|
|
<div className="space-y-1.5">
|
|
<Label htmlFor="dueDate">Due Date</Label>
|
|
<Input id="dueDate" type="date" {...register("dueDate")} />
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
{/* ── Shot Naming ── */}
|
|
<section className="space-y-4">
|
|
<div className="flex items-center gap-2">
|
|
<Settings className="h-4 w-4 text-zinc-400" />
|
|
<h2 className="text-sm font-semibold text-zinc-200">Shot Naming</h2>
|
|
</div>
|
|
<Separator />
|
|
|
|
<div className="grid grid-cols-2 gap-4">
|
|
<div className="space-y-1.5">
|
|
<Label htmlFor="showId">Show ID</Label>
|
|
<Input id="showId" className="uppercase font-mono" maxLength={10} {...register("showId")} />
|
|
{errors.showId && <p className="text-xs text-destructive">{errors.showId.message}</p>}
|
|
<p className="text-xs text-muted-foreground">Used in shot codes, e.g. SHOWID_SC010_0010</p>
|
|
</div>
|
|
|
|
<div className="space-y-1.5">
|
|
<Label>Project Type</Label>
|
|
<Select
|
|
defaultValue={project.projectType}
|
|
onValueChange={(v) => setValue("projectType", v as "STANDARD" | "EPISODIC", { shouldDirty: true })}
|
|
>
|
|
<SelectTrigger>
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="STANDARD">Standard</SelectItem>
|
|
<SelectItem value="EPISODIC">Episodic</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
<p className="text-xs text-muted-foreground">Episodic adds an episode segment to shot codes</p>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
{/* ── Client & Team ── */}
|
|
<section className="space-y-4">
|
|
<div className="flex items-center gap-2">
|
|
<Building2 className="h-4 w-4 text-zinc-400" />
|
|
<h2 className="text-sm font-semibold text-zinc-200">Client & Team</h2>
|
|
</div>
|
|
<Separator />
|
|
|
|
<div className="grid grid-cols-2 gap-4">
|
|
<div className="space-y-1.5 col-span-2">
|
|
<Label>Client</Label>
|
|
<Select
|
|
defaultValue={project.clientId ?? NONE}
|
|
onValueChange={(v) => setValue("clientId", v, { shouldDirty: true })}
|
|
>
|
|
<SelectTrigger>
|
|
<SelectValue placeholder="No client assigned" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value={NONE}>No client</SelectItem>
|
|
{clients.map((c) => (
|
|
<SelectItem key={c.id} value={c.id}>
|
|
{c.company}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
|
|
<div className="space-y-1.5">
|
|
<Label>Producer</Label>
|
|
<Select
|
|
defaultValue={project.producerId ?? NONE}
|
|
onValueChange={(v) => setValue("producerId", v, { shouldDirty: true })}
|
|
>
|
|
<SelectTrigger>
|
|
<SelectValue placeholder="Not assigned" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value={NONE}>Not assigned</SelectItem>
|
|
{producers.map((m) => (
|
|
<SelectItem key={m.id} value={m.id}>
|
|
{m.name ?? m.email}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
|
|
<div className="space-y-1.5">
|
|
<Label>Supervisor</Label>
|
|
<Select
|
|
defaultValue={project.supervisorId ?? NONE}
|
|
onValueChange={(v) => setValue("supervisorId", v, { shouldDirty: true })}
|
|
>
|
|
<SelectTrigger>
|
|
<SelectValue placeholder="Not assigned" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value={NONE}>Not assigned</SelectItem>
|
|
{supervisors.map((m) => (
|
|
<SelectItem key={m.id} value={m.id}>
|
|
{m.name ?? m.email}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
{/* ── Integrations ── */}
|
|
<section className="space-y-4">
|
|
<div className="flex items-center gap-2">
|
|
<Webhook className="h-4 w-4 text-zinc-400" />
|
|
<h2 className="text-sm font-semibold text-zinc-200">Slack Integration</h2>
|
|
</div>
|
|
<Separator />
|
|
|
|
<div className="grid grid-cols-2 gap-4">
|
|
<div className="space-y-1.5 col-span-2">
|
|
<Label htmlFor="slackWebhook">Webhook URL</Label>
|
|
<Input
|
|
id="slackWebhook"
|
|
type="url"
|
|
placeholder="https://hooks.slack.com/services/..."
|
|
{...register("slackWebhook")}
|
|
/>
|
|
</div>
|
|
<div className="space-y-1.5">
|
|
<Label htmlFor="slackChannel">Channel</Label>
|
|
<Input
|
|
id="slackChannel"
|
|
placeholder="#vfx-pipeline"
|
|
{...register("slackChannel")}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
{/* ── Pipeline ── */}
|
|
<section className="space-y-4">
|
|
<div className="flex items-center gap-2">
|
|
<Clapperboard className="h-4 w-4 text-zinc-400" />
|
|
<h2 className="text-sm font-semibold text-zinc-200">Pipeline</h2>
|
|
</div>
|
|
<Separator />
|
|
<p className="text-xs text-zinc-500">
|
|
Where this show's After Effects template lives. Used by the connector panel and by the
|
|
render farm's preview build alike, so both work from the same template — and, above
|
|
all, the same show LUT.
|
|
</p>
|
|
|
|
<div className="grid grid-cols-2 gap-4">
|
|
<div className="space-y-1.5 col-span-2">
|
|
<Label htmlFor="templateAep">Template project (.aep)</Label>
|
|
<Input
|
|
id="templateAep"
|
|
placeholder="X:/shared_projects_2026/UNGO_VFX/production/working_files/UNG_VFXOVERLAY_SLATE_TEMPLATE.aep"
|
|
{...register("templateAep")}
|
|
/>
|
|
<p className="text-xs text-zinc-500">
|
|
Holds the overlay, the slate/export template and the show LUT. Imported by
|
|
Initialise Workspace and by every headless preview build.
|
|
</p>
|
|
</div>
|
|
|
|
<div className="space-y-1.5 col-span-2">
|
|
<Label htmlFor="rendersRoot">Renders root</Label>
|
|
<Input
|
|
id="rendersRoot"
|
|
placeholder="X:/shared_projects_2026/UNGO_VFX/production/renders"
|
|
{...register("rendersRoot")}
|
|
/>
|
|
<p className="text-xs text-zinc-500">
|
|
Where per-shot pre-renders live; the panel appends the shot code. Used by Import Renders.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="space-y-1.5">
|
|
<p className="text-xs text-zinc-400">
|
|
Comp names default to the convention below and only need filling in when this show's
|
|
template departs from it. Leave blank to follow the convention.
|
|
</p>
|
|
<div className="grid grid-cols-3 gap-4 pt-1">
|
|
<div className="space-y-1.5">
|
|
<Label htmlFor="overlayComp">Overlay comp</Label>
|
|
<Input id="overlayComp" placeholder={`${showId}_VFX_OVERLAY`} {...register("overlayComp")} />
|
|
</div>
|
|
<div className="space-y-1.5">
|
|
<Label htmlFor="previewComp">Slate / export comp</Label>
|
|
<Input id="previewComp" placeholder={`${showId}_EXPORT_TEMPLATE`} {...register("previewComp")} />
|
|
</div>
|
|
<div className="space-y-1.5">
|
|
<Label htmlFor="lutComp">Show LUT comp</Label>
|
|
<Input id="lutComp" placeholder="_SHOW LUT" {...register("lutComp")} />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
{/* Save */}
|
|
<div className="flex justify-end pt-2">
|
|
<Button type="submit" disabled={isSubmitting || !isDirty} className="min-w-[120px]">
|
|
{isSubmitting ? "Saving..." : "Save Changes"}
|
|
</Button>
|
|
</div>
|
|
</form>
|
|
);
|
|
}
|