485 lines
18 KiB
TypeScript
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 };
|
|
}
|