product crud and management functional

This commit is contained in:
shafi54 2026-05-23 15:50:26 +05:30
parent c0fd8671e4
commit 94fbd8c87e
30 changed files with 1970 additions and 17 deletions

View file

@ -1,19 +1,34 @@
import { import {
createStorageSpacesRepo, createStorageSpacesRepo,
createDistributorsRepo, createDistributorsRepo,
createProductsRepo,
createDrugInfoRepo,
createUnitsRepo,
type StorageSpacesRepo, type StorageSpacesRepo,
type DistributorsRepo, type DistributorsRepo,
type ProductsRepo,
type DrugInfoRepo,
type UnitsRepo,
} from "data-manager-sqlite"; } from "data-manager-sqlite";
export class DataManager { export class DataManager {
readonly storageSpaces: StorageSpacesRepo; readonly storageSpaces: StorageSpacesRepo;
readonly distributors: DistributorsRepo; readonly distributors: DistributorsRepo;
readonly products: ProductsRepo;
readonly drugInfo: DrugInfoRepo;
readonly units: UnitsRepo;
constructor() { constructor() {
const { repo: storageSpacesRepo } = createStorageSpacesRepo(); const { repo: storageSpacesRepo } = createStorageSpacesRepo();
const { repo: distributorsRepo } = createDistributorsRepo(); const { repo: distributorsRepo } = createDistributorsRepo();
const { repo: productsRepo } = createProductsRepo();
const { repo: drugInfoRepo } = createDrugInfoRepo();
const { repo: unitsRepo } = createUnitsRepo();
this.storageSpaces = storageSpacesRepo; this.storageSpaces = storageSpacesRepo;
this.distributors = distributorsRepo; this.distributors = distributorsRepo;
this.products = productsRepo;
this.drugInfo = drugInfoRepo;
this.units = unitsRepo;
} }
} }

View file

@ -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<typeof DrugInfoSchema>;
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)),
});

View file

@ -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 };
}),
});

View file

@ -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<typeof UnitSchema>;
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)),
});

View file

@ -1,10 +1,16 @@
import { t } from "./init"; import { t } from "./init";
import { storageRouter } from "./pharmanager/v1/storage"; import { storageRouter } from "./pharmanager/v1/storage";
import { distributorRouter } from "./pharmanager/v1/distributor"; 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({ export const appRouter = t.router({
storage: storageRouter, storage: storageRouter,
distributor: distributorRouter, distributor: distributorRouter,
product: productRouter,
drugInfo: drugInfoRouter,
units: unitsRouter,
}); });
export type AppRouter = typeof appRouter; export type AppRouter = typeof appRouter;

View file

