From 0daec5311d4e35a29217ce2dea34f8b95fdad512 Mon Sep 17 00:00:00 2001 From: abhishek Date: Thu, 13 Aug 2026 18:33:22 +0530 Subject: [PATCH] conversion done with astryx --- .vscode/css_custom_data.json | 9 + .vscode/settings.json | 3 +- src/App.tsx | 377 ++--- src/components/AdminConsole.tsx | 1969 +++++++---------------- src/components/AdminCustomersView.tsx | 202 --- src/components/DeliveriesView.tsx | 215 ++- src/components/DeliveryReportsView.tsx | 153 +- src/components/DispatchHubView.tsx | 130 +- src/components/InventoryView.tsx | 634 ++++---- src/components/OperationsView.tsx | 789 --------- src/components/OrdersDeliveriesView.tsx | 240 +-- src/components/OrdersView.tsx | 615 ++++--- src/components/PosStaffPanel.tsx | 298 ++-- src/components/PosView.tsx | 633 ++++---- src/components/SettingsView.tsx | 315 ++-- src/components/StoreCatalogView.tsx | 389 ++--- src/components/StoreDetailView.tsx | 1144 +++++++------ src/components/UsersPanel.tsx | 531 +++--- src/services/fiestaQueries.ts | 7 +- 19 files changed, 3509 insertions(+), 5144 deletions(-) create mode 100644 .vscode/css_custom_data.json delete mode 100644 src/components/AdminCustomersView.tsx delete mode 100644 src/components/OperationsView.tsx diff --git a/.vscode/css_custom_data.json b/.vscode/css_custom_data.json new file mode 100644 index 0000000..ac8e6b1 --- /dev/null +++ b/.vscode/css_custom_data.json @@ -0,0 +1,9 @@ +{ + "version": 1.1, + "atDirectives": [ + { + "name": "@theme", + "description": "Tailwind CSS v4 theme configuration." + } + ] +} diff --git a/.vscode/settings.json b/.vscode/settings.json index 5a586b3..536b825 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,3 +1,4 @@ { - "css.lint.unknownAtRules": "ignore" + "css.lint.unknownAtRules": "ignore", + "css.customData": [".vscode/css_custom_data.json"] } diff --git a/src/App.tsx b/src/App.tsx index b8c7d47..6452d64 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -9,33 +9,18 @@ import PosStaffPanelAstryx from './components/spike/PosStaffPanelAstryx'; // PHA import ThemeShowcase from './components/spike/ThemeShowcase'; // PHASE 2 gate — throwaway import ConsoleUiProbe from './components/spike/ConsoleUiProbe'; // PHASE 3 verification — throwaway import { - Network, - Truck, - Sliders, - Calendar, - FileCheck, Building, - CheckCircle2, - Clock, - ShieldCheck, - Send, - HelpCircle, - Database, ArrowRight, - X, Search, - Plus, - MapPin, Phone, - Activity, - TrendingUp, - Award, + // Passed by reference into the Header's `icon` slot rather than rendered as + // JSX here, which is why a JSX-only usage scan misses them. + LayoutDashboard, Layers, Store, + TrendingUp, Settings, - LayoutDashboard, - Users, - Box + Truck } from 'lucide-react'; import { MainSection } from './types'; @@ -43,7 +28,6 @@ import { useFiestaTenantLocations, useFiestaLocationSummary, useFiestaUpdateLocation, - useFiestaCreateLocation, useFiestaOrderSummary, } from './services/fiestaQueries'; import { FIESTA_TENANT_ID, FIESTA_PRIMARY_LOCATION_ID, str as fstr, num as fnum } from './services/fiestaApi'; @@ -60,11 +44,18 @@ import PosErrorBoundary from './components/PosErrorBoundary'; import LoginView from './components/LoginView'; import UserStorePage from './components/UserStorePage'; import SuperAdminPage from './components/SuperAdminPage'; -import AwaitingApi from './components/AwaitingApi'; import ComparisonModal from './components/ComparisonModal'; import CatalogueBrowser from './components/CatalogueBrowser'; import type { AuthUser } from './services/auth'; import ragulStoreCover from './assets/images/store_front_view_1780299351800.png'; +import { useNotify } from './components/Toasts'; +import { Button } from '@astryxdesign/core/Button'; +import { IconButton } from '@astryxdesign/core/IconButton'; +import { TextInput } from '@astryxdesign/core/TextInput'; +import { Switch } from '@astryxdesign/core/Switch'; +import { EmptyState } from '@astryxdesign/core/EmptyState'; +import { Spinner } from '@astryxdesign/core/Spinner'; +import { SegmentedControl, SegmentedControlItem } from '@astryxdesign/core/SegmentedControl'; const AUTH_STORAGE_KEY = 'nearledaily.auth'; @@ -115,6 +106,9 @@ export default function App() { // Navigation indicators states const location = useLocation(); const navigate = useNavigate(); + // PHASE 6 — replaces the last four alert() calls in the app. A native alert + // blocks the whole browser until dismissed; a toast does not. + const { notifyOk } = useNotify(); // Derive current section from URL path, default to 'dashboard' const pathParts = location.pathname.split('/'); @@ -143,9 +137,10 @@ export default function App() { const tenantId = authUser?.tenantid || FIESTA_TENANT_ID; // ── Live data for the secondary sections (Fiesta) ───────────────────────── - // Stores ← tenant locations + per-location order summary (seeded into local - // state so the "Add Store" handler keeps working). Users management now lives - // under Settings → Users & Access (see UsersPanel). + // Stores ← tenant locations + per-location order summary, held in local state + // so the per-card status toggle can update a row optimistically before the + // refetch lands. Users management lives under Settings → Users & Access + // (see UsersPanel); outlet creation lives under Settings → Outlets. const locationsQ = useFiestaTenantLocations(tenantId); const locSummaryQ = useFiestaLocationSummary(tenantId); const updateLocation = useFiestaUpdateLocation(); @@ -238,41 +233,19 @@ export default function App() { } }, [locationsQ.data, locSummaryQ.data]); - // Secondary sub-sections modals triggers - const [showAddStoreModal, setShowAddStoreModal] = useState(false); - - // New forms states - const [newStore, setNewStore] = useState({ name: '', zone: '', lead: '', sales: '₹1,50,000' }); - - // Form submission handles for secondary sections - const handleCreateStore = (e: React.FormEvent) => { - e.preventDefault(); - if (!newStore.name || !newStore.zone || !newStore.lead) { - alert('Kindly fill store metadata completely.'); - return; - } - setStoresList([...storesList, { - locationid: 10000 + Math.floor(Math.random() * 9000), - name: newStore.name, - zone: newStore.zone, - deliveries: 0, - sales: '0 orders', - orders: 0, - staff: newStore.lead, - color: 'emerald', - status: 'Active' - }]); - setShowAddStoreModal(false); - setNewStore({ name: '', zone: '', lead: '', sales: '₹1,50,000' }); - alert(`Node outlet "${newStore.name}" commissioned to live operations feed successfully.`); - }; - - // Calendar Event Modal state - const [showCalendarModal, setShowCalendarModal] = useState(false); + // The "Add Store" and "Scheduled Reports Calendar" modals that used to live + // here are gone, along with their state and `handleCreateStore`. Nothing ever + // opened them: `setShowAddStoreModal(true)` and `setShowCalendarModal(true)` + // were called nowhere in the codebase, so both were permanently false and the + // markup was unreachable. `handleCreateStore` was reachable only through one + // of them, and it never wrote to the backend — it pushed a row into local + // `storesList` with a random `locationid`, which the next refetch discarded. + // Real outlet creation is Settings → Outlets → Add Store Branch, which goes + // through AdminConsole and the createlocation endpoint. // Callback action triggers const handleHelp = () => { - alert('nearledaily User Manual & Documentation Center linked successfully. Contact Coimbatore regional IT hub desk for urgent escalations.'); + notifyOk('Contact the Coimbatore regional IT hub desk for urgent escalations.'); }; const handleLogout = () => setAuthUser(null); @@ -302,86 +275,65 @@ export default function App() { {/* ── Search & Filter Toolbar ── */}
{/* Search Input */} -
-
- -
- + setStoresSearch(e.target.value)} - className="w-full pl-11 pr-12 py-3 bg-transparent border-none text-sm font-semibold text-slate-800 placeholder-slate-400 focus:outline-none focus:ring-0 transition-all duration-300" + onChange={(v) => setStoresSearch(v)} + startIcon={} + hasClear /> -
- {storesSearch && ( - - )} -
{/* Vertical Divider (Hidden on Mobile) */}
- {/* Filter Segmented Control */} -
- - - + + s.status.toLowerCase() === 'active').length})`} + /> + s.status.toLowerCase() !== 'active').length})`} + /> +
{/* Empty States */} {filteredStoresList.length === 0 && ( -
+
{locationsQ.isLoading ? ( -
-
- Loading live store locations… -
+
) : ( - No store locations found matching your filter criteria. + } + title="No store locations found" + description={ + storesSearch || storesFilter !== 'ALL' + ? 'Nothing matches your search or filter.' + : 'Commission your first outlet to start taking orders.' + } + actions={ + storesSearch || storesFilter !== 'ALL' + ?
)} @@ -505,34 +457,41 @@ export default function App() {
- + />
{/* Card footer - enter console */}
- + {/* A real Switch. This was a -
- -
-

- Automated compliance summaries will appear here once scheduled-report exports are available. -

- - -
- -
- -
- - - )} - - {/* CREATE NEW STORE MODAL */} - {showAddStoreModal && ( -
{ if (e.target === e.currentTarget) setShowAddStoreModal(false); }} - > -
-
-

- - Commission New Regional Store Node -

- -
- -
-
-
-
- - setNewStore({ ...newStore, name: e.target.value })} - className="w-full border border-[#e2e8f0] rounded-lg p-sm bg-[#f8fafc] focus:bg-white outline-none focus:ring-1 focus:ring-[#662582]" - required - /> -
- -
- - setNewStore({ ...newStore, zone: e.target.value })} - className="w-full border border-[#e2e8f0] rounded-lg p-sm bg-[#f8fafc] focus:bg-white outline-none focus:ring-1 focus:ring-[#662582]" - required - /> -
- -
- - setNewStore({ ...newStore, lead: e.target.value })} - className="w-full border border-[#e2e8f0] rounded-lg p-sm bg-[#f8fafc] focus:bg-white outline-none focus:ring-1 focus:ring-[#662582]" - required - /> -
- -
- - setNewStore({ ...newStore, sales: e.target.value })} - className="w-full border border-[#e2e8f0] rounded-lg p-sm bg-[#f8fafc] focus:bg-white outline-none focus:ring-1 focus:ring-[#662582]" - /> -
-
-
- -
- - -
-
-
-
- )} - ); diff --git a/src/components/AdminConsole.tsx b/src/components/AdminConsole.tsx index a9d2178..38db32c 100644 --- a/src/components/AdminConsole.tsx +++ b/src/components/AdminConsole.tsx @@ -10,16 +10,12 @@ import { Bike, Check, Copy, - Plus, - ArrowRight, ArrowLeft, ShieldAlert, - Loader2, Mail, Phone, FileText, MapPin, - Clock, Navigation } from 'lucide-react'; import { @@ -34,6 +30,17 @@ import { FIESTA_TENANT_ID, str as fstr, num as fnum } from '../services/fiestaAp import StoreQRView from './StoreQRView'; import AddressAutocomplete, { type AddressResult } from './AddressAutocomplete'; import { useNotify } from './Toasts'; +import { Card as ShellCard } from './consoleUi'; +import { Button } from '@astryxdesign/core/Button'; +import { TextInput } from '@astryxdesign/core/TextInput'; +import { NumberInput } from '@astryxdesign/core/NumberInput'; +import { Selector } from '@astryxdesign/core/Selector'; +import { TimeInput } from '@astryxdesign/core/TimeInput'; +import type { ISOTimeString } from '@astryxdesign/core/utils'; +import { Banner } from '@astryxdesign/core/Banner'; +import { CodeBlock } from '@astryxdesign/core/CodeBlock'; +import { Heading } from '@astryxdesign/core/Heading'; +import { Text } from '@astryxdesign/core/Text'; export default function AdminConsole({ activeTab: propActiveTab, showHeader = true, onBack, tenantId }: { activeTab?: 'tenant' | 'store' | 'rider', showHeader?: boolean, onBack?: () => void, tenantId?: number }) { // PHASE 6 — replaces alert(). A native alert freezes the whole browser @@ -297,1368 +304,620 @@ VALUES (${newUserId}, 1, 'Active', NOW()); const locationsQ = useFiestaTenantLocations(FIESTA_TENANT_ID); const locations = locationsQ.data || []; + /** + * PHASE 6 — the two render branches are now one. + * + * This file carried the tenant, store and rider forms TWICE: once for + * `showHeader === false` (embedded in SettingsView) and once for + * `showHeader === true` (the standalone super-admin page). 55 `` for + * 28 distinct fields. The copies shared all state and all three submit + * handlers, so they were pure duplicated markup — and duplicated markup on a + * provisioning screen is where the two copies quietly drift apart. + * + * Verified before collapsing them: the field sets were identical except for + * one control. The standalone page lets you choose the owning tenant; the + * embedded one shows it read-only, because there the tenant is already fixed + * to the signed-in admin's. That difference survives as `canPickTenant`. + */ + const resetAll = () => { setTenantSuccess(null); setStoreSuccess(null); setRiderSuccess(null); }; + + const panels: Record<'tenant' | 'store' | 'rider', React.ReactNode> = { + tenant: tenantSuccess ? ( + { setTenantSuccess(null); setTenantForm(TENANT_FORM_INITIAL); }} + > + Tenant {tenantForm.tenantname} has been provisioned. An administrator account + has been dispatched with credentials tied to {tenantForm.primaryemail}. + + ) : ( + + ), + + store: storeSuccess ? ( + { + setStoreSuccess(null); + setStoreForm({ + tenantid: FIESTA_TENANT_ID, locationname: '', address: '', suburb: '', city: 'Coimbatore', + state: 'Tamil Nadu', postcode: '', latitude: '', longitude: '', contactno: '', email: '', + opentime: '06:00:00', closetime: '22:00:00', deliverymins: 45, deliveryradius: 5000, + }); + }} + onBack={onBack} + backLabel="Back to Outlets" + extra={ +
+ +
+ } + > + Store {storeForm.locationname} has been initialized. A default placeholder + manager user has been spawned in inactive mode (requires password setup). +
+ ) : ( + + ), + + rider: riderSuccess ? ( + { + setRiderSuccess(null); + setRiderForm({ + firstname: '', lastname: '', email: '', contactno: '', password: '', + locationid: 0, shiftid: 0, vehiclename: '', vehiclemodel: '', licensenumber: '', + }); + }} + /> + ) : ( + + ), + }; + if (!showHeader) { - return ( -
- {/* TAB 1: Tenant Onboarding */} - {activeTab === 'tenant' && ( -
-
-

Provision New Merchant Tenant

-

- Onboard a new merchant group. This registers their enterprise, defaults the order serialization, and spawns the primary Administrator account. -

-
- - {tenantSuccess ? ( -
-
✓
-

Onboarding Complete!

-

- Tenant {tenantForm.tenantname} has been provisioned. An administrator account has been dispatched with credentials tied to {tenantForm.primaryemail}. -

- -
- ) : ( -
-
-
- - setTenantForm({ ...tenantForm, tenantname: e.target.value })} - className="w-full border border-slate-250 rounded-xl p-3 bg-slate-50/40 hover:bg-slate-100 focus:bg-white outline-none focus:border-purple-500 transition-all font-semibold text-xs text-slate-800" - required - /> -
-
- - setTenantForm({ ...tenantForm, companyname: e.target.value })} - className="w-full border border-slate-250 rounded-xl p-3 bg-slate-50/40 hover:bg-slate-100 focus:bg-white outline-none focus:border-purple-500 transition-all font-semibold text-xs text-slate-800" - required - /> -
-
- - setTenantForm({ ...tenantForm, primarycontact: e.target.value.replace(/\D/g, '') })} - className="w-full border border-slate-250 rounded-xl p-3 bg-slate-50/40 hover:bg-slate-100 focus:bg-white outline-none focus:border-purple-500 transition-all font-semibold text-xs text-slate-800" - required - /> -
-
- - setTenantForm({ ...tenantForm, primaryemail: e.target.value })} - className="w-full border border-slate-250 rounded-xl p-3 bg-slate-50/40 hover:bg-slate-100 focus:bg-white outline-none focus:border-purple-500 transition-all font-semibold text-xs text-slate-800" - required - /> -
-
- -
-
- - setTenantForm({ ...tenantForm, locationname: e.target.value })} - className="w-full border border-slate-250 rounded-xl p-3 bg-slate-50/40 hover:bg-slate-100 focus:bg-white outline-none focus:border-purple-500 transition-all font-semibold text-xs text-slate-800" - required - /> -
-
- - -
-
- -
- - -
- -
-
- - setTenantForm({ ...tenantForm, suburb: e.target.value })} - className="w-full border border-slate-250 rounded-xl p-3 bg-slate-50/40 hover:bg-slate-100 focus:bg-white outline-none focus:border-purple-500 transition-all font-semibold text-xs text-slate-800" - /> -
-
- - setTenantForm({ ...tenantForm, city: e.target.value })} - className="w-full border border-slate-250 rounded-xl p-3 bg-slate-50/40 hover:bg-slate-100 focus:bg-white outline-none focus:border-purple-500 transition-all font-semibold text-xs text-slate-800" - /> -
-
- - setTenantForm({ ...tenantForm, state: e.target.value })} - className="w-full border border-slate-250 rounded-xl p-3 bg-slate-50/40 hover:bg-slate-100 focus:bg-white outline-none focus:border-purple-500 transition-all font-semibold text-xs text-slate-800" - /> -
-
- - setTenantForm({ ...tenantForm, postcode: e.target.value })} - className="w-full border border-slate-250 rounded-xl p-3 bg-slate-50/40 hover:bg-slate-100 focus:bg-white outline-none focus:border-purple-500 transition-all font-semibold text-xs text-slate-800" - /> -
-
- -
- -
-
- )} -
- )} - - {/* TAB 2: Store Onboarding */} - {activeTab === 'store' && ( -
-
-
-

- - Add Store Outlet Location -

-

- Commission a new store branch/hub under a tenant. This sets up store parameters, delivery thresholds, and spawns a placeholder branch manager account. -

-
-
- - {storeSuccess ? ( -
-
✓
-

Store Branch Active!

-

- Store {storeForm.locationname} has been initialized. A default placeholder manager user has been spawned in inactive mode (requires password setup). -

-
- -
-
- {onBack && ( - - )} - -
-
- ) : ( -
- - {/* SECTION 1: Identity & Primary Contact */} -
-

- Owner & Identity -

-
-
- -
-
- -
- -
-
- -
- -
-
- -
- setStoreForm({ ...storeForm, locationname: e.target.value })} - className="pl-10 pr-4 py-2.5 w-full border border-slate-200 rounded-xl bg-slate-50/40 hover:bg-slate-100/60 focus:bg-white outline-none focus:ring-4 focus:ring-purple-100 focus:border-purple-600 transition-all font-semibold text-xs text-slate-800 shadow-sm" - required - /> -
-
- -
- -
-
- -
- setStoreForm({ ...storeForm, email: e.target.value })} - className="pl-10 pr-4 py-2.5 w-full border border-slate-200 rounded-xl bg-slate-50/40 hover:bg-slate-100/60 focus:bg-white outline-none focus:ring-4 focus:ring-purple-100 focus:border-purple-600 transition-all font-semibold text-xs text-slate-800 shadow-sm" - /> -
-
- -
- -
-
- -
- setStoreForm({ ...storeForm, contactno: e.target.value.replace(/\D/g, '') })} - className="pl-10 pr-4 py-2.5 w-full border border-slate-200 rounded-xl bg-slate-50/40 hover:bg-slate-100/60 focus:bg-white outline-none focus:ring-4 focus:ring-purple-100 focus:border-purple-600 transition-all font-semibold text-xs text-slate-800 shadow-sm" - /> -
-
-
-
- - {/* SECTION 2: Location & Address */} -
-

- Location & Address Details -

-
- - -
- -
-
- - setStoreForm({ ...storeForm, suburb: e.target.value })} - className="px-4 py-2.5 w-full border border-slate-200 rounded-xl bg-slate-50/40 hover:bg-slate-100/60 focus:bg-white outline-none focus:ring-4 focus:ring-purple-100 focus:border-purple-600 transition-all font-semibold text-xs text-slate-800 shadow-sm" - /> -
-
- - setStoreForm({ ...storeForm, city: e.target.value })} - className="px-4 py-2.5 w-full border border-slate-200 rounded-xl bg-slate-50/40 hover:bg-slate-100/60 focus:bg-white outline-none focus:ring-4 focus:ring-purple-100 focus:border-purple-600 transition-all font-semibold text-xs text-slate-800 shadow-sm" - /> -
-
- - setStoreForm({ ...storeForm, state: e.target.value })} - className="px-4 py-2.5 w-full border border-slate-200 rounded-xl bg-slate-50/40 hover:bg-slate-100/60 focus:bg-white outline-none focus:ring-4 focus:ring-purple-100 focus:border-purple-600 transition-all font-semibold text-xs text-slate-800 shadow-sm" - /> -
-
- - setStoreForm({ ...storeForm, postcode: e.target.value })} - className="px-4 py-2.5 w-full border border-slate-200 rounded-xl bg-slate-50/40 hover:bg-slate-100/60 focus:bg-white outline-none focus:ring-4 focus:ring-purple-100 focus:border-purple-600 transition-all font-semibold text-xs text-slate-800 shadow-sm" - /> -
-
-
- - {/* SECTION 3: Operations & Logistics */} -
-

