+178
-1
@@ -4,7 +4,8 @@ import { auth } from "@/auth";
|
||||
import { db } from "@/lib/db";
|
||||
import { revalidatePath } from "next/cache";
|
||||
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({
|
||||
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(),
|
||||
status: z.nativeEnum(ShotStatus).optional(),
|
||||
priority: z.nativeEnum(ShotPriority).optional(),
|
||||
shotApprovalStatus: z.nativeEnum(ShotApprovalStatus).optional(),
|
||||
sharedWithClient: z.boolean().optional(),
|
||||
fps: z.number().optional(),
|
||||
frameStart: 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 };
|
||||
}
|
||||
|
||||
// ── 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,
|
||||
Video,
|
||||
Copy,
|
||||
ShieldCheck,
|
||||
Share2,
|
||||
Eye,
|
||||
EyeOff,
|
||||
} 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";
|
||||
import { duplicateShot, internallyApproveShot, shareWithClient, unshareFromClient } from "@/actions/shots";
|
||||
|
||||
const STATUS_CONFIG: Record<
|
||||
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 },
|
||||
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 },
|
||||
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 [loading, setLoading] = useState(true);
|
||||
const [canApprove, setCanApprove] = useState(false);
|
||||
const [canInternallyApprove, setCanInternallyApprove] = useState(false);
|
||||
const [tasks, setTasks] = useState<any[]>([]);
|
||||
const [artists, setArtists] = useState<any[]>([]);
|
||||
const [canManage, setCanManage] = useState(false);
|
||||
const [isDuplicating, setIsDuplicating] = useState(false);
|
||||
const [isActioning, setIsActioning] = useState(false);
|
||||
const [activeTab, setActiveTab] = useState<"tasks" | "footage" | "settings">("tasks");
|
||||
|
||||
const fetchShot = async () => {
|
||||
@@ -72,6 +80,7 @@ export default function ShotDetailPage() {
|
||||
setShot(data.shot);
|
||||
setProjectName(data.projectName ?? "");
|
||||
setCanApprove(data.canApprove ?? false);
|
||||
setCanInternallyApprove(data.canInternallyApprove ?? false);
|
||||
setTasks(data.tasks ?? []);
|
||||
setArtists(data.artists ?? []);
|
||||
setCanManage(data.canApprove ?? false);
|
||||
@@ -86,6 +95,48 @@ export default function ShotDetailPage() {
|
||||
fetchShot();
|
||||
}, [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 () => {
|
||||
if (!shot) return;
|
||||
setIsDuplicating(true);
|
||||
@@ -167,6 +218,37 @@ export default function ShotDetailPage() {
|
||||
{statusCfg.label}
|
||||
</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">
|
||||
<span
|
||||
className={`h-2 w-2 rounded-full ${priorityCfg.dot}`}
|
||||
@@ -193,7 +275,48 @@ export default function ShotDetailPage() {
|
||||
</div>
|
||||
|
||||
{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
|
||||
variant="outline"
|
||||
size="sm"
|
||||
|
||||
@@ -35,6 +35,8 @@ import {
|
||||
ChevronRight,
|
||||
Loader2,
|
||||
X,
|
||||
ShieldCheck,
|
||||
Eye,
|
||||
} from "lucide-react";
|
||||
import { format } from "date-fns";
|
||||
|
||||
@@ -72,7 +74,9 @@ interface ShotStatusClientProps {
|
||||
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 },
|
||||
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 },
|
||||
COMPLETE: { label: "Complete", color: "bg-emerald-500/10 text-emerald-400 border-emerald-500/20", icon: CheckCircle2 },
|
||||
};
|
||||
|
||||
@@ -34,8 +34,47 @@ export async function POST(
|
||||
}
|
||||
|
||||
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"];
|
||||
if (!versionId || !validStatuses.includes(status)) {
|
||||
return NextResponse.json({ error: "versionId and valid status required" }, { status: 400 });
|
||||
@@ -101,4 +140,3 @@ export async function POST(
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
}
|
||||
|
||||
|
||||
@@ -28,15 +28,11 @@ export async function GET(
|
||||
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({
|
||||
where: {
|
||||
projectId: session.projectId,
|
||||
tasks: {
|
||||
some: {
|
||||
versions: { some: { isClientVisible: true } },
|
||||
},
|
||||
},
|
||||
sharedWithClient: true,
|
||||
},
|
||||
orderBy: [{ sequence: "asc" }, { shotCode: "asc" }],
|
||||
select: {
|
||||
@@ -45,6 +41,7 @@ export async function GET(
|
||||
sequence: true,
|
||||
description: true,
|
||||
status: true,
|
||||
shotApprovalStatus: true,
|
||||
thumbnailUrl: true,
|
||||
tasks: {
|
||||
where: {
|
||||
|
||||
@@ -83,6 +83,7 @@ export async function GET(
|
||||
shot: shotSerialized,
|
||||
projectName: project?.name ?? "",
|
||||
canApprove,
|
||||
canInternallyApprove: ["ADMIN", "PRODUCER", "SUPERVISOR"].includes(session.user.role as string),
|
||||
tasks,
|
||||
artists,
|
||||
});
|
||||
|
||||
@@ -14,6 +14,9 @@ import {
|
||||
Package,
|
||||
CalendarDays,
|
||||
CheckCircle2,
|
||||
AlertCircle,
|
||||
ShieldCheck,
|
||||
Eye,
|
||||
} from "lucide-react";
|
||||
import { ShotStatus } from "@prisma/client";
|
||||
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" },
|
||||
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" },
|
||||
COMPLETE: { label: "Complete", color: "bg-emerald-500/10 text-emerald-400 border-emerald-500/20" },
|
||||
};
|
||||
|
||||
@@ -12,6 +12,8 @@ import {
|
||||
AlertCircle,
|
||||
Film,
|
||||
MessageSquare,
|
||||
ShieldCheck,
|
||||
Eye,
|
||||
} from "lucide-react";
|
||||
import type { ShotWithDetails } from "@/types";
|
||||
|
||||
@@ -24,7 +26,9 @@ interface ShotQueueProps {
|
||||
const STATUS_STYLES: Record<string, string> = {
|
||||
WAITING: "text-zinc-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",
|
||||
COMPLETE: "text-emerald-400",
|
||||
};
|
||||
@@ -32,7 +36,9 @@ const STATUS_STYLES: Record<string, string> = {
|
||||
const STATUS_ICONS: Record<string, React.ElementType> = {
|
||||
WAITING: Clock,
|
||||
IN_PROGRESS: Film,
|
||||
IN_REVIEW: AlertCircle,
|
||||
INTERNAL_REVIEW: AlertCircle,
|
||||
READY_FOR_CLIENT: ShieldCheck,
|
||||
CLIENT_REVIEW: Eye,
|
||||
REVISIONS: AlertCircle,
|
||||
COMPLETE: CheckCircle2,
|
||||
};
|
||||
|
||||
@@ -26,6 +26,9 @@ import {
|
||||
AlertCircle,
|
||||
ArrowUpRight,
|
||||
Copy,
|
||||
ShieldCheck,
|
||||
Share2,
|
||||
Eye,
|
||||
} from "lucide-react";
|
||||
import type { ShotWithDetails } from "@/types";
|
||||
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 },
|
||||
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 },
|
||||
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> = {
|
||||
LOW: "bg-zinc-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) {
|
||||
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">
|
||||
@@ -209,6 +222,24 @@ export function ShotCard({ shot, projectId, compact = false, canManage = false }
|
||||
</span>
|
||||
)}
|
||||
</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>
|
||||
|
||||
<CardFooter className="pt-2 flex items-center justify-between">
|
||||
|
||||
@@ -25,7 +25,7 @@ import { Upload, X, Film, ImageIcon, Trash2 } from "lucide-react";
|
||||
const settingsSchema = z.object({
|
||||
shotCode: z.string().min(1, "Required").max(120).regex(/^[A-Z0-9_]+$/i, "Alphanumeric and underscores only"),
|
||||
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"]),
|
||||
fps: z.coerce.number().min(1).max(240),
|
||||
frameStart: z.coerce.number().int().optional().or(z.literal("")),
|
||||
@@ -197,7 +197,9 @@ export function ShotSettingsTab({ shot, artists, onSaved }: ShotSettingsTabProps
|
||||
<SelectContent>
|
||||
<SelectItem value="WAITING">Waiting</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="COMPLETE">Complete</SelectItem>
|
||||
</SelectContent>
|
||||
|
||||
@@ -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
@@ -1,4 +1,4 @@
|
||||
import { ShotStatus, TaskStatus } from "@prisma/client";
|
||||
import { ShotStatus, ShotApprovalStatus, TaskStatus } from "@prisma/client";
|
||||
import { db } from "@/lib/db";
|
||||
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):
|
||||
* CHANGES on any task → REVISIONS
|
||||
* INTERNAL_REVIEW / CLIENT_REVIEW on any task → IN_REVIEW
|
||||
* TODO / IN_PROGRESS on any task → IN_PROGRESS
|
||||
* All tasks DONE → COMPLETE
|
||||
* No tasks → WAITING
|
||||
* 1. REVISIONS — any task is CHANGES
|
||||
* 2. COMPLETE — shotApprovalStatus === CLIENT_APPROVED
|
||||
* 3. CLIENT_REVIEW — shotApprovalStatus === INTERNALLY_APPROVED && sharedWithClient
|
||||
* 4. READY_FOR_CLIENT — shotApprovalStatus === INTERNALLY_APPROVED && !sharedWithClient
|
||||
* 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(
|
||||
tasks: { status: TaskStatus }[]
|
||||
tasks: { status: TaskStatus }[],
|
||||
shotApprovalStatus: ShotApprovalStatus,
|
||||
sharedWithClient: boolean
|
||||
): 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 === "INTERNAL_REVIEW" || t.status === "CLIENT_REVIEW"))
|
||||
return "IN_REVIEW";
|
||||
if (tasks.some((t) => t.status === "TODO" || t.status === "IN_PROGRESS"))
|
||||
|
||||
// 2. Client approved — terminal completion state
|
||||
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";
|
||||
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";
|
||||
}
|
||||
|
||||
/**
|
||||
* Query all tasks for a shot, derive the correct ShotStatus, and persist it.
|
||||
* Accepts an optional Prisma transaction client (tx) for use inside transactions.
|
||||
* Query all tasks + approval fields for a shot, derive the correct ShotStatus,
|
||||
* and persist it. Accepts an optional Prisma transaction client (tx).
|
||||
*/
|
||||
export async function recalcShotStatus(
|
||||
shotId: string,
|
||||
@@ -39,12 +57,20 @@ export async function recalcShotStatus(
|
||||
): Promise<void> {
|
||||
const client = tx ?? db;
|
||||
|
||||
const tasks = await client.task.findMany({
|
||||
const [tasks, shot] = await Promise.all([
|
||||
client.task.findMany({
|
||||
where: { shotId },
|
||||
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({
|
||||
where: { id: shotId },
|
||||
|
||||
Generated
+1310
-1
File diff suppressed because it is too large
Load Diff
+4
-1
@@ -7,6 +7,8 @@
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "next lint",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"db:generate": "prisma generate",
|
||||
"db:push": "prisma db push",
|
||||
"db:migrate": "prisma migrate dev",
|
||||
@@ -71,6 +73,7 @@
|
||||
"prisma": "^6.19.3",
|
||||
"tailwindcss": "^3.4.17",
|
||||
"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
@@ -33,11 +33,19 @@ enum ProjectStatus {
|
||||
enum ShotStatus {
|
||||
WAITING
|
||||
IN_PROGRESS
|
||||
IN_REVIEW
|
||||
INTERNAL_REVIEW
|
||||
READY_FOR_CLIENT
|
||||
CLIENT_REVIEW
|
||||
REVISIONS
|
||||
COMPLETE
|
||||
}
|
||||
|
||||
enum ShotApprovalStatus {
|
||||
PENDING
|
||||
INTERNALLY_APPROVED
|
||||
CLIENT_APPROVED
|
||||
}
|
||||
|
||||
enum ShotPriority {
|
||||
LOW
|
||||
NORMAL
|
||||
@@ -287,6 +295,8 @@ model Shot {
|
||||
originalFootageUrl String?
|
||||
originalFootageKey String?
|
||||
shotGroupId String?
|
||||
shotApprovalStatus ShotApprovalStatus @default(PENDING)
|
||||
sharedWithClient Boolean @default(false)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
|
||||
+2
-2
@@ -123,14 +123,14 @@ async function main() {
|
||||
shotCode: "SH010",
|
||||
sequence: "010",
|
||||
description: "Wide establishing shot of the space station exterior.",
|
||||
status: ShotStatus.IN_REVIEW,
|
||||
status: ShotStatus.INTERNAL_REVIEW,
|
||||
priority: ShotPriority.HIGH,
|
||||
},
|
||||
{
|
||||
shotCode: "SH020",
|
||||
sequence: "010",
|
||||
description: "Close-up of astronaut helmet reflection.",
|
||||
status: ShotStatus.IN_REVIEW,
|
||||
status: ShotStatus.INTERNAL_REVIEW,
|
||||
priority: ShotPriority.HIGH,
|
||||
},
|
||||
{
|
||||
|
||||
+4
-2
@@ -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
|
||||
export { Role, ApprovalStatus, ReviewStatus, ShotStatus, ShotPriority, ProjectStatus, TaskStatus, TaskType };
|
||||
export { Role, ApprovalStatus, ReviewStatus, ShotStatus, ShotApprovalStatus, ShotPriority, ProjectStatus, TaskStatus, TaskType };
|
||||
|
||||
// ── Annotation Types ─────────────────────────────────────────────────────────
|
||||
|
||||
@@ -166,6 +166,8 @@ export interface ShotWithDetails {
|
||||
originalFootageUrl: string | null;
|
||||
originalFootageKey: string | null;
|
||||
shotGroupId: string | null;
|
||||
shotApprovalStatus: ShotApprovalStatus;
|
||||
sharedWithClient: boolean;
|
||||
shotGroup: { id: string; name: string } | null;
|
||||
footagePlates: {
|
||||
id: string;
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
import path from "path";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
environment: "node",
|
||||
},
|
||||
resolve: {
|
||||
alias: {
|
||||
"@": path.resolve(__dirname, "."),
|
||||
},
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user