Files
lunarfront-app/packages/admin/src/components/repairs/generate-pdf.ts
ryan 95cf017b4b feat: repair-POS integration, receipt formats, manager overrides, price adjustments
- Add thermal/full-page receipt format toggle (per-device, localStorage)
- Full-page receipt uses clean invoice layout matching repair PDF style
- Settings page reorganized into tabbed sections (Store, Locations, Modules, Receipt, POS Security, Advanced)
- Manager override system: configurable PIN prompt for void, refund, discount, cash in/out
- Discount threshold setting: require manager approval above X%
- Consumable product type: tracked for internal job costing, excluded from POS search, receipts, and customer-facing totals
- Repair line item dialog: product picker dropdown for parts/consumables from inventory
- Repair → POS checkout: load ready-for-pickup tickets into repair_payment transactions with proper tax categories (labor=service, parts=goods)
- Transaction completion auto-updates repair ticket status to picked_up
- POS Repairs dialog with Pickup and New Intake tabs, customer account lookup
- Inline price adjustment on cart items: % off, $ off, or set price with live preview
- Order-level discount button with same three input modes
- Backend: migration 0043 (consumable enum + is_consumable flag), createFromRepairTicket service, ready-for-pickup endpoint
- Fix: backend dev script uses --env-file for turbo compatibility

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 16:05:19 +00:00

213 lines
6.3 KiB
TypeScript

import jsPDF from 'jspdf'
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 = 'LunarFront' }: 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('Item', 110, y)
y += 5
doc.setFont('helvetica', 'normal')
doc.text(ticket.customerName, 14, y)
doc.text(ticket.itemDescription ?? '-', 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 (exclude consumables — internal only)
const billableItems = lineItems.filter((i) => i.itemType !== 'consumable')
if (billableItems.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 billableItems) {
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 = billableItems.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
}
}