product crud and management functional
This commit is contained in:
parent
c0fd8671e4
commit
94fbd8c87e
30 changed files with 1970 additions and 17 deletions
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
25
apps/backend/src/trpc/pharmanager/v1/drugInfo.ts
Normal file
25
apps/backend/src/trpc/pharmanager/v1/drugInfo.ts
Normal 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)),
|
||||
});
|
||||
40
apps/backend/src/trpc/pharmanager/v1/product.ts
Normal file
40
apps/backend/src/trpc/pharmanager/v1/product.ts
Normal 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 };
|
||||
}),
|
||||
});
|
||||
25
apps/backend/src/trpc/pharmanager/v1/units.ts
Normal file
25
apps/backend/src/trpc/pharmanager/v1/units.ts
Normal 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)),
|
||||
});
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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: () => <Outlet />,
|
||||
});
|
||||
|
||||
function ProductsPage() {
|
||||
return <div>Products</div>;
|
||||
}
|
||||
|
|
|
|||
188
apps/pharmanager/src/routes/products/$id.tsx
Normal file
188
apps/pharmanager/src/routes/products/$id.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
406
apps/pharmanager/src/routes/products/add.tsx
Normal file
406
apps/pharmanager/src/routes/products/add.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
242
apps/pharmanager/src/routes/products/index.tsx
Normal file
242
apps/pharmanager/src/routes/products/index.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
41
packages/data-manager-sqlite/drizzle/0002_sour_praxagora.sql
Normal file
41
packages/data-manager-sqlite/drizzle/0002_sour_praxagora.sql
Normal 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
|
||||
);
|
||||
398
packages/data-manager-sqlite/drizzle/meta/0002_snapshot.json
Normal file
398
packages/data-manager-sqlite/drizzle/meta/0002_snapshot.json
Normal 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": {}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -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 }
|
||||
|
|
|
|||
30
packages/data-manager-sqlite/src/drugInfo.ts
Normal file
30
packages/data-manager-sqlite/src/drugInfo.ts
Normal 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 }
|
||||
}
|
||||
|
|
@ -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'
|
||||
|
|
|
|||
265
packages/data-manager-sqlite/src/products.ts
Normal file
265
packages/data-manager-sqlite/src/products.ts
Normal 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 }
|
||||
}
|
||||
6
packages/data-manager-sqlite/src/schema/drugInfo.ts
Normal file
6
packages/data-manager-sqlite/src/schema/drugInfo.ts
Normal 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(),
|
||||
})
|
||||
|
|
@ -1,2 +1,6 @@
|
|||
export * from './storageSpacesSchema'
|
||||
export * from './distributors'
|
||||
export * from './products'
|
||||
export * from './drugInfo'
|
||||
export * from './units'
|
||||
export * from './productCompositions'
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
})
|
||||
20
packages/data-manager-sqlite/src/schema/products.ts
Normal file
20
packages/data-manager-sqlite/src/schema/products.ts
Normal 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),
|
||||
})
|
||||
6
packages/data-manager-sqlite/src/schema/units.ts
Normal file
6
packages/data-manager-sqlite/src/schema/units.ts
Normal 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(),
|
||||
})
|
||||
30
packages/data-manager-sqlite/src/units.ts
Normal file
30
packages/data-manager-sqlite/src/units.ts
Normal 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 }
|
||||
}
|
||||
5
packages/shared-react/src/hooks/drugInfo.ts
Normal file
5
packages/shared-react/src/hooks/drugInfo.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
import { trpc } from "../trpc";
|
||||
|
||||
export function useListDrugInfo() {
|
||||
return trpc.drugInfo.list.useQuery();
|
||||
}
|
||||
21
packages/shared-react/src/hooks/products.ts
Normal file
21
packages/shared-react/src/hooks/products.ts
Normal 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();
|
||||
}
|
||||
5
packages/shared-react/src/hooks/units.ts
Normal file
5
packages/shared-react/src/hooks/units.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
import { trpc } from "../trpc";
|
||||
|
||||
export function useListUnits() {
|
||||
return trpc.units.list.useQuery();
|
||||
}
|
||||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -16,3 +16,6 @@ export function greetPerson(person: Person) {
|
|||
export type {
|
||||
AppRouter,
|
||||
} from '../../../apps/backend/src/trpc/router'
|
||||
|
||||
// Shared schemas
|
||||
export * from './schemas/product'
|
||||
|
|
|
|||
57
packages/shared/src/schemas/product.ts
Normal file
57
packages/shared/src/schemas/product.ts
Normal 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>;
|
||||
Loading…
Add table
Reference in a new issue