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:
265
packages/admin/src/components/repairs/pdf-modal.tsx
Normal file
265
packages/admin/src/components/repairs/pdf-modal.tsx
Normal 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" />
|
||||
}
|
||||
Reference in New Issue
Block a user