new Approval Updates
Deploy / deploy (push) Failing after 3m34s

This commit is contained in:
twotalesanimation
2026-06-11 12:30:28 +02:00
parent b9610454d9
commit 3f0c5d1dbe
19 changed files with 2015 additions and 70 deletions
+178 -1
View File
@@ -4,7 +4,8 @@ import { auth } from "@/auth";
import { db } from "@/lib/db"; import { db } from "@/lib/db";
import { revalidatePath } from "next/cache"; import { revalidatePath } from "next/cache";
import { z } from "zod"; import { z } from "zod";
import { ShotStatus, ShotPriority } from "@prisma/client"; import { ShotStatus, ShotPriority, ShotApprovalStatus } from "@prisma/client";
import { recalcShotStatus } from "@/lib/shot-status";
const createShotSchema = z.object({ const createShotSchema = z.object({
scene: z.string().min(1).max(50).regex(/^[A-Z0-9_]+$/i, "Alphanumeric and underscore only"), scene: z.string().min(1).max(50).regex(/^[A-Z0-9_]+$/i, "Alphanumeric and underscore only"),
@@ -201,6 +202,8 @@ const updateShotSchema = z.object({
description: z.string().optional(), description: z.string().optional(),
status: z.nativeEnum(ShotStatus).optional(), status: z.nativeEnum(ShotStatus).optional(),
priority: z.nativeEnum(ShotPriority).optional(), priority: z.nativeEnum(ShotPriority).optional(),
shotApprovalStatus: z.nativeEnum(ShotApprovalStatus).optional(),
sharedWithClient: z.boolean().optional(),
fps: z.number().optional(), fps: z.number().optional(),
frameStart: z.number().int().optional().nullable(), frameStart: z.number().int().optional().nullable(),
frameEnd: z.number().int().optional().nullable(), frameEnd: z.number().int().optional().nullable(),
@@ -569,3 +572,177 @@ export async function renameFootagePlate(plateId: string, label: string) {
return { success: true }; return { success: true };
} }
// ── Internal Approval ─────────────────────────────────────────────────────────
/**
* Supervisor/Producer/Admin: mark a shot as internally approved.
* Sets shotApprovalStatus = INTERNALLY_APPROVED, sharedWithClient = false.
* Shot status becomes READY_FOR_CLIENT.
*/
export async function internallyApproveShot(shotId: 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");
}
const shot = await db.shot.findUnique({
where: { id: shotId },
select: { projectId: true },
});
if (!shot) throw new Error("Shot not found");
await db.shot.update({
where: { id: shotId },
data: {
shotApprovalStatus: "INTERNALLY_APPROVED",
sharedWithClient: false,
status: "READY_FOR_CLIENT",
},
});
revalidatePath(`/projects/${shot.projectId}`);
revalidatePath(`/projects/${shot.projectId}/shots/${shotId}`);
revalidatePath(`/shot-status`);
return { success: true };
}
// ── Share / Unshare With Client ───────────────────────────────────────────────
/**
* Producer/Supervisor/Admin: share an internally-approved shot with the client.
* Sets sharedWithClient = true → status becomes CLIENT_REVIEW.
*/
export async function shareWithClient(shotId: 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");
}
const shot = await db.shot.findUnique({
where: { id: shotId },
select: { projectId: true, shotApprovalStatus: true },
});
if (!shot) throw new Error("Shot not found");
if (shot.shotApprovalStatus !== "INTERNALLY_APPROVED") {
throw new Error("Shot must be internally approved before sharing with client");
}
await db.shot.update({
where: { id: shotId },
data: { sharedWithClient: true, status: "CLIENT_REVIEW" },
});
revalidatePath(`/projects/${shot.projectId}`);
revalidatePath(`/projects/${shot.projectId}/shots/${shotId}`);
revalidatePath(`/shot-status`);
return { success: true };
}
/**
* Producer/Supervisor/Admin: remove a shot from client review.
* Sets sharedWithClient = false → status becomes READY_FOR_CLIENT.
*/
export async function unshareFromClient(shotId: 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");
}
const shot = await db.shot.findUnique({
where: { id: shotId },
select: { projectId: true },
});
if (!shot) throw new Error("Shot not found");
await db.shot.update({
where: { id: shotId },
data: { sharedWithClient: false, status: "READY_FOR_CLIENT" },
});
revalidatePath(`/projects/${shot.projectId}`);
revalidatePath(`/projects/${shot.projectId}/shots/${shotId}`);
revalidatePath(`/shot-status`);
return { success: true };
}
// ── Shot-Level Client Approval ────────────────────────────────────────────────
/**
* Internal: handle client approving the shot.
* Sets shotApprovalStatus = CLIENT_APPROVED → status becomes COMPLETE.
*/
export async function clientApproveShot(shotId: string) {
const session = await auth();
if (!session?.user) throw new Error("Unauthorized");
if (!["ADMIN", "PRODUCER", "SUPERVISOR", "CLIENT"].includes(session.user.role)) {
throw new Error("Insufficient permissions");
}
const shot = await db.shot.findUnique({
where: { id: shotId },
select: { projectId: true, sharedWithClient: true },
});
if (!shot) throw new Error("Shot not found");
if (!shot.sharedWithClient) throw new Error("Shot is not shared with client");
await db.shot.update({
where: { id: shotId },
data: { shotApprovalStatus: "CLIENT_APPROVED", status: "COMPLETE" },
});
revalidatePath(`/projects/${shot.projectId}`);
revalidatePath(`/projects/${shot.projectId}/shots/${shotId}`);
revalidatePath(`/shot-status`);
return { success: true };
}
/**
* Internal: handle client requesting changes on a shot.
* Resets shotApprovalStatus = PENDING, sharedWithClient = false.
* Recalculates shot status (will become REVISIONS if tasks set to CHANGES).
*/
export async function clientRequestShotChanges(shotId: string, taskId?: string) {
const session = await auth();
if (!session?.user) throw new Error("Unauthorized");
if (!["ADMIN", "PRODUCER", "SUPERVISOR", "CLIENT"].includes(session.user.role)) {
throw new Error("Insufficient permissions");
}
const shot = await db.shot.findUnique({
where: { id: shotId },
select: { projectId: true },
});
if (!shot) throw new Error("Shot not found");
await db.$transaction(async (tx) => {
// Reset shot approval
await tx.shot.update({
where: { id: shotId },
data: { shotApprovalStatus: "PENDING", sharedWithClient: false },
});
// If a specific task is called out, mark it CHANGES; otherwise mark all non-DONE tasks
if (taskId) {
await tx.task.update({
where: { id: taskId },
data: { status: "CHANGES" },
});
} else {
await tx.task.updateMany({
where: { shotId, status: { not: "DONE" } },
data: { status: "CHANGES" },
});
}
await recalcShotStatus(shotId, tx);
});
revalidatePath(`/projects/${shot.projectId}`);
revalidatePath(`/projects/${shot.projectId}/shots/${shotId}`);
revalidatePath(`/shot-status`);
return { success: true };
}
@@ -21,11 +21,15 @@ import {
ListTodo, ListTodo,
Video, Video,
Copy, Copy,
ShieldCheck,
Share2,
Eye,
EyeOff,
} 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"; import { duplicateShot, internallyApproveShot, shareWithClient, unshareFromClient } from "@/actions/shots";
const STATUS_CONFIG: Record< const STATUS_CONFIG: Record<
string, string,
@@ -33,7 +37,9 @@ const STATUS_CONFIG: Record<
> = { > = {
WAITING: { label: "Waiting", className: "bg-zinc-500/10 text-zinc-400 border-zinc-500/20", Icon: Clock }, WAITING: { label: "Waiting", className: "bg-zinc-500/10 text-zinc-400 border-zinc-500/20", Icon: Clock },
IN_PROGRESS: { label: "In Progress", className: "bg-blue-500/10 text-blue-400 border-blue-500/20", Icon: Film }, IN_PROGRESS: { label: "In Progress", className: "bg-blue-500/10 text-blue-400 border-blue-500/20", Icon: Film },
IN_REVIEW: { label: "In Review", className: "bg-amber-500/10 text-amber-400 border-amber-500/20", Icon: AlertCircle }, INTERNAL_REVIEW: { label: "Internal Review", className: "bg-purple-500/10 text-purple-400 border-purple-500/20", Icon: AlertCircle },
READY_FOR_CLIENT: { label: "Ready for Client", className: "bg-sky-500/10 text-sky-400 border-sky-500/20", Icon: ShieldCheck },
CLIENT_REVIEW: { label: "Client Review", className: "bg-indigo-500/10 text-indigo-400 border-indigo-500/20", Icon: Eye },
REVISIONS: { label: "Revisions", className: "bg-orange-500/10 text-orange-400 border-orange-500/20", Icon: AlertCircle }, REVISIONS: { label: "Revisions", className: "bg-orange-500/10 text-orange-400 border-orange-500/20", Icon: AlertCircle },
COMPLETE: { label: "Complete", className: "bg-emerald-500/10 text-emerald-400 border-emerald-500/20", Icon: CheckCircle2 }, COMPLETE: { label: "Complete", className: "bg-emerald-500/10 text-emerald-400 border-emerald-500/20", Icon: CheckCircle2 },
}; };
@@ -55,10 +61,12 @@ export default function ShotDetailPage() {
const [projectName, setProjectName] = useState<string>(""); const [projectName, setProjectName] = useState<string>("");
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [canApprove, setCanApprove] = useState(false); const [canApprove, setCanApprove] = useState(false);
const [canInternallyApprove, setCanInternallyApprove] = useState(false);
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 [isDuplicating, setIsDuplicating] = useState(false);
const [isActioning, setIsActioning] = useState(false);
const [activeTab, setActiveTab] = useState<"tasks" | "footage" | "settings">("tasks"); const [activeTab, setActiveTab] = useState<"tasks" | "footage" | "settings">("tasks");
const fetchShot = async () => { const fetchShot = async () => {
@@ -72,6 +80,7 @@ export default function ShotDetailPage() {
setShot(data.shot); setShot(data.shot);
setProjectName(data.projectName ?? ""); setProjectName(data.projectName ?? "");
setCanApprove(data.canApprove ?? false); setCanApprove(data.canApprove ?? false);
setCanInternallyApprove(data.canInternallyApprove ?? false);
setTasks(data.tasks ?? []); setTasks(data.tasks ?? []);
setArtists(data.artists ?? []); setArtists(data.artists ?? []);
setCanManage(data.canApprove ?? false); setCanManage(data.canApprove ?? false);
@@ -86,6 +95,48 @@ export default function ShotDetailPage() {
fetchShot(); fetchShot();
}, [params.shotId]); }, [params.shotId]);
const handleInternalApprove = async () => {
if (!shot) return;
setIsActioning(true);
try {
await internallyApproveShot(shot.id);
toast({ title: "Shot internally approved", description: "Status: Ready for Client" });
fetchShot();
} catch (e) {
toast({ title: "Failed", description: e instanceof Error ? e.message : undefined, variant: "destructive" });
} finally {
setIsActioning(false);
}
};
const handleShareWithClient = async () => {
if (!shot) return;
setIsActioning(true);
try {
await shareWithClient(shot.id);
toast({ title: "Shared with client", description: "Status: Client Review" });
fetchShot();
} catch (e) {
toast({ title: "Failed", description: e instanceof Error ? e.message : undefined, variant: "destructive" });
} finally {
setIsActioning(false);
}
};
const handleUnshare = async () => {
if (!shot) return;
setIsActioning(true);
try {
await unshareFromClient(shot.id);
toast({ title: "Removed from client review", description: "Status: Ready for Client" });
fetchShot();
} catch (e) {
toast({ title: "Failed", description: e instanceof Error ? e.message : undefined, variant: "destructive" });
} finally {
setIsActioning(false);
}
};
const handleDuplicate = async () => { const handleDuplicate = async () => {
if (!shot) return; if (!shot) return;
setIsDuplicating(true); setIsDuplicating(true);
@@ -167,6 +218,37 @@ export default function ShotDetailPage() {
{statusCfg.label} {statusCfg.label}
</Badge> </Badge>
{/* Approval status badges */}
{shot.shotApprovalStatus === "PENDING" && (
<Badge variant="outline" className="bg-zinc-500/10 text-zinc-400 border-zinc-500/20 text-[11px]">
Pending Internal Approval
</Badge>
)}
{shot.shotApprovalStatus === "INTERNALLY_APPROVED" && (
<Badge variant="outline" className="bg-sky-500/10 text-sky-400 border-sky-500/20 text-[11px]">
<ShieldCheck className="h-3 w-3 mr-1" />
Internally Approved
</Badge>
)}
{shot.shotApprovalStatus === "CLIENT_APPROVED" && (
<Badge variant="outline" className="bg-emerald-500/10 text-emerald-400 border-emerald-500/20 text-[11px]">
<CheckCircle2 className="h-3 w-3 mr-1" />
Client Approved
</Badge>
)}
{/* Client sharing badge */}
{shot.shotApprovalStatus === "INTERNALLY_APPROVED" && (
<Badge variant="outline" className={cn(
"text-[11px]",
shot.sharedWithClient
? "bg-indigo-500/10 text-indigo-400 border-indigo-500/20"
: "bg-zinc-500/10 text-zinc-500 border-zinc-600/20"
)}>
{shot.sharedWithClient ? "Shared With Client" : "Not Shared"}
</Badge>
)}
<div className="flex items-center gap-1.5 text-sm text-muted-foreground"> <div className="flex items-center gap-1.5 text-sm text-muted-foreground">
<span <span
className={`h-2 w-2 rounded-full ${priorityCfg.dot}`} className={`h-2 w-2 rounded-full ${priorityCfg.dot}`}
@@ -193,7 +275,48 @@ export default function ShotDetailPage() {
</div> </div>
{canManage && ( {canManage && (
<div className="ml-auto shrink-0"> <div className="ml-auto shrink-0 flex items-center gap-2">
{/* Internal approval action */}
{canInternallyApprove && shot.shotApprovalStatus === "PENDING" && (
<Button
variant="outline"
size="sm"
onClick={handleInternalApprove}
disabled={isActioning}
className="gap-2 border-sky-500/40 text-sky-400 hover:bg-sky-500/10"
>
<ShieldCheck className="h-3.5 w-3.5" />
Approve Internally
</Button>
)}
{/* Share / Unshare with client */}
{canInternallyApprove && shot.shotApprovalStatus === "INTERNALLY_APPROVED" && !shot.sharedWithClient && (
<Button
variant="outline"
size="sm"
onClick={handleShareWithClient}
disabled={isActioning}
className="gap-2 border-indigo-500/40 text-indigo-400 hover:bg-indigo-500/10"
>
<Share2 className="h-3.5 w-3.5" />
Share With Client
</Button>
)}
{canInternallyApprove && shot.sharedWithClient && (
<Button
variant="outline"
size="sm"
onClick={handleUnshare}
disabled={isActioning}
className="gap-2 border-zinc-500/40 text-zinc-400 hover:bg-zinc-500/10"
>
<EyeOff className="h-3.5 w-3.5" />
Remove From Client Review
</Button>
)}
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"
@@ -35,6 +35,8 @@ import {
ChevronRight, ChevronRight,
Loader2, Loader2,
X, X,
ShieldCheck,
Eye,
} from "lucide-react"; } from "lucide-react";
import { format } from "date-fns"; import { format } from "date-fns";
@@ -72,7 +74,9 @@ interface ShotStatusClientProps {
const STATUS_CONFIG: Record<string, { label: string; color: string; icon: React.ElementType }> = { const STATUS_CONFIG: Record<string, { label: string; color: string; icon: React.ElementType }> = {
WAITING: { label: "Waiting", color: "bg-zinc-500/10 text-zinc-400 border-zinc-500/20", icon: Clock }, WAITING: { label: "Waiting", color: "bg-zinc-500/10 text-zinc-400 border-zinc-500/20", icon: Clock },
IN_PROGRESS: { label: "In Progress", color: "bg-blue-500/10 text-blue-400 border-blue-500/20", icon: Film }, IN_PROGRESS: { label: "In Progress", color: "bg-blue-500/10 text-blue-400 border-blue-500/20", icon: Film },
IN_REVIEW: { label: "In Review", color: "bg-purple-500/10 text-purple-400 border-purple-500/20", icon: AlertCircle }, INTERNAL_REVIEW: { label: "Internal Review", color: "bg-purple-500/10 text-purple-400 border-purple-500/20", icon: AlertCircle },
READY_FOR_CLIENT: { label: "Ready for Client", color: "bg-sky-500/10 text-sky-400 border-sky-500/20", icon: ShieldCheck },
CLIENT_REVIEW: { label: "Client Review", color: "bg-indigo-500/10 text-indigo-400 border-indigo-500/20", icon: Eye },
REVISIONS: { label: "Revisions", color: "bg-orange-500/10 text-orange-400 border-orange-500/20", icon: AlertCircle }, REVISIONS: { label: "Revisions", color: "bg-orange-500/10 text-orange-400 border-orange-500/20", icon: AlertCircle },
COMPLETE: { label: "Complete", color: "bg-emerald-500/10 text-emerald-400 border-emerald-500/20", icon: CheckCircle2 }, COMPLETE: { label: "Complete", color: "bg-emerald-500/10 text-emerald-400 border-emerald-500/20", icon: CheckCircle2 },
}; };
+40 -2
View File
@@ -34,8 +34,47 @@ export async function POST(
} }
const body = await req.json(); const body = await req.json();
const { versionId, status, notes } = body; const { versionId, shotId, action, status, notes } = body;
// ── Shot-level approval action ──────────────────────────────────────────────
if (shotId && action) {
const shot = await db.shot.findUnique({
where: { id: shotId },
select: { projectId: true, sharedWithClient: true },
});
if (!shot || shot.projectId !== session.projectId) {
return NextResponse.json({ error: "Shot not found" }, { status: 404 });
}
if (!shot.sharedWithClient) {
return NextResponse.json({ error: "Shot is not available for client review" }, { status: 403 });
}
if (action === "APPROVE") {
await db.shot.update({
where: { id: shotId },
data: { shotApprovalStatus: "CLIENT_APPROVED", status: "COMPLETE" },
});
} else if (action === "NEEDS_CHANGES") {
await db.$transaction(async (tx) => {
await tx.shot.update({
where: { id: shotId },
data: { shotApprovalStatus: "PENDING", sharedWithClient: false },
});
// Mark all non-DONE tasks as CHANGES
await tx.task.updateMany({
where: { shotId, status: { not: "DONE" } },
data: { status: "CHANGES" },
});
await recalcShotStatus(shotId, tx);
});
} else {
return NextResponse.json({ error: "Invalid action" }, { status: 400 });
}
return NextResponse.json({ success: true });
}
// ── Version-level approval (legacy task-based flow) ─────────────────────────
const validStatuses: ApprovalStatus[] = ["APPROVED", "REJECTED", "NEEDS_CHANGES"]; const validStatuses: ApprovalStatus[] = ["APPROVED", "REJECTED", "NEEDS_CHANGES"];
if (!versionId || !validStatuses.includes(status)) { if (!versionId || !validStatuses.includes(status)) {
return NextResponse.json({ error: "versionId and valid status required" }, { status: 400 }); return NextResponse.json({ error: "versionId and valid status required" }, { status: 400 });
@@ -101,4 +140,3 @@ export async function POST(
return NextResponse.json({ success: true }); return NextResponse.json({ success: true });
} }
+3 -6
View File
@@ -28,15 +28,11 @@ export async function GET(
return NextResponse.json({ error: "Project not found" }, { status: 404 }); return NextResponse.json({ error: "Project not found" }, { status: 404 });
} }
// Find shots that have at least one task with a client-visible version // Only return shots that have been explicitly shared with the client
const shots = await db.shot.findMany({ const shots = await db.shot.findMany({
where: { where: {
projectId: session.projectId, projectId: session.projectId,
tasks: { sharedWithClient: true,
some: {
versions: { some: { isClientVisible: true } },
},
},
}, },
orderBy: [{ sequence: "asc" }, { shotCode: "asc" }], orderBy: [{ sequence: "asc" }, { shotCode: "asc" }],
select: { select: {
@@ -45,6 +41,7 @@ export async function GET(
sequence: true, sequence: true,
description: true, description: true,
status: true, status: true,
shotApprovalStatus: true,
thumbnailUrl: true, thumbnailUrl: true,
tasks: { tasks: {
where: { where: {
+1
View File
@@ -83,6 +83,7 @@ export async function GET(
shot: shotSerialized, shot: shotSerialized,
projectName: project?.name ?? "", projectName: project?.name ?? "",
canApprove, canApprove,
canInternallyApprove: ["ADMIN", "PRODUCER", "SUPERVISOR"].includes(session.user.role as string),
tasks, tasks,
artists, artists,
}); });
+6 -1
View File
@@ -14,6 +14,9 @@ import {
Package, Package,
CalendarDays, CalendarDays,
CheckCircle2, CheckCircle2,
AlertCircle,
ShieldCheck,
Eye,
} from "lucide-react"; } from "lucide-react";
import { ShotStatus } from "@prisma/client"; import { ShotStatus } from "@prisma/client";
import { formatDistanceToNow } from "date-fns"; import { formatDistanceToNow } from "date-fns";
@@ -24,7 +27,9 @@ const STATUS_CONFIG: Record<
> = { > = {
WAITING: { label: "Waiting", color: "bg-zinc-500/10 text-zinc-400 border-zinc-500/20" }, WAITING: { label: "Waiting", color: "bg-zinc-500/10 text-zinc-400 border-zinc-500/20" },
IN_PROGRESS: { label: "In Progress", color: "bg-blue-500/10 text-blue-400 border-blue-500/20" }, IN_PROGRESS: { label: "In Progress", color: "bg-blue-500/10 text-blue-400 border-blue-500/20" },
IN_REVIEW: { label: "In Review", color: "bg-purple-500/10 text-purple-400 border-purple-500/20" }, INTERNAL_REVIEW: { label: "Internal Review", color: "bg-purple-500/10 text-purple-400 border-purple-500/20" },
READY_FOR_CLIENT: { label: "Ready for Client", color: "bg-sky-500/10 text-sky-400 border-sky-500/20" },
CLIENT_REVIEW: { label: "Client Review", color: "bg-indigo-500/10 text-indigo-400 border-indigo-500/20" },
REVISIONS: { label: "Revisions", color: "bg-orange-500/10 text-orange-400 border-orange-500/20" }, REVISIONS: { label: "Revisions", color: "bg-orange-500/10 text-orange-400 border-orange-500/20" },
COMPLETE: { label: "Complete", color: "bg-emerald-500/10 text-emerald-400 border-emerald-500/20" }, COMPLETE: { label: "Complete", color: "bg-emerald-500/10 text-emerald-400 border-emerald-500/20" },
}; };
+8 -2
View File
@@ -12,6 +12,8 @@ import {
AlertCircle, AlertCircle,
Film, Film,
MessageSquare, MessageSquare,
ShieldCheck,
Eye,
} from "lucide-react"; } from "lucide-react";
import type { ShotWithDetails } from "@/types"; import type { ShotWithDetails } from "@/types";
@@ -24,7 +26,9 @@ interface ShotQueueProps {
const STATUS_STYLES: Record<string, string> = { const STATUS_STYLES: Record<string, string> = {
WAITING: "text-zinc-400", WAITING: "text-zinc-400",
IN_PROGRESS: "text-blue-400", IN_PROGRESS: "text-blue-400",
IN_REVIEW: "text-purple-400", INTERNAL_REVIEW: "text-purple-400",
READY_FOR_CLIENT: "text-sky-400",
CLIENT_REVIEW: "text-indigo-400",
REVISIONS: "text-orange-400", REVISIONS: "text-orange-400",
COMPLETE: "text-emerald-400", COMPLETE: "text-emerald-400",
}; };
@@ -32,7 +36,9 @@ const STATUS_STYLES: Record<string, string> = {
const STATUS_ICONS: Record<string, React.ElementType> = { const STATUS_ICONS: Record<string, React.ElementType> = {
WAITING: Clock, WAITING: Clock,
IN_PROGRESS: Film, IN_PROGRESS: Film,
IN_REVIEW: AlertCircle, INTERNAL_REVIEW: AlertCircle,
READY_FOR_CLIENT: ShieldCheck,
CLIENT_REVIEW: Eye,
REVISIONS: AlertCircle, REVISIONS: AlertCircle,
COMPLETE: CheckCircle2, COMPLETE: CheckCircle2,
}; };
+32 -1
View File
@@ -26,6 +26,9 @@ import {
AlertCircle, AlertCircle,
ArrowUpRight, ArrowUpRight,
Copy, Copy,
ShieldCheck,
Share2,
Eye,
} from "lucide-react"; } from "lucide-react";
import type { ShotWithDetails } from "@/types"; import type { ShotWithDetails } from "@/types";
import { duplicateShot } from "@/actions/shots"; import { duplicateShot } from "@/actions/shots";
@@ -44,11 +47,19 @@ const STATUS_CONFIG: Record<
> = { > = {
WAITING: { label: "Waiting", color: "bg-zinc-500/10 text-zinc-400 border-zinc-500/20", icon: Clock }, WAITING: { label: "Waiting", color: "bg-zinc-500/10 text-zinc-400 border-zinc-500/20", icon: Clock },
IN_PROGRESS: { label: "In Progress", color: "bg-blue-500/10 text-blue-400 border-blue-500/20", icon: Film }, IN_PROGRESS: { label: "In Progress", color: "bg-blue-500/10 text-blue-400 border-blue-500/20", icon: Film },
IN_REVIEW: { label: "In Review", color: "bg-purple-500/10 text-purple-400 border-purple-500/20", icon: AlertCircle }, INTERNAL_REVIEW: { label: "Internal Review", color: "bg-purple-500/10 text-purple-400 border-purple-500/20", icon: AlertCircle },
READY_FOR_CLIENT: { label: "Ready for Client", color: "bg-sky-500/10 text-sky-400 border-sky-500/20", icon: ShieldCheck },
CLIENT_REVIEW: { label: "Client Review", color: "bg-indigo-500/10 text-indigo-400 border-indigo-500/20", icon: Eye },
REVISIONS: { label: "Revisions", color: "bg-orange-500/10 text-orange-400 border-orange-500/20", icon: AlertCircle }, REVISIONS: { label: "Revisions", color: "bg-orange-500/10 text-orange-400 border-orange-500/20", icon: AlertCircle },
COMPLETE: { label: "Complete", color: "bg-emerald-500/10 text-emerald-400 border-emerald-500/20", icon: CheckCircle2 }, COMPLETE: { label: "Complete", color: "bg-emerald-500/10 text-emerald-400 border-emerald-500/20", icon: CheckCircle2 },
}; };
const APPROVAL_BADGE: Record<string, { label: string; color: string }> = {
PENDING: { label: "Pending Internal Approval", color: "bg-zinc-500/10 text-zinc-400 border-zinc-500/20" },
INTERNALLY_APPROVED: { label: "Internally Approved", color: "bg-sky-500/10 text-sky-400 border-sky-500/20" },
CLIENT_APPROVED: { label: "Client Approved", color: "bg-emerald-500/10 text-emerald-400 border-emerald-500/20" },
};
const PRIORITY_DOT: Record<string, string> = { const PRIORITY_DOT: Record<string, string> = {
LOW: "bg-zinc-500", LOW: "bg-zinc-500",
NORMAL: "bg-blue-500", NORMAL: "bg-blue-500",
@@ -79,6 +90,8 @@ export function ShotCard({ shot, projectId, compact = false, canManage = false }
} }
}; };
const approvalCfg = APPROVAL_BADGE[shot.shotApprovalStatus] ?? APPROVAL_BADGE.PENDING;
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">
@@ -209,6 +222,24 @@ export function ShotCard({ shot, projectId, compact = false, canManage = false }
</span> </span>
)} )}
</div> </div>
{/* Approval + sharing badges */}
<div className="flex flex-wrap gap-1.5 mt-2">
<span className={cn("text-[10px] px-1.5 py-0.5 rounded border", approvalCfg.color)}>
{approvalCfg.label}
</span>
{shot.shotApprovalStatus === "INTERNALLY_APPROVED" && (
<span className={cn(
"text-[10px] px-1.5 py-0.5 rounded border flex items-center gap-1",
shot.sharedWithClient
? "bg-indigo-500/10 text-indigo-400 border-indigo-500/20"
: "bg-zinc-500/10 text-zinc-500 border-zinc-600/20"
)}>
<Share2 className="h-2.5 w-2.5" />
{shot.sharedWithClient ? "Shared With Client" : "Not Shared"}
</span>
)}
</div>
</CardContent> </CardContent>
<CardFooter className="pt-2 flex items-center justify-between"> <CardFooter className="pt-2 flex items-center justify-between">
+4 -2
View File
@@ -25,7 +25,7 @@ import { Upload, X, Film, ImageIcon, Trash2 } from "lucide-react";
const settingsSchema = z.object({ const settingsSchema = z.object({
shotCode: z.string().min(1, "Required").max(120).regex(/^[A-Z0-9_]+$/i, "Alphanumeric and underscores only"), shotCode: z.string().min(1, "Required").max(120).regex(/^[A-Z0-9_]+$/i, "Alphanumeric and underscores only"),
description: z.string().optional(), description: z.string().optional(),
status: z.enum(["WAITING", "IN_PROGRESS", "IN_REVIEW", "REVISIONS", "COMPLETE"]), status: z.enum(["WAITING", "IN_PROGRESS", "INTERNAL_REVIEW", "READY_FOR_CLIENT", "CLIENT_REVIEW", "REVISIONS", "COMPLETE"]),
priority: z.enum(["LOW", "NORMAL", "HIGH", "URGENT"]), priority: z.enum(["LOW", "NORMAL", "HIGH", "URGENT"]),
fps: z.coerce.number().min(1).max(240), fps: z.coerce.number().min(1).max(240),
frameStart: z.coerce.number().int().optional().or(z.literal("")), frameStart: z.coerce.number().int().optional().or(z.literal("")),
@@ -197,7 +197,9 @@ export function ShotSettingsTab({ shot, artists, onSaved }: ShotSettingsTabProps
<SelectContent> <SelectContent>
<SelectItem value="WAITING">Waiting</SelectItem> <SelectItem value="WAITING">Waiting</SelectItem>
<SelectItem value="IN_PROGRESS">In Progress</SelectItem> <SelectItem value="IN_PROGRESS">In Progress</SelectItem>
<SelectItem value="IN_REVIEW">In Review</SelectItem> <SelectItem value="INTERNAL_REVIEW">Internal Review</SelectItem>
<SelectItem value="READY_FOR_CLIENT">Ready for Client</SelectItem>
<SelectItem value="CLIENT_REVIEW">Client Review</SelectItem>
<SelectItem value="REVISIONS">Revisions</SelectItem> <SelectItem value="REVISIONS">Revisions</SelectItem>
<SelectItem value="COMPLETE">Complete</SelectItem> <SelectItem value="COMPLETE">Complete</SelectItem>
</SelectContent> </SelectContent>
+144
View File
@@ -0,0 +1,144 @@
import { describe, it, expect, vi } from "vitest";
// Mock Prisma db so the pure deriveShotStatus function can be tested in isolation
vi.mock("@/lib/db", () => ({ db: {} }));
import { deriveShotStatus } from "./shot-status";
import type { TaskStatus } from "@prisma/client";
// Helper to build task arrays quickly
const tasks = (...statuses: TaskStatus[]) => statuses.map((status) => ({ status }));
describe("deriveShotStatus", () => {
// ── WAITING ───────────────────────────────────────────────────────────────
it("returns WAITING when there are no tasks", () => {
expect(deriveShotStatus([], "PENDING", false)).toBe("WAITING");
});
it("returns WAITING when no tasks and internally approved (edge case: no tasks)", () => {
// With INTERNALLY_APPROVED + no tasks the approval fields take precedence
expect(deriveShotStatus([], "INTERNALLY_APPROVED", false)).toBe("READY_FOR_CLIENT");
});
// ── IN_PROGRESS ───────────────────────────────────────────────────────────
it("returns IN_PROGRESS when a task is TODO", () => {
expect(deriveShotStatus(tasks("TODO"), "PENDING", false)).toBe("IN_PROGRESS");
});
it("returns IN_PROGRESS when a task is IN_PROGRESS", () => {
expect(deriveShotStatus(tasks("IN_PROGRESS"), "PENDING", false)).toBe("IN_PROGRESS");
});
it("returns IN_PROGRESS when tasks are mixed TODO and DONE", () => {
expect(deriveShotStatus(tasks("TODO", "DONE"), "PENDING", false)).toBe("IN_PROGRESS");
});
it("returns IN_PROGRESS for INTERNAL_REVIEW task status (task in internal review)", () => {
// Task is in INTERNAL_REVIEW — not TODO/IN_PROGRESS/CHANGES → falls through to INTERNAL_REVIEW shot status
expect(deriveShotStatus(tasks("INTERNAL_REVIEW"), "PENDING", false)).toBe("INTERNAL_REVIEW");
});
// ── REVISIONS (highest priority) ─────────────────────────────────────────
it("returns REVISIONS when any task is CHANGES", () => {
expect(deriveShotStatus(tasks("CHANGES"), "PENDING", false)).toBe("REVISIONS");
});
it("returns REVISIONS even when some tasks are DONE", () => {
expect(deriveShotStatus(tasks("DONE", "CHANGES"), "PENDING", false)).toBe("REVISIONS");
});
it("returns REVISIONS even when shot is INTERNALLY_APPROVED", () => {
// If somehow a task is in CHANGES after approval, REVISIONS wins
expect(deriveShotStatus(tasks("CHANGES"), "INTERNALLY_APPROVED", false)).toBe("REVISIONS");
});
it("returns REVISIONS even when shot is CLIENT_APPROVED", () => {
expect(deriveShotStatus(tasks("CHANGES"), "CLIENT_APPROVED", false)).toBe("REVISIONS");
});
// ── INTERNAL_REVIEW ───────────────────────────────────────────────────────
it("returns INTERNAL_REVIEW when all tasks are DONE and approval is PENDING", () => {
expect(deriveShotStatus(tasks("DONE", "DONE"), "PENDING", false)).toBe("INTERNAL_REVIEW");
});
it("returns INTERNAL_REVIEW when tasks are INTERNAL_REVIEW/CLIENT_REVIEW and approval is PENDING", () => {
expect(deriveShotStatus(tasks("INTERNAL_REVIEW", "CLIENT_REVIEW"), "PENDING", false)).toBe("INTERNAL_REVIEW");
});
it("returns INTERNAL_REVIEW when all tasks are DONE, regardless of sharedWithClient", () => {
// sharedWithClient has no effect when approval is still PENDING
expect(deriveShotStatus(tasks("DONE"), "PENDING", true)).toBe("INTERNAL_REVIEW");
});
// ── READY_FOR_CLIENT ──────────────────────────────────────────────────────
it("returns READY_FOR_CLIENT when internally approved and not shared", () => {
expect(deriveShotStatus(tasks("DONE"), "INTERNALLY_APPROVED", false)).toBe("READY_FOR_CLIENT");
});
it("returns READY_FOR_CLIENT when internally approved and not shared (no tasks)", () => {
expect(deriveShotStatus([], "INTERNALLY_APPROVED", false)).toBe("READY_FOR_CLIENT");
});
// ── CLIENT_REVIEW ─────────────────────────────────────────────────────────
it("returns CLIENT_REVIEW when internally approved and shared", () => {
expect(deriveShotStatus(tasks("DONE"), "INTERNALLY_APPROVED", true)).toBe("CLIENT_REVIEW");
});
it("returns CLIENT_REVIEW when internally approved, shared, and tasks still in progress", () => {
// Approval field takes precedence over task statuses (except CHANGES)
expect(deriveShotStatus(tasks("TODO"), "INTERNALLY_APPROVED", true)).toBe("CLIENT_REVIEW");
});
// ── COMPLETE ──────────────────────────────────────────────────────────────
it("returns COMPLETE when client approved", () => {
expect(deriveShotStatus(tasks("DONE"), "CLIENT_APPROVED", true)).toBe("COMPLETE");
});
it("returns COMPLETE when client approved even if not shared anymore", () => {
expect(deriveShotStatus(tasks("DONE"), "CLIENT_APPROVED", false)).toBe("COMPLETE");
});
// ── State transition scenarios ────────────────────────────────────────────
describe("full workflow transitions", () => {
it("Artist work → tasks TODO → IN_PROGRESS", () => {
expect(deriveShotStatus(tasks("TODO"), "PENDING", false)).toBe("IN_PROGRESS");
});
it("Tasks in internal review → INTERNAL_REVIEW shot status", () => {
expect(deriveShotStatus(tasks("INTERNAL_REVIEW"), "PENDING", false)).toBe("INTERNAL_REVIEW");
});
it("All tasks done → INTERNAL_REVIEW (awaiting supervisor)", () => {
expect(deriveShotStatus(tasks("DONE", "DONE", "DONE"), "PENDING", false)).toBe("INTERNAL_REVIEW");
});
it("Supervisor approves → READY_FOR_CLIENT", () => {
expect(deriveShotStatus(tasks("DONE"), "INTERNALLY_APPROVED", false)).toBe("READY_FOR_CLIENT");
});
it("Producer shares → CLIENT_REVIEW", () => {
expect(deriveShotStatus(tasks("DONE"), "INTERNALLY_APPROVED", true)).toBe("CLIENT_REVIEW");
});
it("Producer unshares → READY_FOR_CLIENT", () => {
expect(deriveShotStatus(tasks("DONE"), "INTERNALLY_APPROVED", false)).toBe("READY_FOR_CLIENT");
});
it("Client approves → COMPLETE", () => {
expect(deriveShotStatus(tasks("DONE"), "CLIENT_APPROVED", false)).toBe("COMPLETE");
});
it("Client requests changes → approval reset to PENDING + task CHANGES → REVISIONS", () => {
// When changes are requested: shotApprovalStatus → PENDING, sharedWithClient → false, task → CHANGES
expect(deriveShotStatus(tasks("CHANGES"), "PENDING", false)).toBe("REVISIONS");
});
});
});
+44 -18
View File
@@ -1,4 +1,4 @@
import { ShotStatus, TaskStatus } from "@prisma/client"; import { ShotStatus, ShotApprovalStatus, TaskStatus } from "@prisma/client";
import { db } from "@/lib/db"; import { db } from "@/lib/db";
import type { PrismaClient } from "@prisma/client"; import type { PrismaClient } from "@prisma/client";
@@ -8,30 +8,48 @@ type TxClient = Omit<
>; >;
/** /**
* Derive the shot status from its tasks. * Derive shot status from tasks + shot-level approval fields.
*
* Priority order (highest → lowest): * Priority order (highest → lowest):
* CHANGES on any task → REVISIONS * 1. REVISIONS — any task is CHANGES
* INTERNAL_REVIEW / CLIENT_REVIEW on any task → IN_REVIEW * 2. COMPLETE — shotApprovalStatus === CLIENT_APPROVED
* TODO / IN_PROGRESS on any task → IN_PROGRESS * 3. CLIENT_REVIEW — shotApprovalStatus === INTERNALLY_APPROVED && sharedWithClient
* All tasks DONE → COMPLETE * 4. READY_FOR_CLIENT — shotApprovalStatus === INTERNALLY_APPROVED && !sharedWithClient
* No tasks → WAITING * 5. IN_PROGRESS — any task is TODO or IN_PROGRESS
* 6. INTERNAL_REVIEW — tasks exist but none blocking, approval still PENDING
* 7. WAITING — no tasks
*/ */
export function deriveShotStatus( export function deriveShotStatus(
tasks: { status: TaskStatus }[] tasks: { status: TaskStatus }[],
shotApprovalStatus: ShotApprovalStatus,
sharedWithClient: boolean
): ShotStatus { ): ShotStatus {
if (tasks.length === 0) return "WAITING"; // 1. Changes requested — highest priority
if (tasks.some((t) => t.status === "CHANGES")) return "REVISIONS"; if (tasks.some((t) => t.status === "CHANGES")) return "REVISIONS";
if (tasks.some((t) => t.status === "INTERNAL_REVIEW" || t.status === "CLIENT_REVIEW"))
return "IN_REVIEW"; // 2. Client approved — terminal completion state
if (tasks.some((t) => t.status === "TODO" || t.status === "IN_PROGRESS")) if (shotApprovalStatus === "CLIENT_APPROVED") return "COMPLETE";
// 3 & 4. Internally approved — client-facing states
if (shotApprovalStatus === "INTERNALLY_APPROVED") {
return sharedWithClient ? "CLIENT_REVIEW" : "READY_FOR_CLIENT";
}
// 5. Active work in progress (approval still PENDING)
if (tasks.some((t) => t.status === "TODO" || t.status === "IN_PROGRESS")) {
return "IN_PROGRESS"; return "IN_PROGRESS";
if (tasks.every((t) => t.status === "DONE")) return "COMPLETE"; }
// 6. All work done, awaiting internal approval
if (tasks.length > 0) return "INTERNAL_REVIEW";
// 7. No tasks yet
return "WAITING"; return "WAITING";
} }
/** /**
* Query all tasks for a shot, derive the correct ShotStatus, and persist it. * Query all tasks + approval fields for a shot, derive the correct ShotStatus,
* Accepts an optional Prisma transaction client (tx) for use inside transactions. * and persist it. Accepts an optional Prisma transaction client (tx).
*/ */
export async function recalcShotStatus( export async function recalcShotStatus(
shotId: string, shotId: string,
@@ -39,12 +57,20 @@ export async function recalcShotStatus(
): Promise<void> { ): Promise<void> {
const client = tx ?? db; const client = tx ?? db;
const tasks = await client.task.findMany({ const [tasks, shot] = await Promise.all([
client.task.findMany({
where: { shotId }, where: { shotId },
select: { status: true }, select: { status: true },
}); }),
client.shot.findUnique({
where: { id: shotId },
select: { shotApprovalStatus: true, sharedWithClient: true },
}),
]);
const newStatus = deriveShotStatus(tasks); if (!shot) return;
const newStatus = deriveShotStatus(tasks, shot.shotApprovalStatus, shot.sharedWithClient);
await client.shot.update({ await client.shot.update({
where: { id: shotId }, where: { id: shotId },
+1310 -1
View File
File diff suppressed because it is too large Load Diff
+4 -1
View File
@@ -7,6 +7,8 @@
"build": "next build", "build": "next build",
"start": "next start", "start": "next start",
"lint": "next lint", "lint": "next lint",
"test": "vitest run",
"test:watch": "vitest",
"db:generate": "prisma generate", "db:generate": "prisma generate",
"db:push": "prisma db push", "db:push": "prisma db push",
"db:migrate": "prisma migrate dev", "db:migrate": "prisma migrate dev",
@@ -71,6 +73,7 @@
"prisma": "^6.19.3", "prisma": "^6.19.3",
"tailwindcss": "^3.4.17", "tailwindcss": "^3.4.17",
"tsx": "^4.19.2", "tsx": "^4.19.2",
"typescript": "^5" "typescript": "^5",
"vitest": "^2.1.9"
} }
} }
@@ -0,0 +1,54 @@
-- Two-stage review gate migration
-- Replaces IN_REVIEW with INTERNAL_REVIEW, adds READY_FOR_CLIENT and CLIENT_REVIEW,
-- adds ShotApprovalStatus enum, and adds shotApprovalStatus + sharedWithClient to shots.
-- ── 1. Recreate ShotStatus enum with new values ───────────────────────────────
-- Create replacement enum
CREATE TYPE "ShotStatus_new" AS ENUM (
'WAITING',
'IN_PROGRESS',
'INTERNAL_REVIEW',
'READY_FOR_CLIENT',
'CLIENT_REVIEW',
'REVISIONS',
'COMPLETE'
);
-- Migrate shots table: IN_REVIEW → INTERNAL_REVIEW
ALTER TABLE "shots"
ALTER COLUMN "status" TYPE "ShotStatus_new"
USING (
CASE "status"::text
WHEN 'IN_REVIEW' THEN 'INTERNAL_REVIEW'::"ShotStatus_new"
ELSE "status"::text::"ShotStatus_new"
END
);
-- Migrate assets table: IN_REVIEW → INTERNAL_REVIEW
ALTER TABLE "assets"
ALTER COLUMN "status" TYPE "ShotStatus_new"
USING (
CASE "status"::text
WHEN 'IN_REVIEW' THEN 'INTERNAL_REVIEW'::"ShotStatus_new"
ELSE "status"::text::"ShotStatus_new"
END
);
-- Drop old enum and rename new one
DROP TYPE "ShotStatus";
ALTER TYPE "ShotStatus_new" RENAME TO "ShotStatus";
-- ── 2. Create ShotApprovalStatus enum ────────────────────────────────────────
CREATE TYPE "ShotApprovalStatus" AS ENUM (
'PENDING',
'INTERNALLY_APPROVED',
'CLIENT_APPROVED'
);
-- ── 3. Add new columns to shots ───────────────────────────────────────────────
ALTER TABLE "shots"
ADD COLUMN "shotApprovalStatus" "ShotApprovalStatus" NOT NULL DEFAULT 'PENDING',
ADD COLUMN "sharedWithClient" BOOLEAN NOT NULL DEFAULT false;
+11 -1
View File
@@ -33,11 +33,19 @@ enum ProjectStatus {
enum ShotStatus { enum ShotStatus {
WAITING WAITING
IN_PROGRESS IN_PROGRESS
IN_REVIEW INTERNAL_REVIEW
READY_FOR_CLIENT
CLIENT_REVIEW
REVISIONS REVISIONS
COMPLETE COMPLETE
} }
enum ShotApprovalStatus {
PENDING
INTERNALLY_APPROVED
CLIENT_APPROVED
}
enum ShotPriority { enum ShotPriority {
LOW LOW
NORMAL NORMAL
@@ -287,6 +295,8 @@ model Shot {
originalFootageUrl String? originalFootageUrl String?
originalFootageKey String? originalFootageKey String?
shotGroupId String? shotGroupId String?
shotApprovalStatus ShotApprovalStatus @default(PENDING)
sharedWithClient Boolean @default(false)
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
+2 -2
View File
@@ -123,14 +123,14 @@ async function main() {
shotCode: "SH010", shotCode: "SH010",
sequence: "010", sequence: "010",
description: "Wide establishing shot of the space station exterior.", description: "Wide establishing shot of the space station exterior.",
status: ShotStatus.IN_REVIEW, status: ShotStatus.INTERNAL_REVIEW,
priority: ShotPriority.HIGH, priority: ShotPriority.HIGH,
}, },
{ {
shotCode: "SH020", shotCode: "SH020",
sequence: "010", sequence: "010",
description: "Close-up of astronaut helmet reflection.", description: "Close-up of astronaut helmet reflection.",
status: ShotStatus.IN_REVIEW, status: ShotStatus.INTERNAL_REVIEW,
priority: ShotPriority.HIGH, priority: ShotPriority.HIGH,
}, },
{ {
+4 -2
View File
@@ -1,7 +1,7 @@
import { Role, ApprovalStatus, ReviewStatus, ShotStatus, ShotPriority, ProjectStatus, TaskStatus, TaskType } from "@prisma/client"; import { Role, ApprovalStatus, ReviewStatus, ShotStatus, ShotApprovalStatus, ShotPriority, ProjectStatus, TaskStatus, TaskType } from "@prisma/client";
// Re-export Prisma enums for convenience // Re-export Prisma enums for convenience
export { Role, ApprovalStatus, ReviewStatus, ShotStatus, ShotPriority, ProjectStatus, TaskStatus, TaskType }; export { Role, ApprovalStatus, ReviewStatus, ShotStatus, ShotApprovalStatus, ShotPriority, ProjectStatus, TaskStatus, TaskType };
// ── Annotation Types ───────────────────────────────────────────────────────── // ── Annotation Types ─────────────────────────────────────────────────────────
@@ -166,6 +166,8 @@ export interface ShotWithDetails {
originalFootageUrl: string | null; originalFootageUrl: string | null;
originalFootageKey: string | null; originalFootageKey: string | null;
shotGroupId: string | null; shotGroupId: string | null;
shotApprovalStatus: ShotApprovalStatus;
sharedWithClient: boolean;
shotGroup: { id: string; name: string } | null; shotGroup: { id: string; name: string } | null;
footagePlates: { footagePlates: {
id: string; id: string;
+13
View File
@@ -0,0 +1,13 @@
import { defineConfig } from "vitest/config";
import path from "path";
export default defineConfig({
test: {
environment: "node",
},
resolve: {
alias: {
"@": path.resolve(__dirname, "."),
},
},
});