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.
This commit is contained in:
115
packages/admin/src/components/repairs/status-progress.tsx
Normal file
115
packages/admin/src/components/repairs/status-progress.tsx
Normal file
@@ -0,0 +1,115 @@
|
||||
import { Check, Truck, ClipboardList, Search, Clock, ThumbsUp, Wrench, Package, HandMetal, Ban } from 'lucide-react'
|
||||
|
||||
const STEPS = [
|
||||
{ key: 'in_transit', label: 'In Transit', icon: Truck },
|
||||
{ key: 'intake', label: 'Intake', icon: ClipboardList },
|
||||
{ key: 'diagnosing', label: 'Diagnosing', icon: Search },
|
||||
{ key: 'pending_approval', label: 'Pending Approval', icon: Clock },
|
||||
{ key: 'approved', label: 'Approved', icon: ThumbsUp },
|
||||
{ key: 'in_progress', label: 'In Progress', icon: Wrench },
|
||||
{ key: 'ready', label: 'Ready', icon: Package },
|
||||
{ key: 'picked_up', label: 'Picked Up', icon: HandMetal },
|
||||
] as const
|
||||
|
||||
const BRANCH_STATUSES: Record<string, { label: string; parentStep: string }> = {
|
||||
pending_parts: { label: 'Pending Parts', parentStep: 'in_progress' },
|
||||
delivered: { label: 'Delivered', parentStep: 'picked_up' },
|
||||
}
|
||||
|
||||
interface StatusProgressProps {
|
||||
currentStatus: string
|
||||
onStatusClick?: (status: string) => void
|
||||
}
|
||||
|
||||
export function StatusProgress({ currentStatus, onStatusClick }: StatusProgressProps) {
|
||||
const isCancelled = currentStatus === 'cancelled'
|
||||
const isBranch = currentStatus in BRANCH_STATUSES
|
||||
|
||||
// Find the effective step index for branch statuses
|
||||
const effectiveStatus = isBranch ? BRANCH_STATUSES[currentStatus].parentStep : currentStatus
|
||||
const currentIdx = STEPS.findIndex((s) => s.key === effectiveStatus)
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center w-full">
|
||||
{STEPS.map((step, idx) => {
|
||||
const isCompleted = !isCancelled && currentIdx > idx
|
||||
const isCurrent = !isCancelled && currentIdx === idx
|
||||
const isFuture = isCancelled || currentIdx < idx
|
||||
|
||||
return (
|
||||
<div key={step.key} className="flex items-center flex-1 last:flex-none">
|
||||
{/* Step circle */}
|
||||
<button
|
||||
type="button"
|
||||
disabled={!onStatusClick || isCancelled}
|
||||
onClick={() => onStatusClick?.(step.key)}
|
||||
className={`
|
||||
relative flex flex-col items-center gap-1 group
|
||||
${onStatusClick && !isCancelled ? 'cursor-pointer' : 'cursor-default'}
|
||||
`}
|
||||
>
|
||||
<div
|
||||
className={`
|
||||
flex items-center justify-center h-9 w-9 rounded-full border-2 transition-colors
|
||||
${isCompleted ? 'bg-primary border-primary text-primary-foreground' : ''}
|
||||
${isCurrent ? 'border-primary bg-primary/10 text-primary ring-2 ring-primary/30' : ''}
|
||||
${isFuture ? 'border-muted-foreground/30 text-muted-foreground/40' : ''}
|
||||
${isCancelled ? 'border-destructive/30 text-destructive/40' : ''}
|
||||
${onStatusClick && !isCancelled ? 'group-hover:border-primary group-hover:text-primary' : ''}
|
||||
`}
|
||||
>
|
||||
{isCompleted ? (
|
||||
<Check className="h-4 w-4" />
|
||||
) : (
|
||||
<step.icon className="h-4 w-4" />
|
||||
)}
|
||||
</div>
|
||||
<span
|
||||
className={`
|
||||
text-[10px] font-medium text-center leading-tight max-w-[70px]
|
||||
${isCompleted ? 'text-primary' : ''}
|
||||
${isCurrent ? 'text-primary font-semibold' : ''}
|
||||
${isFuture ? 'text-muted-foreground/50' : ''}
|
||||
${isCancelled ? 'text-destructive/50' : ''}
|
||||
`}
|
||||
>
|
||||
{step.label}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{/* Connector line */}
|
||||
{idx < STEPS.length - 1 && (
|
||||
<div
|
||||
className={`
|
||||
flex-1 h-0.5 mx-1 mt-[-18px]
|
||||
${!isCancelled && currentIdx > idx ? 'bg-primary' : 'bg-muted-foreground/20'}
|
||||
${isCancelled ? 'bg-destructive/20' : ''}
|
||||
`}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Branch status indicator */}
|
||||
{isBranch && (
|
||||
<div className="flex items-center gap-2 pl-4">
|
||||
<div className="h-3 w-3 rounded-full bg-amber-500" />
|
||||
<span className="text-sm font-medium text-amber-600">
|
||||
{BRANCH_STATUSES[currentStatus].label}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Cancelled overlay */}
|
||||
{isCancelled && (
|
||||
<div className="flex items-center gap-2 pl-4">
|
||||
<Ban className="h-4 w-4 text-destructive" />
|
||||
<span className="text-sm font-medium text-destructive">Cancelled</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
171
packages/admin/src/components/repairs/ticket-photos.tsx
Normal file
171
packages/admin/src/components/repairs/ticket-photos.tsx
Normal file
@@ -0,0 +1,171 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user