@ -19,9 +19,12 @@ import { Route as CustomersRouteImport } from './routes/customers'
import { Route as BillingRouteImport } from './routes/billing' import { Route as BillingRouteImport } from './routes/billing'
import { Route as IndexRouteImport } from './routes/index' import { Route as IndexRouteImport } from './routes/index'
import { Route as StorageIndexRouteImport } from './routes/storage/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 DistributorsIndexRouteImport } from './routes/distributors/index'
import { Route as StorageAddRouteImport } from './routes/storage/add' import { Route as StorageAddRouteImport } from './routes/storage/add'
import { Route as StorageIdRouteImport } from './routes/storage/$id' 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 DistributorsAddRouteImport } from './routes/distributors/add'
import { Route as DistributorsIdRouteImport } from './routes/distributors/$id' import { Route as DistributorsIdRouteImport } from './routes/distributors/$id'
@ -75,6 +78,11 @@ const StorageIndexRoute = StorageIndexRouteImport.update({
path: '/', path: '/',
getParentRoute: () => StorageRoute, getParentRoute: () => StorageRoute,
} as any) } as any)
const ProductsIndexRoute = ProductsIndexRouteImport.update({
id: '/',
path: '/',
getParentRoute: () => ProductsRoute,
} as any)
const DistributorsIndexRoute = DistributorsIndexRouteImport.update({ const DistributorsIndexRoute = DistributorsIndexRouteImport.update({
id: '/', id: '/',
path: '/', path: '/',
@ -90,6 +98,16 @@ const StorageIdRoute = StorageIdRouteImport.update({
path: '/$id', path: '/$id',
getParentRoute: () => StorageRoute, getParentRoute: () => StorageRoute,
} as any) } 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({ const DistributorsAddRoute = DistributorsAddRouteImport.update({
id: '/add', id: '/add',
path: '/add', path: '/add',
@ -106,31 +124,36 @@ export interface FileRoutesByFullPath {
'/billing': typeof BillingRoute '/billing': typeof BillingRoute
'/customers': typeof CustomersRoute '/customers': typeof CustomersRoute
'/distributors': typeof DistributorsRouteWithChildren '/distributors': typeof DistributorsRouteWithChildren
'/products': typeof ProductsRoute '/products': typeof ProductsRouteWithChildren
'/profile': typeof ProfileRoute '/profile': typeof ProfileRoute
'/staff': typeof StaffRoute '/staff': typeof StaffRoute
'/stock': typeof StockRoute '/stock': typeof StockRoute
'/storage': typeof StorageRouteWithChildren '/storage': typeof StorageRouteWithChildren
'/distributors/$id': typeof DistributorsIdRoute '/distributors/$id': typeof DistributorsIdRoute
'/distributors/add': typeof DistributorsAddRoute '/distributors/add': typeof DistributorsAddRoute
'/products/$id': typeof ProductsIdRoute
'/products/add': typeof ProductsAddRoute
'/storage/$id': typeof StorageIdRoute '/storage/$id': typeof StorageIdRoute
'/storage/add': typeof StorageAddRoute '/storage/add': typeof StorageAddRoute
'/distributors/': typeof DistributorsIndexRoute '/distributors/': typeof DistributorsIndexRoute
'/products/': typeof ProductsIndexRoute
'/storage/': typeof StorageIndexRoute '/storage/': typeof StorageIndexRoute
} }
export interface FileRoutesByTo { export interface FileRoutesByTo {
'/': typeof IndexRoute '/': typeof IndexRoute
'/billing': typeof BillingRoute '/billing': typeof BillingRoute
'/customers': typeof CustomersRoute '/customers': typeof CustomersRoute
'/products': typeof ProductsRoute
'/profile': typeof ProfileRoute '/profile': typeof ProfileRoute
'/staff': typeof StaffRoute '/staff': typeof StaffRoute
'/stock': typeof StockRoute '/stock': typeof StockRoute
'/distributors/$id': typeof DistributorsIdRoute '/distributors/$id': typeof DistributorsIdRoute
'/distributors/add': typeof DistributorsAddRoute '/distributors/add': typeof DistributorsAddRoute
'/products/$id': typeof ProductsIdRoute
'/products/add': typeof ProductsAddRoute
'/storage/$id': typeof StorageIdRoute '/storage/$id': typeof StorageIdRoute
'/storage/add': typeof StorageAddRoute '/storage/add': typeof StorageAddRoute
'/distributors': typeof DistributorsIndexRoute '/distributors': typeof DistributorsIndexRoute
'/products': typeof ProductsIndexRoute
'/storage': typeof StorageIndexRoute '/storage': typeof StorageIndexRoute
} }
export interface FileRoutesById { export interface FileRoutesById {
@ -139,16 +162,19 @@ export interface FileRoutesById {
'/billing': typeof BillingRoute '/billing': typeof BillingRoute
'/customers': typeof CustomersRoute '/customers': typeof CustomersRoute
'/distributors': typeof DistributorsRouteWithChildren '/distributors': typeof DistributorsRouteWithChildren
'/products': typeof ProductsRoute '/products': typeof ProductsRouteWithChildren
'/profile': typeof ProfileRoute '/profile': typeof ProfileRoute
'/staff': typeof StaffRoute '/staff': typeof StaffRoute
'/stock': typeof StockRoute '/stock': typeof StockRoute
'/storage': typeof StorageRouteWithChildren '/storage': typeof StorageRouteWithChildren
'/distributors/$id': typeof DistributorsIdRoute '/distributors/$id': typeof DistributorsIdRoute
'/distributors/add': typeof DistributorsAddRoute '/distributors/add': typeof DistributorsAddRoute
'/products/$id': typeof ProductsIdRoute
'/products/add': typeof ProductsAddRoute
'/storage/$id': typeof StorageIdRoute '/storage/$id': typeof StorageIdRoute
'/storage/add': typeof StorageAddRoute '/storage/add': typeof StorageAddRoute
'/distributors/': typeof DistributorsIndexRoute '/distributors/': typeof DistributorsIndexRoute
'/products/': typeof ProductsIndexRoute
'/storage/': typeof StorageIndexRoute '/storage/': typeof StorageIndexRoute
} }
export interface FileRouteTypes { export interface FileRouteTypes {
@ -165,24 +191,29 @@ export interface FileRouteTypes {
| '/storage' | '/storage'
| '/distributors/$id' | '/distributors/$id'
| '/distributors/add' | '/distributors/add'
| '/products/$id'
| '/products/add'
| '/storage/$id' | '/storage/$id'
| '/storage/add' | '/storage/add'
| '/distributors/' | '/distributors/'
| '/products/'
| '/storage/' | '/storage/'
fileRoutesByTo: FileRoutesByTo fileRoutesByTo: FileRoutesByTo
to: to:
| '/' | '/'
| '/billing' | '/billing'
| '/customers' | '/customers'
| '/products'
| '/profile' | '/profile'
| '/staff' | '/staff'
| '/stock' | '/stock'
| '/distributors/$id' | '/distributors/$id'
| '/distributors/add' | '/distributors/add'
| '/products/$id'
| '/products/add'
| '/storage/$id' | '/storage/$id'
| '/storage/add' | '/storage/add'
| '/distributors' | '/distributors'
| '/products'
| '/storage' | '/storage'
id: id:
| '__root__' | '__root__'
@ -197,9 +228,12 @@ export interface FileRouteTypes {
| '/storage' | '/storage'
| '/distributors/$id' | '/distributors/$id'
| '/distributors/add' | '/distributors/add'
| '/products/$id'
| '/products/add'
| '/storage/$id' | '/storage/$id'
| '/storage/add' | '/storage/add'
| '/distributors/' | '/distributors/'
| '/products/'
| '/storage/' | '/storage/'
fileRoutesById: FileRoutesById fileRoutesById: FileRoutesById
} }
@ -208,7 +242,7 @@ export interface RootRouteChildren {
BillingRoute: typeof BillingRoute BillingRoute: typeof BillingRoute
CustomersRoute: typeof CustomersRoute CustomersRoute: typeof CustomersRoute
DistributorsRoute: typeof DistributorsRouteWithChildren DistributorsRoute: typeof DistributorsRouteWithChildren
ProductsRoute: typeof ProductsRoute ProductsRoute: typeof ProductsRouteWithChildren
ProfileRoute: typeof ProfileRoute ProfileRoute: typeof ProfileRoute
StaffRoute: typeof StaffRoute StaffRoute: typeof StaffRoute
StockRoute: typeof StockRoute StockRoute: typeof StockRoute
@ -287,6 +321,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof StorageIndexRouteImport preLoaderRoute: typeof StorageIndexRouteImport
parentRoute: typeof StorageRoute parentRoute: typeof StorageRoute
} }
'/products/': {
id: '/products/'
path: '/'
fullPath: '/products/'
preLoaderRoute: typeof ProductsIndexRouteImport
parentRoute: typeof ProductsRoute
}
'/distributors/': { '/distributors/': {
id: '/distributors/' id: '/distributors/'
path: '/' path: '/'
@ -308,6 +349,20 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof StorageIdRouteImport preLoaderRoute: typeof StorageIdRouteImport
parentRoute: typeof StorageRoute 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': { '/distributors/add': {
id: '/distributors/add' id: '/distributors/add'
path: '/add' path: '/add'
@ -341,6 +396,22 @@ const DistributorsRouteWithChildren = DistributorsRoute._addFileChildren(
DistributorsRouteChildren, 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 { interface StorageRouteChildren {
StorageIdRoute: typeof StorageIdRoute StorageIdRoute: typeof StorageIdRoute
StorageAddRoute: typeof StorageAddRoute StorageAddRoute: typeof StorageAddRoute
@ -361,7 +432,7 @@ const rootRouteChildren: RootRouteChildren = {
BillingRoute: BillingRoute, BillingRoute: BillingRoute,
CustomersRoute: CustomersRoute, CustomersRoute: CustomersRoute,
DistributorsRoute: DistributorsRouteWithChildren, DistributorsRoute: DistributorsRouteWithChildren,
ProductsRoute: ProductsRoute, ProductsRoute: ProductsRouteWithChildren,
ProfileRoute: ProfileRoute, ProfileRoute: ProfileRoute,
StaffRoute: StaffRoute, StaffRoute: StaffRoute,
StockRoute: StockRoute, StockRoute: StockRoute,

View file

@ -1,13 +1,5 @@
import { createFileRoute } from "@tanstack/react-router"; import { Outlet, createFileRoute } from "@tanstack/react-router";
export const Route = createFileRoute("/products")({ export const Route = createFileRoute("/products")({
component: ProductsPage, component: () => <Outlet />,
staticData: {
title: "Products",
subtitle: "Medicine catalog with search, add, edit, delete",
},
}); });
function ProductsPage() {
return <div>Products</div>;
}

View file

@ -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 <div className="text-sm text-slate-600 py-8">Loading product details...</div>;
}
if (error || !product) {
return (
<div className="flex flex-col items-center py-16 px-6 text-slate-600">
<Pill className="w-12 h-12 mb-4 opacity-40" />
<h3 className="text-base font-semibold text-slate-900 mb-1.5">Product not found</h3>
<p className="text-sm mb-4">The product you're looking for doesn't exist.</p>
<Link
to="/products"
className="inline-flex items-center px-4 py-2 bg-blue-600 text-white rounded-md text-sm font-medium hover:bg-blue-700 transition-colors"
>
Back to Products
</Link>
</div>
);
}
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 (
<div>
<Link
to="/products"
className="inline-flex items-center gap-1.5 text-sm text-blue-600 hover:underline mb-5"
>
<ArrowLeft className="w-4 h-4" />
Back to Products
</Link>
<div className="grid grid-cols-2 gap-6 max-w-[960px]">
{/* Basic Info */}
<div className="bg-white rounded-lg shadow-[0_0_0_1px_rgba(0,0,0,0.06),0_1px_2px_rgba(0,0,0,0.04)] overflow-hidden">
<div className="px-5 py-4 border-b border-slate-200 flex items-center gap-2">
<Pill className="w-[14px] h-[14px] text-slate-600" />
<span className="text-xs font-semibold text-slate-600 uppercase tracking-wider">Basic Information</span>
</div>
<div className="p-5">
<DetailRow label="Product Name" value={product.name} />
<DetailRow label="Brand Name" value={product.brand} />
<DetailRow label="Category" value={product.category} />
{product.units_per_strip && (
<DetailRow label="Units per Strip" value={String(product.units_per_strip)} last />
)}
</div>
</div>
{/* Inventory */}
<div className="bg-white rounded-lg shadow-[0_0_0_1px_rgba(0,0,0,0.06),0_1px_2px_rgba(0,0,0,0.04)] overflow-hidden">
<div className="px-5 py-4 border-b border-slate-200 flex items-center gap-2">
<Package className="w-[14px] h-[14px] text-slate-600" />
<span className="text-xs font-semibold text-slate-600 uppercase tracking-wider">Inventory</span>
</div>
<div className="p-5">
<DetailRow label="Current Stock" value={`${product.quantity} units`} valueClass={stockClass} />
<DetailRow label="Reorder Level" value={`${product.reorder_level} units`} last />
</div>
</div>
{/* Composition */}
<div className="bg-white rounded-lg shadow-[0_0_0_1px_rgba(0,0,0,0.06),0_1px_2px_rgba(0,0,0,0.04)] overflow-hidden">
<div className="px-5 py-4 border-b border-slate-200 flex items-center gap-2">
<Layers className="w-[14px] h-[14px] text-slate-600" />
<span className="text-xs font-semibold text-slate-600 uppercase tracking-wider">Composition</span>
</div>
<div className="p-5">
{product.compositions.length > 0 ? (
<div className="space-y-1">
{product.compositions.map((c) => (
<div
key={c.id}
className="flex justify-between items-center px-3 py-2 bg-slate-50 rounded text-[13px]"
>
<span className="font-medium">{c.drug.name}</span>
<span className="text-slate-600">{c.quantity}{c.unit.name}</span>
</div>
))}
</div>
) : (
<span className="text-sm text-slate-400">No composition data</span>
)}
</div>
</div>
{/* Pricing */}
<div className="bg-white rounded-lg shadow-[0_0_0_1px_rgba(0,0,0,0.06),0_1px_2px_rgba(0,0,0,0.04)] overflow-hidden">
<div className="px-5 py-4 border-b border-slate-200 flex items-center gap-2">
<DollarSign className="w-[14px] h-[14px] text-slate-600" />
<span className="text-xs font-semibold text-slate-600 uppercase tracking-wider">Pricing</span>
</div>
<div className="p-5">
<DetailRow label="Procured Price" value={`${product.procured_price.toFixed(2)}`} />
<DetailRow label="MRP" value={`${product.mrp.toFixed(2)}`} valueClass="text-slate-500 line-through font-normal" />
<DetailRow label="Selling Price" value={`${product.selling_price.toFixed(2)}`} valueClass="text-emerald-600 font-semibold" />
<DetailRow label="Margin" value={`${margin}% (₹${marginVal})`} valueClass="text-emerald-600 font-semibold" last />
</div>
</div>
{/* Visibility */}
<div className="bg-white rounded-lg shadow-[0_0_0_1px_rgba(0,0,0,0.06),0_1px_2px_rgba(0,0,0,0.04)] overflow-hidden col-span-full">
<div className="px-5 py-4 border-b border-slate-200 flex items-center gap-2">
<EyeOff className="w-[14px] h-[14px] text-slate-600" />
<span className="text-xs font-semibold text-slate-600 uppercase tracking-wider">Visibility</span>
</div>
<div className="p-5">
<DetailRow label="Hide Product from Public" value={product.hide_product_from_public ? "Yes" : "No"} />
<DetailRow label="Hide Price from Public" value={product.hide_price_from_public ? "Yes" : "No"} last />
</div>
</div>
</div>
{/* Actions */}
<div className="flex items-center gap-2.5 py-5 mt-2 max-w-[960px]">
<Button variant="primary">
<Pencil className="w-[15px] h-[15px]" />
Edit Product
</Button>
<Button variant="danger" onClick={handleDelete}>
<Trash2 className="w-[15px] h-[15px]" />
Delete Product
</Button>
</div>
</div>
);
}
function DetailRow({
label,
value,
valueClass = "",
last = false,
}: {
label: string;
value: string;
valueClass?: string;
last?: boolean;
}) {
return (
<div className={`flex justify-between items-center py-2.5 text-sm ${last ? "" : "border-b border-slate-200"}`}>
<span className="text-slate-600 text-[13px]">{label}</span>
<span className={`font-medium text-right ${valueClass}`}>{value}</span>
</div>
);
}

View file

@ -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<typeof formSchema>;
const CATEGORIES = [
"Tablets",
"Capsules",
"Syrups",
"Injections",
"Ointments",
"Drops",
"Inhalers",
];
export const Route = createFileRoute("/products/add")({
component: AddProductPage,
validateSearch: (search: Record<string, unknown>) => ({
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<FormValues>({
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 (
<div>
<Link
to="/products"
className="inline-flex items-center gap-1.5 text-sm text-blue-600 hover:underline mb-5"
>
<ArrowLeft className="w-4 h-4" />
Back to Products
</Link>
<form
onSubmit={handleSubmit(onSubmit)}
className="bg-white rounded-lg shadow-[0_0_0_1px_rgba(0,0,0,0.06),0_1px_2px_rgba(0,0,0,0.04)] p-8 max-w-3xl"
>
<h2 className="text-xl font-semibold mb-1">
{isEditing ? "Edit Product" : "New Product Entry"}
</h2>
<p className="text-sm text-slate-600 mb-7">
{isEditing
? "Update the details of this medicine."
: "Fill in the details below to add a new medicine."}
</p>
<div className="grid grid-cols-2 gap-5">
{/* Basic Info */}
<div className="col-span-full border-t border-slate-200 pt-5 mt-1">
<h3 className="text-[13px] font-semibold text-slate-900 mb-1">Basic Information</h3>
<p className="text-xs text-slate-500 mb-4">Core product identifiers</p>
</div>
<div>
<label className="block text-sm font-medium text-slate-900 mb-1.5">
Product Name <span className="text-red-600">*</span>
</label>
<Input variant={errors.name ? "error" : "default"} {...register("name")} placeholder="e.g. Paracetamol" />
{errors.name && <p className="text-sm text-red-600 mt-1">{errors.name.message}</p>}
</div>
<div>
<label className="block text-sm font-medium text-slate-900 mb-1.5">
Brand Name <span className="text-red-600">*</span>
</label>
<Input variant={errors.brand ? "error" : "default"} {...register("brand")} placeholder="e.g. Crocin" />
{errors.brand && <p className="text-sm text-red-600 mt-1">{errors.brand.message}</p>}
</div>
<div>
<label className="block text-sm font-medium text-slate-900 mb-1.5">Category</label>
<select
{...register("category")}
className="w-full px-3.5 py-2.5 border border-slate-200 rounded-md text-sm text-slate-900 bg-white focus:outline-none focus:ring-[3px] focus:ring-blue-100 focus:border-blue-600"
>
{CATEGORIES.map((c) => (
<option key={c} value={c}>{c}</option>
))}
</select>
</div>
{showUnitsPerStrip && (
<div>
<label className="block text-sm font-medium text-slate-900 mb-1.5">
Units per Strip <span className="text-red-600">*</span>
</label>
<Input type="number" {...register("units_per_strip")} placeholder="e.g. 10" />
</div>
)}
<div>
<label className="block text-sm font-medium text-slate-900 mb-1.5">
Quantity <span className="text-red-600">*</span>
</label>
<Input type="number" {...register("quantity")} placeholder="0" />
</div>
<div>
<label className="block text-sm font-medium text-slate-900 mb-1.5">
Product Unit <span className="text-red-600">*</span>
</label>
<select
{...register("unit_name")}
className={`w-full px-3.5 py-2.5 border rounded-md text-sm text-slate-900 bg-white focus:outline-none focus:ring-[3px] focus:ring-blue-100 ${errors.unit_name ? "border-red-600" : "border-slate-200 focus:border-blue-600"}`}
>
<option value="">Select...</option>
{(unitList ?? []).map((u) => (
<option key={u.id} value={u.name}>{u.name}</option>
))}
</select>
{errors.unit_name && <p className="text-sm text-red-600 mt-1">{errors.unit_name.message}</p>}
</div>
<div className={showUnitsPerStrip ? "" : "col-span-full"}>
<label className="block text-sm font-medium text-slate-900 mb-1.5">Distributor</label>
<select
{...register("distributor_id")}
className="w-full px-3.5 py-2.5 border border-slate-200 rounded-md text-sm text-slate-900 bg-white focus:outline-none focus:ring-[3px] focus:ring-blue-100 focus:border-blue-600"
>
<option value="">None</option>
{(distributorList ?? []).map((d) => (
<option key={d.id} value={d.id}>{d.agency}</option>
))}
</select>
</div>
{/* Composition */}
<div className="col-span-full border-t border-slate-200 pt-5 mt-1">
<h3 className="text-[13px] font-semibold text-slate-900 mb-1">Medicine Composition</h3>
<p className="text-xs text-slate-500 mb-4">Active chemical ingredients and quantities per unit</p>
</div>
<div className="col-span-full space-y-2">
{fields.map((field, index) => (
<div key={field.id} className="flex items-end gap-2">
<div className="flex-1">
<label className="block text-xs font-medium text-slate-600 mb-1">Drug</label>
<Input
{...register(`compositions.${index}.drug_name`)}
variant={errors.compositions?.[index]?.drug_name ? "error" : "default"}
placeholder="e.g. Paracetamol"
list={`drugs-list-${index}`}
/>
<datalist id={`drugs-list-${index}`}>
{(drugs ?? []).map((d) => (
<option key={d.id} value={d.name} />
))}
</datalist>
</div>
<div className="flex-1">
<label className="block text-xs font-medium text-slate-600 mb-1">Quantity</label>
<Input
{...register(`compositions.${index}.quantity`)}
variant={errors.compositions?.[index]?.quantity ? "error" : "default"}
placeholder="e.g. 500"
/>
</div>
<div className="w-28">
<label className="block text-xs font-medium text-slate-600 mb-1">Unit</label>
<select
{...register(`compositions.${index}.unit_name`)}
className={`w-full px-3 py-2 border rounded-md text-sm text-slate-900 bg-white focus:outline-none focus:ring-[3px] focus:ring-blue-100 ${errors.compositions?.[index]?.unit_name ? "border-red-600" : "border-slate-200 focus:border-blue-600"}`}
>
<option value="">Select...</option>
{(unitList ?? []).map((u) => (
<option key={u.id} value={u.name}>{u.name}</option>
))}
</select>
</div>
<Button
type="button"
variant="ghost-red"
size="icon"
disabled={fields.length <= 1}
onClick={() => remove(index)}
>
<X className="w-4 h-4" />
</Button>
</div>
))}
{errors.compositions?.message && (
<p className="text-sm text-red-600">{errors.compositions.message}</p>
)}
<Button
type="button"
variant="ghost-blue"
size="sm"
onClick={() => append({ drug_name: "", quantity: "", unit_name: "" })}
>
<Plus className="w-3.5 h-3.5" />
Add Ingredient
</Button>
</div>
{/* Pricing */}
<div className="col-span-full border-t border-slate-200 pt-5 mt-1">
<h3 className="text-[13px] font-semibold text-slate-900 mb-1">Pricing</h3>
<p className="text-xs text-slate-500 mb-4">Cost and retail price information</p>
</div>
<div>
<label className="block text-sm font-medium text-slate-900 mb-1.5">
Procured Price () <span className="text-red-600">*</span>
</label>
<Input type="number" step="0.01" {...register("procured_price")} placeholder="0.00" />
</div>
<div>
<label className="block text-sm font-medium text-slate-900 mb-1.5">
MRP () <span className="text-red-600">*</span>
</label>
<Input type="number" step="0.01" {...register("mrp")} placeholder="0.00" />
</div>
<div className="col-span-full">
<label className="block text-sm font-medium text-slate-900 mb-1.5">
Selling Price () <span className="text-red-600">*</span>
</label>
<Input type="number" step="0.01" {...register("selling_price")} placeholder="0.00" />
</div>
{/* Visibility */}
<div className="col-span-full border-t border-slate-200 pt-5 mt-1">
<h3 className="text-[13px] font-semibold text-slate-900 mb-1">Visibility</h3>
<p className="text-xs text-slate-500 mb-4">Control public-facing display</p>
</div>
<div className="col-span-full space-y-3">
<label className="flex items-center gap-2.5 cursor-pointer">
<input
type="checkbox"
{...register("hide_product_from_public")}
className="w-[18px] h-[18px] accent-blue-600 cursor-pointer"
/>
<span className="text-sm font-medium text-slate-900">Hide Product from Public</span>
</label>
<label className="flex items-center gap-2.5 cursor-pointer">
<input
type="checkbox"
{...register("hide_price_from_public")}
disabled={hideProduct}
className="w-[18px] h-[18px] accent-blue-600 cursor-pointer disabled:opacity-40 disabled:cursor-not-allowed"
/>
<span className="text-sm font-medium text-slate-900">Hide Price from Public</span>
</label>
</div>
<div className="col-span-full flex justify-end gap-3 pt-5 mt-2 border-t border-slate-200">
<Link to="/products" className={buttonVariants({ variant: "outline" })}>
Cancel
</Link>
<Button type="submit" disabled={mutation.isPending}>
<Plus className="w-[15px] h-[15px]" />
{mutation.isPending
? "Saving..."
: isEditing
? "Update Product"
: "Add to Inventory"}
</Button>
</div>
</div>
{mutation.error && (
<p className="text-sm text-red-600 mt-4">
Failed to {isEditing ? "update" : "create"} product. Please try again.
</p>
)}
</form>
</div>
);
}

View file

@ -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<ProductRow>[] {
return [
{
id: "name",
header: "Product Name",
cell: ({ row }) => (
<div>
<Link
to="/products/$id"
params={{ id: row.id.toString() }}
className="font-semibold text-[13px] text-blue-600 hover:underline"
>
{row.name}
</Link>
<div className="text-xs text-slate-500 mt-px">{row.category}</div>
</div>
),
},
{
id: "brand",
header: "Brand",
cell: ({ row }) => (
<span className="font-semibold text-[13px]">{row.brand}</span>
),
},
{
id: "selling_price",
header: "Selling",
cell: ({ row }) => (
<span className="font-semibold text-[13px] text-emerald-600 whitespace-nowrap">
{row.selling_price.toFixed(2)}
</span>
),
},
{
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 (
<span
className="text-[13px] font-medium text-slate-700 border-b border-dashed border-slate-400 cursor-help"
title={tip}
>
{count} item{count !== 1 ? "s" : ""}
</span>
);
},
},
{
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 (
<span className={`font-semibold text-[13px] ${cls} whitespace-nowrap`}>
{row.quantity} {row.unit.name}
</span>
);
},
},
{
id: "distributor",
header: "Distributor",
cell: ({ row }) => (
<span className="text-xs text-slate-500">
{row.distributor?.name || "—"}
</span>
),
},
{
id: "procured_price",
header: "Procured",
cell: ({ row }) => (
<span className="text-[13px] font-semibold whitespace-nowrap">
{row.procured_price.toFixed(2)}
</span>
),
},
{
id: "mrp",
header: "MRP",
cell: ({ row }) => (
<span className="text-xs text-slate-500 line-through whitespace-nowrap">
{row.mrp.toFixed(2)}
</span>
),
},
{
id: "actions",
header: "Actions",
size: 100,
cell: ({ row }) => (
<div className="flex items-center justify-center gap-1.5">
<Link
to="/products/add"
search={{ id: row.id }}
className="text-[10px] font-medium text-blue-600 hover:underline whitespace-nowrap"
>
Details
</Link>
<Link
to="/products/add"
search={{ id: row.id }}
>
<Button variant="ghost-blue" size="icon" aria-label={`Edit ${row.name}`} type="button">
<Pencil className="w-[15px] h-[15px]" />
</Button>
</Link>
<Button
variant="ghost-red"
size="icon"
aria-label={`Delete ${row.name}`}
onClick={() => onDelete(row)}
>
<Trash2 className="w-[15px] h-[15px]" />
</Button>
</div>
),
},
];
}
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 <div className="text-sm text-slate-600 py-8">Loading products...</div>;
}
if (error) {
return <div className="text-sm text-red-600 py-8">Failed to load products.</div>;
}
return (
<div>
<div className="flex items-center gap-3 mb-5 flex-wrap">
<div className="flex items-center gap-2 flex-1 min-w-[200px] max-w-[480px] px-3.5 py-2 bg-white rounded-md border border-slate-200 transition-all duration-200 focus-within:border-blue-600 focus-within:shadow-[0_0_0_3px_rgba(37,99,235,0.1)]">
<Search className="w-4 h-4 text-slate-600 shrink-0" />
<input
type="text"
value={searchQuery}
onChange={(e) => 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"
/>
</div>
<Link
to="/products/add"
className={buttonVariants({ variant: "primary" })}
>
<Plus className="w-[15px] h-[15px]" />
Add Product
</Link>
</div>
<GridTable
columns={columns}
data={filtered}
emptyState={
<div className="flex flex-col items-center py-16 px-6 text-slate-600">
<Pill className="w-12 h-12 mb-4 opacity-40" />
<h3 className="text-base font-semibold text-slate-900 mb-1.5">
No products found
</h3>
<p className="text-sm">
{searchQuery
? "No products match your search."
: "Add your first product to the catalog."}
</p>
</div>
}
/>
</div>
);
}

View file

@ -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
);

View file

@ -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": {}
}
}

View file

@ -15,6 +15,13 @@
"when": 1779527160219, "when": 1779527160219,
"tag": "0001_overjoyed_dakota_north", "tag": "0001_overjoyed_dakota_north",
"breakpoints": true "breakpoints": true
},
{
"idx": 2,
"version": "6",
"when": 1779530472486,
"tag": "0002_sour_praxagora",
"breakpoints": true
} }
] ]
} }

View file

@ -4,4 +4,10 @@ import { runMigrations } from './migrate'
const { db, sqlite } = createDb() const { db, sqlite } = createDb()
runMigrations(sqlite) 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 } export { db, sqlite }

View file

@ -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<DrugInfo[]>
createDrugInfo: (input: { name: string }) => Promise<DrugInfo>
}
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 }
}

View file

@ -13,3 +13,24 @@ export {
} from './distributors' } from './distributors'
export { storageSpaces } from './schema/storageSpacesSchema' export { storageSpaces } from './schema/storageSpacesSchema'
export { distributors } from './schema/distributors' 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'

