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

@@ -20,8 +20,8 @@ sudo -u "$APP_USER" bash -c \
"cd ${APP_DIR}/packages/backend && ${BUN_BIN} x drizzle-kit migrate" "cd ${APP_DIR}/packages/backend && ${BUN_BIN} x drizzle-kit migrate"
echo "==> Restarting backend..." echo "==> Restarting backend..."
systemctl restart lunarfront sudo systemctl restart lunarfront
echo "==> Done! Checking status..." echo "==> Done! Checking status..."
sleep 2 sleep 2
systemctl status lunarfront --no-pager sudo systemctl status lunarfront --no-pager

25
deploy/sync-and-deploy.sh Executable file
View File

@@ -0,0 +1,25 @@
#!/usr/bin/env bash
# LunarFront — Sync local source to EC2 and redeploy
# Usage: bash deploy/sync-and-deploy.sh
set -euo pipefail
EC2_HOST="18.217.233.214"
EC2_USER="ubuntu"
SSH_KEY="$HOME/.ssh/lunarfront-dev.pem"
APP_DIR="/opt/lunarfront"
echo "==> Syncing source to ${EC2_USER}@${EC2_HOST}:${APP_DIR} ..."
rsync -az --delete \
--exclude='.git' \
--exclude='node_modules' \
--exclude='packages/*/node_modules' \
--exclude='packages/admin/dist' \
--exclude='packages/backend/dist' \
--exclude='*.env' \
-e "ssh -i ${SSH_KEY} -o StrictHostKeyChecking=no" \
/home/ryan/pos/ \
"${EC2_USER}@${EC2_HOST}:${APP_DIR}/"
echo "==> Running deploy script on server..."
ssh -i "${SSH_KEY}" -o StrictHostKeyChecking=no "${EC2_USER}@${EC2_HOST}" \
"sudo bash ${APP_DIR}/deploy/deploy.sh"

View File

@@ -0,0 +1,162 @@
import { queryOptions } from '@tanstack/react-query'
import { api } from '@/lib/api-client'
import type { Category, Supplier, Product, InventoryUnit, ProductSupplier, StockReceipt, PriceHistory } from '@/types/inventory'
import type { PaginatedResponse, PaginationInput } from '@lunarfront/shared/schemas'
// ─── Categories ──────────────────────────────────────────────────────────────
export const categoryKeys = {
all: ['categories'] as const,
list: (params: PaginationInput) => [...categoryKeys.all, 'list', params] as const,
allCategories: [...['categories'], 'all-flat'] as const,
detail: (id: string) => [...categoryKeys.all, 'detail', id] as const,
}
export function categoryListOptions(params: PaginationInput) {
return queryOptions({
queryKey: categoryKeys.list(params),
queryFn: () => api.get<PaginatedResponse<Category>>('/v1/categories', params as Record<string, unknown>),
})
}
export function categoryAllOptions() {
return queryOptions({
queryKey: categoryKeys.allCategories,
queryFn: () => api.get<{ data: Category[] }>('/v1/categories/all'),
})
}
export const categoryMutations = {
create: (data: Record<string, unknown>) => api.post<Category>('/v1/categories', data),
update: (id: string, data: Record<string, unknown>) => api.patch<Category>(`/v1/categories/${id}`, data),
delete: (id: string) => api.del<Category>(`/v1/categories/${id}`),
}
// ─── Suppliers ───────────────────────────────────────────────────────────────
export const supplierKeys = {
all: ['suppliers'] as const,
list: (params: PaginationInput) => [...supplierKeys.all, 'list', params] as const,
detail: (id: string) => [...supplierKeys.all, 'detail', id] as const,
}
export function supplierListOptions(params: PaginationInput) {
return queryOptions({
queryKey: supplierKeys.list(params),
queryFn: () => api.get<PaginatedResponse<Supplier>>('/v1/suppliers', params as Record<string, unknown>),
})
}
export const supplierMutations = {
create: (data: Record<string, unknown>) => api.post<Supplier>('/v1/suppliers', data),
update: (id: string, data: Record<string, unknown>) => api.patch<Supplier>(`/v1/suppliers/${id}`, data),
delete: (id: string) => api.del<Supplier>(`/v1/suppliers/${id}`),
}
// ─── Products ────────────────────────────────────────────────────────────────
export const productKeys = {
all: ['products'] as const,
list: (params: Record<string, unknown>) => [...productKeys.all, 'list', params] as const,
detail: (id: string) => [...productKeys.all, 'detail', id] as const,
}
export function productListOptions(params: Record<string, unknown>) {
return queryOptions({
queryKey: productKeys.list(params),
queryFn: () => api.get<PaginatedResponse<Product>>('/v1/products', params),
})
}
export function productDetailOptions(id: string) {
return queryOptions({
queryKey: productKeys.detail(id),
queryFn: () => api.get<Product>(`/v1/products/${id}`),
enabled: !!id,
})
}
export const productMutations = {
create: (data: Record<string, unknown>) => api.post<Product>('/v1/products', data),
update: (id: string, data: Record<string, unknown>) => api.patch<Product>(`/v1/products/${id}`, data),
delete: (id: string) => api.del<Product>(`/v1/products/${id}`),
}
// ─── Inventory Units ─────────────────────────────────────────────────────────
export const unitKeys = {
all: ['inventory-units'] as const,
byProduct: (productId: string) => [...unitKeys.all, 'product', productId] as const,
detail: (id: string) => [...unitKeys.all, 'detail', id] as const,
}
export function unitListOptions(productId: string) {
return queryOptions({
queryKey: unitKeys.byProduct(productId),
queryFn: () => api.get<{ data: InventoryUnit[] }>(`/v1/products/${productId}/units`),
enabled: !!productId,
})
}
export const unitMutations = {
create: (productId: string, data: Record<string, unknown>) =>
api.post<InventoryUnit>(`/v1/products/${productId}/units`, data),
update: (id: string, data: Record<string, unknown>) =>
api.patch<InventoryUnit>(`/v1/units/${id}`, data),
}
// ─── Product Suppliers ───────────────────────────────────────────────────────
export const productSupplierKeys = {
byProduct: (productId: string) => ['products', productId, 'suppliers'] as const,
}
export function productSupplierListOptions(productId: string) {
return queryOptions({
queryKey: productSupplierKeys.byProduct(productId),
queryFn: () => api.get<{ data: ProductSupplier[] }>(`/v1/products/${productId}/suppliers`),
enabled: !!productId,
})
}
export const productSupplierMutations = {
create: (productId: string, data: Record<string, unknown>) =>
api.post<ProductSupplier>(`/v1/products/${productId}/suppliers`, data),
update: (productId: string, id: string, data: Record<string, unknown>) =>
api.patch<ProductSupplier>(`/v1/products/${productId}/suppliers/${id}`, data),
delete: (productId: string, id: string) =>
api.del(`/v1/products/${productId}/suppliers/${id}`),
}
// ─── Stock Receipts ──────────────────────────────────────────────────────────
export const stockReceiptKeys = {
byProduct: (productId: string) => ['products', productId, 'stock-receipts'] as const,
}
export function stockReceiptListOptions(productId: string) {
return queryOptions({
queryKey: stockReceiptKeys.byProduct(productId),
queryFn: () => api.get<{ data: StockReceipt[] }>(`/v1/products/${productId}/stock-receipts`),
enabled: !!productId,
})
}
export const stockReceiptMutations = {
create: (productId: string, data: Record<string, unknown>) =>
api.post<StockReceipt>(`/v1/products/${productId}/stock-receipts`, data),
}
// ─── Price History ───────────────────────────────────────────────────────────
export const priceHistoryKeys = {
byProduct: (productId: string) => ['products', productId, 'price-history'] as const,
}
export function priceHistoryOptions(productId: string) {
return queryOptions({
queryKey: priceHistoryKeys.byProduct(productId),
queryFn: () => api.get<{ data: PriceHistory[] }>(`/v1/products/${productId}/price-history`),
enabled: !!productId,
})
}

View File

