Build inventory frontend and stock management features

- Full inventory UI: product list with search/filter, product detail with
  tabs (details, units, suppliers, stock receipts, price history)
- Product filters: category, type (serialized/rental/repair), low stock,
  active/inactive — all server-side with URL-synced state
- Product-supplier junction: link products to multiple suppliers with
  preferred flag, joined supplier details in UI
- Stock receipts: record incoming stock with supplier, qty, cost per unit,
  invoice number; auto-increments qty_on_hand for non-serialized products
- Price history tab on product detail page
- categories/all endpoint to avoid pagination limit on dropdown fetches
- categoryId filter on product list endpoint
- Repair parts and additional inventory items in music store seed data
- isDualUseRepair corrected: instruments set to false, strings/parts true
- Product-supplier links and stock receipts in seed data
- Price history seed data simulating cost increases over past year
- 37 API tests covering categories, suppliers, products, units,
  product-suppliers, and stock receipts
- alert-dialog and checkbox UI components
- sync-and-deploy.sh script for rsync + remote deploy
This commit is contained in:
Ryan Moon
2026-03-30 20:12:07 -05:00
parent ec09e319ed
commit 5f5ba9e4a2
24 changed files with 4023 additions and 187 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -26,6 +26,11 @@ export const inventoryRoutes: FastifyPluginAsync = async (app) => {
return reply.send(result)
})
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) => {
const { id } = request.params as { id: string }
const category = await CategoryService.getById(app.db, id)

View File

@@ -4,9 +4,12 @@ import {
ProductUpdateSchema,
InventoryUnitCreateSchema,
InventoryUnitUpdateSchema,
ProductSupplierCreateSchema,
ProductSupplierUpdateSchema,
StockReceiptCreateSchema,
PaginationSchema,
} 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) => {
// --- Products ---
@@ -22,7 +25,16 @@ export const productRoutes: FastifyPluginAsync = async (app) => {
app.get('/products', { preHandler: [app.authenticate, app.requirePermission('inventory.view')] }, async (request, reply) => {
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)
})
@@ -87,4 +99,66 @@ export const productRoutes: FastifyPluginAsync = async (app) => {
if (!unit) return reply.status(404).send({ error: { message: 'Unit not found', statusCode: 404 } })
return reply.send(unit)
})
// --- Product Suppliers ---
app.get('/products/:productId/suppliers', { preHandler: [app.authenticate, app.requirePermission('inventory.view')] }, async (request, reply) => {
const { productId } = request.params as { productId: string }
const rows = await ProductSupplierService.listByProduct(app.db, productId)
return reply.send({ data: rows })
})
app.post('/products/:productId/suppliers', { preHandler: [app.authenticate, app.requirePermission('inventory.edit')] }, async (request, reply) => {
const { productId } = request.params as { productId: string }
const parsed = ProductSupplierCreateSchema.safeParse(request.body)
if (!parsed.success) {
return reply.status(400).send({ error: { message: 'Validation failed', details: parsed.error.flatten(), statusCode: 400 } })
}
const row = await ProductSupplierService.create(app.db, productId, parsed.data)
return reply.status(201).send(row)
})
app.patch('/products/:productId/suppliers/:id', { preHandler: [app.authenticate, app.requirePermission('inventory.edit')] }, async (request, reply) => {
const { productId, id } = request.params as { productId: string; id: string }
const parsed = ProductSupplierUpdateSchema.safeParse(request.body)
if (!parsed.success) {
return reply.status(400).send({ error: { message: 'Validation failed', details: parsed.error.flatten(), statusCode: 400 } })
}
const row = await ProductSupplierService.update(app.db, id, productId, parsed.data)
if (!row) return reply.status(404).send({ error: { message: 'Not found', statusCode: 404 } })
return reply.send(row)
})
app.delete('/products/:productId/suppliers/:id', { preHandler: [app.authenticate, app.requirePermission('inventory.edit')] }, async (request, reply) => {
const { id } = request.params as { id: string }
const row = await ProductSupplierService.delete(app.db, id)
if (!row) return reply.status(404).send({ error: { message: 'Not found', statusCode: 404 } })
return reply.send(row)
})
// --- Price History ---
app.get('/products/:productId/price-history', { preHandler: [app.authenticate, app.requirePermission('inventory.view')] }, async (request, reply) => {
const { productId } = request.params as { productId: string }
const rows = await ProductService.listPriceHistory(app.db, productId)
return reply.send({ data: rows })
})
// --- Stock Receipts ---
app.post('/products/:productId/stock-receipts', { preHandler: [app.authenticate, app.requirePermission('inventory.edit')] }, async (request, reply) => {
const { productId } = request.params as { productId: string }
const parsed = StockReceiptCreateSchema.safeParse(request.body)
if (!parsed.success) {
return reply.status(400).send({ error: { message: 'Validation failed', details: parsed.error.flatten(), statusCode: 400 } })
}
const receipt = await StockReceiptService.create(app.db, productId, parsed.data)
return reply.status(201).send(receipt)
})
app.get('/products/:productId/stock-receipts', { preHandler: [app.authenticate, app.requirePermission('inventory.view')] }, async (request, reply) => {
const { productId } = request.params as { productId: string }
const rows = await StockReceiptService.listByProduct(app.db, productId)
return reply.send({ data: rows })
})
}

View File

@@ -60,6 +60,14 @@ export const CategoryService = {
return paginatedResponse(data, total, params.page, params.limit)
},
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) {
const [category] = await db
.update(categories)

View File

@@ -1,12 +1,15 @@
import { eq, and, count, type Column } from 'drizzle-orm'
import { eq, and, count, desc, lte, sql, type Column } from 'drizzle-orm'
import type { PostgresJsDatabase } from 'drizzle-orm/postgres-js'
import { 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 type {
ProductCreateInput,
ProductUpdateInput,
InventoryUnitCreateInput,
InventoryUnitUpdateInput,
ProductSupplierCreateInput,
ProductSupplierUpdateInput,
StockReceiptCreateInput,
PaginationInput,
} from '@lunarfront/shared/schemas'
import {
@@ -40,14 +43,39 @@ export const ProductService = {
return product ?? null
},
async list(db: PostgresJsDatabase<any>, params: PaginationInput) {
const baseWhere = eq(products.isActive, true)
async list(db: PostgresJsDatabase<any>, params: PaginationInput, filters?: {
categoryId?: string
isActive?: boolean
isSerialized?: boolean
isRental?: boolean
isDualUseRepair?: boolean
lowStock?: boolean
}) {
const conditions = [eq(products.isActive, filters?.isActive ?? true)]
const searchCondition = params.q
? buildSearchCondition(params.q, [products.name, products.sku, products.upc, products.brand])
: undefined
if (params.q) {
conditions.push(buildSearchCondition(params.q, [products.name, products.sku, products.upc, products.brand])!)
}
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> = {
name: products.name,
@@ -78,7 +106,7 @@ export const ProductService = {
if (input.price !== undefined || input.minPrice !== undefined) {
const existing = await this.getById(db, id)
if (existing) {
await db.insert(priceHistory).values({
await db.insert(priceHistoryTable).values({
productId: id,
previousPrice: existing.price,
newPrice: input.price?.toString() ?? existing.price ?? '0',
@@ -111,6 +139,14 @@ export const ProductService = {
.returning()
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 = {
@@ -200,3 +236,118 @@ export const InventoryUnitService = {
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))
},
}