View file

@ -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<keyof ProductFields, ProductOptionalKeys>
export type CreateProductInput =
Pick<ProductFields, ProductRequiredKeys> &
Partial<Pick<ProductFields, ProductOptionalKeys>> & {
distributor_id?: number | null
unit_name: string
compositions: CompositionInput[]
}
export type UpdateProductPatch = Partial<CreateProductInput>
export type ProductsRepo = {
getProducts: () => Promise<Product[]>
getProductById: (id: number) => Promise<Product | null>
createProduct: (input: CreateProductInput) => Promise<Product>
updateProduct: (id: number, patch: UpdateProductPatch) => Promise<Product | null>
deleteProduct: (id: number) => Promise<boolean>
}
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<Product, 'compositions'> {
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<string, unknown> = {}
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 }
}

View file

@ -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(),
})

View file

@ -1,2 +1,6 @@
export * from './storageSpacesSchema' export * from './storageSpacesSchema'
export * from './distributors' export * from './distributors'
export * from './products'
export * from './drugInfo'
export * from './units'
export * from './productCompositions'

View file

@ -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),
})

View file

@ -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),
})

View file

@ -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(),
})

View file

@ -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<Unit[]>
createUnit: (input: { name: string }) => Promise<Unit>
}
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 }
}

View file