- Logistics & Operational Hours -

-
-
- -
-
- -
- setStoreForm({ ...storeForm, opentime: e.target.value })} - className="pl-10 pr-4 py-2.5 w-full border border-slate-200 rounded-xl bg-slate-50/40 focus:bg-white outline-none focus:ring-4 focus:ring-purple-100 focus:border-purple-600 transition-all font-semibold text-xs text-slate-800 shadow-sm" - /> -
-
-
- -
-
- -
- setStoreForm({ ...storeForm, closetime: e.target.value })} - className="pl-10 pr-4 py-2.5 w-full border border-slate-200 rounded-xl bg-slate-50/40 focus:bg-white outline-none focus:ring-4 focus:ring-purple-100 focus:border-purple-600 transition-all font-semibold text-xs text-slate-800 shadow-sm" - /> -
-
-
- -
-
- -
- setStoreForm({ ...storeForm, deliveryradius: Number(e.target.value) })} - className="pl-10 pr-4 py-2.5 w-full border border-slate-200 rounded-xl bg-slate-50/40 focus:bg-white outline-none focus:ring-4 focus:ring-purple-100 focus:border-purple-600 transition-all font-semibold text-xs text-slate-800 shadow-sm" - /> -
-
-
- -
-
- -
- setStoreForm({ ...storeForm, deliverymins: Number(e.target.value) })} - className="pl-10 pr-4 py-2.5 w-full border border-slate-200 rounded-xl bg-slate-50/40 focus:bg-white outline-none focus:ring-4 focus:ring-purple-100 focus:border-purple-600 transition-all font-semibold text-xs text-slate-800 shadow-sm" - /> -
-
-
-
- - {/* FORM ACTIONS */} -
- {onBack && ( - - )} - -
-
- )} -
- )} - - {/* TAB 3: Rider Onboarding */} - {activeTab === 'rider' && ( -
-
-

- - Onboard Delivery Rider -

-

- Onboarding riders is a multi-table database transaction. We register the user profile in app_users, and dynamically generate SQL configuration queries to inject vehicle, shift, and availability settings. -

-
- - {riderSuccess ? ( -
-
-
✓
-
-

Base Account Registered successfully!

-

- Rider user created in app_users with ID: #{riderSuccess.userid}. -

-
-
- - {/* SQL generated panel */} -
-
- - Administrative SQL Synchronization script - - -
- -
-                    {riderSuccess.sql}
-                  
- -
- -
- Immediate Action Required: - Please share this SQL script with the Database Administrator (DBA) or run it against the Postgres instance to complete vehicle assignments, shifts, and active pool configuration. -
-
-
- -
- -
-
- ) : ( -
- {/* Account personal details */} -
-

1. Base Profile Details

-
-
- - setRiderForm({ ...riderForm, firstname: e.target.value })} - className="w-full border border-slate-250 rounded-xl p-3 bg-white outline-none focus:border-purple-500 transition-all font-semibold text-xs text-slate-800" - required - /> -
-
- - setRiderForm({ ...riderForm, lastname: e.target.value })} - className="w-full border border-slate-250 rounded-xl p-3 bg-white outline-none focus:border-purple-500 transition-all font-semibold text-xs text-slate-800" - /> -
-
- - setRiderForm({ ...riderForm, email: e.target.value })} - className="w-full border border-slate-250 rounded-xl p-3 bg-white outline-none focus:border-purple-500 transition-all font-semibold text-xs text-slate-800" - required - /> -
-
- - setRiderForm({ ...riderForm, contactno: e.target.value.replace(/\D/g, '') })} - className="w-full border border-slate-250 rounded-xl p-3 bg-white outline-none focus:border-purple-500 transition-all font-semibold text-xs text-slate-800" - required - /> -
-
-
- - {/* Vehicle, Shift & Hub configurations */} -
-

2. Fleet & Shift configuration

-
-
- - -
- -
- - -
- -
- - setRiderForm({ ...riderForm, vehiclename: e.target.value })} - className="w-full border border-slate-250 rounded-xl p-3 bg-white outline-none focus:border-purple-500 transition-all font-semibold text-xs text-slate-800" - /> -
- -
- - setRiderForm({ ...riderForm, vehiclemodel: e.target.value })} - className="w-full border border-slate-250 rounded-xl p-3 bg-white outline-none focus:border-purple-500 transition-all font-semibold text-xs text-slate-800" - /> -
- -
- - setRiderForm({ ...riderForm, licensenumber: e.target.value })} - className="w-full border border-slate-250 rounded-xl p-3 bg-white outline-none focus:border-purple-500 transition-all font-semibold text-xs text-slate-800" - /> -
-
-
- -
- -
-
- )} -
- )} -
- ); + return
{panels[activeTab]}
; } return ( -
- - {/* Tabs Layout */} +
- {/* Navigation Tabs */} -
+ {/* Navigation. Each tab clears only its OWN success panel, exactly as + the three hand-rolled buttons did — switching away from a completed + rider onboarding must not wipe a completed tenant one. */} + -
+ - {/* Form Panel */} -
- {/* TAB 1: Tenant Onboarding */} - {activeTab === 'tenant' && ( -
-
-

Provision New Merchant Tenant

-

- Onboard a new merchant group. This registers their enterprise, defaults the order serialization, and spawns the primary Administrator account. -

-
- - {tenantSuccess ? ( -
-
✓
-

Onboarding Complete!

-

- Tenant {tenantForm.tenantname} has been provisioned. An administrator account has been dispatched with credentials tied to {tenantForm.primaryemail}. -

- -
- ) : ( -
-
-
- - setTenantForm({ ...tenantForm, tenantname: e.target.value })} - className="w-full border border-slate-250 rounded-xl p-3 bg-slate-50/40 hover:bg-slate-100 focus:bg-white outline-none focus:border-purple-500 transition-all font-semibold text-xs text-slate-800" - required - /> -
-
- - setTenantForm({ ...tenantForm, companyname: e.target.value })} - className="w-full border border-slate-250 rounded-xl p-3 bg-slate-50/40 hover:bg-slate-100 focus:bg-white outline-none focus:border-purple-500 transition-all font-semibold text-xs text-slate-800" - required - /> -
-
- - setTenantForm({ ...tenantForm, primarycontact: e.target.value.replace(/\D/g, '') })} - className="w-full border border-slate-250 rounded-xl p-3 bg-slate-50/40 hover:bg-slate-100 focus:bg-white outline-none focus:border-purple-500 transition-all font-semibold text-xs text-slate-800" - required - /> -
-
- - setTenantForm({ ...tenantForm, primaryemail: e.target.value })} - className="w-full border border-slate-250 rounded-xl p-3 bg-slate-50/40 hover:bg-slate-100 focus:bg-white outline-none focus:border-purple-500 transition-all font-semibold text-xs text-slate-800" - required - /> -
-
- -
-
- - setTenantForm({ ...tenantForm, locationname: e.target.value })} - className="w-full border border-slate-250 rounded-xl p-3 bg-slate-50/40 hover:bg-slate-100 focus:bg-white outline-none focus:border-purple-500 transition-all font-semibold text-xs text-slate-800" - required - /> -
-
- - -
-
- -
- - -
- -
-
- - setTenantForm({ ...tenantForm, suburb: e.target.value })} - className="w-full border border-slate-250 rounded-xl p-3 bg-slate-50/40 hover:bg-slate-100 focus:bg-white outline-none focus:border-purple-500 transition-all font-semibold text-xs text-slate-800" - /> -
-
- - setTenantForm({ ...tenantForm, city: e.target.value })} - className="w-full border border-slate-250 rounded-xl p-3 bg-slate-50/40 hover:bg-slate-100 focus:bg-white outline-none focus:border-purple-500 transition-all font-semibold text-xs text-slate-800" - /> -
-
- - setTenantForm({ ...tenantForm, state: e.target.value })} - className="w-full border border-slate-250 rounded-xl p-3 bg-slate-50/40 hover:bg-slate-100 focus:bg-white outline-none focus:border-purple-500 transition-all font-semibold text-xs text-slate-800" - /> -
-
- - setTenantForm({ ...tenantForm, postcode: e.target.value })} - className="w-full border border-slate-250 rounded-xl p-3 bg-slate-50/40 hover:bg-slate-100 focus:bg-white outline-none focus:border-purple-500 transition-all font-semibold text-xs text-slate-800" - /> -
-
- -
- -
-
- )} -
- )} - - {/* TAB 2: Store Onboarding */} - {activeTab === 'store' && ( -
-
-

Add Store Outlet Location

-

- Commission a new store branch/hub under a tenant. This sets up store parameters, delivery thresholds, and spawns an placeholder branch manager account. -

-
- - {storeSuccess ? ( -
-
✓
-

Store Branch Active!

-

- Store {storeForm.locationname} has been initialized. A default placeholder manager user has been spawned in inactive mode (requires password setup). -

-
- -
-
- {onBack && ( - - )} - -
-
- ) : ( -
-
-
- - -
- -
- - setStoreForm({ ...storeForm, locationname: e.target.value })} - className="w-full border border-slate-250 rounded-xl p-3 bg-slate-50/40 hover:bg-slate-100 focus:bg-white outline-none focus:border-purple-500 transition-all font-semibold text-xs text-slate-800" - required - /> -
- -
- - setStoreForm({ ...storeForm, email: e.target.value })} - className="w-full border border-slate-250 rounded-xl p-3 bg-slate-50/40 hover:bg-slate-100 focus:bg-white outline-none focus:border-purple-500 transition-all font-semibold text-xs text-slate-800" - /> -
- -
- - setStoreForm({ ...storeForm, contactno: e.target.value.replace(/\D/g, '') })} - className="w-full border border-slate-250 rounded-xl p-3 bg-slate-50/40 hover:bg-slate-100 focus:bg-white outline-none focus:border-purple-500 transition-all font-semibold text-xs text-slate-800" - /> -
-
- -
- - -
- -
-
- - setStoreForm({ ...storeForm, suburb: e.target.value })} - className="w-full border border-slate-250 rounded-xl p-3 bg-slate-50/40 hover:bg-slate-100 focus:bg-white outline-none focus:border-purple-500 transition-all font-semibold text-xs text-slate-800" - /> -
-
- - setStoreForm({ ...storeForm, city: e.target.value })} - className="w-full border border-slate-250 rounded-xl p-3 bg-slate-50/40 hover:bg-slate-100 focus:bg-white outline-none focus:border-purple-500 transition-all font-semibold text-xs text-slate-800" - /> -
-
- - setStoreForm({ ...storeForm, state: e.target.value })} - className="w-full border border-slate-250 rounded-xl p-3 bg-slate-50/40 hover:bg-slate-100 focus:bg-white outline-none focus:border-purple-500 transition-all font-semibold text-xs text-slate-800" - /> -
-
- - setStoreForm({ ...storeForm, postcode: e.target.value })} - className="w-full border border-slate-250 rounded-xl p-3 bg-slate-50/40 hover:bg-slate-100 focus:bg-white outline-none focus:border-purple-500 transition-all font-semibold text-xs text-slate-800" - /> -
-
- -
-
- - setStoreForm({ ...storeForm, opentime: e.target.value })} - className="w-full border border-slate-250 rounded-xl p-3 bg-slate-50/40 focus:bg-white outline-none focus:border-purple-500 transition-all font-semibold text-xs text-slate-800" - /> -
-
- - setStoreForm({ ...storeForm, closetime: e.target.value })} - className="w-full border border-slate-250 rounded-xl p-3 bg-slate-50/40 focus:bg-white outline-none focus:border-purple-500 transition-all font-semibold text-xs text-slate-800" - /> -
-
- - setStoreForm({ ...storeForm, deliveryradius: Number(e.target.value) })} - className="w-full border border-slate-250 rounded-xl p-3 bg-slate-50/40 focus:bg-white outline-none focus:border-purple-500 transition-all font-semibold text-xs text-slate-800" - /> -
-
- - setStoreForm({ ...storeForm, deliverymins: Number(e.target.value) })} - className="w-full border border-slate-250 rounded-xl p-3 bg-slate-50/40 focus:bg-white outline-none focus:border-purple-500 transition-all font-semibold text-xs text-slate-800" - /> -
-
- -
- {onBack && ( - - )} - -
-
- )} -
- )} - - {/* TAB 3: Rider Onboarding */} - {activeTab === 'rider' && ( -
-
-

- - Onboard Delivery Rider -

-

- Onboarding riders is a multi-table database transaction. We register the user profile in app_users, and dynamically generate SQL configuration queries to inject vehicle, shift, and availability settings. -

-
- - {riderSuccess ? ( -
-
-
✓
-
-

Base Account Registered successfully!

-

- Rider user created in app_users with ID: #{riderSuccess.userid}. -

-
-
- - {/* SQL generated panel */} -
-
- - Administrative SQL Synchronization script - - -
- -
-                      {riderSuccess.sql}
-                    
- -
- -
- Immediate Action Required: - Please share this SQL script with the Database Administrator (DBA) or run it against the Postgres instance to complete vehicle assignments, shifts, and active pool configuration. -
-
-
- -
- -
-
- ) : ( -
- {/* Account personal details */} -
-

1. Base Profile Details

-
-
- - setRiderForm({ ...riderForm, firstname: e.target.value })} - className="w-full border border-slate-250 rounded-xl p-3 bg-white outline-none focus:border-purple-500 transition-all font-semibold text-xs text-slate-800" - required - /> -
-
- - setRiderForm({ ...riderForm, lastname: e.target.value })} - className="w-full border border-slate-250 rounded-xl p-3 bg-white outline-none focus:border-purple-500 transition-all font-semibold text-xs text-slate-800" - /> -
-
- - setRiderForm({ ...riderForm, email: e.target.value })} - className="w-full border border-slate-250 rounded-xl p-3 bg-white outline-none focus:border-purple-500 transition-all font-semibold text-xs text-slate-800" - required - /> -
-
- - setRiderForm({ ...riderForm, contactno: e.target.value.replace(/\D/g, '') })} - className="w-full border border-slate-250 rounded-xl p-3 bg-white outline-none focus:border-purple-500 transition-all font-semibold text-xs text-slate-800" - required - /> -
-
-
- - {/* Vehicle, Shift & Hub configurations */} -
-

2. Fleet & Shift configuration

-
-
- - -
- -
- - -
- -
- - setRiderForm({ ...riderForm, vehiclename: e.target.value })} - className="w-full border border-slate-250 rounded-xl p-3 bg-white outline-none focus:border-purple-500 transition-all font-semibold text-xs text-slate-800" - /> -
- -
- - setRiderForm({ ...riderForm, vehiclemodel: e.target.value })} - className="w-full border border-slate-250 rounded-xl p-3 bg-white outline-none focus:border-purple-500 transition-all font-semibold text-xs text-slate-800" - /> -
- -
- - setRiderForm({ ...riderForm, licensenumber: e.target.value })} - className="w-full border border-slate-250 rounded-xl p-3 bg-white outline-none focus:border-purple-500 transition-all font-semibold text-xs text-slate-800" - /> -
-
-
- -
- -
-
- )} -
- )} -
+
{panels[activeTab]}
+
+
+ ); +} + +// ── Shared pieces ───────────────────────────────────────────────────────────── + +/** Section heading inside a form card. */ +function SectionTitle({ icon, children }: { icon: React.ReactNode; children: React.ReactNode }) { + return ( +

