page wise astryx conversion completed
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
Reference in New Issue
Block a user