266 lines
10 KiB
TypeScript
266 lines
10 KiB
TypeScript
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 } from 'lucide-react'
|
|
import { toast } from 'sonner'
|
|
import type { RepairTicket, RepairLineItem } 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)
|
|
if (next.has(id)) { next.delete(id) } else { next.add(id) }
|
|
return next
|
|
})
|
|
}
|
|
|
|
function togglePhoto(id: string) {
|
|
setSelectedPhotoIds((prev) => {
|
|
const next = new Set(prev)
|
|
if (next.has(id)) { next.delete(id) } else { 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 {
|
|
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" />
|
|
}
|