+ {icon} {children} +

+ ); +} + +/** + * The "you're done" card shared by the tenant and store flows. + * + * All three success states were the same emerald card with a tick glyph, a + * heading, a sentence and a button. The tick was a `✓` character in a coloured + * circle, which screen readers announce as "check mark" mid-sentence; it is a + * real icon marked decorative now. + */ +function SuccessPanel({ + title, children, actionLabel, onAction, onBack, backLabel, extra, +}: { + title: string; + children: React.ReactNode; + actionLabel: string; + onAction: () => void; + onBack?: () => void; + backLabel?: string; + extra?: React.ReactNode; +}) { + return ( + + + {title} +
{children}
+ {extra} +
+ {onBack && ( +
+
+ ); +} + +// ── Tenant onboarding ───────────────────────────────────────────────────────── + +type TenantFormState = { + tenantname: string; companyname: string; primarycontact: string; primaryemail: string; + address: string; suburb: string; city: string; state: string; postcode: string; + latitude: string; longitude: string; locationname: string; categoryid: number; applocationid: number; +}; + +const BUSINESS_CATEGORIES = [ + { value: '1', label: 'Food & Dining' }, + { value: '2', label: 'Grocery & Daily' }, + { value: '3', label: 'Pharmacy' }, + { value: '4', label: 'Retail' }, +]; + +function TenantForm({ + form, setForm, onSubmit, onAddressSelect, isPending, +}: { + form: TenantFormState; + setForm: React.Dispatch>; + onSubmit: (e: React.FormEvent) => void; + onAddressSelect: (r: AddressResult | null) => void; + isPending: boolean; +}) { + const set = (k: K, v: TenantFormState[K]) => + setForm((f) => ({ ...f, [k]: v })); + + return ( + +
+ Provision New Merchant Tenant +
+ Onboard a new merchant group. This registers their enterprise, defaults the order + serialization, and spawns the primary Administrator account. +
+
+ +
+
+ set('tenantname', v)} + /> + set('companyname', v)} + /> + {/* `maxLength` has no TextInput equivalent, so the 10-digit cap moves + into the handler — which also catches a pasted longer number, as + the attribute did not. */} + set('primarycontact', v.replace(/\D/g, '').slice(0, 10))} + startIcon={} + /> + set('primaryemail', v)} + startIcon={} + /> + set('locationname', v)} + startIcon={} + /> + set('categoryid', Number(v) || 2)} + options={BUSINESS_CATEGORIES} + /> +
+ +
+ + +
+ +
+ set('suburb', v)} /> + set('city', v)} /> + set('state', v)} /> + set('postcode', v)} /> +
+ +
+
+
+
+ ); +} + +// ── Store onboarding ────────────────────────────────────────────────────────── + +type StoreFormState = { + tenantid: number; locationname: string; address: string; suburb: string; city: string; + state: string; postcode: string; latitude: string; longitude: string; contactno: string; + email: string; opentime: string; closetime: string; deliverymins: number; deliveryradius: number; +}; + +function StoreForm({ + form, setForm, onSubmit, onAddressSelect, isPending, onBack, + canPickTenant, tenants, tenantsLoading, tenantNameDisplay, +}: { + form: StoreFormState; + setForm: React.Dispatch>; + onSubmit: (e: React.FormEvent) => void; + onAddressSelect: (r: AddressResult | null) => void; + isPending: boolean; + onBack?: () => void; + /** Standalone super-admin page picks the tenant; the embedded one cannot. */ + canPickTenant: boolean; + tenants: Array>; + tenantsLoading: boolean; + tenantNameDisplay: string; +}) { + const set = (k: K, v: StoreFormState[K]) => + setForm((f) => ({ ...f, [k]: v })); + + return ( + +
+
+ + Add Store Outlet Location + +
+ Commission a new store branch/hub under a tenant. This sets up store parameters, + delivery thresholds, and spawns a placeholder branch manager account. +
+
+
+ +
+ + }>Owner & Identity +
+ {canPickTenant ? ( + set('tenantid', Number(v) || form.tenantid)} + isLoading={tenantsLoading} + placeholder={tenantsLoading ? 'Loading tenants…' : 'Select tenant…'} + startIcon={} + options={tenants.map((t) => ({ + value: String(fnum(t.tenantid)), + label: `${fstr(t.tenantname)} (#${fstr(t.tenantid)})`, + }))} + /> + ) : ( + // Was a `readOnly` styled to look disabled — a control in + // the tab order that could be focused and never edited. It is a + // value, so it reads as one. +
+ Owner Tenant +
+ + {tenantsLoading ? 'Loading tenant info…' : tenantNameDisplay} +
+
+ )} + + set('locationname', v)} + startIcon={} + /> + set('email', v)} + startIcon={} + /> + set('contactno', v.replace(/\D/g, '').slice(0, 10))} + startIcon={} + /> +
+
+ + + }>Location & Address Details +
+ + +
+ +
+ set('suburb', v)} /> + set('city', v)} /> + set('state', v)} /> + set('postcode', v)} /> +
+
+ + + }>Logistics & Operational Hours +
+ {/* Open/close were free-text boxes holding "06:00:00" — nothing + stopped "6pm" or "25:00" reaching the backend. TimeInput parses + and constrains them. + `hasSeconds` is NOT optional here: it defaults to false, and + without it the control emits "06:00" where this payload has + always carried "06:00:00". That would have changed what + createlocation receives on every edit, silently. */} + { if (v) set('opentime', v); }} + /> + { if (v) set('closetime', v); }} + /> + set('deliveryradius', v ?? 0)} + min={0} + /> + set('deliverymins', v ?? 0)} + min={0} + /> +
+
+ +
+ {onBack &&
+
+
+ ); +} + +// ── Rider onboarding ────────────────────────────────────────────────────────── + +type RiderFormState = { + firstname: string; lastname: string; email: string; contactno: string; password: string; + locationid: number; shiftid: number; vehiclename: string; vehiclemodel: string; licensenumber: string; +}; + +function RiderForm({ + form, setForm, onSubmit, isPending, locations, shifts, shiftsLoading, +}: { + form: RiderFormState; + setForm: React.Dispatch>; + onSubmit: (e: React.FormEvent) => void; + isPending: boolean; + locations: Array>; + shifts: Array>; + shiftsLoading: boolean; +}) { + const set = (k: K, v: RiderFormState[K]) => + setForm((f) => ({ ...f, [k]: v })); + + return ( + +
+ + Onboard Delivery Rider + +
+ Onboarding riders is a multi-table database transaction. We register the user profile in + app_users, and dynamically generate SQL configuration queries to inject vehicle, shift, + and availability settings. +
+
+ +
+ + }>1. Base Profile Details +
+ set('firstname', v)} /> + set('lastname', v)} /> + set('email', v)} + startIcon={} + /> + set('contactno', v.replace(/\D/g, '').slice(0, 10))} + startIcon={} + /> +
+
+ + + }>2. Fleet & Shift configuration +
+ set('locationid', Number(v) || 0)} + placeholder="Select Hub location…" + options={locations.map((loc) => ({ value: String(fnum(loc.locationid)), label: fstr(loc.locationname) }))} + /> + set('shiftid', Number(v) || 0)} + isLoading={shiftsLoading} + placeholder={shiftsLoading ? 'Loading shifts…' : 'Select Shift timings…'} + options={shifts.map((sh) => ({ value: String(fnum(sh.shiftid)), label: fstr(sh.shiftname) }))} + /> + set('vehiclename', v)} /> + set('vehiclemodel', v)} /> +
+ set('licensenumber', v)} /> +
+
+
+ +
+
+
+
+ ); +} + +function RiderSuccessPanel({ + riderSuccess, copiedSql, onCopy, onAgain, +}: { + riderSuccess: { sql: string; userid: number | string }; + copiedSql: boolean; + onCopy: () => void; + onAgain: () => void; +}) { + return ( +
+ + Rider user created in app_users with ID: #{riderSuccess.userid}. + + +
+
+ + Administrative SQL Synchronization script + + {/* CodeBlock ships its own copy button, but this one reports "Copied!" + for two seconds and that feedback is the only confirmation the + operator gets before handing the script to a DBA — so it stays. */} +
+ + + + } + > + Please share this SQL script with the Database Administrator (DBA) or run it against the + Postgres instance to complete vehicle assignments, shifts, and active pool configuration. + +
+ +
+
); diff --git a/src/components/AdminCustomersView.tsx b/src/components/AdminCustomersView.tsx deleted file mode 100644 index 6f76abf..0000000 --- a/src/components/AdminCustomersView.tsx +++ /dev/null @@ -1,202 +0,0 @@ -/** - * @license - * SPDX-License-Identifier: Apache-2.0 - */ - -import React, { useState } from 'react'; -import { Users, Search, Phone, Mail, ChevronRight, Award, MapPin } from 'lucide-react'; -import { useFiestaTenantCustomers, useFiestaTenantLocations } from '../services/fiestaQueries'; -import { num as fnum, str as fstr, customerName, customerStoreId } from '../services/fiestaApi'; - -interface AdminCustomersViewProps { - tenantId: number; -} - -export default function AdminCustomersView({ tenantId }: AdminCustomersViewProps) { - const [customerSearch, setCustomerSearch] = useState(''); - - // Fetch all customers for the tenant by passing locationid: 0 - const customersQ = useFiestaTenantCustomers({ - tenantid: tenantId, - locationid: 0, - pagesize: 500, - }); - - const locationsQ = useFiestaTenantLocations(tenantId); - const locations = locationsQ.data ?? []; - - const getMergedCustomers = () => { - const rawCustomers = customersQ.data ?? []; - return rawCustomers.map((c: any) => { - const id = - fnum(c.customerid) || - fnum(c.customerheaderid) || - fnum(c.userid) || - fnum(c.id) || - null; - const spent = fnum(c.totalspent); - - // `locationid` is not a column on this response — the store link is - // aliased `tenantlocationid`. Matching on the wrong name meant every - // customer fell through to the invented store name 'Central'. - const locId = customerStoreId(c); - const loc = locations.find((l: any) => fnum(l.locationid) === locId); - const storeName = (loc ? fstr(loc.locationname) : fstr(c.locationname)).trim() || '—'; - - return { - id, - name: customerName(c) || 'Customer', - phone: fstr(c.contactno) || '—', - email: fstr(c.email), - address: fstr(c.address) || '—', - ordersCount: Number(c.orderscount) || 0, - totalSpent: spent > 0 ? `₹${spent.toLocaleString('en-IN')}` : '—', - storeName, - }; - }); - }; - - const customersList = getMergedCustomers().filter(c => - c.name.toLowerCase().includes(customerSearch.toLowerCase()) || - c.phone.includes(customerSearch) || - c.address.toLowerCase().includes(customerSearch.toLowerCase()) || - c.storeName.toLowerCase().includes(customerSearch.toLowerCase()) - ); - - // Helper for jewel-tone avatars - const toneFor = (str: string) => { - let hash = 0; - for (let i = 0; i < str.length; i++) hash = str.charCodeAt(i) + ((hash << 5) - hash); - const hues = [ - { soft: '#f3e8ff', fg: '#6b21a8' }, // purple - { soft: '#e0e7ff', fg: '#3730a3' }, // indigo - { soft: '#fce7f3', fg: '#9d174d' }, // pink - { soft: '#ffedd5', fg: '#9a3412' }, // orange - { soft: '#dcfce7', fg: '#166534' }, // green - { soft: '#cffafe', fg: '#155e75' }, // cyan - ]; - return hues[Math.abs(hash) % hues.length]; - }; - - const initialsOf = (name: string) => { - const p = name.split(' ').filter(Boolean); - return ((p[0]?.[0] || '') + (p.length > 1 ? p[p.length - 1][0] : '')).toUpperCase(); - }; - - const localityOf = (addr: string) => { - if (!addr) return ''; - const p = addr.split(','); - return p.length > 1 ? p[p.length - 2].trim() : p[0].trim(); - }; - - return ( -
-
-
-

- - Customers -

-
- -
- - setCustomerSearch(e.target.value)} - className="w-full pl-9 pr-3 py-2.5 border border-[#662582]/20 rounded-xl text-[13px] text-[#0f172a] placeholder:text-zinc-400 outline-none bg-white focus:border-[#662582] focus:ring-4 focus:ring-[#662582]/10 transition-all shadow-sm" - /> -
-
- - {customersQ.isLoading ? ( -
-
- - -
-

Loading Customers...

-
- ) : customersList.length === 0 ? ( -
- -

No customers found

-

- {customerSearch ? 'Nothing matches your search criteria. Try a different term.' : 'There are no customers registered in the tenant yet.'} -

- {customerSearch && ( - - )} -
- ) : ( -
- {customersList.map((c: any, idx: number) => { - const tone = toneFor(c.name || `c${idx}`); - const locality = localityOf(c.address); - return ( -
-
-
- - {initialsOf(c.name)} - -
-

{c.name}

- {locality && ( -

- {locality} -

- )} -
-
- {c.ordersCount > 5 && ( - - Loyal - - )} -
- -
-
- Orders - {c.ordersCount} -
-
- Gross Spend - {c.totalSpent} -
-
- -
-
-
- {c.phone} -
- {c.email && c.email !== '—' && ( -
-
- {c.email} -
- )} -
- -
- - {c.storeName} - -
-
- ); - })} -
- )} -
- ); -} diff --git a/src/components/DeliveriesView.tsx b/src/components/DeliveriesView.tsx index 19255a6..299c27f 100644 --- a/src/components/DeliveriesView.tsx +++ b/src/components/DeliveriesView.tsx @@ -14,7 +14,6 @@ */ import React, { useMemo, useState } from 'react'; -import { createPortal } from 'react-dom'; import { Truck, Clock, CheckCircle2, XCircle, Calendar, Sun, Sunset, Moon, Layers, UserCheck, MapPin, Phone, Package, Loader2, X, Bike, } from 'lucide-react'; @@ -22,9 +21,22 @@ import { useFiestaDeliverySummary, useFiestaDeliveries, useFiestaRiders, useFies import { FIESTA_TENANT_ID, FIESTA_APPLOCATION_ID, RIDER_MESSAGES, RiderNotReachableError, num as fnum, str as fstr, ymd, type Row } from '../services/fiestaApi'; import { shortTime } from '../services/fiestaMappers'; import AwaitingApi from './AwaitingApi'; +import { Button } from '@astryxdesign/core/Button'; +import { IconButton } from '@astryxdesign/core/IconButton'; +import { Dialog } from '@astryxdesign/core/Dialog'; +import { DateInput } from '@astryxdesign/core/DateInput'; +import type { ISODateString } from '@astryxdesign/core/utils'; +import { Selector } from '@astryxdesign/core/Selector'; +import { Heading } from '@astryxdesign/core/Heading'; +import { Spinner } from '@astryxdesign/core/Spinner'; +import { EmptyState } from '@astryxdesign/core/EmptyState'; +import { Card as ShellCard } from './consoleUi'; import { - GradientHeader, LiveStatus, KpiStrip, Pill, StatusChip, MetricPill, SearchPill, FilterBar, TH_STYLE, - DELIVERY_STATUS, statusColor, BRAND, BRAND_LIGHT, TEXT, TEXT_2, TEXT_3, BORDER, DIVIDER, SURFACE_ALT, tint, soft, edge, + Table, TableHeader, TableHeaderCell, TableBody, TableRow, TableCell, +} from '@astryxdesign/core/Table'; +import { + GradientHeader, LiveStatus, KpiStrip, Pill, StatusChip, MetricPill, SearchPill, FilterBar, + DELIVERY_STATUS, statusColor, BRAND, TEXT, TEXT_2, TEXT_3, BORDER, DIVIDER, SURFACE_ALT, tint, soft, edge, } from './consoleUi'; interface DeliveriesViewProps { searchQuery?: string; locationid?: number; tenantId?: number; applocationid?: number; date?: string; } @@ -144,9 +156,25 @@ export default function DeliveriesView({ searchQuery = '', locationid, tenantId ))}
- setFromdate(e.target.value)} className="rounded-full outline-none font-semibold" style={{ padding: '6px 12px', border: `1.5px solid ${edge('#f59e0b')}`, background: tint('#f59e0b'), color: '#b45309' }} /> + {/* Both were unlabelled ``; the min/max + cross-guard between them is preserved. */} + { if (v) setFromdate(v); }} + /> → - setTodate(e.target.value)} className="rounded-full outline-none font-semibold" style={{ padding: '6px 12px', border: `1.5px solid ${edge('#f59e0b')}`, background: tint('#f59e0b'), color: '#b45309' }} /> + { if (v) setTodate(v); }} + />
@@ -166,17 +194,17 @@ export default function DeliveriesView({ searchQuery = '', locationid, tenantId {/* Table */} -
+
- - - {['S.No', 'Tenant', 'Order ID', 'Pickup', 'Delivery', 'Rider', 'KMS', 'Amount', 'Status', 'Notes', 'Action'].map((h, i) => ())} - - +
{h}
+ + {['S.No', 'Tenant', 'Order ID', 'Pickup', 'Delivery', 'Rider', 'KMS', 'Amount', 'Status', 'Notes', 'Action'].map((h, i) => ({h}))} + + {deliveriesQ.isLoading ? ( - +
) : rows.length === 0 ? ( - + } title={`No ${status} deliveries in this wave`} description="Try another status, wave, or date." /> ) : ( rows.map((r, i) => { const st = fstr(r.orderstatus).toLowerCase(); @@ -192,64 +220,65 @@ export default function DeliveriesView({ searchQuery = '', locationid, tenantId const dropAddr = fstr(r.deliveryaddress) || fstr(r.deliverylocation) || fstr(r.deliverysuburb); const notes = fstr(r.ordernotes) || fstr(r.notes); return ( - (e.currentTarget.style.background = SURFACE_ALT)} onMouseLeave={(e) => (e.currentTarget.style.background = 'transparent')}> - - - - - - - - - - - - + + +
+
+
+ ); }) )} - -
Loading deliveries…
No {status} deliveries in this wave. Try another status, wave, or date.
{i + 1} + + {i + 1} +

{tenant || '—'}

{fstr(r.tenantcity) &&

{fstr(r.tenantcity)}

} -
+ +

{fstr(r.orderid) || `DLV-${fstr(r.deliveryid)}`}

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

-
+ +

{pickupName || pickupAddr || '—'}

{pickupName ? pickupAddr : ''}

-
+ +

{dropName || dropAddr || '—'}

{dropName ? dropAddr : ''}

-
+ + {rider ? ( {rider} ) : Unassigned} - + +
{kms ? kms.toFixed(1) : '—'} {actualKms > 0 && {actualKms.toFixed(1)}}
-
+ +
{charge > 0 && ₹{charge.toLocaleString('en-IN')}} {amt > 0 && ₹{amt.toLocaleString('en-IN')}} {charge === 0 && amt === 0 && —}
-
+ + +

{notes || '—'}

-
- -
+ +
{rows.length} {status} · {BATCHES.find((b) => b.id === batch)?.label} wave
-
+ {detailRow && setDetailRow(null)} />} @@ -329,24 +358,27 @@ function RiderActions({ row, riders }: { row: Row; riders: Row[] }) { {canChange ? (
- -
+ + /> ) : (

@@ -358,25 +390,27 @@ function RiderActions({ row, riders }: { row: Row; riders: Row[] }) { {canNotify && (

- - + /> + {/* Destructive: this tells the rider app to drop the delivery. It was + styled red but rendered as an ordinary button, and its only + explanation was a `title` — which a disabled control never shows. */} + +
)} @@ -401,15 +435,21 @@ function DeliveryDetailModal({ row, riders, onClose }: { row: Row; riders: Row[] { label: 'Picked', field: 'pickuptime' }, { label: 'Delivered', field: 'deliverytime' }, ]; - // Portal to so `fixed inset-0` is viewport-relative even when an ancestor - // in the view tree is transformed/blurred (otherwise the panel collapses). - return createPortal( -
{ if (e.target === e.currentTarget) onClose(); }}> -
-
-
-

{fstr(row.orderid) || `Delivery ${fstr(row.deliveryid)}`}

- + // PHASE 6 — the portal and the `width: min(32rem, 92vw)` are gone with the + // overlay. Both existed for one reason: `fixed inset-0` resolves against the + // nearest transformed/filtered ancestor rather than the viewport, and this + // page sits inside several, so the panel used to collapse. Dialog renders in + // the browser's top layer, which no ancestor can contain. + return ( + { if (!open) onClose(); }} width={512} maxHeight="90vh" purpose="info"> +
+
+ + + {fstr(row.orderid) || `Delivery ${fstr(row.deliveryid)}`} + + + } onClick={onClose} />
@@ -451,11 +491,10 @@ function DeliveryDetailModal({ row, riders, onClose }: { row: Row; riders: Row[]
-
- +
+
-
, - document.body, +
); } diff --git a/src/components/DeliveryReportsView.tsx b/src/components/DeliveryReportsView.tsx index 3b96ca8..d22f364 100644 --- a/src/components/DeliveryReportsView.tsx +++ b/src/components/DeliveryReportsView.tsx @@ -25,6 +25,15 @@ import { } from './consoleUi'; import SalesRevenueReport from './SalesRevenueReport'; +import { DateInput } from '@astryxdesign/core/DateInput'; +import type { ISODateString } from '@astryxdesign/core/utils'; +import { SegmentedControl, SegmentedControlItem } from '@astryxdesign/core/SegmentedControl'; +import { Badge } from '@astryxdesign/core/Badge'; +import { EmptyState } from '@astryxdesign/core/EmptyState'; +import { Card as ShellCard } from './consoleUi'; +import { + Table, TableHeader, TableHeaderCell, TableBody, TableRow, TableCell, +} from '@astryxdesign/core/Table'; type ReportTab = 'orders-summary' | 'riders-summary'; const TABS: Array<{ key: ReportTab; label: string; icon: typeof TrendingUp }> = [ @@ -55,19 +64,17 @@ export default function DeliveryReportsView({ searchQuery = '', tenantId = FIEST return (
-
- - + + +
{activeTab === 'overview' ? ( @@ -93,10 +100,28 @@ export default function DeliveryReportsView({ searchQuery = '', tenantId = FIEST { setFromdate(p.from); setTodate(p.to); }}>{p.label} ))}
+ {/* Both were unlabelled `` — a screen reader + announced "date" twice with no way to tell the ends of the range + apart. The min/max cross-guard is preserved. */}
- setFromdate(e.target.value)} className="rounded-full outline-none font-semibold w-28 sm:w-auto" style={{ padding: '6px 12px', border: `1.5px solid ${edge('#f59e0b')}`, background: tint('#f59e0b'), color: '#b45309' }} /> + { if (v) setFromdate(v); }} + /> → - setTodate(e.target.value)} className="rounded-full outline-none font-semibold w-28 sm:w-auto" style={{ padding: '6px 12px', border: `1.5px solid ${edge('#f59e0b')}`, background: tint('#f59e0b'), color: '#b45309' }} /> + { if (v) setTodate(v); }} + />
@@ -202,8 +227,21 @@ function OrdersSummaryReport({ tenantId, locationid, fromdate, todate }: { tenan return (
- {/* Revenue */} - {/* Orders */} -
-
+
- - - - - - - - - - - - - - {q.isLoading || revenueQ.isLoading ? - : rows.length === 0 ? - : rows.map((r, i) => { +
#OutletRevenueAOVOrdersFulfillmentCancel Rate
No outlet data available.
+ + + # + Outlet + Revenue + AOV + Orders + Fulfillment + Cancel Rate + + + + {q.isLoading || revenueQ.isLoading ? ( + +
+
+ ) : rows.length === 0 ? ( + + } title="No outlet data available" description="Nothing recorded for this period." /> + + ) : rows.map((r, i) => { const rowRev = locationRevenueMap.get(fnum(r.locationid)) ?? 0; const rowAov = r.total > 0 ? rowRev / r.total : 0; const rowFulfill = r.total > 0 ? (r.delivered / r.total) * 100 : 0; const rowCancel = r.total > 0 ? (r.cancelled / r.total) * 100 : 0; return ( -
- - - - - - - - + + + {/* Over 10% was rose, otherwise grey — that is a + threshold, so it reads as error vs neutral. */} +
+ 10 ? 'error' : 'neutral'} label={`${rowCancel.toFixed(1)}%`} /> +
+
+ ); })} - -
{i + 1}{r.locationname || `Location ${r.locationid}`}₹{rowRev.toLocaleString('en-IN')}₹{Math.round(rowAov).toLocaleString('en-IN')}{r.total.toLocaleString('en-IN')} + + {i + 1} + {r.locationname || `Location ${r.locationid}`} +
₹{rowRev.toLocaleString('en-IN')}
+
₹{Math.round(rowAov).toLocaleString('en-IN')}
+
{r.total.toLocaleString('en-IN')}
+
{rowFulfill.toFixed(0)}%
-
- 10 ? 'bg-rose-100 text-rose-700' : 'bg-slate-100 text-slate-600'}`}> - {rowCancel.toFixed(1)}% - -
+ +
{rows.length > 0 && (
@@ -368,7 +419,7 @@ function OrdersSummaryReport({ tenantId, locationid, fromdate, todate }: { tenan {totals.pending} pending
)} -
+ {/* Insights Bento */}
diff --git a/src/components/DispatchHubView.tsx b/src/components/DispatchHubView.tsx index 13f3141..ba44770 100644 --- a/src/components/DispatchHubView.tsx +++ b/src/components/DispatchHubView.tsx @@ -4,7 +4,7 @@ */ import React, { useState, useMemo } from 'react'; -import { Route, ShoppingBag, Truck, MapPin, Calendar, ChevronLeft, ChevronRight, Store, Users } from 'lucide-react'; +import { Route, ShoppingBag, Truck, MapPin, ChevronLeft, ChevronRight, Store, Users } from 'lucide-react'; import DispatchView from './DispatchView'; import OrdersView from './OrdersView'; import DeliveriesView from './DeliveriesView'; @@ -12,14 +12,17 @@ import { useFiestaDeliveries, useFiestaTenantLocations } from '../services/fiest import { FIESTA_TENANT_ID, ymd, num as fnum, str as fstr } from '../services/fiestaApi'; import type { Row } from '../services/fiestaApi'; import './DispatchView.css'; // For #hdr and date-chip styles +import { IconButton } from '@astryxdesign/core/IconButton'; +import { DateInput } from '@astryxdesign/core/DateInput'; +import type { ISODateString } from '@astryxdesign/core/utils'; +import { Badge } from '@astryxdesign/core/Badge'; +import { SegmentedControl, SegmentedControlItem } from '@astryxdesign/core/SegmentedControl'; interface DispatchHubViewProps { locationid?: number; tenantId?: number; } -const WEEKDAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']; -const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']; export default function DispatchHubView({ locationid, tenantId = FIESTA_TENANT_ID }: DispatchHubViewProps) { const [activeTab, setActiveTab] = useState<'map' | 'orders' | 'deliveries'>('map'); @@ -55,8 +58,6 @@ export default function DispatchHubView({ locationid, tenantId = FIESTA_TENANT_I }, [locationsQ.data, locationid]); const isToday = date === ymd(today); - const dateObj = new Date(`${date}T00:00:00`); - const prettyDate = `${WEEKDAYS[dateObj.getDay()]}, ${dateObj.getDate()} ${MONTHS[dateObj.getMonth()]}`; const shiftDate = (delta: number) => { const d = new Date(`${date}T00:00:00`); d.setDate(d.getDate() + delta); @@ -103,86 +104,75 @@ export default function DispatchHubView({ locationid, tenantId = FIESTA_TENANT_I )} -
- -
- -
- - Date {isToday && TODAY} - - {prettyDate} -
- setDate(e.target.value)} - className="absolute inset-0 opacity-0 cursor-pointer w-full h-full" - aria-label="Pick date" + {/* PHASE 6 — the date stepper. + The picker used to be an `opacity-0` stretched + over a custom display, so the same date was rendered twice: once + as `prettyDate` for the eye and once inside an invisible control. + DateInput formats and announces it itself, so the duplicate + display goes and the TODAY marker becomes a Badge beside it. + The two chevrons had only a `title` — no accessible name. */} +
+ } + onClick={() => shiftDate(-1)} + /> +
+ { if (v) setDate(v); }} />
- + {isToday && } + } + isDisabled={isToday} + onClick={() => shiftDate(1)} + />
{/* ── Unified Tab Row ── */}
-
- - - - - + } /> + } /> + } /> +
{/* Sub-tabs for Map View — only meaningful on the Map tab; they used to stay visible (and inert) on Orders and Deliveries. */} {activeTab === 'map' && ( -
- - - + } /> + } /> +
)}
diff --git a/src/components/InventoryView.tsx b/src/components/InventoryView.tsx index 6c22151..0ed7244 100644 --- a/src/components/InventoryView.tsx +++ b/src/components/InventoryView.tsx @@ -4,39 +4,20 @@ */ import React, { useState, useEffect, useMemo } from 'react'; -import { useNavigate } from 'react-router-dom'; import { Layers, Search, Plus, - RefreshCw, - AlertTriangle, - TrendingUp, - Sparkles, Check, Package, - ChevronRight, - TrendingDown, - Trash2, - PackageCheck, ShieldCheck, Tag, UploadCloud, - FileSpreadsheet, - Palette, - Info, X, - Server, - ChevronDown, - ChevronUp, CheckCircle, - ShoppingCart, Inbox, Store, - Activity, - Award, Filter, - Box, ArrowLeft } from 'lucide-react'; import { ProductMatrixItem } from '../types'; @@ -55,7 +36,21 @@ import { useStoreCatalogue, usePriceEverywhere, isPublishedItem } from '../servi import { CardCarousel, ProductGallery } from './CatalogueGallery'; import BulkCartDrawer from './BulkCartDrawer'; import AwaitingApi from './AwaitingApi'; -import { SlideDrawer, Skeleton, TH_STYLE, SURFACE_ALT, TEXT, TEXT_2, TEXT_3, BORDER, BRAND, tint, edge, StatusChip } from './consoleUi'; +import { SlideDrawer, Skeleton, Card as ShellCard } from './consoleUi'; +import { Button } from '@astryxdesign/core/Button'; +import { IconButton } from '@astryxdesign/core/IconButton'; +import { TextInput } from '@astryxdesign/core/TextInput'; +import { NumberInput } from '@astryxdesign/core/NumberInput'; +import { Selector } from '@astryxdesign/core/Selector'; +import { DateInput } from '@astryxdesign/core/DateInput'; +import type { ISODateString } from '@astryxdesign/core/utils'; +import { CheckboxInput } from '@astryxdesign/core/CheckboxInput'; +import { Badge } from '@astryxdesign/core/Badge'; +import { Heading } from '@astryxdesign/core/Heading'; +import { EmptyState } from '@astryxdesign/core/EmptyState'; +import { + Table, TableHeader, TableHeaderCell, TableBody, TableRow, TableCell, +} from '@astryxdesign/core/Table'; import FMCGHoverOverlay from './FMCGHoverOverlay'; import CatalogueDetailPanel from './CatalogueDetailPanel'; import { useNotify } from './Toasts'; @@ -86,7 +81,6 @@ export default function InventoryView({ const [searchTerm, setSearchTerm] = useState(''); const [showCatalogueModal, setShowCatalogueModal] = useState(false); const [showOfflineSales, setShowOfflineSales] = useState(false); - const navigate = useNavigate(); // ── Live stock across every outlet (Fiesta) ─────────────────────────────── // This page is the admin's command surface. The GLOBAL CATALOG is the deduped @@ -432,48 +426,46 @@ export default function InventoryView({
{/* Small card metrics grid */}
+ {/* PHASE 6 — four `bg-white` slabs onto Astryx Cards. The icon chips + keep their hues: those are category identity, not surface. */} {/* Card 1: Total SKUs */} -
+
- Total SKUs + Total SKUs
-

- {products.length} -

-

Master catalogue

+

{products.length}

+

Master catalogue

-
+ {/* Card 2: Synced Outlets */} -
+
- Active Outlets + Active Outlets
-

- {locations.length} -

-

Synced locations

+

{locations.length}

+

Synced locations

-
+ {/* Card 3: Total On-Hand Volume */} -
+
- Total Stock + Total Stock
-

+

{storesStock.reduce((total, store) => { return total + (store.rows || []).reduce((subTotal, r) => { const inv = stockRowToInventory(r, store.locationname); @@ -481,25 +473,25 @@ export default function InventoryView({ }, 0); }, 0).toLocaleString('en-IN')}

-

Units on hand

+

Units on hand

-
+ {/* Card 4: Catalog Health */} -
+
- Catalogue Sync Ratio + Catalogue Sync Ratio
-

+

{products.length > 0 ? `${Math.round((products.filter(p => p.verified).length / products.length) * 100)}%` : '100%'}

-

Active Portfolio

+

Active Portfolio

-
+
)} @@ -509,47 +501,47 @@ export default function InventoryView({
{isLocalSidebarOpen && (
-
-
-

+ +
+

Filter Product

-

+

Categories

+ {/* PHASE 6 — the category filter was an `appearance-none` box + with a floating tick icon: a real checkbox stripped of its + focus ring and its label association, so it was invisible to + keyboard focus and announced nothing. CheckboxInput's label + IS the accessible name. */}
{categories.map((cat) => ( - +
+ toggleCategory(cat)} + /> +
))}
{(selectedCategories.length > 0 || localSearch) && ( - +
+
)}
-

+
)} @@ -572,54 +564,53 @@ export default function InventoryView({
- - -
- + +
+ setLocalSearch(e.target.value)} - className="w-full h-full pl-8 pr-3 py-1.5 bg-white border border-slate-200 rounded-lg text-[10px] text-slate-800 placeholder-slate-400 focus:outline-none focus:border-[#662582] transition-all shadow-sm font-medium" + onChange={(v) => setLocalSearch(v)} + startIcon={} + hasClear /> - - {localSearch && ( - - )}
- -
+ - + />
@@ -640,7 +631,16 @@ export default function InventoryView({ ))}
) : filteredProducts.length === 0 ? ( -
No catalogue products match your selection.
+ } + title="No catalogue products match your selection" + description={localSearch || selectedCategories.length ? 'Try a different search or category.' : 'Import products to build the catalogue.'} + actions={ + localSearch || selectedCategories.length + ? + - + />
) : ( - + /> )}
@@ -783,13 +786,15 @@ export default function InventoryView({
- + />
@@ -806,172 +811,160 @@ export default function InventoryView({
+ {/* PHASE 6 — both filters were `appearance-none` selects with a + hand-drawn chevron layered on top and no label; the store one had + a decorative icon absolutely positioned over the text. Selector + supplies the chevron, the label and the keyboard behaviour. */}
-
- - -
- -
+ onChange={(v) => setRequestStoreFilter(v || 'All Stores')} + startIcon={} + options={['All Stores', ...requestingStores]} + />
-
- -
- -
+ onChange={(v) => setRequestStatusFilter(v || 'All Statuses')} + options={[ + { value: 'All Statuses', label: 'All Statuses' }, + { value: 'pending', label: 'Pending' }, + { value: 'approved', label: 'Approved' }, + { value: 'rejected', label: 'Rejected' }, + { value: 'received', label: 'Received' }, + ]} + />
- -
- setRequestDate(e.target.value)} - className="px-3 py-2 border border-slate-200 rounded-xl shadow-sm focus:outline-none focus:ring-2 focus:ring-[#662582] focus:border-[#662582] text-sm font-semibold text-slate-700 bg-white hover:border-[#662582]/40 hover:shadow-md transition-all" + +
+ { if (v) setRequestDate(v); }} />
{flattenedRequests.length === 0 ? ( -
-
- -
-

No Pending Requests

-

- You're all caught up! All store stock requests have been processed and there is currently no pending action required. -

-
+ + } + title="No Pending Requests" + description="You're all caught up! All store stock requests have been processed and there is currently no pending action required." + /> + ) : ( -
+
- - - +
+ + {['Requested At', 'Store', 'Product', 'Qty', 'Status', 'Resolved At', ''].map((h, i) => ( - + {h} ))} - - - + + + {flattenedRequests.map((req, idx) => { - const isApproved = req.pickData.status === 'Approved'; - const isRejected = req.pickData.status === 'Rejected'; const isPending = req.pickData.status === 'Pending'; - const isCancelled = req.pickData.status === 'Cancelled'; - const isReceived = req.pickData.status === 'Received'; - + // The five-way class ladder collapses to a Badge variant. + // 'Received' was indigo and 'Cancelled' slate; neither has a + // Badge equivalent, so they map to the nearest semantic + // meaning (info / neutral) rather than an invented hue. + const statusVariant = + req.pickData.status === 'Received' ? 'info' : + req.pickData.status === 'Approved' ? 'success' : + req.pickData.status === 'Rejected' ? 'error' : + req.pickData.status === 'Cancelled' ? 'neutral' : + 'warning'; + return ( - setSelectedRequest(req)} > - - - - - - - - + + ); })} - -
{h}
-
- + +
+ {req.pickData.requestedAt ? new Date(req.pickData.requestedAt).toLocaleDateString('en-IN', { day: 'numeric', month: 'short' }) : '—'} - + {req.pickData.requestedAt ? new Date(req.pickData.requestedAt).toLocaleTimeString('en-IN', { hour: '2-digit', minute: '2-digit' }) : ''}
-
-
- + + + + {req.locationname} -
-
+ + +
-
- {req.product?.name} +
+
-

{req.product?.name}

-

{req.product?.sku}

+

{req.product?.name}

+

{req.product?.sku}

-
- - {req.pickData.qty || '—'} - - - - - {req.pickData.status || '—'} - - - + + + {req.pickData.qty || '—'} + + + + + + {req.pickData.resolvedAt ? new Date(req.pickData.resolvedAt).toLocaleString('en-IN', { dateStyle: 'medium', timeStyle: 'short' }) : '—'} - + + {isPending ? ( -
- - + // The `opacity-0 group-hover:opacity-100` reveal is + // gone: it hid the only two actions on the row from + // anyone not using a mouse, and a keyboard user + // could tab to an invisible button. +
+
) : ( - + Processed )} -
+ +
-
+ )}
) : activeTab === 'global_catalogue' ? ( @@ -1070,23 +1063,29 @@ export default function InventoryView({
e.stopPropagation()}> - -
- ₹ - setImportPrice(e.target.value)} - onClick={(e) => e.stopPropagation()} - placeholder="e.g. 50" - className="w-full pl-7 pr-3 py-2.5 border border-slate-200 rounded-xl text-sm font-bold text-slate-900 focus:outline-none focus:ring-2 focus:ring-[#662582]/20 focus:border-[#662582] bg-white transition-all shadow-xs" - /> -
+ {/* The ₹ was an absolutely-positioned span overlapping the + field's padding; NumberInput carries it as a real prefix + so it cannot drift out of alignment. */} + setImportPrice(v == null ? '' : String(v))} + step={0.01} + min={0} + />
- -

+ /> +

Applies to {allLocationIds.length === 1 ? 'your store' : `all ${allLocationIds.length} stores`}

{storeCat.has(selectedAdminProduct.id) && ( - +
@@ -1201,24 +1192,21 @@ export default function InventoryView({ {isPending ? (
- - + />
) : ( -
- +
+ This request has been {req.pickData.status.toLowerCase()}.
diff --git a/src/components/OperationsView.tsx b/src/components/OperationsView.tsx deleted file mode 100644 index e7a25f1..0000000 --- a/src/components/OperationsView.tsx +++ /dev/null @@ -1,789 +0,0 @@ -/** - * @license - * SPDX-License-Identifier: Apache-2.0 - */ - -import React, { useState, useEffect } from 'react'; -import { - Layers, - AlertTriangle, - CheckCircle, - HelpCircle, - ArrowUpRight, - TrendingUp, - Sliders, - DollarSign, - PackageCheck, - PlusSquare, - ArrowRightLeft, - XCircle, - FolderSync, - UploadCloud, - Download, - X, -} from 'lucide-react'; -import { InventoryItem } from '../types'; -import { - useFiestaStockStatement, - useFiestaTenantLocations, -} from '../services/fiestaQueries'; -import { FIESTA_TENANT_ID, FIESTA_PRIMARY_LOCATION_ID, num as fnum, str as fstr } from '../services/fiestaApi'; -import { stockRowToProduct, stockRowToInventory } from '../services/fiestaMappers'; -import AwaitingApi from './AwaitingApi'; -import { useNotify } from './Toasts'; - -interface OperationsViewProps { - searchQuery: string; - isCoimbatoreView: boolean; -} - -export default function OperationsView({ searchQuery, isCoimbatoreView }: OperationsViewProps) { - // PHASE 6 — replaces alert(). A native alert freezes the whole browser - // until dismissed; a toast does not, and errors stay until read. - const { notifyOk, notifyFail } = useNotify(); - - // Sub-tabs state - const [activeSubTab, setActiveSubTab] = useState<'inventory' | 'catalogue' | 'import'>('inventory'); - - // ── Live operations data (Fiesta) ───────────────────────────────────────── - const locationsQ = useFiestaTenantLocations(FIESTA_TENANT_ID); - const primaryLocation = - (locationsQ.data ?? []).find((l) => Number(l.locationid) === FIESTA_PRIMARY_LOCATION_ID) || - (locationsQ.data ?? [])[0]; - const locationId = primaryLocation ? Number(primaryLocation.locationid) : FIESTA_PRIMARY_LOCATION_ID; - const locationName = fstr(primaryLocation?.locationname) || 'Primary Outlet'; - - const stockQ = useFiestaStockStatement({ - tenantid: FIESTA_TENANT_ID, - locationid: locationId, - keyword: '', - pageno: 1, - pagesize: 100, - }); - // Total inventory value = Σ closing × unit cost across the live stock statement. - const inventoryValue = (stockQ.data ?? []).reduce( - (sum, r) => sum + fnum(r.closing) * fnum(r.productcost), - 0, - ); - - // Dynamic state arrays for interaction (seeded from live data once it loads). - const [inventoryList, setInventoryList] = useState([]); - const [productList, setProductList] = useState[]>([]); - - useEffect(() => { - if (stockQ.data) { - setProductList(stockQ.data.map(stockRowToProduct)); - setInventoryList(stockQ.data.map((r) => stockRowToInventory(r, locationName))); - } - }, [stockQ.data, locationName]); - - // Modal open states - const [showAddSkuModal, setShowAddSkuModal] = useState(false); - const [showTransferModal, setShowTransferModal] = useState(false); - - // Form states - const [newSku, setNewSku] = useState({ - sku: '', - name: '', - warehouse: '', - stockLevel: 0, - maxCapacity: 1000, - status: 'Optimal' as 'Optimal' | 'Low Stock' | 'Critical', - region: 'CBE-NORTH' as 'CBE-NORTH' | 'CBE-SOUTH' | 'CBE-EAST' | 'CBE-WEST' | 'TIRUPPUR' - }); - - const [transferData, setTransferData] = useState({ - sku: '', - origin: '', - destination: '', - quantity: 100 - }); - - // Filter lists based on global Search query - const filteredInventory = inventoryList.filter(item => - item.sku.toLowerCase().includes(searchQuery.toLowerCase()) || - item.name.toLowerCase().includes(searchQuery.toLowerCase()) || - item.warehouse.toLowerCase().includes(searchQuery.toLowerCase()) - ); - - const filteredCatalogue = productList.filter(prod => - prod.name.toLowerCase().includes(searchQuery.toLowerCase()) || - prod.sku.toLowerCase().includes(searchQuery.toLowerCase()) || - prod.category.toLowerCase().includes(searchQuery.toLowerCase()) - ); - - // Form submit handles - const handleAddSku = (e: React.FormEvent) => { - e.preventDefault(); - if (!newSku.sku || !newSku.name || !newSku.warehouse) { - notifyFail('Fill out all metadata fields before committing.'); - return; - } - - const itemToAdd: InventoryItem = { - sku: newSku.sku, - name: newSku.name, - warehouse: newSku.warehouse, - stockLevel: Number(newSku.stockLevel), - maxCapacity: Number(newSku.maxCapacity), - status: newSku.stockLevel < 20 ? 'Critical' : newSku.stockLevel < 100 ? 'Low Stock' : 'Optimal', - region: newSku.region - }; - - setInventoryList([itemToAdd, ...inventoryList]); - setShowAddSkuModal(false); - - // reset form - setNewSku({ - sku: '', - name: '', - warehouse: '', - stockLevel: 0, - maxCapacity: 1000, - status: 'Optimal', - region: 'CBE-NORTH' - }); - }; - - const handleExecuteTransfer = (e: React.FormEvent) => { - e.preventDefault(); - if (!transferData.sku || !transferData.origin || !transferData.destination) { - notifyFail('Configure the transfer coordinates and SKU first.'); - return; - } - - // Attempt to update origin stock levels - setInventoryList(prev => - prev.map(item => { - if (item.sku === transferData.sku) { - const newLevel = Math.max(0, item.stockLevel - transferData.quantity); - return { - ...item, - stockLevel: newLevel, - status: newLevel < 20 ? 'Critical' : newLevel < 100 ? 'Low Stock' : 'Optimal' - }; - } - return item; - }) - ); - - notifyOk(`Transfer of ${transferData.quantity} units committed successfully. Tracking ID: TRF-${Math.floor(Math.random() * 900000 + 100000)}`); - setShowTransferModal(false); - }; - - const handleToggleProductExposure = (id: string) => { - setProductList(prev => - prev.map(p => p.id === id ? { ...p, verified: !p.verified } : p) - ); - }; - - return ( -
- - {/* Tab Navigation header */} -
-
-

- Operations Hub{isCoimbatoreView && ': Coimbatore'} -

-

- Global product assortment, inventory levels, warehouse tracking, and data sync tools. -

-
- - {/* Dynamic Nav Sub-Tabs */} - -
- - {/* Dynamic Display Area based on tabs selection */} - {activeSubTab === 'inventory' && ( -
- {/* Top Key Operational Indicators Grid */} -
-
-
-

- Total Inventory Value -

-

- ₹{inventoryValue.toLocaleString('en-IN', { maximumFractionDigits: 0 })} -

-

- - {productList.length} SKUs · {locationName} -

-
-
- -
-
- -
-
-

- Low Stock Alerts -

-

- {inventoryList.filter(item => item.status !== 'Optimal').length} SKUs -

-

Across local regional warehouses

-
-
- -
-
- -
-
-

- Fulfillment Health -

- -
-
- -
-
-
- - {/* Sub Grid splits: Product state table (3 cols) and Action sidebar panels (1 col) */} -
- - {/* Left table container */} -
-
-
-

- Product Inventory Levels -

- -
- Auto-synced -
-
- -
- - - - - - - - - - - - {filteredInventory.length === 0 ? ( - - - - ) : ( - filteredInventory.map((item, idx) => { - const percentage = (item.stockLevel / item.maxCapacity) * 100; - return ( - - - - - - - - ); - }) - )} - -
Product SKUWarehouseStock LevelStatusActions
- No matching items identified. Try another query or reload. -
-

{item.sku}

-

{item.name}

-
- - {item.region} - - {item.warehouse} - -

{item.stockLevel.toLocaleString()} units

-
-
-
-
- - {item.status} - - -
- -
-
-
-
- -
- Displaying 1-{filteredInventory.length} of {inventoryList.length} global items - Active Ledger Nodes Online -
-
- - {/* Right sidebar quick tasks */} -
- {/* Forecast module visual details */} -
-
- - Forecast Efficiency - - -
- - {/* Embedded SVG graphic visual */} -
- -
-
- - {/* Quick Actions buttons block */} -
- - Quick Actions Ledger - - -
- - - - - - - -
-
-
-
-
- )} - - {activeSubTab === 'catalogue' && ( -
-
-
-

- Master Assortment Catalogue -

-

- Global inventory master list and exposure levels across {productList.length.toLocaleString('en-IN')} nodes. -

-
- - -
- -
- - - - - - - - - - - - {filteredCatalogue.map((prod) => ( - - - - - - - - ))} - -
Product DetailsCategory segmentVerification statusStore exposureExposure toggle
-
- {prod.name} -
-
-

{prod.name}

-

SKU: {prod.sku}

-
-
{prod.category} - {prod.verified ? ( - - Verified Portfolio - - ) : ( - - Under Inspection - - )} - {prod.exposure} - -
-
-
- )} - - {activeSubTab === 'import' && ( -
- {/* Upload panel zone */} -
{ - const fileRef = prompt('Enter CSV filename representation path:'); - if (fileRef) { - notifyOk('Uploaded. Metadata schema verified.'); - } - }} - className="bg-white border-2 border-dashed border-zinc-300 rounded-xl p-xl flex flex-col items-center justify-center text-center cursor-pointer hover:bg-[#faf5ff]/30 hover:border-[#662582] transition-all duration-200" - > -
- -
- -

- Upload Inventory CSV -

- -

- Drag and drop your .csv, .xlsx or .xml sheets here to automatically update global stock balances or master portfolios schemas. -

- -
- - -
-
- - {/* Validation indicators checker logs split */} -
-
- - Interactive Schema Validator - - - -
-
-
- )} - - {/* MODAL 1: ADD SKU MODAL */} - {showAddSkuModal && ( -
-
-
-

- Add Item SKU to Balance Ledger -

- -
- -
-
-
- - setNewSku({ ...newSku, sku: e.target.value })} - className="w-full border border-[#e2e8f0] rounded-lg p-sm bg-[#f1f5f9] focus:bg-white outline-none focus:ring-1 focus:ring-[#662582]" - required - /> -
- -
- - setNewSku({ ...newSku, name: e.target.value })} - className="w-full border border-[#e2e8f0] rounded-lg p-sm bg-[#f1f5f9] focus:bg-white outline-none focus:ring-1 focus:ring-[#662582]" - required - /> -
-
- -
- - setNewSku({ ...newSku, warehouse: e.target.value })} - className="w-full border border-[#e2e8f0] rounded-lg p-sm bg-[#f1f5f9] focus:bg-white outline-none focus:ring-1 focus:ring-[#662582]" - required - /> -
- -
-
- - setNewSku({ ...newSku, stockLevel: Number(e.target.value) })} - className="w-full border border-[#e2e8f0] rounded-lg p-sm bg-[#f1f5f9] focus:bg-white outline-none focus:ring-1 focus:ring-[#662582]" - required - /> -
- -
- - -
-
- -
- - -
-
-
-
- )} - - {/* MODAL 2: TRANSFER STOCK MODAL */} - {showTransferModal && ( -
-
-
-

- Request Node Stock Transfer -

- -
- -
-
- - setTransferData({ ...transferData, sku: e.target.value })} - className="w-full border border-[#e2e8f0] rounded-lg p-sm bg-[#f1f5f9] font-mono focus:bg-white outline-none focus:ring-1 focus:ring-[#662582]" - required - /> -
- -
- - setTransferData({ ...transferData, origin: e.target.value })} - className="w-full border border-[#e2e8f0] rounded-lg p-sm bg-[#f1f5f9] focus:bg-white outline-none text-zinc-800 font-medium focus:ring-1 focus:ring-[#662582]" - required - /> -
- -
- - setTransferData({ ...transferData, destination: e.target.value })} - className="w-full border border-[#e2e8f0] rounded-lg p-sm bg-[#f1f5f9] focus:bg-white outline-none focus:ring-1 focus:ring-[#662582]" - required - /> -
- -
- - setTransferData({ ...transferData, quantity: Number(e.target.value) })} - className="w-full border border-[#e2e8f0] rounded-lg p-sm bg-[#f1f5f9] focus:bg-white outline-none focus:ring-1 focus:ring-[#662582]" - required - /> -
- -
- - -
-
-
-
- )} -
- ); -} diff --git a/src/components/OrdersDeliveriesView.tsx b/src/components/OrdersDeliveriesView.tsx index 24447e5..c5013ab 100644 --- a/src/components/OrdersDeliveriesView.tsx +++ b/src/components/OrdersDeliveriesView.tsx @@ -13,10 +13,9 @@ */ import React, { useMemo, useState, useRef, useEffect } from 'react'; -import { createPortal } from 'react-dom'; import { ShoppingBag, Clock, CheckCircle2, XCircle, MapPin, Phone, - Package, Loader2, X, Download, ChevronLeft, ChevronRight, Truck, + Package, Loader2, X, Download, ChevronLeft, ChevronRight, UserCheck, } from 'lucide-react'; import { @@ -28,10 +27,21 @@ import { import { FIESTA_TENANT_ID, num as fnum, str as fstr, ymd, type Row } from '../services/fiestaApi'; import { shortTime } from '../services/fiestaMappers'; import { - GradientHeader, LiveStatus, KpiStrip, Pill, StatusChip, SearchPill, FilterBar, TH_STYLE, - ORDER_STATUS, statusColor, BRAND, BRAND_LIGHT, TEXT, TEXT_2, TEXT_3, BORDER, DIVIDER, SURFACE_ALT, - tint, soft, edge, ring, + GradientHeader, LiveStatus, KpiStrip, Pill, StatusChip, SearchPill, FilterBar, Card as ShellCard, + ORDER_STATUS, statusColor, BRAND, TEXT, TEXT_2, TEXT_3, BORDER, DIVIDER, SURFACE_ALT, + tint, soft, edge, } from './consoleUi'; +import { Button } from '@astryxdesign/core/Button'; +import { IconButton } from '@astryxdesign/core/IconButton'; +import { Dialog } from '@astryxdesign/core/Dialog'; +import { DateInput } from '@astryxdesign/core/DateInput'; +import type { ISODateString } from '@astryxdesign/core/utils'; +import { Heading } from '@astryxdesign/core/Heading'; +import { Spinner } from '@astryxdesign/core/Spinner'; +import { EmptyState } from '@astryxdesign/core/EmptyState'; +import { + Table, TableHeader, TableHeaderCell, TableBody, TableRow, TableCell, +} from '@astryxdesign/core/Table'; interface OrdersDeliveriesViewProps { searchQuery?: string; @@ -187,10 +197,28 @@ export default function OrdersDeliveriesView({ } right={
+ {/* PHASE 6 — two unlabelled ``. A screen reader + announced only "date" for both, so which end of the range you + were on was invisible. The min/max cross-guard is preserved. */}
- setFromdate(e.target.value)} 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); }} + /> — - setTodate(e.target.value)} 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); }} + />
@@ -220,90 +248,92 @@ export default function OrdersDeliveriesView({
- + />
{/* Table */} -
+
- - - +
+ + {['#', 'Order', 'Branch', 'Pickup', 'Drop', 'Qty', 'COD', 'Amount', 'Status', ''].map((h, i) => ( - + {h} ))} - - - + + + {allOrdersQ.isLoading ? ( - + +
+
) : pageRows.length === 0 ? ( - + + } + title={`No ${status} orders found`} + description="Nothing matches this date range or search." + /> + ) : ( pageRows.map((r, i) => { const st = fstr(r.orderstatus).toLowerCase(); const cod = fnum(r.collectionamt); 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')} - > - - - - - - - - - - - + + {fnum(r.quantity) || '—'} + 0 ? undefined : { color: TEXT_3 }}>{cod > 0 ? `₹${cod.toLocaleString('en-IN')}` : '—'} + 0 ? undefined : { color: TEXT_3 }}>{amount > 0 ? `₹${amount.toLocaleString('en-IN')}` : '—'} + + +
+
+
+ ); }) )} - -
{h}
- - Loading orders… - -
- No {status} orders found for this date range or search. -
{(pageno - 1) * PAGE_SIZE + i + 1} -

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

+ // Keyed on orderheaderid first. `orderid` is not unique in + // this tenant's data — the same defect fixed in OrdersView, + // which reads the same rows through the same endpoint. + + {(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.locationname)}

} -
-

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

+ + +

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

{fstr(r.pickupsuburb) || fstr(r.pickupaddress)}

-
-

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

+ + +

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

{fstr(r.deliverysuburb) || fstr(r.deliveryaddress)}

-
{fnum(r.quantity) || '—'} 0 ? TEXT : TEXT_3 }}>{cod > 0 ? `₹${cod.toLocaleString('en-IN')}` : '—'} 0 ? TEXT : TEXT_3 }}>{amount > 0 ? `₹${amount.toLocaleString('en-IN')}` : '—'} - -
+ +
{/* Totals footer */} @@ -322,16 +352,29 @@ export default function OrdersDeliveriesView({ Page {pageno} · {pageRows.length} of {rows.length} shown + {/* Same reasoning as OrdersView: this list is cursor-shaped, so + Astryx's Pagination (which wants a known page count) would have to + invent a total it does not have. */}
- setPageno((p) => Math.max(1, p - 1))}> - Prev - - setPageno((p) => p + 1)}> - Next - +
- + {detailOrder && setDetailOrder(null)} />} @@ -350,16 +393,6 @@ function TotalChip({ label, value, color }: { label: string; value: string; colo ); } -function PagerBtn({ children, disabled, onClick }: { children: React.ReactNode; disabled?: boolean; onClick: () => void }) { - return ( - - ); -} - // ── Order details modal ──────────────────────────────────────────────────────── function OrderDetailModal({ order, onClose }: { order: Row; onClose: () => void }) { const orderheaderid = order.orderheaderid ?? order.orderid; @@ -387,26 +420,23 @@ function OrderDetailModal({ order, onClose }: { order: Row; onClose: () => void { label: 'Delivered', field: 'deliverytime' }, ]; - return createPortal( -
{ if (e.target === e.currentTarget) onClose(); }} - > -
- {/* Brand accent bar */} -
- + // PHASE 6 — the portal and the `width: min(32rem, 92vw)` are gone with the + // overlay. Both existed because a `fixed inset-0` resolves against the nearest + // transformed ancestor rather than the viewport, and this page has several, so + // the panel used to collapse. Dialog renders in the browser's top layer, which + // no ancestor can contain. + return ( + { if (!open) onClose(); }} width={512} maxHeight="90vh" purpose="info"> +
{/* Modal header */} -
-

- - Order {fstr(order.orderid) || `#${fstr(order.orderheaderid)}`} -

- +
+ + + + Order {fstr(order.orderid) || `#${fstr(order.orderheaderid)}`} + + + } onClick={onClose} />
{/* Body */} @@ -498,14 +528,10 @@ function OrderDetailModal({ order, onClose }: { order: Row; onClose: () => void
{/* Footer */} -
- +
+
-
, - document.body, +
); } diff --git a/src/components/OrdersView.tsx b/src/components/OrdersView.tsx index c652e68..4551fd7 100644 --- a/src/components/OrdersView.tsx +++ b/src/components/OrdersView.tsx @@ -13,7 +13,7 @@ import React, { useMemo, useState, useRef, useEffect } from 'react'; import { createPortal } from 'react-dom'; -import { ShoppingBag, Clock, CheckCircle2, XCircle, Calendar, ChevronLeft, ChevronRight, Package, MapPin, Phone, X, Loader2, Download, UserCheck, ClipboardList, ArrowLeft } from 'lucide-react'; +import { ShoppingBag, Clock, CheckCircle2, XCircle, Calendar, ChevronLeft, ChevronRight, Package, MapPin, Phone, X, Download, UserCheck, ClipboardList, ArrowLeft } from 'lucide-react'; import { useFiestaOrderSummary, useFiestaOrders, useFiestaOrderDetails, useFiestaRiders, useFiestaAssignRider, useFiestaNotifyRider } from '../services/fiestaQueries'; import { FIESTA_TENANT_ID, FIESTA_APPLOCATION_ID, RIDER_MESSAGES, RiderNotReachableError, @@ -21,9 +21,26 @@ import { } from '../services/fiestaApi'; import { shortTime } from '../services/fiestaMappers'; import { - GradientHeader, LiveStatus, KpiStrip, Pill, StatusChip, MetricPill, SearchPill, FilterBar, TH_STYLE, - ORDER_STATUS, statusColor, BRAND, BRAND_LIGHT, TEXT, TEXT_2, TEXT_3, BORDER, SURFACE_ALT, tint, soft, edge, ring, + KpiStrip, Pill, StatusChip, SearchPill, FilterBar, Card as ShellCard, + ORDER_STATUS, statusColor, BRAND, TEXT_2, TEXT_3, BORDER, SURFACE_ALT, tint, soft, edge, ring, } from './consoleUi'; +import { Button } from '@astryxdesign/core/Button'; +import { IconButton } from '@astryxdesign/core/IconButton'; +import { Dialog } from '@astryxdesign/core/Dialog'; +import { DateInput } from '@astryxdesign/core/DateInput'; +// DateInput's value is a branded `YYYY-MM-DD` template-literal type, while our +// dates are plain strings out of `ymd()`. Importing the real type keeps the cast +// honest and localised instead of reaching for `as any` at four call sites. +import type { ISODateString } from '@astryxdesign/core/utils'; +import { Selector } from '@astryxdesign/core/Selector'; +import { SegmentedControl, SegmentedControlItem } from '@astryxdesign/core/SegmentedControl'; +import { CheckboxInput } from '@astryxdesign/core/CheckboxInput'; +import { Heading } from '@astryxdesign/core/Heading'; +import { Spinner } from '@astryxdesign/core/Spinner'; +import { EmptyState } from '@astryxdesign/core/EmptyState'; +import { + Table, TableHeader, TableHeaderCell, TableBody, TableRow, TableCell, +} from '@astryxdesign/core/Table'; interface OrdersViewProps { searchQuery?: string; @@ -357,12 +374,28 @@ export default function OrdersView({ ))}
+ {/* PHASE 6 — two bare `` become DateInputs. Both + were unlabelled, so a screen reader announced only "date"; and the + native picker was the only way in, with no keyboard entry format + hint. The min/max cross-guard between the two is preserved. */}
- setScope({ from: e.target.value })} - className="rounded-full outline-none font-semibold" style={{ padding: '6px 12px', border: `1px solid ${BORDER}`, background: '#fff', color: TEXT_2 }} /> + { if (v) setScope({ from: v }); }} + /> → - setScope({ to: e.target.value })} - className="rounded-full outline-none font-semibold" style={{ padding: '6px 12px', border: `1px solid ${BORDER}`, background: '#fff', color: TEXT_2 }} /> + { if (v) setScope({ to: v }); }} + />
@@ -386,27 +419,28 @@ export default function OrdersView({
{branches.length > 1 && ( - +
+ { setBranch(Number(v) || 0); setPageno(1); }} + options={[ + { value: '0', label: 'All branches' }, + ...branches.map((b) => ({ value: String(b.id), label: b.name })), + ]} + /> +
)}
- + />
@@ -417,131 +451,170 @@ export default function OrdersView({ {selected.size} selected -
- - + + +
- - + /> {selected.size > 0 && ( - + , + // PHASE 6 — the label used to be collapsed to `max-w-0` and revealed + // only on hover, so the button read as an unlabelled icon until you + // pointed at it and was invisible to anyone not using a mouse. The + // Astryx Button carries the count in its label instead. +
+
, document.body, )} - {showSelected && - createPortal( - toggleRow(k)} - onClose={() => setShowSelected(false)} - />, - document.body, - )} + {/* No createPortal any more — Dialog renders in the browser's top layer, + which is already outside every ancestor's containing block. */} + {showSelected && ( + toggleRow(k)} + onClose={() => setShowSelected(false)} + /> + )} ); } @@ -623,75 +713,114 @@ function SelectedOrdersPage({ onClose: () => void; }) { return ( -
- {/* Sticky page header with the assign controls */} -
-
- -
- -
-

Selected Orders

-

{rows.length} order{rows.length === 1 ? '' : 's'} ready to assign

+ // PHASE 6 — was a `fixed inset-0` page takeover with no Escape and no focus + // trap: tabbing from it walked straight back into the orders board + // underneath. Dialog's fullscreen variant gives both, and keeps the same + // full-bleed shape. + // + // What it does NOT give is focus restore — verified in Chrome: closing this + // leaves focus on , not on the control that opened it. consoleUi's + // SlideDrawer restores it because it does so by hand. That gap is Astryx's, + // it applies to every Dialog in the app, and it is a missing gain rather + // than a regression (the old overlay managed no focus at all), so it is not + // patched here one screen at a time. + { if (!open) onClose(); }} variant="fullscreen" purpose="info"> +
+ {/* Sticky page header with the assign controls */} +
+
+
-
-
- -
-
-
- {assignMsg &&
{assignMsg}
} - {rows.length === 0 ? ( -
- No orders selected. -
- ) : ( -
-
- - {['#', 'Order', 'Pickup', 'Drop', 'Status', ''].map((h, i) => )} - - {rows.map((r, i) => { - const st = fstr(r.orderstatus).toLowerCase(); - return ( - - - - - - - - - ); - })} - -
{h}
{i + 1} -

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

-

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

-
-

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

-

{fstr(r.pickupsuburb) || fstr(r.pickupaddress)}

-
-

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

-

{fstr(r.deliverysuburb) || fstr(r.deliveryaddress)}

-
- -
-
-
- )} +
+ {assignMsg &&
{assignMsg}
} + {rows.length === 0 ? ( + + } + /> + + ) : ( + +
+ + + + {['#', 'Order', 'Pickup', 'Drop', 'Status', ''].map((h, i) => {h})} + + + + {rows.map((r, i) => { + const st = fstr(r.orderstatus).toLowerCase(); + return ( + + {i + 1} + +

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

+

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

+
+ +

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

+

{fstr(r.pickupsuburb) || fstr(r.pickupaddress)}

+
+ +

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

+

{fstr(r.deliverysuburb) || fstr(r.deliveryaddress)}

+
+ + +
+ } + onClick={() => onRemove(rowKey(r))} + /> +
+
+
+ ); + })} +
+
+
+
+ )} +
-
+ ); } @@ -704,16 +833,6 @@ function TotalChip({ label, value, color }: { label: string; value: string; colo ); } -function PagerBtn({ children, disabled, onClick }: { children: React.ReactNode; disabled?: boolean; onClick: () => void }) { - return ( - - ); -} - // ── Order details modal ───────────────────────────────────────────────────────── function OrderDetailModal({ order, onClose }: { order: Row; onClose: () => void }) { const orderheaderid = order.orderheaderid ?? order.orderid; @@ -726,39 +845,42 @@ function OrderDetailModal({ order, onClose }: { order: Row; onClose: () => void const st = fstr(order.orderstatus).toLowerCase(); const total = fnum(order.deliveryamt) || fnum(order.orderamount); - // Portal to so the overlay escapes any transformed / blurred / overflow - // ancestor in the view tree — otherwise `fixed inset-0` resolves against that - // ancestor (not the viewport) and the panel collapses to a sliver. The explicit - // viewport-relative width is a belt-and-suspenders so sizing never depends on - // percentage resolution against a broken containing block. - return createPortal( -
{ if (e.target === e.currentTarget) onClose(); }}> -
-
-
-

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

