From cd079950ebd18603163be84fc2a321819033816f Mon Sep 17 00:00:00 2001 From: abhishek Date: Fri, 14 Aug 2026 12:33:42 +0530 Subject: [PATCH] page wise astryx conversion completed --- src/components/AddressAutocomplete.tsx | 84 +++++-- src/components/CatalogueBrowser.tsx | 58 +++-- src/components/CatalogueGallery.tsx | 56 +++-- src/components/ComparisonModal.tsx | 62 +++-- src/components/CustomerDetailPanel.tsx | 12 +- src/components/DispatchMap.tsx | 14 +- src/components/DispatchView.tsx | 131 ++++++---- src/components/Header.tsx | 163 +++++++++---- src/components/ImportProductModal.tsx | 12 +- src/components/LoginView.tsx | 231 +++++++++++------- src/components/OfflineSalesUpload.tsx | 324 +++++++++++++------------ src/components/OrderDetailsModal.tsx | 61 +++-- src/components/PosView.tsx | 52 ++-- src/components/ReportsView.tsx | 257 ++++++++++++-------- src/components/SalesRevenueReport.tsx | 160 +++++++----- src/components/StoreQRView.tsx | 11 +- src/components/UserStorePage.tsx | 23 +- src/components/consoleUi.tsx | 139 ++++++++--- 18 files changed, 1154 insertions(+), 696 deletions(-) diff --git a/src/components/AddressAutocomplete.tsx b/src/components/AddressAutocomplete.tsx index 2d79166..27c03f0 100644 --- a/src/components/AddressAutocomplete.tsx +++ b/src/components/AddressAutocomplete.tsx @@ -13,8 +13,9 @@ * Nominatim usage policy: light, debounced, one request per keystroke-pause. */ -import React, { useEffect, useRef, useState } from 'react'; +import React, { useEffect, useId, useRef, useState } from 'react'; import { MapPin, Loader2, Search } from 'lucide-react'; +import { TextInput } from '@astryxdesign/core/TextInput'; export interface AddressResult { address: string; @@ -62,6 +63,9 @@ export default function AddressAutocomplete({ const [loading, setLoading] = useState(false); const [highlight, setHighlight] = useState(-1); const boxRef = useRef(null); + const listId = useId(); + /** Set when `query` is changed by us rather than by typing (see the effect). */ + const skipSearchRef = useRef(false); // Keep the input in sync if the parent resets the value (e.g. after submit). useEffect(() => { setQuery(value); }, [value]); @@ -69,6 +73,13 @@ export default function AddressAutocomplete({ // Debounced Nominatim lookup. useEffect(() => { const q = query.trim(); + // Picking a suggestion writes the chosen address back into `query`, which + // lands here as if the user had typed it — so the list re-opened over the + // answer they had just chosen, and fired another Nominatim call to do it. + // Pre-existing, and invisible enough to live with while the open state was + // only a background colour; now that it is `aria-expanded`, it would be + // telling a screen reader the list is open every time it closes. + if (skipSearchRef.current) { skipSearchRef.current = false; return; } if (q.length < 3) { setOptions([]); setOpen(false); return; } let active = true; setLoading(true); @@ -100,6 +111,7 @@ export default function AddressAutocomplete({ const pick = (row: NominatimRow) => { const result = parseRow(row); + skipSearchRef.current = true; setQuery(result.address); setOptions([]); setOpen(false); @@ -116,36 +128,64 @@ export default function AddressAutocomplete({ return (
- - - - of +
  • setHighlight(i)} + onClick={() => pick(o)} + className={`w-full text-left px-3 py-2 flex items-start gap-2 cursor-pointer transition-colors ${ + highlight === i ? 'bg-purple-50' : 'hover:bg-slate-50' + }`} + > + + {o.display_name}
  • ))} diff --git a/src/components/CatalogueBrowser.tsx b/src/components/CatalogueBrowser.tsx index ae2ac52..27ab106 100644 --- a/src/components/CatalogueBrowser.tsx +++ b/src/components/CatalogueBrowser.tsx @@ -19,6 +19,8 @@ import { CardCarousel } from './CatalogueGallery'; // lands on two logins at once. import { TextInput } from '@astryxdesign/core/TextInput'; import { CheckboxInput } from '@astryxdesign/core/CheckboxInput'; +import { Button } from '@astryxdesign/core/Button'; +import { IconButton } from '@astryxdesign/core/IconButton'; interface CatalogueBrowserProps { tenantid: number; @@ -158,12 +160,12 @@ export default function CatalogueBrowser({ tenantid, locationid, onClose }: Cata
    {(brand || keyword) && ( - + /> )} @@ -175,21 +177,28 @@ export default function CatalogueBrowser({ tenantid, locationid, onClose }: Cata
    {onClose && ( - + /> )} - + />

    Global Product Catalogue @@ -263,15 +272,24 @@ export default function CatalogueBrowser({ tenantid, locationid, onClose }: Cata

    {isImported ? ( - + ) : ( - + )}
    diff --git a/src/components/CatalogueGallery.tsx b/src/components/CatalogueGallery.tsx index ac33e47..91e03bb 100644 --- a/src/components/CatalogueGallery.tsx +++ b/src/components/CatalogueGallery.tsx @@ -19,6 +19,7 @@ import React, { useCallback, useEffect, useState } from 'react'; import { Box, ChevronLeft, ChevronRight, X } from 'lucide-react'; +import { IconButton } from '@astryxdesign/core/IconButton'; /** * The image on a browse card, with the rest reachable. @@ -62,20 +63,22 @@ export function CardCarousel({ {/* Faint at rest and solid on hover rather than hidden until hover: a touch device never hovers, and controls that only exist on a mouse are controls half the users never find. */} - - + />
    {list.slice(0, 8).map((_, n) => ( @@ -157,13 +160,14 @@ export function Lightbox({
    - + />
    1 && ( <>
    - + /> {index + 1} of {images.length} - + />
    diff --git a/src/components/ComparisonModal.tsx b/src/components/ComparisonModal.tsx index ae4dc50..5e19fc0 100644 --- a/src/components/ComparisonModal.tsx +++ b/src/components/ComparisonModal.tsx @@ -1,6 +1,8 @@ import React from 'react'; import { createPortal } from 'react-dom'; import { X, CheckCircle2, ArrowRight } from 'lucide-react'; +import { Button } from '@astryxdesign/core/Button'; +import { IconButton } from '@astryxdesign/core/IconButton'; import { useCompare } from '../contexts/CompareContext'; export default function ComparisonModal() { @@ -27,18 +29,24 @@ export default function ComparisonModal() {
    - - + Compare Now +
    )} @@ -58,25 +66,33 @@ export default function ComparisonModal() {
    {selectedProducts.length < 5 && ( - + + Add Product + )} - - + />
    @@ -115,12 +131,18 @@ export default function ComparisonModal() {
    {/* Remove button overlay */} - + /> {/* Product Header Card */}
    diff --git a/src/components/CustomerDetailPanel.tsx b/src/components/CustomerDetailPanel.tsx index a51427e..2d9b74f 100644 --- a/src/components/CustomerDetailPanel.tsx +++ b/src/components/CustomerDetailPanel.tsx @@ -2,6 +2,7 @@ import React, { useMemo, useState } from 'react'; import { useFiestaCustomerOrders } from '../services/fiestaQueries'; import { num as fnum, str as fstr, customerName, customerStoreId, type Row } from '../services/fiestaApi'; import { Phone, MapPin, Mail, Receipt, X, Calendar, ShoppingBag, Wallet, TrendingUp, IndianRupee, Store } from 'lucide-react'; +import { IconButton } from '@astryxdesign/core/IconButton'; import OrderDetailsModal from './OrderDetailsModal'; import './CustomerDetailPanel.css'; @@ -98,9 +99,14 @@ export default function CustomerDetailPanel({ customer, onClose }: CustomerDetai
    {onClose && ( - + } + label={`Close ${name} details`} + variant="ghost" + onClick={onClose} + className="cdp-close-btn" + style={{ height: 'auto', width: 'auto', minWidth: 0 }} + /> )}
    diff --git a/src/components/DispatchMap.tsx b/src/components/DispatchMap.tsx index 7e491f1..3a0cc19 100644 --- a/src/components/DispatchMap.tsx +++ b/src/components/DispatchMap.tsx @@ -18,6 +18,7 @@ import React, { useEffect, useState } from 'react'; import { MapContainer, TileLayer, Marker, Polyline, useMap } from 'react-leaflet'; import { Bike, Mailbox, Utensils, MapPin, Map as MapIcon, Ruler, X } from 'lucide-react'; +import { IconButton } from '@astryxdesign/core/IconButton'; import L from 'leaflet'; import 'leaflet/dist/leaflet.css'; @@ -89,9 +90,16 @@ function OrderPopup({ o, onClose }: { o: Record; onClose: () => return (
    { if (e.target === e.currentTarget) onClose(); }}>
    - + {/* "Close" alone did not say what closes — this popup sits over a map + that has its own controls. */} + } + 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 }} + />
    diff --git a/src/components/DispatchView.tsx b/src/components/DispatchView.tsx index 8cbd72f..46109b3 100644 --- a/src/components/DispatchView.tsx +++ b/src/components/DispatchView.tsx @@ -53,6 +53,9 @@ import { customerStoreId, type Row, } from '../services/fiestaApi'; +import { Button } from '@astryxdesign/core/Button'; +import { IconButton } from '@astryxdesign/core/IconButton'; +import { Selector } from '@astryxdesign/core/Selector'; import { colorFor } from '../services/dispatchShared'; import DispatchMap, { type MapPoint } from './DispatchMap'; import CustomerDetailPanel from './CustomerDetailPanel'; @@ -355,13 +358,18 @@ export default function DispatchView({ locationid, tenantId = FIESTA_TENANT_ID,
    {/* ── Body ── */}
    - + className={`sidebar-toggle-tab${sidebarCollapsed ? ' is-collapsed' : ''}`} + style={{ height: 'auto', width: 'auto', minWidth: 0 }} + /> {/* Sidebar */} )} @@ -622,9 +647,13 @@ function FocusedDetail({ return ( <> {onBack && ( - + - + {/* Single-select pair whose choice was carried by an `is-active` + class alone — same pattern as the other filter groups. */} +
    + +
    diff --git a/src/components/Header.tsx b/src/components/Header.tsx index 5f2f347..c33e18f 100644 --- a/src/components/Header.tsx +++ b/src/components/Header.tsx @@ -5,6 +5,8 @@ import React, { useState, useRef, useEffect } from 'react'; import { Menu, HelpCircle, LogOut, ChevronDown, Mail, QrCode, User } from 'lucide-react'; +import { Button } from '@astryxdesign/core/Button'; +import { IconButton } from '@astryxdesign/core/IconButton'; import { MainSection } from '../types'; interface HeaderProps { @@ -44,19 +46,34 @@ export default function Header({ const [showProfileDropdown, setShowProfileDropdown] = useState(false); const profileRef = useRef(null); - // Close the profile dropdown on any click/tap outside of it. + // Close the profile dropdown on any click/tap outside of it, or on Escape. + // + // Escape and the focus return are new in Phase 6. The panel was dismissable + // by pointer only, which left anyone driving the header from the keyboard + // with no way out of it, and on close focus was dropped wherever it lay + // rather than handed back to the trigger. Both are the behaviours Astryx's + // own overlays provide; this panel is hand-rolled (it is a profile card, not + // a menu — putting a card inside role="menu" would be worse ARIA than + // leaving it alone), so it has to provide them itself. useEffect(() => { if (!showProfileDropdown) return; + const opener = document.activeElement as HTMLElement | null; const handlePointerDown = (e: MouseEvent | TouchEvent) => { if (profileRef.current && !profileRef.current.contains(e.target as Node)) { setShowProfileDropdown(false); } }; + const handleKey = (e: KeyboardEvent) => { + if (e.key === 'Escape') setShowProfileDropdown(false); + }; document.addEventListener('mousedown', handlePointerDown); document.addEventListener('touchstart', handlePointerDown); + document.addEventListener('keydown', handleKey); return () => { document.removeEventListener('mousedown', handlePointerDown); document.removeEventListener('touchstart', handlePointerDown); + document.removeEventListener('keydown', handleKey); + opener?.focus?.(); }; }, [showProfileDropdown]); @@ -101,17 +118,25 @@ export default function Header({ Collapsed the cell is only 64px and centred, so the natural inline position already sits beside the icon logo, which is itself aligned over the sidebar's icon rail. */} - + />
    {/* Dynamic Store Name Context */} @@ -134,41 +159,60 @@ export default function Header({
    {/* Store QR — opens the QR modal (user store page only) */} {onQrClick && ( - + /> )} {/* User profile with dropdown */}
    - + + + {showProfileDropdown && (
    @@ -197,36 +241,57 @@ export default function Header({ {/* Account actions (moved here from the sidebar) */}
    + {/* 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 && ( - + )} - +
    - +
    )} diff --git a/src/components/ImportProductModal.tsx b/src/components/ImportProductModal.tsx index b599d79..8e3f0d8 100644 --- a/src/components/ImportProductModal.tsx +++ b/src/components/ImportProductModal.tsx @@ -1,5 +1,6 @@ import React, { useState } from 'react'; import { X, Save, AlertCircle, CheckCircle, DownloadCloud, Plus, Box } from 'lucide-react'; +import { Button } from '@astryxdesign/core/Button'; import { CatalogueProduct, ImportCatalogueProductRequest } from '../services/catalogueApi'; import { useProductSubcategories, useTenantCategories } from '../hooks/useCatalogueImport'; import { Lightbox } from './CatalogueGallery'; @@ -319,14 +320,15 @@ export default function ImportProductModal({ It is not visible to any store until you set a price and publish it there.

    - +
    )}
    diff --git a/src/components/LoginView.tsx b/src/components/LoginView.tsx index b3ce6be..d30fd71 100644 --- a/src/components/LoginView.tsx +++ b/src/components/LoginView.tsx @@ -11,12 +11,26 @@ import { ArrowRight, Eye, EyeOff, - Loader2, } from 'lucide-react'; +import { Button } from '@astryxdesign/core/Button'; +import { IconButton } from '@astryxdesign/core/IconButton'; +import { TextInput } from '@astryxdesign/core/TextInput'; +import { CheckboxInput } from '@astryxdesign/core/CheckboxInput'; +import { Banner } from '@astryxdesign/core/Banner'; import { checkEmailRequest, PasswordSetupRequiredError } from '../services/auth'; import type { AuthUser } from '../services/auth'; import { useLogin, useSetPassword } from '../services/fiestaQueries'; +/** + * Astryx's BaseProps builds on `React.HTMLAttributes`, which carries no + * `autoComplete` — that attribute is declared on `InputHTMLAttributes`. TextInput + * spreads whatever it does not recognise straight onto the underlying ``, + * 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 => ({ autoComplete: value }); + interface LoginViewProps { /** Called with the authenticated user once credentials are verified. */ onLogin: (user: AuthUser) => void; @@ -41,6 +55,13 @@ export default function LoginView({ onLogin }: LoginViewProps) { const [confirmPassword, setConfirmPassword] = useState(''); const [showNewPassword, setShowNewPassword] = useState(false); + // NOTE: nothing reads this. The "Remember me" box was uncontrolled and inert + // before Phase 6 too — CheckboxInput is a controlled component, so it needs + // state to tick at all, but ticking it still changes nothing about session + // lifetime. Left visible rather than removed because that is a product call, + // not a conversion one. + const [rememberMe, setRememberMe] = useState(false); + const login = useLogin(); const setPasswordMut = useSetPassword(); const loading = login.isPending || setPasswordMut.isPending; @@ -197,13 +218,19 @@ export default function LoginView({ onLogin }: LoginViewProps) {
    {/* Email or username (the API's `authname` accepts either) */}
    + {/* PHASE 6 — the caption is a , not a
    {/* First-login password setup */} {step === 'setup' && (
    - +
    - - setNewPassword(e.target.value)} + onChange={(v) => setNewPassword(v)} placeholder="At least 6 characters" - className="w-full h-12 pl-10 pr-11 bg-slate-50 border border-slate-200 rounded-xl text-sm text-slate-800 placeholder-slate-400 focus:outline-none focus:border-purple-500 focus:bg-white focus:ring-4 focus:ring-purple-500/10 transition-all" + startIcon={} + className="w-full h-12 pl-3.5 pr-11 bg-slate-50 border border-slate-200 rounded-xl text-sm text-slate-800" /> - + className="absolute right-3 top-1/2 -translate-y-1/2 text-slate-400 hover:text-slate-600 cursor-pointer z-10" + style={{ height: 'auto', width: 'auto', minWidth: 0, padding: 4, background: 'transparent', border: 'none' }} + />
    - -
    - - setConfirmPassword(e.target.value)} - placeholder="Re-enter password" - className="w-full h-12 pl-10 pr-4 bg-slate-50 border border-slate-200 rounded-xl text-sm text-slate-800 placeholder-slate-400 focus:outline-none focus:border-purple-500 focus:bg-white focus:ring-4 focus:ring-purple-500/10 transition-all" - /> -
    + + setConfirmPassword(v)} + placeholder="Re-enter password" + startIcon={} + className="w-full h-12 px-3.5 bg-slate-50 border border-slate-200 rounded-xl text-sm text-slate-800" + />
    )} @@ -282,68 +310,91 @@ export default function LoginView({ onLogin }: LoginViewProps) { {/* Password */} {step === 'password' && (
    - +
    - - setPassword(e.target.value)} + onChange={(v) => setPassword(v)} placeholder="••••••••" - className="w-full h-12 pl-10 pr-11 bg-slate-50 border border-slate-200 rounded-xl text-sm text-slate-800 placeholder-slate-400 focus:outline-none focus:border-purple-500 focus:bg-white focus:ring-4 focus:ring-purple-500/10 transition-all" + startIcon={} + className="w-full h-12 pl-3.5 pr-11 bg-slate-50 border border-slate-200 rounded-xl text-sm text-slate-800" /> - + className="absolute right-3 top-1/2 -translate-y-1/2 text-slate-400 hover:text-slate-600 cursor-pointer z-10" + style={{ height: 'auto', width: 'auto', minWidth: 0, padding: 4, background: 'transparent', border: 'none' }} + />
    )} + {/* PHASE 6 — Banner, not a bare

    . 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} -

    + )} {/* Options row */} {step === 'password' && (
    - - + setRememberMe(checked)} + className="text-slate-500 font-medium" + /> + {/* Also inert — this button had no onClick before the + conversion and still has none. There is no password-reset + flow behind it. */} +
    )} - {/* Submit */} - + label={ + checkingEmail + ? 'Checking Email…' + : setPasswordMut.isPending + ? 'Setting Password…' + : loading + ? 'Verifying…' + : step === 'email' + ? 'Continue' + : step === 'setup' + ? 'Set Password & Sign In' + : 'Sign in' + } + isLoading={loading || checkingEmail} + isDisabled={loading || checkingEmail} + endContent={loading || checkingEmail ? undefined : } + 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" + /> {/* Helper note */} diff --git a/src/components/OfflineSalesUpload.tsx b/src/components/OfflineSalesUpload.tsx index 75ba689..4b8500c 100644 --- a/src/components/OfflineSalesUpload.tsx +++ b/src/components/OfflineSalesUpload.tsx @@ -27,14 +27,13 @@ * their branch and rejects rows for any other — enforced again server-side. */ -import { useCallback, useMemo, useRef, useState } from 'react'; +import { useCallback, useMemo, useState } from 'react'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { AlertTriangle, CheckCircle2, Download, FileSpreadsheet, - Loader2, RotateCcw, Store, Upload, @@ -53,6 +52,12 @@ import { toBills, type ParsedSheet, } from '../services/offlineSalesSheet'; +import { Button } from '@astryxdesign/core/Button'; +import { IconButton } from '@astryxdesign/core/IconButton'; +import { FileInput } from '@astryxdesign/core/FileInput'; +import { + Table, TableHeader, TableHeaderCell, TableBody, TableRow, TableCell, +} from '@astryxdesign/core/Table'; interface OfflineSalesUploadProps { tenantId: number; @@ -79,11 +84,15 @@ export default function OfflineSalesUpload({ onClose, }: OfflineSalesUploadProps) { const queryClient = useQueryClient(); - const fileInputRef = useRef(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(null); const [fileName, setFileName] = useState(''); - const [dragging, setDragging] = useState(false); const [result, setResult] = useState(null); const [uploadError, setUploadError] = useState(''); @@ -157,7 +166,7 @@ export default function OfflineSalesUpload({ setFileName(''); setResult(null); setUploadError(''); - if (fileInputRef.current) fileInputRef.current.value = ''; + setFileInputNonce((n) => n + 1); }; const storeCount = template?.locations.length ?? 0; @@ -182,13 +191,14 @@ export default function OfflineSalesUpload({

    - + />
    @@ -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.`}

    - + style={{ height: 'auto' }} + className="flex items-center gap-2 rounded-lg bg-[#662582] px-4 py-2.5 text-sm font-semibold text-white border-none transition-colors hover:bg-[#551f6d]" + />
    {/* 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 -
    { - e.preventDefault(); - setDragging(true); - }} - onDragLeave={() => setDragging(false)} - onDrop={(e) => { - e.preventDefault(); - setDragging(false); - const file = e.dataTransfer.files?.[0]; + {/* PHASE 6 — FileInput in dropzone mode, replacing a hand-rolled + drop target that nobody could reach from a keyboard: it was a + plain
    with an onClick, no role, no tabindex, driving a + `hidden` file input. Mouse users got a drop zone; everyone + else got nothing at all, on the only step of this flow that + accepts input. FileInput brings the drag handling, the accept + filter and a real focusable control with it, so `dragging` + and the input ref are no longer needed here. */} + { + const file = Array.isArray(f) ? f[0] : f; if (file) void loadFile(file); }} - onClick={() => fileInputRef.current?.click()} - className={`cursor-pointer rounded-lg border-2 border-dashed p-8 text-center transition-colors ${ - dragging - ? 'border-[#662582] bg-purple-50' - : 'border-slate-300 bg-white hover:border-slate-400 hover:bg-slate-50' - }`} - > - -

    - {fileName || 'Drop the .xlsx file here, or click to choose'} -

    -

    Only the template downloaded above will import correctly.

    - { - const file = e.target.files?.[0]; - if (file) void loadFile(file); - }} - /> -
    + /> + {/* `description` above is what ties this sentence to the input + for a screen reader — but Astryx hides the description + together with the label, so with `isLabelHidden` set it + collapses to a 1x1px span and sighted users simply lose it. + Repeated here so it stays on screen, as it was before. */} +

    + Only the template downloaded above will import correctly. +

    {/* Step 3 — the preview. Every problem is shown at once, against @@ -345,37 +349,37 @@ export default function OfflineSalesUpload({ sanity-check what is about to be deducted where. */} {summary.stores > 1 && (
    - - - - - - - - - - - +
    StoreLinesUnitsAmountProblems
    + + + Store + Lines + Units + Amount + Problems + + + {summary.byStore.map((s) => ( - 0 ? 'bg-red-50' : 'bg-white'}> - - - - - - + + ))} - -
    + 0 ? 'bg-red-50' : 'bg-white'}> + {s.locationname || `Outlet ${s.locationid}`} #{s.locationid} - {s.lines}{s.units}{money(s.amount)} + {s.lines} + {s.units} + {money(s.amount)} + 0 ? 'text-red-700' : 'text-emerald-700' }`} > {s.errors} -
    + +
    )} @@ -399,45 +403,45 @@ export default function OfflineSalesUpload({ )}
    - - - - - - - - - - - - - - +
    RowStoreProductQtyPriceAmountBillStatus
    + + + Row + Store + Product + Qty + Price + Amount + Bill + Status + + + {parsed.rows.map((r) => { const bad = r.errors.length > 0; const warn = !bad && r.warnings.length > 0; return ( - - - - - - - - - - + + ); })} - -
    {r.excelRow} + {r.excelRow} + {r.locationname || `#${r.locationid}`} - + + {r.productname || '—'} #{r.productid} - {r.qtysold} + + {r.qtysold} + {r.unitprice ? money(r.unitprice) : —} - + + {money(Math.max(0, (r.unitprice ?? 0) * r.qtysold - r.discountamount))} - {r.billno || '—'} + + {r.billno || '—'} + {bad ? ( {r.errors.join('; ')} ) : warn ? ( @@ -447,12 +451,12 @@ export default function OfflineSalesUpload({ ready )} -
    + +
    )} @@ -476,29 +480,29 @@ export default function OfflineSalesUpload({

    {parsed && ( - + Clear + )} - + style={{ height: 'auto' }} + className="flex items-center gap-2 rounded-lg bg-[#662582] px-5 py-2 text-sm font-semibold text-white border-none transition-colors hover:bg-[#551f6d]" + />
    )} @@ -563,29 +567,29 @@ function ResultPanel({
    - - - - - - - - - - - - - +
    StoreBillResultOrderItemsAmountDetail
    + + + Store + Bill + Result + Order + Items + Amount + Detail + + + {result.results.map((r, i) => ( - - - - - - - - - + + {r.orderid || '—'} + {r.itemcount || '—'} + {r.amount ? money(r.amount) : '—'} + {r.message} + ))} - -
    {r.locationname || (r.locationid ? `#${r.locationid}` : '—')}{r.billno || '—'} + {r.locationname || (r.locationid ? `#${r.locationid}` : '—')} + {r.billno || '—'} + {r.status === 'imported' && ( imported )} @@ -595,30 +599,30 @@ function ResultPanel({ {r.status === 'failed' && ( failed )} - {r.orderid || '—'}{r.itemcount || '—'}{r.amount ? money(r.amount) : '—'}{r.message}
    + +
    - - + 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]" + />
    ); diff --git a/src/components/OrderDetailsModal.tsx b/src/components/OrderDetailsModal.tsx index 398913c..092a952 100644 --- a/src/components/OrderDetailsModal.tsx +++ b/src/components/OrderDetailsModal.tsx @@ -3,6 +3,10 @@ import { createPortal } from 'react-dom'; import { useFiestaOrderDetails } from '../services/fiestaQueries'; import { num as fnum, str as fstr, type Row } from '../services/fiestaApi'; import { X, Calendar, MapPin, Loader2, CreditCard } from 'lucide-react'; +import { IconButton } from '@astryxdesign/core/IconButton'; +import { + Table, TableHeader, TableHeaderCell, TableBody, TableRow, TableCell, +} from '@astryxdesign/core/Table'; import './OrderDetailsModal.css'; interface OrderDetailsModalProps { @@ -76,9 +80,16 @@ export default function OrderDetailsModal({ order, onClose }: OrderDetailsModalP {orderDate}
    - + {/* Had no text and no aria-label at all — the only way out of this + modal announced itself as "button". */} + } + label={`Close order ${orderId} details`} + variant="ghost" + onClick={onClose} + className="odm-close-btn" + style={{ height: 'auto', width: 'auto', minWidth: 0 }} + />
    @@ -98,16 +109,16 @@ export default function OrderDetailsModal({ order, onClose }: OrderDetailsModalP ) : ( <>
    Order Items
    - - - - - - - - - - +
    ItemPriceQtyTotal
    + + + Item + Price + Qty + Total + + + {details && details.length > 0 ? ( details.map((item, i) => { const name = fstr(item.productname) || fstr(item.itemname) || 'Item'; @@ -115,23 +126,23 @@ export default function OrderDetailsModal({ order, onClose }: OrderDetailsModalP const price = fnum(item.price) || fnum(item.unitprice) || fnum(item.retailprice) || 0; const lineTotal = fnum(item.amount) || fnum(item.productsumprice) || (qty * price); return ( - - - - - - + + ₹{price.toLocaleString('en-IN', { maximumFractionDigits: 0 })} + x{qty} + ₹{lineTotal.toLocaleString('en-IN', { maximumFractionDigits: 0 })} + ); }) ) : ( - - - + + No line items found. + )} - -
    + +
    {name}
    -
    ₹{price.toLocaleString('en-IN', { maximumFractionDigits: 0 })}x{qty}₹{lineTotal.toLocaleString('en-IN', { maximumFractionDigits: 0 })}
    No line items found.
    + +
    diff --git a/src/components/PosView.tsx b/src/components/PosView.tsx index fdb52b2..e9b55db 100644 --- a/src/components/PosView.tsx +++ b/src/components/PosView.tsx @@ -772,8 +772,10 @@ export default function PosView({ locationid, tenantId = FIESTA_TENANT_ID }: Pos {/* PAY keeps its own emerald face and full width: it is the one control the whole screen exists to reach, and it is hit hundreds of - times a shift. Everything around it is Astryx; this stays a - deliberate exception, marked as one. */} + times a shift. Keeping that face never required keeping a raw + + PAY +
    @@ -828,12 +833,22 @@ export default function PosView({ locationid, tenantId = FIESTA_TENANT_ID }: Pos // whole surface is the hit target, which Button's fixed // padding and centred label cannot express. It already had a // disabled state and a text label, so nothing is lost. - + + ))}
    @@ -1118,13 +1134,19 @@ export default function PosView({ locationid, tenantId = FIESTA_TENANT_ID }: Pos {/* Same exception as PAY, and for the same reason: this is the commit point of the transaction. */} - +
    )} diff --git a/src/components/ReportsView.tsx b/src/components/ReportsView.tsx index 93fd8f0..5bedf0f 100644 --- a/src/components/ReportsView.tsx +++ b/src/components/ReportsView.tsx @@ -38,6 +38,12 @@ import { } from '../services/fiestaQueries'; import { FIESTA_TENANT_ID, FIESTA_PRIMARY_LOCATION_ID, num as fnum, str as fstr, ymd } from '../services/fiestaApi'; import { stockRowToProduct } from '../services/fiestaMappers'; +import { Button } from '@astryxdesign/core/Button'; +import { IconButton } from '@astryxdesign/core/IconButton'; +import { Selector } from '@astryxdesign/core/Selector'; +import { + Table, TableHeader, TableHeaderCell, TableBody, TableRow, TableCell, +} from '@astryxdesign/core/Table'; import AwaitingApi from './AwaitingApi'; import SalesRevenueReport from './SalesRevenueReport'; import { useNotify } from './Toasts'; @@ -507,37 +513,49 @@ export default function ReportsView({ searchQuery, isCoimbatoreView, setIsCoimba return (
    + {/* PHASE 6 — Button + aria-pressed, NOT Astryx's TabList. + TabList looks like the obvious fit and is not: it renders a
    @@ -587,11 +605,21 @@ export default function ReportsView({ searchQuery, isCoimbatoreView, setIsCoimba } return ( - + + ); })}
    @@ -668,12 +697,13 @@ export default function ReportsView({ searchQuery, isCoimbatoreView, setIsCoimba {selectedCell.day} · {selectedCell.hour} : {selectedCell.val} orders
    - + style={{ height: 'auto' }} + className="text-xs font-bold text-purple-700 hover:text-purple-900 cursor-pointer hover:underline px-2" + /> )} @@ -682,40 +712,48 @@ export default function ReportsView({ searchQuery, isCoimbatoreView, setIsCoimba {insightQ.isLoading ? 'Loading weekly order distribution…' : 'No order insight available for this region.'} ) : ( - - - - +
    Outlet Name
    + + + Outlet Name {WEEK_LABELS.map((w) => ( - + {w} ))} - - - + + + {insightRows.map((row, idx) => ( - - + {WEEK_KEYS.map((key, mIdx) => { const val = fnum(row.weeks[key]); + const isPicked = + selectedCell?.day === row.name && selectedCell?.hour === WEEK_LABELS[mIdx]; return ( - + + ); })} - + ))} - -
    - {w} -
    + + {row.name} - - -
    + + )} @@ -907,56 +945,60 @@ export default function ReportsView({ searchQuery, isCoimbatoreView, setIsCoimba {/* Quick interactive filter pills */}
    {(['All', 'Healthy', 'Low Stock', 'Critical'] as const).map((filter) => ( - + ))}
    {/* Matrix Data table */}
    - - - - - - - - - - - - +
    Product NameSKU IDUnits SoldOverall RevenueStock Status
    + + + Expand row + Product Name + SKU ID + Units Sold + Overall Revenue + Stock Status + + + {paginatedProducts.length === 0 ? ( - - - + + ) : ( paginatedProducts.map((prod) => { const isExpanded = expandedProductId === prod.id; return ( - 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' : ''}`} > - - - - - - - + + {/* Expanded details row */} {isExpanded && (() => { @@ -1022,8 +1064,8 @@ export default function ReportsView({ searchQuery, isCoimbatoreView, setIsCoimba }).filter(Boolean); return ( - - - + + ); })()} ); }) )} - -
    + + No matching items matching stock filter criteria. -
    + - + +
    {prod.name} {prod.category}
    -
    + + {prod.sku} - + + {prod.unitsSold.toLocaleString()} - + + ₹{prod.revenue.toLocaleString()} - + + {prod.stockStatus} -
    + +

    @@ -1067,16 +1109,16 @@ export default function ReportsView({ searchQuery, isCoimbatoreView, setIsCoimba

    )} -
    + +
    {/* Matrix table pagination */} @@ -1084,25 +1126,32 @@ export default function ReportsView({ searchQuery, isCoimbatoreView, setIsCoimba 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)
    - - + style={{ height: 32, width: 32, minWidth: 0, background: 'transparent' }} + className="w-8 h-8 border border-[#e2e8f0] rounded-lg flex items-center justify-center cursor-pointer transition-colors hover:bg-white active:bg-[#f8fafc]" + /> +
    Page {currentPage} of {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 hover:bg-white active:bg-[#f8fafc]" + />
    diff --git a/src/components/SalesRevenueReport.tsx b/src/components/SalesRevenueReport.tsx index 94f8c2b..5e8e9e8 100644 --- a/src/components/SalesRevenueReport.tsx +++ b/src/components/SalesRevenueReport.tsx @@ -25,10 +25,17 @@ import { FIESTA_TENANT_ID, num as fnum, str as fstr, ymd, type Row } from '../se // ... inside the component ... import { shortTime } from '../services/fiestaMappers'; +import { Button } from '@astryxdesign/core/Button'; +import { DateInput } from '@astryxdesign/core/DateInput'; +import type { ISODateString } from '@astryxdesign/core/utils'; +import { Selector } from '@astryxdesign/core/Selector'; import { - KpiStrip, Pill, FilterBar, TH_STYLE, - BRAND, BRAND_LIGHT, TEXT, TEXT_2, TEXT_3, BORDER, DIVIDER, SURFACE_ALT, - tint, soft, edge, ring + Table, TableHeader, TableHeaderCell, TableBody, TableRow, TableCell, +} from '@astryxdesign/core/Table'; +import { + KpiStrip, Pill, FilterBar, + BRAND, BRAND_LIGHT, TEXT, TEXT_2, TEXT_3, BORDER, SURFACE_ALT, + ring } from './consoleUi'; import { ResponsiveContainer, AreaChart, Area, BarChart, Bar, PieChart, Pie, Cell, XAxis, YAxis, Tooltip as RechartsTooltip, CartesianGrid } from 'recharts'; @@ -242,20 +249,39 @@ export default function SalesRevenueReport({
    + {/* PHASE 6 — both were unlabelled ``, 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. */}
    - { 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 }} /> + { if (v) { setFromdate(v); setPageno(1); } }} + /> — - { 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 }} /> + { if (v) { setTodate(v); setPageno(1); } }} + />
    - + className="inline-flex items-center gap-1.5 rounded-full font-extrabold text-white cursor-pointer whitespace-nowrap shrink-0" + style={{ height: 'auto', padding: '8px 14px', fontSize: 12, border: 'none', color: '#fff', background: `linear-gradient(135deg, ${BRAND}, ${BRAND_LIGHT})`, boxShadow: `0 6px 18px ${ring(BRAND)}` }} + />
    @@ -415,77 +441,75 @@ export default function SalesRevenueReport({

    Completed Order Ledger

    {!locationid && uniqueStoreNames.length > 0 && ( - + onChange={(v) => { setStoreFilter(v); setPageno(1); }} + options={[ + { value: 'All', label: 'All Stores' }, + ...uniqueStoreNames.map((store) => ({ value: store, label: store })), + ]} + /> )}
    - - - +
    + + {['#', 'Order', 'Branch', 'Customer', 'Qty', 'Payment', 'Amount (₹)'].map((h, i) => ( - + {h} ))} - - - + + + {allOrdersQ.isLoading ? ( - + ) : pageRows.length === 0 ? ( - + ) : ( pageRows.map((r, i) => { const amount = fnum(r.ordervalue) || fnum(r.orderamount) || fnum(r.deliveryamt); return ( - (e.currentTarget.style.background = SURFACE_ALT)} - onMouseLeave={(e) => (e.currentTarget.style.background = 'transparent')} + className="align-top" > - - - - - - - - + + ); }) )} - -
    {h}
    + Loading data… -
    + No completed orders found for this date range. -
    {(pageno - 1) * PAGE_SIZE + i + 1} + {(pageno - 1) * PAGE_SIZE + i + 1} +

    {fstr(r.orderid) || `#${fstr(r.orderheaderid)}`}

    {shortTime(r.orderdate || r.deliverydate)}

    -
    + + {fstr(r.applocation) || fstr(r.locationname) || '—'} - + +

    {fstr(r.deliverycustomer) || fstr(r.pickupcustomer) || fstr(r.tenantname) || '—'}

    -
    {fnum(r.quantity) || '—'} + + {fnum(r.quantity) || '—'} + {(fstr(r.paymentmode) || fstr(r.paymenttype) || 'CASH').toUpperCase()} - + + {amount > 0 ? `₹${amount.toLocaleString('en-IN')}` : '—'} -
    + +
    {/* Pagination */} @@ -494,16 +518,26 @@ export default function SalesRevenueReport({ Page {pageno} · {pageRows.length} of {tableRows.length} shown
    - - + +
    diff --git a/src/components/StoreQRView.tsx b/src/components/StoreQRView.tsx index 3e019aa..b1e1d76 100644 --- a/src/components/StoreQRView.tsx +++ b/src/components/StoreQRView.tsx @@ -1,6 +1,7 @@ import React, { useMemo, useRef } from 'react'; import { QRCodeSVG, QRCodeCanvas } from 'qrcode.react'; import { MapPin, ScanLine, AlertTriangle, Download } from 'lucide-react'; +import { Button } from '@astryxdesign/core/Button'; import { buildStoreQrPayload } from '../services/fiestaApi'; interface StoreQRViewProps { @@ -119,13 +120,13 @@ export default function StoreQRView({ {/* Download Action Button */}
    - + />
    {/* Hidden high-res canvas — source for the PNG download */} diff --git a/src/components/UserStorePage.tsx b/src/components/UserStorePage.tsx index 67356ef..9898a9c 100644 --- a/src/components/UserStorePage.tsx +++ b/src/components/UserStorePage.tsx @@ -28,6 +28,8 @@ import { useFiestaOrderSummary, FIESTA_TENANT_ID, } from '../services/fiestaQueries'; +import { Button } from '@astryxdesign/core/Button'; +import { IconButton } from '@astryxdesign/core/IconButton'; import { str as fstr, num as fnum, roleName, ymd } from '../services/fiestaApi'; import type { AuthUser } from '../services/auth'; import Header from './Header'; @@ -240,15 +242,17 @@ export default function UserStorePage({ onLogout, user }: UserStorePageProps) {

    We couldn’t reach the store service. Check your connection and try again.

    - + ); @@ -431,14 +435,15 @@ export default function UserStorePage({ onLogout, user }: UserStorePageProps) { onClick={(e) => { if (e.target === e.currentTarget) setShowQrModal(false); }} >
    - + /> Label` + * can still name itself. Only used to build the accessible name. + */ +function textOf(node: React.ReactNode): string { + if (node == null || typeof node === 'boolean') return ''; + if (typeof node === 'string' || typeof node === 'number') return String(node); + if (Array.isArray(node)) return node.map(textOf).join(''); + if (React.isValidElement(node)) return textOf((node.props as { children?: React.ReactNode }).children); + return ''; +} + export function Pill({ active, color, onClick, title, children, count }: PillProps) { + // PHASE 6 — Astryx Button carrying the pill's own visual. + // + // Why Button and not ToggleButton, which is the semantic match: ToggleButton + // destructures `className` as `_className` and throws it away, and every + // colour a Pill wears arrives at runtime through the `color` prop (fed from + // ORDER_STATUS / DELIVERY_STATUS). Losing the class hook means losing the + // layout with it. Button keeps both, and `aria-pressed` can be set by hand. + // + // What this fixes: the pill announced nothing about being chosen. Selection + // was carried by fill colour alone, so the eight filter bars built on this + // read as eight identical buttons to a screen reader. `aria-pressed` is not + // the last word — every call site is single-select (`active={tab === t.key}`), + // which really wants a radiogroup — but that is a change to the call sites, + // and this is a change to one file. + // + // The name is assembled rather than left to the text content so the count + // reads as its own word: "Pending, 12" instead of "Pending12". + const name = [title ?? textOf(children).trim(), count != null ? String(count) : ''] + .filter(Boolean) + .join(', '); return ( - + + {children} + {count != null && ( + + {count} + + )} + + ); } @@ -284,27 +323,49 @@ export function StampCell({ date, time }: { date?: string; time?: string }) { // ── Search pill ────────────────────────────────────────────────────────────────── export function SearchPill({ value, onChange, placeholder, color = BRAND, inputRef }: { value: string; onChange: (v: string) => void; placeholder?: string; color?: string; inputRef?: React.Ref }) { + // 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 (
    - - - - - onChange(e.target.value)} + onChange={(v) => onChange(v)} placeholder={placeholder} - className="block w-full rounded-full outline-none font-medium transition-all box-border" - style={{ height: 38, paddingLeft: 32, paddingRight: value ? 30 : 14, fontSize: 12.5, background: tint(color), border: `1.5px solid ${edge(color)}`, color: TEXT }} - onFocus={(e) => { e.currentTarget.style.borderColor = color; e.currentTarget.style.boxShadow = `0 0 0 3px ${ring(color)}`; }} - onBlur={(e) => { e.currentTarget.style.borderColor = edge(color); e.currentTarget.style.boxShadow = 'none'; }} + startIcon={ + + + + + } + 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 && ( - + } + // 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 }} + /> )}
    ); @@ -458,12 +519,18 @@ export function SlideDrawer({ {/* Minimalist Header */}

    {title}

    - + style={{ height: 32, width: 32, minWidth: 0, background: 'transparent', border: 'none', color: 'rgba(255,255,255,0.8)' }} + />
    {/* Scrollable Content */}