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([]); 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(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 (
{tab === "new" && (
{/* Customer */}

Customer

{ setCustomerSearch(e.target.value); setShowCustomerDropdown(true); }} onFocus={() => setShowCustomerDropdown(true)} placeholder="Search by name or mobile..." /> {showCustomerDropdown && (
Unknown / Anonymous
{customerSearchResults.map((c) => (
selectCustomer(c)} > {c.name || c.mobile} {c.name && {c.mobile}}
))} {customerSearch && (
Add New Customer
)}
)}
{customerMobile && ( {customerName || customerMobile} )}
{/* Items */}

Items

{items.map((item, idx) => ( ))}
Product Batch Packs Loose Price Total
{item.product_id > 0 ? (
{item.product_name}
{item.brand}
) : ( { 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." /> )}
{item.product_id > 0 && ( )} 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} /> 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} /> 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} /> ₹{item.total.toFixed(2)}
{/* Summary */}

Bill Summary

Total ₹{total.toFixed(2)}
)} {tab === "history" && (
{billsLoading &&
Loading bills...
} {(bills ?? []).length === 0 && !billsLoading && (

No bills yet

Generate your first bill to see it here.

)}
{(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 (
{bill.bill_no}
{bill.customer_name || bill.customer_mobile}
{dateStr}
₹{bill.total.toFixed(2)}
{bill.items.length} item{bill.items.length > 1 ? "s" : ""}
Completed
); })}
)}
); }