Files
daily_merchant_web/src/components/PosView.tsx
2026-08-13 18:33:22 +05:30

1289 lines
61 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 (
<Dialog
isOpen
onOpenChange={(open) => { if (!open) onClose(); }}
width={width}
maxHeight={maxHeight}
purpose="info"
>
{children}
</Dialog>
);
}
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<CartItem[]>([]);
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<string | null>(null);
const [couponCodeInput, setCouponCodeInput] = useState('');
const [discountAmountInput, setDiscountAmountInput] = useState('');
const couponInputRef = useRef<HTMLInputElement>(null);
const searchInputRef = useRef<HTMLInputElement>(null);
const [dailyReport, setDailyReport] = useState({ sales: 0, tx: 0, gst: 0, cash: 0, upi: 0, card: 0, products: {} as Record<string, number> });
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<HTMLInputElement>) => {
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 (
<div className="flex flex-col lg:flex-row h-full bg-slate-100 print:p-0 print:bg-white print:h-auto">
<style>{`
.hide-spin-button::-webkit-inner-spin-button,
.hide-spin-button::-webkit-outer-spin-button { -webkit-appearance: none; margin: 0; }
.hide-spin-button { -moz-appearance: textfield; }
.pos-scan-glow { box-shadow: 0 0 0 3px rgba(16,185,129,0.15), 0 1px 3px rgba(0,0,0,0.08); }
.pos-scan-glow-red { box-shadow: 0 0 0 3px rgba(239,68,68,0.15), 0 1px 3px rgba(0,0,0,0.08); }
/* The scan field sits inside its own glowing frame, so the input's own
chrome is removed and its type size restored. A cashier aims a
scanner at this from arm's length — the size is the affordance. */
.pos-scan-field input {
font-size: 20px; font-weight: 700; background: transparent;
border: none; box-shadow: none; height: auto; padding-left: 0;
}
.pos-scan-field input::placeholder { font-weight: 400; }
@keyframes scan-pulse { 0%,100% { opacity:1; } 50% { opacity:0.4; } }
.scan-pulse { animation: scan-pulse 1.8s ease-in-out infinite; }
`}</style>
{/* ── LEFT PANEL: Cart Items ─────────────────────────────────────── */}
<div className="flex-1 flex flex-col min-h-0 overflow-hidden print:hidden">
{/* Action Bar */}
<div className={`flex items-center justify-between gap-3 px-5 py-3 border-b border-slate-200 ${isRefundMode ? 'bg-red-600' : 'bg-white'} transition-colors duration-300`}>
<div className="flex items-center gap-3">
<div className="flex items-center gap-2">
<Barcode className={`w-5 h-5 ${isRefundMode ? 'text-white/70' : 'text-slate-400'}`} />
<span className={`font-bold text-sm tracking-wide ${isRefundMode ? 'text-white' : 'text-slate-900'}`}>POS Terminal</span>
</div>
{isRefundMode && (
<span className="flex items-center gap-1.5 bg-white/20 text-white text-[11px] font-bold uppercase tracking-widest px-2.5 py-1 rounded-full border border-white/30">
<AlertCircle className="w-3 h-3" /> Refund Mode
</span>
)}
</div>
{/* 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. */}
<div className="flex items-center gap-1.5">
<Button label="Browse" variant="secondary" size="sm" icon={<Search className="w-3.5 h-3.5" />} onClick={() => setShowManualSearch(true)} />
<Button
label="Refund"
variant={isRefundMode ? 'destructive' : 'secondary'}
size="sm"
icon={<RefreshCcw className="w-3.5 h-3.5" />}
onClick={() => setIsRefundMode(!isRefundMode)}
/>
<Button
label="Park"
variant="secondary"
size="sm"
icon={<Save className="w-3.5 h-3.5" />}
isDisabled={cart.length === 0}
onClick={parkCart}
/>
{/* The parked count was a floating dot pinned to the button corner,
outside its label — nothing announced it. */}
<div className="flex items-center gap-1">
<Button label="Retrieve" variant="secondary" size="sm" icon={<Download className="w-3.5 h-3.5" />} onClick={() => setShowParkedPrompt(true)} />
{parkedCarts.length > 0 && <Badge variant="warning" label={String(parkedCarts.length)} />}
</div>
<Button label="Report" variant="secondary" size="sm" icon={<PieChart className="w-3.5 h-3.5" />} onClick={() => { checkDailyReset(); setShowReport(true); }} />
</div>
</div>
{/* Scan Bar */}
<div className="px-5 pt-4 pb-3 bg-white border-b border-slate-200">
<div className={`flex items-center gap-4 rounded-xl border-2 px-5 py-3.5 transition-all duration-200 ${isRefundMode ? 'border-red-400 bg-red-50 pos-scan-glow-red' : 'border-emerald-400 bg-emerald-50/40 pos-scan-glow'}`}>
<div className={`scan-pulse ${isRefundMode ? 'text-red-400' : 'text-emerald-500'}`}>
<Barcode className="w-7 h-7" />
</div>
{/* 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 <button> beside it. */}
<div className="flex-1 pos-scan-field">
<TextInput
ref={searchInputRef}
label={isRefundMode ? 'Scan item to refund' : 'Scan barcode or type SKU'}
isLabelHidden
size="lg"
placeholder={isRefundMode ? 'Scan item to refund…' : 'Scan barcode or type SKU…'}
value={search}
onChange={(v) => setSearch(v)}
onKeyDown={handleSearchKeyDown}
hasClear
hasAutoFocus
/>
</div>
{!search && (
<span className="text-[11px] font-bold text-slate-400 uppercase tracking-widest hidden md:block">
Press Enter
</span>
)}
</div>
</div>
{/* Cart Table */}
<div className="flex-1 overflow-y-auto bg-white">
{stockQ.isLoading ? (
<div className="h-full flex items-center justify-center">
<Spinner size="xl" label="Initializing inventory…" />
</div>
) : cart.length === 0 ? (
<div className="h-full flex items-center justify-center">
<EmptyState
icon={<ShoppingCart className="w-12 h-12" />}
title="Cart is empty"
description="Scan a barcode or browse products to add items"
actions={<Button label="Browse Products" icon={<Search className="w-4 h-4" />} onClick={() => setShowManualSearch(true)} />}
/>
</div>
) : (
<div className="w-full overflow-x-auto custom-scrollbar">
<Table style={{ minWidth: 500 }}>
<TableHeader>
<TableRow>
<TableHeaderCell>Product</TableHeaderCell>
<TableHeaderCell>Qty</TableHeaderCell>
<TableHeaderCell>Rate</TableHeaderCell>
<TableHeaderCell>Disc.</TableHeaderCell>
<TableHeaderCell>Amount</TableHeaderCell>
<TableHeaderCell><span className="sr-only">Row actions</span></TableHeaderCell>
</TableRow>
</TableHeader>
<TableBody>
{cartWithPromos.map((item, idx) => (
<TableRow
key={item.id}
style={item.isRefund ? { background: 'rgba(244,63,94,0.06)' } : undefined}
>
<TableCell>
<div className="flex items-start gap-3">
<div className="w-8 h-8 rounded-lg bg-slate-100 flex items-center justify-center shrink-0 mt-0.5">
<span className="text-[10px] font-black text-slate-500">{idx + 1}</span>
</div>
<div>
<div className="flex items-center gap-2">
{item.isRefund && <Badge variant="error" label="Refund" />}
<span className="font-bold text-[14px] leading-snug">{item.name}</span>
</div>
<div className="text-[11px] font-mono mt-0.5 opacity-50">{item.sku}</div>
</div>
</div>
</TableCell>
<TableCell>
{/* 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. */}
<div className="flex items-center gap-1 w-28">
<NumberInput
label={`Quantity for ${item.name}`}
isLabelHidden
size="sm"
value={item.qty}
onChange={(v) => updateCartQty(item.id, v ?? 0)}
step={item.unit === 'kg' ? 0.001 : 1}
min={0}
max={item.maxQty}
/>
<span className="text-[10px] font-bold shrink-0 opacity-50">{item.unit}</span>
</div>
</TableCell>
<TableCell>
<div className="text-right">
<span className="font-bold text-sm">₹{item.price.toFixed(2)}</span>
<span className="block text-[10px] opacity-50">/{item.unit}</span>
</div>
</TableCell>
<TableCell>
{item.discountPerItem > 0 || item.hasBogo ? (
<div className="flex flex-col items-end gap-1">
{item.discountPerItem > 0 && (
<span className="text-emerald-600 font-bold text-sm">
-₹{(item.discountPerItem * item.qty).toFixed(2)}
</span>
)}
{item.hasBogo && (
<span className="text-amber-500 font-bold text-sm">
-₹{(item.promoDiscount || 0).toFixed(2)}
</span>
)}
<div className="flex gap-1 flex-wrap justify-end items-center">
{item.discountPerItem > 0 && (
<>
<Badge variant="success" label={item.couponCode || 'MANUAL'} />
<IconButton
label={`Remove discount from ${item.name}`}
variant="ghost"
size="sm"
icon={<X className="w-3 h-3" />}
onClick={() => removeCoupon(item.id)}
/>
</>
)}
{item.hasBogo && <Badge variant="warning" label="BOGO" />}
</div>
</div>
) : (
<span className="text-sm block text-right opacity-30">—</span>
)}
</TableCell>
<TableCell>
<div className={`text-right font-bold text-[15px] ${item.isRefund ? 'text-red-600' : ''}`}>
{item.isRefund ? '-' : ''}₹
{(
item.price * item.qty -
item.discountPerItem * item.qty -
(item.promoDiscount || 0)
).toFixed(2)}
</div>
</TableCell>
<TableCell>
{/* 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. */}
<div className="flex items-center justify-center gap-1">
<IconButton
label={`Apply discount to ${item.name}`}
variant="secondary"
size="sm"
icon={<Tag className="w-3.5 h-3.5" />}
onClick={() => {
setDiscountPromptItem(item.id);
setCouponCodeInput(item.couponCode || '');
setDiscountAmountInput(item.discountPerItem > 0 ? item.discountPerItem.toString() : '');
}}
/>
<IconButton
label={`Remove ${item.name} from cart`}
variant="destructive"
size="sm"
icon={<Trash2 className="w-3.5 h-3.5" />}
onClick={() => removeCartItem(item.id)}
/>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
</div>
{/* Cart Footer Summary (visible on mobile) */}
{cart.length > 0 && (
<div className="px-5 py-2.5 bg-slate-50 border-t border-slate-200 flex items-center justify-between text-sm lg:hidden">
<span className="text-slate-500 font-medium">{cart.length} item{cart.length !== 1 ? 's' : ''}</span>
<span className="font-black text-slate-900 text-lg">₹{grandTotal.toFixed(2)}</span>
</div>
)}
</div>
{/* ── RIGHT PANEL: Billing ───────────────────────────────────────── */}
<div className="w-full lg:w-[360px] xl:w-[400px] flex flex-col bg-white border-l border-slate-200 shadow-xl overflow-hidden flex-shrink-0 print:hidden">
{/* Panel Header */}
<div className="bg-white border-b border-slate-200 px-5 pt-5 pb-4 text-slate-900">
<div className="flex items-center justify-between mb-4">
<div>
<h2 className="font-black text-lg tracking-tight">Checkout</h2>
<p className="text-slate-500 text-xs mt-0.5">{dateString} · {timeString}</p>
</div>
<div className="flex items-center gap-2">
{customer ? (
<div className="bg-slate-50 border border-slate-200 rounded-xl px-3 py-2 text-right">
<div className="flex items-center gap-1.5 justify-end">
<User className="w-3 h-3 text-blue-500" />
<span className="text-xs font-bold text-slate-900">{customer.name || customer.phone}</span>
<IconButton
label="Detach customer from this sale"
variant="ghost"
size="sm"
icon={<X className="w-3 h-3" />}
onClick={() => setCustomer(null)}
/>
</div>
<div className="text-[10px] font-bold text-amber-500 mt-0.5 flex items-center justify-end gap-1">
<Zap className="w-2.5 h-2.5" /> {customer.points} pts
</div>
</div>
) : (
<Button
label="Customer"
variant="secondary"
size="sm"
icon={<Plus className="w-3.5 h-3.5" />}
onClick={() => setShowCustomerPrompt(true)}
/>
)}
</div>
</div>
{/* Item Count */}
<div className="flex items-center gap-2 text-slate-500 text-xs">
<ShoppingCart className="w-3.5 h-3.5" />
<span>{cart.length} item{cart.length !== 1 ? 's' : ''} in cart</span>
</div>
</div>
{/* Order Breakdown */}
<div className="flex-1 overflow-y-auto px-5 py-5 space-y-1">
<div className="flex justify-between items-center py-2.5 border-b border-slate-100">
<span className="text-sm text-slate-600">Subtotal</span>
<span className="font-bold text-slate-900">₹{subtotal.toFixed(2)}</span>
</div>
{totalDiscount > 0 && (
<div className="flex justify-between items-center py-2.5 border-b border-slate-100">
<div className="flex items-center gap-1.5 text-emerald-600">
<Tag className="w-3.5 h-3.5" />
<span className="text-sm font-bold">Discounts</span>
</div>
<span className="font-bold text-emerald-600">-₹{totalDiscount.toFixed(2)}</span>
</div>
)}
<div className="flex justify-between items-center py-2.5 border-b border-slate-100">
<span className="text-sm text-slate-500">CGST (9%)</span>
<span className="text-sm font-medium text-slate-700">₹{cgst.toFixed(2)}</span>
</div>
<div className="flex justify-between items-center py-2.5 border-b border-slate-100">
<span className="text-sm text-slate-500">SGST (9%)</span>
<span className="text-sm font-medium text-slate-700">₹{sgst.toFixed(2)}</span>
</div>
<div className="flex justify-between items-center py-2.5">
<span className="text-sm text-slate-500">Packaging</span>
<span className="text-sm font-medium text-slate-700">₹{packagingCharge.toFixed(2)}</span>
</div>
{cart.length === 0 && (
<div className="pt-8 text-center text-slate-300">
<p className="text-xs font-medium">Add items to see totals</p>
</div>
)}
</div>
{/* Grand Total + Pay */}
<div className="px-5 pt-4 pb-5 border-t border-slate-200 bg-slate-50 space-y-4">
<div className="bg-white border border-slate-200 rounded-2xl px-5 py-4 flex items-center justify-between shadow-sm">
<div>
<p className="text-slate-500 text-[11px] font-bold uppercase tracking-widest">Grand Total</p>
<p className="text-slate-900 text-3xl font-black tracking-tight mt-0.5">₹{grandTotal.toFixed(2)}</p>
</div>
{grandTotal < 0 && (
<div className="bg-red-50 text-red-600 text-[10px] font-bold uppercase tracking-wider px-2.5 py-1 rounded-lg border border-red-200">
Refund Due
</div>
)}
</div>
{/* 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. */}
<div className="flex gap-2 pos-pay-row">
<IconButton
label="Clear cart"
variant="secondary"
size="lg"
icon={<Trash2 className="w-5 h-5" />}
isDisabled={cart.length === 0}
onClick={clearCart}
/>
<button
onClick={handleCheckoutBtn}
disabled={cart.length === 0 || !customer}
className="flex-1 h-12 bg-emerald-600 hover:bg-emerald-700 disabled:bg-slate-200 disabled:text-slate-400 text-white rounded-xl font-black text-lg transition-all shadow-lg shadow-emerald-600/25 disabled:shadow-none flex items-center justify-center gap-2 active:scale-[0.98]"
>
PAY <ChevronRight className="w-5 h-5" />
</button>
</div>
</div>
</div>
{/* ── MODAL: Manual Product Search ──────────────────────────────── */}
{showManualSearch && (
<PosModal onClose={() => setShowManualSearch(false)} width={500} maxHeight="85vh">
<div className="flex items-center justify-between px-5 py-4 border-b border-slate-100">
<h3 className="font-bold text-slate-900 flex items-center gap-2">
<Search className="w-5 h-5 text-indigo-500" /> Product Search
</h3>
<IconButton label="Close product search" variant="ghost" size="sm" icon={<X className="w-4 h-4" />} onClick={() => setShowManualSearch(false)} />
</div>
<div className="px-5 py-3 border-b border-slate-100">
<TextInput
label="Search products"
isLabelHidden
placeholder="Search by name, SKU…"
value={search}
onChange={(v) => setSearch(v)}
startIcon={<Search className="w-4 h-4" />}
hasClear
hasAutoFocus
/>
</div>
<div className="flex-1 overflow-y-auto">
{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.
<button
key={product.id}
disabled={product.closing <= 0}
onClick={() => { handleProductScanned(product); setSearch(''); }}
className="w-full text-left px-5 py-3.5 flex justify-between items-center border-b border-slate-100 hover:bg-slate-50 transition-colors disabled:opacity-40 disabled:cursor-not-allowed group"
>
<div>
<p className="font-bold text-slate-900 text-sm">{product.name}</p>
<p className="text-[11px] text-slate-400 font-mono mt-0.5">SKU: {product.sku}</p>
</div>
<div className="text-right">
<p className="font-bold text-emerald-600 text-sm">₹{product.price.toFixed(2)}/{product.unit}</p>
<p className={`text-[11px] font-bold mt-0.5 ${product.closing <= 5 ? 'text-amber-500' : 'text-slate-400'}`}>
{product.closing <= 0 ? 'Out of stock' : `${product.closing} in stock`}
</p>
</div>
</button>
))}
</div>
</PosModal>
)}
{/* ── MODAL: Customer CRM ───────────────────────────────────────── */}
{showCustomerPrompt && (
<PosModal onClose={closeCustomerPrompt} width={400}>
<div className="px-6 pt-6 pb-5">
<div className="flex justify-between items-start mb-5">
<div className="w-12 h-12 bg-blue-50 rounded-xl flex items-center justify-center">
<User className="w-6 h-6 text-blue-500" />
</div>
<IconButton label="Close customer panel" variant="ghost" size="sm" icon={<X className="w-4 h-4" />} onClick={closeCustomerPrompt} />
</div>
<h3 className="font-bold text-xl text-slate-900 mb-1">{isNewCustomer ? 'New Customer' : 'Attach Customer'}</h3>
<p className="text-sm text-slate-500 mb-5">{isNewCustomer ? "Please enter the customer's name." : 'Enter mobile number to retrieve loyalty profile.'}</p>
{/* 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. */}
<div className="mb-4">
<TextInput
label="Customer mobile number"
placeholder="e.g. 9876543210"
value={customerPhoneInput}
onChange={(v) => setCustomerPhoneInput(v.replace(/\D/g, '').slice(0, 10))}
onEnter={() => { if (!isNewCustomer) checkCustomerPhone(); }}
isDisabled={isNewCustomer}
hasAutoFocus={!isNewCustomer}
/>
</div>
{isNewCustomer && (
<div className="mb-4">
<TextInput
label="Customer name"
placeholder="Customer Name"
value={customerNameInput}
onChange={(v) => setCustomerNameInput(v)}
onEnter={saveNewCustomer}
hasAutoFocus
/>
</div>
)}
{isNewCustomer ? (
<Button
label="Save & Attach"
isDisabled={!customerNameInput.trim()}
onClick={saveNewCustomer}
/>
) : (
<Button
label="Find & Attach"
isDisabled={customerPhoneInput.length < 10}
onClick={checkCustomerPhone}
/>
)}
</div>
</PosModal>
)}
{/* ── MODAL: Parked Carts ───────────────────────────────────────── */}
{showParkedPrompt && (
<PosModal onClose={() => setShowParkedPrompt(false)} width={450} maxHeight="80vh">
<div className="flex items-center justify-between px-5 py-4 border-b border-slate-100">
<h3 className="font-bold text-slate-900 flex items-center gap-2">
<Download className="w-5 h-5 text-blue-500" /> Parked Sales
</h3>
<IconButton label="Close parked sales" variant="ghost" size="sm" icon={<X className="w-4 h-4" />} onClick={() => setShowParkedPrompt(false)} />
</div>
<div className="flex-1 overflow-y-auto p-4 space-y-2">
{parkedCarts.length === 0 ? (
<EmptyState isCompact icon={<Save className="w-10 h-10" />} title="No parked sales" />
) : (
parkedCarts.map((pc) => (
<div key={pc.id} className="bg-slate-50 border border-slate-200 rounded-xl p-4 flex justify-between items-center hover:border-blue-200 transition-colors">
<div>
<p className="font-bold text-slate-900 text-sm">{pc.id}</p>
<p className="text-xs text-slate-500 mt-0.5">
{new Date(pc.time).toLocaleTimeString()} · {pc.cart.length} items · {pc.customer ? pc.customer.phone : 'Guest'}
</p>
</div>
<Button label="Retrieve" size="sm" onClick={() => retrieveCart(pc)} />
</div>
))
)}
</div>
</PosModal>
)}
{/* ── MODAL: Discount ───────────────────────────────────────────── */}
{discountPromptItem && (
<PosModal onClose={() => setDiscountPromptItem(null)} width={400}>
<div className="px-6 pt-6 pb-5">
<div className="flex justify-between items-start mb-5">
<div className="w-12 h-12 bg-emerald-50 rounded-xl flex items-center justify-center">
<Tag className="w-6 h-6 text-emerald-600" />
</div>
<IconButton label="Close discount panel" variant="ghost" size="sm" icon={<X className="w-4 h-4" />} onClick={() => setDiscountPromptItem(null)} />
</div>
<h3 className="font-bold text-xl text-slate-900 mb-1">Apply Discount</h3>
<p className="text-sm text-slate-500 mb-5">Enter a manual discount or attach a coupon code.</p>
<div className="space-y-3 mb-5">
<NumberInput
label="Discount Amount (₹)"
value={discountAmountInput === '' ? null : Number(discountAmountInput)}
onChange={(v) => setDiscountAmountInput(v == null ? '' : String(v))}
min={0}
step={0.01}
/>
<TextInput
label="Coupon Code"
isOptional
placeholder="e.g. OFF10"
value={couponCodeInput}
onChange={(v) => setCouponCodeInput(v.toUpperCase())}
onEnter={applyDiscountToItem}
/>
</div>
<Button label="Confirm Discount" onClick={applyDiscountToItem} />
</div>
</PosModal>
)}
{/* ── MODAL: Payment ────────────────────────────────────────────── */}
{showPayment && (
<PosModal onClose={() => setShowPayment(false)} width={450}>
{/* Header */}
<div className="bg-white border-b border-slate-200 px-6 py-5 text-slate-900 flex items-start justify-between">
<div>
<p className="text-slate-500 text-xs font-bold uppercase tracking-widest mb-1">
{grandTotal < 0 ? 'Refund Due' : 'Amount Due'}
</p>
<p className="text-4xl font-black tracking-tight text-slate-900">₹{Math.abs(grandTotal).toFixed(2)}</p>
</div>
<IconButton label="Close payment panel" variant="ghost" size="sm" icon={<X className="w-4 h-4" />} onClick={() => setShowPayment(false)} />
</div>
<div className="p-6 space-y-5">
{/* Payment Method Tabs */}
<div>
<p className="text-[11px] font-bold text-slate-400 uppercase tracking-wider mb-2.5">Payment Method</p>
{/* 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. */}
<SegmentedControl
label="Payment method"
layout="fill"
value={payMethod}
onChange={(v) => setPayMethod(v as any)}
>
{(grandTotal >= 0 ? ['cash', 'upi', 'card'] : ['cash', 'store_credit']).map((m) => {
const icons: Record<string, React.ReactNode> = {
cash: <Wallet className="w-4 h-4" />,
upi: <Smartphone className="w-4 h-4" />,
card: <CreditCard className="w-4 h-4" />,
store_credit: <User className="w-4 h-4" />,
};
const labels: Record<string, string> = {
cash: 'Cash', upi: 'UPI', card: 'Card', store_credit: 'Store Credit',
};
return <SegmentedControlItem key={m} value={m} label={labels[m]} icon={icons[m]} />;
})}
</SegmentedControl>
</div>
{/* Cash Payment */}
{payMethod === 'cash' && grandTotal >= 0 && (
<div className="space-y-4 animate-in slide-in-from-bottom-2 duration-200">
<div className="pos-cash-field">
<NumberInput
label="Cash Received"
value={cashReceived === '' ? null : Number(cashReceived)}
onChange={(v) => setCashReceived(v == null ? '' : String(v))}
min={0}
step={0.01}
hasAutoFocus
/>
</div>
{/* Quick denominations */}
<div>
<p className="text-[11px] font-bold text-slate-400 uppercase tracking-wider mb-2">Quick Select</p>
<div className="flex flex-wrap gap-1.5">
{cashDenominations
.filter((d) => d >= grandTotal * 0.5)
.slice(0, 6)
.map((d) => (
<Button
key={d}
label={`₹${d}`}
size="sm"
variant={parseFloat(cashReceived) === d ? 'primary' : 'secondary'}
onClick={() => setCashReceived(d.toString())}
/>
))}
<Button
label="Exact"
size="sm"
variant="secondary"
onClick={() => setCashReceived(Math.ceil(grandTotal).toString())}
/>
</div>
</div>
{cashReceived && parseFloat(cashReceived) >= grandTotal && (
<div className="flex justify-between items-center bg-emerald-50 border border-emerald-100 rounded-xl px-4 py-3">
<span className="text-sm font-bold text-emerald-700">Change Due</span>
<span className="text-2xl font-black text-emerald-700">₹{changeAmt.toFixed(2)}</span>
</div>
)}
</div>
)}
{/* Cash Refund */}
{payMethod === 'cash' && grandTotal < 0 && (
<div className="text-center p-6 border-2 border-dashed border-red-200 bg-red-50 rounded-xl animate-in slide-in-from-bottom-2 duration-200">
<p className="text-sm font-bold text-red-600 mb-1">Return Cash to Customer</p>
<p className="text-3xl font-black text-red-700">₹{Math.abs(grandTotal).toFixed(2)}</p>
</div>
)}
{/* Store Credit */}
{payMethod === 'store_credit' && grandTotal < 0 && (
<div className="animate-in slide-in-from-bottom-2 duration-200">
{customer ? (
<div className="text-center p-5 border-2 border-dashed border-blue-200 bg-blue-50 rounded-xl">
<User className="w-8 h-8 text-blue-500 mx-auto mb-2" />
<p className="text-sm font-bold text-blue-700">Credit to: <span className="text-blue-900">{customer.phone}</span></p>
</div>
) : (
<div className="text-center p-5 border-2 border-dashed border-red-200 bg-red-50 rounded-xl">
<AlertCircle className="w-8 h-8 text-red-500 mx-auto mb-2" />
<p className="text-sm font-bold text-red-600 mb-3">Attach a customer to issue Store Credit</p>
<Button
label="Add Customer"
variant="destructive"
size="sm"
onClick={() => { setShowPayment(false); setShowCustomerPrompt(true); }}
/>
</div>
)}
</div>
)}
{/* UPI */}
{payMethod === 'upi' && grandTotal >= 0 && (
<div className="text-center space-y-3 animate-in slide-in-from-bottom-2 duration-200">
<div className="w-36 h-36 bg-slate-100 rounded-2xl mx-auto border-2 border-dashed border-slate-300 flex items-center justify-center">
<QrCode className="w-14 h-14 text-slate-400" />
</div>
<p className="text-sm font-bold text-slate-600">Scan QR Code to Pay</p>
<p className="text-xs text-slate-400">Awaiting payment confirmation…</p>
</div>
)}
{/* Card */}
{payMethod === 'card' && grandTotal >= 0 && (
<div className="text-center p-8 border-2 border-dashed border-slate-200 rounded-xl animate-in slide-in-from-bottom-2 duration-200">
<CreditCard className="w-12 h-12 text-slate-300 mx-auto mb-3" />
<p className="font-bold text-slate-600">Tap, swipe or insert card</p>
<p className="text-xs text-slate-400 mt-1">Waiting for terminal…</p>
</div>
)}
{/* Same exception as PAY, and for the same reason: this is the
commit point of the transaction. */}
<button
onClick={confirmPayment}
disabled={payMethod === 'store_credit' && !customer}
className="w-full py-4 bg-emerald-600 hover:bg-emerald-700 disabled:bg-slate-200 disabled:text-slate-400 text-white rounded-xl font-black text-lg transition-all shadow-lg shadow-emerald-600/25 disabled:shadow-none"
>
Confirm {grandTotal < 0 ? 'Refund' : 'Payment'}
</button>
</div>
</PosModal>
)}
{/* ── MODAL: Receipt ────────────────────────────────────────────── */}
{showReceipt && (
<PosModal onClose={resetPos} width={400}>
<div className="px-5 py-4 border-b border-slate-100 flex justify-between items-center bg-slate-50 print:hidden">
<h3 className="font-bold text-slate-900 flex items-center gap-2">
<Receipt className="w-4 h-4" /> Receipt
</h3>
<IconButton label="Close receipt" variant="ghost" size="sm" icon={<X className="w-4 h-4" />} onClick={resetPos} />
</div>
<div className="p-6 font-mono text-sm text-black print:p-0">
<div className="text-center mb-6">
<h2 className="text-lg font-black">Departmental Store</h2>
<p className="text-xs text-slate-500 mt-1">GSTIN: 29ABCDE1234F1Z5</p>
<p className="text-xs text-slate-500">{new Date().toLocaleString('en-IN')}</p>
</div>
<div className="border-t border-dashed border-slate-300 my-4" />
<div className="space-y-3">
{cartWithPromos.map((item) => (
<div key={item.id} className="text-xs">
<div className="font-bold text-sm flex items-center gap-1">
{item.isRefund && <span className="text-red-600">REFUND</span>} {item.name}
</div>
<div className="flex justify-between text-slate-600 mt-0.5">
<span>{item.qty} {item.unit} × ₹{item.price.toFixed(2)}</span>
<span className={item.isRefund ? 'text-red-600' : ''}>{item.isRefund ? '-' : ''}₹{(item.price * item.qty).toFixed(2)}</span>
</div>
{(item.discountPerItem > 0 || item.hasBogo) && (
<div className="flex justify-between font-bold">
<span>Discount {item.couponCode ? `(${item.couponCode})` : item.hasBogo ? '(BOGO)' : ''}</span>
<span>-₹{((item.discountPerItem * item.qty) + (item.promoDiscount || 0)).toFixed(2)}</span>
</div>
)}
</div>
))}
</div>
<div className="border-t border-dashed border-slate-300 my-4" />
<div className="space-y-1 text-xs">
<div className="flex justify-between"><span>Subtotal</span><span>₹{subtotal.toFixed(2)}</span></div>
<div className="flex justify-between"><span>Discounts</span><span>-₹{totalDiscount.toFixed(2)}</span></div>
<div className="flex justify-between"><span>CGST (9%)</span><span>₹{cgst.toFixed(2)}</span></div>
<div className="flex justify-between"><span>SGST (9%)</span><span>₹{sgst.toFixed(2)}</span></div>
<div className="flex justify-between"><span>Packaging</span><span>₹{packagingCharge.toFixed(2)}</span></div>
<div className="flex justify-between font-bold text-sm mt-2 pt-2 border-t border-dashed border-slate-300">
<span>Grand Total</span><span>₹{grandTotal.toFixed(2)}</span>
</div>
<div className="flex justify-between mt-1">
<span>{grandTotal < 0 ? 'Refund via' : 'Paid via'}</span>
<span className="uppercase font-bold">{payMethod.replace('_', ' ')}</span>
</div>
</div>
<div className="border-t border-dashed border-slate-300 my-4" />
<div className="text-center text-xs font-bold">Thank you for shopping! 🛍</div>
</div>
<div className="px-5 py-4 border-t border-slate-100 bg-slate-50 flex gap-3 print:hidden">
<Button label="New Sale" variant="secondary" onClick={resetPos} />
<Button label="Print" icon={<Receipt className="w-4 h-4" />} onClick={() => window.print()} />
</div>
</PosModal>
)}
{/* ── MODAL: Daily Report ───────────────────────────────────────── */}
{showReport && (
<PosModal onClose={() => setShowReport(false)} width={450}>
<div className="flex items-center justify-between px-5 py-4 border-b border-slate-100">
<h3 className="font-bold text-slate-900 flex items-center gap-2">
<PieChart className="w-5 h-5 text-emerald-500" /> Daily Sales Report
</h3>
<IconButton label="Close daily report" variant="ghost" size="sm" icon={<X className="w-4 h-4" />} onClick={() => setShowReport(false)} />
</div>
<div className="p-5 max-h-[70vh] overflow-y-auto space-y-5">
<div className="grid grid-cols-3 gap-3">
<div className="col-span-3 bg-white border border-slate-200 rounded-xl px-4 py-3 flex items-center justify-between shadow-sm">
<span className="text-slate-500 text-[11px] font-bold uppercase tracking-wider">Total Revenue</span>
<span className="text-slate-900 text-2xl font-black">₹{dailyReport.sales.toFixed(2)}</span>
</div>
<div className="bg-slate-50 rounded-xl px-3 py-3 border border-slate-100">
<p className="text-[10px] font-bold text-slate-500 uppercase tracking-wider mb-1">Transactions</p>
<p className="text-xl font-black text-slate-900">{dailyReport.tx}</p>
</div>
<div className="bg-slate-50 rounded-xl px-3 py-3 border border-slate-100">
<p className="text-[10px] font-bold text-slate-500 uppercase tracking-wider mb-1">GST</p>
<p className="text-xl font-black text-slate-900">₹{dailyReport.gst.toFixed(2)}</p>
</div>
<div className="bg-slate-50 rounded-xl px-3 py-3 border border-slate-100">
<p className="text-[10px] font-bold text-slate-500 uppercase tracking-wider mb-1">Avg. Bill</p>
<p className="text-xl font-black text-slate-900">
₹{dailyReport.tx > 0 ? (dailyReport.sales / dailyReport.tx).toFixed(0) : '0'}
</p>
</div>
</div>
<div>
<p className="text-xs font-bold text-slate-500 uppercase tracking-wider mb-2.5">Payment Split</p>
{dailyReport.sales > 0 ? (
<>
<div className="h-2.5 rounded-full flex overflow-hidden bg-slate-100 mb-3">
{dailyReport.cash > 0 && (
<div style={{ width: `${(dailyReport.cash / dailyReport.sales) * 100}%` }} className="bg-emerald-500" />
)}
{dailyReport.upi > 0 && (
<div style={{ width: `${(dailyReport.upi / dailyReport.sales) * 100}%` }} className="bg-blue-500" />
)}
{dailyReport.card > 0 && (
<div style={{ width: `${(dailyReport.card / dailyReport.sales) * 100}%` }} className="bg-amber-500" />
)}
</div>
<div className="grid grid-cols-3 gap-2">
{[
{ 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) => (
<div key={p.key} className="bg-slate-50 rounded-lg p-2.5 border border-slate-100">
<div className={`w-2.5 h-2.5 rounded-full ${p.color} mb-1.5`} />
<p className="text-[10px] font-bold text-slate-500 uppercase">{p.label}</p>
<p className="font-black text-slate-900 text-sm">₹{p.val.toFixed(0)}</p>
</div>
))}
</div>
</>
) : (
<div className="text-center py-4 text-slate-400 text-sm bg-slate-50 rounded-xl">
No transactions recorded today.
</div>
)}
</div>
<div>
<p className="text-xs font-bold text-slate-500 uppercase tracking-wider mb-2.5">Top Products</p>
{Object.keys(dailyReport.products || {}).length > 0 ? (
<div className="space-y-1.5 max-h-48 overflow-y-auto">
{Object.entries(dailyReport.products)
.sort(([, a], [, b]) => (b as number) - (a as number))
.map(([name, qty]) => (
<div key={name} className="flex justify-between items-center bg-slate-50 border border-slate-100 rounded-lg px-3.5 py-2.5">
<span className="text-sm font-medium text-slate-700 truncate pr-3">{name}</span>
<span className="font-bold text-emerald-700 text-sm shrink-0 bg-emerald-50 px-2 py-0.5 rounded-md">
{Number(qty).toFixed(2)} sold
</span>
</div>
))}
</div>
) : (
<div className="text-center py-4 text-slate-400 text-sm bg-slate-50 rounded-xl">
No products sold yet.
</div>
)}
</div>
</div>
</PosModal>
)}
</div>
);
}