Files
XBid/src/actions/shots-assets.ts
T
2026-08-18 12:11:20 +02:00

485 lines
18 KiB
TypeScript

"use server";
import { revalidatePath } from "next/cache";
import { prisma } from "@/lib/prisma";
import { shotSchema, assetSchema, supervisionSchema, previsSchema, lineItemSchema, taskTemplateSchema } from "@/lib/validations";
import { calcLineItem } from "@/lib/calculations";
// ─── External Shots API ──────────────────────────────────────────────────────
export interface ExternalShot {
id: string;
shotCode: string;
scene: string;
episode: string | null;
shotNumber: number;
description: string | null;
status: string;
priority: string;
frameStart: number | null;
frameEnd: number | null;
fps: number | null;
dueDate: string | null;
artist: { id: string; name: string; email: string } | null;
_count: { versions: number; tasks: number };
}
export async function fetchExternalShots(projectId: string, episode?: string) {
const apiKey = process.env.SHOTS_API_KEY;
if (!apiKey) throw new Error("SHOTS_API_KEY is not configured on this server");
const url = new URL("https://review.twotalesvfx.com/api/ext/shots");
url.searchParams.set("projectId", projectId.trim());
if (episode) url.searchParams.set("episode", episode.trim());
const res = await fetch(url.toString(), {
headers: { Authorization: `Bearer ${apiKey}` },
cache: "no-store",
});
if (res.status === 401) throw new Error("Invalid API key — check SHOTS_API_KEY");
if (res.status === 400) throw new Error("Invalid or missing project ID");
if (res.status === 404) throw new Error("Project not found");
if (!res.ok) throw new Error(`API error: ${res.status}`);
const data = await res.json() as { shots: ExternalShot[]; total: number };
return data;
}
export async function importShotsFromApi(jobId: string, shots: ExternalShot[]) {
if (!shots.length) return { success: true, shots: [] };
const job = await prisma.job.findUnique({ where: { id: jobId }, select: { showId: true, jobType: true } });
if (!job) throw new Error("Job not found");
// Check for existing shot codes in this job to avoid duplicates
const existingCodes = await prisma.shot.findMany({
where: { jobId, shotCode: { in: shots.map((s) => s.shotCode) } },
select: { shotCode: true },
});
const existingSet = new Set(existingCodes.map((s) => s.shotCode));
const toCreate = shots.filter((s) => !existingSet.has(s.shotCode));
if (!toCreate.length) return { success: true, shots: [], skipped: shots.length };
const baseCount = await prisma.shot.count({ where: { jobId } });
const created = await Promise.all(
toCreate.map((s, i) =>
prisma.shot.create({
data: {
jobId,
shotCode: s.shotCode,
scene: s.scene,
episode: s.episode ?? null,
description: s.description ?? null,
shotNumber: s.shotNumber,
sortOrder: baseCount + i,
},
include: { lineItems: { include: { rateCard: true } } },
})
)
);
revalidatePath(`/jobs/${jobId}`);
return {
success: true,
shots: JSON.parse(JSON.stringify(created)),
skipped: existingSet.size,
};
}
export interface CsvShotRow {
shotCode: string;
durationSeconds?: number;
description?: string;
}
export async function importShotsFromCsv(jobId: string, rows: CsvShotRow[]) {
if (!rows.length) return { success: true, shots: [], skipped: 0 };
// Check for existing shot codes to avoid duplicates
const existingCodes = await prisma.shot.findMany({
where: { jobId, shotCode: { in: rows.map((r) => r.shotCode) } },
select: { shotCode: true },
});
const existingSet = new Set(existingCodes.map((s) => s.shotCode));
const toCreate = rows.filter((r) => !existingSet.has(r.shotCode));
if (!toCreate.length) return { success: true, shots: [], skipped: rows.length };
const baseCount = await prisma.shot.count({ where: { jobId } });
const created = await Promise.all(
toCreate.map((r, i) =>
prisma.shot.create({
data: {
jobId,
shotCode: r.shotCode,
scene: "SC",
description: r.description ?? null,
durationSeconds: r.durationSeconds ?? null,
shotNumber: (baseCount + i + 1) * 10,
sortOrder: baseCount + i,
},
include: { lineItems: { include: { rateCard: true } } },
})
)
);
revalidatePath(`/jobs/${jobId}`);
return {
success: true,
shots: JSON.parse(JSON.stringify(created)),
skipped: existingSet.size,
};
}
// ─── Shots ────────────────────────────────────────────────────────────────────
export async function createShot(jobId: string, data: unknown) {
const validated = shotSchema.parse(data);
// Fetch job to get showId and jobType for shot code generation
const job = await prisma.job.findUnique({
where: { id: jobId },
select: { showId: true, jobType: true },
});
if (!job) throw new Error("Job not found");
// Auto-increment shot number scoped to this scene (and episode for episodic)
const sceneKey = (validated.scene || "SC").toUpperCase();
const episodeKey = validated.episode ? validated.episode.toUpperCase() : null;
const agg = await prisma.shot.aggregate({
where: {
jobId,
scene: sceneKey,
...(job.jobType === "EPISODIC" ? { episode: episodeKey } : {}),
},
_max: { shotNumber: true },
});
const shotNumber = (agg._max.shotNumber ?? 0) + 10;
const paddedNumber = String(shotNumber).padStart(4, "0");
// Build shot code
const showId = (job.showId || "UNK").toUpperCase();
const scene = (validated.scene || "SC").toUpperCase();
const shotCode =
job.jobType === "EPISODIC" && validated.episode
? `${showId}_${validated.episode.toUpperCase()}_${scene}_${paddedNumber}`
: `${showId}_${scene}_${paddedNumber}`;
const count = await prisma.shot.count({ where: { jobId } });
const shot = await prisma.shot.create({
data: {
...validated,
jobId,
shotCode,
shotNumber,
sortOrder: count,
},
include: { lineItems: { include: { rateCard: true } } },
});
revalidatePath(`/jobs/${jobId}`);
return { success: true, shot: JSON.parse(JSON.stringify(shot)) };
}
export async function updateShot(shotId: string, jobId: string, data: unknown) {
const validated = shotSchema.parse(data);
const { shotCode: customShotCode, ...rest } = validated;
const shot = await prisma.shot.update({
where: { id: shotId },
data: customShotCode ? { ...rest, shotCode: customShotCode.trim() } : rest,
include: { lineItems: { include: { rateCard: true } } },
});
revalidatePath(`/jobs/${jobId}`);
return { success: true, shot: JSON.parse(JSON.stringify(shot)) };
}
export async function deleteShot(shotId: string, jobId: string) {
await prisma.shot.delete({ where: { id: shotId } });
revalidatePath(`/jobs/${jobId}`);
return { success: true };
}
export async function duplicateShot(shotId: string, jobId: string) {
const original = await prisma.shot.findUnique({
where: { id: shotId },
include: { lineItems: true },
});
if (!original) throw new Error("Shot not found");
// Auto-increment shot number scoped to the same scene (and episode)
const agg = await prisma.shot.aggregate({
where: {
jobId,
scene: original.scene,
...(original.episode != null ? { episode: original.episode } : {}),
},
_max: { shotNumber: true },
});
const shotNumber = (agg._max.shotNumber ?? 0) + 10;
const paddedNumber = String(shotNumber).padStart(4, "0");
// Rebuild shotCode preserving the original structure, just with new number
// Replace the last 4-digit segment in the code
const shotCode = original.shotCode.replace(/\d{4}$/, paddedNumber);
const { id: _id, createdAt: _ca, updatedAt: _ua, sortOrder, ...rest } = original;
const shot = await prisma.shot.create({
data: {
...rest,
shotCode,
shotNumber,
sortOrder: sortOrder + 1,
lineItems: {
create: original.lineItems.map(
({ id: _lid, shotId: _sid, assetId: _aid, createdAt: _lca, updatedAt: _lua, ...li }) => li
),
},
},
include: { lineItems: { include: { rateCard: true } } },
});
revalidatePath(`/jobs/${jobId}`);
return { success: true, shot: JSON.parse(JSON.stringify(shot)) };
}
export async function reorderShots(jobId: string, orderedIds: string[]) {
await Promise.all(
orderedIds.map((id, index) => prisma.shot.update({ where: { id }, data: { sortOrder: index } }))
);
revalidatePath(`/jobs/${jobId}`);
return { success: true };
}
// ─── Assets ───────────────────────────────────────────────────────────────────
export async function createAsset(jobId: string, data: unknown) {
const validated = assetSchema.parse(data);
const count = await prisma.asset.count({ where: { jobId } });
const asset = await prisma.asset.create({
data: { ...validated, jobId, sortOrder: count },
include: { lineItems: { include: { rateCard: true } } },
});
revalidatePath(`/jobs/${jobId}`);
return { success: true, asset };
}
export async function updateAsset(assetId: string, jobId: string, data: unknown) {
const validated = assetSchema.parse(data);
const asset = await prisma.asset.update({
where: { id: assetId },
data: validated,
include: { lineItems: { include: { rateCard: true } } },
});
revalidatePath(`/jobs/${jobId}`);
return { success: true, asset };
}
export async function deleteAsset(assetId: string, jobId: string) {
await prisma.asset.delete({ where: { id: assetId } });
revalidatePath(`/jobs/${jobId}`);
return { success: true };
}
export async function reorderAssets(jobId: string, orderedIds: string[]) {
await Promise.all(
orderedIds.map((id, index) => prisma.asset.update({ where: { id }, data: { sortOrder: index } }))
);
revalidatePath(`/jobs/${jobId}`);
return { success: true };
}
// ─── Supervision ──────────────────────────────────────────────────────────────
export async function createSupervision(jobId: string, data: unknown) {
const validated = supervisionSchema.parse(data);
const count = await prisma.supervision.count({ where: { jobId } });
const supervision = await prisma.supervision.create({
data: { ...validated, jobId, sortOrder: count },
include: { lineItems: { include: { rateCard: true } } },
});
revalidatePath(`/jobs/${jobId}`);
return { success: true, supervision: JSON.parse(JSON.stringify(supervision)) };
}
export async function updateSupervision(supervisionId: string, jobId: string, data: unknown) {
const validated = supervisionSchema.parse(data);
const supervision = await prisma.supervision.update({
where: { id: supervisionId },
data: validated,
include: { lineItems: { include: { rateCard: true } } },
});
revalidatePath(`/jobs/${jobId}`);
return { success: true, supervision: JSON.parse(JSON.stringify(supervision)) };
}
export async function deleteSupervision(supervisionId: string, jobId: string) {
await prisma.supervision.delete({ where: { id: supervisionId } });
revalidatePath(`/jobs/${jobId}`);
return { success: true };
}
export async function reorderSupervisions(jobId: string, orderedIds: string[]) {
await Promise.all(
orderedIds.map((id, index) => prisma.supervision.update({ where: { id }, data: { sortOrder: index } }))
);
revalidatePath(`/jobs/${jobId}`);
return { success: true };
}
// ─── Previs ───────────────────────────────────────────────────────────────────
export async function createPrevis(jobId: string, data: unknown) {
const validated = previsSchema.parse(data);
const count = await prisma.previs.count({ where: { jobId } });
const previs = await prisma.previs.create({
data: { ...validated, jobId, sortOrder: count },
include: { lineItems: { include: { rateCard: true } } },
});
revalidatePath(`/jobs/${jobId}`);
return { success: true, previs: JSON.parse(JSON.stringify(previs)) };
}
export async function updatePrevis(previsId: string, jobId: string, data: unknown) {
const validated = previsSchema.parse(data);
const previs = await prisma.previs.update({
where: { id: previsId },
data: validated,
include: { lineItems: { include: { rateCard: true } } },
});
revalidatePath(`/jobs/${jobId}`);
return { success: true, previs: JSON.parse(JSON.stringify(previs)) };
}
export async function deletePrevis(previsId: string, jobId: string) {
await prisma.previs.delete({ where: { id: previsId } });
revalidatePath(`/jobs/${jobId}`);
return { success: true };
}
export async function reorderPrevises(jobId: string, orderedIds: string[]) {
await Promise.all(
orderedIds.map((id, index) => prisma.previs.update({ where: { id }, data: { sortOrder: index } }))
);
revalidatePath(`/jobs/${jobId}`);
return { success: true };
}
// ─── Line Items ───────────────────────────────────────────────────────────────
export async function createLineItem(
parentId: string,
parentType: "shot" | "asset" | "supervision" | "previs",
jobId: string,
data: unknown
) {
const validated = lineItemSchema.parse(data);
const count =
parentType === "shot"
? await prisma.lineItem.count({ where: { shotId: parentId } })
: parentType === "asset"
? await prisma.lineItem.count({ where: { assetId: parentId } })
: parentType === "previs"
? await prisma.lineItem.count({ where: { previsId: parentId } })
: await prisma.lineItem.count({ where: { supervisionId: parentId } });
// Get complexity multiplier from parent
let complexityMultiplier = "1";
if (parentType === "shot") {
const shot = await prisma.shot.findUnique({ where: { id: parentId }, select: { complexityMultiplier: true } });
complexityMultiplier = shot?.complexityMultiplier.toString() ?? "1";
} else if (parentType === "asset") {
const asset = await prisma.asset.findUnique({ where: { id: parentId }, select: { complexityMultiplier: true } });
complexityMultiplier = asset?.complexityMultiplier.toString() ?? "1";
} else if (parentType === "previs") {
const previs = await prisma.previs.findUnique({ where: { id: parentId }, select: { complexityMultiplier: true } });
complexityMultiplier = previs?.complexityMultiplier.toString() ?? "1";
} else {
const supervision = await prisma.supervision.findUnique({ where: { id: parentId }, select: { complexityMultiplier: true } });
complexityMultiplier = supervision?.complexityMultiplier.toString() ?? "1";
}
const calc = calcLineItem(validated as any, complexityMultiplier);
const lineItem = await prisma.lineItem.create({
data: {
...validated,
...(parentType === "shot"
? { shotId: parentId }
: parentType === "asset"
? { assetId: parentId }
: parentType === "previs"
? { previsId: parentId }
: { supervisionId: parentId }),
sortOrder: count,
hourlyRate: calc.hourlyRate.toFixed(4),
subtotal: calc.subtotal.toFixed(4),
markupAmount: calc.markupAmount.toFixed(4),
discountAmount: calc.discountAmount.toFixed(4),
taxAmount: calc.taxAmount.toFixed(4),
total: calc.total.toFixed(4),
},
include: { rateCard: true },
});
revalidatePath(`/jobs/${jobId}`);
return { success: true, lineItem: JSON.parse(JSON.stringify(lineItem)) };
}
export async function updateLineItem(
lineItemId: string,
jobId: string,
data: unknown,
complexityMultiplier?: string
) {
const validated = lineItemSchema.parse(data);
const calc = calcLineItem(validated as any, complexityMultiplier ?? "1");
const lineItem = await prisma.lineItem.update({
where: { id: lineItemId },
data: {
...validated,
hourlyRate: calc.hourlyRate.toFixed(4),
subtotal: calc.subtotal.toFixed(4),
markupAmount: calc.markupAmount.toFixed(4),
discountAmount: calc.discountAmount.toFixed(4),
taxAmount: calc.taxAmount.toFixed(4),
total: calc.total.toFixed(4),
},
include: { rateCard: true },
});
revalidatePath(`/jobs/${jobId}`);
return { success: true, lineItem: JSON.parse(JSON.stringify(lineItem)) };
}
export async function deleteLineItem(lineItemId: string, jobId: string) {
await prisma.lineItem.delete({ where: { id: lineItemId } });
revalidatePath(`/jobs/${jobId}`);
return { success: true };
}
export async function reorderLineItems(parentId: string, parentType: "shot" | "asset" | "supervision" | "previs", jobId: string, orderedIds: string[]) {
await Promise.all(
orderedIds.map((id, index) => prisma.lineItem.update({ where: { id }, data: { sortOrder: index } }))
);
revalidatePath(`/jobs/${jobId}`);
return { success: true };
}
// ─── Task Templates ───────────────────────────────────────────────────────────
export async function getTaskTemplates() {
const templates = await prisma.taskTemplate.findMany({ orderBy: { name: "asc" } });
return JSON.parse(JSON.stringify(templates));
}
export async function createTaskTemplate(data: unknown) {
const validated = taskTemplateSchema.parse(data);
const template = await prisma.taskTemplate.create({ data: validated });
return { success: true, template: JSON.parse(JSON.stringify(template)) };
}
export async function deleteTaskTemplate(id: string) {
await prisma.taskTemplate.delete({ where: { id } });
return { success: true };
}