Build inventory frontend and stock management features

- Full inventory UI: product list with search/filter, product detail with
  tabs (details, units, suppliers, stock receipts, price history)
- Product filters: category, type (serialized/rental/repair), low stock,
  active/inactive — all server-side with URL-synced state
- Product-supplier junction: link products to multiple suppliers with
  preferred flag, joined supplier details in UI
- Stock receipts: record incoming stock with supplier, qty, cost per unit,
  invoice number; auto-increments qty_on_hand for non-serialized products
- Price history tab on product detail page
- categories/all endpoint to avoid pagination limit on dropdown fetches
- categoryId filter on product list endpoint
- Repair parts and additional inventory items in music store seed data
- isDualUseRepair corrected: instruments set to false, strings/parts true
- Product-supplier links and stock receipts in seed data
- Price history seed data simulating cost increases over past year
- 37 API tests covering categories, suppliers, products, units,
  product-suppliers, and stock receipts
- alert-dialog and checkbox UI components
- sync-and-deploy.sh script for rsync + remote deploy
This commit is contained in:
Ryan Moon
2026-03-30 20:12:07 -05:00
parent ec09e319ed
commit 5f5ba9e4a2
24 changed files with 4023 additions and 187 deletions

View File

@@ -0,0 +1,786 @@
import { useState } from 'react'
import { createFileRoute, useParams, useNavigate } from '@tanstack/react-router'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import {
productDetailOptions, productMutations, productKeys,
unitListOptions, unitMutations, unitKeys,
productSupplierListOptions, productSupplierMutations, productSupplierKeys,
priceHistoryOptions, supplierListOptions,
stockReceiptListOptions, stockReceiptMutations, stockReceiptKeys,
} from '@/api/inventory'
import { ProductForm } from '@/components/inventory/product-form'
import { InventoryUnitForm } from '@/components/inventory/inventory-unit-form'
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 { Skeleton } from '@/components/ui/skeleton'
import { Checkbox } from '@/components/ui/checkbox'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog'
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger } from '@/components/ui/alert-dialog'
import { ArrowLeft, Plus, Pencil, Star, Trash2 } from 'lucide-react'
import { toast } from 'sonner'
import { cn } from '@/lib/utils'
import { useAuthStore } from '@/stores/auth.store'
import type { InventoryUnit, ProductSupplier, StockReceipt, UnitCondition, UnitStatus } from '@/types/inventory'
const CONDITION_CLASSES: Record<UnitCondition, string> = {
new: 'bg-blue-100 text-blue-800 border border-blue-300',
excellent: 'bg-green-100 text-green-800 border border-green-300',
good: 'bg-emerald-100 text-emerald-800 border border-emerald-300',
fair: 'bg-yellow-100 text-yellow-800 border border-yellow-300',
poor: 'bg-red-100 text-red-800 border border-red-300',
}
const STATUS_CLASSES: Record<UnitStatus, string> = {
available: 'bg-green-100 text-green-800 border border-green-300',
sold: 'bg-gray-100 text-gray-600 border border-gray-300',
rented: 'bg-purple-100 text-purple-800 border border-purple-300',
on_trial: 'bg-cyan-100 text-cyan-800 border border-cyan-300',
in_repair: 'bg-orange-100 text-orange-800 border border-orange-300',
layaway: 'bg-indigo-100 text-indigo-800 border border-indigo-300',
lost: 'bg-red-100 text-red-800 border border-red-300',
retired: 'bg-gray-100 text-gray-400 border border-gray-200',
}
const STATUS_LABELS: Record<UnitStatus, string> = {
available: 'Available', sold: 'Sold', rented: 'Rented', on_trial: 'On Trial',
in_repair: 'In Repair', layaway: 'Layaway', lost: 'Lost', retired: 'Retired',
}
export const Route = createFileRoute('/_authenticated/inventory/$productId')({
validateSearch: (search: Record<string, unknown>) => ({
tab: (search.tab as string) || 'details',
}),
component: ProductDetailPage,
})
function ProductDetailPage() {
const { productId } = useParams({ from: '/_authenticated/inventory/$productId' })
const search = Route.useSearch()
const navigate = useNavigate()
const queryClient = useQueryClient()
const hasPermission = useAuthStore((s) => s.hasPermission)
const tab = search.tab ?? 'details'
const [addUnitOpen, setAddUnitOpen] = useState(false)
const [editUnit, setEditUnit] = useState<InventoryUnit | null>(null)
const [qtyEdit, setQtyEdit] = useState<string>('')
const [addSupplierOpen, setAddSupplierOpen] = useState(false)
const [editSupplier, setEditSupplier] = useState<ProductSupplier | null>(null)
const [addReceiptOpen, setAddReceiptOpen] = useState(false)
const { data: product, isLoading } = useQuery(productDetailOptions(productId))
const { data: unitsData } = useQuery({ ...unitListOptions(productId), enabled: tab === 'units' })
const units = unitsData?.data ?? []
const { data: suppliersData } = useQuery({ ...productSupplierListOptions(productId), enabled: tab === 'suppliers' })
const linkedSuppliers = suppliersData?.data ?? []
const { data: priceHistoryData } = useQuery({ ...priceHistoryOptions(productId), enabled: tab === 'price-history' })
const priceHistoryRows = priceHistoryData?.data ?? []
const { data: stockReceiptsData } = useQuery({ ...stockReceiptListOptions(productId), enabled: tab === 'stock-receipts' })
const stockReceiptRows = stockReceiptsData?.data ?? []
const updateMutation = useMutation({
mutationFn: (data: Record<string, unknown>) => productMutations.update(productId, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: productKeys.detail(productId) })
queryClient.invalidateQueries({ queryKey: productKeys.all })
toast.success('Product updated')
},
onError: (err) => toast.error(err instanceof Error ? err.message : 'Update failed'),
})
const createUnitMutation = useMutation({
mutationFn: (data: Record<string, unknown>) => unitMutations.create(productId, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: unitKeys.byProduct(productId) })
toast.success('Unit added')
setAddUnitOpen(false)
},
onError: (err) => toast.error(err.message),
})
const updateUnitMutation = useMutation({
mutationFn: ({ id, data }: { id: string; data: Record<string, unknown> }) =>
unitMutations.update(id, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: unitKeys.byProduct(productId) })
toast.success('Unit updated')
setEditUnit(null)
},
onError: (err) => toast.error(err.message),
})
const addSupplierMutation = useMutation({
mutationFn: (data: Record<string, unknown>) => productSupplierMutations.create(productId, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: productSupplierKeys.byProduct(productId) })
toast.success('Supplier linked')
setAddSupplierOpen(false)
},
onError: (err) => toast.error(err instanceof Error ? err.message : 'Failed'),
})
const updateSupplierMutation = useMutation({
mutationFn: ({ id, data }: { id: string; data: Record<string, unknown> }) =>
productSupplierMutations.update(productId, id, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: productSupplierKeys.byProduct(productId) })
toast.success('Supplier updated')
setEditSupplier(null)
},
onError: (err) => toast.error(err instanceof Error ? err.message : 'Failed'),
})
const removeSupplierMutation = useMutation({
mutationFn: (id: string) => productSupplierMutations.delete(productId, id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: productSupplierKeys.byProduct(productId) })
toast.success('Supplier removed')
setEditSupplier(null)
},
onError: (err) => toast.error(err instanceof Error ? err.message : 'Failed'),
})
const createReceiptMutation = useMutation({
mutationFn: (data: Record<string, unknown>) => stockReceiptMutations.create(productId, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: stockReceiptKeys.byProduct(productId) })
queryClient.invalidateQueries({ queryKey: productKeys.detail(productId) })
toast.success('Stock receipt recorded')
setAddReceiptOpen(false)
},
onError: (err) => toast.error(err instanceof Error ? err.message : 'Failed'),
})
function setTab(t: string) {
navigate({ to: '/inventory/$productId', params: { productId }, search: { tab: t } as any })
}
function handleQtySave() {
const qty = parseInt(qtyEdit, 10)
if (isNaN(qty) || qty < 0) return
updateMutation.mutate({ qtyOnHand: qty })
}
if (isLoading) {
return (
<div className="space-y-4">
<Skeleton className="h-8 w-48" />
<Skeleton className="h-10 w-full" />
<Skeleton className="h-64 w-full max-w-lg" />
</div>
)
}
if (!product) return <p className="text-muted-foreground">Product not found</p>
const tabs = [
{ key: 'details', label: 'Details' },
{ key: 'units', label: product.isSerialized ? 'Units' : 'Quantity' },
{ key: 'suppliers', label: 'Suppliers' },
{ key: 'stock-receipts', label: 'Stock Receipts' },
{ key: 'price-history', label: 'Price History' },
]
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center gap-3">
<Button variant="ghost" size="sm" onClick={() => navigate({ to: '/inventory', search: {} as any })}>
<ArrowLeft className="h-4 w-4" />
</Button>
<div className="min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<h1 className="text-2xl font-bold">{product.name}</h1>
{product.sku && <span className="font-mono text-sm text-muted-foreground bg-muted px-2 py-0.5 rounded">{product.sku}</span>}
{product.isActive ? <Badge>Active</Badge> : <Badge variant="secondary">Inactive</Badge>}
</div>
<p className="text-sm text-muted-foreground mt-0.5">
{[product.brand, product.model].filter(Boolean).join(' · ') || 'No brand/model'}
</p>
</div>
</div>
{/* Tabs */}
<nav className="flex gap-1 border-b">
{tabs.map((t) => (
<button
key={t.key}
onClick={() => setTab(t.key)}
className={cn(
'px-4 py-2 text-sm font-medium border-b-2 -mb-px transition-colors',
tab === t.key
? 'border-primary text-foreground'
: 'border-transparent text-muted-foreground hover:text-foreground hover:border-border',
)}
>
{t.label}
</button>
))}
</nav>
{/* Details tab */}
{tab === 'details' && (
<div className="max-w-lg">
<ProductForm
defaultValues={product}
onSubmit={updateMutation.mutate}
loading={updateMutation.isPending}
/>
</div>
)}
{/* Units / Quantity tab */}
{tab === 'units' && (
<div className="space-y-4">
{product.isSerialized ? (
<>
<div className="flex items-center justify-between">
<p className="text-sm text-muted-foreground">{units.length} unit(s) on file</p>
{hasPermission('inventory.edit') && (
<Dialog open={addUnitOpen} onOpenChange={setAddUnitOpen}>
<DialogTrigger asChild>
<Button size="sm"><Plus className="mr-2 h-4 w-4" />Add Unit</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader><DialogTitle>Add Unit</DialogTitle></DialogHeader>
<InventoryUnitForm
onSubmit={createUnitMutation.mutate}
loading={createUnitMutation.isPending}
/>
</DialogContent>
</Dialog>
)}
</div>
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead>Serial #</TableHead>
<TableHead>Condition</TableHead>
<TableHead>Status</TableHead>
<TableHead>Purchased</TableHead>
<TableHead>Cost</TableHead>
<TableHead>Notes</TableHead>
{hasPermission('inventory.edit') && <TableHead className="w-10" />}
</TableRow>
</TableHeader>
<TableBody>
{units.length === 0 ? (
<TableRow>
<TableCell colSpan={7} className="text-center py-8 text-muted-foreground">
No units yet add the first unit above
</TableCell>
</TableRow>
) : (
units.map((u) => (
<TableRow key={u.id}>
<TableCell className="font-mono text-sm">{u.serialNumber ?? '—'}</TableCell>
<TableCell>
<span className={cn('inline-flex items-center px-2 py-0.5 rounded text-xs font-medium', CONDITION_CLASSES[u.condition])}>
{u.condition}
</span>
</TableCell>
<TableCell>
<span className={cn('inline-flex items-center px-2 py-0.5 rounded text-xs font-medium', STATUS_CLASSES[u.status])}>
{STATUS_LABELS[u.status]}
</span>
</TableCell>
<TableCell className="text-sm text-muted-foreground">
{u.purchaseDate ? new Date(u.purchaseDate + 'T00:00:00').toLocaleDateString() : '—'}
</TableCell>
<TableCell className="text-sm">
{u.purchaseCost ? `$${Number(u.purchaseCost).toFixed(2)}` : '—'}
</TableCell>
<TableCell className="text-sm text-muted-foreground max-w-[200px] truncate">
{u.notes ?? '—'}
</TableCell>
{hasPermission('inventory.edit') && (
<TableCell>
<Button variant="ghost" size="sm" className="h-7 w-7 p-0" onClick={() => setEditUnit(u)}>
<Pencil className="h-3 w-3" />
</Button>
</TableCell>
)}
</TableRow>
))
)}
</TableBody>
</Table>
</div>
</>
) : (
<div className="max-w-xs space-y-4">
<p className="text-sm text-muted-foreground">
This product is not serialized track quantity as a single number.
</p>
<div className="space-y-2">
<Label htmlFor="qty-edit">Quantity On Hand</Label>
<div className="flex gap-2">
<Input
id="qty-edit"
type="number"
min={0}
defaultValue={product.qtyOnHand}
onChange={(e) => setQtyEdit(e.target.value)}
className="w-32"
/>
<Button onClick={handleQtySave} disabled={updateMutation.isPending}>
{updateMutation.isPending ? 'Saving...' : 'Save'}
</Button>
</div>
</div>
{product.qtyReorderPoint !== null && (
<p className="text-xs text-muted-foreground">Reorder point: {product.qtyReorderPoint}</p>
)}
</div>
)}
</div>
)}
{/* Suppliers tab */}
{tab === 'suppliers' && (
<SuppliersTab
productId={productId}
linkedSuppliers={linkedSuppliers}
addOpen={addSupplierOpen}
setAddOpen={setAddSupplierOpen}
editTarget={editSupplier}
setEditTarget={setEditSupplier}
addMutation={addSupplierMutation}
updateMutation={updateSupplierMutation}
removeMutation={removeSupplierMutation}
canEdit={hasPermission('inventory.edit')}
/>
)}
{/* Stock Receipts tab */}
{tab === 'stock-receipts' && (
<div className="space-y-4">
<div className="flex items-center justify-between">
<p className="text-sm text-muted-foreground">{stockReceiptRows.length} receipt(s) on record</p>
{hasPermission('inventory.edit') && (
<Dialog open={addReceiptOpen} onOpenChange={setAddReceiptOpen}>
<DialogTrigger asChild>
<Button size="sm"><Plus className="h-4 w-4 mr-1" />Receive Stock</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader><DialogTitle>Receive Stock</DialogTitle></DialogHeader>
<StockReceiptForm
linkedSuppliers={linkedSuppliers}
loading={createReceiptMutation.isPending}
onSubmit={(data) => createReceiptMutation.mutate(data)}
/>
</DialogContent>
</Dialog>
)}
</div>
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead>Date</TableHead>
<TableHead>Supplier</TableHead>
<TableHead>Invoice #</TableHead>
<TableHead className="text-right">Qty</TableHead>
<TableHead className="text-right">Cost / Unit</TableHead>
<TableHead className="text-right">Total Cost</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{stockReceiptRows.length === 0 ? (
<TableRow>
<TableCell colSpan={6} className="text-center py-8 text-muted-foreground">
No stock receipts yet
</TableCell>
</TableRow>
) : (
stockReceiptRows.map((r) => (
<TableRow key={r.id}>
<TableCell className="text-sm text-muted-foreground">
{new Date(r.receivedDate + 'T12:00:00').toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' })}
</TableCell>
<TableCell className="text-sm">{r.supplierName ?? <span className="text-muted-foreground"></span>}</TableCell>
<TableCell className="text-sm font-mono">{r.invoiceNumber ?? <span className="text-muted-foreground"></span>}</TableCell>
<TableCell className="text-sm text-right">{r.qty}</TableCell>
<TableCell className="text-sm text-right">${Number(r.costPerUnit).toFixed(2)}</TableCell>
<TableCell className="text-sm text-right font-medium">${Number(r.totalCost).toFixed(2)}</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
{stockReceiptRows.length > 0 && (
<p className="text-xs text-muted-foreground text-right">
Total invested: ${stockReceiptRows.reduce((sum, r) => sum + Number(r.totalCost), 0).toFixed(2)}
</p>
)}
</div>
)}
{/* Price History tab */}
{tab === 'price-history' && (
<div className="space-y-4">
<p className="text-sm text-muted-foreground">{priceHistoryRows.length} price change(s) on record</p>
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead>Date</TableHead>
<TableHead>Previous Price</TableHead>
<TableHead>New Price</TableHead>
<TableHead>Previous Min</TableHead>
<TableHead>New Min</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{priceHistoryRows.length === 0 ? (
<TableRow>
<TableCell colSpan={5} className="text-center py-8 text-muted-foreground">
No price changes recorded yet
</TableCell>
</TableRow>
) : (
priceHistoryRows.map((h) => (
<TableRow key={h.id}>
<TableCell className="text-sm text-muted-foreground">
{new Date(h.createdAt).toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' })}
</TableCell>
<TableCell className="text-sm">
{h.previousPrice ? `$${Number(h.previousPrice).toFixed(2)}` : '—'}
</TableCell>
<TableCell className="text-sm font-medium">
${Number(h.newPrice).toFixed(2)}
</TableCell>
<TableCell className="text-sm">
{h.previousMinPrice ? `$${Number(h.previousMinPrice).toFixed(2)}` : '—'}
</TableCell>
<TableCell className="text-sm">
{h.newMinPrice ? `$${Number(h.newMinPrice).toFixed(2)}` : '—'}
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
</div>
)}
{/* Edit unit dialog */}
<Dialog open={!!editUnit} onOpenChange={(o) => !o && setEditUnit(null)}>
<DialogContent>
<DialogHeader><DialogTitle>Edit Unit</DialogTitle></DialogHeader>
{editUnit && (
<InventoryUnitForm
defaultValues={editUnit}
onSubmit={(data) => updateUnitMutation.mutate({ id: editUnit.id, data })}
loading={updateUnitMutation.isPending}
/>
)}
</DialogContent>
</Dialog>
</div>
)
}
// ── Suppliers tab ─────────────────────────────────────────────────────────────
function SuppliersTab({
productId,
linkedSuppliers,
addOpen, setAddOpen,
editTarget, setEditTarget,
addMutation, updateMutation, removeMutation,
canEdit,
}: {
productId: string
linkedSuppliers: ProductSupplier[]
addOpen: boolean
setAddOpen: (v: boolean) => void
editTarget: ProductSupplier | null
setEditTarget: (v: ProductSupplier | null) => void
addMutation: any
updateMutation: any
removeMutation: any
canEdit: boolean
}) {
const { data: allSuppliersData } = useQuery(supplierListOptions({ page: 1, limit: 500, order: 'asc', sort: 'name' } as any))
const allSuppliers = allSuppliersData?.data ?? []
const linkedIds = new Set(linkedSuppliers.map((s) => s.supplierId))
const availableSuppliers = allSuppliers.filter((s) => !linkedIds.has(s.id))
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<p className="text-sm text-muted-foreground">{linkedSuppliers.length} supplier(s) linked</p>
{canEdit && availableSuppliers.length > 0 && (
<Dialog open={addOpen} onOpenChange={setAddOpen}>
<DialogTrigger asChild>
<Button size="sm"><Plus className="mr-2 h-4 w-4" />Link Supplier</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader><DialogTitle>Link Supplier</DialogTitle></DialogHeader>
<SupplierLinkForm
suppliers={availableSuppliers}
onSubmit={addMutation.mutate}
loading={addMutation.isPending}
hasExisting={linkedSuppliers.length > 0}
/>
</DialogContent>
</Dialog>
)}
</div>
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead>Supplier</TableHead>
<TableHead>Their SKU</TableHead>
<TableHead>Contact</TableHead>
<TableHead>Terms</TableHead>
<TableHead>Preferred</TableHead>
{canEdit && <TableHead className="w-10" />}
</TableRow>
</TableHeader>
<TableBody>
{linkedSuppliers.length === 0 ? (
<TableRow>
<TableCell colSpan={6} className="text-center py-8 text-muted-foreground">
No suppliers linked add the first supplier above
</TableCell>
</TableRow>
) : (
linkedSuppliers.map((s) => (
<TableRow key={s.id}>
<TableCell>
<div>
<p className="font-medium text-sm">{s.supplierName}</p>
{s.supplierEmail && (
<a href={`mailto:${s.supplierEmail}`} className="text-xs text-muted-foreground hover:underline" onClick={(e) => e.stopPropagation()}>
{s.supplierEmail}
</a>
)}
</div>
</TableCell>
<TableCell className="font-mono text-sm">{s.supplierSku ?? <span className="text-muted-foreground"></span>}</TableCell>
<TableCell className="text-sm text-muted-foreground">{s.supplierContactName ?? '—'}</TableCell>
<TableCell className="text-sm text-muted-foreground">{s.supplierPaymentTerms ?? '—'}</TableCell>
<TableCell>
{s.isPreferred && (
<span className="inline-flex items-center gap-1 text-xs font-medium text-amber-700 bg-amber-50 border border-amber-200 px-2 py-0.5 rounded">
<Star className="h-3 w-3 fill-amber-500 text-amber-500" />Preferred
</span>
)}
</TableCell>
{canEdit && (
<TableCell>
<Button variant="ghost" size="sm" className="h-7 w-7 p-0" onClick={() => setEditTarget(s)}>
<Pencil className="h-3 w-3" />
</Button>
</TableCell>
)}
</TableRow>
))
)}
</TableBody>
</Table>
</div>
{/* Edit/remove supplier link dialog */}
<Dialog open={!!editTarget} onOpenChange={(o) => !o && setEditTarget(null)}>
<DialogContent>
<DialogHeader><DialogTitle>Edit Supplier Link</DialogTitle></DialogHeader>
{editTarget && (
<div className="space-y-4">
<p className="text-sm font-medium">{editTarget.supplierName}</p>
<SupplierLinkEditForm
defaultValues={editTarget}
onSubmit={(data) => updateMutation.mutate({ id: editTarget.id, data })}
loading={updateMutation.isPending}
/>
<div className="border-t pt-4">
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="destructive" size="sm" disabled={removeMutation.isPending}>
<Trash2 className="h-4 w-4 mr-2" />Remove Supplier
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Remove supplier?</AlertDialogTitle>
<AlertDialogDescription>
This will unlink {editTarget.supplierName} from this product. You can re-add it later.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={() => removeMutation.mutate(editTarget.id)}>Remove</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
</div>
)}
</DialogContent>
</Dialog>
</div>
)
}
function SupplierLinkForm({
suppliers, onSubmit, loading, hasExisting,
}: {
suppliers: { id: string; name: string }[]
onSubmit: (data: Record<string, unknown>) => void
loading: boolean
hasExisting: boolean
}) {
const [supplierId, setSupplierId] = useState('')
const [supplierSku, setSupplierSku] = useState('')
const [isPreferred, setIsPreferred] = useState(!hasExisting)
function handleSubmit(e: React.FormEvent) {
e.preventDefault()
if (!supplierId) return
onSubmit({ supplierId, supplierSku: supplierSku || undefined, isPreferred })
}
return (
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Label>Supplier</Label>
<Select value={supplierId} onValueChange={setSupplierId}>
<SelectTrigger><SelectValue placeholder="Select supplier…" /></SelectTrigger>
<SelectContent>
{suppliers.map((s) => <SelectItem key={s.id} value={s.id}>{s.name}</SelectItem>)}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="supplier-sku">Their SKU / Part #</Label>
<Input id="supplier-sku" value={supplierSku} onChange={(e) => setSupplierSku(e.target.value)} placeholder="e.g. VLN-44-EA" />
</div>
<div className="flex items-center gap-2">
<Checkbox id="is-preferred" checked={isPreferred} onCheckedChange={(v: boolean | 'indeterminate') => setIsPreferred(!!v)} />
<Label htmlFor="is-preferred">Preferred supplier for this product</Label>
</div>
<Button type="submit" disabled={!supplierId || loading}>{loading ? 'Linking…' : 'Link Supplier'}</Button>
</form>
)
}
function StockReceiptForm({
linkedSuppliers, onSubmit, loading,
}: {
linkedSuppliers: ProductSupplier[]
onSubmit: (data: Record<string, unknown>) => void
loading: boolean
}) {
const today = new Date().toISOString().split('T')[0]
const [supplierId, setSupplierId] = useState('')
const [qty, setQty] = useState('1')
const [costPerUnit, setCostPerUnit] = useState('')
const [receivedDate, setReceivedDate] = useState(today)
const [invoiceNumber, setInvoiceNumber] = useState('')
const [notes, setNotes] = useState('')
const total = (parseFloat(qty) || 0) * (parseFloat(costPerUnit) || 0)
function handleSubmit(e: React.FormEvent) {
e.preventDefault()
onSubmit({
supplierId: supplierId || undefined,
qty: parseInt(qty, 10),
costPerUnit: parseFloat(costPerUnit),
receivedDate,
invoiceNumber: invoiceNumber || undefined,
notes: notes || undefined,
})
}
return (
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Label>Supplier</Label>
<Select value={supplierId || 'none'} onValueChange={(v) => setSupplierId(v === 'none' ? '' : v)}>
<SelectTrigger><SelectValue placeholder="Select supplier…" /></SelectTrigger>
<SelectContent>
<SelectItem value="none">No supplier</SelectItem>
{linkedSuppliers.map((s) => (
<SelectItem key={s.supplierId} value={s.supplierId}>{s.supplierName}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="rcpt-qty">Qty Received *</Label>
<Input id="rcpt-qty" type="number" min="1" value={qty} onChange={(e) => setQty(e.target.value)} required />
</div>
<div className="space-y-2">
<Label htmlFor="rcpt-cost">Cost / Unit *</Label>
<Input id="rcpt-cost" type="number" step="0.01" min="0" value={costPerUnit} onChange={(e) => setCostPerUnit(e.target.value)} placeholder="0.00" required />
</div>
</div>
{costPerUnit && qty && (
<p className="text-sm text-muted-foreground">Total cost: <span className="font-medium text-foreground">${total.toFixed(2)}</span></p>
)}
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="rcpt-date">Received Date *</Label>
<Input id="rcpt-date" type="date" value={receivedDate} onChange={(e) => setReceivedDate(e.target.value)} required />
</div>
<div className="space-y-2">
<Label htmlFor="rcpt-inv">Invoice #</Label>
<Input id="rcpt-inv" value={invoiceNumber} onChange={(e) => setInvoiceNumber(e.target.value)} placeholder="INV-001" />
</div>
</div>
<div className="space-y-2">
<Label htmlFor="rcpt-notes">Notes</Label>
<Input id="rcpt-notes" value={notes} onChange={(e) => setNotes(e.target.value)} placeholder="Optional notes" />
</div>
<Button type="submit" disabled={loading || !costPerUnit || !qty} className="w-full">
{loading ? 'Recording…' : 'Record Receipt'}
</Button>
</form>
)
}
function SupplierLinkEditForm({
defaultValues, onSubmit, loading,
}: {
defaultValues: ProductSupplier
onSubmit: (data: Record<string, unknown>) => void
loading: boolean
}) {
const [supplierSku, setSupplierSku] = useState(defaultValues.supplierSku ?? '')
const [isPreferred, setIsPreferred] = useState(defaultValues.isPreferred)
function handleSubmit(e: React.FormEvent) {
e.preventDefault()
onSubmit({ supplierSku: supplierSku || undefined, isPreferred })
}
return (
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="edit-sku">Their SKU / Part #</Label>
<Input id="edit-sku" value={supplierSku} onChange={(e) => setSupplierSku(e.target.value)} placeholder="e.g. VLN-44-EA" />
</div>
<div className="flex items-center gap-2">
<Checkbox id="edit-preferred" checked={isPreferred} onCheckedChange={(v: boolean | 'indeterminate') => setIsPreferred(!!v)} />
<Label htmlFor="edit-preferred">Preferred supplier for this product</Label>
</div>
<Button type="submit" disabled={loading}>{loading ? 'Saving…' : 'Save Changes'}</Button>
</form>
)
}

View File

@@ -0,0 +1,170 @@
import { useState } from 'react'
import { createFileRoute } from '@tanstack/react-router'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { categoryListOptions, categoryMutations, categoryKeys } from '@/api/inventory'
import { CategoryForm } from '@/components/inventory/category-form'
import { usePagination } from '@/hooks/use-pagination'
import { DataTable, type Column } from '@/components/shared/data-table'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import { Search, Plus } from 'lucide-react'
import { toast } from 'sonner'
import { useAuthStore } from '@/stores/auth.store'
import type { Category } from '@/types/inventory'
export const Route = createFileRoute('/_authenticated/inventory/categories')({
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: CategoriesPage,
})
function CategoriesPage() {
const queryClient = useQueryClient()
const hasPermission = useAuthStore((s) => s.hasPermission)
const { params, setPage, setSearch, setSort } = usePagination()
const [searchInput, setSearchInput] = useState(params.q ?? '')
const [createOpen, setCreateOpen] = useState(false)
const [editTarget, setEditTarget] = useState<Category | null>(null)
const { data, isLoading } = useQuery(categoryListOptions(params))
const createMutation = useMutation({
mutationFn: categoryMutations.create,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: categoryKeys.all })
toast.success('Category created')
setCreateOpen(false)
},
onError: (err) => toast.error(err.message),
})
const updateMutation = useMutation({
mutationFn: ({ id, data }: { id: string; data: Record<string, unknown> }) =>
categoryMutations.update(id, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: categoryKeys.all })
toast.success('Category updated')
setEditTarget(null)
},
onError: (err) => toast.error(err.message),
})
const deleteMutation = useMutation({
mutationFn: categoryMutations.delete,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: categoryKeys.all })
toast.success('Category deleted')
setEditTarget(null)
},
onError: (err) => toast.error(err.message),
})
function handleSearchSubmit(e: React.FormEvent) {
e.preventDefault()
setSearch(searchInput)
}
const columns: Column<Category>[] = [
{
key: 'name',
header: 'Name',
sortable: true,
render: (c) => <span className="font-medium">{c.name}</span>,
},
{
key: 'sort_order',
header: 'Order',
sortable: true,
render: (c) => <span className="text-muted-foreground text-sm">{c.sortOrder}</span>,
},
{
key: 'is_active',
header: 'Status',
sortable: true,
render: (c) => c.isActive
? <Badge>Active</Badge>
: <Badge variant="secondary">Inactive</Badge>,
},
{
key: 'actions',
header: '',
render: (c) => hasPermission('inventory.edit') ? (
<Button variant="ghost" size="sm" onClick={(e) => { e.stopPropagation(); setEditTarget(c) }}>
Edit
</Button>
) : null,
},
]
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold">Categories</h1>
{hasPermission('inventory.edit') && (
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
<DialogTrigger asChild>
<Button><Plus className="mr-2 h-4 w-4" />New Category</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader><DialogTitle>New Category</DialogTitle></DialogHeader>
<CategoryForm
onSubmit={createMutation.mutate}
loading={createMutation.isPending}
/>
</DialogContent>
</Dialog>
)}
</div>
<form onSubmit={handleSearchSubmit} className="flex gap-2 max-w-sm">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Search categories..."
value={searchInput}
onChange={(e) => setSearchInput(e.target.value)}
className="pl-9"
/>
</div>
<Button type="submit" variant="secondary">Search</Button>
</form>
<DataTable
columns={columns}
data={data?.data ?? []}
loading={isLoading}
page={params.page}
totalPages={data?.pagination.totalPages ?? 1}
total={data?.pagination.total ?? 0}
sort={params.sort}
order={params.order}
onPageChange={setPage}
onSort={setSort}
onRowClick={hasPermission('inventory.edit') ? setEditTarget : undefined}
/>
{/* Edit dialog */}
<Dialog open={!!editTarget} onOpenChange={(o) => !o && setEditTarget(null)}>
<DialogContent>
<DialogHeader><DialogTitle>Edit Category</DialogTitle></DialogHeader>
{editTarget && (
<CategoryForm
defaultValues={editTarget}
onSubmit={(data) => updateMutation.mutate({ id: editTarget.id, data })}
loading={updateMutation.isPending}
onDelete={hasPermission('inventory.admin') ? () => deleteMutation.mutate(editTarget.id) : undefined}
deleteLoading={deleteMutation.isPending}
/>
)}
</DialogContent>
</Dialog>
</div>
)
}

View File

@@ -0,0 +1,253 @@
import { useState } from 'react'
import { createFileRoute, useNavigate } from '@tanstack/react-router'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { productListOptions, productMutations, productKeys, categoryAllOptions } from '@/api/inventory'
import { ProductForm } from '@/components/inventory/product-form'
import { usePagination } from '@/hooks/use-pagination'
import { DataTable, type Column } from '@/components/shared/data-table'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog'
import { Search, Plus } from 'lucide-react'
import { toast } from 'sonner'
import { useAuthStore } from '@/stores/auth.store'
import type { Product } from '@/types/inventory'
export const Route = createFileRoute('/_authenticated/inventory/')({
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',
categoryId: (search.categoryId as string) || undefined,
isActive: (search.isActive as string) || undefined,
type: (search.type as string) || undefined,
lowStock: (search.lowStock as string) || undefined,
}),
component: InventoryPage,
})
function qtyBadge(qty: number, reorderPoint: number | null) {
if (qty === 0) return <Badge variant="destructive">{qty}</Badge>
if (reorderPoint !== null && qty <= reorderPoint)
return <Badge variant="secondary" className="bg-amber-100 text-amber-800 border-amber-300">{qty} Low</Badge>
return <span className="text-sm">{qty}</span>
}
function InventoryPage() {
const navigate = useNavigate()
const queryClient = useQueryClient()
const hasPermission = useAuthStore((s) => s.hasPermission)
const { params, setPage, setSearch, setSort } = usePagination()
const [searchInput, setSearchInput] = useState(params.q ?? '')
const [createOpen, setCreateOpen] = useState(false)
const search = Route.useSearch()
const [categoryFilter, setCategoryFilter] = useState(search.categoryId ?? '')
const [activeFilter, setActiveFilter] = useState(search.isActive ?? '')
const [typeFilter, setTypeFilter] = useState(search.type ?? '')
const [lowStockFilter, setLowStockFilter] = useState(search.lowStock === 'true')
const { data: categoriesData } = useQuery(categoryAllOptions())
const categories = categoriesData?.data ?? []
const categoryMap = new Map(categories.map((c) => [c.id, c.name]))
const queryParams: Record<string, unknown> = { ...params }
if (categoryFilter) queryParams.categoryId = categoryFilter
if (activeFilter) queryParams.isActive = activeFilter === 'true'
if (typeFilter === 'serialized') queryParams.isSerialized = true
if (typeFilter === 'rental') queryParams.isRental = true
if (typeFilter === 'repair') queryParams.isDualUseRepair = true
if (lowStockFilter) queryParams.lowStock = true
const { data, isLoading } = useQuery(productListOptions(queryParams))
const createMutation = useMutation({
mutationFn: productMutations.create,
onSuccess: (product) => {
queryClient.invalidateQueries({ queryKey: productKeys.all })
toast.success('Product created')
setCreateOpen(false)
navigate({ to: '/inventory/$productId', params: { productId: product.id }, search: {} as any })
},
onError: (err) => toast.error(err.message),
})
function handleSearchSubmit(e: React.FormEvent) {
e.preventDefault()
setSearch(searchInput)
}
function handleCategoryChange(v: string) {
setCategoryFilter(v === 'all' ? '' : v)
navigate({ to: '/inventory', search: { ...search, categoryId: v === 'all' ? undefined : v, page: 1 } as any })
}
function handleActiveChange(v: string) {
setActiveFilter(v === 'all' ? '' : v)
navigate({ to: '/inventory', search: { ...search, isActive: v === 'all' ? undefined : v, page: 1 } as any })
}
function handleTypeChange(v: string) {
setTypeFilter(v === 'all' ? '' : v)
navigate({ to: '/inventory', search: { ...search, type: v === 'all' ? undefined : v, page: 1 } as any })
}
function handleLowStockChange(v: string) {
const on = v === 'true'
setLowStockFilter(on)
navigate({ to: '/inventory', search: { ...search, lowStock: on ? 'true' : undefined, page: 1 } as any })
}
const columns: Column<Product>[] = [
{
key: 'name',
header: 'Name',
sortable: true,
render: (p) => <span className="font-medium">{p.name}</span>,
},
{
key: 'sku',
header: 'SKU',
sortable: true,
render: (p) => p.sku
? <span className="font-mono text-sm">{p.sku}</span>
: <span className="text-muted-foreground"></span>,
},
{
key: 'brand',
header: 'Brand',
sortable: true,
render: (p) => p.brand ?? <span className="text-muted-foreground"></span>,
},
{
key: 'category',
header: 'Category',
render: (p) => p.categoryId
? (categoryMap.get(p.categoryId) ?? <span className="text-muted-foreground"></span>)
: <span className="text-muted-foreground"></span>,
},
{
key: 'price',
header: 'Price',
sortable: true,
render: (p) => p.price ? `$${Number(p.price).toFixed(2)}` : <span className="text-muted-foreground"></span>,
},
{
key: 'qty_on_hand',
header: 'Qty',
sortable: true,
render: (p) => qtyBadge(p.qtyOnHand, p.qtyReorderPoint),
},
{
key: 'flags',
header: 'Type',
render: (p) => (
<div className="flex gap-1">
{p.isSerialized && <Badge variant="outline" className="text-xs">Serial</Badge>}
{p.isRental && <Badge variant="outline" className="text-xs">Rental</Badge>}
{p.isDualUseRepair && <Badge variant="outline" className="text-xs">Repair</Badge>}
</div>
),
},
{
key: 'is_active',
header: 'Status',
sortable: true,
render: (p) => p.isActive
? <Badge>Active</Badge>
: <Badge variant="secondary">Inactive</Badge>,
},
]
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold">Products</h1>
{hasPermission('inventory.edit') && (
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
<DialogTrigger asChild>
<Button><Plus className="mr-2 h-4 w-4" />New Product</Button>
</DialogTrigger>
<DialogContent className="max-w-xl max-h-[90vh] overflow-y-auto">
<DialogHeader><DialogTitle>New Product</DialogTitle></DialogHeader>
<ProductForm onSubmit={createMutation.mutate} loading={createMutation.isPending} />
</DialogContent>
</Dialog>
)}
</div>
<div className="flex gap-3 flex-wrap">
<form onSubmit={handleSearchSubmit} className="flex gap-2">
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Search products..."
value={searchInput}
onChange={(e) => setSearchInput(e.target.value)}
className="pl-9 w-64"
/>
</div>
<Button type="submit" variant="secondary">Search</Button>
</form>
<Select value={categoryFilter || 'all'} onValueChange={handleCategoryChange}>
<SelectTrigger className="w-44">
<SelectValue placeholder="All Categories" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Categories</SelectItem>
{categories.filter((c) => c.isActive).map((c) => (
<SelectItem key={c.id} value={c.id}>{c.name}</SelectItem>
))}
</SelectContent>
</Select>
<Select value={typeFilter || 'all'} onValueChange={handleTypeChange}>
<SelectTrigger className="w-36">
<SelectValue placeholder="All Types" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Types</SelectItem>
<SelectItem value="serialized">Serialized</SelectItem>
<SelectItem value="rental">Rental</SelectItem>
<SelectItem value="repair">Repair Parts</SelectItem>
</SelectContent>
</Select>
<Select value={lowStockFilter ? 'true' : 'all'} onValueChange={handleLowStockChange}>
<SelectTrigger className="w-36">
<SelectValue placeholder="All Stock" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Stock</SelectItem>
<SelectItem value="true">Low / Out of Stock</SelectItem>
</SelectContent>
</Select>
<Select value={activeFilter || 'all'} onValueChange={handleActiveChange}>
<SelectTrigger className="w-32">
<SelectValue placeholder="Active" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Active</SelectItem>
<SelectItem value="false">Inactive</SelectItem>
</SelectContent>
</Select>
</div>
<DataTable
columns={columns}
data={data?.data ?? []}
loading={isLoading}
page={params.page}
totalPages={data?.pagination.totalPages ?? 1}
total={data?.pagination.total ?? 0}
sort={params.sort}
order={params.order}
onPageChange={setPage}
onSort={setSort}
onRowClick={(p) => navigate({ to: '/inventory/$productId', params: { productId: p.id }, search: {} as any })}
/>
</div>
)
}

