@@ -970,3 +970,35 @@ export async function clientRequestShotChanges(shotId: string, taskId?: string)
|
|||||||
revalidatePath(`/shot-status`);
|
revalidatePath(`/shot-status`);
|
||||||
return { success: true };
|
return { success: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Shot Version ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const VERSION_RE = /^v\d{3}$/;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set or manually override the shot's version string (format: v###).
|
||||||
|
*/
|
||||||
|
export async function updateShotVersion(shotId: string, version: 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");
|
||||||
|
}
|
||||||
|
if (!VERSION_RE.test(version)) {
|
||||||
|
throw new Error("Version must be in format v### (e.g. v001, v012)");
|
||||||
|
}
|
||||||
|
|
||||||
|
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: { shotVersion: version },
|
||||||
|
});
|
||||||
|
|
||||||
|
revalidatePath(`/projects/${shot.projectId}/shots/${shotId}`);
|
||||||
|
return { success: true, shotVersion: version };
|
||||||
|
}
|
||||||
|
|||||||
@@ -28,12 +28,15 @@ import {
|
|||||||
ExternalLink,
|
ExternalLink,
|
||||||
XCircle,
|
XCircle,
|
||||||
FileVideo,
|
FileVideo,
|
||||||
|
Pencil,
|
||||||
|
Check,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
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 { HighResUploadDialog } from "@/components/shots/HighResUploadDialog";
|
import { HighResUploadDialog } from "@/components/shots/HighResUploadDialog";
|
||||||
import { duplicateShot, internallyApproveShot, shareWithClient, unshareFromClient, unapproveShot } from "@/actions/shots";
|
import { duplicateShot, internallyApproveShot, shareWithClient, unshareFromClient, unapproveShot, updateShotVersion } from "@/actions/shots";
|
||||||
|
|
||||||
const STATUS_CONFIG: Record<
|
const STATUS_CONFIG: Record<
|
||||||
string,
|
string,
|
||||||
@@ -87,6 +90,9 @@ export default function ShotDetailPage() {
|
|||||||
const [isActioning, setIsActioning] = useState(false);
|
const [isActioning, setIsActioning] = useState(false);
|
||||||
const [highResDialogOpen, setHighResDialogOpen] = useState(false);
|
const [highResDialogOpen, setHighResDialogOpen] = useState(false);
|
||||||
const [activeTab, setActiveTab] = useState<"tasks" | "reviews" | "footage" | "settings">("tasks");
|
const [activeTab, setActiveTab] = useState<"tasks" | "reviews" | "footage" | "settings">("tasks");
|
||||||
|
const [editingVersion, setEditingVersion] = useState(false);
|
||||||
|
const [versionInput, setVersionInput] = useState("");
|
||||||
|
const [savingVersion, setSavingVersion] = useState(false);
|
||||||
|
|
||||||
const fetchShot = async () => {
|
const fetchShot = async () => {
|
||||||
try {
|
try {
|
||||||
@@ -171,6 +177,26 @@ export default function ShotDetailPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleSaveVersion = async () => {
|
||||||
|
if (!shot) return;
|
||||||
|
const trimmed = versionInput.trim().toLowerCase();
|
||||||
|
if (!/^v\d{3}$/.test(trimmed)) {
|
||||||
|
toast({ title: "Invalid format", description: "Version must be v### (e.g. v001)", variant: "destructive" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSavingVersion(true);
|
||||||
|
try {
|
||||||
|
await updateShotVersion(shot.id, trimmed);
|
||||||
|
toast({ title: `Version set to ${trimmed}` });
|
||||||
|
setEditingVersion(false);
|
||||||
|
fetchShot();
|
||||||
|
} catch (e) {
|
||||||
|
toast({ title: "Failed", description: e instanceof Error ? e.message : undefined, variant: "destructive" });
|
||||||
|
} finally {
|
||||||
|
setSavingVersion(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleDuplicate = async () => {
|
const handleDuplicate = async () => {
|
||||||
if (!shot) return;
|
if (!shot) return;
|
||||||
setIsDuplicating(true);
|
setIsDuplicating(true);
|
||||||
@@ -237,6 +263,55 @@ export default function ShotDetailPage() {
|
|||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
|
|
||||||
<h1 className="text-2xl font-bold font-mono">{shot.shotCode}</h1>
|
<h1 className="text-2xl font-bold font-mono">{shot.shotCode}</h1>
|
||||||
|
|
||||||
|
{/* Shot version badge — click to edit for managers */}
|
||||||
|
{canManage ? (
|
||||||
|
editingVersion ? (
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<Input
|
||||||
|
autoFocus
|
||||||
|
value={versionInput}
|
||||||
|
onChange={(e) => setVersionInput(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter") handleSaveVersion();
|
||||||
|
if (e.key === "Escape") setEditingVersion(false);
|
||||||
|
}}
|
||||||
|
className="h-7 w-20 font-mono text-xs px-2"
|
||||||
|
placeholder="v001"
|
||||||
|
maxLength={4}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={handleSaveVersion}
|
||||||
|
disabled={savingVersion}
|
||||||
|
className="text-emerald-400 hover:text-emerald-300 disabled:opacity-50"
|
||||||
|
title="Save version"
|
||||||
|
>
|
||||||
|
<Check className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setEditingVersion(false)}
|
||||||
|
className="text-zinc-500 hover:text-zinc-300"
|
||||||
|
title="Cancel"
|
||||||
|
>
|
||||||
|
<XCircle className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
onClick={() => { setVersionInput(shot.shotVersion ?? "v001"); setEditingVersion(true); }}
|
||||||
|
className="group flex items-center gap-1 font-mono text-sm px-2 py-0.5 rounded border border-zinc-700 bg-zinc-800/60 text-zinc-300 hover:border-amber-500/50 hover:text-amber-400 transition-colors"
|
||||||
|
title="Click to edit version"
|
||||||
|
>
|
||||||
|
{shot.shotVersion ?? "v001"}
|
||||||
|
<Pencil className="h-3 w-3 opacity-0 group-hover:opacity-100 transition-opacity" />
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
<span className="font-mono text-sm px-2 py-0.5 rounded border border-zinc-700 bg-zinc-800/60 text-zinc-300">
|
||||||
|
{shot.shotVersion ?? "v001"}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
|
||||||
{shot.sequence && (
|
{shot.sequence && (
|
||||||
<span className="text-sm text-muted-foreground">Seq: {shot.sequence}</span>
|
<span className="text-sm text-muted-foreground">Seq: {shot.sequence}</span>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ export async function POST(
|
|||||||
if (shotId && action) {
|
if (shotId && action) {
|
||||||
const shot = await db.shot.findUnique({
|
const shot = await db.shot.findUnique({
|
||||||
where: { id: shotId },
|
where: { id: shotId },
|
||||||
select: { projectId: true, sharedWithClient: true },
|
select: { projectId: true, sharedWithClient: true, shotVersion: true },
|
||||||
});
|
});
|
||||||
if (!shot || shot.projectId !== session.projectId) {
|
if (!shot || shot.projectId !== session.projectId) {
|
||||||
return NextResponse.json({ error: "Shot not found" }, { status: 404 });
|
return NextResponse.json({ error: "Shot not found" }, { status: 404 });
|
||||||
@@ -53,10 +53,16 @@ export async function POST(
|
|||||||
data: { shotApprovalStatus: "CLIENT_APPROVED", status: "COMPLETE" },
|
data: { shotApprovalStatus: "CLIENT_APPROVED", status: "COMPLETE" },
|
||||||
});
|
});
|
||||||
} else if (action === "NEEDS_CHANGES") {
|
} else if (action === "NEEDS_CHANGES") {
|
||||||
|
// Increment shotVersion: v001 → v002, v009 → v010, etc.
|
||||||
|
const currentVer = shot.shotVersion ?? "v001";
|
||||||
|
const verMatch = currentVer.match(/^v(\d+)$/);
|
||||||
|
const nextNum = verMatch ? parseInt(verMatch[1], 10) + 1 : 2;
|
||||||
|
const nextVersion = "v" + String(nextNum).padStart(3, "0");
|
||||||
|
|
||||||
await db.$transaction(async (tx) => {
|
await db.$transaction(async (tx) => {
|
||||||
await tx.shot.update({
|
await tx.shot.update({
|
||||||
where: { id: shotId },
|
where: { id: shotId },
|
||||||
data: { shotApprovalStatus: "PENDING", sharedWithClient: false },
|
data: { shotApprovalStatus: "PENDING", sharedWithClient: false, shotVersion: nextVersion },
|
||||||
});
|
});
|
||||||
// Mark all non-DONE tasks as CHANGES
|
// Mark all non-DONE tasks as CHANGES
|
||||||
await tx.task.updateMany({
|
await tx.task.updateMany({
|
||||||
|
|||||||
@@ -57,6 +57,7 @@ export async function GET(
|
|||||||
exrOutput: true,
|
exrOutput: true,
|
||||||
seqTimecodeStart: true,
|
seqTimecodeStart: true,
|
||||||
seqTimecodeEnd: true,
|
seqTimecodeEnd: true,
|
||||||
|
shotVersion: true,
|
||||||
createdAt: true,
|
createdAt: true,
|
||||||
updatedAt: true,
|
updatedAt: true,
|
||||||
project: {
|
project: {
|
||||||
@@ -102,3 +103,51 @@ export async function GET(
|
|||||||
|
|
||||||
return NextResponse.json({ shot });
|
return NextResponse.json({ shot });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── PATCH /api/ext/shots/[shotId] ─────────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// Update mutable shot fields from pipeline tools.
|
||||||
|
// Currently supports: shotVersion (format v###)
|
||||||
|
//
|
||||||
|
// Body (JSON): { shotVersion?: string }
|
||||||
|
// Returns: { success: true, shot: { id, shotVersion } }
|
||||||
|
|
||||||
|
export async function PATCH(
|
||||||
|
req: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ shotId: string }> }
|
||||||
|
) {
|
||||||
|
if (!isAuthorized(req)) {
|
||||||
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { shotId } = await params;
|
||||||
|
const body = await req.json().catch(() => ({}));
|
||||||
|
const { shotVersion } = body as { shotVersion?: string };
|
||||||
|
|
||||||
|
if (shotVersion !== undefined) {
|
||||||
|
if (!/^v\d{3}$/.test(shotVersion)) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "shotVersion must match format v### (e.g. v001)" },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return NextResponse.json({ error: "No updatable fields provided" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const shot = await db.shot.findUnique({
|
||||||
|
where: { id: shotId },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
if (!shot) {
|
||||||
|
return NextResponse.json({ error: "Shot not found" }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const updated = await db.shot.update({
|
||||||
|
where: { id: shotId },
|
||||||
|
data: { ...(shotVersion !== undefined && { shotVersion }) },
|
||||||
|
select: { id: true, shotVersion: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true, shot: updated });
|
||||||
|
}
|
||||||
|
|||||||
@@ -65,6 +65,7 @@ export async function GET(req: NextRequest) {
|
|||||||
exrOutput: true,
|
exrOutput: true,
|
||||||
seqTimecodeStart: true,
|
seqTimecodeStart: true,
|
||||||
seqTimecodeEnd: true,
|
seqTimecodeEnd: true,
|
||||||
|
shotVersion: true,
|
||||||
thumbnailUrl: true,
|
thumbnailUrl: true,
|
||||||
createdAt: true,
|
createdAt: true,
|
||||||
updatedAt: true,
|
updatedAt: true,
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "shots" ADD COLUMN "shotVersion" TEXT NOT NULL DEFAULT 'v001';
|
||||||
@@ -309,6 +309,8 @@ model Shot {
|
|||||||
// High-res deliverable (stored in Hetzner Object Storage)
|
// High-res deliverable (stored in Hetzner Object Storage)
|
||||||
highResKey String?
|
highResKey String?
|
||||||
highResFilename String?
|
highResFilename String?
|
||||||
|
// Shot-level version tracking (e.g. v001, v002)
|
||||||
|
shotVersion String @default("v001")
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
|||||||
@@ -179,6 +179,8 @@ export interface ShotWithDetails {
|
|||||||
seqTimecodeEnd: string | null;
|
seqTimecodeEnd: string | null;
|
||||||
// High-res deliverable
|
// High-res deliverable
|
||||||
highResFilename: string | null;
|
highResFilename: string | null;
|
||||||
|
// Shot-level version tracking
|
||||||
|
shotVersion: string;
|
||||||
shotGroup: { id: string; name: string } | null;
|
shotGroup: { id: string; name: string } | null;
|
||||||
footagePlates: {
|
footagePlates: {
|
||||||
id: string;
|
id: string;
|
||||||
|
|||||||
Reference in New Issue
Block a user