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 +
+
+
+