diff --git a/src/components/InventoryView.tsx b/src/components/InventoryView.tsx index 1cf0d84..949afee 100644 --- a/src/components/InventoryView.tsx +++ b/src/components/InventoryView.tsx @@ -1214,113 +1214,156 @@ export default function InventoryView({ ) : activeTab === 'requests' ? (
-
-
-

- Pending Stock Requests -

-

Store Branch Inventory Requests

+
+
+
+ +
+
+

+ Pending Stock Requests +

+
+ Store Branch Inventory Requests + + {flattenedRequests.length} Total + +
+
+
- +
+ +
+ +
+
{flattenedRequests.length === 0 ? ( -
- -

No Pending Requests

-

All store stock requests have been processed.

+
+
+ +
+

No Pending Requests

+

+ You're all caught up! All store stock requests have been processed and there is currently no pending action required. +

) : ( -
+
- +
- + {['Requested At', 'Store', 'Product', 'Qty', 'Status', 'Resolved At', ''].map((h, i) => ( - + ))} - + {flattenedRequests.map((req, idx) => { const isApproved = req.pickData.status === 'Approved'; const isRejected = req.pickData.status === 'Rejected'; const isPending = req.pickData.status === 'Pending'; const isCancelled = req.pickData.status === 'Cancelled'; - const color = isApproved ? '#10b981' : isRejected ? '#f43f5e' : isCancelled ? '#94a3b8' : '#f59e0b'; - const DIVIDER_C = '#f1f5f9'; return ( - { e.currentTarget.style.background = SURFACE_ALT; }} onMouseLeave={(e) => { e.currentTarget.style.background = 'transparent'; }}> - setSelectedRequest(req)} + > + - - - - - -
{h}{h}
- - {req.pickData.requestedAt ? new Date(req.pickData.requestedAt).toLocaleString('en-IN', { dateStyle: 'medium', timeStyle: 'short' }) : '—'} - +
+
+ + {req.pickData.requestedAt ? new Date(req.pickData.requestedAt).toLocaleDateString('en-IN', { day: 'numeric', month: 'short' }) : '—'} + + + {req.pickData.requestedAt ? new Date(req.pickData.requestedAt).toLocaleTimeString('en-IN', { hour: '2-digit', minute: '2-digit' }) : ''} + +
- - {req.locationname} - + +
+ + {req.locationname} +
+
- {req.product?.name} -
-

{req.product?.name}

-

{req.product?.sku}

+
+ {req.product?.name} +
+
+

{req.product?.name}

+

{req.product?.sku}

- {req.pickData.qty || '—'} + + + {req.pickData.qty || '—'} + - + + + + {req.pickData.status || '—'} + - + + {req.pickData.resolvedAt ? new Date(req.pickData.resolvedAt).toLocaleString('en-IN', { dateStyle: 'medium', timeStyle: 'short' }) : '—'} + {isPending ? ( -
+
) : ( - + Processed )} diff --git a/src/components/PosView.tsx b/src/components/PosView.tsx new file mode 100644 index 0000000..1f362e6 --- /dev/null +++ b/src/components/PosView.tsx @@ -0,0 +1,1280 @@ +import React, { useState, useMemo, useEffect, useCallback, useRef } from 'react'; +import { Search, Plus, Minus, 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'; + +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) { + const stockQ = useFiestaStockStatement({ tenantid: tenantId, locationid: locationid ?? 0, pagesize: 200 }); + + const [search, setSearch] = useState(''); + const [cart, setCart] = useState([]); + + const [showPayment, setShowPayment] = useState(false); + const [showReceipt, setShowReceipt] = useState(false); + const [showReport, setShowReport] = useState(false); + const [showManualSearch, setShowManualSearch] = useState(false); + const [payMethod, setPayMethod] = useState<'cash' | 'upi' | 'card' | 'store_credit'>('cash'); + const [cashReceived, setCashReceived] = useState(''); + + const [discountPromptItem, setDiscountPromptItem] = useState(null); + const [couponCodeInput, setCouponCodeInput] = useState(''); + const [discountAmountInput, setDiscountAmountInput] = useState(''); + const couponInputRef = useRef(null); + const searchInputRef = useRef(null); + + const [dailyReport, setDailyReport] = useState({ sales: 0, tx: 0, gst: 0, cash: 0, upi: 0, card: 0, products: {} as Record }); + + const [isRefundMode, setIsRefundMode] = useState(false); + + const [customer, setCustomer] = useState<{ phone: string; points: number; name: string } | null>(null); + const [showCustomerPrompt, setShowCustomerPrompt] = useState(false); + const [customerPhoneInput, setCustomerPhoneInput] = useState(''); + const [customerNameInput, setCustomerNameInput] = useState(''); + const [isNewCustomer, setIsNewCustomer] = useState(false); + + const [parkedCarts, setParkedCarts] = useState<{ id: string; cart: CartItem[]; time: number; customer: any }[]>([]); + const [showParkedPrompt, setShowParkedPrompt] = useState(false); + + const inventory = useMemo(() => { + return (stockQ.data ?? []).map((r: Row, i: number) => { + const isWeighable = i % 4 === 0; + return { + id: fstr(r.productid), + name: fstr(r.productname) || 'Unnamed product', + sku: fstr(r.sku) || `SKU-${fstr(r.productid)}`, + closing: fnum(r.closing) ?? 0, + price: Math.floor(Math.random() * 500) + 50, + unit: isWeighable ? 'kg' : 'pc', + }; + }); + }, [stockQ.data]); + + const handleProductScanned = useCallback( + (product: any) => { + if (product.closing <= 0 && !isRefundMode) return; + setCart((prev) => { + const existing = prev.find((item) => item.id === product.id && !!item.isRefund === isRefundMode); + if (existing) { + if (!isRefundMode && existing.qty >= product.closing) return prev; + return prev.map((item) => + item.id === product.id && !!item.isRefund === isRefundMode ? { ...item, qty: item.qty + 1 } : item + ); + } + return [ + { + id: product.id, + name: product.name, + sku: product.sku, + price: product.price, + qty: product.unit === 'kg' ? 1.0 : 1, + maxQty: product.closing, + discountPerItem: 0, + unit: product.unit, + isRefund: isRefundMode, + }, + ...prev, + ]; + }); + setSearch(''); + }, + [isRefundMode] + ); + + const updateCartQty = (id: string, newQty: number) => { + setCart((prev) => + prev + .map((item) => { + if (item.id === id) { + if (newQty <= 0) return { ...item, qty: 0 }; + if (newQty > item.maxQty) return item; + return { ...item, qty: newQty }; + } + return item; + }) + .filter((item) => item.qty > 0) + ); + }; + + const removeCartItem = (id: string) => { + setCart((prev) => prev.filter((item) => item.id !== id)); + }; + + const clearCart = () => setCart([]); + + const applyDiscountToItem = () => { + if (!discountPromptItem) return; + const amt = parseFloat(discountAmountInput) || 0; + const code = couponCodeInput.trim().toUpperCase() || undefined; + setCart((prev) => + prev.map((item) => (item.id === discountPromptItem ? { ...item, discountPerItem: amt, couponCode: code } : item)) + ); + setDiscountPromptItem(null); + setCouponCodeInput(''); + setDiscountAmountInput(''); + setTimeout(() => searchInputRef.current?.focus(), 100); + }; + + const removeCoupon = (id: string) => { + setCart((prev) => + prev.map((item) => (item.id === id ? { ...item, discountPerItem: 0, couponCode: undefined } : item)) + ); + }; + + const cartWithPromos = useMemo(() => { + return cart.map((item) => { + if (item.isRefund) return { ...item, promoDiscount: 0, hasBogo: false }; + let promoDiscount = 0; + let hasBogo = false; + if (item.unit !== 'kg' && item.qty >= 3) { + const freeItems = Math.floor(item.qty / 3); + promoDiscount = freeItems * item.price; + hasBogo = true; + } + return { ...item, promoDiscount, hasBogo }; + }); + }, [cart]); + + const subtotal = cartWithPromos.reduce((sum, item) => sum + item.price * item.qty * (item.isRefund ? -1 : 1), 0); + const totalDiscount = cartWithPromos.reduce( + (sum, item) => sum + (item.discountPerItem * item.qty + (item.promoDiscount || 0)), + 0 + ); + const taxableAmount = Math.max(0, subtotal - totalDiscount); + const cgst = taxableAmount * 0.09; + const sgst = taxableAmount * 0.09; + const packagingCharge = cart.length > 0 && subtotal > 0 ? 10 : 0; + const grandTotal = subtotal < 0 ? subtotal : taxableAmount + cgst + sgst + packagingCharge; + + const parkCart = () => { + if (cart.length === 0) return; + setParkedCarts((prev) => [ + ...prev, + { id: `CART-${Date.now().toString().slice(-4)}`, cart, time: Date.now(), customer }, + ]); + setCart([]); + setCustomer(null); + setIsRefundMode(false); + }; + + const retrieveCart = (pc: any) => { + setCart(pc.cart); + setCustomer(pc.customer); + setParkedCarts((prev) => prev.filter((p) => p.id !== pc.id)); + setShowParkedPrompt(false); + }; + + useEffect(() => { + let barcode = ''; + let lastKeyTime = Date.now(); + const handleKeyDown = (e: KeyboardEvent) => { + if ( + showPayment || + showReceipt || + showReport || + discountPromptItem || + showManualSearch || + showCustomerPrompt || + showParkedPrompt + ) + return; + if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) return; + const currentTime = Date.now(); + if (currentTime - lastKeyTime > 100) barcode = ''; + lastKeyTime = currentTime; + if (e.key === 'Enter') { + if (barcode.length > 0) { + const product = inventory.find((p) => p.sku === barcode || p.id === barcode); + if (product) handleProductScanned(product); + barcode = ''; + e.preventDefault(); + } + } else if (e.key.length === 1) { + barcode += e.key; + } + }; + window.addEventListener('keydown', handleKeyDown); + return () => window.removeEventListener('keydown', handleKeyDown); + }, [ + inventory, + handleProductScanned, + showPayment, + showReceipt, + showReport, + discountPromptItem, + showManualSearch, + showCustomerPrompt, + showParkedPrompt, + ]); + + const handleSearchKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Enter') { + const val = search.trim(); + if (val) { + const product = inventory.find( + (p) => p.sku === val || p.id === val || p.name.toLowerCase() === val.toLowerCase() + ); + if (product) { + handleProductScanned(product); + } else { + alert('Product not found in inventory.'); + } + } + } + }; + + const getTodayKey = () => new Date().toISOString().split('T')[0]; + + const checkDailyReset = useCallback(() => { + const savedDate = localStorage.getItem('nearly_pos_date'); + const today = getTodayKey(); + if (savedDate !== today) { + localStorage.setItem('nearly_pos_date', today); + const initialReport = { sales: 0, tx: 0, gst: 0, cash: 0, upi: 0, card: 0, products: {} }; + localStorage.setItem('nearly_pos_report', JSON.stringify(initialReport)); + setDailyReport(initialReport); + } else { + const rep = localStorage.getItem('nearly_pos_report'); + if (rep) setDailyReport(JSON.parse(rep)); + } + }, []); + + useEffect(() => { + checkDailyReset(); + }, [checkDailyReset]); + + const recordSale = () => { + checkDailyReset(); + const rep = JSON.parse(localStorage.getItem('nearly_pos_report') || '{}'); + rep.sales = (rep.sales || 0) + grandTotal; + rep.tx = (rep.tx || 0) + 1; + rep.gst = (rep.gst || 0) + cgst + sgst; + rep[payMethod] = (rep[payMethod] || 0) + grandTotal; + if (!rep.products) rep.products = {}; + cart.forEach((item) => { + rep.products[item.name] = (rep.products[item.name] || 0) + item.qty; + }); + localStorage.setItem('nearly_pos_report', JSON.stringify(rep)); + setDailyReport(rep); + }; + + const handleCheckoutBtn = () => { + if (cart.length === 0) return; + setCashReceived(''); + setPayMethod(grandTotal < 0 ? 'store_credit' : 'cash'); + setShowPayment(true); + }; + + const confirmPayment = () => { + recordSale(); + setShowPayment(false); + setShowReceipt(true); + }; + + const resetPos = () => { + setCart([]); + setCustomer(null); + setIsRefundMode(false); + setShowReceipt(false); + setSearch(''); + setTimeout(() => searchInputRef.current?.focus(), 100); + }; + + const changeAmt = Math.max(0, (parseFloat(cashReceived) || 0) - grandTotal); + + const checkCustomerPhone = () => { + if (customerPhoneInput.length >= 10) { + const db = JSON.parse(localStorage.getItem('nearly_pos_customers') || '{}'); + if (db[customerPhoneInput]) { + // Existing customer + setCustomer({ + phone: customerPhoneInput, + name: db[customerPhoneInput].name, + points: db[customerPhoneInput].points || Math.floor(Math.random() * 500) + 50, + }); + closeCustomerPrompt(); + } else { + // New customer + setIsNewCustomer(true); + } + } + }; + + const saveNewCustomer = () => { + if (customerNameInput.trim()) { + const db = JSON.parse(localStorage.getItem('nearly_pos_customers') || '{}'); + const newCustomer = { + name: customerNameInput.trim(), + points: 50, // Starting points + }; + db[customerPhoneInput] = newCustomer; + localStorage.setItem('nearly_pos_customers', JSON.stringify(db)); + + setCustomer({ + phone: customerPhoneInput, + name: newCustomer.name, + points: newCustomer.points, + }); + closeCustomerPrompt(); + } + }; + + const closeCustomerPrompt = () => { + setShowCustomerPrompt(false); + setCustomerPhoneInput(''); + setCustomerNameInput(''); + setIsNewCustomer(false); + }; + + const cashDenominations = [10, 20, 50, 100, 200, 500, 2000]; + const now = new Date(); + const timeString = now.toLocaleTimeString('en-IN', { hour: '2-digit', minute: '2-digit' }); + const dateString = now.toLocaleDateString('en-IN', { day: '2-digit', month: 'short', year: 'numeric' }); + + return ( +
+ + + {/* ── LEFT PANEL: Cart Items ─────────────────────────────────────── */} +
+ + {/* Action Bar */} +
+
+
+ + POS Terminal +
+ {isRefundMode && ( + + Refund Mode + + )} +
+
+ + + + + +
+
+ + {/* Scan Bar */} +
+
+
+ +
+ setSearch(e.target.value)} + onKeyDown={handleSearchKeyDown} + className="flex-1 bg-transparent border-none outline-none font-bold text-xl text-slate-800 placeholder:font-normal placeholder:text-slate-400" + autoFocus + /> + {search && ( + + )} + {!search && ( + + Press Enter + + )} +
+
+ + {/* Cart Table */} +
+ {stockQ.isLoading ? ( +
+
+

Initializing inventory…

+
+ ) : cart.length === 0 ? ( +
+
+ +
+
+

Cart is empty

+

Scan a barcode or browse products to add items

+
+ +
+ ) : ( + + + + + + + + + + + + + {cartWithPromos.map((item, idx) => ( + + + + + + + + + ))} + +
ProductQtyRateDisc.Amount
+
+
+ {idx + 1} +
+
+
+ {item.isRefund && ( + + Refund + + )} + {item.name} +
+
{item.sku}
+
+
+
+
+ +
+ updateCartQty(item.id, parseFloat(e.target.value) || 0)} + className="w-12 text-center text-sm font-bold text-slate-900 bg-transparent border-none outline-none hide-spin-button" + /> + {item.unit} +
+ +
+
+ ₹{item.price.toFixed(2)} + /{item.unit} + + {item.discountPerItem > 0 || item.hasBogo ? ( +
+ {item.discountPerItem > 0 && ( + + -₹{(item.discountPerItem * item.qty).toFixed(2)} + + )} + {item.hasBogo && ( + + -₹{(item.promoDiscount || 0).toFixed(2)} + + )} +
+ {item.discountPerItem > 0 && ( + + {item.couponCode || 'MANUAL'} + + + )} + {item.hasBogo && ( + + BOGO + + )} +
+
+ ) : ( + + )} +
+ {item.isRefund ? '-' : ''}₹ + {( + item.price * item.qty - + item.discountPerItem * item.qty - + (item.promoDiscount || 0) + ).toFixed(2)} + +
+ + +
+
+ )} +
+ + {/* Cart Footer Summary (visible on mobile) */} + {cart.length > 0 && ( +
+ {cart.length} item{cart.length !== 1 ? 's' : ''} + ₹{grandTotal.toFixed(2)} +
+ )} +
+ + {/* ── RIGHT PANEL: Billing ───────────────────────────────────────── */} +
+ + {/* Panel Header */} +
+
+
+

Checkout

+

{dateString} · {timeString}

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

Add items to see totals

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

Grand Total

+

₹{grandTotal.toFixed(2)}

+
+ {grandTotal < 0 && ( +
+ Refund Due +
+ )} +
+ +
+ + +
+
+
+ + {/* ── MODAL: Manual Product Search ──────────────────────────────── */} + {showManualSearch && ( +
+
+
+

+ Product Search +

+ +
+
+ setSearch(e.target.value)} + autoFocus + className="w-full px-4 py-2.5 bg-slate-50 border border-slate-200 rounded-xl text-sm font-medium text-slate-800 outline-none focus:border-indigo-400 focus:ring-2 focus:ring-indigo-400/20 transition-all" + /> +
+
+ {inventory + .filter( + (p) => + !search || + p.name.toLowerCase().includes(search.toLowerCase()) || + p.sku.toLowerCase().includes(search.toLowerCase()) + ) + .map((product) => ( + + ))} +
+
+
+ )} + + {/* ── MODAL: Customer CRM ───────────────────────────────────────── */} + {showCustomerPrompt && ( +
+
+
+
+
+ +
+ +
+

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

+

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

+ + setCustomerPhoneInput(e.target.value)} + onKeyDown={(e) => e.key === 'Enter' && !isNewCustomer && checkCustomerPhone()} + disabled={isNewCustomer} + className={`w-full px-4 py-3 bg-slate-50 border border-slate-200 rounded-xl font-bold text-slate-800 outline-none focus:border-blue-400 focus:ring-2 focus:ring-blue-400/20 transition-all ${isNewCustomer ? 'opacity-50 mb-3' : 'mb-4'}`} + autoFocus={!isNewCustomer} + /> + + {isNewCustomer && ( + setCustomerNameInput(e.target.value)} + onKeyDown={(e) => e.key === 'Enter' && saveNewCustomer()} + className="w-full px-4 py-3 bg-slate-50 border border-slate-200 rounded-xl font-bold text-slate-800 outline-none focus:border-blue-400 focus:ring-2 focus:ring-blue-400/20 transition-all mb-4" + autoFocus + /> + )} + + {isNewCustomer ? ( + + ) : ( + + )} +
+
+
+ )} + + {/* ── MODAL: Parked Carts ───────────────────────────────────────── */} + {showParkedPrompt && ( +
+
+
+

+ Parked Sales +

+ +
+
+ {parkedCarts.length === 0 ? ( +
+ +

No parked sales

+
+ ) : ( + parkedCarts.map((pc) => ( +
+
+

{pc.id}

+

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

+
+ +
+ )) + )} +
+
+
+ )} + + {/* ── MODAL: Discount ───────────────────────────────────────────── */} + {discountPromptItem && ( +
+
+
+
+
+ +
+ +
+

Apply Discount

+

Enter a manual discount or attach a coupon code.

+
+
+ + setDiscountAmountInput(e.target.value)} + onKeyDown={(e) => e.key === 'Enter' && applyDiscountToItem()} + className="w-full px-4 py-3 bg-slate-50 border border-slate-200 rounded-xl font-bold text-slate-800 outline-none focus:border-emerald-400 focus:ring-2 focus:ring-emerald-400/20 transition-all" + autoFocus + /> +
+
+ + setCouponCodeInput(e.target.value.toUpperCase())} + onKeyDown={(e) => e.key === 'Enter' && applyDiscountToItem()} + className="w-full px-4 py-3 bg-slate-50 border border-slate-200 rounded-xl font-bold text-slate-800 uppercase outline-none focus:border-emerald-400 focus:ring-2 focus:ring-emerald-400/20 transition-all" + /> +
+
+ +
+
+
+ )} + + {/* ── MODAL: Payment ────────────────────────────────────────────── */} + {showPayment && ( +
+
+ {/* Header */} +
+
+

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

+

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

+
+ +
+ +
+ {/* Payment Method Tabs */} +
+

Payment Method

+
+ {(grandTotal >= 0 ? ['cash', 'upi', 'card'] : ['cash', 'store_credit']).map((m) => { + const icons: Record = { + cash: , + upi: , + card: , + store_credit: , + }; + const labels: Record = { + cash: 'Cash', upi: 'UPI', card: 'Card', store_credit: 'Store Credit', + }; + return ( + + ); + })} +
+
+ + {/* Cash Payment */} + {payMethod === 'cash' && grandTotal >= 0 && ( +
+
+ + setCashReceived(e.target.value)} + className="w-full px-4 py-3 bg-slate-50 border-2 border-slate-200 rounded-xl font-black text-2xl text-slate-900 outline-none focus:border-emerald-400 focus:ring-2 focus:ring-emerald-400/20 transition-all text-right" + placeholder="0.00" + autoFocus + /> +
+ {/* Quick denominations */} +
+

Quick Select

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

Return Cash to Customer

+

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

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

Credit to: {customer.phone}

+
+ ) : ( +
+ +

Attach a customer to issue Store Credit

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

Scan QR Code to Pay

+

Awaiting payment confirmation…

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

Tap, swipe or insert card

+

Waiting for terminal…

+
+ )} + + +
+
+
+ )} + + {/* ── MODAL: Receipt ────────────────────────────────────────────── */} + {showReceipt && ( +
+
+
+

+ Receipt +

+ +
+ +
+
+

Departmental Store

+

GSTIN: 29ABCDE1234F1Z5

+

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

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

+ Daily Sales Report +

+ +
+ +
+
+
+ Total Revenue + ₹{dailyReport.sales.toFixed(2)} +
+
+

Transactions

+

{dailyReport.tx}

+
+
+

GST

+

₹{dailyReport.gst.toFixed(2)}

+
+
+

Avg. Bill

+

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

+
+
+ +
+

Payment Split

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

{p.label}

+

₹{p.val.toFixed(0)}

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

Top Products

+ {Object.keys(dailyReport.products || {}).length > 0 ? ( +
+ {Object.entries(dailyReport.products) + .sort(([, a], [, b]) => (b as number) - (a as number)) + .map(([name, qty]) => ( +
+ {name} + + {Number(qty).toFixed(2)} sold + +
+ ))} +
+ ) : ( +
+ No products sold yet. +
+ )} +
+
+
+
+ )} +
+ ); +} diff --git a/src/components/StoreCatalogView.tsx b/src/components/StoreCatalogView.tsx index c1f6daf..6648c50 100644 --- a/src/components/StoreCatalogView.tsx +++ b/src/components/StoreCatalogView.tsx @@ -540,40 +540,45 @@ export default function StoreCatalogView({ locationid, storeName = 'your store', sub="Browse the catalogue and add items to your store to request stock." /> ) : ( -
+
- +
- + {['Requested At', 'Product', 'Qty', 'Status', 'Resolved At'].map((h, i) => ( - + ))} - + {Object.entries(picks).map(([pid, data]: [string, any]) => { const prod = products.find(p => p.id === pid); const isApproved = data.status === 'Approved'; const isRejected = data.status === 'Rejected'; - const color = isApproved ? '#10b981' : isRejected ? '#f43f5e' : data.status === 'Cancelled' ? '#94a3b8' : '#f59e0b'; - const DIVIDER_C = '#f1f5f9'; + const isCancelled = data.status === 'Cancelled'; return ( - { e.currentTarget.style.background = SURFACE_ALT; }} onMouseLeave={(e) => { e.currentTarget.style.background = 'transparent'; }}> - + - - - - diff --git a/src/components/UserStorePage.tsx b/src/components/UserStorePage.tsx index 1308990..02831aa 100644 --- a/src/components/UserStorePage.tsx +++ b/src/components/UserStorePage.tsx @@ -34,6 +34,7 @@ import StoreCatalogView from './StoreCatalogView'; import DispatchHubView from './DispatchHubView'; import DeliveryReportsView from './DeliveryReportsView'; import StoreQRView from './StoreQRView'; +import PosView from './PosView'; import UserStoreSidebar, { type UserNavItem } from './UserStoreSidebar'; import ComparisonModal from './ComparisonModal'; interface UserStorePageProps { @@ -48,6 +49,7 @@ interface UserStorePageProps { const NAV_ITEMS: UserNavItem[] = [ { id: 'console', label: 'Store Console', icon: LayoutDashboard }, { id: 'inventory', label: 'Products', icon: Layers }, + { id: 'pos', label: 'POS Terminal', icon: ShoppingBag }, { id: 'customers', label: 'Customers', icon: Users }, { id: 'dispatch', label: 'Dispatch', icon: Route }, { id: 'reports', label: 'Reports', icon: ClipboardList }, @@ -191,6 +193,7 @@ export default function UserStorePage({ onLogout, user }: UserStorePageProps) { // Logistics console — scoped to this user's store. These views own their // loading/error states, so they don't need the store-console load gating below. + if (activeSection === 'pos') return ; if (activeSection === 'dispatch') return ; if (activeSection === 'reports') return ; // Inventory & Catalog is its own page: the manager-curated catalog the user @@ -278,6 +281,8 @@ export default function UserStorePage({ onLogout, user }: UserStorePageProps) { ? storeName : activeSection === 'inventory' ? 'Products' + : activeSection === 'pos' + ? 'POS Terminal' : activeSection === 'account' ? 'My Account' : activeSection.charAt(0).toUpperCase() + activeSection.slice(1), @@ -285,6 +290,8 @@ export default function UserStorePage({ onLogout, user }: UserStorePageProps) { ? LayoutDashboard : activeSection === 'inventory' ? Layers + : activeSection === 'pos' + ? ShoppingBag : activeSection === 'customers' ? Users : activeSection === 'dispatch' @@ -307,10 +314,10 @@ export default function UserStorePage({ onLogout, user }: UserStorePageProps) { className={`flex-1 min-w-0 transition-all duration-300 ${ // Dispatch is a full-bleed cockpit — fill the area exactly (no page // padding) so it sits flush under the header. Other pages stay padded. - activeSection === 'dispatch' ? 'h-[calc(100vh-64px)] overflow-hidden' : 'min-h-[calc(100vh-64px)]' + activeSection === 'dispatch' || activeSection === 'pos' ? 'h-[calc(100vh-64px)] overflow-hidden' : 'min-h-[calc(100vh-64px)]' } ${sidebarOpen ? 'md:pl-64' : 'md:pl-20'}`} > - {activeSection === 'dispatch' ? ( + {activeSection === 'dispatch' || activeSection === 'pos' ? (
{renderSection()}
) : (
{h}{h}
- - {data.requestedAt ? new Date(data.requestedAt).toLocaleString('en-IN', { dateStyle: 'medium', timeStyle: 'short' }) : '—'} - +
+
+ + {data.requestedAt ? new Date(data.requestedAt).toLocaleDateString('en-IN', { day: 'numeric', month: 'short' }) : '—'} + + + {data.requestedAt ? new Date(data.requestedAt).toLocaleTimeString('en-IN', { hour: '2-digit', minute: '2-digit' }) : ''} + +
+
{prod ? ( <> - {prod.name} -
-

{prod.name}

-

{prod.sku}

+
+ {prod.name} +
+
+

{prod.name}

+

{prod.sku}

) : ( @@ -581,14 +586,29 @@ export default function StoreCatalogView({ locationid, storeName = 'your store', )}
- {data.qty || '—'} + + + {data.qty || '—'} + - + + + + {data.status || '—'} + - + + {data.resolvedAt ? new Date(data.resolvedAt).toLocaleString('en-IN', { dateStyle: 'medium', timeStyle: 'short' }) : '—'}