@@ -90,6 +90,100 @@ export async function createShot(data: z.infer<typeof createShotSchema>) {
|
|||||||
return { success: true, shot };
|
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 ───────────────────────────────────────────────────────────────
|
// ── Update Shot ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const updateShotSchema = z.object({
|
const updateShotSchema = z.object({
|
||||||
|
|||||||
@@ -154,7 +154,7 @@ export function ProjectTabsClient({
|
|||||||
) : (
|
) : (
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-3">
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-3">
|
||||||
{shots.map((shot) => (
|
{shots.map((shot) => (
|
||||||
<ShotCard key={shot.id} shot={shot} projectId={projectId} />
|
<ShotCard key={shot.id} shot={shot} projectId={projectId} canManage={canManage} />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -20,10 +20,12 @@ import {
|
|||||||
Settings,
|
Settings,
|
||||||
ListTodo,
|
ListTodo,
|
||||||
Video,
|
Video,
|
||||||
|
Copy,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import type { ShotWithDetails } from "@/types";
|
import type { ShotWithDetails } from "@/types";
|
||||||
import { ShotSettingsTab } from "@/components/shots/ShotSettingsTab";
|
import { ShotSettingsTab } from "@/components/shots/ShotSettingsTab";
|
||||||
import { FootageViewer } from "@/components/shots/FootageViewer";
|
import { FootageViewer } from "@/components/shots/FootageViewer";
|
||||||
|
import { duplicateShot } from "@/actions/shots";
|
||||||
|
|
||||||
const STATUS_CONFIG: Record<
|
const STATUS_CONFIG: Record<
|
||||||
string,
|
string,
|
||||||
@@ -43,9 +45,12 @@ const PRIORITY_CONFIG: Record<string, { label: string; dot: string }> = {
|
|||||||
CRITICAL: { label: "Critical", dot: "bg-red-500" },
|
CRITICAL: { label: "Critical", dot: "bg-red-500" },
|
||||||
};
|
};
|
||||||
|
|
||||||
|
import { useToast } from "@/components/ui/use-toast";
|
||||||
|
|
||||||
export default function ShotDetailPage() {
|
export default function ShotDetailPage() {
|
||||||
const params = useParams<{ id: string; shotId: string }>();
|
const params = useParams<{ id: string; shotId: string }>();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const { toast } = useToast();
|
||||||
const [shot, setShot] = useState<ShotWithDetails | null>(null);
|
const [shot, setShot] = useState<ShotWithDetails | null>(null);
|
||||||
const [projectName, setProjectName] = useState<string>("");
|
const [projectName, setProjectName] = useState<string>("");
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
@@ -53,6 +58,7 @@ export default function ShotDetailPage() {
|
|||||||
const [tasks, setTasks] = useState<any[]>([]);
|
const [tasks, setTasks] = useState<any[]>([]);
|
||||||
const [artists, setArtists] = useState<any[]>([]);
|
const [artists, setArtists] = useState<any[]>([]);
|
||||||
const [canManage, setCanManage] = useState(false);
|
const [canManage, setCanManage] = useState(false);
|
||||||
|
const [isDuplicating, setIsDuplicating] = useState(false);
|
||||||
const [activeTab, setActiveTab] = useState<"tasks" | "footage" | "settings">("tasks");
|
const [activeTab, setActiveTab] = useState<"tasks" | "footage" | "settings">("tasks");
|
||||||
|
|
||||||
const fetchShot = async () => {
|
const fetchShot = async () => {
|
||||||
@@ -80,6 +86,20 @@ export default function ShotDetailPage() {
|
|||||||
fetchShot();
|
fetchShot();
|
||||||
}, [params.shotId]);
|
}, [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) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center h-64">
|
<div className="flex items-center justify-center h-64">
|
||||||
@@ -172,7 +192,20 @@ export default function ShotDetailPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{canManage && (
|
||||||
|
<div className="ml-auto shrink-0">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleDuplicate}
|
||||||
|
disabled={isDuplicating}
|
||||||
|
className="gap-2"
|
||||||
|
>
|
||||||
|
<Copy className="h-3.5 w-3.5" />
|
||||||
|
{isDuplicating ? "Duplicating…" : "Duplicate Shot"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Separator />
|
<Separator />
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
|
import { useState } from "react";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Card, CardContent, CardFooter, CardHeader } from "@/components/ui/card";
|
import { Card, CardContent, CardFooter, CardHeader } from "@/components/ui/card";
|
||||||
@@ -11,6 +12,7 @@ import {
|
|||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
DropdownMenuContent,
|
DropdownMenuContent,
|
||||||
DropdownMenuItem,
|
DropdownMenuItem,
|
||||||
|
DropdownMenuSeparator,
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from "@/components/ui/dropdown-menu";
|
} from "@/components/ui/dropdown-menu";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
@@ -23,13 +25,17 @@ import {
|
|||||||
CheckCircle2,
|
CheckCircle2,
|
||||||
AlertCircle,
|
AlertCircle,
|
||||||
ArrowUpRight,
|
ArrowUpRight,
|
||||||
|
Copy,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import type { ShotWithDetails } from "@/types";
|
import type { ShotWithDetails } from "@/types";
|
||||||
|
import { duplicateShot } from "@/actions/shots";
|
||||||
|
import { useToast } from "@/components/ui/use-toast";
|
||||||
|
|
||||||
interface ShotCardProps {
|
interface ShotCardProps {
|
||||||
shot: ShotWithDetails;
|
shot: ShotWithDetails;
|
||||||
projectId: string;
|
projectId: string;
|
||||||
compact?: boolean;
|
compact?: boolean;
|
||||||
|
canManage?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const STATUS_CONFIG: Record<
|
const STATUS_CONFIG: Record<
|
||||||
@@ -50,14 +56,29 @@ const PRIORITY_DOT: Record<string, string> = {
|
|||||||
URGENT: "bg-red-500",
|
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 router = useRouter();
|
||||||
|
const { toast } = useToast();
|
||||||
|
const [isDuplicating, setIsDuplicating] = useState(false);
|
||||||
const statusCfg = STATUS_CONFIG[shot.status] ?? STATUS_CONFIG.WAITING;
|
const statusCfg = STATUS_CONFIG[shot.status] ?? STATUS_CONFIG.WAITING;
|
||||||
const StatusIcon = statusCfg.icon;
|
const StatusIcon = statusCfg.icon;
|
||||||
const latestVersion = shot.versions?.[0];
|
const latestVersion = shot.versions?.[0];
|
||||||
const openComments = shot.versions
|
const openComments = shot.versions
|
||||||
?.reduce((sum, v) => sum + (v._count?.comments ?? 0), 0) ?? 0;
|
?.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) {
|
if (compact) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-3 px-3 py-2.5 rounded-lg border border-border hover:border-border/80 transition-colors group">
|
<div className="flex items-center gap-3 px-3 py-2.5 rounded-lg border border-border hover:border-border/80 transition-colors group">
|
||||||
@@ -145,6 +166,19 @@ export function ShotCard({ shot, projectId, compact = false }: ShotCardProps) {
|
|||||||
View shot
|
View shot
|
||||||
</Link>
|
</Link>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
|
{canManage && (
|
||||||
|
<>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={handleDuplicate}
|
||||||
|
disabled={isDuplicating}
|
||||||
|
className="gap-2"
|
||||||
|
>
|
||||||
|
<Copy className="h-3.5 w-3.5" />
|
||||||
|
{isDuplicating ? "Duplicating…" : "Duplicate shot"}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user