page wise astryx conversion completed

This commit is contained in:
2026-08-14 12:33:42 +05:30
parent 0daec5311d
commit cd079950eb
18 changed files with 1154 additions and 696 deletions

View File

@@ -13,8 +13,9 @@
* Nominatim usage policy: light, debounced, one request per keystroke-pause.
*/
import React, { useEffect, useRef, useState } from 'react';
import React, { useEffect, useId, useRef, useState } from 'react';
import { MapPin, Loader2, Search } from 'lucide-react';
import { TextInput } from '@astryxdesign/core/TextInput';
export interface AddressResult {
address: string;
@@ -62,6 +63,9 @@ export default function AddressAutocomplete({
const [loading, setLoading] = useState(false);
const [highlight, setHighlight] = useState(-1);
const boxRef = useRef<HTMLDivElement>(null);
const listId = useId();
/** Set when `query` is changed by us rather than by typing (see the effect). */
const skipSearchRef = useRef(false);
// Keep the input in sync if the parent resets the value (e.g. after submit).
useEffect(() => { setQuery(value); }, [value]);
@@ -69,6 +73,13 @@ export default function AddressAutocomplete({
// Debounced Nominatim lookup.
useEffect(() => {
const q = query.trim();
// Picking a suggestion writes the chosen address back into `query`, which
// lands here as if the user had typed it — so the list re-opened over the
// answer they had just chosen, and fired another Nominatim call to do it.
// Pre-existing, and invisible enough to live with while the open state was
// only a background colour; now that it is `aria-expanded`, it would be
// telling a screen reader the list is open every time it closes.
if (skipSearchRef.current) { skipSearchRef.current = false; return; }
if (q.length < 3) { setOptions([]); setOpen(false); return; }
let active = true;
setLoading(true);
@@ -100,6 +111,7 @@ export default function AddressAutocomplete({
const pick = (row: NominatimRow) => {
const result = parseRow(row);
skipSearchRef.current = true;
setQuery(result.address);
setOptions([]);
setOpen(false);
@@ -116,36 +128,64 @@ export default function AddressAutocomplete({
return (
<div className="relative" ref={boxRef}>
<span className="absolute inset-y-0 left-0 pl-3.5 flex items-center pointer-events-none text-slate-400">
<Search size={14} />
</span>
<input
type="text"
{/*
PHASE 6 — this was a combobox in behaviour with none of it declared.
The input carried no label and no role; the suggestion list was a plain
<ul> of <button>s. Arrow keys moved a purple highlight that existed only
as a background colour, so a screen reader user typed an address, was
told nothing had happened, and had no way to know six suggestions had
appeared beneath them.
It is now the real pattern: the input is a combobox owning a listbox,
each row is an option, and `aria-activedescendant` follows the same
highlight the arrow keys already moved. The rows stop being <button>s
because an option must not contain one — keyboard interaction belongs
to the input, which is where it already was.
NOT Astryx's Typeahead, which is otherwise the exact component: it holds
a selected *item*, and all three call sites pre-fill `value` with a saved
address string when editing an existing store (AdminConsole 157/170).
Under Typeahead that address would render as an empty box.
*/}
<TextInput
label="Address"
isLabelHidden
value={query}
placeholder={placeholder}
autoComplete="off"
onChange={(e) => { setQuery(e.target.value); if (!e.target.value) onSelect(null); }}
startIcon={<Search size={14} />}
onChange={(v) => { setQuery(v); if (!v) onSelect(null); }}
onFocus={() => { if (options.length) setOpen(true); }}
onKeyDown={onKeyDown}
className="w-full border border-slate-200 rounded-xl pl-10 pr-9 py-3 bg-slate-50/40 hover:bg-slate-100 focus:bg-white outline-none focus:border-purple-500 focus:ring-4 focus:ring-purple-500/10 transition-all text-slate-800 font-semibold text-sm shadow-sm"
role="combobox"
aria-expanded={open && options.length > 0}
aria-controls={listId}
aria-autocomplete="list"
aria-activedescendant={open && highlight >= 0 ? `${listId}-${highlight}` : undefined}
className="w-full border border-slate-200 rounded-xl bg-slate-50/40 hover:bg-slate-100 text-slate-800 font-semibold text-sm shadow-sm"
/>
{loading && <span className="absolute inset-y-0 right-0 pr-3 flex items-center text-slate-400"><Loader2 size={14} className="animate-spin" /></span>}
{open && options.length > 0 && (
<ul className="absolute z-20 mt-1 w-full bg-white border border-slate-200 rounded-xl shadow-lg max-h-60 overflow-y-auto py-1">
<ul
id={listId}
role="listbox"
aria-label="Address suggestions"
className="absolute z-20 mt-1 w-full bg-white border border-slate-200 rounded-xl shadow-lg max-h-60 overflow-y-auto py-1"
>
{options.map((o, i) => (
<li key={`${o.lat},${o.lon},${i}`}>
<button
type="button"
onMouseEnter={() => setHighlight(i)}
onClick={() => pick(o)}
className={`w-full text-left px-3 py-2 flex items-start gap-2 cursor-pointer transition-colors ${
highlight === i ? 'bg-purple-50' : 'hover:bg-slate-50'
}`}
>
<MapPin size={13} className="text-purple-500 shrink-0 mt-0.5" />
<span className="text-[12.5px] text-slate-700 leading-snug">{o.display_name}</span>
</button>
<li
key={`${o.lat},${o.lon},${i}`}
id={`${listId}-${i}`}
role="option"
aria-selected={highlight === i}
onMouseEnter={() => setHighlight(i)}
onClick={() => pick(o)}
className={`w-full text-left px-3 py-2 flex items-start gap-2 cursor-pointer transition-colors ${
highlight === i ? 'bg-purple-50' : 'hover:bg-slate-50'
}`}
>
<MapPin size={13} className="text-purple-500 shrink-0 mt-0.5" />
<span className="text-[12.5px] text-slate-700 leading-snug">{o.display_name}</span>
</li>
))}
</ul>

View File

@@ -19,6 +19,8 @@ import { CardCarousel } from './CatalogueGallery';
// lands on two logins at once.
import { TextInput } from '@astryxdesign/core/TextInput';
import { CheckboxInput } from '@astryxdesign/core/CheckboxInput';
import { Button } from '@astryxdesign/core/Button';
import { IconButton } from '@astryxdesign/core/IconButton';
interface CatalogueBrowserProps {
tenantid: number;
@@ -158,12 +160,12 @@ export default function CatalogueBrowser({ tenantid, locationid, onClose }: Cata
</div>
{(brand || keyword) && (
<button
<Button
label="Clear All Filters"
onClick={() => { setBrand(undefined); setKeyword(''); }}
style={{ height: 'auto' }}
className="w-full py-2 bg-slate-100 text-slate-600 rounded-none text-[10px] font-bold uppercase tracking-wider hover:bg-slate-200 transition-colors cursor-pointer border-none mt-4"
>
Clear All Filters
</button>
/>
)}
</div>
</div>
@@ -175,21 +177,28 @@ export default function CatalogueBrowser({ tenantid, locationid, onClose }: Cata
<div className={`flex justify-between gap-4 mb-5 shrink-0 ${isLocalSidebarOpen ? 'flex-col lg:flex-row lg:items-center items-start' : 'flex-col lg:flex-row lg:items-center items-start'}`}>
<div className="flex-1 min-w-0 pr-4 flex items-start gap-2">
{onClose && (
<button
<IconButton
icon={<ArrowLeft size={16} />}
label="Back to Products"
tooltip="Back to Products"
variant="ghost"
onClick={onClose}
style={{ height: 'auto', width: 'auto', minWidth: 0 }}
className="p-1.5 rounded-lg border border-slate-200 text-slate-500 hover:bg-slate-100 hover:text-[#662582] transition-colors mt-0.5 cursor-pointer shrink-0"
title="Back to Products"
>
<ArrowLeft size={16} />
</button>
/>
)}
<button
{/* Was a `title`-only toggle with no pressed state — the filter
rail's open/closed status was visible but never announced. */}
<IconButton
icon={<Filter size={16} />}
label={isLocalSidebarOpen ? 'Hide filters' : 'Show filters'}
tooltip="Toggle Filters"
variant="ghost"
aria-expanded={isLocalSidebarOpen}
onClick={() => setIsLocalSidebarOpen(!isLocalSidebarOpen)}
style={{ height: 'auto', width: 'auto', minWidth: 0 }}
className="p-1.5 rounded-lg border border-slate-200 text-slate-500 hover:bg-slate-100 hover:text-[#662582] transition-colors mt-0.5 cursor-pointer shrink-0"
title="Toggle Filters"
>
<Filter size={16} />
</button>
/>
<div>
<h3 className="font-sans font-bold text-sm text-[#0f172a] flex items-center gap-1.5 flex-wrap">
<Box size={15} className="text-[#662582] shrink-0" /> <span className="truncate">Global Product Catalogue</span>
@@ -263,15 +272,24 @@ export default function CatalogueBrowser({ tenantid, locationid, onClose }: Cata
</div>
{isImported ? (
<button
<Button
label={`${p.product_name} is already imported — open its details`}
icon={<CheckCircle2 size={12} />}
onClick={(e) => { e.stopPropagation(); setImportingProduct(p); }}
style={{ height: 'auto' }}
className="w-full bg-emerald-50 text-emerald-700 border border-emerald-200 px-3 py-2 rounded-none text-[10px] font-bold uppercase tracking-wider transition-all shadow-sm flex items-center justify-center gap-1.5 cursor-pointer"
>
<CheckCircle2 size={12} />
Imported
</button>
</Button>
) : (
<button
<Button
// Every card repeated the same two words, so the
// grid read as "Import Product" N times over with
// no way to tell which product was about to be
// imported. The label names it; the visible text
// is unchanged.
label={`Import ${p.product_name}`}
icon={<DownloadCloud size={12} />}
// One click, straight into the admin catalogue.
//
// No price, no category prompt: importing does not
@@ -301,11 +319,11 @@ export default function CatalogueBrowser({ tenantid, locationid, onClose }: Cata
taxpercent: 0,
});
}}
style={{ height: 'auto' }}
className="w-full bg-[#f8fafc] hover:bg-[#662582] text-slate-600 hover:text-white border border-slate-200 hover:border-[#662582] px-3 py-2 rounded-none text-[10px] font-bold uppercase tracking-wider transition-all shadow-sm flex items-center justify-center gap-1.5 cursor-pointer"
>
<DownloadCloud size={12} />
Import Product
</button>
</Button>
)}
</div>
</div>

View File

@@ -19,6 +19,7 @@
import React, { useCallback, useEffect, useState } from 'react';
import { Box, ChevronLeft, ChevronRight, X } from 'lucide-react';
import { IconButton } from '@astryxdesign/core/IconButton';
/**
* The image on a browse card, with the rest reachable.
@@ -62,20 +63,22 @@ export function CardCarousel({
{/* Faint at rest and solid on hover rather than hidden until hover:
a touch device never hovers, and controls that only exist on a
mouse are controls half the users never find. */}
<button
<IconButton
icon={<ChevronLeft size={15} />}
label={`Previous image of ${alt}`}
variant="ghost"
onClick={(e) => step(e, -1)}
aria-label="Previous image"
style={{ height: 28, width: 28, minWidth: 0 }}
className="absolute left-1 top-1/2 -translate-y-1/2 z-10 h-7 w-7 rounded-full bg-white/70 hover:bg-white text-slate-700 flex items-center justify-center shadow-sm opacity-40 group-hover:opacity-100 transition-opacity cursor-pointer"
>
<ChevronLeft size={15} />
</button>
<button
/>
<IconButton
icon={<ChevronRight size={15} />}
label={`Next image of ${alt}`}
variant="ghost"
onClick={(e) => step(e, 1)}
aria-label="Next image"
style={{ height: 28, width: 28, minWidth: 0 }}
className="absolute right-1 top-1/2 -translate-y-1/2 z-10 h-7 w-7 rounded-full bg-white/70 hover:bg-white text-slate-700 flex items-center justify-center shadow-sm opacity-40 group-hover:opacity-100 transition-opacity cursor-pointer"
>
<ChevronRight size={15} />
</button>
/>
<div className="absolute bottom-1.5 left-0 right-0 z-10 flex items-center justify-center gap-1 pointer-events-none">
{list.slice(0, 8).map((_, n) => (
@@ -157,13 +160,14 @@ export function Lightbox({
<div className="fixed inset-0 z-[400] flex items-center justify-center p-4">
<div className="absolute inset-0 bg-slate-950/85" onClick={onClose} />
<button
<IconButton
icon={<X size={18} />}
label="Close image viewer"
variant="ghost"
onClick={onClose}
aria-label="Close"
style={{ height: 36, width: 36, minWidth: 0 }}
className="absolute top-4 right-4 z-10 h-9 w-9 rounded-full bg-white/10 hover:bg-white/20 text-white flex items-center justify-center cursor-pointer"
>
<X size={18} />
</button>
/>
<div className="relative z-10 flex flex-col items-center gap-3 max-w-full">
<img
@@ -175,21 +179,23 @@ export function Lightbox({
{images.length > 1 && (
<>
<div className="flex items-center gap-3 text-white/80 text-[12px] font-semibold">
<button
<IconButton
icon={<ChevronLeft size={16} />}
label="Previous image"
variant="ghost"
onClick={() => move(-1)}
aria-label="Previous image"
style={{ height: 32, width: 32, minWidth: 0 }}
className="h-8 w-8 rounded-full bg-white/10 hover:bg-white/20 flex items-center justify-center cursor-pointer"
>
<ChevronLeft size={16} />
</button>
/>
{index + 1} of {images.length}
<button
<IconButton
icon={<ChevronRight size={16} />}
label="Next image"
variant="ghost"
onClick={() => move(1)}
aria-label="Next image"
style={{ height: 32, width: 32, minWidth: 0 }}
className="h-8 w-8 rounded-full bg-white/10 hover:bg-white/20 flex items-center justify-center cursor-pointer"
>
<ChevronRight size={16} />
</button>
/>
</div>
<div className="flex gap-2 overflow-x-auto max-w-[90vw] pb-1">

View File

@@ -1,6 +1,8 @@
import React from 'react';
import { createPortal } from 'react-dom';
import { X, CheckCircle2, ArrowRight } from 'lucide-react';
import { Button } from '@astryxdesign/core/Button';
import { IconButton } from '@astryxdesign/core/IconButton';
import { useCompare } from '../contexts/CompareContext';
export default function ComparisonModal() {
@@ -27,18 +29,24 @@ export default function ComparisonModal() {
</div>
<div className="flex items-center gap-3 pl-2">
<button
<Button
label="Clear selected products"
variant="ghost"
onClick={clearSelection}
className="text-xs font-bold text-slate-500 hover:text-slate-800 transition-colors"
style={{ height: 'auto' }}
className="text-xs font-bold text-slate-500 hover:text-slate-800 bg-transparent transition-colors"
>
Clear
</button>
<button
</Button>
<Button
label={`Compare ${selectedProducts.length} selected products`}
endContent={<ArrowRight size={14} />}
onClick={() => setIsComparing(true)}
className="bg-[#662582] hover:bg-purple-800 text-white px-5 py-2 rounded-full font-bold text-sm shadow-md transition-colors flex items-center gap-2"
style={{ height: 'auto' }}
className="bg-[#662582] hover:bg-purple-800 text-white px-5 py-2 rounded-full font-bold text-sm border-none shadow-md transition-colors flex items-center gap-2"
>
Compare Now <ArrowRight size={14} />
</button>
Compare Now
</Button>
</div>
</div>
)}
@@ -58,25 +66,33 @@ export default function ComparisonModal() {
</div>
<div className="flex items-center gap-3">
{selectedProducts.length < 5 && (
<button
<Button
label="Add another product to the comparison"
onClick={() => setIsComparing(false)}
style={{ height: 'auto' }}
className="text-xs font-bold text-[#662582] hover:bg-purple-50 bg-white px-3 py-1.5 rounded-lg border border-purple-200 shadow-sm transition-colors flex items-center gap-1.5"
>
<span className="text-lg leading-none">+</span> Add Product
</button>
<span className="flex items-center gap-1.5"><span className="text-lg leading-none">+</span> Add Product</span>
</Button>
)}
<button
<Button
label="Clear all compared products"
onClick={clearSelection}
style={{ height: 'auto' }}
className="text-xs font-bold text-rose-500 hover:text-rose-600 bg-rose-50 px-3 py-1.5 rounded-lg border border-rose-100 transition-colors"
>
Clear All
</button>
<button
</Button>
{/* Had no text and no aria-label — the only close on this
full-screen drawer read as "button". */}
<IconButton
icon={<X size={20} />}
label="Close product comparison"
variant="ghost"
onClick={() => setIsComparing(false)}
style={{ height: 32, width: 32, minWidth: 0 }}
className="w-8 h-8 flex items-center justify-center rounded-full hover:bg-slate-200 text-slate-500 transition-colors"
>
<X size={20} />
</button>
/>
</div>
</div>
@@ -115,12 +131,18 @@ export default function ComparisonModal() {
<div key={prod.id} className="w-64 shrink-0 flex flex-col gap-4 relative group">
{/* Remove button overlay */}
<button
{/* One of these sits on every column and each was
nameless, so the drawer offered N identical
unlabelled buttons with no way to tell which
product any of them would drop. */}
<IconButton
icon={<X size={14} />}
label={`Remove ${prod.name} from comparison`}
variant="ghost"
onClick={() => removeProduct(prod.id)}
style={{ height: 28, width: 28, minWidth: 0 }}
className="absolute -top-3 -right-3 w-7 h-7 bg-white border border-slate-200 rounded-full flex items-center justify-center text-slate-400 hover:text-rose-500 hover:border-rose-200 hover:bg-rose-50 shadow-sm z-20 opacity-0 group-hover:opacity-100 transition-all"
>
<X size={14} />
</button>
/>
{/* Product Header Card */}
<div className="h-[140px] bg-white rounded-3xl border border-[#e2e8f0] shadow-sm p-4 flex flex-col justify-center relative hover:shadow-md transition-shadow">

View File

@@ -2,6 +2,7 @@ import React, { useMemo, useState } from 'react';
import { useFiestaCustomerOrders } from '../services/fiestaQueries';
import { num as fnum, str as fstr, customerName, customerStoreId, type Row } from '../services/fiestaApi';
import { Phone, MapPin, Mail, Receipt, X, Calendar, ShoppingBag, Wallet, TrendingUp, IndianRupee, Store } from 'lucide-react';
import { IconButton } from '@astryxdesign/core/IconButton';
import OrderDetailsModal from './OrderDetailsModal';
import './CustomerDetailPanel.css';
@@ -98,9 +99,14 @@ export default function CustomerDetailPanel({ customer, onClose }: CustomerDetai
<div className="cdp-top-pinned">
<div className="cdp-header">
{onClose && (
<button className="cdp-close-btn" onClick={onClose} aria-label="Close panel">
<X size={18} />
</button>
<IconButton
icon={<X size={18} />}
label={`Close ${name} details`}
variant="ghost"
onClick={onClose}
className="cdp-close-btn"
style={{ height: 'auto', width: 'auto', minWidth: 0 }}
/>
)}
<div className="cdp-header-row">

View File

@@ -18,6 +18,7 @@
import React, { useEffect, useState } from 'react';
import { MapContainer, TileLayer, Marker, Polyline, useMap } from 'react-leaflet';
import { Bike, Mailbox, Utensils, MapPin, Map as MapIcon, Ruler, X } from 'lucide-react';
import { IconButton } from '@astryxdesign/core/IconButton';
import L from 'leaflet';
import 'leaflet/dist/leaflet.css';
@@ -89,9 +90,16 @@ function OrderPopup({ o, onClose }: { o: Record<string, unknown>; onClose: () =>
return (
<div className="dispatch-popup-center" onClick={(e) => { if (e.target === e.currentTarget) onClose(); }}>
<div className="dispatch-popup-card dispatch-popup">
<button className="dispatch-popup-center-close" onClick={onClose} aria-label="Close">
<X size={15} />
</button>
{/* "Close" alone did not say what closes — this popup sits over a map
that has its own controls. */}
<IconButton
icon={<X size={15} />}
label={`Close order ${S(o.orderid) || S(o.deliveryid)} details`}
variant="ghost"
onClick={onClose}
className="dispatch-popup-center-close"
style={{ height: 'auto', width: 'auto', minWidth: 0 }}
/>
<div style={{ height: '100%', width: '100%' }}>
<div className="pu-header">
<div className="pu-header-top">

View File

@@ -53,6 +53,9 @@ import {
customerStoreId,
type Row,
} from '../services/fiestaApi';
import { Button } from '@astryxdesign/core/Button';
import { IconButton } from '@astryxdesign/core/IconButton';
import { Selector } from '@astryxdesign/core/Selector';
import { colorFor } from '../services/dispatchShared';
import DispatchMap, { type MapPoint } from './DispatchMap';
import CustomerDetailPanel from './CustomerDetailPanel';
@@ -355,13 +358,18 @@ export default function DispatchView({ locationid, tenantId = FIESTA_TENANT_ID,
<div className="dispatch-container embedded">
{/* ── Body ── */}
<div id="body" className={sidebarCollapsed ? 'sidebar-collapsed' : ''}>
<button
className={`sidebar-toggle-tab${sidebarCollapsed ? ' is-collapsed' : ''}`}
{/* Had only a `title`, which is not an accessible name, and no
expanded state. */}
<IconButton
icon={sidebarCollapsed ? <ChevronRight size={26} /> : <ChevronLeft size={18} />}
label={sidebarCollapsed ? 'Show panel' : 'Hide panel'}
tooltip={sidebarCollapsed ? 'Show panel' : 'Hide panel'}
variant="ghost"
aria-expanded={!sidebarCollapsed}
onClick={() => setSidebarCollapsed((c) => !c)}
title={sidebarCollapsed ? 'Show panel' : 'Hide panel'}
>
{sidebarCollapsed ? <ChevronRight size={26} /> : <ChevronLeft size={18} />}
</button>
className={`sidebar-toggle-tab${sidebarCollapsed ? ' is-collapsed' : ''}`}
style={{ height: 'auto', width: 'auto', minWidth: 0 }}
/>
{/* Sidebar */}
<div id="sidebar">
@@ -423,26 +431,31 @@ export default function DispatchView({ locationid, tenantId = FIESTA_TENANT_ID,
<div className="ph relative flex items-center justify-between w-full h-8">
<span>{viewMode === 'customers' ? 'Customers' : 'Stores'} ({groups.length})</span>
{viewMode === 'customers' && !locationid && locationsQ.data && (
<div className="absolute right-0 flex items-center group/filter cursor-pointer">
<select
className="appearance-none w-36 text-[11px] font-extrabold tracking-wide rounded-md pl-3 pr-8 py-1.5 bg-white text-slate-800 shadow-sm ring-1 ring-slate-900/5 hover:bg-slate-50 outline-none focus:ring-2 focus:ring-[#662582]/30 transition-all duration-300 cursor-pointer relative z-0 truncate"
style={{ textOverflow: 'ellipsis' }}
// The hand-drawn chevron that used to sit beside this goes
// with the <select>: Selector draws its own, and keeping
// both would show two.
<div className="absolute right-0 flex items-center w-36">
<Selector
label="Filter customers by store"
isLabelHidden
size="sm"
// The <select> was a fixed w-36 that truncated long
// outlet names; Selector sizes to content instead and
// would jump width on every choice.
width="100%"
value={customerStoreFilter}
onChange={(e) => {
setCustomerStoreFilter(e.target.value);
onChange={(v) => {
setCustomerStoreFilter(v);
setFocusedId(null);
}}
>
<option value="all">All Stores</option>
{locationsQ.data.map(loc => (
<option key={fnum(loc.locationid)} value={String(fnum(loc.locationid))}>
{fstr(loc.locationname) || `Store ${fnum(loc.locationid)}`}
</option>
))}
</select>
<div className="absolute right-2 text-slate-400 pointer-events-none group-hover/filter:text-[#662582] transition-colors">
<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round"><path d="m6 9 6 6 6-6"/></svg>
</div>
options={[
{ value: 'all', label: 'All Stores' },
...locationsQ.data.map((loc) => ({
value: String(fnum(loc.locationid)),
label: fstr(loc.locationname) || `Store ${fnum(loc.locationid)}`,
})),
]}
/>
</div>
)}
</div>
@@ -509,22 +522,34 @@ export default function DispatchView({ locationid, tenantId = FIESTA_TENANT_ID,
{/* bottom-right overlay controls (gated) */}
<div id="ov-br">
<button
className={`sbt ${animating ? 'active' : ''}`}
disabled={!focused || mapPoints.length < 2}
<Button
label={animating ? 'Animating…' : 'Animate Routes'}
icon={<Play size={14} />}
isDisabled={!focused || mapPoints.length < 2}
tooltip={focused ? 'Replay the route draw' : 'Select a rider to animate its route'}
onClick={() => {
if (!focused || mapPoints.length < 2) return;
setAnimating(true);
setAnimateNonce((n) => n + 1);
window.setTimeout(() => setAnimating(false), 2300);
}}
title={focused ? 'Replay the route draw' : 'Select a rider to animate its route'}
className={`sbt ${animating ? 'active' : ''}`}
style={{ height: 'auto' }}
/>
{/* Permanently disabled — planned-vs-actual compare needs rider
GPS telemetry the backend does not send yet. The reason was
in a `title`, which a disabled button never surfaces to a
screen reader; it is in the label now. */}
<Button
label="Compare (needs rider GPS telemetry, not available yet)"
icon={<ArrowLeftRight size={14} />}
isDisabled
tooltip="Planned-vs-actual compare needs rider GPS telemetry (awaiting backend)"
className="sbt"
style={{ height: 'auto' }}
>
<span className="sbt-icon"><Play size={14} /></span> {animating ? 'Animating…' : 'Animate Routes'}
</button>
<button className="sbt" disabled title="Planned-vs-actual compare needs rider GPS telemetry (awaiting backend)">
<span className="sbt-icon"><ArrowLeftRight size={14} /></span> Compare
</button>
Compare
</Button>
</div>
</div>
)}
@@ -622,9 +647,13 @@ function FocusedDetail({
return (
<>
{onBack && (
<button className="sbt" onClick={onBack} style={{ marginBottom: 12 }}>
<span className="sbt-icon"><ChevronLeft size={15} /></span> Back to list
</button>
<Button
label="Back to list"
icon={<ChevronLeft size={15} />}
onClick={onBack}
className="sbt"
style={{ height: 'auto', marginBottom: 12 }}
/>
)}
{tripBlocks.length === 0 && (
@@ -645,13 +674,31 @@ function FocusedDetail({
<span><MapPin size={11} /> {blk.orders.length} stops</span>
<span><Ruler size={11} /> {blk.orders.reduce((a, o) => a + fnum(o.kms), 0).toFixed(1)} km</span>
</span>
<div className="trip-sort-toggle" role="group">
<button className={`trip-sort-pill ${tripSort === 'planned' ? 'is-active' : ''}`} onClick={() => setTripSort('planned')}>
<List size={12} /> <span>Planned</span>
</button>
<button className={`trip-sort-pill ${tripSort === 'time' ? 'is-active' : ''}`} onClick={() => setTripSort('time')}>
<Clock size={12} /> <span>By time</span>
</button>
{/* Single-select pair whose choice was carried by an `is-active`
class alone — same pattern as the other filter groups. */}
<div className="trip-sort-toggle" role="group" aria-label="Sort stops">
<Button
label="Sort stops in planned order"
icon={<List size={12} />}
aria-pressed={tripSort === 'planned'}
variant="ghost"
onClick={() => setTripSort('planned')}
className={`trip-sort-pill ${tripSort === 'planned' ? 'is-active' : ''}`}
style={{ height: 'auto' }}
>
<span>Planned</span>
</Button>
<Button
label="Sort stops by time"
icon={<Clock size={12} />}
aria-pressed={tripSort === 'time'}
variant="ghost"
onClick={() => setTripSort('time')}
className={`trip-sort-pill ${tripSort === 'time' ? 'is-active' : ''}`}
style={{ height: 'auto' }}
>
<span>By time</span>
</Button>
</div>
</div>

View File

@@ -5,6 +5,8 @@
import React, { useState, useRef, useEffect } from 'react';
import { Menu, HelpCircle, LogOut, ChevronDown, Mail, QrCode, User } from 'lucide-react';
import { Button } from '@astryxdesign/core/Button';
import { IconButton } from '@astryxdesign/core/IconButton';
import { MainSection } from '../types';
interface HeaderProps {
@@ -44,19 +46,34 @@ export default function Header({
const [showProfileDropdown, setShowProfileDropdown] = useState(false);
const profileRef = useRef<HTMLDivElement>(null);
// Close the profile dropdown on any click/tap outside of it.
// Close the profile dropdown on any click/tap outside of it, or on Escape.
//
// Escape and the focus return are new in Phase 6. The panel was dismissable
// by pointer only, which left anyone driving the header from the keyboard
// with no way out of it, and on close focus was dropped wherever it lay
// rather than handed back to the trigger. Both are the behaviours Astryx's
// own overlays provide; this panel is hand-rolled (it is a profile card, not
// a menu — putting a card inside role="menu" would be worse ARIA than
// leaving it alone), so it has to provide them itself.
useEffect(() => {
if (!showProfileDropdown) return;
const opener = document.activeElement as HTMLElement | null;
const handlePointerDown = (e: MouseEvent | TouchEvent) => {
if (profileRef.current && !profileRef.current.contains(e.target as Node)) {
setShowProfileDropdown(false);
}
};
const handleKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') setShowProfileDropdown(false);
};
document.addEventListener('mousedown', handlePointerDown);
document.addEventListener('touchstart', handlePointerDown);
document.addEventListener('keydown', handleKey);
return () => {
document.removeEventListener('mousedown', handlePointerDown);
document.removeEventListener('touchstart', handlePointerDown);
document.removeEventListener('keydown', handleKey);
opener?.focus?.();
};
}, [showProfileDropdown]);
@@ -101,17 +118,25 @@ export default function Header({
Collapsed the cell is only 64px and centred, so the natural
inline position already sits beside the icon logo, which is itself
aligned over the sidebar's icon rail. */}
<button
{/* PHASE 6 — IconButton. `label` is the accessible name (Astryx omits
`title` from its props, so `tooltip` carries the hover text). The
sizing overrides below are not decoration: Astryx pins a Button to
28/32/36px and paints its own ground, and this control sits on the
brand purple bar whose alignment with the sidebar seam is load
bearing (see the note above). Height/width auto hand spacing back
to the Tailwind padding classes that were already tuned for it. */}
<IconButton
icon={<Menu size={18} />}
label={isSidebarOpen ? 'Close sidebar' : 'Open sidebar'}
tooltip={isSidebarOpen ? 'Close sidebar' : 'Open sidebar'}
variant="ghost"
onClick={onToggleSidebar}
title={isSidebarOpen ? 'Close sidebar' : 'Open sidebar'}
aria-label={isSidebarOpen ? 'Close sidebar' : 'Open sidebar'}
aria-expanded={isSidebarOpen}
style={{ height: 'auto', width: 'auto', minWidth: 0, background: 'transparent', color: '#fff', border: 'none' }}
className={`rounded-full hover:bg-purple-800 transition-colors cursor-pointer text-white shrink-0 ${
isSidebarOpen ? 'md:ml-auto p-1.5 sm:p-2' : 'p-1.5'
}`}
>
<Menu size={18} />
</button>
/>
</div>
{/* Dynamic Store Name Context */}
@@ -134,41 +159,60 @@ export default function Header({
<div className="flex items-center gap-md">
{/* Store QR — opens the QR modal (user store page only) */}
{onQrClick && (
<button
<IconButton
icon={<QrCode size={18} />}
label="Store QR code"
tooltip="Store QR code"
variant="ghost"
onClick={onQrClick}
title="Store QR code"
aria-label="Store QR code"
style={{ height: 'auto', width: 'auto', minWidth: 0, background: 'transparent', color: '#fff', border: 'none' }}
className="p-2 rounded-full hover:bg-purple-800 transition-colors cursor-pointer text-white"
>
<QrCode size={18} />
</button>
/>
)}
{/* User profile with dropdown */}
<div className="relative" ref={profileRef}>
<button
{/* PHASE 6 — Astryx Button carrying a bespoke visual. Button renders
`children ?? label` visibly and, when the two differ, promotes
`label` to the accessible name. That is the fix this control
needed: the name was previously assembled from the raw text
content, so a screen reader announced the initials glued onto the
name — "RS RMart Store User Manager". Now it reads
"RMart Store User, Manager. Account menu".
The children are wrapped in their own flex row because Button
nests them inside a span of its own; laying them out on the
button root would leave them stacked. */}
<Button
label={`${profile.name}, ${profile.role}. Account menu`}
variant="ghost"
onClick={() => setShowProfileDropdown(!showProfileDropdown)}
className="group flex items-center gap-2.5 pl-1 pr-1 sm:pr-2.5 py-1 rounded-full bg-white/10 hover:bg-white/15 border border-white/15 backdrop-blur-sm focus:ring-2 focus:ring-purple-300/60 outline-none cursor-pointer transition-all duration-150 active:scale-[0.98]"
aria-expanded={showProfileDropdown}
aria-haspopup="dialog"
style={{ height: 'auto', background: 'transparent', border: 'none' }}
className="group flex items-center pl-1 pr-1 sm:pr-2.5 py-1 rounded-full bg-white/10 hover:bg-white/15 border border-white/15 backdrop-blur-sm focus:ring-2 focus:ring-purple-300/60 outline-none cursor-pointer transition-all duration-150 active:scale-[0.98]"
>
{/* Initials avatar with live status dot */}
<span className="relative shrink-0">
<span className="w-9 h-9 rounded-full bg-white/15 ring-2 ring-white/30 flex items-center justify-center text-xs font-bold text-white tracking-wide">
{initials}
<span className="flex items-center gap-2.5">
{/* Initials avatar with live status dot */}
<span className="relative shrink-0">
<span className="w-9 h-9 rounded-full bg-white/15 ring-2 ring-white/30 flex items-center justify-center text-xs font-bold text-white tracking-wide">
{initials}
</span>
<span className="absolute bottom-0 right-0 w-2.5 h-2.5 rounded-full bg-emerald-400 ring-2 ring-[#662582]" />
</span>
<span className="absolute bottom-0 right-0 w-2.5 h-2.5 rounded-full bg-emerald-400 ring-2 ring-[#662582]" />
</span>
{/* Identity (hidden on small screens) */}
<span className="hidden sm:flex flex-col items-start leading-tight">
<span className="text-xs font-bold text-white truncate max-w-[130px]">{profile.name}</span>
<span className="text-[10px] text-purple-200 font-medium truncate max-w-[130px]">{profile.role}</span>
</span>
{/* Identity (hidden on small screens) */}
<span className="hidden sm:flex flex-col items-start leading-tight">
<span className="text-xs font-bold text-white truncate max-w-[130px]">{profile.name}</span>
<span className="text-[10px] text-purple-200 font-medium truncate max-w-[130px]">{profile.role}</span>
</span>
<ChevronDown
size={15}
className={`hidden sm:block text-purple-200 transition-transform duration-200 ${showProfileDropdown ? 'rotate-180' : ''}`}
/>
</button>
<ChevronDown
size={15}
className={`hidden sm:block text-purple-200 transition-transform duration-200 ${showProfileDropdown ? 'rotate-180' : ''}`}
/>
</span>
</Button>
{showProfileDropdown && (
<div className="absolute right-0 mt-2.5 w-72 bg-white border border-slate-200/80 rounded-2xl shadow-2xl shadow-purple-950/15 z-50 text-slate-700 animate-in fade-in slide-in-from-top-2 duration-200 overflow-hidden">
@@ -197,36 +241,57 @@ export default function Header({
{/* Account actions (moved here from the sidebar) */}
<div className="p-2 flex flex-col gap-0.5">
{/* PHASE 6 — the three account actions on Astryx Button. Each
keeps its icon tile, so `children` carries the visual and
`label` the accessible name. Note what is NOT in `style`:
padding. Inline style beats a Tailwind class, so pinning it
here silently killed the px/py classes and collapsed every
row — the classes are left to win on their own instead. */}
{onAccountClick && (
<button
<Button
label="My Account"
variant="ghost"
onClick={() => { setShowProfileDropdown(false); onAccountClick(); }}
className="w-full flex items-center gap-2.5 px-2.5 py-2 rounded-xl text-xs font-semibold text-slate-700 hover:bg-slate-50 cursor-pointer transition-colors group/item"
style={{ height: 'auto', background: 'transparent', border: 'none', justifyContent: 'flex-start' }}
className="w-full flex items-center px-2.5 py-2 rounded-xl text-xs font-semibold text-slate-700 hover:bg-slate-50 cursor-pointer transition-colors group/item"
>
<span className="h-7 w-7 rounded-lg bg-purple-50 text-[#662582] ring-1 ring-purple-100 flex items-center justify-center group-hover/item:scale-110 transition-transform">
<User size={14} />
<span className="w-full flex items-center gap-2.5">
<span className="h-7 w-7 rounded-lg bg-purple-50 text-[#662582] ring-1 ring-purple-100 flex items-center justify-center shrink-0 group-hover/item:scale-110 transition-transform">
<User size={14} />
</span>
My Account
</span>
My Account
</button>
</Button>
)}
<button
<Button
label="Help Center"
variant="ghost"
onClick={() => { setShowProfileDropdown(false); onHelpClick(); }}
className="w-full flex items-center gap-2.5 px-2.5 py-2 rounded-xl text-xs font-semibold text-slate-700 hover:bg-slate-50 cursor-pointer transition-colors group/item"
style={{ height: 'auto', background: 'transparent', border: 'none', justifyContent: 'flex-start' }}
className="w-full flex items-center px-2.5 py-2 rounded-xl text-xs font-semibold text-slate-700 hover:bg-slate-50 cursor-pointer transition-colors group/item"
>
<span className="h-7 w-7 rounded-lg bg-slate-100 text-slate-500 ring-1 ring-slate-200 flex items-center justify-center group-hover/item:scale-110 transition-transform">
<HelpCircle size={14} />
<span className="w-full flex items-center gap-2.5">
<span className="h-7 w-7 rounded-lg bg-slate-100 text-slate-500 ring-1 ring-slate-200 flex items-center justify-center shrink-0 group-hover/item:scale-110 transition-transform">
<HelpCircle size={14} />
</span>
Help Center
</span>
Help Center
</button>
</Button>
<div className="my-1 h-px bg-slate-100" />
<button
<Button
label="Log Out"
variant="ghost"
onClick={() => { setShowProfileDropdown(false); onLogoutClick(); }}
className="w-full flex items-center gap-2.5 px-2.5 py-2 rounded-xl text-xs font-semibold text-rose-600 hover:bg-rose-50 cursor-pointer transition-colors group/item"
style={{ height: 'auto', background: 'transparent', border: 'none', justifyContent: 'flex-start' }}
className="w-full flex items-center px-2.5 py-2 rounded-xl text-xs font-semibold text-rose-600 hover:bg-rose-50 cursor-pointer transition-colors group/item"
>
<span className="h-7 w-7 rounded-lg bg-rose-50 text-rose-500 ring-1 ring-rose-100 flex items-center justify-center group-hover/item:scale-110 transition-transform">
<LogOut size={14} />
<span className="w-full flex items-center gap-2.5">
<span className="h-7 w-7 rounded-lg bg-rose-50 text-rose-500 ring-1 ring-rose-100 flex items-center justify-center shrink-0 group-hover/item:scale-110 transition-transform">
<LogOut size={14} />
</span>
Log Out
</span>
Log Out
</button>
</Button>
</div>
</div>
)}

View File

@@ -1,5 +1,6 @@
import React, { useState } from 'react';
import { X, Save, AlertCircle, CheckCircle, DownloadCloud, Plus, Box } from 'lucide-react';
import { Button } from '@astryxdesign/core/Button';
import { CatalogueProduct, ImportCatalogueProductRequest } from '../services/catalogueApi';
import { useProductSubcategories, useTenantCategories } from '../hooks/useCatalogueImport';
import { Lightbox } from './CatalogueGallery';
@@ -319,14 +320,15 @@ export default function ImportProductModal({
It is not visible to any store until you set a price and publish it there.
</p>
<button
type="button"
<Button
label={`Import ${product.product_name} to your Admin Catalogue`}
icon={<Plus size={18} strokeWidth={2.5} />}
onClick={handleSubmit}
className="w-full flex items-center justify-center gap-2 py-3.5 rounded-xl text-sm font-extrabold transition-all bg-[#662582] hover:bg-[#531e6a] text-white shadow-md cursor-pointer"
style={{ height: 'auto' }}
className="w-full flex items-center justify-center gap-2 py-3.5 rounded-xl text-sm font-extrabold transition-all bg-[#662582] hover:bg-[#531e6a] text-white border-none shadow-md cursor-pointer"
>
<Plus size={18} strokeWidth={2.5} />
Import Product
</button>
</Button>
</div>
)}
</div>

View File

@@ -11,12 +11,26 @@ import {
ArrowRight,
Eye,
EyeOff,
Loader2,
} from 'lucide-react';
import { Button } from '@astryxdesign/core/Button';
import { IconButton } from '@astryxdesign/core/IconButton';
import { TextInput } from '@astryxdesign/core/TextInput';
import { CheckboxInput } from '@astryxdesign/core/CheckboxInput';
import { Banner } from '@astryxdesign/core/Banner';
import { checkEmailRequest, PasswordSetupRequiredError } from '../services/auth';
import type { AuthUser } from '../services/auth';
import { useLogin, useSetPassword } from '../services/fiestaQueries';
/**
* Astryx's BaseProps builds on `React.HTMLAttributes`, which carries no
* `autoComplete` — that attribute is declared on `InputHTMLAttributes`. TextInput
* spreads whatever it does not recognise straight onto the underlying `<input>`,
* so the attribute lands correctly at runtime and only the type stands in the
* way. Worth the escape hatch: dropping it would silently break password-manager
* autofill on the one form in the app that depends on it.
*/
const autofill = (value: string): Record<string, string> => ({ autoComplete: value });
interface LoginViewProps {
/** Called with the authenticated user once credentials are verified. */
onLogin: (user: AuthUser) => void;
@@ -41,6 +55,13 @@ export default function LoginView({ onLogin }: LoginViewProps) {
const [confirmPassword, setConfirmPassword] = useState('');
const [showNewPassword, setShowNewPassword] = useState(false);
// NOTE: nothing reads this. The "Remember me" box was uncontrolled and inert
// before Phase 6 too — CheckboxInput is a controlled component, so it needs
// state to tick at all, but ticking it still changes nothing about session
// lifetime. Left visible rather than removed because that is a product call,
// not a conversion one.
const [rememberMe, setRememberMe] = useState(false);
const login = useLogin();
const setPasswordMut = useSetPassword();
const loading = login.isPending || setPasswordMut.isPending;
@@ -197,13 +218,19 @@ export default function LoginView({ onLogin }: LoginViewProps) {
<form onSubmit={handleSubmit} className="space-y-4">
{/* Email or username (the API's `authname` accepts either) */}
<div className="space-y-2">
{/* PHASE 6 — the caption is a <span>, not a <label>, because the
real label now lives inside the TextInput (hidden, same text).
Two labels pointing at one input is worse than one; this keeps
the design's caption row, which has to hold "Change Email"
beside it, and leaves the programmatic label to Astryx. */}
<div className="flex justify-between items-center">
<label htmlFor="login-email" className="block text-xs font-bold text-slate-600 uppercase tracking-wider">
<span className="block text-xs font-bold text-slate-600 uppercase tracking-wider">
Email or Username
</label>
</span>
{(step === 'password' || step === 'setup') && (
<button
type="button"
<Button
label="Change Email"
variant="ghost"
onClick={() => {
setStep('email');
setError('');
@@ -212,69 +239,70 @@ export default function LoginView({ onLogin }: LoginViewProps) {
setNewPassword('');
setConfirmPassword('');
}}
className="text-xs font-semibold text-purple-600 hover:text-purple-800 bg-transparent border-none cursor-pointer p-0"
>
Change Email
</button>
style={{ height: 'auto', background: 'transparent', border: 'none', padding: 0 }}
className="text-xs font-semibold text-purple-600 hover:text-purple-800 cursor-pointer"
/>
)}
</div>
<div className="relative">
<Mail size={16} className="absolute left-3.5 top-1/2 -translate-y-1/2 text-slate-400 pointer-events-none" />
<input
id="login-email"
type="text"
autoComplete="username"
disabled={step === 'password' || step === 'setup'}
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="you@nearledaily.com"
className="w-full h-12 pl-10 pr-4 bg-slate-50 border border-slate-200 rounded-xl text-sm text-slate-800 placeholder-slate-400 focus:outline-none focus:border-purple-500 focus:bg-white focus:ring-4 focus:ring-purple-500/10 transition-all disabled:opacity-75 disabled:cursor-not-allowed"
/>
</div>
<TextInput
label="Email or Username"
isLabelHidden
type="text"
{...autofill("username")}
isDisabled={step === 'password' || step === 'setup'}
value={email}
onChange={(v) => setEmail(v)}
placeholder="you@nearledaily.com"
startIcon={<Mail size={16} className="text-slate-400" />}
className="w-full h-12 px-3.5 bg-slate-50 border border-slate-200 rounded-xl text-sm text-slate-800"
/>
</div>
{/* First-login password setup */}
{step === 'setup' && (
<div className="space-y-4">
<div className="space-y-2">
<label htmlFor="new-password" className="block text-xs font-bold text-slate-600 uppercase tracking-wider">
<span className="block text-xs font-bold text-slate-600 uppercase tracking-wider">
New Password
</label>
</span>
<div className="relative">
<Lock size={16} className="absolute left-3.5 top-1/2 -translate-y-1/2 text-slate-400 pointer-events-none" />
<input
id="new-password"
<TextInput
label="New Password"
isLabelHidden
type={showNewPassword ? 'text' : 'password'}
{...autofill("new-password")}
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
onChange={(v) => setNewPassword(v)}
placeholder="At least 6 characters"
className="w-full h-12 pl-10 pr-11 bg-slate-50 border border-slate-200 rounded-xl text-sm text-slate-800 placeholder-slate-400 focus:outline-none focus:border-purple-500 focus:bg-white focus:ring-4 focus:ring-purple-500/10 transition-all"
startIcon={<Lock size={16} className="text-slate-400" />}
className="w-full h-12 pl-3.5 pr-11 bg-slate-50 border border-slate-200 rounded-xl text-sm text-slate-800"
/>
<button
type="button"
<IconButton
icon={showNewPassword ? <EyeOff size={16} /> : <Eye size={16} />}
label={showNewPassword ? 'Hide password' : 'Show password'}
tooltip={showNewPassword ? 'Hide password' : 'Show password'}
variant="ghost"
onClick={() => setShowNewPassword((s) => !s)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-slate-400 hover:text-slate-600 bg-transparent border-none cursor-pointer p-1"
title={showNewPassword ? 'Hide password' : 'Show password'}
>
{showNewPassword ? <EyeOff size={16} /> : <Eye size={16} />}
</button>
className="absolute right-3 top-1/2 -translate-y-1/2 text-slate-400 hover:text-slate-600 cursor-pointer z-10"
style={{ height: 'auto', width: 'auto', minWidth: 0, padding: 4, background: 'transparent', border: 'none' }}
/>
</div>
</div>
<div className="space-y-2">
<label htmlFor="confirm-password" className="block text-xs font-bold text-slate-600 uppercase tracking-wider">
<span className="block text-xs font-bold text-slate-600 uppercase tracking-wider">
Confirm Password
</label>
<div className="relative">
<Lock size={16} className="absolute left-3.5 top-1/2 -translate-y-1/2 text-slate-400 pointer-events-none" />
<input
id="confirm-password"
type={showNewPassword ? 'text' : 'password'}
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
placeholder="Re-enter password"
className="w-full h-12 pl-10 pr-4 bg-slate-50 border border-slate-200 rounded-xl text-sm text-slate-800 placeholder-slate-400 focus:outline-none focus:border-purple-500 focus:bg-white focus:ring-4 focus:ring-purple-500/10 transition-all"
/>
</div>
</span>
<TextInput
label="Confirm Password"
isLabelHidden
type={showNewPassword ? 'text' : 'password'}
{...autofill("new-password")}
value={confirmPassword}
onChange={(v) => setConfirmPassword(v)}
placeholder="Re-enter password"
startIcon={<Lock size={16} className="text-slate-400" />}
className="w-full h-12 px-3.5 bg-slate-50 border border-slate-200 rounded-xl text-sm text-slate-800"
/>
</div>
</div>
)}
@@ -282,68 +310,91 @@ export default function LoginView({ onLogin }: LoginViewProps) {
{/* Password */}
{step === 'password' && (
<div className="space-y-2">
<label htmlFor="login-password" className="block text-xs font-bold text-slate-600 uppercase tracking-wider">
<span className="block text-xs font-bold text-slate-600 uppercase tracking-wider">
Password
</label>
</span>
<div className="relative">
<Lock size={16} className="absolute left-3.5 top-1/2 -translate-y-1/2 text-slate-400 pointer-events-none" />
<input
id="login-password"
<TextInput
label="Password"
isLabelHidden
type={showPassword ? 'text' : 'password'}
{...autofill("current-password")}
hasAutoFocus
value={password}
onChange={(e) => setPassword(e.target.value)}
onChange={(v) => setPassword(v)}
placeholder="••••••••"
className="w-full h-12 pl-10 pr-11 bg-slate-50 border border-slate-200 rounded-xl text-sm text-slate-800 placeholder-slate-400 focus:outline-none focus:border-purple-500 focus:bg-white focus:ring-4 focus:ring-purple-500/10 transition-all"
startIcon={<Lock size={16} className="text-slate-400" />}
className="w-full h-12 pl-3.5 pr-11 bg-slate-50 border border-slate-200 rounded-xl text-sm text-slate-800"
/>
<button
type="button"
<IconButton
icon={showPassword ? <EyeOff size={16} /> : <Eye size={16} />}
label={showPassword ? 'Hide password' : 'Show password'}
tooltip={showPassword ? 'Hide password' : 'Show password'}
variant="ghost"
onClick={() => setShowPassword((s) => !s)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-slate-400 hover:text-slate-600 bg-transparent border-none cursor-pointer p-1"
title={showPassword ? 'Hide password' : 'Show password'}
>
{showPassword ? <EyeOff size={16} /> : <Eye size={16} />}
</button>
className="absolute right-3 top-1/2 -translate-y-1/2 text-slate-400 hover:text-slate-600 cursor-pointer z-10"
style={{ height: 'auto', width: 'auto', minWidth: 0, padding: 4, background: 'transparent', border: 'none' }}
/>
</div>
</div>
)}
{/* PHASE 6 — Banner, not a bare <p>. Every failure on this form —
wrong password, unknown email, mismatched setup — was written
into a paragraph with no role, so it was painted on screen and
announced to nobody. Banner carries role="alert", which is the
whole point on the one form where the user cannot proceed until
they hear what went wrong. */}
{error && (
<p className="text-xs font-semibold text-rose-600 bg-rose-50 border border-rose-100 rounded-lg px-3 py-2.5">
{error}
</p>
<Banner status="error" title={error} />
)}
{/* Options row */}
{step === 'password' && (
<div className="flex items-center justify-between text-xs">
<label className="flex items-center gap-2 text-slate-500 font-medium cursor-pointer select-none">
<input type="checkbox" className="h-3.5 w-3.5 rounded border-slate-300 text-purple-600 accent-purple-600 focus:ring-purple-500/30" />
Remember me
</label>
<button type="button" className="font-semibold text-purple-650 hover:text-purple-800 bg-transparent border-none cursor-pointer">
Forgot password?
</button>
<CheckboxInput
label="Remember me"
size="sm"
value={rememberMe}
onChange={(checked) => setRememberMe(checked)}
className="text-slate-500 font-medium"
/>
{/* Also inert — this button had no onClick before the
conversion and still has none. There is no password-reset
flow behind it. */}
<Button
label="Forgot password?"
variant="ghost"
style={{ height: 'auto', background: 'transparent', border: 'none', padding: 0 }}
className="text-xs font-semibold text-purple-600 hover:text-purple-800 cursor-pointer"
/>
</div>
)}
{/* Submit */}
<button
{/* Submit — Astryx supplies its own spinner and the aria-busy /
live-region wiring behind `isLoading`, so the hand-rolled
Loader2 is gone with it. */}
<Button
type="submit"
disabled={loading || checkingEmail}
className="w-full h-12 flex items-center justify-center gap-2 bg-[#662582] hover:bg-purple-800 text-white font-bold text-sm rounded-xl shadow-sm hover:shadow-lg transition-all active:scale-[0.98] cursor-pointer border-none disabled:opacity-70 disabled:cursor-not-allowed disabled:active:scale-100"
>
{loading || checkingEmail ? (
<>
<Loader2 size={16} className="animate-spin" />
{checkingEmail ? 'Checking Email…' : setPasswordMut.isPending ? 'Setting Password…' : 'Verifying…'}
</>
) : (
<>
{step === 'email' ? 'Continue' : step === 'setup' ? 'Set Password & Sign In' : 'Sign in'}
<ArrowRight size={16} />
</>
)}
</button>
label={
checkingEmail
? 'Checking Email…'
: setPasswordMut.isPending
? 'Setting Password…'
: loading
? 'Verifying…'
: step === 'email'
? 'Continue'
: step === 'setup'
? 'Set Password & Sign In'
: 'Sign in'
}
isLoading={loading || checkingEmail}
isDisabled={loading || checkingEmail}
endContent={loading || checkingEmail ? undefined : <ArrowRight size={16} />}
style={{ height: 48, background: '#662582', border: 'none', color: '#fff' }}
className="w-full h-12 flex items-center justify-center gap-2 hover:bg-purple-800 text-white font-bold text-sm rounded-xl shadow-sm hover:shadow-lg transition-all active:scale-[0.98] cursor-pointer"
/>
</form>
{/* Helper note */}

View File

@@ -27,14 +27,13 @@
* their branch and rejects rows for any other — enforced again server-side.
*/
import { useCallback, useMemo, useRef, useState } from 'react';
import { useCallback, useMemo, useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import {
AlertTriangle,
CheckCircle2,
Download,
FileSpreadsheet,
Loader2,
RotateCcw,
Store,
Upload,
@@ -53,6 +52,12 @@ import {
toBills,
type ParsedSheet,
} from '../services/offlineSalesSheet';
import { Button } from '@astryxdesign/core/Button';
import { IconButton } from '@astryxdesign/core/IconButton';
import { FileInput } from '@astryxdesign/core/FileInput';
import {
Table, TableHeader, TableHeaderCell, TableBody, TableRow, TableCell,
} from '@astryxdesign/core/Table';
interface OfflineSalesUploadProps {
tenantId: number;
@@ -79,11 +84,15 @@ export default function OfflineSalesUpload({
onClose,
}: OfflineSalesUploadProps) {
const queryClient = useQueryClient();
const fileInputRef = useRef<HTMLInputElement>(null);
// Remount key for the FileInput. The old dropzone cleared its hidden input's
// `.value` on reset, which is what let the operator fix a spreadsheet and
// pick the very same filename again. FileInput owns that element now, so the
// equivalent is to give it a fresh one.
const [fileInputNonce, setFileInputNonce] = useState(0);
const [parsed, setParsed] = useState<ParsedSheet | null>(null);
const [fileName, setFileName] = useState('');
const [dragging, setDragging] = useState(false);
const [result, setResult] = useState<OfflineSalesUploadResponse | null>(null);
const [uploadError, setUploadError] = useState('');
@@ -157,7 +166,7 @@ export default function OfflineSalesUpload({
setFileName('');
setResult(null);
setUploadError('');
if (fileInputRef.current) fileInputRef.current.value = '';
setFileInputNonce((n) => n + 1);
};
const storeCount = template?.locations.length ?? 0;
@@ -182,13 +191,14 @@ export default function OfflineSalesUpload({
</p>
</div>
</div>
<button
<IconButton
icon={<X size={20} />}
label="Close offline sales upload"
variant="ghost"
onClick={onClose}
style={{ height: 32, width: 32, minWidth: 0 }}
className="flex h-8 w-8 items-center justify-center rounded-full text-white/80 transition-colors hover:bg-white/10 hover:text-white"
aria-label="Close"
>
<X size={20} />
</button>
/>
</div>
<div className="flex-1 overflow-y-auto p-6">
@@ -217,18 +227,17 @@ export default function OfflineSalesUpload({
: `${template?.products.length ?? 0} rows covering all ${storeCount} stores. Every row already says which store it belongs to — fill in qtysold wherever you sold something and upload the one file.`}
</p>
</div>
<button
{/* Astryx supplies the spinner behind `isLoading`, so the
hand-rolled Loader2 goes with it. */}
<Button
label="Download Template"
icon={<Download size={16} />}
isLoading={templateQuery.isLoading}
isDisabled={!template || templateQuery.isLoading}
onClick={() => template && downloadSaleTemplate(template)}
disabled={!template || templateQuery.isLoading}
className="flex items-center gap-2 rounded-lg bg-[#662582] px-4 py-2.5 text-sm font-semibold text-white transition-colors hover:bg-[#551f6d] disabled:cursor-not-allowed disabled:opacity-50"
>
{templateQuery.isLoading ? (
<Loader2 size={16} className="animate-spin" />
) : (
<Download size={16} />
)}
Download Template
</button>
style={{ height: 'auto' }}
className="flex items-center gap-2 rounded-lg bg-[#662582] px-4 py-2.5 text-sm font-semibold text-white border-none transition-colors hover:bg-[#551f6d]"
/>
</div>
{/* What the one file covers. Shown so it is obvious up front
@@ -265,41 +274,36 @@ export default function OfflineSalesUpload({
Upload the filled-in file
</h3>
<div
onDragOver={(e) => {
e.preventDefault();
setDragging(true);
}}
onDragLeave={() => setDragging(false)}
onDrop={(e) => {
e.preventDefault();
setDragging(false);
const file = e.dataTransfer.files?.[0];
{/* PHASE 6 — FileInput in dropzone mode, replacing a hand-rolled
drop target that nobody could reach from a keyboard: it was a
plain <div> with an onClick, no role, no tabindex, driving a
`hidden` file input. Mouse users got a drop zone; everyone
else got nothing at all, on the only step of this flow that
accepts input. FileInput brings the drag handling, the accept
filter and a real focusable control with it, so `dragging`
and the input ref are no longer needed here. */}
<FileInput
key={fileInputNonce}
label="Filled-in sales file"
isLabelHidden
mode="dropzone"
accept=".xlsx,.xls,.csv"
value={null}
placeholder={fileName || 'Drop the .xlsx file here, or click to choose'}
description="Only the template downloaded above will import correctly."
onChange={(f) => {
const file = Array.isArray(f) ? f[0] : f;
if (file) void loadFile(file);
}}
onClick={() => fileInputRef.current?.click()}
className={`cursor-pointer rounded-lg border-2 border-dashed p-8 text-center transition-colors ${
dragging
? 'border-[#662582] bg-purple-50'
: 'border-slate-300 bg-white hover:border-slate-400 hover:bg-slate-50'
}`}
>
<Upload size={26} className="mx-auto mb-2 text-slate-400" />
<p className="text-sm font-medium text-slate-700">
{fileName || 'Drop the .xlsx file here, or click to choose'}
</p>
<p className="mt-1 text-xs text-slate-500">Only the template downloaded above will import correctly.</p>
<input
ref={fileInputRef}
type="file"
accept=".xlsx,.xls,.csv"
className="hidden"
onChange={(e) => {
const file = e.target.files?.[0];
if (file) void loadFile(file);
}}
/>
</div>
/>
{/* `description` above is what ties this sentence to the input
for a screen reader — but Astryx hides the description
together with the label, so with `isLabelHidden` set it
collapses to a 1x1px span and sighted users simply lose it.
Repeated here so it stays on screen, as it was before. */}
<p className="mt-2 text-xs text-slate-500">
Only the template downloaded above will import correctly.
</p>
</section>
{/* Step 3 — the preview. Every problem is shown at once, against
@@ -345,37 +349,37 @@ export default function OfflineSalesUpload({
sanity-check what is about to be deducted where. */}
{summary.stores > 1 && (
<div className="mb-4 overflow-hidden rounded-lg border border-slate-200">
<table className="w-full text-left text-xs">
<thead className="bg-slate-100 text-[11px] uppercase tracking-wide text-slate-600">
<tr>
<th className="px-3 py-2 font-semibold">Store</th>
<th className="px-3 py-2 text-right font-semibold">Lines</th>
<th className="px-3 py-2 text-right font-semibold">Units</th>
<th className="px-3 py-2 text-right font-semibold">Amount</th>
<th className="px-3 py-2 text-right font-semibold">Problems</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-100">
<Table className="w-full text-left text-xs">
<TableHeader>
<TableRow>
<TableHeaderCell>Store</TableHeaderCell>
<TableHeaderCell className="text-right">Lines</TableHeaderCell>
<TableHeaderCell className="text-right">Units</TableHeaderCell>
<TableHeaderCell className="text-right">Amount</TableHeaderCell>
<TableHeaderCell className="text-right">Problems</TableHeaderCell>
</TableRow>
</TableHeader>
<TableBody>
{summary.byStore.map((s) => (
<tr key={s.locationid} className={s.errors > 0 ? 'bg-red-50' : 'bg-white'}>
<td className="px-3 py-2 font-medium text-slate-800">
<TableRow key={s.locationid} className={s.errors > 0 ? 'bg-red-50' : 'bg-white'}>
<TableCell className="font-medium text-slate-800">
{s.locationname || `Outlet ${s.locationid}`}
<span className="ml-1.5 font-mono text-[10px] text-slate-400">#{s.locationid}</span>
</td>
<td className="px-3 py-2 text-right text-slate-600">{s.lines}</td>
<td className="px-3 py-2 text-right text-slate-600">{s.units}</td>
<td className="px-3 py-2 text-right font-semibold text-slate-800">{money(s.amount)}</td>
<td
className={`px-3 py-2 text-right font-semibold ${
</TableCell>
<TableCell className="text-right text-slate-600">{s.lines}</TableCell>
<TableCell className="text-right text-slate-600">{s.units}</TableCell>
<TableCell className="text-right font-semibold text-slate-800">{money(s.amount)}</TableCell>
<TableCell
className={`text-right font-semibold ${
s.errors > 0 ? 'text-red-700' : 'text-emerald-700'
}`}
>
{s.errors}
</td>
</tr>
</TableCell>
</TableRow>
))}
</tbody>
</table>
</TableBody>
</Table>
</div>
)}
@@ -399,45 +403,45 @@ export default function OfflineSalesUpload({
)}
<div className="max-h-72 overflow-auto rounded-lg border border-slate-200">
<table className="w-full min-w-[920px] text-left text-xs">
<thead className="sticky top-0 bg-slate-100 text-[11px] uppercase tracking-wide text-slate-600">
<tr>
<th className="px-3 py-2 font-semibold">Row</th>
<th className="px-3 py-2 font-semibold">Store</th>
<th className="px-3 py-2 font-semibold">Product</th>
<th className="px-3 py-2 text-right font-semibold">Qty</th>
<th className="px-3 py-2 text-right font-semibold">Price</th>
<th className="px-3 py-2 text-right font-semibold">Amount</th>
<th className="px-3 py-2 font-semibold">Bill</th>
<th className="px-3 py-2 font-semibold">Status</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-100">
<Table className="w-full min-w-[920px] text-left text-xs">
<TableHeader>
<TableRow>
<TableHeaderCell>Row</TableHeaderCell>
<TableHeaderCell>Store</TableHeaderCell>
<TableHeaderCell>Product</TableHeaderCell>
<TableHeaderCell className="text-right">Qty</TableHeaderCell>
<TableHeaderCell className="text-right">Price</TableHeaderCell>
<TableHeaderCell className="text-right">Amount</TableHeaderCell>
<TableHeaderCell>Bill</TableHeaderCell>
<TableHeaderCell>Status</TableHeaderCell>
</TableRow>
</TableHeader>
<TableBody>
{parsed.rows.map((r) => {
const bad = r.errors.length > 0;
const warn = !bad && r.warnings.length > 0;
return (
<tr
<TableRow
key={r.excelRow}
className={bad ? 'bg-red-50' : warn ? 'bg-amber-50' : 'bg-white'}
>
<td className="px-3 py-2 font-mono text-slate-500">{r.excelRow}</td>
<td className="px-3 py-2 text-slate-700">
<TableCell className="font-mono text-slate-500">{r.excelRow}</TableCell>
<TableCell className="text-slate-700">
{r.locationname || `#${r.locationid}`}
</td>
<td className="px-3 py-2">
</TableCell>
<TableCell>
<span className="font-medium text-slate-800">{r.productname || '—'}</span>
<span className="ml-1.5 font-mono text-[10px] text-slate-400">#{r.productid}</span>
</td>
<td className="px-3 py-2 text-right font-semibold text-slate-800">{r.qtysold}</td>
<td className="px-3 py-2 text-right text-slate-600">
</TableCell>
<TableCell className="text-right font-semibold text-slate-800">{r.qtysold}</TableCell>
<TableCell className="text-right text-slate-600">
{r.unitprice ? money(r.unitprice) : <span className="text-amber-600">—</span>}
</td>
<td className="px-3 py-2 text-right text-slate-600">
</TableCell>
<TableCell className="text-right text-slate-600">
{money(Math.max(0, (r.unitprice ?? 0) * r.qtysold - r.discountamount))}
</td>
<td className="px-3 py-2 font-mono text-slate-500">{r.billno || '—'}</td>
<td className="px-3 py-2">
</TableCell>
<TableCell className="font-mono text-slate-500">{r.billno || '—'}</TableCell>
<TableCell>
{bad ? (
<span className="text-red-700">{r.errors.join('; ')}</span>
) : warn ? (
@@ -447,12 +451,12 @@ export default function OfflineSalesUpload({
<CheckCircle2 size={12} /> ready
</span>
)}
</td>
</tr>
</TableCell>
</TableRow>
);
})}
</tbody>
</table>
</TableBody>
</Table>
</div>
</>
)}
@@ -476,29 +480,29 @@ export default function OfflineSalesUpload({
</p>
<div className="flex items-center gap-3">
{parsed && (
<button
<Button
label="Clear the loaded file"
icon={<RotateCcw size={14} />}
onClick={reset}
style={{ height: 'auto' }}
className="flex items-center gap-1.5 rounded-lg border border-slate-300 bg-white px-4 py-2 text-sm font-medium text-slate-700 transition-colors hover:bg-slate-100"
>
<RotateCcw size={14} /> Clear
</button>
Clear
</Button>
)}
<button
<Button
label={
uploadMutation.isPending
? 'Importing…'
: `Import ${summary?.bills ? `${summary.bills} Bill${summary.bills === 1 ? '' : 's'}` : 'Sales'}`
}
icon={uploadMutation.isPending ? undefined : <Upload size={16} />}
isLoading={uploadMutation.isPending}
isDisabled={!parsed || blocked || uploadMutation.isPending}
onClick={() => uploadMutation.mutate()}
disabled={!parsed || blocked || uploadMutation.isPending}
className="flex items-center gap-2 rounded-lg bg-[#662582] px-5 py-2 text-sm font-semibold text-white transition-colors hover:bg-[#551f6d] disabled:cursor-not-allowed disabled:opacity-40"
>
{uploadMutation.isPending ? (
<>
<Loader2 size={16} className="animate-spin" /> Importing…
</>
) : (
<>
<Upload size={16} /> Import{' '}
{summary?.bills ? `${summary.bills} Bill${summary.bills === 1 ? '' : 's'}` : 'Sales'}
</>
)}
</button>
style={{ height: 'auto' }}
className="flex items-center gap-2 rounded-lg bg-[#662582] px-5 py-2 text-sm font-semibold text-white border-none transition-colors hover:bg-[#551f6d]"
/>
</div>
</div>
)}
@@ -563,29 +567,29 @@ function ResultPanel({
</div>
<div className="overflow-hidden rounded-lg border border-slate-200">
<table className="w-full text-left text-xs">
<thead className="bg-slate-100 text-[11px] uppercase tracking-wide text-slate-600">
<tr>
<th className="px-3 py-2 font-semibold">Store</th>
<th className="px-3 py-2 font-semibold">Bill</th>
<th className="px-3 py-2 font-semibold">Result</th>
<th className="px-3 py-2 font-semibold">Order</th>
<th className="px-3 py-2 text-right font-semibold">Items</th>
<th className="px-3 py-2 text-right font-semibold">Amount</th>
<th className="px-3 py-2 font-semibold">Detail</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-100">
<Table className="w-full text-left text-xs">
<TableHeader>
<TableRow>
<TableHeaderCell>Store</TableHeaderCell>
<TableHeaderCell>Bill</TableHeaderCell>
<TableHeaderCell>Result</TableHeaderCell>
<TableHeaderCell>Order</TableHeaderCell>
<TableHeaderCell className="text-right">Items</TableHeaderCell>
<TableHeaderCell className="text-right">Amount</TableHeaderCell>
<TableHeaderCell>Detail</TableHeaderCell>
</TableRow>
</TableHeader>
<TableBody>
{result.results.map((r, i) => (
<tr
<TableRow
key={`${r.locationid}-${r.billno}-${i}`}
className={
r.status === 'imported' ? 'bg-white' : r.status === 'duplicate' ? 'bg-slate-50' : 'bg-red-50'
}
>
<td className="px-3 py-2 text-slate-700">{r.locationname || (r.locationid ? `#${r.locationid}` : '—')}</td>
<td className="px-3 py-2 font-mono text-slate-700">{r.billno || '—'}</td>
<td className="px-3 py-2">
<TableCell className="text-slate-700">{r.locationname || (r.locationid ? `#${r.locationid}` : '—')}</TableCell>
<TableCell className="font-mono text-slate-700">{r.billno || '—'}</TableCell>
<TableCell>
{r.status === 'imported' && (
<span className="rounded bg-emerald-100 px-2 py-0.5 font-semibold text-emerald-800">imported</span>
)}
@@ -595,30 +599,30 @@ function ResultPanel({
{r.status === 'failed' && (
<span className="rounded bg-red-100 px-2 py-0.5 font-semibold text-red-800">failed</span>
)}
</td>
<td className="px-3 py-2 font-mono text-slate-600">{r.orderid || '—'}</td>
<td className="px-3 py-2 text-right text-slate-600">{r.itemcount || '—'}</td>
<td className="px-3 py-2 text-right text-slate-600">{r.amount ? money(r.amount) : '—'}</td>
<td className="px-3 py-2 text-slate-600">{r.message}</td>
</tr>
</TableCell>
<TableCell className="font-mono text-slate-600">{r.orderid || '—'}</TableCell>
<TableCell className="text-right text-slate-600">{r.itemcount || '—'}</TableCell>
<TableCell className="text-right text-slate-600">{r.amount ? money(r.amount) : '—'}</TableCell>
<TableCell className="text-slate-600">{r.message}</TableCell>
</TableRow>
))}
</tbody>
</table>
</TableBody>
</Table>
</div>
<div className="mt-6 flex justify-end gap-3">
<button
<Button
label="Upload Another File"
onClick={onAnother}
style={{ height: 'auto' }}
className="rounded-lg border border-slate-300 bg-white px-4 py-2 text-sm font-medium text-slate-700 transition-colors hover:bg-slate-100"
>
Upload Another File
</button>
<button
/>
<Button
label="Done"
onClick={onClose}
className="rounded-lg bg-[#662582] px-5 py-2 text-sm font-semibold text-white transition-colors hover:bg-[#551f6d]"
>
Done
</button>
style={{ height: 'auto' }}
className="rounded-lg bg-[#662582] px-5 py-2 text-sm font-semibold text-white border-none transition-colors hover:bg-[#551f6d]"
/>
</div>
</div>
);

View File

@@ -3,6 +3,10 @@ import { createPortal } from 'react-dom';
import { useFiestaOrderDetails } from '../services/fiestaQueries';
import { num as fnum, str as fstr, type Row } from '../services/fiestaApi';
import { X, Calendar, MapPin, Loader2, CreditCard } from 'lucide-react';
import { IconButton } from '@astryxdesign/core/IconButton';
import {
Table, TableHeader, TableHeaderCell, TableBody, TableRow, TableCell,
} from '@astryxdesign/core/Table';
import './OrderDetailsModal.css';
interface OrderDetailsModalProps {
@@ -76,9 +80,16 @@ export default function OrderDetailsModal({ order, onClose }: OrderDetailsModalP
{orderDate}
</div>
</div>
<button className="odm-close-btn" onClick={onClose}>
<X size={18} />
</button>
{/* Had no text and no aria-label at all — the only way out of this
modal announced itself as "button". */}
<IconButton
icon={<X size={18} />}
label={`Close order ${orderId} details`}
variant="ghost"
onClick={onClose}
className="odm-close-btn"
style={{ height: 'auto', width: 'auto', minWidth: 0 }}
/>
</div>
<div className="odm-status-row">
@@ -98,16 +109,16 @@ export default function OrderDetailsModal({ order, onClose }: OrderDetailsModalP
) : (
<>
<div className="odm-section-title">Order Items</div>
<table className="odm-items-table">
<thead>
<tr>
<th>Item</th>
<th className="text-right">Price</th>
<th className="text-right">Qty</th>
<th className="text-right">Total</th>
</tr>
</thead>
<tbody>
<Table className="odm-items-table">
<TableHeader>
<TableRow>
<TableHeaderCell>Item</TableHeaderCell>
<TableHeaderCell className="text-right">Price</TableHeaderCell>
<TableHeaderCell className="text-right">Qty</TableHeaderCell>
<TableHeaderCell className="text-right">Total</TableHeaderCell>
</TableRow>
</TableHeader>
<TableBody>
{details && details.length > 0 ? (
details.map((item, i) => {
const name = fstr(item.productname) || fstr(item.itemname) || 'Item';
@@ -115,23 +126,23 @@ export default function OrderDetailsModal({ order, onClose }: OrderDetailsModalP
const price = fnum(item.price) || fnum(item.unitprice) || fnum(item.retailprice) || 0;
const lineTotal = fnum(item.amount) || fnum(item.productsumprice) || (qty * price);
return (
<tr key={i}>
<td>
<TableRow key={i}>
<TableCell>
<div className="odm-item-name">{name}</div>
</td>
<td className="text-right text-slate-500">₹{price.toLocaleString('en-IN', { maximumFractionDigits: 0 })}</td>
<td className="text-right text-slate-500">x{qty}</td>
<td className="text-right font-bold text-slate-800">₹{lineTotal.toLocaleString('en-IN', { maximumFractionDigits: 0 })}</td>
</tr>
</TableCell>
<TableCell className="text-right text-slate-500">₹{price.toLocaleString('en-IN', { maximumFractionDigits: 0 })}</TableCell>
<TableCell className="text-right text-slate-500">x{qty}</TableCell>
<TableCell className="text-right font-bold text-slate-800">₹{lineTotal.toLocaleString('en-IN', { maximumFractionDigits: 0 })}</TableCell>
</TableRow>
);
})
) : (
<tr>
<td colSpan={4} className="text-center text-slate-400 py-4">No line items found.</td>
</tr>
<TableRow>
<TableCell colSpan={4} className="text-center text-slate-400 py-4">No line items found.</TableCell>
</TableRow>
)}
</tbody>
</table>
</TableBody>
</Table>
<div className="odm-totals-section">
<div className="odm-total-row">

View File

@@ -772,8 +772,10 @@ export default function PosView({ locationid, tenantId = FIESTA_TENANT_ID }: Pos
{/* 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. */}
times a shift. Keeping that face never required keeping a raw
<button> — the classes ride on an Astryx Button unchanged. What it
gains is a name that says what is about to be charged: the visible
word stays "PAY", while a screen reader hears the amount. */}
<div className="flex gap-2 pos-pay-row">
<IconButton
label="Clear cart"
@@ -783,13 +785,16 @@ export default function PosView({ locationid, tenantId = FIESTA_TENANT_ID }: Pos
isDisabled={cart.length === 0}
onClick={clearCart}
/>
<button
<Button
label={grandTotal < 0 ? `Refund ₹${Math.abs(grandTotal).toFixed(2)}` : `Pay ₹${grandTotal.toFixed(2)}`}
endContent={<ChevronRight className="w-5 h-5" />}
isDisabled={cart.length === 0 || !customer}
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]"
style={{ height: 48 }}
className="flex-1 h-12 bg-emerald-600 hover:bg-emerald-700 disabled:bg-slate-200 disabled:text-slate-400 text-white border-none 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>
PAY
</Button>
</div>
</div>
</div>
@@ -828,12 +833,22 @@ export default function PosView({ locationid, tenantId = FIESTA_TENANT_ID }: Pos
// 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
// Each row's name was assembled from the four scraps of text
// inside it, so a reader heard the SKU, price and stock line
// run together before knowing what the product was.
<Button
key={product.id}
disabled={product.closing <= 0}
label={
product.closing <= 0
? `${product.name} — out of stock`
: `Add ${product.name}, ₹${product.price.toFixed(2)} per ${product.unit}`
}
isDisabled={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"
style={{ height: 'auto' }}
className="w-full text-left px-5 py-3.5 flex justify-between items-center border-b border-slate-100 bg-transparent hover:bg-slate-50 transition-colors group"
>
<span className="w-full flex justify-between items-center">
<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>
@@ -844,7 +859,8 @@ export default function PosView({ locationid, tenantId = FIESTA_TENANT_ID }: Pos
{product.closing <= 0 ? 'Out of stock' : `${product.closing} in stock`}
</p>
</div>
</button>
</span>
</Button>
))}
</div>
</PosModal>
@@ -1118,13 +1134,19 @@ export default function PosView({ locationid, tenantId = FIESTA_TENANT_ID }: Pos
{/* Same exception as PAY, and for the same reason: this is the
commit point of the transaction. */}
<button
<Button
label={
grandTotal < 0
? `Confirm refund of ₹${Math.abs(grandTotal).toFixed(2)}`
: `Confirm payment of ₹${grandTotal.toFixed(2)}`
}
isDisabled={payMethod === 'store_credit' && !customer}
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"
style={{ height: 'auto' }}
className="w-full py-4 bg-emerald-600 hover:bg-emerald-700 disabled:bg-slate-200 disabled:text-slate-400 text-white border-none rounded-xl font-black text-lg transition-all shadow-lg shadow-emerald-600/25 disabled:shadow-none"
>
Confirm {grandTotal < 0 ? 'Refund' : 'Payment'}
</button>
</Button>
</div>
</PosModal>
)}

View File

@@ -38,6 +38,12 @@ import {
} from '../services/fiestaQueries';
import { FIESTA_TENANT_ID, FIESTA_PRIMARY_LOCATION_ID, num as fnum, str as fstr, ymd } from '../services/fiestaApi';
import { stockRowToProduct } from '../services/fiestaMappers';
import { Button } from '@astryxdesign/core/Button';
import { IconButton } from '@astryxdesign/core/IconButton';
import { Selector } from '@astryxdesign/core/Selector';
import {
Table, TableHeader, TableHeaderCell, TableBody, TableRow, TableCell,
} from '@astryxdesign/core/Table';
import AwaitingApi from './AwaitingApi';
import SalesRevenueReport from './SalesRevenueReport';
import { useNotify } from './Toasts';
@@ -507,37 +513,49 @@ export default function ReportsView({ searchQuery, isCoimbatoreView, setIsCoimba
return (
<div className="space-y-lg animate-in fade-in duration-500 font-sans">
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 relative z-20 -mt-2 pb-2 border-b border-slate-200/70">
{/* PHASE 6 — Button + aria-pressed, NOT Astryx's TabList.
TabList looks like the obvious fit and is not: it renders a <nav>
and marks the active entry `aria-current="page"`, which is the
navigation pattern. These two switch an in-page panel and never
touch the URL, so claiming a page changed would be plainly untrue.
Astryx ships no ARIA tablist, so this uses the same pressed-button
pattern as the other two single-select groups on this page, and
keeps the original underline styling exactly. */}
<div className="flex items-center gap-4">
<button
onClick={() => setActiveTab('overview')}
className={`pb-1 text-sm font-bold border-b-2 transition-colors ${activeTab === 'overview' ? 'border-[#662582] text-[#662582]' : 'border-transparent text-slate-500 hover:text-slate-700'}`}
>
Overview
</button>
<button
onClick={() => setActiveTab('sales_revenue')}
className={`pb-1 text-sm font-bold border-b-2 transition-colors ${activeTab === 'sales_revenue' ? 'border-[#662582] text-[#662582]' : 'border-transparent text-slate-500 hover:text-slate-700'}`}
>
Sales & Revenue
</button>
{([
{ id: 'overview', label: 'Overview' },
{ id: 'sales_revenue', label: 'Sales & Revenue' },
] as const).map((t) => (
<Button
key={t.id}
label={t.label}
aria-pressed={activeTab === t.id}
variant="ghost"
onClick={() => setActiveTab(t.id)}
style={{ height: 'auto' }}
className={`pb-1 text-sm font-bold border-b-2 rounded-none bg-transparent transition-colors ${activeTab === t.id ? 'border-[#662582] text-[#662582]' : 'border-transparent text-slate-500 hover:text-slate-700'}`}
/>
))}
</div>
{/* Global Store Filter */}
{activeTab === 'sales_revenue' && (
<div className="flex items-center gap-2">
<Store size={16} className="text-slate-500" />
<select
value={activeStoreId}
onChange={(e) => setActiveStoreId(e.target.value === 'all' ? 'all' : Number(e.target.value))}
className="text-sm font-bold outline-none rounded-lg px-3 py-1 border border-slate-200 bg-white text-slate-700 focus:ring-2 focus:ring-purple-500 focus:border-transparent transition-all cursor-pointer"
>
<option value="all">All Stores</option>
{regionLocations.map(loc => (
<option key={loc.locationid} value={Number(loc.locationid)}>
{loc.locationname || `Store ${loc.locationid}`}
</option>
))}
</select>
<Selector
label="Filter reports by store"
isLabelHidden
size="sm"
value={String(activeStoreId)}
onChange={(v) => setActiveStoreId(v === 'all' ? 'all' : Number(v))}
options={[
{ value: 'all', label: 'All Stores' },
...regionLocations.map((loc) => ({
value: String(Number(loc.locationid)),
label: loc.locationname || `Store ${loc.locationid}`,
})),
]}
/>
</div>
)}
</div>
@@ -587,11 +605,21 @@ export default function ReportsView({ searchQuery, isCoimbatoreView, setIsCoimba
}
return (
<button
// PHASE 6 — these four cards choose which metric the chart below
// plots, but said so only by growing a coloured ring. `aria-pressed`
// makes the choice audible, and `label` names what the card does
// rather than leaving the reader to assemble a name out of the
// title, trend, value and status text crammed inside it.
<Button
key={kpi.id}
label={`Chart ${kpi.title}`}
aria-pressed={isActive}
variant="ghost"
onClick={() => setChartMetric(kpi.id)}
style={{ height: 'auto' }}
className={`rounded-xl p-3 flex flex-col justify-between transition-all duration-300 text-left cursor-pointer outline-none relative group border ${activeStyles}`}
>
<span className="w-full flex flex-col justify-between">
<div className="flex justify-between items-start w-full">
<span className="text-[10px] text-slate-500 uppercase tracking-widest font-bold">{kpi.title}</span>
<div className="flex items-center gap-2">
@@ -633,7 +661,8 @@ export default function ReportsView({ searchQuery, isCoimbatoreView, setIsCoimba
</svg>
</div>
</div>
</button>
</span>
</Button>
);
})}
</div>
@@ -668,12 +697,13 @@ export default function ReportsView({ searchQuery, isCoimbatoreView, setIsCoimba
{selectedCell.day} · {selectedCell.hour} : {selectedCell.val} orders
</span>
</div>
<button
<Button
label="Dismiss"
variant="ghost"
onClick={() => setSelectedCell(null)}
className="text-xs font-bold text-purple-700 hover:text-purple-900 cursor-pointer border-none bg-transparent hover:underline px-2"
>
Dismiss
</button>
style={{ height: 'auto' }}
className="text-xs font-bold text-purple-700 hover:text-purple-900 cursor-pointer hover:underline px-2"
/>
</div>
)}
@@ -682,40 +712,48 @@ export default function ReportsView({ searchQuery, isCoimbatoreView, setIsCoimba
{insightQ.isLoading ? 'Loading weekly order distribution…' : 'No order insight available for this region.'}
</div>
) : (
<table className="w-full text-center border-collapse text-xs font-sans">
<thead>
<tr>
<th className="p-2 text-[9px] font-bold text-slate-400 w-32 uppercase italic text-left pl-4">Outlet Name</th>
<Table className="w-full text-center text-xs font-sans">
<TableHeader>
<TableRow>
<TableHeaderCell className="w-32 text-left">Outlet Name</TableHeaderCell>
{WEEK_LABELS.map((w) => (
<th key={w} className="p-2 text-[10px] font-bold text-slate-800 border-b border-slate-100">
{w}
</th>
<TableHeaderCell key={w} className="text-center">{w}</TableHeaderCell>
))}
</tr>
</thead>
<tbody>
</TableRow>
</TableHeader>
<TableBody>
{insightRows.map((row, idx) => (
<tr key={idx}>
<td className="p-2 text-left font-bold text-slate-800 tracking-wide truncate max-w-[8rem] border-r border-slate-100 pl-4">
<TableRow key={idx}>
<TableCell className="text-left font-bold text-slate-800 tracking-wide truncate max-w-[8rem] border-r border-slate-100">
{row.name}
</td>
</TableCell>
{WEEK_KEYS.map((key, mIdx) => {
const val = fnum(row.weeks[key]);
const isPicked =
selectedCell?.day === row.name && selectedCell?.hour === WEEK_LABELS[mIdx];
return (
<td key={key} className="p-1 border border-white">
<button
<TableCell key={key} className="p-1">
{/* Every one of these announced itself as nothing
but its own number — "12", with no hint of which
outlet or which week produced it. The label now
carries the whole cell. */}
<Button
label={`${row.name}, ${WEEK_LABELS[mIdx]}: ${val} orders`}
aria-pressed={isPicked}
variant="ghost"
onClick={() => setSelectedCell({ day: row.name, hour: WEEK_LABELS[mIdx], val })}
className={`w-full py-2.5 rounded-lg font-semibold transition-all duration-200 cursor-pointer border-none ${getHeatmapColorClass(val)}`}
style={{ height: 'auto' }}
className={`w-full py-2.5 rounded-lg font-semibold transition-all duration-200 cursor-pointer ${getHeatmapColorClass(val)}`}
>
{val}
</button>
</td>
</Button>
</TableCell>
);
})}
</tr>
</TableRow>
))}
</tbody>
</table>
</TableBody>
</Table>
)}
</div>
</div>
@@ -907,56 +945,60 @@ export default function ReportsView({ searchQuery, isCoimbatoreView, setIsCoimba
{/* Quick interactive filter pills */}
<div className="flex gap-2 text-[10px] font-bold uppercase tracking-wider">
{(['All', 'Healthy', 'Low Stock', 'Critical'] as const).map((filter) => (
<button
<Button
key={filter}
label={filter === 'All' ? 'Show all products' : `Show ${filter} products`}
aria-pressed={stockFilter === filter}
variant="ghost"
onClick={() => setStockFilter(filter)}
style={{ height: 'auto' }}
className={`px-3 py-1.5 rounded-lg cursor-pointer transition-colors border ${stockFilter === filter
? 'bg-[#662582] text-white border-[#662582] shadow-sm'
: 'bg-white border-[#e2e8f0] text-zinc-655 hover:bg-zinc-55'
}`}
>
{filter}
</button>
</Button>
))}
</div>
</div>
{/* Matrix Data table */}
<div className="overflow-x-auto text-xs font-sans">
<table className="w-full text-left">
<thead className="bg-[#f8fafc] border-b border-[#e2e8f0] text-zinc-500 text-[9px] uppercase font-bold tracking-wider">
<tr>
<th className="w-10 px-md py-sm"></th>
<th className="px-md py-sm">Product Name</th>
<th className="px-md py-sm">SKU ID</th>
<th className="px-md py-sm text-right">Units Sold</th>
<th className="px-md py-sm text-right">Overall Revenue</th>
<th className="px-md py-sm text-right">Stock Status</th>
</tr>
</thead>
<tbody className="divide-y divide-[#f1f5f9]">
<Table className="w-full text-left">
<TableHeader>
<TableRow>
<TableHeaderCell className="w-10"><span className="sr-only">Expand row</span></TableHeaderCell>
<TableHeaderCell>Product Name</TableHeaderCell>
<TableHeaderCell>SKU ID</TableHeaderCell>
<TableHeaderCell className="text-right">Units Sold</TableHeaderCell>
<TableHeaderCell className="text-right">Overall Revenue</TableHeaderCell>
<TableHeaderCell className="text-right">Stock Status</TableHeaderCell>
</TableRow>
</TableHeader>
<TableBody>
{paginatedProducts.length === 0 ? (
<tr>
<td colSpan={6} className="text-center py-8 text-zinc-400 font-medium">
<TableRow>
<TableCell colSpan={6} className="text-center py-8 text-zinc-400 font-medium">
No matching items matching stock filter criteria.
</td>
</tr>
</TableCell>
</TableRow>
) : (
paginatedProducts.map((prod) => {
const isExpanded = expandedProductId === prod.id;
return (
<React.Fragment key={prod.id}>
<tr
<TableRow
onClick={() => setExpandedProductId(isExpanded ? null : prod.id)}
className={`hover:bg-[#f2f4f6]/40 transition-all font-medium text-zinc-700 cursor-pointer ${isExpanded ? 'bg-slate-50/50' : ''}`}
className={`transition-all font-medium text-zinc-700 cursor-pointer ${isExpanded ? 'bg-slate-50/50' : ''}`}
>
<td className="px-md py-md text-center text-zinc-400">
<TableCell className="text-center text-zinc-400">
<ChevronDown
size={14}
className={`transition-transform duration-200 ${isExpanded ? 'rotate-180 text-purple-650' : 'text-zinc-400'}`}
/>
</td>
<td className="px-md py-md flex items-center gap-sm">
</TableCell>
<TableCell className="flex items-center gap-sm">
<div className="w-8 h-8 rounded-lg shrink-0 border border-[#e2e8f0] overflow-hidden bg-zinc-50">
<img
src={prod.image}
@@ -969,17 +1011,17 @@ export default function ReportsView({ searchQuery, isCoimbatoreView, setIsCoimba
<span className="font-bold text-[#0f172a] block">{prod.name}</span>
<span className="text-[9px] text-zinc-455 block font-semibold uppercase mt-0.5">{prod.category}</span>
</div>
</td>
<td className="px-md py-md font-mono text-zinc-500 font-semibold">
</TableCell>
<TableCell className="font-mono text-zinc-500 font-semibold">
{prod.sku}
</td>
<td className="px-md py-md text-right font-mono text-zinc-655 font-bold">
</TableCell>
<TableCell className="text-right font-mono text-zinc-655 font-bold">
{prod.unitsSold.toLocaleString()}
</td>
<td className="px-md py-md text-right font-mono text-zinc-800 font-extrabold">
</TableCell>
<TableCell className="text-right font-mono text-zinc-800 font-extrabold">
₹{prod.revenue.toLocaleString()}
</td>
<td className="px-md py-md text-right">
</TableCell>
<TableCell className="text-right">
<span className={`px-2 py-0.5 rounded-full text-[9px] font-bold uppercase tracking-wide inline-block ${prod.stockStatus === 'Healthy'
? 'bg-emerald-50 text-emerald-600 border border-emerald-100'
: prod.stockStatus === 'Low Stock'
@@ -988,8 +1030,8 @@ export default function ReportsView({ searchQuery, isCoimbatoreView, setIsCoimba
}`}>
{prod.stockStatus}
</span>
</td>
</tr>
</TableCell>
</TableRow>
{/* Expanded details row */}
{isExpanded && (() => {
@@ -1022,8 +1064,8 @@ export default function ReportsView({ searchQuery, isCoimbatoreView, setIsCoimba
}).filter(Boolean);
return (
<tr className="bg-slate-50/50 border-b border-[#e2e8f0]">
<td colSpan={6} className="p-0">
<TableRow className="bg-slate-50/50 border-b border-[#e2e8f0]">
<TableCell colSpan={6} className="p-0">
<div className="px-8 py-6 bg-white border-y border-slate-100 shadow-[inset_0_4px_6px_-4px_rgba(0,0,0,0.05)] animate-in slide-in-from-top-2 duration-300">
<h4 className="text-xs font-bold text-slate-800 mb-4 flex items-center gap-2">
<div className="w-1.5 h-1.5 rounded-full bg-purple-600 animate-pulse"></div>
@@ -1067,16 +1109,16 @@ export default function ReportsView({ searchQuery, isCoimbatoreView, setIsCoimba
</div>
)}
</div>
</td>
</tr>
</TableCell>
</TableRow>
);
})()}
</React.Fragment>
);
})
)}
</tbody>
</table>
</TableBody>
</Table>
</div>
{/* Matrix table pagination */}
@@ -1084,25 +1126,32 @@ export default function ReportsView({ searchQuery, isCoimbatoreView, setIsCoimba
<span>Showing {Math.min((currentPage - 1) * ITEMS_PER_PAGE + 1, filteredProducts.length)}-{Math.min(currentPage * ITEMS_PER_PAGE, filteredProducts.length)} of {filteredProducts.length} filtered products (from {liveProducts.length} total)</span>
<div className="flex gap-xs">
<button
{/* Both were icon-only <button>s with no text and no aria-label,
so the pager read as "button, button" either side of the page
count. */}
<IconButton
icon={<ChevronLeft size={12} />}
label="Previous page"
tooltip="Previous page"
isDisabled={currentPage === 1}
onClick={() => setCurrentPage(prev => Math.max(1, prev - 1))}
disabled={currentPage === 1}
className={`w-8 h-8 border border-[#e2e8f0] rounded-lg flex items-center justify-center cursor-pointer transition-colors ${currentPage === 1 ? 'opacity-50 cursor-not-allowed' : 'hover:bg-white active:bg-[#f8fafc]'}`}
>
<ChevronLeft size={12} />
</button>
style={{ height: 32, width: 32, minWidth: 0, background: 'transparent' }}
className="w-8 h-8 border border-[#e2e8f0] rounded-lg flex items-center justify-center cursor-pointer transition-colors hover:bg-white active:bg-[#f8fafc]"
/>
<div className="flex items-center px-2 font-bold text-[10px] text-zinc-600">
Page {currentPage} of {totalPages}
</div>
<button
<IconButton
icon={<ChevronRight size={12} />}
label="Next page"
tooltip="Next page"
isDisabled={currentPage === totalPages}
onClick={() => setCurrentPage(prev => Math.min(totalPages, prev + 1))}
disabled={currentPage === totalPages}
className={`w-8 h-8 border border-[#e2e8f0] rounded-lg flex items-center justify-center cursor-pointer transition-colors ${currentPage === totalPages ? 'opacity-50 cursor-not-allowed' : 'hover:bg-white active:bg-[#f8fafc]'}`}
>
<ChevronRight size={12} />
</button>
style={{ height: 32, width: 32, minWidth: 0, background: 'transparent' }}
className="w-8 h-8 border border-[#e2e8f0] rounded-lg flex items-center justify-center cursor-pointer transition-colors hover:bg-white active:bg-[#f8fafc]"
/>
</div>
</div>
</div>

View File

@@ -25,10 +25,17 @@ import { FIESTA_TENANT_ID, num as fnum, str as fstr, ymd, type Row } from '../se
// ... inside the component ...
import { shortTime } from '../services/fiestaMappers';
import { Button } from '@astryxdesign/core/Button';
import { DateInput } from '@astryxdesign/core/DateInput';
import type { ISODateString } from '@astryxdesign/core/utils';
import { Selector } from '@astryxdesign/core/Selector';
import {
KpiStrip, Pill, FilterBar, TH_STYLE,
BRAND, BRAND_LIGHT, TEXT, TEXT_2, TEXT_3, BORDER, DIVIDER, SURFACE_ALT,
tint, soft, edge, ring
Table, TableHeader, TableHeaderCell, TableBody, TableRow, TableCell,
} from '@astryxdesign/core/Table';
import {
KpiStrip, Pill, FilterBar,
BRAND, BRAND_LIGHT, TEXT, TEXT_2, TEXT_3, BORDER, SURFACE_ALT,
ring
} from './consoleUi';
import { ResponsiveContainer, AreaChart, Area, BarChart, Bar, PieChart, Pie, Cell, XAxis, YAxis, Tooltip as RechartsTooltip, CartesianGrid } from 'recharts';
@@ -242,20 +249,39 @@ export default function SalesRevenueReport({
</div>
<div className="flex items-center gap-3 flex-wrap lg:justify-end min-w-0">
{/* PHASE 6 — both were unlabelled `<input type="date">`, announcing
themselves as bare date fields with no hint of which end of the
range they set. The min/max cross-guard is preserved, including
`todayStr` as the ceiling on the To date. */}
<div className="flex items-center gap-1.5">
<input type="date" value={fromdate} max={todate} onChange={(e) => { setFromdate(e.target.value); setPageno(1); }} className="rounded-full outline-none font-semibold text-xs transition-colors" style={{ padding: '6px 12px', border: `1.5px solid ${edge(BRAND)}`, background: tint(BRAND), color: BRAND }} />
<DateInput
label="From date"
isLabelHidden
size="sm"
value={fromdate as ISODateString}
max={todate as ISODateString}
onChange={(v) => { if (v) { setFromdate(v); setPageno(1); } }}
/>
<span className="text-zinc-400 font-bold px-1 text-xs">—</span>
<input type="date" value={todate} min={fromdate} max={todayStr} onChange={(e) => { setTodate(e.target.value); setPageno(1); }} className="rounded-full outline-none font-semibold text-xs transition-colors" style={{ padding: '6px 12px', border: `1.5px solid ${edge(BRAND)}`, background: tint(BRAND), color: BRAND }} />
<DateInput
label="To date"
isLabelHidden
size="sm"
value={todate as ISODateString}
min={fromdate as ISODateString}
max={todayStr as ISODateString}
onChange={(v) => { if (v) { setTodate(v); setPageno(1); } }}
/>
</div>
<button
<Button
label="Export CSV"
icon={<Download size={13} />}
isDisabled={tableRows.length === 0}
tooltip="Export to CSV"
onClick={exportCsv}
disabled={tableRows.length === 0}
title="Export to CSV"
className="inline-flex items-center gap-1.5 rounded-full font-extrabold text-white cursor-pointer disabled:opacity-40 disabled:cursor-not-allowed whitespace-nowrap shrink-0"
style={{ padding: '8px 14px', fontSize: 12, background: `linear-gradient(135deg, ${BRAND}, ${BRAND_LIGHT})`, boxShadow: `0 6px 18px ${ring(BRAND)}` }}
>
<Download size={13} /> Export CSV
</button>
className="inline-flex items-center gap-1.5 rounded-full font-extrabold text-white cursor-pointer whitespace-nowrap shrink-0"
style={{ height: 'auto', padding: '8px 14px', fontSize: 12, border: 'none', color: '#fff', background: `linear-gradient(135deg, ${BRAND}, ${BRAND_LIGHT})`, boxShadow: `0 6px 18px ${ring(BRAND)}` }}
/>
</div>
</div>
</FilterBar>
@@ -415,77 +441,75 @@ export default function SalesRevenueReport({
<h3 className="text-sm font-bold text-slate-800">Completed Order Ledger</h3>
</div>
{!locationid && uniqueStoreNames.length > 0 && (
<select
<Selector
label="Filter ledger by store"
isLabelHidden
size="sm"
value={storeFilter}
onChange={(e) => { setStoreFilter(e.target.value); setPageno(1); }}
className="text-xs font-bold outline-none rounded-lg px-3 py-1.5 border border-slate-200 bg-white text-slate-700 focus:ring-2 focus:ring-emerald-500 focus:border-transparent transition-all cursor-pointer"
>
<option value="All">All Stores</option>
{uniqueStoreNames.map(store => (
<option key={store} value={store}>{store}</option>
))}
</select>
onChange={(v) => { setStoreFilter(v); setPageno(1); }}
options={[
{ value: 'All', label: 'All Stores' },
...uniqueStoreNames.map((store) => ({ value: store, label: store })),
]}
/>
)}
</div>
<div className="overflow-x-auto">
<table className="w-full" style={{ minWidth: 800 }}>
<thead>
<tr>
<Table style={{ minWidth: 800 }}>
<TableHeader>
<TableRow>
{['#', 'Order', 'Branch', 'Customer', 'Qty', 'Payment', 'Amount (₹)'].map((h, i) => (
<th key={i} className={`px-3 py-2.5 ${i === 6 ? 'text-right' : 'text-left'}`} style={TH_STYLE}>{h}</th>
<TableHeaderCell key={i} className={i === 6 ? 'text-right' : 'text-left'}>{h}</TableHeaderCell>
))}
</tr>
</thead>
<tbody>
</TableRow>
</TableHeader>
<TableBody>
{allOrdersQ.isLoading ? (
<tr><td colSpan={7} className="px-3 py-12 text-center" style={{ color: TEXT_3 }}>
<TableRow><TableCell colSpan={7} className="px-3 py-12 text-center" style={{ color: TEXT_3 }}>
<span className="inline-flex items-center gap-2 text-xs font-semibold">
<Loader2 size={15} className="animate-spin" style={{ color: BRAND }} /> Loading data…
</span>
</td></tr>
</TableCell></TableRow>
) : pageRows.length === 0 ? (
<tr><td colSpan={7} className="px-3 py-12 text-center text-xs" style={{ color: TEXT_3 }}>
<TableRow><TableCell colSpan={7} className="px-3 py-12 text-center text-xs" style={{ color: TEXT_3 }}>
No completed orders found for this date range.
</td></tr>
</TableCell></TableRow>
) : (
pageRows.map((r, i) => {
const amount = fnum(r.ordervalue) || fnum(r.orderamount) || fnum(r.deliveryamt);
return (
<tr
<TableRow
key={fstr(r.orderid) || fstr(r.orderheaderid) || i}
className="transition-colors align-top"
style={{ borderBottom: `1px solid ${DIVIDER}` }}
onMouseEnter={(e) => (e.currentTarget.style.background = SURFACE_ALT)}
onMouseLeave={(e) => (e.currentTarget.style.background = 'transparent')}
className="align-top"
>
<td className="px-3 py-2.5 font-mono" style={{ color: TEXT_3 }}>{(pageno - 1) * PAGE_SIZE + i + 1}</td>
<td className="px-3 py-2.5">
<TableCell className="font-mono" style={{ color: TEXT_3 }}>{(pageno - 1) * PAGE_SIZE + i + 1}</TableCell>
<TableCell>
<p className="font-extrabold font-mono text-[13px]" style={{ color: TEXT }}>{fstr(r.orderid) || `#${fstr(r.orderheaderid)}`}</p>
<p className="text-[10px]" style={{ color: TEXT_2 }}>{shortTime(r.orderdate || r.deliverydate)}</p>
</td>
<td className="px-3 py-2.5">
</TableCell>
<TableCell>
<span className="inline-flex items-center gap-1 font-bold text-[12px]" style={{ color: BRAND }}>
<MapPin size={11} /> {fstr(r.applocation) || fstr(r.locationname) || '—'}
</span>
</td>
<td className="px-3 py-2.5">
</TableCell>
<TableCell>
<p className="font-bold text-[12px] truncate max-w-[140px]" style={{ color: TEXT }}>{fstr(r.deliverycustomer) || fstr(r.pickupcustomer) || fstr(r.tenantname) || '—'}</p>
</td>
<td className="px-3 py-2.5 font-mono text-[12px]" style={{ color: TEXT }}>{fnum(r.quantity) || '—'}</td>
<td className="px-3 py-2.5">
</TableCell>
<TableCell className="font-mono text-[12px]" style={{ color: TEXT }}>{fnum(r.quantity) || '—'}</TableCell>
<TableCell>
<span className="inline-flex items-center px-1.5 py-0.5 rounded-full text-[10px] font-black tracking-wide bg-slate-100 text-slate-600">
{(fstr(r.paymentmode) || fstr(r.paymenttype) || 'CASH').toUpperCase()}
</span>
</td>
<td className="px-3 py-2.5 text-right font-mono text-[13px] font-bold text-emerald-600">
</TableCell>
<TableCell className="text-right font-mono text-[13px] font-bold text-emerald-600">
{amount > 0 ? `₹${amount.toLocaleString('en-IN')}` : '—'}
</td>
</tr>
</TableCell>
</TableRow>
);
})
)}
</tbody>
</table>
</TableBody>
</Table>
</div>
{/* Pagination */}
@@ -494,16 +518,26 @@ export default function SalesRevenueReport({
Page {pageno} · {pageRows.length} of {tableRows.length} shown
</span>
<div className="flex items-center gap-2">
<button disabled={pageno === 1} onClick={() => setPageno((p) => Math.max(1, p - 1))}
className="inline-flex items-center gap-1 rounded-full font-bold transition-colors cursor-pointer disabled:opacity-40 disabled:cursor-not-allowed"
style={{ padding: '6px 12px', fontSize: 11, border: `1px solid ${BORDER}`, background: '#fff', color: TEXT_2 }}>
<ChevronLeft size={13} /> Prev
</button>
<button disabled={!hasNext} onClick={() => setPageno((p) => p + 1)}
className="inline-flex items-center gap-1 rounded-full font-bold transition-colors cursor-pointer disabled:opacity-40 disabled:cursor-not-allowed"
style={{ padding: '6px 12px', fontSize: 11, border: `1px solid ${BORDER}`, background: '#fff', color: TEXT_2 }}>
Next <ChevronRight size={13} />
</button>
<Button
label="Previous page"
icon={<ChevronLeft size={13} />}
isDisabled={pageno === 1}
onClick={() => setPageno((p) => Math.max(1, p - 1))}
className="inline-flex items-center gap-1 rounded-full font-bold cursor-pointer"
style={{ height: 'auto', padding: '6px 12px', fontSize: 11, border: `1px solid ${BORDER}`, background: '#fff', color: TEXT_2 }}
>
Prev
</Button>
<Button
label="Next page"
endContent={<ChevronRight size={13} />}
isDisabled={!hasNext}
onClick={() => setPageno((p) => p + 1)}
className="inline-flex items-center gap-1 rounded-full font-bold cursor-pointer"
style={{ height: 'auto', padding: '6px 12px', fontSize: 11, border: `1px solid ${BORDER}`, background: '#fff', color: TEXT_2 }}
>
Next
</Button>
</div>
</div>
</div>

View File

@@ -1,6 +1,7 @@
import React, { useMemo, useRef } from 'react';
import { QRCodeSVG, QRCodeCanvas } from 'qrcode.react';
import { MapPin, ScanLine, AlertTriangle, Download } from 'lucide-react';
import { Button } from '@astryxdesign/core/Button';
import { buildStoreQrPayload } from '../services/fiestaApi';
interface StoreQRViewProps {
@@ -119,13 +120,13 @@ export default function StoreQRView({
{/* Download Action Button */}
<div className="w-full mt-6">
<button
<Button
label="Download QR Code"
icon={<Download size={14} />}
onClick={downloadPng}
style={{ height: 'auto' }}
className="w-full flex items-center justify-center gap-2 py-3 px-5 bg-gradient-to-r from-[#662582] to-indigo-650 hover:from-purple-800 hover:to-indigo-700 text-white rounded-xl text-xs font-bold uppercase tracking-wider cursor-pointer active:scale-95 transition-all shadow-md border-none"
>
<Download size={14} />
Download QR Code
</button>
/>
</div>
{/* Hidden high-res canvas — source for the PNG download */}

View File

@@ -28,6 +28,8 @@ import {
useFiestaOrderSummary,
FIESTA_TENANT_ID,
} from '../services/fiestaQueries';
import { Button } from '@astryxdesign/core/Button';
import { IconButton } from '@astryxdesign/core/IconButton';
import { str as fstr, num as fnum, roleName, ymd } from '../services/fiestaApi';
import type { AuthUser } from '../services/auth';
import Header from './Header';
@@ -240,15 +242,17 @@ export default function UserStorePage({ onLogout, user }: UserStorePageProps) {
<p className="text-[15px] text-slate-500 leading-relaxed mb-6">
We couldn’t reach the store service. Check your connection and try again.
</p>
<button
<Button
label="Retry loading your store"
onClick={() => {
locationsQ.refetch();
orderSummaryQ.refetch();
}}
className="px-5 py-2.5 bg-[#662582] hover:bg-purple-800 text-white text-sm font-bold rounded-xl cursor-pointer transition-colors shadow-sm"
style={{ height: 'auto' }}
className="px-5 py-2.5 bg-[#662582] hover:bg-purple-800 text-white text-sm font-bold rounded-xl border-none cursor-pointer transition-colors shadow-sm"
>
Retry
</button>
</Button>
</div>
</div>
);
@@ -431,14 +435,15 @@ export default function UserStorePage({ onLogout, user }: UserStorePageProps) {
onClick={(e) => { if (e.target === e.currentTarget) setShowQrModal(false); }}
>
<div className="relative w-full mx-auto" style={{ maxWidth: 600 }}>
<button
<IconButton
icon={<X size={16} />}
label="Close store QR code"
tooltip="Close"
variant="ghost"
onClick={() => setShowQrModal(false)}
title="Close"
aria-label="Close"
style={{ height: 'auto', width: 'auto', minWidth: 0 }}
className="absolute top-3 right-3 z-10 p-1.5 rounded-full bg-white/15 hover:bg-white/25 text-white ring-1 ring-white/25 backdrop-blur-sm cursor-pointer transition-colors"
>
<X size={16} />
</button>
/>
<StoreQRView
tenantId={tenantId}
locationid={resolvedLocationId || undefined}

View File

@@ -18,6 +18,9 @@
import React, { useEffect } from 'react';
import { createPortal } from 'react-dom';
import { X, Info } from 'lucide-react';
import { Button } from '@astryxdesign/core/Button';
import { IconButton } from '@astryxdesign/core/IconButton';
import { TextInput } from '@astryxdesign/core/TextInput';
import { Card as AxCard } from '@astryxdesign/core/Card';
import { Skeleton as AxSkeleton } from '@astryxdesign/core/Skeleton';
import { Tooltip as AxTooltip } from '@astryxdesign/core/Tooltip';
@@ -218,32 +221,68 @@ interface PillProps {
children: React.ReactNode;
count?: number | string;
}
/**
* Flattens a node to its text, so a Pill whose children are `<Icon /> Label`
* can still name itself. Only used to build the accessible name.
*/
function textOf(node: React.ReactNode): string {
if (node == null || typeof node === 'boolean') return '';
if (typeof node === 'string' || typeof node === 'number') return String(node);
if (Array.isArray(node)) return node.map(textOf).join('');
if (React.isValidElement(node)) return textOf((node.props as { children?: React.ReactNode }).children);
return '';
}
export function Pill({ active, color, onClick, title, children, count }: PillProps) {
// PHASE 6 — Astryx Button carrying the pill's own visual.
//
// Why Button and not ToggleButton, which is the semantic match: ToggleButton
// destructures `className` as `_className` and throws it away, and every
// colour a Pill wears arrives at runtime through the `color` prop (fed from
// ORDER_STATUS / DELIVERY_STATUS). Losing the class hook means losing the
// layout with it. Button keeps both, and `aria-pressed` can be set by hand.
//
// What this fixes: the pill announced nothing about being chosen. Selection
// was carried by fill colour alone, so the eight filter bars built on this
// read as eight identical buttons to a screen reader. `aria-pressed` is not
// the last word — every call site is single-select (`active={tab === t.key}`),
// which really wants a radiogroup — but that is a change to the call sites,
// and this is a change to one file.
//
// The name is assembled rather than left to the text content so the count
// reads as its own word: "Pending, 12" instead of "Pending12".
const name = [title ?? textOf(children).trim(), count != null ? String(count) : '']
.filter(Boolean)
.join(', ');
return (
<button
<Button
label={name}
onClick={onClick}
title={title}
tooltip={title}
aria-pressed={active}
className="inline-flex items-center gap-1.5 rounded-full font-bold whitespace-nowrap transition-all duration-150 cursor-pointer shrink-0"
style={
active
? { padding: '5px 12px', fontSize: 12.5, background: color, color: '#fff', border: `1.5px solid ${color}`, boxShadow: `0 6px 18px ${ring(color)}` }
: { padding: '5px 12px', fontSize: 12.5, background: tint(color), color, border: `1.5px solid ${edge(color)}` }
? { height: 'auto', padding: '5px 12px', fontSize: 12.5, background: color, color: '#fff', border: `1.5px solid ${color}`, boxShadow: `0 6px 18px ${ring(color)}` }
: { height: 'auto', padding: '5px 12px', fontSize: 12.5, background: tint(color), color, border: `1.5px solid ${edge(color)}` }
}
>
{children}
{count != null && (
<span
className="inline-flex items-center justify-center rounded-full font-extrabold"
style={
active
? { minWidth: 22, height: 18, padding: '0 6px', fontSize: 10.5, background: 'rgba(255,255,255,0.22)', color: '#fff' }
: { minWidth: 22, height: 18, padding: '0 6px', fontSize: 10.5, background: '#fff', color, border: `1px solid ${edge(color)}` }
}
>
{count}
</span>
)}
</button>
<span className="inline-flex items-center gap-1.5">
{children}
{count != null && (
<span
className="inline-flex items-center justify-center rounded-full font-extrabold"
style={
active
? { minWidth: 22, height: 18, padding: '0 6px', fontSize: 10.5, background: 'rgba(255,255,255,0.22)', color: '#fff' }
: { minWidth: 22, height: 18, padding: '0 6px', fontSize: 10.5, background: '#fff', color, border: `1px solid ${edge(color)}` }
}
>
{count}
</span>
)}
</span>
</Button>
);
}
@@ -284,27 +323,49 @@ export function StampCell({ date, time }: { date?: string; time?: string }) {
// ── Search pill ──────────────────────────────────────────────────────────────────
export function SearchPill({ value, onChange, placeholder, color = BRAND, inputRef }: { value: string; onChange: (v: string) => void; placeholder?: string; color?: string; inputRef?: React.Ref<HTMLInputElement> }) {
// PHASE 6 — Astryx TextInput.
//
// The defect this closes: the field had no label at all. It carried a
// placeholder, which vanishes the moment anyone types and is not a label to
// begin with, so the control announced itself as an unnamed text box on every
// filter bar in the console. `label` + `isLabelHidden` gives it a real
// accessible name and keeps the compact pill.
//
// Astryx puts `className`/`style` on the input *wrapper* — the bordered box
// itself — so the tinted pill survives intact. The manual onFocus/onBlur
// ring is gone with it: the wrapper has its own :focus-within treatment, and
// hand-painting a second one on top only fought it.
const name = placeholder || 'Search';
return (
<div className="relative w-full">
<svg className="absolute left-3 top-1/2 -translate-y-1/2" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke={color} strokeWidth="2.5" strokeLinecap="round">
<circle cx="11" cy="11" r="7" />
<path d="m21 21-4.3-4.3" />
</svg>
<input
<TextInput
ref={inputRef}
type="text"
label={name}
isLabelHidden
value={value}
onChange={(e) => onChange(e.target.value)}
onChange={(v) => onChange(v)}
placeholder={placeholder}
className="block w-full rounded-full outline-none font-medium transition-all box-border"
style={{ height: 38, paddingLeft: 32, paddingRight: value ? 30 : 14, fontSize: 12.5, background: tint(color), border: `1.5px solid ${edge(color)}`, color: TEXT }}
onFocus={(e) => { e.currentTarget.style.borderColor = color; e.currentTarget.style.boxShadow = `0 0 0 3px ${ring(color)}`; }}
onBlur={(e) => { e.currentTarget.style.borderColor = edge(color); e.currentTarget.style.boxShadow = 'none'; }}
startIcon={
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke={color} strokeWidth="2.5" strokeLinecap="round">
<circle cx="11" cy="11" r="7" />
<path d="m21 21-4.3-4.3" />
</svg>
}
className="w-full rounded-full transition-all box-border"
style={{ height: 38, paddingLeft: 12, paddingRight: value ? 30 : 14, fontSize: 12.5, background: tint(color), border: `1.5px solid ${edge(color)}`, color: TEXT }}
/>
{value && (
<button onClick={() => onChange('')} className="absolute right-3 top-1/2 -translate-y-1/2 cursor-pointer" style={{ color: TEXT_3 }} title="Clear">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round"><path d="M18 6 6 18M6 6l12 12" /></svg>
</button>
<IconButton
icon={<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round"><path d="M18 6 6 18M6 6l12 12" /></svg>}
// Not `Clear ${name}` — placeholders here carry shortcut hints, and
// lowercasing one gave "Clear search orders (ctrl+k)…".
label="Clear search"
tooltip="Clear"
variant="ghost"
onClick={() => onChange('')}
className="absolute right-2 top-1/2 -translate-y-1/2 cursor-pointer z-10"
style={{ height: 'auto', width: 'auto', minWidth: 0, padding: 2, background: 'transparent', border: 'none', color: TEXT_3 }}
/>
)}
</div>
);
@@ -458,12 +519,18 @@ export function SlideDrawer({
{/* Minimalist Header */}
<div className="flex items-center justify-between px-7 py-5 bg-[#662582] border-b border-purple-900 z-10 shrink-0">
<h2 className="text-lg font-bold text-white tracking-tight">{title}</h2>
<button
{/* PHASE 6 — IconButton. It had no accessible name at all: an X glyph
in a button with no text and no aria-label, so the only way out of
the drawer announced itself as "button". */}
<IconButton
icon={<X size={20} strokeWidth={2} />}
label={`Close ${title}`}
tooltip="Close"
variant="ghost"
onClick={onClose}
className="w-8 h-8 flex items-center justify-center rounded-full hover:bg-white/10 text-white/80 hover:text-white transition-colors"
>
<X size={20} strokeWidth={2} />
</button>
style={{ height: 32, width: 32, minWidth: 0, background: 'transparent', border: 'none', color: 'rgba(255,255,255,0.8)' }}
/>
</div>
{/* Scrollable Content */}