import React, { useState, useMemo, useEffect, useCallback, useRef } from 'react'; import { Search, Plus, Trash2, Receipt, X, Tag, QrCode, CreditCard, PieChart, Barcode, ShoppingCart, User, RefreshCcw, Save, Download, ChevronRight, Zap, Wallet, Smartphone, AlertCircle } from 'lucide-react'; import { useFiestaStockStatement, FIESTA_TENANT_ID } from '../services/fiestaQueries'; import { num as fnum, str as fstr, type Row } from '../services/fiestaApi'; import { useNotify } from './Toasts'; import { Dialog } from '@astryxdesign/core/Dialog'; import { Button } from '@astryxdesign/core/Button'; import { IconButton } from '@astryxdesign/core/IconButton'; import { TextInput } from '@astryxdesign/core/TextInput'; import { NumberInput } from '@astryxdesign/core/NumberInput'; import { Badge } from '@astryxdesign/core/Badge'; import { Spinner } from '@astryxdesign/core/Spinner'; import { EmptyState } from '@astryxdesign/core/EmptyState'; import { SegmentedControl, SegmentedControlItem } from '@astryxdesign/core/SegmentedControl'; import { Table, TableHeader, TableHeaderCell, TableBody, TableRow, TableCell, } from '@astryxdesign/core/Table'; /** * PHASE 6 — the one modal shell for this screen. * * PosView carried SEVEN hand-rolled `fixed inset-0` overlays, the largest * concentration anywhere in the app. Every one was the same construction: a * blurred backdrop, a white card, an inline `maxWidth`, and a header row with a * close button. None trapped focus, none closed on Escape, and each had to be * dismissed by finding its own X. * * They now share this. Widths are passed per call site so each dialog keeps the * exact size it had (500 / 450 / 400px), and `maxHeight` is preserved where the * original set one — the two that scroll internally would otherwise grow past * the viewport on a long list. * * `print:hidden` is intentionally NOT carried over. It was on the backdrop of * every overlay including the receipt one, which is why the receipt modal had * to fight it back with `print:max-w-none`. Dialog renders in the top layer, * where the browser's own print handling applies. */ function PosModal({ onClose, width, maxHeight, children, }: { onClose: () => void; width: number; maxHeight?: string; children: React.ReactNode; }) { return ( { if (!open) onClose(); }} width={width} maxHeight={maxHeight} purpose="info" > {children} ); } interface PosViewProps { locationid?: number; tenantId?: number; } interface CartItem { id: string; name: string; sku: string; price: number; qty: number; maxQty: number; discountPerItem: number; couponCode?: string; unit: string; isRefund?: boolean; promoDiscount?: number; hasBogo?: boolean; } export default function PosView({ locationid, tenantId = FIESTA_TENANT_ID }: PosViewProps) { // PHASE 6 — replaces alert(). A native alert freezes the whole browser // until dismissed; a toast does not, and errors stay until read. const { notifyOk, notifyFail } = useNotify(); const stockQ = useFiestaStockStatement({ tenantid: tenantId, locationid: locationid ?? 0, pagesize: 200 }); const [search, setSearch] = useState(''); const [cart, setCart] = useState([]); const [showPayment, setShowPayment] = useState(false); const [showReceipt, setShowReceipt] = useState(false); const [showReport, setShowReport] = useState(false); const [showManualSearch, setShowManualSearch] = useState(false); const [payMethod, setPayMethod] = useState<'cash' | 'upi' | 'card' | 'store_credit'>('cash'); const [cashReceived, setCashReceived] = useState(''); const [discountPromptItem, setDiscountPromptItem] = useState(null); const [couponCodeInput, setCouponCodeInput] = useState(''); const [discountAmountInput, setDiscountAmountInput] = useState(''); const couponInputRef = useRef(null); const searchInputRef = useRef(null); const [dailyReport, setDailyReport] = useState({ sales: 0, tx: 0, gst: 0, cash: 0, upi: 0, card: 0, products: {} as Record }); const [isRefundMode, setIsRefundMode] = useState(false); const [customer, setCustomer] = useState<{ phone: string; points: number; name: string } | null>(null); const [showCustomerPrompt, setShowCustomerPrompt] = useState(false); const [customerPhoneInput, setCustomerPhoneInput] = useState(''); const [customerNameInput, setCustomerNameInput] = useState(''); const [isNewCustomer, setIsNewCustomer] = useState(false); const [parkedCarts, setParkedCarts] = useState<{ id: string; cart: CartItem[]; time: number; customer: any }[]>([]); const [showParkedPrompt, setShowParkedPrompt] = useState(false); const inventory = useMemo(() => { return (stockQ.data ?? []).map((r: Row, i: number) => { const isWeighable = i % 4 === 0; return { id: fstr(r.productid), name: fstr(r.productname) || 'Unnamed product', sku: fstr(r.sku) || `SKU-${fstr(r.productid)}`, closing: fnum(r.closing) ?? 0, price: Math.floor(Math.random() * 500) + 50, unit: isWeighable ? 'kg' : 'pc', }; }); }, [stockQ.data]); const handleProductScanned = useCallback( (product: any) => { if (product.closing <= 0 && !isRefundMode) return; setCart((prev) => { const existing = prev.find((item) => item.id === product.id && !!item.isRefund === isRefundMode); if (existing) { if (!isRefundMode && existing.qty >= product.closing) return prev; return prev.map((item) => item.id === product.id && !!item.isRefund === isRefundMode ? { ...item, qty: item.qty + 1 } : item ); } return [ { id: product.id, name: product.name, sku: product.sku, price: product.price, qty: product.unit === 'kg' ? 1.0 : 1, maxQty: product.closing, discountPerItem: 0, unit: product.unit, isRefund: isRefundMode, }, ...prev, ]; }); setSearch(''); }, [isRefundMode] ); const updateCartQty = (id: string, newQty: number) => { setCart((prev) => prev .map((item) => { if (item.id === id) { if (newQty <= 0) return { ...item, qty: 0 }; if (newQty > item.maxQty) return item; return { ...item, qty: newQty }; } return item; }) .filter((item) => item.qty > 0) ); }; const removeCartItem = (id: string) => { setCart((prev) => prev.filter((item) => item.id !== id)); }; const clearCart = () => setCart([]); const applyDiscountToItem = () => { if (!discountPromptItem) return; const amt = parseFloat(discountAmountInput) || 0; const code = couponCodeInput.trim().toUpperCase() || undefined; setCart((prev) => prev.map((item) => (item.id === discountPromptItem ? { ...item, discountPerItem: amt, couponCode: code } : item)) ); setDiscountPromptItem(null); setCouponCodeInput(''); setDiscountAmountInput(''); setTimeout(() => searchInputRef.current?.focus(), 100); }; const removeCoupon = (id: string) => { setCart((prev) => prev.map((item) => (item.id === id ? { ...item, discountPerItem: 0, couponCode: undefined } : item)) ); }; const cartWithPromos = useMemo(() => { return cart.map((item) => { if (item.isRefund) return { ...item, promoDiscount: 0, hasBogo: false }; let promoDiscount = 0; let hasBogo = false; if (item.unit !== 'kg' && item.qty >= 3) { const freeItems = Math.floor(item.qty / 3); promoDiscount = freeItems * item.price; hasBogo = true; } return { ...item, promoDiscount, hasBogo }; }); }, [cart]); const subtotal = cartWithPromos.reduce((sum, item) => sum + item.price * item.qty * (item.isRefund ? -1 : 1), 0); const totalDiscount = cartWithPromos.reduce( (sum, item) => sum + (item.discountPerItem * item.qty + (item.promoDiscount || 0)), 0 ); const taxableAmount = Math.max(0, subtotal - totalDiscount); const cgst = taxableAmount * 0.09; const sgst = taxableAmount * 0.09; const packagingCharge = cart.length > 0 && subtotal > 0 ? 10 : 0; const grandTotal = subtotal < 0 ? subtotal : taxableAmount + cgst + sgst + packagingCharge; const parkCart = () => { if (cart.length === 0) return; setParkedCarts((prev) => [ ...prev, { id: `CART-${Date.now().toString().slice(-4)}`, cart, time: Date.now(), customer }, ]); setCart([]); setCustomer(null); setIsRefundMode(false); }; const retrieveCart = (pc: any) => { setCart(pc.cart); setCustomer(pc.customer); setParkedCarts((prev) => prev.filter((p) => p.id !== pc.id)); setShowParkedPrompt(false); }; useEffect(() => { let barcode = ''; let lastKeyTime = Date.now(); const handleKeyDown = (e: KeyboardEvent) => { if ( showPayment || showReceipt || showReport || discountPromptItem || showManualSearch || showCustomerPrompt || showParkedPrompt ) return; if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) return; const currentTime = Date.now(); if (currentTime - lastKeyTime > 100) barcode = ''; lastKeyTime = currentTime; if (e.key === 'Enter') { if (barcode.length > 0) { const product = inventory.find((p) => p.sku === barcode || p.id === barcode); if (product) handleProductScanned(product); barcode = ''; e.preventDefault(); } } else if (e.key.length === 1) { barcode += e.key; } }; window.addEventListener('keydown', handleKeyDown); return () => window.removeEventListener('keydown', handleKeyDown); }, [ inventory, handleProductScanned, showPayment, showReceipt, showReport, discountPromptItem, showManualSearch, showCustomerPrompt, showParkedPrompt, ]); const handleSearchKeyDown = (e: React.KeyboardEvent) => { if (e.key === 'Enter') { const val = search.trim(); if (val) { const product = inventory.find( (p) => p.sku === val || p.id === val || p.name.toLowerCase() === val.toLowerCase() ); if (product) { handleProductScanned(product); } else { notifyFail('Product not found in inventory.'); } } } }; const getTodayKey = () => new Date().toISOString().split('T')[0]; const checkDailyReset = useCallback(() => { const savedDate = localStorage.getItem('nearly_pos_date'); const today = getTodayKey(); if (savedDate !== today) { localStorage.setItem('nearly_pos_date', today); const initialReport = { sales: 0, tx: 0, gst: 0, cash: 0, upi: 0, card: 0, products: {} }; localStorage.setItem('nearly_pos_report', JSON.stringify(initialReport)); setDailyReport(initialReport); } else { const rep = localStorage.getItem('nearly_pos_report'); if (rep) setDailyReport(JSON.parse(rep)); } }, []); useEffect(() => { checkDailyReset(); }, [checkDailyReset]); const recordSale = () => { checkDailyReset(); const rep = JSON.parse(localStorage.getItem('nearly_pos_report') || '{}'); rep.sales = (rep.sales || 0) + grandTotal; rep.tx = (rep.tx || 0) + 1; rep.gst = (rep.gst || 0) + cgst + sgst; rep[payMethod] = (rep[payMethod] || 0) + grandTotal; if (!rep.products) rep.products = {}; cart.forEach((item) => { rep.products[item.name] = (rep.products[item.name] || 0) + item.qty; }); localStorage.setItem('nearly_pos_report', JSON.stringify(rep)); setDailyReport(rep); }; const handleCheckoutBtn = () => { if (cart.length === 0) return; setCashReceived(''); setPayMethod(grandTotal < 0 ? 'store_credit' : 'cash'); setShowPayment(true); }; const confirmPayment = () => { recordSale(); setShowPayment(false); setShowReceipt(true); }; const resetPos = () => { setCart([]); setCustomer(null); setIsRefundMode(false); setShowReceipt(false); setSearch(''); setTimeout(() => searchInputRef.current?.focus(), 100); }; const changeAmt = Math.max(0, (parseFloat(cashReceived) || 0) - grandTotal); const checkCustomerPhone = () => { if (customerPhoneInput.length >= 10) { const db = JSON.parse(localStorage.getItem('nearly_pos_customers') || '{}'); if (db[customerPhoneInput]) { // Existing customer setCustomer({ phone: customerPhoneInput, name: db[customerPhoneInput].name, points: db[customerPhoneInput].points || Math.floor(Math.random() * 500) + 50, }); closeCustomerPrompt(); } else { // New customer setIsNewCustomer(true); } } }; const saveNewCustomer = () => { if (customerNameInput.trim()) { const db = JSON.parse(localStorage.getItem('nearly_pos_customers') || '{}'); const newCustomer = { name: customerNameInput.trim(), points: 50, // Starting points }; db[customerPhoneInput] = newCustomer; localStorage.setItem('nearly_pos_customers', JSON.stringify(db)); setCustomer({ phone: customerPhoneInput, name: newCustomer.name, points: newCustomer.points, }); closeCustomerPrompt(); } }; const closeCustomerPrompt = () => { setShowCustomerPrompt(false); setCustomerPhoneInput(''); setCustomerNameInput(''); setIsNewCustomer(false); }; const cashDenominations = [10, 20, 50, 100, 200, 500, 2000]; const now = new Date(); const timeString = now.toLocaleTimeString('en-IN', { hour: '2-digit', minute: '2-digit' }); const dateString = now.toLocaleDateString('en-IN', { day: '2-digit', month: 'short', year: 'numeric' }); return (
{/* ── LEFT PANEL: Cart Items ─────────────────────────────────────── */}
{/* Action Bar */}
POS Terminal
{isRefundMode && ( Refund Mode )}
{/* PHASE 6 — the till's action bar. Refund mode used to restyle every button inline (white-on-red); that state now reads from the Refund button's own variant plus the banner beside the title, so the other four stop changing colour for a mode they do not control. */}
{/* Scan Bar */}
{/* The scan field keeps its oversized type deliberately — an operator aims a scanner at this from arm's length and the size is the affordance, so `pos-scan-field` restores the 20px bold face that TextInput's `lg` does not reach. What it gains is a real label (it had none at all) and a clear button that is now part of the control rather than a bare
{/* Cart Table */}
{stockQ.isLoading ? (
) : cart.length === 0 ? (
} title="Cart is empty" description="Scan a barcode or browse products to add items" actions={
) : (
Product Qty Rate Disc. Amount Row actions {cartWithPromos.map((item, idx) => (
{idx + 1}
{item.isRefund && } {item.name}
{item.sku}
{/* The −/+ pair plus a bare number box is one NumberInput now: it brings its own steppers, so the two icon-only buttons (neither of which had an accessible name) are gone, and `max` finally expresses the stock ceiling that only the + button used to enforce. */}
updateCartQty(item.id, v ?? 0)} step={item.unit === 'kg' ? 0.001 : 1} min={0} max={item.maxQty} /> {item.unit}
₹{item.price.toFixed(2)} /{item.unit}
{item.discountPerItem > 0 || item.hasBogo ? (
{item.discountPerItem > 0 && ( -₹{(item.discountPerItem * item.qty).toFixed(2)} )} {item.hasBogo && ( -₹{(item.promoDiscount || 0).toFixed(2)} )}
{item.discountPerItem > 0 && ( <> } onClick={() => removeCoupon(item.id)} /> )} {item.hasBogo && }
) : ( — )}
{item.isRefund ? '-' : ''}₹ {( item.price * item.qty - item.discountPerItem * item.qty - (item.promoDiscount || 0) ).toFixed(2)}
{/* The `opacity-0 group-hover` reveal is gone. On a till these two are the only way to fix a mis-scan, and they were invisible until the mouse happened to be over the row — while still being in the tab order. */}
} onClick={() => { setDiscountPromptItem(item.id); setCouponCodeInput(item.couponCode || ''); setDiscountAmountInput(item.discountPerItem > 0 ? item.discountPerItem.toString() : ''); }} /> } onClick={() => removeCartItem(item.id)} />
))}
)}
{/* Cart Footer Summary (visible on mobile) */} {cart.length > 0 && (
{cart.length} item{cart.length !== 1 ? 's' : ''} ₹{grandTotal.toFixed(2)}
)}
{/* ── RIGHT PANEL: Billing ───────────────────────────────────────── */}
{/* Panel Header */}

