- Add thermal/full-page receipt format toggle (per-device, localStorage) - Full-page receipt uses clean invoice layout matching repair PDF style - Settings page reorganized into tabbed sections (Store, Locations, Modules, Receipt, POS Security, Advanced) - Manager override system: configurable PIN prompt for void, refund, discount, cash in/out - Discount threshold setting: require manager approval above X% - Consumable product type: tracked for internal job costing, excluded from POS search, receipts, and customer-facing totals - Repair line item dialog: product picker dropdown for parts/consumables from inventory - Repair → POS checkout: load ready-for-pickup tickets into repair_payment transactions with proper tax categories (labor=service, parts=goods) - Transaction completion auto-updates repair ticket status to picked_up - POS Repairs dialog with Pickup and New Intake tabs, customer account lookup - Inline price adjustment on cart items: % off, $ off, or set price with live preview - Order-level discount button with same three input modes - Backend: migration 0043 (consumable enum + is_consumable flag), createFromRepairTicket service, ready-for-pickup endpoint - Fix: backend dev script uses --env-file for turbo compatibility Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
532 lines
27 KiB
TypeScript
532 lines
27 KiB
TypeScript
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,
|
|
repairServiceTemplateListOptions,
|
|
} from '@/api/repairs'
|
|
import { api } from '@/lib/api-client'
|
|
import { usePagination } from '@/hooks/use-pagination'
|
|
import { StatusProgress } from '@/components/repairs/status-progress'
|
|
import { TicketPhotos } from '@/components/repairs/ticket-photos'
|
|
import { TicketNotes } from '@/components/repairs/ticket-notes'
|
|
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, RotateCcw, Save, Search } from 'lucide-react'
|
|
import { PdfModal } from '@/components/repairs/pdf-modal'
|
|
import { toast } from 'sonner'
|
|
import { useAuthStore } from '@/stores/auth.store'
|
|
import type { RepairLineItem } from '@/types/repair'
|
|
|
|
export const Route = createFileRoute('/_authenticated/repairs/$ticketId')({
|
|
validateSearch: (search: Record<string, unknown>) => ({
|
|
page: Number(search.page) || 1,
|
|
limit: Number(search.limit) || 25,
|
|
q: (search.q as string) || undefined,
|
|
sort: (search.sort as string) || undefined,
|
|
order: (search.order as 'asc' | 'desc') || 'asc',
|
|
}),
|
|
component: RepairTicketDetailPage,
|
|
})
|
|
|
|
const STATUS_LABELS: Record<string, string> = {
|
|
new: 'New',
|
|
in_transit: 'In Transit',
|
|
intake: 'Intake',
|
|
diagnosing: 'Diagnosing',
|
|
pending_approval: 'Pending Approval',
|
|
approved: 'Approved',
|
|
in_progress: 'In Progress',
|
|
pending_parts: 'Pending Parts',
|
|
ready: 'Ready for Pickup',
|
|
picked_up: 'Picked Up',
|
|
delivered: 'Delivered',
|
|
cancelled: 'Cancelled',
|
|
}
|
|
|
|
const STATUS_FLOW = ['new', 'intake', 'diagnosing', 'pending_approval', 'approved', 'in_progress', 'ready', 'picked_up']
|
|
|
|
const TABS = [
|
|
{ key: 'details', label: 'Details' },
|
|
{ key: 'line-items', label: 'Line Items' },
|
|
{ key: 'notes', label: 'Notes' },
|
|
{ key: 'files', label: 'Photos & Docs' },
|
|
] as const
|
|
|
|
type TabKey = typeof TABS[number]['key']
|
|
|
|
function RepairTicketDetailPage() {
|
|
const { ticketId } = useParams({ from: '/_authenticated/repairs/$ticketId' })
|
|
const navigate = useNavigate()
|
|
const queryClient = useQueryClient()
|
|
const hasPermission = useAuthStore((s) => s.hasPermission)
|
|
const [activeTab, setActiveTab] = useState<TabKey>('details')
|
|
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))
|
|
|
|
const [editFields, setEditFields] = useState<Record<string, string>>({})
|
|
|
|
const statusMutation = useMutation({
|
|
mutationFn: (status: string) => repairTicketMutations.updateStatus(ticketId, status),
|
|
onSuccess: () => {
|
|
queryClient.invalidateQueries({ queryKey: repairTicketKeys.detail(ticketId) })
|
|
toast.success('Status updated')
|
|
},
|
|
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: () => {
|
|
queryClient.invalidateQueries({ queryKey: repairLineItemKeys.all(ticketId) })
|
|
toast.success('Line item removed')
|
|
},
|
|
onError: (err) => toast.error(err.message),
|
|
})
|
|
|
|
if (isLoading) {
|
|
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 ?? '',
|
|
itemDescription: ticket!.itemDescription ?? '',
|
|
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.itemDescription !== (ticket!.itemDescription ?? '')) data.itemDescription = editFields.itemDescription || 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) => (
|
|
<div className="flex items-center gap-1">
|
|
<Badge variant="outline">{i.itemType.replace('_', ' ')}</Badge>
|
|
{i.itemType === 'consumable' && <Badge variant="secondary" className="text-[10px]">Internal</Badge>}
|
|
</div>
|
|
) },
|
|
{ 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') ? (
|
|
<Button variant="ghost" size="sm" onClick={(e) => { e.stopPropagation(); deleteItemMutation.mutate(i.id) }}>
|
|
<Trash2 className="h-4 w-4 text-destructive" />
|
|
</Button>
|
|
) : null,
|
|
},
|
|
]
|
|
|
|
return (
|
|
<div className="space-y-4 max-w-5xl">
|
|
{/* Header */}
|
|
<div className="flex items-center gap-3">
|
|
<Button variant="ghost" size="sm" onClick={() => navigate({ to: '/repairs', search: { page: 1, limit: 25, q: undefined, sort: undefined, order: 'asc' as const } })}>
|
|
<ArrowLeft className="h-4 w-4" />
|
|
</Button>
|
|
<div className="flex-1">
|
|
<h1 className="text-2xl font-bold">Ticket #{ticket.ticketNumber}</h1>
|
|
<p className="text-sm text-muted-foreground">{ticket.customerName} — {ticket.itemDescription ?? 'No item description'}</p>
|
|
</div>
|
|
<PdfModal ticket={ticket} lineItems={lineItemsData?.data ?? []} ticketId={ticketId} />
|
|
</div>
|
|
|
|
{/* Status Progress Bar */}
|
|
<Card>
|
|
<CardContent className="pt-6 pb-4">
|
|
<StatusProgress
|
|
currentStatus={ticket.status}
|
|
onStatusClick={hasPermission('repairs.edit') && !isTerminal ? handleStatusClick : undefined}
|
|
/>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* Status Actions */}
|
|
<div className="flex gap-2 flex-wrap">
|
|
{hasPermission('repairs.edit') && !isTerminal && (
|
|
<>
|
|
{nextStatus && (
|
|
<Button onClick={() => statusMutation.mutate(nextStatus)} disabled={statusMutation.isPending}>
|
|
Move to {STATUS_LABELS[nextStatus]}
|
|
</Button>
|
|
)}
|
|
{ticket.status === 'new' && (
|
|
<Button variant="secondary" onClick={() => statusMutation.mutate('in_transit')} disabled={statusMutation.isPending}>
|
|
In Transit
|
|
</Button>
|
|
)}
|
|
{ticket.status === 'in_progress' && (
|
|
<Button variant="secondary" onClick={() => statusMutation.mutate('pending_parts')} disabled={statusMutation.isPending}>
|
|
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
|
|
</Button>
|
|
)}
|
|
</>
|
|
)}
|
|
{ticket.status === 'cancelled' && hasPermission('repairs.admin') && (
|
|
<Button variant="outline" onClick={() => statusMutation.mutate('new')} disabled={statusMutation.isPending}>
|
|
<RotateCcw className="mr-2 h-4 w-4" />Reopen
|
|
</Button>
|
|
)}
|
|
</div>
|
|
|
|
{/* Tabs */}
|
|
<div className="border-b">
|
|
<div className="flex gap-1">
|
|
{TABS.map((tab) => (
|
|
<button
|
|
key={tab.key}
|
|
type="button"
|
|
onClick={() => setActiveTab(tab.key)}
|
|
className={`px-4 py-2 text-sm font-medium border-b-2 transition-colors ${
|
|
activeTab === tab.key
|
|
? 'border-primary text-primary'
|
|
: 'border-transparent text-muted-foreground hover:text-foreground hover:border-border'
|
|
}`}
|
|
>
|
|
{tab.label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Tab Content */}
|
|
{activeTab === 'details' && (
|
|
<Card>
|
|
<CardHeader className="flex flex-row items-center justify-between">
|
|
<CardTitle className="text-lg">Ticket 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>Item Description</Label><Input value={editFields.itemDescription} onChange={(e) => setEditFields((p) => ({ ...p, itemDescription: 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">Item:</span> {ticket.itemDescription ?? '-'}</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 flex-wrap">
|
|
<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>
|
|
)}
|
|
|
|
{activeTab === 'line-items' && (
|
|
<Card>
|
|
<CardHeader className="flex flex-row items-center justify-between">
|
|
<CardTitle className="text-lg">Line Items</CardTitle>
|
|
{hasPermission('repairs.edit') && (
|
|
<AddLineItemDialog ticketId={ticketId} open={addItemOpen} onOpenChange={setAddItemOpen} />
|
|
)}
|
|
</CardHeader>
|
|
<CardContent>
|
|
<DataTable
|
|
columns={lineItemColumns}
|
|
data={lineItemsData?.data ?? []}
|
|
loading={itemsLoading}
|
|
page={params.page}
|
|
totalPages={lineItemsData?.pagination.totalPages ?? 1}
|
|
total={lineItemsData?.pagination.total ?? 0}
|
|
sort={params.sort}
|
|
order={params.order}
|
|
onPageChange={setPage}
|
|
onSort={setSort}
|
|
/>
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
|
|
{activeTab === 'notes' && (
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle className="text-lg">Notes</CardTitle>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<TicketNotes ticketId={ticketId} />
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
|
|
{activeTab === 'files' && (
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle className="text-lg">Photos & Documents</CardTitle>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<TicketPhotos ticketId={ticketId} currentStatus={ticket.status} />
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
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 [productId, setProductId] = useState<string | null>(null)
|
|
const [productSearch, setProductSearch] = useState('')
|
|
const [showProducts, setShowProducts] = useState(false)
|
|
|
|
const showProductPicker = itemType === 'part' || itemType === 'consumable'
|
|
|
|
const { data: templatesData } = useQuery(
|
|
repairServiceTemplateListOptions({ page: 1, limit: 20, q: templateSearch || undefined, order: 'asc', sort: 'name' }),
|
|
)
|
|
|
|
const { data: productsData } = useQuery({
|
|
queryKey: ['products', 'repair-picker', productSearch, itemType],
|
|
queryFn: () => {
|
|
const params: Record<string, string> = { q: productSearch, limit: '10', isActive: 'true' }
|
|
if (itemType === 'consumable') params.isConsumable = 'true'
|
|
else params.isDualUseRepair = 'true'
|
|
return api.get<{ data: { id: string; name: string; sku: string | null; price: string | null; brand: string | null }[] }>('/v1/products', params)
|
|
},
|
|
enabled: showProductPicker && productSearch.length >= 1,
|
|
})
|
|
|
|
const mutation = useMutation({
|
|
mutationFn: (data: Record<string, unknown>) => repairLineItemMutations.create(ticketId, data),
|
|
onSuccess: () => {
|
|
queryClient.invalidateQueries({ queryKey: repairLineItemKeys.all(ticketId) })
|
|
toast.success('Line item added')
|
|
onOpenChange(false)
|
|
resetForm()
|
|
},
|
|
onError: (err) => toast.error(err.message),
|
|
})
|
|
|
|
function resetForm() {
|
|
setDescription(''); setQty('1'); setUnitPrice('0'); setCost(''); setItemType('labor'); setTemplateSearch(''); setShowTemplates(false); setProductId(null); setProductSearch(''); setShowProducts(false)
|
|
}
|
|
|
|
function selectTemplate(template: { name: string; itemCategory: string | null; size: string | null; itemType: string; defaultPrice: string; defaultCost: string | null }) {
|
|
const desc = [template.name, template.itemCategory, template.size].filter(Boolean).join(' — ')
|
|
setDescription(desc); setItemType(template.itemType); setUnitPrice(template.defaultPrice); setCost(template.defaultCost ?? ''); setShowTemplates(false); setTemplateSearch('')
|
|
}
|
|
|
|
function selectProduct(product: { id: string; name: string; price: string | null; brand: string | null }) {
|
|
setProductId(product.id)
|
|
setDescription(product.brand ? `${product.brand} ${product.name}` : product.name)
|
|
setUnitPrice(product.price ?? '0')
|
|
setProductSearch('')
|
|
setShowProducts(false)
|
|
}
|
|
|
|
function handleSubmit(e: React.FormEvent) {
|
|
e.preventDefault()
|
|
const q = parseFloat(qty) || 1
|
|
const up = parseFloat(unitPrice) || 0
|
|
const c = cost ? parseFloat(cost) : undefined
|
|
mutation.mutate({ itemType, description, qty: q, unitPrice: up, totalPrice: q * up, cost: c, productId: productId ?? undefined })
|
|
}
|
|
|
|
const templates = templatesData?.data ?? []
|
|
const products = productsData?.data ?? []
|
|
|
|
return (
|
|
<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>
|
|
<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..." 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={() => selectTemplate(t)}>
|
|
<span>{t.name}{t.itemCategory ? ` — ${t.itemCategory}` : ''}{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>
|
|
<Select value={itemType} onValueChange={(v) => { setItemType(v); setProductId(null); setProductSearch(''); setShowProducts(false) }}><SelectTrigger><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><SelectItem value="consumable">Consumable (internal)</SelectItem></SelectContent></Select>
|
|
</div>
|
|
{showProductPicker && (
|
|
<div className="relative space-y-2">
|
|
<Label>Search Inventory</Label>
|
|
<div className="relative">
|
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
|
<Input
|
|
placeholder={itemType === 'consumable' ? 'Search consumables...' : 'Search parts...'}
|
|
value={productSearch}
|
|
onChange={(e) => { setProductSearch(e.target.value); setShowProducts(true) }}
|
|
onFocus={() => productSearch && setShowProducts(true)}
|
|
className="pl-9"
|
|
/>
|
|
</div>
|
|
{productId && (
|
|
<div className="text-xs text-muted-foreground flex items-center gap-1">
|
|
Linked to product <button type="button" className="underline text-destructive" onClick={() => setProductId(null)}>clear</button>
|
|
</div>
|
|
)}
|
|
{showProducts && productSearch.length >= 1 && (
|
|
<div className="absolute z-50 mt-1 w-full rounded-md border bg-popover shadow-lg max-h-48 overflow-auto">
|
|
{products.length === 0 ? <div className="p-3 text-sm text-muted-foreground">No products found</div> : products.map((p) => (
|
|
<button key={p.id} type="button" className="w-full text-left px-3 py-2 text-sm hover:bg-accent flex justify-between" onClick={() => selectProduct(p)}>
|
|
<span>{p.brand ? `${p.brand} ` : ''}{p.name}{p.sku ? ` (${p.sku})` : ''}</span>
|
|
{p.price && <span className="text-muted-foreground">${p.price}</span>}
|
|
</button>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
<div className="space-y-2"><Label>Description *</Label><Input value={description} onChange={(e) => setDescription(e.target.value)} required /></div>
|
|
<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)} /></div>
|
|
<div className="space-y-2"><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'}</Button>
|
|
</form>
|
|
</DialogContent>
|
|
</Dialog>
|
|
)
|
|
}
|