From 5f5ba9e4a252c5f9f7b7554a0f955c8e966d840f Mon Sep 17 00:00:00 2001 From: Ryan Moon Date: Mon, 30 Mar 2026 20:12:07 -0500 Subject: [PATCH] Build inventory frontend and stock management features MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- deploy/deploy.sh | 4 +- deploy/sync-and-deploy.sh | 25 + packages/admin/src/api/inventory.ts | 162 +++ .../components/inventory/category-form.tsx | 91 ++ .../inventory/inventory-unit-form.tsx | 118 ++ .../src/components/inventory/product-form.tsx | 172 +++ .../components/inventory/supplier-form.tsx | 88 ++ .../admin/src/components/ui/alert-dialog.tsx | 142 +++ packages/admin/src/components/ui/checkbox.tsx | 30 + packages/admin/src/routeTree.gen.ts | 89 ++ packages/admin/src/routes/_authenticated.tsx | 10 +- .../_authenticated/inventory/$productId.tsx | 786 ++++++++++++ .../_authenticated/inventory/categories.tsx | 170 +++ .../routes/_authenticated/inventory/index.tsx | 253 ++++ .../inventory/suppliers/index.tsx | 185 +++ packages/admin/src/types/inventory.ts | 108 ++ .../backend/api-tests/suites/inventory.ts | 364 ++++++ .../backend/src/db/seeds/music-store-seed.ts | 1126 ++++++++++++++--- packages/backend/src/routes/v1/inventory.ts | 5 + packages/backend/src/routes/v1/products.ts | 78 +- .../backend/src/services/inventory.service.ts | 8 + .../backend/src/services/product.service.ts | 169 ++- packages/shared/src/schemas/index.ts | 6 + .../shared/src/schemas/inventory.schema.ts | 21 + 24 files changed, 4023 insertions(+), 187 deletions(-) create mode 100755 deploy/sync-and-deploy.sh create mode 100644 packages/admin/src/api/inventory.ts create mode 100644 packages/admin/src/components/inventory/category-form.tsx create mode 100644 packages/admin/src/components/inventory/inventory-unit-form.tsx create mode 100644 packages/admin/src/components/inventory/product-form.tsx create mode 100644 packages/admin/src/components/inventory/supplier-form.tsx create mode 100644 packages/admin/src/components/ui/alert-dialog.tsx create mode 100644 packages/admin/src/components/ui/checkbox.tsx create mode 100644 packages/admin/src/routes/_authenticated/inventory/$productId.tsx create mode 100644 packages/admin/src/routes/_authenticated/inventory/categories.tsx create mode 100644 packages/admin/src/routes/_authenticated/inventory/index.tsx create mode 100644 packages/admin/src/routes/_authenticated/inventory/suppliers/index.tsx create mode 100644 packages/admin/src/types/inventory.ts create mode 100644 packages/backend/api-tests/suites/inventory.ts diff --git a/deploy/deploy.sh b/deploy/deploy.sh index c6e01d7..134d6ba 100755 --- a/deploy/deploy.sh +++ b/deploy/deploy.sh @@ -20,8 +20,8 @@ sudo -u "$APP_USER" bash -c \ "cd ${APP_DIR}/packages/backend && ${BUN_BIN} x drizzle-kit migrate" echo "==> Restarting backend..." -systemctl restart lunarfront +sudo systemctl restart lunarfront echo "==> Done! Checking status..." sleep 2 -systemctl status lunarfront --no-pager +sudo systemctl status lunarfront --no-pager diff --git a/deploy/sync-and-deploy.sh b/deploy/sync-and-deploy.sh new file mode 100755 index 0000000..f98467d --- /dev/null +++ b/deploy/sync-and-deploy.sh @@ -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" diff --git a/packages/admin/src/api/inventory.ts b/packages/admin/src/api/inventory.ts new file mode 100644 index 0000000..d6e6b16 --- /dev/null +++ b/packages/admin/src/api/inventory.ts @@ -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>('/v1/categories', params as Record), + }) +} + +export function categoryAllOptions() { + return queryOptions({ + queryKey: categoryKeys.allCategories, + queryFn: () => api.get<{ data: Category[] }>('/v1/categories/all'), + }) +} + +export const categoryMutations = { + create: (data: Record) => api.post('/v1/categories', data), + update: (id: string, data: Record) => api.patch(`/v1/categories/${id}`, data), + delete: (id: string) => api.del(`/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>('/v1/suppliers', params as Record), + }) +} + +export const supplierMutations = { + create: (data: Record) => api.post('/v1/suppliers', data), + update: (id: string, data: Record) => api.patch(`/v1/suppliers/${id}`, data), + delete: (id: string) => api.del(`/v1/suppliers/${id}`), +} + +// ─── Products ──────────────────────────────────────────────────────────────── + +export const productKeys = { + all: ['products'] as const, + list: (params: Record) => [...productKeys.all, 'list', params] as const, + detail: (id: string) => [...productKeys.all, 'detail', id] as const, +} + +export function productListOptions(params: Record) { + return queryOptions({ + queryKey: productKeys.list(params), + queryFn: () => api.get>('/v1/products', params), + }) +} + +export function productDetailOptions(id: string) { + return queryOptions({ + queryKey: productKeys.detail(id), + queryFn: () => api.get(`/v1/products/${id}`), + enabled: !!id, + }) +} + +export const productMutations = { + create: (data: Record) => api.post('/v1/products', data), + update: (id: string, data: Record) => api.patch(`/v1/products/${id}`, data), + delete: (id: string) => api.del(`/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) => + api.post(`/v1/products/${productId}/units`, data), + update: (id: string, data: Record) => + api.patch(`/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) => + api.post(`/v1/products/${productId}/suppliers`, data), + update: (productId: string, id: string, data: Record) => + api.patch(`/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) => + api.post(`/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, + }) +} diff --git a/packages/admin/src/components/inventory/category-form.tsx b/packages/admin/src/components/inventory/category-form.tsx new file mode 100644 index 0000000..b8cbfd2 --- /dev/null +++ b/packages/admin/src/components/inventory/category-form.tsx @@ -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 + onSubmit: (data: Record) => 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 ( +
+
+ + +
+
+ + +
+
+
+ + +
+
+ setValue('isActive', e.target.checked)} + className="h-4 w-4" + /> + +
+
+
+ + {onDelete && ( + + )} +
+
+ ) +} diff --git a/packages/admin/src/components/inventory/inventory-unit-form.tsx b/packages/admin/src/components/inventory/inventory-unit-form.tsx new file mode 100644 index 0000000..82a6b5e --- /dev/null +++ b/packages/admin/src/components/inventory/inventory-unit-form.tsx @@ -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 + onSubmit: (data: Record) => 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) { + 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 ( +
+
+ + +
+
+
+ + +
+
+ + +
+
+
+
+ + +
+
+ + +
+
+
+ +