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 { queryOptions } from '@tanstack/react-query'
import { api } from '@/lib/api-client' 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' import type { PaginatedResponse, PaginationInput } from '@forte/shared/schemas'
// --- Repair Tickets --- // --- 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 = { export const repairBatchMutations = {
create: (data: Record<string, unknown>) => create: (data: Record<string, unknown>) =>
api.post<RepairBatch>('/v1/repair-batches', data), 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 { useState } from 'react'
import { createFileRoute, useParams, useNavigate } from '@tanstack/react-router' import { createFileRoute, useParams, useNavigate } from '@tanstack/react-router'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' 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 { 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 { DataTable, type Column } from '@/components/shared/data-table'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label' import { Label } from '@/components/ui/label'
import { Textarea } from '@/components/ui/textarea'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Skeleton } from '@/components/ui/skeleton' import { Skeleton } from '@/components/ui/skeleton'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog' 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 { toast } from 'sonner'
import { useAuthStore } from '@/stores/auth.store' import { useAuthStore } from '@/stores/auth.store'
import type { RepairLineItem } from '@/types/repair' import type { RepairLineItem } from '@/types/repair'
@@ -29,6 +36,7 @@ export const Route = createFileRoute('/_authenticated/repairs/$ticketId')({
}) })
const STATUS_LABELS: Record<string, string> = { const STATUS_LABELS: Record<string, string> = {
in_transit: 'In Transit',
intake: 'Intake', intake: 'Intake',
diagnosing: 'Diagnosing', diagnosing: 'Diagnosing',
pending_approval: 'Pending Approval', pending_approval: 'Pending Approval',
@@ -41,7 +49,7 @@ const STATUS_LABELS: Record<string, string> = {
cancelled: 'Cancelled', 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() { function RepairTicketDetailPage() {
const { ticketId } = useParams({ from: '/_authenticated/repairs/$ticketId' }) const { ticketId } = useParams({ from: '/_authenticated/repairs/$ticketId' })
@@ -49,11 +57,15 @@ function RepairTicketDetailPage() {
const queryClient = useQueryClient() const queryClient = useQueryClient()
const hasPermission = useAuthStore((s) => s.hasPermission) const hasPermission = useAuthStore((s) => s.hasPermission)
const [addItemOpen, setAddItemOpen] = useState(false) const [addItemOpen, setAddItemOpen] = useState(false)
const [editing, setEditing] = useState(false)
const { params, setPage, setSort } = usePagination() const { params, setPage, setSort } = usePagination()
const { data: ticket, isLoading } = useQuery(repairTicketDetailOptions(ticketId)) const { data: ticket, isLoading } = useQuery(repairTicketDetailOptions(ticketId))
const { data: lineItemsData, isLoading: itemsLoading } = useQuery(repairLineItemListOptions(ticketId, params)) const { data: lineItemsData, isLoading: itemsLoading } = useQuery(repairLineItemListOptions(ticketId, params))
// Edit form state
const [editFields, setEditFields] = useState<Record<string, string>>({})
const statusMutation = useMutation({ const statusMutation = useMutation({
mutationFn: (status: string) => repairTicketMutations.updateStatus(ticketId, status), mutationFn: (status: string) => repairTicketMutations.updateStatus(ticketId, status),
onSuccess: () => { onSuccess: () => {
@@ -63,6 +75,16 @@ function RepairTicketDetailPage() {
onError: (err) => toast.error(err.message), 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({ const deleteItemMutation = useMutation({
mutationFn: repairLineItemMutations.delete, mutationFn: repairLineItemMutations.delete,
onSuccess: () => { onSuccess: () => {
@@ -73,36 +95,67 @@ function RepairTicketDetailPage() {
}) })
if (isLoading) { if (isLoading) {
return ( return <div className="space-y-4"><Skeleton className="h-8 w-48" /><Skeleton className="h-64 w-full" /></div>
<div className="space-y-4">
<Skeleton className="h-8 w-48" />
<Skeleton className="h-64 w-full" />
</div>
)
} }
if (!ticket) { if (!ticket) {
return <p className="text-muted-foreground">Repair ticket not found</p> 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 currentIdx = STATUS_FLOW.indexOf(ticket.status)
const nextStatus = currentIdx >= 0 && currentIdx < STATUS_FLOW.length - 1 ? STATUS_FLOW[currentIdx + 1] : null 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>[] = [ 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: 'description', header: 'Description', render: (i) => <>{i.description}</> },
{ key: 'qty', header: 'Qty', render: (i) => <>{i.qty}</> }, { key: 'qty', header: 'Qty', render: (i) => <>{i.qty}</> },
{ key: 'unit_price', header: 'Unit Price', render: (i) => <>${i.unitPrice}</> }, { 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: 'total_price', header: 'Total', render: (i) => <span className="font-medium">${i.totalPrice}</span> },
{ {
key: 'actions', key: 'actions', header: '', render: (i) => hasPermission('repairs.admin') ? (
header: '',
render: (i) => hasPermission('repairs.admin') ? (
<Button variant="ghost" size="sm" onClick={(e) => { e.stopPropagation(); deleteItemMutation.mutate(i.id) }}> <Button variant="ghost" size="sm" onClick={(e) => { e.stopPropagation(); deleteItemMutation.mutate(i.id) }}>
<Trash2 className="h-4 w-4 text-destructive" /> <Trash2 className="h-4 w-4 text-destructive" />
</Button> </Button>
@@ -112,6 +165,7 @@ function RepairTicketDetailPage() {
return ( return (
<div className="space-y-6 max-w-4xl"> <div className="space-y-6 max-w-4xl">
{/* Header */}
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<Button variant="ghost" size="sm" onClick={() => navigate({ to: '/repairs', search: {} as any })}> <Button variant="ghost" size="sm" onClick={() => navigate({ to: '/repairs', search: {} as any })}>
<ArrowLeft className="h-4 w-4" /> <ArrowLeft className="h-4 w-4" />
@@ -120,13 +174,20 @@ function RepairTicketDetailPage() {
<h1 className="text-2xl font-bold">Ticket #{ticket.ticketNumber}</h1> <h1 className="text-2xl font-bold">Ticket #{ticket.ticketNumber}</h1>
<p className="text-sm text-muted-foreground">{ticket.customerName}</p> <p className="text-sm text-muted-foreground">{ticket.customerName}</p>
</div> </div>
<Badge variant={ticket.status === 'cancelled' ? 'destructive' : 'default'} className="text-sm px-3 py-1">
{STATUS_LABELS[ticket.status]}
</Badge>
</div> </div>
{/* Status Progress Bar */}
<Card>
<CardContent className="pt-6">
<StatusProgress
currentStatus={ticket.status}
onStatusClick={hasPermission('repairs.edit') && !isTerminal ? handleStatusClick : undefined}
/>
</CardContent>
</Card>
{/* Status Actions */} {/* Status Actions */}
{hasPermission('repairs.edit') && ticket.status !== 'cancelled' && ticket.status !== 'picked_up' && ticket.status !== 'delivered' && ( {hasPermission('repairs.edit') && !isTerminal && (
<div className="flex gap-2"> <div className="flex gap-2">
{nextStatus && ( {nextStatus && (
<Button onClick={() => statusMutation.mutate(nextStatus)} disabled={statusMutation.isPending}> <Button onClick={() => statusMutation.mutate(nextStatus)} disabled={statusMutation.isPending}>
@@ -138,6 +199,11 @@ function RepairTicketDetailPage() {
Pending Parts Pending Parts
</Button> </Button>
)} )}
{ticket.status === 'pending_parts' && (
<Button variant="secondary" onClick={() => statusMutation.mutate('in_progress')} disabled={statusMutation.isPending}>
Parts Received
</Button>
)}
{hasPermission('repairs.admin') && ( {hasPermission('repairs.admin') && (
<Button variant="destructive" onClick={() => statusMutation.mutate('cancelled')} disabled={statusMutation.isPending}> <Button variant="destructive" onClick={() => statusMutation.mutate('cancelled')} disabled={statusMutation.isPending}>
Cancel Cancel
@@ -146,37 +212,125 @@ function RepairTicketDetailPage() {
</div> </div>
)} )}
{/* Ticket Details */} {/* Reopen for cancelled tickets */}
<div className="grid grid-cols-2 gap-6"> {ticket.status === 'cancelled' && hasPermission('repairs.admin') && (
<Card> <Button variant="outline" onClick={() => statusMutation.mutate('intake')} disabled={statusMutation.isPending}>
<CardHeader><CardTitle className="text-lg">Customer</CardTitle></CardHeader> <RotateCcw className="mr-2 h-4 w-4" />
<CardContent className="space-y-2 text-sm"> Reopen Ticket
<div><span className="text-muted-foreground">Name:</span> {ticket.customerName}</div> </Button>
<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>
{/* Ticket Details — View or Edit */}
<Card> <Card>
<CardHeader><CardTitle className="text-lg">Problem & Notes</CardTitle></CardHeader> <CardHeader className="flex flex-row items-center justify-between">
<CardContent className="space-y-2 text-sm"> <CardTitle className="text-lg">Details</CardTitle>
<div><span className="text-muted-foreground">Problem:</span> {ticket.problemDescription}</div> {hasPermission('repairs.edit') && !editing && (
{ticket.conditionInNotes && <div><span className="text-muted-foreground">Condition Notes:</span> {ticket.conditionInNotes}</div>} <Button variant="outline" size="sm" onClick={startEdit}>Edit</Button>
{ticket.technicianNotes && <div><span className="text-muted-foreground">Tech Notes:</span> {ticket.technicianNotes}</div>} )}
<div className="flex gap-6 pt-2"> {editing && (
<div><span className="text-muted-foreground">Estimate:</span> {ticket.estimatedCost ? `$${ticket.estimatedCost}` : '-'}</div> <div className="flex gap-2">
<div><span className="text-muted-foreground">Actual:</span> {ticket.actualCost ? `$${ticket.actualCost}` : '-'}</div> <Button size="sm" onClick={saveEdit} disabled={updateMutation.isPending}>
<div><span className="text-muted-foreground">Promised:</span> {ticket.promisedDate ? new Date(ticket.promisedDate).toLocaleDateString() : '-'}</div> <Save className="mr-1 h-3 w-3" />{updateMutation.isPending ? 'Saving...' : 'Save'}
</div> </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> </CardContent>
</Card> </Card>
@@ -209,10 +363,17 @@ function RepairTicketDetailPage() {
function AddLineItemDialog({ ticketId, open, onOpenChange }: { ticketId: string; open: boolean; onOpenChange: (open: boolean) => void }) { function AddLineItemDialog({ ticketId, open, onOpenChange }: { ticketId: string; open: boolean; onOpenChange: (open: boolean) => void }) {
const queryClient = useQueryClient() const queryClient = useQueryClient()
const [templateSearch, setTemplateSearch] = useState('')
const [showTemplates, setShowTemplates] = useState(false)
const [itemType, setItemType] = useState('labor') const [itemType, setItemType] = useState('labor')
const [description, setDescription] = useState('') const [description, setDescription] = useState('')
const [qty, setQty] = useState('1') const [qty, setQty] = useState('1')
const [unitPrice, setUnitPrice] = useState('0') 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({ const mutation = useMutation({
mutationFn: (data: Record<string, unknown>) => repairLineItemMutations.create(ticketId, data), 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) }) queryClient.invalidateQueries({ queryKey: repairLineItemKeys.all(ticketId) })
toast.success('Line item added') toast.success('Line item added')
onOpenChange(false) onOpenChange(false)
setDescription('') resetForm()
setQty('1')
setUnitPrice('0')
}, },
onError: (err) => toast.error(err.message), 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) { function handleSubmit(e: React.FormEvent) {
e.preventDefault() e.preventDefault()
const q = parseFloat(qty) || 1 const q = parseFloat(qty) || 1
const up = parseFloat(unitPrice) || 0 const up = parseFloat(unitPrice) || 0
mutation.mutate({ const c = cost ? parseFloat(cost) : undefined
itemType, mutation.mutate({ itemType, description, qty: q, unitPrice: up, totalPrice: q * up, cost: c })
description,
qty: q,
unitPrice: up,
totalPrice: q * up,
})
} }
const templates = templatesData?.data ?? []
return ( return (
<Dialog open={open} onOpenChange={onOpenChange}> <Dialog open={open} onOpenChange={(o) => { onOpenChange(o); if (!o) resetForm() }}>
<DialogTrigger asChild> <DialogTrigger asChild>
<Button size="sm"><Plus className="mr-2 h-4 w-4" />Add Item</Button> <Button size="sm"><Plus className="mr-2 h-4 w-4" />Add Item</Button>
</DialogTrigger> </DialogTrigger>
<DialogContent> <DialogContent>
<DialogHeader><DialogTitle>Add Line Item</DialogTitle></DialogHeader> <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"> <form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2"> <div className="space-y-2">
<Label>Type</Label> <Label>Type</Label>
@@ -264,7 +478,7 @@ function AddLineItemDialog({ ticketId, open, onOpenChange }: { ticketId: string;
<Label>Description *</Label> <Label>Description *</Label>
<Input value={description} onChange={(e) => setDescription(e.target.value)} required /> <Input value={description} onChange={(e) => setDescription(e.target.value)} required />
</div> </div>
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-3 gap-4">
<div className="space-y-2"> <div className="space-y-2">
<Label>Qty</Label> <Label>Qty</Label>
<Input type="number" step="0.001" value={qty} onChange={(e) => setQty(e.target.value)} /> <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> <Label>Unit Price</Label>
<Input type="number" step="0.01" value={unitPrice} onChange={(e) => setUnitPrice(e.target.value)} /> <Input type="number" step="0.01" value={unitPrice} onChange={(e) => setUnitPrice(e.target.value)} />
</div> </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> </div>
<Button type="submit" disabled={mutation.isPending}> <Button type="submit" disabled={mutation.isPending}>
{mutation.isPending ? 'Adding...' : 'Add'} {mutation.isPending ? 'Adding...' : 'Add'}

View File

@@ -4,7 +4,7 @@ import { useQuery, useMutation } from '@tanstack/react-query'
import { useForm } from 'react-hook-form' import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod' import { zodResolver } from '@hookform/resolvers/zod'
import { RepairTicketCreateSchema } from '@forte/shared/schemas' import { RepairTicketCreateSchema } from '@forte/shared/schemas'
import { repairTicketMutations } from '@/api/repairs' import { repairTicketMutations, repairLineItemMutations, repairServiceTemplateListOptions } from '@/api/repairs'
import { accountListOptions } from '@/api/accounts' import { accountListOptions } from '@/api/accounts'
import { useAuthStore } from '@/stores/auth.store' import { useAuthStore } from '@/stores/auth.store'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
@@ -14,10 +14,20 @@ import { Textarea } from '@/components/ui/textarea'
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' 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 { toast } from 'sonner'
import type { Account } from '@/types/account' 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')({ export const Route = createFileRoute('/_authenticated/repairs/new')({
component: NewRepairPage, component: NewRepairPage,
}) })
@@ -31,6 +41,20 @@ function NewRepairPage() {
const [selectedAccount, setSelectedAccount] = useState<Account | null>(null) const [selectedAccount, setSelectedAccount] = useState<Account | null>(null)
const [showAccountDropdown, setShowAccountDropdown] = useState(false) 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 // Photos
const [photos, setPhotos] = useState<File[]>([]) const [photos, setPhotos] = useState<File[]>([])
const photoInputRef = useRef<HTMLInputElement>(null) const photoInputRef = useRef<HTMLInputElement>(null)
@@ -39,11 +63,14 @@ function NewRepairPage() {
accountListOptions({ page: 1, limit: 20, q: accountSearch || undefined, order: 'asc', sort: 'name' }), 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 { const {
register, register,
handleSubmit, handleSubmit,
setValue, setValue,
watch,
formState: { errors }, formState: { errors },
} = useForm({ } = useForm({
resolver: zodResolver(RepairTicketCreateSchema), 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({ const mutation = useMutation({
mutationFn: async (data: Record<string, unknown>) => { 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) 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) { for (const photo of photos) {
const formData = new FormData() const formData = new FormData()
formData.append('file', photo) formData.append('file', photo)
@@ -102,6 +142,43 @@ function NewRepairPage() {
setValue('customerPhone', '') 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>) { function addPhotos(e: React.ChangeEvent<HTMLInputElement>) {
const files = Array.from(e.target.files ?? []) const files = Array.from(e.target.files ?? [])
setPhotos((prev) => [...prev, ...files]) setPhotos((prev) => [...prev, ...files])
@@ -113,6 +190,7 @@ function NewRepairPage() {
} }
const accounts = accountsData?.data ?? [] const accounts = accountsData?.data ?? []
const templates = templatesData?.data ?? []
return ( return (
<div className="space-y-6 max-w-4xl"> <div className="space-y-6 max-w-4xl">
@@ -136,19 +214,15 @@ function NewRepairPage() {
</div> </div>
</CardHeader> </CardHeader>
<CardContent className="space-y-4"> <CardContent className="space-y-4">
{/* Account search */}
{!selectedAccount ? ( {!selectedAccount ? (
<div className="relative"> <div className="relative">
<Label>Search Account</Label> <Label>Search Account</Label>
<div className="relative mt-1"> <div className="relative mt-1">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" /> <Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input <Input
placeholder="Type to search accounts..." placeholder="Type to search by name, email, or phone..."
value={accountSearch} value={accountSearch}
onChange={(e) => { onChange={(e) => { setAccountSearch(e.target.value); setShowAccountDropdown(true) }}
setAccountSearch(e.target.value)
setShowAccountDropdown(true)
}}
onFocus={() => setShowAccountDropdown(true)} onFocus={() => setShowAccountDropdown(true)}
className="pl-9" className="pl-9"
/> />
@@ -159,14 +233,10 @@ function NewRepairPage() {
<div className="p-3 text-sm text-muted-foreground">No accounts found</div> <div className="p-3 text-sm text-muted-foreground">No accounts found</div>
) : ( ) : (
accounts.map((a) => ( accounts.map((a) => (
<button <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)}>
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> <div>
<span className="font-medium">{a.name}</span> <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>} {a.phone && <span className="text-muted-foreground ml-2">{a.phone}</span>}
</div> </div>
{a.accountNumber && <span className="text-xs text-muted-foreground font-mono">#{a.accountNumber}</span>} {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>} {selectedAccount.accountNumber && <span className="font-mono">#{selectedAccount.accountNumber}</span>}
</div> </div>
</div> </div>
<Button type="button" variant="ghost" size="sm" onClick={clearAccount}> <Button type="button" variant="ghost" size="sm" onClick={clearAccount}><X className="h-4 w-4" /></Button>
<X className="h-4 w-4" />
</Button>
</div> </div>
)} )}
@@ -209,9 +277,7 @@ function NewRepairPage() {
{/* Instrument Section */} {/* Instrument Section */}
<Card> <Card>
<CardHeader> <CardHeader><CardTitle className="text-lg">Instrument</CardTitle></CardHeader>
<CardTitle className="text-lg">Instrument</CardTitle>
</CardHeader>
<CardContent className="space-y-4"> <CardContent className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4"> <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2"> <div className="space-y-2">
@@ -223,14 +289,11 @@ function NewRepairPage() {
<Input {...register('serialNumber')} /> <Input {...register('serialNumber')} />
</div> </div>
</div> </div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4"> <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2"> <div className="space-y-2">
<Label>Condition at Intake</Label> <Label>Condition at Intake</Label>
<Select onValueChange={(v) => setValue('conditionIn', v as any)}> <Select onValueChange={(v) => setValue('conditionIn', v as any)}>
<SelectTrigger> <SelectTrigger><SelectValue placeholder="Select condition" /></SelectTrigger>
<SelectValue placeholder="Select condition" />
</SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="excellent">Excellent</SelectItem> <SelectItem value="excellent">Excellent</SelectItem>
<SelectItem value="good">Good</SelectItem> <SelectItem value="good">Good</SelectItem>
@@ -240,62 +303,159 @@ function NewRepairPage() {
</Select> </Select>
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label>Estimated Cost</Label> <Label>Problem Description *</Label>
<Input type="number" step="0.01" {...register('estimatedCost', { valueAsNumber: true })} /> <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> </div>
<div className="space-y-2"> {/* Line items table */}
<Label>Problem Description *</Label> {lineItems.length > 0 && (
<Textarea {...register('problemDescription')} rows={3} placeholder="Describe the issue..." /> <div className="rounded-md border">
{errors.problemDescription && <p className="text-sm text-destructive">{errors.problemDescription.message}</p>} <Table>
</div> <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> </CardContent>
</Card> </Card>
{/* Photos Section */} {/* Photos Section */}
<Card> <Card>
<CardHeader> <CardHeader><CardTitle className="text-lg">Intake Photos</CardTitle></CardHeader>
<CardTitle className="text-lg">Intake Photos</CardTitle>
</CardHeader>
<CardContent className="space-y-4"> <CardContent className="space-y-4">
<p className="text-sm text-muted-foreground">Optional document instrument condition at intake</p> <p className="text-sm text-muted-foreground">Optional document instrument condition at intake</p>
<div className="flex flex-wrap gap-3"> <div className="flex flex-wrap gap-3">
{photos.map((photo, i) => ( {photos.map((photo, i) => (
<div key={i} className="relative group"> <div key={i} className="relative group">
<img <img src={URL.createObjectURL(photo)} alt={`Intake photo ${i + 1}`} className="h-24 w-24 object-cover rounded-md border" />
src={URL.createObjectURL(photo)} <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)}>
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" /> <X className="h-3 w-3" />
</button> </button>
</div> </div>
))} ))}
<button <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()}>
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" /> <ImageIcon className="h-6 w-6 mb-1" />
<span className="text-xs">Add Photo</span> <span className="text-xs">Add Photo</span>
</button> </button>
</div> </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> </CardContent>
</Card> </Card>

View File

@@ -14,7 +14,7 @@ export interface RepairTicket {
conditionInNotes: string | null conditionInNotes: string | null
problemDescription: string problemDescription: string
technicianNotes: string | null 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 assignedTechnicianId: string | null
estimatedCost: string | null estimatedCost: string | null
actualCost: string | null actualCost: string | null
@@ -66,3 +66,19 @@ export interface RepairBatch {
createdAt: string createdAt: string
updatedAt: 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, "when": 1774760000000,
"tag": "0016_repair_service_templates", "tag": "0016_repair_service_templates",
"breakpoints": true "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 --- // --- Enums ---
export const repairTicketStatusEnum = pgEnum('repair_ticket_status', [ export const repairTicketStatusEnum = pgEnum('repair_ticket_status', [
'in_transit',
'intake', 'intake',
'diagnosing', 'diagnosing',
'pending_approval', 'pending_approval',

View File

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