Checkout

{dateString} · {timeString}

{customer ? (
{customer.name || customer.phone} } onClick={() => setCustomer(null)} />
{customer.points} pts
) : (
{/* Item Count */}
{cart.length} item{cart.length !== 1 ? 's' : ''} in cart
{/* Order Breakdown */}
Subtotal ₹{subtotal.toFixed(2)}
{totalDiscount > 0 && (
Discounts
-₹{totalDiscount.toFixed(2)}
)}
CGST (9%) ₹{cgst.toFixed(2)}
SGST (9%) ₹{sgst.toFixed(2)}
Packaging ₹{packagingCharge.toFixed(2)}
{cart.length === 0 && (

Add items to see totals

)}
{/* Grand Total + Pay */}

Grand Total

₹{grandTotal.toFixed(2)}

{grandTotal < 0 && (
Refund Due
)}
{/* PAY keeps its own emerald face and full width: it is the one control the whole screen exists to reach, and it is hit hundreds of times a shift. Everything around it is Astryx; this stays a deliberate exception, marked as one. */}
} isDisabled={cart.length === 0} onClick={clearCart} />
{/* ── MODAL: Manual Product Search ──────────────────────────────── */} {showManualSearch && ( setShowManualSearch(false)} width={500} maxHeight="85vh">

Product Search

} onClick={() => setShowManualSearch(false)} />
setSearch(v)} startIcon={} hasClear hasAutoFocus />
{inventory .filter( (p) => !search || p.name.toLowerCase().includes(search.toLowerCase()) || p.sku.toLowerCase().includes(search.toLowerCase()) ) .map((product) => ( // Stays a native button: it is a full-width result row whose // whole surface is the hit target, which Button's fixed // padding and centred label cannot express. It already had a // disabled state and a text label, so nothing is lost. ))}
)} {/* ── MODAL: Customer CRM ───────────────────────────────────────── */} {showCustomerPrompt && (
} onClick={closeCustomerPrompt} />

{isNewCustomer ? 'New Customer' : 'Attach Customer'}

{isNewCustomer ? "Please enter the customer's name." : 'Enter mobile number to retrieve loyalty profile.'}

{/* Was `type="tel"` with no filtering, so letters reached a field the Find button gates on `.length >= 10` — "abcdefghij" opened a lookup. Astryx has no tel type; digits-only in the handler is the stricter fix anyway. */}
setCustomerPhoneInput(v.replace(/\D/g, '').slice(0, 10))} onEnter={() => { if (!isNewCustomer) checkCustomerPhone(); }} isDisabled={isNewCustomer} hasAutoFocus={!isNewCustomer} />
{isNewCustomer && (
setCustomerNameInput(v)} onEnter={saveNewCustomer} hasAutoFocus />
)} {isNewCustomer ? (
)} {/* ── MODAL: Parked Carts ───────────────────────────────────────── */} {showParkedPrompt && ( setShowParkedPrompt(false)} width={450} maxHeight="80vh">

Parked Sales

} onClick={() => setShowParkedPrompt(false)} />
{parkedCarts.length === 0 ? ( } title="No parked sales" /> ) : ( parkedCarts.map((pc) => (

{pc.id}

{new Date(pc.time).toLocaleTimeString()} · {pc.cart.length} items · {pc.customer ? pc.customer.phone : 'Guest'}

)) )}
)} {/* ── MODAL: Discount ───────────────────────────────────────────── */} {discountPromptItem && ( setDiscountPromptItem(null)} width={400}>
} onClick={() => setDiscountPromptItem(null)} />

