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:
@@ -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
25
deploy/sync-and-deploy.sh
Executable 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"
|
||||||
162
packages/admin/src/api/inventory.ts
Normal file
162
packages/admin/src/api/inventory.ts
Normal 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,
|
||||||
|
})
|
||||||
|
}
|
||||||
91
packages/admin/src/components/inventory/category-form.tsx
Normal file
91
packages/admin/src/components/inventory/category-form.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
118
packages/admin/src/components/inventory/inventory-unit-form.tsx
Normal file
118
packages/admin/src/components/inventory/inventory-unit-form.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
172
packages/admin/src/components/inventory/product-form.tsx
Normal file
172
packages/admin/src/components/inventory/product-form.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
88
packages/admin/src/components/inventory/supplier-form.tsx
Normal file
88
packages/admin/src/components/inventory/supplier-form.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
142
packages/admin/src/components/ui/alert-dialog.tsx
Normal file
142
packages/admin/src/components/ui/alert-dialog.tsx
Normal 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,
|
||||||
|
}
|
||||||
30
packages/admin/src/components/ui/checkbox.tsx
Normal file
30
packages/admin/src/components/ui/checkbox.tsx
Normal 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 }
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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} />
|
||||||
|
|||||||
@@ -0,0 +1,786 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import { createFileRoute, useParams, useNavigate } from '@tanstack/react-router'
|
||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
|
import {
|
||||||
|
productDetailOptions, productMutations, productKeys,
|
||||||
|
unitListOptions, unitMutations, unitKeys,
|
||||||
|
productSupplierListOptions, productSupplierMutations, productSupplierKeys,
|
||||||
|
priceHistoryOptions, supplierListOptions,
|
||||||
|
stockReceiptListOptions, stockReceiptMutations, stockReceiptKeys,
|
||||||
|
} from '@/api/inventory'
|
||||||
|
import { ProductForm } from '@/components/inventory/product-form'
|
||||||
|
import { InventoryUnitForm } from '@/components/inventory/inventory-unit-form'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Badge } from '@/components/ui/badge'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
|
import { Label } from '@/components/ui/label'
|
||||||
|
import { Skeleton } from '@/components/ui/skeleton'
|
||||||
|
import { Checkbox } from '@/components/ui/checkbox'
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||||
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog'
|
||||||
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
|
||||||
|
import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger } from '@/components/ui/alert-dialog'
|
||||||
|
import { ArrowLeft, Plus, Pencil, Star, Trash2 } from 'lucide-react'
|
||||||
|
import { toast } from 'sonner'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
import { useAuthStore } from '@/stores/auth.store'
|
||||||
|
import type { InventoryUnit, ProductSupplier, StockReceipt, UnitCondition, UnitStatus } from '@/types/inventory'
|
||||||
|
|
||||||
|
const CONDITION_CLASSES: Record<UnitCondition, string> = {
|
||||||
|
new: 'bg-blue-100 text-blue-800 border border-blue-300',
|
||||||
|
excellent: 'bg-green-100 text-green-800 border border-green-300',
|
||||||
|
good: 'bg-emerald-100 text-emerald-800 border border-emerald-300',
|
||||||
|
fair: 'bg-yellow-100 text-yellow-800 border border-yellow-300',
|
||||||
|
poor: 'bg-red-100 text-red-800 border border-red-300',
|
||||||
|
}
|
||||||
|
|
||||||
|
const STATUS_CLASSES: Record<UnitStatus, string> = {
|
||||||
|
available: 'bg-green-100 text-green-800 border border-green-300',
|
||||||
|
sold: 'bg-gray-100 text-gray-600 border border-gray-300',
|
||||||
|
rented: 'bg-purple-100 text-purple-800 border border-purple-300',
|
||||||
|
on_trial: 'bg-cyan-100 text-cyan-800 border border-cyan-300',
|
||||||
|
in_repair: 'bg-orange-100 text-orange-800 border border-orange-300',
|
||||||
|
layaway: 'bg-indigo-100 text-indigo-800 border border-indigo-300',
|
||||||
|
lost: 'bg-red-100 text-red-800 border border-red-300',
|
||||||
|
retired: 'bg-gray-100 text-gray-400 border border-gray-200',
|
||||||
|
}
|
||||||
|
|
||||||
|
const STATUS_LABELS: Record<UnitStatus, string> = {
|
||||||
|
available: 'Available', sold: 'Sold', rented: 'Rented', on_trial: 'On Trial',
|
||||||
|
in_repair: 'In Repair', layaway: 'Layaway', lost: 'Lost', retired: 'Retired',
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Route = createFileRoute('/_authenticated/inventory/$productId')({
|
||||||
|
validateSearch: (search: Record<string, unknown>) => ({
|
||||||
|
tab: (search.tab as string) || 'details',
|
||||||
|
}),
|
||||||
|
component: ProductDetailPage,
|
||||||
|
})
|
||||||
|
|
||||||
|
function ProductDetailPage() {
|
||||||
|
const { productId } = useParams({ from: '/_authenticated/inventory/$productId' })
|
||||||
|
const search = Route.useSearch()
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
const hasPermission = useAuthStore((s) => s.hasPermission)
|
||||||
|
const tab = search.tab ?? 'details'
|
||||||
|
const [addUnitOpen, setAddUnitOpen] = useState(false)
|
||||||
|
const [editUnit, setEditUnit] = useState<InventoryUnit | null>(null)
|
||||||
|
const [qtyEdit, setQtyEdit] = useState<string>('')
|
||||||
|
const [addSupplierOpen, setAddSupplierOpen] = useState(false)
|
||||||
|
const [editSupplier, setEditSupplier] = useState<ProductSupplier | null>(null)
|
||||||
|
const [addReceiptOpen, setAddReceiptOpen] = useState(false)
|
||||||
|
|
||||||
|
const { data: product, isLoading } = useQuery(productDetailOptions(productId))
|
||||||
|
const { data: unitsData } = useQuery({ ...unitListOptions(productId), enabled: tab === 'units' })
|
||||||
|
const units = unitsData?.data ?? []
|
||||||
|
|
||||||
|
const { data: suppliersData } = useQuery({ ...productSupplierListOptions(productId), enabled: tab === 'suppliers' })
|
||||||
|
const linkedSuppliers = suppliersData?.data ?? []
|
||||||
|
|
||||||
|
const { data: priceHistoryData } = useQuery({ ...priceHistoryOptions(productId), enabled: tab === 'price-history' })
|
||||||
|
const priceHistoryRows = priceHistoryData?.data ?? []
|
||||||
|
|
||||||
|
const { data: stockReceiptsData } = useQuery({ ...stockReceiptListOptions(productId), enabled: tab === 'stock-receipts' })
|
||||||
|
const stockReceiptRows = stockReceiptsData?.data ?? []
|
||||||
|
|
||||||
|
|
||||||
|
const updateMutation = useMutation({
|
||||||
|
mutationFn: (data: Record<string, unknown>) => productMutations.update(productId, data),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: productKeys.detail(productId) })
|
||||||
|
queryClient.invalidateQueries({ queryKey: productKeys.all })
|
||||||
|
toast.success('Product updated')
|
||||||
|
},
|
||||||
|
onError: (err) => toast.error(err instanceof Error ? err.message : 'Update failed'),
|
||||||
|
})
|
||||||
|
|
||||||
|
const createUnitMutation = useMutation({
|
||||||
|
mutationFn: (data: Record<string, unknown>) => unitMutations.create(productId, data),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: unitKeys.byProduct(productId) })
|
||||||
|
toast.success('Unit added')
|
||||||
|
setAddUnitOpen(false)
|
||||||
|
},
|
||||||
|
onError: (err) => toast.error(err.message),
|
||||||
|
})
|
||||||
|
|
||||||
|
const updateUnitMutation = useMutation({
|
||||||
|
mutationFn: ({ id, data }: { id: string; data: Record<string, unknown> }) =>
|
||||||
|
unitMutations.update(id, data),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: unitKeys.byProduct(productId) })
|
||||||
|
toast.success('Unit updated')
|
||||||
|
setEditUnit(null)
|
||||||
|
},
|
||||||
|
onError: (err) => toast.error(err.message),
|
||||||
|
})
|
||||||
|
|
||||||
|
const addSupplierMutation = useMutation({
|
||||||
|
mutationFn: (data: Record<string, unknown>) => productSupplierMutations.create(productId, data),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: productSupplierKeys.byProduct(productId) })
|
||||||
|
toast.success('Supplier linked')
|
||||||
|
setAddSupplierOpen(false)
|
||||||
|
},
|
||||||
|
onError: (err) => toast.error(err instanceof Error ? err.message : 'Failed'),
|
||||||
|
})
|
||||||
|
|
||||||
|
const updateSupplierMutation = useMutation({
|
||||||
|
mutationFn: ({ id, data }: { id: string; data: Record<string, unknown> }) =>
|
||||||
|
productSupplierMutations.update(productId, id, data),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: productSupplierKeys.byProduct(productId) })
|
||||||
|
toast.success('Supplier updated')
|
||||||
|
setEditSupplier(null)
|
||||||
|
},
|
||||||
|
onError: (err) => toast.error(err instanceof Error ? err.message : 'Failed'),
|
||||||
|
})
|
||||||
|
|
||||||
|
const removeSupplierMutation = useMutation({
|
||||||
|
mutationFn: (id: string) => productSupplierMutations.delete(productId, id),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: productSupplierKeys.byProduct(productId) })
|
||||||
|
toast.success('Supplier removed')
|
||||||
|
setEditSupplier(null)
|
||||||
|
},
|
||||||
|
onError: (err) => toast.error(err instanceof Error ? err.message : 'Failed'),
|
||||||
|
})
|
||||||
|
|
||||||
|
const createReceiptMutation = useMutation({
|
||||||
|
mutationFn: (data: Record<string, unknown>) => stockReceiptMutations.create(productId, data),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: stockReceiptKeys.byProduct(productId) })
|
||||||
|
queryClient.invalidateQueries({ queryKey: productKeys.detail(productId) })
|
||||||
|
toast.success('Stock receipt recorded')
|
||||||
|
setAddReceiptOpen(false)
|
||||||
|
},
|
||||||
|
onError: (err) => toast.error(err instanceof Error ? err.message : 'Failed'),
|
||||||
|
})
|
||||||
|
|
||||||
|
function setTab(t: string) {
|
||||||
|
navigate({ to: '/inventory/$productId', params: { productId }, search: { tab: t } as any })
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleQtySave() {
|
||||||
|
const qty = parseInt(qtyEdit, 10)
|
||||||
|
if (isNaN(qty) || qty < 0) return
|
||||||
|
updateMutation.mutate({ qtyOnHand: qty })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<Skeleton className="h-8 w-48" />
|
||||||
|
<Skeleton className="h-10 w-full" />
|
||||||
|
<Skeleton className="h-64 w-full max-w-lg" />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!product) return <p className="text-muted-foreground">Product not found</p>
|
||||||
|
|
||||||
|
const tabs = [
|
||||||
|
{ key: 'details', label: 'Details' },
|
||||||
|
{ key: 'units', label: product.isSerialized ? 'Units' : 'Quantity' },
|
||||||
|
{ key: 'suppliers', label: 'Suppliers' },
|
||||||
|
{ key: 'stock-receipts', label: 'Stock Receipts' },
|
||||||
|
{ key: 'price-history', label: 'Price History' },
|
||||||
|
]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Button variant="ghost" size="sm" onClick={() => navigate({ to: '/inventory', search: {} as any })}>
|
||||||
|
<ArrowLeft className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
<h1 className="text-2xl font-bold">{product.name}</h1>
|
||||||
|
{product.sku && <span className="font-mono text-sm text-muted-foreground bg-muted px-2 py-0.5 rounded">{product.sku}</span>}
|
||||||
|
{product.isActive ? <Badge>Active</Badge> : <Badge variant="secondary">Inactive</Badge>}
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-muted-foreground mt-0.5">
|
||||||
|
{[product.brand, product.model].filter(Boolean).join(' · ') || 'No brand/model'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tabs */}
|
||||||
|
<nav className="flex gap-1 border-b">
|
||||||
|
{tabs.map((t) => (
|
||||||
|
<button
|
||||||
|
key={t.key}
|
||||||
|
onClick={() => setTab(t.key)}
|
||||||
|
className={cn(
|
||||||
|
'px-4 py-2 text-sm font-medium border-b-2 -mb-px transition-colors',
|
||||||
|
tab === t.key
|
||||||
|
? 'border-primary text-foreground'
|
||||||
|
: 'border-transparent text-muted-foreground hover:text-foreground hover:border-border',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{t.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
{/* Details tab */}
|
||||||
|
{tab === 'details' && (
|
||||||
|
<div className="max-w-lg">
|
||||||
|
<ProductForm
|
||||||
|
defaultValues={product}
|
||||||
|
onSubmit={updateMutation.mutate}
|
||||||
|
loading={updateMutation.isPending}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Units / Quantity tab */}
|
||||||
|
{tab === 'units' && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{product.isSerialized ? (
|
||||||
|
<>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<p className="text-sm text-muted-foreground">{units.length} unit(s) on file</p>
|
||||||
|
{hasPermission('inventory.edit') && (
|
||||||
|
<Dialog open={addUnitOpen} onOpenChange={setAddUnitOpen}>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
<Button size="sm"><Plus className="mr-2 h-4 w-4" />Add Unit</Button>
|
||||||
|
</DialogTrigger>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader><DialogTitle>Add Unit</DialogTitle></DialogHeader>
|
||||||
|
<InventoryUnitForm
|
||||||
|
onSubmit={createUnitMutation.mutate}
|
||||||
|
loading={createUnitMutation.isPending}
|
||||||
|
/>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="rounded-md border">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>Serial #</TableHead>
|
||||||
|
<TableHead>Condition</TableHead>
|
||||||
|
<TableHead>Status</TableHead>
|
||||||
|
<TableHead>Purchased</TableHead>
|
||||||
|
<TableHead>Cost</TableHead>
|
||||||
|
<TableHead>Notes</TableHead>
|
||||||
|
{hasPermission('inventory.edit') && <TableHead className="w-10" />}
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{units.length === 0 ? (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={7} className="text-center py-8 text-muted-foreground">
|
||||||
|
No units yet — add the first unit above
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
) : (
|
||||||
|
units.map((u) => (
|
||||||
|
<TableRow key={u.id}>
|
||||||
|
<TableCell className="font-mono text-sm">{u.serialNumber ?? '—'}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<span className={cn('inline-flex items-center px-2 py-0.5 rounded text-xs font-medium', CONDITION_CLASSES[u.condition])}>
|
||||||
|
{u.condition}
|
||||||
|
</span>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<span className={cn('inline-flex items-center px-2 py-0.5 rounded text-xs font-medium', STATUS_CLASSES[u.status])}>
|
||||||
|
{STATUS_LABELS[u.status]}
|
||||||
|
</span>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-sm text-muted-foreground">
|
||||||
|
{u.purchaseDate ? new Date(u.purchaseDate + 'T00:00:00').toLocaleDateString() : '—'}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-sm">
|
||||||
|
{u.purchaseCost ? `$${Number(u.purchaseCost).toFixed(2)}` : '—'}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-sm text-muted-foreground max-w-[200px] truncate">
|
||||||
|
{u.notes ?? '—'}
|
||||||
|
</TableCell>
|
||||||
|
{hasPermission('inventory.edit') && (
|
||||||
|
<TableCell>
|
||||||
|
<Button variant="ghost" size="sm" className="h-7 w-7 p-0" onClick={() => setEditUnit(u)}>
|
||||||
|
<Pencil className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
</TableCell>
|
||||||
|
)}
|
||||||
|
</TableRow>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className="max-w-xs space-y-4">
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
This product is not serialized — track quantity as a single number.
|
||||||
|
</p>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="qty-edit">Quantity On Hand</Label>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Input
|
||||||
|
id="qty-edit"
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
defaultValue={product.qtyOnHand}
|
||||||
|
onChange={(e) => setQtyEdit(e.target.value)}
|
||||||
|
className="w-32"
|
||||||
|
/>
|
||||||
|
<Button onClick={handleQtySave} disabled={updateMutation.isPending}>
|
||||||
|
{updateMutation.isPending ? 'Saving...' : 'Save'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{product.qtyReorderPoint !== null && (
|
||||||
|
<p className="text-xs text-muted-foreground">Reorder point: {product.qtyReorderPoint}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Suppliers tab */}
|
||||||
|
{tab === 'suppliers' && (
|
||||||
|
<SuppliersTab
|
||||||
|
productId={productId}
|
||||||
|
linkedSuppliers={linkedSuppliers}
|
||||||
|
addOpen={addSupplierOpen}
|
||||||
|
setAddOpen={setAddSupplierOpen}
|
||||||
|
editTarget={editSupplier}
|
||||||
|
setEditTarget={setEditSupplier}
|
||||||
|
addMutation={addSupplierMutation}
|
||||||
|
updateMutation={updateSupplierMutation}
|
||||||
|
removeMutation={removeSupplierMutation}
|
||||||
|
canEdit={hasPermission('inventory.edit')}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Stock Receipts tab */}
|
||||||
|
{tab === 'stock-receipts' && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<p className="text-sm text-muted-foreground">{stockReceiptRows.length} receipt(s) on record</p>
|
||||||
|
{hasPermission('inventory.edit') && (
|
||||||
|
<Dialog open={addReceiptOpen} onOpenChange={setAddReceiptOpen}>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
<Button size="sm"><Plus className="h-4 w-4 mr-1" />Receive Stock</Button>
|
||||||
|
</DialogTrigger>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader><DialogTitle>Receive Stock</DialogTitle></DialogHeader>
|
||||||
|
<StockReceiptForm
|
||||||
|
linkedSuppliers={linkedSuppliers}
|
||||||
|
loading={createReceiptMutation.isPending}
|
||||||
|
onSubmit={(data) => createReceiptMutation.mutate(data)}
|
||||||
|
/>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="rounded-md border">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>Date</TableHead>
|
||||||
|
<TableHead>Supplier</TableHead>
|
||||||
|
<TableHead>Invoice #</TableHead>
|
||||||
|
<TableHead className="text-right">Qty</TableHead>
|
||||||
|
<TableHead className="text-right">Cost / Unit</TableHead>
|
||||||
|
<TableHead className="text-right">Total Cost</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{stockReceiptRows.length === 0 ? (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={6} className="text-center py-8 text-muted-foreground">
|
||||||
|
No stock receipts yet
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
) : (
|
||||||
|
stockReceiptRows.map((r) => (
|
||||||
|
<TableRow key={r.id}>
|
||||||
|
<TableCell className="text-sm text-muted-foreground">
|
||||||
|
{new Date(r.receivedDate + 'T12:00:00').toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' })}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-sm">{r.supplierName ?? <span className="text-muted-foreground">—</span>}</TableCell>
|
||||||
|
<TableCell className="text-sm font-mono">{r.invoiceNumber ?? <span className="text-muted-foreground">—</span>}</TableCell>
|
||||||
|
<TableCell className="text-sm text-right">{r.qty}</TableCell>
|
||||||
|
<TableCell className="text-sm text-right">${Number(r.costPerUnit).toFixed(2)}</TableCell>
|
||||||
|
<TableCell className="text-sm text-right font-medium">${Number(r.totalCost).toFixed(2)}</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
{stockReceiptRows.length > 0 && (
|
||||||
|
<p className="text-xs text-muted-foreground text-right">
|
||||||
|
Total invested: ${stockReceiptRows.reduce((sum, r) => sum + Number(r.totalCost), 0).toFixed(2)}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Price History tab */}
|
||||||
|
{tab === 'price-history' && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<p className="text-sm text-muted-foreground">{priceHistoryRows.length} price change(s) on record</p>
|
||||||
|
<div className="rounded-md border">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>Date</TableHead>
|
||||||
|
<TableHead>Previous Price</TableHead>
|
||||||
|
<TableHead>New Price</TableHead>
|
||||||
|
<TableHead>Previous Min</TableHead>
|
||||||
|
<TableHead>New Min</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{priceHistoryRows.length === 0 ? (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={5} className="text-center py-8 text-muted-foreground">
|
||||||
|
No price changes recorded yet
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
) : (
|
||||||
|
priceHistoryRows.map((h) => (
|
||||||
|
<TableRow key={h.id}>
|
||||||
|
<TableCell className="text-sm text-muted-foreground">
|
||||||
|
{new Date(h.createdAt).toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' })}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-sm">
|
||||||
|
{h.previousPrice ? `$${Number(h.previousPrice).toFixed(2)}` : '—'}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-sm font-medium">
|
||||||
|
${Number(h.newPrice).toFixed(2)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-sm">
|
||||||
|
{h.previousMinPrice ? `$${Number(h.previousMinPrice).toFixed(2)}` : '—'}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-sm">
|
||||||
|
{h.newMinPrice ? `$${Number(h.newMinPrice).toFixed(2)}` : '—'}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Edit unit dialog */}
|
||||||
|
<Dialog open={!!editUnit} onOpenChange={(o) => !o && setEditUnit(null)}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader><DialogTitle>Edit Unit</DialogTitle></DialogHeader>
|
||||||
|
{editUnit && (
|
||||||
|
<InventoryUnitForm
|
||||||
|
defaultValues={editUnit}
|
||||||
|
onSubmit={(data) => updateUnitMutation.mutate({ id: editUnit.id, data })}
|
||||||
|
loading={updateUnitMutation.isPending}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Suppliers tab ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function SuppliersTab({
|
||||||
|
productId,
|
||||||
|
linkedSuppliers,
|
||||||
|
addOpen, setAddOpen,
|
||||||
|
editTarget, setEditTarget,
|
||||||
|
addMutation, updateMutation, removeMutation,
|
||||||
|
canEdit,
|
||||||
|
}: {
|
||||||
|
productId: string
|
||||||
|
linkedSuppliers: ProductSupplier[]
|
||||||
|
addOpen: boolean
|
||||||
|
setAddOpen: (v: boolean) => void
|
||||||
|
editTarget: ProductSupplier | null
|
||||||
|
setEditTarget: (v: ProductSupplier | null) => void
|
||||||
|
addMutation: any
|
||||||
|
updateMutation: any
|
||||||
|
removeMutation: any
|
||||||
|
canEdit: boolean
|
||||||
|
}) {
|
||||||
|
const { data: allSuppliersData } = useQuery(supplierListOptions({ page: 1, limit: 500, order: 'asc', sort: 'name' } as any))
|
||||||
|
const allSuppliers = allSuppliersData?.data ?? []
|
||||||
|
const linkedIds = new Set(linkedSuppliers.map((s) => s.supplierId))
|
||||||
|
const availableSuppliers = allSuppliers.filter((s) => !linkedIds.has(s.id))
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<p className="text-sm text-muted-foreground">{linkedSuppliers.length} supplier(s) linked</p>
|
||||||
|
{canEdit && availableSuppliers.length > 0 && (
|
||||||
|
<Dialog open={addOpen} onOpenChange={setAddOpen}>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
<Button size="sm"><Plus className="mr-2 h-4 w-4" />Link Supplier</Button>
|
||||||
|
</DialogTrigger>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader><DialogTitle>Link Supplier</DialogTitle></DialogHeader>
|
||||||
|
<SupplierLinkForm
|
||||||
|
suppliers={availableSuppliers}
|
||||||
|
onSubmit={addMutation.mutate}
|
||||||
|
loading={addMutation.isPending}
|
||||||
|
hasExisting={linkedSuppliers.length > 0}
|
||||||
|
/>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-md border">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>Supplier</TableHead>
|
||||||
|
<TableHead>Their SKU</TableHead>
|
||||||
|
<TableHead>Contact</TableHead>
|
||||||
|
<TableHead>Terms</TableHead>
|
||||||
|
<TableHead>Preferred</TableHead>
|
||||||
|
{canEdit && <TableHead className="w-10" />}
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{linkedSuppliers.length === 0 ? (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={6} className="text-center py-8 text-muted-foreground">
|
||||||
|
No suppliers linked — add the first supplier above
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
) : (
|
||||||
|
linkedSuppliers.map((s) => (
|
||||||
|
<TableRow key={s.id}>
|
||||||
|
<TableCell>
|
||||||
|
<div>
|
||||||
|
<p className="font-medium text-sm">{s.supplierName}</p>
|
||||||
|
{s.supplierEmail && (
|
||||||
|
<a href={`mailto:${s.supplierEmail}`} className="text-xs text-muted-foreground hover:underline" onClick={(e) => e.stopPropagation()}>
|
||||||
|
{s.supplierEmail}
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="font-mono text-sm">{s.supplierSku ?? <span className="text-muted-foreground">—</span>}</TableCell>
|
||||||
|
<TableCell className="text-sm text-muted-foreground">{s.supplierContactName ?? '—'}</TableCell>
|
||||||
|
<TableCell className="text-sm text-muted-foreground">{s.supplierPaymentTerms ?? '—'}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{s.isPreferred && (
|
||||||
|
<span className="inline-flex items-center gap-1 text-xs font-medium text-amber-700 bg-amber-50 border border-amber-200 px-2 py-0.5 rounded">
|
||||||
|
<Star className="h-3 w-3 fill-amber-500 text-amber-500" />Preferred
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
{canEdit && (
|
||||||
|
<TableCell>
|
||||||
|
<Button variant="ghost" size="sm" className="h-7 w-7 p-0" onClick={() => setEditTarget(s)}>
|
||||||
|
<Pencil className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
</TableCell>
|
||||||
|
)}
|
||||||
|
</TableRow>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Edit/remove supplier link dialog */}
|
||||||
|
<Dialog open={!!editTarget} onOpenChange={(o) => !o && setEditTarget(null)}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader><DialogTitle>Edit Supplier Link</DialogTitle></DialogHeader>
|
||||||
|
{editTarget && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<p className="text-sm font-medium">{editTarget.supplierName}</p>
|
||||||
|
<SupplierLinkEditForm
|
||||||
|
defaultValues={editTarget}
|
||||||
|
onSubmit={(data) => updateMutation.mutate({ id: editTarget.id, data })}
|
||||||
|
loading={updateMutation.isPending}
|
||||||
|
/>
|
||||||
|
<div className="border-t pt-4">
|
||||||
|
<AlertDialog>
|
||||||
|
<AlertDialogTrigger asChild>
|
||||||
|
<Button variant="destructive" size="sm" disabled={removeMutation.isPending}>
|
||||||
|
<Trash2 className="h-4 w-4 mr-2" />Remove Supplier
|
||||||
|
</Button>
|
||||||
|
</AlertDialogTrigger>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>Remove supplier?</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
This will unlink {editTarget.supplierName} from this product. You can re-add it later.
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||||
|
<AlertDialogAction onClick={() => removeMutation.mutate(editTarget.id)}>Remove</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SupplierLinkForm({
|
||||||
|
suppliers, onSubmit, loading, hasExisting,
|
||||||
|
}: {
|
||||||
|
suppliers: { id: string; name: string }[]
|
||||||
|
onSubmit: (data: Record<string, unknown>) => void
|
||||||
|
loading: boolean
|
||||||
|
hasExisting: boolean
|
||||||
|
}) {
|
||||||
|
const [supplierId, setSupplierId] = useState('')
|
||||||
|
const [supplierSku, setSupplierSku] = useState('')
|
||||||
|
const [isPreferred, setIsPreferred] = useState(!hasExisting)
|
||||||
|
|
||||||
|
function handleSubmit(e: React.FormEvent) {
|
||||||
|
e.preventDefault()
|
||||||
|
if (!supplierId) return
|
||||||
|
onSubmit({ supplierId, supplierSku: supplierSku || undefined, isPreferred })
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Supplier</Label>
|
||||||
|
<Select value={supplierId} onValueChange={setSupplierId}>
|
||||||
|
<SelectTrigger><SelectValue placeholder="Select supplier…" /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{suppliers.map((s) => <SelectItem key={s.id} value={s.id}>{s.name}</SelectItem>)}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="supplier-sku">Their SKU / Part #</Label>
|
||||||
|
<Input id="supplier-sku" value={supplierSku} onChange={(e) => setSupplierSku(e.target.value)} placeholder="e.g. VLN-44-EA" />
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Checkbox id="is-preferred" checked={isPreferred} onCheckedChange={(v: boolean | 'indeterminate') => setIsPreferred(!!v)} />
|
||||||
|
<Label htmlFor="is-preferred">Preferred supplier for this product</Label>
|
||||||
|
</div>
|
||||||
|
<Button type="submit" disabled={!supplierId || loading}>{loading ? 'Linking…' : 'Link Supplier'}</Button>
|
||||||
|
</form>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function StockReceiptForm({
|
||||||
|
linkedSuppliers, onSubmit, loading,
|
||||||
|
}: {
|
||||||
|
linkedSuppliers: ProductSupplier[]
|
||||||
|
onSubmit: (data: Record<string, unknown>) => void
|
||||||
|
loading: boolean
|
||||||
|
}) {
|
||||||
|
const today = new Date().toISOString().split('T')[0]
|
||||||
|
const [supplierId, setSupplierId] = useState('')
|
||||||
|
const [qty, setQty] = useState('1')
|
||||||
|
const [costPerUnit, setCostPerUnit] = useState('')
|
||||||
|
const [receivedDate, setReceivedDate] = useState(today)
|
||||||
|
const [invoiceNumber, setInvoiceNumber] = useState('')
|
||||||
|
const [notes, setNotes] = useState('')
|
||||||
|
|
||||||
|
const total = (parseFloat(qty) || 0) * (parseFloat(costPerUnit) || 0)
|
||||||
|
|
||||||
|
function handleSubmit(e: React.FormEvent) {
|
||||||
|
e.preventDefault()
|
||||||
|
onSubmit({
|
||||||
|
supplierId: supplierId || undefined,
|
||||||
|
qty: parseInt(qty, 10),
|
||||||
|
costPerUnit: parseFloat(costPerUnit),
|
||||||
|
receivedDate,
|
||||||
|
invoiceNumber: invoiceNumber || undefined,
|
||||||
|
notes: notes || undefined,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Supplier</Label>
|
||||||
|
<Select value={supplierId || 'none'} onValueChange={(v) => setSupplierId(v === 'none' ? '' : v)}>
|
||||||
|
<SelectTrigger><SelectValue placeholder="Select supplier…" /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="none">No supplier</SelectItem>
|
||||||
|
{linkedSuppliers.map((s) => (
|
||||||
|
<SelectItem key={s.supplierId} value={s.supplierId}>{s.supplierName}</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="rcpt-qty">Qty Received *</Label>
|
||||||
|
<Input id="rcpt-qty" type="number" min="1" value={qty} onChange={(e) => setQty(e.target.value)} required />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="rcpt-cost">Cost / Unit *</Label>
|
||||||
|
<Input id="rcpt-cost" type="number" step="0.01" min="0" value={costPerUnit} onChange={(e) => setCostPerUnit(e.target.value)} placeholder="0.00" required />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{costPerUnit && qty && (
|
||||||
|
<p className="text-sm text-muted-foreground">Total cost: <span className="font-medium text-foreground">${total.toFixed(2)}</span></p>
|
||||||
|
)}
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="rcpt-date">Received Date *</Label>
|
||||||
|
<Input id="rcpt-date" type="date" value={receivedDate} onChange={(e) => setReceivedDate(e.target.value)} required />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="rcpt-inv">Invoice #</Label>
|
||||||
|
<Input id="rcpt-inv" value={invoiceNumber} onChange={(e) => setInvoiceNumber(e.target.value)} placeholder="INV-001" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="rcpt-notes">Notes</Label>
|
||||||
|
<Input id="rcpt-notes" value={notes} onChange={(e) => setNotes(e.target.value)} placeholder="Optional notes" />
|
||||||
|
</div>
|
||||||
|
<Button type="submit" disabled={loading || !costPerUnit || !qty} className="w-full">
|
||||||
|
{loading ? 'Recording…' : 'Record Receipt'}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SupplierLinkEditForm({
|
||||||
|
defaultValues, onSubmit, loading,
|
||||||
|
}: {
|
||||||
|
defaultValues: ProductSupplier
|
||||||
|
onSubmit: (data: Record<string, unknown>) => void
|
||||||
|
loading: boolean
|
||||||
|
}) {
|
||||||
|
const [supplierSku, setSupplierSku] = useState(defaultValues.supplierSku ?? '')
|
||||||
|
const [isPreferred, setIsPreferred] = useState(defaultValues.isPreferred)
|
||||||
|
|
||||||
|
function handleSubmit(e: React.FormEvent) {
|
||||||
|
e.preventDefault()
|
||||||
|
onSubmit({ supplierSku: supplierSku || undefined, isPreferred })
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="edit-sku">Their SKU / Part #</Label>
|
||||||
|
<Input id="edit-sku" value={supplierSku} onChange={(e) => setSupplierSku(e.target.value)} placeholder="e.g. VLN-44-EA" />
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Checkbox id="edit-preferred" checked={isPreferred} onCheckedChange={(v: boolean | 'indeterminate') => setIsPreferred(!!v)} />
|
||||||
|
<Label htmlFor="edit-preferred">Preferred supplier for this product</Label>
|
||||||
|
</div>
|
||||||
|
<Button type="submit" disabled={loading}>{loading ? 'Saving…' : 'Save Changes'}</Button>
|
||||||
|
</form>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,170 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import { createFileRoute } from '@tanstack/react-router'
|
||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
|
import { categoryListOptions, categoryMutations, categoryKeys } from '@/api/inventory'
|
||||||
|
import { CategoryForm } from '@/components/inventory/category-form'
|
||||||
|
import { usePagination } from '@/hooks/use-pagination'
|
||||||
|
import { DataTable, type Column } from '@/components/shared/data-table'
|
||||||
|
import { Badge } from '@/components/ui/badge'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
|
import { Search, Plus } from 'lucide-react'
|
||||||
|
import { toast } from 'sonner'
|
||||||
|
import { useAuthStore } from '@/stores/auth.store'
|
||||||
|
import type { Category } from '@/types/inventory'
|
||||||
|
|
||||||
|
export const Route = createFileRoute('/_authenticated/inventory/categories')({
|
||||||
|
validateSearch: (search: Record<string, unknown>) => ({
|
||||||
|
page: Number(search.page) || 1,
|
||||||
|
limit: Number(search.limit) || 25,
|
||||||
|
q: (search.q as string) || undefined,
|
||||||
|
sort: (search.sort as string) || undefined,
|
||||||
|
order: (search.order as 'asc' | 'desc') || 'asc',
|
||||||
|
}),
|
||||||
|
component: CategoriesPage,
|
||||||
|
})
|
||||||
|
|
||||||
|
function CategoriesPage() {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
const hasPermission = useAuthStore((s) => s.hasPermission)
|
||||||
|
const { params, setPage, setSearch, setSort } = usePagination()
|
||||||
|
const [searchInput, setSearchInput] = useState(params.q ?? '')
|
||||||
|
const [createOpen, setCreateOpen] = useState(false)
|
||||||
|
const [editTarget, setEditTarget] = useState<Category | null>(null)
|
||||||
|
|
||||||
|
const { data, isLoading } = useQuery(categoryListOptions(params))
|
||||||
|
|
||||||
|
const createMutation = useMutation({
|
||||||
|
mutationFn: categoryMutations.create,
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: categoryKeys.all })
|
||||||
|
toast.success('Category created')
|
||||||
|
setCreateOpen(false)
|
||||||
|
},
|
||||||
|
onError: (err) => toast.error(err.message),
|
||||||
|
})
|
||||||
|
|
||||||
|
const updateMutation = useMutation({
|
||||||
|
mutationFn: ({ id, data }: { id: string; data: Record<string, unknown> }) =>
|
||||||
|
categoryMutations.update(id, data),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: categoryKeys.all })
|
||||||
|
toast.success('Category updated')
|
||||||
|
setEditTarget(null)
|
||||||
|
},
|
||||||
|
onError: (err) => toast.error(err.message),
|
||||||
|
})
|
||||||
|
|
||||||
|
const deleteMutation = useMutation({
|
||||||
|
mutationFn: categoryMutations.delete,
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: categoryKeys.all })
|
||||||
|
toast.success('Category deleted')
|
||||||
|
setEditTarget(null)
|
||||||
|
},
|
||||||
|
onError: (err) => toast.error(err.message),
|
||||||
|
})
|
||||||
|
|
||||||
|
function handleSearchSubmit(e: React.FormEvent) {
|
||||||
|
e.preventDefault()
|
||||||
|
setSearch(searchInput)
|
||||||
|
}
|
||||||
|
|
||||||
|
const columns: Column<Category>[] = [
|
||||||
|
{
|
||||||
|
key: 'name',
|
||||||
|
header: 'Name',
|
||||||
|
sortable: true,
|
||||||
|
render: (c) => <span className="font-medium">{c.name}</span>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'sort_order',
|
||||||
|
header: 'Order',
|
||||||
|
sortable: true,
|
||||||
|
render: (c) => <span className="text-muted-foreground text-sm">{c.sortOrder}</span>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'is_active',
|
||||||
|
header: 'Status',
|
||||||
|
sortable: true,
|
||||||
|
render: (c) => c.isActive
|
||||||
|
? <Badge>Active</Badge>
|
||||||
|
: <Badge variant="secondary">Inactive</Badge>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'actions',
|
||||||
|
header: '',
|
||||||
|
render: (c) => hasPermission('inventory.edit') ? (
|
||||||
|
<Button variant="ghost" size="sm" onClick={(e) => { e.stopPropagation(); setEditTarget(c) }}>
|
||||||
|
Edit
|
||||||
|
</Button>
|
||||||
|
) : null,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h1 className="text-2xl font-bold">Categories</h1>
|
||||||
|
{hasPermission('inventory.edit') && (
|
||||||
|
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
<Button><Plus className="mr-2 h-4 w-4" />New Category</Button>
|
||||||
|
</DialogTrigger>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader><DialogTitle>New Category</DialogTitle></DialogHeader>
|
||||||
|
<CategoryForm
|
||||||
|
onSubmit={createMutation.mutate}
|
||||||
|
loading={createMutation.isPending}
|
||||||
|
/>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={handleSearchSubmit} className="flex gap-2 max-w-sm">
|
||||||
|
<div className="relative flex-1">
|
||||||
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
placeholder="Search categories..."
|
||||||
|
value={searchInput}
|
||||||
|
onChange={(e) => setSearchInput(e.target.value)}
|
||||||
|
className="pl-9"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button type="submit" variant="secondary">Search</Button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<DataTable
|
||||||
|
columns={columns}
|
||||||
|
data={data?.data ?? []}
|
||||||
|
loading={isLoading}
|
||||||
|
page={params.page}
|
||||||
|
totalPages={data?.pagination.totalPages ?? 1}
|
||||||
|
total={data?.pagination.total ?? 0}
|
||||||
|
sort={params.sort}
|
||||||
|
order={params.order}
|
||||||
|
onPageChange={setPage}
|
||||||
|
onSort={setSort}
|
||||||
|
onRowClick={hasPermission('inventory.edit') ? setEditTarget : undefined}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Edit dialog */}
|
||||||
|
<Dialog open={!!editTarget} onOpenChange={(o) => !o && setEditTarget(null)}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader><DialogTitle>Edit Category</DialogTitle></DialogHeader>
|
||||||
|
{editTarget && (
|
||||||
|
<CategoryForm
|
||||||
|
defaultValues={editTarget}
|
||||||
|
onSubmit={(data) => updateMutation.mutate({ id: editTarget.id, data })}
|
||||||
|
loading={updateMutation.isPending}
|
||||||
|
onDelete={hasPermission('inventory.admin') ? () => deleteMutation.mutate(editTarget.id) : undefined}
|
||||||
|
deleteLoading={deleteMutation.isPending}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
253
packages/admin/src/routes/_authenticated/inventory/index.tsx
Normal file
253
packages/admin/src/routes/_authenticated/inventory/index.tsx
Normal file
@@ -0,0 +1,253 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import { createFileRoute, useNavigate } from '@tanstack/react-router'
|
||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
|
import { productListOptions, productMutations, productKeys, categoryAllOptions } from '@/api/inventory'
|
||||||
|
import { ProductForm } from '@/components/inventory/product-form'
|
||||||
|
import { usePagination } from '@/hooks/use-pagination'
|
||||||
|
import { DataTable, type Column } from '@/components/shared/data-table'
|
||||||
|
import { Badge } from '@/components/ui/badge'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||||
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog'
|
||||||
|
import { Search, Plus } from 'lucide-react'
|
||||||
|
import { toast } from 'sonner'
|
||||||
|
import { useAuthStore } from '@/stores/auth.store'
|
||||||
|
import type { Product } from '@/types/inventory'
|
||||||
|
|
||||||
|
export const Route = createFileRoute('/_authenticated/inventory/')({
|
||||||
|
validateSearch: (search: Record<string, unknown>) => ({
|
||||||
|
page: Number(search.page) || 1,
|
||||||
|
limit: Number(search.limit) || 25,
|
||||||
|
q: (search.q as string) || undefined,
|
||||||
|
sort: (search.sort as string) || undefined,
|
||||||
|
order: (search.order as 'asc' | 'desc') || 'asc',
|
||||||
|
categoryId: (search.categoryId as string) || undefined,
|
||||||
|
isActive: (search.isActive as string) || undefined,
|
||||||
|
type: (search.type as string) || undefined,
|
||||||
|
lowStock: (search.lowStock as string) || undefined,
|
||||||
|
}),
|
||||||
|
component: InventoryPage,
|
||||||
|
})
|
||||||
|
|
||||||
|
function qtyBadge(qty: number, reorderPoint: number | null) {
|
||||||
|
if (qty === 0) return <Badge variant="destructive">{qty}</Badge>
|
||||||
|
if (reorderPoint !== null && qty <= reorderPoint)
|
||||||
|
return <Badge variant="secondary" className="bg-amber-100 text-amber-800 border-amber-300">{qty} Low</Badge>
|
||||||
|
return <span className="text-sm">{qty}</span>
|
||||||
|
}
|
||||||
|
|
||||||
|
function InventoryPage() {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
const hasPermission = useAuthStore((s) => s.hasPermission)
|
||||||
|
const { params, setPage, setSearch, setSort } = usePagination()
|
||||||
|
const [searchInput, setSearchInput] = useState(params.q ?? '')
|
||||||
|
const [createOpen, setCreateOpen] = useState(false)
|
||||||
|
|
||||||
|
const search = Route.useSearch()
|
||||||
|
const [categoryFilter, setCategoryFilter] = useState(search.categoryId ?? '')
|
||||||
|
const [activeFilter, setActiveFilter] = useState(search.isActive ?? '')
|
||||||
|
const [typeFilter, setTypeFilter] = useState(search.type ?? '')
|
||||||
|
const [lowStockFilter, setLowStockFilter] = useState(search.lowStock === 'true')
|
||||||
|
|
||||||
|
const { data: categoriesData } = useQuery(categoryAllOptions())
|
||||||
|
const categories = categoriesData?.data ?? []
|
||||||
|
const categoryMap = new Map(categories.map((c) => [c.id, c.name]))
|
||||||
|
|
||||||
|
const queryParams: Record<string, unknown> = { ...params }
|
||||||
|
if (categoryFilter) queryParams.categoryId = categoryFilter
|
||||||
|
if (activeFilter) queryParams.isActive = activeFilter === 'true'
|
||||||
|
if (typeFilter === 'serialized') queryParams.isSerialized = true
|
||||||
|
if (typeFilter === 'rental') queryParams.isRental = true
|
||||||
|
if (typeFilter === 'repair') queryParams.isDualUseRepair = true
|
||||||
|
if (lowStockFilter) queryParams.lowStock = true
|
||||||
|
|
||||||
|
const { data, isLoading } = useQuery(productListOptions(queryParams))
|
||||||
|
|
||||||
|
const createMutation = useMutation({
|
||||||
|
mutationFn: productMutations.create,
|
||||||
|
onSuccess: (product) => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: productKeys.all })
|
||||||
|
toast.success('Product created')
|
||||||
|
setCreateOpen(false)
|
||||||
|
navigate({ to: '/inventory/$productId', params: { productId: product.id }, search: {} as any })
|
||||||
|
},
|
||||||
|
onError: (err) => toast.error(err.message),
|
||||||
|
})
|
||||||
|
|
||||||
|
function handleSearchSubmit(e: React.FormEvent) {
|
||||||
|
e.preventDefault()
|
||||||
|
setSearch(searchInput)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleCategoryChange(v: string) {
|
||||||
|
setCategoryFilter(v === 'all' ? '' : v)
|
||||||
|
navigate({ to: '/inventory', search: { ...search, categoryId: v === 'all' ? undefined : v, page: 1 } as any })
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleActiveChange(v: string) {
|
||||||
|
setActiveFilter(v === 'all' ? '' : v)
|
||||||
|
navigate({ to: '/inventory', search: { ...search, isActive: v === 'all' ? undefined : v, page: 1 } as any })
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleTypeChange(v: string) {
|
||||||
|
setTypeFilter(v === 'all' ? '' : v)
|
||||||
|
navigate({ to: '/inventory', search: { ...search, type: v === 'all' ? undefined : v, page: 1 } as any })
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleLowStockChange(v: string) {
|
||||||
|
const on = v === 'true'
|
||||||
|
setLowStockFilter(on)
|
||||||
|
navigate({ to: '/inventory', search: { ...search, lowStock: on ? 'true' : undefined, page: 1 } as any })
|
||||||
|
}
|
||||||
|
|
||||||
|
const columns: Column<Product>[] = [
|
||||||
|
{
|
||||||
|
key: 'name',
|
||||||
|
header: 'Name',
|
||||||
|
sortable: true,
|
||||||
|
render: (p) => <span className="font-medium">{p.name}</span>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'sku',
|
||||||
|
header: 'SKU',
|
||||||
|
sortable: true,
|
||||||
|
render: (p) => p.sku
|
||||||
|
? <span className="font-mono text-sm">{p.sku}</span>
|
||||||
|
: <span className="text-muted-foreground">—</span>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'brand',
|
||||||
|
header: 'Brand',
|
||||||
|
sortable: true,
|
||||||
|
render: (p) => p.brand ?? <span className="text-muted-foreground">—</span>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'category',
|
||||||
|
header: 'Category',
|
||||||
|
render: (p) => p.categoryId
|
||||||
|
? (categoryMap.get(p.categoryId) ?? <span className="text-muted-foreground">—</span>)
|
||||||
|
: <span className="text-muted-foreground">—</span>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'price',
|
||||||
|
header: 'Price',
|
||||||
|
sortable: true,
|
||||||
|
render: (p) => p.price ? `$${Number(p.price).toFixed(2)}` : <span className="text-muted-foreground">—</span>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'qty_on_hand',
|
||||||
|
header: 'Qty',
|
||||||
|
sortable: true,
|
||||||
|
render: (p) => qtyBadge(p.qtyOnHand, p.qtyReorderPoint),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'flags',
|
||||||
|
header: 'Type',
|
||||||
|
render: (p) => (
|
||||||
|
<div className="flex gap-1">
|
||||||
|
{p.isSerialized && <Badge variant="outline" className="text-xs">Serial</Badge>}
|
||||||
|
{p.isRental && <Badge variant="outline" className="text-xs">Rental</Badge>}
|
||||||
|
{p.isDualUseRepair && <Badge variant="outline" className="text-xs">Repair</Badge>}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'is_active',
|
||||||
|
header: 'Status',
|
||||||
|
sortable: true,
|
||||||
|
render: (p) => p.isActive
|
||||||
|
? <Badge>Active</Badge>
|
||||||
|
: <Badge variant="secondary">Inactive</Badge>,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h1 className="text-2xl font-bold">Products</h1>
|
||||||
|
{hasPermission('inventory.edit') && (
|
||||||
|
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
<Button><Plus className="mr-2 h-4 w-4" />New Product</Button>
|
||||||
|
</DialogTrigger>
|
||||||
|
<DialogContent className="max-w-xl max-h-[90vh] overflow-y-auto">
|
||||||
|
<DialogHeader><DialogTitle>New Product</DialogTitle></DialogHeader>
|
||||||
|
<ProductForm onSubmit={createMutation.mutate} loading={createMutation.isPending} />
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-3 flex-wrap">
|
||||||
|
<form onSubmit={handleSearchSubmit} className="flex gap-2">
|
||||||
|
<div className="relative">
|
||||||
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
placeholder="Search products..."
|
||||||
|
value={searchInput}
|
||||||
|
onChange={(e) => setSearchInput(e.target.value)}
|
||||||
|
className="pl-9 w-64"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button type="submit" variant="secondary">Search</Button>
|
||||||
|
</form>
|
||||||
|
<Select value={categoryFilter || 'all'} onValueChange={handleCategoryChange}>
|
||||||
|
<SelectTrigger className="w-44">
|
||||||
|
<SelectValue placeholder="All Categories" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="all">All Categories</SelectItem>
|
||||||
|
{categories.filter((c) => c.isActive).map((c) => (
|
||||||
|
<SelectItem key={c.id} value={c.id}>{c.name}</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<Select value={typeFilter || 'all'} onValueChange={handleTypeChange}>
|
||||||
|
<SelectTrigger className="w-36">
|
||||||
|
<SelectValue placeholder="All Types" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="all">All Types</SelectItem>
|
||||||
|
<SelectItem value="serialized">Serialized</SelectItem>
|
||||||
|
<SelectItem value="rental">Rental</SelectItem>
|
||||||
|
<SelectItem value="repair">Repair Parts</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<Select value={lowStockFilter ? 'true' : 'all'} onValueChange={handleLowStockChange}>
|
||||||
|
<SelectTrigger className="w-36">
|
||||||
|
<SelectValue placeholder="All Stock" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="all">All Stock</SelectItem>
|
||||||
|
<SelectItem value="true">Low / Out of Stock</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<Select value={activeFilter || 'all'} onValueChange={handleActiveChange}>
|
||||||
|
<SelectTrigger className="w-32">
|
||||||
|
<SelectValue placeholder="Active" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="all">Active</SelectItem>
|
||||||
|
<SelectItem value="false">Inactive</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DataTable
|
||||||
|
columns={columns}
|
||||||
|
data={data?.data ?? []}
|
||||||
|
loading={isLoading}
|
||||||
|
page={params.page}
|
||||||
|
totalPages={data?.pagination.totalPages ?? 1}
|
||||||
|
total={data?.pagination.total ?? 0}
|
||||||
|
sort={params.sort}
|
||||||
|
order={params.order}
|
||||||
|
onPageChange={setPage}
|
||||||
|
onSort={setSort}
|
||||||
|
onRowClick={(p) => navigate({ to: '/inventory/$productId', params: { productId: p.id }, search: {} as any })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,185 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import { createFileRoute } from '@tanstack/react-router'
|
||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
|
import { supplierListOptions, supplierMutations, supplierKeys } from '@/api/inventory'
|
||||||
|
import { SupplierForm } from '@/components/inventory/supplier-form'
|
||||||
|
import { usePagination } from '@/hooks/use-pagination'
|
||||||
|
import { DataTable, type Column } from '@/components/shared/data-table'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog'
|
||||||
|
import { Search, Plus } from 'lucide-react'
|
||||||
|
import { toast } from 'sonner'
|
||||||
|
import { useAuthStore } from '@/stores/auth.store'
|
||||||
|
import type { Supplier } from '@/types/inventory'
|
||||||
|
|
||||||
|
export const Route = createFileRoute('/_authenticated/inventory/suppliers/')({
|
||||||
|
validateSearch: (search: Record<string, unknown>) => ({
|
||||||
|
page: Number(search.page) || 1,
|
||||||
|
limit: Number(search.limit) || 25,
|
||||||
|
q: (search.q as string) || undefined,
|
||||||
|
sort: (search.sort as string) || undefined,
|
||||||
|
order: (search.order as 'asc' | 'desc') || 'asc',
|
||||||
|
}),
|
||||||
|
component: SuppliersPage,
|
||||||
|
})
|
||||||
|
|
||||||
|
function SuppliersPage() {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
const hasPermission = useAuthStore((s) => s.hasPermission)
|
||||||
|
const { params, setPage, setSearch, setSort } = usePagination()
|
||||||
|
const [searchInput, setSearchInput] = useState(params.q ?? '')
|
||||||
|
const [createOpen, setCreateOpen] = useState(false)
|
||||||
|
const [editTarget, setEditTarget] = useState<Supplier | null>(null)
|
||||||
|
|
||||||
|
const { data, isLoading } = useQuery(supplierListOptions(params))
|
||||||
|
|
||||||
|
const createMutation = useMutation({
|
||||||
|
mutationFn: supplierMutations.create,
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: supplierKeys.all })
|
||||||
|
toast.success('Supplier created')
|
||||||
|
setCreateOpen(false)
|
||||||
|
},
|
||||||
|
onError: (err) => toast.error(err.message),
|
||||||
|
})
|
||||||
|
|
||||||
|
const updateMutation = useMutation({
|
||||||
|
mutationFn: ({ id, data }: { id: string; data: Record<string, unknown> }) =>
|
||||||
|
supplierMutations.update(id, data),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: supplierKeys.all })
|
||||||
|
toast.success('Supplier updated')
|
||||||
|
setEditTarget(null)
|
||||||
|
},
|
||||||
|
onError: (err) => toast.error(err.message),
|
||||||
|
})
|
||||||
|
|
||||||
|
const deleteMutation = useMutation({
|
||||||
|
mutationFn: supplierMutations.delete,
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: supplierKeys.all })
|
||||||
|
toast.success('Supplier deleted')
|
||||||
|
setEditTarget(null)
|
||||||
|
},
|
||||||
|
onError: (err) => toast.error(err.message),
|
||||||
|
})
|
||||||
|
|
||||||
|
function handleSearchSubmit(e: React.FormEvent) {
|
||||||
|
e.preventDefault()
|
||||||
|
setSearch(searchInput)
|
||||||
|
}
|
||||||
|
|
||||||
|
const columns: Column<Supplier>[] = [
|
||||||
|
{
|
||||||
|
key: 'name',
|
||||||
|
header: 'Name',
|
||||||
|
sortable: true,
|
||||||
|
render: (s) => <span className="font-medium">{s.name}</span>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'contact_name',
|
||||||
|
header: 'Contact',
|
||||||
|
render: (s) => s.contactName ?? <span className="text-muted-foreground">—</span>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'email',
|
||||||
|
header: 'Email',
|
||||||
|
sortable: true,
|
||||||
|
render: (s) => s.email
|
||||||
|
? <a href={`mailto:${s.email}`} className="hover:underline" onClick={(e) => e.stopPropagation()}>{s.email}</a>
|
||||||
|
: <span className="text-muted-foreground">—</span>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'phone',
|
||||||
|
header: 'Phone',
|
||||||
|
render: (s) => s.phone ?? <span className="text-muted-foreground">—</span>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'account_number',
|
||||||
|
header: 'Account #',
|
||||||
|
render: (s) => s.accountNumber
|
||||||
|
? <span className="font-mono text-sm">{s.accountNumber}</span>
|
||||||
|
: <span className="text-muted-foreground">—</span>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'payment_terms',
|
||||||
|
header: 'Terms',
|
||||||
|
render: (s) => s.paymentTerms ?? <span className="text-muted-foreground">—</span>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'actions',
|
||||||
|
header: '',
|
||||||
|
render: (s) => hasPermission('inventory.edit') ? (
|
||||||
|
<Button variant="ghost" size="sm" onClick={(e) => { e.stopPropagation(); setEditTarget(s) }}>
|
||||||
|
Edit
|
||||||
|
</Button>
|
||||||
|
) : null,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h1 className="text-2xl font-bold">Suppliers</h1>
|
||||||
|
{hasPermission('inventory.edit') && (
|
||||||
|
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
<Button><Plus className="mr-2 h-4 w-4" />New Supplier</Button>
|
||||||
|
</DialogTrigger>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader><DialogTitle>New Supplier</DialogTitle></DialogHeader>
|
||||||
|
<SupplierForm
|
||||||
|
onSubmit={supplierMutations.create.bind(null) as any}
|
||||||
|
loading={createMutation.isPending}
|
||||||
|
/>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={handleSearchSubmit} className="flex gap-2 max-w-sm">
|
||||||
|
<div className="relative flex-1">
|
||||||
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
placeholder="Search suppliers..."
|
||||||
|
value={searchInput}
|
||||||
|
onChange={(e) => setSearchInput(e.target.value)}
|
||||||
|
className="pl-9"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button type="submit" variant="secondary">Search</Button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<DataTable
|
||||||
|
columns={columns}
|
||||||
|
data={data?.data ?? []}
|
||||||
|
loading={isLoading}
|
||||||
|
page={params.page}
|
||||||
|
totalPages={data?.pagination.totalPages ?? 1}
|
||||||
|
total={data?.pagination.total ?? 0}
|
||||||
|
sort={params.sort}
|
||||||
|
order={params.order}
|
||||||
|
onPageChange={setPage}
|
||||||
|
onSort={setSort}
|
||||||
|
onRowClick={hasPermission('inventory.edit') ? setEditTarget : undefined}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Edit dialog */}
|
||||||
|
<Dialog open={!!editTarget} onOpenChange={(o) => !o && setEditTarget(null)}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader><DialogTitle>Edit Supplier</DialogTitle></DialogHeader>
|
||||||
|
{editTarget && (
|
||||||
|
<SupplierForm
|
||||||
|
defaultValues={editTarget}
|
||||||
|
onSubmit={(data) => updateMutation.mutate({ id: editTarget.id, data })}
|
||||||
|
loading={updateMutation.isPending}
|
||||||
|
onDelete={hasPermission('inventory.admin') ? () => deleteMutation.mutate(editTarget.id) : undefined}
|
||||||
|
deleteLoading={deleteMutation.isPending}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
108
packages/admin/src/types/inventory.ts
Normal file
108
packages/admin/src/types/inventory.ts
Normal 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
|
||||||
|
}
|
||||||
364
packages/backend/api-tests/suites/inventory.ts
Normal file
364
packages/backend/api-tests/suites/inventory.ts
Normal 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
@@ -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)
|
||||||
|
|||||||
@@ -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 })
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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))
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user