229 lines
7.5 KiB
TypeScript
229 lines
7.5 KiB
TypeScript
import { useRef, useState, useEffect } from 'react'
|
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
|
import { queryOptions } from '@tanstack/react-query'
|
|
import { api } from '@/lib/api-client'
|
|
import { useAuthStore } from '@/stores/auth.store'
|
|
import { Button } from '@/components/ui/button'
|
|
import { FileText, Plus, Trash2 } from 'lucide-react'
|
|
import { toast } from 'sonner'
|
|
|
|
function AuthImage({ path, alt, className, onClick }: { path: string; alt: string; className?: string; onClick?: () => void }) {
|
|
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={`${className} bg-muted animate-pulse`} />
|
|
return <img src={src} alt={alt} className={className} onClick={onClick} />
|
|
}
|
|
|
|
async function openSignedFile(fileId: string) {
|
|
try {
|
|
const res = await api.get<{ url: string }>(`/v1/files/signed-url/${fileId}`)
|
|
window.open(res.url, '_blank')
|
|
} catch {
|
|
toast.error('Failed to open file')
|
|
}
|
|
}
|
|
|
|
interface FileRecord {
|
|
id: string
|
|
path: string
|
|
category: string
|
|
filename: 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,
|
|
})
|
|
}
|
|
|
|
const PHOTO_CATEGORIES = [
|
|
{ key: 'intake', label: 'Intake Photos', description: 'Condition at intake' },
|
|
{ key: 'in_progress', label: 'Work in Progress', description: 'During repair' },
|
|
{ key: 'completed', label: 'Completed', description: 'Final result' },
|
|
{ key: 'document', label: 'Documents', description: 'Signed approvals, quotes, receipts' },
|
|
] as const
|
|
|
|
interface TicketPhotosProps {
|
|
ticketId: string
|
|
currentStatus: string
|
|
}
|
|
|
|
export function TicketPhotos({ ticketId, currentStatus }: TicketPhotosProps) {
|
|
const queryClient = useQueryClient()
|
|
const token = useAuthStore((s) => s.token)
|
|
const { data: filesData } = useQuery(ticketFilesOptions(ticketId))
|
|
|
|
const files = filesData?.data ?? []
|
|
|
|
// Determine which category to highlight based on current status
|
|
const activeCategory =
|
|
['in_progress', 'pending_parts'].includes(currentStatus) ? 'in_progress'
|
|
: ['ready', 'picked_up', 'delivered'].includes(currentStatus) ? 'completed'
|
|
: 'intake'
|
|
|
|
return (
|
|
<div className="space-y-4">
|
|
{PHOTO_CATEGORIES.map((cat) => (
|
|
<PhotoSection
|
|
key={cat.key}
|
|
ticketId={ticketId}
|
|
category={cat.key}
|
|
label={cat.label}
|
|
description={cat.description}
|
|
photos={files.filter((f) => f.category === cat.key)}
|
|
isActive={activeCategory === cat.key}
|
|
token={token}
|
|
onUpdate={() => queryClient.invalidateQueries({ queryKey: ['files', 'repair_ticket', ticketId] })}
|
|
/>
|
|
))}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function PhotoSection({
|
|
ticketId,
|
|
category,
|
|
label,
|
|
description,
|
|
photos,
|
|
isActive,
|
|
token,
|
|
onUpdate,
|
|
}: {
|
|
ticketId: string
|
|
category: string
|
|
label: string
|
|
description: string
|
|
photos: FileRecord[]
|
|
isActive: boolean
|
|
token: string | null
|
|
onUpdate: () => void
|
|
}) {
|
|
const fileInputRef = useRef<HTMLInputElement>(null)
|
|
|
|
const deleteMutation = useMutation({
|
|
mutationFn: (fileId: string) => api.del(`/v1/files/${fileId}`),
|
|
onSuccess: () => {
|
|
onUpdate()
|
|
toast.success('Photo removed')
|
|
},
|
|
onError: (err) => toast.error(err.message),
|
|
})
|
|
|
|
async function handleUpload(e: React.ChangeEvent<HTMLInputElement>) {
|
|
const files = Array.from(e.target.files ?? [])
|
|
for (const file of files) {
|
|
const formData = new FormData()
|
|
formData.append('file', file)
|
|
formData.append('entityType', 'repair_ticket')
|
|
formData.append('entityId', ticketId)
|
|
formData.append('category', category)
|
|
|
|
try {
|
|
const res = await fetch('/v1/files', {
|
|
method: 'POST',
|
|
headers: { Authorization: `Bearer ${token}` },
|
|
body: formData,
|
|
})
|
|
if (!res.ok) {
|
|
const err = await res.json()
|
|
throw new Error(err.error?.message ?? 'Upload failed')
|
|
}
|
|
} catch (err) {
|
|
toast.error(err instanceof Error ? err.message : 'Upload failed')
|
|
}
|
|
}
|
|
onUpdate()
|
|
toast.success(`${files.length} photo(s) uploaded`)
|
|
e.target.value = ''
|
|
}
|
|
|
|
return (
|
|
<div className={`rounded-md border p-3 ${isActive ? 'border-primary/50 bg-primary/5' : ''}`}>
|
|
<div className="flex items-center justify-between mb-2">
|
|
<div>
|
|
<h4 className="text-sm font-semibold">{label}</h4>
|
|
<p className="text-xs text-muted-foreground">{description}</p>
|
|
</div>
|
|
<Button variant="ghost" size="sm" onClick={() => fileInputRef.current?.click()}>
|
|
<Plus className="mr-1 h-3 w-3" />Add
|
|
</Button>
|
|
</div>
|
|
|
|
{photos.length === 0 ? (
|
|
<p className="text-xs text-muted-foreground py-2">No photos</p>
|
|
) : (
|
|
<div className="flex flex-wrap gap-2">
|
|
{photos.map((photo) => {
|
|
const isPdf = photo.filename?.endsWith('.pdf') || photo.path?.endsWith('.pdf')
|
|
return (
|
|
<div key={photo.id} className="relative group">
|
|
{isPdf ? (
|
|
<button
|
|
type="button"
|
|
onClick={() => openSignedFile(photo.id)}
|
|
className="h-20 w-20 rounded-md border flex flex-col items-center justify-center bg-muted hover:bg-muted/80 transition-colors cursor-pointer"
|
|
>
|
|
<FileText className="h-6 w-6 text-muted-foreground" />
|
|
<span className="text-[9px] text-muted-foreground mt-1 px-1 truncate max-w-full">{photo.filename}</span>
|
|
</button>
|
|
) : (
|
|
<button type="button" onClick={() => openSignedFile(photo.id)}>
|
|
<AuthImage
|
|
path={photo.path}
|
|
alt={photo.filename}
|
|
className="h-20 w-20 object-cover rounded-md border cursor-pointer hover:opacity-80 transition-opacity"
|
|
/>
|
|
</button>
|
|
)}
|
|
<button
|
|
type="button"
|
|
className="absolute -top-1.5 -right-1.5 h-5 w-5 rounded-full bg-destructive text-destructive-foreground flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity"
|
|
onClick={() => deleteMutation.mutate(photo.id)}
|
|
>
|
|
<Trash2 className="h-3 w-3" />
|
|
</button>
|
|
</div>
|
|
)
|
|
})}
|
|
</div>
|
|
)}
|
|
|
|
<input
|
|
ref={fileInputRef}
|
|
type="file"
|
|
accept={category === 'document' ? 'image/jpeg,image/png,image/webp,application/pdf' : 'image/jpeg,image/png,image/webp'}
|
|
multiple
|
|
className="hidden"
|
|
onChange={handleUpload}
|
|
/>
|
|
</div>
|
|
)
|
|
}
|