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 && (
+
+ )}
+
+