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

This commit is contained in:
twotalesanimation
2026-06-11 12:30:28 +02:00
parent b9610454d9
commit 3f0c5d1dbe
19 changed files with 2015 additions and 70 deletions
@@ -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 ──────────────────────────────────────────────────────
+40 -2
View File
@@ -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 });
}
+3 -6
View File
@@ -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: {
+1
View File
@@ -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,
});