diff --git a/apps/backend/src/lib/data-manager.ts b/apps/backend/src/lib/data-manager.ts index ffd5187..890d842 100644 --- a/apps/backend/src/lib/data-manager.ts +++ b/apps/backend/src/lib/data-manager.ts @@ -4,11 +4,13 @@ import { createProductsRepo, createDrugInfoRepo, createUnitsRepo, + createStockBatchesRepo, type StorageSpacesRepo, type DistributorsRepo, type ProductsRepo, type DrugInfoRepo, type UnitsRepo, + type StockBatchesRepo, } from "data-manager-sqlite"; export class DataManager { @@ -17,6 +19,7 @@ export class DataManager { readonly products: ProductsRepo; readonly drugInfo: DrugInfoRepo; readonly units: UnitsRepo; + readonly stockBatches: StockBatchesRepo; constructor() { const { repo: storageSpacesRepo } = createStorageSpacesRepo(); @@ -24,11 +27,13 @@ export class DataManager { const { repo: productsRepo } = createProductsRepo(); const { repo: drugInfoRepo } = createDrugInfoRepo(); const { repo: unitsRepo } = createUnitsRepo(); + const { repo: stockBatchesRepo } = createStockBatchesRepo(); this.storageSpaces = storageSpacesRepo; this.distributors = distributorsRepo; this.products = productsRepo; this.drugInfo = drugInfoRepo; this.units = unitsRepo; + this.stockBatches = stockBatchesRepo; } } diff --git a/apps/backend/src/trpc/pharmanager/v1/stock.ts b/apps/backend/src/trpc/pharmanager/v1/stock.ts new file mode 100644 index 0000000..ac672bf --- /dev/null +++ b/apps/backend/src/trpc/pharmanager/v1/stock.ts @@ -0,0 +1,66 @@ +import { z } from "zod"; +import { t } from "../../init"; +import { dataManager } from "../../../lib/data-manager-instance"; + +export const StockBatchSchema = z.object({ + id: z.number().int(), + product: z.object({ id: z.number().int(), name: z.string(), brand: z.string() }), + arrived: z.string(), + batch_no: z.string(), + mfg: z.string(), + expiry: z.string(), + rack: z.object({ id: z.number().int(), name: z.string() }).nullable(), + distributor: z.object({ id: z.number().int(), agency: z.string() }).nullable(), + is_default: z.boolean(), +}); + +const { shape } = StockBatchSchema; + +export const CreateStockBatchInput = z.object({ + product_id: shape.product.shape.id, + arrived: shape.arrived.min(1), + batch_no: shape.batch_no.min(1), + mfg: shape.mfg.min(1), + expiry: shape.expiry.min(1), + rack_id: z.number().int().nullable().optional(), + distributor_id: z.number().int().nullable().optional(), + is_default: shape.is_default.default(false), +}); + +export const UpdateStockBatchInput = z + .object({ id: z.number().int() }) + .merge(CreateStockBatchInput.partial()); + +export type StockBatch = z.infer; + +export const stockRouter = t.router({ + list: t.procedure + .output(z.array(StockBatchSchema)) + .query(() => dataManager.stockBatches.getStockBatches()), + + byId: t.procedure + .input(z.object({ id: z.number().int() })) + .output(StockBatchSchema.nullable()) + .query(({ input }) => dataManager.stockBatches.getStockBatchById(input.id)), + + create: t.procedure + .input(CreateStockBatchInput) + .output(StockBatchSchema) + .mutation(({ input }) => dataManager.stockBatches.createStockBatch(input)), + + update: t.procedure + .input(UpdateStockBatchInput) + .output(StockBatchSchema.nullable()) + .mutation(({ input }) => { + const { id, ...patch } = input; + return dataManager.stockBatches.updateStockBatch(id, patch); + }), + + remove: t.procedure + .input(z.object({ id: z.number().int() })) + .output(z.object({ ok: z.boolean() })) + .mutation(async ({ input }) => { + const ok = await dataManager.stockBatches.deleteStockBatch(input.id); + return { ok }; + }), +}); diff --git a/apps/backend/src/trpc/router.ts b/apps/backend/src/trpc/router.ts index 67a8001..0f9e0e6 100644 --- a/apps/backend/src/trpc/router.ts +++ b/apps/backend/src/trpc/router.ts @@ -4,6 +4,7 @@ import { distributorRouter } from "./pharmanager/v1/distributor"; import { productRouter } from "./pharmanager/v1/product"; import { drugInfoRouter } from "./pharmanager/v1/drugInfo"; import { unitsRouter } from "./pharmanager/v1/units"; +import { stockRouter } from "./pharmanager/v1/stock"; export const appRouter = t.router({ storage: storageRouter, @@ -11,6 +12,7 @@ export const appRouter = t.router({ product: productRouter, drugInfo: drugInfoRouter, units: unitsRouter, + stock: stockRouter, }); export type AppRouter = typeof appRouter; diff --git a/apps/pharmanager/src/routeTree.gen.ts b/apps/pharmanager/src/routeTree.gen.ts index 40738bd..21366c3 100644 --- a/apps/pharmanager/src/routeTree.gen.ts +++ b/apps/pharmanager/src/routeTree.gen.ts @@ -19,10 +19,13 @@ import { Route as CustomersRouteImport } from './routes/customers' import { Route as BillingRouteImport } from './routes/billing' import { Route as IndexRouteImport } from './routes/index' import { Route as StorageIndexRouteImport } from './routes/storage/index' +import { Route as StockIndexRouteImport } from './routes/stock/index' import { Route as ProductsIndexRouteImport } from './routes/products/index' import { Route as DistributorsIndexRouteImport } from './routes/distributors/index' import { Route as StorageAddRouteImport } from './routes/storage/add' import { Route as StorageIdRouteImport } from './routes/storage/$id' +import { Route as StockAddRouteImport } from './routes/stock/add' +import { Route as StockIdRouteImport } from './routes/stock/$id' import { Route as ProductsAddRouteImport } from './routes/products/add' import { Route as ProductsIdRouteImport } from './routes/products/$id' import { Route as DistributorsAddRouteImport } from './routes/distributors/add' @@ -78,6 +81,11 @@ const StorageIndexRoute = StorageIndexRouteImport.update({ path: '/', getParentRoute: () => StorageRoute, } as any) +const StockIndexRoute = StockIndexRouteImport.update({ + id: '/', + path: '/', + getParentRoute: () => StockRoute, +} as any) const ProductsIndexRoute = ProductsIndexRouteImport.update({ id: '/', path: '/', @@ -98,6 +106,16 @@ const StorageIdRoute = StorageIdRouteImport.update({ path: '/$id', getParentRoute: () => StorageRoute, } as any) +const StockAddRoute = StockAddRouteImport.update({ + id: '/add', + path: '/add', + getParentRoute: () => StockRoute, +} as any) +const StockIdRoute = StockIdRouteImport.update({ + id: '/$id', + path: '/$id', + getParentRoute: () => StockRoute, +} as any) const ProductsAddRoute = ProductsAddRouteImport.update({ id: '/add', path: '/add', @@ -127,16 +145,19 @@ export interface FileRoutesByFullPath { '/products': typeof ProductsRouteWithChildren '/profile': typeof ProfileRoute '/staff': typeof StaffRoute - '/stock': typeof StockRoute + '/stock': typeof StockRouteWithChildren '/storage': typeof StorageRouteWithChildren '/distributors/$id': typeof DistributorsIdRoute '/distributors/add': typeof DistributorsAddRoute '/products/$id': typeof ProductsIdRoute '/products/add': typeof ProductsAddRoute + '/stock/$id': typeof StockIdRoute + '/stock/add': typeof StockAddRoute '/storage/$id': typeof StorageIdRoute '/storage/add': typeof StorageAddRoute '/distributors/': typeof DistributorsIndexRoute '/products/': typeof ProductsIndexRoute + '/stock/': typeof StockIndexRoute '/storage/': typeof StorageIndexRoute } export interface FileRoutesByTo { @@ -145,15 +166,17 @@ export interface FileRoutesByTo { '/customers': typeof CustomersRoute '/profile': typeof ProfileRoute '/staff': typeof StaffRoute - '/stock': typeof StockRoute '/distributors/$id': typeof DistributorsIdRoute '/distributors/add': typeof DistributorsAddRoute '/products/$id': typeof ProductsIdRoute '/products/add': typeof ProductsAddRoute + '/stock/$id': typeof StockIdRoute + '/stock/add': typeof StockAddRoute '/storage/$id': typeof StorageIdRoute '/storage/add': typeof StorageAddRoute '/distributors': typeof DistributorsIndexRoute '/products': typeof ProductsIndexRoute + '/stock': typeof StockIndexRoute '/storage': typeof StorageIndexRoute } export interface FileRoutesById { @@ -165,16 +188,19 @@ export interface FileRoutesById { '/products': typeof ProductsRouteWithChildren '/profile': typeof ProfileRoute '/staff': typeof StaffRoute - '/stock': typeof StockRoute + '/stock': typeof StockRouteWithChildren '/storage': typeof StorageRouteWithChildren '/distributors/$id': typeof DistributorsIdRoute '/distributors/add': typeof DistributorsAddRoute '/products/$id': typeof ProductsIdRoute '/products/add': typeof ProductsAddRoute + '/stock/$id': typeof StockIdRoute + '/stock/add': typeof StockAddRoute '/storage/$id': typeof StorageIdRoute '/storage/add': typeof StorageAddRoute '/distributors/': typeof DistributorsIndexRoute '/products/': typeof ProductsIndexRoute + '/stock/': typeof StockIndexRoute '/storage/': typeof StorageIndexRoute } export interface FileRouteTypes { @@ -193,10 +219,13 @@ export interface FileRouteTypes { | '/distributors/add' | '/products/$id' | '/products/add' + | '/stock/$id' + | '/stock/add' | '/storage/$id' | '/storage/add' | '/distributors/' | '/products/' + | '/stock/' | '/storage/' fileRoutesByTo: FileRoutesByTo to: @@ -205,15 +234,17 @@ export interface FileRouteTypes { | '/customers' | '/profile' | '/staff' - | '/stock' | '/distributors/$id' | '/distributors/add' | '/products/$id' | '/products/add' + | '/stock/$id' + | '/stock/add' | '/storage/$id' | '/storage/add' | '/distributors' | '/products' + | '/stock' | '/storage' id: | '__root__' @@ -230,10 +261,13 @@ export interface FileRouteTypes { | '/distributors/add' | '/products/$id' | '/products/add' + | '/stock/$id' + | '/stock/add' | '/storage/$id' | '/storage/add' | '/distributors/' | '/products/' + | '/stock/' | '/storage/' fileRoutesById: FileRoutesById } @@ -245,7 +279,7 @@ export interface RootRouteChildren { ProductsRoute: typeof ProductsRouteWithChildren ProfileRoute: typeof ProfileRoute StaffRoute: typeof StaffRoute - StockRoute: typeof StockRoute + StockRoute: typeof StockRouteWithChildren StorageRoute: typeof StorageRouteWithChildren } @@ -321,6 +355,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof StorageIndexRouteImport parentRoute: typeof StorageRoute } + '/stock/': { + id: '/stock/' + path: '/' + fullPath: '/stock/' + preLoaderRoute: typeof StockIndexRouteImport + parentRoute: typeof StockRoute + } '/products/': { id: '/products/' path: '/' @@ -349,6 +390,20 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof StorageIdRouteImport parentRoute: typeof StorageRoute } + '/stock/add': { + id: '/stock/add' + path: '/add' + fullPath: '/stock/add' + preLoaderRoute: typeof StockAddRouteImport + parentRoute: typeof StockRoute + } + '/stock/$id': { + id: '/stock/$id' + path: '/$id' + fullPath: '/stock/$id' + preLoaderRoute: typeof StockIdRouteImport + parentRoute: typeof StockRoute + } '/products/add': { id: '/products/add' path: '/add' @@ -412,6 +467,20 @@ const ProductsRouteWithChildren = ProductsRoute._addFileChildren( ProductsRouteChildren, ) +interface StockRouteChildren { + StockIdRoute: typeof StockIdRoute + StockAddRoute: typeof StockAddRoute + StockIndexRoute: typeof StockIndexRoute +} + +const StockRouteChildren: StockRouteChildren = { + StockIdRoute: StockIdRoute, + StockAddRoute: StockAddRoute, + StockIndexRoute: StockIndexRoute, +} + +const StockRouteWithChildren = StockRoute._addFileChildren(StockRouteChildren) + interface StorageRouteChildren { StorageIdRoute: typeof StorageIdRoute StorageAddRoute: typeof StorageAddRoute @@ -435,7 +504,7 @@ const rootRouteChildren: RootRouteChildren = { ProductsRoute: ProductsRouteWithChildren, ProfileRoute: ProfileRoute, StaffRoute: StaffRoute, - StockRoute: StockRoute, + StockRoute: StockRouteWithChildren, StorageRoute: StorageRouteWithChildren, } export const routeTree = rootRouteImport diff --git a/apps/pharmanager/src/routes/products/$id.tsx b/apps/pharmanager/src/routes/products/$id.tsx index c3ab0de..6e2460e 100644 --- a/apps/pharmanager/src/routes/products/$id.tsx +++ b/apps/pharmanager/src/routes/products/$id.tsx @@ -56,9 +56,9 @@ function ProductDetailsPage() { const marginVal = (product.selling_price - product.procured_price).toFixed(2); const stockClass = - product.quantity <= product.reorder_level + product.size <= product.reorder_level ? "text-red-600 font-semibold" - : product.quantity <= product.reorder_level * 2 + : product.size <= product.reorder_level * 2 ? "text-amber-600" : ""; @@ -96,7 +96,7 @@ function ProductDetailsPage() { Inventory
- +
diff --git a/apps/pharmanager/src/routes/products/add.tsx b/apps/pharmanager/src/routes/products/add.tsx index 6c0389b..9bb70b3 100644 --- a/apps/pharmanager/src/routes/products/add.tsx +++ b/apps/pharmanager/src/routes/products/add.tsx @@ -25,7 +25,7 @@ const formSchema = CreateProductInput.extend({ procured_price: z.coerce.number().min(0, "Must be ≥ 0"), mrp: z.coerce.number().min(0, "Must be ≥ 0"), selling_price: z.coerce.number().min(0, "Must be ≥ 0"), - quantity: z.coerce.number().int().default(0), + size: z.coerce.number().int().default(0), reorder_level: z.coerce.number().int().default(0), units_per_strip: z.coerce.number().int().nullable().optional(), }); @@ -85,7 +85,7 @@ function AddProductPage() { procured_price: 0, mrp: 0, selling_price: 0, - quantity: 0, + size: 0, reorder_level: 0, units_per_strip: null, hide_product_from_public: false, @@ -120,7 +120,7 @@ function AddProductPage() { procured_price: existingProduct.procured_price, mrp: existingProduct.mrp, selling_price: existingProduct.selling_price, - quantity: existingProduct.quantity, + size: existingProduct.size, reorder_level: existingProduct.reorder_level, units_per_strip: existingProduct.units_per_strip, hide_product_from_public: existingProduct.hide_product_from_public, @@ -227,9 +227,9 @@ function AddProductPage() {
- +
diff --git a/apps/pharmanager/src/routes/products/index.tsx b/apps/pharmanager/src/routes/products/index.tsx index becd854..92004a4 100644 --- a/apps/pharmanager/src/routes/products/index.tsx +++ b/apps/pharmanager/src/routes/products/index.tsx @@ -14,7 +14,7 @@ interface ProductRow { selling_price: number; mrp: number; procured_price: number; - quantity: number; + size: number; reorder_level: number; distributor: { id: number; name: string } | null; unit: { id: number; name: string }; @@ -76,18 +76,18 @@ function makeColumns( }, }, { - id: "quantity", - header: "Quantity", + id: "size", + header: "Size", cell: ({ row }) => { const cls = - row.quantity <= row.reorder_level + row.size <= row.reorder_level ? "text-red-600" - : row.quantity <= row.reorder_level * 2 + : row.size <= row.reorder_level * 2 ? "text-amber-600" : "text-emerald-600"; return ( - {row.quantity} {row.unit.name} + {row.size} {row.unit.name} ); }, diff --git a/apps/pharmanager/src/routes/stock.tsx b/apps/pharmanager/src/routes/stock.tsx index 00973d6..4087d7e 100644 --- a/apps/pharmanager/src/routes/stock.tsx +++ b/apps/pharmanager/src/routes/stock.tsx @@ -1,13 +1,5 @@ -import { createFileRoute } from "@tanstack/react-router"; +import { Outlet, createFileRoute } from "@tanstack/react-router"; export const Route = createFileRoute("/stock")({ - component: StockPage, - staticData: { - title: "Stock", - subtitle: "Inventory levels, low-stock alerts, purchase orders", - }, + component: () => , }); - -function StockPage() { - return
Stock
; -} diff --git a/apps/pharmanager/src/routes/stock/$id.tsx b/apps/pharmanager/src/routes/stock/$id.tsx new file mode 100644 index 0000000..6abafc4 --- /dev/null +++ b/apps/pharmanager/src/routes/stock/$id.tsx @@ -0,0 +1,157 @@ +import { createFileRoute, Link } from "@tanstack/react-router"; +import { ArrowLeft, Pencil, Trash2, Package, Calendar, MapPin, Truck } from "lucide-react"; +import { Button } from "#/components/ui"; +import { useGetStockBatchById, useRemoveStockBatch, trpc } from "shared-react"; + +function daysUntil(expiry: string): number { + const today = new Date(); + today.setHours(0, 0, 0, 0); + const exp = new Date(expiry + "T00:00:00"); + return Math.ceil((exp.getTime() - today.getTime()) / (1000 * 60 * 60 * 24)); +} + +function fmtDate(d: string): string { + const date = new Date(d + "T00:00:00"); + return date.toLocaleDateString("en-US", { day: "numeric", month: "short", year: "numeric" }); +} + +export const Route = createFileRoute("/stock/$id")({ + component: StockDetailsPage, + staticData: { + title: "Stock Batch Details", + subtitle: "Batch information", + }, +}); + +function StockDetailsPage() { + const { id } = Route.useParams(); + const batchId = Number(id); + const { data: batch, isLoading, error } = useGetStockBatchById(batchId); + const removeMutation = useRemoveStockBatch(); + const utils = trpc.useUtils(); + + function handleDelete() { + if (!batch) return; + if (!confirm(`Delete batch ${batch.batch_no}? This cannot be undone.`)) return; + removeMutation.mutate( + { id: batch.id }, + { + onSuccess: () => { + utils.stock.list.invalidate(); + window.location.href = "/stock"; + }, + }, + ); + } + + if (isLoading) return
Loading batch details...
; + if (error || !batch) { + return ( +
+ +

Batch not found

+

The batch you're looking for doesn't exist.

+ + Back to Stock + +
+ ); + } + + const days = daysUntil(batch.expiry); + const dayCls = days < 0 ? "expired" : days <= 30 ? "critical" : days <= 90 ? "warning" : "healthy"; + const dayLabel = days < 0 ? "Expired" : days <= 30 ? "Expiring soon" : days <= 90 ? "Approaching expiry" : "Healthy"; + const dayBg = dayCls === "healthy" ? "bg-emerald-50 text-emerald-600" : dayCls === "warning" ? "bg-amber-50 text-amber-600" : "bg-red-50 text-red-600"; + const dateCls = days < 0 ? "text-red-600 font-semibold" : days <= 30 ? "text-amber-600 font-semibold" : ""; + + return ( +
+ + + Back to Stock + + +
+
+
+ + Product Information +
+
+ + +
+
+ +
+
+
+ + Batch Timeline +
+ {batch.is_default && ( + + ★ Default + + )} +
+
+ + + + + +
+ Shelf Life Status +
+
{Math.abs(days)}
+
{days < 0 ? "days past expiry" : "days remaining"}
+ {dayLabel} +
+
+
+
+ +
+
+ + Location +
+
+
{batch.rack?.name || "— Not assigned"}
+
+
+ +
+
+ + Supplier +
+
+
{batch.distributor?.agency || "— Not recorded"}
+
+
+
+ +
+ + +
+
+ ); +} + +function DetailRow({ label, value, valueClass = "", last = false }: { label: string; value: string; valueClass?: string; last?: boolean }) { + return ( +
+ {label} + {value} +
+ ); +} diff --git a/apps/pharmanager/src/routes/stock/add.tsx b/apps/pharmanager/src/routes/stock/add.tsx new file mode 100644 index 0000000..620d951 --- /dev/null +++ b/apps/pharmanager/src/routes/stock/add.tsx @@ -0,0 +1,247 @@ +import { useEffect } from "react"; +import { createFileRoute, Link, useNavigate } from "@tanstack/react-router"; +import { ArrowLeft, Plus } from "lucide-react"; +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { z } from "zod"; +import { + useCreateStockBatch, + useUpdateStockBatch, + useGetStockBatchById, + useListProducts, + useListStorage, + useListDistributors, + trpc, +} from "shared-react"; +import { Button, Input, buttonVariants } from "#/components/ui"; +import { CreateStockBatchInput } from "@repo/shared"; + +const formSchema = CreateStockBatchInput.extend({ + product_id: z.coerce.number().int().min(1, "Select a product"), + rack_id: z.coerce.number().int().nullable().optional(), + distributor_id: z.coerce.number().int().nullable().optional(), + quantity: z.coerce.number().int().min(1, "Quantity is required"), +}); + +type FormValues = z.infer; + +export const Route = createFileRoute("/stock/add")({ + component: AddStockPage, + validateSearch: (search: Record) => ({ + id: search.id ? Number(search.id) : undefined, + }), + staticData: { + title: "Add Stock Batch", + subtitle: "Register a new inventory batch", + }, +}); + +function AddStockPage() { + const navigate = useNavigate(); + const { id: editId } = Route.useSearch(); + const createMutation = useCreateStockBatch(); + const updateMutation = useUpdateStockBatch(); + const { data: existingBatch } = useGetStockBatchById(editId ?? 0); + const utils = trpc.useUtils(); + const { data: products } = useListProducts(); + const { data: racks } = useListStorage(); + const { data: distributorList } = useListDistributors(); + + const isEditing = typeof editId === "number" && editId > 0; + + const { + register, + handleSubmit, + reset, + formState: { errors }, + } = useForm({ + resolver: zodResolver(formSchema), + defaultValues: { + product_id: 0, + arrived: "", + batch_no: "", + mfg: "", + expiry: "", + rack_id: null, + distributor_id: null, + quantity: 0, + is_default: false, + }, + }); + + useEffect(() => { + if (isEditing && existingBatch) { + reset({ + product_id: existingBatch.product.id, + arrived: existingBatch.arrived, + batch_no: existingBatch.batch_no, + mfg: existingBatch.mfg, + expiry: existingBatch.expiry, + rack_id: existingBatch.rack?.id ?? null, + distributor_id: existingBatch.distributor?.id ?? null, + quantity: existingBatch.quantity, + is_default: existingBatch.is_default, + }); + } + }, [isEditing, existingBatch, reset]); + + function onSubmit(values: FormValues) { + if (isEditing) { + updateMutation.mutate( + { id: editId!, ...values }, + { + onSuccess: () => { + utils.stock.list.invalidate(); + utils.stock.byId.invalidate({ id: editId! }); + navigate({ to: "/stock" }); + }, + }, + ); + } else { + createMutation.mutate(values, { + onSuccess: () => { + utils.stock.list.invalidate(); + navigate({ to: "/stock" }); + }, + }); + } + } + + const mutation = isEditing ? updateMutation : createMutation; + + return ( +
+ + + Back to Stock + + +
+

+ {isEditing ? "Edit Stock Entry" : "New Stock Entry"} +

+

+ {isEditing ? "Update batch details." : "Record a new batch with manufacture and expiry dates."} +

+ +
+
+ + + {errors.product_id &&

{errors.product_id.message}

} +
+ +
+ + + {errors.arrived &&

{errors.arrived.message}

} +
+ +
+ + + {errors.batch_no &&

{errors.batch_no.message}

} +
+ +
+ + +
+ +
+ + + {errors.mfg &&

{errors.mfg.message}

} +
+ +
+ + + {errors.expiry &&

{errors.expiry.message}

} +
+ +
+ + +
+ +
+ + +
+ +
+ +
+ +
+ + Cancel + + +
+
+ + {mutation.error && ( +

Failed to {isEditing ? "update" : "create"} batch. Please try again.

+ )} +
+
+ ); +} diff --git a/apps/pharmanager/src/routes/stock/index.tsx b/apps/pharmanager/src/routes/stock/index.tsx new file mode 100644 index 0000000..14a3710 --- /dev/null +++ b/apps/pharmanager/src/routes/stock/index.tsx @@ -0,0 +1,221 @@ +import { useState, useMemo, useCallback } from "react"; +import { createFileRoute, Link } from "@tanstack/react-router"; +import { Search, Plus, Pencil, Trash2, Package, Star } from "lucide-react"; +import { GridTable } from "#/components/GridTable"; +import type { GridTableColumn } from "#/components/GridTable"; +import { Button, buttonVariants } from "#/components/ui"; +import { useListStockBatches, useRemoveStockBatch, trpc } from "shared-react"; + +interface StockRow { + id: number; + product: { id: number; name: string; brand: string }; + arrived: string; + batch_no: string; + mfg: string; + expiry: string; + rack: { id: number; name: string } | null; + distributor: { id: number; agency: string } | null; + quantity: number; + is_default: boolean; +} + +function daysUntil(expiry: string): number { + const today = new Date(); + today.setHours(0, 0, 0, 0); + const exp = new Date(expiry + "T00:00:00"); + return Math.ceil((exp.getTime() - today.getTime()) / (1000 * 60 * 60 * 24)); +} + +function fmtDate(d: string): string { + const date = new Date(d + "T00:00:00"); + return date.toLocaleDateString("en-US", { day: "numeric", month: "short", year: "numeric" }); +} + +function daysBadge(days: number) { + if (days < 0) return Expired; + if (days <= 30) return {days} days; + if (days <= 90) return {days} days; + return {days} days; +} + +function makeColumns( + onDelete: (row: StockRow) => void, +): GridTableColumn[] { + return [ + { + id: "product", + header: "Product", + cell: ({ row }) => ( +
+
{row.product.name}
+
{row.product.brand}
+
+ ), + }, + { + id: "arrived", + header: "Arrived Date", + cell: ({ row }) => {fmtDate(row.arrived)}, + }, + { + id: "batch_no", + header: "Batch No", + cell: ({ row }) => ( + {row.batch_no} + ), + }, + { + id: "quantity", + header: "Qty", + size: 60, + cell: ({ row }) => ( + {row.quantity} + ), + }, + { + id: "is_default", + header: "Default", + size: 60, + cell: ({ row }) => ( + + {row.is_default ? ( + + ) : ( + + )} + + ), + }, + { + id: "mfg", + header: "Manufacture Date", + cell: ({ row }) => {fmtDate(row.mfg)}, + }, + { + id: "expiry", + header: "Expiry Date", + cell: ({ row }) => { + const days = daysUntil(row.expiry); + return ( + + {fmtDate(row.expiry)} + + ); + }, + }, + { + id: "days", + header: "Days Remaining", + cell: ({ row }) => daysBadge(daysUntil(row.expiry)), + }, + { + id: "rack", + header: "Rack", + cell: ({ row }) => {row.rack?.name || "—"}, + }, + { + id: "distributor", + header: "Distributor", + cell: ({ row }) => {row.distributor?.agency || "—"}, + }, + { + id: "actions", + header: "Actions", + size: 90, + cell: ({ row }) => ( +
+ + View + + + + + +
+ ), + }, + ]; +} + +export const Route = createFileRoute("/stock/")({ + component: StockIndexPage, + staticData: { + title: "Stock Batches", + subtitle: "Track inventory batches by expiry and rack location", + }, +}); + +function StockIndexPage() { + const [searchQuery, setSearchQuery] = useState(""); + const { data: batches, isLoading, error } = useListStockBatches(); + const removeMutation = useRemoveStockBatch(); + const utils = trpc.useUtils(); + + const handleDelete = useCallback( + (row: StockRow) => { + if (!confirm(`Delete batch ${row.batch_no}?`)) return; + removeMutation.mutate( + { id: row.id }, + { onSuccess: () => utils.stock.list.invalidate() }, + ); + }, + [removeMutation, utils], + ); + + const columns = useMemo(() => makeColumns(handleDelete), [handleDelete]); + + const filtered = useMemo(() => { + const q = searchQuery.toLowerCase().trim(); + if (!q) return batches ?? []; + return (batches ?? []).filter((b) => { + const text = `${b.product.name} ${b.product.brand} ${b.batch_no} ${b.rack?.name || ""} ${b.distributor?.agency || ""}`; + return text.toLowerCase().includes(q); + }); + }, [searchQuery, batches]); + + if (isLoading) return
Loading stock batches...
; + if (error) return
Failed to load stock batches.
; + + return ( +
+
+
+ + setSearchQuery(e.target.value)} + placeholder="Search by product, brand, batch, or rack..." + className="bg-transparent border-none outline-none text-sm text-slate-900 w-full placeholder:text-slate-400" + /> +
+ + + Add Stock + +
+ + + +

No stock batches found

+

+ {searchQuery ? "No batches match your search." : "Add your first stock batch to get started."} +

+
+ } + /> +
+ ); +} diff --git a/packages/data-manager-sqlite/drizzle/0003_useful_ultron.sql b/packages/data-manager-sqlite/drizzle/0003_useful_ultron.sql new file mode 100644 index 0000000..e78137b --- /dev/null +++ b/packages/data-manager-sqlite/drizzle/0003_useful_ultron.sql @@ -0,0 +1,14 @@ +CREATE TABLE `stock_batches` ( + `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL, + `product_id` integer NOT NULL, + `arrived` text NOT NULL, + `batch_no` text NOT NULL, + `mfg` text NOT NULL, + `expiry` text NOT NULL, + `rack_id` integer, + `distributor_id` integer, + `is_default` integer DEFAULT false NOT NULL, + FOREIGN KEY (`product_id`) REFERENCES `products`(`id`) ON UPDATE no action ON DELETE no action, + FOREIGN KEY (`rack_id`) REFERENCES `storage_spaces`(`id`) ON UPDATE no action ON DELETE no action, + FOREIGN KEY (`distributor_id`) REFERENCES `distributors`(`id`) ON UPDATE no action ON DELETE no action +); diff --git a/packages/data-manager-sqlite/drizzle/0004_ambiguous_captain_america.sql b/packages/data-manager-sqlite/drizzle/0004_ambiguous_captain_america.sql new file mode 100644 index 0000000..7fb9b31 --- /dev/null +++ b/packages/data-manager-sqlite/drizzle/0004_ambiguous_captain_america.sql @@ -0,0 +1,2 @@ +ALTER TABLE `products` RENAME COLUMN "quantity" TO "size";--> statement-breakpoint +ALTER TABLE `stock_batches` ADD `quantity` integer DEFAULT 0 NOT NULL; \ No newline at end of file diff --git a/packages/data-manager-sqlite/drizzle/meta/0003_snapshot.json b/packages/data-manager-sqlite/drizzle/meta/0003_snapshot.json new file mode 100644 index 0000000..390e469 --- /dev/null +++ b/packages/data-manager-sqlite/drizzle/meta/0003_snapshot.json @@ -0,0 +1,512 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "7e0de346-d773-4526-85aa-96c7c9652ac9", + "prevId": "e65ed66a-0a3c-4338-8ba7-90ca587a7c07", + "tables": { + "storage_spaces": { + "name": "storage_spaces", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "aliases": { + "name": "aliases", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'[]'" + }, + "image_urls": { + "name": "image_urls", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'[]'" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "distributors": { + "name": "distributors", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "agency": { + "name": "agency", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "contact": { + "name": "contact", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "mobile": { + "name": "mobile", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "address": { + "name": "address", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "products": { + "name": "products", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "brand": { + "name": "brand", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "category": { + "name": "category", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "distributor_id": { + "name": "distributor_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "unit_id": { + "name": "unit_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "procured_price": { + "name": "procured_price", + "type": "real", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "mrp": { + "name": "mrp", + "type": "real", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "selling_price": { + "name": "selling_price", + "type": "real", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "quantity": { + "name": "quantity", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "reorder_level": { + "name": "reorder_level", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "units_per_strip": { + "name": "units_per_strip", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "hide_product_from_public": { + "name": "hide_product_from_public", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "hide_price_from_public": { + "name": "hide_price_from_public", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + } + }, + "indexes": {}, + "foreignKeys": { + "products_distributor_id_distributors_id_fk": { + "name": "products_distributor_id_distributors_id_fk", + "tableFrom": "products", + "tableTo": "distributors", + "columnsFrom": [ + "distributor_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "products_unit_id_units_id_fk": { + "name": "products_unit_id_units_id_fk", + "tableFrom": "products", + "tableTo": "units", + "columnsFrom": [ + "unit_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "drug_info": { + "name": "drug_info", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "drug_info_name_unique": { + "name": "drug_info_name_unique", + "columns": [ + "name" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "units": { + "name": "units", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "units_name_unique": { + "name": "units_name_unique", + "columns": [ + "name" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "product_compositions": { + "name": "product_compositions", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "product_id": { + "name": "product_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "drug_info_id": { + "name": "drug_info_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "quantity": { + "name": "quantity", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "unit_id": { + "name": "unit_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "product_compositions_product_id_products_id_fk": { + "name": "product_compositions_product_id_products_id_fk", + "tableFrom": "product_compositions", + "tableTo": "products", + "columnsFrom": [ + "product_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "product_compositions_drug_info_id_drug_info_id_fk": { + "name": "product_compositions_drug_info_id_drug_info_id_fk", + "tableFrom": "product_compositions", + "tableTo": "drug_info", + "columnsFrom": [ + "drug_info_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "product_compositions_unit_id_units_id_fk": { + "name": "product_compositions_unit_id_units_id_fk", + "tableFrom": "product_compositions", + "tableTo": "units", + "columnsFrom": [ + "unit_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "stock_batches": { + "name": "stock_batches", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "product_id": { + "name": "product_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "arrived": { + "name": "arrived", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "batch_no": { + "name": "batch_no", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "mfg": { + "name": "mfg", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expiry": { + "name": "expiry", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "rack_id": { + "name": "rack_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "distributor_id": { + "name": "distributor_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_default": { + "name": "is_default", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + } + }, + "indexes": {}, + "foreignKeys": { + "stock_batches_product_id_products_id_fk": { + "name": "stock_batches_product_id_products_id_fk", + "tableFrom": "stock_batches", + "tableTo": "products", + "columnsFrom": [ + "product_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "stock_batches_rack_id_storage_spaces_id_fk": { + "name": "stock_batches_rack_id_storage_spaces_id_fk", + "tableFrom": "stock_batches", + "tableTo": "storage_spaces", + "columnsFrom": [ + "rack_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "stock_batches_distributor_id_distributors_id_fk": { + "name": "stock_batches_distributor_id_distributors_id_fk", + "tableFrom": "stock_batches", + "tableTo": "distributors", + "columnsFrom": [ + "distributor_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/packages/data-manager-sqlite/drizzle/meta/0004_snapshot.json b/packages/data-manager-sqlite/drizzle/meta/0004_snapshot.json new file mode 100644 index 0000000..bbd0581 --- /dev/null +++ b/packages/data-manager-sqlite/drizzle/meta/0004_snapshot.json @@ -0,0 +1,522 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "dfa18399-c3f0-4ef0-896b-632b38f67c1f", + "prevId": "7e0de346-d773-4526-85aa-96c7c9652ac9", + "tables": { + "storage_spaces": { + "name": "storage_spaces", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "aliases": { + "name": "aliases", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'[]'" + }, + "image_urls": { + "name": "image_urls", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'[]'" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "distributors": { + "name": "distributors", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "agency": { + "name": "agency", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "contact": { + "name": "contact", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "mobile": { + "name": "mobile", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "address": { + "name": "address", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "products": { + "name": "products", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "brand": { + "name": "brand", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "category": { + "name": "category", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "distributor_id": { + "name": "distributor_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "unit_id": { + "name": "unit_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "procured_price": { + "name": "procured_price", + "type": "real", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "mrp": { + "name": "mrp", + "type": "real", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "selling_price": { + "name": "selling_price", + "type": "real", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "reorder_level": { + "name": "reorder_level", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "units_per_strip": { + "name": "units_per_strip", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "hide_product_from_public": { + "name": "hide_product_from_public", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "hide_price_from_public": { + "name": "hide_price_from_public", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + } + }, + "indexes": {}, + "foreignKeys": { + "products_distributor_id_distributors_id_fk": { + "name": "products_distributor_id_distributors_id_fk", + "tableFrom": "products", + "tableTo": "distributors", + "columnsFrom": [ + "distributor_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "products_unit_id_units_id_fk": { + "name": "products_unit_id_units_id_fk", + "tableFrom": "products", + "tableTo": "units", + "columnsFrom": [ + "unit_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "drug_info": { + "name": "drug_info", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "drug_info_name_unique": { + "name": "drug_info_name_unique", + "columns": [ + "name" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "units": { + "name": "units", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "units_name_unique": { + "name": "units_name_unique", + "columns": [ + "name" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "product_compositions": { + "name": "product_compositions", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "product_id": { + "name": "product_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "drug_info_id": { + "name": "drug_info_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "quantity": { + "name": "quantity", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "unit_id": { + "name": "unit_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "product_compositions_product_id_products_id_fk": { + "name": "product_compositions_product_id_products_id_fk", + "tableFrom": "product_compositions", + "tableTo": "products", + "columnsFrom": [ + "product_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "product_compositions_drug_info_id_drug_info_id_fk": { + "name": "product_compositions_drug_info_id_drug_info_id_fk", + "tableFrom": "product_compositions", + "tableTo": "drug_info", + "columnsFrom": [ + "drug_info_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "product_compositions_unit_id_units_id_fk": { + "name": "product_compositions_unit_id_units_id_fk", + "tableFrom": "product_compositions", + "tableTo": "units", + "columnsFrom": [ + "unit_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "stock_batches": { + "name": "stock_batches", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "product_id": { + "name": "product_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "arrived": { + "name": "arrived", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "batch_no": { + "name": "batch_no", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "mfg": { + "name": "mfg", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expiry": { + "name": "expiry", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "rack_id": { + "name": "rack_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "distributor_id": { + "name": "distributor_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "quantity": { + "name": "quantity", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "is_default": { + "name": "is_default", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + } + }, + "indexes": {}, + "foreignKeys": { + "stock_batches_product_id_products_id_fk": { + "name": "stock_batches_product_id_products_id_fk", + "tableFrom": "stock_batches", + "tableTo": "products", + "columnsFrom": [ + "product_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "stock_batches_rack_id_storage_spaces_id_fk": { + "name": "stock_batches_rack_id_storage_spaces_id_fk", + "tableFrom": "stock_batches", + "tableTo": "storage_spaces", + "columnsFrom": [ + "rack_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "stock_batches_distributor_id_distributors_id_fk": { + "name": "stock_batches_distributor_id_distributors_id_fk", + "tableFrom": "stock_batches", + "tableTo": "distributors", + "columnsFrom": [ + "distributor_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": { + "\"products\".\"quantity\"": "\"products\".\"size\"" + } + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/packages/data-manager-sqlite/drizzle/meta/_journal.json b/packages/data-manager-sqlite/drizzle/meta/_journal.json index 34a2a84..a435b2b 100644 --- a/packages/data-manager-sqlite/drizzle/meta/_journal.json +++ b/packages/data-manager-sqlite/drizzle/meta/_journal.json @@ -22,6 +22,20 @@ "when": 1779530472486, "tag": "0002_sour_praxagora", "breakpoints": true + }, + { + "idx": 3, + "version": "6", + "when": 1779532150299, + "tag": "0003_useful_ultron", + "breakpoints": true + }, + { + "idx": 4, + "version": "6", + "when": 1779533139096, + "tag": "0004_ambiguous_captain_america", + "breakpoints": true } ] } \ No newline at end of file diff --git a/packages/data-manager-sqlite/src/index.ts b/packages/data-manager-sqlite/src/index.ts index 5088f93..20baf35 100644 --- a/packages/data-manager-sqlite/src/index.ts +++ b/packages/data-manager-sqlite/src/index.ts @@ -34,3 +34,9 @@ export { type Unit, type UnitsRepo, } from './units' +export { + createStockBatchesRepo, + type StockBatch, + type StockBatchesRepo, +} from './stockBatches' +export { stockBatches } from './schema/stockBatches' diff --git a/packages/data-manager-sqlite/src/products.ts b/packages/data-manager-sqlite/src/products.ts index 669f753..ade3168 100644 --- a/packages/data-manager-sqlite/src/products.ts +++ b/packages/data-manager-sqlite/src/products.ts @@ -28,7 +28,7 @@ interface ProductFields { procured_price: number mrp: number selling_price: number - quantity: number + size: number reorder_level: number units_per_strip: number | null hide_product_from_public: boolean @@ -92,7 +92,7 @@ function toProduct( procured_price: row.procuredPrice, mrp: row.mrp, selling_price: row.sellingPrice, - quantity: row.quantity, + size: row.size, reorder_level: row.reorderLevel, units_per_strip: row.unitsPerStrip, hide_product_from_public: row.hideProductFromPublic, @@ -191,7 +191,7 @@ export function createProductsRepo(): { repo: ProductsRepo } { procuredPrice: input.procured_price, mrp: input.mrp, sellingPrice: input.selling_price, - quantity: input.quantity, + size: input.size, reorderLevel: input.reorder_level, unitsPerStrip: input.units_per_strip ?? null, hideProductFromPublic: input.hide_product_from_public ?? false, @@ -229,7 +229,7 @@ export function createProductsRepo(): { repo: ProductsRepo } { if (patch.procured_price !== undefined) setData.procuredPrice = patch.procured_price if (patch.mrp !== undefined) setData.mrp = patch.mrp if (patch.selling_price !== undefined) setData.sellingPrice = patch.selling_price - if (patch.quantity !== undefined) setData.quantity = patch.quantity + if (patch.size !== undefined) setData.size = patch.size if (patch.reorder_level !== undefined) setData.reorderLevel = patch.reorder_level if (patch.units_per_strip !== undefined) setData.unitsPerStrip = patch.units_per_strip ?? null if (patch.hide_product_from_public !== undefined) setData.hideProductFromPublic = patch.hide_product_from_public diff --git a/packages/data-manager-sqlite/src/schema/index.ts b/packages/data-manager-sqlite/src/schema/index.ts index d8e1861..adeb0af 100644 --- a/packages/data-manager-sqlite/src/schema/index.ts +++ b/packages/data-manager-sqlite/src/schema/index.ts @@ -4,3 +4,4 @@ export * from './products' export * from './drugInfo' export * from './units' export * from './productCompositions' +export * from './stockBatches' diff --git a/packages/data-manager-sqlite/src/schema/products.ts b/packages/data-manager-sqlite/src/schema/products.ts index 1869649..137d333 100644 --- a/packages/data-manager-sqlite/src/schema/products.ts +++ b/packages/data-manager-sqlite/src/schema/products.ts @@ -12,7 +12,7 @@ export const products = sqliteTable('products', { procuredPrice: real('procured_price').notNull(), mrp: real('mrp').notNull(), sellingPrice: real('selling_price').notNull(), - quantity: integer('quantity').notNull().default(0), + size: integer('size').notNull().default(0), reorderLevel: integer('reorder_level').notNull().default(0), unitsPerStrip: integer('units_per_strip'), hideProductFromPublic: integer('hide_product_from_public', { mode: 'boolean' }).notNull().default(false), diff --git a/packages/data-manager-sqlite/src/schema/stockBatches.ts b/packages/data-manager-sqlite/src/schema/stockBatches.ts new file mode 100644 index 0000000..620ada5 --- /dev/null +++ b/packages/data-manager-sqlite/src/schema/stockBatches.ts @@ -0,0 +1,17 @@ +import { integer, sqliteTable, text } from 'drizzle-orm/sqlite-core' +import { products } from './products' +import { storageSpaces } from './storageSpacesSchema' +import { distributors } from './distributors' + +export const stockBatches = sqliteTable('stock_batches', { + id: integer('id').primaryKey({ autoIncrement: true }), + productId: integer('product_id').notNull().references(() => products.id), + arrived: text('arrived').notNull(), + batchNo: text('batch_no').notNull(), + mfg: text('mfg').notNull(), + expiry: text('expiry').notNull(), + rackId: integer('rack_id').references(() => storageSpaces.id), + distributorId: integer('distributor_id').references(() => distributors.id), + quantity: integer('quantity').notNull().default(0), + isDefault: integer('is_default', { mode: 'boolean' }).notNull().default(false), +}) diff --git a/packages/data-manager-sqlite/src/stockBatches.ts b/packages/data-manager-sqlite/src/stockBatches.ts new file mode 100644 index 0000000..f04c883 --- /dev/null +++ b/packages/data-manager-sqlite/src/stockBatches.ts @@ -0,0 +1,161 @@ +import { eq } from 'drizzle-orm' + +import { db, sqlite } from './db-instance' +import { stockBatches } from './schema/stockBatches' +import { products } from './schema/products' +import { storageSpaces } from './schema/storageSpacesSchema' +import { distributors } from './schema/distributors' + +export type StockBatch = { + id: number + product: { id: number; name: string; brand: string } + arrived: string + batch_no: string + mfg: string + expiry: string + rack: { id: number; name: string } | null + distributor: { id: number; agency: string } | null + quantity: number + is_default: boolean +} + +export type CreateStockBatchInput = { + product_id: number + arrived: string + batch_no: string + mfg: string + expiry: string + rack_id?: number | null + distributor_id?: number | null + quantity: number + is_default?: boolean +} + +export type UpdateStockBatchPatch = Partial + +export type StockBatchesRepo = { + getStockBatches: () => Promise + getStockBatchById: (id: number) => Promise + createStockBatch: (input: CreateStockBatchInput) => Promise + updateStockBatch: (id: number, patch: UpdateStockBatchPatch) => Promise + deleteStockBatch: (id: number) => Promise +} + +function fetchProduct(productId: number): { id: number; name: string; brand: string } | null { + const p = db.select().from(products).where(eq(products.id, productId)).get() + return p ? { id: p.id, name: p.name, brand: p.brand } : null +} + +function fetchRack(rackId: number | null): { id: number; name: string } | null { + if (!rackId) return null + const r = db.select().from(storageSpaces).where(eq(storageSpaces.id, rackId)).get() + return r ? { id: r.id, name: r.name } : null +} + +function fetchDistributor(distributorId: number | null): { id: number; agency: string } | null { + if (!distributorId) return null + const d = db.select().from(distributors).where(eq(distributors.id, distributorId)).get() + return d ? { id: d.id, agency: d.agency } : null +} + +function toStockBatch(row: typeof stockBatches.$inferSelect): StockBatch { + const product = fetchProduct(row.productId) + const rack = fetchRack(row.rackId) + const distributor = fetchDistributor(row.distributorId) + return { + id: row.id, + product: product ?? { id: row.productId, name: 'Unknown', brand: '' }, + arrived: row.arrived, + batch_no: row.batchNo, + mfg: row.mfg, + expiry: row.expiry, + rack, + distributor, + quantity: row.quantity, + is_default: row.isDefault, + } +} + +export function createStockBatchesRepo(): { repo: StockBatchesRepo } { + const repo: StockBatchesRepo = { + getStockBatches() { + const rows = db.select().from(stockBatches).all() + return Promise.resolve(rows.map(toStockBatch)) + }, + + getStockBatchById(id) { + const row = db.select().from(stockBatches).where(eq(stockBatches.id, id)).get() + return Promise.resolve(row ? toStockBatch(row) : null) + }, + + createStockBatch(input) { + const result = sqlite.transaction(() => { + if (input.is_default) { + db.update(stockBatches) + .set({ isDefault: false }) + .where(eq(stockBatches.productId, input.product_id)) + .run() + } + + const created = db + .insert(stockBatches) + .values({ + productId: input.product_id, + arrived: input.arrived, + batchNo: input.batch_no, + mfg: input.mfg, + expiry: input.expiry, + rackId: input.rack_id ?? null, + distributorId: input.distributor_id ?? null, + quantity: input.quantity ?? 0, + isDefault: input.is_default ?? false, + }) + .returning() + .get() + + return created + })() + + return Promise.resolve(toStockBatch(result)) + }, + + updateStockBatch(id, patch) { + const existing = db.select().from(stockBatches).where(eq(stockBatches.id, id)).get() + if (!existing) return Promise.resolve(null) + + sqlite.transaction(() => { + if (patch.is_default) { + db.update(stockBatches) + .set({ isDefault: false }) + .where(eq(stockBatches.productId, patch.product_id ?? existing.productId)) + .run() + } + + const setData: Record = {} + if (patch.product_id !== undefined) setData.productId = patch.product_id + if (patch.arrived !== undefined) setData.arrived = patch.arrived + if (patch.batch_no !== undefined) setData.batchNo = patch.batch_no + if (patch.mfg !== undefined) setData.mfg = patch.mfg + if (patch.expiry !== undefined) setData.expiry = patch.expiry + if (patch.rack_id !== undefined) setData.rackId = patch.rack_id ?? null + if (patch.distributor_id !== undefined) setData.distributorId = patch.distributor_id ?? null + if (patch.quantity !== undefined) setData.quantity = patch.quantity + if (patch.is_default !== undefined) setData.isDefault = patch.is_default + + if (Object.keys(setData).length > 0) { + db.update(stockBatches).set(setData).where(eq(stockBatches.id, id)).run() + } + })() + + const updated = db.select().from(stockBatches).where(eq(stockBatches.id, id)).get()! + return Promise.resolve(toStockBatch(updated)) + }, + + deleteStockBatch(id) { + const deleted = db.delete(stockBatches).where(eq(stockBatches.id, id)).returning({ id: stockBatches.id }).get() + return Promise.resolve(Boolean(deleted)) + }, + } + + return { repo } +} diff --git a/packages/shared-react/src/hooks/stockBatches.ts b/packages/shared-react/src/hooks/stockBatches.ts new file mode 100644 index 0000000..73a9ffc --- /dev/null +++ b/packages/shared-react/src/hooks/stockBatches.ts @@ -0,0 +1,21 @@ +import { trpc } from "../trpc"; + +export function useListStockBatches() { + return trpc.stock.list.useQuery(); +} + +export function useGetStockBatchById(id: number) { + return trpc.stock.byId.useQuery({ id }); +} + +export function useCreateStockBatch() { + return trpc.stock.create.useMutation(); +} + +export function useUpdateStockBatch() { + return trpc.stock.update.useMutation(); +} + +export function useRemoveStockBatch() { + return trpc.stock.remove.useMutation(); +} diff --git a/packages/shared-react/src/index.ts b/packages/shared-react/src/index.ts index 61b71ab..a77176a 100644 --- a/packages/shared-react/src/index.ts +++ b/packages/shared-react/src/index.ts @@ -6,4 +6,5 @@ export * from './hooks/distributors' export * from './hooks/products' export * from './hooks/drugInfo' export * from './hooks/units' +export * from './hooks/stockBatches' export { trpc } from './trpc' diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index e7c4318..4c00057 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -19,3 +19,4 @@ export type { // Shared schemas export * from './schemas/product' +export * from './schemas/stock' diff --git a/packages/shared/src/schemas/product.ts b/packages/shared/src/schemas/product.ts index c807471..f5052c9 100644 --- a/packages/shared/src/schemas/product.ts +++ b/packages/shared/src/schemas/product.ts @@ -23,7 +23,7 @@ export const ProductSchema = z.object({ procured_price: z.number(), mrp: z.number(), selling_price: z.number(), - quantity: z.number().int(), + size: z.number().int(), reorder_level: z.number().int(), units_per_strip: z.number().int().nullable(), hide_product_from_public: z.boolean(), @@ -42,7 +42,7 @@ export const CreateProductInput = z.object({ procured_price: shape.procured_price.min(0), mrp: shape.mrp.min(0), selling_price: shape.selling_price.min(0), - quantity: shape.quantity.default(0), + size: shape.size.default(0), reorder_level: shape.reorder_level.default(0), units_per_strip: shape.units_per_strip.optional(), hide_product_from_public: shape.hide_product_from_public.default(false), diff --git a/packages/shared/src/schemas/stock.ts b/packages/shared/src/schemas/stock.ts new file mode 100644 index 0000000..3889845 --- /dev/null +++ b/packages/shared/src/schemas/stock.ts @@ -0,0 +1,34 @@ +import { z } from "zod"; + +export const StockBatchSchema = z.object({ + id: z.number().int(), + product: z.object({ id: z.number().int(), name: z.string(), brand: z.string() }), + arrived: z.string(), + batch_no: z.string(), + mfg: z.string(), + expiry: z.string(), + rack: z.object({ id: z.number().int(), name: z.string() }).nullable(), + distributor: z.object({ id: z.number().int(), agency: z.string() }).nullable(), + quantity: z.number().int(), + is_default: z.boolean(), +}); + +const { shape } = StockBatchSchema; + +export const CreateStockBatchInput = z.object({ + product_id: shape.product.shape.id, + arrived: shape.arrived.min(1), + batch_no: shape.batch_no.min(1), + mfg: shape.mfg.min(1), + expiry: shape.expiry.min(1), + rack_id: z.number().int().nullable().optional(), + distributor_id: z.number().int().nullable().optional(), + quantity: z.number().int().min(1), + is_default: shape.is_default.default(false), +}); + +export const UpdateStockBatchInput = z + .object({ id: z.number().int() }) + .merge(CreateStockBatchInput.partial()); + +export type StockBatch = z.infer;