diff --git a/app/(dashboard)/tasks/[taskId]/TaskDetailClient.tsx b/app/(dashboard)/tasks/[taskId]/TaskDetailClient.tsx
index 0902fd5..0efa2a5 100644
--- a/app/(dashboard)/tasks/[taskId]/TaskDetailClient.tsx
+++ b/app/(dashboard)/tasks/[taskId]/TaskDetailClient.tsx
@@ -17,11 +17,13 @@ import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
+ DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { cn } from "@/lib/utils";
import { getInitials, formatRelativeDate, formatFileSize } from "@/lib/utils";
import { updateTask, updateTaskStatus } from "@/actions/tasks";
+import { submitApproval, unapproveVersion } from "@/actions/approvals";
import { useToast } from "@/components/ui/use-toast";
import { TaskStatus, TaskType } from "@prisma/client";
import { formatDistanceToNow, format } from "date-fns";
@@ -42,6 +44,8 @@ import {
User,
ExternalLink,
Trash2,
+ XCircle,
+ MoreHorizontal,
} from "lucide-react";
import { TASK_STATUS_CONFIG, TASK_TYPE_LABELS } from "@/components/tasks/TaskCard";
import { VersionUpload } from "@/components/versions/VersionUpload";
@@ -195,6 +199,29 @@ export function TaskDetailClient({
}
};
+ const handleApproveVersion = async (versionId: string, status: "APPROVED" | "REJECTED" | "NEEDS_CHANGES") => {
+ try {
+ await submitApproval({ versionId, status, notes: "" });
+ toast({
+ title: status === "APPROVED" ? "Version approved" : status === "REJECTED" ? "Version rejected" : "Changes requested",
+ });
+ router.refresh();
+ } catch (e) {
+ toast({ title: "Failed", description: e instanceof Error ? e.message : undefined, variant: "destructive" });
+ }
+ };
+
+ const handleUnapproveVersion = async (versionId: string) => {
+ if (!confirm("Undo approval? The version will return to Pending Review and the task will return to Internal Review.")) return;
+ try {
+ await unapproveVersion(versionId);
+ toast({ title: "Approval undone", description: "Version reset to Pending Review" });
+ router.refresh();
+ } catch (e) {
+ toast({ title: "Failed to unapprove", description: e instanceof Error ? e.message : undefined, variant: "destructive" });
+ }
+ };
+
return (
@@ -339,19 +366,58 @@ export function TaskDetailClient({
{canManage && (
-
+
+
+
+
+
+ handleApproveVersion(v.id, "APPROVED")}
+ >
+
+ Approve
+
+ handleApproveVersion(v.id, "NEEDS_CHANGES")}
+ >
+
+ Needs Changes
+
+ handleApproveVersion(v.id, "REJECTED")}
+ >
+
+ Reject
+
+ {v.approvalStatus === "APPROVED" && (
+ handleUnapproveVersion(v.id)}
+ >
+
+ Undo Approval
+
+ )}
+
+
+ handleDeleteVersion(
+ v.id,
+ `v${String(v.versionNumber).padStart(3, "0")}`
+ )
+ }
+ >
+
+ Delete version
+
+
+
)}
diff --git a/app/api/client/[token]/approve/route.ts b/app/api/client/[token]/approve/route.ts
index e456c5f..2d8b0cd 100644
--- a/app/api/client/[token]/approve/route.ts
+++ b/app/api/client/[token]/approve/route.ts
@@ -113,6 +113,21 @@ export async function POST(
if (version.task) {
if (status === "APPROVED") {
await db.task.update({ where: { id: version.task.id }, data: { status: "DONE" } });
+
+ // If this version belongs to a shot that is shared with the client,
+ // also approve the shot at the shot level so it moves to COMPLETE.
+ if (version.task.shot) {
+ const shot = await db.shot.findUnique({
+ where: { id: version.task.shot.id },
+ select: { sharedWithClient: true, shotApprovalStatus: true },
+ });
+ if (shot?.sharedWithClient && shot.shotApprovalStatus !== "CLIENT_APPROVED") {
+ await db.shot.update({
+ where: { id: version.task.shot.id },
+ data: { shotApprovalStatus: "CLIENT_APPROVED", status: "COMPLETE" },
+ });
+ }
+ }
} else {
await db.task.update({ where: { id: version.task.id }, data: { status: "CHANGES" } });
}
diff --git a/app/client/[token]/review/[versionId]/page.tsx b/app/client/[token]/review/[versionId]/page.tsx
index 733ea17..a9bcf0d 100644
--- a/app/client/[token]/review/[versionId]/page.tsx
+++ b/app/client/[token]/review/[versionId]/page.tsx
@@ -176,10 +176,14 @@ export default function ClientReviewPage({
toast({
title:
approvalDialog.status === "APPROVED"
- ? "Version approved!"
+ ? "Shot approved!"
: approvalDialog.status === "REJECTED"
? "Version rejected"
: "Changes requested",
+ description:
+ approvalDialog.status === "APPROVED"
+ ? "The shot has been marked as client approved."
+ : undefined,
});
} catch {
toast({ title: "Failed to submit decision", variant: "destructive" });