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:
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
253
packages/admin/src/routes/_authenticated/inventory/index.tsx
Normal file
253
packages/admin/src/routes/_authenticated/inventory/index.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user