507 lines
17 KiB
TypeScript
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>
|
|
);
|
|
}
|