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:
@@ -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'}
|
||||
|
||||
Reference in New Issue
Block a user