billing functional
This commit is contained in:
parent
302866dc58
commit
32449b45f3
25 changed files with 3009 additions and 32 deletions
|
|
@ -9,6 +9,8 @@ import {
|
|||
createStaffRepo,
|
||||
createEnterpriseStaffRepo,
|
||||
createRolesRepo,
|
||||
createCustomersRepo,
|
||||
createBillsRepo,
|
||||
type StorageSpacesRepo,
|
||||
type DistributorsRepo,
|
||||
type ProductsRepo,
|
||||
|
|
@ -19,6 +21,8 @@ import {
|
|||
type StaffRepo,
|
||||
type EnterpriseStaffRepo,
|
||||
type RolesRepo,
|
||||
type CustomersRepo,
|
||||
type BillsRepo,
|
||||
} from "data-manager-sqlite";
|
||||
|
||||
export class DataManager {
|
||||
|
|
@ -32,6 +36,8 @@ export class DataManager {
|
|||
readonly staff: StaffRepo;
|
||||
readonly enterpriseStaff: EnterpriseStaffRepo;
|
||||
readonly roles: RolesRepo;
|
||||
readonly customers: CustomersRepo;
|
||||
readonly bills: BillsRepo;
|
||||
|
||||
constructor() {
|
||||
const { repo: storageSpacesRepo } = createStorageSpacesRepo();
|
||||
|
|
@ -44,6 +50,8 @@ export class DataManager {
|
|||
const { repo: staffRepo } = createStaffRepo();
|
||||
const { repo: enterpriseStaffRepo } = createEnterpriseStaffRepo();
|
||||
const { repo: rolesRepo } = createRolesRepo();
|
||||
const { repo: customersRepo } = createCustomersRepo();
|
||||
const { repo: billsRepo } = createBillsRepo();
|
||||
|
||||
this.storageSpaces = storageSpacesRepo;
|
||||
this.distributors = distributorsRepo;
|
||||
|
|
@ -55,5 +63,7 @@ export class DataManager {
|
|||
this.staff = staffRepo;
|
||||
this.enterpriseStaff = enterpriseStaffRepo;
|
||||
this.roles = rolesRepo;
|
||||
this.customers = customersRepo;
|
||||
this.bills = billsRepo;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
85
apps/backend/src/trpc/pharmanager/v1/billing.ts
Normal file
85
apps/backend/src/trpc/pharmanager/v1/billing.ts
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
import { z } from "zod";
|
||||
import { protectedProcedure, router } from "../../init";
|
||||
import { dataManager } from "../../../lib/data-manager-instance";
|
||||
|
||||
export const BillItemSchema = z.object({
|
||||
id: z.number().int(),
|
||||
product_id: z.number().int(),
|
||||
product_name: z.string(),
|
||||
brand: z.string().nullable(),
|
||||
batch_id: z.number().int().nullable(),
|
||||
strips: z.number().int(),
|
||||
loose: z.number().int(),
|
||||
qty: z.number().int(),
|
||||
original_price: z.number(),
|
||||
selling_price: z.number(),
|
||||
total: z.number(),
|
||||
});
|
||||
|
||||
export const BillSchema = z.object({
|
||||
id: z.number().int(),
|
||||
bill_no: z.string(),
|
||||
customer_mobile: z.string(),
|
||||
customer_name: z.string().nullable(),
|
||||
subtotal: z.number(),
|
||||
tax: z.number(),
|
||||
tax_rate: z.number(),
|
||||
total: z.number(),
|
||||
discount: z.number(),
|
||||
discount_percent: z.number().int(),
|
||||
generated_by: z.number().int(),
|
||||
created_at: z.string(),
|
||||
items: z.array(BillItemSchema),
|
||||
});
|
||||
|
||||
const BillItemInputSchema = z.object({
|
||||
product_id: z.number().int(),
|
||||
product_name: z.string(),
|
||||
brand: z.string().nullable().optional(),
|
||||
batch_id: z.number().int().nullable().optional(),
|
||||
strips: z.number().int(),
|
||||
loose: z.number().int(),
|
||||
qty: z.number().int(),
|
||||
original_price: z.number(),
|
||||
selling_price: z.number(),
|
||||
total: z.number(),
|
||||
});
|
||||
|
||||
export const CreateBillInput = z.object({
|
||||
bill_no: z.string(),
|
||||
customer_mobile: z.string(),
|
||||
customer_name: z.string().nullable().optional(),
|
||||
subtotal: z.number(),
|
||||
tax: z.number(),
|
||||
tax_rate: z.number(),
|
||||
total: z.number(),
|
||||
discount: z.number().default(0),
|
||||
discount_percent: z.number().int().default(0),
|
||||
created_at: z.string(),
|
||||
items: z.array(BillItemInputSchema).min(1),
|
||||
});
|
||||
|
||||
export const billingRouter = router({
|
||||
list: protectedProcedure
|
||||
.output(z.array(BillSchema))
|
||||
.query(({ ctx }) =>
|
||||
dataManager.bills.getBills(ctx.staff.enterpriseId),
|
||||
),
|
||||
|
||||
byId: protectedProcedure
|
||||
.input(z.object({ id: z.number().int() }))
|
||||
.output(BillSchema.nullable())
|
||||
.query(({ ctx, input }) =>
|
||||
dataManager.bills.getBillById(input.id, ctx.staff.enterpriseId),
|
||||
),
|
||||
|
||||
create: protectedProcedure
|
||||
.input(CreateBillInput)
|
||||
.output(BillSchema)
|
||||
.mutation(({ ctx, input }) =>
|
||||
dataManager.bills.createBill(
|
||||
{ ...input, generated_by: ctx.staff.staffId },
|
||||
ctx.staff.enterpriseId,
|
||||
),
|
||||
),
|
||||
});
|
||||
67
apps/backend/src/trpc/pharmanager/v1/customers.ts
Normal file
67
apps/backend/src/trpc/pharmanager/v1/customers.ts
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
import { z } from "zod";
|
||||
import { protectedProcedure, router } from "../../init";
|
||||
import { dataManager } from "../../../lib/data-manager-instance";
|
||||
|
||||
export const CustomerSchema = z.object({
|
||||
id: z.number().int(),
|
||||
mobile: z.string(),
|
||||
name: z.string().nullable(),
|
||||
added_on: z.string(),
|
||||
});
|
||||
|
||||
export const CreateCustomerInput = z.object({
|
||||
mobile: z.string().min(1),
|
||||
name: z.string().nullable().optional(),
|
||||
added_on: z.string(),
|
||||
});
|
||||
|
||||
export const UpdateCustomerInput = z.object({
|
||||
id: z.number().int(),
|
||||
mobile: z.string().min(1).optional(),
|
||||
name: z.string().nullable().optional(),
|
||||
});
|
||||
|
||||
export const customersRouter = router({
|
||||
list: protectedProcedure
|
||||
.output(z.array(CustomerSchema))
|
||||
.query(({ ctx }) =>
|
||||
dataManager.customers.listCustomers(ctx.staff.enterpriseId),
|
||||
),
|
||||
|
||||
search: protectedProcedure
|
||||
.input(z.object({ query: z.string() }))
|
||||
.output(z.array(CustomerSchema))
|
||||
.query(({ ctx, input }) =>
|
||||
dataManager.customers.searchCustomers(input.query, ctx.staff.enterpriseId),
|
||||
),
|
||||
|
||||
byId: protectedProcedure
|
||||
.input(z.object({ id: z.number().int() }))
|
||||
.output(CustomerSchema.nullable())
|
||||
.query(({ ctx, input }) =>
|
||||
dataManager.customers.getCustomerById(input.id, ctx.staff.enterpriseId),
|
||||
),
|
||||
|
||||
create: protectedProcedure
|
||||
.input(CreateCustomerInput)
|
||||
.output(CustomerSchema)
|
||||
.mutation(({ ctx, input }) =>
|
||||
dataManager.customers.createCustomer(input, ctx.staff.enterpriseId),
|
||||
),
|
||||
|
||||
update: protectedProcedure
|
||||
.input(UpdateCustomerInput)
|
||||
.output(CustomerSchema.nullable())
|
||||
.mutation(({ ctx, input }) => {
|
||||
const { id, ...patch } = input;
|
||||
return dataManager.customers.updateCustomer(id, patch, ctx.staff.enterpriseId);
|
||||
}),
|
||||
|
||||
remove: protectedProcedure
|
||||
.input(z.object({ id: z.number().int() }))
|
||||
.output(z.object({ ok: z.boolean() }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const ok = await dataManager.customers.deleteCustomer(input.id, ctx.staff.enterpriseId);
|
||||
return { ok };
|
||||
}),
|
||||
});
|
||||
|
|
@ -8,6 +8,8 @@ import { stockRouter } from "./pharmanager/v1/stock";
|
|||
import { authRouter } from "./pharmanager/v1/auth";
|
||||
import { staffManagementRouter } from "./pharmanager/v1/staffManagement";
|
||||
import { rolesRouter } from "./pharmanager/v1/roles";
|
||||
import { billingRouter } from "./pharmanager/v1/billing";
|
||||
import { customersRouter } from "./pharmanager/v1/customers";
|
||||
|
||||
export const appRouter = router({
|
||||
storage: storageRouter,
|
||||
|
|
@ -19,6 +21,8 @@ export const appRouter = router({
|
|||
auth: authRouter,
|
||||
staffManagement: staffManagementRouter,
|
||||
roles: rolesRouter,
|
||||
billing: billingRouter,
|
||||
customers: customersRouter,
|
||||
});
|
||||
|
||||
export type AppRouter = typeof appRouter;
|
||||
|
|
|
|||
|
|
@ -24,6 +24,8 @@ import { Route as StockIndexRouteImport } from './routes/stock/index'
|
|||
import { Route as StaffIndexRouteImport } from './routes/staff/index'
|
||||
import { Route as ProductsIndexRouteImport } from './routes/products/index'
|
||||
import { Route as DistributorsIndexRouteImport } from './routes/distributors/index'
|
||||
import { Route as CustomersIndexRouteImport } from './routes/customers/index'
|
||||
import { Route as BillingIndexRouteImport } from './routes/billing/index'
|
||||
import { Route as StorageAddRouteImport } from './routes/storage/add'
|
||||
import { Route as StorageIdRouteImport } from './routes/storage/$id'
|
||||
import { Route as StockAddRouteImport } from './routes/stock/add'
|
||||
|
|
@ -34,6 +36,9 @@ 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'
|
||||
import { Route as CustomersAddRouteImport } from './routes/customers/add'
|
||||
import { Route as CustomersIdRouteImport } from './routes/customers/$id'
|
||||
import { Route as BillingIdRouteImport } from './routes/billing/$id'
|
||||
|
||||
const StorageRoute = StorageRouteImport.update({
|
||||
id: '/storage',
|
||||
|
|
@ -110,6 +115,16 @@ const DistributorsIndexRoute = DistributorsIndexRouteImport.update({
|
|||
path: '/',
|
||||
getParentRoute: () => DistributorsRoute,
|
||||
} as any)
|
||||
const CustomersIndexRoute = CustomersIndexRouteImport.update({
|
||||
id: '/',
|
||||
path: '/',
|
||||
getParentRoute: () => CustomersRoute,
|
||||
} as any)
|
||||
const BillingIndexRoute = BillingIndexRouteImport.update({
|
||||
id: '/',
|
||||
path: '/',
|
||||
getParentRoute: () => BillingRoute,
|
||||
} as any)
|
||||
const StorageAddRoute = StorageAddRouteImport.update({
|
||||
id: '/add',
|
||||
path: '/add',
|
||||
|
|
@ -160,11 +175,26 @@ const DistributorsIdRoute = DistributorsIdRouteImport.update({
|
|||
path: '/$id',
|
||||
getParentRoute: () => DistributorsRoute,
|
||||
} as any)
|
||||
const CustomersAddRoute = CustomersAddRouteImport.update({
|
||||
id: '/add',
|
||||
path: '/add',
|
||||
getParentRoute: () => CustomersRoute,
|
||||
} as any)
|
||||
const CustomersIdRoute = CustomersIdRouteImport.update({
|
||||
id: '/$id',
|
||||
path: '/$id',
|
||||
getParentRoute: () => CustomersRoute,
|
||||
} as any)
|
||||
const BillingIdRoute = BillingIdRouteImport.update({
|
||||
id: '/$id',
|
||||
path: '/$id',
|
||||
getParentRoute: () => BillingRoute,
|
||||
} as any)
|
||||
|
||||
export interface FileRoutesByFullPath {
|
||||
'/': typeof IndexRoute
|
||||
'/billing': typeof BillingRoute
|
||||
'/customers': typeof CustomersRoute
|
||||
'/billing': typeof BillingRouteWithChildren
|
||||
'/customers': typeof CustomersRouteWithChildren
|
||||
'/distributors': typeof DistributorsRouteWithChildren
|
||||
'/login': typeof LoginRoute
|
||||
'/products': typeof ProductsRouteWithChildren
|
||||
|
|
@ -172,6 +202,9 @@ export interface FileRoutesByFullPath {
|
|||
'/staff': typeof StaffRouteWithChildren
|
||||
'/stock': typeof StockRouteWithChildren
|
||||
'/storage': typeof StorageRouteWithChildren
|
||||
'/billing/$id': typeof BillingIdRoute
|
||||
'/customers/$id': typeof CustomersIdRoute
|
||||
'/customers/add': typeof CustomersAddRoute
|
||||
'/distributors/$id': typeof DistributorsIdRoute
|
||||
'/distributors/add': typeof DistributorsAddRoute
|
||||
'/products/$id': typeof ProductsIdRoute
|
||||
|
|
@ -182,6 +215,8 @@ export interface FileRoutesByFullPath {
|
|||
'/stock/add': typeof StockAddRoute
|
||||
'/storage/$id': typeof StorageIdRoute
|
||||
'/storage/add': typeof StorageAddRoute
|
||||
'/billing/': typeof BillingIndexRoute
|
||||
'/customers/': typeof CustomersIndexRoute
|
||||
'/distributors/': typeof DistributorsIndexRoute
|
||||
'/products/': typeof ProductsIndexRoute
|
||||
'/staff/': typeof StaffIndexRoute
|
||||
|
|
@ -190,10 +225,11 @@ export interface FileRoutesByFullPath {
|
|||
}
|
||||
export interface FileRoutesByTo {
|
||||
'/': typeof IndexRoute
|
||||
'/billing': typeof BillingRoute
|
||||
'/customers': typeof CustomersRoute
|
||||
'/login': typeof LoginRoute
|
||||
'/profile': typeof ProfileRoute
|
||||
'/billing/$id': typeof BillingIdRoute
|
||||
'/customers/$id': typeof CustomersIdRoute
|
||||
'/customers/add': typeof CustomersAddRoute
|
||||
'/distributors/$id': typeof DistributorsIdRoute
|
||||
'/distributors/add': typeof DistributorsAddRoute
|
||||
'/products/$id': typeof ProductsIdRoute
|
||||
|
|
@ -204,6 +240,8 @@ export interface FileRoutesByTo {
|
|||
'/stock/add': typeof StockAddRoute
|
||||
'/storage/$id': typeof StorageIdRoute
|
||||
'/storage/add': typeof StorageAddRoute
|
||||
'/billing': typeof BillingIndexRoute
|
||||
'/customers': typeof CustomersIndexRoute
|
||||
'/distributors': typeof DistributorsIndexRoute
|
||||
'/products': typeof ProductsIndexRoute
|
||||
'/staff': typeof StaffIndexRoute
|
||||
|
|
@ -213,8 +251,8 @@ export interface FileRoutesByTo {
|
|||
export interface FileRoutesById {
|
||||
__root__: typeof rootRouteImport
|
||||
'/': typeof IndexRoute
|
||||
'/billing': typeof BillingRoute
|
||||
'/customers': typeof CustomersRoute
|
||||
'/billing': typeof BillingRouteWithChildren
|
||||
'/customers': typeof CustomersRouteWithChildren
|
||||
'/distributors': typeof DistributorsRouteWithChildren
|
||||
'/login': typeof LoginRoute
|
||||
'/products': typeof ProductsRouteWithChildren
|
||||
|
|
@ -222,6 +260,9 @@ export interface FileRoutesById {
|
|||
'/staff': typeof StaffRouteWithChildren
|
||||
'/stock': typeof StockRouteWithChildren
|
||||
'/storage': typeof StorageRouteWithChildren
|
||||
'/billing/$id': typeof BillingIdRoute
|
||||
'/customers/$id': typeof CustomersIdRoute
|
||||
'/customers/add': typeof CustomersAddRoute
|
||||
'/distributors/$id': typeof DistributorsIdRoute
|
||||
'/distributors/add': typeof DistributorsAddRoute
|
||||
'/products/$id': typeof ProductsIdRoute
|
||||
|
|
@ -232,6 +273,8 @@ export interface FileRoutesById {
|
|||
'/stock/add': typeof StockAddRoute
|
||||
'/storage/$id': typeof StorageIdRoute
|
||||
'/storage/add': typeof StorageAddRoute
|
||||
'/billing/': typeof BillingIndexRoute
|
||||
'/customers/': typeof CustomersIndexRoute
|
||||
'/distributors/': typeof DistributorsIndexRoute
|
||||
'/products/': typeof ProductsIndexRoute
|
||||
'/staff/': typeof StaffIndexRoute
|
||||
|
|
@ -251,6 +294,9 @@ export interface FileRouteTypes {
|
|||
| '/staff'
|
||||
| '/stock'
|
||||
| '/storage'
|
||||
| '/billing/$id'
|
||||
| '/customers/$id'
|
||||
| '/customers/add'
|
||||
| '/distributors/$id'
|
||||
| '/distributors/add'
|
||||
| '/products/$id'
|
||||
|
|
@ -261,6 +307,8 @@ export interface FileRouteTypes {
|
|||
| '/stock/add'
|
||||
| '/storage/$id'
|
||||
| '/storage/add'
|
||||
| '/billing/'
|
||||
| '/customers/'
|
||||
| '/distributors/'
|
||||
| '/products/'
|
||||
| '/staff/'
|
||||
|
|
@ -269,10 +317,11 @@ export interface FileRouteTypes {
|
|||
fileRoutesByTo: FileRoutesByTo
|
||||
to:
|
||||
| '/'
|
||||
| '/billing'
|
||||
| '/customers'
|
||||
| '/login'
|
||||
| '/profile'
|
||||
| '/billing/$id'
|
||||
| '/customers/$id'
|
||||
| '/customers/add'
|
||||
| '/distributors/$id'
|
||||
| '/distributors/add'
|
||||
| '/products/$id'
|
||||
|
|
@ -283,6 +332,8 @@ export interface FileRouteTypes {
|
|||
| '/stock/add'
|
||||
| '/storage/$id'
|
||||
| '/storage/add'
|
||||
| '/billing'
|
||||
| '/customers'
|
||||
| '/distributors'
|
||||
| '/products'
|
||||
| '/staff'
|
||||
|
|
@ -300,6 +351,9 @@ export interface FileRouteTypes {
|
|||
| '/staff'
|
||||
| '/stock'
|
||||
| '/storage'
|
||||
| '/billing/$id'
|
||||
| '/customers/$id'
|
||||
| '/customers/add'
|
||||
| '/distributors/$id'
|
||||
| '/distributors/add'
|
||||
| '/products/$id'
|
||||
|
|
@ -310,6 +364,8 @@ export interface FileRouteTypes {
|
|||
| '/stock/add'
|
||||
| '/storage/$id'
|
||||
| '/storage/add'
|
||||
| '/billing/'
|
||||
| '/customers/'
|
||||
| '/distributors/'
|
||||
| '/products/'
|
||||
| '/staff/'
|
||||
|
|
@ -319,8 +375,8 @@ export interface FileRouteTypes {
|
|||
}
|
||||
export interface RootRouteChildren {
|
||||
IndexRoute: typeof IndexRoute
|
||||
BillingRoute: typeof BillingRoute
|
||||
CustomersRoute: typeof CustomersRoute
|
||||
BillingRoute: typeof BillingRouteWithChildren
|
||||
CustomersRoute: typeof CustomersRouteWithChildren
|
||||
DistributorsRoute: typeof DistributorsRouteWithChildren
|
||||
LoginRoute: typeof LoginRoute
|
||||
ProductsRoute: typeof ProductsRouteWithChildren
|
||||
|
|
@ -437,6 +493,20 @@ declare module '@tanstack/react-router' {
|
|||
preLoaderRoute: typeof DistributorsIndexRouteImport
|
||||
parentRoute: typeof DistributorsRoute
|
||||
}
|
||||
'/customers/': {
|
||||
id: '/customers/'
|
||||
path: '/'
|
||||
fullPath: '/customers/'
|
||||
preLoaderRoute: typeof CustomersIndexRouteImport
|
||||
parentRoute: typeof CustomersRoute
|
||||
}
|
||||
'/billing/': {
|
||||
id: '/billing/'
|
||||
path: '/'
|
||||
fullPath: '/billing/'
|
||||
preLoaderRoute: typeof BillingIndexRouteImport
|
||||
parentRoute: typeof BillingRoute
|
||||
}
|
||||
'/storage/add': {
|
||||
id: '/storage/add'
|
||||
path: '/add'
|
||||
|
|
@ -507,8 +577,58 @@ declare module '@tanstack/react-router' {
|
|||
preLoaderRoute: typeof DistributorsIdRouteImport
|
||||
parentRoute: typeof DistributorsRoute
|
||||
}
|
||||
'/customers/add': {
|
||||
id: '/customers/add'
|
||||
path: '/add'
|
||||
fullPath: '/customers/add'
|
||||
preLoaderRoute: typeof CustomersAddRouteImport
|
||||
parentRoute: typeof CustomersRoute
|
||||
}
|
||||
'/customers/$id': {
|
||||
id: '/customers/$id'
|
||||
path: '/$id'
|
||||
fullPath: '/customers/$id'
|
||||
preLoaderRoute: typeof CustomersIdRouteImport
|
||||
parentRoute: typeof CustomersRoute
|
||||
}
|
||||
'/billing/$id': {
|
||||
id: '/billing/$id'
|
||||
path: '/$id'
|
||||
fullPath: '/billing/$id'
|
||||
preLoaderRoute: typeof BillingIdRouteImport
|
||||
parentRoute: typeof BillingRoute
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
interface BillingRouteChildren {
|
||||
BillingIdRoute: typeof BillingIdRoute
|
||||
BillingIndexRoute: typeof BillingIndexRoute
|
||||
}
|
||||
|
||||
const BillingRouteChildren: BillingRouteChildren = {
|
||||
BillingIdRoute: BillingIdRoute,
|
||||
BillingIndexRoute: BillingIndexRoute,
|
||||
}
|
||||
|
||||
const BillingRouteWithChildren =
|
||||
BillingRoute._addFileChildren(BillingRouteChildren)
|
||||
|
||||
interface CustomersRouteChildren {
|
||||
CustomersIdRoute: typeof CustomersIdRoute
|
||||
CustomersAddRoute: typeof CustomersAddRoute
|
||||
CustomersIndexRoute: typeof CustomersIndexRoute
|
||||
}
|
||||
|
||||
const CustomersRouteChildren: CustomersRouteChildren = {
|
||||
CustomersIdRoute: CustomersIdRoute,
|
||||
CustomersAddRoute: CustomersAddRoute,
|
||||
CustomersIndexRoute: CustomersIndexRoute,
|
||||
}
|
||||
|
||||
const CustomersRouteWithChildren = CustomersRoute._addFileChildren(
|
||||
CustomersRouteChildren,
|
||||
)
|
||||
|
||||
interface DistributorsRouteChildren {
|
||||
DistributorsIdRoute: typeof DistributorsIdRoute
|
||||
|
|
@ -587,8 +707,8 @@ const StorageRouteWithChildren =
|
|||
|
||||
const rootRouteChildren: RootRouteChildren = {
|
||||
IndexRoute: IndexRoute,
|
||||
BillingRoute: BillingRoute,
|
||||
CustomersRoute: CustomersRoute,
|
||||
BillingRoute: BillingRouteWithChildren,
|
||||
CustomersRoute: CustomersRouteWithChildren,
|
||||
DistributorsRoute: DistributorsRouteWithChildren,
|
||||
LoginRoute: LoginRoute,
|
||||
ProductsRoute: ProductsRouteWithChildren,
|
||||
|
|
|
|||
|
|
@ -1,13 +1,5 @@
|
|||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { Outlet, createFileRoute } from "@tanstack/react-router";
|
||||
|
||||
export const Route = createFileRoute("/billing")({
|
||||
component: BillingPage,
|
||||
staticData: {
|
||||
title: "Billing",
|
||||
subtitle: "Invoices, payments, revenue tracking",
|
||||
},
|
||||
component: () => <Outlet />,
|
||||
});
|
||||
|
||||
function BillingPage() {
|
||||
return <div>Billing</div>;
|
||||
}
|
||||
|
|
|
|||
122
apps/pharmanager/src/routes/billing/$id.tsx
Normal file
122
apps/pharmanager/src/routes/billing/$id.tsx
Normal file
|
|
@ -0,0 +1,122 @@
|
|||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { ReceiptText, User, Clock, DollarSign } from "lucide-react";
|
||||
import { DetailRow, BackLink, EmptyState } from "#/components/ui";
|
||||
import { useGetBillById } from "shared-react";
|
||||
|
||||
export const Route = createFileRoute("/billing/$id")({
|
||||
component: BillDetailsPage,
|
||||
staticData: {
|
||||
title: "Bill Details",
|
||||
subtitle: "Loading...",
|
||||
},
|
||||
});
|
||||
|
||||
function BillDetailsPage() {
|
||||
const { id } = Route.useParams();
|
||||
const billId = Number(id);
|
||||
const { data: bill, isLoading } = useGetBillById(billId);
|
||||
|
||||
if (isLoading) return <div className="text-sm text-slate-600 py-8">Loading bill...</div>;
|
||||
if (!bill) {
|
||||
return (
|
||||
<EmptyState
|
||||
icon={ReceiptText}
|
||||
title="Bill not found"
|
||||
description="The bill you're looking for doesn't exist."
|
||||
actionLabel="Back to Billing"
|
||||
actionTo="/billing"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const d = new Date(bill.created_at);
|
||||
const dateStr = d.toLocaleDateString("en-US", { weekday: "long", month: "long", day: "numeric", year: "numeric", hour: "2-digit", minute: "2-digit" });
|
||||
|
||||
return (
|
||||
<div>
|
||||
<BackLink to="/billing" label="Billing" />
|
||||
|
||||
<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)] p-6 mb-5 flex justify-between items-start gap-5 max-w-[960px]">
|
||||
<div>
|
||||
<div className="text-[22px] font-bold text-blue-600">{bill.bill_no}</div>
|
||||
<div className="text-[13px] text-slate-500 mt-1">{dateStr}</div>
|
||||
<div className="mt-3 flex items-center gap-2 text-sm text-slate-700">
|
||||
<User className="w-4 h-4" />
|
||||
{bill.customer_name ? `${bill.customer_name} (${bill.customer_mobile})` : bill.customer_mobile}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right shrink-0">
|
||||
<div className="text-[28px] font-bold text-emerald-600">₹{bill.total.toFixed(2)}</div>
|
||||
<span className="inline-block mt-1.5 px-2.5 py-0.5 rounded-full text-xs font-semibold bg-emerald-50 text-emerald-600">Completed</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-5 max-w-[960px] mb-5">
|
||||
<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)] p-5">
|
||||
<h3 className="flex items-center gap-1.5 text-xs font-semibold text-slate-600 uppercase tracking-wider mb-3">
|
||||
<Clock className="w-[14px] h-[14px]" />Timings & Info
|
||||
</h3>
|
||||
<div className="grid grid-cols-2 gap-2 text-[13px]">
|
||||
<span className="text-slate-500">Generated</span><span className="font-medium">{dateStr}</span>
|
||||
<span className="text-slate-500">Items</span><span className="font-medium">{bill.items.length}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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)] p-5">
|
||||
<h3 className="flex items-center gap-1.5 text-xs font-semibold text-slate-600 uppercase tracking-wider mb-3">
|
||||
<DollarSign className="w-[14px] h-[14px]" />Payment Summary
|
||||
</h3>
|
||||
<div className="grid grid-cols-2 gap-2 text-[13px]">
|
||||
<span className="text-slate-500">Subtotal</span><span className="font-medium text-right">₹{bill.subtotal.toFixed(2)}</span>
|
||||
<span className="text-slate-500">Tax ({bill.tax_rate}%)</span><span className="font-medium text-right">₹{bill.tax.toFixed(2)}</span>
|
||||
<span className="text-emerald-600">Discount</span><span className="font-medium text-right text-emerald-600">{bill.discount_percent}%</span>
|
||||
<span className="text-slate-900 font-semibold border-t border-slate-200 pt-1.5">Total</span>
|
||||
<span className="font-bold text-right text-blue-600 border-t border-slate-200 pt-1.5">₹{bill.total.toFixed(2)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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)] p-5 max-w-[960px]">
|
||||
<h3 className="flex items-center gap-1.5 text-xs font-semibold text-slate-600 uppercase tracking-wider mb-4">
|
||||
<ReceiptText className="w-[14px] h-[14px]" />Bill Items
|
||||
</h3>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="text-[11px] font-semibold text-slate-600 uppercase tracking-wider border-b border-slate-200">
|
||||
<th className="text-left pb-2 px-2.5">Product</th>
|
||||
<th className="text-center pb-2 px-2.5">Strips</th>
|
||||
<th className="text-center pb-2 px-2.5">Loose</th>
|
||||
<th className="text-left pb-2 px-2.5">Price</th>
|
||||
<th className="text-right pb-2 px-2.5">Total</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{bill.items.map((item) => (
|
||||
<tr key={item.id} className="border-b border-slate-100">
|
||||
<td className="py-2.5 px-2.5">
|
||||
<div className="font-semibold text-[13px]">{item.product_name}</div>
|
||||
{item.brand && <div className="text-xs text-slate-500">{item.brand}</div>}
|
||||
</td>
|
||||
<td className="py-2.5 px-2.5 text-center">{item.strips}</td>
|
||||
<td className="py-2.5 px-2.5 text-center">{item.loose}</td>
|
||||
<td className="py-2.5 px-2.5">
|
||||
{item.original_price !== item.selling_price ? (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-xs text-slate-400 line-through">₹{item.original_price.toFixed(2)}</span>
|
||||
<span className="font-semibold text-emerald-600">₹{item.selling_price.toFixed(2)}</span>
|
||||
</div>
|
||||
) : (
|
||||
<span className="font-semibold">₹{item.selling_price.toFixed(2)}</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="py-2.5 px-2.5 text-right font-semibold">₹{item.total.toFixed(2)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
512
apps/pharmanager/src/routes/billing/index.tsx
Normal file
512
apps/pharmanager/src/routes/billing/index.tsx
Normal file
|
|
@ -0,0 +1,512 @@
|
|||
import { useState, useMemo, useRef, useEffect } from "react";
|
||||
import { createFileRoute, Link } from "@tanstack/react-router";
|
||||
import { Plus, Trash2, ReceiptText } from "lucide-react";
|
||||
import { Button, Input, Combobox, buttonVariants } from "#/components/ui";
|
||||
import {
|
||||
useListBills,
|
||||
useCreateBill,
|
||||
useSearchCustomers,
|
||||
useCreateCustomer,
|
||||
useListProducts,
|
||||
useListStockBatches,
|
||||
trpc,
|
||||
} from "shared-react";
|
||||
|
||||
export const Route = createFileRoute("/billing/")({
|
||||
component: BillingPage,
|
||||
staticData: {
|
||||
title: "Billing",
|
||||
subtitle: "Invoice generation & payment management",
|
||||
},
|
||||
});
|
||||
|
||||
interface BillItemForm {
|
||||
product_id: number;
|
||||
product_name: string;
|
||||
brand: string;
|
||||
batch_id: number | null;
|
||||
strips: number;
|
||||
loose: number;
|
||||
qty: number;
|
||||
original_price: number;
|
||||
selling_price: number;
|
||||
total: number;
|
||||
batchOptions: { id: number; batch_no: string; quantity: number; is_default: boolean }[];
|
||||
}
|
||||
|
||||
function BillingPage() {
|
||||
const [tab, setTab] = useState<"new" | "history">("new");
|
||||
const [customerMobile, setCustomerMobile] = useState("");
|
||||
const [customerName, setCustomerName] = useState("");
|
||||
const [customerSearch, setCustomerSearch] = useState("");
|
||||
const [showCustomerDropdown, setShowCustomerDropdown] = useState(false);
|
||||
const [items, setItems] = useState<BillItemForm[]>([]);
|
||||
|
||||
const { data: bills, isLoading: billsLoading } = useListBills();
|
||||
const { data: customerResults } = useSearchCustomers(customerSearch);
|
||||
const { data: products } = useListProducts();
|
||||
const { data: batches } = useListStockBatches();
|
||||
const createBillMutation = useCreateBill();
|
||||
const createCustomerMutation = useCreateCustomer();
|
||||
const utils = trpc.useUtils();
|
||||
|
||||
const custRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
function handleClickOutside(e: MouseEvent) {
|
||||
if (custRef.current && !custRef.current.contains(e.target as Node)) {
|
||||
setShowCustomerDropdown(false);
|
||||
}
|
||||
}
|
||||
document.addEventListener("mousedown", handleClickOutside);
|
||||
return () => document.removeEventListener("mousedown", handleClickOutside);
|
||||
}, []);
|
||||
|
||||
const customerSearchResults = useMemo(() => {
|
||||
if (!customerSearch) return [];
|
||||
return (customerResults ?? []).filter(
|
||||
(c) => c.mobile.includes(customerSearch) || (c.name || "").toLowerCase().includes(customerSearch.toLowerCase()),
|
||||
);
|
||||
}, [customerSearch, customerResults]);
|
||||
|
||||
function selectCustomer(c: { mobile: string; name: string | null }) {
|
||||
setCustomerMobile(c.mobile);
|
||||
setCustomerName(c.name || "");
|
||||
setShowCustomerDropdown(false);
|
||||
setCustomerSearch("");
|
||||
}
|
||||
|
||||
function selectAnonymous() {
|
||||
setCustomerMobile("Anonymous");
|
||||
setCustomerName("");
|
||||
setShowCustomerDropdown(false);
|
||||
setCustomerSearch("");
|
||||
}
|
||||
|
||||
function addCustomer() {
|
||||
const mobile = customerSearch.trim();
|
||||
if (!mobile) return;
|
||||
createCustomerMutation.mutate(
|
||||
{ mobile, name: null, added_on: new Date().toISOString().slice(0, 10) },
|
||||
{
|
||||
onSuccess: (c) => {
|
||||
setCustomerMobile(c.mobile);
|
||||
setCustomerName(c.name || "");
|
||||
setShowCustomerDropdown(false);
|
||||
setCustomerSearch("");
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function addItem(product?: (typeof products)[number]) {
|
||||
if (product) {
|
||||
const productBatches = (batches ?? []).filter((b) => b.product.id === product.id);
|
||||
const defaultBatch = productBatches.find((b) => b.is_default);
|
||||
setItems((prev) => [
|
||||
...prev,
|
||||
{
|
||||
product_id: product.id,
|
||||
product_name: product.name,
|
||||
brand: product.brand,
|
||||
batch_id: defaultBatch?.id ?? null,
|
||||
strips: product.units_per_strip ? 1 : 0,
|
||||
loose: 0,
|
||||
qty: product.units_per_strip ? product.units_per_strip * 1 : 1,
|
||||
original_price: product.selling_price,
|
||||
selling_price: product.selling_price,
|
||||
total: product.units_per_strip ? product.units_per_strip * 1 * product.selling_price : product.selling_price,
|
||||
batchOptions: productBatches.map((b) => ({
|
||||
id: b.id,
|
||||
batch_no: b.batch_no,
|
||||
quantity: b.quantity,
|
||||
is_default: b.is_default,
|
||||
})),
|
||||
},
|
||||
]);
|
||||
} else {
|
||||
setItems((prev) => [
|
||||
...prev,
|
||||
{
|
||||
product_id: 0,
|
||||
product_name: "",
|
||||
brand: "",
|
||||
batch_id: null,
|
||||
strips: 0,
|
||||
loose: 0,
|
||||
qty: 0,
|
||||
original_price: 0,
|
||||
selling_price: 0,
|
||||
total: 0,
|
||||
batchOptions: [],
|
||||
},
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
function removeItem(idx: number) {
|
||||
setItems((prev) => prev.filter((_, i) => i !== idx));
|
||||
}
|
||||
|
||||
function updateItem(idx: number, field: keyof BillItemForm, value: string | number | null) {
|
||||
setItems((prev) =>
|
||||
prev.map((item, i) => {
|
||||
if (i !== idx) return item;
|
||||
|
||||
let updated = { ...item, [field]: value };
|
||||
|
||||
if (field === "batch_id") {
|
||||
const batch = item.batchOptions.find((b) => b.id === Number(value));
|
||||
if (batch) updated.qty = updated.strips * (products?.find((p) => p.id === item.product_id)?.units_per_strip ?? 1) + updated.loose;
|
||||
}
|
||||
|
||||
if (field === "selling_price") {
|
||||
const ups = products?.find((p) => p.id === item.product_id)?.units_per_strip ?? 1;
|
||||
updated.qty = updated.strips * ups + updated.loose;
|
||||
updated.total = Number(updated.qty) * Number(updated.selling_price);
|
||||
}
|
||||
|
||||
if (field === "strips" || field === "loose") {
|
||||
const ups = products?.find((p) => p.id === item.product_id)?.units_per_strip ?? 1;
|
||||
updated.qty = Number(updated.strips) * ups + Number(updated.loose);
|
||||
updated.total = Number(updated.qty) * Number(updated.selling_price);
|
||||
}
|
||||
|
||||
return updated;
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
function applyProductSearch(idx: number, product: (typeof products)[number]) {
|
||||
const productBatches = (batches ?? []).filter((b) => b.product.id === product.id);
|
||||
const defaultBatch = productBatches.find((b) => b.is_default);
|
||||
setItems((prev) =>
|
||||
prev.map((item, i) => {
|
||||
if (i !== idx) return item;
|
||||
const ups = product.units_per_strip ?? 1;
|
||||
const strips = product.units_per_strip ? 1 : 0;
|
||||
const qty = strips * ups;
|
||||
return {
|
||||
...item,
|
||||
product_id: product.id,
|
||||
product_name: product.name,
|
||||
brand: product.brand,
|
||||
batch_id: defaultBatch?.id ?? null,
|
||||
strips,
|
||||
loose: 0,
|
||||
qty,
|
||||
original_price: product.selling_price,
|
||||
selling_price: product.selling_price,
|
||||
total: qty * product.selling_price,
|
||||
batchOptions: productBatches.map((b) => ({
|
||||
id: b.id,
|
||||
batch_no: b.batch_no,
|
||||
quantity: b.quantity,
|
||||
is_default: b.is_default,
|
||||
})),
|
||||
};
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
const subtotal = items.reduce((s, i) => s + i.total, 0);
|
||||
const tax = subtotal * 0.18;
|
||||
const total = subtotal + tax;
|
||||
|
||||
function handleGenerateBill() {
|
||||
if (!customerMobile) return;
|
||||
const validItems = items.filter((i) => i.product_id > 0 && i.qty > 0);
|
||||
if (validItems.length === 0) return;
|
||||
|
||||
createBillMutation.mutate(
|
||||
{
|
||||
bill_no: "BIL-" + Date.now().toString(36).toUpperCase(),
|
||||
customer_mobile: customerMobile,
|
||||
customer_name: customerName || null,
|
||||
subtotal,
|
||||
tax,
|
||||
tax_rate: 18,
|
||||
total,
|
||||
discount: 0,
|
||||
discount_percent: 0,
|
||||
created_at: new Date().toISOString(),
|
||||
items: validItems.map((i) => ({
|
||||
product_id: i.product_id,
|
||||
product_name: i.product_name,
|
||||
brand: i.brand || null,
|
||||
batch_id: i.batch_id,
|
||||
strips: i.strips,
|
||||
loose: i.loose,
|
||||
qty: i.qty,
|
||||
original_price: i.original_price,
|
||||
selling_price: i.selling_price,
|
||||
total: i.total,
|
||||
})),
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
utils.billing.list.invalidate();
|
||||
setCustomerMobile("");
|
||||
setCustomerName("");
|
||||
setItems([]);
|
||||
setTab("history");
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex gap-0 mb-0 border-b border-slate-200 sticky top-0 bg-white z-20 -mx-6 px-6">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setTab("new")}
|
||||
className={`px-5 py-3.5 text-sm font-medium border-b-2 transition-colors ${tab === "new" ? "text-blue-600 border-blue-600" : "text-slate-600 border-transparent hover:text-slate-900"}`}
|
||||
>
|
||||
New Bill
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setTab("history")}
|
||||
className={`px-5 py-3.5 text-sm font-medium border-b-2 transition-colors ${tab === "history" ? "text-blue-600 border-blue-600" : "text-slate-600 border-transparent hover:text-slate-900"}`}
|
||||
>
|
||||
History
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="pt-6">
|
||||
{tab === "new" && (
|
||||
<div className="max-w-[960px]">
|
||||
{/* Customer */}
|
||||
<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)] p-5 mb-5">
|
||||
<h3 className="flex items-center gap-2 text-[13px] font-semibold text-slate-600 uppercase tracking-wider mb-4">
|
||||
<ReceiptText className="w-4 h-4" />
|
||||
Customer
|
||||
</h3>
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div ref={custRef} className="relative flex-1 max-w-[320px]">
|
||||
<Input
|
||||
value={customerSearch}
|
||||
onChange={(e) => {
|
||||
setCustomerSearch(e.target.value);
|
||||
setShowCustomerDropdown(true);
|
||||
}}
|
||||
onFocus={() => setShowCustomerDropdown(true)}
|
||||
placeholder="Search by name or mobile..."
|
||||
/>
|
||||
{showCustomerDropdown && (
|
||||
<div className="absolute top-full left-0 right-0 z-30 bg-white border border-slate-200 rounded-md shadow-lg max-h-56 overflow-y-auto mt-1">
|
||||
<div
|
||||
className="px-3.5 py-2.5 text-sm text-slate-500 italic border-b border-slate-100 cursor-pointer hover:bg-slate-50"
|
||||
onClick={selectAnonymous}
|
||||
>
|
||||
Unknown / Anonymous
|
||||
</div>
|
||||
{customerSearchResults.map((c) => (
|
||||
<div
|
||||
key={c.id}
|
||||
className="px-3.5 py-2.5 text-sm border-b border-slate-100 cursor-pointer hover:bg-blue-50"
|
||||
onClick={() => selectCustomer(c)}
|
||||
>
|
||||
<span className="font-medium">{c.name || c.mobile}</span>
|
||||
{c.name && <span className="text-slate-500 ml-2">{c.mobile}</span>}
|
||||
</div>
|
||||
))}
|
||||
{customerSearch && (
|
||||
<div
|
||||
className="px-3.5 py-2.5 text-sm text-cyan-600 font-semibold border-t border-slate-200 cursor-pointer hover:bg-cyan-50 flex items-center gap-2"
|
||||
onClick={addCustomer}
|
||||
>
|
||||
<Plus className="w-[18px] h-[18px]" />
|
||||
Add New Customer
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{customerMobile && (
|
||||
<span className="text-sm text-emerald-600 font-medium">
|
||||
{customerName || customerMobile}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Items */}
|
||||
<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)] p-5 mb-5">
|
||||
<h3 className="flex items-center gap-2 text-[13px] font-semibold text-slate-600 uppercase tracking-wider mb-4">
|
||||
<ReceiptText className="w-4 h-4" />
|
||||
Items
|
||||
</h3>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="text-[11px] font-semibold text-slate-600 uppercase tracking-wider border-b border-slate-200">
|
||||
<th className="text-left pb-2 px-2">Product</th>
|
||||
<th className="text-left pb-2 px-2">Batch</th>
|
||||
<th className="text-center pb-2 px-2 w-[70px]">Strips</th>
|
||||
<th className="text-center pb-2 px-2 w-[70px]">Loose</th>
|
||||
<th className="text-right pb-2 px-2 w-[110px]">Price</th>
|
||||
<th className="text-right pb-2 px-2">Total</th>
|
||||
<th className="pb-2 px-2 w-[36px]" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{items.map((item, idx) => (
|
||||
<tr key={idx} className="border-b border-slate-100">
|
||||
<td className="py-2 px-2 relative" style={{ minWidth: 180 }}>
|
||||
{item.product_id > 0 ? (
|
||||
<div>
|
||||
<div className="font-semibold text-[13px]">{item.product_name}</div>
|
||||
<div className="text-xs text-slate-500">{item.brand}</div>
|
||||
</div>
|
||||
) : (
|
||||
<Combobox
|
||||
value=""
|
||||
onChange={(val) => {
|
||||
const product = (products ?? []).find((p) => String(p.id) === val);
|
||||
if (product) applyProductSearch(idx, product);
|
||||
}}
|
||||
options={(products ?? []).map((p) => ({
|
||||
value: String(p.id),
|
||||
label: `${p.name} — ${p.brand} — ₹${p.selling_price.toFixed(2)}`,
|
||||
}))}
|
||||
placeholder="Search product..."
|
||||
emptyMessage="No products found."
|
||||
/>
|
||||
)}
|
||||
</td>
|
||||
<td className="py-2 px-2">
|
||||
{item.product_id > 0 && (
|
||||
<select
|
||||
value={item.batch_id ?? ""}
|
||||
onChange={(e) => updateItem(idx, "batch_id", Number(e.target.value) || null)}
|
||||
className="w-full px-2 py-1.5 border border-slate-200 rounded text-xs bg-slate-50"
|
||||
>
|
||||
{item.batchOptions.map((b) => (
|
||||
<option key={b.id} value={b.id}>
|
||||
{b.batch_no}{b.is_default ? " ★" : ""} (qty: {b.quantity})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
</td>
|
||||
<td className="py-2 px-2">
|
||||
<input
|
||||
type="number"
|
||||
value={item.strips}
|
||||
onChange={(e) => updateItem(idx, "strips", Number(e.target.value))}
|
||||
className="w-full px-2 py-1.5 border border-slate-200 rounded text-xs text-center"
|
||||
min={0}
|
||||
disabled={!products?.find((p) => p.id === item.product_id)?.units_per_strip}
|
||||
/>
|
||||
</td>
|
||||
<td className="py-2 px-2">
|
||||
<input
|
||||
type="number"
|
||||
value={item.loose}
|
||||
onChange={(e) => updateItem(idx, "loose", Number(e.target.value))}
|
||||
className="w-full px-2 py-1.5 border border-slate-200 rounded text-xs text-center"
|
||||
min={0}
|
||||
/>
|
||||
</td>
|
||||
<td className="py-2 px-2">
|
||||
<input
|
||||
type="number"
|
||||
value={item.selling_price}
|
||||
onChange={(e) => updateItem(idx, "selling_price", Number(e.target.value))}
|
||||
className="w-full px-2 py-1.5 border border-slate-200 rounded text-xs text-right"
|
||||
min={0}
|
||||
step={0.01}
|
||||
/>
|
||||
</td>
|
||||
<td className="py-2 px-2 text-right font-semibold">₹{item.total.toFixed(2)}</td>
|
||||
<td className="py-2 px-2 text-center">
|
||||
<button type="button" onClick={() => removeItem(idx)} className="w-[30px] h-[30px] flex items-center justify-center rounded text-red-600 hover:bg-red-50">
|
||||
<Trash2 className="w-[15px] h-[15px]" />
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<Button variant="ghost-blue" size="sm" className="mt-3" onClick={() => addItem()}>
|
||||
<Plus className="w-3.5 h-3.5" />
|
||||
Add Item
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Summary */}
|
||||
<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)] p-5">
|
||||
<h3 className="flex items-center gap-2 text-[13px] font-semibold text-slate-600 uppercase tracking-wider mb-4">
|
||||
Bill Summary
|
||||
</h3>
|
||||
<div className="max-w-[360px] ml-auto">
|
||||
<div className="flex justify-between py-1.5 text-sm">
|
||||
<span className="text-slate-600">Subtotal</span>
|
||||
<span className="font-semibold">₹{subtotal.toFixed(2)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between py-1.5 text-[13px] text-slate-500">
|
||||
<span>Tax (18% GST)</span>
|
||||
<span>₹{tax.toFixed(2)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between py-1.5 text-lg font-bold border-t-2 border-slate-900 mt-1.5 pt-2.5">
|
||||
<span>Total</span>
|
||||
<span className="text-blue-600">₹{total.toFixed(2)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right mt-5">
|
||||
<Button
|
||||
variant="primary"
|
||||
disabled={!customerMobile || items.filter((i) => i.product_id > 0 && i.qty > 0).length === 0 || createBillMutation.isPending}
|
||||
onClick={handleGenerateBill}
|
||||
>
|
||||
{createBillMutation.isPending ? "Generating..." : "Generate Bill"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{tab === "history" && (
|
||||
<div>
|
||||
{billsLoading && <div className="text-sm text-slate-600 py-8">Loading bills...</div>}
|
||||
{(bills ?? []).length === 0 && !billsLoading && (
|
||||
<div className="text-center py-16 text-slate-500">
|
||||
<ReceiptText className="w-14 h-14 mx-auto mb-4 opacity-30" />
|
||||
<h3 className="text-base font-semibold text-slate-900 mb-1.5">No bills yet</h3>
|
||||
<p className="text-sm">Generate your first bill to see it here.</p>
|
||||
</div>
|
||||
)}
|
||||
<div className="space-y-3">
|
||||
{(bills ?? []).map((bill) => {
|
||||
const d = new Date(bill.created_at);
|
||||
const dateStr = d.toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric", hour: "2-digit", minute: "2-digit" });
|
||||
return (
|
||||
<Link
|
||||
key={bill.id}
|
||||
to="/billing/$id"
|
||||
params={{ id: bill.id.toString() }}
|
||||
className="flex items-center justify-between gap-4 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-4 hover:shadow-lg transition-shadow cursor-pointer"
|
||||
>
|
||||
<div>
|
||||
<div className="text-[13px] font-semibold text-blue-600">{bill.bill_no}</div>
|
||||
<div className="text-sm mt-1">{bill.customer_name || bill.customer_mobile}</div>
|
||||
<div className="text-xs text-slate-500 mt-0.5">{dateStr}</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className="text-base font-bold text-emerald-600">₹{bill.total.toFixed(2)}</div>
|
||||
<div className="text-xs text-slate-500">{bill.items.length} item{bill.items.length > 1 ? "s" : ""}</div>
|
||||
<span className="inline-block mt-1 px-2 py-0.5 rounded-full text-[11px] font-semibold bg-emerald-50 text-emerald-600">Completed</span>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,13 +1,5 @@
|
|||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { Outlet, createFileRoute } from "@tanstack/react-router";
|
||||
|
||||
export const Route = createFileRoute("/customers")({
|
||||
component: CustomersPage,
|
||||
staticData: {
|
||||
title: "Customers",
|
||||
subtitle: "Customer management with purchase history",
|
||||
},
|
||||
component: () => <Outlet />,
|
||||
});
|
||||
|
||||
function CustomersPage() {
|
||||
return <div>Customers</div>;
|
||||
}
|
||||
|
|
|
|||
60
apps/pharmanager/src/routes/customers/$id.tsx
Normal file
60
apps/pharmanager/src/routes/customers/$id.tsx
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { Pencil, Trash2, Users, Phone, Calendar } from "lucide-react";
|
||||
import { Button, DetailRow, BackLink, EmptyState } from "#/components/ui";
|
||||
import { useGetCustomerById, useRemoveCustomer, trpc } from "shared-react";
|
||||
|
||||
function fmtDate(d: string): string {
|
||||
const date = new Date(d + "T00:00:00");
|
||||
return date.toLocaleDateString("en-US", { day: "numeric", month: "short", year: "numeric" });
|
||||
}
|
||||
|
||||
export const Route = createFileRoute("/customers/$id")({
|
||||
component: CustomerDetailsPage,
|
||||
staticData: { title: "Customer Details", subtitle: "Customer information" },
|
||||
});
|
||||
|
||||
function CustomerDetailsPage() {
|
||||
const { id } = Route.useParams();
|
||||
const customerId = Number(id);
|
||||
const { data: customer, isLoading, error } = useGetCustomerById(customerId);
|
||||
const removeMutation = useRemoveCustomer();
|
||||
const utils = trpc.useUtils();
|
||||
|
||||
function handleDelete() {
|
||||
if (!customer) return;
|
||||
if (!confirm(`Delete ${customer.name || customer.mobile}?`)) return;
|
||||
removeMutation.mutate({ id: customer.id }, { onSuccess: () => { utils.customers.list.invalidate(); window.location.href = "/customers"; } });
|
||||
}
|
||||
|
||||
if (isLoading) return <div className="text-sm text-slate-600 py-8">Loading...</div>;
|
||||
if (error || !customer) return <EmptyState icon={Users} title="Customer not found" description="The customer you're looking for doesn't exist." actionLabel="Back to Customers" actionTo="/customers" />;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<BackLink to="/customers" label="Customers" />
|
||||
<div className="flex items-center gap-5 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-6 mb-5 max-w-[800px]">
|
||||
<div className="w-16 h-16 rounded-full bg-blue-600 flex items-center justify-center text-white font-semibold text-[22px] shrink-0">
|
||||
{(customer.name || customer.mobile)[0].toUpperCase()}
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h2 className="text-xl font-semibold text-slate-900">{customer.name || "Unnamed"}</h2>
|
||||
<p className="text-sm text-slate-500 mt-0.5">{customer.mobile}</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="primary"><Pencil className="w-[15px] h-[15px]" />Edit</Button>
|
||||
<Button variant="danger" onClick={handleDelete}><Trash2 className="w-[15px] h-[15px]" />Delete</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4 max-w-[800px]">
|
||||
<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)] p-5">
|
||||
<h3 className="flex items-center gap-1.5 text-xs font-semibold text-slate-600 uppercase tracking-wider mb-3"><Phone className="w-[14px] h-[14px]" />Contact</h3>
|
||||
<DetailRow label="Mobile" value={customer.mobile} last />
|
||||
</div>
|
||||
<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)] p-5">
|
||||
<h3 className="flex items-center gap-1.5 text-xs font-semibold text-slate-600 uppercase tracking-wider mb-3"><Calendar className="w-[14px] h-[14px]" />Details</h3>
|
||||
<DetailRow label="Registered" value={fmtDate(customer.added_on)} last />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
89
apps/pharmanager/src/routes/customers/add.tsx
Normal file
89
apps/pharmanager/src/routes/customers/add.tsx
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
import { useEffect } from "react";
|
||||
import { createFileRoute, Link, useNavigate } from "@tanstack/react-router";
|
||||
import { Plus } from "lucide-react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { z } from "zod";
|
||||
import { useCreateCustomer, useUpdateCustomer, useGetCustomerById, trpc } from "shared-react";
|
||||
import { Button, Input, BackLink, buttonVariants } from "#/components/ui";
|
||||
|
||||
const formSchema = z.object({
|
||||
mobile: z.string().min(1, "Mobile is required"),
|
||||
name: z.string().nullable().optional(),
|
||||
added_on: z.string().min(1, "Date is required"),
|
||||
});
|
||||
|
||||
type FormValues = z.infer<typeof formSchema>;
|
||||
|
||||
export const Route = createFileRoute("/customers/add")({
|
||||
component: AddCustomerPage,
|
||||
validateSearch: (search: Record<string, unknown>) => ({ id: search.id ? Number(search.id) : undefined }),
|
||||
staticData: { title: "Add Customer", subtitle: "Register a new customer" },
|
||||
});
|
||||
|
||||
function AddCustomerPage() {
|
||||
const navigate = useNavigate();
|
||||
const { id: editId } = Route.useSearch();
|
||||
const createMutation = useCreateCustomer();
|
||||
const updateMutation = useUpdateCustomer();
|
||||
const { data: existingCustomer } = useGetCustomerById(editId ?? 0);
|
||||
const utils = trpc.useUtils();
|
||||
const isEditing = typeof editId === "number" && editId > 0;
|
||||
|
||||
const { register, handleSubmit, reset, formState: { errors } } = useForm<FormValues>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: { mobile: "", name: null, added_on: new Date().toISOString().slice(0, 10) },
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (isEditing && existingCustomer) {
|
||||
reset({ mobile: existingCustomer.mobile, name: existingCustomer.name, added_on: existingCustomer.added_on });
|
||||
}
|
||||
}, [isEditing, existingCustomer, reset]);
|
||||
|
||||
function onSubmit(values: FormValues) {
|
||||
if (isEditing) {
|
||||
updateMutation.mutate(
|
||||
{ id: editId!, ...values },
|
||||
{ onSuccess: () => { utils.customers.list.invalidate(); utils.customers.byId.invalidate({ id: editId! }); navigate({ to: "/customers" }); } },
|
||||
);
|
||||
} else {
|
||||
createMutation.mutate(values, { onSuccess: () => { utils.customers.list.invalidate(); navigate({ to: "/customers" }); } });
|
||||
}
|
||||
}
|
||||
|
||||
const mutation = isEditing ? updateMutation : createMutation;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<BackLink to="/customers" label="Customers" />
|
||||
<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-xl">
|
||||
<h2 className="text-xl font-semibold mb-1">{isEditing ? "Edit Customer" : "New Customer"}</h2>
|
||||
<p className="text-sm text-slate-600 mb-7">{isEditing ? "Update customer details." : "Register a new customer."}</p>
|
||||
<div className="space-y-5">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-900 mb-1.5">Mobile <span className="text-red-600">*</span></label>
|
||||
<Input {...register("mobile")} variant={errors.mobile ? "error" : "default"} placeholder="9876543210" />
|
||||
{errors.mobile && <p className="text-sm text-red-600 mt-1">{errors.mobile.message}</p>}
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-900 mb-1.5">Name</label>
|
||||
<Input {...register("name", { setValueAs: (v: string) => v || null })} placeholder="John Doe" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-900 mb-1.5">Registered Date <span className="text-red-600">*</span></label>
|
||||
<Input type="date" {...register("added_on")} variant={errors.added_on ? "error" : "default"} />
|
||||
{errors.added_on && <p className="text-sm text-red-600 mt-1">{errors.added_on.message}</p>}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end gap-3 pt-5 mt-5 border-t border-slate-200">
|
||||
<Link to="/customers" className={buttonVariants({ variant: "outline" })}>Cancel</Link>
|
||||
<Button type="submit" disabled={mutation.isPending}>
|
||||
<Plus className="w-[15px] h-[15px]" />{mutation.isPending ? "Saving..." : isEditing ? "Update" : "Save"}
|
||||
</Button>
|
||||
</div>
|
||||
{mutation.error && <p className="text-sm text-red-600 mt-4">Failed to {isEditing ? "update" : "create"} customer.</p>}
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
114
apps/pharmanager/src/routes/customers/index.tsx
Normal file
114
apps/pharmanager/src/routes/customers/index.tsx
Normal file
|
|
@ -0,0 +1,114 @@
|
|||
import { useState, useMemo, useCallback } from "react";
|
||||
import { createFileRoute, Link } from "@tanstack/react-router";
|
||||
import { Pencil, Trash2, Users } from "lucide-react";
|
||||
import { GridTable } from "#/components/GridTable";
|
||||
import type { GridTableColumn } from "#/components/GridTable";
|
||||
import { Button, SearchToolbar } from "#/components/ui";
|
||||
import { useListCustomers, useRemoveCustomer, trpc } from "shared-react";
|
||||
|
||||
interface CustomerRow {
|
||||
id: number;
|
||||
mobile: string;
|
||||
name: string | null;
|
||||
added_on: string;
|
||||
}
|
||||
|
||||
function fmtDate(d: string): string {
|
||||
const date = new Date(d + "T00:00:00");
|
||||
return date.toLocaleDateString("en-US", { day: "numeric", month: "short", year: "numeric" });
|
||||
}
|
||||
|
||||
function makeColumns(onDelete: (row: CustomerRow) => void): GridTableColumn<CustomerRow>[] {
|
||||
return [
|
||||
{
|
||||
id: "name",
|
||||
header: "Name",
|
||||
cell: ({ row }) => (
|
||||
<Link to="/customers/$id" params={{ id: row.id.toString() }} className="font-semibold text-blue-600 hover:underline">
|
||||
{row.name || "—"}
|
||||
</Link>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "mobile",
|
||||
header: "Mobile",
|
||||
cell: ({ row }) => <span className="text-sm text-slate-600 font-mono">{row.mobile}</span>,
|
||||
},
|
||||
{
|
||||
id: "added_on",
|
||||
header: "Registered",
|
||||
cell: ({ row }) => <span className="text-sm text-slate-600 whitespace-nowrap">{fmtDate(row.added_on)}</span>,
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
header: "Actions",
|
||||
size: 90,
|
||||
cell: ({ row }) => (
|
||||
<div className="flex items-center justify-center gap-1">
|
||||
<Link to="/customers/add" search={{ id: row.id }}>
|
||||
<Button variant="ghost-blue" size="icon" aria-label={`Edit ${row.name || row.mobile}`} type="button">
|
||||
<Pencil className="w-[15px] h-[15px]" />
|
||||
</Button>
|
||||
</Link>
|
||||
<Button variant="ghost-red" size="icon" aria-label={`Delete ${row.name || row.mobile}`} onClick={() => onDelete(row)}>
|
||||
<Trash2 className="w-[15px] h-[15px]" />
|
||||
</Button>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
export const Route = createFileRoute("/customers/")({
|
||||
component: CustomersIndexPage,
|
||||
staticData: {
|
||||
title: "Customers",
|
||||
subtitle: "Customer management with purchase history",
|
||||
},
|
||||
});
|
||||
|
||||
function CustomersIndexPage() {
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const { data: customerList, isLoading, error } = useListCustomers();
|
||||
const removeMutation = useRemoveCustomer();
|
||||
const utils = trpc.useUtils();
|
||||
|
||||
const handleDelete = useCallback(
|
||||
(row: CustomerRow) => {
|
||||
if (!confirm(`Delete ${row.name || row.mobile}?`)) return;
|
||||
removeMutation.mutate({ id: row.id }, { onSuccess: () => utils.customers.list.invalidate() });
|
||||
},
|
||||
[removeMutation, utils],
|
||||
);
|
||||
|
||||
const columns = useMemo(() => makeColumns(handleDelete), [handleDelete]);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const q = searchQuery.toLowerCase().trim();
|
||||
if (!q) return customerList ?? [];
|
||||
return (customerList ?? []).filter((c) => {
|
||||
const text = `${c.name || ""} ${c.mobile}`;
|
||||
return text.toLowerCase().includes(q);
|
||||
});
|
||||
}, [searchQuery, customerList]);
|
||||
|
||||
if (isLoading) return <div className="text-sm text-slate-600 py-8">Loading customers...</div>;
|
||||
if (error) return <div className="text-sm text-red-600 py-8">Failed to load customers.</div>;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<SearchToolbar value={searchQuery} onChange={setSearchQuery} placeholder="Search by name or mobile..." addLink="/customers/add" addLabel="Add Customer" />
|
||||
<GridTable
|
||||
columns={columns}
|
||||
data={filtered}
|
||||
emptyState={
|
||||
<div className="flex flex-col items-center py-16 px-6 text-slate-600">
|
||||
<Users className="w-12 h-12 mb-4 opacity-40" />
|
||||
<h3 className="text-base font-semibold text-slate-900 mb-1.5">No customers found</h3>
|
||||
<p className="text-sm">{searchQuery ? "No customers match your search." : "Add your first customer to get started."}</p>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
47
packages/data-manager-sqlite/drizzle/0007_yellow_venom.sql
Normal file
47
packages/data-manager-sqlite/drizzle/0007_yellow_venom.sql
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
CREATE TABLE `customers` (
|
||||
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
`mobile` text NOT NULL,
|
||||
`name` text,
|
||||
`added_on` text NOT NULL,
|
||||
`enterprise_id` integer NOT NULL,
|
||||
FOREIGN KEY (`enterprise_id`) REFERENCES `enterprises`(`id`) ON UPDATE no action ON DELETE no action
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX `customers_mobile_unique` ON `customers` (`mobile`);--> statement-breakpoint
|
||||
CREATE TABLE `bills` (
|
||||
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
`bill_no` text NOT NULL,
|
||||
`customer_mobile` text NOT NULL,
|
||||
`customer_name` text,
|
||||
`subtotal` real NOT NULL,
|
||||
`tax` real NOT NULL,
|
||||
`tax_rate` real DEFAULT 18 NOT NULL,
|
||||
`total` real NOT NULL,
|
||||
`discount` real DEFAULT 0 NOT NULL,
|
||||
`discount_percent` integer DEFAULT 0 NOT NULL,
|
||||
`generated_by` integer NOT NULL,
|
||||
`created_at` text NOT NULL,
|
||||
`enterprise_id` integer NOT NULL,
|
||||
FOREIGN KEY (`generated_by`) REFERENCES `staff`(`id`) ON UPDATE no action ON DELETE no action,
|
||||
FOREIGN KEY (`enterprise_id`) REFERENCES `enterprises`(`id`) ON UPDATE no action ON DELETE no action
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `bill_items` (
|
||||
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
`bill_id` integer NOT NULL,
|
||||
`product_id` integer NOT NULL,
|
||||
`batch_id` integer,
|
||||
`product_name` text NOT NULL,
|
||||
`brand` text,
|
||||
`strips` integer DEFAULT 0 NOT NULL,
|
||||
`loose` integer DEFAULT 0 NOT NULL,
|
||||
`qty` integer NOT NULL,
|
||||
`original_price` real NOT NULL,
|
||||
`selling_price` real NOT NULL,
|
||||
`total` real NOT NULL,
|
||||
`enterprise_id` integer NOT NULL,
|
||||
FOREIGN KEY (`bill_id`) REFERENCES `bills`(`id`) ON UPDATE no action ON DELETE no action,
|
||||
FOREIGN KEY (`product_id`) REFERENCES `products`(`id`) ON UPDATE no action ON DELETE no action,
|
||||
FOREIGN KEY (`batch_id`) REFERENCES `stock_batches`(`id`) ON UPDATE no action ON DELETE no action,
|
||||
FOREIGN KEY (`enterprise_id`) REFERENCES `enterprises`(`id`) ON UPDATE no action ON DELETE no action
|
||||
);
|
||||
1380
packages/data-manager-sqlite/drizzle/meta/0007_snapshot.json
Normal file
1380
packages/data-manager-sqlite/drizzle/meta/0007_snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -50,6 +50,13 @@
|
|||
"when": 1779551911536,
|
||||
"tag": "0006_nappy_sir_ram",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 7,
|
||||
"version": "6",
|
||||
"when": 1779561725701,
|
||||
"tag": "0007_yellow_venom",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
170
packages/data-manager-sqlite/src/bills.ts
Normal file
170
packages/data-manager-sqlite/src/bills.ts
Normal file
|
|
@ -0,0 +1,170 @@
|
|||
import { eq } from 'drizzle-orm'
|
||||
|
||||
import { db, sqlite } from './db-instance'
|
||||
import { bills } from './schema/bills'
|
||||
import { billItems } from './schema/billItems'
|
||||
import { stockBatches } from './schema/stockBatches'
|
||||
|
||||
export type BillItem = {
|
||||
id: number
|
||||
product_id: number
|
||||
product_name: string
|
||||
brand: string | null
|
||||
batch_id: number | null
|
||||
strips: number
|
||||
loose: number
|
||||
qty: number
|
||||
original_price: number
|
||||
selling_price: number
|
||||
total: number
|
||||
}
|
||||
|
||||
export type Bill = {
|
||||
id: number
|
||||
bill_no: string
|
||||
customer_mobile: string
|
||||
customer_name: string | null
|
||||
subtotal: number
|
||||
tax: number
|
||||
tax_rate: number
|
||||
total: number
|
||||
discount: number
|
||||
discount_percent: number
|
||||
generated_by: number
|
||||
created_at: string
|
||||
items: BillItem[]
|
||||
}
|
||||
|
||||
export type BillsRepo = {
|
||||
getBills: (enterpriseId: number) => Promise<Bill[]>
|
||||
getBillById: (id: number, enterpriseId: number) => Promise<Bill | null>
|
||||
createBill: (input: {
|
||||
bill_no: string
|
||||
customer_mobile: string
|
||||
customer_name?: string | null
|
||||
subtotal: number
|
||||
tax: number
|
||||
tax_rate: number
|
||||
total: number
|
||||
discount: number
|
||||
discount_percent: number
|
||||
generated_by: number
|
||||
created_at: string
|
||||
items: {
|
||||
product_id: number
|
||||
product_name: string
|
||||
brand?: string | null
|
||||
batch_id?: number | null
|
||||
strips: number
|
||||
loose: number
|
||||
qty: number
|
||||
original_price: number
|
||||
selling_price: number
|
||||
total: number
|
||||
}[]
|
||||
}, enterpriseId: number) => Promise<Bill>
|
||||
}
|
||||
|
||||
function toBill(row: typeof bills.$inferSelect): Omit<Bill, 'items'> {
|
||||
return {
|
||||
id: row.id,
|
||||
bill_no: row.billNo,
|
||||
customer_mobile: row.customerMobile,
|
||||
customer_name: row.customerName,
|
||||
subtotal: row.subtotal,
|
||||
tax: row.tax,
|
||||
tax_rate: row.taxRate,
|
||||
total: row.total,
|
||||
discount: row.discount,
|
||||
discount_percent: row.discountPercent,
|
||||
generated_by: row.generatedBy,
|
||||
created_at: row.createdAt,
|
||||
}
|
||||
}
|
||||
|
||||
function getItemsForBill(billId: number): BillItem[] {
|
||||
const rows = db.select().from(billItems).where(eq(billItems.billId, billId)).all()
|
||||
return rows.map((r) => ({
|
||||
id: r.id,
|
||||
product_id: r.productId,
|
||||
product_name: r.productName,
|
||||
brand: r.brand,
|
||||
batch_id: r.batchId,
|
||||
strips: r.strips,
|
||||
loose: r.loose,
|
||||
qty: r.qty,
|
||||
original_price: r.originalPrice,
|
||||
selling_price: r.sellingPrice,
|
||||
total: r.total,
|
||||
}))
|
||||
}
|
||||
|
||||
function deductStock(batchId: number | null, qty: number) {
|
||||
if (!batchId) return
|
||||
const batch = db.select().from(stockBatches).where(eq(stockBatches.id, batchId)).get()
|
||||
if (batch && batch.quantity >= qty) {
|
||||
db.update(stockBatches)
|
||||
.set({ quantity: batch.quantity - qty })
|
||||
.where(eq(stockBatches.id, batchId))
|
||||
.run()
|
||||
}
|
||||
}
|
||||
|
||||
export function createBillsRepo(): { repo: BillsRepo } {
|
||||
const repo: BillsRepo = {
|
||||
getBills(enterpriseId) {
|
||||
const rows = db.select().from(bills).where(eq(bills.enterpriseId, enterpriseId)).all()
|
||||
return Promise.resolve(rows.map((r) => ({ ...toBill(r), items: getItemsForBill(r.id) })))
|
||||
},
|
||||
|
||||
getBillById(id, enterpriseId) {
|
||||
const row = db.select().from(bills).where(eq(bills.id, id)).get()
|
||||
if (!row || row.enterpriseId !== enterpriseId) return Promise.resolve(null)
|
||||
return Promise.resolve({ ...toBill(row), items: getItemsForBill(row.id) })
|
||||
},
|
||||
|
||||
createBill(input, enterpriseId) {
|
||||
const result = sqlite.transaction(() => {
|
||||
const created = db.insert(bills).values({
|
||||
billNo: input.bill_no,
|
||||
customerMobile: input.customer_mobile,
|
||||
customerName: input.customer_name ?? null,
|
||||
subtotal: input.subtotal,
|
||||
tax: input.tax,
|
||||
taxRate: input.tax_rate,
|
||||
total: input.total,
|
||||
discount: input.discount,
|
||||
discountPercent: input.discount_percent,
|
||||
generatedBy: input.generated_by,
|
||||
createdAt: input.created_at,
|
||||
enterpriseId,
|
||||
}).returning().get()
|
||||
|
||||
for (const item of input.items) {
|
||||
db.insert(billItems).values({
|
||||
billId: created.id,
|
||||
productId: item.product_id,
|
||||
productName: item.product_name,
|
||||
brand: item.brand ?? null,
|
||||
batchId: item.batch_id ?? null,
|
||||
strips: item.strips,
|
||||
loose: item.loose,
|
||||
qty: item.qty,
|
||||
originalPrice: item.original_price,
|
||||
sellingPrice: item.selling_price,
|
||||
total: item.total,
|
||||
enterpriseId,
|
||||
}).run()
|
||||
|
||||
deductStock(item.batch_id ?? null, item.qty)
|
||||
}
|
||||
|
||||
return created
|
||||
})()
|
||||
|
||||
return Promise.resolve({ ...toBill(result), items: getItemsForBill(result.id) })
|
||||
},
|
||||
}
|
||||
|
||||
return { repo }
|
||||
}
|
||||
99
packages/data-manager-sqlite/src/customers.ts
Normal file
99
packages/data-manager-sqlite/src/customers.ts
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
import { eq, or, like } from 'drizzle-orm'
|
||||
|
||||
import { db } from './db-instance'
|
||||
import { customers } from './schema/customers'
|
||||
|
||||
export type Customer = {
|
||||
id: number
|
||||
mobile: string
|
||||
name: string | null
|
||||
added_on: string
|
||||
}
|
||||
|
||||
export type CustomersRepo = {
|
||||
listCustomers: (enterpriseId: number) => Promise<Customer[]>
|
||||
searchCustomers: (query: string, enterpriseId: number) => Promise<Customer[]>
|
||||
getCustomerById: (id: number, enterpriseId: number) => Promise<Customer | null>
|
||||
getCustomerByMobile: (mobile: string, enterpriseId: number) => Promise<Customer | null>
|
||||
createCustomer: (input: { mobile: string; name?: string | null; added_on: string }, enterpriseId: number) => Promise<Customer>
|
||||
updateCustomer: (id: number, input: { mobile?: string; name?: string | null }, enterpriseId: number) => Promise<Customer | null>
|
||||
deleteCustomer: (id: number, enterpriseId: number) => Promise<boolean>
|
||||
}
|
||||
|
||||
function toCustomer(row: typeof customers.$inferSelect): Customer {
|
||||
return { id: row.id, mobile: row.mobile, name: row.name, added_on: row.addedOn }
|
||||
}
|
||||
|
||||
export function createCustomersRepo(): { repo: CustomersRepo } {
|
||||
const repo: CustomersRepo = {
|
||||
listCustomers(enterpriseId) {
|
||||
const rows = db.select().from(customers).where(eq(customers.enterpriseId, enterpriseId)).all()
|
||||
return Promise.resolve(rows.map(toCustomer))
|
||||
},
|
||||
|
||||
searchCustomers(query, enterpriseId) {
|
||||
const q = `%${query}%`
|
||||
const rows = db
|
||||
.select()
|
||||
.from(customers)
|
||||
.where(
|
||||
or(
|
||||
like(customers.mobile, q),
|
||||
like(customers.name, q),
|
||||
),
|
||||
)
|
||||
.all()
|
||||
return Promise.resolve(rows.filter(r => r.enterpriseId === enterpriseId).map(toCustomer))
|
||||
},
|
||||
|
||||
getCustomerById(id, enterpriseId) {
|
||||
const row = db.select().from(customers).where(eq(customers.id, id)).get()
|
||||
if (!row || row.enterpriseId !== enterpriseId) return Promise.resolve(null)
|
||||
return Promise.resolve(toCustomer(row))
|
||||
},
|
||||
|
||||
getCustomerByMobile(mobile, enterpriseId) {
|
||||
const row = db
|
||||
.select()
|
||||
.from(customers)
|
||||
.where(eq(customers.mobile, mobile))
|
||||
.get()
|
||||
if (!row || row.enterpriseId !== enterpriseId) return Promise.resolve(null)
|
||||
return Promise.resolve(toCustomer(row))
|
||||
},
|
||||
|
||||
createCustomer(input, enterpriseId) {
|
||||
const created = db.insert(customers).values({
|
||||
mobile: input.mobile,
|
||||
name: input.name ?? null,
|
||||
addedOn: input.added_on,
|
||||
enterpriseId,
|
||||
}).returning().get()
|
||||
return Promise.resolve(toCustomer(created))
|
||||
},
|
||||
|
||||
updateCustomer(id, input, enterpriseId) {
|
||||
const existing = db.select().from(customers).where(eq(customers.id, id)).get()
|
||||
if (!existing || existing.enterpriseId !== enterpriseId) return Promise.resolve(null)
|
||||
|
||||
const setData: Record<string, unknown> = {}
|
||||
if (input.mobile !== undefined) setData.mobile = input.mobile
|
||||
if (input.name !== undefined) setData.name = input.name ?? null
|
||||
|
||||
if (Object.keys(setData).length > 0) {
|
||||
db.update(customers).set(setData).where(eq(customers.id, id)).run()
|
||||
}
|
||||
const updated = db.select().from(customers).where(eq(customers.id, id)).get()!
|
||||
return Promise.resolve(toCustomer(updated))
|
||||
},
|
||||
|
||||
deleteCustomer(id, enterpriseId) {
|
||||
const existing = db.select().from(customers).where(eq(customers.id, id)).get()
|
||||
if (!existing || existing.enterpriseId !== enterpriseId) return Promise.resolve(false)
|
||||
db.delete(customers).where(eq(customers.id, id)).run()
|
||||
return Promise.resolve(true)
|
||||
},
|
||||
}
|
||||
|
||||
return { repo }
|
||||
}
|
||||
|
|
@ -77,3 +77,17 @@ export {
|
|||
type StaffRole,
|
||||
type StaffRolesRepo,
|
||||
} from './staffRoles'
|
||||
export {
|
||||
createCustomersRepo,
|
||||
type Customer,
|
||||
type CustomersRepo,
|
||||
} from './customers'
|
||||
export {
|
||||
createBillsRepo,
|
||||
type Bill,
|
||||
type BillItem,
|
||||
type BillsRepo,
|
||||
} from './bills'
|
||||
export { customers } from './schema/customers'
|
||||
export { bills } from './schema/bills'
|
||||
export { billItems } from './schema/billItems'
|
||||
|
|
|
|||
21
packages/data-manager-sqlite/src/schema/billItems.ts
Normal file
21
packages/data-manager-sqlite/src/schema/billItems.ts
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
import { integer, sqliteTable, text, real } from 'drizzle-orm/sqlite-core'
|
||||
import { bills } from './bills'
|
||||
import { products } from './products'
|
||||
import { stockBatches } from './stockBatches'
|
||||
import { enterprises } from './enterprises'
|
||||
|
||||
export const billItems = sqliteTable('bill_items', {
|
||||
id: integer('id').primaryKey({ autoIncrement: true }),
|
||||
billId: integer('bill_id').notNull().references(() => bills.id),
|
||||
productId: integer('product_id').notNull().references(() => products.id),
|
||||
batchId: integer('batch_id').references(() => stockBatches.id),
|
||||
productName: text('product_name').notNull(),
|
||||
brand: text('brand'),
|
||||
strips: integer('strips').notNull().default(0),
|
||||
loose: integer('loose').notNull().default(0),
|
||||
qty: integer('qty').notNull(),
|
||||
originalPrice: real('original_price').notNull(),
|
||||
sellingPrice: real('selling_price').notNull(),
|
||||
total: real('total').notNull(),
|
||||
enterpriseId: integer('enterprise_id').notNull().references(() => enterprises.id),
|
||||
})
|
||||
19
packages/data-manager-sqlite/src/schema/bills.ts
Normal file
19
packages/data-manager-sqlite/src/schema/bills.ts
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
import { integer, sqliteTable, text, real } from 'drizzle-orm/sqlite-core'
|
||||
import { staff } from './staff'
|
||||
import { enterprises } from './enterprises'
|
||||
|
||||
export const bills = sqliteTable('bills', {
|
||||
id: integer('id').primaryKey({ autoIncrement: true }),
|
||||
billNo: text('bill_no').notNull(),
|
||||
customerMobile: text('customer_mobile').notNull(),
|
||||
customerName: text('customer_name'),
|
||||
subtotal: real('subtotal').notNull(),
|
||||
tax: real('tax').notNull(),
|
||||
taxRate: real('tax_rate').notNull().default(18),
|
||||
total: real('total').notNull(),
|
||||
discount: real('discount').notNull().default(0),
|
||||
discountPercent: integer('discount_percent').notNull().default(0),
|
||||
generatedBy: integer('generated_by').notNull().references(() => staff.id),
|
||||
createdAt: text('created_at').notNull(),
|
||||
enterpriseId: integer('enterprise_id').notNull().references(() => enterprises.id),
|
||||
})
|
||||
10
packages/data-manager-sqlite/src/schema/customers.ts
Normal file
10
packages/data-manager-sqlite/src/schema/customers.ts
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
import { integer, sqliteTable, text } from 'drizzle-orm/sqlite-core'
|
||||
import { enterprises } from './enterprises'
|
||||
|
||||
export const customers = sqliteTable('customers', {
|
||||
id: integer('id').primaryKey({ autoIncrement: true }),
|
||||
mobile: text('mobile').notNull().unique(),
|
||||
name: text('name'),
|
||||
addedOn: text('added_on').notNull(),
|
||||
enterpriseId: integer('enterprise_id').notNull().references(() => enterprises.id),
|
||||
})
|
||||
|
|
@ -12,3 +12,6 @@ export * from './permissions'
|
|||
export * from './rolePermissions'
|
||||
export * from './staffRoles'
|
||||
export * from './enterpriseStaff'
|
||||
export * from './customers'
|
||||
export * from './bills'
|
||||
export * from './billItems'
|
||||
|
|
|
|||
13
packages/shared-react/src/hooks/billing.ts
Normal file
13
packages/shared-react/src/hooks/billing.ts
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
import { trpc } from "../trpc";
|
||||
|
||||
export function useListBills() {
|
||||
return trpc.billing.list.useQuery();
|
||||
}
|
||||
|
||||
export function useGetBillById(id: number) {
|
||||
return trpc.billing.byId.useQuery({ id });
|
||||
}
|
||||
|
||||
export function useCreateBill() {
|
||||
return trpc.billing.create.useMutation();
|
||||
}
|
||||
25
packages/shared-react/src/hooks/customers.ts
Normal file
25
packages/shared-react/src/hooks/customers.ts
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
import { trpc } from "../trpc";
|
||||
|
||||
export function useListCustomers() {
|
||||
return trpc.customers.list.useQuery();
|
||||
}
|
||||
|
||||
export function useSearchCustomers(query: string) {
|
||||
return trpc.customers.search.useQuery({ query }, { enabled: query.length > 0 });
|
||||
}
|
||||
|
||||
export function useGetCustomerById(id: number) {
|
||||
return trpc.customers.byId.useQuery({ id });
|
||||
}
|
||||
|
||||
export function useCreateCustomer() {
|
||||
return trpc.customers.create.useMutation();
|
||||
}
|
||||
|
||||
export function useUpdateCustomer() {
|
||||
return trpc.customers.update.useMutation();
|
||||
}
|
||||
|
||||
export function useRemoveCustomer() {
|
||||
return trpc.customers.remove.useMutation();
|
||||
}
|
||||
|
|
@ -9,6 +9,8 @@ export * from './hooks/units'
|
|||
export * from './hooks/stockBatches'
|
||||
export * from './hooks/staffManagement'
|
||||
export * from './hooks/roles'
|
||||
export * from './hooks/billing'
|
||||
export * from './hooks/customers'
|
||||
export { trpc } from './trpc'
|
||||
export { useAuthStore, useWhoAmI, useLogin } from './auth'
|
||||
export type { AuthStaff, AuthEnterprise } from './auth'
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue