diff --git a/FIESTA_BACKEND_API.docx b/FIESTA_BACKEND_API.docx new file mode 100644 index 0000000..394529c Binary files /dev/null and b/FIESTA_BACKEND_API.docx differ 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 (