@@ -21,21 +21,27 @@ 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,
|
||||
{ label: string; className: string; Icon: React.ElementType }
|
||||
> = {
|
||||
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 },
|
||||
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 },
|
||||
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 },
|
||||
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 },
|
||||
};
|
||||
|
||||
const PRIORITY_CONFIG: Record<string, { label: string; dot: string }> = {
|
||||
@@ -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";
|
||||
|
||||
@@ -70,11 +72,13 @@ 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 },
|
||||
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 },
|
||||
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 },
|
||||
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 },
|
||||
};
|
||||
|
||||
// ── IndeterminateCheckbox ──────────────────────────────────────────────────────
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user