View File

@@ -0,0 +1,185 @@
import { useState } from 'react'
import { createFileRoute } from '@tanstack/react-router'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { supplierListOptions, supplierMutations, supplierKeys } from '@/api/inventory'
import { SupplierForm } from '@/components/inventory/supplier-form'
import { usePagination } from '@/hooks/use-pagination'
import { DataTable, type Column } from '@/components/shared/data-table'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog'
import { Search, Plus } from 'lucide-react'
import { toast } from 'sonner'
import { useAuthStore } from '@/stores/auth.store'
import type { Supplier } from '@/types/inventory'
export const Route = createFileRoute('/_authenticated/inventory/suppliers/')({
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: SuppliersPage,
})
function SuppliersPage() {
const queryClient = useQueryClient()
const hasPermission = useAuthStore((s) => s.hasPermission)
const { params, setPage, setSearch, setSort } = usePagination()
const [searchInput, setSearchInput] = useState(params.q ?? '')
const [createOpen, setCreateOpen] = useState(false)
const [editTarget, setEditTarget] = useState<Supplier | null>(null)
const { data, isLoading } = useQuery(supplierListOptions(params))
const createMutation = useMutation({
mutationFn: supplierMutations.create,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: supplierKeys.all })
toast.success('Supplier created')
setCreateOpen(false)
},
onError: (err) => toast.error(err.message),
})
const updateMutation = useMutation({
mutationFn: ({ id, data }: { id: string; data: Record<string, unknown> }) =>
supplierMutations.update(id, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: supplierKeys.all })
toast.success('Supplier updated')
setEditTarget(null)
},
onError: (err) => toast.error(err.message),
})
const deleteMutation = useMutation({
mutationFn: supplierMutations.delete,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: supplierKeys.all })
toast.success('Supplier deleted')
setEditTarget(null)
},
onError: (err) => toast.error(err.message),
})
function handleSearchSubmit(e: React.FormEvent) {
e.preventDefault()
setSearch(searchInput)
}
const columns: Column<Supplier>[] = [
{
key: 'name',
header: 'Name',
sortable: true,
render: (s) => <span className="font-medium">{s.name}</span>,
},
{
key: 'contact_name',
header: 'Contact',
render: (s) => s.contactName ?? <span className="text-muted-foreground"></span>,
},
{
key: 'email',
header: 'Email',
sortable: true,
render: (s) => s.email
? <a href={`mailto:${s.email}`} className="hover:underline" onClick={(e) => e.stopPropagation()}>{s.email}</a>
: <span className="text-muted-foreground"></span>,
},
{
key: 'phone',
header: 'Phone',
render: (s) => s.phone ?? <span className="text-muted-foreground"></span>,
},
{
key: 'account_number',
header: 'Account #',
render: (s) => s.accountNumber
? <span className="font-mono text-sm">{s.accountNumber}</span>
: <span className="text-muted-foreground"></span>,
},
{
key: 'payment_terms',
header: 'Terms',
render: (s) => s.paymentTerms ?? <span className="text-muted-foreground"></span>,
},
{
key: 'actions',
header: '',
render: (s) => hasPermission('inventory.edit') ? (
<Button variant="ghost" size="sm" onClick={(e) => { e.stopPropagation(); setEditTarget(s) }}>
Edit
</Button>
) : null,
},
]
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold">Suppliers</h1>
{hasPermission('inventory.edit') && (
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
<DialogTrigger asChild>
<Button><Plus className="mr-2 h-4 w-4" />New Supplier</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader><DialogTitle>New Supplier</DialogTitle></DialogHeader>
<SupplierForm
onSubmit={supplierMutations.create.bind(null) as any}
loading={createMutation.isPending}
/>
</DialogContent>
</Dialog>
)}
</div>
<form onSubmit={handleSearchSubmit} className="flex gap-2 max-w-sm">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Search suppliers..."
value={searchInput}
onChange={(e) => setSearchInput(e.target.value)}
className="pl-9"
/>
</div>
<Button type="submit" variant="secondary">Search</Button>
</form>
<DataTable
columns={columns}
data={data?.data ?? []}
loading={isLoading}
page={params.page}
totalPages={data?.pagination.totalPages ?? 1}
total={data?.pagination.total ?? 0}
sort={params.sort}
order={params.order}
onPageChange={setPage}
onSort={setSort}
onRowClick={hasPermission('inventory.edit') ? setEditTarget : undefined}
/>
{/* Edit dialog */}
<Dialog open={!!editTarget} onOpenChange={(o) => !o && setEditTarget(null)}>
<DialogContent>
<DialogHeader><DialogTitle>Edit Supplier</DialogTitle></DialogHeader>
{editTarget && (
<SupplierForm
defaultValues={editTarget}
onSubmit={(data) => updateMutation.mutate({ id: editTarget.id, data })}
loading={updateMutation.isPending}
onDelete={hasPermission('inventory.admin') ? () => deleteMutation.mutate(editTarget.id) : undefined}
deleteLoading={deleteMutation.isPending}
/>
)}
</DialogContent>
</Dialog>
</div>
)
}