Files
lunarfront-app/packages/admin/src/routes/_authenticated/repairs/$ticketId.tsx
ryan 95cf017b4b feat: repair-POS integration, receipt formats, manager overrides, price adjustments
- 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>
2026-04-05 16:05:19 +00:00

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