@ -0,0 +1,5 @@
import { trpc } from "../trpc";
export function useListDrugInfo() {
return trpc.drugInfo.list.useQuery();
}

View file

@ -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();
}

View file

@ -0,0 +1,5 @@
import { trpc } from "../trpc";
export function useListUnits() {
return trpc.units.list.useQuery();
}

View file

@ -3,4 +3,7 @@ export * from './store'
export * from './provider' export * from './provider'
export * from './hooks/storageSpaces' export * from './hooks/storageSpaces'
export * from './hooks/distributors' export * from './hooks/distributors'
export * from './hooks/products'
export * from './hooks/drugInfo'
export * from './hooks/units'
export { trpc } from './trpc' export { trpc } from './trpc'

View file

@ -2,5 +2,8 @@
"name": "@repo/shared", "name": "@repo/shared",
"version": "0.0.1", "version": "0.0.1",
"main": "src/index.ts", "main": "src/index.ts",
"types": "src/index.ts" "types": "src/index.ts",
"dependencies": {
"zod": "^3.25.0"
}
} }

View file

@ -16,3 +16,6 @@ export function greetPerson(person: Person) {
export type { export type {
AppRouter, AppRouter,
} from '../../../apps/backend/src/trpc/router' } from '../../../apps/backend/src/trpc/router'
// Shared schemas
export * from './schemas/product'

View file

@ -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<typeof ProductSchema>;