- + // PHASE 6 — the hand-rolled overlay is gone, and with it the portal and the + // `width: min(32rem, 92vw)` workaround. Both existed for one reason: a + // `fixed inset-0` resolves against the nearest transformed/filtered ancestor + // rather than the viewport, and this page has several, so the panel used to + // collapse to a sliver. Dialog renders in the browser's top layer, which no + // ancestor can contain — so the escape hatches are no longer needed. + return ( + { if (!open) onClose(); }} width={512} maxHeight="90vh" purpose="info"> +
+
+ + + Order {fstr(order.orderid) || `#${fstr(order.orderheaderid)}`} + + + } onClick={onClose} />
{shortTime(order.orderdate || order.deliverydate)}
-
-
{fstr(order.deliverycustomer) || 'Customer'}
+
+
{fstr(order.deliverycustomer) || 'Customer'}
{fstr(order.deliverycontactno) &&
{fstr(order.deliverycontactno)}
}
{fstr(order.deliveryaddress) || fstr(order.deliverysuburb) || 'Address unavailable'}
Order Items -
- {detailsQ.isLoading &&
Loading line items…
} +
+ {detailsQ.isLoading &&
} {!detailsQ.isLoading && lines.length === 0 &&
No line items returned for this order.
} {lines.map((item, idx) => (
-

{item.name}

Qty: {item.quantity} × ₹{item.price}

- ₹{item.lineTotal.toLocaleString('en-IN')} +

{item.name}

Qty: {item.quantity} × ₹{item.price}

+ ₹{item.lineTotal.toLocaleString('en-IN')}
))} {total > 0 && ( @@ -769,13 +891,10 @@ function OrderDetailModal({ order, onClose }: { order: Row; onClose: () => void
-
- +
+
-
, - document.body, +
); } - -const BRAND_LIGHT_LOCAL = '#9255AB'; diff --git a/src/components/PosStaffPanel.tsx b/src/components/PosStaffPanel.tsx index 94cd06c..f781de6 100644 --- a/src/components/PosStaffPanel.tsx +++ b/src/components/PosStaffPanel.tsx @@ -26,6 +26,13 @@ import { Selector } from '@astryxdesign/core/Selector'; import { Button } from '@astryxdesign/core/Button'; import { Dialog, DialogHeader } from '@astryxdesign/core/Dialog'; import { TimeInput } from '@astryxdesign/core/TimeInput'; +import { IconButton } from '@astryxdesign/core/IconButton'; +import { ToggleButton } from '@astryxdesign/core/ToggleButton'; +import { Badge } from '@astryxdesign/core/Badge'; +import { Banner } from '@astryxdesign/core/Banner'; +import { + Table, TableHeader, TableHeaderCell, TableBody, TableRow, TableCell, +} from '@astryxdesign/core/Table'; import { AlertTriangle, Check, @@ -60,6 +67,7 @@ import { BORDER, BRAND, Card, + Card as ShellCard, DIVIDER, Pill, StatusChip, @@ -67,7 +75,6 @@ import { TEXT, TEXT_2, TEXT_3, - TH_STYLE, edge, tint, } from './consoleUi'; @@ -443,89 +450,99 @@ export default function PosStaffPanel({
) : ( -
+
- - - - - - - - - - - - - - - + {/* whiteSpace: the old
NameRoleMobileShiftUsernamePINPasswordStatus 
carried `whitespace-nowrap`; Astryx's + Table does not, and without it the till username breaks + mid-token ("supervisor.1169@po / s.nearle.in"). The wrapper + above already scrolls horizontally. */} +
+ + + Name + Role + Mobile + Shift + Username + PIN + Password + Status + Row actions + + + {staff.map((s) => { const inactive = (s.status || '').toLowerCase() === 'inactive'; + const who = s.full_name || `#${s.user_id}`; return ( - - - - - - - - - - - + + ); })} - -
{s.full_name || `#${s.user_id}`} + + {who} + - - {s.contactno || 'not set'} - - {s.shift_name ? `${s.shift_name} · ${s.shift_start}–${s.shift_end}` : '—'} - {s.authname || '—'}{s.pin || '—'} - {s.has_password ? ( - - Set - - ) : ( - - Not set - - )} - + + + + {s.contactno || 'not set'} + + + + + {s.shift_name ? `${s.shift_name} · ${s.shift_start}–${s.shift_end}` : '—'} + + + {s.authname || '—'} + {s.pin || '—'} + + {/* Was two hand-tinted spans. "Set"/"Not set" is a state, + so it reads as success vs warning. */} + {s.has_password + ? } /> + : } />} + + - + +
{inactive ? ( - + /> ) : ( <> - openEdit(s)}> + {/* Names, not bare verbs. A staff table repeats + "Edit / Reset password / Deactivate" on every + row, so without the person's name a screen + reader hears the same three words N times with + no idea whose account is about to change. */} + openEdit(s)}> - setConfirmReset(s)}> + setConfirmReset(s)}> - setConfirmOff(s)}> + setConfirmOff(s)}> )}
-
+ +
-
+ )} {rowError && ( @@ -595,37 +612,28 @@ export default function PosStaffPanel({ anyone signed in at a counter with it will be refused on their next sign-in.

- - + /> +
)} {/* One-time credentials. The password is not recoverable after this. */} {issued && ( -
-
setIssued(null)} /> -
-
- -

Account created

-
-

- This password is shown once and cannot be looked up again. Copy it now. -

+ // PHASE 6 — the last hand-rolled overlay on this page, and the one that + // mattered most: it shows a password that exists nowhere else. It now + // goes through the same Modal as edit / reset / deactivate, so it gains + // Escape and a focus trap like the rest. + setIssued(null)} title="Account created"> +
+ + It is shown once and cannot be looked up again. +
@@ -644,24 +652,19 @@ export default function PosStaffPanel({ )}
- - + />
-
+
)} {/* Uses the same Modal as edit and reset. It was a hand-rolled copy of @@ -674,26 +677,19 @@ export default function PosStaffPanel({ deleted, because past bills carry their name.

- - + /> +
)} @@ -702,6 +698,10 @@ export default function PosStaffPanel({ } const WEEKDAY_LABELS = ['M', 'T', 'W', 'T', 'F', 'S', 'S']; +/** Full names for the day toggles' accessible names. The single letters are + * ambiguous by design — two T's and two S's — so "T" alone tells a screen + * reader user nothing about which day they just switched on. */ +const WEEKDAY_NAMES = ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday']; /** * The shift windows an outlet runs. @@ -827,20 +827,15 @@ function ShiftManager({ Days {WEEKDAY_LABELS.map((d, i) => ( - + label={WEEKDAY_NAMES[i]} + isIconOnly + icon={{d}} + size="sm" + isPressed={days[i]} + onPressedChange={() => toggleDay(i)} + /> ))}
@@ -851,21 +846,13 @@ function ShiftManager({ {error &&

{error}

}
- - + /> +
)} @@ -893,7 +880,12 @@ function ShiftManager({ : WEEKDAY_LABELS.filter((_, i) => s.weekdays?.[i] === '1').join(' ') || '—'}
- + />
); })} @@ -941,15 +924,20 @@ function IconBtn({ onClick: () => void; children: React.ReactNode; }) { + // PHASE 6 — on Astryx IconButton. Like Modal below, converting this one + // helper converts every call site (edit / reset password / deactivate) at + // once. `title` becomes `label`: it was the only thing naming these buttons, + // and a title attribute is not an accessible name — three icon-only controls + // per row announced as nothing at all. return ( - + style={{ color }} + /> ); } diff --git a/src/components/PosView.tsx b/src/components/PosView.tsx index 8725712..fdb52b2 100644 --- a/src/components/PosView.tsx +++ b/src/components/PosView.tsx @@ -1,9 +1,20 @@ import React, { useState, useMemo, useEffect, useCallback, useRef } from 'react'; -import { Search, Plus, Minus, Trash2, Receipt, X, Tag, QrCode, CreditCard, PieChart, Barcode, ShoppingCart, User, RefreshCcw, Save, Download, ChevronRight, Zap, Wallet, Smartphone, AlertCircle } from 'lucide-react'; +import { Search, Plus, Trash2, Receipt, X, Tag, QrCode, CreditCard, PieChart, Barcode, ShoppingCart, User, RefreshCcw, Save, Download, ChevronRight, Zap, Wallet, Smartphone, AlertCircle } from 'lucide-react'; import { useFiestaStockStatement, FIESTA_TENANT_ID } from '../services/fiestaQueries'; import { num as fnum, str as fstr, type Row } from '../services/fiestaApi'; import { useNotify } from './Toasts'; import { Dialog } from '@astryxdesign/core/Dialog'; +import { Button } from '@astryxdesign/core/Button'; +import { IconButton } from '@astryxdesign/core/IconButton'; +import { TextInput } from '@astryxdesign/core/TextInput'; +import { NumberInput } from '@astryxdesign/core/NumberInput'; +import { Badge } from '@astryxdesign/core/Badge'; +import { Spinner } from '@astryxdesign/core/Spinner'; +import { EmptyState } from '@astryxdesign/core/EmptyState'; +import { SegmentedControl, SegmentedControlItem } from '@astryxdesign/core/SegmentedControl'; +import { + Table, TableHeader, TableHeaderCell, TableBody, TableRow, TableCell, +} from '@astryxdesign/core/Table'; /** * PHASE 6 — the one modal shell for this screen. @@ -407,6 +418,14 @@ export default function PosView({ locationid, tenantId = FIESTA_TENANT_ID }: Pos .hide-spin-button { -moz-appearance: textfield; } .pos-scan-glow { box-shadow: 0 0 0 3px rgba(16,185,129,0.15), 0 1px 3px rgba(0,0,0,0.08); } .pos-scan-glow-red { box-shadow: 0 0 0 3px rgba(239,68,68,0.15), 0 1px 3px rgba(0,0,0,0.08); } + /* The scan field sits inside its own glowing frame, so the input's own + chrome is removed and its type size restored. A cashier aims a + scanner at this from arm's length — the size is the affordance. */ + .pos-scan-field input { + font-size: 20px; font-weight: 700; background: transparent; + border: none; box-shadow: none; height: auto; padding-left: 0; + } + .pos-scan-field input::placeholder { font-weight: 400; } @keyframes scan-pulse { 0%,100% { opacity:1; } 50% { opacity:0.4; } } .scan-pulse { animation: scan-pulse 1.8s ease-in-out infinite; } `} @@ -427,43 +446,34 @@ export default function PosView({ locationid, tenantId = FIESTA_TENANT_ID }: Pos )}
+ {/* PHASE 6 — the till's action bar. Refund mode used to restyle every + button inline (white-on-red); that state now reads from the Refund + button's own variant plus the banner beside the title, so the other + four stop changing colour for a mode they do not control. */}
- - - - - + /> + {/* The parked count was a floating dot pinned to the button corner, + outside its label — nothing announced it. */} +
+
+
@@ -473,21 +483,26 @@ export default function PosView({ locationid, tenantId = FIESTA_TENANT_ID }: Pos
- setSearch(e.target.value)} - onKeyDown={handleSearchKeyDown} - className="flex-1 bg-transparent border-none outline-none font-bold text-xl text-slate-800 placeholder:font-normal placeholder:text-slate-400" - autoFocus - /> - {search && ( - - )} + {/* The scan field keeps its oversized type deliberately — an + operator aims a scanner at this from arm's length and the size + is the affordance, so `pos-scan-field` restores the 20px bold + face that TextInput's `lg` does not reach. What it gains is a + real label (it had none at all) and a clear button that is now + part of the control rather than a bare +
+ } + title="Cart is empty" + description="Scan a barcode or browse products to add items" + actions={
) : (
- - - - - - - - - - - - +
ProductQtyRateDisc.Amount
+ + + Product + Qty + Rate + Disc. + Amount + Row actions + + + {cartWithPromos.map((item, idx) => ( - - - - - - - - + + ))} - -
+
{idx + 1}
- {item.isRefund && ( - - Refund - - )} - {item.name} + {item.isRefund && } + {item.name}
-
{item.sku}
+
{item.sku}
-
-
- -
- updateCartQty(item.id, parseFloat(e.target.value) || 0)} - className="w-12 text-center text-sm font-bold text-slate-900 bg-transparent border-none outline-none hide-spin-button" - /> - {item.unit} -
- + + + {/* The −/+ pair plus a bare number box is one NumberInput + now: it brings its own steppers, so the two icon-only + buttons (neither of which had an accessible name) are + gone, and `max` finally expresses the stock ceiling that + only the + button used to enforce. */} +
+ updateCartQty(item.id, v ?? 0)} + step={item.unit === 'kg' ? 0.001 : 1} + min={0} + max={item.maxQty} + /> + {item.unit}
-
- ₹{item.price.toFixed(2)} - /{item.unit} - + + +
+ ₹{item.price.toFixed(2)} + /{item.unit} +
+
+ {item.discountPerItem > 0 || item.hasBogo ? (
{item.discountPerItem > 0 && ( @@ -600,60 +598,66 @@ export default function PosView({ locationid, tenantId = FIESTA_TENANT_ID }: Pos -₹{(item.promoDiscount || 0).toFixed(2)} )} -
+
{item.discountPerItem > 0 && ( - - {item.couponCode || 'MANUAL'} - - - )} - {item.hasBogo && ( - - BOGO - + <> + + } + onClick={() => removeCoupon(item.id)} + /> + )} + {item.hasBogo && }
) : ( - — + — )} -
- {item.isRefund ? '-' : ''}₹ - {( - item.price * item.qty - - item.discountPerItem * item.qty - - (item.promoDiscount || 0) - ).toFixed(2)} - -
- - + />
-
+ +
)}
@@ -683,21 +687,26 @@ export default function PosView({ locationid, tenantId = FIESTA_TENANT_ID }: Pos
{customer.name || customer.phone} - + } + onClick={() => setCustomer(null)} + />
{customer.points} pts
) : ( - + /> )} @@ -761,14 +770,19 @@ export default function PosView({ locationid, tenantId = FIESTA_TENANT_ID }: Pos )} -
- + /> + } onClick={() => setShowManualSearch(false)} />
- setSearch(e.target.value)} - autoFocus - className="w-full px-4 py-2.5 bg-slate-50 border border-slate-200 rounded-xl text-sm font-medium text-slate-800 outline-none focus:border-indigo-400 focus:ring-2 focus:ring-indigo-400/20 transition-all" + onChange={(v) => setSearch(v)} + startIcon={} + hasClear + hasAutoFocus />
@@ -810,6 +824,10 @@ export default function PosView({ locationid, tenantId = FIESTA_TENANT_ID }: Pos p.sku.toLowerCase().includes(search.toLowerCase()) ) .map((product) => ( + // Stays a native button: it is a full-width result row whose + // whole surface is the hit target, which Button's fixed + // padding and centred label cannot express. It already had a + // disabled state and a text label, so nothing is lost.
- + } onClick={closeCustomerPrompt} />

{isNewCustomer ? 'New Customer' : 'Attach Customer'}

{isNewCustomer ? "Please enter the customer's name." : 'Enter mobile number to retrieve loyalty profile.'}

- setCustomerPhoneInput(e.target.value)} - onKeyDown={(e) => e.key === 'Enter' && !isNewCustomer && checkCustomerPhone()} - disabled={isNewCustomer} - className={`w-full px-4 py-3 bg-slate-50 border border-slate-200 rounded-xl font-bold text-slate-800 outline-none focus:border-blue-400 focus:ring-2 focus:ring-blue-400/20 transition-all ${isNewCustomer ? 'opacity-50 mb-3' : 'mb-4'}`} - autoFocus={!isNewCustomer} - /> + {/* Was `type="tel"` with no filtering, so letters reached a field + the Find button gates on `.length >= 10` — "abcdefghij" opened + a lookup. Astryx has no tel type; digits-only in the handler is + the stricter fix anyway. */} +
+ setCustomerPhoneInput(v.replace(/\D/g, '').slice(0, 10))} + onEnter={() => { if (!isNewCustomer) checkCustomerPhone(); }} + isDisabled={isNewCustomer} + hasAutoFocus={!isNewCustomer} + /> +
{isNewCustomer && ( - setCustomerNameInput(e.target.value)} - onKeyDown={(e) => e.key === 'Enter' && saveNewCustomer()} - className="w-full px-4 py-3 bg-slate-50 border border-slate-200 rounded-xl font-bold text-slate-800 outline-none focus:border-blue-400 focus:ring-2 focus:ring-blue-400/20 transition-all mb-4" - autoFocus - /> +
+ setCustomerNameInput(v)} + onEnter={saveNewCustomer} + hasAutoFocus + /> +
)} {isNewCustomer ? ( - + /> ) : ( - + /> )} @@ -898,16 +916,11 @@ export default function PosView({ locationid, tenantId = FIESTA_TENANT_ID }: Pos

Parked Sales

- + } onClick={() => setShowParkedPrompt(false)} />
{parkedCarts.length === 0 ? ( -
- -

No parked sales

-
+ } title="No parked sales" /> ) : ( parkedCarts.map((pc) => (
@@ -917,12 +930,7 @@ export default function PosView({ locationid, tenantId = FIESTA_TENANT_ID }: Pos {new Date(pc.time).toLocaleTimeString()} · {pc.cart.length} items · {pc.customer ? pc.customer.phone : 'Guest'}

- +
)) )} @@ -938,47 +946,28 @@ export default function PosView({ locationid, tenantId = FIESTA_TENANT_ID }: Pos
- + } onClick={() => setDiscountPromptItem(null)} />

