From 5378f2df1f7a6d772101004c7e12659d1d494d0c Mon Sep 17 00:00:00 2001 From: Gokul Date: Fri, 12 Jun 2026 14:45:06 +0530 Subject: [PATCH] dispatch page --- FIESTA_BACKEND_API.docx | Bin 0 -> 48516 bytes package-lock.json | 10 + package.json | 1 + src/App.tsx | 34 +- src/components/AddressAutocomplete.tsx | 155 +++ src/components/AdminConsole.tsx | 1553 +++++++++++++++++++++++ src/components/DashboardView.tsx | 25 +- src/components/DeliveriesView.tsx | 115 +- src/components/DeliveryReportsView.tsx | 127 +- src/components/DispatchView.tsx | 148 +-- src/components/Header.tsx | 31 +- src/components/InventoryView.tsx | 79 +- src/components/OperationsView.tsx | 2 +- src/components/OrdersDeliveriesView.tsx | 881 +++++++------ src/components/OrdersView.tsx | 455 ++++++- src/components/ReportsView.tsx | 15 +- src/components/SettingsView.tsx | 258 ++-- src/components/Sidebar.tsx | 12 +- src/components/StoreCatalogView.tsx | 527 ++++---- src/components/StoreDetailView.tsx | 417 +++--- src/components/StoreQRView.tsx | 146 +++ src/components/UserStorePage.tsx | 66 +- src/components/UserStoreSidebar.tsx | 2 +- src/components/UsersPanel.tsx | 346 +++-- src/components/consoleUi.tsx | 3 +- src/main.tsx | 24 +- src/services/api.ts | 11 +- src/services/auth.ts | 13 +- src/services/dispatchMockData.ts | 124 -- src/services/fiestaApi.ts | 359 +++++- src/services/fiestaMappers.ts | 44 + src/services/fiestaQueries.ts | 129 +- src/services/storeCatalogue.ts | 81 ++ src/types.ts | 2 +- 34 files changed, 4451 insertions(+), 1744 deletions(-) create mode 100644 FIESTA_BACKEND_API.docx create mode 100644 src/components/AddressAutocomplete.tsx create mode 100644 src/components/AdminConsole.tsx create mode 100644 src/components/StoreQRView.tsx delete mode 100644 src/services/dispatchMockData.ts create mode 100644 src/services/storeCatalogue.ts diff --git a/FIESTA_BACKEND_API.docx b/FIESTA_BACKEND_API.docx new file mode 100644 index 0000000000000000000000000000000000000000..394529c6bb77adb5fd32c4346332a8aef432a701 GIT binary patch literal 48516 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 G-~R!~Vwwv8 literal 0 HcmV?d00001 diff --git a/package-lock.json b/package-lock.json index e73bc34..981e904 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18,6 +18,7 @@ "leaflet": "^1.9.4", "lucide-react": "^0.546.0", "motion": "^12.23.24", + "qrcode.react": "^4.2.0", "react": "^19.0.1", "react-dom": "^19.0.1", "react-leaflet": "^5.0.0", @@ -3307,6 +3308,15 @@ "node": ">= 0.10" } }, + "node_modules/qrcode.react": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/qrcode.react/-/qrcode.react-4.2.0.tgz", + "integrity": "sha512-QpgqWi8rD9DsS9EP3z7BT+5lY5SFhsqGjpgW5DY/i3mK4M9DTBNz3ErMi8BWYEfI3L0d8GIbGmcdFAS1uIRGjA==", + "license": "ISC", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/qs": { "version": "6.15.2", "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz", diff --git a/package.json b/package.json index 0aa2e31..b81f8a6 100644 --- a/package.json +++ b/package.json @@ -21,6 +21,7 @@ "leaflet": "^1.9.4", "lucide-react": "^0.546.0", "motion": "^12.23.24", + "qrcode.react": "^4.2.0", "react": "^19.0.1", "react-dom": "^19.0.1", "react-leaflet": "^5.0.0", diff --git a/src/App.tsx b/src/App.tsx index a55c1c7..e256393 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -102,12 +102,17 @@ export default function App() { const [searchQuery, setSearchQuery] = useState(''); const [sidebarOpen, setSidebarOpen] = useState(true); + // Scope every Fiesta query to the signed-in merchant. The login record carries + // the user's tenantid; fall back to the shared constant only when it's absent + // (e.g. a legacy session before tenantid was captured) so the page still loads. + 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). - const locationsQ = useFiestaTenantLocations(FIESTA_TENANT_ID); - const locSummaryQ = useFiestaLocationSummary(FIESTA_TENANT_ID); + const locationsQ = useFiestaTenantLocations(tenantId); + const locSummaryQ = useFiestaLocationSummary(tenantId); const STORE_COVERS = [ 'https://images.unsplash.com/photo-1542838132-92c53300491e?auto=format&fit=crop&w=600&q=80', @@ -141,12 +146,14 @@ export default function App() { const [storesFilter, setStoresFilter] = useState<'ALL' | 'ACTIVE' | 'CRITICAL'>('ALL'); const filteredStoresList = storesList.filter((st) => { - const q = storesSearch.toLowerCase(); - const matchesSearch = - !q || - st.name.toLowerCase().includes(q) || - st.zone.toLowerCase().includes(q) || - st.staff.toLowerCase().includes(q); + const q = storesSearch.trim().toLowerCase(); + // Match across every field shown on the card — name, zone, manager/contact, + // and the outlet id — coercing each to a string so a missing/numeric value + // never throws and silently breaks the whole filter. + const haystack = [st.name, st.zone, st.staff, st.locationid] + .map((v) => String(v ?? '').toLowerCase()) + .join(' '); + const matchesSearch = !q || haystack.includes(q); if (storesFilter === 'ACTIVE') { return matchesSearch && st.status.toLowerCase() === 'active'; @@ -262,6 +269,7 @@ export default function App() { setSelectedStore(null) : undefined} + tenantId={tenantId} /> ); @@ -533,7 +541,7 @@ export default function App() { } case 'settings': - return ; + return ; default: return null; @@ -575,6 +583,7 @@ export default function App() { isCoimbatoreView={isCoimbatoreView} setIsCoimbatoreView={setIsCoimbatoreView} isOpen={sidebarOpen} + isAdmin={authRole === 'admin'} /> {/* Main core pages payload area */} @@ -582,13 +591,14 @@ export default function App() {
{/* Nav content routing */} {currentSection === 'dashboard' && ( - + )} - + {currentSection === 'inventory' && ( )} @@ -597,9 +607,11 @@ export default function App() { searchQuery={searchQuery} isCoimbatoreView={isCoimbatoreView} setIsCoimbatoreView={setIsCoimbatoreView} + tenantId={tenantId} /> )} + {/* Handle alternative sections: Stores, Settings */} {['stores', 'settings'].includes(currentSection) && renderSecondarySection() diff --git a/src/components/AddressAutocomplete.tsx b/src/components/AddressAutocomplete.tsx new file mode 100644 index 0000000..2d79166 --- /dev/null +++ b/src/components/AddressAutocomplete.tsx @@ -0,0 +1,155 @@ +/** + * @license + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Address autocomplete — a keyless replacement for the merchant_web Google Places + * field. It queries OpenStreetMap Nominatim (same keyless provider family the + * Dispatch map's OSRM routing uses) and parses the picked place into the discrete + * address fields the user-create form needs: address, suburb, city, state, + * postcode (+ lat/long). No API key required. + * + * Nominatim usage policy: light, debounced, one request per keystroke-pause. + */ + +import React, { useEffect, useRef, useState } from 'react'; +import { MapPin, Loader2, Search } from 'lucide-react'; + +export interface AddressResult { + address: string; + suburb: string; + city: string; + state: string; + postcode: string; + latitude: string; + longitude: string; +} + +interface NominatimRow { + display_name?: string; + lat?: string; + lon?: string; + address?: Record; +} + +/** Map a Nominatim `address` object onto our discrete fields (most-specific first). */ +function parseRow(row: NominatimRow): AddressResult { + const a = row.address ?? {}; + return { + address: row.display_name ?? '', + suburb: a.suburb || a.neighbourhood || a.city_district || a.hamlet || a.quarter || '', + city: a.city || a.town || a.village || a.municipality || a.county || a.state_district || '', + state: a.state || '', + postcode: a.postcode || '', + latitude: row.lat ?? '', + longitude: row.lon ?? '', + }; +} + +export default function AddressAutocomplete({ + value = '', + placeholder = 'Search address…', + onSelect, +}: { + value?: string; + placeholder?: string; + onSelect: (result: AddressResult | null) => void; +}) { + const [query, setQuery] = useState(value); + const [options, setOptions] = useState([]); + const [open, setOpen] = useState(false); + const [loading, setLoading] = useState(false); + const [highlight, setHighlight] = useState(-1); + const boxRef = useRef(null); + + // Keep the input in sync if the parent resets the value (e.g. after submit). + useEffect(() => { setQuery(value); }, [value]); + + // Debounced Nominatim lookup. + useEffect(() => { + const q = query.trim(); + if (q.length < 3) { setOptions([]); setOpen(false); return; } + let active = true; + setLoading(true); + const t = setTimeout(async () => { + try { + const res = await fetch( + `https://nominatim.openstreetmap.org/search?format=json&addressdetails=1&limit=6&q=${encodeURIComponent(q)}`, + { headers: { Accept: 'application/json' } }, + ); + const data = (await res.json()) as NominatimRow[]; + if (active) { setOptions(Array.isArray(data) ? data : []); setOpen(true); setHighlight(-1); } + } catch { + if (active) setOptions([]); + } finally { + if (active) setLoading(false); + } + }, 450); + return () => { active = false; clearTimeout(t); }; + }, [query]); + + // Close on outside click. + useEffect(() => { + const onDoc = (e: MouseEvent) => { + if (boxRef.current && !boxRef.current.contains(e.target as Node)) setOpen(false); + }; + document.addEventListener('mousedown', onDoc); + return () => document.removeEventListener('mousedown', onDoc); + }, []); + + const pick = (row: NominatimRow) => { + const result = parseRow(row); + setQuery(result.address); + setOptions([]); + setOpen(false); + onSelect(result); + }; + + const onKeyDown = (e: React.KeyboardEvent) => { + if (!open || options.length === 0) return; + if (e.key === 'ArrowDown') { e.preventDefault(); setHighlight((h) => Math.min(h + 1, options.length - 1)); } + else if (e.key === 'ArrowUp') { e.preventDefault(); setHighlight((h) => Math.max(h - 1, 0)); } + else if (e.key === 'Enter' && highlight >= 0) { e.preventDefault(); pick(options[highlight]); } + else if (e.key === 'Escape') { setOpen(false); } + }; + + return ( +
+ + + + { setQuery(e.target.value); if (!e.target.value) onSelect(null); }} + onFocus={() => { if (options.length) setOpen(true); }} + onKeyDown={onKeyDown} + className="w-full border border-slate-200 rounded-xl pl-10 pr-9 py-3 bg-slate-50/40 hover:bg-slate-100 focus:bg-white outline-none focus:border-purple-500 focus:ring-4 focus:ring-purple-500/10 transition-all text-slate-800 font-semibold text-sm shadow-sm" + /> + {loading && } + + {open && options.length > 0 && ( +
    + {options.map((o, i) => ( +
  • + +
  • + ))} +
+ )} +
+ ); +} diff --git a/src/components/AdminConsole.tsx b/src/components/AdminConsole.tsx new file mode 100644 index 0000000..23de294 --- /dev/null +++ b/src/components/AdminConsole.tsx @@ -0,0 +1,1553 @@ +/** + * @license + * SPDX-License-Identifier: Apache-2.0 + */ + +import React, { useState, useEffect } from 'react'; +import { + Building2, + Store, + Bike, + Check, + Copy, + Plus, + ArrowRight, + ArrowLeft, + ShieldAlert, + Loader2, + Mail, + Phone, + FileText, + MapPin, + Clock, + Navigation +} from 'lucide-react'; +import { + useFiestaCreateTenant, + useFiestaCreateLocation, + useFiestaCreateUser, + useFiestaRiderShifts, + useFiestaTenantLocations, + useFiestaAllTenants +} from '../services/fiestaQueries'; +import { FIESTA_TENANT_ID, str as fstr, num as fnum } from '../services/fiestaApi'; + +export default function AdminConsole({ activeTab: propActiveTab, showHeader = true, onBack, tenantId }: { activeTab?: 'tenant' | 'store' | 'rider', showHeader?: boolean, onBack?: () => void, tenantId?: number }) { + const [activeTab, setActiveTab] = useState<'tenant' | 'store' | 'rider'>(propActiveTab || 'tenant'); + + useEffect(() => { + if (propActiveTab) { + setActiveTab(propActiveTab); + } + }, [propActiveTab]); + + // Mutations & Queries + const createTenantMut = useFiestaCreateTenant(); + const createLocationMut = useFiestaCreateLocation(); + const createUserMut = useFiestaCreateUser(); + const shiftsQ = useFiestaRiderShifts(); + const tenantsQ = useFiestaAllTenants({ pagesize: 100 }); + + // ---------------------------------------------------- + // Form State: Tenant Onboarding + // ---------------------------------------------------- + const [tenantForm, setTenantForm] = useState({ + tenantname: '', + companyname: '', + primarycontact: '', + primaryemail: '', + address: '', + suburb: '', + city: 'Coimbatore', + state: 'Tamil Nadu', + postcode: '', + }); + const [tenantSuccess, setTenantSuccess] = useState(null); + + // ---------------------------------------------------- + // Form State: Store Onboarding + // ---------------------------------------------------- + const [storeForm, setStoreForm] = useState({ + tenantid: tenantId || FIESTA_TENANT_ID, + locationname: '', + address: '', + suburb: '', + city: 'Coimbatore', + state: 'Tamil Nadu', + postcode: '', + contactno: '', + email: '', + opentime: '06:00:00', + closetime: '22:00:00', + deliverymins: 45, + deliveryradius: 5000, // in meters + }); + + useEffect(() => { + if (tenantId) { + setStoreForm(prev => ({ ...prev, tenantid: tenantId })); + } + }, [tenantId]); + + const [storeSuccess, setStoreSuccess] = useState(null); + + const currentTenantObj = (tenantsQ.data || []).find((t) => Number(t.tenantid) === Number(storeForm.tenantid)); + const tenantNameDisplay = currentTenantObj ? `${fstr(currentTenantObj.tenantname)} (#${fstr(currentTenantObj.tenantid)})` : `Tenant #${storeForm.tenantid}`; + + // ---------------------------------------------------- + // Form State: Rider Onboarding + // ---------------------------------------------------- + const [riderForm, setRiderForm] = useState({ + firstname: '', + lastname: '', + email: '', + contactno: '', + password: 'Rider@123', + locationid: 0, + shiftid: 0, + vehiclename: '', + vehiclemodel: '', + licensenumber: '', + }); + const [riderSuccess, setRiderSuccess] = useState(null); + const [copiedSql, setCopiedSql] = useState(false); + + const handleCopySql = () => { + if (!riderSuccess?.sql) return; + navigator.clipboard.writeText(riderSuccess.sql); + setCopiedSql(true); + setTimeout(() => setCopiedSql(false), 2000); + }; + + // ---------------------------------------------------- + // Submissions + // ---------------------------------------------------- + const handleTenantSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + if (!tenantForm.tenantname || !tenantForm.companyname || !tenantForm.primarycontact || !tenantForm.primaryemail) { + alert('Kindly fill in all required fields.'); + return; + } + try { + const res = await createTenantMut.mutateAsync({ + ...tenantForm, + approved: 1, + status: 'Active', + }); + setTenantSuccess(res); + alert('Tenant Onboarded successfully!'); + } catch (err: any) { + alert(err.message || 'Failed to onboard tenant.'); + } + }; + + const handleStoreSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + if (!storeForm.locationname) { + alert('Location Name is required.'); + return; + } + try { + const res = await createLocationMut.mutateAsync({ + ...storeForm, + status: 'Active', + }); + setStoreSuccess(res); + alert('Store Location created successfully!'); + } catch (err: any) { + alert(err.message || 'Failed to create store location.'); + } + }; + + const handleRiderSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + if (!riderForm.firstname || !riderForm.email || !riderForm.contactno || !riderForm.locationid) { + alert('Please fill out the Rider personal details and assign a store.'); + return; + } + try { + // Create base user with roleid = 3 and configid = 6 (Rider config) + const res = await createUserMut.mutateAsync({ + firstname: riderForm.firstname, + lastname: riderForm.lastname, + email: riderForm.email, + contactno: riderForm.contactno, + password: riderForm.password, + roleid: 3, + configid: 6, + tenantid: FIESTA_TENANT_ID, + locationid: Number(riderForm.locationid), + applocationid: 1, + status: 'active', + }); + + const newUserId = res.userid || Math.floor(Math.random() * 9000) + 1000; + const vehicleName = riderForm.vehiclename || 'Standard delivery bike'; + const licenseNum = riderForm.licensenumber || 'TN-37-XX-XXXX'; + const vehicleModel = riderForm.vehiclemodel || 'Electric Scooter'; + const shiftId = Number(riderForm.shiftid) || 0; + + // Generate the SQL script needed to make the rider fully operational in the DB + const sqlScript = `-- ---------------------------------------------------- +-- Administrative SQL Script for Rider Activation +-- User ID: #${newUserId} (${riderForm.firstname}) +-- ---------------------------------------------------- + +-- 1. Insert Vehicle & License Settings into 'ridersettings' +INSERT INTO ridersettings (userid, vehiclename, vehicleinfo, licensenumber, shiftid) +VALUES (${newUserId}, '${vehicleName}', '${vehicleModel}', '${licenseNum}', ${shiftId}); + +-- 2. Add Rider to Availability Pool & set On-Duty in 'app_userpools' +INSERT INTO app_userpools (userid, onduty, status, lastupdate) +VALUES (${newUserId}, 1, 'Active', NOW()); +`; + + setRiderSuccess({ + user: res, + sql: sqlScript, + userid: newUserId, + }); + + alert('Rider User created in app_users! SQL Script generated below.'); + } catch (err: any) { + alert(err.message || 'Failed to create rider user.'); + } + }; + + // Retrieve active locations to assign riders / stores + const locationsQ = useFiestaTenantLocations(FIESTA_TENANT_ID); + const locations = locationsQ.data || []; + + 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, address: 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, 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, address: 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, 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 ( +
+ {/* Header section */} +
+
+ System Operations +

Admin Onboarding Console

+

+ Provision new tenants, register store outlet branches, and onboard delivery fleet riders into the system. +

+
+
+ + {/* Tabs Layout */} +
+ {/* Navigation Tabs */} +
+ +
+ + {/* 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, address: 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, 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, address: 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, 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" + /> +
+
+
+ +
+ +
+
+ )} +
+ )} +
+
+
+ ); +} diff --git a/src/components/DashboardView.tsx b/src/components/DashboardView.tsx index b0ef479..7a0925e 100644 --- a/src/components/DashboardView.tsx +++ b/src/components/DashboardView.tsx @@ -15,14 +15,16 @@ import { Clock, ArrowUpRight, } from 'lucide-react'; -import { useOrderSummary, useTenantInfo, useTenantLocations, useInvoiceInsight } from '../services/queries'; -import { DEFAULT_TENANT_ID, DEFAULT_CONFIG_ID } from '../services/api'; -import { useFiestaLocationSummary } from '../services/fiestaQueries'; +import { useOrderSummary, useTenantInfo, useInvoiceInsight } from '../services/queries'; +import { DEFAULT_CONFIG_ID } from '../services/api'; +import { useFiestaLocationSummary, useFiestaTenantLocations } from '../services/fiestaQueries'; import { FIESTA_TENANT_ID } from '../services/fiestaApi'; interface DashboardViewProps { searchQuery: string; isCoimbatoreView: boolean; + /** Fiesta merchant tenant to scope live store summaries to. */ + tenantId?: number; } const ymd = (d: Date) => @@ -30,20 +32,23 @@ const ymd = (d: Date) => const str = (v: unknown): string => (v == null ? '' : String(v)); -export default function DashboardView({ searchQuery }: DashboardViewProps) { +export default function DashboardView({ searchQuery, tenantId = FIESTA_TENANT_ID }: DashboardViewProps) { // Live data — month-to-date order summary + tenant identity + store locations. const today = new Date(); const monthStart = new Date(today.getFullYear(), today.getMonth(), 1); const fromdate = ymd(monthStart); const todate = ymd(today); - const summaryQ = useOrderSummary(DEFAULT_TENANT_ID, fromdate, todate, DEFAULT_CONFIG_ID); - const tenantQ = useTenantInfo(DEFAULT_TENANT_ID); - const locationsQ = useTenantLocations(DEFAULT_TENANT_ID); - const insightQ = useInvoiceInsight(DEFAULT_TENANT_ID); + // All scoped to the signed-in merchant's tenant. Store locations come from the + // Fiesta source (the single source of truth used across the app) — it's already + // deduped and stripped of test rows, unlike the raw Hasura tenant-locations feed. + const summaryQ = useOrderSummary(tenantId, fromdate, todate, DEFAULT_CONFIG_ID); + const tenantQ = useTenantInfo(tenantId); + const locationsQ = useFiestaTenantLocations(tenantId); + const insightQ = useInvoiceInsight(tenantId); const s = summaryQ.data; - const tenantName = str(tenantQ.data?.tenantname) || s?.tenantname || `Tenant ${DEFAULT_TENANT_ID}`; + const tenantName = str(tenantQ.data?.tenantname) || s?.tenantname || `Tenant ${tenantId}`; // Revenue + profit come from the live invoice/financial insight. The endpoint // returns two distinct figures (revenue and profit); we surface both rather than @@ -54,7 +59,7 @@ export default function DashboardView({ searchQuery }: DashboardViewProps) { const monthlyRevenue = insight ? insight.revenue : null; const monthlyProfit = insight ? insight.profit : null; - const locSummaryQ = useFiestaLocationSummary(FIESTA_TENANT_ID); + const locSummaryQ = useFiestaLocationSummary(tenantId); const summaries = locSummaryQ.data ?? []; // Region fulfillment — live month-to-date delivered ÷ total orders for the tenant. diff --git a/src/components/DeliveriesView.tsx b/src/components/DeliveriesView.tsx index 7eb5f92..c3b852e 100644 --- a/src/components/DeliveriesView.tsx +++ b/src/components/DeliveriesView.tsx @@ -14,6 +14,7 @@ */ 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'; @@ -26,7 +27,7 @@ import { DELIVERY_STATUS, statusColor, BRAND, BRAND_LIGHT, TEXT, TEXT_2, TEXT_3, BORDER, DIVIDER, SURFACE_ALT, tint, soft, edge, } from './consoleUi'; -interface DeliveriesViewProps { searchQuery?: string; locationid?: number; } +interface DeliveriesViewProps { searchQuery?: string; locationid?: number; tenantId?: number; } type DeliveryStatus = 'pending' | 'accepted' | 'arrived' | 'picked' | 'active' | 'skipped' | 'delivered' | 'cancelled'; const STATUS_TABS: Array<{ key: DeliveryStatus; label: string }> = [ @@ -55,34 +56,32 @@ function inBatch(r: Row, b: BatchId): boolean { if (b === 'afternoon') return h >= 9 && h < 12.5; return h >= 16 && h < 19; } -function initialBatch(): BatchId { - const h = new Date().getHours(); - if (h >= 0 && h < 8) return 'morning'; - if (h >= 9 && h < 12.5) return 'afternoon'; - if (h >= 16 && h < 19) return 'evening'; - return 'all'; -} -export default function DeliveriesView({ searchQuery = '', locationid }: DeliveriesViewProps) { +export default function DeliveriesView({ searchQuery = '', locationid, tenantId = FIESTA_TENANT_ID }: DeliveriesViewProps) { const today = new Date(); - const [fromdate, setFromdate] = useState(ymd(today)); - const [todate, setTodate] = useState(ymd(today)); + const monthStart = new Date(today.getFullYear(), today.getMonth(), 1); const dayOffset = (n: number) => { const d = new Date(); d.setDate(d.getDate() - n); return ymd(d); }; + const dayAhead = (n: number) => { const d = new Date(); d.setDate(d.getDate() + n); return ymd(d); }; + const [fromdate, setFromdate] = useState(dayOffset(6)); + const [todate, setTodate] = useState(ymd(today)); const presets = [ - { key: 'today', label: 'Today', from: ymd(today), to: ymd(today) }, - { key: 'yesterday', label: 'Yesterday', from: dayOffset(1), to: dayOffset(1) }, - { key: '7d', label: 'Last 7 Days', from: dayOffset(6), to: ymd(today) }, + { key: 'today', label: 'Today', from: ymd(today), to: ymd(today) }, + { key: '7d', label: 'Last 7 Days', from: dayOffset(6), to: ymd(today) }, + { key: 'month', label: 'This Month', from: ymd(monthStart), to: dayAhead(7) }, ]; const activePreset = presets.find((p) => p.from === fromdate && p.to === todate)?.key ?? 'custom'; - const [batch, setBatch] = useState(initialBatch()); + const batch: BatchId = 'all'; const [status, setStatus] = useState('pending'); const [localSearch, setLocalSearch] = useState(''); const [detailRow, setDetailRow] = useState(null); - const summaryQ = useFiestaDeliverySummary({ tenantid: FIESTA_TENANT_ID, fromdate, todate }); - const deliveriesQ = useFiestaDeliveries({ tenantid: FIESTA_TENANT_ID, fromdate, todate }); - const ridersQ = useFiestaRiders({ tenantid: FIESTA_TENANT_ID }); + // Scope to the user's store when a locationid is supplied (server-side per the + // backend's deliverysummary/getdeliveries locationid param). getDeliveries loads + // the whole day (status='all', large pagesize); status/search filter client-side. + const summaryQ = useFiestaDeliverySummary({ tenantid: tenantId, fromdate, todate, locationid }); + const deliveriesQ = useFiestaDeliveries({ tenantid: tenantId, fromdate, todate, locationid, status: 'all', pagesize: 200 }); + const ridersQ = useFiestaRiders({ tenantid: tenantId }); const allRows = deliveriesQ.data ?? []; const summary = summaryQ.data; @@ -143,70 +142,70 @@ export default function DeliveriesView({ searchQuery = '', locationid }: Deliver
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' }} /> - 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' }} /> + 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' }} />
-
- Wave - {BATCHES.map((b) => { - const Icon = b.icon; - const count = allRows.filter((r) => (!locationid || fnum(r.locationid) === locationid) && inBatch(r, b.id)).length; - return ( - - setBatch(b.id)} title={b.range} count={count}> {b.label} - - ); - })} -
{/* Status tabs + search */}
- {STATUS_TABS.map((t) => { - const color = statusColor(DELIVERY_STATUS, t.key); - return ( - - setStatus(t.key)} count={statusCounts[t.key] ?? 0}>{t.label} - - ); - })} + {STATUS_TABS.map((t) => ( + + setStatus(t.key)} count={statusCounts[t.key] ?? 0}>{t.label} + + ))}
-
+
{/* Table */}
- +
- {['#', 'Status', 'Order', 'Drop', 'Rider', 'ETA', 'KMs', 'Amount', ''].map((h, i) => ())} + {['S.No', 'Tenant', 'Order ID', 'Pickup', 'Delivery', 'Rider', 'KMS', 'Amount', 'Status', 'Notes', 'Action'].map((h, i) => ())} {deliveriesQ.isLoading ? ( - + ) : rows.length === 0 ? ( - + ) : ( rows.map((r, i) => { const st = fstr(r.orderstatus).toLowerCase(); const rider = fstr(r.ridername) || fstr(r.username); - const kms = fnum(r.kms); const actualKms = fnum(r.cumulativekms); + const kms = fnum(r.kms); const actualKms = fnum(r.actualkms) || fnum(r.riderkms); const charge = fnum(r.deliverycharges); const amt = fnum(r.deliveryamt); + const tenant = fstr(r.tenantname) || fstr(r.pickupcustomer); + // Pickup/Delivery: the backend often leaves customer/contact blank for + // app-created jobs but populates the address — fall back so cells aren't bare. + const pickupName = fstr(r.pickupcustomer) || fstr(r.pickupcontactno); + const pickupAddr = fstr(r.pickupsuburb) || fstr(r.pickuplocation) || fstr(r.Pickupaddress) || fstr(r.pickupaddress); + const dropName = fstr(r.deliverycustomer) || fstr(r.deliverycontactno); + 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')}> - + + - + + @@ -262,13 +264,15 @@ function DeliveryDetailModal({ row, onClose }: { row: Row; onClose: () => void } const st = fstr(row.orderstatus).toLowerCase(); const rider = fstr(row.ridername) || fstr(row.username); const steps = [ - { label: 'Assigned', field: 'assigntime' }, { label: 'Accepted', field: 'acceptedtime' }, { label: 'Arrived', field: 'arrivaltime' }, + { label: 'Assigned', field: 'assigntime' }, { label: 'Accepted', field: 'starttime' }, { label: 'Arrived', field: 'arrivaltime' }, { label: 'Picked', field: 'pickuptime' }, { label: 'Delivered', field: 'deliverytime' }, ]; - return ( + // 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)}`}

@@ -280,9 +284,9 @@ function DeliveryDetailModal({ row, onClose }: { row: Row; onClose: () => void } {rider || 'Unassigned'}
-
{fstr(row.deliverycustomer) || 'Customer'}
+
{fstr(row.deliverycustomer) || fstr(row.deliverycontactno) || 'Customer'}
{fstr(row.deliverycontactno) &&
{fstr(row.deliverycontactno)}
} -
{fstr(row.deliveryaddress) || fstr(row.deliverysuburb) || 'Address unavailable'}
+
{fstr(row.deliveryaddress) || fstr(row.deliverylocation) || fstr(row.deliverysuburb) || 'Address unavailable'}
Delivery Timeline @@ -318,6 +322,7 @@ function DeliveryDetailModal({ row, onClose }: { row: Row; onClose: () => void }
-
+
, + document.body, ); } diff --git a/src/components/DeliveryReportsView.tsx b/src/components/DeliveryReportsView.tsx index 5a5d097..19d88ca 100644 --- a/src/components/DeliveryReportsView.tsx +++ b/src/components/DeliveryReportsView.tsx @@ -14,27 +14,23 @@ */ import React, { useMemo, useState } from 'react'; -import { TrendingUp, Clock, CheckCircle2, IndianRupee, Bike, Truck, Calendar, Download, Store, ClipboardList, Route } from 'lucide-react'; -import { useFiestaLocationSummary, useFiestaFleetSummary, useFiestaDeliveries } from '../services/fiestaQueries'; +import { TrendingUp, Clock, CheckCircle2, IndianRupee, Bike, Truck, Calendar, Store } from 'lucide-react'; +import { useFiestaLocationSummary, useFiestaFleetSummary } from '../services/fiestaQueries'; import { FIESTA_TENANT_ID, num as fnum, str as fstr, ymd, type Row } from '../services/fiestaApi'; -import { shortTime } from '../services/fiestaMappers'; -import AwaitingApi from './AwaitingApi'; import { - GradientHeader, KpiStrip, Pill, StatusChip, MetricPill, SearchPill, FilterBar, TH_STYLE, + GradientHeader, KpiStrip, Pill, StatusChip, MetricPill, FilterBar, TH_STYLE, DELIVERY_STATUS, statusColor, BRAND, BRAND_LIGHT, TEXT, TEXT_2, TEXT_3, BORDER, DIVIDER, SURFACE_ALT, tint, soft, edge, ring, } from './consoleUi'; -type ReportTab = 'orders-summary' | 'riders-summary' | 'orders-details' | 'maps'; +type ReportTab = 'orders-summary' | 'riders-summary'; const TABS: Array<{ key: ReportTab; label: string; icon: typeof TrendingUp }> = [ { key: 'orders-summary', label: 'Orders Summary', icon: Store }, { key: 'riders-summary', label: 'Riders Summary', icon: Bike }, - { key: 'orders-details', label: 'Orders Details', icon: ClipboardList }, - { key: 'maps', label: 'Rider Routes', icon: Route }, ]; -interface DeliveryReportsViewProps { searchQuery?: string; } +interface DeliveryReportsViewProps { searchQuery?: string; tenantId?: number; } -export default function DeliveryReportsView({ searchQuery = '' }: DeliveryReportsViewProps) { +export default function DeliveryReportsView({ searchQuery = '', tenantId = FIESTA_TENANT_ID }: DeliveryReportsViewProps) { const today = new Date(); const monthStart = new Date(today.getFullYear(), today.getMonth(), 1); const [fromdate, setFromdate] = useState(ymd(monthStart)); @@ -52,7 +48,7 @@ export default function DeliveryReportsView({ searchQuery = '' }: DeliveryReport return (
- + {/* Tab nav */} @@ -85,15 +81,8 @@ export default function DeliveryReportsView({ searchQuery = '' }: DeliveryReport
- {tab === 'orders-summary' && } - {tab === 'riders-summary' && } - {tab === 'orders-details' && } - {tab === 'maps' && ( -
- Planned routes & live rider logs - -
- )} + {tab === 'orders-summary' && } + {tab === 'riders-summary' && }
); } @@ -115,8 +104,8 @@ function TableShell({ minWidth, head, children, footer }: { minWidth: number; he } // ── Orders Summary (per outlet) ────────────────────────────────────────────────── -function OrdersSummaryReport() { - const q = useFiestaLocationSummary(FIESTA_TENANT_ID); +function OrdersSummaryReport({ tenantId }: { tenantId: number }) { + const q = useFiestaLocationSummary(tenantId); const rows = q.data ?? []; const totals = rows.reduce((a, r) => ({ total: a.total + r.total, pending: a.pending + r.pending, delivered: a.delivered + r.delivered, cancelled: a.cancelled + r.cancelled }), { total: 0, pending: 0, delivered: 0, cancelled: 0 }); const kpis = [ @@ -150,8 +139,8 @@ function OrdersSummaryReport() { } // ── Riders Summary (per rider) ─────────────────────────────────────────────────── -function RidersSummaryReport({ fromdate, todate }: { fromdate: string; todate: string }) { - const q = useFiestaFleetSummary({ tenantid: FIESTA_TENANT_ID, fromdate, todate }); +function RidersSummaryReport({ fromdate, todate, tenantId }: { fromdate: string; todate: string; tenantId: number }) { + const q = useFiestaFleetSummary({ tenantid: tenantId, fromdate, todate }); const rows = q.data ?? []; const mapped = rows.map((r) => ({ name: fstr(r.fullname) || `${fstr(r.firstname)} ${fstr(r.lastname)}`.trim() || fstr(r.username) || `Rider ${fstr(r.userid)}`, @@ -196,97 +185,7 @@ function RidersSummaryReport({ fromdate, todate }: { fromdate: string; todate: s ); } -// ── Orders Details (line-level + CSV) ──────────────────────────────────────────── -const DETAIL_STATUSES = ['all', 'pending', 'accepted', 'arrived', 'picked', 'active', 'delivered', 'skipped', 'cancelled'] as const; -type DetailStatus = (typeof DETAIL_STATUSES)[number]; -function OrdersDetailsReport({ fromdate, todate, searchQuery }: { fromdate: string; todate: string; searchQuery: string }) { - const q = useFiestaDeliveries({ tenantid: FIESTA_TENANT_ID, fromdate, todate }); - const allRows = q.data ?? []; - const [status, setStatus] = useState('all'); - const [localSearch, setLocalSearch] = useState(''); - - const statusCounts = useMemo(() => { - const acc: Record = {}; - for (const r of allRows) { const s = fstr(r.orderstatus).toLowerCase(); acc[s] = (acc[s] ?? 0) + 1; } - return acc; - }, [allRows]); - const rows = useMemo(() => { - const term = (localSearch || searchQuery).toLowerCase(); - return allRows.filter((r) => { - if (status !== 'all' && fstr(r.orderstatus).toLowerCase() !== status) return false; - if (!term) return true; - return [r.orderid, r.deliverycustomer, r.deliveryaddress, r.ridername].some((f) => fstr(f).toLowerCase().includes(term)); - }); - }, [allRows, status, localSearch, searchQuery]); - - const exportCsv = () => { - const headers = ['Order ID', 'Status', 'Rider', 'Customer', 'Suburb', 'Address', 'Assigned', 'Delivered', 'KMs', 'Actual KMs', 'Charges', 'Amount']; - const esc = (v: unknown) => `"${fstr(v).replace(/"/g, '""')}"`; - const lines = rows.map((r) => [r.orderid, r.orderstatus, fstr(r.ridername) || fstr(r.username), r.deliverycustomer, r.deliverysuburb, r.deliveryaddress, shortTime(r.assigntime), shortTime(r.deliverytime), fnum(r.kms), fnum(r.cumulativekms), fnum(r.deliverycharges), fnum(r.deliveryamt)].map(esc).join(',')); - const blob = new Blob([[headers.join(','), ...lines].join('\n')], { type: 'text/csv;charset=utf-8;' }); - const url = URL.createObjectURL(blob); - const a = document.createElement('a'); a.href = url; a.download = `Orders_Detail_${fromdate}_to_${todate}.csv`; a.click(); URL.revokeObjectURL(url); - }; - - return ( -
- -
-
- {DETAIL_STATUSES.map((s) => { - const color = s === 'all' ? BRAND : statusColor(DELIVERY_STATUS, s); - return ( - - setStatus(s)} count={s === 'all' ? allRows.length : statusCounts[s] ?? 0}> - {s} - - - ); - })} -
-
-
- -
-
-
- - {rows.length} rows · {fromdate} → {todate}
}> - {q.isLoading ?
- : rows.length === 0 ? - : rows.map((r, i) => { - const st = fstr(r.orderstatus).toLowerCase(); - const rider = fstr(r.ridername) || fstr(r.username); - const charge = fnum(r.deliverycharges) || fnum(r.deliveryamt); - return ( - (e.currentTarget.style.background = SURFACE_ALT)} onMouseLeave={(e) => (e.currentTarget.style.background = 'transparent')}> - - - - - - - - - - - ); - })} - - - ); -} // ── Total bar (gradient) ───────────────────────────────────────────────────────── function TotalBar({ chips, grand }: { chips: Array<{ label: string; color: string }>; grand?: string }) { diff --git a/src/components/DispatchView.tsx b/src/components/DispatchView.tsx index 6e65908..7025d8f 100644 --- a/src/components/DispatchView.tsx +++ b/src/components/DispatchView.tsx @@ -7,9 +7,9 @@ * Dispatch page — a faithful port of the operations console's dispatch cockpit * (nearle_console/dispatch). It reuses that page's actual stylesheet verbatim * (`./DispatchView.css`, copied from Dispatch.css) and reproduces the same DOM / - * class structure: the `#hdr` bar, `#strat-row` view tabs, `#batch-row` wave - * selector, the 400px `#sidebar` (RIDER DISPATCH header + KPI tiles + rider/zone - * cards + per-trip order cards), and the `#map-wrap` centrepiece. + * class structure: the `#hdr` bar, `#strat-row` view tabs, the 400px `#sidebar` + * (RIDER DISPATCH header + KPI tiles + rider/zone cards + per-trip order cards), + * and the `#map-wrap` centrepiece. * * The source map is a Leaflet canvas of planned-vs-actual rider routes (OSRM * road-snapping, Kalman-smoothed GPS) plus AI rider-assignment posting to @@ -24,8 +24,8 @@ import { Map as MapIcon, MapPin, Bike, - Globe, - Info, + ShoppingBag, + Truck, Package, Ruler, Wallet, @@ -40,11 +40,9 @@ import { ChevronRight, List, Play, - PlugZap, } from 'lucide-react'; import { useFiestaDeliveries, useFiestaRiders } from '../services/fiestaQueries'; import { FIESTA_TENANT_ID, num as fnum, str as fstr, ymd, type Row } from '../services/fiestaApi'; -import { MOCK_DELIVERIES, MOCK_RIDERS } from '../services/dispatchMockData'; import DispatchMap, { type MapPoint } from './DispatchMap'; import './DispatchView.css'; @@ -86,45 +84,14 @@ function pickupLatLon(r: Row): [number, number] | null { return lat && lon ? [lat, lon] : null; } -// ── Batch / wave model (canonical half-open hour ranges, local time) ───────────── -// Mirrors Dispatch.js BATCH_OPTIONS: gaps (8–9, 12:30–16, after 19) are intentional. -type BatchId = 'all' | 'morning' | 'afternoon' | 'evening'; -const BATCHES: Array<{ id: BatchId; label: string; range: string }> = [ - { id: 'all', label: 'All', range: 'Full day' }, - { id: 'morning', label: 'Morning', range: '12 AM – 8 AM' }, - { id: 'afternoon', label: 'Afternoon', range: '9 AM – 12:30 PM' }, - { id: 'evening', label: 'Evening', range: '4 PM – 7 PM' }, -]; -function rowHourFrac(r: Row): number | null { - const raw = fstr(r.assigntime) || fstr(r.deliverytime) || fstr(r.deliverydate); - const m = raw.match(/[ T](\d{1,2}):(\d{2})/); - if (!m) return null; - return Number(m[1]) + Number(m[2]) / 60; -} -function inBatch(r: Row, b: BatchId): boolean { - if (b === 'all') return true; - const h = rowHourFrac(r); - if (h == null) return false; - if (b === 'morning') return h >= 0 && h < 8; - if (b === 'afternoon') return h >= 9 && h < 12.5; - return h >= 16 && h < 19; // evening -} -function initialBatch(): BatchId { - const h = new Date().getHours(); - if (h >= 0 && h < 8) return 'morning'; - if (h >= 9 && h < 12.5) return 'afternoon'; - if (h >= 16 && h < 19) return 'evening'; - return 'all'; -} - // ── View modes (match #strat-row tabs) ─────────────────────────────────────────── -type ViewMode = 'kitchens' | 'zones' | 'riders' | 'all' | 'rider-info'; +type ViewMode = 'kitchens' | 'zones' | 'riders' | 'orders' | 'deliveries'; const VIEW_TABS: Array<{ id: ViewMode; label: string; icon: typeof MapIcon }> = [ { id: 'kitchens', label: 'By Location', icon: MapPin }, { id: 'zones', label: 'By Zone', icon: MapIcon }, { id: 'riders', label: 'By Rider', icon: Bike }, - { id: 'all', label: 'All Routes', icon: Globe }, - { id: 'rider-info', label: 'Rider Info', icon: Info }, + { id: 'orders', label: 'By Orders', icon: ShoppingBag }, + { id: 'deliveries', label: 'By Deliveries', icon: Truck }, ]; interface Group { @@ -142,15 +109,15 @@ interface Group { interface DispatchViewProps { 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 DispatchView({ locationid }: DispatchViewProps) { +export default function DispatchView({ locationid, tenantId = FIESTA_TENANT_ID }: DispatchViewProps) { const today = new Date(); const [date, setDate] = useState(ymd(today)); - const [batch, setBatch] = useState(initialBatch()); const [viewMode, setViewMode] = useState('riders'); const [focusedId, setFocusedId] = useState(null); const [sidebarCollapsed, setSidebarCollapsed] = useState(false); @@ -158,37 +125,26 @@ export default function DispatchView({ locationid }: DispatchViewProps) { const [animateNonce, setAnimateNonce] = useState(0); const [animating, setAnimating] = useState(false); - const deliveriesQ = useFiestaDeliveries({ tenantid: FIESTA_TENANT_ID, fromdate: date, todate: date }); - const ridersQ = useFiestaRiders({ tenantid: FIESTA_TENANT_ID }); + const deliveriesQ = useFiestaDeliveries({ tenantid: tenantId, fromdate: date, todate: date, locationid }); + const ridersQ = useFiestaRiders({ tenantid: tenantId }); - // Sample-data fallback: when the live feed returns nothing, render the demo set - // so the cockpit isn't blank. The header labels it "Sample data" so it's never - // mistaken for live (see services/dispatchMockData.ts). - const liveRows = deliveriesQ.data ?? []; - const usingMock = !deliveriesQ.isLoading && !deliveriesQ.isError && liveRows.length === 0; - const allRows = usingMock ? MOCK_DELIVERIES : liveRows; - // Sample rows aren't tied to the signed-in store, so skip the outlet filter for them. - const inScope = (r: Row) => usingMock || !locationid || fnum(r.locationid) === locationid; + // Live deliveries only — no sample/demo fallback. When the feed is empty the + // cockpit shows a genuine empty state rather than fabricated riders/stops. + const allRows = deliveriesQ.data ?? []; + const inScope = (r: Row) => !locationid || fnum(r.locationid) === locationid; const rows = useMemo( - () => allRows.filter((r) => inScope(r) && inBatch(r, batch)), + () => allRows.filter(inScope), // eslint-disable-next-line react-hooks/exhaustive-deps - [allRows, batch, locationid, usingMock], + [allRows, locationid], ); - const batchCounts = useMemo(() => { - const acc: Record = { all: 0, morning: 0, afternoon: 0, evening: 0 }; - const scoped = allRows.filter(inScope); - for (const b of BATCHES) acc[b.id] = scoped.filter((r) => inBatch(r, b.id)).length; - return acc; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [allRows, locationid, usingMock]); - // ── Grouping ──────────────────────────────────────────────────────────────── const groups = useMemo(() => { const map = new Map(); + const titleCase = (s: string) => (s ? s.charAt(0).toUpperCase() + s.slice(1) : s); const keyOf = (r: Row): { id: string; name: string } => { - if (viewMode === 'riders' || viewMode === 'rider-info') { + if (viewMode === 'riders') { const id = fstr(r.userid) || fstr(r.ridername) || 'unassigned'; return { id, name: fstr(r.ridername) || fstr(r.username) || (id === 'unassigned' ? 'Unassigned' : `Rider ${id}`) }; } @@ -196,7 +152,16 @@ export default function DispatchView({ locationid }: DispatchViewProps) { const name = fstr(r.pickupcustomer) || fstr(r.pickuplocation) || 'Pickup'; return { id: name.toLowerCase(), name }; } - if (viewMode === 'all') return { id: 'all', name: 'All Routes' }; + if (viewMode === 'orders') { + // Bucket by ORDER status (created / pending / processing / delivered / cancelled). + const s = fstr(r.orderstatus).toLowerCase() || 'unknown'; + return { id: `o:${s}`, name: titleCase(s) }; + } + if (viewMode === 'deliveries') { + // Bucket by DELIVERY/dispatch status (falls back to order status, then unassigned). + const s = (fstr(r.deliverystatus) || fstr(r.orderstatus)).toLowerCase() || 'unassigned'; + return { id: `d:${s}`, name: titleCase(s) }; + } const name = fstr(r.deliverysuburb) || fstr(r.zone_name) || 'Unzoned'; return { id: name.toLowerCase(), name }; }; @@ -222,7 +187,7 @@ export default function DispatchView({ locationid }: DispatchViewProps) { }, [rows, viewMode]); const focused = groups.find((g) => g.id === focusedId) ?? null; - const groupedByRider = viewMode === 'zones' || viewMode === 'kitchens' || viewMode === 'all'; + const groupedByRider = viewMode !== 'riders'; // Trip blocks for the focused group: by trip# (rider view) or by rider (zone/all view). const tripBlocks = useMemo(() => { @@ -264,7 +229,7 @@ export default function DispatchView({ locationid }: DispatchViewProps) { }, [focused, groupedByRider, tripSort]); // Map points: the focused group's ordered stops (with a route), else every stop - // in the wave (coloured per rider). Rows without coordinates are skipped. + // for the day (coloured per rider). Rows without coordinates are skipped. const mapPoints = useMemo(() => { const src = focused ? tripBlocks.flatMap((b) => b.orders) : rows; const out: MapPoint[] = []; @@ -293,8 +258,7 @@ export default function DispatchView({ locationid }: DispatchViewProps) { // KPI scope. const totalOrders = rows.length; const activeRiders = new Set(rows.map((r) => fstr(r.userid) || fstr(r.ridername)).filter(Boolean)).size; - const fleetSize = usingMock ? MOCK_RIDERS.length : (ridersQ.data ?? []).length; - const scopeLabel = BATCHES.find((b) => b.id === batch)?.label ?? 'All'; + const fleetSize = (ridersQ.data ?? []).length; // Date chip helpers. const isToday = date === ymd(today); @@ -338,9 +302,9 @@ export default function DispatchView({ locationid }: DispatchViewProps) { Offline - ) : usingMock ? ( - - Sample data · {totalOrders} orders + ) : totalOrders === 0 ? ( + + No deliveries today ) : ( @@ -383,7 +347,7 @@ export default function DispatchView({ locationid }: DispatchViewProps) { return ( - ))} - - - {/* ── Body ── */}
@@ -466,15 +412,15 @@ export default function DispatchView({ locationid }: DispatchViewProps) { fmtTime={fmtTime} /> ) : groups.length === 0 ? ( -
No deliveries in this wave
+
No deliveries for this day
) : ( <>
- {viewMode === 'riders' || viewMode === 'rider-info' ? 'Riders' : viewMode === 'kitchens' ? 'Pickup points' : viewMode === 'all' ? 'All routes' : 'Zones'} ({groups.length}) + {viewMode === 'riders' ? 'Riders' : viewMode === 'kitchens' ? 'Pickup points' : viewMode === 'orders' ? 'Order statuses' : viewMode === 'deliveries' ? 'Delivery statuses' : 'Zones'} ({groups.length})
{groups.map((g) => ( - {viewMode === 'riders' || viewMode === 'rider-info' + {viewMode === 'riders' ? setFocusedId(g.id)} /> : setFocusedId(g.id)} />} @@ -492,22 +438,18 @@ export default function DispatchView({ locationid }: DispatchViewProps) { route={Boolean(focused)} routeColor={focused?.color || '#581c87'} start={routeStart} - resizeKey={`${sidebarCollapsed}|${viewMode}|${focusedId}|${batch}`} + resizeKey={`${sidebarCollapsed}|${viewMode}|${focusedId}`} animateNonce={animateNonce} /> {/* Contextual note overlaid on the map */} - {viewMode === 'rider-info' ? ( + {mapPoints.length === 0 ? (
- Live rider telemetry (battery · GPS · speed) awaiting backend — map shows planned drops. -
- ) : mapPoints.length === 0 ? ( -
- No drop coordinates in this {focused ? 'route' : 'wave'} yet. + No drop coordinates in {focused ? 'this route' : 'these deliveries'} yet.
) : !focused ? (
- Select a {viewMode === 'kitchens' ? 'pickup point' : viewMode === 'zones' ? 'zone' : 'rider'} to draw its route. + Select a {viewMode === 'kitchens' ? 'pickup point' : viewMode === 'zones' ? 'zone' : viewMode === 'riders' ? 'rider' : 'group'} to draw its route.
) : null} diff --git a/src/components/Header.tsx b/src/components/Header.tsx index f58bb59..68f6b19 100644 --- a/src/components/Header.tsx +++ b/src/components/Header.tsx @@ -4,7 +4,7 @@ */ import React, { useState, useRef, useEffect } from 'react'; -import { Menu, HelpCircle, LogOut, ChevronDown, Mail } from 'lucide-react'; +import { Menu, HelpCircle, LogOut, ChevronDown, Mail, QrCode, User } from 'lucide-react'; import { MainSection } from '../types'; interface HeaderProps { @@ -17,6 +17,10 @@ interface HeaderProps { isSidebarOpen: boolean; onHelpClick: () => void; onLogoutClick: () => void; + /** When provided, shows a "My Account" item in the profile dropdown (user store page). */ + onAccountClick?: () => void; + /** When provided, shows a Store QR button on the right of the navbar (user store page). */ + onQrClick?: () => void; /** Signed-in user shown in the profile dropdown. */ profile: { name: string; role: string; email: string }; } @@ -26,6 +30,8 @@ export default function Header({ isSidebarOpen, onHelpClick, onLogoutClick, + onAccountClick, + onQrClick, profile }: HeaderProps) { const [showProfileDropdown, setShowProfileDropdown] = useState(false); @@ -81,6 +87,18 @@ export default function Header({ {/* Global Actions Bar */}
+ {/* Store QR — opens the QR modal (user store page only) */} + {onQrClick && ( + + )} + {/* User profile with dropdown */}
+ )}
@@ -405,7 +410,7 @@ export default function InventoryView({ {/* Card 4: Catalog Health */}
- Catalog Sync Ratio + Catalogue Sync Ratio
@@ -489,18 +494,26 @@ export default function InventoryView({ {/* Global Catalog — master assortment grid (full width) */}
-

- Global Catalog Assortment -

- - {filteredProducts.length} item{filteredProducts.length === 1 ? '' : 's'} loaded - +
+

+ Global Catalogue Assortment +

+

Pick products & set quantities — selected items appear in every store's catalogue.

+
+
+ + {storeCat.items.length} in store catalogue + + + {filteredProducts.length} item{filteredProducts.length === 1 ? '' : 's'} loaded + +
{storesLoading && products.length === 0 ? (
Synchronizing regional database...
) : filteredProducts.length === 0 ? ( -
No catalog products match your selection.
+
No catalogue products match your selection.
) : (
{filteredProducts.map((prod) => ( @@ -565,6 +578,26 @@ export default function InventoryView({
+ + {/* Store-catalogue curation: pick the product + quantity to show to store users */} + {storeCat.has(prod.id) ? ( +
+ In Store Catalogue +
+ + {storeCat.getQty(prod.id)} + + +
+
+ ) : ( + + )}
))}
@@ -874,10 +907,10 @@ export default function InventoryView({
-

Cooperative Catalog Presets

+

Cooperative Catalogue Presets

- +
{/* Custom CSV Parsing Box */} @@ -955,7 +988,7 @@ export default function InventoryView({

- Introduce New Grocery Catalog SKU + Introduce New Grocery Catalogue SKU

+
+
+ + + {/* Table */} +
+
+
{h}
{h}
Loading deliveries…
Loading deliveries…
No {status} deliveries in this wave. Try another status, wave, or date.
No {status} deliveries in this wave. Try another status, wave, or date.
{i + 1} +

{tenant || '—'}

+ {fstr(r.tenantcity) &&

{fstr(r.tenantcity)}

} +

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

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

-

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

-

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

+

{pickupName || pickupAddr || '—'}

+

{pickupName ? pickupAddr : ''}

+
+

{dropName || dropAddr || '—'}

+

{dropName ? dropAddr : ''}

{rider ? ( @@ -216,7 +215,6 @@ export default function DeliveriesView({ searchQuery = '', locationid }: Deliver ) : Unassigned} {shortTime(r.expecteddeliverytime) || '—'}
{kms ? kms.toFixed(1) : '—'} @@ -230,6 +228,10 @@ export default function DeliveriesView({ searchQuery = '', locationid }: Deliver {charge === 0 && amt === 0 && }
+

{notes || '—'}

+
Loading order details…
No deliveries match this filter.
{i + 1} -

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

-

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

-
-

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

-

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

-
{rider || '—'}{shortTime(r.assigntime) || '—'}{fstr(r.deliverytime) ? shortTime(r.deliverytime) : '—'}{fnum(r.kms) ? {fnum(r.kms).toFixed(1)} : }{charge > 0 ? ₹{charge.toLocaleString('en-IN')} : }
+ + + {['#', 'Order', 'Branch', 'Pickup', 'Drop', 'Qty', 'COD', 'Amount', 'Status', ''].map((h, i) => ( + + ))} + + + + {allOrdersQ.isLoading ? ( + + ) : pageRows.length === 0 ? ( + + ) : ( + 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')} + > + + + + + + + + + + + + ); + }) + )} + +
{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)}`}

+

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

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

{fstr(r.locationname)}

} +
+

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

+

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

+
+

{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 */} + {rows.length > 0 && ( +
+ + Totals · {rows.length} order{rows.length === 1 ? '' : 's'} + + {totals.cod > 0 && } + +
+ )} + + {/* Pagination */} +
+ + Page {pageno} · {pageRows.length} of {rows.length} shown + +
+ setPageno((p) => Math.max(1, p - 1))}> + Prev + + setPageno((p) => p + 1)}> + Next + +
+
+
+ + {detailOrder && setDetailOrder(null)} />} + + ); +} + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +function TotalChip({ label, value, color }: { label: string; value: string; color: string }) { + return ( + + {label} + {value} + + ); +} + +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; + const detailsQ = useFiestaOrderDetails(orderheaderid as number | string); + const lines = (detailsQ.data ?? []).map((row) => { + const quantity = fnum(row.quantity) || fnum(row.qty) || fnum(row.orderqty); + const price = fnum(row.price) || fnum(row.unitprice) || fnum(row.retailprice); return { name: fstr(row.productname) || fstr(row.itemname) || 'Item', quantity, price, - lineTotal, + lineTotal: fnum(row.amount) || fnum(row.productsumprice) || price * quantity, }; }); - return ( -
- - {/* View Header with Statistics Overview */} -
-
-

- Orders & Delivery Operations -

-

- Real-time tracking of app orders, dispatch queues, and active delivery partners across Coimbatore regional sub-hubs. -

-
- {deliveriesQ.isLoading ? ( - - Loading live deliveries… - - ) : deliveriesQ.isError ? ( - - Live data unavailable - - ) : ( - - Live · {orders.length} deliveries · {executives.length} riders - - )} -
-
-
+ const st = fstr(order.orderstatus).toLowerCase(); + const total = fnum(order.ordervalue) || fnum(order.orderamount) || fnum(order.deliveryamt); + const rider = fstr(order.ridername) || fstr(order.username); - {/* Top Level Delivery Performance Indicators */} -
- -
-
- -
-
-

Deliveries in Range

-

{totalDeliveriesCount.toLocaleString('en-IN')} total

-

{fromdate === todate ? fromdate : `${fromdate} → ${todate}`}

-
+ const STEPS = [ + { label: 'Order Placed', field: 'orderdate' }, + { label: 'Confirmed', field: 'starttime' }, + { label: 'Packed & Ready', field: 'packtime' }, + { label: 'Out for Delivery',field: 'pickuptime' }, + { label: 'Delivered', field: 'deliverytime' }, + ]; + + return createPortal( +
{ if (e.target === e.currentTarget) onClose(); }} + > +
+ {/* Brand accent bar */} +
+ + {/* Modal header */} +
+

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

+
-
-
- -
-
-

Pending Fulfilment

-

- {pendingFulfillmentCount + activeDispatchCount} active -

-

Awaiting dispatch / in transit

-
-
- -
-
- -
-
-

Successful Deliveries

-

- {completedDeliveriesCount} done -

-

{locationid ? 'At this location' : 'Across all locations'}

-
-
- -
-
- -
-
-

Active Delivery Fleet

-

- {executives.filter(e => e.status !== 'Offline').length} partners -

-

{executives.length} riders registered

-
-
- -
- - {/* Day-wise date filter — drives the live deliveries + summary queries */} -
-
- - View - - {presets.map((p) => ( - - ))} -
- -
-
- - setFromdate(e.target.value)} - className="border border-[#e2e8f0] rounded-lg px-2 py-1.5 text-zinc-700 font-medium outline-none focus:ring-1 focus:ring-[#581c87] cursor-pointer" - /> -
- -
- - setTodate(e.target.value)} - className="border border-[#e2e8f0] rounded-lg px-2 py-1.5 text-zinc-700 font-medium outline-none focus:ring-1 focus:ring-[#581c87] cursor-pointer" - /> -
-
-
- - {/* Main interactive segment splits */} -
- - {/* Left List of Customer App Orders */} -
-
-
-
-
-
-

- Customer Orders Feed ({filteredOrdersList.length}) -

-

Interactive list of customer purchases made via client app

-
-
- -
- {/* Local Search Input */} -
- - setLocalSearch(e.target.value)} - className="w-full pl-8 pr-4 py-1.5 border border-[#e2e8f0] rounded-lg text-[11px] outline-none bg-white focus:ring-1 focus:ring-[#581c87] transition-all" - /> -
- - {/* Filter Status buttons */} -
- {['ALL', 'PROCESSING', 'CONFIRMED', 'OUT_FOR_DELIVERY', 'DELIVERED'].map((st) => ( - - ))} -
-
-
- - {/* Order item rows — flex-fills the column so the feed matches the Order Details card height */} -
- {filteredOrdersList.length === 0 ? ( -
- No orders matching status filter found. Try another query or adjust the date range. -
- ) : ( - filteredOrdersList.map(order => ( -
setSelectedOrder(order)} - className={`p-md flex items-center justify-between hover:bg-zinc-50 border-l-4 transition-all cursor-pointer ${ - selectedOrder?.id === order.id ? 'bg-[#faf5ff]/50 border-[#581c87]' : 'border-transparent' - }`} - > -
-
- {order.customerName} - • {order.time} -
-

{order.address}

-
- {order.hub} - {order.itemCount ?? order.items.length} Items -
-
- -
-

₹{order.amount.toLocaleString()}

- - {order.status.replace(/_/g, ' ')} - -
-
- )) - )} -
+ {/* Body */} +
+ {/* Status + rider */} +
+ +
+ {rider && ( + + {rider} + + )} + {shortTime(order.orderdate || order.deliverydate)}
-
- {/* Right column — Order Details, shown parallel to the orders feed */} -
- {selectedOrder ? ( -
- - Order Details: {selectedOrder.id} - - - {/* Customer summary */} -
-
- Customer Name - {selectedOrder.customerName} -
-
- Contact info - {selectedOrder.phone} -
-
- Delivery Address -

{selectedOrder.address}

-
+ {/* Customer card */} +
+
+ {fstr(order.deliverycustomer) || 'Customer'} +
+ {fstr(order.deliverycontactno) && ( +
+ {fstr(order.deliverycontactno)}
+ )} +
+ + {fstr(order.deliveryaddress) || fstr(order.deliverysuburb) || 'Address unavailable'} +
+
- {/* Category items description list */} -
- Ordered Grocery basket Items: -
- {orderDetailsQ.isLoading && ( -
- Loading order line items… -
- )} - {!orderDetailsQ.isLoading && orderItems.length === 0 && ( -
- {selectedOrder.itemCount ?? 0} line item(s) - Detail lines not loaded on board view -
- )} - {orderItems.map((item, idx) => ( -
-
-

{item.name}

-

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

-
- ₹{item.lineTotal} -
- ))} -
- Grand Total Invoice - ₹{selectedOrder.amount.toLocaleString()} + {/* Delivery timeline */} +
+ + Delivery Timeline + +
+ {STEPS.map((s) => { + const ts = fstr(order[s.field]); + const done = Boolean(ts); + return ( +
+ + {s.label} + {done ? shortTime(ts) : '—'}
-
-
+ ); + })} +
+
- {/* Live GPS route tracker — no rider-telemetry/GPS API yet */} - {selectedOrder.status === 'OUT_FOR_DELIVERY' && ( -
- - LIVE GPS ROUTE TRACKER - - + {/* Line items */} +
+ + Items + +
+ {detailsQ.isLoading && ( +
+ Loading line items…
)} - - {/* Delivery tracking visual roadmap layout */} -
- - Live Dispatch Timeline Tracker - - -
-
- -
-
Order Received ({selectedOrder.time})
-

Placed via customer app cart checkout successfully.

-
-
- -
- -
-
Assortment Packaged & Bagged
-

Verified fresh produce items in-stock levels.

-
-
- -
- -
-
Out for Delivery
-

Dispatched with executive partner on bike route.

-
-
- -
- -
-
Handover Verified
-

Delivered directly to door step location.

-
-
+ {!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')} +
+ ))} + {total > 0 && ( +
+ Order Total + ₹{total.toLocaleString('en-IN')} +
+ )}
- ) : ( -
- Select any customer order from the feed to view its details. -
- )} +
+ {/* Footer */} +
+ +
-
+
, + document.body, ); } diff --git a/src/components/OrdersView.tsx b/src/components/OrdersView.tsx index 2115a4f..fb9be6b 100644 --- a/src/components/OrdersView.tsx +++ b/src/components/OrdersView.tsx @@ -11,19 +11,22 @@ * to the live Fiesta order endpoints (status-scoped, date-ranged, paginated). */ -import React, { useMemo, useState } from 'react'; -import { ShoppingBag, Clock, CheckCircle2, XCircle, Calendar, ChevronLeft, ChevronRight, Package, MapPin, Phone, X, Loader2 } from 'lucide-react'; -import { useFiestaOrderSummary, useFiestaOrders, useFiestaOrderDetails } from '../services/fiestaQueries'; +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 { useFiestaOrderSummary, useFiestaOrders, useFiestaOrderDetails, useFiestaRiders, useFiestaAssignRider } from '../services/fiestaQueries'; 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, MetricPill, SearchPill, FilterBar, TH_STYLE, - ORDER_STATUS, statusColor, BRAND, TEXT, TEXT_2, TEXT_3, BORDER, SURFACE_ALT, tint, soft, edge, + ORDER_STATUS, statusColor, BRAND, BRAND_LIGHT, TEXT, TEXT_2, TEXT_3, BORDER, SURFACE_ALT, tint, soft, edge, ring, } from './consoleUi'; interface OrdersViewProps { searchQuery?: string; locationid?: number; + /** Merchant tenant to scope to; defaults to the shared constant. */ + tenantId?: number; } type StatusKey = 'created' | 'pending' | 'processing' | 'delivered' | 'cancelled'; @@ -36,57 +39,167 @@ const STATUS_TABS: Array<{ key: StatusKey; label: string }> = [ ]; const PAGE_SIZE = 25; -export default function OrdersView({ searchQuery = '', locationid }: OrdersViewProps) { +export default function OrdersView({ searchQuery = '', locationid, tenantId = FIESTA_TENANT_ID }: OrdersViewProps) { const today = new Date(); const monthStart = new Date(today.getFullYear(), today.getMonth(), 1); const [fromdate, setFromdate] = useState(ymd(today)); const [todate, setTodate] = useState(ymd(today)); const dayOffset = (n: number) => { const d = new Date(); d.setDate(d.getDate() - n); return ymd(d); }; + const dayAhead = (n: number) => { const d = new Date(); d.setDate(d.getDate() + n); return ymd(d); }; + // NOTE: the backend lists orders by DELIVERY date (deliverytime), not creation + // date — so an order created today for a future slot only appears once the range + // covers its delivery date. "Next 7 Days" surfaces upcoming-delivery orders. + // "All time" can't pass empty dates (the query is gated on from/to), so it uses + // a wide window — from the platform's earliest plausible data to a year ahead. const presets = [ - { key: 'today', label: 'Today', from: ymd(today), to: ymd(today) }, - { key: 'yesterday', label: 'Yesterday', from: dayOffset(1), to: dayOffset(1) }, - { key: '7d', label: 'Last 7 Days', from: dayOffset(6), to: ymd(today) }, - { key: '30d', label: 'Last 30 Days', from: dayOffset(29), to: ymd(today) }, - { key: 'month', label: 'This Month', from: ymd(monthStart), to: ymd(today) }, + { key: 'today', label: 'Today', from: ymd(today), to: ymd(today) }, + { key: '7d', label: 'Last 7 Days', from: dayOffset(6), to: ymd(today) }, + { key: 'month', label: 'This Month', from: ymd(monthStart), to: dayAhead(7) }, ]; const activePreset = presets.find((p) => p.from === fromdate && p.to === todate)?.key ?? 'custom'; const [status, setStatus] = useState('created'); const [pageno, setPageno] = useState(1); const [localSearch, setLocalSearch] = useState(''); + const [branch, setBranch] = useState(0); // applocationid filter (0 = all branches) const [detailOrder, setDetailOrder] = useState(null); - const summaryQ = useFiestaOrderSummary(FIESTA_TENANT_ID, fromdate, todate); - const ordersQ = useFiestaOrders({ tenantid: FIESTA_TENANT_ID, status, fromdate, todate, pageno, pagesize: PAGE_SIZE }); + // ── Multi-select rider assignment (parity with the ops console) ───────────── + const [selected, setSelected] = useState>(new Set()); + const [assignRiderId, setAssignRiderId] = useState(0); + const [assignMsg, setAssignMsg] = useState(''); + const [showSelected, setShowSelected] = useState(false); // full-page review of selection + const assignMut = useFiestaAssignRider(); + + // Ctrl/Cmd+K focuses search; Escape blurs it (parity with the ops console). + const searchRef = useRef(null); + useEffect(() => { + const onKey = (e: KeyboardEvent) => { + if (e.key === 'k' && (e.metaKey || e.ctrlKey)) { + e.preventDefault(); + searchRef.current?.focus(); + } else if (e.key === 'Escape' && document.activeElement === searchRef.current) { + searchRef.current?.blur(); + } + }; + document.addEventListener('keydown', onKey); + return () => document.removeEventListener('keydown', onKey); + }, []); + + // Reset the selection whenever the visible result set changes, so an assign + // can never act on rows the operator can no longer see. + useEffect(() => { + setSelected(new Set()); + setAssignMsg(''); + setShowSelected(false); + }, [fromdate, todate, status, branch, pageno, locationid]); + + // Scope to the user's store when a locationid is supplied (server-side per the + // backend's getordersummary/getorders locationid param); tenant-wide otherwise. + const summaryQ = useFiestaOrderSummary(tenantId, fromdate, todate, locationid); + const ordersQ = useFiestaOrders({ tenantid: tenantId, status, fromdate, todate, locationid, pageno, pagesize: PAGE_SIZE }); const summary = summaryQ.data; const rawRows = ordersQ.data ?? []; + // Riders must share the orders' tenant + partner to be assignable (the backend + // rejects cross-tenant/partner riders), so derive the partner/app-location from + // the live order rows and scope the rider list to them. An out-of-tenant rider + // simply won't appear — the intended guard. + const orderPartnerId = useMemo(() => fnum(rawRows.find((r) => fnum(r.partnerid))?.partnerid), [rawRows]); + const orderApplocationId = useMemo(() => fnum(rawRows.find((r) => fnum(r.applocationid))?.applocationid), [rawRows]); + const ridersQ = useFiestaRiders({ + tenantid: tenantId, + applocationid: orderApplocationId || undefined, + partnerid: orderPartnerId || undefined, + }); + const riderOptions = useMemo( + () => + (ridersQ.data ?? []) + .map((r) => ({ + id: fnum(r.userid), + label: `${fstr(r.firstname)} ${fstr(r.lastname)}`.trim() + (fstr(r.contactno) ? ` · ${fstr(r.contactno)}` : ''), + })) + .filter((o) => o.id > 0 && o.label), + [ridersQ.data], + ); + + // Branches (app-locations) present in the data — drives the branch filter so the + // operator can see which branch an order was placed at. Each order row carries + // applocationid + applocation (the app-location name). + const branches = useMemo(() => { + const m = new Map(); + for (const r of rawRows) { + const id = fnum(r.applocationid); + if (id && !m.has(id)) m.set(id, fstr(r.applocation) || fstr(r.locationname) || `Branch ${id}`); + } + return [...m.entries()].map(([id, name]) => ({ id, name })); + }, [rawRows]); + const rows = useMemo(() => { const term = (localSearch || searchQuery).toLowerCase(); return rawRows.filter((r) => { if (locationid && fnum(r.locationid) !== locationid) return false; + if (branch && fnum(r.applocationid) !== branch) return false; if (!term) return true; - return ( - fstr(r.orderid).toLowerCase().includes(term) || - fstr(r.deliverycustomer).toLowerCase().includes(term) || - fstr(r.pickupcustomer).toLowerCase().includes(term) || - fstr(r.deliveryaddress).toLowerCase().includes(term) || - fstr(r.deliverysuburb).toLowerCase().includes(term) - ); + // Broad match across every order field shown or relevant (mirrors the ops + // console search): id, both parties + contacts + addresses, branch, rider, + // status, and notes. + return [ + r.orderid, r.orderstatus, r.ordernotes, r.tenantname, + r.pickupcustomer, r.pickupcontactno, r.pickupsuburb, r.pickupaddress, r.pickuplocation, + r.deliverycustomer, r.deliverycontactno, r.deliverysuburb, r.deliveryaddress, r.deliverylocation, + r.applocation, r.locationname, r.ridername, + ].some((v) => fstr(v).toLowerCase().includes(term)); }); - }, [rawRows, localSearch, searchQuery, locationid]); + }, [rawRows, localSearch, searchQuery, locationid, branch]); + + // Footer totals across the filtered rows (parity with the ops console's + // Total Charges / Total Amount summary). + const totals = useMemo(() => { + let cod = 0, charges = 0, amount = 0; + for (const r of rows) { + cod += fnum(r.collectionamt); + charges += fnum(r.deliverycharge) || fnum(r.deliverycharges); + amount += fnum(r.orderamount) || fnum(r.deliveryamt); + } + return { cod, charges, amount }; + }, [rows]); + + const inr = (n: number) => `₹${n.toLocaleString('en-IN')}`; + + // Export the currently-filtered orders to CSV (RFC-4180 quoting). + const exportCsv = () => { + const headers = ['#', 'Order ID', 'Status', 'Branch', 'Order Date', 'Pickup', 'Pickup Contact', 'Pickup Address', 'Drop', 'Drop Contact', 'Drop Address', 'Qty', 'COD', 'KMs', 'Charges', 'Amount']; + const esc = (v: unknown) => `"${fstr(v).replace(/"/g, '""')}"`; + const lines = rows.map((r, i) => [ + i + 1, fstr(r.orderid) || fstr(r.orderheaderid), fstr(r.orderstatus), fstr(r.applocation) || fstr(r.locationname), + shortTime(r.orderdate || r.deliverydate), fstr(r.pickupcustomer) || fstr(r.tenantname), fstr(r.pickupcontactno), + fstr(r.pickupaddress) || fstr(r.pickupsuburb), fstr(r.deliverycustomer), fstr(r.deliverycontactno), + fstr(r.deliveryaddress) || fstr(r.deliverysuburb), fnum(r.quantity), fnum(r.collectionamt), + fnum(r.kms), fnum(r.deliverycharge) || fnum(r.deliverycharges), fnum(r.orderamount) || fnum(r.deliveryamt), + ].map(esc).join(',')); + const blob = new Blob([[headers.join(','), ...lines].join('\n')], { type: 'text/csv;charset=utf-8;' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `Orders_${status}_${fromdate}_to_${todate}.csv`; + a.click(); + URL.revokeObjectURL(url); + }; const hasNext = rawRows.length === PAGE_SIZE; const total = summary?.total ?? 0; const pct = (n: number) => (total > 0 ? Math.round((n / total) * 100) : 0); const countFor = (key: StatusKey): number => (summary ? (summary[key] ?? 0) : 0); + // Restrained, professional palette — deep muted tones (not neon) so the KPI + // strip reads as a serious business dashboard rather than a colourful one. const kpis = [ - { label: 'Created Orders', value: (summary?.created ?? 0).toLocaleString('en-IN'), color: '#0ea5e9', icon: , badge: `${pct(summary?.created ?? 0)}% of total` }, - { label: 'Pending Orders', value: (summary?.pending ?? 0).toLocaleString('en-IN'), color: '#f59e0b', icon: , badge: `${pct(summary?.pending ?? 0)}% of total` }, - { label: 'Delivered Orders', value: (summary?.delivered ?? 0).toLocaleString('en-IN'), color: '#10b981', icon: , badge: `${pct(summary?.delivered ?? 0)}% of total` }, - { label: 'Cancelled Orders', value: (summary?.cancelled ?? 0).toLocaleString('en-IN'), color: '#ef4444', icon: , badge: `${pct(summary?.cancelled ?? 0)}% of total` }, + { label: 'Created Orders', value: (summary?.created ?? 0).toLocaleString('en-IN'), color: '#475569', icon: , badge: `${pct(summary?.created ?? 0)}% of total` }, + { label: 'Pending Orders', value: (summary?.pending ?? 0).toLocaleString('en-IN'), color: '#9a6700', icon: , badge: `${pct(summary?.pending ?? 0)}% of total` }, + { label: 'Delivered Orders', value: (summary?.delivered ?? 0).toLocaleString('en-IN'), color: '#15803d', icon: , badge: `${pct(summary?.delivered ?? 0)}% of total` }, + { label: 'Cancelled Orders', value: (summary?.cancelled ?? 0).toLocaleString('en-IN'), color: '#b42318', icon: , badge: `${pct(summary?.cancelled ?? 0)}% of total` }, ]; const setScope = (next: Partial<{ status: StatusKey; from: string; to: string }>) => { @@ -96,6 +209,46 @@ export default function OrdersView({ searchQuery = '', locationid }: OrdersViewP setPageno(1); }; + // ── Selection helpers ─────────────────────────────────────────────────────── + const rowKey = (r: Row) => fstr(r.orderheaderid) || fstr(r.orderid); + const pageKeys = rows.map(rowKey); + const allSelected = pageKeys.length > 0 && pageKeys.every((k) => selected.has(k)); + const toggleRow = (k: string) => + setSelected((prev) => { + const n = new Set(prev); + if (n.has(k)) n.delete(k); + else n.add(k); + return n; + }); + const toggleAll = () => + setSelected((prev) => { + const n = new Set(prev); + if (allSelected) pageKeys.forEach((k) => n.delete(k)); + else pageKeys.forEach((k) => n.add(k)); + return n; + }); + + const handleAssign = async () => { + if (!assignRiderId || selected.size === 0) return; + const toAssign = rows.filter((r) => selected.has(rowKey(r))); + const rider = riderOptions.find((o) => o.id === assignRiderId)?.label ?? 'rider'; + try { + const res = await assignMut.mutateAsync({ userid: assignRiderId, orders: toAssign }); + setAssignMsg( + res.failed + ? `Assigned ${res.ok}/${res.total} to ${rider} · ${res.failed} failed` + : `Assigned ${res.ok} order${res.ok === 1 ? '' : 's'} to ${rider}`, + ); + setSelected(new Set()); + setShowSelected(false); // return to the board with the result shown in the bar + } catch { + setAssignMsg('Assignment failed — please retry.'); + } + }; + + // Rows currently selected (selection is always within the visible page). + const selectedRows = useMemo(() => rows.filter((r) => selected.has(rowKey(r))), [rows, selected]); + return (
setScope({ from: 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' }} /> + className="rounded-full outline-none font-semibold" style={{ padding: '6px 12px', border: `1px solid ${BORDER}`, background: '#fff', color: TEXT_2 }} /> - setScope({ to: 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' }} /> + 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 }} />
@@ -145,7 +298,9 @@ export default function OrdersView({ searchQuery = '', locationid }: OrdersViewP
{STATUS_TABS.map((t) => { - const color = statusColor(ORDER_STATUS, t.key); + // Single brand accent for the tab row (calmer than per-status colours); + // the per-status hue still appears on the row Status chip where it aids scanning. + const color = BRAND; return ( setScope({ status: t.key })} count={summaryQ.isLoading ? '·' : countFor(t.key).toLocaleString('en-IN')}> @@ -155,41 +310,110 @@ export default function OrdersView({ searchQuery = '', locationid }: OrdersViewP ); })}
-
+
+ {branches.length > 1 && ( + + )} +
+ +
+ {/* Multi-select assign bar — shown while rows are selected (or to report a result) */} + {(selected.size > 0 || assignMsg) && ( +
+ + {selected.size} selected + + + + {selected.size > 0 && ( + + )} + {assignMsg && {assignMsg}} +
+ )} + {/* Table */}
- {['#', 'Order', 'Pickup', 'Drop', 'Qty', 'COD', 'KMs', 'Charges', 'Status', ''].map((h, i) => ( + + {['#', 'Order', 'Branch', 'Pickup', 'Drop', 'Qty', 'COD', 'KMs', 'Charges', 'Status', ''].map((h, i) => ( ))} {ordersQ.isLoading ? ( - ) : rows.length === 0 ? ( - + ) : ( rows.map((r, i) => { const st = fstr(r.orderstatus).toLowerCase(); const cod = fnum(r.collectionamt); const charges = fnum(r.deliverycharge) || fnum(r.deliverycharges); return ( - (e.currentTarget.style.background = SURFACE_ALT)} onMouseLeave={(e) => (e.currentTarget.style.background = 'transparent')}> + { if (!selected.has(rowKey(r))) e.currentTarget.style.background = SURFACE_ALT; }} onMouseLeave={(e) => { e.currentTarget.style.background = selected.has(rowKey(r)) ? tint(BRAND) : 'transparent'; }}> + + - - - + + +
+ + {h}
+
Loading orders…
No orders found for this status, date range, or search.
No orders found for this status, date range, or search.
+ toggleRow(rowKey(r))} aria-label="Select order" style={{ accentColor: BRAND, cursor: 'pointer', width: 15, height: 15 }} /> + {(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.pickupsuburb) || fstr(r.pickupaddress)}

@@ -199,9 +423,9 @@ export default function OrdersView({ searchQuery = '', locationid }: OrdersViewP

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

{fnum(r.quantity) || '—'}{cod > 0 ? ₹{cod.toLocaleString('en-IN')} : }{fnum(r.kms) ? {fnum(r.kms).toFixed(1)} : }{charges > 0 ? ₹{charges.toLocaleString('en-IN')} : } 0 ? TEXT : TEXT_3 }}>{cod > 0 ? `₹${cod.toLocaleString('en-IN')}` : '—'}{fnum(r.kms) ? fnum(r.kms).toFixed(1) : '—'} 0 ? TEXT : TEXT_3 }}>{charges > 0 ? `₹${charges.toLocaleString('en-IN')}` : '—'}
+ {/* Totals across the filtered rows */} + {rows.length > 0 && ( +
+ Totals · {rows.length} order{rows.length === 1 ? '' : 's'} + {totals.cod > 0 && } + + +
+ )}
Page {pageno} · {rows.length} shown
@@ -224,12 +457,148 @@ export default function OrdersView({ searchQuery = '', locationid }: OrdersViewP
{detailOrder && setDetailOrder(null)} />} + + {/* Right-edge floating badge — only on the Created tab and only when + MULTIPLE orders are selected (created orders are what get dispatched). + Opens the full-page review/assign view on click. */} + {status === 'created' && selected.size > 1 && !showSelected && + createPortal( + , + document.body, + )} + + {showSelected && + createPortal( + toggleRow(k)} + onClose={() => setShowSelected(false)} + />, + document.body, + )}
); } const DIVIDER_C = '#f1f5f9'; +// ── Selected-orders review page (opened from the right-edge floating badge) ────── +function SelectedOrdersPage({ + rows, rowKey, riderOptions, ridersLoading, assignRiderId, setAssignRiderId, assigning, assignMsg, onAssign, onRemove, onClose, +}: { + rows: Row[]; + rowKey: (r: Row) => string; + riderOptions: { id: number; label: string }[]; + ridersLoading: boolean; + assignRiderId: number; + setAssignRiderId: (n: number) => void; + assigning: boolean; + assignMsg: string; + onAssign: () => void; + onRemove: (k: string) => void; + onClose: () => void; +}) { + return ( +
+ {/* Sticky page header with the assign controls */} +
+
+ +
+ +
+

Selected Orders

+

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

+
+
+
+ + +
+
+
+ +
+ {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)}

+
+ +
+
+
+ )} +
+
+ ); +} + +function TotalChip({ label, value, color }: { label: string; value: string; color: string }) { + return ( + + {label} + {value} + + ); +} + function PagerBtn({ children, disabled, onClick }: { children: React.ReactNode; disabled?: boolean; onClick: () => void }) { return (
-
+
, + document.body, ); } diff --git a/src/components/ReportsView.tsx b/src/components/ReportsView.tsx index 6869791..b58b0bb 100644 --- a/src/components/ReportsView.tsx +++ b/src/components/ReportsView.tsx @@ -38,12 +38,13 @@ interface ReportsViewProps { searchQuery: string; isCoimbatoreView: boolean; setIsCoimbatoreView: (val: boolean) => void; + tenantId?: number; } const MONTH_KEYS = ['jan', 'feb', 'mar', 'apr', 'may', 'jun', 'jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dece']; const MONTH_LABELS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']; -export default function ReportsView({ searchQuery, isCoimbatoreView, setIsCoimbatoreView }: ReportsViewProps) { +export default function ReportsView({ searchQuery, isCoimbatoreView, setIsCoimbatoreView, tenantId = FIESTA_TENANT_ID }: ReportsViewProps) { const [selectedTimeframe, setSelectedTimeframe] = useState('This Year (YTD)'); const [selectedRegion, setSelectedRegion] = useState<'all' | 'coimbatore' | 'chennai' | 'bangalore'>('all'); const [stockFilter, setStockFilter] = useState<'All' | 'Healthy' | 'Low Stock' | 'Critical'>('All'); @@ -87,12 +88,12 @@ export default function ReportsView({ searchQuery, isCoimbatoreView, setIsCoimba const prevEnd = new Date(yearStart.getTime() - 86400000); const prevStart = new Date(prevEnd.getTime() - periodDays * 86400000); - const summaryQ = useFiestaOrderSummary(FIESTA_TENANT_ID, ymd(yearStart), todate); - const prevSummaryQ = useFiestaOrderSummary(FIESTA_TENANT_ID, ymd(prevStart), ymd(prevEnd)); - const locSummaryQ = useFiestaLocationSummary(FIESTA_TENANT_ID); - const insightQ = useFiestaOrderInsight(FIESTA_TENANT_ID); + const summaryQ = useFiestaOrderSummary(tenantId, ymd(yearStart), todate); + const prevSummaryQ = useFiestaOrderSummary(tenantId, ymd(prevStart), ymd(prevEnd)); + const locSummaryQ = useFiestaLocationSummary(tenantId); + const insightQ = useFiestaOrderInsight(tenantId); const stockQ = useFiestaStockStatement({ - tenantid: FIESTA_TENANT_ID, + tenantid: tenantId, locationid: FIESTA_PRIMARY_LOCATION_ID, keyword: '', pageno: 1, @@ -652,7 +653,7 @@ export default function ReportsView({ searchQuery, isCoimbatoreView, setIsCoimba {chartMetric === 'orders' ? 'Total Orders Velocity Trend' : chartMetric === 'revenue' ? 'Revenue Expansion Trajectory' : chartMetric === 'cancelled' ? 'Order Cancellation Frequency' : - 'Catalog Active SKUs Growth'} + 'Catalogue Active SKUs Growth'}
diff --git a/src/components/SettingsView.tsx b/src/components/SettingsView.tsx index 7788469..a39d8e4 100644 --- a/src/components/SettingsView.tsx +++ b/src/components/SettingsView.tsx @@ -14,19 +14,17 @@ import { MapPin, Phone, Mail, - Plus + Plus, + Bike } from 'lucide-react'; import { useFiestaAllTenants, useFiestaTenantLocations } from '../services/fiestaQueries'; import { useAppRoles } from '../services/queries'; import { FIESTA_TENANT_ID, str as fstr, num as fnum, roleName } from '../services/fiestaApi'; import UsersPanel from './UsersPanel'; import AwaitingApi from './AwaitingApi'; +import AdminConsole from './AdminConsole'; -interface SettingsViewProps { - tenantId?: number; -} - -type TabKey = 'profile' | 'outlets' | 'users' | 'delivery' | 'payment' | 'preferences'; +type TabKey = 'profile' | 'outlets' | 'users'; /** Locally-persisted merchant preferences (survive reload via localStorage). */ interface MerchantSettings { @@ -138,6 +136,13 @@ export default function SettingsView({ tenantId = FIESTA_TENANT_ID }: SettingsVi // (see [R6]) so they are not persisted; the operational controls that would // need persistence show an AwaitingApi notice instead of saving silently. const [form, setForm] = useState({ ...DEFAULTS }); + const [showStoreOnboarding, setShowStoreOnboarding] = useState(false); + + useEffect(() => { + if (activeTab !== 'outlets') { + setShowStoreOnboarding(false); + } + }, [activeTab]); // First-run seeding: fill region/role defaults from the live tenant once it // arrives (used at runtime by the Add User dialog / region label). @@ -177,9 +182,6 @@ export default function SettingsView({ tenantId = FIESTA_TENANT_ID }: SettingsVi { key: 'profile', label: 'Business Profile', icon: Building2 }, { key: 'outlets', label: 'Outlets', icon: Store }, { key: 'users', label: 'Users & Access', icon: Users }, - { key: 'delivery', label: 'Delivery', icon: Truck }, - { key: 'payment', label: 'Payment & Tax', icon: CreditCard }, - { key: 'preferences', label: 'Preferences', icon: SlidersHorizontal }, ]; // Build role options from the live app-roles API; fall back to the known @@ -392,74 +394,91 @@ export default function SettingsView({ tenantId = FIESTA_TENANT_ID }: SettingsVi
- Our Stores -

Store Directory

+ + {showStoreOnboarding ? 'Onboarding' : 'Our Stores'} + +

+ {showStoreOnboarding ? 'Add Store Outlet Location' : 'Store Directory'} +

- - {locationsQ.isLoading ? 'Loading…' : `${cleanOutlets.length} outlet${cleanOutlets.length === 1 ? '' : 's'}`} - + +
- {locationsQ.isLoading ? ( -
Loading live outlets…
- ) : cleanOutlets.length === 0 ? ( -
No outlets configured yet.
- ) : ( -
- {cleanOutlets.map((loc, i) => ( -
-
- {/* Header: Outlet name & status */} -
-
-
- -
-
-

{loc.locationname}

-

- - {[loc.suburb, loc.city].filter(Boolean).join(', ') || '—'} -

-
-
- - {loc.status || '—'} - -
- - {/* Outlet Details Grid */} -
-
- Delivery Range -

- {loc.deliveryradius ? `Up to ${loc.deliveryradius / 1000} km` : '—'} -

-
-
- Delivery Speed -

- {loc.deliverymins ? `${loc.deliverymins} mins avg` : '—'} -

-
-
- Opening Hours -

- - {loc.opentime && loc.closetime - ? `Open: ${formatFriendlyTime(loc.opentime)} – ${formatFriendlyTime(loc.closetime)}` - : 'Hours not set'} -

-
-
-
-
- ))} + {showStoreOnboarding ? ( +
+ setShowStoreOnboarding(false)} tenantId={tenantId} />
+ ) : ( + <> + {locationsQ.isLoading ? ( +
Loading live outlets…
+ ) : cleanOutlets.length === 0 ? ( +
No outlets configured yet.
+ ) : ( +
+ {cleanOutlets.map((loc, i) => ( +
+
+ {/* Header: Outlet name & status */} +
+
+
+ +
+
+

{loc.locationname}

+

+ + {[loc.suburb, loc.city].filter(Boolean).join(', ') || '—'} +

+
+
+ + {loc.status || '—'} + +
+ + {/* Outlet Details Grid */} +
+
+ Delivery Range +

+ {loc.deliveryradius ? `Up to ${loc.deliveryradius / 1000} km` : '—'} +

+
+
+ Delivery Speed +

+ {loc.deliverymins ? `${loc.deliverymins} mins avg` : '—'} +

+
+
+ Opening Hours +

+ + {loc.opentime && loc.closetime + ? `Open: ${formatFriendlyTime(loc.opentime)} – ${formatFriendlyTime(loc.closetime)}` + : 'Hours not set'} +

+
+
+
+
+ ))} +
+ )} + )}
)} @@ -468,100 +487,7 @@ export default function SettingsView({ tenantId = FIESTA_TENANT_ID }: SettingsVi )} - {activeTab === 'delivery' && ( -
-
- Delivery -

Order Prep, Timings & Dispatch

-
- {/* No merchant-settings API yet — these operational controls cannot be persisted live. */} - -
- )} - {activeTab === 'payment' && ( -
-
- Payment & Tax -

Checkout & Taxation

-
- - {/* Live (read-only) tenant payment details. */} -
- - Store Payment Details - -
- - - {tenant && fnum(tenant.minorder) ? `₹${fnum(tenant.minorder).toLocaleString('en-IN')}` : '—'} - - - - - {tenant && fnum(tenant.paymenttype) ? fnum(tenant.paymenttype) : '—'} - - -
-
- - {/* Editable checkout gateways + tax rules have no persistence backend. */} -
- - Checkout Gateways & Taxation - - -
-
- )} - - {activeTab === 'preferences' && ( -
- {/* Group 1: General Defaults */} -
- - General Defaults - -
- -
-
- -
- set('defaultRegion', e.target.value)} - className="w-44 pl-8 pr-4 py-2 border border-slate-200 rounded-xl font-bold text-slate-700 bg-slate-50/40 hover:bg-slate-50 focus:bg-white outline-none focus:border-purple-500 focus:ring-2 focus:ring-purple-500/10 transition-all text-sm text-right" - /> -
-
- - - -
-

- Region and default-role are in-session workspace preferences applied at runtime; they are not saved to a backend. -

-
- - {/* Group 2: Notifications, sync interval & sandbox — no persistence backend. */} -
- - Notifications, Sync & Test Mode - - -
-
- )}
diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx index 642d739..c4e53fc 100644 --- a/src/components/Sidebar.tsx +++ b/src/components/Sidebar.tsx @@ -9,7 +9,8 @@ import { Store, Layers, Settings, - TrendingUp + TrendingUp, + ShieldAlert } from 'lucide-react'; import { MainSection } from '../types'; @@ -19,6 +20,7 @@ interface SidebarProps { isCoimbatoreView: boolean; setIsCoimbatoreView: (val: boolean) => void; isOpen: boolean; + isAdmin?: boolean; } export default function Sidebar({ @@ -26,20 +28,21 @@ export default function Sidebar({ setCurrentSection, isCoimbatoreView, setIsCoimbatoreView, - isOpen + isOpen, + isAdmin }: SidebarProps) { // Navigation elements const navItems = [ { id: 'dashboard' as MainSection, label: 'Dashboard', icon: LayoutDashboard }, { id: 'stores' as MainSection, label: 'Stores', icon: Store }, - { id: 'inventory' as MainSection, label: 'Product Catalog', icon: Layers }, + { id: 'inventory' as MainSection, label: 'Product Catalogue', icon: Layers }, { id: 'reports' as MainSection, label: 'Reports', icon: TrendingUp }, { id: 'settings' as MainSection, label: 'Settings', icon: Settings } ]; return ( ); } + diff --git a/src/components/StoreCatalogView.tsx b/src/components/StoreCatalogView.tsx index d261c85..5d3efda 100644 --- a/src/components/StoreCatalogView.tsx +++ b/src/components/StoreCatalogView.tsx @@ -4,57 +4,35 @@ */ /** - * Inventory & Catalog — the store user's page. + * Inventory & Catalogue — the store user's page. * - * Flow: the manager curates an assortment from the global catalog; the store user - * sees ONLY that manager-selected catalog (never the global one) and chooses which - * products to stock in their own store. Two tabs: - * • Browse Catalog — the manager-approved products, each addable to the store. - * • My Store Inventory — what's currently stocked at this outlet (live stock). + * Product-management flow (3 tiers): + * 1. Admin adds products to the GLOBAL catalogue and selects which ones (+ qty) + * to publish — that's the shared "store catalogue" (services/storeCatalogue). + * 2. The user sees ONLY that admin-curated catalogue here (never the global one) + * and chooses which products they need, each with their own quantity. + * 3. Those picks are the user's request for their store. * - * The "manager-selected catalog" is sourced from the tenant master catalog - * (getMasterCatalog) for now — see CATALOG_SOURCE below; swap that one hook for - * the approved-products endpoint once it exists. - * - * Stocking a product at a location needs a write endpoint that isn't built yet, - * so selections are kept locally (persisted per store) and marked "pending sync". - * `commitSelectionToStore()` is the single integration point: replace its body - * with the real mutation when the backend is ready. + * The catalogue source is the shared store catalogue (localStorage bridge for now; + * backend: GET /products/getlocationproducts). The user's picks persist per store + * and `commitSelectionToStore()` is the single backend integration point + * (POST /products/createproductlocation / a stock-request endpoint). */ import React, { useEffect, useMemo, useState } from 'react'; -import { - Search, Boxes, Layers, Plus, Check, CheckCircle2, X, Tag, Store, PackageSearch, AlertTriangle, -} from 'lucide-react'; -import { - useFiestaMasterCatalog, - useFiestaStockStatement, - useFiestaProductCategories, - useFiestaProductSubcategories, - FIESTA_TENANT_ID, -} from '../services/fiestaQueries'; +import { Search, Boxes, Layers, Plus, Minus, Check, CheckCircle2, X, Store, PackageSearch } from 'lucide-react'; +import { useFiestaStockStatement, FIESTA_TENANT_ID } from '../services/fiestaQueries'; import { num as fnum, str as fstr, type Row } from '../services/fiestaApi'; import { categoryName } from '../services/fiestaMappers'; +import { useStoreCatalogue } from '../services/storeCatalogue'; import AwaitingApi from './AwaitingApi'; -const BRAND = '#581c87'; const PLACEHOLDER = 'https://images.unsplash.com/photo-1542838132-92c53300491e?auto=format&fit=crop&q=80&w=200'; interface StoreCatalogViewProps { locationid?: number; storeName?: string; -} - -interface CatalogProduct { - id: string; - name: string; - image: string; - category: string; - categoryid: number; - subcategoryid: number; - subcategoryname: string; - price: number; - unit: string; + tenantId?: number; } function stockStatus(closing: number): { label: string; color: string } { @@ -64,56 +42,68 @@ function stockStatus(closing: number): { label: string; color: string } { return { label: 'Healthy', color: '#10b981' }; } -export default function StoreCatalogView({ locationid, storeName = 'your store' }: StoreCatalogViewProps) { - const tenantid = FIESTA_TENANT_ID; - const [view, setView] = useState<'catalog' | 'inventory'>('catalog'); +/** Category → pill badge classes (mirrors the admin Global Catalogue card). */ +function catBadgeClass(category: string): string { + const c = category.toLowerCase(); + if (c.startsWith('staple')) return 'bg-amber-50 text-amber-600 border border-amber-100'; + if (c.includes('grocer')) return 'bg-emerald-50 text-emerald-600 border border-emerald-100'; + if (c.includes('beverage')) return 'bg-sky-50 text-sky-600 border border-sky-100'; + return 'bg-rose-50 text-rose-600 border border-rose-100'; +} + +export default function StoreCatalogView({ locationid, storeName = 'your store', tenantId = FIESTA_TENANT_ID }: StoreCatalogViewProps) { + const tenantid = tenantId; + const [view, setView] = useState<'catalogue' | 'inventory'>('catalogue'); const [search, setSearch] = useState(''); - const [categoryid, setCategoryid] = useState(0); - const [subcategoryid, setSubcategoryid] = useState(0); + const [category, setCategory] = useState('ALL'); const [notice, setNotice] = useState(false); - // Selections "to stock at this store" — persisted per outlet so choices survive - // a refresh until the backend write exists. - const storageKey = `nearledaily.catalog.selected.${locationid ?? 'na'}`; - const [selected, setSelected] = useState>(() => { + // The admin-curated catalogue (what the user is allowed to pick from). + const storeCat = useStoreCatalogue(); + const products = useMemo( + () => + storeCat.items.map((it) => ({ + id: it.productid, + name: it.name, + sku: it.sku || `SKU-${it.productid}`, + image: it.image || PLACEHOLDER, + category: it.category || 'General', + price: it.price, + unit: it.unit, + adminQty: it.qty, + })), + [storeCat.items], + ); + + // The user's picks: productid → quantity they need. Persisted per store. + const storageKey = `nearledaily.catalogue.request.${locationid ?? 'na'}`; + const [picks, setPicks] = useState>(() => { try { const raw = localStorage.getItem(storageKey); - return new Set(raw ? (JSON.parse(raw) as string[]) : []); + return raw ? (JSON.parse(raw) as Record) : {}; } catch { - return new Set(); + return {}; } }); useEffect(() => { - try { localStorage.setItem(storageKey, JSON.stringify([...selected])); } catch { /* ignore */ } - }, [selected, storageKey]); + try { localStorage.setItem(storageKey, JSON.stringify(picks)); } catch { /* ignore */ } + }, [picks, storageKey]); - // ── Data ────────────────────────────────────────────────────────────────────── - // CATALOG_SOURCE: the manager-selected assortment. Swap this hook for the - // approved-products endpoint when it's available; the rest of the page is agnostic. - const catalogQ = useFiestaMasterCatalog({ tenantid, subcategoryid: subcategoryid || undefined, pagesize: 200 }); + const togglePick = (id: string) => { + setNotice(false); + setPicks((prev) => { + const next = { ...prev }; + if (next[id] != null) delete next[id]; + else next[id] = 1; + return next; + }); + }; + const setPickQty = (id: string, qty: number) => setPicks((prev) => ({ ...prev, [id]: Math.max(1, Math.round(qty) || 1) })); + const pickCount = Object.keys(picks).length; + + // Store inventory (live stock) for the "My Store Inventory" tab + "In Store" tags. const stockQ = useFiestaStockStatement({ tenantid, locationid: locationid ?? 0, pagesize: 200 }); - const categoriesQ = useFiestaProductCategories(); - const subcategoriesQ = useFiestaProductSubcategories({ categoryid, tenantid }); - - const products = useMemo( - () => - (catalogQ.data ?? []).map((r: Row) => ({ - id: fstr(r.productid) || fstr(r.productname), - name: fstr(r.productname) || 'Unnamed product', - image: fstr(r.productimage) || PLACEHOLDER, - category: categoryName(fnum(r.categoryid)), - categoryid: fnum(r.categoryid), - subcategoryid: fnum(r.subcategoryid), - subcategoryname: fstr(r.subcategoryname), - price: fnum(r.retailprice) || fnum(r.productcost), - unit: `${fstr(r.productunit) || 'unit'} · ${fstr(r.unitvalue) || '1'}`, - })), - [catalogQ.data], - ); - - // Products already stocked at this store (by productid) — drives the "In Store" state. const inStore = useMemo(() => new Set((stockQ.data ?? []).map((r) => fstr(r.productid))), [stockQ.data]); - const inventory = useMemo( () => (stockQ.data ?? []).map((r: Row) => { @@ -121,6 +111,8 @@ export default function StoreCatalogView({ locationid, storeName = 'your store' return { id: fstr(r.productid), name: fstr(r.productname) || 'Unnamed product', + sku: fstr(r.sku) || `SKU-${fstr(r.productid)}`, + image: fstr(r.productimage) || PLACEHOLDER, category: categoryName(fnum(r.categoryid)), closing, ...stockStatus(closing), @@ -128,78 +120,46 @@ export default function StoreCatalogView({ locationid, storeName = 'your store' }), [stockQ.data], ); + const filteredInventory = useMemo(() => { + const term = search.toLowerCase(); + if (!term) return inventory; + return inventory.filter((it) => it.name.toLowerCase().includes(term) || it.category.toLowerCase().includes(term) || it.id.toLowerCase().includes(term)); + }, [inventory, search]); + const categories = useMemo(() => [...new Set(products.map((p) => p.category))].sort(), [products]); const filtered = useMemo(() => { const term = search.toLowerCase(); return products.filter((p) => { - if (categoryid && p.categoryid !== categoryid) return false; + if (category !== 'ALL' && p.category !== category) return false; if (!term) return true; return p.name.toLowerCase().includes(term) || p.category.toLowerCase().includes(term) || p.id.toLowerCase().includes(term); }); - }, [products, search, categoryid]); - - // Categories come from the Fiesta product-categories endpoint; if it returns - // nothing, fall back to the categories present in the loaded catalog so the - // filter is never empty. - const categories = useMemo(() => { - const fromApi = (categoriesQ.data ?? []) - .map((c) => ({ id: fnum(c.categoryid), name: fstr(c.categoryname) || categoryName(fnum(c.categoryid)) })) - .filter((c) => c.id); - if (fromApi.length) return fromApi; - const seen = new Map(); - for (const p of products) if (p.categoryid && !seen.has(p.categoryid)) seen.set(p.categoryid, p.category); - return [...seen.entries()].map(([id, name]) => ({ id, name })); - }, [categoriesQ.data, products]); - // Subcategories: Fiesta endpoint as source of truth; fall back to the - // subcategories present in the loaded catalog for the selected category. - const subcategories = useMemo(() => { - const fromApi = (subcategoriesQ.data ?? []) - .map((s) => ({ id: fnum(s.subcategoryid), name: fstr(s.subcategoryname) || `Subcategory ${fnum(s.subcategoryid)}` })) - .filter((s) => s.id); - if (fromApi.length) return fromApi; - const seen = new Map(); - for (const p of products) { - if (categoryid && p.categoryid !== categoryid) continue; - if (p.subcategoryid && !seen.has(p.subcategoryid)) seen.set(p.subcategoryid, p.subcategoryname || `Subcategory ${p.subcategoryid}`); - } - return [...seen.entries()].map(([id, name]) => ({ id, name })); - }, [subcategoriesQ.data, products, categoryid]); - - const toggle = (id: string) => { - setNotice(false); - setSelected((prev) => { - const next = new Set(prev); - next.has(id) ? next.delete(id) : next.add(id); - return next; - }); - }; + }, [products, search, category]); // ── Integration point ────────────────────────────────────────────────────────── - // Replace this body with the real mutation: POST the selected product ids to the - // store/location assortment (stock-entry) endpoint, then invalidate stockQ. - const commitSelectionToStore = () => { - setNotice(true); - }; + // Replace with the real request/stock POST (selected productids + quantities), + // then invalidate stockQ. + const commitSelectionToStore = () => setNotice(true); return ( -
+
{/* Header */}
-

Inventory & Catalog

+

Product Catalogue

- Browse the products approved for your store and choose what to stock at {storeName}. + Products your admin published for {storeName} — choose what you need and set quantities.

{/* Tabs */}
+ )}
- - {view === 'catalog' && ( + {view === 'catalogue' && categories.length > 0 && (
- - Filter - + Filter - {categoryid > 0 && subcategories.length > 0 && ( - - )}
)} -
- {view === 'catalog' ? `${filtered.length} products` : `${inventory.length} stocked`} + {view === 'catalogue' ? `${filtered.length} products` : `${inventory.length} stocked`}
- {/* ── Browse Catalog ── */} - {view === 'catalog' && ( - catalogQ.isLoading ? ( - } title="Loading catalog…" /> - ) : catalogQ.isError ? ( - } title="Couldn't load the catalog" sub="Check your connection and try again." tone="error" /> + {/* ── Browse Catalogue ── */} + {view === 'catalogue' && ( + products.length === 0 ? ( + } + title="No products published yet" + sub="Your admin hasn't added any products to the catalogue. Once they do, they'll appear here automatically for you to select." + /> ) : filtered.length === 0 ? ( - } title="No products found" sub="Your manager hasn't approved products matching this filter yet." /> + } + title="No products match your search" + sub="Try a different keyword or clear the filters to see the full catalogue." + action={ + + } + /> ) : ( -
+
{filtered.map((p) => { const stocked = inStore.has(p.id); - const isSelected = selected.has(p.id); + const picked = picks[p.id] != null; return ( -
-
- {p.name} - {stocked && ( - - In Store - - )} -
-
- - {p.category} - -

{p.name}

-
- {p.price > 0 ? `₹${p.price.toLocaleString('en-IN')}` : '—'} - {p.unit} +
+
+ {/* Thumbnail with hover zoom */} +
+ {p.name} + {stocked && ( + + )}
+
+
+
+

{p.name}

+ {p.sku} +
+ {/* Category pill badge */} + + {p.category.split(' / ')[0]} + +
- {stocked ? ( - - ) : isSelected ? ( - - ) : ( - - )} +
+
+ Price + {p.price > 0 ? `₹${p.price.toLocaleString('en-IN')}` : '—'} +
+
+ Admin Stock + {p.adminQty}{p.unit ? ` ${p.unit}` : ''} +
+
+
+ + {/* Stocked-status row (mirrors the admin card's status line) */} +
+ + + {stocked ? 'In Your Store' : 'Not stocked yet'} + + {p.unit && {p.unit}} +
+ + {/* Pick action: quantity stepper when selected, else add button */} + {picked ? ( +
+ Selected +
+ + {picks[p.id]} + + +
+
+ ) : ( + + )}
); })} @@ -314,73 +301,98 @@ export default function StoreCatalogView({ locationid, storeName = 'your store' ) )} - {/* ── My Store Inventory ── */} + {/* ── My Store Inventory ── (card grid — same design as Browse Catalogue) */} {view === 'inventory' && ( -
-
- - - - - - - - - - - - {stockQ.isLoading ? ( - - ) : !locationid ? ( - - ) : inventory.length === 0 ? ( - - ) : ( - inventory.map((it, i) => ( - - - - - - - - )) - )} - -
#ProductCategoryIn StockStatus
Loading your stock…
No store linked to your account yet.
No products stocked yet — add some from the catalog.
{i + 1}{it.name}{it.category}{it.closing.toLocaleString('en-IN')} - - {it.label} - -
+ stockQ.isLoading ? ( + } title="Loading your stock…" sub="Fetching the latest stock levels for your store." /> + ) : !locationid ? ( + } title="No store linked yet" sub="Your account isn't linked to a store outlet, so there's no inventory to show." /> + ) : inventory.length === 0 ? ( + } title="No products stocked yet" sub="Add products from the catalogue and they'll appear here with live stock levels." /> + ) : filteredInventory.length === 0 ? ( + } + title="No stock matches your search" + sub="Try a different keyword to find an item in your store." + action={ + + } + /> + ) : ( +
+ {filteredInventory.map((it, i) => ( +
+
+ {/* Thumbnail with status corner dot */} +
+ {it.name} + +
+
+
+
+

{it.name}

+ {it.sku} +
+ + {it.category.split(' / ')[0]} + +
+ +
+
+ In Stock + {it.closing.toLocaleString('en-IN')} +
+
+ Status + {it.label} +
+
+
+
+ + {/* Status line (mirrors the catalogue card's footer) */} +
+ + + {it.label} + + {it.category.split(' / ')[0]} +
+
+ ))}
-
+ ) )} - {/* ── Selection action bar (sticky) ── */} - {view === 'catalog' && selected.size > 0 && ( -
+ {/* ── Selection action bar ── */} + {view === 'catalogue' && pickCount > 0 && ( +
{notice ? (
- {selected.size} product{selected.size > 1 ? 's' : ''} marked for {storeName} - + {pickCount} product{pickCount > 1 ? 's' : ''} requested for {storeName} +
- +
) : (
-

{selected.size} product{selected.size > 1 ? 's' : ''} selected

-

Ready to stock at {storeName}

+

{pickCount} product{pickCount > 1 ? 's' : ''} · {Object.values(picks).reduce((a: number, b: number) => a + b, 0)} units

+

Selected for {storeName}

- +
@@ -392,12 +404,45 @@ export default function StoreCatalogView({ locationid, storeName = 'your store' ); } -function CenterState({ icon, title, sub, tone }: { icon: React.ReactNode; title: string; sub?: string; tone?: 'error' }) { +function CenterState({ icon, title, sub, action }: { icon: React.ReactNode; title: string; sub?: string; action?: React.ReactNode }) { return ( -
-
{icon}
-

{title}

- {sub &&

{sub}

} +
+ {/* Soft decorative glows */} +
+
+ +
+ {/* Icon with halo */} +
+ + + {icon} + +
+ +

{title}

+ {sub &&

{sub}

} + + {action &&
{action}
} + + {/* Ghost preview cards — hint at what will appear here */} +
+ {[0, 1, 2].map((i) => ( +
+
+
+
+
+ ))} +
+ +
+ Syncs automatically +
+
); } diff --git a/src/components/StoreDetailView.tsx b/src/components/StoreDetailView.tsx index f248aab..1c6d0bc 100644 --- a/src/components/StoreDetailView.tsx +++ b/src/components/StoreDetailView.tsx @@ -30,19 +30,24 @@ import { CreditCard, History, Building, - Award + Award, + ShoppingBag, + QrCode, + ChevronRight, + AtSign } from 'lucide-react'; import { useFiestaStockStatement, useFiestaTenantCustomers, useFiestaCustomerOrders, - useFiestaMasterCatalog, useFiestaRiders, FIESTA_TENANT_ID } from '../services/fiestaQueries'; import { str as fstr, num as fnum } from '../services/fiestaApi'; import { mapOrderStatus, shortTime } from '../services/fiestaMappers'; import ragulStoreCover from '../assets/images/store_front_view_1780299351800.png'; +import OrdersDeliveriesView from './OrdersDeliveriesView'; +import StoreQRView from './StoreQRView'; import AwaitingApi from './AwaitingApi'; interface StoreDetailViewProps { @@ -68,6 +73,8 @@ interface StoreDetailViewProps { * Overview, Inventory & Catalogue, and Customers into separate pages. When * omitted, the full tabbed console renders (admin store detail). */ only?: 'overview' | 'inventory' | 'customers'; + /** Merchant tenant to scope to; defaults to the shared constant. */ + tenantId?: number; } // Fallback cover images @@ -86,8 +93,8 @@ const DETAIL_STORE_COVERS = [ 'https://images.unsplash.com/photo-1579621970563-ebec7560ff3e?auto=format&fit=crop&w=800&q=80' ]; -export default function StoreDetailView({ store, onBack, canManage = true, only }: StoreDetailViewProps) { - const [activeTab, setActiveTab] = useState<'overview' | 'inventory' | 'customers'>('overview'); +export default function StoreDetailView({ store, onBack, canManage = true, only, tenantId = FIESTA_TENANT_ID }: StoreDetailViewProps) { + const [activeTab, setActiveTab] = useState<'overview' | 'inventory' | 'customers' | 'orders' | 'qr'>('overview'); // Which section to show: forced by `only` (separate-page mode) or the active tab. const section = only ?? activeTab; // The immersive store banner shows on Overview (and the admin tabbed console); @@ -133,8 +140,6 @@ export default function StoreDetailView({ store, onBack, canManage = true, only const [localInventory, setLocalInventory] = useState([]); const [showImportModal, setShowImportModal] = useState(false); const [importState, setImportState] = useState<'idle' | 'reading' | 'parsing' | 'saving' | 'done'>('idle'); - const [showGlobalModal, setShowGlobalModal] = useState(false); - const [selectedGlobalSkus, setSelectedGlobalSkus] = useState([]); // ── Customer CRM Profile Drawer state ────────────────────────────────────── const [selectedCustomer, setSelectedCustomer] = useState(null); @@ -142,23 +147,17 @@ export default function StoreDetailView({ store, onBack, canManage = true, only // ── API Queries with live locationid ─────────────────────────────────────── const locationid = store.locationid || 1097; const stockQ = useFiestaStockStatement({ - tenantid: FIESTA_TENANT_ID, + tenantid: tenantId, locationid, pagesize: 100 }); const customersQ = useFiestaTenantCustomers({ - tenantid: FIESTA_TENANT_ID, + tenantid: tenantId, locationid, pagesize: 100 }); // Live active rider fleet for this tenant (powers KPI fleet count + fleet list) - const ridersQ = useFiestaRiders({ tenantid: FIESTA_TENANT_ID }); - // Master catalogue rows for the Global Catalogue modal - const masterCatalogQ = useFiestaMasterCatalog({ - tenantid: FIESTA_TENANT_ID, - locationid, - pagesize: 100 - }); + const ridersQ = useFiestaRiders({ tenantid: tenantId }); // Past orders for the currently-open CRM drawer customer (disabled when no id) const customerOrdersQ = useFiestaCustomerOrders({ customerid: selectedCustomer?.id ?? null, @@ -268,20 +267,6 @@ export default function StoreDetailView({ store, onBack, canManage = true, only }; }); - // ── Global Master Catalogue (live) for the "Add from Catalogue" modal ────── - const globalCatalogueItems = (masterCatalogQ.data ?? []).map((row: any) => { - const price = fnum(row.retailprice) || fnum(row.price) || fnum(row.productcost); - return { - sku: fstr(row.sku) || fstr(row.productsku) || `SKU-${fstr(row.productid)}` || 'SKU-UNKNOWN', - name: fstr(row.productname) || fstr(row.name) || 'Product Item', - category: fstr(row.subcategoryname) || fstr(row.categoryname) || 'Catalogue', - price: price > 0 ? price : null, - image: - fstr(row.productimage) || - 'https://images.unsplash.com/photo-1542838132-92c53300491e?auto=format&fit=crop&w=150&q=80' - }; - }); - // Actions simulation handles const handleReplenishSubmit = (e: React.FormEvent) => { e.preventDefault(); @@ -328,30 +313,6 @@ export default function StoreDetailView({ store, onBack, canManage = true, only }, 700); }; - // Add items from Global Catalog - const handleAddGlobalCatalogue = () => { - if (selectedGlobalSkus.length === 0) { - showToast('Kindly select at least one catalogue item.', 'warning'); - return; - } - - const itemsToAdd = globalCatalogueItems.filter(item => selectedGlobalSkus.includes(item.sku)).map(item => ({ - ...item, - stockLevel: 0, - maxCapacity: 200, - status: 'Critical' - })); - - setLocalInventory(prev => { - const filtered = prev.filter(item => !itemsToAdd.some(ni => ni.sku === item.sku)); - return [...filtered, ...itemsToAdd]; - }); - - showToast(`${itemsToAdd.length} products synced from Master Global Catalogue successfully!`, 'success'); - setSelectedGlobalSkus([]); - setShowGlobalModal(false); - }; - const handleExportLedger = () => { showToast(`Generating secure PDF ledger audit reports for ${store.name}...`, 'info'); setTimeout(() => { @@ -521,7 +482,7 @@ export default function StoreDetailView({ store, onBack, canManage = true, only }`} > - Inventory & Catalogue ({inventoryList.length}) + Inventory ({inventoryList.length}) {inventoryList.some(item => item.status === 'Critical') && ( ! )} @@ -537,6 +498,28 @@ export default function StoreDetailView({ store, onBack, canManage = true, only Customer CRM Base ({customersList.length}) + +
)} @@ -730,7 +713,7 @@ export default function StoreDetailView({ store, onBack, canManage = true, only 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-[#581c87] transition-all" @@ -749,14 +732,6 @@ export default function StoreDetailView({ store, onBack, canManage = true, only Import Manual (CSV) - - )} @@ -774,7 +749,7 @@ export default function StoreDetailView({ store, onBack, canManage = true, only

- Product Stock Levels & Catalog + Product Stock Levels

Live list
@@ -885,111 +860,175 @@ export default function StoreDetailView({ store, onBack, canManage = true, only
)} - {section === 'customers' && ( -
- - {/* Customer directory search and metrics */} -
-
- + {section === 'customers' && (() => { + const withPhone = customersList.filter((c: any) => c.phone && c.phone !== '—').length; + const withEmail = customersList.filter((c: any) => c.email).length; + // Jewel-tone identity per customer (deterministic by name) — a calm header + // band gradient + a soft solid avatar tint drawn from the same hue. + const tones = [ + { soft: '#f3effb', fg: '#6d28d9', band: 'linear-gradient(135deg,#6d28d9 0%,#9333ea 100%)' }, + { soft: '#e9f5f1', fg: '#0f766e', band: 'linear-gradient(135deg,#0f766e 0%,#14b8a6 100%)' }, + { soft: '#fdf0eb', fg: '#c2410c', band: 'linear-gradient(135deg,#c2410c 0%,#f97316 100%)' }, + { soft: '#ebeefb', fg: '#3a4fc4', band: 'linear-gradient(135deg,#3949c4 0%,#6366f1 100%)' }, + { soft: '#fceef4', fg: '#be185d', band: 'linear-gradient(135deg,#be185d 0%,#ec4899 100%)' }, + { soft: '#e9f3fb', fg: '#0369a1', band: 'linear-gradient(135deg,#0369a1 0%,#0ea5e9 100%)' }, + ]; + const toneFor = (name: string) => { + let h = 0; + for (let i = 0; i < name.length; i++) h = (h * 31 + name.charCodeAt(i)) >>> 0; + return tones[h % tones.length]; + }; + const initialsOf = (name: string) => (name || 'C').split(' ').filter(Boolean).map((n: string) => n[0]).slice(0, 2).join('').toUpperCase(); + // A short, human locality from the messy delivery address (skip door numbers). + const localityOf = (addr: string) => { + const parts = (addr || '').split(',').map((s) => s.trim()).filter(Boolean); + return parts.find((p) => /[a-zA-Z]/.test(p) && !/^\d/.test(p)) || parts[1] || parts[0] || ''; + }; + + return ( +
+ + {/* Page heading */} +
+
+

Customers

+

+ {customersList.length} {customersList.length === 1 ? 'person orders' : 'people order'} from{' '} + {store.name} + · + {withPhone} with phone · {withEmail} with email +

+
+
+ setCustomerSearch(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-[#581c87] transition-all" + className="w-full pl-9 pr-3 py-2.5 border border-[#e6e8ee] rounded-full text-[13px] text-[#0f172a] placeholder:text-zinc-400 outline-none bg-white focus:border-[#581c87] focus:ring-4 focus:ring-[#581c87]/8 transition-all" />
- -
- -
- {/* Customer list directory */} -
-
-

- Active Customer Directory -

- Customer registry + {/* Profile cards */} + {customersList.length === 0 ? ( +
+ +
+

No customers yet

+

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

+
+ {customerSearch && ( + + )}
- -
- - - - - - - - - - - - - {customersList.length === 0 ? ( - - + ) : ( +
+
+
Customer ProfileContact DetailsDelivery AddressTotal DispatchesGross Volume SpentAudit CRM Actions
- No customer accounts found matching search keyword. -
+ + + + + + + - ) : ( - customersList.map((c, idx) => { - const initials = c.name.split(' ').map((n: string) => n[0]).join(''); - const gradients = [ - 'from-purple-500 to-indigo-500 text-white', - 'from-rose-500 to-pink-500 text-white', - 'from-sky-500 to-indigo-500 text-white', - 'from-emerald-500 to-teal-500 text-white', - 'from-amber-500 to-orange-500 text-white' - ]; - const avatarGrad = gradients[idx % gradients.length]; - + + + {customersList.map((c: any, idx: number) => { + const tone = toneFor(c.name || `c${idx}`); + const locality = localityOf(c.address); return ( - - + {/* Customer */} + - - - - - + {/* Address */} + + {/* Action */} + ); - }) - )} - -
CustomerPhoneEmailDelivery addressProfile
-
-
- {initials} +
+
+ + {initialsOf(c.name)} + +
+

{c.name}

+ {locality && ( +

+ {locality} +

+ )}
- {c.name}
{c.phone} - {c.address} + {/* Phone */} + + {c.phone} {c.ordersCount} orders{c.totalSpent} - {canManage && ( + {/* Email */} + + {c.email + ? {c.email} + : } + + {c.address} + +
+ {canManage && ( + + )} - )} - +
+ })} + + +
+
+ Showing {customersList.length} {customersList.length === 1 ? 'customer' : 'customers'} +
-
+ )}
+ ); + })()} + + {/* Orders & Deliveries — admin full console only (user store pages use the + dedicated Orders / Deliveries nav items instead). */} + {section === 'orders' && ( + )} - {/* Orders & Deliveries moved out of the store console into their own pages. */} + {/* Store QR — scannable storefront link for this outlet. */} + {section === 'qr' && ( +
+ +
+ )} {/* ── Replenishment Modal Dialog Overlay ── */} {replenishModal.show && replenishModal.item && ( @@ -1112,7 +1151,7 @@ export default function StoreDetailView({ store, onBack, canManage = true, only

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

Kindly keep this window open while processing dispatches.

@@ -1145,94 +1184,6 @@ export default function StoreDetailView({ store, onBack, canManage = true, only
)} - {/* ── Choose from Global Catalogue Modal ── */} - {showGlobalModal && ( -
{ if (e.target === e.currentTarget) setShowGlobalModal(false); }} - > -
-
-

- - Select Products from Master Catalogue -

- -
- -
-

- Choose master items from the national database to stock and commission locally at {store.name}. -

- - {globalCatalogueItems.length === 0 ? ( -
- No catalogue products available yet. -
- ) : ( -
- {globalCatalogueItems.map((item) => { - const isChecked = selectedGlobalSkus.includes(item.sku); - return ( -
{ - setSelectedGlobalSkus(prev => - isChecked ? prev.filter(s => s !== item.sku) : [...prev, item.sku] - ); - }} - className="py-2.5 flex items-center justify-between gap-sm cursor-pointer select-none hover:bg-zinc-50/50 rounded-lg px-1 transition-colors" - > -
- {}} // handled by row click - className="w-4 h-4 rounded text-[#581c87] border-[#e2e8f0] focus:ring-purple-500" - /> - {item.name} -
-

{item.name}

-

{item.category} · SKU: {item.sku}

-
-
- {item.price != null ? `₹${item.price.toLocaleString('en-IN')}` : '—'} -
- ); - })} -
- )} -
- -
- - -
-
-
- )} - {/* ── Customer CRM Profile Side Drawer Overlay ── */} {selectedCustomer && (
(null); + + const payload = useMemo( + () => (locationid ? buildStoreQrPayload({ tenantid: tenantId, locationid }) : ''), + [tenantId, locationid], + ); + + const slug = useMemo(() => { + return storeName + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, '') || 'store'; + }, [storeName]); + + const downloadPng = () => { + const canvas = canvasRef.current; + if (!canvas) return; + const a = document.createElement('a'); + a.href = canvas.toDataURL('image/png'); + a.download = `${slug}-qr.png`; + a.click(); + }; + + if (!locationid) { + return ( +
+
+
+ +
+

No store to encode

+

+ This QR opens a specific outlet's storefront, but no outlet is resolved yet. Once a store + location is assigned, its scannable code appears here. +

+
+
+ ); + } + + return ( +
+ + {/* Premium Table Tent Mockup Card */} +
+ + {/* Ambient Background Glow inside card */} +
+ + {/* Gradient Header banner */} +
+
+ + Scan & Shop + +

{storeName}

+ {(storeZone || storeAddress) && ( +

+ + {storeZone || storeAddress} +

+ )} +
+ + {/* QR Code Frame with Overlap Layout */} +
+
+ +
+ + {/* Counter instructions info block */} +
+

Storefront QR Code

+

+ Customers scan this using the consumer app to shop this branch. +

+
+
+
+ + {/* Download Action Button */} +
+ +
+ + {/* Hidden high-res canvas — source for the PNG download */} + +
+ ); +} + diff --git a/src/components/UserStorePage.tsx b/src/components/UserStorePage.tsx index d669d4a..818663d 100644 --- a/src/components/UserStorePage.tsx +++ b/src/components/UserStorePage.tsx @@ -4,10 +4,10 @@ */ import React, { useState } from 'react'; +import { createPortal } from 'react-dom'; import { AlertTriangle, LayoutDashboard, - User, Mail, Phone, Store, @@ -18,6 +18,7 @@ import { ClipboardList, Layers, Users, + X, } from 'lucide-react'; import { useFiestaTenantLocations, @@ -33,6 +34,7 @@ import OrdersView from './OrdersView'; import DeliveriesView from './DeliveriesView'; import DispatchView from './DispatchView'; import DeliveryReportsView from './DeliveryReportsView'; +import StoreQRView from './StoreQRView'; import UserStoreSidebar, { type UserNavItem } from './UserStoreSidebar'; interface UserStorePageProps { @@ -46,13 +48,12 @@ interface UserStorePageProps { // gets a matching branch in `renderSection` below. const NAV_ITEMS: UserNavItem[] = [ { id: 'console', label: 'Store Console', icon: LayoutDashboard }, - { id: 'inventory', label: 'Inventory & Catalog', icon: Layers }, + { id: 'inventory', label: 'Product Catalogue', icon: Layers }, { id: 'customers', label: 'Customers', icon: Users }, { id: 'orders', label: 'Orders', icon: ShoppingBag }, { id: 'deliveries', label: 'Deliveries', icon: Truck }, { id: 'dispatch', label: 'Dispatch', icon: Route }, - { id: 'reports', label: 'Delivery Reports', icon: ClipboardList }, - { id: 'account', label: 'My Account', icon: User }, + { id: 'reports', label: 'Reports', icon: ClipboardList }, ]; type StoreShape = React.ComponentProps['store']; @@ -66,9 +67,14 @@ type StoreShape = React.ComponentProps['store']; export default function UserStorePage({ onLogout, user }: UserStorePageProps) { const [sidebarOpen, setSidebarOpen] = useState(true); const [activeSection, setActiveSection] = useState('console'); + const [showQrModal, setShowQrModal] = useState(false); - const locationsQ = useFiestaTenantLocations(FIESTA_TENANT_ID); - const locSummaryQ = useFiestaLocationSummary(FIESTA_TENANT_ID); + // Scope every query to the signed-in merchant's tenant; the shared constant is + // only a fallback for legacy sessions whose record predates tenantid capture. + const tenantId = user.tenantid || FIESTA_TENANT_ID; + + const locationsQ = useFiestaTenantLocations(tenantId); + const locSummaryQ = useFiestaLocationSummary(tenantId); const locations = locationsQ.data ?? []; const summaries = locSummaryQ.data ?? []; @@ -188,14 +194,14 @@ export default function UserStorePage({ onLogout, user }: UserStorePageProps) { // Logistics console — scoped to this user's store. These views own their // loading/error states, so they don't need the store-console load gating below. - if (activeSection === 'orders') return ; - if (activeSection === 'deliveries') return ; - if (activeSection === 'dispatch') return ; - if (activeSection === 'reports') return ; + if (activeSection === 'orders') return ; + if (activeSection === 'deliveries') return ; + if (activeSection === 'dispatch') return ; + if (activeSection === 'reports') return ; // Inventory & Catalog is its own page: the manager-curated catalog the user // stocks from (the catalog query is tenant-level, so it doesn't need the store // gating below — only "My Store Inventory" uses the resolved location id). - if (activeSection === 'inventory') return ; + if (activeSection === 'inventory') return ; // The store console needs a resolved store, so gate it on the load state. if (locationsQ.isLoading || locSummaryQ.isLoading) { @@ -259,7 +265,7 @@ export default function UserStorePage({ onLogout, user }: UserStorePageProps) { // Overview & Performance; Customers is its own page (Inventory & Catalog is the // dedicated StoreCatalogView, handled above). const only = activeSection === 'customers' ? 'customers' : 'overview'; - return ; + return ; }; return ( @@ -269,6 +275,8 @@ export default function UserStorePage({ onLogout, user }: UserStorePageProps) { onToggleSidebar={() => setSidebarOpen((s) => !s)} onHelpClick={handleHelp} onLogoutClick={onLogout} + onAccountClick={() => setActiveSection('account')} + onQrClick={() => setShowQrModal(true)} profile={profile} /> @@ -296,6 +304,40 @@ export default function UserStorePage({ onLogout, user }: UserStorePageProps) { )}
+ + {/* Store QR — centered modal opened from the navbar QR button. Portaled to + body so `fixed inset-0` is viewport-relative regardless of ancestors. */} + {showQrModal && + createPortal( +
{ if (e.target === e.currentTarget) setShowQrModal(false); }} + > +
+ + +
+
, + document.body, + )}
); } diff --git a/src/components/UserStoreSidebar.tsx b/src/components/UserStoreSidebar.tsx index 3bd3cf0..7635c8d 100644 --- a/src/components/UserStoreSidebar.tsx +++ b/src/components/UserStoreSidebar.tsx @@ -29,7 +29,7 @@ interface UserStoreSidebarProps { export default function UserStoreSidebar({ items, activeId, onSelect, isOpen }: UserStoreSidebarProps) { return (