Files
lunarfront-app/packages/admin/src/components/repairs/ticket-photos.tsx
Ryan Moon 7d55fbe7ef Add repair ticket detail improvements and intake estimate builder
Status progress bar component with visual step indicator, in_transit
status for instruments being transported to shop. Ticket detail page
reworked with inline edit form, reopen for cancelled tickets, photos
grouped by repair phase (intake/in_progress/completed). Intake form
now supports building estimates with template picker and manual line
items that carry over to the ticket. Service template API client and
types added for template search in line item dialogs.
2026-03-29 09:56:28 -05:00

172 lines
5.2 KiB
TypeScript

import { useRef } 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 { ImageIcon, Plus, Trash2 } from 'lucide-react'
import { toast } from 'sonner'
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' },
] 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) => (
<div key={photo.id} className="relative group">
<img
src={`/v1/files/serve/${photo.path}`}
alt={photo.filename}
className="h-20 w-20 object-cover rounded-md border"
/>
<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="image/jpeg,image/png,image/webp"
multiple
className="hidden"
onChange={handleUpload}
/>
</div>
)
}