+178
-1
@@ -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 },
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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: {
|
||||||
|
|||||||
@@ -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,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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" },
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 { 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 },
|
||||||
|
|||||||
Generated
+1310
-1
File diff suppressed because it is too large
Load Diff
+4
-1
@@ -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
@@ -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
@@ -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
@@ -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;
|
||||||
|
|||||||
@@ -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