Replace all `any` types with proper types across 36 files:
- TanStack Router search params: `{} as Record<string, unknown>`
- API response pagination: proper typed interface
- DataTable column casts: remove unnecessary `as any`
- Function params and event handlers: use specific types
- Remove unused imports and variables in POS components
Frontend lint now passes with 0 errors and 0 warnings.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
254 lines
10 KiB
TypeScript
254 lines
10 KiB
TypeScript
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 Record<string, unknown> })
|
|
},
|
|
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 Record<string, unknown> })
|
|
}
|
|
|
|
function handleActiveChange(v: string) {
|
|
setActiveFilter(v === 'all' ? '' : v)
|
|
navigate({ to: '/inventory', search: { ...search, isActive: v === 'all' ? undefined : v, page: 1 } as Record<string, unknown> })
|
|
}
|
|
|
|
function handleTypeChange(v: string) {
|
|
setTypeFilter(v === 'all' ? '' : v)
|
|
navigate({ to: '/inventory', search: { ...search, type: v === 'all' ? undefined : v, page: 1 } as Record<string, unknown> })
|
|
}
|
|
|
|
function handleLowStockChange(v: string) {
|
|
const on = v === 'true'
|
|
setLowStockFilter(on)
|
|
navigate({ to: '/inventory', search: { ...search, lowStock: on ? 'true' : undefined, page: 1 } as Record<string, unknown> })
|
|
}
|
|
|
|
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 Record<string, unknown> })}
|
|
/>
|
|
</div>
|
|
)
|
|
}
|