billing functional

This commit is contained in:
shafi54 2026-05-24 15:12:43 +05:30
parent 302866dc58
commit 32449b45f3
25 changed files with 3009 additions and 32 deletions

View file

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

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

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

View file

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

View file

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

View file

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

View 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>
);
}

View 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>
);
}

View file

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

View 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>
);
}

View 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>
);
}

View 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>
);
}

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

File diff suppressed because it is too large Load diff

View file

@ -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
}
]
}

View 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 }
}

View 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 }
}

View file

@ -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'

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

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

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

View file

@ -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'

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

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

View file

@ -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'