1289 lines
61 KiB
TypeScript
1289 lines
61 KiB
TypeScript
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>
|
||
);
|
||
}
|