Files
lunarfront-app/packages/admin/src/routes/_authenticated/inventory/index.tsx
ryan a0be16d848
All checks were successful
CI / ci (pull_request) Successful in 21s
CI / e2e (pull_request) Successful in 59s
fix: resolve all frontend lint errors and warnings
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>
2026-04-04 20:12:17 +00:00

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