Apply Discount

Enter a manual discount or attach a coupon code.

setDiscountAmountInput(v == null ? '' : String(v))} min={0} step={0.01} /> setCouponCodeInput(v.toUpperCase())} onEnter={applyDiscountToItem} />
)} {/* ── MODAL: Payment ────────────────────────────────────────────── */} {showPayment && ( setShowPayment(false)} width={450}> {/* Header */}

{grandTotal < 0 ? 'Refund Due' : 'Amount Due'}

₹{Math.abs(grandTotal).toFixed(2)}

} onClick={() => setShowPayment(false)} />
{/* Payment Method Tabs */}

Payment Method

{/* SegmentedControl, not three buttons and not SelectableCard. This is a single-select, and as plain buttons the active method lived only in border colour. SelectableCard was tried first and rejected: verified in Chrome, it renders a focusable div carrying only `aria-label` — no role, no aria-checked — and it drops a `role`/`aria-checked` you pass it, so the choice stayed silent. SegmentedControl emits a real radiogroup with arrow-key navigation. */} setPayMethod(v as any)} > {(grandTotal >= 0 ? ['cash', 'upi', 'card'] : ['cash', 'store_credit']).map((m) => { const icons: Record = { cash: , upi: , card: , store_credit: , }; const labels: Record = { cash: 'Cash', upi: 'UPI', card: 'Card', store_credit: 'Store Credit', }; return ; })}
{/* Cash Payment */} {payMethod === 'cash' && grandTotal >= 0 && (
setCashReceived(v == null ? '' : String(v))} min={0} step={0.01} hasAutoFocus />
{/* Quick denominations */}

Quick Select

{cashDenominations .filter((d) => d >= grandTotal * 0.5) .slice(0, 6) .map((d) => (
{cashReceived && parseFloat(cashReceived) >= grandTotal && (
Change Due ₹{changeAmt.toFixed(2)}
)}
)} {/* Cash Refund */} {payMethod === 'cash' && grandTotal < 0 && (

Return Cash to Customer

₹{Math.abs(grandTotal).toFixed(2)}

)} {/* Store Credit */} {payMethod === 'store_credit' && grandTotal < 0 && (
{customer ? (

Credit to: {customer.phone}

) : (

Attach a customer to issue Store Credit

)}
)} {/* UPI */} {payMethod === 'upi' && grandTotal >= 0 && (

Scan QR Code to Pay

Awaiting payment confirmation…

)} {/* Card */} {payMethod === 'card' && grandTotal >= 0 && (

Tap, swipe or insert card

Waiting for terminal…

)} {/* Same exception as PAY, and for the same reason: this is the commit point of the transaction. */}
)} {/* ── MODAL: Receipt ────────────────────────────────────────────── */} {showReceipt && (

Receipt

} onClick={resetPos} />

Departmental Store

GSTIN: 29ABCDE1234F1Z5

{new Date().toLocaleString('en-IN')}

{cartWithPromos.map((item) => (
{item.isRefund && REFUND} {item.name}
{item.qty} {item.unit} × ₹{item.price.toFixed(2)} {item.isRefund ? '-' : ''}₹{(item.price * item.qty).toFixed(2)}
{(item.discountPerItem > 0 || item.hasBogo) && (
Discount {item.couponCode ? `(${item.couponCode})` : item.hasBogo ? '(BOGO)' : ''} -₹{((item.discountPerItem * item.qty) + (item.promoDiscount || 0)).toFixed(2)}
)}
))}
Subtotal₹{subtotal.toFixed(2)}
Discounts-₹{totalDiscount.toFixed(2)}
CGST (9%)₹{cgst.toFixed(2)}
SGST (9%)₹{sgst.toFixed(2)}
Packaging₹{packagingCharge.toFixed(2)}
Grand Total₹{grandTotal.toFixed(2)}
{grandTotal < 0 ? 'Refund via' : 'Paid via'} {payMethod.replace('_', ' ')}
Thank you for shopping! 🛍
)} {/* ── MODAL: Daily Report ───────────────────────────────────────── */} {showReport && ( setShowReport(false)} width={450}>

Daily Sales Report

} onClick={() => setShowReport(false)} />
Total Revenue ₹{dailyReport.sales.toFixed(2)}

Transactions

{dailyReport.tx}

GST

₹{dailyReport.gst.toFixed(2)}

Avg. Bill

₹{dailyReport.tx > 0 ? (dailyReport.sales / dailyReport.tx).toFixed(0) : '0'}

Payment Split

{dailyReport.sales > 0 ? ( <>
{dailyReport.cash > 0 && (
)} {dailyReport.upi > 0 && (
)} {dailyReport.card > 0 && (
)}
{[ { key: 'cash', label: 'Cash', color: 'bg-emerald-500', val: dailyReport.cash }, { key: 'upi', label: 'UPI', color: 'bg-blue-500', val: dailyReport.upi }, { key: 'card', label: 'Card', color: 'bg-amber-500', val: dailyReport.card }, ].map((p) => (

{p.label}

₹{p.val.toFixed(0)}

))}
) : (
No transactions recorded today.
)}

Top Products

{Object.keys(dailyReport.products || {}).length > 0 ? (
{Object.entries(dailyReport.products) .sort(([, a], [, b]) => (b as number) - (a as number)) .map(([name, qty]) => (
{name} {Number(qty).toFixed(2)} sold
))}
) : (
No products sold yet.
)}
)}
); }