From ee8d6f7aadf37541be0bd8fac1b34ea97efe2c85 Mon Sep 17 00:00:00 2001 From: Ryan Moon Date: Sun, 29 Mar 2026 10:52:17 -0500 Subject: [PATCH] Add jsPDF dependency and PDF generation module for repair tickets Install jsPDF, add generate-pdf.ts with customer-facing PDF layout, wire PDF button in ticket detail to generate and auto-upload. --- packages/admin/package.json | 1 + .../src/components/repairs/generate-pdf.ts | 212 ++++++++++++++++++ .../_authenticated/repairs/$ticketId.tsx | 15 +- 3 files changed, 227 insertions(+), 1 deletion(-) create mode 100644 packages/admin/src/components/repairs/generate-pdf.ts diff --git a/packages/admin/package.json b/packages/admin/package.json index 22f1323..f30ebbb 100644 --- a/packages/admin/package.json +++ b/packages/admin/package.json @@ -23,6 +23,7 @@ "@tanstack/react-router": "^1.121.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "jspdf": "^4.2.1", "lucide-react": "^1.7.0", "radix-ui": "^1.4.3", "react": "^19.1.0", diff --git a/packages/admin/src/components/repairs/generate-pdf.ts b/packages/admin/src/components/repairs/generate-pdf.ts new file mode 100644 index 0000000..53a0855 --- /dev/null +++ b/packages/admin/src/components/repairs/generate-pdf.ts @@ -0,0 +1,212 @@ +import jsPDF from 'jspdf' +import { api } from '@/lib/api-client' +import type { RepairTicket, RepairLineItem, RepairNote } from '@/types/repair' + +const STATUS_LABELS: Record = { + in_transit: 'In Transit', + intake: 'Intake', + diagnosing: 'Diagnosing', + pending_approval: 'Pending Approval', + approved: 'Approved', + in_progress: 'In Progress', + pending_parts: 'Pending Parts', + ready: 'Ready for Pickup', + picked_up: 'Picked Up', + delivered: 'Delivered', + cancelled: 'Cancelled', +} + +interface GeneratePdfOptions { + ticket: RepairTicket + lineItems: RepairLineItem[] + notes: RepairNote[] + includeNotes: boolean + companyName?: string +} + +export function generateRepairPdf({ ticket, lineItems, notes, includeNotes, companyName = 'Forte Music' }: GeneratePdfOptions): jsPDF { + const doc = new jsPDF() + let y = 20 + + // Header + doc.setFontSize(18) + doc.setFont('helvetica', 'bold') + doc.text(companyName, 14, y) + y += 8 + doc.setFontSize(12) + doc.setFont('helvetica', 'normal') + doc.text('Repair Ticket', 14, y) + + // Ticket number and status — right aligned + doc.setFontSize(14) + doc.setFont('helvetica', 'bold') + doc.text(`#${ticket.ticketNumber ?? ''}`, 196, 20, { align: 'right' }) + doc.setFontSize(10) + doc.setFont('helvetica', 'normal') + doc.text(STATUS_LABELS[ticket.status] ?? ticket.status, 196, 28, { align: 'right' }) + doc.text(new Date(ticket.intakeDate).toLocaleDateString(), 196, 34, { align: 'right' }) + + y += 12 + + // Divider + doc.setDrawColor(200) + doc.line(14, y, 196, y) + y += 8 + + // Customer info + doc.setFontSize(10) + doc.setFont('helvetica', 'bold') + doc.text('Customer', 14, y) + doc.text('Instrument', 110, y) + y += 5 + doc.setFont('helvetica', 'normal') + doc.text(ticket.customerName, 14, y) + doc.text(ticket.instrumentDescription ?? '-', 110, y) + y += 5 + if (ticket.customerPhone) { doc.text(ticket.customerPhone, 14, y); y += 5 } + if (ticket.serialNumber) { doc.text(`S/N: ${ticket.serialNumber}`, 110, y - 5) } + y += 3 + + // Problem description + doc.setFont('helvetica', 'bold') + doc.text('Problem Description', 14, y) + y += 5 + doc.setFont('helvetica', 'normal') + const problemLines = doc.splitTextToSize(ticket.problemDescription, 180) + doc.text(problemLines, 14, y) + y += problemLines.length * 4 + 5 + + // Dates + doc.setFontSize(9) + const dateInfo = [ + `Intake: ${new Date(ticket.intakeDate).toLocaleDateString()}`, + ticket.promisedDate ? `Promised: ${new Date(ticket.promisedDate).toLocaleDateString()}` : null, + ticket.completedDate ? `Completed: ${new Date(ticket.completedDate).toLocaleDateString()}` : null, + ].filter(Boolean).join(' | ') + doc.text(dateInfo, 14, y) + y += 8 + + // Line items table + if (lineItems.length > 0) { + doc.setDrawColor(200) + doc.line(14, y, 196, y) + y += 6 + + doc.setFontSize(10) + doc.setFont('helvetica', 'bold') + doc.text('Line Items', 14, y) + y += 6 + + // Table header + doc.setFontSize(8) + doc.setFillColor(245, 245, 245) + doc.rect(14, y - 3, 182, 6, 'F') + doc.text('Type', 16, y) + doc.text('Description', 40, y) + doc.text('Qty', 130, y, { align: 'right' }) + doc.text('Unit Price', 155, y, { align: 'right' }) + doc.text('Total', 190, y, { align: 'right' }) + y += 5 + + // Table rows + doc.setFont('helvetica', 'normal') + for (const item of lineItems) { + if (y > 270) { doc.addPage(); y = 20 } + doc.text(item.itemType.replace('_', ' '), 16, y) + const descLines = doc.splitTextToSize(item.description, 85) + doc.text(descLines, 40, y) + doc.text(String(item.qty), 130, y, { align: 'right' }) + doc.text(`$${item.unitPrice}`, 155, y, { align: 'right' }) + doc.text(`$${item.totalPrice}`, 190, y, { align: 'right' }) + y += descLines.length * 4 + 2 + } + + // Total + y += 2 + doc.setDrawColor(200) + doc.line(140, y, 196, y) + y += 5 + doc.setFont('helvetica', 'bold') + doc.setFontSize(10) + const total = lineItems.reduce((sum, i) => sum + parseFloat(i.totalPrice), 0) + doc.text('Total:', 155, y, { align: 'right' }) + doc.text(`$${total.toFixed(2)}`, 190, y, { align: 'right' }) + y += 4 + + if (ticket.estimatedCost) { + doc.setFont('helvetica', 'normal') + doc.setFontSize(9) + doc.text(`Estimated: $${ticket.estimatedCost}`, 190, y, { align: 'right' }) + y += 4 + } + y += 4 + } + + // Customer-visible notes + if (includeNotes) { + const customerNotes = notes.filter((n) => n.visibility === 'customer') + if (customerNotes.length > 0) { + if (y > 250) { doc.addPage(); y = 20 } + doc.setDrawColor(200) + doc.line(14, y, 196, y) + y += 6 + doc.setFontSize(10) + doc.setFont('helvetica', 'bold') + doc.text('Notes', 14, y) + y += 6 + + doc.setFontSize(9) + doc.setFont('helvetica', 'normal') + for (const note of customerNotes) { + if (y > 270) { doc.addPage(); y = 20 } + doc.setFont('helvetica', 'bold') + doc.text(`${note.authorName} — ${new Date(note.createdAt).toLocaleDateString()}`, 14, y) + y += 4 + doc.setFont('helvetica', 'normal') + const noteLines = doc.splitTextToSize(note.content, 180) + doc.text(noteLines, 14, y) + y += noteLines.length * 3.5 + 4 + } + } + } + + // Footer + if (y > 270) { doc.addPage(); y = 20 } + y = 280 + doc.setFontSize(8) + doc.setFont('helvetica', 'normal') + doc.setTextColor(150) + doc.text(`Generated ${new Date().toLocaleString()} — Ticket #${ticket.ticketNumber}`, 105, y, { align: 'center' }) + + return doc +} + +export async function generateAndUploadPdf( + options: GeneratePdfOptions, + ticketId: string, + token: string | null, +): Promise { + const doc = generateRepairPdf(options) + const filename = `repair-${options.ticket.ticketNumber}-${options.ticket.status}.pdf` + + // Download for user + doc.save(filename) + + // Also upload to ticket documents + const blob = doc.output('blob') + const formData = new FormData() + formData.append('file', new File([blob], filename, { type: 'application/pdf' })) + formData.append('entityType', 'repair_ticket') + formData.append('entityId', ticketId) + formData.append('category', 'document') + + try { + await fetch('/v1/files', { + method: 'POST', + headers: { Authorization: `Bearer ${token}` }, + body: formData, + }) + } catch { + // Download succeeded even if upload fails — non-critical + } +} diff --git a/packages/admin/src/routes/_authenticated/repairs/$ticketId.tsx b/packages/admin/src/routes/_authenticated/repairs/$ticketId.tsx index 3773e6a..641aefb 100644 --- a/packages/admin/src/routes/_authenticated/repairs/$ticketId.tsx +++ b/packages/admin/src/routes/_authenticated/repairs/$ticketId.tsx @@ -21,6 +21,8 @@ import { Skeleton } from '@/components/ui/skeleton' import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog' import { ArrowLeft, Plus, Trash2, RotateCcw, Save, FileText, Search } from 'lucide-react' +import { generateAndUploadPdf } from '@/components/repairs/generate-pdf' +import { repairNoteListOptions } from '@/api/repairs' import { toast } from 'sonner' import { useAuthStore } from '@/stores/auth.store' import type { RepairLineItem } from '@/types/repair' @@ -73,6 +75,8 @@ function RepairTicketDetailPage() { const { data: ticket, isLoading } = useQuery(repairTicketDetailOptions(ticketId)) const { data: lineItemsData, isLoading: itemsLoading } = useQuery(repairLineItemListOptions(ticketId, params)) + const { data: notesData } = useQuery(repairNoteListOptions(ticketId)) + const token = useAuthStore((s) => s.token) const [editFields, setEditFields] = useState>({}) @@ -180,7 +184,16 @@ function RepairTicketDetailPage() {

Ticket #{ticket.ticketNumber}

{ticket.customerName} — {ticket.instrumentDescription ?? 'No instrument'}

-