diff --git a/actions/shots.ts b/actions/shots.ts index a3d9e91..5e2ed63 100644 --- a/actions/shots.ts +++ b/actions/shots.ts @@ -90,6 +90,100 @@ export async function createShot(data: z.infer) { return { success: true, shot }; } +// ── Duplicate Shot ──────────────────────────────────────────────────────────── + +export async function duplicateShot(sourceShotId: 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"); + } + + // Load source shot with tasks + const source = await db.shot.findUnique({ + where: { id: sourceShotId }, + include: { + tasks: { + select: { + title: true, + description: true, + type: true, + priority: true, + estimatedHours: true, + sortOrder: true, + dueDate: true, + assignedArtistId: true, + }, + }, + }, + }); + if (!source) throw new Error("Shot not found"); + + const project = await db.project.findUnique({ + where: { id: source.projectId }, + select: { showId: true, projectType: true }, + }); + if (!project?.showId) throw new Error("Project has no Show ID"); + + // Determine next shot number in same scene/episode scope + const scopeWhere = { + projectId: source.projectId, + scene: source.scene, + ...(project.projectType === "EPISODIC" ? { episode: source.episode } : {}), + }; + + const maxShot = await db.shot.findFirst({ + where: scopeWhere, + orderBy: { shotNumber: "desc" }, + select: { shotNumber: true }, + }); + + const shotNumber = (maxShot?.shotNumber ?? 0) + 10; + const paddedNumber = shotNumber.toString().padStart(4, "0"); + + const shotCode = + project.projectType === "EPISODIC" && source.episode + ? `${project.showId}_${source.episode}_${source.scene}_${paddedNumber}` + : `${project.showId}_${source.scene}_${paddedNumber}`; + + const newShot = await db.shot.create({ + data: { + shotCode, + scene: source.scene, + episode: source.episode, + shotNumber, + sequence: source.sequence, + description: source.description, + projectId: source.projectId, + artistId: source.artistId, + priority: source.priority, + fps: source.fps, + frameStart: source.frameStart, + frameEnd: source.frameEnd, + dueDate: source.dueDate, + thumbnailUrl: source.thumbnailUrl, + // Duplicate tasks — reset status and schedule fields + tasks: { + create: source.tasks.map((t) => ({ + title: t.title, + description: t.description, + type: t.type, + priority: t.priority, + estimatedHours: t.estimatedHours, + sortOrder: t.sortOrder, + dueDate: t.dueDate, + assignedArtistId: t.assignedArtistId, + projectId: source.projectId, + status: "TODO" as const, + })), + }, + }, + }); + + revalidatePath(`/projects/${source.projectId}`); + return { success: true, shot: newShot }; +} + // ── Update Shot ─────────────────────────────────────────────────────────────── const updateShotSchema = z.object({ diff --git a/app/(dashboard)/projects/[id]/ProjectTabsClient.tsx b/app/(dashboard)/projects/[id]/ProjectTabsClient.tsx index 8594b4a..a6414fa 100644 --- a/app/(dashboard)/projects/[id]/ProjectTabsClient.tsx +++ b/app/(dashboard)/projects/[id]/ProjectTabsClient.tsx @@ -154,7 +154,7 @@ export function ProjectTabsClient({ ) : (
{shots.map((shot) => ( - + ))}
)} diff --git a/app/(dashboard)/projects/[id]/shots/[shotId]/page.tsx b/app/(dashboard)/projects/[id]/shots/[shotId]/page.tsx index 7bbfccb..7bfba97 100644 --- a/app/(dashboard)/projects/[id]/shots/[shotId]/page.tsx +++ b/app/(dashboard)/projects/[id]/shots/[shotId]/page.tsx @@ -20,10 +20,12 @@ import { Settings, ListTodo, Video, + Copy, } from "lucide-react"; import type { ShotWithDetails } from "@/types"; import { ShotSettingsTab } from "@/components/shots/ShotSettingsTab"; import { FootageViewer } from "@/components/shots/FootageViewer"; +import { duplicateShot } from "@/actions/shots"; const STATUS_CONFIG: Record< string, @@ -43,9 +45,12 @@ const PRIORITY_CONFIG: Record = { CRITICAL: { label: "Critical", dot: "bg-red-500" }, }; +import { useToast } from "@/components/ui/use-toast"; + export default function ShotDetailPage() { const params = useParams<{ id: string; shotId: string }>(); const router = useRouter(); + const { toast } = useToast(); const [shot, setShot] = useState(null); const [projectName, setProjectName] = useState(""); const [loading, setLoading] = useState(true); @@ -53,6 +58,7 @@ export default function ShotDetailPage() { const [tasks, setTasks] = useState([]); const [artists, setArtists] = useState([]); const [canManage, setCanManage] = useState(false); + const [isDuplicating, setIsDuplicating] = useState(false); const [activeTab, setActiveTab] = useState<"tasks" | "footage" | "settings">("tasks"); const fetchShot = async () => { @@ -80,6 +86,20 @@ export default function ShotDetailPage() { fetchShot(); }, [params.shotId]); + const handleDuplicate = async () => { + if (!shot) return; + setIsDuplicating(true); + try { + const { shot: newShot } = await duplicateShot(shot.id); + toast({ title: "Shot duplicated", description: `Created ${newShot.shotCode}` }); + router.push(`/projects/${params.id}/shots/${newShot.id}`); + } catch (e) { + toast({ title: "Duplicate failed", description: e instanceof Error ? e.message : undefined, variant: "destructive" }); + } finally { + setIsDuplicating(false); + } + }; + if (loading) { return (
@@ -172,7 +192,20 @@ export default function ShotDetailPage() {
- + {canManage && ( +
+ +
+ )} diff --git a/components/shots/ShotCard.tsx b/components/shots/ShotCard.tsx index a672ef4..395077e 100644 --- a/components/shots/ShotCard.tsx +++ b/components/shots/ShotCard.tsx @@ -3,6 +3,7 @@ import Link from "next/link"; import Image from "next/image"; import { useRouter } from "next/navigation"; +import { useState } from "react"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Card, CardContent, CardFooter, CardHeader } from "@/components/ui/card"; @@ -11,6 +12,7 @@ import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, + DropdownMenuSeparator, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; import { cn } from "@/lib/utils"; @@ -23,13 +25,17 @@ import { CheckCircle2, AlertCircle, ArrowUpRight, + Copy, } from "lucide-react"; import type { ShotWithDetails } from "@/types"; +import { duplicateShot } from "@/actions/shots"; +import { useToast } from "@/components/ui/use-toast"; interface ShotCardProps { shot: ShotWithDetails; projectId: string; compact?: boolean; + canManage?: boolean; } const STATUS_CONFIG: Record< @@ -50,14 +56,29 @@ const PRIORITY_DOT: Record = { URGENT: "bg-red-500", }; -export function ShotCard({ shot, projectId, compact = false }: ShotCardProps) { +export function ShotCard({ shot, projectId, compact = false, canManage = false }: ShotCardProps) { const router = useRouter(); + const { toast } = useToast(); + const [isDuplicating, setIsDuplicating] = useState(false); const statusCfg = STATUS_CONFIG[shot.status] ?? STATUS_CONFIG.WAITING; const StatusIcon = statusCfg.icon; const latestVersion = shot.versions?.[0]; const openComments = shot.versions ?.reduce((sum, v) => sum + (v._count?.comments ?? 0), 0) ?? 0; + const handleDuplicate = async () => { + setIsDuplicating(true); + try { + const { shot: newShot } = await duplicateShot(shot.id); + toast({ title: "Shot duplicated", description: `Created ${newShot.shotCode}` }); + router.push(`/projects/${projectId}/shots/${newShot.id}`); + } catch (e) { + toast({ title: "Duplicate failed", description: e instanceof Error ? e.message : undefined, variant: "destructive" }); + } finally { + setIsDuplicating(false); + } + }; + if (compact) { return (
@@ -145,6 +166,19 @@ export function ShotCard({ shot, projectId, compact = false }: ShotCardProps) { View shot + {canManage && ( + <> + + + + {isDuplicating ? "Duplicating…" : "Duplicate shot"} + + + )}