health-petal/apps/pharmanager/src/routes/billing/index.tsx
2026-05-24 15:35:11 +05:30

507 lines
17 KiB
TypeScript

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;
packs: 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,
packs: 1,
loose: 0,
qty: product.loose_sale_allowed ? (product.units_per_pack ?? 1) : 1,
original_price: product.selling_price,
selling_price: product.selling_price,
total: 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,
packs: 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 };
const product = products?.find((p) => p.id === item.product_id);
const ups = product?.units_per_pack ?? 0;
const looseAllowed = product?.loose_sale_allowed ?? false;
const isQuantityField = field === "batch_id" || field === "selling_price" || field === "packs" || field === "loose";
if (isQuantityField) {
if (field === "batch_id") {
const batch = item.batchOptions.find((b) => b.id === Number(value));
if (!batch) return updated;
}
if (looseAllowed) {
updated.qty = Number(updated.packs) * ups + Number(updated.loose);
updated.total = Number(updated.packs) * Number(updated.selling_price) + Number(updated.loose) * (Number(updated.selling_price) / ups);
} else {
updated.qty = Number(updated.packs);
updated.total = Number(updated.packs) * 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 packs = 1;
const loose = 0;
const ups = product.units_per_pack ?? 0;
const qty = product.loose_sale_allowed ? ups : 1;
return {
...item,
product_id: product.id,
product_name: product.name,
brand: product.brand,
batch_id: defaultBatch?.id ?? null,
packs,
loose,
qty,
original_price: product.selling_price,
selling_price: product.selling_price,
total: product.loose_sale_allowed ? ups * 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,
})),
};
}),
);
}
const subtotal = items.reduce((s, i) => s + i.total, 0);
const total = subtotal;
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: 0,
tax_rate: 0,
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,
packs: i.packs,
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]">Packs</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.packs}
onChange={(e) => updateItem(idx, "packs", 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.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}
disabled={!products?.find((p) => p.id === item.product_id)?.loose_sale_allowed}
/>
</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-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>
);
}