conversion done with astryx

This commit is contained in:
2026-08-13 18:33:22 +05:30
parent d935542b11
commit 0daec5311d
19 changed files with 3509 additions and 5144 deletions

View File

@@ -1,9 +1,20 @@
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 { 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.
@@ -407,6 +418,14 @@ export default function PosView({ locationid, tenantId = FIESTA_TENANT_ID }: Pos
.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>
@@ -427,43 +446,34 @@ export default function PosView({ locationid, tenantId = FIESTA_TENANT_ID }: Pos
</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
onClick={() => setShowManualSearch(true)}
className={`flex items-center gap-1.5 px-3 py-1.5 text-xs font-bold rounded-lg border transition-colors ${isRefundMode ? 'bg-white/10 hover:bg-white/20 text-white border-white/15' : 'bg-slate-50 hover:bg-slate-100 text-slate-700 border-slate-200'}`}
>
<Search className="w-3.5 h-3.5" /> Browse
</button>
<button
<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)}
className={`flex items-center gap-1.5 px-3 py-1.5 text-xs font-bold rounded-lg border transition-colors ${isRefundMode ? 'bg-white text-red-600 border-white' : 'bg-slate-50 hover:bg-slate-100 text-slate-700 border-slate-200'}`}
>
<RefreshCcw className="w-3.5 h-3.5" /> Refund
</button>
<button
/>
<Button
label="Park"
variant="secondary"
size="sm"
icon={<Save className="w-3.5 h-3.5" />}
isDisabled={cart.length === 0}
onClick={parkCart}
disabled={cart.length === 0}
className={`flex items-center gap-1.5 px-3 py-1.5 text-xs font-bold rounded-lg border transition-colors disabled:opacity-40 ${isRefundMode ? 'bg-white/10 hover:bg-white/20 text-white border-white/15' : 'bg-slate-50 hover:bg-slate-100 text-slate-700 border-slate-200'}`}
>
<Save className="w-3.5 h-3.5" /> Park
</button>
<button
onClick={() => setShowParkedPrompt(true)}
className={`relative flex items-center gap-1.5 px-3 py-1.5 text-xs font-bold rounded-lg border transition-colors ${isRefundMode ? 'bg-white/10 hover:bg-white/20 text-white border-white/15' : 'bg-slate-50 hover:bg-slate-100 text-slate-700 border-slate-200'}`}
>
<Download className="w-3.5 h-3.5" /> Retrieve
{parkedCarts.length > 0 && (
<span className="absolute -top-1.5 -right-1.5 w-4 h-4 bg-amber-400 text-slate-900 rounded-full text-[9px] font-black flex items-center justify-center">
{parkedCarts.length}
</span>
)}
</button>
<button
onClick={() => { checkDailyReset(); setShowReport(true); }}
className={`flex items-center gap-1.5 px-3 py-1.5 text-xs font-bold rounded-lg border transition-colors ${isRefundMode ? 'bg-white/10 hover:bg-white/20 text-white border-white/15' : 'bg-slate-50 hover:bg-slate-100 text-slate-700 border-slate-200'}`}
>
<PieChart className="w-3.5 h-3.5" /> Report
</button>
/>
{/* 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>
@@ -473,21 +483,26 @@ export default function PosView({ locationid, tenantId = FIESTA_TENANT_ID }: Pos
<div className={`scan-pulse ${isRefundMode ? 'text-red-400' : 'text-emerald-500'}`}>
<Barcode className="w-7 h-7" />
</div>
<input
ref={searchInputRef}
type="text"
placeholder={isRefundMode ? 'Scan item to refund…' : 'Scan barcode or type SKU…'}
value={search}
onChange={(e) => 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 && (
<button onClick={() => setSearch('')} className="text-slate-400 hover:text-slate-600">
<X className="w-4 h-4" />
</button>
)}
{/* 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
@@ -499,95 +514,78 @@ export default function PosView({ locationid, tenantId = FIESTA_TENANT_ID }: Pos
{/* Cart Table */}
<div className="flex-1 overflow-y-auto bg-white">
{stockQ.isLoading ? (
<div className="h-full flex flex-col items-center justify-center gap-3 text-slate-400">
<div className="w-8 h-8 border-2 border-emerald-500 border-t-transparent rounded-full animate-spin" />
<p className="text-sm font-semibold">Initializing inventory…</p>
<div className="h-full flex items-center justify-center">
<Spinner size="xl" label="Initializing inventory…" />
</div>
) : cart.length === 0 ? (
<div className="h-full flex flex-col items-center justify-center gap-5 text-slate-300 select-none">
<div className="w-24 h-24 rounded-3xl bg-slate-100 flex items-center justify-center">
<ShoppingCart className="w-12 h-12 text-slate-300" />
</div>
<div className="text-center">
<p className="font-bold text-slate-400 text-lg">Cart is empty</p>
<p className="text-sm text-slate-400 mt-1">Scan a barcode or browse products to add items</p>
</div>
<button
onClick={() => setShowManualSearch(true)}
className="flex items-center gap-2 px-5 py-2.5 bg-slate-900 text-white text-sm font-bold rounded-xl hover:bg-slate-800 transition-colors"
>
<Search className="w-4 h-4" /> Browse Products
</button>
<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 className="w-full text-left border-collapse min-w-[500px]">
<thead className="sticky top-0 z-10 bg-slate-50 border-b border-slate-200">
<tr>
<th className="px-5 py-3 text-[11px] font-bold uppercase tracking-wider text-slate-500">Product</th>
<th className="px-4 py-3 text-[11px] font-bold uppercase tracking-wider text-slate-500 text-center">Qty</th>
<th className="px-4 py-3 text-[11px] font-bold uppercase tracking-wider text-slate-500 text-right">Rate</th>
<th className="px-4 py-3 text-[11px] font-bold uppercase tracking-wider text-slate-500 text-right">Disc.</th>
<th className="px-4 py-3 text-[11px] font-bold uppercase tracking-wider text-slate-500 text-right">Amount</th>
<th className="px-5 py-3 text-[11px] font-bold uppercase tracking-wider text-slate-500 text-center w-20"></th>
</tr>
</thead>
<tbody>
<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) => (
<tr
<TableRow
key={item.id}
className={`group border-b border-slate-100 hover:bg-slate-50/80 transition-colors ${item.isRefund ? 'bg-red-50/50' : ''}`}
style={item.isRefund ? { background: 'rgba(244,63,94,0.06)' } : undefined}
>
<td className="px-5 py-3.5">
<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 && (
<span className="px-1.5 py-0.5 bg-red-100 text-red-600 text-[9px] uppercase font-black rounded tracking-wider">
Refund
</span>
)}
<span className="font-bold text-slate-900 text-[14px] leading-snug">{item.name}</span>
{item.isRefund && <Badge variant="error" label="Refund" />}
<span className="font-bold text-[14px] leading-snug">{item.name}</span>
</div>
<div className="text-[11px] text-slate-400 font-mono mt-0.5">{item.sku}</div>
<div className="text-[11px] font-mono mt-0.5 opacity-50">{item.sku}</div>
</div>
</div>
</td>
<td className="px-4 py-3.5 text-center">
<div className="inline-flex items-center bg-slate-100 rounded-lg overflow-hidden border border-slate-200">
<button
onClick={() => updateCartQty(item.id, item.qty - 1)}
className="w-8 h-8 flex items-center justify-center text-slate-500 hover:bg-red-50 hover:text-red-500 transition-colors"
>
<Minus className="w-3.5 h-3.5" />
</button>
<div className="flex items-center gap-0.5 px-1">
<input
type="number"
step={item.unit === 'kg' ? '0.001' : '1'}
value={item.qty}
onChange={(e) => 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"
/>
<span className="text-[10px] font-bold text-slate-400">{item.unit}</span>
</div>
<button
onClick={() => updateCartQty(item.id, item.qty + 1)}
disabled={item.qty >= item.maxQty}
className="w-8 h-8 flex items-center justify-center text-slate-500 hover:bg-emerald-50 hover:text-emerald-600 transition-colors disabled:opacity-30"
>
<Plus className="w-3.5 h-3.5" />
</button>
</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>
</td>
<td className="px-4 py-3.5 text-right">
<span className="font-bold text-slate-700 text-sm">₹{item.price.toFixed(2)}</span>
<span className="block text-[10px] text-slate-400">/{item.unit}</span>
</td>
<td className="px-4 py-3.5 text-right">
</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 && (
@@ -600,60 +598,66 @@ export default function PosView({ locationid, tenantId = FIESTA_TENANT_ID }: Pos
-₹{(item.promoDiscount || 0).toFixed(2)}
</span>
)}
<div className="flex gap-1 flex-wrap justify-end">
<div className="flex gap-1 flex-wrap justify-end items-center">
{item.discountPerItem > 0 && (
<span className="flex items-center gap-0.5 text-[9px] font-black text-emerald-700 bg-emerald-100 px-1.5 py-0.5 rounded-md uppercase tracking-wide">
{item.couponCode || 'MANUAL'}
<button onClick={() => removeCoupon(item.id)} className="ml-0.5 hover:text-red-500">
<X className="w-2.5 h-2.5" />
</button>
</span>
)}
{item.hasBogo && (
<span className="text-[9px] font-black text-amber-700 bg-amber-100 px-1.5 py-0.5 rounded-md uppercase tracking-wide">
BOGO
</span>
<>
<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-slate-300 text-sm">—</span>
<span className="text-sm block text-right opacity-30">—</span>
)}
</td>
<td className={`px-4 py-3.5 text-right font-bold text-[15px] ${item.isRefund ? 'text-red-600' : 'text-slate-900'}`}>
{item.isRefund ? '-' : ''}₹
{(
item.price * item.qty -
item.discountPerItem * item.qty -
(item.promoDiscount || 0)
).toFixed(2)}
</td>
<td className="px-5 py-3.5 text-center">
<div className="flex items-center justify-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
<button
</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() : '');
}}
className="w-8 h-8 flex items-center justify-center rounded-lg bg-blue-50 text-blue-500 hover:bg-blue-100 transition-colors"
title="Apply Discount"
>
<Tag className="w-3.5 h-3.5" />
</button>
<button
/>
<IconButton
label={`Remove ${item.name} from cart`}
variant="destructive"
size="sm"
icon={<Trash2 className="w-3.5 h-3.5" />}
onClick={() => removeCartItem(item.id)}
className="w-8 h-8 flex items-center justify-center rounded-lg bg-red-50 text-red-500 hover:bg-red-100 transition-colors"
title="Remove"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
/>
</div>
</td>
</tr>
</TableCell>
</TableRow>
))}
</tbody>
</table>
</TableBody>
</Table>
</div>
)}
</div>
@@ -683,21 +687,26 @@ export default function PosView({ locationid, tenantId = FIESTA_TENANT_ID }: Pos
<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>
<button onClick={() => setCustomer(null)} className="text-slate-400 hover:text-slate-600 ml-1">
<X className="w-3 h-3" />
</button>
<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
<Button
label="Customer"
variant="secondary"
size="sm"
icon={<Plus className="w-3.5 h-3.5" />}
onClick={() => setShowCustomerPrompt(true)}
className="flex items-center gap-1.5 bg-slate-50 hover:bg-slate-100 border border-slate-200 text-slate-700 text-xs font-bold px-3 py-2 rounded-xl transition-colors"
>
<Plus className="w-3.5 h-3.5" /> Customer
</button>
/>
)}
</div>
</div>
@@ -761,14 +770,19 @@ export default function PosView({ locationid, tenantId = FIESTA_TENANT_ID }: Pos
)}
</div>
<div className="flex gap-2">
<button
{/* 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}
disabled={cart.length === 0}
className="w-12 h-12 flex items-center justify-center bg-white border border-slate-200 hover:bg-red-50 hover:border-red-200 text-slate-500 hover:text-red-500 rounded-xl transition-all disabled:opacity-40"
>
<Trash2 className="w-5 h-5" />
</button>
/>
<button
onClick={handleCheckoutBtn}
disabled={cart.length === 0 || !customer}
@@ -787,18 +801,18 @@ export default function PosView({ locationid, tenantId = FIESTA_TENANT_ID }: Pos
<h3 className="font-bold text-slate-900 flex items-center gap-2">
<Search className="w-5 h-5 text-indigo-500" /> Product Search
</h3>
<button onClick={() => setShowManualSearch(false)} className="w-8 h-8 flex items-center justify-center rounded-lg hover:bg-slate-100 text-slate-400">
<X className="w-4 h-4" />
</button>
<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">
<input
type="text"
<TextInput
label="Search products"
isLabelHidden
placeholder="Search by name, SKU…"
value={search}
onChange={(e) => 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"
onChange={(v) => setSearch(v)}
startIcon={<Search className="w-4 h-4" />}
hasClear
hasAutoFocus
/>
</div>
<div className="flex-1 overflow-y-auto">
@@ -810,6 +824,10 @@ export default function PosView({ locationid, tenantId = FIESTA_TENANT_ID }: Pos
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}
@@ -840,52 +858,52 @@ export default function PosView({ locationid, tenantId = FIESTA_TENANT_ID }: Pos
<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>
<button onClick={closeCustomerPrompt} className="w-8 h-8 flex items-center justify-center rounded-lg hover:bg-slate-100 text-slate-400">
<X className="w-4 h-4" />
</button>
<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>
<input
type="tel"
placeholder="e.g. 9876543210"
value={customerPhoneInput}
onChange={(e) => 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}
/>
{/* 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 && (
<input
type="text"
placeholder="Customer Name"
value={customerNameInput}
onChange={(e) => 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
/>
<div className="mb-4">
<TextInput
label="Customer name"
placeholder="Customer Name"
value={customerNameInput}
onChange={(v) => setCustomerNameInput(v)}
onEnter={saveNewCustomer}
hasAutoFocus
/>
</div>
)}
{isNewCustomer ? (
<button
<Button
label="Save & Attach"
isDisabled={!customerNameInput.trim()}
onClick={saveNewCustomer}
disabled={!customerNameInput.trim()}
className="w-full py-3 bg-blue-600 hover:bg-blue-700 disabled:bg-slate-200 disabled:text-slate-400 text-white rounded-xl font-bold transition-colors"
>
Save & Attach
</button>
/>
) : (
<button
<Button
label="Find & Attach"
isDisabled={customerPhoneInput.length < 10}
onClick={checkCustomerPhone}
disabled={customerPhoneInput.length < 10}
className="w-full py-3 bg-blue-600 hover:bg-blue-700 disabled:bg-slate-200 disabled:text-slate-400 text-white rounded-xl font-bold transition-colors"
>
Find & Attach
</button>
/>
)}
</div>
</PosModal>
@@ -898,16 +916,11 @@ export default function PosView({ locationid, tenantId = FIESTA_TENANT_ID }: Pos
<h3 className="font-bold text-slate-900 flex items-center gap-2">
<Download className="w-5 h-5 text-blue-500" /> Parked Sales
</h3>
<button onClick={() => setShowParkedPrompt(false)} className="w-8 h-8 flex items-center justify-center rounded-lg hover:bg-slate-100 text-slate-400">
<X className="w-4 h-4" />
</button>
<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 ? (
<div className="text-center py-12 text-slate-400">
<Save className="w-10 h-10 mx-auto mb-3 opacity-30" />
<p className="text-sm font-medium">No parked sales</p>
</div>
<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">
@@ -917,12 +930,7 @@ export default function PosView({ locationid, tenantId = FIESTA_TENANT_ID }: Pos
{new Date(pc.time).toLocaleTimeString()} · {pc.cart.length} items · {pc.customer ? pc.customer.phone : 'Guest'}
</p>
</div>
<button
onClick={() => retrieveCart(pc)}
className="px-4 py-2 bg-blue-600 text-white text-sm font-bold rounded-lg hover:bg-blue-700 transition-colors"
>
Retrieve
</button>
<Button label="Retrieve" size="sm" onClick={() => retrieveCart(pc)} />
</div>
))
)}
@@ -938,47 +946,28 @@ export default function PosView({ locationid, tenantId = FIESTA_TENANT_ID }: Pos
<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>
<button onClick={() => setDiscountPromptItem(null)} className="w-8 h-8 flex items-center justify-center rounded-lg hover:bg-slate-100 text-slate-400">
<X className="w-4 h-4" />
</button>
<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">
<div>
<label className="block text-[11px] font-bold text-slate-500 uppercase tracking-wider mb-1.5">
Discount Amount (₹)
</label>
<input
type="number"
placeholder="0.00"
value={discountAmountInput}
onChange={(e) => 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
/>
</div>
<div>
<label className="block text-[11px] font-bold text-slate-500 uppercase tracking-wider mb-1.5">
Coupon Code (optional)
</label>
<input
type="text"
placeholder="e.g. OFF10"
value={couponCodeInput}
onChange={(e) => 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"
/>
</div>
<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
onClick={applyDiscountToItem}
className="w-full py-3 bg-emerald-600 hover:bg-emerald-700 text-white rounded-xl font-bold transition-colors"
>
Confirm Discount
</button>
<Button label="Confirm Discount" onClick={applyDiscountToItem} />
</div>
</PosModal>
)}
@@ -994,16 +983,27 @@ export default function PosView({ locationid, tenantId = FIESTA_TENANT_ID }: Pos
</p>
<p className="text-4xl font-black tracking-tight text-slate-900">₹{Math.abs(grandTotal).toFixed(2)}</p>
</div>
<button onClick={() => setShowPayment(false)} className="w-8 h-8 flex items-center justify-center rounded-lg bg-slate-100 hover:bg-slate-200 text-slate-500 mt-1">
<X className="w-4 h-4" />
</button>
<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>
<div className="grid grid-cols-3 gap-2">
{/* 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" />,
@@ -1014,38 +1014,22 @@ export default function PosView({ locationid, tenantId = FIESTA_TENANT_ID }: Pos
const labels: Record<string, string> = {
cash: 'Cash', upi: 'UPI', card: 'Card', store_credit: 'Store Credit',
};
return (
<button
key={m}
onClick={() => setPayMethod(m as any)}
className={`flex flex-col items-center gap-1.5 py-3 rounded-xl border-2 font-bold text-sm transition-all ${
payMethod === m
? 'border-emerald-500 bg-emerald-50 text-emerald-700'
: 'border-slate-200 bg-slate-50 text-slate-500 hover:border-slate-300'
}`}
>
{icons[m]}
<span className="text-[11px]">{labels[m]}</span>
</button>
);
return <SegmentedControlItem key={m} value={m} label={labels[m]} icon={icons[m]} />;
})}
</div>
</SegmentedControl>
</div>
{/* Cash Payment */}
{payMethod === 'cash' && grandTotal >= 0 && (
<div className="space-y-4 animate-in slide-in-from-bottom-2 duration-200">
<div>
<label className="block text-[11px] font-bold text-slate-400 uppercase tracking-wider mb-2">
Cash Received
</label>
<input
type="number"
value={cashReceived}
onChange={(e) => 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
<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 */}
@@ -1056,24 +1040,20 @@ export default function PosView({ locationid, tenantId = FIESTA_TENANT_ID }: Pos
.filter((d) => d >= grandTotal * 0.5)
.slice(0, 6)
.map((d) => (
<button
<Button
key={d}
label={`₹${d}`}
size="sm"
variant={parseFloat(cashReceived) === d ? 'primary' : 'secondary'}
onClick={() => setCashReceived(d.toString())}
className={`px-3 py-1.5 rounded-lg text-xs font-bold border transition-colors ${
parseFloat(cashReceived) === d
? 'bg-slate-900 text-white border-slate-900'
: 'bg-slate-50 text-slate-700 border-slate-200 hover:border-slate-400'
}`}
>
₹{d}
</button>
/>
))}
<button
<Button
label="Exact"
size="sm"
variant="secondary"
onClick={() => setCashReceived(Math.ceil(grandTotal).toString())}
className="px-3 py-1.5 rounded-lg text-xs font-bold border bg-emerald-50 text-emerald-700 border-emerald-200 hover:bg-emerald-100"
>
Exact
</button>
/>
</div>
</div>
{cashReceived && parseFloat(cashReceived) >= grandTotal && (
@@ -1105,12 +1085,12 @@ export default function PosView({ locationid, tenantId = FIESTA_TENANT_ID }: Pos
<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
<Button
label="Add Customer"
variant="destructive"
size="sm"
onClick={() => { setShowPayment(false); setShowCustomerPrompt(true); }}
className="px-4 py-2 bg-red-600 text-white rounded-lg text-sm font-bold hover:bg-red-700"
>
Add Customer
</button>
/>
</div>
)}
</div>
@@ -1136,6 +1116,8 @@ export default function PosView({ locationid, tenantId = FIESTA_TENANT_ID }: Pos
</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}
@@ -1154,9 +1136,7 @@ export default function PosView({ locationid, tenantId = FIESTA_TENANT_ID }: Pos
<h3 className="font-bold text-slate-900 flex items-center gap-2">
<Receipt className="w-4 h-4" /> Receipt
</h3>
<button onClick={resetPos} className="w-8 h-8 flex items-center justify-center rounded-lg hover:bg-slate-100 text-slate-400">
<X className="w-4 h-4" />
</button>
<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">
@@ -1205,15 +1185,8 @@ export default function PosView({ locationid, tenantId = FIESTA_TENANT_ID }: Pos
</div>
<div className="px-5 py-4 border-t border-slate-100 bg-slate-50 flex gap-3 print:hidden">
<button onClick={resetPos} className="flex-1 py-3 bg-white border border-slate-200 text-slate-700 rounded-xl font-bold hover:bg-slate-50 transition-colors">
New Sale
</button>
<button
onClick={() => window.print()}
className="flex-1 py-3 bg-slate-900 text-white rounded-xl font-bold flex items-center justify-center gap-2 hover:bg-slate-800 transition-colors"
>
<Receipt className="w-4 h-4" /> Print
</button>
<Button label="New Sale" variant="secondary" onClick={resetPos} />
<Button label="Print" icon={<Receipt className="w-4 h-4" />} onClick={() => window.print()} />
</div>
</PosModal>
)}
@@ -1225,9 +1198,7 @@ export default function PosView({ locationid, tenantId = FIESTA_TENANT_ID }: Pos
<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>
<button onClick={() => setShowReport(false)} className="w-8 h-8 flex items-center justify-center rounded-lg hover:bg-slate-100 text-slate-400">
<X className="w-4 h-4" />
</button>
<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">