diff --git a/Shot task workflow.md b/Shot task workflow.md
new file mode 100644
index 0000000..0c6226e
--- /dev/null
+++ b/Shot task workflow.md
@@ -0,0 +1,208 @@
+# Shot / Task / Version & Approval Status Workflow
+
+## Core Entities & Status Fields
+
+| Entity | Status Field | Enum |
+|---|---|---|
+| **Shot** | `status` (derived) | `ShotStatus` |
+| **Shot** | `shotApprovalStatus` | `ShotApprovalStatus` |
+| **Task** | `status` | `TaskStatus` |
+| **Version** | `approvalStatus` | `ApprovalStatus` |
+| **Version** | `reviewStatus` | `ReviewStatus` (lightly used) |
+
+---
+
+## Data Model & Enums
+
+| Enum | States | Purpose |
+|------|--------|---------|
+| **ShotStatus** | `WAITING`, `IN_PROGRESS`, `INTERNAL_REVIEW`, `READY_FOR_CLIENT`, `CLIENT_REVIEW`, `REVISIONS`, `COMPLETE` | Derived shot state (read-only, calculated) |
+| **ShotApprovalStatus** | `PENDING`, `INTERNALLY_APPROVED`, `CLIENT_APPROVED` | Shot-level approval stage (manually set) |
+| **TaskStatus** | `TODO`, `IN_PROGRESS`, `INTERNAL_REVIEW`, `CLIENT_REVIEW`, `CHANGES`, `DONE` | Task execution state |
+| **ApprovalStatus** | `PENDING_REVIEW`, `APPROVED`, `REJECTED`, `NEEDS_CHANGES` | Version review decision |
+| **ReviewStatus** | `PENDING`, `INTERNAL_APPROVED`, `CLIENT_APPROVED`, `NEEDS_CHANGES`, `FINAL_APPROVED` | Version review progression (legacy/parallel) |
+
+### Core Relationships
+
+```
+Project → Shot (1:N)
+ ↓
+Shot → Task (1:N)
+ ↓
+Task → Version (1:N)
+ ↓
+Version → Approval (1:N, one per reviewer)
+```
+
+---
+
+## Shot Status is Derived, Not Set Directly
+
+`ShotStatus` is computed by `deriveShotStatus()` in `lib/shot-status.ts` from task states and approval fields. It is recalculated via `recalcShotStatus()` whenever a task, version, or approval changes.
+
+Priority rules (highest wins):
+
+```
+REVISIONS ← any task is CHANGES
+COMPLETE ← shotApprovalStatus === CLIENT_APPROVED
+CLIENT_REVIEW ← INTERNALLY_APPROVED + sharedWithClient = true
+READY_FOR_CLIENT ← INTERNALLY_APPROVED + sharedWithClient = false
+IN_PROGRESS ← any task is TODO or IN_PROGRESS
+INTERNAL_REVIEW ← tasks exist, none blocking (approval still PENDING)
+WAITING ← no tasks
+```
+
+`recalcShotStatus()` is automatically triggered on:
+- Task creation (added to shot)
+- Task status update
+- Approval submission
+
+---
+
+## Version → Approval → Task Pipeline
+
+```
+Artist uploads Version
+ → all prior versions: isLatest = false
+ → new Version: approvalStatus = PENDING_REVIEW, isLatest = true
+ → Task auto-moves → INTERNAL_REVIEW
+ → Shot recalculated → INTERNAL_REVIEW
+ → Notifications sent to supervisors/producers
+ ↓
+Reviewer submits Approval (creates Approval record)
+ → Version.approvalStatus updated
+ → APPROVED → Task → DONE
+ → REJECTED → Task → CHANGES
+ → NEEDS_CHANGES → Task → CHANGES
+ → Shot recalculated
+ → Notifications sent (artist, Slack webhook)
+```
+
+### Approval Status Mapping
+
+| `Version.approvalStatus` | `Task.status` | `Shot.status` (if isolated) |
+|---|---|---|
+| `PENDING_REVIEW` | `INTERNAL_REVIEW` | `INTERNAL_REVIEW` |
+| `APPROVED` | `DONE` | depends on other tasks |
+| `REJECTED` | `CHANGES` | `REVISIONS` |
+| `NEEDS_CHANGES` | `CHANGES` | `REVISIONS` |
+
+### Role-Based Access for Approvals
+
+- Only ADMIN, PRODUCER, SUPERVISOR, CLIENT can approve
+- Client can only approve if shot is shared (`sharedWithClient = true`)
+- Approval records are **append-only** — each review creates a new `Approval` row; the latest one wins
+
+---
+
+## Task Status Transitions
+
+```
+TODO
+ ↓ (artist starts work / uploads version)
+IN_PROGRESS / INTERNAL_REVIEW
+ ↓ (approval submitted)
+ ├─→ DONE (if APPROVED)
+ └─→ CHANGES (if REJECTED or NEEDS_CHANGES)
+ ↓
+CLIENT_REVIEW (when shot is shared with client)
+```
+
+| Action | Trigger | Effect |
+|--------|---------|--------|
+| `createTask()` | Producer creates task | Creates in TODO; calls `recalcShotStatus()` |
+| `updateTaskStatus()` | Manual status change | Updates task; triggers notifications + shot recalc |
+| Task → `INTERNAL_REVIEW` | Version uploaded | Auto-triggered in `actions/versions.ts` |
+| Task → `CLIENT_REVIEW` | Shot shared with client | Auto-triggered in `actions/shots.ts` |
+| Task → `DONE` | Version approved | Auto-triggered in `actions/approvals.ts` |
+| Task → `CHANGES` | Version rejected/needs changes | Auto-triggered in `actions/approvals.ts` |
+
+**Permissions:** ADMIN, PRODUCER, SUPERVISOR can create/update tasks.
+
+---
+
+## Shot-Level Approval Lifecycle
+
+```
+PENDING (initial)
+ ↓ internallyApproveShot() [ADMIN / PRODUCER / SUPERVISOR]
+INTERNALLY_APPROVED → Shot status: READY_FOR_CLIENT
+ ↓ shareWithClient()
+sharedWithClient = true → Shot status: CLIENT_REVIEW
+ latest versions: isClientVisible = true, sharedAt = now
+ ↓ ↓
+clientApproveShot() clientRequestsChanges()
+shotApprovalStatus = shotApprovalStatus = PENDING
+CLIENT_APPROVED sharedWithClient = false
+Shot status: COMPLETE isClientVisible = false (all task versions)
+ Shot status: REVISIONS (if tasks have CHANGES)
+```
+
+### State Details
+
+| State | `shotApprovalStatus` | `sharedWithClient` | `Shot.status` |
+|---|---|---|---|
+| Initial | `PENDING` | `false` | `WAITING` / `IN_PROGRESS` / `INTERNAL_REVIEW` |
+| Internally approved | `INTERNALLY_APPROVED` | `false` | `READY_FOR_CLIENT` |
+| Shared with client | `INTERNALLY_APPROVED` | `true` | `CLIENT_REVIEW` |
+| Client approved | `CLIENT_APPROVED` | `true` | `COMPLETE` |
+| Revisions requested | `PENDING` | `false` | `REVISIONS` |
+
+---
+
+## Key Implementation Files
+
+| Concern | File |
+|---|---|
+| Status derivation engine | `lib/shot-status.ts` |
+| Shot actions (share/approve/revise) | `actions/shots.ts` |
+| Approval creation + cascades | `actions/approvals.ts` |
+| Version upload + task auto-transition | `actions/versions.ts` |
+| Task creation / status updates | `actions/tasks.ts` |
+| Shot status UI badges | `components/shots/ShotCard.tsx` |
+| Task status UI badges | `components/tasks/TaskCard.tsx` |
+
+---
+
+## Notable Design Choices
+
+- **`ShotStatus` is never written directly** — always a computed projection of task + approval state, preventing inconsistency.
+- **`ReviewStatus` on Version is largely redundant** with `approvalStatus`; it appears to be a legacy/parallel field not driving much logic currently.
+- **Approval records are append-only** — each review creates a new `Approval` row; the latest one wins for updating `Version.approvalStatus`.
+- **Client visibility is gated at two levels**: `Shot.sharedWithClient` and `Version.isClientVisible`, both toggled atomically when sharing.
+- **`shareWithClient()` is atomic**: all task versions are updated in a single transaction.
+
+---
+
+## Unapprove / Undo Approval
+
+### Version-Level Unapprove (`unapproveVersion`)
+
+Available to: **ADMIN, PRODUCER, SUPERVISOR**
+Shown as: **"Undo Approval"** in the version dropdown (only visible when `approvalStatus === APPROVED`)
+Location: `VersionList.tsx` → `actions/approvals.ts`
+
+```
+Version.approvalStatus === APPROVED
+ ↓ unapproveVersion()
+Version.approvalStatus → PENDING_REVIEW
+Task.status (if DONE) → INTERNAL_REVIEW
+Audit: new Approval record created with status = PENDING_REVIEW, notes = "Approval undone"
+Shot recalculated
+```
+
+### Shot-Level Unapprove (`unapproveShot`)
+
+Available to: **ADMIN, PRODUCER, SUPERVISOR**
+Shown as: **"Undo Client Approval"** button on the shot detail page (only when `shotApprovalStatus === CLIENT_APPROVED`)
+Location: `shots/[shotId]/page.tsx` → `actions/shots.ts`
+
+```
+shotApprovalStatus === CLIENT_APPROVED (Shot status: COMPLETE)
+ ↓ unapproveShot()
+shotApprovalStatus → INTERNALLY_APPROVED
+Shot.status → CLIENT_REVIEW (if still sharedWithClient)
+ → READY_FOR_CLIENT (if sharedWithClient = false)
+```
+
+Neither unapprove action modifies the `sharedWithClient` flag or version visibility — only the approval state is rolled back.
diff --git a/actions/approvals.ts b/actions/approvals.ts
index f44653b..3cdd382 100644
--- a/actions/approvals.ts
+++ b/actions/approvals.ts
@@ -128,3 +128,68 @@ export async function getApprovalHistory(versionId: string) {
orderBy: { createdAt: "desc" },
});
}
+
+/**
+ * Resets an approved version back to PENDING_REVIEW and the associated task
+ * back to INTERNAL_REVIEW. Recalculates shot status.
+ * Only ADMIN, PRODUCER, SUPERVISOR can unapprove.
+ */
+export async function unapproveVersion(versionId: string) {
+ const session = await auth();
+ if (!session?.user) throw new Error("Unauthorized");
+
+ const allowedRoles = ["ADMIN", "PRODUCER", "SUPERVISOR"];
+ if (!allowedRoles.includes(session.user.role)) {
+ throw new Error("Insufficient permissions to unapprove versions");
+ }
+
+ const version = await db.version.findUnique({
+ where: { id: versionId },
+ include: {
+ task: {
+ include: { shot: true },
+ },
+ },
+ });
+ if (!version) throw new Error("Version not found");
+ if (version.approvalStatus !== "APPROVED") {
+ throw new Error("Only approved versions can be unapproved");
+ }
+
+ // Create an audit record for the unapproval
+ await db.approval.create({
+ data: {
+ versionId,
+ userId: session.user.id,
+ status: "PENDING_REVIEW",
+ notes: "Approval undone",
+ },
+ });
+
+ // Reset version approval status
+ await db.version.update({
+ where: { id: versionId },
+ data: { approvalStatus: "PENDING_REVIEW" },
+ });
+
+ // Reset task back to INTERNAL_REVIEW if it was moved to DONE
+ if (version.task && version.task.status === "DONE") {
+ await db.task.update({
+ where: { id: version.task.id },
+ data: { status: "INTERNAL_REVIEW" },
+ });
+ }
+
+ // Recalculate shot status
+ if (version.task?.shot) {
+ await recalcShotStatus(version.task.shot.id).catch(() => {});
+ }
+
+ revalidatePath(`/review/${versionId}`);
+ if (version.task) {
+ revalidatePath(`/tasks/${version.task.id}`);
+ revalidatePath(`/projects/${version.task.projectId}`);
+ }
+
+ return { success: true };
+}
diff --git a/actions/shots.ts b/actions/shots.ts
index 5233667..a317e13 100644
--- a/actions/shots.ts
+++ b/actions/shots.ts
@@ -744,6 +744,43 @@ export async function clientApproveShot(shotId: string) {
return { success: true };
}
+/**
+ * Admin/Producer/Supervisor: undo a client approval on a shot.
+ * Resets shotApprovalStatus from CLIENT_APPROVED → INTERNALLY_APPROVED.
+ * Shot status reverts to CLIENT_REVIEW (if still shared) or READY_FOR_CLIENT.
+ */
+export async function unapproveShot(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, sharedWithClient: true },
+ });
+ if (!shot) throw new Error("Shot not found");
+ if (shot.shotApprovalStatus !== "CLIENT_APPROVED") {
+ throw new Error("Shot is not client-approved");
+ }
+
+ const newStatus = shot.sharedWithClient ? "CLIENT_REVIEW" : "READY_FOR_CLIENT";
+
+ await db.shot.update({
+ where: { id: shotId },
+ data: {
+ shotApprovalStatus: "INTERNALLY_APPROVED",
+ status: newStatus,
+ },
+ });
+
+ 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.
diff --git a/app/(dashboard)/projects/[id]/shots/[shotId]/page.tsx b/app/(dashboard)/projects/[id]/shots/[shotId]/page.tsx
index 0cef147..213a04b 100644
--- a/app/(dashboard)/projects/[id]/shots/[shotId]/page.tsx
+++ b/app/(dashboard)/projects/[id]/shots/[shotId]/page.tsx
@@ -29,7 +29,7 @@ import {
import type { ShotWithDetails } from "@/types";
import { ShotSettingsTab } from "@/components/shots/ShotSettingsTab";
import { FootageViewer } from "@/components/shots/FootageViewer";
-import { duplicateShot, internallyApproveShot, shareWithClient, unshareFromClient } from "@/actions/shots";
+import { duplicateShot, internallyApproveShot, shareWithClient, unshareFromClient, unapproveShot } from "@/actions/shots";
const STATUS_CONFIG: Record<
string,
@@ -137,6 +137,21 @@ export default function ShotDetailPage() {
}
};
+ const handleUnapproveShot = async () => {
+ if (!shot) return;
+ if (!confirm("Undo client approval? The shot will return to Internally Approved.")) return;
+ setIsActioning(true);
+ try {
+ await unapproveShot(shot.id);
+ toast({ title: "Client approval undone", 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);
@@ -317,6 +332,20 @@ export default function ShotDetailPage() {
)}
+ {/* Undo client approval */}
+ {canInternallyApprove && shot.shotApprovalStatus === "CLIENT_APPROVED" && (
+
+ )}
+