page wise astryx conversion completed
This commit is contained in:
@@ -13,8 +13,9 @@
|
|||||||
* Nominatim usage policy: light, debounced, one request per keystroke-pause.
|
* 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 { MapPin, Loader2, Search } from 'lucide-react';
|
||||||
|
import { TextInput } from '@astryxdesign/core/TextInput';
|
||||||
|
|
||||||
export interface AddressResult {
|
export interface AddressResult {
|
||||||
address: string;
|
address: string;
|
||||||
@@ -62,6 +63,9 @@ export default function AddressAutocomplete({
|
|||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [highlight, setHighlight] = useState(-1);
|
const [highlight, setHighlight] = useState(-1);
|
||||||
const boxRef = useRef<HTMLDivElement>(null);
|
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).
|
// Keep the input in sync if the parent resets the value (e.g. after submit).
|
||||||
useEffect(() => { setQuery(value); }, [value]);
|
useEffect(() => { setQuery(value); }, [value]);
|
||||||
@@ -69,6 +73,13 @@ export default function AddressAutocomplete({
|
|||||||
// Debounced Nominatim lookup.
|
// Debounced Nominatim lookup.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const q = query.trim();
|
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; }
|
if (q.length < 3) { setOptions([]); setOpen(false); return; }
|
||||||
let active = true;
|
let active = true;
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
@@ -100,6 +111,7 @@ export default function AddressAutocomplete({
|
|||||||
|
|
||||||
const pick = (row: NominatimRow) => {
|
const pick = (row: NominatimRow) => {
|
||||||
const result = parseRow(row);
|
const result = parseRow(row);
|
||||||
|
skipSearchRef.current = true;
|
||||||
setQuery(result.address);
|
setQuery(result.address);
|
||||||
setOptions([]);
|
setOptions([]);
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
@@ -116,36 +128,64 @@ export default function AddressAutocomplete({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative" ref={boxRef}>
|
<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} />
|
PHASE 6 — this was a combobox in behaviour with none of it declared.
|
||||||
</span>
|
The input carried no label and no role; the suggestion list was a plain
|
||||||
<input
|
<ul> of <button>s. Arrow keys moved a purple highlight that existed only
|
||||||
type="text"
|
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}
|
value={query}
|
||||||
placeholder={placeholder}
|
placeholder={placeholder}
|
||||||
autoComplete="off"
|
startIcon={<Search size={14} />}
|
||||||
onChange={(e) => { setQuery(e.target.value); if (!e.target.value) onSelect(null); }}
|
onChange={(v) => { setQuery(v); if (!v) onSelect(null); }}
|
||||||
onFocus={() => { if (options.length) setOpen(true); }}
|
onFocus={() => { if (options.length) setOpen(true); }}
|
||||||
onKeyDown={onKeyDown}
|
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>}
|
{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 && (
|
{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) => (
|
{options.map((o, i) => (
|
||||||
<li key={`${o.lat},${o.lon},${i}`}>
|
<li
|
||||||
<button
|
key={`${o.lat},${o.lon},${i}`}
|
||||||
type="button"
|
id={`${listId}-${i}`}
|
||||||
onMouseEnter={() => setHighlight(i)}
|
role="option"
|
||||||
onClick={() => pick(o)}
|
aria-selected={highlight === i}
|
||||||
className={`w-full text-left px-3 py-2 flex items-start gap-2 cursor-pointer transition-colors ${
|
onMouseEnter={() => setHighlight(i)}
|
||||||
highlight === i ? 'bg-purple-50' : 'hover:bg-slate-50'
|
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>
|
<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>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ import { CardCarousel } from './CatalogueGallery';
|
|||||||
// lands on two logins at once.
|
// lands on two logins at once.
|
||||||
import { TextInput } from '@astryxdesign/core/TextInput';
|
import { TextInput } from '@astryxdesign/core/TextInput';
|
||||||
import { CheckboxInput } from '@astryxdesign/core/CheckboxInput';
|
import { CheckboxInput } from '@astryxdesign/core/CheckboxInput';
|
||||||
|
import { Button } from '@astryxdesign/core/Button';
|
||||||
|
import { IconButton } from '@astryxdesign/core/IconButton';
|
||||||
|
|
||||||
interface CatalogueBrowserProps {
|
interface CatalogueBrowserProps {
|
||||||
tenantid: number;
|
tenantid: number;
|
||||||
@@ -158,12 +160,12 @@ export default function CatalogueBrowser({ tenantid, locationid, onClose }: Cata
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{(brand || keyword) && (
|
{(brand || keyword) && (
|
||||||
<button
|
<Button
|
||||||
|
label="Clear All Filters"
|
||||||
onClick={() => { setBrand(undefined); setKeyword(''); }}
|
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"
|
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>
|
||||||
</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 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">
|
<div className="flex-1 min-w-0 pr-4 flex items-start gap-2">
|
||||||
{onClose && (
|
{onClose && (
|
||||||
<button
|
<IconButton
|
||||||
|
icon={<ArrowLeft size={16} />}
|
||||||
|
label="Back to Products"
|
||||||
|
tooltip="Back to Products"
|
||||||
|
variant="ghost"
|
||||||
onClick={onClose}
|
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"
|
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)}
|
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"
|
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>
|
<div>
|
||||||
<h3 className="font-sans font-bold text-sm text-[#0f172a] flex items-center gap-1.5 flex-wrap">
|
<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>
|
<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>
|
</div>
|
||||||
|
|
||||||
{isImported ? (
|
{isImported ? (
|
||||||
<button
|
<Button
|
||||||
|
label={`${p.product_name} is already imported — open its details`}
|
||||||
|
icon={<CheckCircle2 size={12} />}
|
||||||
onClick={(e) => { e.stopPropagation(); setImportingProduct(p); }}
|
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"
|
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
|
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.
|
// One click, straight into the admin catalogue.
|
||||||
//
|
//
|
||||||
// No price, no category prompt: importing does not
|
// No price, no category prompt: importing does not
|
||||||
@@ -301,11 +319,11 @@ export default function CatalogueBrowser({ tenantid, locationid, onClose }: Cata
|
|||||||
taxpercent: 0,
|
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"
|
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
|
Import Product
|
||||||
</button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -19,6 +19,7 @@
|
|||||||
|
|
||||||
import React, { useCallback, useEffect, useState } from 'react';
|
import React, { useCallback, useEffect, useState } from 'react';
|
||||||
import { Box, ChevronLeft, ChevronRight, X } from 'lucide-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.
|
* 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:
|
{/* Faint at rest and solid on hover rather than hidden until hover:
|
||||||
a touch device never hovers, and controls that only exist on a
|
a touch device never hovers, and controls that only exist on a
|
||||||
mouse are controls half the users never find. */}
|
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)}
|
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"
|
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} />
|
<IconButton
|
||||||
</button>
|
icon={<ChevronRight size={15} />}
|
||||||
<button
|
label={`Next image of ${alt}`}
|
||||||
|
variant="ghost"
|
||||||
onClick={(e) => step(e, 1)}
|
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"
|
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">
|
<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) => (
|
{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="fixed inset-0 z-[400] flex items-center justify-center p-4">
|
||||||
<div className="absolute inset-0 bg-slate-950/85" onClick={onClose} />
|
<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}
|
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"
|
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">
|
<div className="relative z-10 flex flex-col items-center gap-3 max-w-full">
|
||||||
<img
|
<img
|
||||||
@@ -175,21 +179,23 @@ export function Lightbox({
|
|||||||
{images.length > 1 && (
|
{images.length > 1 && (
|
||||||
<>
|
<>
|
||||||
<div className="flex items-center gap-3 text-white/80 text-[12px] font-semibold">
|
<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)}
|
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"
|
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}
|
{index + 1} of {images.length}
|
||||||
<button
|
<IconButton
|
||||||
|
icon={<ChevronRight size={16} />}
|
||||||
|
label="Next image"
|
||||||
|
variant="ghost"
|
||||||
onClick={() => move(1)}
|
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"
|
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>
|
||||||
|
|
||||||
<div className="flex gap-2 overflow-x-auto max-w-[90vw] pb-1">
|
<div className="flex gap-2 overflow-x-auto max-w-[90vw] pb-1">
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { createPortal } from 'react-dom';
|
import { createPortal } from 'react-dom';
|
||||||
import { X, CheckCircle2, ArrowRight } from 'lucide-react';
|
import { X, CheckCircle2, ArrowRight } from 'lucide-react';
|
||||||
|
import { Button } from '@astryxdesign/core/Button';
|
||||||
|
import { IconButton } from '@astryxdesign/core/IconButton';
|
||||||
import { useCompare } from '../contexts/CompareContext';
|
import { useCompare } from '../contexts/CompareContext';
|
||||||
|
|
||||||
export default function ComparisonModal() {
|
export default function ComparisonModal() {
|
||||||
@@ -27,18 +29,24 @@ export default function ComparisonModal() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-3 pl-2">
|
<div className="flex items-center gap-3 pl-2">
|
||||||
<button
|
<Button
|
||||||
|
label="Clear selected products"
|
||||||
|
variant="ghost"
|
||||||
onClick={clearSelection}
|
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
|
Clear
|
||||||
</button>
|
</Button>
|
||||||
<button
|
<Button
|
||||||
|
label={`Compare ${selectedProducts.length} selected products`}
|
||||||
|
endContent={<ArrowRight size={14} />}
|
||||||
onClick={() => setIsComparing(true)}
|
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} />
|
Compare Now
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -58,25 +66,33 @@ export default function ComparisonModal() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
{selectedProducts.length < 5 && (
|
{selectedProducts.length < 5 && (
|
||||||
<button
|
<Button
|
||||||
|
label="Add another product to the comparison"
|
||||||
onClick={() => setIsComparing(false)}
|
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"
|
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
|
<span className="flex items-center gap-1.5"><span className="text-lg leading-none">+</span> Add Product</span>
|
||||||
</button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
<button
|
<Button
|
||||||
|
label="Clear all compared products"
|
||||||
onClick={clearSelection}
|
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"
|
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
|
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)}
|
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"
|
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>
|
||||||
</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">
|
<div key={prod.id} className="w-64 shrink-0 flex flex-col gap-4 relative group">
|
||||||
|
|
||||||
{/* Remove button overlay */}
|
{/* 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)}
|
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"
|
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 */}
|
{/* 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">
|
<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 { useFiestaCustomerOrders } from '../services/fiestaQueries';
|
||||||
import { num as fnum, str as fstr, customerName, customerStoreId, type Row } from '../services/fiestaApi';
|
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 { 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 OrderDetailsModal from './OrderDetailsModal';
|
||||||
import './CustomerDetailPanel.css';
|
import './CustomerDetailPanel.css';
|
||||||
|
|
||||||
@@ -98,9 +99,14 @@ export default function CustomerDetailPanel({ customer, onClose }: CustomerDetai
|
|||||||
<div className="cdp-top-pinned">
|
<div className="cdp-top-pinned">
|
||||||
<div className="cdp-header">
|
<div className="cdp-header">
|
||||||
{onClose && (
|
{onClose && (
|
||||||
<button className="cdp-close-btn" onClick={onClose} aria-label="Close panel">
|
<IconButton
|
||||||
<X size={18} />
|
icon={<X size={18} />}
|
||||||
</button>
|
label={`Close ${name} details`}
|
||||||
|
variant="ghost"
|
||||||
|
onClick={onClose}
|
||||||
|
className="cdp-close-btn"
|
||||||
|
style={{ height: 'auto', width: 'auto', minWidth: 0 }}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="cdp-header-row">
|
<div className="cdp-header-row">
|
||||||
|
|||||||
@@ -18,6 +18,7 @@
|
|||||||
import React, { useEffect, useState } from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
import { MapContainer, TileLayer, Marker, Polyline, useMap } from 'react-leaflet';
|
import { MapContainer, TileLayer, Marker, Polyline, useMap } from 'react-leaflet';
|
||||||
import { Bike, Mailbox, Utensils, MapPin, Map as MapIcon, Ruler, X } from 'lucide-react';
|
import { Bike, Mailbox, Utensils, MapPin, Map as MapIcon, Ruler, X } from 'lucide-react';
|
||||||
|
import { IconButton } from '@astryxdesign/core/IconButton';
|
||||||
import L from 'leaflet';
|
import L from 'leaflet';
|
||||||
import 'leaflet/dist/leaflet.css';
|
import 'leaflet/dist/leaflet.css';
|
||||||
|
|
||||||
@@ -89,9 +90,16 @@ function OrderPopup({ o, onClose }: { o: Record<string, unknown>; onClose: () =>
|
|||||||
return (
|
return (
|
||||||
<div className="dispatch-popup-center" onClick={(e) => { if (e.target === e.currentTarget) onClose(); }}>
|
<div className="dispatch-popup-center" onClick={(e) => { if (e.target === e.currentTarget) onClose(); }}>
|
||||||
<div className="dispatch-popup-card dispatch-popup">
|
<div className="dispatch-popup-card dispatch-popup">
|
||||||
<button className="dispatch-popup-center-close" onClick={onClose} aria-label="Close">
|
{/* "Close" alone did not say what closes — this popup sits over a map
|
||||||
<X size={15} />
|
that has its own controls. */}
|
||||||
</button>
|
<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 style={{ height: '100%', width: '100%' }}>
|
||||||
<div className="pu-header">
|
<div className="pu-header">
|
||||||
<div className="pu-header-top">
|
<div className="pu-header-top">
|
||||||
|
|||||||
@@ -53,6 +53,9 @@ import {
|
|||||||
customerStoreId,
|
customerStoreId,
|
||||||
type Row,
|
type Row,
|
||||||
} from '../services/fiestaApi';
|
} 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 { colorFor } from '../services/dispatchShared';
|
||||||
import DispatchMap, { type MapPoint } from './DispatchMap';
|
import DispatchMap, { type MapPoint } from './DispatchMap';
|
||||||
import CustomerDetailPanel from './CustomerDetailPanel';
|
import CustomerDetailPanel from './CustomerDetailPanel';
|
||||||
@@ -355,13 +358,18 @@ export default function DispatchView({ locationid, tenantId = FIESTA_TENANT_ID,
|
|||||||
<div className="dispatch-container embedded">
|
<div className="dispatch-container embedded">
|
||||||
{/* ── Body ── */}
|
{/* ── Body ── */}
|
||||||
<div id="body" className={sidebarCollapsed ? 'sidebar-collapsed' : ''}>
|
<div id="body" className={sidebarCollapsed ? 'sidebar-collapsed' : ''}>
|
||||||
<button
|
{/* Had only a `title`, which is not an accessible name, and no
|
||||||
className={`sidebar-toggle-tab${sidebarCollapsed ? ' is-collapsed' : ''}`}
|
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)}
|
onClick={() => setSidebarCollapsed((c) => !c)}
|
||||||
title={sidebarCollapsed ? 'Show panel' : 'Hide panel'}
|
className={`sidebar-toggle-tab${sidebarCollapsed ? ' is-collapsed' : ''}`}
|
||||||
>
|
style={{ height: 'auto', width: 'auto', minWidth: 0 }}
|
||||||
{sidebarCollapsed ? <ChevronRight size={26} /> : <ChevronLeft size={18} />}
|
/>
|
||||||
</button>
|
|
||||||
|
|
||||||
{/* Sidebar */}
|
{/* Sidebar */}
|
||||||
<div id="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">
|
<div className="ph relative flex items-center justify-between w-full h-8">
|
||||||
<span>{viewMode === 'customers' ? 'Customers' : 'Stores'} ({groups.length})</span>
|
<span>{viewMode === 'customers' ? 'Customers' : 'Stores'} ({groups.length})</span>
|
||||||
{viewMode === 'customers' && !locationid && locationsQ.data && (
|
{viewMode === 'customers' && !locationid && locationsQ.data && (
|
||||||
<div className="absolute right-0 flex items-center group/filter cursor-pointer">
|
// The hand-drawn chevron that used to sit beside this goes
|
||||||
<select
|
// with the <select>: Selector draws its own, and keeping
|
||||||
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"
|
// both would show two.
|
||||||
style={{ textOverflow: 'ellipsis' }}
|
<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}
|
value={customerStoreFilter}
|
||||||
onChange={(e) => {
|
onChange={(v) => {
|
||||||
setCustomerStoreFilter(e.target.value);
|
setCustomerStoreFilter(v);
|
||||||
setFocusedId(null);
|
setFocusedId(null);
|
||||||
}}
|
}}
|
||||||
>
|
options={[
|
||||||
<option value="all">All Stores</option>
|
{ value: 'all', label: 'All Stores' },
|
||||||
{locationsQ.data.map(loc => (
|
...locationsQ.data.map((loc) => ({
|
||||||
<option key={fnum(loc.locationid)} value={String(fnum(loc.locationid))}>
|
value: String(fnum(loc.locationid)),
|
||||||
{fstr(loc.locationname) || `Store ${fnum(loc.locationid)}`}
|
label: 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>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -509,22 +522,34 @@ export default function DispatchView({ locationid, tenantId = FIESTA_TENANT_ID,
|
|||||||
|
|
||||||
{/* bottom-right overlay controls (gated) */}
|
{/* bottom-right overlay controls (gated) */}
|
||||||
<div id="ov-br">
|
<div id="ov-br">
|
||||||
<button
|
<Button
|
||||||
className={`sbt ${animating ? 'active' : ''}`}
|
label={animating ? 'Animating…' : 'Animate Routes'}
|
||||||
disabled={!focused || mapPoints.length < 2}
|
icon={<Play size={14} />}
|
||||||
|
isDisabled={!focused || mapPoints.length < 2}
|
||||||
|
tooltip={focused ? 'Replay the route draw' : 'Select a rider to animate its route'}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (!focused || mapPoints.length < 2) return;
|
if (!focused || mapPoints.length < 2) return;
|
||||||
setAnimating(true);
|
setAnimating(true);
|
||||||
setAnimateNonce((n) => n + 1);
|
setAnimateNonce((n) => n + 1);
|
||||||
window.setTimeout(() => setAnimating(false), 2300);
|
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'}
|
Compare
|
||||||
</button>
|
</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>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -622,9 +647,13 @@ function FocusedDetail({
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{onBack && (
|
{onBack && (
|
||||||
<button className="sbt" onClick={onBack} style={{ marginBottom: 12 }}>
|
<Button
|
||||||
<span className="sbt-icon"><ChevronLeft size={15} /></span> Back to list
|
label="Back to list"
|
||||||
</button>
|
icon={<ChevronLeft size={15} />}
|
||||||
|
onClick={onBack}
|
||||||
|
className="sbt"
|
||||||
|
style={{ height: 'auto', marginBottom: 12 }}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{tripBlocks.length === 0 && (
|
{tripBlocks.length === 0 && (
|
||||||
@@ -645,13 +674,31 @@ function FocusedDetail({
|
|||||||
<span><MapPin size={11} /> {blk.orders.length} stops</span>
|
<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><Ruler size={11} /> {blk.orders.reduce((a, o) => a + fnum(o.kms), 0).toFixed(1)} km</span>
|
||||||
</span>
|
</span>
|
||||||
<div className="trip-sort-toggle" role="group">
|
{/* Single-select pair whose choice was carried by an `is-active`
|
||||||
<button className={`trip-sort-pill ${tripSort === 'planned' ? 'is-active' : ''}`} onClick={() => setTripSort('planned')}>
|
class alone — same pattern as the other filter groups. */}
|
||||||
<List size={12} /> <span>Planned</span>
|
<div className="trip-sort-toggle" role="group" aria-label="Sort stops">
|
||||||
</button>
|
<Button
|
||||||
<button className={`trip-sort-pill ${tripSort === 'time' ? 'is-active' : ''}`} onClick={() => setTripSort('time')}>
|
label="Sort stops in planned order"
|
||||||
<Clock size={12} /> <span>By time</span>
|
icon={<List size={12} />}
|
||||||
</button>
|
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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,8 @@
|
|||||||
|
|
||||||
import React, { useState, useRef, useEffect } from 'react';
|
import React, { useState, useRef, useEffect } from 'react';
|
||||||
import { Menu, HelpCircle, LogOut, ChevronDown, Mail, QrCode, User } from 'lucide-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';
|
import { MainSection } from '../types';
|
||||||
|
|
||||||
interface HeaderProps {
|
interface HeaderProps {
|
||||||
@@ -44,19 +46,34 @@ export default function Header({
|
|||||||
const [showProfileDropdown, setShowProfileDropdown] = useState(false);
|
const [showProfileDropdown, setShowProfileDropdown] = useState(false);
|
||||||
const profileRef = useRef<HTMLDivElement>(null);
|
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(() => {
|
useEffect(() => {
|
||||||
if (!showProfileDropdown) return;
|
if (!showProfileDropdown) return;
|
||||||
|
const opener = document.activeElement as HTMLElement | null;
|
||||||
const handlePointerDown = (e: MouseEvent | TouchEvent) => {
|
const handlePointerDown = (e: MouseEvent | TouchEvent) => {
|
||||||
if (profileRef.current && !profileRef.current.contains(e.target as Node)) {
|
if (profileRef.current && !profileRef.current.contains(e.target as Node)) {
|
||||||
setShowProfileDropdown(false);
|
setShowProfileDropdown(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
const handleKey = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'Escape') setShowProfileDropdown(false);
|
||||||
|
};
|
||||||
document.addEventListener('mousedown', handlePointerDown);
|
document.addEventListener('mousedown', handlePointerDown);
|
||||||
document.addEventListener('touchstart', handlePointerDown);
|
document.addEventListener('touchstart', handlePointerDown);
|
||||||
|
document.addEventListener('keydown', handleKey);
|
||||||
return () => {
|
return () => {
|
||||||
document.removeEventListener('mousedown', handlePointerDown);
|
document.removeEventListener('mousedown', handlePointerDown);
|
||||||
document.removeEventListener('touchstart', handlePointerDown);
|
document.removeEventListener('touchstart', handlePointerDown);
|
||||||
|
document.removeEventListener('keydown', handleKey);
|
||||||
|
opener?.focus?.();
|
||||||
};
|
};
|
||||||
}, [showProfileDropdown]);
|
}, [showProfileDropdown]);
|
||||||
|
|
||||||
@@ -101,17 +118,25 @@ export default function Header({
|
|||||||
Collapsed the cell is only 64px and centred, so the natural
|
Collapsed the cell is only 64px and centred, so the natural
|
||||||
inline position already sits beside the icon logo, which is itself
|
inline position already sits beside the icon logo, which is itself
|
||||||
aligned over the sidebar's icon rail. */}
|
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}
|
onClick={onToggleSidebar}
|
||||||
title={isSidebarOpen ? 'Close sidebar' : 'Open sidebar'}
|
|
||||||
aria-label={isSidebarOpen ? 'Close sidebar' : 'Open sidebar'}
|
|
||||||
aria-expanded={isSidebarOpen}
|
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 ${
|
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'
|
isSidebarOpen ? 'md:ml-auto p-1.5 sm:p-2' : 'p-1.5'
|
||||||
}`}
|
}`}
|
||||||
>
|
/>
|
||||||
<Menu size={18} />
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Dynamic Store Name Context */}
|
{/* Dynamic Store Name Context */}
|
||||||
@@ -134,41 +159,60 @@ export default function Header({
|
|||||||
<div className="flex items-center gap-md">
|
<div className="flex items-center gap-md">
|
||||||
{/* Store QR — opens the QR modal (user store page only) */}
|
{/* Store QR — opens the QR modal (user store page only) */}
|
||||||
{onQrClick && (
|
{onQrClick && (
|
||||||
<button
|
<IconButton
|
||||||
|
icon={<QrCode size={18} />}
|
||||||
|
label="Store QR code"
|
||||||
|
tooltip="Store QR code"
|
||||||
|
variant="ghost"
|
||||||
onClick={onQrClick}
|
onClick={onQrClick}
|
||||||
title="Store QR code"
|
style={{ height: 'auto', width: 'auto', minWidth: 0, background: 'transparent', color: '#fff', border: 'none' }}
|
||||||
aria-label="Store QR code"
|
|
||||||
className="p-2 rounded-full hover:bg-purple-800 transition-colors cursor-pointer text-white"
|
className="p-2 rounded-full hover:bg-purple-800 transition-colors cursor-pointer text-white"
|
||||||
>
|
/>
|
||||||
<QrCode size={18} />
|
|
||||||
</button>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* User profile with dropdown */}
|
{/* User profile with dropdown */}
|
||||||
<div className="relative" ref={profileRef}>
|
<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)}
|
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="flex items-center gap-2.5">
|
||||||
<span className="relative shrink-0">
|
{/* Initials avatar with live status dot */}
|
||||||
<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">
|
<span className="relative shrink-0">
|
||||||
{initials}
|
<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>
|
||||||
<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) */}
|
{/* Identity (hidden on small screens) */}
|
||||||
<span className="hidden sm:flex flex-col items-start leading-tight">
|
<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-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 className="text-[10px] text-purple-200 font-medium truncate max-w-[130px]">{profile.role}</span>
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
<ChevronDown
|
<ChevronDown
|
||||||
size={15}
|
size={15}
|
||||||
className={`hidden sm:block text-purple-200 transition-transform duration-200 ${showProfileDropdown ? 'rotate-180' : ''}`}
|
className={`hidden sm:block text-purple-200 transition-transform duration-200 ${showProfileDropdown ? 'rotate-180' : ''}`}
|
||||||
/>
|
/>
|
||||||
</button>
|
</span>
|
||||||
|
</Button>
|
||||||
|
|
||||||
{showProfileDropdown && (
|
{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">
|
<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) */}
|
{/* Account actions (moved here from the sidebar) */}
|
||||||
<div className="p-2 flex flex-col gap-0.5">
|
<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 && (
|
{onAccountClick && (
|
||||||
<button
|
<Button
|
||||||
|
label="My Account"
|
||||||
|
variant="ghost"
|
||||||
onClick={() => { setShowProfileDropdown(false); onAccountClick(); }}
|
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">
|
<span className="w-full flex items-center gap-2.5">
|
||||||
<User size={14} />
|
<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>
|
</span>
|
||||||
My Account
|
</Button>
|
||||||
</button>
|
|
||||||
)}
|
)}
|
||||||
<button
|
<Button
|
||||||
|
label="Help Center"
|
||||||
|
variant="ghost"
|
||||||
onClick={() => { setShowProfileDropdown(false); onHelpClick(); }}
|
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">
|
<span className="w-full flex items-center gap-2.5">
|
||||||
<HelpCircle size={14} />
|
<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>
|
</span>
|
||||||
Help Center
|
</Button>
|
||||||
</button>
|
|
||||||
<div className="my-1 h-px bg-slate-100" />
|
<div className="my-1 h-px bg-slate-100" />
|
||||||
<button
|
<Button
|
||||||
|
label="Log Out"
|
||||||
|
variant="ghost"
|
||||||
onClick={() => { setShowProfileDropdown(false); onLogoutClick(); }}
|
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">
|
<span className="w-full flex items-center gap-2.5">
|
||||||
<LogOut size={14} />
|
<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>
|
</span>
|
||||||
Log Out
|
</Button>
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { X, Save, AlertCircle, CheckCircle, DownloadCloud, Plus, Box } from 'lucide-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 { CatalogueProduct, ImportCatalogueProductRequest } from '../services/catalogueApi';
|
||||||
import { useProductSubcategories, useTenantCategories } from '../hooks/useCatalogueImport';
|
import { useProductSubcategories, useTenantCategories } from '../hooks/useCatalogueImport';
|
||||||
import { Lightbox } from './CatalogueGallery';
|
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.
|
It is not visible to any store until you set a price and publish it there.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<button
|
<Button
|
||||||
type="button"
|
label={`Import ${product.product_name} to your Admin Catalogue`}
|
||||||
|
icon={<Plus size={18} strokeWidth={2.5} />}
|
||||||
onClick={handleSubmit}
|
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
|
Import Product
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -11,12 +11,26 @@ import {
|
|||||||
ArrowRight,
|
ArrowRight,
|
||||||
Eye,
|
Eye,
|
||||||
EyeOff,
|
EyeOff,
|
||||||
Loader2,
|
|
||||||
} from 'lucide-react';
|
} 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 { checkEmailRequest, PasswordSetupRequiredError } from '../services/auth';
|
||||||
import type { AuthUser } from '../services/auth';
|
import type { AuthUser } from '../services/auth';
|
||||||
import { useLogin, useSetPassword } from '../services/fiestaQueries';
|
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 {
|
interface LoginViewProps {
|
||||||
/** Called with the authenticated user once credentials are verified. */
|
/** Called with the authenticated user once credentials are verified. */
|
||||||
onLogin: (user: AuthUser) => void;
|
onLogin: (user: AuthUser) => void;
|
||||||
@@ -41,6 +55,13 @@ export default function LoginView({ onLogin }: LoginViewProps) {
|
|||||||
const [confirmPassword, setConfirmPassword] = useState('');
|
const [confirmPassword, setConfirmPassword] = useState('');
|
||||||
const [showNewPassword, setShowNewPassword] = useState(false);
|
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 login = useLogin();
|
||||||
const setPasswordMut = useSetPassword();
|
const setPasswordMut = useSetPassword();
|
||||||
const loading = login.isPending || setPasswordMut.isPending;
|
const loading = login.isPending || setPasswordMut.isPending;
|
||||||
@@ -197,13 +218,19 @@ export default function LoginView({ onLogin }: LoginViewProps) {
|
|||||||
<form onSubmit={handleSubmit} className="space-y-4">
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
{/* Email or username (the API's `authname` accepts either) */}
|
{/* Email or username (the API's `authname` accepts either) */}
|
||||||
<div className="space-y-2">
|
<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">
|
<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
|
Email or Username
|
||||||
</label>
|
</span>
|
||||||
{(step === 'password' || step === 'setup') && (
|
{(step === 'password' || step === 'setup') && (
|
||||||
<button
|
<Button
|
||||||
type="button"
|
label="Change Email"
|
||||||
|
variant="ghost"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setStep('email');
|
setStep('email');
|
||||||
setError('');
|
setError('');
|
||||||
@@ -212,69 +239,70 @@ export default function LoginView({ onLogin }: LoginViewProps) {
|
|||||||
setNewPassword('');
|
setNewPassword('');
|
||||||
setConfirmPassword('');
|
setConfirmPassword('');
|
||||||
}}
|
}}
|
||||||
className="text-xs font-semibold text-purple-600 hover:text-purple-800 bg-transparent border-none cursor-pointer p-0"
|
style={{ height: 'auto', background: 'transparent', border: 'none', padding: 0 }}
|
||||||
>
|
className="text-xs font-semibold text-purple-600 hover:text-purple-800 cursor-pointer"
|
||||||
Change Email
|
/>
|
||||||
</button>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="relative">
|
<TextInput
|
||||||
<Mail size={16} className="absolute left-3.5 top-1/2 -translate-y-1/2 text-slate-400 pointer-events-none" />
|
label="Email or Username"
|
||||||
<input
|
isLabelHidden
|
||||||
id="login-email"
|
type="text"
|
||||||
type="text"
|
{...autofill("username")}
|
||||||
autoComplete="username"
|
isDisabled={step === 'password' || step === 'setup'}
|
||||||
disabled={step === 'password' || step === 'setup'}
|
value={email}
|
||||||
value={email}
|
onChange={(v) => setEmail(v)}
|
||||||
onChange={(e) => setEmail(e.target.value)}
|
placeholder="you@nearledaily.com"
|
||||||
placeholder="you@nearledaily.com"
|
startIcon={<Mail size={16} className="text-slate-400" />}
|
||||||
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"
|
className="w-full h-12 px-3.5 bg-slate-50 border border-slate-200 rounded-xl text-sm text-slate-800"
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* First-login password setup */}
|
{/* First-login password setup */}
|
||||||
{step === 'setup' && (
|
{step === 'setup' && (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="space-y-2">
|
<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
|
New Password
|
||||||
</label>
|
</span>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<Lock size={16} className="absolute left-3.5 top-1/2 -translate-y-1/2 text-slate-400 pointer-events-none" />
|
<TextInput
|
||||||
<input
|
label="New Password"
|
||||||
id="new-password"
|
isLabelHidden
|
||||||
type={showNewPassword ? 'text' : 'password'}
|
type={showNewPassword ? 'text' : 'password'}
|
||||||
|
{...autofill("new-password")}
|
||||||
value={newPassword}
|
value={newPassword}
|
||||||
onChange={(e) => setNewPassword(e.target.value)}
|
onChange={(v) => setNewPassword(v)}
|
||||||
placeholder="At least 6 characters"
|
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
|
<IconButton
|
||||||
type="button"
|
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)}
|
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"
|
className="absolute right-3 top-1/2 -translate-y-1/2 text-slate-400 hover:text-slate-600 cursor-pointer z-10"
|
||||||
title={showNewPassword ? 'Hide password' : 'Show password'}
|
style={{ height: 'auto', width: 'auto', minWidth: 0, padding: 4, background: 'transparent', border: 'none' }}
|
||||||
>
|
/>
|
||||||
{showNewPassword ? <EyeOff size={16} /> : <Eye size={16} />}
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<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
|
Confirm Password
|
||||||
</label>
|
</span>
|
||||||
<div className="relative">
|
<TextInput
|
||||||
<Lock size={16} className="absolute left-3.5 top-1/2 -translate-y-1/2 text-slate-400 pointer-events-none" />
|
label="Confirm Password"
|
||||||
<input
|
isLabelHidden
|
||||||
id="confirm-password"
|
type={showNewPassword ? 'text' : 'password'}
|
||||||
type={showNewPassword ? 'text' : 'password'}
|
{...autofill("new-password")}
|
||||||
value={confirmPassword}
|
value={confirmPassword}
|
||||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
onChange={(v) => setConfirmPassword(v)}
|
||||||
placeholder="Re-enter password"
|
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"
|
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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -282,68 +310,91 @@ export default function LoginView({ onLogin }: LoginViewProps) {
|
|||||||
{/* Password */}
|
{/* Password */}
|
||||||
{step === 'password' && (
|
{step === 'password' && (
|
||||||
<div className="space-y-2">
|
<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
|
Password
|
||||||
</label>
|
</span>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<Lock size={16} className="absolute left-3.5 top-1/2 -translate-y-1/2 text-slate-400 pointer-events-none" />
|
<TextInput
|
||||||
<input
|
label="Password"
|
||||||
id="login-password"
|
isLabelHidden
|
||||||
type={showPassword ? 'text' : 'password'}
|
type={showPassword ? 'text' : 'password'}
|
||||||
|
{...autofill("current-password")}
|
||||||
|
hasAutoFocus
|
||||||
value={password}
|
value={password}
|
||||||
onChange={(e) => setPassword(e.target.value)}
|
onChange={(v) => setPassword(v)}
|
||||||
placeholder="••••••••"
|
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
|
<IconButton
|
||||||
type="button"
|
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)}
|
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"
|
className="absolute right-3 top-1/2 -translate-y-1/2 text-slate-400 hover:text-slate-600 cursor-pointer z-10"
|
||||||
title={showPassword ? 'Hide password' : 'Show password'}
|
style={{ height: 'auto', width: 'auto', minWidth: 0, padding: 4, background: 'transparent', border: 'none' }}
|
||||||
>
|
/>
|
||||||
{showPassword ? <EyeOff size={16} /> : <Eye size={16} />}
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</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 && (
|
{error && (
|
||||||
<p className="text-xs font-semibold text-rose-600 bg-rose-50 border border-rose-100 rounded-lg px-3 py-2.5">
|
<Banner status="error" title={error} />
|
||||||
{error}
|
|
||||||
</p>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Options row */}
|
{/* Options row */}
|
||||||
{step === 'password' && (
|
{step === 'password' && (
|
||||||
<div className="flex items-center justify-between text-xs">
|
<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">
|
<CheckboxInput
|
||||||
<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" />
|
label="Remember me"
|
||||||
Remember me
|
size="sm"
|
||||||
</label>
|
value={rememberMe}
|
||||||
<button type="button" className="font-semibold text-purple-650 hover:text-purple-800 bg-transparent border-none cursor-pointer">
|
onChange={(checked) => setRememberMe(checked)}
|
||||||
Forgot password?
|
className="text-slate-500 font-medium"
|
||||||
</button>
|
/>
|
||||||
|
{/* 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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Submit */}
|
{/* Submit — Astryx supplies its own spinner and the aria-busy /
|
||||||
<button
|
live-region wiring behind `isLoading`, so the hand-rolled
|
||||||
|
Loader2 is gone with it. */}
|
||||||
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={loading || checkingEmail}
|
label={
|
||||||
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"
|
checkingEmail
|
||||||
>
|
? 'Checking Email…'
|
||||||
{loading || checkingEmail ? (
|
: setPasswordMut.isPending
|
||||||
<>
|
? 'Setting Password…'
|
||||||
<Loader2 size={16} className="animate-spin" />
|
: loading
|
||||||
{checkingEmail ? 'Checking Email…' : setPasswordMut.isPending ? 'Setting Password…' : 'Verifying…'}
|
? 'Verifying…'
|
||||||
</>
|
: step === 'email'
|
||||||
) : (
|
? 'Continue'
|
||||||
<>
|
: step === 'setup'
|
||||||
{step === 'email' ? 'Continue' : step === 'setup' ? 'Set Password & Sign In' : 'Sign in'}
|
? 'Set Password & Sign In'
|
||||||
<ArrowRight size={16} />
|
: 'Sign in'
|
||||||
</>
|
}
|
||||||
)}
|
isLoading={loading || checkingEmail}
|
||||||
</button>
|
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>
|
</form>
|
||||||
|
|
||||||
{/* Helper note */}
|
{/* Helper note */}
|
||||||
|
|||||||
@@ -27,14 +27,13 @@
|
|||||||
* their branch and rejects rows for any other — enforced again server-side.
|
* 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 { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
import {
|
import {
|
||||||
AlertTriangle,
|
AlertTriangle,
|
||||||
CheckCircle2,
|
CheckCircle2,
|
||||||
Download,
|
Download,
|
||||||
FileSpreadsheet,
|
FileSpreadsheet,
|
||||||
Loader2,
|
|
||||||
RotateCcw,
|
RotateCcw,
|
||||||
Store,
|
Store,
|
||||||
Upload,
|
Upload,
|
||||||
@@ -53,6 +52,12 @@ import {
|
|||||||
toBills,
|
toBills,
|
||||||
type ParsedSheet,
|
type ParsedSheet,
|
||||||
} from '../services/offlineSalesSheet';
|
} 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 {
|
interface OfflineSalesUploadProps {
|
||||||
tenantId: number;
|
tenantId: number;
|
||||||
@@ -79,11 +84,15 @@ export default function OfflineSalesUpload({
|
|||||||
onClose,
|
onClose,
|
||||||
}: OfflineSalesUploadProps) {
|
}: OfflineSalesUploadProps) {
|
||||||
const queryClient = useQueryClient();
|
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 [parsed, setParsed] = useState<ParsedSheet | null>(null);
|
||||||
const [fileName, setFileName] = useState('');
|
const [fileName, setFileName] = useState('');
|
||||||
const [dragging, setDragging] = useState(false);
|
|
||||||
const [result, setResult] = useState<OfflineSalesUploadResponse | null>(null);
|
const [result, setResult] = useState<OfflineSalesUploadResponse | null>(null);
|
||||||
const [uploadError, setUploadError] = useState('');
|
const [uploadError, setUploadError] = useState('');
|
||||||
|
|
||||||
@@ -157,7 +166,7 @@ export default function OfflineSalesUpload({
|
|||||||
setFileName('');
|
setFileName('');
|
||||||
setResult(null);
|
setResult(null);
|
||||||
setUploadError('');
|
setUploadError('');
|
||||||
if (fileInputRef.current) fileInputRef.current.value = '';
|
setFileInputNonce((n) => n + 1);
|
||||||
};
|
};
|
||||||
|
|
||||||
const storeCount = template?.locations.length ?? 0;
|
const storeCount = template?.locations.length ?? 0;
|
||||||
@@ -182,13 +191,14 @@ export default function OfflineSalesUpload({
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<IconButton
|
||||||
|
icon={<X size={20} />}
|
||||||
|
label="Close offline sales upload"
|
||||||
|
variant="ghost"
|
||||||
onClick={onClose}
|
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"
|
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>
|
||||||
|
|
||||||
<div className="flex-1 overflow-y-auto p-6">
|
<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.`}
|
: `${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>
|
</p>
|
||||||
</div>
|
</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)}
|
onClick={() => template && downloadSaleTemplate(template)}
|
||||||
disabled={!template || templateQuery.isLoading}
|
style={{ height: 'auto' }}
|
||||||
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"
|
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]"
|
||||||
>
|
/>
|
||||||
{templateQuery.isLoading ? (
|
|
||||||
<Loader2 size={16} className="animate-spin" />
|
|
||||||
) : (
|
|
||||||
<Download size={16} />
|
|
||||||
)}
|
|
||||||
Download Template
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* What the one file covers. Shown so it is obvious up front
|
{/* 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
|
Upload the filled-in file
|
||||||
</h3>
|
</h3>
|
||||||
|
|
||||||
<div
|
{/* PHASE 6 — FileInput in dropzone mode, replacing a hand-rolled
|
||||||
onDragOver={(e) => {
|
drop target that nobody could reach from a keyboard: it was a
|
||||||
e.preventDefault();
|
plain <div> with an onClick, no role, no tabindex, driving a
|
||||||
setDragging(true);
|
`hidden` file input. Mouse users got a drop zone; everyone
|
||||||
}}
|
else got nothing at all, on the only step of this flow that
|
||||||
onDragLeave={() => setDragging(false)}
|
accepts input. FileInput brings the drag handling, the accept
|
||||||
onDrop={(e) => {
|
filter and a real focusable control with it, so `dragging`
|
||||||
e.preventDefault();
|
and the input ref are no longer needed here. */}
|
||||||
setDragging(false);
|
<FileInput
|
||||||
const file = e.dataTransfer.files?.[0];
|
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);
|
if (file) void loadFile(file);
|
||||||
}}
|
}}
|
||||||
onClick={() => fileInputRef.current?.click()}
|
/>
|
||||||
className={`cursor-pointer rounded-lg border-2 border-dashed p-8 text-center transition-colors ${
|
{/* `description` above is what ties this sentence to the input
|
||||||
dragging
|
for a screen reader — but Astryx hides the description
|
||||||
? 'border-[#662582] bg-purple-50'
|
together with the label, so with `isLabelHidden` set it
|
||||||
: 'border-slate-300 bg-white hover:border-slate-400 hover:bg-slate-50'
|
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">
|
||||||
<Upload size={26} className="mx-auto mb-2 text-slate-400" />
|
Only the template downloaded above will import correctly.
|
||||||
<p className="text-sm font-medium text-slate-700">
|
</p>
|
||||||
{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>
|
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* Step 3 — the preview. Every problem is shown at once, against
|
{/* 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. */}
|
sanity-check what is about to be deducted where. */}
|
||||||
{summary.stores > 1 && (
|
{summary.stores > 1 && (
|
||||||
<div className="mb-4 overflow-hidden rounded-lg border border-slate-200">
|
<div className="mb-4 overflow-hidden rounded-lg border border-slate-200">
|
||||||
<table className="w-full text-left text-xs">
|
<Table className="w-full text-left text-xs">
|
||||||
<thead className="bg-slate-100 text-[11px] uppercase tracking-wide text-slate-600">
|
<TableHeader>
|
||||||
<tr>
|
<TableRow>
|
||||||
<th className="px-3 py-2 font-semibold">Store</th>
|
<TableHeaderCell>Store</TableHeaderCell>
|
||||||
<th className="px-3 py-2 text-right font-semibold">Lines</th>
|
<TableHeaderCell className="text-right">Lines</TableHeaderCell>
|
||||||
<th className="px-3 py-2 text-right font-semibold">Units</th>
|
<TableHeaderCell className="text-right">Units</TableHeaderCell>
|
||||||
<th className="px-3 py-2 text-right font-semibold">Amount</th>
|
<TableHeaderCell className="text-right">Amount</TableHeaderCell>
|
||||||
<th className="px-3 py-2 text-right font-semibold">Problems</th>
|
<TableHeaderCell className="text-right">Problems</TableHeaderCell>
|
||||||
</tr>
|
</TableRow>
|
||||||
</thead>
|
</TableHeader>
|
||||||
<tbody className="divide-y divide-slate-100">
|
<TableBody>
|
||||||
{summary.byStore.map((s) => (
|
{summary.byStore.map((s) => (
|
||||||
<tr key={s.locationid} className={s.errors > 0 ? 'bg-red-50' : 'bg-white'}>
|
<TableRow key={s.locationid} className={s.errors > 0 ? 'bg-red-50' : 'bg-white'}>
|
||||||
<td className="px-3 py-2 font-medium text-slate-800">
|
<TableCell className="font-medium text-slate-800">
|
||||||
{s.locationname || `Outlet ${s.locationid}`}
|
{s.locationname || `Outlet ${s.locationid}`}
|
||||||
<span className="ml-1.5 font-mono text-[10px] text-slate-400">#{s.locationid}</span>
|
<span className="ml-1.5 font-mono text-[10px] text-slate-400">#{s.locationid}</span>
|
||||||
</td>
|
</TableCell>
|
||||||
<td className="px-3 py-2 text-right text-slate-600">{s.lines}</td>
|
<TableCell className="text-right text-slate-600">{s.lines}</TableCell>
|
||||||
<td className="px-3 py-2 text-right text-slate-600">{s.units}</td>
|
<TableCell className="text-right text-slate-600">{s.units}</TableCell>
|
||||||
<td className="px-3 py-2 text-right font-semibold text-slate-800">{money(s.amount)}</td>
|
<TableCell className="text-right font-semibold text-slate-800">{money(s.amount)}</TableCell>
|
||||||
<td
|
<TableCell
|
||||||
className={`px-3 py-2 text-right font-semibold ${
|
className={`text-right font-semibold ${
|
||||||
s.errors > 0 ? 'text-red-700' : 'text-emerald-700'
|
s.errors > 0 ? 'text-red-700' : 'text-emerald-700'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{s.errors}
|
{s.errors}
|
||||||
</td>
|
</TableCell>
|
||||||
</tr>
|
</TableRow>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</TableBody>
|
||||||
</table>
|
</Table>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -399,45 +403,45 @@ export default function OfflineSalesUpload({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="max-h-72 overflow-auto rounded-lg border border-slate-200">
|
<div className="max-h-72 overflow-auto rounded-lg border border-slate-200">
|
||||||
<table className="w-full min-w-[920px] text-left text-xs">
|
<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">
|
<TableHeader>
|
||||||
<tr>
|
<TableRow>
|
||||||
<th className="px-3 py-2 font-semibold">Row</th>
|
<TableHeaderCell>Row</TableHeaderCell>
|
||||||
<th className="px-3 py-2 font-semibold">Store</th>
|
<TableHeaderCell>Store</TableHeaderCell>
|
||||||
<th className="px-3 py-2 font-semibold">Product</th>
|
<TableHeaderCell>Product</TableHeaderCell>
|
||||||
<th className="px-3 py-2 text-right font-semibold">Qty</th>
|
<TableHeaderCell className="text-right">Qty</TableHeaderCell>
|
||||||
<th className="px-3 py-2 text-right font-semibold">Price</th>
|
<TableHeaderCell className="text-right">Price</TableHeaderCell>
|
||||||
<th className="px-3 py-2 text-right font-semibold">Amount</th>
|
<TableHeaderCell className="text-right">Amount</TableHeaderCell>
|
||||||
<th className="px-3 py-2 font-semibold">Bill</th>
|
<TableHeaderCell>Bill</TableHeaderCell>
|
||||||
<th className="px-3 py-2 font-semibold">Status</th>
|
<TableHeaderCell>Status</TableHeaderCell>
|
||||||
</tr>
|
</TableRow>
|
||||||
</thead>
|
</TableHeader>
|
||||||
<tbody className="divide-y divide-slate-100">
|
<TableBody>
|
||||||
{parsed.rows.map((r) => {
|
{parsed.rows.map((r) => {
|
||||||
const bad = r.errors.length > 0;
|
const bad = r.errors.length > 0;
|
||||||
const warn = !bad && r.warnings.length > 0;
|
const warn = !bad && r.warnings.length > 0;
|
||||||
return (
|
return (
|
||||||
<tr
|
<TableRow
|
||||||
key={r.excelRow}
|
key={r.excelRow}
|
||||||
className={bad ? 'bg-red-50' : warn ? 'bg-amber-50' : 'bg-white'}
|
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>
|
<TableCell className="font-mono text-slate-500">{r.excelRow}</TableCell>
|
||||||
<td className="px-3 py-2 text-slate-700">
|
<TableCell className="text-slate-700">
|
||||||
{r.locationname || `#${r.locationid}`}
|
{r.locationname || `#${r.locationid}`}
|
||||||
</td>
|
</TableCell>
|
||||||
<td className="px-3 py-2">
|
<TableCell>
|
||||||
<span className="font-medium text-slate-800">{r.productname || '—'}</span>
|
<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>
|
<span className="ml-1.5 font-mono text-[10px] text-slate-400">#{r.productid}</span>
|
||||||
</td>
|
</TableCell>
|
||||||
<td className="px-3 py-2 text-right font-semibold text-slate-800">{r.qtysold}</td>
|
<TableCell className="text-right font-semibold text-slate-800">{r.qtysold}</TableCell>
|
||||||
<td className="px-3 py-2 text-right text-slate-600">
|
<TableCell className="text-right text-slate-600">
|
||||||
{r.unitprice ? money(r.unitprice) : <span className="text-amber-600">—</span>}
|
{r.unitprice ? money(r.unitprice) : <span className="text-amber-600">—</span>}
|
||||||
</td>
|
</TableCell>
|
||||||
<td className="px-3 py-2 text-right text-slate-600">
|
<TableCell className="text-right text-slate-600">
|
||||||
{money(Math.max(0, (r.unitprice ?? 0) * r.qtysold - r.discountamount))}
|
{money(Math.max(0, (r.unitprice ?? 0) * r.qtysold - r.discountamount))}
|
||||||
</td>
|
</TableCell>
|
||||||
<td className="px-3 py-2 font-mono text-slate-500">{r.billno || '—'}</td>
|
<TableCell className="font-mono text-slate-500">{r.billno || '—'}</TableCell>
|
||||||
<td className="px-3 py-2">
|
<TableCell>
|
||||||
{bad ? (
|
{bad ? (
|
||||||
<span className="text-red-700">{r.errors.join('; ')}</span>
|
<span className="text-red-700">{r.errors.join('; ')}</span>
|
||||||
) : warn ? (
|
) : warn ? (
|
||||||
@@ -447,12 +451,12 @@ export default function OfflineSalesUpload({
|
|||||||
<CheckCircle2 size={12} /> ready
|
<CheckCircle2 size={12} /> ready
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</td>
|
</TableCell>
|
||||||
</tr>
|
</TableRow>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</tbody>
|
</TableBody>
|
||||||
</table>
|
</Table>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
@@ -476,29 +480,29 @@ export default function OfflineSalesUpload({
|
|||||||
</p>
|
</p>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
{parsed && (
|
{parsed && (
|
||||||
<button
|
<Button
|
||||||
|
label="Clear the loaded file"
|
||||||
|
icon={<RotateCcw size={14} />}
|
||||||
onClick={reset}
|
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"
|
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
|
Clear
|
||||||
</button>
|
</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()}
|
onClick={() => uploadMutation.mutate()}
|
||||||
disabled={!parsed || blocked || uploadMutation.isPending}
|
style={{ height: 'auto' }}
|
||||||
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"
|
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]"
|
||||||
>
|
/>
|
||||||
{uploadMutation.isPending ? (
|
|
||||||
<>
|
|
||||||
<Loader2 size={16} className="animate-spin" /> Importing…
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<Upload size={16} /> Import{' '}
|
|
||||||
{summary?.bills ? `${summary.bills} Bill${summary.bills === 1 ? '' : 's'}` : 'Sales'}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -563,29 +567,29 @@ function ResultPanel({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="overflow-hidden rounded-lg border border-slate-200">
|
<div className="overflow-hidden rounded-lg border border-slate-200">
|
||||||
<table className="w-full text-left text-xs">
|
<Table className="w-full text-left text-xs">
|
||||||
<thead className="bg-slate-100 text-[11px] uppercase tracking-wide text-slate-600">
|
<TableHeader>
|
||||||
<tr>
|
<TableRow>
|
||||||
<th className="px-3 py-2 font-semibold">Store</th>
|
<TableHeaderCell>Store</TableHeaderCell>
|
||||||
<th className="px-3 py-2 font-semibold">Bill</th>
|
<TableHeaderCell>Bill</TableHeaderCell>
|
||||||
<th className="px-3 py-2 font-semibold">Result</th>
|
<TableHeaderCell>Result</TableHeaderCell>
|
||||||
<th className="px-3 py-2 font-semibold">Order</th>
|
<TableHeaderCell>Order</TableHeaderCell>
|
||||||
<th className="px-3 py-2 text-right font-semibold">Items</th>
|
<TableHeaderCell className="text-right">Items</TableHeaderCell>
|
||||||
<th className="px-3 py-2 text-right font-semibold">Amount</th>
|
<TableHeaderCell className="text-right">Amount</TableHeaderCell>
|
||||||
<th className="px-3 py-2 font-semibold">Detail</th>
|
<TableHeaderCell>Detail</TableHeaderCell>
|
||||||
</tr>
|
</TableRow>
|
||||||
</thead>
|
</TableHeader>
|
||||||
<tbody className="divide-y divide-slate-100">
|
<TableBody>
|
||||||
{result.results.map((r, i) => (
|
{result.results.map((r, i) => (
|
||||||
<tr
|
<TableRow
|
||||||
key={`${r.locationid}-${r.billno}-${i}`}
|
key={`${r.locationid}-${r.billno}-${i}`}
|
||||||
className={
|
className={
|
||||||
r.status === 'imported' ? 'bg-white' : r.status === 'duplicate' ? 'bg-slate-50' : 'bg-red-50'
|
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>
|
<TableCell className="text-slate-700">{r.locationname || (r.locationid ? `#${r.locationid}` : '—')}</TableCell>
|
||||||
<td className="px-3 py-2 font-mono text-slate-700">{r.billno || '—'}</td>
|
<TableCell className="font-mono text-slate-700">{r.billno || '—'}</TableCell>
|
||||||
<td className="px-3 py-2">
|
<TableCell>
|
||||||
{r.status === 'imported' && (
|
{r.status === 'imported' && (
|
||||||
<span className="rounded bg-emerald-100 px-2 py-0.5 font-semibold text-emerald-800">imported</span>
|
<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' && (
|
{r.status === 'failed' && (
|
||||||
<span className="rounded bg-red-100 px-2 py-0.5 font-semibold text-red-800">failed</span>
|
<span className="rounded bg-red-100 px-2 py-0.5 font-semibold text-red-800">failed</span>
|
||||||
)}
|
)}
|
||||||
</td>
|
</TableCell>
|
||||||
<td className="px-3 py-2 font-mono text-slate-600">{r.orderid || '—'}</td>
|
<TableCell className="font-mono text-slate-600">{r.orderid || '—'}</TableCell>
|
||||||
<td className="px-3 py-2 text-right text-slate-600">{r.itemcount || '—'}</td>
|
<TableCell className="text-right text-slate-600">{r.itemcount || '—'}</TableCell>
|
||||||
<td className="px-3 py-2 text-right text-slate-600">{r.amount ? money(r.amount) : '—'}</td>
|
<TableCell className="text-right text-slate-600">{r.amount ? money(r.amount) : '—'}</TableCell>
|
||||||
<td className="px-3 py-2 text-slate-600">{r.message}</td>
|
<TableCell className="text-slate-600">{r.message}</TableCell>
|
||||||
</tr>
|
</TableRow>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</TableBody>
|
||||||
</table>
|
</Table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-6 flex justify-end gap-3">
|
<div className="mt-6 flex justify-end gap-3">
|
||||||
<button
|
<Button
|
||||||
|
label="Upload Another File"
|
||||||
onClick={onAnother}
|
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"
|
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>
|
label="Done"
|
||||||
<button
|
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
className="rounded-lg bg-[#662582] px-5 py-2 text-sm font-semibold text-white transition-colors hover:bg-[#551f6d]"
|
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]"
|
||||||
Done
|
/>
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -3,6 +3,10 @@ import { createPortal } from 'react-dom';
|
|||||||
import { useFiestaOrderDetails } from '../services/fiestaQueries';
|
import { useFiestaOrderDetails } from '../services/fiestaQueries';
|
||||||
import { num as fnum, str as fstr, type Row } from '../services/fiestaApi';
|
import { num as fnum, str as fstr, type Row } from '../services/fiestaApi';
|
||||||
import { X, Calendar, MapPin, Loader2, CreditCard } from 'lucide-react';
|
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';
|
import './OrderDetailsModal.css';
|
||||||
|
|
||||||
interface OrderDetailsModalProps {
|
interface OrderDetailsModalProps {
|
||||||
@@ -76,9 +80,16 @@ export default function OrderDetailsModal({ order, onClose }: OrderDetailsModalP
|
|||||||
{orderDate}
|
{orderDate}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button className="odm-close-btn" onClick={onClose}>
|
{/* Had no text and no aria-label at all — the only way out of this
|
||||||
<X size={18} />
|
modal announced itself as "button". */}
|
||||||
</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>
|
||||||
|
|
||||||
<div className="odm-status-row">
|
<div className="odm-status-row">
|
||||||
@@ -98,16 +109,16 @@ export default function OrderDetailsModal({ order, onClose }: OrderDetailsModalP
|
|||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<div className="odm-section-title">Order Items</div>
|
<div className="odm-section-title">Order Items</div>
|
||||||
<table className="odm-items-table">
|
<Table className="odm-items-table">
|
||||||
<thead>
|
<TableHeader>
|
||||||
<tr>
|
<TableRow>
|
||||||
<th>Item</th>
|
<TableHeaderCell>Item</TableHeaderCell>
|
||||||
<th className="text-right">Price</th>
|
<TableHeaderCell className="text-right">Price</TableHeaderCell>
|
||||||
<th className="text-right">Qty</th>
|
<TableHeaderCell className="text-right">Qty</TableHeaderCell>
|
||||||
<th className="text-right">Total</th>
|
<TableHeaderCell className="text-right">Total</TableHeaderCell>
|
||||||
</tr>
|
</TableRow>
|
||||||
</thead>
|
</TableHeader>
|
||||||
<tbody>
|
<TableBody>
|
||||||
{details && details.length > 0 ? (
|
{details && details.length > 0 ? (
|
||||||
details.map((item, i) => {
|
details.map((item, i) => {
|
||||||
const name = fstr(item.productname) || fstr(item.itemname) || 'Item';
|
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 price = fnum(item.price) || fnum(item.unitprice) || fnum(item.retailprice) || 0;
|
||||||
const lineTotal = fnum(item.amount) || fnum(item.productsumprice) || (qty * price);
|
const lineTotal = fnum(item.amount) || fnum(item.productsumprice) || (qty * price);
|
||||||
return (
|
return (
|
||||||
<tr key={i}>
|
<TableRow key={i}>
|
||||||
<td>
|
<TableCell>
|
||||||
<div className="odm-item-name">{name}</div>
|
<div className="odm-item-name">{name}</div>
|
||||||
</td>
|
</TableCell>
|
||||||
<td className="text-right text-slate-500">₹{price.toLocaleString('en-IN', { maximumFractionDigits: 0 })}</td>
|
<TableCell className="text-right text-slate-500">₹{price.toLocaleString('en-IN', { maximumFractionDigits: 0 })}</TableCell>
|
||||||
<td className="text-right text-slate-500">x{qty}</td>
|
<TableCell className="text-right text-slate-500">x{qty}</TableCell>
|
||||||
<td className="text-right font-bold text-slate-800">₹{lineTotal.toLocaleString('en-IN', { maximumFractionDigits: 0 })}</td>
|
<TableCell className="text-right font-bold text-slate-800">₹{lineTotal.toLocaleString('en-IN', { maximumFractionDigits: 0 })}</TableCell>
|
||||||
</tr>
|
</TableRow>
|
||||||
);
|
);
|
||||||
})
|
})
|
||||||
) : (
|
) : (
|
||||||
<tr>
|
<TableRow>
|
||||||
<td colSpan={4} className="text-center text-slate-400 py-4">No line items found.</td>
|
<TableCell colSpan={4} className="text-center text-slate-400 py-4">No line items found.</TableCell>
|
||||||
</tr>
|
</TableRow>
|
||||||
)}
|
)}
|
||||||
</tbody>
|
</TableBody>
|
||||||
</table>
|
</Table>
|
||||||
|
|
||||||
<div className="odm-totals-section">
|
<div className="odm-totals-section">
|
||||||
<div className="odm-total-row">
|
<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
|
{/* 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
|
control the whole screen exists to reach, and it is hit hundreds of
|
||||||
times a shift. Everything around it is Astryx; this stays a
|
times a shift. Keeping that face never required keeping a raw
|
||||||
deliberate exception, marked as one. */}
|
<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">
|
<div className="flex gap-2 pos-pay-row">
|
||||||
<IconButton
|
<IconButton
|
||||||
label="Clear cart"
|
label="Clear cart"
|
||||||
@@ -783,13 +785,16 @@ export default function PosView({ locationid, tenantId = FIESTA_TENANT_ID }: Pos
|
|||||||
isDisabled={cart.length === 0}
|
isDisabled={cart.length === 0}
|
||||||
onClick={clearCart}
|
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}
|
onClick={handleCheckoutBtn}
|
||||||
disabled={cart.length === 0 || !customer}
|
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 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]"
|
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" />
|
PAY
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</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
|
// whole surface is the hit target, which Button's fixed
|
||||||
// padding and centred label cannot express. It already had a
|
// padding and centred label cannot express. It already had a
|
||||||
// disabled state and a text label, so nothing is lost.
|
// 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}
|
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(''); }}
|
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>
|
<div>
|
||||||
<p className="font-bold text-slate-900 text-sm">{product.name}</p>
|
<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>
|
<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`}
|
{product.closing <= 0 ? 'Out of stock' : `${product.closing} in stock`}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</span>
|
||||||
|
</Button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</PosModal>
|
</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
|
{/* Same exception as PAY, and for the same reason: this is the
|
||||||
commit point of the transaction. */}
|
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}
|
onClick={confirmPayment}
|
||||||
disabled={payMethod === 'store_credit' && !customer}
|
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 rounded-xl font-black text-lg transition-all shadow-lg shadow-emerald-600/25 disabled:shadow-none"
|
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'}
|
Confirm {grandTotal < 0 ? 'Refund' : 'Payment'}
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</PosModal>
|
</PosModal>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -38,6 +38,12 @@ import {
|
|||||||
} from '../services/fiestaQueries';
|
} from '../services/fiestaQueries';
|
||||||
import { FIESTA_TENANT_ID, FIESTA_PRIMARY_LOCATION_ID, num as fnum, str as fstr, ymd } from '../services/fiestaApi';
|
import { FIESTA_TENANT_ID, FIESTA_PRIMARY_LOCATION_ID, num as fnum, str as fstr, ymd } from '../services/fiestaApi';
|
||||||
import { stockRowToProduct } from '../services/fiestaMappers';
|
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 AwaitingApi from './AwaitingApi';
|
||||||
import SalesRevenueReport from './SalesRevenueReport';
|
import SalesRevenueReport from './SalesRevenueReport';
|
||||||
import { useNotify } from './Toasts';
|
import { useNotify } from './Toasts';
|
||||||
@@ -507,37 +513,49 @@ export default function ReportsView({ searchQuery, isCoimbatoreView, setIsCoimba
|
|||||||
return (
|
return (
|
||||||
<div className="space-y-lg animate-in fade-in duration-500 font-sans">
|
<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">
|
<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">
|
<div className="flex items-center gap-4">
|
||||||
<button
|
{([
|
||||||
onClick={() => setActiveTab('overview')}
|
{ id: 'overview', label: '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'}`}
|
{ id: 'sales_revenue', label: 'Sales & Revenue' },
|
||||||
>
|
] as const).map((t) => (
|
||||||
Overview
|
<Button
|
||||||
</button>
|
key={t.id}
|
||||||
<button
|
label={t.label}
|
||||||
onClick={() => setActiveTab('sales_revenue')}
|
aria-pressed={activeTab === t.id}
|
||||||
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'}`}
|
variant="ghost"
|
||||||
>
|
onClick={() => setActiveTab(t.id)}
|
||||||
Sales & Revenue
|
style={{ height: 'auto' }}
|
||||||
</button>
|
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>
|
</div>
|
||||||
|
|
||||||
{/* Global Store Filter */}
|
{/* Global Store Filter */}
|
||||||
{activeTab === 'sales_revenue' && (
|
{activeTab === 'sales_revenue' && (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Store size={16} className="text-slate-500" />
|
<Store size={16} className="text-slate-500" />
|
||||||
<select
|
<Selector
|
||||||
value={activeStoreId}
|
label="Filter reports by store"
|
||||||
onChange={(e) => setActiveStoreId(e.target.value === 'all' ? 'all' : Number(e.target.value))}
|
isLabelHidden
|
||||||
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"
|
size="sm"
|
||||||
>
|
value={String(activeStoreId)}
|
||||||
<option value="all">All Stores</option>
|
onChange={(v) => setActiveStoreId(v === 'all' ? 'all' : Number(v))}
|
||||||
{regionLocations.map(loc => (
|
options={[
|
||||||
<option key={loc.locationid} value={Number(loc.locationid)}>
|
{ value: 'all', label: 'All Stores' },
|
||||||
{loc.locationname || `Store ${loc.locationid}`}
|
...regionLocations.map((loc) => ({
|
||||||
</option>
|
value: String(Number(loc.locationid)),
|
||||||
))}
|
label: loc.locationname || `Store ${loc.locationid}`,
|
||||||
</select>
|
})),
|
||||||
|
]}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -587,11 +605,21 @@ export default function ReportsView({ searchQuery, isCoimbatoreView, setIsCoimba
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
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}
|
key={kpi.id}
|
||||||
|
label={`Chart ${kpi.title}`}
|
||||||
|
aria-pressed={isActive}
|
||||||
|
variant="ghost"
|
||||||
onClick={() => setChartMetric(kpi.id)}
|
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}`}
|
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">
|
<div className="flex justify-between items-start w-full">
|
||||||
<span className="text-[10px] text-slate-500 uppercase tracking-widest font-bold">{kpi.title}</span>
|
<span className="text-[10px] text-slate-500 uppercase tracking-widest font-bold">{kpi.title}</span>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
@@ -633,7 +661,8 @@ export default function ReportsView({ searchQuery, isCoimbatoreView, setIsCoimba
|
|||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</span>
|
||||||
|
</Button>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
@@ -668,12 +697,13 @@ export default function ReportsView({ searchQuery, isCoimbatoreView, setIsCoimba
|
|||||||
{selectedCell.day} · {selectedCell.hour} : {selectedCell.val} orders
|
{selectedCell.day} · {selectedCell.hour} : {selectedCell.val} orders
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<Button
|
||||||
|
label="Dismiss"
|
||||||
|
variant="ghost"
|
||||||
onClick={() => setSelectedCell(null)}
|
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"
|
style={{ height: 'auto' }}
|
||||||
>
|
className="text-xs font-bold text-purple-700 hover:text-purple-900 cursor-pointer hover:underline px-2"
|
||||||
Dismiss
|
/>
|
||||||
</button>
|
|
||||||
</div>
|
</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.'}
|
{insightQ.isLoading ? 'Loading weekly order distribution…' : 'No order insight available for this region.'}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<table className="w-full text-center border-collapse text-xs font-sans">
|
<Table className="w-full text-center text-xs font-sans">
|
||||||
<thead>
|
<TableHeader>
|
||||||
<tr>
|
<TableRow>
|
||||||
<th className="p-2 text-[9px] font-bold text-slate-400 w-32 uppercase italic text-left pl-4">Outlet Name</th>
|
<TableHeaderCell className="w-32 text-left">Outlet Name</TableHeaderCell>
|
||||||
{WEEK_LABELS.map((w) => (
|
{WEEK_LABELS.map((w) => (
|
||||||
<th key={w} className="p-2 text-[10px] font-bold text-slate-800 border-b border-slate-100">
|
<TableHeaderCell key={w} className="text-center">{w}</TableHeaderCell>
|
||||||
{w}
|
|
||||||
</th>
|
|
||||||
))}
|
))}
|
||||||
</tr>
|
</TableRow>
|
||||||
</thead>
|
</TableHeader>
|
||||||
<tbody>
|
<TableBody>
|
||||||
{insightRows.map((row, idx) => (
|
{insightRows.map((row, idx) => (
|
||||||
<tr key={idx}>
|
<TableRow 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">
|
<TableCell className="text-left font-bold text-slate-800 tracking-wide truncate max-w-[8rem] border-r border-slate-100">
|
||||||
{row.name}
|
{row.name}
|
||||||
</td>
|
</TableCell>
|
||||||
{WEEK_KEYS.map((key, mIdx) => {
|
{WEEK_KEYS.map((key, mIdx) => {
|
||||||
const val = fnum(row.weeks[key]);
|
const val = fnum(row.weeks[key]);
|
||||||
|
const isPicked =
|
||||||
|
selectedCell?.day === row.name && selectedCell?.hour === WEEK_LABELS[mIdx];
|
||||||
return (
|
return (
|
||||||
<td key={key} className="p-1 border border-white">
|
<TableCell key={key} className="p-1">
|
||||||
<button
|
{/* 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 })}
|
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}
|
{val}
|
||||||
</button>
|
</Button>
|
||||||
</td>
|
</TableCell>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</tr>
|
</TableRow>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</TableBody>
|
||||||
</table>
|
</Table>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -907,56 +945,60 @@ export default function ReportsView({ searchQuery, isCoimbatoreView, setIsCoimba
|
|||||||
{/* Quick interactive filter pills */}
|
{/* Quick interactive filter pills */}
|
||||||
<div className="flex gap-2 text-[10px] font-bold uppercase tracking-wider">
|
<div className="flex gap-2 text-[10px] font-bold uppercase tracking-wider">
|
||||||
{(['All', 'Healthy', 'Low Stock', 'Critical'] as const).map((filter) => (
|
{(['All', 'Healthy', 'Low Stock', 'Critical'] as const).map((filter) => (
|
||||||
<button
|
<Button
|
||||||
key={filter}
|
key={filter}
|
||||||
|
label={filter === 'All' ? 'Show all products' : `Show ${filter} products`}
|
||||||
|
aria-pressed={stockFilter === filter}
|
||||||
|
variant="ghost"
|
||||||
onClick={() => setStockFilter(filter)}
|
onClick={() => setStockFilter(filter)}
|
||||||
|
style={{ height: 'auto' }}
|
||||||
className={`px-3 py-1.5 rounded-lg cursor-pointer transition-colors border ${stockFilter === filter
|
className={`px-3 py-1.5 rounded-lg cursor-pointer transition-colors border ${stockFilter === filter
|
||||||
? 'bg-[#662582] text-white border-[#662582] shadow-sm'
|
? 'bg-[#662582] text-white border-[#662582] shadow-sm'
|
||||||
: 'bg-white border-[#e2e8f0] text-zinc-655 hover:bg-zinc-55'
|
: 'bg-white border-[#e2e8f0] text-zinc-655 hover:bg-zinc-55'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{filter}
|
{filter}
|
||||||
</button>
|
</Button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Matrix Data table */}
|
{/* Matrix Data table */}
|
||||||
<div className="overflow-x-auto text-xs font-sans">
|
<div className="overflow-x-auto text-xs font-sans">
|
||||||
<table className="w-full text-left">
|
<Table className="w-full text-left">
|
||||||
<thead className="bg-[#f8fafc] border-b border-[#e2e8f0] text-zinc-500 text-[9px] uppercase font-bold tracking-wider">
|
<TableHeader>
|
||||||
<tr>
|
<TableRow>
|
||||||
<th className="w-10 px-md py-sm"></th>
|
<TableHeaderCell className="w-10"><span className="sr-only">Expand row</span></TableHeaderCell>
|
||||||
<th className="px-md py-sm">Product Name</th>
|
<TableHeaderCell>Product Name</TableHeaderCell>
|
||||||
<th className="px-md py-sm">SKU ID</th>
|
<TableHeaderCell>SKU ID</TableHeaderCell>
|
||||||
<th className="px-md py-sm text-right">Units Sold</th>
|
<TableHeaderCell className="text-right">Units Sold</TableHeaderCell>
|
||||||
<th className="px-md py-sm text-right">Overall Revenue</th>
|
<TableHeaderCell className="text-right">Overall Revenue</TableHeaderCell>
|
||||||
<th className="px-md py-sm text-right">Stock Status</th>
|
<TableHeaderCell className="text-right">Stock Status</TableHeaderCell>
|
||||||
</tr>
|
</TableRow>
|
||||||
</thead>
|
</TableHeader>
|
||||||
<tbody className="divide-y divide-[#f1f5f9]">
|
<TableBody>
|
||||||
{paginatedProducts.length === 0 ? (
|
{paginatedProducts.length === 0 ? (
|
||||||
<tr>
|
<TableRow>
|
||||||
<td colSpan={6} className="text-center py-8 text-zinc-400 font-medium">
|
<TableCell colSpan={6} className="text-center py-8 text-zinc-400 font-medium">
|
||||||
No matching items matching stock filter criteria.
|
No matching items matching stock filter criteria.
|
||||||
</td>
|
</TableCell>
|
||||||
</tr>
|
</TableRow>
|
||||||
) : (
|
) : (
|
||||||
paginatedProducts.map((prod) => {
|
paginatedProducts.map((prod) => {
|
||||||
const isExpanded = expandedProductId === prod.id;
|
const isExpanded = expandedProductId === prod.id;
|
||||||
return (
|
return (
|
||||||
<React.Fragment key={prod.id}>
|
<React.Fragment key={prod.id}>
|
||||||
<tr
|
<TableRow
|
||||||
onClick={() => setExpandedProductId(isExpanded ? null : prod.id)}
|
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
|
<ChevronDown
|
||||||
size={14}
|
size={14}
|
||||||
className={`transition-transform duration-200 ${isExpanded ? 'rotate-180 text-purple-650' : 'text-zinc-400'}`}
|
className={`transition-transform duration-200 ${isExpanded ? 'rotate-180 text-purple-650' : 'text-zinc-400'}`}
|
||||||
/>
|
/>
|
||||||
</td>
|
</TableCell>
|
||||||
<td className="px-md py-md flex items-center gap-sm">
|
<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">
|
<div className="w-8 h-8 rounded-lg shrink-0 border border-[#e2e8f0] overflow-hidden bg-zinc-50">
|
||||||
<img
|
<img
|
||||||
src={prod.image}
|
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="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>
|
<span className="text-[9px] text-zinc-455 block font-semibold uppercase mt-0.5">{prod.category}</span>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</TableCell>
|
||||||
<td className="px-md py-md font-mono text-zinc-500 font-semibold">
|
<TableCell className="font-mono text-zinc-500 font-semibold">
|
||||||
{prod.sku}
|
{prod.sku}
|
||||||
</td>
|
</TableCell>
|
||||||
<td className="px-md py-md text-right font-mono text-zinc-655 font-bold">
|
<TableCell className="text-right font-mono text-zinc-655 font-bold">
|
||||||
{prod.unitsSold.toLocaleString()}
|
{prod.unitsSold.toLocaleString()}
|
||||||
</td>
|
</TableCell>
|
||||||
<td className="px-md py-md text-right font-mono text-zinc-800 font-extrabold">
|
<TableCell className="text-right font-mono text-zinc-800 font-extrabold">
|
||||||
₹{prod.revenue.toLocaleString()}
|
₹{prod.revenue.toLocaleString()}
|
||||||
</td>
|
</TableCell>
|
||||||
<td className="px-md py-md text-right">
|
<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'
|
<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'
|
? 'bg-emerald-50 text-emerald-600 border border-emerald-100'
|
||||||
: prod.stockStatus === 'Low Stock'
|
: prod.stockStatus === 'Low Stock'
|
||||||
@@ -988,8 +1030,8 @@ export default function ReportsView({ searchQuery, isCoimbatoreView, setIsCoimba
|
|||||||
}`}>
|
}`}>
|
||||||
{prod.stockStatus}
|
{prod.stockStatus}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</TableCell>
|
||||||
</tr>
|
</TableRow>
|
||||||
|
|
||||||
{/* Expanded details row */}
|
{/* Expanded details row */}
|
||||||
{isExpanded && (() => {
|
{isExpanded && (() => {
|
||||||
@@ -1022,8 +1064,8 @@ export default function ReportsView({ searchQuery, isCoimbatoreView, setIsCoimba
|
|||||||
}).filter(Boolean);
|
}).filter(Boolean);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<tr className="bg-slate-50/50 border-b border-[#e2e8f0]">
|
<TableRow className="bg-slate-50/50 border-b border-[#e2e8f0]">
|
||||||
<td colSpan={6} className="p-0">
|
<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">
|
<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">
|
<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>
|
<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>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</TableCell>
|
||||||
</tr>
|
</TableRow>
|
||||||
);
|
);
|
||||||
})()}
|
})()}
|
||||||
</React.Fragment>
|
</React.Fragment>
|
||||||
);
|
);
|
||||||
})
|
})
|
||||||
)}
|
)}
|
||||||
</tbody>
|
</TableBody>
|
||||||
</table>
|
</Table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Matrix table pagination */}
|
{/* 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>
|
<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">
|
<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))}
|
onClick={() => setCurrentPage(prev => Math.max(1, prev - 1))}
|
||||||
disabled={currentPage === 1}
|
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 ${currentPage === 1 ? 'opacity-50 cursor-not-allowed' : 'hover:bg-white active:bg-[#f8fafc]'}`}
|
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]"
|
||||||
>
|
/>
|
||||||
<ChevronLeft size={12} />
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<div className="flex items-center px-2 font-bold text-[10px] text-zinc-600">
|
<div className="flex items-center px-2 font-bold text-[10px] text-zinc-600">
|
||||||
Page {currentPage} of {totalPages}
|
Page {currentPage} of {totalPages}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button
|
<IconButton
|
||||||
|
icon={<ChevronRight size={12} />}
|
||||||
|
label="Next page"
|
||||||
|
tooltip="Next page"
|
||||||
|
isDisabled={currentPage === totalPages}
|
||||||
onClick={() => setCurrentPage(prev => Math.min(totalPages, prev + 1))}
|
onClick={() => setCurrentPage(prev => Math.min(totalPages, prev + 1))}
|
||||||
disabled={currentPage === totalPages}
|
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 ${currentPage === totalPages ? 'opacity-50 cursor-not-allowed' : 'hover:bg-white active:bg-[#f8fafc]'}`}
|
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]"
|
||||||
>
|
/>
|
||||||
<ChevronRight size={12} />
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</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 ...
|
// ... inside the component ...
|
||||||
import { shortTime } from '../services/fiestaMappers';
|
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 {
|
import {
|
||||||
KpiStrip, Pill, FilterBar, TH_STYLE,
|
Table, TableHeader, TableHeaderCell, TableBody, TableRow, TableCell,
|
||||||
BRAND, BRAND_LIGHT, TEXT, TEXT_2, TEXT_3, BORDER, DIVIDER, SURFACE_ALT,
|
} from '@astryxdesign/core/Table';
|
||||||
tint, soft, edge, ring
|
import {
|
||||||
|
KpiStrip, Pill, FilterBar,
|
||||||
|
BRAND, BRAND_LIGHT, TEXT, TEXT_2, TEXT_3, BORDER, SURFACE_ALT,
|
||||||
|
ring
|
||||||
} from './consoleUi';
|
} from './consoleUi';
|
||||||
import { ResponsiveContainer, AreaChart, Area, BarChart, Bar, PieChart, Pie, Cell, XAxis, YAxis, Tooltip as RechartsTooltip, CartesianGrid } from 'recharts';
|
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>
|
||||||
|
|
||||||
<div className="flex items-center gap-3 flex-wrap lg:justify-end min-w-0">
|
<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">
|
<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>
|
<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>
|
</div>
|
||||||
<button
|
<Button
|
||||||
|
label="Export CSV"
|
||||||
|
icon={<Download size={13} />}
|
||||||
|
isDisabled={tableRows.length === 0}
|
||||||
|
tooltip="Export to CSV"
|
||||||
onClick={exportCsv}
|
onClick={exportCsv}
|
||||||
disabled={tableRows.length === 0}
|
className="inline-flex items-center gap-1.5 rounded-full font-extrabold text-white cursor-pointer whitespace-nowrap shrink-0"
|
||||||
title="Export to CSV"
|
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)}` }}
|
||||||
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>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</FilterBar>
|
</FilterBar>
|
||||||
@@ -415,77 +441,75 @@ export default function SalesRevenueReport({
|
|||||||
<h3 className="text-sm font-bold text-slate-800">Completed Order Ledger</h3>
|
<h3 className="text-sm font-bold text-slate-800">Completed Order Ledger</h3>
|
||||||
</div>
|
</div>
|
||||||
{!locationid && uniqueStoreNames.length > 0 && (
|
{!locationid && uniqueStoreNames.length > 0 && (
|
||||||
<select
|
<Selector
|
||||||
|
label="Filter ledger by store"
|
||||||
|
isLabelHidden
|
||||||
|
size="sm"
|
||||||
value={storeFilter}
|
value={storeFilter}
|
||||||
onChange={(e) => { setStoreFilter(e.target.value); setPageno(1); }}
|
onChange={(v) => { setStoreFilter(v); 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"
|
options={[
|
||||||
>
|
{ value: 'All', label: 'All Stores' },
|
||||||
<option value="All">All Stores</option>
|
...uniqueStoreNames.map((store) => ({ value: store, label: store })),
|
||||||
{uniqueStoreNames.map(store => (
|
]}
|
||||||
<option key={store} value={store}>{store}</option>
|
/>
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
<table className="w-full" style={{ minWidth: 800 }}>
|
<Table style={{ minWidth: 800 }}>
|
||||||
<thead>
|
<TableHeader>
|
||||||
<tr>
|
<TableRow>
|
||||||
{['#', 'Order', 'Branch', 'Customer', 'Qty', 'Payment', 'Amount (₹)'].map((h, i) => (
|
{['#', '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>
|
</TableRow>
|
||||||
</thead>
|
</TableHeader>
|
||||||
<tbody>
|
<TableBody>
|
||||||
{allOrdersQ.isLoading ? (
|
{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">
|
<span className="inline-flex items-center gap-2 text-xs font-semibold">
|
||||||
<Loader2 size={15} className="animate-spin" style={{ color: BRAND }} /> Loading data…
|
<Loader2 size={15} className="animate-spin" style={{ color: BRAND }} /> Loading data…
|
||||||
</span>
|
</span>
|
||||||
</td></tr>
|
</TableCell></TableRow>
|
||||||
) : pageRows.length === 0 ? (
|
) : 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.
|
No completed orders found for this date range.
|
||||||
</td></tr>
|
</TableCell></TableRow>
|
||||||
) : (
|
) : (
|
||||||
pageRows.map((r, i) => {
|
pageRows.map((r, i) => {
|
||||||
const amount = fnum(r.ordervalue) || fnum(r.orderamount) || fnum(r.deliveryamt);
|
const amount = fnum(r.ordervalue) || fnum(r.orderamount) || fnum(r.deliveryamt);
|
||||||
return (
|
return (
|
||||||
<tr
|
<TableRow
|
||||||
key={fstr(r.orderid) || fstr(r.orderheaderid) || i}
|
key={fstr(r.orderid) || fstr(r.orderheaderid) || i}
|
||||||
className="transition-colors align-top"
|
className="align-top"
|
||||||
style={{ borderBottom: `1px solid ${DIVIDER}` }}
|
|
||||||
onMouseEnter={(e) => (e.currentTarget.style.background = SURFACE_ALT)}
|
|
||||||
onMouseLeave={(e) => (e.currentTarget.style.background = 'transparent')}
|
|
||||||
>
|
>
|
||||||
<td className="px-3 py-2.5 font-mono" style={{ color: TEXT_3 }}>{(pageno - 1) * PAGE_SIZE + i + 1}</td>
|
<TableCell className="font-mono" style={{ color: TEXT_3 }}>{(pageno - 1) * PAGE_SIZE + i + 1}</TableCell>
|
||||||
<td className="px-3 py-2.5">
|
<TableCell>
|
||||||
<p className="font-extrabold font-mono text-[13px]" style={{ color: TEXT }}>{fstr(r.orderid) || `#${fstr(r.orderheaderid)}`}</p>
|
<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>
|
<p className="text-[10px]" style={{ color: TEXT_2 }}>{shortTime(r.orderdate || r.deliverydate)}</p>
|
||||||
</td>
|
</TableCell>
|
||||||
<td className="px-3 py-2.5">
|
<TableCell>
|
||||||
<span className="inline-flex items-center gap-1 font-bold text-[12px]" style={{ color: BRAND }}>
|
<span className="inline-flex items-center gap-1 font-bold text-[12px]" style={{ color: BRAND }}>
|
||||||
<MapPin size={11} /> {fstr(r.applocation) || fstr(r.locationname) || '—'}
|
<MapPin size={11} /> {fstr(r.applocation) || fstr(r.locationname) || '—'}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</TableCell>
|
||||||
<td className="px-3 py-2.5">
|
<TableCell>
|
||||||
<p className="font-bold text-[12px] truncate max-w-[140px]" style={{ color: TEXT }}>{fstr(r.deliverycustomer) || fstr(r.pickupcustomer) || fstr(r.tenantname) || '—'}</p>
|
<p className="font-bold text-[12px] truncate max-w-[140px]" style={{ color: TEXT }}>{fstr(r.deliverycustomer) || fstr(r.pickupcustomer) || fstr(r.tenantname) || '—'}</p>
|
||||||
</td>
|
</TableCell>
|
||||||
<td className="px-3 py-2.5 font-mono text-[12px]" style={{ color: TEXT }}>{fnum(r.quantity) || '—'}</td>
|
<TableCell className="font-mono text-[12px]" style={{ color: TEXT }}>{fnum(r.quantity) || '—'}</TableCell>
|
||||||
<td className="px-3 py-2.5">
|
<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">
|
<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()}
|
{(fstr(r.paymentmode) || fstr(r.paymenttype) || 'CASH').toUpperCase()}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</TableCell>
|
||||||
<td className="px-3 py-2.5 text-right font-mono text-[13px] font-bold text-emerald-600">
|
<TableCell className="text-right font-mono text-[13px] font-bold text-emerald-600">
|
||||||
{amount > 0 ? `₹${amount.toLocaleString('en-IN')}` : '—'}
|
{amount > 0 ? `₹${amount.toLocaleString('en-IN')}` : '—'}
|
||||||
</td>
|
</TableCell>
|
||||||
</tr>
|
</TableRow>
|
||||||
);
|
);
|
||||||
})
|
})
|
||||||
)}
|
)}
|
||||||
</tbody>
|
</TableBody>
|
||||||
</table>
|
</Table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Pagination */}
|
{/* Pagination */}
|
||||||
@@ -494,16 +518,26 @@ export default function SalesRevenueReport({
|
|||||||
Page {pageno} · {pageRows.length} of {tableRows.length} shown
|
Page {pageno} · {pageRows.length} of {tableRows.length} shown
|
||||||
</span>
|
</span>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<button disabled={pageno === 1} onClick={() => setPageno((p) => Math.max(1, p - 1))}
|
<Button
|
||||||
className="inline-flex items-center gap-1 rounded-full font-bold transition-colors cursor-pointer disabled:opacity-40 disabled:cursor-not-allowed"
|
label="Previous page"
|
||||||
style={{ padding: '6px 12px', fontSize: 11, border: `1px solid ${BORDER}`, background: '#fff', color: TEXT_2 }}>
|
icon={<ChevronLeft size={13} />}
|
||||||
<ChevronLeft size={13} /> Prev
|
isDisabled={pageno === 1}
|
||||||
</button>
|
onClick={() => setPageno((p) => Math.max(1, p - 1))}
|
||||||
<button disabled={!hasNext} onClick={() => setPageno((p) => p + 1)}
|
className="inline-flex items-center gap-1 rounded-full font-bold cursor-pointer"
|
||||||
className="inline-flex items-center gap-1 rounded-full font-bold transition-colors cursor-pointer disabled:opacity-40 disabled:cursor-not-allowed"
|
style={{ height: 'auto', padding: '6px 12px', fontSize: 11, border: `1px solid ${BORDER}`, background: '#fff', color: TEXT_2 }}
|
||||||
style={{ padding: '6px 12px', fontSize: 11, border: `1px solid ${BORDER}`, background: '#fff', color: TEXT_2 }}>
|
>
|
||||||
Next <ChevronRight size={13} />
|
Prev
|
||||||
</button>
|
</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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import React, { useMemo, useRef } from 'react';
|
import React, { useMemo, useRef } from 'react';
|
||||||
import { QRCodeSVG, QRCodeCanvas } from 'qrcode.react';
|
import { QRCodeSVG, QRCodeCanvas } from 'qrcode.react';
|
||||||
import { MapPin, ScanLine, AlertTriangle, Download } from 'lucide-react';
|
import { MapPin, ScanLine, AlertTriangle, Download } from 'lucide-react';
|
||||||
|
import { Button } from '@astryxdesign/core/Button';
|
||||||
import { buildStoreQrPayload } from '../services/fiestaApi';
|
import { buildStoreQrPayload } from '../services/fiestaApi';
|
||||||
|
|
||||||
interface StoreQRViewProps {
|
interface StoreQRViewProps {
|
||||||
@@ -119,13 +120,13 @@ export default function StoreQRView({
|
|||||||
|
|
||||||
{/* Download Action Button */}
|
{/* Download Action Button */}
|
||||||
<div className="w-full mt-6">
|
<div className="w-full mt-6">
|
||||||
<button
|
<Button
|
||||||
|
label="Download QR Code"
|
||||||
|
icon={<Download size={14} />}
|
||||||
onClick={downloadPng}
|
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"
|
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>
|
</div>
|
||||||
|
|
||||||
{/* Hidden high-res canvas — source for the PNG download */}
|
{/* Hidden high-res canvas — source for the PNG download */}
|
||||||
|
|||||||
@@ -28,6 +28,8 @@ import {
|
|||||||
useFiestaOrderSummary,
|
useFiestaOrderSummary,
|
||||||
FIESTA_TENANT_ID,
|
FIESTA_TENANT_ID,
|
||||||
} from '../services/fiestaQueries';
|
} 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 { str as fstr, num as fnum, roleName, ymd } from '../services/fiestaApi';
|
||||||
import type { AuthUser } from '../services/auth';
|
import type { AuthUser } from '../services/auth';
|
||||||
import Header from './Header';
|
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">
|
<p className="text-[15px] text-slate-500 leading-relaxed mb-6">
|
||||||
We couldn’t reach the store service. Check your connection and try again.
|
We couldn’t reach the store service. Check your connection and try again.
|
||||||
</p>
|
</p>
|
||||||
<button
|
<Button
|
||||||
|
label="Retry loading your store"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
locationsQ.refetch();
|
locationsQ.refetch();
|
||||||
orderSummaryQ.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
|
Retry
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -431,14 +435,15 @@ export default function UserStorePage({ onLogout, user }: UserStorePageProps) {
|
|||||||
onClick={(e) => { if (e.target === e.currentTarget) setShowQrModal(false); }}
|
onClick={(e) => { if (e.target === e.currentTarget) setShowQrModal(false); }}
|
||||||
>
|
>
|
||||||
<div className="relative w-full mx-auto" style={{ maxWidth: 600 }}>
|
<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)}
|
onClick={() => setShowQrModal(false)}
|
||||||
title="Close"
|
style={{ height: 'auto', width: 'auto', minWidth: 0 }}
|
||||||
aria-label="Close"
|
|
||||||
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"
|
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
|
<StoreQRView
|
||||||
tenantId={tenantId}
|
tenantId={tenantId}
|
||||||
locationid={resolvedLocationId || undefined}
|
locationid={resolvedLocationId || undefined}
|
||||||
|
|||||||
@@ -18,6 +18,9 @@
|
|||||||
import React, { useEffect } from 'react';
|
import React, { useEffect } from 'react';
|
||||||
import { createPortal } from 'react-dom';
|
import { createPortal } from 'react-dom';
|
||||||
import { X, Info } from 'lucide-react';
|
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 { Card as AxCard } from '@astryxdesign/core/Card';
|
||||||
import { Skeleton as AxSkeleton } from '@astryxdesign/core/Skeleton';
|
import { Skeleton as AxSkeleton } from '@astryxdesign/core/Skeleton';
|
||||||
import { Tooltip as AxTooltip } from '@astryxdesign/core/Tooltip';
|
import { Tooltip as AxTooltip } from '@astryxdesign/core/Tooltip';
|
||||||
@@ -218,32 +221,68 @@ interface PillProps {
|
|||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
count?: number | string;
|
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) {
|
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 (
|
return (
|
||||||
<button
|
<Button
|
||||||
|
label={name}
|
||||||
onClick={onClick}
|
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"
|
className="inline-flex items-center gap-1.5 rounded-full font-bold whitespace-nowrap transition-all duration-150 cursor-pointer shrink-0"
|
||||||
style={
|
style={
|
||||||
active
|
active
|
||||||
? { 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: 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: tint(color), color, border: `1.5px solid ${edge(color)}` }
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{children}
|
<span className="inline-flex items-center gap-1.5">
|
||||||
{count != null && (
|
{children}
|
||||||
<span
|
{count != null && (
|
||||||
className="inline-flex items-center justify-center rounded-full font-extrabold"
|
<span
|
||||||
style={
|
className="inline-flex items-center justify-center rounded-full font-extrabold"
|
||||||
active
|
style={
|
||||||
? { minWidth: 22, height: 18, padding: '0 6px', fontSize: 10.5, background: 'rgba(255,255,255,0.22)', color: '#fff' }
|
active
|
||||||
: { minWidth: 22, height: 18, padding: '0 6px', fontSize: 10.5, background: '#fff', color, border: `1px solid ${edge(color)}` }
|
? { 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>
|
{count}
|
||||||
)}
|
</span>
|
||||||
</button>
|
)}
|
||||||
|
</span>
|
||||||
|
</Button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -284,27 +323,49 @@ export function StampCell({ date, time }: { date?: string; time?: string }) {
|
|||||||
|
|
||||||
// ── Search pill ──────────────────────────────────────────────────────────────────
|
// ── 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> }) {
|
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 (
|
return (
|
||||||
<div className="relative w-full">
|
<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">
|
<TextInput
|
||||||
<circle cx="11" cy="11" r="7" />
|
|
||||||
<path d="m21 21-4.3-4.3" />
|
|
||||||
</svg>
|
|
||||||
<input
|
|
||||||
ref={inputRef}
|
ref={inputRef}
|
||||||
type="text"
|
label={name}
|
||||||
|
isLabelHidden
|
||||||
value={value}
|
value={value}
|
||||||
onChange={(e) => onChange(e.target.value)}
|
onChange={(v) => onChange(v)}
|
||||||
placeholder={placeholder}
|
placeholder={placeholder}
|
||||||
className="block w-full rounded-full outline-none font-medium transition-all box-border"
|
startIcon={
|
||||||
style={{ height: 38, paddingLeft: 32, paddingRight: value ? 30 : 14, fontSize: 12.5, background: tint(color), border: `1.5px solid ${edge(color)}`, color: TEXT }}
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke={color} strokeWidth="2.5" strokeLinecap="round">
|
||||||
onFocus={(e) => { e.currentTarget.style.borderColor = color; e.currentTarget.style.boxShadow = `0 0 0 3px ${ring(color)}`; }}
|
<circle cx="11" cy="11" r="7" />
|
||||||
onBlur={(e) => { e.currentTarget.style.borderColor = edge(color); e.currentTarget.style.boxShadow = 'none'; }}
|
<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 && (
|
{value && (
|
||||||
<button onClick={() => onChange('')} className="absolute right-3 top-1/2 -translate-y-1/2 cursor-pointer" style={{ color: TEXT_3 }} title="Clear">
|
<IconButton
|
||||||
<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>
|
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>}
|
||||||
</button>
|
// 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>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -458,12 +519,18 @@ export function SlideDrawer({
|
|||||||
{/* Minimalist Header */}
|
{/* Minimalist Header */}
|
||||||
<div className="flex items-center justify-between px-7 py-5 bg-[#662582] border-b border-purple-900 z-10 shrink-0">
|
<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>
|
<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}
|
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"
|
className="w-8 h-8 flex items-center justify-center rounded-full hover:bg-white/10 text-white/80 hover:text-white transition-colors"
|
||||||
>
|
style={{ height: 32, width: 32, minWidth: 0, background: 'transparent', border: 'none', color: 'rgba(255,255,255,0.8)' }}
|
||||||
<X size={20} strokeWidth={2} />
|
/>
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Scrollable Content */}
|
{/* Scrollable Content */}
|
||||||
|
|||||||
Reference in New Issue
Block a user