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:
Ryan Moon
2026-03-29 09:56:28 -05:00
parent f17bbff02c
commit 7d55fbe7ef
10 changed files with 839 additions and 127 deletions

View File

@@ -1,6 +1,6 @@
import { queryOptions } from '@tanstack/react-query'
import { api } from '@/lib/api-client'
import type { RepairTicket, RepairLineItem, RepairBatch } from '@/types/repair'
import type { RepairTicket, RepairLineItem, RepairBatch, RepairServiceTemplate } from '@/types/repair'
import type { PaginatedResponse, PaginationInput } from '@forte/shared/schemas'
// --- Repair Tickets ---
@@ -89,6 +89,29 @@ export function repairBatchTicketsOptions(batchId: string, params: PaginationInp
})
}
// --- Repair Service Templates ---
export const repairServiceTemplateKeys = {
all: ['repair-service-templates'] as const,
list: (params: PaginationInput) => [...repairServiceTemplateKeys.all, 'list', params] as const,
}
export function repairServiceTemplateListOptions(params: PaginationInput) {
return queryOptions({
queryKey: repairServiceTemplateKeys.list(params),
queryFn: () => api.get<PaginatedResponse<RepairServiceTemplate>>('/v1/repair-service-templates', params),
})
}
export const repairServiceTemplateMutations = {
create: (data: Record<string, unknown>) =>
api.post<RepairServiceTemplate>('/v1/repair-service-templates', data),
update: (id: string, data: Record<string, unknown>) =>
api.patch<RepairServiceTemplate>(`/v1/repair-service-templates/${id}`, data),
delete: (id: string) =>
api.del<RepairServiceTemplate>(`/v1/repair-service-templates/${id}`),
}
export const repairBatchMutations = {
create: (data: Record<string, unknown>) =>
api.post<RepairBatch>('/v1/repair-batches', data),

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

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

View File

@@ -1,18 +1,25 @@
import { useState } from 'react'
import { createFileRoute, useParams, useNavigate } from '@tanstack/react-router'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { repairTicketDetailOptions, repairTicketMutations, repairTicketKeys, repairLineItemListOptions, repairLineItemMutations, repairLineItemKeys } from '@/api/repairs'
import {
repairTicketDetailOptions, repairTicketMutations, repairTicketKeys,
repairLineItemListOptions, repairLineItemMutations, repairLineItemKeys,
repairServiceTemplateListOptions,
} from '@/api/repairs'
import { usePagination } from '@/hooks/use-pagination'
import { StatusProgress } from '@/components/repairs/status-progress'
import { TicketPhotos } from '@/components/repairs/ticket-photos'
import { DataTable, type Column } from '@/components/shared/data-table'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Textarea } from '@/components/ui/textarea'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Skeleton } from '@/components/ui/skeleton'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog'
import { ArrowLeft, Plus, Trash2 } from 'lucide-react'
import { ArrowLeft, Plus, Trash2, RotateCcw, Save } from 'lucide-react'
import { toast } from 'sonner'
import { useAuthStore } from '@/stores/auth.store'
import type { RepairLineItem } from '@/types/repair'
@@ -29,6 +36,7 @@ export const Route = createFileRoute('/_authenticated/repairs/$ticketId')({
})
const STATUS_LABELS: Record<string, string> = {
in_transit: 'In Transit',
intake: 'Intake',
diagnosing: 'Diagnosing',
pending_approval: 'Pending Approval',
@@ -41,7 +49,7 @@ const STATUS_LABELS: Record<string, string> = {
cancelled: 'Cancelled',
}
const STATUS_FLOW = ['intake', 'diagnosing', 'pending_approval', 'approved', 'in_progress', 'ready', 'picked_up']
const STATUS_FLOW = ['in_transit', 'intake', 'diagnosing', 'pending_approval', 'approved', 'in_progress', 'ready', 'picked_up']
function RepairTicketDetailPage() {
const { ticketId } = useParams({ from: '/_authenticated/repairs/$ticketId' })
@@ -49,11 +57,15 @@ function RepairTicketDetailPage() {
const queryClient = useQueryClient()
const hasPermission = useAuthStore((s) => s.hasPermission)
const [addItemOpen, setAddItemOpen] = useState(false)
const [editing, setEditing] = useState(false)
const { params, setPage, setSort } = usePagination()
const { data: ticket, isLoading } = useQuery(repairTicketDetailOptions(ticketId))
const { data: lineItemsData, isLoading: itemsLoading } = useQuery(repairLineItemListOptions(ticketId, params))
// Edit form state
const [editFields, setEditFields] = useState<Record<string, string>>({})
const statusMutation = useMutation({
mutationFn: (status: string) => repairTicketMutations.updateStatus(ticketId, status),
onSuccess: () => {
@@ -63,6 +75,16 @@ function RepairTicketDetailPage() {
onError: (err) => toast.error(err.message),
})
const updateMutation = useMutation({
mutationFn: (data: Record<string, unknown>) => repairTicketMutations.update(ticketId, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: repairTicketKeys.detail(ticketId) })
toast.success('Ticket updated')
setEditing(false)
},
onError: (err) => toast.error(err.message),
})
const deleteItemMutation = useMutation({
mutationFn: repairLineItemMutations.delete,
onSuccess: () => {
@@ -73,36 +95,67 @@ function RepairTicketDetailPage() {
})
if (isLoading) {
return (
<div className="space-y-4">
<Skeleton className="h-8 w-48" />
<Skeleton className="h-64 w-full" />
</div>
)
return <div className="space-y-4"><Skeleton className="h-8 w-48" /><Skeleton className="h-64 w-full" /></div>
}
if (!ticket) {
return <p className="text-muted-foreground">Repair ticket not found</p>
}
function startEdit() {
setEditFields({
customerName: ticket!.customerName,
customerPhone: ticket!.customerPhone ?? '',
instrumentDescription: ticket!.instrumentDescription ?? '',
serialNumber: ticket!.serialNumber ?? '',
conditionIn: ticket!.conditionIn ?? '',
conditionInNotes: ticket!.conditionInNotes ?? '',
problemDescription: ticket!.problemDescription,
technicianNotes: ticket!.technicianNotes ?? '',
estimatedCost: ticket!.estimatedCost ?? '',
actualCost: ticket!.actualCost ?? '',
})
setEditing(true)
}
function saveEdit() {
const data: Record<string, unknown> = {}
if (editFields.customerName !== ticket!.customerName) data.customerName = editFields.customerName
if (editFields.customerPhone !== (ticket!.customerPhone ?? '')) data.customerPhone = editFields.customerPhone || undefined
if (editFields.instrumentDescription !== (ticket!.instrumentDescription ?? '')) data.instrumentDescription = editFields.instrumentDescription || undefined
if (editFields.serialNumber !== (ticket!.serialNumber ?? '')) data.serialNumber = editFields.serialNumber || undefined
if (editFields.conditionIn !== (ticket!.conditionIn ?? '')) data.conditionIn = editFields.conditionIn || undefined
if (editFields.conditionInNotes !== (ticket!.conditionInNotes ?? '')) data.conditionInNotes = editFields.conditionInNotes || undefined
if (editFields.problemDescription !== ticket!.problemDescription) data.problemDescription = editFields.problemDescription
if (editFields.technicianNotes !== (ticket!.technicianNotes ?? '')) data.technicianNotes = editFields.technicianNotes || undefined
if (editFields.estimatedCost !== (ticket!.estimatedCost ?? '')) data.estimatedCost = editFields.estimatedCost ? parseFloat(editFields.estimatedCost) : undefined
if (editFields.actualCost !== (ticket!.actualCost ?? '')) data.actualCost = editFields.actualCost ? parseFloat(editFields.actualCost) : undefined
if (Object.keys(data).length === 0) {
setEditing(false)
return
}
updateMutation.mutate(data)
}
const currentIdx = STATUS_FLOW.indexOf(ticket.status)
const nextStatus = currentIdx >= 0 && currentIdx < STATUS_FLOW.length - 1 ? STATUS_FLOW[currentIdx + 1] : null
const isTerminal = ['picked_up', 'delivered', 'cancelled'].includes(ticket.status)
function handleStatusClick(status: string) {
if (hasPermission('repairs.edit') && ticket && status !== ticket.status) {
statusMutation.mutate(status)
}
}
const lineItemColumns: Column<RepairLineItem>[] = [
{
key: 'item_type',
header: 'Type',
sortable: true,
render: (i) => <Badge variant="outline">{i.itemType.replace('_', ' ')}</Badge>,
},
{ key: 'item_type', header: 'Type', sortable: true, render: (i) => <Badge variant="outline">{i.itemType.replace('_', ' ')}</Badge> },
{ key: 'description', header: 'Description', render: (i) => <>{i.description}</> },
{ key: 'qty', header: 'Qty', render: (i) => <>{i.qty}</> },
{ key: 'unit_price', header: 'Unit Price', render: (i) => <>${i.unitPrice}</> },
{ key: 'total_price', header: 'Total', render: (i) => <span className="font-medium">${i.totalPrice}</span> },
{
key: 'actions',
header: '',
render: (i) => hasPermission('repairs.admin') ? (
key: 'actions', header: '', render: (i) => hasPermission('repairs.admin') ? (
<Button variant="ghost" size="sm" onClick={(e) => { e.stopPropagation(); deleteItemMutation.mutate(i.id) }}>
<Trash2 className="h-4 w-4 text-destructive" />
</Button>
@@ -112,6 +165,7 @@ function RepairTicketDetailPage() {
return (
<div className="space-y-6 max-w-4xl">
{/* Header */}
<div className="flex items-center gap-3">
<Button variant="ghost" size="sm" onClick={() => navigate({ to: '/repairs', search: {} as any })}>
<ArrowLeft className="h-4 w-4" />
@@ -120,13 +174,20 @@ function RepairTicketDetailPage() {
<h1 className="text-2xl font-bold">Ticket #{ticket.ticketNumber}</h1>
<p className="text-sm text-muted-foreground">{ticket.customerName}</p>
</div>
<Badge variant={ticket.status === 'cancelled' ? 'destructive' : 'default'} className="text-sm px-3 py-1">
{STATUS_LABELS[ticket.status]}
</Badge>
</div>
{/* Status Progress Bar */}
<Card>
<CardContent className="pt-6">
<StatusProgress
currentStatus={ticket.status}
onStatusClick={hasPermission('repairs.edit') && !isTerminal ? handleStatusClick : undefined}
/>
</CardContent>
</Card>
{/* Status Actions */}
{hasPermission('repairs.edit') && ticket.status !== 'cancelled' && ticket.status !== 'picked_up' && ticket.status !== 'delivered' && (
{hasPermission('repairs.edit') && !isTerminal && (
<div className="flex gap-2">
{nextStatus && (
<Button onClick={() => statusMutation.mutate(nextStatus)} disabled={statusMutation.isPending}>
@@ -138,6 +199,11 @@ function RepairTicketDetailPage() {
Pending Parts
</Button>
)}
{ticket.status === 'pending_parts' && (
<Button variant="secondary" onClick={() => statusMutation.mutate('in_progress')} disabled={statusMutation.isPending}>
Parts Received
</Button>
)}
{hasPermission('repairs.admin') && (
<Button variant="destructive" onClick={() => statusMutation.mutate('cancelled')} disabled={statusMutation.isPending}>
Cancel
@@ -146,37 +212,125 @@ function RepairTicketDetailPage() {
</div>
)}
{/* Ticket Details */}
<div className="grid grid-cols-2 gap-6">
<Card>
<CardHeader><CardTitle className="text-lg">Customer</CardTitle></CardHeader>
<CardContent className="space-y-2 text-sm">
<div><span className="text-muted-foreground">Name:</span> {ticket.customerName}</div>
<div><span className="text-muted-foreground">Phone:</span> {ticket.customerPhone ?? '-'}</div>
<div><span className="text-muted-foreground">Account:</span> {ticket.accountId ?? 'Walk-in'}</div>
</CardContent>
</Card>
<Card>
<CardHeader><CardTitle className="text-lg">Instrument</CardTitle></CardHeader>
<CardContent className="space-y-2 text-sm">
<div><span className="text-muted-foreground">Description:</span> {ticket.instrumentDescription ?? '-'}</div>
<div><span className="text-muted-foreground">Serial:</span> {ticket.serialNumber ?? '-'}</div>
<div><span className="text-muted-foreground">Condition:</span> {ticket.conditionIn ?? '-'}</div>
</CardContent>
</Card>
</div>
{/* Reopen for cancelled tickets */}
{ticket.status === 'cancelled' && hasPermission('repairs.admin') && (
<Button variant="outline" onClick={() => statusMutation.mutate('intake')} disabled={statusMutation.isPending}>
<RotateCcw className="mr-2 h-4 w-4" />
Reopen Ticket
</Button>
)}
{/* Ticket Details — View or Edit */}
<Card>
<CardHeader><CardTitle className="text-lg">Problem & Notes</CardTitle></CardHeader>
<CardContent className="space-y-2 text-sm">
<div><span className="text-muted-foreground">Problem:</span> {ticket.problemDescription}</div>
{ticket.conditionInNotes && <div><span className="text-muted-foreground">Condition Notes:</span> {ticket.conditionInNotes}</div>}
{ticket.technicianNotes && <div><span className="text-muted-foreground">Tech Notes:</span> {ticket.technicianNotes}</div>}
<div className="flex gap-6 pt-2">
<div><span className="text-muted-foreground">Estimate:</span> {ticket.estimatedCost ? `$${ticket.estimatedCost}` : '-'}</div>
<div><span className="text-muted-foreground">Actual:</span> {ticket.actualCost ? `$${ticket.actualCost}` : '-'}</div>
<div><span className="text-muted-foreground">Promised:</span> {ticket.promisedDate ? new Date(ticket.promisedDate).toLocaleDateString() : '-'}</div>
</div>
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle className="text-lg">Details</CardTitle>
{hasPermission('repairs.edit') && !editing && (
<Button variant="outline" size="sm" onClick={startEdit}>Edit</Button>
)}
{editing && (
<div className="flex gap-2">
<Button size="sm" onClick={saveEdit} disabled={updateMutation.isPending}>
<Save className="mr-1 h-3 w-3" />{updateMutation.isPending ? 'Saving...' : 'Save'}
</Button>
<Button variant="ghost" size="sm" onClick={() => setEditing(false)}>Cancel</Button>
</div>
)}
</CardHeader>
<CardContent>
{editing ? (
<div className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2">
<Label>Customer Name</Label>
<Input value={editFields.customerName} onChange={(e) => setEditFields((p) => ({ ...p, customerName: e.target.value }))} />
</div>
<div className="space-y-2">
<Label>Phone</Label>
<Input value={editFields.customerPhone} onChange={(e) => setEditFields((p) => ({ ...p, customerPhone: e.target.value }))} />
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2">
<Label>Instrument</Label>
<Input value={editFields.instrumentDescription} onChange={(e) => setEditFields((p) => ({ ...p, instrumentDescription: e.target.value }))} />
</div>
<div className="space-y-2">
<Label>Serial Number</Label>
<Input value={editFields.serialNumber} onChange={(e) => setEditFields((p) => ({ ...p, serialNumber: e.target.value }))} />
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div className="space-y-2">
<Label>Condition</Label>
<Select value={editFields.conditionIn} onValueChange={(v) => setEditFields((p) => ({ ...p, conditionIn: v }))}>
<SelectTrigger><SelectValue placeholder="Select" /></SelectTrigger>
<SelectContent>
<SelectItem value="excellent">Excellent</SelectItem>
<SelectItem value="good">Good</SelectItem>
<SelectItem value="fair">Fair</SelectItem>
<SelectItem value="poor">Poor</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Estimated Cost</Label>
<Input type="number" step="0.01" value={editFields.estimatedCost} onChange={(e) => setEditFields((p) => ({ ...p, estimatedCost: e.target.value }))} />
</div>
<div className="space-y-2">
<Label>Actual Cost</Label>
<Input type="number" step="0.01" value={editFields.actualCost} onChange={(e) => setEditFields((p) => ({ ...p, actualCost: e.target.value }))} />
</div>
</div>
<div className="space-y-2">
<Label>Condition Notes</Label>
<Textarea value={editFields.conditionInNotes} onChange={(e) => setEditFields((p) => ({ ...p, conditionInNotes: e.target.value }))} rows={2} />
</div>
<div className="space-y-2">
<Label>Problem Description</Label>
<Textarea value={editFields.problemDescription} onChange={(e) => setEditFields((p) => ({ ...p, problemDescription: e.target.value }))} rows={3} />
</div>
<div className="space-y-2">
<Label>Technician Notes</Label>
<Textarea value={editFields.technicianNotes} onChange={(e) => setEditFields((p) => ({ ...p, technicianNotes: e.target.value }))} rows={3} />
</div>
</div>
) : (
<div className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="space-y-2 text-sm">
<div><span className="text-muted-foreground">Customer:</span> {ticket.customerName}</div>
<div><span className="text-muted-foreground">Phone:</span> {ticket.customerPhone ?? '-'}</div>
<div><span className="text-muted-foreground">Account:</span> {ticket.accountId ?? 'Walk-in'}</div>
</div>
<div className="space-y-2 text-sm">
<div><span className="text-muted-foreground">Instrument:</span> {ticket.instrumentDescription ?? '-'}</div>
<div><span className="text-muted-foreground">Serial:</span> {ticket.serialNumber ?? '-'}</div>
<div><span className="text-muted-foreground">Condition:</span> {ticket.conditionIn ?? '-'}</div>
</div>
</div>
<div className="space-y-2 text-sm">
<div><span className="text-muted-foreground">Problem:</span> {ticket.problemDescription}</div>
{ticket.conditionInNotes && <div><span className="text-muted-foreground">Condition Notes:</span> {ticket.conditionInNotes}</div>}
{ticket.technicianNotes && <div><span className="text-muted-foreground">Tech Notes:</span> {ticket.technicianNotes}</div>}
</div>
<div className="flex gap-6 text-sm">
<div><span className="text-muted-foreground">Estimate:</span> {ticket.estimatedCost ? `$${ticket.estimatedCost}` : '-'}</div>
<div><span className="text-muted-foreground">Actual:</span> {ticket.actualCost ? `$${ticket.actualCost}` : '-'}</div>
<div><span className="text-muted-foreground">Promised:</span> {ticket.promisedDate ? new Date(ticket.promisedDate).toLocaleDateString() : '-'}</div>
<div><span className="text-muted-foreground">Intake:</span> {new Date(ticket.intakeDate).toLocaleDateString()}</div>
</div>
</div>
)}
</CardContent>
</Card>
{/* Photos by Phase */}
<Card>
<CardHeader>
<CardTitle className="text-lg">Photos</CardTitle>
</CardHeader>
<CardContent>
<TicketPhotos ticketId={ticketId} currentStatus={ticket.status} />
</CardContent>
</Card>
@@ -209,10 +363,17 @@ function RepairTicketDetailPage() {
function AddLineItemDialog({ ticketId, open, onOpenChange }: { ticketId: string; open: boolean; onOpenChange: (open: boolean) => void }) {
const queryClient = useQueryClient()
const [templateSearch, setTemplateSearch] = useState('')
const [showTemplates, setShowTemplates] = useState(false)
const [itemType, setItemType] = useState('labor')
const [description, setDescription] = useState('')
const [qty, setQty] = useState('1')
const [unitPrice, setUnitPrice] = useState('0')
const [cost, setCost] = useState('')
const { data: templatesData } = useQuery(
repairServiceTemplateListOptions({ page: 1, limit: 20, q: templateSearch || undefined, order: 'asc', sort: 'name' }),
)
const mutation = useMutation({
mutationFn: (data: Record<string, unknown>) => repairLineItemMutations.create(ticketId, data),
@@ -220,33 +381,86 @@ function AddLineItemDialog({ ticketId, open, onOpenChange }: { ticketId: string;
queryClient.invalidateQueries({ queryKey: repairLineItemKeys.all(ticketId) })
toast.success('Line item added')
onOpenChange(false)
setDescription('')
setQty('1')
setUnitPrice('0')
resetForm()
},
onError: (err) => toast.error(err.message),
})
function resetForm() {
setDescription('')
setQty('1')
setUnitPrice('0')
setCost('')
setItemType('labor')
setTemplateSearch('')
setShowTemplates(false)
}
function selectTemplate(template: { name: string; instrumentType: string | null; size: string | null; itemType: string; defaultPrice: string; defaultCost: string | null }) {
const desc = [template.name, template.instrumentType, template.size].filter(Boolean).join(' — ')
setDescription(desc)
setItemType(template.itemType)
setUnitPrice(template.defaultPrice)
setCost(template.defaultCost ?? '')
setShowTemplates(false)
setTemplateSearch('')
}
function handleSubmit(e: React.FormEvent) {
e.preventDefault()
const q = parseFloat(qty) || 1
const up = parseFloat(unitPrice) || 0
mutation.mutate({
itemType,
description,
qty: q,
unitPrice: up,
totalPrice: q * up,
})
const c = cost ? parseFloat(cost) : undefined
mutation.mutate({ itemType, description, qty: q, unitPrice: up, totalPrice: q * up, cost: c })
}
const templates = templatesData?.data ?? []
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<Dialog open={open} onOpenChange={(o) => { onOpenChange(o); if (!o) resetForm() }}>
<DialogTrigger asChild>
<Button size="sm"><Plus className="mr-2 h-4 w-4" />Add Item</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader><DialogTitle>Add Line Item</DialogTitle></DialogHeader>
{/* Template picker */}
<div className="relative">
<Label>Quick Add from Template</Label>
<Input
placeholder="Search templates (e.g. rehair, valve)..."
value={templateSearch}
onChange={(e) => { setTemplateSearch(e.target.value); setShowTemplates(true) }}
onFocus={() => templateSearch && setShowTemplates(true)}
className="mt-1"
/>
{showTemplates && templateSearch.length > 0 && (
<div className="absolute z-50 mt-1 w-full rounded-md border bg-popover shadow-lg max-h-48 overflow-auto">
{templates.length === 0 ? (
<div className="p-3 text-sm text-muted-foreground">No templates found</div>
) : (
templates.map((t) => (
<button
key={t.id}
type="button"
className="w-full text-left px-3 py-2 text-sm hover:bg-accent flex justify-between"
onClick={() => selectTemplate(t)}
>
<span>{t.name}{t.instrumentType ? `${t.instrumentType}` : ''}{t.size ? ` ${t.size}` : ''}</span>
<span className="text-muted-foreground">${t.defaultPrice}</span>
</button>
))
)}
</div>
)}
</div>
<div className="relative flex items-center gap-2 py-1">
<div className="flex-1 border-t" />
<span className="text-xs text-muted-foreground">or fill manually</span>
<div className="flex-1 border-t" />
</div>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Label>Type</Label>
@@ -264,7 +478,7 @@ function AddLineItemDialog({ ticketId, open, onOpenChange }: { ticketId: string;
<Label>Description *</Label>
<Input value={description} onChange={(e) => setDescription(e.target.value)} required />
</div>
<div className="grid grid-cols-2 gap-4">
<div className="grid grid-cols-3 gap-4">
<div className="space-y-2">
<Label>Qty</Label>
<Input type="number" step="0.001" value={qty} onChange={(e) => setQty(e.target.value)} />
@@ -273,6 +487,10 @@ function AddLineItemDialog({ ticketId, open, onOpenChange }: { ticketId: string;
<Label>Unit Price</Label>
<Input type="number" step="0.01" value={unitPrice} onChange={(e) => setUnitPrice(e.target.value)} />
</div>
<div className="space-y-2">
<Label>Cost (internal)</Label>
<Input type="number" step="0.01" value={cost} onChange={(e) => setCost(e.target.value)} placeholder="Optional" />
</div>
</div>
<Button type="submit" disabled={mutation.isPending}>
{mutation.isPending ? 'Adding...' : 'Add'}

View File

@@ -4,7 +4,7 @@ import { useQuery, useMutation } from '@tanstack/react-query'
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { RepairTicketCreateSchema } from '@forte/shared/schemas'
import { repairTicketMutations } from '@/api/repairs'
import { repairTicketMutations, repairLineItemMutations, repairServiceTemplateListOptions } from '@/api/repairs'
import { accountListOptions } from '@/api/accounts'
import { useAuthStore } from '@/stores/auth.store'
import { Button } from '@/components/ui/button'
@@ -14,10 +14,20 @@ import { Textarea } from '@/components/ui/textarea'
import { Badge } from '@/components/ui/badge'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { ArrowLeft, Search, Plus, Upload, X, ImageIcon } from 'lucide-react'
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
import { ArrowLeft, Search, Plus, X, ImageIcon, Trash2 } from 'lucide-react'
import { toast } from 'sonner'
import type { Account } from '@/types/account'
interface PendingLineItem {
itemType: string
description: string
qty: number
unitPrice: number
totalPrice: number
cost?: number
}
export const Route = createFileRoute('/_authenticated/repairs/new')({
component: NewRepairPage,
})
@@ -31,6 +41,20 @@ function NewRepairPage() {
const [selectedAccount, setSelectedAccount] = useState<Account | null>(null)
const [showAccountDropdown, setShowAccountDropdown] = useState(false)
// Line items built during intake
const [lineItems, setLineItems] = useState<PendingLineItem[]>([])
// Template search for adding line items
const [templateSearch, setTemplateSearch] = useState('')
const [showTemplates, setShowTemplates] = useState(false)
// Manual line item fields
const [manualType, setManualType] = useState('labor')
const [manualDesc, setManualDesc] = useState('')
const [manualQty, setManualQty] = useState('1')
const [manualPrice, setManualPrice] = useState('0')
const [manualCost, setManualCost] = useState('')
// Photos
const [photos, setPhotos] = useState<File[]>([])
const photoInputRef = useRef<HTMLInputElement>(null)
@@ -39,11 +63,14 @@ function NewRepairPage() {
accountListOptions({ page: 1, limit: 20, q: accountSearch || undefined, order: 'asc', sort: 'name' }),
)
const { data: templatesData } = useQuery(
repairServiceTemplateListOptions({ page: 1, limit: 20, q: templateSearch || undefined, order: 'asc', sort: 'name' }),
)
const {
register,
handleSubmit,
setValue,
watch,
formState: { errors },
} = useForm({
resolver: zodResolver(RepairTicketCreateSchema),
@@ -59,11 +86,24 @@ function NewRepairPage() {
},
})
// Auto-calculate estimated cost from line items
const estimatedTotal = lineItems.reduce((sum, item) => sum + item.totalPrice, 0)
const mutation = useMutation({
mutationFn: async (data: Record<string, unknown>) => {
// Set estimated cost from line items if any
if (lineItems.length > 0) {
data.estimatedCost = estimatedTotal
}
const ticket = await repairTicketMutations.create(data)
// Upload photos after ticket creation
// Create line items
for (const item of lineItems) {
await repairLineItemMutations.create(ticket.id, item as unknown as Record<string, unknown>)
}
// Upload photos
for (const photo of photos) {
const formData = new FormData()
formData.append('file', photo)
@@ -102,6 +142,43 @@ function NewRepairPage() {
setValue('customerPhone', '')
}
function addFromTemplate(template: { name: string; instrumentType: string | null; size: string | null; itemType: string; defaultPrice: string; defaultCost: string | null }) {
const desc = [template.name, template.instrumentType, template.size].filter(Boolean).join(' — ')
setLineItems((prev) => [...prev, {
itemType: template.itemType,
description: desc,
qty: 1,
unitPrice: parseFloat(template.defaultPrice),
totalPrice: parseFloat(template.defaultPrice),
cost: template.defaultCost ? parseFloat(template.defaultCost) : undefined,
}])
setTemplateSearch('')
setShowTemplates(false)
}
function addManualItem() {
if (!manualDesc.trim()) return
const q = parseFloat(manualQty) || 1
const p = parseFloat(manualPrice) || 0
const c = manualCost ? parseFloat(manualCost) : undefined
setLineItems((prev) => [...prev, {
itemType: manualType,
description: manualDesc,
qty: q,
unitPrice: p,
totalPrice: q * p,
cost: c,
}])
setManualDesc('')
setManualQty('1')
setManualPrice('0')
setManualCost('')
}
function removeLineItem(index: number) {
setLineItems((prev) => prev.filter((_, i) => i !== index))
}
function addPhotos(e: React.ChangeEvent<HTMLInputElement>) {
const files = Array.from(e.target.files ?? [])
setPhotos((prev) => [...prev, ...files])
@@ -113,6 +190,7 @@ function NewRepairPage() {
}
const accounts = accountsData?.data ?? []
const templates = templatesData?.data ?? []
return (
<div className="space-y-6 max-w-4xl">
@@ -136,19 +214,15 @@ function NewRepairPage() {
</div>
</CardHeader>
<CardContent className="space-y-4">
{/* Account search */}
{!selectedAccount ? (
<div className="relative">
<Label>Search Account</Label>
<div className="relative mt-1">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Type to search accounts..."
placeholder="Type to search by name, email, or phone..."
value={accountSearch}
onChange={(e) => {
setAccountSearch(e.target.value)
setShowAccountDropdown(true)
}}
onChange={(e) => { setAccountSearch(e.target.value); setShowAccountDropdown(true) }}
onFocus={() => setShowAccountDropdown(true)}
className="pl-9"
/>
@@ -159,14 +233,10 @@ function NewRepairPage() {
<div className="p-3 text-sm text-muted-foreground">No accounts found</div>
) : (
accounts.map((a) => (
<button
key={a.id}
type="button"
className="w-full text-left px-3 py-2 text-sm hover:bg-accent flex items-center justify-between"
onClick={() => selectAccount(a)}
>
<button key={a.id} type="button" className="w-full text-left px-3 py-2 text-sm hover:bg-accent flex items-center justify-between" onClick={() => selectAccount(a)}>
<div>
<span className="font-medium">{a.name}</span>
{a.email && <span className="text-muted-foreground ml-2">{a.email}</span>}
{a.phone && <span className="text-muted-foreground ml-2">{a.phone}</span>}
</div>
{a.accountNumber && <span className="text-xs text-muted-foreground font-mono">#{a.accountNumber}</span>}
@@ -187,9 +257,7 @@ function NewRepairPage() {
{selectedAccount.accountNumber && <span className="font-mono">#{selectedAccount.accountNumber}</span>}
</div>
</div>
<Button type="button" variant="ghost" size="sm" onClick={clearAccount}>
<X className="h-4 w-4" />
</Button>
<Button type="button" variant="ghost" size="sm" onClick={clearAccount}><X className="h-4 w-4" /></Button>
</div>
)}
@@ -209,9 +277,7 @@ function NewRepairPage() {
{/* Instrument Section */}
<Card>
<CardHeader>
<CardTitle className="text-lg">Instrument</CardTitle>
</CardHeader>
<CardHeader><CardTitle className="text-lg">Instrument</CardTitle></CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2">
@@ -223,14 +289,11 @@ function NewRepairPage() {
<Input {...register('serialNumber')} />
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2">
<Label>Condition at Intake</Label>
<Select onValueChange={(v) => setValue('conditionIn', v as any)}>
<SelectTrigger>
<SelectValue placeholder="Select condition" />
</SelectTrigger>
<SelectTrigger><SelectValue placeholder="Select condition" /></SelectTrigger>
<SelectContent>
<SelectItem value="excellent">Excellent</SelectItem>
<SelectItem value="good">Good</SelectItem>
@@ -240,62 +303,159 @@ function NewRepairPage() {
</Select>
</div>
<div className="space-y-2">
<Label>Estimated Cost</Label>
<Input type="number" step="0.01" {...register('estimatedCost', { valueAsNumber: true })} />
<Label>Problem Description *</Label>
<Textarea {...register('problemDescription')} rows={2} placeholder="Describe the issue..." />
{errors.problemDescription && <p className="text-sm text-destructive">{errors.problemDescription.message}</p>}
</div>
</div>
</CardContent>
</Card>
{/* Estimate / Line Items Section */}
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle className="text-lg">Estimate</CardTitle>
{lineItems.length > 0 && (
<span className="text-lg font-semibold text-primary">${estimatedTotal.toFixed(2)}</span>
)}
</div>
</CardHeader>
<CardContent className="space-y-4">
{/* Template search */}
<div className="relative">
<Label>Quick Add from Template</Label>
<div className="relative mt-1">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Search templates (e.g. rehair, strings, valve)..."
value={templateSearch}
onChange={(e) => { setTemplateSearch(e.target.value); setShowTemplates(true) }}
onFocus={() => templateSearch && setShowTemplates(true)}
className="pl-9"
/>
</div>
{showTemplates && templateSearch.length > 0 && (
<div className="absolute z-50 mt-1 w-full rounded-md border bg-popover shadow-lg max-h-48 overflow-auto">
{templates.length === 0 ? (
<div className="p-3 text-sm text-muted-foreground">No templates found</div>
) : (
templates.map((t) => (
<button key={t.id} type="button" className="w-full text-left px-3 py-2 text-sm hover:bg-accent flex justify-between" onClick={() => addFromTemplate(t)}>
<span>{t.name}{t.instrumentType ? `${t.instrumentType}` : ''}{t.size ? ` ${t.size}` : ''}</span>
<span className="text-muted-foreground">${t.defaultPrice}</span>
</button>
))
)}
</div>
)}
</div>
{/* Manual line item add */}
<div className="space-y-2">
<div className="relative flex items-center gap-2">
<div className="flex-1 border-t" />
<span className="text-xs text-muted-foreground">or add manually</span>
<div className="flex-1 border-t" />
</div>
<div className="grid grid-cols-12 gap-2 items-end">
<div className="col-span-2">
<Label className="text-xs">Type</Label>
<Select value={manualType} onValueChange={setManualType}>
<SelectTrigger className="h-9"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="labor">Labor</SelectItem>
<SelectItem value="part">Part</SelectItem>
<SelectItem value="flat_rate">Flat Rate</SelectItem>
<SelectItem value="misc">Misc</SelectItem>
</SelectContent>
</Select>
</div>
<div className="col-span-3">
<Label className="text-xs">Description</Label>
<Input className="h-9" value={manualDesc} onChange={(e) => setManualDesc(e.target.value)} placeholder="e.g. Ernie Ball strings" />
</div>
<div className="col-span-2">
<Label className="text-xs">Qty</Label>
<Input className="h-9" type="number" step="0.001" value={manualQty} onChange={(e) => setManualQty(e.target.value)} />
</div>
<div className="col-span-2">
<Label className="text-xs">Price</Label>
<Input className="h-9" type="number" step="0.01" value={manualPrice} onChange={(e) => setManualPrice(e.target.value)} />
</div>
<div className="col-span-2">
<Label className="text-xs">Cost</Label>
<Input className="h-9" type="number" step="0.01" value={manualCost} onChange={(e) => setManualCost(e.target.value)} placeholder="Internal" />
</div>
<div className="col-span-1">
<Button type="button" size="sm" className="h-9 w-full" onClick={addManualItem} disabled={!manualDesc.trim()}>
<Plus className="h-4 w-4" />
</Button>
</div>
</div>
</div>
<div className="space-y-2">
<Label>Problem Description *</Label>
<Textarea {...register('problemDescription')} rows={3} placeholder="Describe the issue..." />
{errors.problemDescription && <p className="text-sm text-destructive">{errors.problemDescription.message}</p>}
</div>
{/* Line items table */}
{lineItems.length > 0 && (
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead>Type</TableHead>
<TableHead>Description</TableHead>
<TableHead className="text-right">Qty</TableHead>
<TableHead className="text-right">Price</TableHead>
<TableHead className="text-right">Total</TableHead>
<TableHead className="w-10"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{lineItems.map((item, i) => (
<TableRow key={i}>
<TableCell><Badge variant="outline">{item.itemType.replace('_', ' ')}</Badge></TableCell>
<TableCell>{item.description}</TableCell>
<TableCell className="text-right">{item.qty}</TableCell>
<TableCell className="text-right">${item.unitPrice.toFixed(2)}</TableCell>
<TableCell className="text-right font-medium">${item.totalPrice.toFixed(2)}</TableCell>
<TableCell>
<Button type="button" variant="ghost" size="sm" onClick={() => removeLineItem(i)}>
<Trash2 className="h-3 w-3 text-destructive" />
</Button>
</TableCell>
</TableRow>
))}
<TableRow>
<TableCell colSpan={4} className="text-right font-semibold">Estimated Total</TableCell>
<TableCell className="text-right font-bold text-primary">${estimatedTotal.toFixed(2)}</TableCell>
<TableCell></TableCell>
</TableRow>
</TableBody>
</Table>
</div>
)}
</CardContent>
</Card>
{/* Photos Section */}
<Card>
<CardHeader>
<CardTitle className="text-lg">Intake Photos</CardTitle>
</CardHeader>
<CardHeader><CardTitle className="text-lg">Intake Photos</CardTitle></CardHeader>
<CardContent className="space-y-4">
<p className="text-sm text-muted-foreground">Optional document instrument condition at intake</p>
<div className="flex flex-wrap gap-3">
{photos.map((photo, i) => (
<div key={i} className="relative group">
<img
src={URL.createObjectURL(photo)}
alt={`Intake photo ${i + 1}`}
className="h-24 w-24 object-cover rounded-md border"
/>
<button
type="button"
className="absolute -top-2 -right-2 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={() => removePhoto(i)}
>
<img src={URL.createObjectURL(photo)} alt={`Intake photo ${i + 1}`} className="h-24 w-24 object-cover rounded-md border" />
<button type="button" className="absolute -top-2 -right-2 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={() => removePhoto(i)}>
<X className="h-3 w-3" />
</button>
</div>
))}
<button
type="button"
className="h-24 w-24 rounded-md border-2 border-dashed border-muted-foreground/30 flex flex-col items-center justify-center text-muted-foreground hover:border-primary hover:text-primary transition-colors"
onClick={() => photoInputRef.current?.click()}
>
<button type="button" className="h-24 w-24 rounded-md border-2 border-dashed border-muted-foreground/30 flex flex-col items-center justify-center text-muted-foreground hover:border-primary hover:text-primary transition-colors" onClick={() => photoInputRef.current?.click()}>
<ImageIcon className="h-6 w-6 mb-1" />
<span className="text-xs">Add Photo</span>
</button>
</div>
<input
ref={photoInputRef}
type="file"
accept="image/jpeg,image/png,image/webp"
multiple
className="hidden"
onChange={addPhotos}
/>
<input ref={photoInputRef} type="file" accept="image/jpeg,image/png,image/webp" multiple className="hidden" onChange={addPhotos} />
</CardContent>
</Card>

View File

@@ -14,7 +14,7 @@ export interface RepairTicket {
conditionInNotes: string | null
problemDescription: string
technicianNotes: string | null
status: 'intake' | 'diagnosing' | 'pending_approval' | 'approved' | 'in_progress' | 'pending_parts' | 'ready' | 'picked_up' | 'delivered' | 'cancelled'
status: 'in_transit' | 'intake' | 'diagnosing' | 'pending_approval' | 'approved' | 'in_progress' | 'pending_parts' | 'ready' | 'picked_up' | 'delivered' | 'cancelled'
assignedTechnicianId: string | null
estimatedCost: string | null
actualCost: string | null
@@ -66,3 +66,19 @@ export interface RepairBatch {
createdAt: string
updatedAt: string
}
export interface RepairServiceTemplate {
id: string
companyId: string
name: string
instrumentType: string | null
size: string | null
description: string | null
itemType: 'labor' | 'part' | 'flat_rate' | 'misc'
defaultPrice: string
defaultCost: string | null
sortOrder: number
isActive: boolean
createdAt: string
updatedAt: string
}

View File

@@ -0,0 +1 @@
ALTER TYPE "repair_ticket_status" ADD VALUE 'in_transit' BEFORE 'intake';

View File

@@ -120,6 +120,13 @@
"when": 1774760000000,
"tag": "0016_repair_service_templates",
"breakpoints": true
},
{
"idx": 17,
"version": "7",
"when": 1774770000000,
"tag": "0017_repair_in_transit_status",
"breakpoints": true
}
]
}

View File

@@ -17,6 +17,7 @@ import { users } from './users.js'
// --- Enums ---
export const repairTicketStatusEnum = pgEnum('repair_ticket_status', [
'in_transit',
'intake',
'diagnosing',
'pending_approval',

View File

@@ -8,7 +8,7 @@ function opt<T extends z.ZodTypeAny>(schema: T) {
// --- Status / Type enums ---
export const RepairTicketStatus = z.enum([
'intake', 'diagnosing', 'pending_approval', 'approved',
'in_transit', 'intake', 'diagnosing', 'pending_approval', 'approved',
'in_progress', 'pending_parts', 'ready', 'picked_up', 'delivered', 'cancelled',
])
export type RepairTicketStatus = z.infer<typeof RepairTicketStatus>