From 94fbd8c87e9219138dd694040c117de9dc39a2f8 Mon Sep 17 00:00:00 2001 From: shafi54 <108669266+shafi-aviz@users.noreply.github.com> Date: Sat, 23 May 2026 15:50:26 +0530 Subject: [PATCH] product crud and management functional --- apps/backend/src/lib/data-manager.ts | 15 + .../src/trpc/pharmanager/v1/drugInfo.ts | 25 ++ .../src/trpc/pharmanager/v1/product.ts | 40 ++ apps/backend/src/trpc/pharmanager/v1/units.ts | 25 ++ apps/backend/src/trpc/router.ts | 6 + apps/pharmanager/src/routeTree.gen.ts | 83 +++- apps/pharmanager/src/routes/products.tsx | 12 +- apps/pharmanager/src/routes/products/$id.tsx | 188 ++++++++ apps/pharmanager/src/routes/products/add.tsx | 406 ++++++++++++++++++ .../pharmanager/src/routes/products/index.tsx | 242 +++++++++++ .../drizzle/0002_sour_praxagora.sql | 41 ++ .../drizzle/meta/0002_snapshot.json | 398 +++++++++++++++++ .../drizzle/meta/_journal.json | 7 + .../data-manager-sqlite/src/db-instance.ts | 6 + packages/data-manager-sqlite/src/drugInfo.ts | 30 ++ packages/data-manager-sqlite/src/index.ts | 21 + packages/data-manager-sqlite/src/products.ts | 265 ++++++++++++ .../src/schema/drugInfo.ts | 6 + .../data-manager-sqlite/src/schema/index.ts | 4 + .../src/schema/productCompositions.ts | 12 + .../src/schema/products.ts | 20 + .../data-manager-sqlite/src/schema/units.ts | 6 + packages/data-manager-sqlite/src/units.ts | 30 ++ packages/shared-react/src/hooks/drugInfo.ts | 5 + packages/shared-react/src/hooks/products.ts | 21 + packages/shared-react/src/hooks/units.ts | 5 + packages/shared-react/src/index.ts | 3 + packages/shared/package.json | 5 +- packages/shared/src/index.ts | 3 + packages/shared/src/schemas/product.ts | 57 +++ 30 files changed, 1970 insertions(+), 17 deletions(-) create mode 100644 apps/backend/src/trpc/pharmanager/v1/drugInfo.ts create mode 100644 apps/backend/src/trpc/pharmanager/v1/product.ts create mode 100644 apps/backend/src/trpc/pharmanager/v1/units.ts create mode 100644 apps/pharmanager/src/routes/products/$id.tsx create mode 100644 apps/pharmanager/src/routes/products/add.tsx create mode 100644 apps/pharmanager/src/routes/products/index.tsx create mode 100644 packages/data-manager-sqlite/drizzle/0002_sour_praxagora.sql create mode 100644 packages/data-manager-sqlite/drizzle/meta/0002_snapshot.json create mode 100644 packages/data-manager-sqlite/src/drugInfo.ts create mode 100644 packages/data-manager-sqlite/src/products.ts create mode 100644 packages/data-manager-sqlite/src/schema/drugInfo.ts create mode 100644 packages/data-manager-sqlite/src/schema/productCompositions.ts create mode 100644 packages/data-manager-sqlite/src/schema/products.ts create mode 100644 packages/data-manager-sqlite/src/schema/units.ts create mode 100644 packages/data-manager-sqlite/src/units.ts create mode 100644 packages/shared-react/src/hooks/drugInfo.ts create mode 100644 packages/shared-react/src/hooks/products.ts create mode 100644 packages/shared-react/src/hooks/units.ts create mode 100644 packages/shared/src/schemas/product.ts diff --git a/apps/backend/src/lib/data-manager.ts b/apps/backend/src/lib/data-manager.ts index 2a1b9a9..ffd5187 100644 --- a/apps/backend/src/lib/data-manager.ts +++ b/apps/backend/src/lib/data-manager.ts @@ -1,19 +1,34 @@ import { createStorageSpacesRepo, createDistributorsRepo, + createProductsRepo, + createDrugInfoRepo, + createUnitsRepo, type StorageSpacesRepo, type DistributorsRepo, + type ProductsRepo, + type DrugInfoRepo, + type UnitsRepo, } from "data-manager-sqlite"; export class DataManager { readonly storageSpaces: StorageSpacesRepo; readonly distributors: DistributorsRepo; + readonly products: ProductsRepo; + readonly drugInfo: DrugInfoRepo; + readonly units: UnitsRepo; constructor() { const { repo: storageSpacesRepo } = createStorageSpacesRepo(); const { repo: distributorsRepo } = createDistributorsRepo(); + const { repo: productsRepo } = createProductsRepo(); + const { repo: drugInfoRepo } = createDrugInfoRepo(); + const { repo: unitsRepo } = createUnitsRepo(); this.storageSpaces = storageSpacesRepo; this.distributors = distributorsRepo; + this.products = productsRepo; + this.drugInfo = drugInfoRepo; + this.units = unitsRepo; } } diff --git a/apps/backend/src/trpc/pharmanager/v1/drugInfo.ts b/apps/backend/src/trpc/pharmanager/v1/drugInfo.ts new file mode 100644 index 0000000..20fef10 --- /dev/null +++ b/apps/backend/src/trpc/pharmanager/v1/drugInfo.ts @@ -0,0 +1,25 @@ +import { z } from "zod"; +import { t } from "../../init"; +import { dataManager } from "../../../lib/data-manager-instance"; + +export const DrugInfoSchema = z.object({ + id: z.number().int(), + name: z.string(), +}); + +export const CreateDrugInfoInput = z.object({ + name: z.string().min(1), +}); + +export type DrugInfo = z.infer; + +export const drugInfoRouter = t.router({ + list: t.procedure + .output(z.array(DrugInfoSchema)) + .query(() => dataManager.drugInfo.listDrugInfo()), + + create: t.procedure + .input(CreateDrugInfoInput) + .output(DrugInfoSchema) + .mutation(({ input }) => dataManager.drugInfo.createDrugInfo(input)), +}); diff --git a/apps/backend/src/trpc/pharmanager/v1/product.ts b/apps/backend/src/trpc/pharmanager/v1/product.ts new file mode 100644 index 0000000..653bb1b --- /dev/null +++ b/apps/backend/src/trpc/pharmanager/v1/product.ts @@ -0,0 +1,40 @@ +import { z } from "zod"; +import { t } from "../../init"; +import { dataManager } from "../../../lib/data-manager-instance"; +import { + ProductSchema, + CreateProductInput, + UpdateProductInput, +} from "@repo/shared"; + +export const productRouter = t.router({ + list: t.procedure + .output(z.array(ProductSchema)) + .query(() => dataManager.products.getProducts()), + + byId: t.procedure + .input(z.object({ id: z.number().int() })) + .output(ProductSchema.nullable()) + .query(({ input }) => dataManager.products.getProductById(input.id)), + + create: t.procedure + .input(CreateProductInput) + .output(ProductSchema) + .mutation(({ input }) => dataManager.products.createProduct(input)), + + update: t.procedure + .input(UpdateProductInput) + .output(ProductSchema.nullable()) + .mutation(({ input }) => { + const { id, ...patch } = input; + return dataManager.products.updateProduct(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.products.deleteProduct(input.id); + return { ok }; + }), +}); diff --git a/apps/backend/src/trpc/pharmanager/v1/units.ts b/apps/backend/src/trpc/pharmanager/v1/units.ts new file mode 100644 index 0000000..fcae8e5 --- /dev/null +++ b/apps/backend/src/trpc/pharmanager/v1/units.ts @@ -0,0 +1,25 @@ +import { z } from "zod"; +import { t } from "../../init"; +import { dataManager } from "../../../lib/data-manager-instance"; + +export const UnitSchema = z.object({ + id: z.number().int(), + name: z.string(), +}); + +export const CreateUnitInput = z.object({ + name: z.string().min(1), +}); + +export type Unit = z.infer; + +export const unitsRouter = t.router({ + list: t.procedure + .output(z.array(UnitSchema)) + .query(() => dataManager.units.listUnits()), + + create: t.procedure + .input(CreateUnitInput) + .output(UnitSchema) + .mutation(({ input }) => dataManager.units.createUnit(input)), +}); diff --git a/apps/backend/src/trpc/router.ts b/apps/backend/src/trpc/router.ts index 5d35f1d..67a8001 100644 --- a/apps/backend/src/trpc/router.ts +++ b/apps/backend/src/trpc/router.ts @@ -1,10 +1,16 @@ import { t } from "./init"; import { storageRouter } from "./pharmanager/v1/storage"; import { distributorRouter } from "./pharmanager/v1/distributor"; +import { productRouter } from "./pharmanager/v1/product"; +import { drugInfoRouter } from "./pharmanager/v1/drugInfo"; +import { unitsRouter } from "./pharmanager/v1/units"; export const appRouter = t.router({ storage: storageRouter, distributor: distributorRouter, + product: productRouter, + drugInfo: drugInfoRouter, + units: unitsRouter, }); export type AppRouter = typeof appRouter; diff --git a/apps/pharmanager/src/routeTree.gen.ts b/apps/pharmanager/src/routeTree.gen.ts index aab3631..40738bd 100644 --- a/apps/pharmanager/src/routeTree.gen.ts +++ b/apps/pharmanager/src/routeTree.gen.ts @@ -19,9 +19,12 @@ 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 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 ProductsAddRouteImport } from './routes/products/add' +import { Route as ProductsIdRouteImport } from './routes/products/$id' import { Route as DistributorsAddRouteImport } from './routes/distributors/add' import { Route as DistributorsIdRouteImport } from './routes/distributors/$id' @@ -75,6 +78,11 @@ const StorageIndexRoute = StorageIndexRouteImport.update({ path: '/', getParentRoute: () => StorageRoute, } as any) +const ProductsIndexRoute = ProductsIndexRouteImport.update({ + id: '/', + path: '/', + getParentRoute: () => ProductsRoute, +} as any) const DistributorsIndexRoute = DistributorsIndexRouteImport.update({ id: '/', path: '/', @@ -90,6 +98,16 @@ const StorageIdRoute = StorageIdRouteImport.update({ path: '/$id', getParentRoute: () => StorageRoute, } as any) +const ProductsAddRoute = ProductsAddRouteImport.update({ + id: '/add', + path: '/add', + getParentRoute: () => ProductsRoute, +} as any) +const ProductsIdRoute = ProductsIdRouteImport.update({ + id: '/$id', + path: '/$id', + getParentRoute: () => ProductsRoute, +} as any) const DistributorsAddRoute = DistributorsAddRouteImport.update({ id: '/add', path: '/add', @@ -106,31 +124,36 @@ export interface FileRoutesByFullPath { '/billing': typeof BillingRoute '/customers': typeof CustomersRoute '/distributors': typeof DistributorsRouteWithChildren - '/products': typeof ProductsRoute + '/products': typeof ProductsRouteWithChildren '/profile': typeof ProfileRoute '/staff': typeof StaffRoute '/stock': typeof StockRoute '/storage': typeof StorageRouteWithChildren '/distributors/$id': typeof DistributorsIdRoute '/distributors/add': typeof DistributorsAddRoute + '/products/$id': typeof ProductsIdRoute + '/products/add': typeof ProductsAddRoute '/storage/$id': typeof StorageIdRoute '/storage/add': typeof StorageAddRoute '/distributors/': typeof DistributorsIndexRoute + '/products/': typeof ProductsIndexRoute '/storage/': typeof StorageIndexRoute } export interface FileRoutesByTo { '/': typeof IndexRoute '/billing': typeof BillingRoute '/customers': typeof CustomersRoute - '/products': typeof ProductsRoute '/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 '/storage/$id': typeof StorageIdRoute '/storage/add': typeof StorageAddRoute '/distributors': typeof DistributorsIndexRoute + '/products': typeof ProductsIndexRoute '/storage': typeof StorageIndexRoute } export interface FileRoutesById { @@ -139,16 +162,19 @@ export interface FileRoutesById { '/billing': typeof BillingRoute '/customers': typeof CustomersRoute '/distributors': typeof DistributorsRouteWithChildren - '/products': typeof ProductsRoute + '/products': typeof ProductsRouteWithChildren '/profile': typeof ProfileRoute '/staff': typeof StaffRoute '/stock': typeof StockRoute '/storage': typeof StorageRouteWithChildren '/distributors/$id': typeof DistributorsIdRoute '/distributors/add': typeof DistributorsAddRoute + '/products/$id': typeof ProductsIdRoute + '/products/add': typeof ProductsAddRoute '/storage/$id': typeof StorageIdRoute '/storage/add': typeof StorageAddRoute '/distributors/': typeof DistributorsIndexRoute + '/products/': typeof ProductsIndexRoute '/storage/': typeof StorageIndexRoute } export interface FileRouteTypes { @@ -165,24 +191,29 @@ export interface FileRouteTypes { | '/storage' | '/distributors/$id' | '/distributors/add' + | '/products/$id' + | '/products/add' | '/storage/$id' | '/storage/add' | '/distributors/' + | '/products/' | '/storage/' fileRoutesByTo: FileRoutesByTo to: | '/' | '/billing' | '/customers' - | '/products' | '/profile' | '/staff' | '/stock' | '/distributors/$id' | '/distributors/add' + | '/products/$id' + | '/products/add' | '/storage/$id' | '/storage/add' | '/distributors' + | '/products' | '/storage' id: | '__root__' @@ -197,9 +228,12 @@ export interface FileRouteTypes { | '/storage' | '/distributors/$id' | '/distributors/add' + | '/products/$id' + | '/products/add' | '/storage/$id' | '/storage/add' | '/distributors/' + | '/products/' | '/storage/' fileRoutesById: FileRoutesById } @@ -208,7 +242,7 @@ export interface RootRouteChildren { BillingRoute: typeof BillingRoute CustomersRoute: typeof CustomersRoute DistributorsRoute: typeof DistributorsRouteWithChildren - ProductsRoute: typeof ProductsRoute + ProductsRoute: typeof ProductsRouteWithChildren ProfileRoute: typeof ProfileRoute StaffRoute: typeof StaffRoute StockRoute: typeof StockRoute @@ -287,6 +321,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof StorageIndexRouteImport parentRoute: typeof StorageRoute } + '/products/': { + id: '/products/' + path: '/' + fullPath: '/products/' + preLoaderRoute: typeof ProductsIndexRouteImport + parentRoute: typeof ProductsRoute + } '/distributors/': { id: '/distributors/' path: '/' @@ -308,6 +349,20 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof StorageIdRouteImport parentRoute: typeof StorageRoute } + '/products/add': { + id: '/products/add' + path: '/add' + fullPath: '/products/add' + preLoaderRoute: typeof ProductsAddRouteImport + parentRoute: typeof ProductsRoute + } + '/products/$id': { + id: '/products/$id' + path: '/$id' + fullPath: '/products/$id' + preLoaderRoute: typeof ProductsIdRouteImport + parentRoute: typeof ProductsRoute + } '/distributors/add': { id: '/distributors/add' path: '/add' @@ -341,6 +396,22 @@ const DistributorsRouteWithChildren = DistributorsRoute._addFileChildren( DistributorsRouteChildren, ) +interface ProductsRouteChildren { + ProductsIdRoute: typeof ProductsIdRoute + ProductsAddRoute: typeof ProductsAddRoute + ProductsIndexRoute: typeof ProductsIndexRoute +} + +const ProductsRouteChildren: ProductsRouteChildren = { + ProductsIdRoute: ProductsIdRoute, + ProductsAddRoute: ProductsAddRoute, + ProductsIndexRoute: ProductsIndexRoute, +} + +const ProductsRouteWithChildren = ProductsRoute._addFileChildren( + ProductsRouteChildren, +) + interface StorageRouteChildren { StorageIdRoute: typeof StorageIdRoute StorageAddRoute: typeof StorageAddRoute @@ -361,7 +432,7 @@ const rootRouteChildren: RootRouteChildren = { BillingRoute: BillingRoute, CustomersRoute: CustomersRoute, DistributorsRoute: DistributorsRouteWithChildren, - ProductsRoute: ProductsRoute, + ProductsRoute: ProductsRouteWithChildren, ProfileRoute: ProfileRoute, StaffRoute: StaffRoute, StockRoute: StockRoute, diff --git a/apps/pharmanager/src/routes/products.tsx b/apps/pharmanager/src/routes/products.tsx index 1c76f61..e461c21 100644 --- a/apps/pharmanager/src/routes/products.tsx +++ b/apps/pharmanager/src/routes/products.tsx @@ -1,13 +1,5 @@ -import { createFileRoute } from "@tanstack/react-router"; +import { Outlet, createFileRoute } from "@tanstack/react-router"; export const Route = createFileRoute("/products")({ - component: ProductsPage, - staticData: { - title: "Products", - subtitle: "Medicine catalog with search, add, edit, delete", - }, + component: () => , }); - -function ProductsPage() { - return
Products
; -} diff --git a/apps/pharmanager/src/routes/products/$id.tsx b/apps/pharmanager/src/routes/products/$id.tsx new file mode 100644 index 0000000..c3ab0de --- /dev/null +++ b/apps/pharmanager/src/routes/products/$id.tsx @@ -0,0 +1,188 @@ +import { createFileRoute, Link } from "@tanstack/react-router"; +import { ArrowLeft, Pencil, Trash2, Pill, Package, Layers, DollarSign, EyeOff } from "lucide-react"; +import { Button } from "#/components/ui"; +import { useGetProductById, useRemoveProduct, trpc } from "shared-react"; + +export const Route = createFileRoute("/products/$id")({ + component: ProductDetailsPage, + staticData: { + title: "Product Details", + subtitle: "Full product information", + }, +}); + +function ProductDetailsPage() { + const { id } = Route.useParams(); + const productId = Number(id); + const { data: product, isLoading, error } = useGetProductById(productId); + const removeMutation = useRemoveProduct(); + const utils = trpc.useUtils(); + + function handleDelete() { + if (!product) return; + if (!confirm(`Delete ${product.name}?`)) return; + removeMutation.mutate( + { id: product.id }, + { + onSuccess: () => { + utils.product.list.invalidate(); + window.location.href = "/products"; + }, + }, + ); + } + + if (isLoading) { + return
Loading product details...
; + } + + if (error || !product) { + return ( +
+ +

Product not found

+

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

+ + Back to Products + +
+ ); + } + + const margin = ((product.selling_price - product.procured_price) / product.selling_price * 100).toFixed(1); + const marginVal = (product.selling_price - product.procured_price).toFixed(2); + + const stockClass = + product.quantity <= product.reorder_level + ? "text-red-600 font-semibold" + : product.quantity <= product.reorder_level * 2 + ? "text-amber-600" + : ""; + + return ( +
+ + + Back to Products + + +
+ {/* Basic Info */} +
+
+ + Basic Information +
+
+ + + + {product.units_per_strip && ( + + )} +
+
+ + {/* Inventory */} +
+
+ + Inventory +
+
+ + +
+
+ + {/* Composition */} +
+
+ + Composition +
+
+ {product.compositions.length > 0 ? ( +
+ {product.compositions.map((c) => ( +
+ {c.drug.name} + {c.quantity}{c.unit.name} +
+ ))} +
+ ) : ( + No composition data + )} +
+
+ + {/* Pricing */} +
+
+ + Pricing +
+
+ + + + +
+
+ + {/* Visibility */} +
+
+ + Visibility +
+
+ + +
+
+
+ + {/* Actions */} +
+ + +
+
+ ); +} + +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/products/add.tsx b/apps/pharmanager/src/routes/products/add.tsx new file mode 100644 index 0000000..6c0389b --- /dev/null +++ b/apps/pharmanager/src/routes/products/add.tsx @@ -0,0 +1,406 @@ +import { useEffect } from "react"; +import { createFileRoute, Link, useNavigate } from "@tanstack/react-router"; +import { ArrowLeft, Plus, X } from "lucide-react"; +import { useForm, useFieldArray } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { z } from "zod"; +import { + useCreateProduct, + useUpdateProduct, + useGetProductById, + useListDrugInfo, + useListUnits, + useListDistributors, + trpc, +} from "shared-react"; +import { Button, Input, buttonVariants } from "#/components/ui"; +import { CreateProductInput } from "@repo/shared"; + +const formSchema = CreateProductInput.extend({ + distributor_id: z.coerce + .number() + .int() + .nullable() + .optional(), + 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), + reorder_level: z.coerce.number().int().default(0), + units_per_strip: z.coerce.number().int().nullable().optional(), +}); + +type FormValues = z.infer; + +const CATEGORIES = [ + "Tablets", + "Capsules", + "Syrups", + "Injections", + "Ointments", + "Drops", + "Inhalers", +]; + +export const Route = createFileRoute("/products/add")({ + component: AddProductPage, + validateSearch: (search: Record) => ({ + id: search.id ? Number(search.id) : undefined, + }), + staticData: { + title: "Add Product", + subtitle: "Register a new medicine to inventory", + }, +}); + +function AddProductPage() { + const navigate = useNavigate(); + const { id: editId } = Route.useSearch(); + const createMutation = useCreateProduct(); + const updateMutation = useUpdateProduct(); + const { data: existingProduct } = useGetProductById(editId ?? 0); + const utils = trpc.useUtils(); + const { data: drugs } = useListDrugInfo(); + const { data: unitList } = useListUnits(); + const { data: distributorList } = useListDistributors(); + + const isEditing = typeof editId === "number" && editId > 0; + + const { + register, + handleSubmit, + control, + watch, + setValue, + reset, + formState: { errors }, + } = useForm({ + resolver: zodResolver(formSchema), + defaultValues: { + name: "", + brand: "", + category: "Tablets", + distributor_id: null, + unit_name: "", + procured_price: 0, + mrp: 0, + selling_price: 0, + quantity: 0, + reorder_level: 0, + units_per_strip: null, + hide_product_from_public: false, + hide_price_from_public: false, + compositions: [{ drug_name: "", quantity: "", unit_name: "" }], + }, + }); + + const { fields, append, remove } = useFieldArray({ + control, + name: "compositions", + }); + + const category = watch("category"); + const hideProduct = watch("hide_product_from_public"); + const showUnitsPerStrip = category === "Tablets" || category === "Capsules"; + + useEffect(() => { + if (hideProduct) { + setValue("hide_price_from_public", true); + } + }, [hideProduct, setValue]); + + useEffect(() => { + if (isEditing && existingProduct) { + reset({ + name: existingProduct.name, + brand: existingProduct.brand, + category: existingProduct.category, + distributor_id: existingProduct.distributor?.id ?? null, + unit_name: existingProduct.unit.name, + procured_price: existingProduct.procured_price, + mrp: existingProduct.mrp, + selling_price: existingProduct.selling_price, + quantity: existingProduct.quantity, + reorder_level: existingProduct.reorder_level, + units_per_strip: existingProduct.units_per_strip, + hide_product_from_public: existingProduct.hide_product_from_public, + hide_price_from_public: existingProduct.hide_price_from_public, + compositions: existingProduct.compositions.map((c) => ({ + drug_name: c.drug.name, + quantity: c.quantity, + unit_name: c.unit.name, + })), + }); + } + }, [isEditing, existingProduct, reset]); + + function onSubmit(values: FormValues) { + if (isEditing) { + updateMutation.mutate( + { id: editId!, ...values }, + { + onSuccess: () => { + utils.product.list.invalidate(); + utils.product.byId.invalidate({ id: editId! }); + navigate({ to: "/products" }); + }, + }, + ); + } else { + createMutation.mutate(values, { + onSuccess: () => { + utils.product.list.invalidate(); + navigate({ to: "/products" }); + }, + }); + } + } + + const mutation = isEditing ? updateMutation : createMutation; + + return ( +
+ + + Back to Products + + +
+

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

+

+ {isEditing + ? "Update the details of this medicine." + : "Fill in the details below to add a new medicine."} +

+ +
+ {/* Basic Info */} +
+

Basic Information

+

Core product identifiers

+
+ +
+ + + {errors.name &&

{errors.name.message}

} +
+ +
+ + + {errors.brand &&

{errors.brand.message}

} +
+ +
+ + +
+ + {showUnitsPerStrip && ( +
+ + +
+ )} + +
+ + +
+ +
+ + + {errors.unit_name &&

{errors.unit_name.message}

} +
+ +
+ + +
+ + {/* Composition */} +
+

Medicine Composition

+

Active chemical ingredients and quantities per unit

+
+ +
+ {fields.map((field, index) => ( +
+
+ + + + {(drugs ?? []).map((d) => ( + +
+
+ + +
+
+ + +
+ +
+ ))} + {errors.compositions?.message && ( +

{errors.compositions.message}

+ )} + +
+ + {/* Pricing */} +
+

Pricing

+

Cost and retail price information

+
+ +
+ + +
+
+ + +
+
+ + +
+ + {/* Visibility */} +
+

Visibility

+

Control public-facing display

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

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

+ )} +
+
+ ); +} diff --git a/apps/pharmanager/src/routes/products/index.tsx b/apps/pharmanager/src/routes/products/index.tsx new file mode 100644 index 0000000..becd854 --- /dev/null +++ b/apps/pharmanager/src/routes/products/index.tsx @@ -0,0 +1,242 @@ +import { useState, useMemo, useCallback } from "react"; +import { createFileRoute, Link } from "@tanstack/react-router"; +import { Search, Plus, Pencil, Trash2, Pill } from "lucide-react"; +import { GridTable } from "#/components/GridTable"; +import type { GridTableColumn } from "#/components/GridTable"; +import { Button, buttonVariants } from "#/components/ui"; +import { useListProducts, useRemoveProduct, trpc } from "shared-react"; + +interface ProductRow { + id: number; + name: string; + brand: string; + category: string; + selling_price: number; + mrp: number; + procured_price: number; + quantity: number; + reorder_level: number; + distributor: { id: number; name: string } | null; + unit: { id: number; name: string }; + compositions: { drug: { id: number; name: string }; quantity: string; unit: { id: number; name: string } }[]; +} + +function makeColumns( + onDelete: (row: ProductRow) => void, +): GridTableColumn[] { + return [ + { + id: "name", + header: "Product Name", + cell: ({ row }) => ( +
+ + {row.name} + +
{row.category}
+
+ ), + }, + { + id: "brand", + header: "Brand", + cell: ({ row }) => ( + {row.brand} + ), + }, + { + id: "selling_price", + header: "Selling", + cell: ({ row }) => ( + + ₹{row.selling_price.toFixed(2)} + + ), + }, + { + id: "composition", + header: "Composition", + cell: ({ row }) => { + const count = row.compositions.length; + const tip = row.compositions + .map((c) => `${c.drug.name} ${c.quantity}${c.unit.name}`) + .join(", "); + return ( + + {count} item{count !== 1 ? "s" : ""} + + ); + }, + }, + { + id: "quantity", + header: "Quantity", + cell: ({ row }) => { + const cls = + row.quantity <= row.reorder_level + ? "text-red-600" + : row.quantity <= row.reorder_level * 2 + ? "text-amber-600" + : "text-emerald-600"; + return ( + + {row.quantity} {row.unit.name} + + ); + }, + }, + { + id: "distributor", + header: "Distributor", + cell: ({ row }) => ( + + {row.distributor?.name || "—"} + + ), + }, + { + id: "procured_price", + header: "Procured", + cell: ({ row }) => ( + + ₹{row.procured_price.toFixed(2)} + + ), + }, + { + id: "mrp", + header: "MRP", + cell: ({ row }) => ( + + ₹{row.mrp.toFixed(2)} + + ), + }, + { + id: "actions", + header: "Actions", + size: 100, + cell: ({ row }) => ( +
+ + Details + + + + + +
+ ), + }, + ]; +} + +export const Route = createFileRoute("/products/")({ + component: ProductsIndexPage, + staticData: { + title: "Products", + subtitle: "Medicine catalog & inventory management", + }, +}); + +function ProductsIndexPage() { + const [searchQuery, setSearchQuery] = useState(""); + const { data: products, isLoading, error } = useListProducts(); + const removeMutation = useRemoveProduct(); + const utils = trpc.useUtils(); + + const handleDelete = useCallback( + (row: ProductRow) => { + if (!confirm(`Delete ${row.name}?`)) return; + removeMutation.mutate( + { id: row.id }, + { onSuccess: () => utils.product.list.invalidate() }, + ); + }, + [removeMutation, utils], + ); + + const columns = useMemo(() => makeColumns(handleDelete), [handleDelete]); + + const filtered = useMemo(() => { + const q = searchQuery.toLowerCase().trim(); + if (!q) return products ?? []; + return (products ?? []).filter((p) => { + const text = `${p.name} ${p.brand} ${p.category} ${p.distributor?.name || ""} ${p.compositions.map((c) => c.drug.name).join(" ")}`; + return text.toLowerCase().includes(q); + }); + }, [searchQuery, products]); + + if (isLoading) { + return
Loading products...
; + } + + if (error) { + return
Failed to load products.
; + } + + return ( +
+
+
+ + setSearchQuery(e.target.value)} + placeholder="Search by product name, brand, or distributor..." + className="bg-transparent border-none outline-none text-sm text-slate-900 w-full placeholder:text-slate-400" + /> +
+ + + Add Product + +
+ + + +

+ No products found +

+

+ {searchQuery + ? "No products match your search." + : "Add your first product to the catalog."} +

+
+ } + /> + + ); +} diff --git a/packages/data-manager-sqlite/drizzle/0002_sour_praxagora.sql b/packages/data-manager-sqlite/drizzle/0002_sour_praxagora.sql new file mode 100644 index 0000000..9332329 --- /dev/null +++ b/packages/data-manager-sqlite/drizzle/0002_sour_praxagora.sql @@ -0,0 +1,41 @@ +CREATE TABLE `products` ( + `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL, + `name` text NOT NULL, + `brand` text NOT NULL, + `category` text NOT NULL, + `distributor_id` integer, + `unit_id` integer NOT NULL, + `procured_price` real NOT NULL, + `mrp` real NOT NULL, + `selling_price` real NOT NULL, + `quantity` integer DEFAULT 0 NOT NULL, + `reorder_level` integer DEFAULT 0 NOT NULL, + `units_per_strip` integer, + `hide_product_from_public` integer DEFAULT false NOT NULL, + `hide_price_from_public` integer DEFAULT false NOT NULL, + FOREIGN KEY (`distributor_id`) REFERENCES `distributors`(`id`) ON UPDATE no action ON DELETE no action, + FOREIGN KEY (`unit_id`) REFERENCES `units`(`id`) ON UPDATE no action ON DELETE no action +); +--> statement-breakpoint +CREATE TABLE `drug_info` ( + `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL, + `name` text NOT NULL +); +--> statement-breakpoint +CREATE UNIQUE INDEX `drug_info_name_unique` ON `drug_info` (`name`);--> statement-breakpoint +CREATE TABLE `units` ( + `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL, + `name` text NOT NULL +); +--> statement-breakpoint +CREATE UNIQUE INDEX `units_name_unique` ON `units` (`name`);--> statement-breakpoint +CREATE TABLE `product_compositions` ( + `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL, + `product_id` integer NOT NULL, + `drug_info_id` integer NOT NULL, + `quantity` text NOT NULL, + `unit_id` integer NOT NULL, + FOREIGN KEY (`product_id`) REFERENCES `products`(`id`) ON UPDATE no action ON DELETE no action, + FOREIGN KEY (`drug_info_id`) REFERENCES `drug_info`(`id`) ON UPDATE no action ON DELETE no action, + FOREIGN KEY (`unit_id`) REFERENCES `units`(`id`) ON UPDATE no action ON DELETE no action +); diff --git a/packages/data-manager-sqlite/drizzle/meta/0002_snapshot.json b/packages/data-manager-sqlite/drizzle/meta/0002_snapshot.json new file mode 100644 index 0000000..371c5f4 --- /dev/null +++ b/packages/data-manager-sqlite/drizzle/meta/0002_snapshot.json @@ -0,0 +1,398 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "e65ed66a-0a3c-4338-8ba7-90ca587a7c07", + "prevId": "73855818-f116-4251-b91d-129f185c5d16", + "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": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "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 748b399..34a2a84 100644 --- a/packages/data-manager-sqlite/drizzle/meta/_journal.json +++ b/packages/data-manager-sqlite/drizzle/meta/_journal.json @@ -15,6 +15,13 @@ "when": 1779527160219, "tag": "0001_overjoyed_dakota_north", "breakpoints": true + }, + { + "idx": 2, + "version": "6", + "when": 1779530472486, + "tag": "0002_sour_praxagora", + "breakpoints": true } ] } \ No newline at end of file diff --git a/packages/data-manager-sqlite/src/db-instance.ts b/packages/data-manager-sqlite/src/db-instance.ts index 50e4ac5..9c9992a 100644 --- a/packages/data-manager-sqlite/src/db-instance.ts +++ b/packages/data-manager-sqlite/src/db-instance.ts @@ -4,4 +4,10 @@ import { runMigrations } from './migrate' const { db, sqlite } = createDb() runMigrations(sqlite) +// Seed reference data +sqlite.run("INSERT OR IGNORE INTO units (name) VALUES ('mg')") +sqlite.run("INSERT OR IGNORE INTO units (name) VALUES ('gm')") +sqlite.run("INSERT OR IGNORE INTO units (name) VALUES ('ml')") +sqlite.run("INSERT OR IGNORE INTO units (name) VALUES ('piece')") + export { db, sqlite } diff --git a/packages/data-manager-sqlite/src/drugInfo.ts b/packages/data-manager-sqlite/src/drugInfo.ts new file mode 100644 index 0000000..a6bdd3b --- /dev/null +++ b/packages/data-manager-sqlite/src/drugInfo.ts @@ -0,0 +1,30 @@ +import { eq } from 'drizzle-orm' + +import { db } from './db-instance' +import { drugInfo } from './schema/drugInfo' + +export type DrugInfo = { + id: number + name: string +} + +export type DrugInfoRepo = { + listDrugInfo: () => Promise + createDrugInfo: (input: { name: string }) => Promise +} + +export function createDrugInfoRepo(): { repo: DrugInfoRepo } { + const repo: DrugInfoRepo = { + listDrugInfo() { + const rows = db.select().from(drugInfo).all() + return Promise.resolve(rows.map((r) => ({ id: r.id, name: r.name }))) + }, + + createDrugInfo(input) { + const created = db.insert(drugInfo).values(input).returning().get() + return Promise.resolve({ id: created.id, name: created.name }) + }, + } + + return { repo } +} diff --git a/packages/data-manager-sqlite/src/index.ts b/packages/data-manager-sqlite/src/index.ts index 012b373..5088f93 100644 --- a/packages/data-manager-sqlite/src/index.ts +++ b/packages/data-manager-sqlite/src/index.ts @@ -13,3 +13,24 @@ export { } from './distributors' export { storageSpaces } from './schema/storageSpacesSchema' export { distributors } from './schema/distributors' +export { products } from './schema/products' +export { drugInfo } from './schema/drugInfo' +export { units } from './schema/units' +export { productCompositions } from './schema/productCompositions' +export { + createProductsRepo, + type Product, + type CompositionItem, + type CompositionInput, + type ProductsRepo, +} from './products' +export { + createDrugInfoRepo, + type DrugInfo, + type DrugInfoRepo, +} from './drugInfo' +export { + createUnitsRepo, + type Unit, + type UnitsRepo, +} from './units' diff --git a/packages/data-manager-sqlite/src/products.ts b/packages/data-manager-sqlite/src/products.ts new file mode 100644 index 0000000..669f753 --- /dev/null +++ b/packages/data-manager-sqlite/src/products.ts @@ -0,0 +1,265 @@ +import { eq } from 'drizzle-orm' +import type { Database } from 'bun:sqlite' + +import { db, sqlite } from './db-instance' +import { products } from './schema/products' +import { productCompositions } from './schema/productCompositions' +import { drugInfo } from './schema/drugInfo' +import { units } from './schema/units' +import { distributors } from './schema/distributors' + +export type CompositionItem = { + id: number + drug: { id: number; name: string } + quantity: string + unit: { id: number; name: string } +} + +export type CompositionInput = { + drug_name: string + quantity: string + unit_name: string +} + +interface ProductFields { + name: string + brand: string + category: string + procured_price: number + mrp: number + selling_price: number + quantity: number + reorder_level: number + units_per_strip: number | null + hide_product_from_public: boolean + hide_price_from_public: boolean +} + +export type Product = ProductFields & { + id: number + distributor: { id: number; name: string } | null + unit: { id: number; name: string } + compositions: CompositionItem[] +} + +type ProductOptionalKeys = 'units_per_strip' | 'hide_product_from_public' | 'hide_price_from_public' +type ProductRequiredKeys = Exclude + +export type CreateProductInput = + Pick & + Partial> & { + distributor_id?: number | null + unit_name: string + compositions: CompositionInput[] + } + +export type UpdateProductPatch = Partial + +export type ProductsRepo = { + getProducts: () => Promise + getProductById: (id: number) => Promise + createProduct: (input: CreateProductInput) => Promise + updateProduct: (id: number, patch: UpdateProductPatch) => Promise + deleteProduct: (id: number) => Promise +} + +function getOrCreateDrug(name: string): number { + const existing = db.select().from(drugInfo).where(eq(drugInfo.name, name)).get() + if (existing) return existing.id + const created = db.insert(drugInfo).values({ name }).returning().get() + return created.id +} + +function getOrCreateUnit(name: string): number { + const existing = db.select().from(units).where(eq(units.name, name)).get() + if (existing) return existing.id + const created = db.insert(units).values({ name }).returning().get() + return created.id +} + +function toProduct( + row: typeof products.$inferSelect, + distributor: { id: number; name: string } | null, + unit: { id: number; name: string }, +): Omit { + return { + id: row.id, + name: row.name, + brand: row.brand, + category: row.category, + distributor, + unit, + procured_price: row.procuredPrice, + mrp: row.mrp, + selling_price: row.sellingPrice, + quantity: row.quantity, + reorder_level: row.reorderLevel, + units_per_strip: row.unitsPerStrip, + hide_product_from_public: row.hideProductFromPublic, + hide_price_from_public: row.hidePriceFromPublic, + } +} + +function fetchDistributor(distributorId: number | null): { id: number; name: string } | null { + if (!distributorId) return null + const d = db.select().from(distributors).where(eq(distributors.id, distributorId)).get() + return d ? { id: d.id, name: d.agency } : null +} + +function fetchUnit(unitId: number): { id: number; name: string } { + const u = db.select().from(units).where(eq(units.id, unitId)).get() + return u ? { id: u.id, name: u.name } : { id: unitId, name: String(unitId) } +} + +function getCompositionsForProduct(productId: number): CompositionItem[] { + const rows = db + .select({ + id: productCompositions.id, + quantity: productCompositions.quantity, + drugId: drugInfo.id, + drugName: drugInfo.name, + unitId: units.id, + unitName: units.name, + }) + .from(productCompositions) + .innerJoin(drugInfo, eq(productCompositions.drugInfoId, drugInfo.id)) + .innerJoin(units, eq(productCompositions.unitId, units.id)) + .where(eq(productCompositions.productId, productId)) + .all() + + return rows.map((r) => ({ + id: r.id, + drug: { id: r.drugId, name: r.drugName }, + quantity: r.quantity, + unit: { id: r.unitId, name: r.unitName }, + })) +} + +function setCompositions(productId: number, comps: CompositionInput[]) { + db.delete(productCompositions).where(eq(productCompositions.productId, productId)).run() + for (const c of comps) { + const drugId = getOrCreateDrug(c.drug_name) + const unitId = getOrCreateUnit(c.unit_name) + db.insert(productCompositions).values({ + productId: productId, + drugInfoId: drugId, + quantity: c.quantity, + unitId, + }).run() + } +} + +export function createProductsRepo(): { repo: ProductsRepo } { + const repo: ProductsRepo = { + getProducts() { + const rows = db.select().from(products).all() + return Promise.resolve( + rows.map((r) => { + const distributor = fetchDistributor(r.distributorId) + const unit = fetchUnit(r.unitId) + return { + ...toProduct(r, distributor, unit), + compositions: getCompositionsForProduct(r.id), + } + }), + ) + }, + + getProductById(id) { + const row = db.select().from(products).where(eq(products.id, id)).get() + if (!row) return Promise.resolve(null) + const distributor = fetchDistributor(row.distributorId) + const unit = fetchUnit(row.unitId) + return Promise.resolve({ + ...toProduct(row, distributor, unit), + compositions: getCompositionsForProduct(row.id), + }) + }, + + createProduct(input) { + const result = sqlite.transaction(() => { + const unitId = getOrCreateUnit(input.unit_name) + + const created = db + .insert(products) + .values({ + name: input.name, + brand: input.brand, + category: input.category, + distributorId: input.distributor_id ?? null, + unitId, + procuredPrice: input.procured_price, + mrp: input.mrp, + sellingPrice: input.selling_price, + quantity: input.quantity, + reorderLevel: input.reorder_level, + unitsPerStrip: input.units_per_strip ?? null, + hideProductFromPublic: input.hide_product_from_public ?? false, + hidePriceFromPublic: input.hide_price_from_public ?? false, + }) + .returning() + .get() + + setCompositions(created.id, input.compositions) + + return created + })() + + const distributor = fetchDistributor(result.distributorId) + const unit = fetchUnit(result.unitId) + + return Promise.resolve({ + ...toProduct(result, distributor, unit), + compositions: getCompositionsForProduct(result.id), + }) + }, + + updateProduct(id, patch) { + const existing = db.select().from(products).where(eq(products.id, id)).get() + if (!existing) return Promise.resolve(null) + + sqlite.transaction(() => { + const setData: Record = {} + + if (patch.name !== undefined) setData.name = patch.name + if (patch.brand !== undefined) setData.brand = patch.brand + if (patch.category !== undefined) setData.category = patch.category + if (patch.distributor_id !== undefined) setData.distributorId = patch.distributor_id ?? null + if (patch.unit_name !== undefined) setData.unitId = getOrCreateUnit(patch.unit_name) + 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.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 + if (patch.hide_price_from_public !== undefined) setData.hidePriceFromPublic = patch.hide_price_from_public + + if (Object.keys(setData).length > 0) { + db.update(products).set(setData).where(eq(products.id, id)).run() + } + + if (patch.compositions) { + setCompositions(id, patch.compositions) + } + })() + + const updated = db.select().from(products).where(eq(products.id, id)).get()! + const distributor = fetchDistributor(updated.distributorId) + const unit = fetchUnit(updated.unitId) + + return Promise.resolve({ + ...toProduct(updated, distributor, unit), + compositions: getCompositionsForProduct(updated.id), + }) + }, + + deleteProduct(id) { + db.delete(productCompositions).where(eq(productCompositions.productId, id)).run() + const deleted = db.delete(products).where(eq(products.id, id)).returning({ id: products.id }).get() + return Promise.resolve(Boolean(deleted)) + }, + } + + return { repo } +} diff --git a/packages/data-manager-sqlite/src/schema/drugInfo.ts b/packages/data-manager-sqlite/src/schema/drugInfo.ts new file mode 100644 index 0000000..06ee1b6 --- /dev/null +++ b/packages/data-manager-sqlite/src/schema/drugInfo.ts @@ -0,0 +1,6 @@ +import { integer, sqliteTable, text } from 'drizzle-orm/sqlite-core' + +export const drugInfo = sqliteTable('drug_info', { + id: integer('id').primaryKey({ autoIncrement: true }), + name: text('name').notNull().unique(), +}) diff --git a/packages/data-manager-sqlite/src/schema/index.ts b/packages/data-manager-sqlite/src/schema/index.ts index a499b62..d8e1861 100644 --- a/packages/data-manager-sqlite/src/schema/index.ts +++ b/packages/data-manager-sqlite/src/schema/index.ts @@ -1,2 +1,6 @@ export * from './storageSpacesSchema' export * from './distributors' +export * from './products' +export * from './drugInfo' +export * from './units' +export * from './productCompositions' diff --git a/packages/data-manager-sqlite/src/schema/productCompositions.ts b/packages/data-manager-sqlite/src/schema/productCompositions.ts new file mode 100644 index 0000000..554aa19 --- /dev/null +++ b/packages/data-manager-sqlite/src/schema/productCompositions.ts @@ -0,0 +1,12 @@ +import { integer, sqliteTable, text } from 'drizzle-orm/sqlite-core' +import { products } from './products' +import { drugInfo } from './drugInfo' +import { units } from './units' + +export const productCompositions = sqliteTable('product_compositions', { + id: integer('id').primaryKey({ autoIncrement: true }), + productId: integer('product_id').notNull().references(() => products.id), + drugInfoId: integer('drug_info_id').notNull().references(() => drugInfo.id), + quantity: text('quantity').notNull(), + unitId: integer('unit_id').notNull().references(() => units.id), +}) diff --git a/packages/data-manager-sqlite/src/schema/products.ts b/packages/data-manager-sqlite/src/schema/products.ts new file mode 100644 index 0000000..1869649 --- /dev/null +++ b/packages/data-manager-sqlite/src/schema/products.ts @@ -0,0 +1,20 @@ +import { integer, sqliteTable, text, real } from 'drizzle-orm/sqlite-core' +import { distributors } from './distributors' +import { units } from './units' + +export const products = sqliteTable('products', { + id: integer('id').primaryKey({ autoIncrement: true }), + name: text('name').notNull(), + brand: text('brand').notNull(), + category: text('category').notNull(), + distributorId: integer('distributor_id').references(() => distributors.id), + unitId: integer('unit_id').notNull().references(() => units.id), + procuredPrice: real('procured_price').notNull(), + mrp: real('mrp').notNull(), + sellingPrice: real('selling_price').notNull(), + quantity: integer('quantity').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), + hidePriceFromPublic: integer('hide_price_from_public', { mode: 'boolean' }).notNull().default(false), +}) diff --git a/packages/data-manager-sqlite/src/schema/units.ts b/packages/data-manager-sqlite/src/schema/units.ts new file mode 100644 index 0000000..397b779 --- /dev/null +++ b/packages/data-manager-sqlite/src/schema/units.ts @@ -0,0 +1,6 @@ +import { integer, sqliteTable, text } from 'drizzle-orm/sqlite-core' + +export const units = sqliteTable('units', { + id: integer('id').primaryKey({ autoIncrement: true }), + name: text('name').notNull().unique(), +}) diff --git a/packages/data-manager-sqlite/src/units.ts b/packages/data-manager-sqlite/src/units.ts new file mode 100644 index 0000000..e1d6017 --- /dev/null +++ b/packages/data-manager-sqlite/src/units.ts @@ -0,0 +1,30 @@ +import { eq } from 'drizzle-orm' + +import { db } from './db-instance' +import { units } from './schema/units' + +export type Unit = { + id: number + name: string +} + +export type UnitsRepo = { + listUnits: () => Promise + createUnit: (input: { name: string }) => Promise +} + +export function createUnitsRepo(): { repo: UnitsRepo } { + const repo: UnitsRepo = { + listUnits() { + const rows = db.select().from(units).all() + return Promise.resolve(rows.map((r) => ({ id: r.id, name: r.name }))) + }, + + createUnit(input) { + const created = db.insert(units).values(input).returning().get() + return Promise.resolve({ id: created.id, name: created.name }) + }, + } + + return { repo } +} diff --git a/packages/shared-react/src/hooks/drugInfo.ts b/packages/shared-react/src/hooks/drugInfo.ts new file mode 100644 index 0000000..375c20d --- /dev/null +++ b/packages/shared-react/src/hooks/drugInfo.ts @@ -0,0 +1,5 @@ +import { trpc } from "../trpc"; + +export function useListDrugInfo() { + return trpc.drugInfo.list.useQuery(); +} diff --git a/packages/shared-react/src/hooks/products.ts b/packages/shared-react/src/hooks/products.ts new file mode 100644 index 0000000..9cf2c6a --- /dev/null +++ b/packages/shared-react/src/hooks/products.ts @@ -0,0 +1,21 @@ +import { trpc } from "../trpc"; + +export function useListProducts() { + return trpc.product.list.useQuery(); +} + +export function useGetProductById(id: number) { + return trpc.product.byId.useQuery({ id }); +} + +export function useCreateProduct() { + return trpc.product.create.useMutation(); +} + +export function useUpdateProduct() { + return trpc.product.update.useMutation(); +} + +export function useRemoveProduct() { + return trpc.product.remove.useMutation(); +} diff --git a/packages/shared-react/src/hooks/units.ts b/packages/shared-react/src/hooks/units.ts new file mode 100644 index 0000000..e30f76b --- /dev/null +++ b/packages/shared-react/src/hooks/units.ts @@ -0,0 +1,5 @@ +import { trpc } from "../trpc"; + +export function useListUnits() { + return trpc.units.list.useQuery(); +} diff --git a/packages/shared-react/src/index.ts b/packages/shared-react/src/index.ts index e26a95d..61b71ab 100644 --- a/packages/shared-react/src/index.ts +++ b/packages/shared-react/src/index.ts @@ -3,4 +3,7 @@ export * from './store' export * from './provider' export * from './hooks/storageSpaces' export * from './hooks/distributors' +export * from './hooks/products' +export * from './hooks/drugInfo' +export * from './hooks/units' export { trpc } from './trpc' diff --git a/packages/shared/package.json b/packages/shared/package.json index d7b32f7..f46ab82 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -2,5 +2,8 @@ "name": "@repo/shared", "version": "0.0.1", "main": "src/index.ts", - "types": "src/index.ts" + "types": "src/index.ts", + "dependencies": { + "zod": "^3.25.0" + } } \ No newline at end of file diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 3dc6d59..e7c4318 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -16,3 +16,6 @@ export function greetPerson(person: Person) { export type { AppRouter, } from '../../../apps/backend/src/trpc/router' + +// Shared schemas +export * from './schemas/product' diff --git a/packages/shared/src/schemas/product.ts b/packages/shared/src/schemas/product.ts new file mode 100644 index 0000000..c807471 --- /dev/null +++ b/packages/shared/src/schemas/product.ts @@ -0,0 +1,57 @@ +import { z } from "zod"; + +export const CompositionItemSchema = z.object({ + id: z.number().int(), + drug: z.object({ id: z.number().int(), name: z.string() }), + quantity: z.string(), + unit: z.object({ id: z.number().int(), name: z.string() }), +}); + +export const CompositionInputSchema = z.object({ + drug_name: z.string().min(1), + quantity: z.string().min(1), + unit_name: z.string().min(1), +}); + +export const ProductSchema = z.object({ + id: z.number().int(), + name: z.string(), + brand: z.string(), + category: z.string(), + distributor: z.object({ id: z.number().int(), name: z.string() }).nullable(), + unit: z.object({ id: z.number().int(), name: z.string() }), + procured_price: z.number(), + mrp: z.number(), + selling_price: z.number(), + quantity: z.number().int(), + reorder_level: z.number().int(), + units_per_strip: z.number().int().nullable(), + hide_product_from_public: z.boolean(), + hide_price_from_public: z.boolean(), + compositions: z.array(CompositionItemSchema), +}); + +const { shape } = ProductSchema; + +export const CreateProductInput = z.object({ + name: shape.name.min(1), + brand: shape.brand.min(1), + category: shape.category.min(1), + distributor_id: z.number().int().nullable().optional(), + unit_name: z.string().min(1), + procured_price: shape.procured_price.min(0), + mrp: shape.mrp.min(0), + selling_price: shape.selling_price.min(0), + quantity: shape.quantity.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), + hide_price_from_public: shape.hide_price_from_public.default(false), + compositions: z.array(CompositionInputSchema).default([]), +}); + +export const UpdateProductInput = z + .object({ id: z.number().int() }) + .merge(CreateProductInput.partial()); + +export type Product = z.infer;