function MobileProductSales({ salesUrl, token }) {
const [products, setProducts] = React.useState([]);
const [sellerProducts, setSellerProducts] = React.useState([]);
const [productNo, setProductNo] = React.useState('');
const [customerNo, setCustomerNo] = React.useState('');
const [customerName, setCustomerName] = React.useState('');
const [error, setError] = React.useState('');
const [showAlert, setShowAlert] = React.useState(false);
const [banks, setBanks] = React.useState([]);
const [selectedBank, setSelectedBank] = React.useState('select');
const [sellingBusy, setSellingBusy] = React.useState(false);
const [lastSale, setLastSale] = React.useState(null);
const [previewHtml, setPreviewHtml] = React.useState('');
const [showPreview, setShowPreview] = React.useState(false);
// state to toggle IME list per product index
const [showImes, setShowImes] = React.useState({});
// Discount state (percentage)
const [discount, setDiscount] = React.useState(0);
// GST state
const [cgst, setCgst] = React.useState(0);
const [sgst, setSgst] = React.useState(0);
const [igst, setIgst] = React.useState(0);
// Fetch branch stock products
React.useEffect(() => {
async function fetchProducts() {
try {
setError('');
const url = new URL(salesUrl + '/api/branch-stock');
url.searchParams.set('only_branch', '1');
const res = await fetch(url, { headers: { Authorization: 'Bearer ' + token } });
const data = await res.json();
if (!res.ok) throw new Error(data.message || 'Failed to load');
setProducts(Array.isArray(data.rows) ? data.rows : []);
} catch (e) {
setError(e.message);
}
}
fetchProducts();
}, [salesUrl, token]);
// show popup when error or message is set
React.useEffect(() => {
if (!error) {
setShowAlert(false);
return;
}
setShowAlert(true);
const t = setTimeout(() => {
setShowAlert(false);
}, 4000);
return () => clearTimeout(t);
}, [error]);
// Fetch banks for online payment dropdown
React.useEffect(() => {
async function loadBanks() {
try {
const url = new URL(salesUrl + '/api/banks');
const res = await fetch(url, { headers: { Authorization: 'Bearer ' + token } });
const data = await res.json();
if (res.ok && Array.isArray(data.banks)) setBanks(data.banks);
} catch (e) {
/* ignore */
}
}
loadBanks();
}, [salesUrl, token]);
function lineTotal(item) {
const qty = Number(item.sellingQty ?? item.qty ?? 0);
const unit = Number(item.sellingPrice ?? item.unitSellingPrice ?? 0);
return qty * unit;
}
const totalCount = sellerProducts.reduce((s, it) => s + Number(it.sellingQty ?? it.qty ?? 0), 0);
const subTotal = sellerProducts.reduce((s, it) => s + lineTotal(it), 0);
const discountAmount = ((Number(discount) || 0) / 100) * subTotal;
const taxableAmount = Math.max(0, subTotal - discountAmount);
// GST calculation (apply on taxableAmount i.e. after discount)
const cgstAmount = ((Number(cgst) || 0) / 100) * taxableAmount;
const sgstAmount = ((Number(sgst) || 0) / 100) * taxableAmount;
const igstAmount = ((Number(igst) || 0) / 100) * taxableAmount;
// Total calculation logic
let totalAmount = taxableAmount;
if (igst > 0) {
totalAmount += igstAmount;
} else {
totalAmount += cgstAmount + sgstAmount;
}
totalAmount = Number(totalAmount.toFixed(1));
function addByProductNo() {
const needle = (productNo || '').toString().trim().toLowerCase();
if (!needle) return;
const found = products.find((p) => String(p.productNo || '').toLowerCase() === needle);
if (!found) {
setError('Product not found');
return;
}
if (Number(found.qty) === 0) {
setError('This product has zero quantity and cannot be added to sales.');
return;
}
setError('');
setSellerProducts((sp) => {
if (sp.some((x) => (x.productId || x._id) === (found.productId || found._id))) return sp;
return [...sp, found];
});
setProductNo('');
}
async function doSell() {
try {
if (sellerProducts.length === 0) {
setError('No products to sell');
return;
}
// validate quantities before sending
const over = sellerProducts.find((it) => Number(it.sellingQty ?? it.qty ?? 0) > Number(it.qty ?? 0));
if (over) {
setError('Your qty is low');
return;
}
// validate IME selections: for products that track IMEs, selected IMEs must match selling qty
const imeMismatch = sellerProducts.find((it) => {
const sellingQty = Number(it.sellingQty ?? it.qty ?? 0);
const availableImes =
Array.isArray(it.centralOnlyImes) && it.centralOnlyImes.length
? it.centralOnlyImes
: Array.isArray(it.centralImes) && it.centralImes.length
? it.centralImes
: Array.isArray(it.imes)
? it.imes
: [];
if (!availableImes || availableImes.length === 0) return false; // not IME-tracked
const selected = Array.isArray(it.selectedImes) ? it.selectedImes.length : 0;
return selected !== sellingQty;
});
if (imeMismatch) {
setError('Selected IMEs must match selling quantity for IME-tracked products');
return;
}
if (!(customerNo || '').toString().replace(/[^0-9]/g, '')) {
setError('Customer mobile number is required');
return;
}
if (!selectedBank || selectedBank === 'select') {
setError('Select a payment method');
return;
}
setSellingBusy(true);
setError('');
const url = new URL(salesUrl + '/api/sales');
// cash option removed: payments are online via selected bank
const paymentMethod = 'online';
const payload = {
items: sellerProducts.map((it) => ({
productId: it.productId || it._id || '',
productNo: it.productNo || '',
productName: it.productName || '',
qty: Number(it.sellingQty ?? it.qty ?? 0),
sellingPrice: Number(it.sellingPrice || 0),
lineTotal: Number(lineTotal(it)),
imes:
Array.isArray(it.selectedImes) && it.selectedImes.length
? it.selectedImes
: Array.isArray(it.imes)
? it.imes
: []
})),
customerNo,
customerName: customerName || 'Walk-in Customer',
subTotal,
cgst: Number(cgst),
sgst: Number(sgst),
igst: Number(igst),
discount: Number(discount) || 0,
discountAmount: Number(discountAmount.toFixed(2)),
cgstAmount: Number(cgstAmount.toFixed(2)),
sgstAmount: Number(sgstAmount.toFixed(2)),
igstAmount: Number(igstAmount.toFixed(2)),
totalAmount: Number(totalAmount.toFixed(2)),
paymentMethod,
amountPaid: Number(totalAmount || 0),
bank_id: selectedBank && selectedBank !== 'select' ? selectedBank : ''
};
const res = await fetch(url, {
method: 'POST',
headers: { Authorization: 'Bearer ' + token, 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
const data = await res.json();
if (!res.ok) throw new Error(data.message || 'Sell failed');
const savedSale = data.sale || data || null;
setLastSale(savedSale);
// update local products: remove sold IMEs and decrement qty
try {
if (Array.isArray(savedSale?.items) && savedSale.items.length) {
setProducts((prev) => {
const next = (prev || []).map((prod) => ({ ...prod }));
for (const sold of savedSale.items) {
const idCandidates = [sold.productId, sold._id, sold.productNo].filter(Boolean).map(String);
const foundIdx = next.findIndex((p) => idCandidates.includes(String(p.productId || p._id || p.productNo)));
if (foundIdx === -1) continue;
const product = next[foundIdx];
const soldQty = Number(sold.qty ?? sold.sellingQty ?? 0);
const soldImes = Array.isArray(sold.imes) ? sold.imes : Array.isArray(sold.selectedImes) ? sold.selectedImes : [];
if (soldImes && soldImes.length) {
['centralOnlyImes', 'centralImes', 'imes'].forEach((key) => {
if (Array.isArray(product[key]) && product[key].length) {
product[key] = product[key].filter((v) => !soldImes.includes(v));
}
});
}
product.qty = Math.max(0, Number(product.qty ?? 0) - soldQty);
next[foundIdx] = product;
}
return next;
});
}
} catch (e) {
/* ignore */
}
setSellerProducts([]);
setCustomerNo('');
setCustomerName('');
setSelectedBank('select');
setCgst(0);
setSgst(0);
setIgst(0);
setDiscount(0);
setError('Sale saved');
} catch (e) {
setError(e.message || 'Sell failed');
} finally {
setSellingBusy(false);
}
}
// decode JWT payload safely (no verification) to read branch/shop info
function decodeJwt(tk) {
try {
const theToken = tk || token || localStorage.getItem('branch_token') || localStorage.getItem('sales_token') || '';
const parts = theToken.split('.');
if (parts.length < 2) return {};
const base64 = parts[1].replace(/-/g, '+').replace(/_/g, '/');
const json = decodeURIComponent(
atob(base64)
.split('')
.map(function (c) {
return '%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2);
})
.join('')
);
return JSON.parse(json);
} catch (e) {
return {};
}
}
async function printSale() {
try {
const sale = lastSale || { items: sellerProducts, totalAmount, customerNo, createdAt: new Date().toISOString() };
// fetch branch info
let shopName = '';
let shopContact = '';
let shopGst = '';
let shopAddress = '';
try {
const res = await fetch(new URL(salesUrl + '/api/branches'), { headers: { Authorization: 'Bearer ' + token } });
const data = await res.json();
if (res.ok && Array.isArray(data.branches) && data.branches.length > 0) {
const payload = decodeJwt();
const branchId = payload?.branch_id || payload?._id || '';
let found = null;
if (branchId) found = data.branches.find((b) => String(b._id) === String(branchId));
if (!found) found = data.branches[0];
shopName = found?.name || '';
shopContact = found?.phoneNumber || found?.phone || '';
shopGst = found?.gstNo || found?.gst || '';
shopAddress = found?.address || found?.branchAddress || '';
}
} catch (e) {
/* ignore */
}
if (!shopName || !shopContact) {
const payload = decodeJwt();
shopName = shopName || payload.shopName || payload.name || payload.branchName || '';
shopContact = shopContact || payload.phone || payload.phoneNumber || payload.branchPhone || '';
shopGst = shopGst || payload.gstNo || payload.gst || '';
shopAddress = shopAddress || payload.address || payload.branchAddress || '';
}
// fetch branch stock to resolve prices
let stock = [];
try {
const sres = await fetch(new URL(salesUrl + '/api/branch-stock?only_branch=1'), { headers: { Authorization: 'Bearer ' + token } });
const sdata = await sres.json();
if (sres.ok && Array.isArray(sdata.rows)) stock = sdata.rows;
} catch (e) {
/* ignore */
}
// GST details - declare before items mapping
const cgstPercent = sale.cgst || cgst;
const sgstPercent = sale.sgst || sgst;
const igstPercent = sale.igst || igst;
const cgstAmt = sale.cgstAmount ?? cgstAmount;
const sgstAmt = sale.sgstAmount ?? sgstAmount;
const igstAmt = sale.igstAmount ?? igstAmount;
const items = (sale.items || [])
.map((i, idx) => {
const found = (stock || []).find(
(p) =>
(String(p._id) && String(p._id) === String(i.productId || i._id)) ||
(p.productId && String(p.productId) === String(i.productId)) ||
(p.productNo && i.productNo && String(p.productNo) === String(i.productNo))
);
const name = found?.productName || found?.name || i.productName || i.productNo || '';
const brand = found?.brand || '';
const model = found?.model || '';
// Extract IMEI numbers from the item - prioritize selectedImes (actually sold)
const imes = Array.isArray(i.selectedImes) && i.selectedImes.length > 0 ? i.selectedImes : Array.isArray(i.imes) ? i.imes : [];
const imeiText = imes.length > 0 ? imes.map((imei) => `IMEI: ${imei}`).join(', ') : '';
// Build product description with brand/model
let productDescription = `${name}`;
if (brand || model) {
productDescription += `
${brand} ${model}`.trim() + ``;
}
if (imeiText) {
productDescription += `
${imeiText}`;
}
const qty = Number(i.qty || i.sellingQty || 0);
const unit = Number(found?.sellingPrice ?? found?.unitSellingPrice ?? i.sellingPrice ?? 0).toFixed(2);
const line = (qty * Number(unit)).toFixed(2);
const taxPerUnit = igstPercent > 0 ? (Number(igstAmt) / Math.max(1, qty)).toFixed(2) : ((Number(cgstAmt) + Number(sgstAmt)) / Math.max(1, qty)).toFixed(2);
return `
| ${idx + 1} |
${productDescription} |
${qty} |
Rs. ${unit} |
Rs. ${taxPerUnit} |
Rs. ${line} |
`;
})
.join('');
const outSubTotal = Number((sale.subTotal ?? subTotal) || 0);
const outDiscount = Number((sale.discountAmount ?? discountAmount) || 0);
const outTotal = Number(sale.totalAmount ?? totalAmount).toFixed(2);
const invoiceHtml = `TAX INVOICE
| Sr. No. |
Items |
Quantity |
Price / Unit |
Tax / Unit |
Amount |
${items}
Notes
- No return deal
- Warranty as per manufacturer terms
Terms & Conditions
- Customer will pay the GST
- Payment due within 15 days
Authorised Signatory For
${shopName || 'Shop Name'}
Signature
Total
Rs. ${outSubTotal.toFixed(2)}
${sale.discount > 0 ? `
Discount (${sale.discount}%)
Rs. ${outDiscount.toFixed(2)}
` : ''}
${cgstPercent > 0 ? `
CGST (${cgstPercent}%)
Rs. ${Number(cgstAmt).toFixed(2)}
` : ''}
${sgstPercent > 0 ? `
SGST (${sgstPercent}%)
Rs. ${Number(sgstAmt).toFixed(2)}
` : ''}
${igstPercent > 0 ? `
IGST (${igstPercent}%)
Rs. ${Number(igstAmt).toFixed(2)}
` : ''}
GRAND TOTAL
Rs. ${Number(outTotal).toFixed(2)}
`;
const w = window.open('', '_blank');
if (!w) {
setPreviewHtml(invoiceHtml);
setShowPreview(true);
setError('Popup blocked: showing preview. Allow popups to print directly.');
return;
}
w.document.open();
w.document.write(invoiceHtml);
w.document.close();
w.focus();
setTimeout(() => {
try {
w.print();
} catch (e) {
/* ignore */
}
}, 300);
} catch (e) {
setError('Failed to open printer: ' + (e.message || e));
}
}
async function printSmallReceipt() {
try {
const sale = lastSale || { items: sellerProducts, totalAmount, customerNo, customerName, createdAt: new Date().toISOString() };
// fetch branch info
let shopName = '';
let shopContact = '';
let shopGst = '';
let shopAddress = '';
try {
const res = await fetch(new URL(salesUrl + '/api/branches'), { headers: { Authorization: 'Bearer ' + token } });
const data = await res.json();
if (res.ok && Array.isArray(data.branches) && data.branches.length > 0) {
const payload = decodeJwt();
const branchId = payload?.branch_id || payload?._id || '';
let found = null;
if (branchId) found = data.branches.find((b) => String(b._id) === String(branchId));
if (!found) found = data.branches[0];
shopName = found?.name || '';
shopContact = found?.phoneNumber || found?.phone || '';
shopGst = found?.gstNo || found?.gst || '';
shopAddress = found?.address || found?.branchAddress || '';
}
} catch (e) {
/* ignore */
}
if (!shopName || !shopContact) {
const payload = decodeJwt();
shopName = shopName || payload.shopName || payload.name || payload.branchName || '';
shopContact = shopContact || payload.phone || payload.phoneNumber || payload.branchPhone || '';
shopGst = shopGst || payload.gstNo || payload.gst || '';
shopAddress = shopAddress || payload.address || payload.branchAddress || '';
}
// fetch branch stock to resolve prices
let stock = [];
try {
const sres = await fetch(new URL(salesUrl + '/api/branch-stock?only_branch=1'), { headers: { Authorization: 'Bearer ' + token } });
const sdata = await sres.json();
if (sres.ok && Array.isArray(sdata.rows)) stock = sdata.rows;
} catch (e) {
/* ignore */
}
const cgstPercent = sale.cgst || cgst;
const sgstPercent = sale.sgst || sgst;
const igstPercent = sale.igst || igst;
const cgstAmt = sale.cgstAmount ?? cgstAmount;
const sgstAmt = sale.sgstAmount ?? sgstAmount;
const igstAmt = sale.igstAmount ?? igstAmount;
const items = (sale.items || [])
.map((i, idx) => {
const found = (stock || []).find(
(p) =>
(String(p._id) && String(p._id) === String(i.productId || i._id)) ||
(p.productId && String(p.productId) === String(i.productId)) ||
(p.productNo && i.productNo && String(p.productNo) === String(i.productNo))
);
const name = found?.productName || found?.name || i.productName || i.productNo || '';
const imes = Array.isArray(i.selectedImes) && i.selectedImes.length > 0 ? i.selectedImes : Array.isArray(i.imes) ? i.imes : [];
const imeiText = imes.length > 0 ? imes.map((imei) => `IMEI: ${imei}`).join(', ') : '';
const productDescription = imeiText
? `${name}
${imeiText}`
: name;
const qty = Number(i.qty || i.sellingQty || 0);
const unit = Number(found?.sellingPrice ?? found?.unitSellingPrice ?? i.sellingPrice ?? 0).toFixed(2);
const line = (qty * Number(unit)).toFixed(2);
const hasImei = imes.length > 0;
return `
| ${idx + 1} |
${productDescription} |
|
${qty} |
โน${unit} |
โน${line} |
`;
})
.join('');
const date = new Date(sale.createdAt || Date.now()).toLocaleString();
const outSubTotal = Number((sale.subTotal ?? subTotal) || 0);
const outDiscount = Number((sale.discountAmount ?? discountAmount) || 0);
const outTaxable = Number((sale.taxableAmount ?? Math.max(0, outSubTotal - outDiscount)) || 0);
const outTotal = Number(sale.totalAmount ?? totalAmount).toFixed(2);
const receiptHtml =
`Receipt` +
`
๐ GSTIN: ${shopGst || 'N/A'}
๐ ${shopContact || 'Contact N/A'}
` +
`๐ฐ PRODUCT SALES RECEIPT ๐ฐ
` +
`${shopName || 'Branch Name'}
` +
`๐ ${shopAddress || 'Branch Address'}
` +
`
๐ค Customer: ${sale.customerName || customerName || 'Walk-in Customer'}
` +
`
๐ฑ Phone: ${sale.customerNo || customerNo || 'N/A'}
๐
Date: ${date}
` +
`
| # |
๐ฆ Product Details |
HSN |
Qty |
Rate |
Amount |
${items}
` +
`` +
`| ๐ Sub Total: | โน ${outSubTotal.toFixed(2)} |
` +
(sale.discount
? `| ๐ท๏ธ Discount (${sale.discount}%): | - โน ${outDiscount.toFixed(2)} |
`
: '') +
`| ๐ต Taxable Amount: | โน ${outTaxable.toFixed(2)} |
` +
(cgstPercent > 0 ? `| ๐๏ธ CGST ${cgstPercent}%: | โน ${Number(cgstAmt).toFixed(2)} |
` : '') +
(sgstPercent > 0 ? `| ๐๏ธ SGST ${sgstPercent}%: | โน ${Number(sgstAmt).toFixed(2)} |
` : '') +
(igstPercent > 0 ? `| ๐๏ธ IGST ${igstPercent}%: | โน ${Number(igstAmt).toFixed(2)} |
` : '') +
`| ๐ฐ GRAND TOTAL: | โน ${outTotal} |
` +
`
` +
`
๐ Thank you for your business! ๐
Visit again soon!
`;
const w = window.open('', '_blank');
if (!w) {
setPreviewHtml(receiptHtml);
setShowPreview(true);
setError('Popup blocked: showing preview. Allow popups to print directly.');
return;
}
w.document.open();
w.document.write(receiptHtml);
w.document.close();
w.focus();
setTimeout(() => {
try {
w.print();
} catch (e) {
/* ignore */
}
}, 300);
} catch (e) {
setError('Failed to open small receipt printer: ' + (e.message || e));
}
}
const currency = (n) =>
new Intl.NumberFormat('en-IN', { style: 'currency', currency: 'INR', maximumFractionDigits: 2 }).format(n || 0);
const getAvailableImes = (p) =>
(Array.isArray(p.centralOnlyImes) && p.centralOnlyImes.length
? p.centralOnlyImes
: Array.isArray(p.centralImes) && p.centralImes.length
? p.centralImes
: Array.isArray(p.imes)
? p.imes
: []) || [];
return (
{showPreview ? (
setShowPreview(false)}
>
e.stopPropagation()}
>
Receipt Preview
) : null}
{showAlert ? (
Message
{error}
) : null}
Add Product
setProductNo(e.target.value)}
placeholder="Enter product no"
style={{ flex: 1 }}
/>
Tip: enter exact product number.
Cart
{totalCount} item(s)
{sellerProducts.length === 0 ? (
๐งพ
No Products Added
Add using product number above.
) : (
{sellerProducts
.filter((p) => Number(p.qty) > 0)
.map((p, i) => {
const availableImes = getAvailableImes(p);
const selected = Array.isArray(p.selectedImes) ? p.selectedImes : [];
const isImeiTracked = availableImes.length > 0;
return (
{p.productName || p.productNo || 'Product'}
#{p.productNo || '-'}
{(p.brand || p.model) ? โข {(p.brand || '').trim()} {(p.model || '').trim()} : null}
Available: {p.qty ?? '-'} | Unit: {currency(p.sellingPrice ?? '-')}
{p.validity ? (
Validity: {new Date(p.validity).toLocaleDateString()}
) : null}
{isImeiTracked ? (
{showImes[i] ? (
{availableImes.map((val, idx2) => {
const checked = selected.includes(val);
const isCentral = Array.isArray(p.centralOnlyImes) && p.centralOnlyImes.includes(val);
return (
);
})}
{selected.length ? `${selected.length} selected` : '0 selected'}
) : null}
) : null}
);
})}
)}
Totals
Sub Total
{currency(subTotal)}
Total Amount
{currency(totalAmount)}
Actions
);
}
window.MobileProductSales = MobileProductSales;