From 2ed7ae46a4e263c6838473611d74b127ea56dcac Mon Sep 17 00:00:00 2001 From: twotalesanimation <80506065+twotalesanimation@users.noreply.github.com> Date: Thu, 27 Aug 2026 16:06:11 +0200 Subject: [PATCH] Add CSV shotlist export for jobs/quotes 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 --- src/app/api/csv/shots/[jobId]/route.ts | 206 +++++++++++++++++++++++++ src/components/shots/shots-editor.tsx | 40 ++++- src/lib/csv.ts | 51 ++++++ 3 files changed, 296 insertions(+), 1 deletion(-) create mode 100644 src/app/api/csv/shots/[jobId]/route.ts create mode 100644 src/lib/csv.ts diff --git a/src/app/api/csv/shots/[jobId]/route.ts b/src/app/api/csv/shots/[jobId]/route.ts new file mode 100644 index 0000000..f1abb44 --- /dev/null +++ b/src/app/api/csv/shots/[jobId]/route.ts @@ -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", + }, + }); +} diff --git a/src/components/shots/shots-editor.tsx b/src/components/shots/shots-editor.tsx index 2d1a52e..463050e 100644 --- a/src/components/shots/shots-editor.tsx +++ b/src/components/shots/shots-editor.tsx @@ -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

Shots ({shots.length})

+ + + } + > + Export CSV + + + handleCsvExport("shots")}> +
+ Shotlist + One row per shot — re-importable +
+
+ handleCsvExport("tasks")}> +
+ Cost breakdown + One row per task line item +
+
+
+