@@ -0,0 +1,91 @@
import { useForm } from 'react-hook-form'
import { useQuery } from '@tanstack/react-query'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { categoryAllOptions } from '@/api/inventory'
import type { Category } from '@/types/inventory'
interface Props {
defaultValues?: Partial<Category>
onSubmit: (data: Record<string, unknown>) => void
onDelete?: () => void
loading?: boolean
deleteLoading?: boolean
}
export function CategoryForm({ defaultValues, onSubmit, onDelete, loading, deleteLoading }: Props) {
const { data: allCats } = useQuery(categoryAllOptions())
const categories = (allCats?.data ?? []).filter((c) => c.id !== defaultValues?.id && c.isActive)
const { register, handleSubmit, setValue, watch } = useForm({
defaultValues: {
name: defaultValues?.name ?? '',
parentId: defaultValues?.parentId ?? '',
sortOrder: defaultValues?.sortOrder ?? 0,
isActive: defaultValues?.isActive ?? true,
},
})
const parentId = watch('parentId')
const isActive = watch('isActive')
function handleFormSubmit(data: { name: string; parentId: string; sortOrder: number; isActive: boolean }) {
onSubmit({
name: data.name,
parentId: data.parentId || undefined,
sortOrder: Number(data.sortOrder),
isActive: data.isActive,
})
}
return (
<form onSubmit={handleSubmit(handleFormSubmit)} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="cat-name">Name *</Label>
<Input id="cat-name" {...register('name')} placeholder="e.g. Guitars, Accessories" required />
</div>
<div className="space-y-2">
<Label>Parent Category</Label>
<Select value={parentId || 'none'} onValueChange={(v) => setValue('parentId', v === 'none' ? '' : v)}>
<SelectTrigger>
<SelectValue placeholder="None (Top Level)" />
</SelectTrigger>
<SelectContent>
<SelectItem value="none">None (Top Level)</SelectItem>
{categories.map((c) => (
<SelectItem key={c.id} value={c.id}>{c.name}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="cat-order">Sort Order</Label>
<Input id="cat-order" type="number" min={0} {...register('sortOrder')} />
</div>
<div className="flex items-center gap-2 pt-7">
<input
id="cat-active"
type="checkbox"
checked={isActive}
onChange={(e) => setValue('isActive', e.target.checked)}
className="h-4 w-4"
/>
<Label htmlFor="cat-active">Active</Label>
</div>
</div>
<div className="flex gap-2 pt-2">
<Button type="submit" disabled={loading} className="flex-1">
{loading ? 'Saving...' : defaultValues?.id ? 'Save Changes' : 'Create Category'}
</Button>
{onDelete && (
<Button type="button" variant="destructive" disabled={deleteLoading} onClick={onDelete}>
{deleteLoading ? 'Deleting...' : 'Delete'}
</Button>
)}
</div>
</form>
)
}

View File

@@ -0,0 +1,118 @@
import { useForm } from 'react-hook-form'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import type { InventoryUnit, UnitCondition, UnitStatus } from '@/types/inventory'
const CONDITIONS: { value: UnitCondition; label: string }[] = [
{ value: 'new', label: 'New' },
{ value: 'excellent', label: 'Excellent' },
{ value: 'good', label: 'Good' },
{ value: 'fair', label: 'Fair' },
{ value: 'poor', label: 'Poor' },
]
const STATUSES: { value: UnitStatus; label: string }[] = [
{ value: 'available', label: 'Available' },
{ value: 'sold', label: 'Sold' },
{ value: 'rented', label: 'Rented' },
{ value: 'on_trial', label: 'On Trial' },
{ value: 'in_repair', label: 'In Repair' },
{ value: 'layaway', label: 'Layaway' },
{ value: 'lost', label: 'Lost' },
{ value: 'retired', label: 'Retired' },
]
interface Props {
defaultValues?: Partial<InventoryUnit>
onSubmit: (data: Record<string, unknown>) => void
loading?: boolean
}
export function InventoryUnitForm({ defaultValues, onSubmit, loading }: Props) {
const { register, handleSubmit, setValue, watch } = useForm({
defaultValues: {
serialNumber: defaultValues?.serialNumber ?? '',
condition: (defaultValues?.condition ?? 'new') as UnitCondition,
status: (defaultValues?.status ?? 'available') as UnitStatus,
purchaseDate: defaultValues?.purchaseDate ?? '',
purchaseCost: defaultValues?.purchaseCost ?? '',
notes: defaultValues?.notes ?? '',
},
})
const condition = watch('condition')
const status = watch('status')
function handleFormSubmit(data: Record<string, unknown>) {
onSubmit({
serialNumber: (data.serialNumber as string) || undefined,
condition: data.condition,
status: data.status,
purchaseDate: (data.purchaseDate as string) || undefined,
purchaseCost: (data.purchaseCost as string) || undefined,
notes: (data.notes as string) || undefined,
})
}
return (
<form onSubmit={handleSubmit(handleFormSubmit)} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="unit-serial">Serial Number</Label>
<Input id="unit-serial" {...register('serialNumber')} placeholder="e.g. US22041234" />
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label>Condition</Label>
<Select value={condition} onValueChange={(v) => setValue('condition', v as UnitCondition)}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{CONDITIONS.map((c) => (
<SelectItem key={c.value} value={c.value}>{c.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Status</Label>
<Select value={status} onValueChange={(v) => setValue('status', v as UnitStatus)}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{STATUSES.map((s) => (
<SelectItem key={s.value} value={s.value}>{s.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="unit-date">Purchase Date</Label>
<Input id="unit-date" type="date" {...register('purchaseDate')} />
</div>
<div className="space-y-2">
<Label htmlFor="unit-cost">Purchase Cost</Label>
<Input id="unit-cost" type="number" step="0.01" min="0" {...register('purchaseCost')} placeholder="0.00" />
</div>
</div>
<div className="space-y-2">
<Label htmlFor="unit-notes">Notes</Label>
<textarea
id="unit-notes"
{...register('notes')}
rows={2}
placeholder="Any notes about this unit..."
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring resize-none"
/>
</div>
<Button type="submit" disabled={loading} className="w-full">
{loading ? 'Saving...' : defaultValues?.id ? 'Save Changes' : 'Add Unit'}
</Button>
</form>
)
}

View File

@@ -0,0 +1,172 @@
import { useForm } from 'react-hook-form'
import { useQuery } from '@tanstack/react-query'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { categoryAllOptions } from '@/api/inventory'
import type { Product } from '@/types/inventory'
interface Props {
defaultValues?: Partial<Product>
onSubmit: (data: Record<string, unknown>) => void
loading?: boolean
}
export function ProductForm({ defaultValues, onSubmit, loading }: Props) {
const { data: allCats } = useQuery(categoryAllOptions())
const categories = (allCats?.data ?? []).filter((c) => c.isActive)
const { register, handleSubmit, setValue, watch } = useForm({
defaultValues: {
name: defaultValues?.name ?? '',
sku: defaultValues?.sku ?? '',
upc: defaultValues?.upc ?? '',
brand: defaultValues?.brand ?? '',
model: defaultValues?.model ?? '',
description: defaultValues?.description ?? '',
categoryId: defaultValues?.categoryId ?? '',
price: defaultValues?.price ?? '',
minPrice: defaultValues?.minPrice ?? '',
rentalRateMonthly: defaultValues?.rentalRateMonthly ?? '',
qtyOnHand: defaultValues?.qtyOnHand ?? 0,
qtyReorderPoint: defaultValues?.qtyReorderPoint ?? '',
isSerialized: defaultValues?.isSerialized ?? false,
isRental: defaultValues?.isRental ?? false,
isDualUseRepair: defaultValues?.isDualUseRepair ?? false,
isActive: defaultValues?.isActive ?? true,
},
})
const categoryId = watch('categoryId')
const isRental = watch('isRental')
const isSerialized = watch('isSerialized')
const isDualUseRepair = watch('isDualUseRepair')
const isActive = watch('isActive')
function handleFormSubmit(data: Record<string, unknown>) {
onSubmit({
name: data.name,
sku: (data.sku as string) || undefined,
upc: (data.upc as string) || undefined,
brand: (data.brand as string) || undefined,
model: (data.model as string) || undefined,
description: (data.description as string) || undefined,
categoryId: (data.categoryId as string) || undefined,
price: (data.price as string) ? Number(data.price) : undefined,
minPrice: (data.minPrice as string) ? Number(data.minPrice) : undefined,
rentalRateMonthly: isRental && (data.rentalRateMonthly as string) ? Number(data.rentalRateMonthly) : undefined,
qtyOnHand: Number(data.qtyOnHand),
qtyReorderPoint: (data.qtyReorderPoint as string) ? Number(data.qtyReorderPoint) : undefined,
isSerialized: data.isSerialized,
isRental: data.isRental,
isDualUseRepair: data.isDualUseRepair,
isActive: data.isActive,
})
}
return (
<form onSubmit={handleSubmit(handleFormSubmit)} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="p-name">Name *</Label>
<Input id="p-name" {...register('name')} placeholder="e.g. Fender Player Stratocaster" required />
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="p-sku">SKU</Label>
<Input id="p-sku" {...register('sku')} placeholder="STR-001" />
</div>
<div className="space-y-2">
<Label htmlFor="p-upc">UPC / Barcode</Label>
<Input id="p-upc" {...register('upc')} placeholder="0123456789" />
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="p-brand">Brand</Label>
<Input id="p-brand" {...register('brand')} placeholder="Fender" />
</div>
<div className="space-y-2">
<Label htmlFor="p-model">Model</Label>
<Input id="p-model" {...register('model')} placeholder="Player Stratocaster" />
</div>
</div>
<div className="space-y-2">
<Label>Category</Label>
<Select value={categoryId || 'none'} onValueChange={(v) => setValue('categoryId', v === 'none' ? '' : v)}>
<SelectTrigger>
<SelectValue placeholder="Select category..." />
</SelectTrigger>
<SelectContent>
<SelectItem value="none">No Category</SelectItem>
{categories.map((c) => (
<SelectItem key={c.id} value={c.id}>{c.name}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="grid grid-cols-3 gap-4">
<div className="space-y-2">
<Label htmlFor="p-price">Price</Label>
<Input id="p-price" type="number" step="0.01" min="0" {...register('price')} placeholder="0.00" />
</div>
<div className="space-y-2">
<Label htmlFor="p-min-price">Min Price</Label>
<Input id="p-min-price" type="number" step="0.01" min="0" {...register('minPrice')} placeholder="0.00" />
</div>
{isRental && (
<div className="space-y-2">
<Label htmlFor="p-rental-rate">Rental / Month</Label>
<Input id="p-rental-rate" type="number" step="0.01" min="0" {...register('rentalRateMonthly')} placeholder="0.00" />
</div>
)}
</div>
{!isSerialized && (
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="p-qty">Qty On Hand</Label>
<Input id="p-qty" type="number" min="0" {...register('qtyOnHand')} />
</div>
<div className="space-y-2">
<Label htmlFor="p-reorder">Reorder Point</Label>
<Input id="p-reorder" type="number" min="0" {...register('qtyReorderPoint')} placeholder="—" />
</div>
</div>
)}
<div className="space-y-2">
<Label htmlFor="p-desc">Description</Label>
<textarea
id="p-desc"
{...register('description')}
rows={3}
placeholder="Product description..."
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring resize-none"
/>
</div>
<div className="space-y-2">
<Label className="text-sm font-medium">Options</Label>
<div className="flex flex-wrap gap-4">
<label className="flex items-center gap-2 text-sm cursor-pointer">
<input type="checkbox" checked={isSerialized} onChange={(e) => setValue('isSerialized', e.target.checked)} className="h-4 w-4" />
Serialized (track individual units)
</label>
<label className="flex items-center gap-2 text-sm cursor-pointer">
<input type="checkbox" checked={isRental} onChange={(e) => setValue('isRental', e.target.checked)} className="h-4 w-4" />
Available for Rental
</label>
<label className="flex items-center gap-2 text-sm cursor-pointer">
<input type="checkbox" checked={isDualUseRepair} onChange={(e) => setValue('isDualUseRepair', e.target.checked)} className="h-4 w-4" />
Available as Repair Line Item
</label>
<label className="flex items-center gap-2 text-sm cursor-pointer">
<input type="checkbox" checked={isActive} onChange={(e) => setValue('isActive', e.target.checked)} className="h-4 w-4" />
Active
</label>
</div>
</div>
<Button type="submit" disabled={loading} className="w-full">
{loading ? 'Saving...' : defaultValues?.id ? 'Save Changes' : 'Create Product'}
</Button>
</form>
)
}

View File

@@ -0,0 +1,88 @@
import { useForm } from 'react-hook-form'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import type { Supplier } from '@/types/inventory'
interface Props {
defaultValues?: Partial<Supplier>
onSubmit: (data: Record<string, unknown>) => void
onDelete?: () => void
loading?: boolean
deleteLoading?: boolean
}
export function SupplierForm({ defaultValues, onSubmit, onDelete, loading, deleteLoading }: Props) {
const { register, handleSubmit } = useForm({
defaultValues: {
name: defaultValues?.name ?? '',
contactName: defaultValues?.contactName ?? '',
email: defaultValues?.email ?? '',
phone: defaultValues?.phone ?? '',
website: defaultValues?.website ?? '',
accountNumber: defaultValues?.accountNumber ?? '',
paymentTerms: defaultValues?.paymentTerms ?? '',
},
})
function handleFormSubmit(data: Record<string, string>) {
onSubmit({
name: data.name,
contactName: data.contactName || undefined,
email: data.email || undefined,
phone: data.phone || undefined,
website: data.website || undefined,
accountNumber: data.accountNumber || undefined,
paymentTerms: data.paymentTerms || undefined,
})
}
return (
<form onSubmit={handleSubmit(handleFormSubmit)} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="sup-name">Name *</Label>
<Input id="sup-name" {...register('name')} placeholder="e.g. Fender Musical Instruments" required />
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="sup-contact">Contact Name</Label>
<Input id="sup-contact" {...register('contactName')} placeholder="Jane Smith" />
</div>
<div className="space-y-2">
<Label htmlFor="sup-email">Email</Label>
<Input id="sup-email" type="email" {...register('email')} placeholder="orders@supplier.com" />
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="sup-phone">Phone</Label>
<Input id="sup-phone" {...register('phone')} placeholder="555-0100" />
</div>
<div className="space-y-2">
<Label htmlFor="sup-website">Website</Label>
<Input id="sup-website" {...register('website')} placeholder="https://supplier.com" />
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="sup-acct">Account Number</Label>
<Input id="sup-acct" {...register('accountNumber')} placeholder="ACC-12345" />
</div>
<div className="space-y-2">
<Label htmlFor="sup-terms">Payment Terms</Label>
<Input id="sup-terms" {...register('paymentTerms')} placeholder="Net 30" />
</div>
</div>
<div className="flex gap-2 pt-2">
<Button type="submit" disabled={loading} className="flex-1">
{loading ? 'Saving...' : defaultValues?.id ? 'Save Changes' : 'Create Supplier'}
</Button>
{onDelete && (
<Button type="button" variant="destructive" disabled={deleteLoading} onClick={onDelete}>
{deleteLoading ? 'Deleting...' : 'Delete'}
</Button>
)}
</div>
</form>
)
}

View File

@@ -0,0 +1,142 @@
import * as React from "react"
import { AlertDialog as AlertDialogPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
import { buttonVariants } from "@/components/ui/button"
function AlertDialog({
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Root>) {
return <AlertDialogPrimitive.Root data-slot="alert-dialog" {...props} />
}
function AlertDialogTrigger({
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Trigger>) {
return <AlertDialogPrimitive.Trigger data-slot="alert-dialog-trigger" {...props} />
}
function AlertDialogPortal({
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Portal>) {
return <AlertDialogPrimitive.Portal data-slot="alert-dialog-portal" {...props} />
}
function AlertDialogOverlay({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Overlay>) {
return (
<AlertDialogPrimitive.Overlay
data-slot="alert-dialog-overlay"
className={cn(
"fixed inset-0 z-50 bg-black/50 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:animate-in data-[state=open]:fade-in-0",
className
)}
{...props}
/>
)
}
function AlertDialogContent({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Content>) {
return (
<AlertDialogPortal>
<AlertDialogOverlay />
<AlertDialogPrimitive.Content
data-slot="alert-dialog-content"
className={cn(
"fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border bg-background p-6 shadow-lg duration-200 outline-none data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95 sm:max-w-lg",
className
)}
{...props}
/>
</AlertDialogPortal>
)
}
function AlertDialogHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-dialog-header"
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
{...props}
/>
)
}
function AlertDialogFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-dialog-footer"
className={cn("flex flex-col-reverse gap-2 sm:flex-row sm:justify-end", className)}
{...props}
/>
)
}
function AlertDialogTitle({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Title>) {
return (
<AlertDialogPrimitive.Title
data-slot="alert-dialog-title"
className={cn("text-lg font-semibold", className)}
{...props}
/>
)
}
function AlertDialogDescription({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Description>) {
return (
<AlertDialogPrimitive.Description
data-slot="alert-dialog-description"
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
)
}
function AlertDialogAction({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Action>) {
return (
<AlertDialogPrimitive.Action
className={cn(buttonVariants(), className)}
{...props}
/>
)
}
function AlertDialogCancel({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Cancel>) {
return (
<AlertDialogPrimitive.Cancel
className={cn(buttonVariants({ variant: "outline" }), className)}
{...props}
/>
)
}
export {
AlertDialog,
AlertDialogPortal,
AlertDialogOverlay,
AlertDialogTrigger,
AlertDialogContent,
AlertDialogHeader,
AlertDialogFooter,
AlertDialogTitle,
AlertDialogDescription,
AlertDialogAction,
AlertDialogCancel,
}

View File

@@ -0,0 +1,30 @@
import * as React from "react"
import { Checkbox as CheckboxPrimitive } from "radix-ui"
import { CheckIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function Checkbox({
className,
...props
}: React.ComponentProps<typeof CheckboxPrimitive.Root>) {
return (
<CheckboxPrimitive.Root
data-slot="checkbox"
className={cn(
"peer size-4 shrink-0 rounded-[4px] border border-input shadow-xs transition-shadow outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground data-[state=checked]:border-primary dark:bg-input/30 dark:data-[state=checked]:bg-primary",
className
)}
{...props}
>
<CheckboxPrimitive.Indicator
data-slot="checkbox-indicator"
className="flex items-center justify-center text-current transition-none"
>
<CheckIcon className="size-3.5" />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
)
}
export { Checkbox }

View File

@@ -21,6 +21,7 @@ import { Route as AuthenticatedRolesIndexRouteImport } from './routes/_authentic
import { Route as AuthenticatedRepairsIndexRouteImport } from './routes/_authenticated/repairs/index' import { Route as AuthenticatedRepairsIndexRouteImport } from './routes/_authenticated/repairs/index'
import { Route as AuthenticatedRepairBatchesIndexRouteImport } from './routes/_authenticated/repair-batches/index' import { Route as AuthenticatedRepairBatchesIndexRouteImport } from './routes/_authenticated/repair-batches/index'
import { Route as AuthenticatedMembersIndexRouteImport } from './routes/_authenticated/members/index' import { Route as AuthenticatedMembersIndexRouteImport } from './routes/_authenticated/members/index'
import { Route as AuthenticatedInventoryIndexRouteImport } from './routes/_authenticated/inventory/index'
import { Route as AuthenticatedFilesIndexRouteImport } from './routes/_authenticated/files/index' import { Route as AuthenticatedFilesIndexRouteImport } from './routes/_authenticated/files/index'
import { Route as AuthenticatedAccountsIndexRouteImport } from './routes/_authenticated/accounts/index' import { Route as AuthenticatedAccountsIndexRouteImport } from './routes/_authenticated/accounts/index'
import { Route as AuthenticatedRolesNewRouteImport } from './routes/_authenticated/roles/new' import { Route as AuthenticatedRolesNewRouteImport } from './routes/_authenticated/roles/new'
@@ -31,6 +32,8 @@ import { Route as AuthenticatedRepairsTicketIdRouteImport } from './routes/_auth
import { Route as AuthenticatedRepairBatchesNewRouteImport } from './routes/_authenticated/repair-batches/new' import { Route as AuthenticatedRepairBatchesNewRouteImport } from './routes/_authenticated/repair-batches/new'
import { Route as AuthenticatedRepairBatchesBatchIdRouteImport } from './routes/_authenticated/repair-batches/$batchId' import { Route as AuthenticatedRepairBatchesBatchIdRouteImport } from './routes/_authenticated/repair-batches/$batchId'
import { Route as AuthenticatedMembersMemberIdRouteImport } from './routes/_authenticated/members/$memberId' import { Route as AuthenticatedMembersMemberIdRouteImport } from './routes/_authenticated/members/$memberId'
import { Route as AuthenticatedInventoryCategoriesRouteImport } from './routes/_authenticated/inventory/categories'
import { Route as AuthenticatedInventoryProductIdRouteImport } from './routes/_authenticated/inventory/$productId'
import { Route as AuthenticatedAccountsNewRouteImport } from './routes/_authenticated/accounts/new' import { Route as AuthenticatedAccountsNewRouteImport } from './routes/_authenticated/accounts/new'
import { Route as AuthenticatedAccountsAccountIdRouteImport } from './routes/_authenticated/accounts/$accountId' import { Route as AuthenticatedAccountsAccountIdRouteImport } from './routes/_authenticated/accounts/$accountId'
import { Route as AuthenticatedLessonsTemplatesIndexRouteImport } from './routes/_authenticated/lessons/templates/index' import { Route as AuthenticatedLessonsTemplatesIndexRouteImport } from './routes/_authenticated/lessons/templates/index'
@@ -38,6 +41,7 @@ import { Route as AuthenticatedLessonsSessionsIndexRouteImport } from './routes/
import { Route as AuthenticatedLessonsScheduleIndexRouteImport } from './routes/_authenticated/lessons/schedule/index' import { Route as AuthenticatedLessonsScheduleIndexRouteImport } from './routes/_authenticated/lessons/schedule/index'
import { Route as AuthenticatedLessonsPlansIndexRouteImport } from './routes/_authenticated/lessons/plans/index' import { Route as AuthenticatedLessonsPlansIndexRouteImport } from './routes/_authenticated/lessons/plans/index'
import { Route as AuthenticatedLessonsEnrollmentsIndexRouteImport } from './routes/_authenticated/lessons/enrollments/index' import { Route as AuthenticatedLessonsEnrollmentsIndexRouteImport } from './routes/_authenticated/lessons/enrollments/index'
import { Route as AuthenticatedInventorySuppliersIndexRouteImport } from './routes/_authenticated/inventory/suppliers/index'
import { Route as AuthenticatedAccountsAccountIdIndexRouteImport } from './routes/_authenticated/accounts/$accountId/index' import { Route as AuthenticatedAccountsAccountIdIndexRouteImport } from './routes/_authenticated/accounts/$accountId/index'
import { Route as AuthenticatedLessonsTemplatesNewRouteImport } from './routes/_authenticated/lessons/templates/new' import { Route as AuthenticatedLessonsTemplatesNewRouteImport } from './routes/_authenticated/lessons/templates/new'
import { Route as AuthenticatedLessonsTemplatesTemplateIdRouteImport } from './routes/_authenticated/lessons/templates/$templateId' import { Route as AuthenticatedLessonsTemplatesTemplateIdRouteImport } from './routes/_authenticated/lessons/templates/$templateId'
@@ -114,6 +118,12 @@ const AuthenticatedMembersIndexRoute =
path: '/members/', path: '/members/',
getParentRoute: () => AuthenticatedRoute, getParentRoute: () => AuthenticatedRoute,
} as any) } as any)
const AuthenticatedInventoryIndexRoute =
AuthenticatedInventoryIndexRouteImport.update({
id: '/inventory/',
path: '/inventory/',
getParentRoute: () => AuthenticatedRoute,
} as any)
const AuthenticatedFilesIndexRoute = AuthenticatedFilesIndexRouteImport.update({ const AuthenticatedFilesIndexRoute = AuthenticatedFilesIndexRouteImport.update({
id: '/files/', id: '/files/',
path: '/files/', path: '/files/',
@@ -171,6 +181,18 @@ const AuthenticatedMembersMemberIdRoute =
path: '/members/$memberId', path: '/members/$memberId',
getParentRoute: () => AuthenticatedRoute, getParentRoute: () => AuthenticatedRoute,
} as any) } as any)
const AuthenticatedInventoryCategoriesRoute =
AuthenticatedInventoryCategoriesRouteImport.update({
id: '/inventory/categories',
path: '/inventory/categories',
getParentRoute: () => AuthenticatedRoute,
} as any)
const AuthenticatedInventoryProductIdRoute =
AuthenticatedInventoryProductIdRouteImport.update({
id: '/inventory/$productId',
path: '/inventory/$productId',
getParentRoute: () => AuthenticatedRoute,
} as any)
const AuthenticatedAccountsNewRoute = const AuthenticatedAccountsNewRoute =
AuthenticatedAccountsNewRouteImport.update({ AuthenticatedAccountsNewRouteImport.update({
id: '/accounts/new', id: '/accounts/new',
@@ -213,6 +235,12 @@ const AuthenticatedLessonsEnrollmentsIndexRoute =
path: '/lessons/enrollments/', path: '/lessons/enrollments/',
getParentRoute: () => AuthenticatedRoute, getParentRoute: () => AuthenticatedRoute,
} as any) } as any)
const AuthenticatedInventorySuppliersIndexRoute =
AuthenticatedInventorySuppliersIndexRouteImport.update({
id: '/inventory/suppliers/',
path: '/inventory/suppliers/',
getParentRoute: () => AuthenticatedRoute,
} as any)
const AuthenticatedAccountsAccountIdIndexRoute = const AuthenticatedAccountsAccountIdIndexRoute =
AuthenticatedAccountsAccountIdIndexRouteImport.update({ AuthenticatedAccountsAccountIdIndexRouteImport.update({
id: '/', id: '/',
@@ -301,6 +329,8 @@ export interface FileRoutesByFullPath {
'/users': typeof AuthenticatedUsersRoute '/users': typeof AuthenticatedUsersRoute
'/accounts/$accountId': typeof AuthenticatedAccountsAccountIdRouteWithChildren '/accounts/$accountId': typeof AuthenticatedAccountsAccountIdRouteWithChildren
'/accounts/new': typeof AuthenticatedAccountsNewRoute '/accounts/new': typeof AuthenticatedAccountsNewRoute
'/inventory/$productId': typeof AuthenticatedInventoryProductIdRoute
'/inventory/categories': typeof AuthenticatedInventoryCategoriesRoute
'/members/$memberId': typeof AuthenticatedMembersMemberIdRoute '/members/$memberId': typeof AuthenticatedMembersMemberIdRoute
'/repair-batches/$batchId': typeof AuthenticatedRepairBatchesBatchIdRoute '/repair-batches/$batchId': typeof AuthenticatedRepairBatchesBatchIdRoute
'/repair-batches/new': typeof AuthenticatedRepairBatchesNewRoute '/repair-batches/new': typeof AuthenticatedRepairBatchesNewRoute
@@ -311,6 +341,7 @@ export interface FileRoutesByFullPath {
'/roles/new': typeof AuthenticatedRolesNewRoute '/roles/new': typeof AuthenticatedRolesNewRoute
'/accounts/': typeof AuthenticatedAccountsIndexRoute '/accounts/': typeof AuthenticatedAccountsIndexRoute
'/files/': typeof AuthenticatedFilesIndexRoute '/files/': typeof AuthenticatedFilesIndexRoute
'/inventory/': typeof AuthenticatedInventoryIndexRoute
'/members/': typeof AuthenticatedMembersIndexRoute '/members/': typeof AuthenticatedMembersIndexRoute
'/repair-batches/': typeof AuthenticatedRepairBatchesIndexRoute '/repair-batches/': typeof AuthenticatedRepairBatchesIndexRoute
'/repairs/': typeof AuthenticatedRepairsIndexRoute '/repairs/': typeof AuthenticatedRepairsIndexRoute
@@ -328,6 +359,7 @@ export interface FileRoutesByFullPath {
'/lessons/templates/$templateId': typeof AuthenticatedLessonsTemplatesTemplateIdRoute '/lessons/templates/$templateId': typeof AuthenticatedLessonsTemplatesTemplateIdRoute
'/lessons/templates/new': typeof AuthenticatedLessonsTemplatesNewRoute '/lessons/templates/new': typeof AuthenticatedLessonsTemplatesNewRoute
'/accounts/$accountId/': typeof AuthenticatedAccountsAccountIdIndexRoute '/accounts/$accountId/': typeof AuthenticatedAccountsAccountIdIndexRoute
'/inventory/suppliers/': typeof AuthenticatedInventorySuppliersIndexRoute
'/lessons/enrollments/': typeof AuthenticatedLessonsEnrollmentsIndexRoute '/lessons/enrollments/': typeof AuthenticatedLessonsEnrollmentsIndexRoute
'/lessons/plans/': typeof AuthenticatedLessonsPlansIndexRoute '/lessons/plans/': typeof AuthenticatedLessonsPlansIndexRoute
'/lessons/schedule/': typeof AuthenticatedLessonsScheduleIndexRoute '/lessons/schedule/': typeof AuthenticatedLessonsScheduleIndexRoute
@@ -343,6 +375,8 @@ export interface FileRoutesByTo {
'/users': typeof AuthenticatedUsersRoute '/users': typeof AuthenticatedUsersRoute
'/': typeof AuthenticatedIndexRoute '/': typeof AuthenticatedIndexRoute
'/accounts/new': typeof AuthenticatedAccountsNewRoute '/accounts/new': typeof AuthenticatedAccountsNewRoute
'/inventory/$productId': typeof AuthenticatedInventoryProductIdRoute
'/inventory/categories': typeof AuthenticatedInventoryCategoriesRoute
'/members/$memberId': typeof AuthenticatedMembersMemberIdRoute '/members/$memberId': typeof AuthenticatedMembersMemberIdRoute
'/repair-batches/$batchId': typeof AuthenticatedRepairBatchesBatchIdRoute '/repair-batches/$batchId': typeof AuthenticatedRepairBatchesBatchIdRoute
'/repair-batches/new': typeof AuthenticatedRepairBatchesNewRoute '/repair-batches/new': typeof AuthenticatedRepairBatchesNewRoute
@@ -353,6 +387,7 @@ export interface FileRoutesByTo {
'/roles/new': typeof AuthenticatedRolesNewRoute '/roles/new': typeof AuthenticatedRolesNewRoute
'/accounts': typeof AuthenticatedAccountsIndexRoute '/accounts': typeof AuthenticatedAccountsIndexRoute
'/files': typeof AuthenticatedFilesIndexRoute '/files': typeof AuthenticatedFilesIndexRoute
'/inventory': typeof AuthenticatedInventoryIndexRoute
'/members': typeof AuthenticatedMembersIndexRoute '/members': typeof AuthenticatedMembersIndexRoute
'/repair-batches': typeof AuthenticatedRepairBatchesIndexRoute '/repair-batches': typeof AuthenticatedRepairBatchesIndexRoute
'/repairs': typeof AuthenticatedRepairsIndexRoute '/repairs': typeof AuthenticatedRepairsIndexRoute
@@ -370,6 +405,7 @@ export interface FileRoutesByTo {
'/lessons/templates/$templateId': typeof AuthenticatedLessonsTemplatesTemplateIdRoute '/lessons/templates/$templateId': typeof AuthenticatedLessonsTemplatesTemplateIdRoute
'/lessons/templates/new': typeof AuthenticatedLessonsTemplatesNewRoute '/lessons/templates/new': typeof AuthenticatedLessonsTemplatesNewRoute
'/accounts/$accountId': typeof AuthenticatedAccountsAccountIdIndexRoute '/accounts/$accountId': typeof AuthenticatedAccountsAccountIdIndexRoute
'/inventory/suppliers': typeof AuthenticatedInventorySuppliersIndexRoute
'/lessons/enrollments': typeof AuthenticatedLessonsEnrollmentsIndexRoute '/lessons/enrollments': typeof AuthenticatedLessonsEnrollmentsIndexRoute
'/lessons/plans': typeof AuthenticatedLessonsPlansIndexRoute '/lessons/plans': typeof AuthenticatedLessonsPlansIndexRoute
'/lessons/schedule': typeof AuthenticatedLessonsScheduleIndexRoute '/lessons/schedule': typeof AuthenticatedLessonsScheduleIndexRoute
@@ -388,6 +424,8 @@ export interface FileRoutesById {
'/_authenticated/': typeof AuthenticatedIndexRoute '/_authenticated/': typeof AuthenticatedIndexRoute
'/_authenticated/accounts/$accountId': typeof AuthenticatedAccountsAccountIdRouteWithChildren '/_authenticated/accounts/$accountId': typeof AuthenticatedAccountsAccountIdRouteWithChildren
'/_authenticated/accounts/new': typeof AuthenticatedAccountsNewRoute '/_authenticated/accounts/new': typeof AuthenticatedAccountsNewRoute
'/_authenticated/inventory/$productId': typeof AuthenticatedInventoryProductIdRoute
'/_authenticated/inventory/categories': typeof AuthenticatedInventoryCategoriesRoute
'/_authenticated/members/$memberId': typeof AuthenticatedMembersMemberIdRoute '/_authenticated/members/$memberId': typeof AuthenticatedMembersMemberIdRoute
'/_authenticated/repair-batches/$batchId': typeof AuthenticatedRepairBatchesBatchIdRoute '/_authenticated/repair-batches/$batchId': typeof AuthenticatedRepairBatchesBatchIdRoute
'/_authenticated/repair-batches/new': typeof AuthenticatedRepairBatchesNewRoute '/_authenticated/repair-batches/new': typeof AuthenticatedRepairBatchesNewRoute
@@ -398,6 +436,7 @@ export interface FileRoutesById {
'/_authenticated/roles/new': typeof AuthenticatedRolesNewRoute '/_authenticated/roles/new': typeof AuthenticatedRolesNewRoute
'/_authenticated/accounts/': typeof AuthenticatedAccountsIndexRoute '/_authenticated/accounts/': typeof AuthenticatedAccountsIndexRoute
'/_authenticated/files/': typeof AuthenticatedFilesIndexRoute '/_authenticated/files/': typeof AuthenticatedFilesIndexRoute
'/_authenticated/inventory/': typeof AuthenticatedInventoryIndexRoute
'/_authenticated/members/': typeof AuthenticatedMembersIndexRoute '/_authenticated/members/': typeof AuthenticatedMembersIndexRoute
'/_authenticated/repair-batches/': typeof AuthenticatedRepairBatchesIndexRoute '/_authenticated/repair-batches/': typeof AuthenticatedRepairBatchesIndexRoute
'/_authenticated/repairs/': typeof AuthenticatedRepairsIndexRoute '/_authenticated/repairs/': typeof AuthenticatedRepairsIndexRoute
@@ -415,6 +454,7 @@ export interface FileRoutesById {
'/_authenticated/lessons/templates/$templateId': typeof AuthenticatedLessonsTemplatesTemplateIdRoute '/_authenticated/lessons/templates/$templateId': typeof AuthenticatedLessonsTemplatesTemplateIdRoute
'/_authenticated/lessons/templates/new': typeof AuthenticatedLessonsTemplatesNewRoute '/_authenticated/lessons/templates/new': typeof AuthenticatedLessonsTemplatesNewRoute
'/_authenticated/accounts/$accountId/': typeof AuthenticatedAccountsAccountIdIndexRoute '/_authenticated/accounts/$accountId/': typeof AuthenticatedAccountsAccountIdIndexRoute
'/_authenticated/inventory/suppliers/': typeof AuthenticatedInventorySuppliersIndexRoute
'/_authenticated/lessons/enrollments/': typeof AuthenticatedLessonsEnrollmentsIndexRoute '/_authenticated/lessons/enrollments/': typeof AuthenticatedLessonsEnrollmentsIndexRoute
'/_authenticated/lessons/plans/': typeof AuthenticatedLessonsPlansIndexRoute '/_authenticated/lessons/plans/': typeof AuthenticatedLessonsPlansIndexRoute
'/_authenticated/lessons/schedule/': typeof AuthenticatedLessonsScheduleIndexRoute '/_authenticated/lessons/schedule/': typeof AuthenticatedLessonsScheduleIndexRoute
@@ -433,6 +473,8 @@ export interface FileRouteTypes {
| '/users' | '/users'
| '/accounts/$accountId' | '/accounts/$accountId'
| '/accounts/new' | '/accounts/new'
| '/inventory/$productId'
| '/inventory/categories'
| '/members/$memberId' | '/members/$memberId'
| '/repair-batches/$batchId' | '/repair-batches/$batchId'
| '/repair-batches/new' | '/repair-batches/new'
@@ -443,6 +485,7 @@ export interface FileRouteTypes {
| '/roles/new' | '/roles/new'
| '/accounts/' | '/accounts/'
| '/files/' | '/files/'
| '/inventory/'
| '/members/' | '/members/'
| '/repair-batches/' | '/repair-batches/'
| '/repairs/' | '/repairs/'
@@ -460,6 +503,7 @@ export interface FileRouteTypes {
| '/lessons/templates/$templateId' | '/lessons/templates/$templateId'
| '/lessons/templates/new' | '/lessons/templates/new'
| '/accounts/$accountId/' | '/accounts/$accountId/'
| '/inventory/suppliers/'
| '/lessons/enrollments/' | '/lessons/enrollments/'
| '/lessons/plans/' | '/lessons/plans/'
| '/lessons/schedule/' | '/lessons/schedule/'
@@ -475,6 +519,8 @@ export interface FileRouteTypes {
| '/users' | '/users'
| '/' | '/'
| '/accounts/new' | '/accounts/new'
| '/inventory/$productId'
| '/inventory/categories'
| '/members/$memberId' | '/members/$memberId'
| '/repair-batches/$batchId' | '/repair-batches/$batchId'
| '/repair-batches/new' | '/repair-batches/new'
@@ -485,6 +531,7 @@ export interface FileRouteTypes {
| '/roles/new' | '/roles/new'
| '/accounts' | '/accounts'
| '/files' | '/files'
| '/inventory'
| '/members' | '/members'
| '/repair-batches' | '/repair-batches'
| '/repairs' | '/repairs'
@@ -502,6 +549,7 @@ export interface FileRouteTypes {
| '/lessons/templates/$templateId' | '/lessons/templates/$templateId'
| '/lessons/templates/new' | '/lessons/templates/new'
| '/accounts/$accountId' | '/accounts/$accountId'
| '/inventory/suppliers'
| '/lessons/enrollments' | '/lessons/enrollments'
| '/lessons/plans' | '/lessons/plans'
| '/lessons/schedule' | '/lessons/schedule'
@@ -519,6 +567,8 @@ export interface FileRouteTypes {
| '/_authenticated/' | '/_authenticated/'
| '/_authenticated/accounts/$accountId' | '/_authenticated/accounts/$accountId'
| '/_authenticated/accounts/new' | '/_authenticated/accounts/new'
| '/_authenticated/inventory/$productId'
| '/_authenticated/inventory/categories'
| '/_authenticated/members/$memberId' | '/_authenticated/members/$memberId'
| '/_authenticated/repair-batches/$batchId' | '/_authenticated/repair-batches/$batchId'
| '/_authenticated/repair-batches/new' | '/_authenticated/repair-batches/new'
@@ -529,6 +579,7 @@ export interface FileRouteTypes {
| '/_authenticated/roles/new' | '/_authenticated/roles/new'
| '/_authenticated/accounts/' | '/_authenticated/accounts/'
| '/_authenticated/files/' | '/_authenticated/files/'
| '/_authenticated/inventory/'
| '/_authenticated/members/' | '/_authenticated/members/'
| '/_authenticated/repair-batches/' | '/_authenticated/repair-batches/'
| '/_authenticated/repairs/' | '/_authenticated/repairs/'
@@ -546,6 +597,7 @@ export interface FileRouteTypes {
| '/_authenticated/lessons/templates/$templateId' | '/_authenticated/lessons/templates/$templateId'
| '/_authenticated/lessons/templates/new' | '/_authenticated/lessons/templates/new'
| '/_authenticated/accounts/$accountId/' | '/_authenticated/accounts/$accountId/'
| '/_authenticated/inventory/suppliers/'
| '/_authenticated/lessons/enrollments/' | '/_authenticated/lessons/enrollments/'
| '/_authenticated/lessons/plans/' | '/_authenticated/lessons/plans/'
| '/_authenticated/lessons/schedule/' | '/_authenticated/lessons/schedule/'
@@ -645,6 +697,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof AuthenticatedMembersIndexRouteImport preLoaderRoute: typeof AuthenticatedMembersIndexRouteImport
parentRoute: typeof AuthenticatedRoute parentRoute: typeof AuthenticatedRoute
} }
'/_authenticated/inventory/': {
id: '/_authenticated/inventory/'
path: '/inventory'
fullPath: '/inventory/'
preLoaderRoute: typeof AuthenticatedInventoryIndexRouteImport
parentRoute: typeof AuthenticatedRoute
}
'/_authenticated/files/': { '/_authenticated/files/': {
id: '/_authenticated/files/' id: '/_authenticated/files/'
path: '/files' path: '/files'
@@ -715,6 +774,20 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof AuthenticatedMembersMemberIdRouteImport preLoaderRoute: typeof AuthenticatedMembersMemberIdRouteImport
parentRoute: typeof AuthenticatedRoute parentRoute: typeof AuthenticatedRoute
} }
'/_authenticated/inventory/categories': {
id: '/_authenticated/inventory/categories'
path: '/inventory/categories'
fullPath: '/inventory/categories'
preLoaderRoute: typeof AuthenticatedInventoryCategoriesRouteImport
parentRoute: typeof AuthenticatedRoute
}
'/_authenticated/inventory/$productId': {
id: '/_authenticated/inventory/$productId'
path: '/inventory/$productId'
fullPath: '/inventory/$productId'
preLoaderRoute: typeof AuthenticatedInventoryProductIdRouteImport
parentRoute: typeof AuthenticatedRoute
}
'/_authenticated/accounts/new': { '/_authenticated/accounts/new': {
id: '/_authenticated/accounts/new' id: '/_authenticated/accounts/new'
path: '/accounts/new' path: '/accounts/new'
@@ -764,6 +837,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof AuthenticatedLessonsEnrollmentsIndexRouteImport preLoaderRoute: typeof AuthenticatedLessonsEnrollmentsIndexRouteImport
parentRoute: typeof AuthenticatedRoute parentRoute: typeof AuthenticatedRoute
} }
'/_authenticated/inventory/suppliers/': {
id: '/_authenticated/inventory/suppliers/'
path: '/inventory/suppliers'
fullPath: '/inventory/suppliers/'
preLoaderRoute: typeof AuthenticatedInventorySuppliersIndexRouteImport
parentRoute: typeof AuthenticatedRoute
}
'/_authenticated/accounts/$accountId/': { '/_authenticated/accounts/$accountId/': {
id: '/_authenticated/accounts/$accountId/' id: '/_authenticated/accounts/$accountId/'
path: '/' path: '/'
@@ -896,6 +976,8 @@ interface AuthenticatedRouteChildren {
AuthenticatedIndexRoute: typeof AuthenticatedIndexRoute AuthenticatedIndexRoute: typeof AuthenticatedIndexRoute
AuthenticatedAccountsAccountIdRoute: typeof AuthenticatedAccountsAccountIdRouteWithChildren AuthenticatedAccountsAccountIdRoute: typeof AuthenticatedAccountsAccountIdRouteWithChildren
AuthenticatedAccountsNewRoute: typeof AuthenticatedAccountsNewRoute AuthenticatedAccountsNewRoute: typeof AuthenticatedAccountsNewRoute
AuthenticatedInventoryProductIdRoute: typeof AuthenticatedInventoryProductIdRoute
AuthenticatedInventoryCategoriesRoute: typeof AuthenticatedInventoryCategoriesRoute
AuthenticatedMembersMemberIdRoute: typeof AuthenticatedMembersMemberIdRoute AuthenticatedMembersMemberIdRoute: typeof AuthenticatedMembersMemberIdRoute
AuthenticatedRepairBatchesBatchIdRoute: typeof AuthenticatedRepairBatchesBatchIdRoute AuthenticatedRepairBatchesBatchIdRoute: typeof AuthenticatedRepairBatchesBatchIdRoute
AuthenticatedRepairBatchesNewRoute: typeof AuthenticatedRepairBatchesNewRoute AuthenticatedRepairBatchesNewRoute: typeof AuthenticatedRepairBatchesNewRoute
@@ -906,6 +988,7 @@ interface AuthenticatedRouteChildren {
AuthenticatedRolesNewRoute: typeof AuthenticatedRolesNewRoute AuthenticatedRolesNewRoute: typeof AuthenticatedRolesNewRoute
AuthenticatedAccountsIndexRoute: typeof AuthenticatedAccountsIndexRoute AuthenticatedAccountsIndexRoute: typeof AuthenticatedAccountsIndexRoute
AuthenticatedFilesIndexRoute: typeof AuthenticatedFilesIndexRoute AuthenticatedFilesIndexRoute: typeof AuthenticatedFilesIndexRoute
AuthenticatedInventoryIndexRoute: typeof AuthenticatedInventoryIndexRoute
AuthenticatedMembersIndexRoute: typeof AuthenticatedMembersIndexRoute AuthenticatedMembersIndexRoute: typeof AuthenticatedMembersIndexRoute
AuthenticatedRepairBatchesIndexRoute: typeof AuthenticatedRepairBatchesIndexRoute AuthenticatedRepairBatchesIndexRoute: typeof AuthenticatedRepairBatchesIndexRoute
AuthenticatedRepairsIndexRoute: typeof AuthenticatedRepairsIndexRoute AuthenticatedRepairsIndexRoute: typeof AuthenticatedRepairsIndexRoute
@@ -917,6 +1000,7 @@ interface AuthenticatedRouteChildren {
AuthenticatedLessonsSessionsSessionIdRoute: typeof AuthenticatedLessonsSessionsSessionIdRoute AuthenticatedLessonsSessionsSessionIdRoute: typeof AuthenticatedLessonsSessionsSessionIdRoute
AuthenticatedLessonsTemplatesTemplateIdRoute: typeof AuthenticatedLessonsTemplatesTemplateIdRoute AuthenticatedLessonsTemplatesTemplateIdRoute: typeof AuthenticatedLessonsTemplatesTemplateIdRoute
AuthenticatedLessonsTemplatesNewRoute: typeof AuthenticatedLessonsTemplatesNewRoute AuthenticatedLessonsTemplatesNewRoute: typeof AuthenticatedLessonsTemplatesNewRoute
AuthenticatedInventorySuppliersIndexRoute: typeof AuthenticatedInventorySuppliersIndexRoute
AuthenticatedLessonsEnrollmentsIndexRoute: typeof AuthenticatedLessonsEnrollmentsIndexRoute AuthenticatedLessonsEnrollmentsIndexRoute: typeof AuthenticatedLessonsEnrollmentsIndexRoute
AuthenticatedLessonsPlansIndexRoute: typeof AuthenticatedLessonsPlansIndexRoute AuthenticatedLessonsPlansIndexRoute: typeof AuthenticatedLessonsPlansIndexRoute
AuthenticatedLessonsScheduleIndexRoute: typeof AuthenticatedLessonsScheduleIndexRoute AuthenticatedLessonsScheduleIndexRoute: typeof AuthenticatedLessonsScheduleIndexRoute
@@ -934,6 +1018,8 @@ const AuthenticatedRouteChildren: AuthenticatedRouteChildren = {
AuthenticatedAccountsAccountIdRoute: AuthenticatedAccountsAccountIdRoute:
AuthenticatedAccountsAccountIdRouteWithChildren, AuthenticatedAccountsAccountIdRouteWithChildren,
AuthenticatedAccountsNewRoute: AuthenticatedAccountsNewRoute, AuthenticatedAccountsNewRoute: AuthenticatedAccountsNewRoute,
AuthenticatedInventoryProductIdRoute: AuthenticatedInventoryProductIdRoute,
AuthenticatedInventoryCategoriesRoute: AuthenticatedInventoryCategoriesRoute,
AuthenticatedMembersMemberIdRoute: AuthenticatedMembersMemberIdRoute, AuthenticatedMembersMemberIdRoute: AuthenticatedMembersMemberIdRoute,
AuthenticatedRepairBatchesBatchIdRoute: AuthenticatedRepairBatchesBatchIdRoute:
AuthenticatedRepairBatchesBatchIdRoute, AuthenticatedRepairBatchesBatchIdRoute,
@@ -945,6 +1031,7 @@ const AuthenticatedRouteChildren: AuthenticatedRouteChildren = {
AuthenticatedRolesNewRoute: AuthenticatedRolesNewRoute, AuthenticatedRolesNewRoute: AuthenticatedRolesNewRoute,
AuthenticatedAccountsIndexRoute: AuthenticatedAccountsIndexRoute, AuthenticatedAccountsIndexRoute: AuthenticatedAccountsIndexRoute,
AuthenticatedFilesIndexRoute: AuthenticatedFilesIndexRoute, AuthenticatedFilesIndexRoute: AuthenticatedFilesIndexRoute,
AuthenticatedInventoryIndexRoute: AuthenticatedInventoryIndexRoute,
AuthenticatedMembersIndexRoute: AuthenticatedMembersIndexRoute, AuthenticatedMembersIndexRoute: AuthenticatedMembersIndexRoute,
AuthenticatedRepairBatchesIndexRoute: AuthenticatedRepairBatchesIndexRoute, AuthenticatedRepairBatchesIndexRoute: AuthenticatedRepairBatchesIndexRoute,
AuthenticatedRepairsIndexRoute: AuthenticatedRepairsIndexRoute, AuthenticatedRepairsIndexRoute: AuthenticatedRepairsIndexRoute,
@@ -960,6 +1047,8 @@ const AuthenticatedRouteChildren: AuthenticatedRouteChildren = {
AuthenticatedLessonsTemplatesTemplateIdRoute: AuthenticatedLessonsTemplatesTemplateIdRoute:
AuthenticatedLessonsTemplatesTemplateIdRoute, AuthenticatedLessonsTemplatesTemplateIdRoute,
AuthenticatedLessonsTemplatesNewRoute: AuthenticatedLessonsTemplatesNewRoute, AuthenticatedLessonsTemplatesNewRoute: AuthenticatedLessonsTemplatesNewRoute,
AuthenticatedInventorySuppliersIndexRoute:
AuthenticatedInventorySuppliersIndexRoute,
AuthenticatedLessonsEnrollmentsIndexRoute: AuthenticatedLessonsEnrollmentsIndexRoute:
AuthenticatedLessonsEnrollmentsIndexRoute, AuthenticatedLessonsEnrollmentsIndexRoute,
AuthenticatedLessonsPlansIndexRoute: AuthenticatedLessonsPlansIndexRoute, AuthenticatedLessonsPlansIndexRoute: AuthenticatedLessonsPlansIndexRoute,

View File

@@ -8,7 +8,7 @@ import { myPermissionsOptions } from '@/api/rbac'
import { moduleListOptions } from '@/api/modules' import { moduleListOptions } from '@/api/modules'
import { Avatar } from '@/components/shared/avatar-upload' import { Avatar } from '@/components/shared/avatar-upload'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Users, UserRound, HelpCircle, Shield, UserCog, LogOut, User, Wrench, Package, ClipboardList, FolderOpen, KeyRound, Settings, PanelLeftClose, PanelLeft, CalendarDays, GraduationCap, CalendarRange, BookOpen, BookMarked } from 'lucide-react' import { Users, UserRound, HelpCircle, Shield, UserCog, LogOut, User, Wrench, Package, ClipboardList, FolderOpen, KeyRound, Settings, PanelLeftClose, PanelLeft, CalendarDays, GraduationCap, CalendarRange, BookOpen, BookMarked, Package2, Tag, Truck } from 'lucide-react'
export const Route = createFileRoute('/_authenticated')({ export const Route = createFileRoute('/_authenticated')({
beforeLoad: () => { beforeLoad: () => {
@@ -143,6 +143,7 @@ function AuthenticatedLayout() {
const canViewAccounts = !permissionsLoaded || hasPermission('accounts.view') const canViewAccounts = !permissionsLoaded || hasPermission('accounts.view')
const canViewRepairs = !permissionsLoaded || hasPermission('repairs.view') const canViewRepairs = !permissionsLoaded || hasPermission('repairs.view')
const canViewLessons = !permissionsLoaded || hasPermission('lessons.view') const canViewLessons = !permissionsLoaded || hasPermission('lessons.view')
const canViewInventory = !permissionsLoaded || hasPermission('inventory.view')
const canViewUsers = !permissionsLoaded || hasPermission('users.view') const canViewUsers = !permissionsLoaded || hasPermission('users.view')
const [collapsed, setCollapsed] = useState(false) const [collapsed, setCollapsed] = useState(false)
@@ -178,6 +179,13 @@ function AuthenticatedLayout() {
<NavLink to="/members" icon={<UserRound className="h-4 w-4" />} label="Members" collapsed={collapsed} /> <NavLink to="/members" icon={<UserRound className="h-4 w-4" />} label="Members" collapsed={collapsed} />
</NavGroup> </NavGroup>
)} )}
{isModuleEnabled('inventory') && canViewInventory && (
<NavGroup label="Inventory" collapsed={collapsed}>
<NavLink to="/inventory" icon={<Package2 className="h-4 w-4" />} label="Products" collapsed={collapsed} />
<NavLink to="/inventory/categories" icon={<Tag className="h-4 w-4" />} label="Categories" collapsed={collapsed} />
<NavLink to="/inventory/suppliers" icon={<Truck className="h-4 w-4" />} label="Suppliers" collapsed={collapsed} />
</NavGroup>
)}
{isModuleEnabled('repairs') && canViewRepairs && ( {isModuleEnabled('repairs') && canViewRepairs && (
<NavGroup label="Repairs" collapsed={collapsed}> <NavGroup label="Repairs" collapsed={collapsed}>
<NavLink to="/repairs" icon={<Wrench className="h-4 w-4" />} label="Tickets" collapsed={collapsed} /> <NavLink to="/repairs" icon={<Wrench className="h-4 w-4" />} label="Tickets" collapsed={collapsed} />

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

View File

@@ -0,0 +1,108 @@
export type UnitCondition = 'new' | 'excellent' | 'good' | 'fair' | 'poor'
export type UnitStatus =
| 'available'
| 'sold'
| 'rented'
| 'on_trial'
| 'in_repair'
| 'layaway'
| 'lost'
| 'retired'
export interface Category {
id: string
name: string
parentId: string | null
sortOrder: number
isActive: boolean
createdAt: string
updatedAt: string
}
export interface Supplier {
id: string
name: string
contactName: string | null
email: string | null
phone: string | null
website: string | null
accountNumber: string | null
paymentTerms: string | null
createdAt: string
updatedAt: string
}
export interface Product {
id: string
name: string
sku: string | null
upc: string | null
brand: string | null
model: string | null
description: string | null
categoryId: string | null
isSerialized: boolean
isRental: boolean
isDualUseRepair: boolean
price: string | null
minPrice: string | null
rentalRateMonthly: string | null
qtyOnHand: number
qtyReorderPoint: number | null
isActive: boolean
createdAt: string
updatedAt: string
}
export interface InventoryUnit {
id: string
productId: string
serialNumber: string | null
condition: UnitCondition
status: UnitStatus
purchaseDate: string | null
purchaseCost: string | null
notes: string | null
createdAt: string
}
export interface ProductSupplier {
id: string
productId: string
supplierId: string
supplierSku: string | null
isPreferred: boolean
createdAt: string
supplierName: string
supplierContactName: string | null
supplierEmail: string | null
supplierPhone: string | null
supplierAccountNumber: string | null
supplierPaymentTerms: string | null
}
export interface StockReceipt {
id: string
productId: string
supplierId: string | null
inventoryUnitId: string | null
qty: number
costPerUnit: string
totalCost: string
receivedDate: string
invoiceNumber: string | null
notes: string | null
createdAt: string
supplierName: string | null
}
export interface PriceHistory {
id: string
productId: string
previousPrice: string | null
newPrice: string
previousMinPrice: string | null
newMinPrice: string | null
createdAt: string
changedBy: string | null
}

View File

@@ -0,0 +1,364 @@
import { suite } from '../lib/context.js'
suite('Inventory', { tags: ['inventory'] }, (t) => {
// ─── Categories ────────────────────────────────────────────────────────────
t.test('creates a category', { tags: ['categories', 'create'] }, async () => {
const res = await t.api.post('/v1/categories', { name: 'Violins', sortOrder: 1 })
t.assert.status(res, 201)
t.assert.equal(res.data.name, 'Violins')
t.assert.equal(res.data.sortOrder, 1)
t.assert.equal(res.data.isActive, true)
t.assert.ok(res.data.id)
})
t.test('rejects category without name', { tags: ['categories', 'create', 'validation'] }, async () => {
const res = await t.api.post('/v1/categories', {})
t.assert.status(res, 400)
})
t.test('lists categories with pagination', { tags: ['categories', 'list'] }, async () => {
await t.api.post('/v1/categories', { name: 'Cat List A' })
await t.api.post('/v1/categories', { name: 'Cat List B' })
const res = await t.api.get('/v1/categories', { page: 1, limit: 25 })
t.assert.status(res, 200)
t.assert.ok(res.data.data.length >= 2)
t.assert.ok(res.data.pagination)
})
t.test('gets category by id', { tags: ['categories', 'read'] }, async () => {
const created = await t.api.post('/v1/categories', { name: 'Get By ID Cat' })
const res = await t.api.get(`/v1/categories/${created.data.id}`)
t.assert.status(res, 200)
t.assert.equal(res.data.name, 'Get By ID Cat')
})
t.test('returns 404 for missing category', { tags: ['categories', 'read'] }, async () => {
const res = await t.api.get('/v1/categories/a0000000-0000-0000-0000-999999999999')
t.assert.status(res, 404)
})
t.test('updates a category', { tags: ['categories', 'update'] }, async () => {
const created = await t.api.post('/v1/categories', { name: 'Before Update' })
const res = await t.api.patch(`/v1/categories/${created.data.id}`, { name: 'After Update', sortOrder: 5 })
t.assert.status(res, 200)
t.assert.equal(res.data.name, 'After Update')
t.assert.equal(res.data.sortOrder, 5)
})
t.test('soft-deletes a category', { tags: ['categories', 'delete'] }, async () => {
const created = await t.api.post('/v1/categories', { name: 'To Delete Cat' })
const res = await t.api.del(`/v1/categories/${created.data.id}`)
t.assert.status(res, 200)
t.assert.equal(res.data.isActive, false)
})
// ─── Suppliers ─────────────────────────────────────────────────────────────
t.test('creates a supplier', { tags: ['suppliers', 'create'] }, async () => {
const res = await t.api.post('/v1/suppliers', {
name: 'Shar Music',
email: 'orders@sharmusic.com',
phone: '800-248-7427',
paymentTerms: 'Net 30',
})
t.assert.status(res, 201)
t.assert.equal(res.data.name, 'Shar Music')
t.assert.equal(res.data.paymentTerms, 'Net 30')
t.assert.ok(res.data.id)
})
t.test('rejects supplier without name', { tags: ['suppliers', 'create', 'validation'] }, async () => {
const res = await t.api.post('/v1/suppliers', { email: 'x@x.com' })
t.assert.status(res, 400)
})
t.test('lists suppliers with pagination', { tags: ['suppliers', 'list'] }, async () => {
await t.api.post('/v1/suppliers', { name: 'Sup List A' })
await t.api.post('/v1/suppliers', { name: 'Sup List B' })
const res = await t.api.get('/v1/suppliers', { page: 1, limit: 25 })
t.assert.status(res, 200)
t.assert.ok(res.data.data.length >= 2)
t.assert.ok(res.data.pagination.total >= 2)
})
t.test('searches suppliers by name', { tags: ['suppliers', 'list', 'search'] }, async () => {
await t.api.post('/v1/suppliers', { name: 'SearchableSupplierXYZ' })
const res = await t.api.get('/v1/suppliers', { q: 'SearchableSupplierXYZ' })
t.assert.status(res, 200)
t.assert.equal(res.data.data.length, 1)
t.assert.equal(res.data.data[0].name, 'SearchableSupplierXYZ')
})
t.test('updates a supplier', { tags: ['suppliers', 'update'] }, async () => {
const created = await t.api.post('/v1/suppliers', { name: 'Old Supplier Name' })
const res = await t.api.patch(`/v1/suppliers/${created.data.id}`, { name: 'New Supplier Name', accountNumber: 'ACC-001' })
t.assert.status(res, 200)
t.assert.equal(res.data.name, 'New Supplier Name')
t.assert.equal(res.data.accountNumber, 'ACC-001')
})
t.test('soft-deletes a supplier', { tags: ['suppliers', 'delete'] }, async () => {
const created = await t.api.post('/v1/suppliers', { name: 'To Delete Sup' })
const res = await t.api.del(`/v1/suppliers/${created.data.id}`)
t.assert.status(res, 200)
t.assert.equal(res.data.isActive, false)
})
// ─── Products ──────────────────────────────────────────────────────────────
t.test('creates a product', { tags: ['products', 'create'] }, async () => {
const res = await t.api.post('/v1/products', {
name: 'Violin 4/4 Student',
sku: 'VLN-44-TST',
brand: 'Eastman',
price: 399,
isSerialized: true,
isRental: true,
isActive: true,
})
t.assert.status(res, 201)
t.assert.equal(res.data.name, 'Violin 4/4 Student')
t.assert.equal(res.data.sku, 'VLN-44-TST')
t.assert.equal(res.data.price, '399.00')
t.assert.equal(res.data.isSerialized, true)
t.assert.ok(res.data.id)
})
t.test('rejects product without name', { tags: ['products', 'create', 'validation'] }, async () => {
const res = await t.api.post('/v1/products', { sku: 'NO-NAME' })
t.assert.status(res, 400)
})
t.test('lists products with pagination', { tags: ['products', 'list'] }, async () => {
await t.api.post('/v1/products', { name: 'Prod List A', price: 100 })
await t.api.post('/v1/products', { name: 'Prod List B', price: 200 })
const res = await t.api.get('/v1/products', { page: 1, limit: 25 })
t.assert.status(res, 200)
t.assert.ok(res.data.data.length >= 2)
t.assert.ok(res.data.pagination)
})
t.test('searches products by name', { tags: ['products', 'list', 'search'] }, async () => {
await t.api.post('/v1/products', { name: 'SearchableViolinXYZ' })
const res = await t.api.get('/v1/products', { q: 'SearchableViolinXYZ' })
t.assert.status(res, 200)
t.assert.equal(res.data.data.length, 1)
t.assert.equal(res.data.data[0].name, 'SearchableViolinXYZ')
})
t.test('filters products by categoryId', { tags: ['products', 'list', 'filter'] }, async () => {
const cat = await t.api.post('/v1/categories', { name: 'FilterCat' })
await t.api.post('/v1/products', { name: 'In Cat Prod', categoryId: cat.data.id })
await t.api.post('/v1/products', { name: 'No Cat Prod' })
const res = await t.api.get('/v1/products', { categoryId: cat.data.id })
t.assert.status(res, 200)
t.assert.ok(res.data.data.every((p: any) => p.categoryId === cat.data.id))
})
t.test('gets product by id', { tags: ['products', 'read'] }, async () => {
const created = await t.api.post('/v1/products', { name: 'Get By ID Prod' })
const res = await t.api.get(`/v1/products/${created.data.id}`)
t.assert.status(res, 200)
t.assert.equal(res.data.name, 'Get By ID Prod')
})
t.test('returns 404 for missing product', { tags: ['products', 'read'] }, async () => {
const res = await t.api.get('/v1/products/a0000000-0000-0000-0000-999999999999')
t.assert.status(res, 404)
})
t.test('updates a product and records price history', { tags: ['products', 'update', 'price-history'] }, async () => {
const created = await t.api.post('/v1/products', { name: 'Price Test Prod', price: 100 })
const res = await t.api.patch(`/v1/products/${created.data.id}`, { price: 150, name: 'Price Test Prod Updated' })
t.assert.status(res, 200)
t.assert.equal(res.data.price, '150.00')
const history = await t.api.get(`/v1/products/${created.data.id}/price-history`)
t.assert.status(history, 200)
t.assert.equal(history.data.data.length, 1)
t.assert.equal(history.data.data[0].previousPrice, '100.00')
t.assert.equal(history.data.data[0].newPrice, '150.00')
})
t.test('price history is empty for new product', { tags: ['products', 'price-history'] }, async () => {
const created = await t.api.post('/v1/products', { name: 'No History Prod', price: 50 })
const res = await t.api.get(`/v1/products/${created.data.id}/price-history`)
t.assert.status(res, 200)
t.assert.equal(res.data.data.length, 0)
})
t.test('soft-deletes a product', { tags: ['products', 'delete'] }, async () => {
const created = await t.api.post('/v1/products', { name: 'To Delete Prod' })
const res = await t.api.del(`/v1/products/${created.data.id}`)
t.assert.status(res, 200)
t.assert.equal(res.data.isActive, false)
})
// ─── Inventory Units ───────────────────────────────────────────────────────
t.test('creates a serialized unit', { tags: ['units', 'create'] }, async () => {
const prod = await t.api.post('/v1/products', { name: 'Serial Prod', isSerialized: true })
const res = await t.api.post(`/v1/products/${prod.data.id}/units`, {
serialNumber: 'SN-001',
condition: 'new',
status: 'available',
purchaseCost: 185,
})
t.assert.status(res, 201)
t.assert.equal(res.data.serialNumber, 'SN-001')
t.assert.equal(res.data.condition, 'new')
t.assert.equal(res.data.status, 'available')
t.assert.equal(res.data.purchaseCost, '185.00')
})
t.test('lists units for a product', { tags: ['units', 'list'] }, async () => {
const prod = await t.api.post('/v1/products', { name: 'Multi Unit Prod', isSerialized: true })
await t.api.post(`/v1/products/${prod.data.id}/units`, { serialNumber: 'SN-A', condition: 'new', status: 'available' })
await t.api.post(`/v1/products/${prod.data.id}/units`, { serialNumber: 'SN-B', condition: 'good', status: 'rented' })
const res = await t.api.get(`/v1/products/${prod.data.id}/units`)
t.assert.status(res, 200)
t.assert.equal(res.data.data.length, 2)
})
t.test('updates a unit status', { tags: ['units', 'update'] }, async () => {
const prod = await t.api.post('/v1/products', { name: 'Update Unit Prod', isSerialized: true })
const unit = await t.api.post(`/v1/products/${prod.data.id}/units`, { serialNumber: 'SN-UPD', condition: 'good', status: 'available' })
const res = await t.api.patch(`/v1/units/${unit.data.id}`, { status: 'rented', notes: 'Rented to customer A' })
t.assert.status(res, 200)
t.assert.equal(res.data.status, 'rented')
t.assert.equal(res.data.notes, 'Rented to customer A')
})
// ─── Product Suppliers ─────────────────────────────────────────────────────
t.test('links a supplier to a product', { tags: ['product-suppliers', 'create'] }, async () => {
const prod = await t.api.post('/v1/products', { name: 'Linkable Prod' })
const sup = await t.api.post('/v1/suppliers', { name: 'Link Supplier' })
const res = await t.api.post(`/v1/products/${prod.data.id}/suppliers`, {
supplierId: sup.data.id,
supplierSku: 'SUP-SKU-001',
isPreferred: true,
})
t.assert.status(res, 201)
t.assert.equal(res.data.supplierId, sup.data.id)
t.assert.equal(res.data.supplierSku, 'SUP-SKU-001')
t.assert.equal(res.data.isPreferred, true)
})
t.test('lists suppliers for a product with joined supplier details', { tags: ['product-suppliers', 'list'] }, async () => {
const prod = await t.api.post('/v1/products', { name: 'Supplier List Prod' })
const sup1 = await t.api.post('/v1/suppliers', { name: 'Supplier Alpha', paymentTerms: 'Net 30' })
const sup2 = await t.api.post('/v1/suppliers', { name: 'Supplier Beta' })
await t.api.post(`/v1/products/${prod.data.id}/suppliers`, { supplierId: sup1.data.id, isPreferred: true })
await t.api.post(`/v1/products/${prod.data.id}/suppliers`, { supplierId: sup2.data.id, isPreferred: false })
const res = await t.api.get(`/v1/products/${prod.data.id}/suppliers`)
t.assert.status(res, 200)
t.assert.equal(res.data.data.length, 2)
// preferred comes first
t.assert.equal(res.data.data[0].supplierName, 'Supplier Alpha')
t.assert.equal(res.data.data[0].isPreferred, true)
t.assert.equal(res.data.data[0].supplierPaymentTerms, 'Net 30')
t.assert.equal(res.data.data[1].supplierName, 'Supplier Beta')
})
t.test('preferred flag moves to new supplier when set', { tags: ['product-suppliers', 'preferred'] }, async () => {
const prod = await t.api.post('/v1/products', { name: 'Preferred Test Prod' })
const sup1 = await t.api.post('/v1/suppliers', { name: 'Pref Sup A' })
const sup2 = await t.api.post('/v1/suppliers', { name: 'Pref Sup B' })
const link1 = await t.api.post(`/v1/products/${prod.data.id}/suppliers`, { supplierId: sup1.data.id, isPreferred: true })
t.assert.equal(link1.data.isPreferred, true)
// Link second supplier as preferred — first should lose preferred status
await t.api.post(`/v1/products/${prod.data.id}/suppliers`, { supplierId: sup2.data.id, isPreferred: true })
const list = await t.api.get(`/v1/products/${prod.data.id}/suppliers`)
const a = list.data.data.find((s: any) => s.supplierId === sup1.data.id)
const b = list.data.data.find((s: any) => s.supplierId === sup2.data.id)
t.assert.equal(a.isPreferred, false)
t.assert.equal(b.isPreferred, true)
})
t.test('updates a product-supplier link', { tags: ['product-suppliers', 'update'] }, async () => {
const prod = await t.api.post('/v1/products', { name: 'Update Link Prod' })
const sup = await t.api.post('/v1/suppliers', { name: 'Update Link Sup' })
const link = await t.api.post(`/v1/products/${prod.data.id}/suppliers`, { supplierId: sup.data.id, supplierSku: 'OLD-SKU' })
const res = await t.api.patch(`/v1/products/${prod.data.id}/suppliers/${link.data.id}`, { supplierSku: 'NEW-SKU' })
t.assert.status(res, 200)
t.assert.equal(res.data.supplierSku, 'NEW-SKU')
})
t.test('removes a supplier link', { tags: ['product-suppliers', 'delete'] }, async () => {
const prod = await t.api.post('/v1/products', { name: 'Remove Link Prod' })
const sup = await t.api.post('/v1/suppliers', { name: 'Remove Link Sup' })
const link = await t.api.post(`/v1/products/${prod.data.id}/suppliers`, { supplierId: sup.data.id })
const res = await t.api.del(`/v1/products/${prod.data.id}/suppliers/${link.data.id}`)
t.assert.status(res, 200)
const list = await t.api.get(`/v1/products/${prod.data.id}/suppliers`)
t.assert.equal(list.data.data.length, 0)
})
t.test('returns 404 for missing product-supplier link update', { tags: ['product-suppliers', 'update'] }, async () => {
const prod = await t.api.post('/v1/products', { name: 'Missing Link Prod' })
const res = await t.api.patch(`/v1/products/${prod.data.id}/suppliers/a0000000-0000-0000-0000-999999999999`, { supplierSku: 'X' })
t.assert.status(res, 404)
})
// ─── Stock Receipts ────────────────────────────────────────────────────────
t.test('records a stock receipt and increments qty_on_hand', { tags: ['stock-receipts', 'create'] }, async () => {
const prod = await t.api.post('/v1/products', { name: 'Rosin Stock', qtyOnHand: 10 })
const res = await t.api.post(`/v1/products/${prod.data.id}/stock-receipts`, {
qty: 5,
costPerUnit: 6.50,
receivedDate: '2026-03-15',
invoiceNumber: 'INV-2026-001',
})
t.assert.status(res, 201)
t.assert.equal(res.data.qty, 5)
t.assert.equal(res.data.costPerUnit, '6.50')
t.assert.equal(res.data.totalCost, '32.50')
t.assert.equal(res.data.invoiceNumber, 'INV-2026-001')
// qty_on_hand should have increased
const updated = await t.api.get(`/v1/products/${prod.data.id}`)
t.assert.equal(updated.data.qtyOnHand, 15)
})
t.test('records receipt with supplier link', { tags: ['stock-receipts', 'create'] }, async () => {
const prod = await t.api.post('/v1/products', { name: 'Strings Stock' })
const sup = await t.api.post('/v1/suppliers', { name: 'Receipt Supplier' })
const res = await t.api.post(`/v1/products/${prod.data.id}/stock-receipts`, {
supplierId: sup.data.id,
qty: 12,
costPerUnit: 19.50,
receivedDate: '2026-03-20',
})
t.assert.status(res, 201)
t.assert.equal(res.data.supplierId, sup.data.id)
})
t.test('does not increment qty for serialized products', { tags: ['stock-receipts', 'create'] }, async () => {
const prod = await t.api.post('/v1/products', { name: 'Serial Stock', isSerialized: true, qtyOnHand: 0 })
await t.api.post(`/v1/products/${prod.data.id}/stock-receipts`, {
qty: 1,
costPerUnit: 185,
receivedDate: '2026-03-01',
})
const updated = await t.api.get(`/v1/products/${prod.data.id}`)
t.assert.equal(updated.data.qtyOnHand, 0)
})
t.test('lists stock receipts for a product', { tags: ['stock-receipts', 'list'] }, async () => {
const prod = await t.api.post('/v1/products', { name: 'List Receipts Prod' })
await t.api.post(`/v1/products/${prod.data.id}/stock-receipts`, { qty: 10, costPerUnit: 5, receivedDate: '2026-01-10' })
await t.api.post(`/v1/products/${prod.data.id}/stock-receipts`, { qty: 20, costPerUnit: 4.75, receivedDate: '2026-02-15' })
const res = await t.api.get(`/v1/products/${prod.data.id}/stock-receipts`)
t.assert.status(res, 200)
t.assert.equal(res.data.data.length, 2)
// most recent date first
t.assert.equal(res.data.data[0].receivedDate, '2026-02-15')
})
t.test('rejects receipt with missing required fields', { tags: ['stock-receipts', 'validation'] }, async () => {
const prod = await t.api.post('/v1/products', { name: 'Invalid Receipt Prod' })
const res = await t.api.post(`/v1/products/${prod.data.id}/stock-receipts`, { qty: 5 })
t.assert.status(res, 400)
})
})

File diff suppressed because it is too large Load Diff

View File

@@ -26,6 +26,11 @@ export const inventoryRoutes: FastifyPluginAsync = async (app) => {
return reply.send(result) return reply.send(result)
}) })
app.get('/categories/all', { preHandler: [app.authenticate, app.requirePermission('inventory.view')] }, async (request, reply) => {
const rows = await CategoryService.listAll(app.db)
return reply.send({ data: rows })
})
app.get('/categories/:id', { preHandler: [app.authenticate, app.requirePermission('inventory.view')] }, async (request, reply) => { app.get('/categories/:id', { preHandler: [app.authenticate, app.requirePermission('inventory.view')] }, async (request, reply) => {
const { id } = request.params as { id: string } const { id } = request.params as { id: string }
const category = await CategoryService.getById(app.db, id) const category = await CategoryService.getById(app.db, id)

View File

@@ -4,9 +4,12 @@ import {
ProductUpdateSchema, ProductUpdateSchema,
InventoryUnitCreateSchema, InventoryUnitCreateSchema,
InventoryUnitUpdateSchema, InventoryUnitUpdateSchema,
ProductSupplierCreateSchema,
ProductSupplierUpdateSchema,
StockReceiptCreateSchema,
PaginationSchema, PaginationSchema,
} from '@lunarfront/shared/schemas' } from '@lunarfront/shared/schemas'
import { ProductService, InventoryUnitService } from '../../services/product.service.js' import { ProductService, InventoryUnitService, ProductSupplierService, StockReceiptService } from '../../services/product.service.js'
export const productRoutes: FastifyPluginAsync = async (app) => { export const productRoutes: FastifyPluginAsync = async (app) => {
// --- Products --- // --- Products ---
@@ -22,7 +25,16 @@ export const productRoutes: FastifyPluginAsync = async (app) => {
app.get('/products', { preHandler: [app.authenticate, app.requirePermission('inventory.view')] }, async (request, reply) => { app.get('/products', { preHandler: [app.authenticate, app.requirePermission('inventory.view')] }, async (request, reply) => {
const params = PaginationSchema.parse(request.query) const params = PaginationSchema.parse(request.query)
const result = await ProductService.list(app.db, params) const q = request.query as Record<string, string>
const filters = {
categoryId: q.categoryId,
isActive: q.isActive === 'true' ? true : q.isActive === 'false' ? false : undefined,
isSerialized: q.isSerialized === 'true' ? true : q.isSerialized === 'false' ? false : undefined,
isRental: q.isRental === 'true' ? true : q.isRental === 'false' ? false : undefined,
isDualUseRepair: q.isDualUseRepair === 'true' ? true : q.isDualUseRepair === 'false' ? false : undefined,
lowStock: q.lowStock === 'true',
}
const result = await ProductService.list(app.db, params, filters)
return reply.send(result) return reply.send(result)
}) })
@@ -87,4 +99,66 @@ export const productRoutes: FastifyPluginAsync = async (app) => {
if (!unit) return reply.status(404).send({ error: { message: 'Unit not found', statusCode: 404 } }) if (!unit) return reply.status(404).send({ error: { message: 'Unit not found', statusCode: 404 } })
return reply.send(unit) return reply.send(unit)
}) })
// --- Product Suppliers ---
app.get('/products/:productId/suppliers', { preHandler: [app.authenticate, app.requirePermission('inventory.view')] }, async (request, reply) => {
const { productId } = request.params as { productId: string }
const rows = await ProductSupplierService.listByProduct(app.db, productId)
return reply.send({ data: rows })
})
app.post('/products/:productId/suppliers', { preHandler: [app.authenticate, app.requirePermission('inventory.edit')] }, async (request, reply) => {
const { productId } = request.params as { productId: string }
const parsed = ProductSupplierCreateSchema.safeParse(request.body)
if (!parsed.success) {
return reply.status(400).send({ error: { message: 'Validation failed', details: parsed.error.flatten(), statusCode: 400 } })
}
const row = await ProductSupplierService.create(app.db, productId, parsed.data)
return reply.status(201).send(row)
})
app.patch('/products/:productId/suppliers/:id', { preHandler: [app.authenticate, app.requirePermission('inventory.edit')] }, async (request, reply) => {
const { productId, id } = request.params as { productId: string; id: string }
const parsed = ProductSupplierUpdateSchema.safeParse(request.body)
if (!parsed.success) {
return reply.status(400).send({ error: { message: 'Validation failed', details: parsed.error.flatten(), statusCode: 400 } })
}
const row = await ProductSupplierService.update(app.db, id, productId, parsed.data)
if (!row) return reply.status(404).send({ error: { message: 'Not found', statusCode: 404 } })
return reply.send(row)
})
app.delete('/products/:productId/suppliers/:id', { preHandler: [app.authenticate, app.requirePermission('inventory.edit')] }, async (request, reply) => {
const { id } = request.params as { id: string }
const row = await ProductSupplierService.delete(app.db, id)
if (!row) return reply.status(404).send({ error: { message: 'Not found', statusCode: 404 } })
return reply.send(row)
})
// --- Price History ---
app.get('/products/:productId/price-history', { preHandler: [app.authenticate, app.requirePermission('inventory.view')] }, async (request, reply) => {
const { productId } = request.params as { productId: string }
const rows = await ProductService.listPriceHistory(app.db, productId)
return reply.send({ data: rows })
})
// --- Stock Receipts ---
app.post('/products/:productId/stock-receipts', { preHandler: [app.authenticate, app.requirePermission('inventory.edit')] }, async (request, reply) => {
const { productId } = request.params as { productId: string }
const parsed = StockReceiptCreateSchema.safeParse(request.body)
if (!parsed.success) {
return reply.status(400).send({ error: { message: 'Validation failed', details: parsed.error.flatten(), statusCode: 400 } })
}
const receipt = await StockReceiptService.create(app.db, productId, parsed.data)
return reply.status(201).send(receipt)
})
app.get('/products/:productId/stock-receipts', { preHandler: [app.authenticate, app.requirePermission('inventory.view')] }, async (request, reply) => {
const { productId } = request.params as { productId: string }
const rows = await StockReceiptService.listByProduct(app.db, productId)
return reply.send({ data: rows })
})
} }

View File

@@ -60,6 +60,14 @@ export const CategoryService = {
return paginatedResponse(data, total, params.page, params.limit) return paginatedResponse(data, total, params.page, params.limit)
}, },
async listAll(db: PostgresJsDatabase<any>) {
return db
.select()
.from(categories)
.where(eq(categories.isActive, true))
.orderBy(categories.sortOrder, categories.name)
},
async update(db: PostgresJsDatabase<any>, id: string, input: CategoryUpdateInput) { async update(db: PostgresJsDatabase<any>, id: string, input: CategoryUpdateInput) {
const [category] = await db const [category] = await db
.update(categories) .update(categories)

View File

@@ -1,12 +1,15 @@
import { eq, and, count, type Column } from 'drizzle-orm' import { eq, and, count, desc, lte, sql, type Column } from 'drizzle-orm'
import type { PostgresJsDatabase } from 'drizzle-orm/postgres-js' import type { PostgresJsDatabase } from 'drizzle-orm/postgres-js'
import { products, inventoryUnits, priceHistory } from '../db/schema/inventory.js' import { products, inventoryUnits, priceHistory as priceHistoryTable, productSuppliers, suppliers, stockReceipts } from '../db/schema/inventory.js'
import { ValidationError } from '../lib/errors.js' import { ValidationError } from '../lib/errors.js'
import type { import type {
ProductCreateInput, ProductCreateInput,
ProductUpdateInput, ProductUpdateInput,
InventoryUnitCreateInput, InventoryUnitCreateInput,
InventoryUnitUpdateInput, InventoryUnitUpdateInput,
ProductSupplierCreateInput,
ProductSupplierUpdateInput,
StockReceiptCreateInput,
PaginationInput, PaginationInput,
} from '@lunarfront/shared/schemas' } from '@lunarfront/shared/schemas'
import { import {
@@ -40,14 +43,39 @@ export const ProductService = {
return product ?? null return product ?? null
}, },
async list(db: PostgresJsDatabase<any>, params: PaginationInput) { async list(db: PostgresJsDatabase<any>, params: PaginationInput, filters?: {
const baseWhere = eq(products.isActive, true) categoryId?: string
isActive?: boolean
isSerialized?: boolean
isRental?: boolean
isDualUseRepair?: boolean
lowStock?: boolean
}) {
const conditions = [eq(products.isActive, filters?.isActive ?? true)]
const searchCondition = params.q if (params.q) {
? buildSearchCondition(params.q, [products.name, products.sku, products.upc, products.brand]) conditions.push(buildSearchCondition(params.q, [products.name, products.sku, products.upc, products.brand])!)
: undefined }
if (filters?.categoryId) {
conditions.push(eq(products.categoryId, filters.categoryId))
}
if (filters?.isSerialized !== undefined) {
conditions.push(eq(products.isSerialized, filters.isSerialized))
}
if (filters?.isRental !== undefined) {
conditions.push(eq(products.isRental, filters.isRental))
}
if (filters?.isDualUseRepair !== undefined) {
conditions.push(eq(products.isDualUseRepair, filters.isDualUseRepair))
}
if (filters?.lowStock) {
// qty_on_hand <= qty_reorder_point (and reorder point is set) OR qty_on_hand = 0
conditions.push(
sql`(${products.qtyOnHand} = 0 OR (${products.qtyReorderPoint} IS NOT NULL AND ${products.qtyOnHand} <= ${products.qtyReorderPoint}))`
)
}
const where = searchCondition ? and(baseWhere, searchCondition) : baseWhere const where = conditions.length === 1 ? conditions[0] : and(...conditions)
const sortableColumns: Record<string, Column> = { const sortableColumns: Record<string, Column> = {
name: products.name, name: products.name,
@@ -78,7 +106,7 @@ export const ProductService = {
if (input.price !== undefined || input.minPrice !== undefined) { if (input.price !== undefined || input.minPrice !== undefined) {
const existing = await this.getById(db, id) const existing = await this.getById(db, id)
if (existing) { if (existing) {
await db.insert(priceHistory).values({ await db.insert(priceHistoryTable).values({
productId: id, productId: id,
previousPrice: existing.price, previousPrice: existing.price,
newPrice: input.price?.toString() ?? existing.price ?? '0', newPrice: input.price?.toString() ?? existing.price ?? '0',
@@ -111,6 +139,14 @@ export const ProductService = {
.returning() .returning()
return product ?? null return product ?? null
}, },
async listPriceHistory(db: PostgresJsDatabase<any>, productId: string) {
return db
.select()
.from(priceHistoryTable)
.where(eq(priceHistoryTable.productId, productId))
.orderBy(desc(priceHistoryTable.createdAt))
},
} }
export const InventoryUnitService = { export const InventoryUnitService = {
@@ -200,3 +236,118 @@ export const InventoryUnitService = {
return unit ?? null return unit ?? null
}, },
} }
export const ProductSupplierService = {
async listByProduct(db: PostgresJsDatabase<any>, productId: string) {
return db
.select({
id: productSuppliers.id,
productId: productSuppliers.productId,
supplierId: productSuppliers.supplierId,
supplierSku: productSuppliers.supplierSku,
isPreferred: productSuppliers.isPreferred,
createdAt: productSuppliers.createdAt,
supplierName: suppliers.name,
supplierContactName: suppliers.contactName,
supplierEmail: suppliers.email,
supplierPhone: suppliers.phone,
supplierAccountNumber: suppliers.accountNumber,
supplierPaymentTerms: suppliers.paymentTerms,
})
.from(productSuppliers)
.innerJoin(suppliers, eq(productSuppliers.supplierId, suppliers.id))
.where(eq(productSuppliers.productId, productId))
.orderBy(desc(productSuppliers.isPreferred), suppliers.name)
},
async create(db: PostgresJsDatabase<any>, productId: string, input: ProductSupplierCreateInput) {
if (input.isPreferred) {
await db
.update(productSuppliers)
.set({ isPreferred: false })
.where(eq(productSuppliers.productId, productId))
}
const [row] = await db
.insert(productSuppliers)
.values({ productId, ...input })
.returning()
return row
},
async update(db: PostgresJsDatabase<any>, id: string, productId: string, input: ProductSupplierUpdateInput) {
if (input.isPreferred) {
await db
.update(productSuppliers)
.set({ isPreferred: false })
.where(eq(productSuppliers.productId, productId))
}
const [row] = await db
.update(productSuppliers)
.set(input)
.where(eq(productSuppliers.id, id))
.returning()
return row ?? null
},
async delete(db: PostgresJsDatabase<any>, id: string) {
const [row] = await db
.delete(productSuppliers)
.where(eq(productSuppliers.id, id))
.returning()
return row ?? null
},
}
export const StockReceiptService = {
async create(db: PostgresJsDatabase<any>, productId: string, input: StockReceiptCreateInput) {
const totalCost = (input.costPerUnit * input.qty).toFixed(2)
const [receipt] = await db
.insert(stockReceipts)
.values({
productId,
supplierId: input.supplierId,
inventoryUnitId: input.inventoryUnitId,
qty: input.qty,
costPerUnit: input.costPerUnit.toString(),
totalCost,
receivedDate: input.receivedDate,
invoiceNumber: input.invoiceNumber,
notes: input.notes,
})
.returning()
// For non-serialized products, increment qty_on_hand
const [product] = await db.select().from(products).where(eq(products.id, productId)).limit(1)
if (product && !product.isSerialized) {
await db
.update(products)
.set({ qtyOnHand: product.qtyOnHand + input.qty, updatedAt: new Date() })
.where(eq(products.id, productId))
}
return receipt
},
async listByProduct(db: PostgresJsDatabase<any>, productId: string) {
return db
.select({
id: stockReceipts.id,
productId: stockReceipts.productId,
supplierId: stockReceipts.supplierId,
inventoryUnitId: stockReceipts.inventoryUnitId,
qty: stockReceipts.qty,
costPerUnit: stockReceipts.costPerUnit,
totalCost: stockReceipts.totalCost,
receivedDate: stockReceipts.receivedDate,
invoiceNumber: stockReceipts.invoiceNumber,
notes: stockReceipts.notes,
createdAt: stockReceipts.createdAt,
supplierName: suppliers.name,
})
.from(stockReceipts)
.leftJoin(suppliers, eq(stockReceipts.supplierId, suppliers.id))
.where(eq(stockReceipts.productId, productId))
.orderBy(desc(stockReceipts.receivedDate), desc(stockReceipts.createdAt))
},
}

View File

@@ -55,6 +55,9 @@ export {
ProductSearchSchema, ProductSearchSchema,
InventoryUnitCreateSchema, InventoryUnitCreateSchema,
InventoryUnitUpdateSchema, InventoryUnitUpdateSchema,
ProductSupplierCreateSchema,
ProductSupplierUpdateSchema,
StockReceiptCreateSchema,
} from './inventory.schema.js' } from './inventory.schema.js'
export type { export type {
CategoryCreateInput, CategoryCreateInput,
@@ -65,6 +68,9 @@ export type {
ProductUpdateInput, ProductUpdateInput,
InventoryUnitCreateInput, InventoryUnitCreateInput,
InventoryUnitUpdateInput, InventoryUnitUpdateInput,
ProductSupplierCreateInput,
ProductSupplierUpdateInput,
StockReceiptCreateInput,
} from './inventory.schema.js' } from './inventory.schema.js'
export { export {

View File

@@ -90,3 +90,24 @@ export type InventoryUnitCreateInput = z.infer<typeof InventoryUnitCreateSchema>
export const InventoryUnitUpdateSchema = InventoryUnitCreateSchema.omit({ productId: true }).partial() export const InventoryUnitUpdateSchema = InventoryUnitCreateSchema.omit({ productId: true }).partial()
export type InventoryUnitUpdateInput = z.infer<typeof InventoryUnitUpdateSchema> export type InventoryUnitUpdateInput = z.infer<typeof InventoryUnitUpdateSchema>
export const ProductSupplierCreateSchema = z.object({
supplierId: z.string().uuid(),
supplierSku: opt(z.string().max(100)),
isPreferred: z.boolean().default(false),
})
export type ProductSupplierCreateInput = z.infer<typeof ProductSupplierCreateSchema>
export const ProductSupplierUpdateSchema = ProductSupplierCreateSchema.omit({ supplierId: true }).partial()
export type ProductSupplierUpdateInput = z.infer<typeof ProductSupplierUpdateSchema>
export const StockReceiptCreateSchema = z.object({
supplierId: opt(z.string().uuid()),
inventoryUnitId: opt(z.string().uuid()),
qty: z.number().int().min(1).default(1),
costPerUnit: z.number().min(0),
receivedDate: z.string().date(),
invoiceNumber: opt(z.string().max(100)),
notes: opt(z.string()),
})
export type StockReceiptCreateInput = z.infer<typeof StockReceiptCreateSchema>