Add PDF generation modal with content picker, repairs help pages

PDF button now opens a modal where staff can select which line items,
customer-visible notes, and photos to include before generating. Defaults
to all customer notes and completed photos. Replaces the old one-click
generation.

Added 4 help/wiki pages for the Repairs module: Repairs Overview, Repair
Templates, Repair Batches, and Notes & Photos. Covers ticket workflow,
template usage, batch management, note visibility, photo phases, and
signed approval process. Updated Getting Started nav to include Repairs.
This commit is contained in:
Ryan Moon
2026-03-29 13:20:32 -05:00
parent 591be589f0
commit 916eb29895
3 changed files with 457 additions and 17 deletions

View File

@@ -0,0 +1,265 @@
import { useState, useEffect } from 'react'
import { useQuery } from '@tanstack/react-query'
import { queryOptions } from '@tanstack/react-query'
import { repairNoteListOptions } from '@/api/repairs'
import { api } from '@/lib/api-client'
import { useAuthStore } from '@/stores/auth.store'
import { generateAndUploadPdf } from './generate-pdf'
import { Button } from '@/components/ui/button'
import { Label } from '@/components/ui/label'
import { Badge } from '@/components/ui/badge'
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog'
import { FileText, Download, Check, Eye, Lock } from 'lucide-react'
import { toast } from 'sonner'
import type { RepairTicket, RepairLineItem, RepairNote } from '@/types/repair'
interface FileRecord {
id: string
path: string
filename: string
category: string
}
function ticketFilesOptions(ticketId: string) {
return queryOptions({
queryKey: ['files', 'repair_ticket', ticketId],
queryFn: () => api.get<{ data: FileRecord[] }>('/v1/files', { entityType: 'repair_ticket', entityId: ticketId }),
enabled: !!ticketId,
})
}
interface PdfModalProps {
ticket: RepairTicket
lineItems: RepairLineItem[]
ticketId: string
}
export function PdfModal({ ticket, lineItems, ticketId }: PdfModalProps) {
const [open, setOpen] = useState(false)
const [generating, setGenerating] = useState(false)
const token = useAuthStore((s) => s.token)
// Fetch notes and photos
const { data: notesData } = useQuery(repairNoteListOptions(ticketId))
const { data: filesData } = useQuery(ticketFilesOptions(ticketId))
const allNotes = notesData?.data ?? []
const customerNotes = allNotes.filter((n) => n.visibility === 'customer')
const allPhotos = (filesData?.data ?? []).filter((f) => f.category !== 'document')
// Selection state
const [selectedNoteIds, setSelectedNoteIds] = useState<Set<string>>(new Set())
const [selectedPhotoIds, setSelectedPhotoIds] = useState<Set<string>>(new Set())
const [includeLineItems, setIncludeLineItems] = useState(true)
// Default: select all customer-visible notes and completed photos
useEffect(() => {
if (open) {
setSelectedNoteIds(new Set(customerNotes.map((n) => n.id)))
const completedPhotos = allPhotos.filter((p) => p.category === 'completed')
setSelectedPhotoIds(new Set(completedPhotos.length > 0 ? completedPhotos.map((p) => p.id) : []))
setIncludeLineItems(true)
}
}, [open, notesData, filesData])
function toggleNote(id: string) {
setSelectedNoteIds((prev) => {
const next = new Set(prev)
next.has(id) ? next.delete(id) : next.add(id)
return next
})
}
function togglePhoto(id: string) {
setSelectedPhotoIds((prev) => {
const next = new Set(prev)
next.has(id) ? next.delete(id) : next.add(id)
return next
})
}
function selectAllNotes() {
setSelectedNoteIds(new Set(customerNotes.map((n) => n.id)))
}
function selectNone() {
setSelectedNoteIds(new Set())
}
async function handleGenerate() {
setGenerating(true)
try {
const selectedNotes = allNotes.filter((n) => selectedNoteIds.has(n.id))
await generateAndUploadPdf(
{
ticket,
lineItems: includeLineItems ? lineItems : [],
notes: selectedNotes,
includeNotes: selectedNotes.length > 0,
},
ticketId,
token,
)
toast.success('PDF generated and saved to documents')
setOpen(false)
} catch (err) {
toast.error('Failed to generate PDF')
} finally {
setGenerating(false)
}
}
function formatDate(dateStr: string) {
return new Date(dateStr).toLocaleDateString(undefined, { month: 'short', day: 'numeric' })
}
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button variant="outline" size="sm">
<FileText className="mr-2 h-4 w-4" />PDF
</Button>
</DialogTrigger>
<DialogContent className="max-w-lg max-h-[80vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>Generate PDF Ticket #{ticket.ticketNumber}</DialogTitle>
</DialogHeader>
<div className="space-y-5">
{/* Line Items toggle */}
<div>
<button
type="button"
onClick={() => setIncludeLineItems(!includeLineItems)}
className={`flex items-center gap-2 w-full text-left px-3 py-2 rounded-md border text-sm transition-colors ${
includeLineItems ? 'border-primary bg-primary/5' : 'border-border'
}`}
>
<div className={`h-4 w-4 rounded border flex items-center justify-center ${includeLineItems ? 'bg-primary border-primary' : 'border-muted-foreground/40'}`}>
{includeLineItems && <Check className="h-3 w-3 text-primary-foreground" />}
</div>
<span className="font-medium">Include Line Items</span>
<span className="text-muted-foreground ml-auto">{lineItems.length} items</span>
</button>
</div>
{/* Notes selection */}
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label className="text-sm font-medium">Customer Notes</Label>
<div className="flex gap-1">
<button type="button" onClick={selectAllNotes} className="text-xs text-primary hover:underline">All</button>
<span className="text-xs text-muted-foreground">|</span>
<button type="button" onClick={selectNone} className="text-xs text-primary hover:underline">None</button>
</div>
</div>
{customerNotes.length === 0 ? (
<p className="text-xs text-muted-foreground py-2">No customer-visible notes</p>
) : (
<div className="space-y-1.5 max-h-40 overflow-y-auto">
{customerNotes.map((note) => (
<button
key={note.id}
type="button"
onClick={() => toggleNote(note.id)}
className={`flex items-start gap-2 w-full text-left px-3 py-2 rounded-md border text-sm transition-colors ${
selectedNoteIds.has(note.id) ? 'border-primary bg-primary/5' : 'border-border'
}`}
>
<div className={`h-4 w-4 rounded border flex-shrink-0 mt-0.5 flex items-center justify-center ${selectedNoteIds.has(note.id) ? 'bg-primary border-primary' : 'border-muted-foreground/40'}`}>
{selectedNoteIds.has(note.id) && <Check className="h-3 w-3 text-primary-foreground" />}
</div>
<div className="min-w-0">
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
<span>{note.authorName}</span>
<span>{formatDate(note.createdAt)}</span>
<Badge variant="secondary" className="text-[9px] px-1 py-0 gap-0.5">
<Eye className="h-2 w-2" />Customer
</Badge>
</div>
<p className="text-xs mt-0.5 line-clamp-2">{note.content}</p>
</div>
</button>
))}
</div>
)}
</div>
{/* Photos selection */}
{allPhotos.length > 0 && (
<div className="space-y-2">
<Label className="text-sm font-medium">Photos ({selectedPhotoIds.size} selected)</Label>
<div className="grid grid-cols-5 gap-2 max-h-40 overflow-y-auto">
{allPhotos.map((photo) => (
<button
key={photo.id}
type="button"
onClick={() => togglePhoto(photo.id)}
className={`relative rounded-md border-2 overflow-hidden transition-colors ${
selectedPhotoIds.has(photo.id) ? 'border-primary' : 'border-transparent'
}`}
>
<AuthThumbnail path={photo.path} />
{selectedPhotoIds.has(photo.id) && (
<div className="absolute top-0.5 right-0.5 h-4 w-4 rounded-full bg-primary flex items-center justify-center">
<Check className="h-2.5 w-2.5 text-primary-foreground" />
</div>
)}
<div className="absolute bottom-0 left-0 right-0 bg-black/50 text-[8px] text-white px-1 py-0.5 text-center">
{photo.category}
</div>
</button>
))}
</div>
</div>
)}
{/* Summary */}
<div className="text-xs text-muted-foreground border-t pt-3">
PDF will include: ticket details, {includeLineItems ? `${lineItems.length} line items` : 'no line items'}, {selectedNoteIds.size} notes{selectedPhotoIds.size > 0 ? `, ${selectedPhotoIds.size} photos` : ''}
</div>
{/* Generate button */}
<div className="flex gap-2">
<Button onClick={handleGenerate} disabled={generating} className="flex-1">
<Download className="mr-2 h-4 w-4" />
{generating ? 'Generating...' : 'Generate & Download PDF'}
</Button>
<Button variant="ghost" onClick={() => setOpen(false)}>Cancel</Button>
</div>
</div>
</DialogContent>
</Dialog>
)
}
function AuthThumbnail({ path }: { path: string }) {
const token = useAuthStore((s) => s.token)
const [src, setSrc] = useState<string | null>(null)
useEffect(() => {
let cancelled = false
let blobUrl: string | null = null
async function load() {
try {
const res = await fetch(`/v1/files/serve/${path}`, {
headers: token ? { Authorization: `Bearer ${token}` } : {},
})
if (!res.ok || cancelled) return
const blob = await res.blob()
if (!cancelled) {
blobUrl = URL.createObjectURL(blob)
setSrc(blobUrl)
}
} catch { /* ignore */ }
}
load()
return () => {
cancelled = true
if (blobUrl) URL.revokeObjectURL(blobUrl)
}
}, [path, token])
if (!src) return <div className="h-14 w-full bg-muted animate-pulse rounded" />
return <img src={src} alt="" className="h-14 w-full object-cover" />
}