Files
lunarfront-app/packages/admin/src/components/repairs/ticket-photos.tsx

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>
)
}