Apply Discount

Enter a manual discount or attach a coupon code.

-
- - setDiscountAmountInput(e.target.value)} - onKeyDown={(e) => e.key === 'Enter' && applyDiscountToItem()} - className="w-full px-4 py-3 bg-slate-50 border border-slate-200 rounded-xl font-bold text-slate-800 outline-none focus:border-emerald-400 focus:ring-2 focus:ring-emerald-400/20 transition-all" - autoFocus - /> -
-
- - setCouponCodeInput(e.target.value.toUpperCase())} - onKeyDown={(e) => e.key === 'Enter' && applyDiscountToItem()} - className="w-full px-4 py-3 bg-slate-50 border border-slate-200 rounded-xl font-bold text-slate-800 uppercase outline-none focus:border-emerald-400 focus:ring-2 focus:ring-emerald-400/20 transition-all" - /> -
+ setDiscountAmountInput(v == null ? '' : String(v))} + min={0} + step={0.01} + /> + setCouponCodeInput(v.toUpperCase())} + onEnter={applyDiscountToItem} + />
- + + } onClick={() => setShowPayment(false)} />
{/* Payment Method Tabs */}

Payment Method

-
+ {/* SegmentedControl, not three buttons and not SelectableCard. + This is a single-select, and as plain buttons the active + method lived only in border colour. SelectableCard was tried + first and rejected: verified in Chrome, it renders a + focusable div carrying only `aria-label` — no role, no + aria-checked — and it drops a `role`/`aria-checked` you pass + it, so the choice stayed silent. SegmentedControl emits a + real radiogroup with arrow-key navigation. */} + setPayMethod(v as any)} + > {(grandTotal >= 0 ? ['cash', 'upi', 'card'] : ['cash', 'store_credit']).map((m) => { const icons: Record = { cash: , @@ -1014,38 +1014,22 @@ export default function PosView({ locationid, tenantId = FIESTA_TENANT_ID }: Pos const labels: Record = { cash: 'Cash', upi: 'UPI', card: 'Card', store_credit: 'Store Credit', }; - return ( - - ); + return ; })} -
+
{/* Cash Payment */} {payMethod === 'cash' && grandTotal >= 0 && (
-
- - setCashReceived(e.target.value)} - className="w-full px-4 py-3 bg-slate-50 border-2 border-slate-200 rounded-xl font-black text-2xl text-slate-900 outline-none focus:border-emerald-400 focus:ring-2 focus:ring-emerald-400/20 transition-all text-right" - placeholder="0.00" - autoFocus +
+ setCashReceived(v == null ? '' : String(v))} + min={0} + step={0.01} + hasAutoFocus />
{/* Quick denominations */} @@ -1056,24 +1040,20 @@ export default function PosView({ locationid, tenantId = FIESTA_TENANT_ID }: Pos .filter((d) => d >= grandTotal * 0.5) .slice(0, 6) .map((d) => ( - + /> ))} - + />
{cashReceived && parseFloat(cashReceived) >= grandTotal && ( @@ -1105,12 +1085,12 @@ export default function PosView({ locationid, tenantId = FIESTA_TENANT_ID }: Pos

Attach a customer to issue Store Credit

- + />
)}
@@ -1136,6 +1116,8 @@ 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. */} + } onClick={resetPos} />
@@ -1205,15 +1185,8 @@ export default function PosView({ locationid, tenantId = FIESTA_TENANT_ID }: Pos
- - +
)} @@ -1225,9 +1198,7 @@ export default function PosView({ locationid, tenantId = FIESTA_TENANT_ID }: Pos

Daily Sales Report

- + } onClick={() => setShowReport(false)} />
diff --git a/src/components/SettingsView.tsx b/src/components/SettingsView.tsx index de3150a..4dd24a9 100644 --- a/src/components/SettingsView.tsx +++ b/src/components/SettingsView.tsx @@ -7,15 +7,12 @@ import React, { useEffect, useMemo, useRef, useState } from 'react'; import { Building2, Store, - Truck, - CreditCard, - SlidersHorizontal, Users, MapPin, Phone, Mail, Plus, - Bike + X } from 'lucide-react'; import { useFiestaAllTenants, useFiestaTenantLocations, useFiestaUpdateLocation, useFiestaDeleteLocation } from '../services/fiestaQueries'; import { useAppRoles } from '../services/queries'; @@ -26,9 +23,47 @@ import AdminConsole from './AdminConsole'; import { ConfirmModal } from './ConfirmModal'; import type { AuthUser } from '../services/auth'; import { useNotify } from './Toasts'; +import { Button } from '@astryxdesign/core/Button'; +import { IconButton } from '@astryxdesign/core/IconButton'; +import { TextInput } from '@astryxdesign/core/TextInput'; +import { Selector } from '@astryxdesign/core/Selector'; +import { Dialog } from '@astryxdesign/core/Dialog'; +import { Badge } from '@astryxdesign/core/Badge'; +import { Heading } from '@astryxdesign/core/Heading'; +import { EmptyState } from '@astryxdesign/core/EmptyState'; +import { Spinner } from '@astryxdesign/core/Spinner'; type TabKey = 'profile' | 'outlets' | 'users'; +/** + * PHASE 6 — the edit-outlet modal's shell. + * + * It was a `fixed inset-0` with a hand-set 450px card and a `✕` character for a + * close button. It did not close on Escape and did not trap focus, so tabbing + * out of the outlet form walked into the store directory behind it. + */ +function SettingsModal({ + onClose, + width, + children, +}: { + onClose: () => void; + width: number; + children: React.ReactNode; +}) { + return ( + { if (!open) onClose(); }} + width={width} + maxHeight="90vh" + purpose="info" + > + {children} + + ); +} + /** Locally-persisted merchant preferences (survive reload via localStorage). */ interface MerchantSettings { // Business profile (seeded from live tenant data, then locally editable) @@ -300,18 +335,13 @@ export default function SettingsView({ tenantId = FIESTA_TENANT_ID, user }: Sett const Icon = t.icon; const active = activeTab === t.key; return ( - + /> ); })} @@ -388,29 +418,27 @@ export default function SettingsView({ tenantId = FIESTA_TENANT_ID, user }: Sett
Customer Support & Contacts
-
- - + {/* PHASE 6 — both were `readOnly` inputs styled to look like + fields: focusable controls in the tab order that could never + be edited, sitting in a section the comment above already + calls "read-only". They are values, so they read as values. + Every other card in this panel already displays live tenant + data exactly this way. */} +
+ + Support Email + +

+ {user?.email || fstr(tenant?.primaryemail) || '—'} +

-
- - +
+ + Phone Number + +

+ {user?.contactno || fstr(tenant?.primarycontact) || '—'} +

@@ -429,13 +457,12 @@ export default function SettingsView({ tenantId = FIESTA_TENANT_ID, user }: Sett
- + />
{showStoreOnboarding ? ( @@ -445,9 +472,14 @@ export default function SettingsView({ tenantId = FIESTA_TENANT_ID, user }: Sett ) : ( <> {locationsQ.isLoading ? ( -
Loading live outlets…
+
) : cleanOutlets.length === 0 ? ( -
No outlets configured yet.
+ } + title="No outlets configured yet" + description="Add your first store branch to start taking orders." + actions={
+ {/* Both actions read only "Edit"/"Delete" across a + grid of outlets — the same two words repeated + with no indication which store they act on. */}
- - {loc.status || '—'} - - - + />
@@ -534,23 +568,24 @@ export default function SettingsView({ tenantId = FIESTA_TENANT_ID, user }: Sett {/* EDIT STORE MODAL */} {showEditStoreModal && editingStore && ( -
{ if (e.target === e.currentTarget) setShowEditStoreModal(false); }} - > -
e.stopPropagation()}> -
-

- - Edit Store Outlet Location -

- + />
@@ -566,87 +601,67 @@ export default function SettingsView({ tenantId = FIESTA_TENANT_ID, user }: Sett
-
- - setEditingStore({ ...editingStore, locationname: e.target.value })} - className="w-full border border-[#e2e8f0] rounded-lg p-sm bg-[#f8fafc] focus:bg-white outline-none focus:ring-1 focus:ring-[#662582] text-xs" - required - /> -
- -
- - setEditingStore({ ...editingStore, suburb: e.target.value })} - className="w-full border border-[#e2e8f0] rounded-lg p-sm bg-[#f8fafc] focus:bg-white outline-none focus:ring-1 focus:ring-[#662582] text-xs" - required - /> -
- -
- - setEditingStore({ ...editingStore, city: e.target.value })} - className="w-full border border-[#e2e8f0] rounded-lg p-sm bg-[#f8fafc] focus:bg-white outline-none focus:ring-1 focus:ring-[#662582] text-xs" - /> -
- -
- - setEditingStore({ ...editingStore, contactno: e.target.value })} - className="w-full border border-[#e2e8f0] rounded-lg p-sm bg-[#f8fafc] focus:bg-white outline-none focus:ring-1 focus:ring-[#662582] text-xs" - required - /> -
- -
- - -
+ setEditingStore({ ...editingStore, locationname: v })} + /> + setEditingStore({ ...editingStore, suburb: v })} + /> + setEditingStore({ ...editingStore, city: v })} + /> + {/* The phone field accepted anything at all. Its sibling in + AdminConsole already strips non-digits and caps at 10; this + one edits the same column and did neither. */} + setEditingStore({ ...editingStore, contactno: v.replace(/\D/g, '').slice(0, 10) })} + startIcon={} + /> + {/* Option values are lower-case because that is what the + column actually holds — verified against gettenantlocations: + every outlet reads "active", not "Active". The old options + were capitalised, so the select never matched the stored + value and showed nothing selected; saving then wrote + whatever it resolved to, which could flip a live store's + status without anyone choosing it. Labels stay capitalised + for display. */} + setEditingStore({ ...editingStore, status: v || 'active' })} + options={[ + { value: 'active', label: 'Active' }, + { value: 'inactive', label: 'Inactive' }, + { value: 'suspended', label: 'Suspended' }, + ]} + />
-
- - + label={updateLocationMut.isPending ? 'Updating…' : 'Update Outlet Profile'} + isLoading={updateLocationMut.isPending} + isDisabled={updateLocationMut.isPending} + />
- + )} void; + width: number; + children: React.ReactNode; +}) { + return ( + { if (!open) onClose(); }} + width={width} + maxHeight="90vh" + purpose="info" + > + {children} + + ); +} const PLACEHOLDER = 'https://images.unsplash.com/photo-1542838132-92c53300491e?auto=format&fit=crop&q=80&w=200'; @@ -374,49 +411,44 @@ export default function StoreCatalogView({ locationid, storeName = 'your store', {/* Tabs and Controls */}
-
- - - + } /> + } /> + } /> +
{/* Counter sales. Deliberately outside the tab group and always visible: it is an action, not another way of looking at the catalogue, and the store needs it from wherever they are on this page. No outlet picker is passed — a store user can only ever import against their own. */} - + +
+
)}
@@ -516,12 +550,13 @@ export default function StoreCatalogView({ locationid, storeName = 'your store', {/* Action Toolbar */}
- + /> {/* `hasClear` replaces the absolutely-positioned X button, and `startIcon` replaces the absolutely-positioned magnifier — so @@ -600,25 +635,39 @@ export default function StoreCatalogView({ locationid, storeName = 'your store',
} {picks[p.id].status === 'Pending' ? 'Requested' : picks[p.id].status} ({picks[p.id].qty}) + {/* Both carried only a `title` and, for the second, + a bare X — so a card grid announced "Edit" and + nothing, N times, with no product name. */}
{picks[p.id].status === 'Pending' && ( - + + } + onClick={(e: React.MouseEvent) => { e.stopPropagation(); togglePick(p.id); }} + /> )}
) : ( - +
@@ -639,105 +688,93 @@ export default function StoreCatalogView({ locationid, storeName = 'your store', sub="Browse the catalogue and add items to your store to request stock." /> ) : ( -
+
- - - +
+ + {['Requested At', 'Store', 'Product', 'Qty', 'Status', 'Resolved At', ''].map((h, i) => ( - + {h} ))} - - - + + + {(stockRequestsQ.data || []).map((data: any) => { const pid = String(data.productid); const prod = products.find(p => String(p.id) === pid); const isApproved = data.status === 'Approved'; - const isRejected = data.status === 'Rejected'; - const isCancelled = data.status === 'Cancelled'; - const isReceived = data.status === 'Received'; - + // Same mapping as the admin side of this flow: 'Received' + // was indigo and 'Cancelled' slate, neither of which has a + // Badge equivalent, so they take the nearest semantic + // meaning rather than an invented hue. + const statusVariant = + data.status === 'Received' ? 'info' : + data.status === 'Approved' ? 'success' : + data.status === 'Rejected' ? 'error' : + data.status === 'Cancelled' ? 'neutral' : + 'warning'; + return ( - - - - - - - - - + + ); })} - -
{h}
-
- + + +
+ {data.created ? new Date(data.created).toLocaleDateString('en-IN', { day: 'numeric', month: 'short' }) : '—'} - + {data.created ? new Date(data.created).toLocaleTimeString('en-IN', { hour: '2-digit', minute: '2-digit' }) : ''}
-
-
- + + + + {storeName} -
-
+ + +
- <> -
- {data.productname -
-
-

{data.productname || prod?.name}

-

{prod?.sku || `SKU-${data.productid}`}

-
- +
+ +
+
+

{data.productname || prod?.name}

+

{prod?.sku || `SKU-${data.productid}`}

+
-
- - {data.qty || '—'} - - - - - {data.status || '—'} - - - + + + {data.qty || '—'} + + + + + + {data.updated ? new Date(data.updated).toLocaleString('en-IN', { dateStyle: 'medium', timeStyle: 'short' }) : '—'} - + + {isApproved && ( - +
+
)} -
+ +
-
+ )} )} @@ -756,9 +793,11 @@ export default function StoreCatalogView({ locationid, storeName = 'your store', title="No stock matches your filters" sub="Try a different keyword or clear the sidebar filters to find an item in your store." action={ - + + @@ -815,7 +856,7 @@ export default function StoreCatalogView({ locationid, storeName = 'your store',
{pickCount} product{pickCount > 1 ? 's' : ''} requested - +
@@ -825,12 +866,8 @@ export default function StoreCatalogView({ locationid, storeName = 'your store', {/* QUANTITY SELECTION CENTERED MODAL */} {activeQtyProduct && ( -
-
setActiveQtyProduct(null)} - /> -
+ setActiveQtyProduct(null)} width={340}> +
{/* Header */}
@@ -838,12 +875,13 @@ export default function StoreCatalogView({ locationid, storeName = 'your store',

Request Stock

Bulk Order

- + />
{/* Product Card summary */} @@ -868,17 +906,14 @@ export default function StoreCatalogView({ locationid, storeName = 'your store',
{[10, 25, 50, 100].map(qty => ( - + /> ))}
@@ -903,21 +938,20 @@ export default function StoreCatalogView({ locationid, storeName = 'your store',
- + />
- + - + )} {/* Floating FMCG Details Panel */} @@ -997,16 +1031,15 @@ export default function StoreCatalogView({ locationid, storeName = 'your store', Approved ({pick.qty} units) — confirm once it arrives - + /> ) : isRejected ? (
@@ -1014,9 +1047,7 @@ export default function StoreCatalogView({ locationid, storeName = 'your store', Stock request was rejected
- + + + + - - )} + // `text-zinc-900` is gone from the root: it pinned every unstyled string on + // the page to near-black regardless of theme, which is exactly the class of + // hardcode that keeps `mode="system"` from shipping. +
- {/* ── Subheader Navigation Bar ── */} {/* ── Subheader Navigation Bar ── */} {onBack && (
- + />
)} @@ -500,68 +556,34 @@ export default function StoreDetailView({ store, onBack, canManage = true, only,
)} - {/* ── Visual Glass-look Tab Controls (full tabbed console only) ── */} + {/* ── Section tabs (full tabbed console only) ── + PHASE 6 — five hand-rolled buttons became one TabList. The strip is now + a single Tab stop with Arrow/Home/End roving focus, which the button row + never had: keyboard users previously tabbed through all five in turn. */} {!only && ( -
- - - - - -
+
+ setActiveTab(v as typeof activeTab)} + hasDivider + aria-label="Store sections" + > + } /> + } + endContent={ + inventoryList.some(item => item.status === 'Critical') + ? + : undefined + } + /> + } /> + } /> + } /> + +
)} {/* ── TAB PAYLOAD AREA ── */} @@ -570,78 +592,81 @@ export default function StoreDetailView({ store, onBack, canManage = true, only, {/* Top Metric Cards */}
-
+ {/* PHASE 6 — the four `bg-white` slabs are Astryx Cards now. The + icon chips keep their own hues: they are category identity, not + surface, and each has a matching dark-mode value in the theme. */} +
- Monthly Revenue + Monthly Revenue
{monthRevenueQ.isLoading ? ( - Loading... + Loading... ) : ( -

₹{monthlyRevenue.toLocaleString('en-IN')}

+ ₹{monthlyRevenue.toLocaleString('en-IN')} )}
-
+ -
+
- Today's Revenue + Today's Revenue
{revenueQ.isLoading ? ( - Loading... + Loading... ) : ( -

₹{totalRevenue.toLocaleString('en-IN')}

+ ₹{totalRevenue.toLocaleString('en-IN')} )}
-
+ -
+
- Total Dispatches -

{store.deliveries}

-
+ Total Dispatches +
{store.deliveries}
+
Daily cap quota: 200
-
+
-
+
- Active Fleet -

{activeRiders.length} Riders

+ Active Fleet +
{activeRiders.length} Riders
{activeRiders.filter(r => r.status !== 'Idle').length} dispatches live
-
+
{/* Interactive Timeline Pipeline Flow */} -
+
-

+

Dispatch Flow Pipeline

-

Audit orders & revenue progression by selecting nodes along the daily operational path.

+

Audit orders & revenue progression by selecting nodes along the daily operational path.

- Time Flow +
{/* Intraday dispatch breakdown */}
{todayOrdersQ.isLoading ? ( -
Loading intraday data...
+
) : ( @@ -658,16 +683,27 @@ export default function StoreDetailView({ store, onBack, canManage = true, only, content={({ active, payload, label }: any) => { if (active && payload && payload.length) { return ( -
-

{label}

+ // Recharts renders this itself, so it gets no + // Astryx component — but it does sit inside the + // theme root, so the tokens reach it. `bg-white` + // here was the last hardcoded surface on the page. +
+

{label}

- + - Revenue: ₹{payload[0].value.toLocaleString('en-IN')} + Revenue: ₹{payload[0].value.toLocaleString('en-IN')} - + - Orders: {payload[0].payload.orders} + Orders: {payload[0].payload.orders}
@@ -681,50 +717,57 @@ export default function StoreDetailView({ store, onBack, canManage = true, only, )}
-
+
{/* Quick Actions Console — management only */} {canManage && ( -
+
-

+

Node Operations Command

-

Automated actions for local outlet hubs.

+

Automated actions for local outlet hubs.

+ {/* PHASE 6 — three bordered pseudo-buttons became real ones. The + trailing tag on each moved out of the button label and into a + Badge beside it, because a Button's label is its accessible + name and "Broadcast Terminal SMS SMS Blast" is not one. */}
- +
+
- +
+
- +
+
-
+ )}
@@ -738,155 +781,172 @@ export default function StoreDetailView({ store, onBack, canManage = true, only,
{/* Inventory search, metrics & catalogue tools */} -
-
- - +
+ {/* PHASE 6 — the search box gains a clear button it never had, and + a label. It was an unlabelled input with a decorative icon, so + a screen reader announced only "edit text". */} + setStockSearch(e.target.value)} - className="w-full pl-9 pr-4 py-2.5 border border-[#e2e8f0] rounded-xl text-xs outline-none bg-[#f8fafc] focus:bg-white focus:ring-1 focus:ring-[#662582] transition-all" + onChange={(v) => setStockSearch(v)} + startIcon={} + hasClear />
{/* Actions for Store Catalogue Management */} -
+
{canManage && ( - <> - - - - +
-
+ - {/* Stocks statement Table */} -
-
-

- Product Stock Levels -

- Live list + {/* Stocks statement Table + PHASE 6 — raw replaced by the Astryx Table primitives. + The category and status pills become Badges, and the capacity bar + becomes a real ProgressBar: the old one was two nested divs with no + role and no value, so assistive tech saw nothing at all. */} + +
+

Product Stock Levels

+
-
- - - - - - - - - {canManage && } - - - +
Product ItemSKU RefCategoryEst. PriceCapacity LoadStatusReplenish
+ + + Product Item + SKU Ref + Category + Est. Price + Capacity Load + Status + {canManage && Replenish} + + + {inventoryList.length === 0 ? ( - - - + + + } + title="No product stocks found" + description={ + stockSearch + ? 'Nothing matches the search keyword.' + : 'Stock will appear here once this outlet holds inventory.' + } + actions={ + stockSearch + ? - - - - - - + + + + {canManage && ( - + +
+
+
)} - + ); }) )} - -
- No product stocks found matching the search keyword. -
+ +
- {item.name} - {item.name} + {item.name}
-
{item.sku} - - {item.category.split(' / ').slice(-1)[0]} + + + {item.sku} + + + + + + + {item.price != null ? `₹${item.price.toLocaleString('en-IN')}` : '—'} - - {item.price != null ? `₹${item.price.toLocaleString('en-IN')}` : '—'} - -
- {item.stockLevel} / {item.maxCapacity} -
-
+ +
+ + {item.stockLevel} / {item.maxCapacity} + +
+
-
- - ● {item.status} - - - -
+ +
-
+
)} @@ -949,14 +1009,15 @@ export default function StoreDetailView({ store, onBack, canManage = true, only,
-
- - + setCustomerSearch(e.target.value)} - className="w-full pl-8 pr-3 py-2 border border-[#662582]/20 rounded-xl text-[12px] text-[#0f172a] placeholder:text-zinc-400 outline-none bg-white/60 backdrop-blur-md focus:border-[#662582] focus:ring-4 focus:ring-[#662582]/10 transition-all shadow-[inset_0_2px_4px_rgba(0,0,0,0.02)]" + onChange={(v) => setCustomerSearch(v)} + startIcon={} + hasClear />
@@ -964,89 +1025,95 @@ export default function StoreDetailView({ store, onBack, canManage = true, only, {/* Profile cards */} {customersList.length === 0 ? ( -
- -

No customers yet

-

- {customerSearch ? 'Nothing matches your search criteria.' : 'Customers will appear here once they place their first order.'} -

- {customerSearch && ( - - )} -
+ + } + title="No customers yet" + description={customerSearch ? 'Nothing matches your search criteria.' : 'Customers will appear here once they place their first order.'} + actions={ + customerSearch + ? + // Was a title-attribute-only icon button, which is not an + // accessible name. IconButton's `label` is. + } + onClick={(e: React.MouseEvent) => { e.stopPropagation(); showToast(`Promo code sent to ${c.phone}.`, 'success'); }} + /> )} - + />
- + ); })} @@ -1073,283 +1140,316 @@ export default function StoreDetailView({ store, onBack, canManage = true, only, )} - {/* ── Replenishment Modal Dialog Overlay ── */} + {/* ── Replenishment Modal ── */} {replenishModal.show && replenishModal.item && ( -
{ if (e.target === e.currentTarget) setReplenishModal({ show: false, item: null }); }} - > -
-
-

- - Replenish Inventory Stack -

- + />
-
- Replenishing Item -

{replenishModal.item.name}

-

SKU: {replenishModal.item.sku}

+
+ Replenishing Item +

{replenishModal.item.name}

+

SKU: {replenishModal.item.sku}

+ {/* The destination was a disabled text input purely to look + like a field. It is read-only text, so it reads as text now + and no longer sits in the tab order as a dead control. */}
- - + Destination Store + {store.name}
-
- - setReplenishQty(Number(e.target.value))} - className="w-full border border-[#e2e8f0] rounded-xl p-sm bg-[#f8fafc] focus:bg-white outline-none focus:ring-1 focus:ring-[#581c87] font-bold" - min={1} - required - /> -
+ setReplenishQty(v ?? 1)} + min={1} + />
-
- - + /> +
-
+ )} {/* ── Manual CSV Import simulated Modal ── */} - {showImportModal && ( -
{ if (e.target === e.currentTarget && importState !== 'reading' && importState !== 'parsing' && importState !== 'saving') setShowImportModal(false); }} - > -
-
-

- - Import Catalogue Manifest -

- + />
-

+ Upload or drop your CSV stock ledger files below to commission new items into the {store.name} local registry database. -

+ {importState === 'idle' && ( -
{ if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); handleStartCsvImport(); } }} + className="border-2 border-dashed rounded-2xl p-xl text-center cursor-pointer transition-colors space-y-sm group" + style={{ borderColor: 'rgba(127,127,127,0.3)' }} > - +
-

Click here to import file

-

Mock file: coimbatore_manifest_v2.csv

+

Click here to import file

+

Mock file: coimbatore_manifest_v2.csv

)} - {['reading', 'parsing', 'saving'].includes(importState) && ( -
-
- - -
-
-

- {importState === 'reading' && 'Reading uploaded CSV sheets...'} - {importState === 'parsing' && 'Scanning item SKU catalogue mapping...'} - {importState === 'saving' && 'Syncing manifest entries with local inventory...'} -

-

Kindly keep this window open while processing dispatches.

-
+ {isBusy && ( +
+ +

Kindly keep this window open while processing dispatches.

)} {importState === 'done' && ( -
+
-

Ledger Imported Successfully

-

3 new SKU codes commissioned successfully into local registry.

+

Ledger Imported Successfully

+

3 new SKU codes commissioned successfully into local registry.

)}
-
- + />
-
+ + ); + })()} + + {/* ── Staff broadcast composer (replaces the native prompt) ── */} + {broadcast.show && ( + setBroadcast({ show: false, text: '' })} width={440}> +
+
+ + + + Broadcast to terminals + + + } + onClick={() => setBroadcast({ show: false, text: '' })} + /> +
+ +
+ + This message goes to every cashier and staff terminal signed in at {store.name}. + +