Add CSV shotlist export for jobs/quotes
Deploy / deploy (push) Successful in 9m11s

Adds a GET /api/csv/shots/[jobId] endpoint with two modes:

  ?detail=shots (default) - one row per shot, with rolled-up task
    count, hours, days, subtotal and total.
  ?detail=tasks - one row per line item, adding artist role, rate
    card, rates and the markup/discount/tax/total split.

Totals run through the existing calcLineItem/sumLineItems helpers so
they match the editor and the PDF. Amounts are emitted as plain 2dp
numbers with the currency named in the header, so spreadsheets can sum
them. Rows follow the editor's display order (grouped by episode or
shot group, Ungrouped last, then alphabetical by shot code) and the
group column header adapts to the job type.

Shotlist column headers deliberately match the CSV importer's aliases
so an export can be edited and re-imported. Free text is flattened to a
single line to keep one shot per row: the importer's parser splits on
newlines before handling quotes, so multi-line quoted fields would
otherwise corrupt a re-import.

Exposed as an "Export CSV" dropdown in the shots editor.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
twotalesanimation
2026-08-27 16:06:11 +02:00
parent 6cc4751c9f
commit 2ed7ae46a4
3 changed files with 296 additions and 1 deletions
+206
View File
@@ -0,0 +1,206 @@
import { NextRequest, NextResponse } from "next/server";
import Decimal from "decimal.js";
import { format } from "date-fns";
import { prisma } from "@/lib/prisma";
import { calcLineItem, sumLineItems } from "@/lib/calculations";
import { toCsv, slugifyFilename, attachmentHeader, type CsvValue } from "@/lib/csv";
/**
* CSV shotlist export for a job/quote.
*
* ?detail=shots (default) — one row per shot, with rolled-up totals.
* Column headers line up with the CSV importer, so an export can be
* edited and re-imported into another quote.
* ?detail=tasks — one row per line item, for a full cost breakdown.
*/
export async function GET(
req: NextRequest,
{ params }: { params: Promise<{ jobId: string }> }
) {
const { jobId } = await params;
const detail = req.nextUrl.searchParams.get("detail") === "tasks" ? "tasks" : "shots";
const job = await prisma.job.findUnique({
where: { id: jobId },
include: {
client: true,
shots: {
orderBy: { sortOrder: "asc" },
include: {
lineItems: {
orderBy: { sortOrder: "asc" },
include: { rateCard: true },
},
},
},
},
});
if (!job) {
return NextResponse.json({ error: "Job not found" }, { status: 404 });
}
const currency = job.currency || "ZAR";
const isEpisodic = job.jobType === "EPISODIC";
// Money to 2dp, other figures trimmed to 4dp — both left unformatted so
// spreadsheets treat them as numbers.
const money = (d: Decimal) => d.toDecimalPlaces(2).toFixed(2);
const qty = (v: Decimal | number | string) =>
new Decimal(v.toString()).toDecimalPlaces(4).toString();
// Free text is flattened to a single line: it keeps one shot on one CSV row,
// which the shot importer requires and which reads better in a spreadsheet.
const oneLine = (v: string | null | undefined) =>
(v ?? "").replace(/\s*[\r\n]+\s*/g, " ").trim();
// Mirror the editor's ordering: grouped by episode/group (Ungrouped last),
// then alphabetical by shot code.
const groupOf = (s: (typeof job.shots)[number]) =>
(isEpisodic ? s.episode : s.shotGroup) || "Ungrouped";
const sortedShots = [...job.shots].sort((a, b) => {
const ga = groupOf(a);
const gb = groupOf(b);
if (ga !== gb) {
if (ga === "Ungrouped") return 1;
if (gb === "Ungrouped") return -1;
return ga.localeCompare(gb);
}
return (a.shotCode ?? "").localeCompare(b.shotCode ?? "");
});
const calcFor = (shot: (typeof job.shots)[number], li: (typeof job.shots)[number]["lineItems"][number]) =>
calcLineItem(
{
quantityHours: li.quantityHours.toString(),
dailyRate: li.dailyRate.toString(),
hoursPerDay: li.hoursPerDay.toString(),
numArtists: li.numArtists.toString(),
numDays: li.numDays.toString(),
manualHourlyRate: li.manualHourlyRate?.toString(),
manualSubtotal: li.manualSubtotal?.toString(),
markupPercent: li.markupPercent.toString(),
discountPercent: li.discountPercent.toString(),
taxPercent: li.taxPercent.toString(),
},
shot.complexityMultiplier.toString()
);
let headers: string[];
const rows: CsvValue[][] = [];
if (detail === "tasks") {
headers = [
"Shot Code",
"Shot Number",
"Scene",
isEpisodic ? "Episode" : "Group",
"Shot Description",
"Duration",
"Complexity",
"Task",
"Artist Role",
"Rate Card",
"Hours",
"Days",
"Artists",
"Daily Rate",
"Hours Per Day",
"Hourly Rate",
`Subtotal (${currency})`,
`Markup (${currency})`,
`Discount (${currency})`,
`Tax (${currency})`,
`Total (${currency})`,
"Task Notes",
];
for (const shot of sortedShots) {
for (const li of shot.lineItems) {
const r = calcFor(shot, li);
rows.push([
shot.shotCode,
shot.shotNumber,
shot.scene,
(isEpisodic ? shot.episode : shot.shotGroup) ?? "",
oneLine(shot.description),
shot.durationSeconds ?? "",
qty(shot.complexityMultiplier),
li.taskName,
li.artistRole ?? "",
li.rateCard?.name ?? "",
qty(li.quantityHours),
qty(li.numDays),
qty(li.numArtists),
money(new Decimal(li.dailyRate.toString())),
qty(li.hoursPerDay),
money(r.hourlyRate),
money(r.subtotal),
money(r.markupAmount),
money(r.discountAmount),
money(r.taxAmount),
money(r.total),
oneLine(li.notes),
]);
}
}
} else {
headers = [
"Shot Code",
"Shot Number",
"Scene",
isEpisodic ? "Episode" : "Group",
"Description",
"Duration",
"Complexity",
"Tasks",
"Hours",
"Days",
`Subtotal (${currency})`,
`Total (${currency})`,
"Notes",
];
for (const shot of sortedShots) {
const results = shot.lineItems.map((li) => calcFor(shot, li));
const summed = sumLineItems(results);
const hours = shot.lineItems.reduce(
(acc, li) => acc.add(new Decimal(li.quantityHours.toString())),
new Decimal(0)
);
const days = shot.lineItems.reduce(
(acc, li) => acc.add(new Decimal(li.numDays.toString())),
new Decimal(0)
);
rows.push([
shot.shotCode,
shot.shotNumber,
shot.scene,
(isEpisodic ? shot.episode : shot.shotGroup) ?? "",
oneLine(shot.description),
shot.durationSeconds ?? "",
qty(shot.complexityMultiplier),
shot.lineItems.length,
qty(hours),
qty(days),
money(summed.subtotal),
money(summed.total),
oneLine(shot.notes),
]);
}
}
const csv = toCsv(headers, rows);
const suffix = detail === "tasks" ? "shot-breakdown" : "shotlist";
const filename = `${slugifyFilename(job.projectCode || job.projectTitle, "job")}-${suffix}-${format(new Date(), "yyyy-MM-dd")}.csv`;
return new NextResponse(csv, {
headers: {
"Content-Type": "text/csv; charset=utf-8",
"Content-Disposition": attachmentHeader(filename),
"Cache-Control": "no-store",
},
});
}
+39 -1
View File
@@ -3,13 +3,19 @@
import { useState, useMemo } from "react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Plus, FolderDown, Upload } from "lucide-react";
import { Plus, FolderDown, Upload, Download } from "lucide-react";
import { createShot, deleteShot, duplicateShot, reorderShots } from "@/actions/shots-assets";
import { ShotCard } from "./shot-card";
import { ShotDialog } from "./shot-dialog";
import { ImportShotsDialog } from "./import-shots-dialog";
import { ImportCsvShotsDialog } from "./import-csv-shots-dialog";
import { calcLineItem, sumLineItems, formatCurrency } from "@/lib/calculations";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
DndContext,
closestCenter,
@@ -141,6 +147,10 @@ export function ShotsEditor({ job, rateCards, taskTemplates }: { job: JobWithRel
}
};
const handleCsvExport = (detail: "shots" | "tasks") => {
window.location.href = `/api/csv/shots/${job.id}?detail=${detail}`;
};
const handleShotUpdate = (updatedShot: any) => {
setShots((prev) => prev.map((s) => (s.id === updatedShot.id ? updatedShot : s)));
};
@@ -150,6 +160,34 @@ export function ShotsEditor({ job, rateCards, taskTemplates }: { job: JobWithRel
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold text-white">Shots ({shots.length})</h2>
<div className="flex items-center gap-2">
<DropdownMenu>
<DropdownMenuTrigger
disabled={shots.length === 0}
render={
<Button
size="sm"
variant="outline"
className="border-zinc-700 text-zinc-300 hover:text-white"
/>
}
>
<Download className="h-4 w-4 mr-1" /> Export CSV
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="bg-zinc-900 border-zinc-800 w-auto min-w-64">
<DropdownMenuItem onClick={() => handleCsvExport("shots")}>
<div className="flex flex-col gap-0.5">
<span>Shotlist</span>
<span className="text-xs text-zinc-500">One row per shot — re-importable</span>
</div>
</DropdownMenuItem>
<DropdownMenuItem onClick={() => handleCsvExport("tasks")}>
<div className="flex flex-col gap-0.5">
<span>Cost breakdown</span>
<span className="text-xs text-zinc-500">One row per task line item</span>
</div>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<Button
onClick={() => setImportCsvDialogOpen(true)}
size="sm"
+51
View File
@@ -0,0 +1,51 @@
/**
* CSV serialisation helpers (RFC 4180).
*/
export type CsvValue = string | number | null | undefined;
// Byte-order mark — makes Excel read the file as UTF-8.
const BOM = "";
/**
* Escape a single field. Wraps in quotes when the value contains a comma,
* quote, or newline; doubles any embedded quotes.
*/
function escapeField(value: CsvValue): string {
if (value == null) return "";
const str = String(value);
if (str === "") return "";
// Prefix values a spreadsheet would otherwise evaluate as a formula.
const safe = /^[=+\-@\t\r]/.test(str) ? `'${str}` : str;
return /[",\r\n]/.test(safe) ? `"${safe.replace(/"/g, '""')}"` : safe;
}
/**
* Build a CSV document from a header row and data rows.
*/
export function toCsv(headers: string[], rows: CsvValue[][]): string {
const lines = [headers, ...rows].map((row) => row.map(escapeField).join(","));
return BOM + lines.join("\r\n") + "\r\n";
}
/**
* Turn arbitrary text into a safe, readable filename segment.
*/
export function slugifyFilename(value: string, fallback = "export"): string {
const slug = value
.normalize("NFKD")
.replace(/[^\w\s.-]/g, "")
.trim()
.replace(/\s+/g, "-")
.replace(/-+/g, "-")
.replace(/^[-.]+|[-.]+$/g, "");
return slug || fallback;
}
/**
* Content-Disposition value with an ASCII fallback plus RFC 5987 encoding.
*/
export function attachmentHeader(filename: string): string {
const ascii = filename.replace(/[^\x20-\x7E]/g, "_").replace(/"/g, "");
return `attachment; filename="${ascii}"; filename*=UTF-8''${encodeURIComponent(filename)}`;
}