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.
This commit is contained in:
@@ -23,6 +23,7 @@
|
|||||||
"@tanstack/react-router": "^1.121.0",
|
"@tanstack/react-router": "^1.121.0",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
|
"jspdf": "^4.2.1",
|
||||||
"lucide-react": "^1.7.0",
|
"lucide-react": "^1.7.0",
|
||||||
"radix-ui": "^1.4.3",
|
"radix-ui": "^1.4.3",
|
||||||
"react": "^19.1.0",
|
"react": "^19.1.0",
|
||||||
|
|||||||
212
packages/admin/src/components/repairs/generate-pdf.ts
Normal file
212
packages/admin/src/components/repairs/generate-pdf.ts
Normal file
@@ -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<string, string> = {
|
||||||
|
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<void> {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -21,6 +21,8 @@ import { Skeleton } from '@/components/ui/skeleton'
|
|||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog'
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog'
|
||||||
import { ArrowLeft, Plus, Trash2, RotateCcw, Save, FileText, Search } from 'lucide-react'
|
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 { toast } from 'sonner'
|
||||||
import { useAuthStore } from '@/stores/auth.store'
|
import { useAuthStore } from '@/stores/auth.store'
|
||||||
import type { RepairLineItem } from '@/types/repair'
|
import type { RepairLineItem } from '@/types/repair'
|
||||||
@@ -73,6 +75,8 @@ function RepairTicketDetailPage() {
|
|||||||
|
|
||||||
const { data: ticket, isLoading } = useQuery(repairTicketDetailOptions(ticketId))
|
const { data: ticket, isLoading } = useQuery(repairTicketDetailOptions(ticketId))
|
||||||
const { data: lineItemsData, isLoading: itemsLoading } = useQuery(repairLineItemListOptions(ticketId, params))
|
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<Record<string, string>>({})
|
const [editFields, setEditFields] = useState<Record<string, string>>({})
|
||||||
|
|
||||||
@@ -180,7 +184,16 @@ function RepairTicketDetailPage() {
|
|||||||
<h1 className="text-2xl font-bold">Ticket #{ticket.ticketNumber}</h1>
|
<h1 className="text-2xl font-bold">Ticket #{ticket.ticketNumber}</h1>
|
||||||
<p className="text-sm text-muted-foreground">{ticket.customerName} — {ticket.instrumentDescription ?? 'No instrument'}</p>
|
<p className="text-sm text-muted-foreground">{ticket.customerName} — {ticket.instrumentDescription ?? 'No instrument'}</p>
|
||||||
</div>
|
</div>
|
||||||
<Button variant="outline" size="sm" onClick={() => toast.info('PDF generation coming soon')}>
|
<Button variant="outline" size="sm" onClick={() => {
|
||||||
|
if (!ticket) return
|
||||||
|
generateAndUploadPdf({
|
||||||
|
ticket,
|
||||||
|
lineItems: lineItemsData?.data ?? [],
|
||||||
|
notes: notesData?.data ?? [],
|
||||||
|
includeNotes: true,
|
||||||
|
}, ticketId, token)
|
||||||
|
toast.success('PDF downloaded and saved to documents')
|
||||||
|
}}>
|
||||||
<FileText className="mr-2 h-4 w-4" />PDF
|
<FileText className="mr-2 h-4 w-4" />PDF
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user