diff --git a/actions/shots.ts b/actions/shots.ts index 5e2ed63..8da85b1 100644 --- a/actions/shots.ts +++ b/actions/shots.ts @@ -18,6 +18,7 @@ const createShotSchema = z.object({ frameEnd: z.number().int().optional(), dueDate: z.string().optional(), thumbnailUrl: z.string().optional(), + shotGroupName: z.string().max(100).optional(), }); export async function createShot(data: z.infer) { @@ -83,6 +84,13 @@ export async function createShot(data: z.infer) { frameEnd: parsed.frameEnd, dueDate: parsed.dueDate ? new Date(parsed.dueDate) : undefined, thumbnailUrl: parsed.thumbnailUrl, + shotGroupId: parsed.shotGroupName?.trim() + ? (await db.shotGroup.upsert({ + where: { projectId_name: { projectId: parsed.projectId, name: parsed.shotGroupName.trim() } }, + create: { projectId: parsed.projectId, name: parsed.shotGroupName.trim() }, + update: {}, + })).id + : undefined, }, }); @@ -162,6 +170,7 @@ export async function duplicateShot(sourceShotId: string) { frameEnd: source.frameEnd, dueDate: source.dueDate, thumbnailUrl: source.thumbnailUrl, + shotGroupId: source.shotGroupId ?? undefined, // Duplicate tasks — reset status and schedule fields tasks: { create: source.tasks.map((t) => ({ diff --git a/app/(dashboard)/projects/[id]/ProjectTabsClient.tsx b/app/(dashboard)/projects/[id]/ProjectTabsClient.tsx index cede4f3..7b74e7f 100644 --- a/app/(dashboard)/projects/[id]/ProjectTabsClient.tsx +++ b/app/(dashboard)/projects/[id]/ProjectTabsClient.tsx @@ -60,6 +60,7 @@ interface ProjectTabsClientProps { assets: any[]; tasks: any[]; artists: Artist[]; + shotGroups: { id: string; name: string }[]; canManage: boolean; } @@ -73,6 +74,7 @@ export function ProjectTabsClient({ assets, tasks, artists, + shotGroups, canManage, }: ProjectTabsClientProps) { const [activeTab, setActiveTab] = useState("shots"); @@ -109,6 +111,32 @@ export function ProjectTabsClient({ })() : []; + // For standard projects: group by custom shot group when any exist + const standardGroups: [string, ShotWithDetails[]][] = projectType === "STANDARD" && shots.some((s) => s.shotGroup) + ? (() => { + const sorted = [...shots].sort((a, b) => { + const ga = a.shotGroup?.name ?? "\uFFFF"; + const gb = b.shotGroup?.name ?? "\uFFFF"; + if (ga !== gb) return ga.localeCompare(gb, undefined, { numeric: true }); + if (a.scene !== b.scene) return a.scene.localeCompare(b.scene, undefined, { numeric: true }); + return a.shotNumber - b.shotNumber; + }); + const map = new Map(); + for (const shot of sorted) { + const key = shot.shotGroup?.name ?? "(Ungrouped)"; + if (!map.has(key)) map.set(key, []); + map.get(key)!.push(shot); + } + // Move ungrouped to the end + const ungrouped = map.get("(Ungrouped)"); + if (ungrouped) { + map.delete("(Ungrouped)"); + map.set("(Ungrouped)", ungrouped); + } + return Array.from(map.entries()); + })() + : []; + const tabs: { id: Tab; label: string; icon: React.ElementType; count: number; managerOnly?: boolean }[] = [ { id: "shots", label: "Shots", icon: Film, count: shots.length }, { id: "assets", label: "Assets", icon: Package, count: assets.length }, @@ -211,6 +239,36 @@ export function ProjectTabsClient({ ); })} + ) : standardGroups.length > 0 ? ( +
+ {standardGroups.map(([groupName, groupShots]) => { + const collapsed = collapsedEpisodes.has(groupName); + return ( +
+ + {!collapsed && ( +
+ {groupShots.map((shot) => ( + + ))} +
+ )} +
+ ); + })} +
) : (
{shots.map((shot) => ( @@ -267,6 +325,7 @@ export function ProjectTabsClient({ setShowNewShot(false)} onSuccess={() => setShowNewShot(false)} diff --git a/app/(dashboard)/projects/[id]/page.tsx b/app/(dashboard)/projects/[id]/page.tsx index 18fac8c..87e35a2 100644 --- a/app/(dashboard)/projects/[id]/page.tsx +++ b/app/(dashboard)/projects/[id]/page.tsx @@ -28,6 +28,7 @@ async function getProject(id: string) { orderBy: { createdAt: "asc" }, include: { artist: { select: { id: true, name: true, image: true, email: true } }, + shotGroup: { select: { id: true, name: true } }, versions: { take: 1, orderBy: { versionNumber: "desc" }, @@ -70,6 +71,10 @@ async function getProject(id: string) { }, }, }, + shotGroups: { + orderBy: [{ sortOrder: "asc" }, { name: "asc" }], + select: { id: true, name: true }, + }, }, }); } @@ -202,6 +207,7 @@ export default async function ProjectPage({ params }: { params: Promise<{ id: st assets={project.assets as any} tasks={project.tasks as any} artists={artists} + shotGroups={project.shotGroups} canManage={!!canManage} />
diff --git a/components/shots/NewShotDialog.tsx b/components/shots/NewShotDialog.tsx index c289b6e..9202d25 100644 --- a/components/shots/NewShotDialog.tsx +++ b/components/shots/NewShotDialog.tsx @@ -29,6 +29,7 @@ import { useToast } from "@/components/ui/use-toast"; const shotSchema = z.object({ scene: z.string().min(1, "Scene is required").max(50).regex(/^[A-Z0-9_]+$/i, "Alphanumeric and underscore only"), episode: z.string().max(50).optional(), + shotGroupName: z.string().max(100).optional(), description: z.string().optional(), priority: z.enum(["LOW", "NORMAL", "HIGH", "URGENT"]), fps: z.coerce.number().min(1).max(120), @@ -39,12 +40,13 @@ type ShotFormValues = z.infer; interface NewShotDialogProps { projectId: string; projectType?: "STANDARD" | "EPISODIC"; + shotGroups?: { id: string; name: string }[]; open: boolean; onClose: () => void; onSuccess?: () => void; } -export function NewShotDialog({ projectId, projectType = "STANDARD", open, onClose, onSuccess }: NewShotDialogProps) { +export function NewShotDialog({ projectId, projectType = "STANDARD", shotGroups = [], open, onClose, onSuccess }: NewShotDialogProps) { const [isSubmitting, setIsSubmitting] = useState(false); const [thumbnailFile, setThumbnailFile] = useState(null); const [thumbnailPreview, setThumbnailPreview] = useState(null); @@ -160,6 +162,23 @@ export function NewShotDialog({ projectId, projectType = "STANDARD", open, onClo {isEpisodic ? "SHOW_EP01_SC010_0010" : "SHOW_SC010_0010"})

+
+ + 0 ? "Choose or type a new group" : "e.g. Action Sequences"} + {...register("shotGroupName")} + /> + {shotGroups.length > 0 && ( + + {shotGroups.map((g) => ( + + )} +
+