dispatch page

This commit is contained in:
2026-06-12 14:45:06 +05:30
parent d8c1517239
commit 5378f2df1f
34 changed files with 4451 additions and 1744 deletions

BIN
FIESTA_BACKEND_API.docx Normal file

Binary file not shown.

10
package-lock.json generated
View File

@@ -18,6 +18,7 @@
"leaflet": "^1.9.4", "leaflet": "^1.9.4",
"lucide-react": "^0.546.0", "lucide-react": "^0.546.0",
"motion": "^12.23.24", "motion": "^12.23.24",
"qrcode.react": "^4.2.0",
"react": "^19.0.1", "react": "^19.0.1",
"react-dom": "^19.0.1", "react-dom": "^19.0.1",
"react-leaflet": "^5.0.0", "react-leaflet": "^5.0.0",
@@ -3307,6 +3308,15 @@
"node": ">= 0.10" "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": { "node_modules/qs": {
"version": "6.15.2", "version": "6.15.2",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz", "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz",

View File

@@ -21,6 +21,7 @@
"leaflet": "^1.9.4", "leaflet": "^1.9.4",
"lucide-react": "^0.546.0", "lucide-react": "^0.546.0",
"motion": "^12.23.24", "motion": "^12.23.24",
"qrcode.react": "^4.2.0",
"react": "^19.0.1", "react": "^19.0.1",
"react-dom": "^19.0.1", "react-dom": "^19.0.1",
"react-leaflet": "^5.0.0", "react-leaflet": "^5.0.0",

View File

@@ -102,12 +102,17 @@ export default function App() {
const [searchQuery, setSearchQuery] = useState(''); const [searchQuery, setSearchQuery] = useState('');
const [sidebarOpen, setSidebarOpen] = useState(true); 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) ───────────────────────── // ── Live data for the secondary sections (Fiesta) ─────────────────────────
// Stores ← tenant locations + per-location order summary (seeded into local // Stores ← tenant locations + per-location order summary (seeded into local
// state so the "Add Store" handler keeps working). Users management now lives // state so the "Add Store" handler keeps working). Users management now lives
// under Settings → Users & Access (see UsersPanel). // under Settings → Users & Access (see UsersPanel).
const locationsQ = useFiestaTenantLocations(FIESTA_TENANT_ID); const locationsQ = useFiestaTenantLocations(tenantId);
const locSummaryQ = useFiestaLocationSummary(FIESTA_TENANT_ID); const locSummaryQ = useFiestaLocationSummary(tenantId);
const STORE_COVERS = [ const STORE_COVERS = [
'https://images.unsplash.com/photo-1542838132-92c53300491e?auto=format&fit=crop&w=600&q=80', '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 [storesFilter, setStoresFilter] = useState<'ALL' | 'ACTIVE' | 'CRITICAL'>('ALL');
const filteredStoresList = storesList.filter((st) => { const filteredStoresList = storesList.filter((st) => {
const q = storesSearch.toLowerCase(); const q = storesSearch.trim().toLowerCase();
const matchesSearch = // Match across every field shown on the card — name, zone, manager/contact,
!q || // and the outlet id — coercing each to a string so a missing/numeric value
st.name.toLowerCase().includes(q) || // never throws and silently breaks the whole filter.
st.zone.toLowerCase().includes(q) || const haystack = [st.name, st.zone, st.staff, st.locationid]
st.staff.toLowerCase().includes(q); .map((v) => String(v ?? '').toLowerCase())
.join(' ');
const matchesSearch = !q || haystack.includes(q);
if (storesFilter === 'ACTIVE') { if (storesFilter === 'ACTIVE') {
return matchesSearch && st.status.toLowerCase() === 'active'; return matchesSearch && st.status.toLowerCase() === 'active';
@@ -262,6 +269,7 @@ export default function App() {
<StoreDetailView <StoreDetailView
store={activeStore} store={activeStore}
onBack={selectedStore ? () => setSelectedStore(null) : undefined} onBack={selectedStore ? () => setSelectedStore(null) : undefined}
tenantId={tenantId}
/> />
</div> </div>
); );
@@ -533,7 +541,7 @@ export default function App() {
} }
case 'settings': case 'settings':
return <SettingsView tenantId={FIESTA_TENANT_ID} />; return <SettingsView tenantId={tenantId} />;
default: default:
return null; return null;
@@ -575,6 +583,7 @@ export default function App() {
isCoimbatoreView={isCoimbatoreView} isCoimbatoreView={isCoimbatoreView}
setIsCoimbatoreView={setIsCoimbatoreView} setIsCoimbatoreView={setIsCoimbatoreView}
isOpen={sidebarOpen} isOpen={sidebarOpen}
isAdmin={authRole === 'admin'}
/> />
{/* Main core pages payload area */} {/* Main core pages payload area */}
@@ -582,13 +591,14 @@ export default function App() {
<div className="w-full p-container-margin md:p-xl space-y-lg transition-all duration-300"> <div className="w-full p-container-margin md:p-xl space-y-lg transition-all duration-300">
{/* Nav content routing */} {/* Nav content routing */}
{currentSection === 'dashboard' && ( {currentSection === 'dashboard' && (
<DashboardView searchQuery={searchQuery} isCoimbatoreView={isCoimbatoreView} /> <DashboardView searchQuery={searchQuery} isCoimbatoreView={isCoimbatoreView} tenantId={tenantId} />
)} )}
{currentSection === 'inventory' && ( {currentSection === 'inventory' && (
<InventoryView <InventoryView
searchQuery={searchQuery} searchQuery={searchQuery}
isCoimbatoreView={isCoimbatoreView} isCoimbatoreView={isCoimbatoreView}
tenantId={tenantId}
/> />
)} )}
@@ -597,9 +607,11 @@ export default function App() {
searchQuery={searchQuery} searchQuery={searchQuery}
isCoimbatoreView={isCoimbatoreView} isCoimbatoreView={isCoimbatoreView}
setIsCoimbatoreView={setIsCoimbatoreView} setIsCoimbatoreView={setIsCoimbatoreView}
tenantId={tenantId}
/> />
)} )}
{/* Handle alternative sections: Stores, Settings */} {/* Handle alternative sections: Stores, Settings */}
{['stores', 'settings'].includes(currentSection) && {['stores', 'settings'].includes(currentSection) &&
renderSecondarySection() renderSecondarySection()

View File

@@ -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<string, string>;
}
/** 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<NominatimRow[]>([]);
const [open, setOpen] = useState(false);
const [loading, setLoading] = useState(false);
const [highlight, setHighlight] = useState(-1);
const boxRef = useRef<HTMLDivElement>(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 (
<div className="relative" ref={boxRef}>
<span className="absolute inset-y-0 left-0 pl-3.5 flex items-center pointer-events-none text-slate-400">
<Search size={14} />
</span>
<input
type="text"
value={query}
placeholder={placeholder}
autoComplete="off"
onChange={(e) => { 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 && <span className="absolute inset-y-0 right-0 pr-3 flex items-center text-slate-400"><Loader2 size={14} className="animate-spin" /></span>}
{open && options.length > 0 && (
<ul className="absolute z-20 mt-1 w-full bg-white border border-slate-200 rounded-xl shadow-lg max-h-60 overflow-y-auto py-1">
{options.map((o, i) => (
<li key={`${o.lat},${o.lon},${i}`}>
<button
type="button"
onMouseEnter={() => setHighlight(i)}
onClick={() => pick(o)}
className={`w-full text-left px-3 py-2 flex items-start gap-2 cursor-pointer transition-colors ${
highlight === i ? 'bg-purple-50' : 'hover:bg-slate-50'
}`}
>
<MapPin size={13} className="text-purple-500 shrink-0 mt-0.5" />
<span className="text-[12.5px] text-slate-700 leading-snug">{o.display_name}</span>
</button>
</li>
))}
</ul>
)}
</div>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -15,14 +15,16 @@ import {
Clock, Clock,
ArrowUpRight, ArrowUpRight,
} from 'lucide-react'; } from 'lucide-react';
import { useOrderSummary, useTenantInfo, useTenantLocations, useInvoiceInsight } from '../services/queries'; import { useOrderSummary, useTenantInfo, useInvoiceInsight } from '../services/queries';
import { DEFAULT_TENANT_ID, DEFAULT_CONFIG_ID } from '../services/api'; import { DEFAULT_CONFIG_ID } from '../services/api';
import { useFiestaLocationSummary } from '../services/fiestaQueries'; import { useFiestaLocationSummary, useFiestaTenantLocations } from '../services/fiestaQueries';
import { FIESTA_TENANT_ID } from '../services/fiestaApi'; import { FIESTA_TENANT_ID } from '../services/fiestaApi';
interface DashboardViewProps { interface DashboardViewProps {
searchQuery: string; searchQuery: string;
isCoimbatoreView: boolean; isCoimbatoreView: boolean;
/** Fiesta merchant tenant to scope live store summaries to. */
tenantId?: number;
} }
const ymd = (d: Date) => const ymd = (d: Date) =>
@@ -30,20 +32,23 @@ const ymd = (d: Date) =>
const str = (v: unknown): string => (v == null ? '' : String(v)); 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. // Live data — month-to-date order summary + tenant identity + store locations.
const today = new Date(); const today = new Date();
const monthStart = new Date(today.getFullYear(), today.getMonth(), 1); const monthStart = new Date(today.getFullYear(), today.getMonth(), 1);
const fromdate = ymd(monthStart); const fromdate = ymd(monthStart);
const todate = ymd(today); const todate = ymd(today);
const summaryQ = useOrderSummary(DEFAULT_TENANT_ID, fromdate, todate, DEFAULT_CONFIG_ID); // All scoped to the signed-in merchant's tenant. Store locations come from the
const tenantQ = useTenantInfo(DEFAULT_TENANT_ID); // Fiesta source (the single source of truth used across the app) — it's already
const locationsQ = useTenantLocations(DEFAULT_TENANT_ID); // deduped and stripped of test rows, unlike the raw Hasura tenant-locations feed.
const insightQ = useInvoiceInsight(DEFAULT_TENANT_ID); 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 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 // Revenue + profit come from the live invoice/financial insight. The endpoint
// returns two distinct figures (revenue and profit); we surface both rather than // 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 monthlyRevenue = insight ? insight.revenue : null;
const monthlyProfit = insight ? insight.profit : null; const monthlyProfit = insight ? insight.profit : null;
const locSummaryQ = useFiestaLocationSummary(FIESTA_TENANT_ID); const locSummaryQ = useFiestaLocationSummary(tenantId);
const summaries = locSummaryQ.data ?? []; const summaries = locSummaryQ.data ?? [];
// Region fulfillment — live month-to-date delivered ÷ total orders for the tenant. // Region fulfillment — live month-to-date delivered ÷ total orders for the tenant.

View File

@@ -14,6 +14,7 @@
*/ */
import React, { useMemo, useState } from 'react'; import React, { useMemo, useState } from 'react';
import { createPortal } from 'react-dom';
import { import {
Truck, Clock, CheckCircle2, XCircle, Calendar, Sun, Sunset, Moon, Layers, UserCheck, MapPin, Phone, Package, Loader2, X, Bike, Truck, Clock, CheckCircle2, XCircle, Calendar, Sun, Sunset, Moon, Layers, UserCheck, MapPin, Phone, Package, Loader2, X, Bike,
} from 'lucide-react'; } 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, DELIVERY_STATUS, statusColor, BRAND, BRAND_LIGHT, TEXT, TEXT_2, TEXT_3, BORDER, DIVIDER, SURFACE_ALT, tint, soft, edge,
} from './consoleUi'; } 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'; type DeliveryStatus = 'pending' | 'accepted' | 'arrived' | 'picked' | 'active' | 'skipped' | 'delivered' | 'cancelled';
const STATUS_TABS: Array<{ key: DeliveryStatus; label: string }> = [ 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; if (b === 'afternoon') return h >= 9 && h < 12.5;
return h >= 16 && h < 19; 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 today = new Date();
const [fromdate, setFromdate] = useState<string>(ymd(today)); const monthStart = new Date(today.getFullYear(), today.getMonth(), 1);
const [todate, setTodate] = useState<string>(ymd(today));
const dayOffset = (n: number) => { const d = new Date(); d.setDate(d.getDate() - n); return ymd(d); }; 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<string>(dayOffset(6));
const [todate, setTodate] = useState<string>(ymd(today));
const presets = [ const presets = [
{ key: 'today', label: 'Today', from: ymd(today), to: ymd(today) }, { 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: '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 activePreset = presets.find((p) => p.from === fromdate && p.to === todate)?.key ?? 'custom';
const [batch, setBatch] = useState<BatchId>(initialBatch()); const batch: BatchId = 'all';
const [status, setStatus] = useState<DeliveryStatus>('pending'); const [status, setStatus] = useState<DeliveryStatus>('pending');
const [localSearch, setLocalSearch] = useState(''); const [localSearch, setLocalSearch] = useState('');
const [detailRow, setDetailRow] = useState<Row | null>(null); const [detailRow, setDetailRow] = useState<Row | null>(null);
const summaryQ = useFiestaDeliverySummary({ tenantid: FIESTA_TENANT_ID, fromdate, todate }); // Scope to the user's store when a locationid is supplied (server-side per the
const deliveriesQ = useFiestaDeliveries({ tenantid: FIESTA_TENANT_ID, fromdate, todate }); // backend's deliverysummary/getdeliveries locationid param). getDeliveries loads
const ridersQ = useFiestaRiders({ tenantid: FIESTA_TENANT_ID }); // 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 allRows = deliveriesQ.data ?? [];
const summary = summaryQ.data; const summary = summaryQ.data;
@@ -143,70 +142,70 @@ export default function DeliveriesView({ searchQuery = '', locationid }: Deliver
<div className="flex items-center gap-2 text-xs"> <div className="flex items-center gap-2 text-xs">
<input type="date" value={fromdate} max={todate} onChange={(e) => 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' }} /> <input type="date" value={fromdate} max={todate} onChange={(e) => 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' }} />
<span style={{ color: TEXT_3 }}></span> <span style={{ color: TEXT_3 }}></span>
<input type="date" value={todate} min={fromdate} max={ymd(today)} onChange={(e) => 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' }} /> <input type="date" value={todate} min={fromdate} onChange={(e) => 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' }} />
</div> </div>
</div> </div>
<div className="flex items-center gap-2 flex-wrap pt-3 mt-3 border-t" style={{ borderColor: DIVIDER }}>
<span className="text-[10px] font-extrabold uppercase tracking-widest pr-1" style={{ color: TEXT_2 }}>Wave</span>
{BATCHES.map((b) => {
const Icon = b.icon;
const count = allRows.filter((r) => (!locationid || fnum(r.locationid) === locationid) && inBatch(r, b.id)).length;
return (
<React.Fragment key={b.id}>
<Pill active={batch === b.id} color={b.color} onClick={() => setBatch(b.id)} title={b.range} count={count}><Icon size={13} /> {b.label}</Pill>
</React.Fragment>
);
})}
</div>
</FilterBar> </FilterBar>
{/* Status tabs + search */} {/* Status tabs + search */}
<FilterBar className="mb-4"> <FilterBar className="mb-4">
<div className="flex flex-col lg:flex-row lg:items-center gap-3"> <div className="flex flex-col lg:flex-row lg:items-center gap-3">
<div className="flex items-center gap-2 overflow-x-auto py-0.5 flex-1 min-w-0"> <div className="flex items-center gap-2 overflow-x-auto py-0.5 flex-1 min-w-0">
{STATUS_TABS.map((t) => { {STATUS_TABS.map((t) => (
const color = statusColor(DELIVERY_STATUS, t.key);
return (
<React.Fragment key={t.key}> <React.Fragment key={t.key}>
<Pill active={status === t.key} color={color} onClick={() => setStatus(t.key)} count={statusCounts[t.key] ?? 0}>{t.label}</Pill> <Pill active={status === t.key} color={BRAND} onClick={() => setStatus(t.key)} count={statusCounts[t.key] ?? 0}>{t.label}</Pill>
</React.Fragment> </React.Fragment>
); ))}
})}
</div> </div>
<div className="w-full lg:w-72 lg:shrink-0"><SearchPill value={localSearch} onChange={setLocalSearch} placeholder="Search by order, rider…" color="#6366f1" /></div> <div className="w-full lg:w-72 lg:shrink-0"><SearchPill value={localSearch} onChange={setLocalSearch} placeholder="Search by order, rider…" /></div>
</div> </div>
</FilterBar> </FilterBar>
{/* Table */} {/* Table */}
<div className="bg-white border rounded-2xl overflow-hidden" style={{ borderColor: BORDER }}> <div className="bg-white border rounded-2xl overflow-hidden" style={{ borderColor: BORDER }}>
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<table className="w-full" style={{ minWidth: 1040 }}> <table className="w-full" style={{ minWidth: 1240 }}>
<thead> <thead>
<tr>{['#', 'Status', 'Order', 'Drop', 'Rider', 'ETA', 'KMs', 'Amount', ''].map((h, i) => (<th key={i} className="px-3 py-2.5 text-left" style={TH_STYLE}>{h}</th>))}</tr> <tr>{['S.No', 'Tenant', 'Order ID', 'Pickup', 'Delivery', 'Rider', 'KMS', 'Amount', 'Status', 'Notes', 'Action'].map((h, i) => (<th key={i} className="px-3 py-2.5 text-left" style={TH_STYLE}>{h}</th>))}</tr>
</thead> </thead>
<tbody> <tbody>
{deliveriesQ.isLoading ? ( {deliveriesQ.isLoading ? (
<tr><td colSpan={9} className="px-3 py-12 text-center" style={{ color: TEXT_3 }}><span className="inline-flex items-center gap-2 text-xs font-semibold"><Loader2 size={15} className="animate-spin" style={{ color: BRAND }} /> Loading deliveries</span></td></tr> <tr><td colSpan={11} className="px-3 py-12 text-center" style={{ color: TEXT_3 }}><span className="inline-flex items-center gap-2 text-xs font-semibold"><Loader2 size={15} className="animate-spin" style={{ color: BRAND }} /> Loading deliveries</span></td></tr>
) : rows.length === 0 ? ( ) : rows.length === 0 ? (
<tr><td colSpan={9} className="px-3 py-12 text-center text-xs" style={{ color: TEXT_3 }}>No {status} deliveries in this wave. Try another status, wave, or date.</td></tr> <tr><td colSpan={11} className="px-3 py-12 text-center text-xs" style={{ color: TEXT_3 }}>No {status} deliveries in this wave. Try another status, wave, or date.</td></tr>
) : ( ) : (
rows.map((r, i) => { rows.map((r, i) => {
const st = fstr(r.orderstatus).toLowerCase(); const st = fstr(r.orderstatus).toLowerCase();
const rider = fstr(r.ridername) || fstr(r.username); 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 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 ( return (
<tr key={fstr(r.deliveryid) || fstr(r.orderid) || i} className="transition-colors align-top" style={{ borderBottom: `1px solid ${DIVIDER}` }} <tr key={fstr(r.deliveryid) || fstr(r.orderid) || i} className="transition-colors align-top" style={{ borderBottom: `1px solid ${DIVIDER}` }}
onMouseEnter={(e) => (e.currentTarget.style.background = SURFACE_ALT)} onMouseLeave={(e) => (e.currentTarget.style.background = 'transparent')}> onMouseEnter={(e) => (e.currentTarget.style.background = SURFACE_ALT)} onMouseLeave={(e) => (e.currentTarget.style.background = 'transparent')}>
<td className="px-3 py-2.5 font-mono" style={{ color: TEXT_3 }}>{i + 1}</td> <td className="px-3 py-2.5 font-mono" style={{ color: TEXT_3 }}>{i + 1}</td>
<td className="px-3 py-2.5"><StatusChip label={st || '—'} color={statusColor(DELIVERY_STATUS, st)} /></td> <td className="px-3 py-2.5">
<p className="font-bold text-[12px] truncate max-w-[150px]" style={{ color: TEXT }}>{tenant || '—'}</p>
{fstr(r.tenantcity) && <p className="text-[10px] truncate max-w-[150px]" style={{ color: TEXT_2 }}>{fstr(r.tenantcity)}</p>}
</td>
<td className="px-3 py-2.5"> <td className="px-3 py-2.5">
<p className="font-extrabold font-mono text-[13px]" style={{ color: TEXT }}>{fstr(r.orderid) || `DLV-${fstr(r.deliveryid)}`}</p> <p className="font-extrabold font-mono text-[13px]" style={{ color: TEXT }}>{fstr(r.orderid) || `DLV-${fstr(r.deliveryid)}`}</p>
<p className="text-[10px]" style={{ color: TEXT_2 }}>{shortTime(r.assigntime || r.deliverydate)}</p> <p className="text-[10px]" style={{ color: TEXT_2 }}>{shortTime(r.assigntime || r.deliverydate)}</p>
</td> </td>
<td className="px-3 py-2.5"> <td className="px-3 py-2.5">
<p className="font-bold text-[12px] truncate max-w-[160px]" style={{ color: TEXT }}>{fstr(r.deliverycustomer) || '—'}</p> <p className="font-bold text-[12px] truncate max-w-[160px]" style={{ color: TEXT }}>{pickupName || pickupAddr || '—'}</p>
<p className="text-[10px] truncate max-w-[160px]" style={{ color: TEXT_2 }}>{fstr(r.deliverysuburb) || fstr(r.deliveryaddress)}</p> <p className="text-[10px] truncate max-w-[160px]" style={{ color: TEXT_2 }}>{pickupName ? pickupAddr : ''}</p>
</td>
<td className="px-3 py-2.5">
<p className="font-bold text-[12px] truncate max-w-[160px]" style={{ color: TEXT }}>{dropName || dropAddr || '—'}</p>
<p className="text-[10px] truncate max-w-[160px]" style={{ color: TEXT_2 }}>{dropName ? dropAddr : ''}</p>
</td> </td>
<td className="px-3 py-2.5"> <td className="px-3 py-2.5">
{rider ? ( {rider ? (
@@ -216,7 +215,6 @@ export default function DeliveriesView({ searchQuery = '', locationid }: Deliver
</span> </span>
) : <span className="text-[11px] italic" style={{ color: TEXT_3 }}>Unassigned</span>} ) : <span className="text-[11px] italic" style={{ color: TEXT_3 }}>Unassigned</span>}
</td> </td>
<td className="px-3 py-2.5"><MetricPill color="#06b6d4">{shortTime(r.expecteddeliverytime) || '—'}</MetricPill></td>
<td className="px-3 py-2.5"> <td className="px-3 py-2.5">
<div className="flex flex-col items-start gap-1"> <div className="flex flex-col items-start gap-1">
<MetricPill color="#ef4444" minWidth={64}>{kms ? kms.toFixed(1) : '—'}</MetricPill> <MetricPill color="#ef4444" minWidth={64}>{kms ? kms.toFixed(1) : '—'}</MetricPill>
@@ -230,6 +228,10 @@ export default function DeliveriesView({ searchQuery = '', locationid }: Deliver
{charge === 0 && amt === 0 && <span style={{ color: TEXT_3 }}></span>} {charge === 0 && amt === 0 && <span style={{ color: TEXT_3 }}></span>}
</div> </div>
</td> </td>
<td className="px-3 py-2.5"><StatusChip label={st || '—'} color={statusColor(DELIVERY_STATUS, st)} /></td>
<td className="px-3 py-2.5">
<p className="text-[11px] truncate max-w-[160px]" style={{ color: notes ? TEXT_2 : TEXT_3 }} title={notes}>{notes || '—'}</p>
</td>
<td className="px-3 py-2.5 text-right"> <td className="px-3 py-2.5 text-right">
<button onClick={() => setDetailRow(r)} className="rounded-full font-extrabold cursor-pointer" style={{ padding: '4px 12px', fontSize: 11, color: BRAND, background: tint(BRAND), border: `1px solid ${edge(BRAND)}` }}>View</button> <button onClick={() => setDetailRow(r)} className="rounded-full font-extrabold cursor-pointer" style={{ padding: '4px 12px', fontSize: 11, color: BRAND, background: tint(BRAND), border: `1px solid ${edge(BRAND)}` }}>View</button>
</td> </td>
@@ -262,13 +264,15 @@ function DeliveryDetailModal({ row, onClose }: { row: Row; onClose: () => void }
const st = fstr(row.orderstatus).toLowerCase(); const st = fstr(row.orderstatus).toLowerCase();
const rider = fstr(row.ridername) || fstr(row.username); const rider = fstr(row.ridername) || fstr(row.username);
const steps = [ 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' }, { label: 'Picked', field: 'pickuptime' }, { label: 'Delivered', field: 'deliverytime' },
]; ];
return ( // Portal to <body> so `fixed inset-0` is viewport-relative even when an ancestor
// in the view tree is transformed/blurred (otherwise the panel collapses).
return createPortal(
<div className="fixed inset-0 z-[200] flex items-center justify-center p-4" style={{ background: 'rgba(15,23,42,0.4)', backdropFilter: 'blur(4px)' }} onClick={(e) => { if (e.target === e.currentTarget) onClose(); }}> <div className="fixed inset-0 z-[200] flex items-center justify-center p-4" style={{ background: 'rgba(15,23,42,0.4)', backdropFilter: 'blur(4px)' }} onClick={(e) => { if (e.target === e.currentTarget) onClose(); }}>
<div className="bg-white w-full max-w-lg max-h-[90vh] flex flex-col overflow-hidden rounded-2xl animate-in zoom-in-95 duration-200" style={{ border: `1px solid ${BORDER}`, boxShadow: '0 18px 50px rgba(15,23,42,0.18)' }}> <div className="bg-white max-h-[90vh] flex flex-col overflow-hidden rounded-2xl animate-in zoom-in-95 duration-200" style={{ width: 'min(32rem, 92vw)', border: `1px solid ${BORDER}`, boxShadow: '0 18px 50px rgba(15,23,42,0.18)' }}>
<div style={{ height: 4, background: `linear-gradient(90deg, #6366f1 0%, ${soft('#6366f1')} 100%)` }} /> <div style={{ height: 4, background: `linear-gradient(90deg, #6366f1 0%, ${soft('#6366f1')} 100%)` }} />
<div className="p-4 border-b flex justify-between items-center shrink-0" style={{ borderColor: BORDER, background: SURFACE_ALT }}> <div className="p-4 border-b flex justify-between items-center shrink-0" style={{ borderColor: BORDER, background: SURFACE_ALT }}>
<h4 className="font-extrabold flex items-center gap-2" style={{ color: TEXT }}><Truck size={16} style={{ color: '#6366f1' }} /> {fstr(row.orderid) || `Delivery ${fstr(row.deliveryid)}`}</h4> <h4 className="font-extrabold flex items-center gap-2" style={{ color: TEXT }}><Truck size={16} style={{ color: '#6366f1' }} /> {fstr(row.orderid) || `Delivery ${fstr(row.deliveryid)}`}</h4>
@@ -280,9 +284,9 @@ function DeliveryDetailModal({ row, onClose }: { row: Row; onClose: () => void }
<span className="inline-flex items-center gap-1.5 text-[11px] font-medium" style={{ color: TEXT_2 }}><UserCheck size={12} /> {rider || 'Unassigned'}</span> <span className="inline-flex items-center gap-1.5 text-[11px] font-medium" style={{ color: TEXT_2 }}><UserCheck size={12} /> {rider || 'Unassigned'}</span>
</div> </div>
<div className="p-3 rounded-xl space-y-1.5" style={{ background: SURFACE_ALT, border: `1px solid ${BORDER}` }}> <div className="p-3 rounded-xl space-y-1.5" style={{ background: SURFACE_ALT, border: `1px solid ${BORDER}` }}>
<div className="font-bold" style={{ color: TEXT }}>{fstr(row.deliverycustomer) || 'Customer'}</div> <div className="font-bold" style={{ color: TEXT }}>{fstr(row.deliverycustomer) || fstr(row.deliverycontactno) || 'Customer'}</div>
{fstr(row.deliverycontactno) && <div className="flex items-center gap-2 font-mono text-xs" style={{ color: TEXT_2 }}><Phone size={12} /> {fstr(row.deliverycontactno)}</div>} {fstr(row.deliverycontactno) && <div className="flex items-center gap-2 font-mono text-xs" style={{ color: TEXT_2 }}><Phone size={12} /> {fstr(row.deliverycontactno)}</div>}
<div className="flex items-start gap-2 text-xs" style={{ color: TEXT_2 }}><MapPin size={12} className="mt-0.5 shrink-0" /> <span className="leading-relaxed">{fstr(row.deliveryaddress) || fstr(row.deliverysuburb) || 'Address unavailable'}</span></div> <div className="flex items-start gap-2 text-xs" style={{ color: TEXT_2 }}><MapPin size={12} className="mt-0.5 shrink-0" /> <span className="leading-relaxed">{fstr(row.deliveryaddress) || fstr(row.deliverylocation) || fstr(row.deliverysuburb) || 'Address unavailable'}</span></div>
</div> </div>
<div> <div>
<span className="text-[10px] font-extrabold uppercase tracking-wide block mb-2" style={{ color: TEXT_2 }}>Delivery Timeline</span> <span className="text-[10px] font-extrabold uppercase tracking-wide block mb-2" style={{ color: TEXT_2 }}>Delivery Timeline</span>
@@ -318,6 +322,7 @@ function DeliveryDetailModal({ row, onClose }: { row: Row; onClose: () => void }
<button onClick={onClose} className="rounded-full font-bold cursor-pointer text-white" style={{ padding: '8px 16px', background: `linear-gradient(135deg, ${BRAND}, ${BRAND_LIGHT})` }}>Close</button> <button onClick={onClose} className="rounded-full font-bold cursor-pointer text-white" style={{ padding: '8px 16px', background: `linear-gradient(135deg, ${BRAND}, ${BRAND_LIGHT})` }}>Close</button>
</div> </div>
</div> </div>
</div> </div>,
document.body,
); );
} }

View File

@@ -14,27 +14,23 @@
*/ */
import React, { useMemo, useState } from 'react'; import React, { useMemo, useState } from 'react';
import { TrendingUp, Clock, CheckCircle2, IndianRupee, Bike, Truck, Calendar, Download, Store, ClipboardList, Route } from 'lucide-react'; import { TrendingUp, Clock, CheckCircle2, IndianRupee, Bike, Truck, Calendar, Store } from 'lucide-react';
import { useFiestaLocationSummary, useFiestaFleetSummary, useFiestaDeliveries } from '../services/fiestaQueries'; import { useFiestaLocationSummary, useFiestaFleetSummary } from '../services/fiestaQueries';
import { FIESTA_TENANT_ID, num as fnum, str as fstr, ymd, type Row } from '../services/fiestaApi'; 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 { 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, DELIVERY_STATUS, statusColor, BRAND, BRAND_LIGHT, TEXT, TEXT_2, TEXT_3, BORDER, DIVIDER, SURFACE_ALT, tint, soft, edge, ring,
} from './consoleUi'; } 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 }> = [ const TABS: Array<{ key: ReportTab; label: string; icon: typeof TrendingUp }> = [
{ key: 'orders-summary', label: 'Orders Summary', icon: Store }, { key: 'orders-summary', label: 'Orders Summary', icon: Store },
{ key: 'riders-summary', label: 'Riders Summary', icon: Bike }, { 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 today = new Date();
const monthStart = new Date(today.getFullYear(), today.getMonth(), 1); const monthStart = new Date(today.getFullYear(), today.getMonth(), 1);
const [fromdate, setFromdate] = useState<string>(ymd(monthStart)); const [fromdate, setFromdate] = useState<string>(ymd(monthStart));
@@ -52,7 +48,7 @@ export default function DeliveryReportsView({ searchQuery = '' }: DeliveryReport
return ( return (
<div className="animate-in fade-in duration-300"> <div className="animate-in fade-in duration-300">
<GradientHeader title="Delivery Reports" subtitle="Operational analytics across outlets, riders, and the full order lifecycle." /> <GradientHeader title="Reports" subtitle="Operational analytics across outlets, riders, and the full order lifecycle." />
{/* Tab nav */} {/* Tab nav */}
<FilterBar className="mb-4"> <FilterBar className="mb-4">
@@ -85,15 +81,8 @@ export default function DeliveryReportsView({ searchQuery = '' }: DeliveryReport
</div> </div>
</FilterBar> </FilterBar>
{tab === 'orders-summary' && <OrdersSummaryReport />} {tab === 'orders-summary' && <OrdersSummaryReport tenantId={tenantId} />}
{tab === 'riders-summary' && <RidersSummaryReport fromdate={fromdate} todate={todate} />} {tab === 'riders-summary' && <RidersSummaryReport fromdate={fromdate} todate={todate} tenantId={tenantId} />}
{tab === 'orders-details' && <OrdersDetailsReport fromdate={fromdate} todate={todate} searchQuery={searchQuery} />}
{tab === 'maps' && (
<div className="bg-white border rounded-2xl p-4" style={{ borderColor: BORDER }}>
<span className="text-[10px] font-extrabold uppercase tracking-widest flex items-center gap-1.5 mb-2" style={{ color: TEXT_2 }}><Route size={12} /> Planned routes & live rider logs</span>
<AwaitingApi label="Rider route maps & live location logs" api="maps + rider telemetry" />
</div>
)}
</div> </div>
); );
} }
@@ -115,8 +104,8 @@ function TableShell({ minWidth, head, children, footer }: { minWidth: number; he
} }
// ── Orders Summary (per outlet) ────────────────────────────────────────────────── // ── Orders Summary (per outlet) ──────────────────────────────────────────────────
function OrdersSummaryReport() { function OrdersSummaryReport({ tenantId }: { tenantId: number }) {
const q = useFiestaLocationSummary(FIESTA_TENANT_ID); const q = useFiestaLocationSummary(tenantId);
const rows = q.data ?? []; 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 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 = [ const kpis = [
@@ -150,8 +139,8 @@ function OrdersSummaryReport() {
} }
// ── Riders Summary (per rider) ─────────────────────────────────────────────────── // ── Riders Summary (per rider) ───────────────────────────────────────────────────
function RidersSummaryReport({ fromdate, todate }: { fromdate: string; todate: string }) { function RidersSummaryReport({ fromdate, todate, tenantId }: { fromdate: string; todate: string; tenantId: number }) {
const q = useFiestaFleetSummary({ tenantid: FIESTA_TENANT_ID, fromdate, todate }); const q = useFiestaFleetSummary({ tenantid: tenantId, fromdate, todate });
const rows = q.data ?? []; const rows = q.data ?? [];
const mapped = rows.map((r) => ({ const mapped = rows.map((r) => ({
name: fstr(r.fullname) || `${fstr(r.firstname)} ${fstr(r.lastname)}`.trim() || fstr(r.username) || `Rider ${fstr(r.userid)}`, 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<DetailStatus>('all');
const [localSearch, setLocalSearch] = useState('');
const statusCounts = useMemo(() => {
const acc: Record<string, number> = {};
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 (
<div className="space-y-4">
<FilterBar>
<div className="flex flex-col lg:flex-row lg:items-center gap-3">
<div className="flex items-center gap-2 overflow-x-auto py-0.5 flex-1 min-w-0">
{DETAIL_STATUSES.map((s) => {
const color = s === 'all' ? BRAND : statusColor(DELIVERY_STATUS, s);
return (
<React.Fragment key={s}>
<Pill active={status === s} color={color} onClick={() => setStatus(s)} count={s === 'all' ? allRows.length : statusCounts[s] ?? 0}>
<span className="capitalize">{s}</span>
</Pill>
</React.Fragment>
);
})}
</div>
<div className="flex items-center gap-2 lg:shrink-0">
<div className="w-full lg:w-56"><SearchPill value={localSearch} onChange={setLocalSearch} placeholder="Search…" /></div>
<button onClick={exportCsv} disabled={rows.length === 0} className="inline-flex items-center gap-1.5 rounded-full font-extrabold text-white cursor-pointer disabled:opacity-40 disabled:cursor-not-allowed whitespace-nowrap"
style={{ padding: '7px 14px', fontSize: 12, background: `linear-gradient(135deg, ${BRAND}, ${BRAND_LIGHT})`, boxShadow: `0 6px 18px ${ring(BRAND)}` }}>
<Download size={13} /> CSV
</button>
</div>
</div>
</FilterBar>
<TableShell minWidth={1040} head={['#', 'Order', 'Drop', 'Rider', 'Assigned', 'Delivered', 'KMs', 'Charges', 'Status']}
footer={<div className="px-4 py-2.5 border-t text-[10px] font-bold uppercase tracking-wider" style={{ borderColor: BORDER, background: SURFACE_ALT, color: TEXT_2 }}>{rows.length} rows · {fromdate} {todate}</div>}>
{q.isLoading ? <tr><td colSpan={9} className="px-3 py-12 text-center text-xs" style={{ color: TEXT_3 }}>Loading order details</td></tr>
: rows.length === 0 ? <tr><td colSpan={9} className="px-3 py-12 text-center text-xs" style={{ color: TEXT_3 }}>No deliveries match this filter.</td></tr>
: 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 (
<tr key={fstr(r.deliveryid) || fstr(r.orderid) || i} className="transition-colors align-top" style={{ borderBottom: `1px solid ${DIVIDER}` }} onMouseEnter={(e) => (e.currentTarget.style.background = SURFACE_ALT)} onMouseLeave={(e) => (e.currentTarget.style.background = 'transparent')}>
<td className="px-3 py-2.5 font-mono text-left" style={{ color: TEXT_3 }}>{i + 1}</td>
<td className="px-3 py-2.5 text-left">
<p className="font-extrabold font-mono text-[13px]" style={{ color: TEXT }}>{fstr(r.orderid) || `DLV-${fstr(r.deliveryid)}`}</p>
<p className="text-[10px]" style={{ color: TEXT_2 }}>{shortTime(r.deliverydate || r.assigntime)}</p>
</td>
<td className="px-3 py-2.5 text-left">
<p className="font-bold text-[12px] truncate max-w-[160px]" style={{ color: TEXT }}>{fstr(r.deliverycustomer) || '—'}</p>
<p className="text-[10px] truncate max-w-[160px]" style={{ color: TEXT_2 }}>{fstr(r.deliverysuburb) || fstr(r.deliveryaddress)}</p>
</td>
<td className="px-3 py-2.5 text-right font-medium text-xs truncate max-w-[110px]" style={{ color: TEXT_2 }}>{rider || '—'}</td>
<td className="px-3 py-2.5 text-right font-mono text-xs" style={{ color: TEXT_2 }}>{shortTime(r.assigntime) || '—'}</td>
<td className="px-3 py-2.5 text-right font-mono text-xs" style={{ color: TEXT_2 }}>{fstr(r.deliverytime) ? shortTime(r.deliverytime) : '—'}</td>
<td className="px-3 py-2.5 text-right">{fnum(r.kms) ? <MetricPill color="#ef4444" minWidth={52}>{fnum(r.kms).toFixed(1)}</MetricPill> : <span style={{ color: TEXT_3 }}></span>}</td>
<td className="px-3 py-2.5 text-right">{charge > 0 ? <MetricPill color="#10b981" minWidth={64}>{charge.toLocaleString('en-IN')}</MetricPill> : <span style={{ color: TEXT_3 }}></span>}</td>
<td className="px-3 py-2.5 text-right"><StatusChip label={st || '—'} color={statusColor(DELIVERY_STATUS, st)} /></td>
</tr>
);
})}
</TableShell>
</div>
);
}
// ── Total bar (gradient) ───────────────────────────────────────────────────────── // ── Total bar (gradient) ─────────────────────────────────────────────────────────
function TotalBar({ chips, grand }: { chips: Array<{ label: string; color: string }>; grand?: string }) { function TotalBar({ chips, grand }: { chips: Array<{ label: string; color: string }>; grand?: string }) {

View File

@@ -7,9 +7,9 @@
* Dispatch page — a faithful port of the operations console's dispatch cockpit * Dispatch page — a faithful port of the operations console's dispatch cockpit
* (nearle_console/dispatch). It reuses that page's actual stylesheet verbatim * (nearle_console/dispatch). It reuses that page's actual stylesheet verbatim
* (`./DispatchView.css`, copied from Dispatch.css) and reproduces the same DOM / * (`./DispatchView.css`, copied from Dispatch.css) and reproduces the same DOM /
* class structure: the `#hdr` bar, `#strat-row` view tabs, `#batch-row` wave * class structure: the `#hdr` bar, `#strat-row` view tabs, the 400px `#sidebar`
* selector, the 400px `#sidebar` (RIDER DISPATCH header + KPI tiles + rider/zone * (RIDER DISPATCH header + KPI tiles + rider/zone cards + per-trip order cards),
* cards + per-trip order cards), and the `#map-wrap` centrepiece. * and the `#map-wrap` centrepiece.
* *
* The source map is a Leaflet canvas of planned-vs-actual rider routes (OSRM * 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 * road-snapping, Kalman-smoothed GPS) plus AI rider-assignment posting to
@@ -24,8 +24,8 @@ import {
Map as MapIcon, Map as MapIcon,
MapPin, MapPin,
Bike, Bike,
Globe, ShoppingBag,
Info, Truck,
Package, Package,
Ruler, Ruler,
Wallet, Wallet,
@@ -40,11 +40,9 @@ import {
ChevronRight, ChevronRight,
List, List,
Play, Play,
PlugZap,
} from 'lucide-react'; } from 'lucide-react';
import { useFiestaDeliveries, useFiestaRiders } from '../services/fiestaQueries'; import { useFiestaDeliveries, useFiestaRiders } from '../services/fiestaQueries';
import { FIESTA_TENANT_ID, num as fnum, str as fstr, ymd, type Row } from '../services/fiestaApi'; 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 DispatchMap, { type MapPoint } from './DispatchMap';
import './DispatchView.css'; import './DispatchView.css';
@@ -86,45 +84,14 @@ function pickupLatLon(r: Row): [number, number] | null {
return lat && lon ? [lat, lon] : null; return lat && lon ? [lat, lon] : null;
} }
// ── Batch / wave model (canonical half-open hour ranges, local time) ─────────────
// Mirrors Dispatch.js BATCH_OPTIONS: gaps (89, 12:3016, 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) ─────────────────────────────────────────── // ── 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 }> = [ const VIEW_TABS: Array<{ id: ViewMode; label: string; icon: typeof MapIcon }> = [
{ id: 'kitchens', label: 'By Location', icon: MapPin }, { id: 'kitchens', label: 'By Location', icon: MapPin },
{ id: 'zones', label: 'By Zone', icon: MapIcon }, { id: 'zones', label: 'By Zone', icon: MapIcon },
{ id: 'riders', label: 'By Rider', icon: Bike }, { id: 'riders', label: 'By Rider', icon: Bike },
{ id: 'all', label: 'All Routes', icon: Globe }, { id: 'orders', label: 'By Orders', icon: ShoppingBag },
{ id: 'rider-info', label: 'Rider Info', icon: Info }, { id: 'deliveries', label: 'By Deliveries', icon: Truck },
]; ];
interface Group { interface Group {
@@ -142,15 +109,15 @@ interface Group {
interface DispatchViewProps { interface DispatchViewProps {
locationid?: number; locationid?: number;
tenantId?: number;
} }
const WEEKDAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']; const WEEKDAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']; 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 today = new Date();
const [date, setDate] = useState<string>(ymd(today)); const [date, setDate] = useState<string>(ymd(today));
const [batch, setBatch] = useState<BatchId>(initialBatch());
const [viewMode, setViewMode] = useState<ViewMode>('riders'); const [viewMode, setViewMode] = useState<ViewMode>('riders');
const [focusedId, setFocusedId] = useState<string | null>(null); const [focusedId, setFocusedId] = useState<string | null>(null);
const [sidebarCollapsed, setSidebarCollapsed] = useState(false); const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
@@ -158,37 +125,26 @@ export default function DispatchView({ locationid }: DispatchViewProps) {
const [animateNonce, setAnimateNonce] = useState(0); const [animateNonce, setAnimateNonce] = useState(0);
const [animating, setAnimating] = useState(false); const [animating, setAnimating] = useState(false);
const deliveriesQ = useFiestaDeliveries({ tenantid: FIESTA_TENANT_ID, fromdate: date, todate: date }); const deliveriesQ = useFiestaDeliveries({ tenantid: tenantId, fromdate: date, todate: date, locationid });
const ridersQ = useFiestaRiders({ tenantid: FIESTA_TENANT_ID }); const ridersQ = useFiestaRiders({ tenantid: tenantId });
// Sample-data fallback: when the live feed returns nothing, render the demo set // Live deliveries only — no sample/demo fallback. When the feed is empty the
// so the cockpit isn't blank. The header labels it "Sample data" so it's never // cockpit shows a genuine empty state rather than fabricated riders/stops.
// mistaken for live (see services/dispatchMockData.ts). const allRows = deliveriesQ.data ?? [];
const liveRows = deliveriesQ.data ?? []; const inScope = (r: Row) => !locationid || fnum(r.locationid) === locationid;
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;
const rows = useMemo( const rows = useMemo(
() => allRows.filter((r) => inScope(r) && inBatch(r, batch)), () => allRows.filter(inScope),
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
[allRows, batch, locationid, usingMock], [allRows, locationid],
); );
const batchCounts = useMemo(() => {
const acc: Record<string, number> = { 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 ──────────────────────────────────────────────────────────────── // ── Grouping ────────────────────────────────────────────────────────────────
const groups = useMemo<Group[]>(() => { const groups = useMemo<Group[]>(() => {
const map = new Map<string, Group>(); const map = new Map<string, Group>();
const titleCase = (s: string) => (s ? s.charAt(0).toUpperCase() + s.slice(1) : s);
const keyOf = (r: Row): { id: string; name: string } => { 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'; const id = fstr(r.userid) || fstr(r.ridername) || 'unassigned';
return { id, name: fstr(r.ridername) || fstr(r.username) || (id === 'unassigned' ? 'Unassigned' : `Rider ${id}`) }; 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'; const name = fstr(r.pickupcustomer) || fstr(r.pickuplocation) || 'Pickup';
return { id: name.toLowerCase(), name }; 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'; const name = fstr(r.deliverysuburb) || fstr(r.zone_name) || 'Unzoned';
return { id: name.toLowerCase(), name }; return { id: name.toLowerCase(), name };
}; };
@@ -222,7 +187,7 @@ export default function DispatchView({ locationid }: DispatchViewProps) {
}, [rows, viewMode]); }, [rows, viewMode]);
const focused = groups.find((g) => g.id === focusedId) ?? null; 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). // Trip blocks for the focused group: by trip# (rider view) or by rider (zone/all view).
const tripBlocks = useMemo(() => { const tripBlocks = useMemo(() => {
@@ -264,7 +229,7 @@ export default function DispatchView({ locationid }: DispatchViewProps) {
}, [focused, groupedByRider, tripSort]); }, [focused, groupedByRider, tripSort]);
// Map points: the focused group's ordered stops (with a route), else every stop // 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<MapPoint[]>(() => { const mapPoints = useMemo<MapPoint[]>(() => {
const src = focused ? tripBlocks.flatMap((b) => b.orders) : rows; const src = focused ? tripBlocks.flatMap((b) => b.orders) : rows;
const out: MapPoint[] = []; const out: MapPoint[] = [];
@@ -293,8 +258,7 @@ export default function DispatchView({ locationid }: DispatchViewProps) {
// KPI scope. // KPI scope.
const totalOrders = rows.length; const totalOrders = rows.length;
const activeRiders = new Set(rows.map((r) => fstr(r.userid) || fstr(r.ridername)).filter(Boolean)).size; 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 fleetSize = (ridersQ.data ?? []).length;
const scopeLabel = BATCHES.find((b) => b.id === batch)?.label ?? 'All';
// Date chip helpers. // Date chip helpers.
const isToday = date === ymd(today); const isToday = date === ymd(today);
@@ -338,9 +302,9 @@ export default function DispatchView({ locationid }: DispatchViewProps) {
<span className="live-status live-status-error"> <span className="live-status live-status-error">
<span className="live-dot error" /> Offline <span className="live-dot error" /> Offline
</span> </span>
) : usingMock ? ( ) : totalOrders === 0 ? (
<span className="live-status" title="No live deliveries for this day — showing sample data"> <span className="live-status" title="No deliveries dispatched for this day">
<span className="live-dot" style={{ background: '#f59e0b' }} /> Sample data · {totalOrders} orders <span className="live-dot" style={{ background: '#94a3b8' }} /> No deliveries today
</span> </span>
) : ( ) : (
<span className="live-status live-status-ready"> <span className="live-status live-status-ready">
@@ -383,7 +347,7 @@ export default function DispatchView({ locationid }: DispatchViewProps) {
return ( return (
<button <button
key={t.id} key={t.id}
className={`sbt ${viewMode === t.id ? 'active' : ''}${t.id === 'rider-info' ? ' sbt-rider-info' : ''}`} className={`sbt ${viewMode === t.id ? 'active' : ''}`}
onClick={() => { setViewMode(t.id); setFocusedId(null); }} onClick={() => { setViewMode(t.id); setFocusedId(null); }}
> >
<span className="sbt-icon"><Icon size={15} /></span> <span className="sbt-icon"><Icon size={15} /></span>
@@ -393,24 +357,6 @@ export default function DispatchView({ locationid }: DispatchViewProps) {
})} })}
</div> </div>
{/* ── Batch / wave bar ── */}
<div id="batch-row">
<span className="batch-label">Batch</span>
<div className="batch-scroll">
{BATCHES.map((b) => (
<button
key={b.id}
className={`batch-btn batch-slot ${batch === b.id ? 'active' : ''}`}
onClick={() => { setBatch(b.id); setFocusedId(null); }}
title={`${b.label} (${b.range})`}
>
<span className="batch-btn-label">{b.label}</span>
<span className="batch-btn-count">{batchCounts[b.id] ?? 0}</span>
</button>
))}
</div>
</div>
{/* ── Body ── */} {/* ── Body ── */}
<div id="body" className={sidebarCollapsed ? 'sidebar-collapsed' : ''}> <div id="body" className={sidebarCollapsed ? 'sidebar-collapsed' : ''}>
<button <button
@@ -431,7 +377,7 @@ export default function DispatchView({ locationid }: DispatchViewProps) {
</div> </div>
<span className="sb-header-scope"> <span className="sb-header-scope">
<span className="sb-scope-dot" /> <span className="sb-scope-dot" />
{scopeLabel} {totalOrders} stops
</span> </span>
</div> </div>
<div className="sb-header-tiles"> <div className="sb-header-tiles">
@@ -466,15 +412,15 @@ export default function DispatchView({ locationid }: DispatchViewProps) {
fmtTime={fmtTime} fmtTime={fmtTime}
/> />
) : groups.length === 0 ? ( ) : groups.length === 0 ? (
<div className="ph">No deliveries in this wave</div> <div className="ph">No deliveries for this day</div>
) : ( ) : (
<> <>
<div className="ph"> <div className="ph">
{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})
</div> </div>
{groups.map((g) => ( {groups.map((g) => (
<React.Fragment key={g.id}> <React.Fragment key={g.id}>
{viewMode === 'riders' || viewMode === 'rider-info' {viewMode === 'riders'
? <RiderCard g={g} onClick={() => setFocusedId(g.id)} /> ? <RiderCard g={g} onClick={() => setFocusedId(g.id)} />
: <ZoneCard g={g} onClick={() => setFocusedId(g.id)} />} : <ZoneCard g={g} onClick={() => setFocusedId(g.id)} />}
</React.Fragment> </React.Fragment>
@@ -492,22 +438,18 @@ export default function DispatchView({ locationid }: DispatchViewProps) {
route={Boolean(focused)} route={Boolean(focused)}
routeColor={focused?.color || '#581c87'} routeColor={focused?.color || '#581c87'}
start={routeStart} start={routeStart}
resizeKey={`${sidebarCollapsed}|${viewMode}|${focusedId}|${batch}`} resizeKey={`${sidebarCollapsed}|${viewMode}|${focusedId}`}
animateNonce={animateNonce} animateNonce={animateNonce}
/> />
{/* Contextual note overlaid on the map */} {/* Contextual note overlaid on the map */}
{viewMode === 'rider-info' ? ( {mapPoints.length === 0 ? (
<div className="dmp-overlay-note"> <div className="dmp-overlay-note">
<PlugZap size={13} /> Live rider telemetry (battery · GPS · speed) awaiting backend map shows planned drops. <MapIcon size={13} /> No drop coordinates in {focused ? 'this route' : 'these deliveries'} yet.
</div>
) : mapPoints.length === 0 ? (
<div className="dmp-overlay-note">
<MapIcon size={13} /> No drop coordinates in this {focused ? 'route' : 'wave'} yet.
</div> </div>
) : !focused ? ( ) : !focused ? (
<div className="dmp-overlay-note"> <div className="dmp-overlay-note">
<MapIcon size={13} /> Select a {viewMode === 'kitchens' ? 'pickup point' : viewMode === 'zones' ? 'zone' : 'rider'} to draw its route. <MapIcon size={13} /> Select a {viewMode === 'kitchens' ? 'pickup point' : viewMode === 'zones' ? 'zone' : viewMode === 'riders' ? 'rider' : 'group'} to draw its route.
</div> </div>
) : null} ) : null}

View File

@@ -4,7 +4,7 @@
*/ */
import React, { useState, useRef, useEffect } from 'react'; 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'; import { MainSection } from '../types';
interface HeaderProps { interface HeaderProps {
@@ -17,6 +17,10 @@ interface HeaderProps {
isSidebarOpen: boolean; isSidebarOpen: boolean;
onHelpClick: () => void; onHelpClick: () => void;
onLogoutClick: () => 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. */ /** Signed-in user shown in the profile dropdown. */
profile: { name: string; role: string; email: string }; profile: { name: string; role: string; email: string };
} }
@@ -26,6 +30,8 @@ export default function Header({
isSidebarOpen, isSidebarOpen,
onHelpClick, onHelpClick,
onLogoutClick, onLogoutClick,
onAccountClick,
onQrClick,
profile profile
}: HeaderProps) { }: HeaderProps) {
const [showProfileDropdown, setShowProfileDropdown] = useState(false); const [showProfileDropdown, setShowProfileDropdown] = useState(false);
@@ -81,6 +87,18 @@ export default function Header({
{/* Global Actions Bar */} {/* Global Actions Bar */}
<div className="flex items-center gap-md"> <div className="flex items-center gap-md">
{/* Store QR — opens the QR modal (user store page only) */}
{onQrClick && (
<button
onClick={onQrClick}
title="Store QR code"
aria-label="Store QR code"
className="p-2 rounded-full hover:bg-purple-800 transition-colors cursor-pointer text-white"
>
<QrCode size={18} />
</button>
)}
{/* User profile with dropdown */} {/* User profile with dropdown */}
<div className="relative" ref={profileRef}> <div className="relative" ref={profileRef}>
<button <button
@@ -134,6 +152,17 @@ export default function Header({
{/* Account actions (moved here from the sidebar) */} {/* Account actions (moved here from the sidebar) */}
<div className="p-2 flex flex-col gap-0.5"> <div className="p-2 flex flex-col gap-0.5">
{onAccountClick && (
<button
onClick={() => { setShowProfileDropdown(false); onAccountClick(); }}
className="w-full flex items-center gap-2.5 px-2.5 py-2 rounded-xl text-xs font-semibold text-slate-700 hover:bg-slate-50 cursor-pointer transition-colors group/item"
>
<span className="h-7 w-7 rounded-lg bg-purple-50 text-[#581c87] ring-1 ring-purple-100 flex items-center justify-center group-hover/item:scale-110 transition-transform">
<User size={14} />
</span>
My Account
</button>
)}
<button <button
onClick={() => { setShowProfileDropdown(false); onHelpClick(); }} onClick={() => { setShowProfileDropdown(false); onHelpClick(); }}
className="w-full flex items-center gap-2.5 px-2.5 py-2 rounded-xl text-xs font-semibold text-slate-700 hover:bg-slate-50 cursor-pointer transition-colors group/item" className="w-full flex items-center gap-2.5 px-2.5 py-2 rounded-xl text-xs font-semibold text-slate-700 hover:bg-slate-50 cursor-pointer transition-colors group/item"

View File

@@ -38,6 +38,7 @@ import {
} from '../services/fiestaQueries'; } from '../services/fiestaQueries';
import { FIESTA_TENANT_ID, str as fstr } from '../services/fiestaApi'; import { FIESTA_TENANT_ID, str as fstr } from '../services/fiestaApi';
import { stockRowToProduct, stockRowToInventory } from '../services/fiestaMappers'; import { stockRowToProduct, stockRowToInventory } from '../services/fiestaMappers';
import { useStoreCatalogue } from '../services/storeCatalogue';
import AwaitingApi from './AwaitingApi'; import AwaitingApi from './AwaitingApi';
type StockRow = Record<string, unknown>; type StockRow = Record<string, unknown>;
@@ -46,18 +47,20 @@ const rowId = (r: StockRow) => String(r.productid ?? '') || String(r.productname
interface InventoryViewProps { interface InventoryViewProps {
searchQuery: string; searchQuery: string;
isCoimbatoreView: boolean; isCoimbatoreView: boolean;
tenantId?: number;
} }
export default function InventoryView({ export default function InventoryView({
searchQuery, searchQuery,
isCoimbatoreView isCoimbatoreView,
tenantId = FIESTA_TENANT_ID
}: InventoryViewProps) { }: InventoryViewProps) {
// ── Live stock across every outlet (Fiesta) ─────────────────────────────── // ── Live stock across every outlet (Fiesta) ───────────────────────────────
// This page is the admin's command surface. The GLOBAL CATALOG is the deduped // This page is the admin's command surface. The GLOBAL CATALOG is the deduped
// union of products across all outlets the tenant owns (admin-only import adds // union of products across all outlets the tenant owns (admin-only import adds
// to it); the STORE STOCK section shows each outlet's live stock so the admin // to it); the STORE STOCK section shows each outlet's live stock so the admin
// can see all the stores under them at a glance. // can see all the stores under them at a glance.
const locationsQ = useFiestaTenantLocations(FIESTA_TENANT_ID); const locationsQ = useFiestaTenantLocations(tenantId);
const locations = useMemo( const locations = useMemo(
() => () =>
(locationsQ.data ?? []).map((l) => ({ (locationsQ.data ?? []).map((l) => ({
@@ -69,7 +72,7 @@ export default function InventoryView({
); );
const storesStock = useFiestaStoresStock( const storesStock = useFiestaStoresStock(
FIESTA_TENANT_ID, tenantId,
locations.map(({ locationid, locationname }) => ({ locationid, locationname })), locations.map(({ locationid, locationname }) => ({ locationid, locationname })),
); );
const storesLoading = locationsQ.isLoading || storesStock.some((s) => s.isLoading); const storesLoading = locationsQ.isLoading || storesStock.some((s) => s.isLoading);
@@ -95,6 +98,8 @@ export default function InventoryView({
const [activeTab, setActiveTab] = useState<'catalog' | 'import_branding'>('catalog'); const [activeTab, setActiveTab] = useState<'catalog' | 'import_branding'>('catalog');
const [selectedCategory, setSelectedCategory] = useState<string>('ALL'); const [selectedCategory, setSelectedCategory] = useState<string>('ALL');
// The store catalogue the admin curates from the global catalogue (shown to users).
const storeCat = useStoreCatalogue();
const [showAddProductModal, setShowAddProductModal] = useState(false); const [showAddProductModal, setShowAddProductModal] = useState(false);
const [outletFilter, setOutletFilter] = useState<'all' | 'alerts'>('all'); const [outletFilter, setOutletFilter] = useState<'all' | 'alerts'>('all');
const [outletSearch, setOutletSearch] = useState(''); const [outletSearch, setOutletSearch] = useState('');
@@ -197,7 +202,7 @@ export default function InventoryView({
const handleAddNewProduct = (e: React.FormEvent) => { const handleAddNewProduct = (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
if (!newProduct.name || !newProduct.sku) { if (!newProduct.name || !newProduct.sku) {
alert('Kindly supply correct product specifications and catalog SKU code.'); alert('Kindly supply correct product specifications and catalogue SKU code.');
return; return;
} }
@@ -217,7 +222,7 @@ export default function InventoryView({
setProducts([createdProd, ...products]); setProducts([createdProd, ...products]);
setShowAddProductModal(false); setShowAddProductModal(false);
alert(`Fresh product "${createdProd.name}" added to the Global Catalog. It is now available to roll out to all outlets.`); alert(`Fresh product "${createdProd.name}" added to the Global Catalogue. It is now available to roll out to all outlets.`);
setNewProduct({ setNewProduct({
name: '', name: '',
@@ -274,9 +279,9 @@ export default function InventoryView({
if (parsedCount > 0) { if (parsedCount > 0) {
setProducts(prev => [...newProds, ...prev]); setProducts(prev => [...newProds, ...prev]);
alert(`Synchronized ${parsedCount} regional products into Catalog database successfully!`); alert(`Synchronized ${parsedCount} regional products into Catalogue database successfully!`);
} else { } else {
alert('All the specified SKU codes are already active in the catalog ledger.'); alert('All the specified SKU codes are already active in the catalogue ledger.');
} }
}; };
@@ -293,7 +298,7 @@ export default function InventoryView({
<div className="absolute inset-0 z-0 overflow-hidden rounded-2xl"> <div className="absolute inset-0 z-0 overflow-hidden rounded-2xl">
<img <img
src="https://images.unsplash.com/photo-1542838132-92c53300491e?auto=format&fit=crop&w=1200&q=80" src="https://images.unsplash.com/photo-1542838132-92c53300491e?auto=format&fit=crop&w=1200&q=80"
alt="Catalog Command Center Banner" alt="Catalogue Command Center Banner"
className="w-full h-full object-cover object-center opacity-30" className="w-full h-full object-cover object-center opacity-30"
/> />
<div className="absolute inset-0 bg-gradient-to-r from-slate-950 via-slate-900/95 to-purple-950/85" /> <div className="absolute inset-0 bg-gradient-to-r from-slate-950 via-slate-900/95 to-purple-950/85" />
@@ -308,13 +313,13 @@ export default function InventoryView({
<div> <div>
<h1 className="font-sans font-bold text-2xl tracking-tight text-white flex items-center gap-2"> <h1 className="font-sans font-bold text-2xl tracking-tight text-white flex items-center gap-2">
<Layers size={24} className="text-purple-300" /> <Layers size={24} className="text-purple-300" />
Product Catalog Product Catalogue
<span className="text-[10px] text-purple-200 font-bold bg-purple-900/60 border border-purple-500/30 px-2 py-0.5 rounded-full uppercase tracking-wider animate-pulse"> <span className="text-[10px] text-purple-200 font-bold bg-purple-900/60 border border-purple-500/30 px-2 py-0.5 rounded-full uppercase tracking-wider animate-pulse">
Global Sync Global Sync
</span> </span>
</h1> </h1>
<p className="text-purple-250 font-sans text-xs mt-1.5 font-medium max-w-2xl"> <p className="text-purple-250 font-sans text-xs mt-1.5 font-medium max-w-2xl">
Master catalog registry with regional assortment presets, brand styling studio, and live stock synchronization feeds. Master catalogue registry with regional assortment presets, brand styling studio, and live stock synchronization feeds.
</p> </p>
</div> </div>
@@ -329,7 +334,7 @@ export default function InventoryView({
}`} }`}
> >
<Package size={13} /> <Package size={13} />
<span>Catalog & Stocks</span> <span>Catalogue & Stocks</span>
</button> </button>
<button <button
@@ -361,7 +366,7 @@ export default function InventoryView({
<h3 className="text-xl font-extrabold tracking-tight font-mono"> <h3 className="text-xl font-extrabold tracking-tight font-mono">
{products.length} {products.length}
</h3> </h3>
<p className="text-[10px] text-purple-400 font-semibold mt-1">Master catalog</p> <p className="text-[10px] text-purple-400 font-semibold mt-1">Master catalogue</p>
</div> </div>
</div> </div>
@@ -405,7 +410,7 @@ export default function InventoryView({
{/* Card 4: Catalog Health */} {/* Card 4: Catalog Health */}
<div className="bg-slate-900/40 backdrop-blur-sm rounded-xl p-4 border border-slate-800/80 hover:border-purple-500/30 transition-all group"> <div className="bg-slate-900/40 backdrop-blur-sm rounded-xl p-4 border border-slate-800/80 hover:border-purple-500/30 transition-all group">
<div className="flex justify-between items-start"> <div className="flex justify-between items-start">
<span className="text-[10px] text-slate-400 uppercase tracking-widest font-bold">Catalog Sync Ratio</span> <span className="text-[10px] text-slate-400 uppercase tracking-widest font-bold">Catalogue Sync Ratio</span>
<div className="p-2 rounded-lg bg-amber-500/10 text-amber-400 border border-amber-500/20 group-hover:scale-110 transition-transform"> <div className="p-2 rounded-lg bg-amber-500/10 text-amber-400 border border-amber-500/20 group-hover:scale-110 transition-transform">
<ShieldCheck className="w-4 h-4" /> <ShieldCheck className="w-4 h-4" />
</div> </div>
@@ -489,18 +494,26 @@ export default function InventoryView({
{/* Global Catalog — master assortment grid (full width) */} {/* Global Catalog — master assortment grid (full width) */}
<div className="bg-white/40 backdrop-blur-md border border-[#e2e8f0] p-md rounded-2xl text-xs font-sans shadow-sm"> <div className="bg-white/40 backdrop-blur-md border border-[#e2e8f0] p-md rounded-2xl text-xs font-sans shadow-sm">
<div className="flex items-center justify-between mb-sm"> <div className="flex items-center justify-between mb-sm">
<div>
<h3 className="font-sans font-bold text-sm text-[#0f172a] flex items-center gap-1.5"> <h3 className="font-sans font-bold text-sm text-[#0f172a] flex items-center gap-1.5">
<Package size={15} className="text-[#581c87]" /> Global Catalog Assortment <Package size={15} className="text-[#581c87]" /> Global Catalogue Assortment
</h3> </h3>
<p className="text-[10px] text-zinc-400 font-medium mt-0.5">Pick products & set quantities selected items appear in every store's catalogue.</p>
</div>
<div className="flex items-center gap-2">
<span className="text-[10px] text-emerald-700 font-bold bg-emerald-50 px-2 py-0.5 rounded-lg border border-emerald-100">
{storeCat.items.length} in store catalogue
</span>
<span className="text-[10px] text-[#581c87] font-bold bg-purple-50 px-2 py-0.5 rounded-lg border border-purple-100/50"> <span className="text-[10px] text-[#581c87] font-bold bg-purple-50 px-2 py-0.5 rounded-lg border border-purple-100/50">
{filteredProducts.length} item{filteredProducts.length === 1 ? '' : 's'} loaded {filteredProducts.length} item{filteredProducts.length === 1 ? '' : 's'} loaded
</span> </span>
</div> </div>
</div>
{storesLoading && products.length === 0 ? ( {storesLoading && products.length === 0 ? (
<div className="text-center py-xl text-zinc-400 text-xs font-bold">Synchronizing regional database...</div> <div className="text-center py-xl text-zinc-400 text-xs font-bold">Synchronizing regional database...</div>
) : filteredProducts.length === 0 ? ( ) : filteredProducts.length === 0 ? (
<div className="text-center py-xl text-zinc-400 text-xs font-bold">No catalog products match your selection.</div> <div className="text-center py-xl text-zinc-400 text-xs font-bold">No catalogue products match your selection.</div>
) : ( ) : (
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-md"> <div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-md">
{filteredProducts.map((prod) => ( {filteredProducts.map((prod) => (
@@ -565,6 +578,26 @@ export default function InventoryView({
<div className="w-8 h-4.5 bg-slate-200 peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-slate-350 after:border after:rounded-full after:h-3.5 after:w-3.5 after:transition-all peer-checked:bg-emerald-500 shadow-inner"></div> <div className="w-8 h-4.5 bg-slate-200 peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-slate-350 after:border after:rounded-full after:h-3.5 after:w-3.5 after:transition-all peer-checked:bg-emerald-500 shadow-inner"></div>
</label> </label>
</div> </div>
{/* Store-catalogue curation: pick the product + quantity to show to store users */}
{storeCat.has(prod.id) ? (
<div className="flex items-center justify-between gap-2 pt-2.5 border-t border-[#f1f5f9] mt-1">
<span className="inline-flex items-center gap-1 text-[10px] font-bold text-emerald-600"><CheckCircle size={12} /> In Store Catalogue</span>
<div className="flex items-center gap-1">
<button onClick={() => storeCat.setQty(prod.id, storeCat.getQty(prod.id) - 1)} className="w-6 h-6 rounded-lg border border-[#e2e8f0] text-zinc-500 hover:bg-zinc-50 font-bold cursor-pointer leading-none"></button>
<span className="w-8 text-center font-mono font-bold text-xs text-[#0f172a]">{storeCat.getQty(prod.id)}</span>
<button onClick={() => storeCat.setQty(prod.id, storeCat.getQty(prod.id) + 1)} className="w-6 h-6 rounded-lg border border-[#e2e8f0] text-zinc-500 hover:bg-zinc-50 font-bold cursor-pointer leading-none">+</button>
<button onClick={() => storeCat.remove(prod.id)} title="Remove from store catalogue" className="ml-1 w-6 h-6 rounded-lg text-rose-500 hover:bg-rose-50 flex items-center justify-center cursor-pointer"><X size={13} /></button>
</div>
</div>
) : (
<button
onClick={() => storeCat.add({ productid: prod.id, name: prod.name, image: prod.image, category: prod.category, sku: prod.sku, price: prod.unitsSold > 0 ? Math.round(prod.revenue / prod.unitsSold) : 0, unit: prod.exposure, qty: 1 })}
className="w-full flex items-center justify-center gap-1.5 pt-2.5 mt-1 border-t border-[#f1f5f9] text-[11px] font-bold text-[#581c87] hover:text-purple-800 cursor-pointer"
>
<Plus size={13} /> Add to Store Catalogue
</button>
)}
</div> </div>
))} ))}
</div> </div>
@@ -874,10 +907,10 @@ export default function InventoryView({
<div className="bg-white/80 backdrop-blur-md border border-[#e2e8f0] p-md rounded-2xl shadow-sm space-y-md"> <div className="bg-white/80 backdrop-blur-md border border-[#e2e8f0] p-md rounded-2xl shadow-sm space-y-md">
<div className="flex items-center gap-xs text-[#0f172a] font-bold text-sm"> <div className="flex items-center gap-xs text-[#0f172a] font-bold text-sm">
<Sparkles className="text-amber-500 animate-pulse" size={18} /> <Sparkles className="text-amber-500 animate-pulse" size={18} />
<h3>Cooperative Catalog Presets</h3> <h3>Cooperative Catalogue Presets</h3>
</div> </div>
<AwaitingApi label="Catalog presets" api="[R5]" compact /> <AwaitingApi label="Catalogue presets" api="[R5]" compact />
</div> </div>
{/* Custom CSV Parsing Box */} {/* Custom CSV Parsing Box */}
@@ -955,7 +988,7 @@ export default function InventoryView({
<div className="p-md border-b border-[#e2e8f0] bg-[#f8fafc] flex justify-between items-center shrink-0"> <div className="p-md border-b border-[#e2e8f0] bg-[#f8fafc] flex justify-between items-center shrink-0">
<h4 className="font-bold text-[#0f172a] flex items-center gap-xs"> <h4 className="font-bold text-[#0f172a] flex items-center gap-xs">
<Package size={15} className="text-[#581c87]" /> <Package size={15} className="text-[#581c87]" />
Introduce New Grocery Catalog SKU Introduce New Grocery Catalogue SKU
</h4> </h4>
<button <button
onClick={() => setShowAddProductModal(false)} onClick={() => setShowAddProductModal(false)}

View File

@@ -454,7 +454,7 @@ export default function OperationsView({ searchQuery, isCoimbatoreView }: Operat
<button <button
onClick={() => { onClick={() => {
const title = prompt('Enter product brand title:'); const title = prompt('Enter product brand title:');
const sku = prompt('Enter SKU catalog code:'); const sku = prompt('Enter SKU catalogue code:');
const category = prompt('Enter SKU Category:'); const category = prompt('Enter SKU Category:');
if (title && sku && category) { if (title && sku && category) {
setProductList(prev => [ setProductList(prev => [

View File

@@ -3,502 +3,495 @@
* SPDX-License-Identifier: Apache-2.0 * SPDX-License-Identifier: Apache-2.0
*/ */
import React, { useState, useEffect } from 'react'; /**
* Orders & Deliveries view — embedded inside each store's detail panel.
* Rebuilt to match the same consoleUi design system used by OrdersView and
* DeliveriesView: gradient header, KPI strip with gradient top-bars, Pill
* filter tabs, status chips, full paginated data table, CSV export, and an
* order detail modal. Wired to the live Fiesta orders/getorders endpoint via
* useFiestaAllOrders (which merges all statuses in parallel).
*/
import React, { useMemo, useState, useRef, useEffect } from 'react';
import { createPortal } from 'react-dom';
import { import {
ShoppingBag, ShoppingBag, Clock, CheckCircle2, XCircle, MapPin, Phone,
Truck, Package, Loader2, X, Download, ChevronLeft, ChevronRight, Truck,
CheckCircle2,
Clock,
UserCheck, UserCheck,
MapPin,
TrendingUp,
ChevronRight,
Package,
ArrowRight,
AlertCircle,
Clock4,
Search,
Check,
Calendar,
X
} from 'lucide-react'; } from 'lucide-react';
import { CustomerOrder } from '../types';
import { import {
useFiestaDeliveries, useFiestaAllOrders,
useFiestaDeliverySummary, useFiestaDeliverySummary,
useFiestaRiders, useFiestaRiders,
useFiestaOrderDetails, useFiestaOrderDetails,
} from '../services/fiestaQueries'; } from '../services/fiestaQueries';
import { FIESTA_TENANT_ID, num as fnum, str as fstr, ymd } from '../services/fiestaApi'; import { FIESTA_TENANT_ID, num as fnum, str as fstr, ymd, type Row } from '../services/fiestaApi';
import { deliveryRowToOrder } from '../services/fiestaMappers'; import { shortTime } from '../services/fiestaMappers';
import AwaitingApi from './AwaitingApi'; import {
GradientHeader, LiveStatus, KpiStrip, Pill, StatusChip, SearchPill, FilterBar, TH_STYLE,
ORDER_STATUS, statusColor, BRAND, BRAND_LIGHT, TEXT, TEXT_2, TEXT_3, BORDER, DIVIDER, SURFACE_ALT,
tint, soft, edge, ring,
} from './consoleUi';
interface OrdersDeliveriesViewProps { interface OrdersDeliveriesViewProps {
searchQuery?: string; searchQuery?: string;
isCoimbatoreView?: boolean; isCoimbatoreView?: boolean;
locationid?: number; locationid?: number;
tenantId?: number;
} }
interface DeliveryExecutive { type StatusKey = 'all' | 'created' | 'pending' | 'processing' | 'delivered' | 'cancelled';
id: string; const STATUS_TABS: Array<{ key: StatusKey; label: string }> = [
name: string; { key: 'all', label: 'All Orders' },
phone: string; { key: 'created', label: 'Created' },
status: 'Active Duty' | 'Idle' | 'Offline'; { key: 'pending', label: 'Pending' },
completedToday: number; { key: 'processing', label: 'Processing' },
currentZone: string; { key: 'delivered', label: 'Delivered' },
avatar: string; { key: 'cancelled', label: 'Cancelled' },
}
const RIDER_AVATARS = [
'https://images.unsplash.com/photo-1534528741775-53994a69daeb?auto=format&fit=crop&w=150&q=80',
'https://images.unsplash.com/photo-1507003211169-0a1dd7228f2d?auto=format&fit=crop&w=150&q=80',
'https://images.unsplash.com/photo-1494790108377-be9c29b29330?auto=format&fit=crop&w=150&q=80',
]; ];
const PAGE_SIZE = 20;
function riderRowToExecutive(row: Record<string, unknown>, idx: number): DeliveryExecutive { export default function OrdersDeliveriesView({
return { searchQuery = '',
id: `DE-${fstr(row.userid) || idx}`, locationid,
name: fstr(row.fullname) || `${fstr(row.firstname)} ${fstr(row.lastname)}`.trim() || 'Rider', tenantId = FIESTA_TENANT_ID,
phone: fstr(row.contactno) || '—', }: OrdersDeliveriesViewProps) {
status: fstr(row.starttime) ? 'Active Duty' : 'Idle', const todayStr = ymd(new Date());
completedToday: fnum(row.completed) || fnum(row.deliverycount),
currentZone: fstr(row.city) || fstr(row.vehiclename) || fstr(row.vehicleno) || 'Coimbatore',
avatar: RIDER_AVATARS[idx % RIDER_AVATARS.length],
};
}
export default function OrdersDeliveriesView({ searchQuery = '', isCoimbatoreView = false, locationid }: OrdersDeliveriesViewProps) { const [status, setStatus] = useState<StatusKey>('all');
// ── Live deliveries / fleet (Fiesta) ────────────────────────────────────── const [pageno, setPageno] = useState(1);
// Order feed + dispatch controls run off the live deliveries board; the KPI
// strip uses the delivery summary; the fleet panel uses the active riders.
// A date-range filter lets the user view orders/deliveries day-wise.
const today = new Date();
const monthStart = new Date(today.getFullYear(), today.getMonth(), 1);
const [fromdate, setFromdate] = useState<string>(ymd(monthStart));
const [todate, setTodate] = useState<string>(ymd(today));
// Quick-range presets (computed off the current day; no Date.now in render path).
const dayOffset = (n: number) => {
const d = new Date();
d.setDate(d.getDate() - n);
return ymd(d);
};
const presets: Array<{ key: string; label: string; from: string; to: string }> = [
{ 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) },
];
const activePreset = presets.find((p) => p.from === fromdate && p.to === todate)?.key ?? 'custom';
const deliveriesQ = useFiestaDeliveries({ tenantid: FIESTA_TENANT_ID, fromdate, todate });
const summaryQ = useFiestaDeliverySummary({ tenantid: FIESTA_TENANT_ID, fromdate, todate });
const ridersQ = useFiestaRiders({ tenantid: FIESTA_TENANT_ID });
const [orders, setOrders] = useState<CustomerOrder[]>([]);
const [executives, setExecutives] = useState<DeliveryExecutive[]>([]);
const [selectedOrder, setSelectedOrder] = useState<CustomerOrder | null>(null);
const [filterStatus, setFilterStatus] = useState<string>('ALL');
const [localSearch, setLocalSearch] = useState(''); const [localSearch, setLocalSearch] = useState('');
const [detailOrder, setDetailOrder] = useState<Row | null>(null);
// Seed local state once live data arrives so existing dispatch/create handlers // Search: Ctrl+K to focus, Escape to blur
// continue to mutate in-session. const searchRef = useRef<HTMLInputElement>(null);
useEffect(() => { useEffect(() => {
if (deliveriesQ.data) { const onKey = (e: KeyboardEvent) => {
const mapped = deliveriesQ.data.map(deliveryRowToOrder); if (e.key === 'k' && (e.metaKey || e.ctrlKey)) { e.preventDefault(); searchRef.current?.focus(); }
setOrders(mapped); else if (e.key === 'Escape' && document.activeElement === searchRef.current) searchRef.current?.blur();
// Keep the current selection only if it's still in the new range; otherwise };
// fall back to the first order so the detail panel stays in sync. document.addEventListener('keydown', onKey);
setSelectedOrder((prev) => return () => document.removeEventListener('keydown', onKey);
(prev && mapped.some((o) => o.id === prev.id)) ? prev : mapped[0] ?? null, }, []);
);
}
}, [deliveriesQ.data]);
useEffect(() => { // ── Queries ──────────────────────────────────────────────────────────────────
if (ridersQ.data) setExecutives(ridersQ.data.map(riderRowToExecutive)); const allOrdersQ = useFiestaAllOrders({ tenantid: tenantId, fromdate: todayStr, todate: todayStr, locationid });
}, [ridersQ.data]); const summaryQ = useFiestaDeliverySummary({ tenantid: tenantId, fromdate: todayStr, todate: todayStr, locationid });
const ridersQ = useFiestaRiders({ tenantid: tenantId });
const allRows = allOrdersQ.data ?? [];
const summary = summaryQ.data; const summary = summaryQ.data;
// Local filtered list of orders // Per-status row counts (client-side from merged data)
const storeOrders = locationid ? orders.filter(o => o.locationid === locationid) : orders; const statusCounts = useMemo(() => {
const acc: Record<string, number> = {};
for (const r of allRows) {
const s = fstr(r.orderstatus).toLowerCase();
acc[s] = (acc[s] ?? 0) + 1;
}
return acc;
}, [allRows]);
const filteredOrdersList = storeOrders.filter(o => { const countFor = (key: StatusKey) => key === 'all' ? allRows.length : (statusCounts[key] ?? 0);
const activeFleet = (ridersQ.data ?? []).filter((r) => fstr(r.starttime)).length;
// Filter by status + search (status='all' shows everything in the date range)
const rows = useMemo(() => {
const term = (localSearch || searchQuery).toLowerCase(); const term = (localSearch || searchQuery).toLowerCase();
const matchesSearch = o.id.toLowerCase().includes(term) || return allRows.filter((r) => {
o.customerName.toLowerCase().includes(term) || if (locationid && fnum(r.locationid) !== locationid) return false;
o.address.toLowerCase().includes(term); if (status !== 'all' && fstr(r.orderstatus).toLowerCase() !== status) return false;
const matchesFilter = filterStatus === 'ALL' || o.status === filterStatus; if (!term) return true;
return matchesSearch && matchesFilter; return [
r.orderid, r.orderheaderid, r.orderstatus, r.tenantname,
r.pickupcustomer, r.pickupcontactno, r.pickupaddress, r.pickupsuburb,
r.deliverycustomer, r.deliverycontactno, r.deliveryaddress, r.deliverysuburb,
r.applocation, r.locationname, r.ridername,
].some((v) => fstr(v).toLowerCase().includes(term));
}); });
}, [allRows, status, localSearch, searchQuery, locationid]);
// Calculate dynamic stats for metrics cards based on filtered storeOrders // Pagination
const totalDeliveriesCount = storeOrders.length; const pageRows = useMemo(
const pendingFulfillmentCount = storeOrders.filter(o => o.status === 'PROCESSING' || o.status === 'CONFIRMED').length; () => rows.slice((pageno - 1) * PAGE_SIZE, pageno * PAGE_SIZE),
const activeDispatchCount = storeOrders.filter(o => o.status === 'OUT_FOR_DELIVERY').length; [rows, pageno],
const completedDeliveriesCount = storeOrders.filter(o => o.status === 'DELIVERED').length; );
const hasNext = rows.length > pageno * PAGE_SIZE;
// Live line-item details for the currently selected order. The deliveries board // Totals
// only carries an itemCount; the actual basket lines come from this endpoint. const totals = useMemo(() => {
const orderDetailsQ = useFiestaOrderDetails(selectedOrder?.id ?? null); let cod = 0, amount = 0;
const orderItems = (orderDetailsQ.data ?? []).map((row) => { for (const r of rows) {
const quantity = fnum(row.quantity) || fnum(row.qty); cod += fnum(r.collectionamt);
const price = fnum(row.price) || fnum(row.unitprice); amount += fnum(r.ordervalue) || fnum(r.orderamount) || fnum(r.deliveryamt);
const lineTotal = fnum(row.amount) || price * quantity; }
return { cod, amount };
}, [rows]);
const total = allRows.length;
const pct = (n: number) => (total > 0 ? Math.round((n / total) * 100) : 0);
const kpis = [
{ label: 'Created Orders', value: countFor('created').toLocaleString('en-IN'), color: '#475569', icon: <ShoppingBag size={20} />, badge: `${pct(countFor('created'))}% of total` },
{ label: 'Pending Orders', value: countFor('pending').toLocaleString('en-IN'), color: '#9a6700', icon: <Clock size={20} />, badge: `${pct(countFor('pending'))}% of total` },
{ label: 'Delivered Orders', value: countFor('delivered').toLocaleString('en-IN'), color: '#15803d', icon: <CheckCircle2 size={20} />, badge: `${pct(countFor('delivered'))}% of total` },
{ label: 'Cancelled Orders', value: countFor('cancelled').toLocaleString('en-IN'), color: '#b42318', icon: <XCircle size={20} />, badge: `${pct(countFor('cancelled'))}% of total` },
];
// CSV export
const exportCsv = () => {
const headers = ['#', 'Order ID', 'Status', 'Branch', 'Order Date', 'Pickup', 'Pickup Contact', 'Pickup Address', 'Drop', 'Drop Contact', 'Drop Address', 'Qty', 'COD', '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.ordervalue) || 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}_${todayStr}.csv`; a.click();
URL.revokeObjectURL(url);
};
return (
<div className="animate-in fade-in duration-300">
<GradientHeader
title="Orders & Deliveries"
subtitle="Live order board across the full lifecycle — created, pending, processing, delivered, and cancelled."
status={
allOrdersQ.isLoading
? <LiveStatus state="loading" label="Loading live orders…" />
: allOrdersQ.isError
? <LiveStatus state="error" label="Live data unavailable" />
: <LiveStatus state="live" label={`Live · ${total.toLocaleString('en-IN')} orders in range · ${activeFleet} riders on duty`} />
}
right={
<span className="inline-flex items-center gap-1.5 rounded-full font-extrabold"
style={{ padding: '6px 12px', fontSize: 12, background: tint(BRAND), border: `1.5px solid ${edge(BRAND)}`, color: BRAND }}>
<MapPin size={13} /> {locationid ? `Location ${locationid}` : 'All Locations'}
</span>
}
/>
<div className="mb-4"><KpiStrip items={kpis} loading={allOrdersQ.isLoading} /></div>
{/* Status tabs + search + CSV */}
<FilterBar className="mb-4">
<div className="flex flex-col lg:flex-row lg:items-center gap-3">
<div className="flex items-center gap-2 overflow-x-auto py-0.5 flex-1 min-w-0">
{STATUS_TABS.map((t) => (
<React.Fragment key={t.key}>
<Pill active={status === t.key} color={BRAND} onClick={() => { setStatus(t.key); setPageno(1); }}
count={allOrdersQ.isLoading ? '·' : countFor(t.key).toLocaleString('en-IN')}>
{t.label}
</Pill>
</React.Fragment>
))}
</div>
<div className="flex items-center gap-2 w-full lg:w-auto lg:shrink-0">
<div className="w-full lg:w-64">
<SearchPill value={localSearch} onChange={setLocalSearch} placeholder="Search orders (Ctrl+K)…" inputRef={searchRef} />
</div>
<button
onClick={exportCsv}
disabled={rows.length === 0}
title="Export current view to CSV"
className="inline-flex items-center gap-1.5 rounded-full font-extrabold text-white cursor-pointer disabled:opacity-40 disabled:cursor-not-allowed whitespace-nowrap shrink-0"
style={{ padding: '8px 14px', fontSize: 12, background: `linear-gradient(135deg, ${BRAND}, ${BRAND_LIGHT})`, boxShadow: `0 6px 18px ${ring(BRAND)}` }}
>
<Download size={13} /> CSV
</button>
</div>
</div>
</FilterBar>
{/* Table */}
<div className="bg-white border rounded-2xl overflow-hidden" style={{ borderColor: BORDER }}>
<div className="overflow-x-auto">
<table className="w-full" style={{ minWidth: 900 }}>
<thead>
<tr>
{['#', 'Order', 'Branch', 'Pickup', 'Drop', 'Qty', 'COD', 'Amount', 'Status', ''].map((h, i) => (
<th key={i} className="px-3 py-2.5 text-left" style={TH_STYLE}>{h}</th>
))}
</tr>
</thead>
<tbody>
{allOrdersQ.isLoading ? (
<tr><td colSpan={10} className="px-3 py-12 text-center" style={{ color: TEXT_3 }}>
<span className="inline-flex items-center gap-2 text-xs font-semibold">
<Loader2 size={15} className="animate-spin" style={{ color: BRAND }} /> Loading orders
</span>
</td></tr>
) : pageRows.length === 0 ? (
<tr><td colSpan={10} className="px-3 py-12 text-center text-xs" style={{ color: TEXT_3 }}>
No {status} orders found for this date range or search.
</td></tr>
) : (
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 (
<tr
key={fstr(r.orderid) || fstr(r.orderheaderid) || i}
className="transition-colors align-top"
style={{ borderBottom: `1px solid ${DIVIDER}` }}
onMouseEnter={(e) => (e.currentTarget.style.background = SURFACE_ALT)}
onMouseLeave={(e) => (e.currentTarget.style.background = 'transparent')}
>
<td className="px-3 py-2.5 font-mono" style={{ color: TEXT_3 }}>{(pageno - 1) * PAGE_SIZE + i + 1}</td>
<td className="px-3 py-2.5">
<p className="font-extrabold font-mono text-[13px]" style={{ color: TEXT }}>{fstr(r.orderid) || `#${fstr(r.orderheaderid)}`}</p>
<p className="text-[10px]" style={{ color: TEXT_2 }}>{shortTime(r.orderdate || r.deliverydate)}</p>
</td>
<td className="px-3 py-2.5">
<span className="inline-flex items-center gap-1 font-bold text-[12px]" style={{ color: BRAND }}>
<MapPin size={11} /> {fstr(r.applocation) || '—'}
</span>
{fstr(r.locationname) && <p className="text-[10px] truncate max-w-[120px]" style={{ color: TEXT_2 }}>{fstr(r.locationname)}</p>}
</td>
<td className="px-3 py-2.5">
<p className="font-bold text-[12px] truncate max-w-[140px]" style={{ color: TEXT }}>{fstr(r.pickupcustomer) || fstr(r.tenantname) || '—'}</p>
<p className="text-[10px] truncate max-w-[140px]" style={{ color: TEXT_2 }}>{fstr(r.pickupsuburb) || fstr(r.pickupaddress)}</p>
</td>
<td className="px-3 py-2.5">
<p className="font-bold text-[12px] truncate max-w-[140px]" style={{ color: TEXT }}>{fstr(r.deliverycustomer) || '—'}</p>
<p className="text-[10px] truncate max-w-[140px]" style={{ color: TEXT_2 }}>{fstr(r.deliverysuburb) || fstr(r.deliveryaddress)}</p>
</td>
<td className="px-3 py-2.5 font-mono text-[12px]" style={{ color: TEXT }}>{fnum(r.quantity) || '—'}</td>
<td className="px-3 py-2.5 font-mono text-[12px] font-semibold" style={{ color: cod > 0 ? TEXT : TEXT_3 }}>{cod > 0 ? `${cod.toLocaleString('en-IN')}` : '—'}</td>
<td className="px-3 py-2.5 font-mono text-[12px] font-semibold" style={{ color: amount > 0 ? TEXT : TEXT_3 }}>{amount > 0 ? `${amount.toLocaleString('en-IN')}` : '—'}</td>
<td className="px-3 py-2.5"><StatusChip label={st || '—'} color={statusColor(ORDER_STATUS, st)} /></td>
<td className="px-3 py-2.5 text-right">
<button onClick={() => setDetailOrder(r)}
className="rounded-full font-extrabold cursor-pointer transition-colors"
style={{ padding: '4px 12px', fontSize: 11, color: BRAND, background: tint(BRAND), border: `1px solid ${edge(BRAND)}` }}>
View
</button>
</td>
</tr>
);
})
)}
</tbody>
</table>
</div>
{/* Totals footer */}
{rows.length > 0 && (
<div className="flex flex-wrap items-center justify-end gap-2 px-4 py-2.5 border-t" style={{ borderColor: BORDER, background: SURFACE_ALT }}>
<span className="text-[10px] font-extrabold uppercase tracking-wider mr-auto" style={{ color: TEXT_2 }}>
Totals · {rows.length} order{rows.length === 1 ? '' : 's'}
</span>
{totals.cod > 0 && <TotalChip label="COD" value={`${totals.cod.toLocaleString('en-IN')}`} color={TEXT_2} />}
<TotalChip label="Amount" value={`${totals.amount.toLocaleString('en-IN')}`} color={BRAND} />
</div>
)}
{/* Pagination */}
<div className="flex items-center justify-between px-4 py-3 border-t" style={{ borderColor: BORDER, background: SURFACE_ALT }}>
<span className="text-[10px] font-bold uppercase tracking-wider" style={{ color: TEXT_2 }}>
Page {pageno} · {pageRows.length} of {rows.length} shown
</span>
<div className="flex items-center gap-2">
<PagerBtn disabled={pageno === 1} onClick={() => setPageno((p) => Math.max(1, p - 1))}>
<ChevronLeft size={13} /> Prev
</PagerBtn>
<PagerBtn disabled={!hasNext} onClick={() => setPageno((p) => p + 1)}>
Next <ChevronRight size={13} />
</PagerBtn>
</div>
</div>
</div>
{detailOrder && <OrderDetailModal order={detailOrder} onClose={() => setDetailOrder(null)} />}
</div>
);
}
// ── Helpers ───────────────────────────────────────────────────────────────────
function TotalChip({ label, value, color }: { label: string; value: string; color: string }) {
return (
<span className="inline-flex items-center gap-1.5 rounded-full font-bold"
style={{ padding: '4px 11px', fontSize: 11.5, background: soft(color), color, border: `1px solid ${edge(color)}` }}>
<span className="uppercase tracking-wider text-[9px] font-extrabold opacity-80">{label}</span>
<span className="font-mono">{value}</span>
</span>
);
}
function PagerBtn({ children, disabled, onClick }: { children: React.ReactNode; disabled?: boolean; onClick: () => void }) {
return (
<button onClick={onClick} disabled={disabled}
className="inline-flex items-center gap-1 rounded-full font-bold transition-colors cursor-pointer disabled:opacity-40 disabled:cursor-not-allowed"
style={{ padding: '6px 12px', fontSize: 11, border: `1px solid ${BORDER}`, background: '#fff', color: TEXT_2 }}>
{children}
</button>
);
}
// ── 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 { return {
name: fstr(row.productname) || fstr(row.itemname) || 'Item', name: fstr(row.productname) || fstr(row.itemname) || 'Item',
quantity, quantity,
price, price,
lineTotal, lineTotal: fnum(row.amount) || fnum(row.productsumprice) || price * quantity,
}; };
}); });
return ( const st = fstr(order.orderstatus).toLowerCase();
<div className="space-y-lg animate-in fade-in duration-500"> const total = fnum(order.ordervalue) || fnum(order.orderamount) || fnum(order.deliveryamt);
const rider = fstr(order.ridername) || fstr(order.username);
{/* View Header with Statistics Overview */} const STEPS = [
<div className="flex flex-col md:flex-row md:items-center justify-between gap-md border-b border-[#e2e8f0] pb-xl"> { label: 'Order Placed', field: 'orderdate' },
<div> { label: 'Confirmed', field: 'starttime' },
<h1 className="font-sans font-bold text-2xl tracking-tight text-[#0f172a]"> { label: 'Packed & Ready', field: 'packtime' },
Orders & Delivery Operations { label: 'Out for Delivery',field: 'pickuptime' },
</h1> { label: 'Delivered', field: 'deliverytime' },
<p className="text-zinc-500 font-sans text-xs mt-1"> ];
Real-time tracking of app orders, dispatch queues, and active delivery partners across Coimbatore regional sub-hubs.
</p>
<div className="mt-1.5">
{deliveriesQ.isLoading ? (
<span className="inline-flex items-center gap-1 text-[10px] font-semibold text-zinc-400 uppercase tracking-wide">
<span className="w-1.5 h-1.5 rounded-full bg-zinc-300 animate-pulse" /> Loading live deliveries
</span>
) : deliveriesQ.isError ? (
<span className="inline-flex items-center gap-1 text-[10px] font-semibold text-rose-600 uppercase tracking-wide">
<span className="w-1.5 h-1.5 rounded-full bg-rose-500" /> Live data unavailable
</span>
) : (
<span className="inline-flex items-center gap-1 text-[10px] font-semibold text-emerald-600 uppercase tracking-wide">
<span className="w-1.5 h-1.5 rounded-full bg-emerald-500" /> Live · {orders.length} deliveries · {executives.length} riders
</span>
)}
</div>
</div>
</div>
{/* Top Level Delivery Performance Indicators */} return createPortal(
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-4 gap-gutter font-sans">
<div className="bg-white border border-[#e2e8f0] p-md rounded-xl flex items-center gap-md shadow-sm">
<div className="p-2 bg-purple-50 text-[#581c87] rounded-lg">
<ShoppingBag size={20} />
</div>
<div>
<p className="text-[10px] text-zinc-400 uppercase tracking-wider font-bold">Deliveries in Range</p>
<p className="font-sans font-bold text-lg text-zinc-800">{totalDeliveriesCount.toLocaleString('en-IN')} total</p>
<p className="text-[10px] text-emerald-600 font-semibold mt-0.5">{fromdate === todate ? fromdate : `${fromdate}${todate}`}</p>
</div>
</div>
<div className="bg-white border border-[#e2e8f0] p-md rounded-xl flex items-center gap-md shadow-sm">
<div className="p-2 bg-amber-50 text-amber-600 rounded-lg">
<Clock size={20} />
</div>
<div>
<p className="text-[10px] text-zinc-400 uppercase tracking-wider font-bold">Pending Fulfilment</p>
<p className="font-sans font-bold text-lg text-zinc-800">
{pendingFulfillmentCount + activeDispatchCount} active
</p>
<p className="text-[10px] text-amber-600 font-semibold mt-0.5">Awaiting dispatch / in transit</p>
</div>
</div>
<div className="bg-white border border-[#e2e8f0] p-md rounded-xl flex items-center gap-md shadow-sm">
<div className="p-2 bg-emerald-50 text-emerald-600 rounded-lg">
<Truck size={20} />
</div>
<div>
<p className="text-[10px] text-zinc-400 uppercase tracking-wider font-bold">Successful Deliveries</p>
<p className="font-sans font-bold text-lg text-zinc-800">
{completedDeliveriesCount} done
</p>
<p className="text-[10px] text-[#581c87] font-semibold mt-0.5">{locationid ? 'At this location' : 'Across all locations'}</p>
</div>
</div>
<div className="bg-white border border-[#e2e8f0] p-md rounded-xl flex items-center gap-md shadow-sm">
<div className="p-2 bg-purple-50 text-purple-600 rounded-lg">
<UserCheck size={20} />
</div>
<div>
<p className="text-[10px] text-zinc-400 uppercase tracking-wider font-bold">Active Delivery Fleet</p>
<p className="font-sans font-bold text-lg text-zinc-800">
{executives.filter(e => e.status !== 'Offline').length} partners
</p>
<p className="text-[10px] text-purple-600 font-semibold mt-0.5">{executives.length} riders registered</p>
</div>
</div>
</div>
{/* Day-wise date filter — drives the live deliveries + summary queries */}
<div className="bg-white border border-[#e2e8f0] rounded-xl p-md shadow-sm flex flex-col lg:flex-row lg:items-center justify-between gap-md">
<div className="flex items-center gap-sm flex-wrap">
<span className="flex items-center gap-1.5 text-[10px] font-bold text-zinc-400 uppercase tracking-widest pr-1">
<Calendar size={13} className="text-[#581c87]" /> View
</span>
{presets.map((p) => (
<button
key={p.key}
onClick={() => { setFromdate(p.from); setTodate(p.to); }}
className={`px-3 py-1.5 rounded-lg text-[11px] font-bold transition-all border cursor-pointer ${
activePreset === p.key
? 'bg-[#581c87] text-white border-[#581c87] shadow-sm'
: 'bg-white text-zinc-600 border-[#e2e8f0] hover:bg-zinc-50'
}`}
>
{p.label}
</button>
))}
</div>
<div className="flex items-center gap-sm text-xs">
<div className="flex items-center gap-1.5">
<label className="text-[10px] font-bold text-zinc-400 uppercase tracking-wider">From</label>
<input
type="date"
value={fromdate}
max={todate}
onChange={(e) => 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"
/>
</div>
<span className="text-zinc-300"></span>
<div className="flex items-center gap-1.5">
<label className="text-[10px] font-bold text-zinc-400 uppercase tracking-wider">To</label>
<input
type="date"
value={todate}
min={fromdate}
max={ymd(today)}
onChange={(e) => 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"
/>
</div>
</div>
</div>
{/* Main interactive segment splits */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-gutter text-xs font-sans">
{/* Left List of Customer App Orders */}
<div className="lg:col-span-2 flex">
<div className="bg-white border border-[#e2e8f0] rounded-xl shadow-sm overflow-hidden flex flex-col h-full w-full min-h-[32rem]">
<div className="flex flex-col flex-1 min-h-0">
<div className="p-md border-b border-[#e2e8f0] bg-[#f8fafc] flex flex-col gap-md shrink-0">
<div className="flex flex-col sm:flex-row justify-between sm:items-center gap-sm">
<div>
<h4 className="font-sans font-bold text-sm text-[#0f172a]">
Customer Orders Feed ({filteredOrdersList.length})
</h4>
<p className="text-[10px] text-zinc-400 font-medium mt-0.5">Interactive list of customer purchases made via client app</p>
</div>
</div>
<div className="flex flex-col sm:flex-row items-center gap-sm w-full">
{/* Local Search Input */}
<div className="relative w-full sm:max-w-xs">
<Search size={13} className="absolute left-3 top-1/2 -translate-y-1/2 text-zinc-400" />
<input
type="text"
placeholder="Search orders by customer, street, ID..."
value={localSearch}
onChange={(e) => 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"
/>
</div>
{/* Filter Status buttons */}
<div className="flex gap-1 overflow-x-auto w-full sm:w-auto">
{['ALL', 'PROCESSING', 'CONFIRMED', 'OUT_FOR_DELIVERY', 'DELIVERED'].map((st) => (
<button
key={st}
onClick={() => setFilterStatus(st)}
className={`px-2 py-1.5 rounded text-[9px] font-bold uppercase transition-all border outline-none cursor-pointer whitespace-nowrap ${
filterStatus === st
? 'bg-[#581c87] text-white border-[#581c87] shadow-sm'
: 'bg-white text-zinc-500 border-[#e2e8f0] hover:bg-zinc-50'
}`}
>
{st.replace(/_/g, ' ')}
</button>
))}
</div>
</div>
</div>
{/* Order item rows — flex-fills the column so the feed matches the Order Details card height */}
<div className="divide-y divide-[#f1f5f9] flex-1 min-h-0 overflow-y-auto">
{filteredOrdersList.length === 0 ? (
<div className="p-xl text-center text-zinc-400 font-medium">
No orders matching status filter found. Try another query or adjust the date range.
</div>
) : (
filteredOrdersList.map(order => (
<div <div
key={order.id} className="fixed inset-0 z-[200] flex items-center justify-center p-4"
onClick={() => setSelectedOrder(order)} style={{ background: 'rgba(15,23,42,0.4)', backdropFilter: 'blur(4px)' }}
className={`p-md flex items-center justify-between hover:bg-zinc-50 border-l-4 transition-all cursor-pointer ${ onClick={(e) => { if (e.target === e.currentTarget) onClose(); }}
selectedOrder?.id === order.id ? 'bg-[#faf5ff]/50 border-[#581c87]' : 'border-transparent'
}`}
> >
<div className="space-y-1"> <div
<div className="flex items-center gap-sm"> className="bg-white max-h-[90vh] flex flex-col overflow-hidden rounded-2xl animate-in zoom-in-95 duration-200"
<span className="font-bold text-zinc-700">{order.customerName}</span> style={{ width: 'min(32rem, 92vw)', border: `1px solid ${BORDER}`, boxShadow: '0 18px 50px rgba(15,23,42,0.18)' }}
<span className="text-[10px] text-zinc-400"> {order.time}</span> >
</div> {/* Brand accent bar */}
<p className="text-zinc-500 truncate max-w-[24rem]">{order.address}</p> <div style={{ height: 4, background: `linear-gradient(90deg, ${BRAND} 0%, ${BRAND_LIGHT} 100%)` }} />
<div className="flex gap-sm py-1 items-center">
<span className="bg-[#f1f5f9] px-1.5 py-0.5 rounded text-[9px] font-bold text-zinc-500 uppercase">{order.hub}</span> {/* Modal header */}
<span className="text-[9px] text-[#581c87] font-bold">{order.itemCount ?? order.items.length} Items</span> <div className="p-4 border-b flex justify-between items-center shrink-0" style={{ borderColor: BORDER, background: SURFACE_ALT }}>
</div> <h4 className="font-extrabold flex items-center gap-2" style={{ color: TEXT }}>
<Package size={16} style={{ color: BRAND }} />
Order {fstr(order.orderid) || `#${fstr(order.orderheaderid)}`}
</h4>
<button onClick={onClose} className="p-1 rounded-full cursor-pointer" style={{ color: TEXT_3 }}><X size={16} /></button>
</div> </div>
<div className="text-right space-y-1"> {/* Body */}
<p className="font-bold font-mono text-sm text-[#0f172a]">{order.amount.toLocaleString()}</p> <div className="p-4 space-y-4 overflow-y-auto flex-1">
<span className={`px-2 py-0.5 rounded text-[9px] font-bold tracking-wider inline-block uppercase ${ {/* Status + rider */}
order.status === 'DELIVERED' <div className="flex items-center justify-between flex-wrap gap-2">
? 'bg-emerald-50 text-emerald-600 border border-emerald-100' <StatusChip label={st || '—'} color={statusColor(ORDER_STATUS, st)} />
: order.status === 'OUT_FOR_DELIVERY' <div className="flex items-center gap-3">
? 'bg-purple-50 text-purple-700 border border-purple-100' {rider && (
: order.status === 'CONFIRMED' <span className="inline-flex items-center gap-1.5 text-[11px] font-medium" style={{ color: TEXT_2 }}>
? 'bg-amber-50 text-amber-600 border border-amber-100 animate-pulse' <UserCheck size={12} style={{ color: BRAND }} /> {rider}
: 'bg-zinc-100 text-zinc-650 border border-zinc-200'
}`}>
{order.status.replace(/_/g, ' ')}
</span> </span>
</div>
</div>
))
)} )}
</div> <span className="text-[11px] font-medium" style={{ color: TEXT_2 }}>{shortTime(order.orderdate || order.deliverydate)}</span>
</div>
</div> </div>
</div> </div>
{/* Right column — Order Details, shown parallel to the orders feed */} {/* Customer card */}
<div className="lg:col-span-1 space-y-md"> <div className="p-3 rounded-xl space-y-1.5" style={{ background: SURFACE_ALT, border: `1px solid ${BORDER}` }}>
{selectedOrder ? ( <div className="flex items-center gap-2 font-bold" style={{ color: TEXT }}>
<div className="bg-white border border-[#e2e8f0] rounded-xl p-md shadow-sm space-y-md animate-in zoom-in-95 duration-150"> {fstr(order.deliverycustomer) || 'Customer'}
<span className="text-[10px] font-sans font-bold text-zinc-400 uppercase tracking-widest block pb-xs border-b border-[#f1f5f9]"> </div>
Order Details: {selectedOrder.id} {fstr(order.deliverycontactno) && (
<div className="flex items-center gap-2 font-mono text-xs" style={{ color: TEXT_2 }}>
<Phone size={12} /> {fstr(order.deliverycontactno)}
</div>
)}
<div className="flex items-start gap-2 text-xs" style={{ color: TEXT_2 }}>
<MapPin size={12} className="mt-0.5 shrink-0" />
<span className="leading-relaxed">{fstr(order.deliveryaddress) || fstr(order.deliverysuburb) || 'Address unavailable'}</span>
</div>
</div>
{/* Delivery timeline */}
<div>
<span className="text-[10px] font-extrabold uppercase tracking-wide block mb-2" style={{ color: TEXT_2 }}>
Delivery Timeline
</span> </span>
<div className="space-y-2.5 pl-1">
{/* Customer summary */} {STEPS.map((s) => {
<div className="p-sm bg-[#f8fafc] rounded-lg border border-[#e2e8f0]/50 space-y-xs"> const ts = fstr(order[s.field]);
<div className="flex justify-between font-semibold"> const done = Boolean(ts);
<span>Customer Name</span> return (
<span className="text-zinc-700">{selectedOrder.customerName}</span> <div key={s.field} className="flex items-center gap-2.5">
</div> <CheckCircle2 size={13} style={{ color: done ? '#10b981' : '#cbd5e1' }} />
<div className="flex justify-between font-semibold"> <span className="font-semibold text-xs" style={{ color: done ? TEXT : TEXT_3 }}>{s.label}</span>
<span>Contact info</span> <span className="ml-auto text-[10px] font-mono" style={{ color: TEXT_3 }}>{done ? shortTime(ts) : '—'}</span>
<span className="text-zinc-600 font-mono">{selectedOrder.phone}</span>
</div>
<div>
<span className="text-[10px] text-zinc-400 font-bold uppercase block mt-1">Delivery Address</span>
<p className="text-zinc-700 mt-0.5 leading-relaxed font-medium">{selectedOrder.address}</p>
</div>
</div>
{/* Category items description list */}
<div>
<span className="text-[10px] text-zinc-400 font-bold uppercase tracking-wide block mb-sm">Ordered Grocery basket Items:</span>
<div className="divide-y divide-[#f1f5f9] bg-zinc-50/50 p-2.5 rounded-lg border border-[#e2e8f0]/40">
{orderDetailsQ.isLoading && (
<div className="py-2 flex items-center gap-1.5 text-[10px] text-zinc-400 font-medium">
<span className="w-1.5 h-1.5 rounded-full bg-zinc-300 animate-pulse" /> Loading order line items
</div>
)}
{!orderDetailsQ.isLoading && orderItems.length === 0 && (
<div className="py-2 flex justify-between items-center text-xs text-zinc-500">
<span className="font-medium">{selectedOrder.itemCount ?? 0} line item(s)</span>
<span className="text-[10px] text-zinc-400">Detail lines not loaded on board view</span>
</div>
)}
{orderItems.map((item, idx) => (
<div key={idx} className="py-2 flex justify-between items-center text-xs">
<div>
<p className="font-bold text-[#0f172a]">{item.name}</p>
<p className="text-[10px] text-zinc-400">Qty: {item.quantity} x {item.price}</p>
</div>
<span className="font-bold font-mono text-zinc-700">{item.lineTotal}</span>
</div>
))}
<div className="pt-2 flex justify-between items-center font-bold text-sm text-[#581c87] border-t border-dashed border-[#e2e8f0]">
<span>Grand Total Invoice</span>
<span className="font-mono">{selectedOrder.amount.toLocaleString()}</span>
</div>
</div>
</div>
{/* Live GPS route tracker — no rider-telemetry/GPS API yet */}
{selectedOrder.status === 'OUT_FOR_DELIVERY' && (
<div className="space-y-xs pt-xs">
<span className="text-[9px] font-sans font-bold text-zinc-400 uppercase tracking-widest block">
LIVE GPS ROUTE TRACKER
</span>
<AwaitingApi label="Live rider GPS & ETA" api="[R9]" compact />
</div>
)}
{/* Delivery tracking visual roadmap layout */}
<div className="bg-zinc-50 border border-[#e2e8f0]/60 rounded-xl p-md">
<span className="text-[9px] font-sans font-bold text-zinc-400 uppercase tracking-widest block pb-xs mb-sm border-b border-[#f1f5f9]">
Live Dispatch Timeline Tracker
</span>
<div className="space-y-xs pt-1 relative text-[11px]">
<div className="flex gap-md items-start relative group">
<span className="text-emerald-500 mt-0.5"><CheckCircle2 size={12} /></span>
<div>
<h5 className="font-semibold text-zinc-800">Order Received ({selectedOrder.time})</h5>
<p className="text-[10px] text-zinc-400">Placed via customer app cart checkout successfully.</p>
</div>
</div>
<div className="flex gap-md items-start pt-3">
<span className={['CONFIRMED', 'OUT_FOR_DELIVERY', 'DELIVERED'].includes(selectedOrder.status) ? 'text-emerald-500 mt-0.5' : 'text-zinc-300 mt-0.5'}><CheckCircle2 size={12} /></span>
<div>
<h5 className="font-semibold text-zinc-800">Assortment Packaged & Bagged</h5>
<p className="text-[10px] text-zinc-400">Verified fresh produce items in-stock levels.</p>
</div>
</div>
<div className="flex gap-md items-start pt-3">
<span className={['OUT_FOR_DELIVERY', 'DELIVERED'].includes(selectedOrder.status) ? 'text-emerald-500 mt-0.5' : 'text-zinc-300 mt-0.5'}><CheckCircle2 size={12} /></span>
<div>
<h5 className="font-semibold text-zinc-800">Out for Delivery</h5>
<p className="text-[10px] text-zinc-400">Dispatched with executive partner on bike route.</p>
</div>
</div>
<div className="flex gap-md items-start pt-3">
<span className={selectedOrder.status === 'DELIVERED' ? 'text-emerald-500 mt-0.5' : 'text-zinc-300 mt-0.5'}><CheckCircle2 size={12} /></span>
<div>
<h5 className="font-semibold text-zinc-800">Handover Verified</h5>
<p className="text-[10px] text-zinc-400">Delivered directly to door step location.</p>
</div>
</div>
</div>
</div>
</div>
) : (
<div className="p-xl bg-white border border-[#e2e8f0] rounded-xl text-center text-zinc-400 font-medium">
Select any customer order from the feed to view its details.
</div>
)}
</div>
</div>
</div> </div>
); );
})}
</div>
</div>
{/* Line items */}
<div>
<span className="text-[10px] font-extrabold uppercase tracking-wide block mb-2 flex items-center gap-1.5" style={{ color: TEXT_2 }}>
<Package size={12} /> Items
</span>
<div className="rounded-xl p-3" style={{ background: 'rgba(248,250,252,0.6)', border: `1px solid ${BORDER}` }}>
{detailsQ.isLoading && (
<div className="py-2 flex items-center gap-1.5 text-[11px] font-medium" style={{ color: TEXT_3 }}>
<Loader2 size={12} className="animate-spin" /> Loading line items
</div>
)}
{!detailsQ.isLoading && lines.length === 0 && (
<div className="py-2 text-[11px] font-medium" style={{ color: TEXT_3 }}>
No line items returned for this order.
</div>
)}
{lines.map((item, idx) => (
<div key={idx} className="py-2 flex justify-between items-center"
style={{ borderTop: idx ? `1px solid ${DIVIDER}` : undefined }}>
<div>
<p className="font-bold text-xs" style={{ color: TEXT }}>{item.name}</p>
<p className="text-[10px]" style={{ color: TEXT_2 }}>Qty: {item.quantity} × {item.price}</p>
</div>
<span className="font-extrabold font-mono text-xs" style={{ color: TEXT }}>{item.lineTotal.toLocaleString('en-IN')}</span>
</div>
))}
{total > 0 && (
<div className="pt-2 mt-1 flex justify-between items-center font-extrabold text-sm"
style={{ color: BRAND, borderTop: `1px dashed ${BORDER}` }}>
<span>Order Total</span>
<span className="font-mono">{total.toLocaleString('en-IN')}</span>
</div>
)}
</div>
</div>
</div>
{/* Footer */}
<div className="p-3 border-t flex justify-end shrink-0" style={{ borderColor: BORDER, background: SURFACE_ALT }}>
<button onClick={onClose} className="rounded-full font-bold cursor-pointer text-white"
style={{ padding: '8px 16px', background: `linear-gradient(135deg, ${BRAND}, ${BRAND_LIGHT})` }}>
Close
</button>
</div>
</div>
</div>,
document.body,
);
} }

View File

@@ -11,19 +11,22 @@
* to the live Fiesta order endpoints (status-scoped, date-ranged, paginated). * to the live Fiesta order endpoints (status-scoped, date-ranged, paginated).
*/ */
import React, { useMemo, useState } from 'react'; import React, { useMemo, useState, useRef, useEffect } from 'react';
import { ShoppingBag, Clock, CheckCircle2, XCircle, Calendar, ChevronLeft, ChevronRight, Package, MapPin, Phone, X, Loader2 } from 'lucide-react'; import { createPortal } from 'react-dom';
import { useFiestaOrderSummary, useFiestaOrders, useFiestaOrderDetails } from '../services/fiestaQueries'; 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 { FIESTA_TENANT_ID, num as fnum, str as fstr, ymd, type Row } from '../services/fiestaApi';
import { shortTime } from '../services/fiestaMappers'; import { shortTime } from '../services/fiestaMappers';
import { import {
GradientHeader, LiveStatus, KpiStrip, Pill, StatusChip, MetricPill, SearchPill, FilterBar, TH_STYLE, 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'; } from './consoleUi';
interface OrdersViewProps { interface OrdersViewProps {
searchQuery?: string; searchQuery?: string;
locationid?: number; locationid?: number;
/** Merchant tenant to scope to; defaults to the shared constant. */
tenantId?: number;
} }
type StatusKey = 'created' | 'pending' | 'processing' | 'delivered' | 'cancelled'; type StatusKey = 'created' | 'pending' | 'processing' | 'delivered' | 'cancelled';
@@ -36,57 +39,167 @@ const STATUS_TABS: Array<{ key: StatusKey; label: string }> = [
]; ];
const PAGE_SIZE = 25; 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 today = new Date();
const monthStart = new Date(today.getFullYear(), today.getMonth(), 1); const monthStart = new Date(today.getFullYear(), today.getMonth(), 1);
const [fromdate, setFromdate] = useState<string>(ymd(today)); const [fromdate, setFromdate] = useState<string>(ymd(today));
const [todate, setTodate] = useState<string>(ymd(today)); const [todate, setTodate] = useState<string>(ymd(today));
const dayOffset = (n: number) => { const d = new Date(); d.setDate(d.getDate() - n); return ymd(d); }; 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 = [ const presets = [
{ key: 'today', label: 'Today', from: ymd(today), to: ymd(today) }, { 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: '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: dayAhead(7) },
{ key: 'month', label: 'This Month', from: ymd(monthStart), to: ymd(today) },
]; ];
const activePreset = presets.find((p) => p.from === fromdate && p.to === todate)?.key ?? 'custom'; const activePreset = presets.find((p) => p.from === fromdate && p.to === todate)?.key ?? 'custom';
const [status, setStatus] = useState<StatusKey>('created'); const [status, setStatus] = useState<StatusKey>('created');
const [pageno, setPageno] = useState(1); const [pageno, setPageno] = useState(1);
const [localSearch, setLocalSearch] = useState(''); const [localSearch, setLocalSearch] = useState('');
const [branch, setBranch] = useState(0); // applocationid filter (0 = all branches)
const [detailOrder, setDetailOrder] = useState<Row | null>(null); const [detailOrder, setDetailOrder] = useState<Row | null>(null);
const summaryQ = useFiestaOrderSummary(FIESTA_TENANT_ID, fromdate, todate); // ── Multi-select rider assignment (parity with the ops console) ─────────────
const ordersQ = useFiestaOrders({ tenantid: FIESTA_TENANT_ID, status, fromdate, todate, pageno, pagesize: PAGE_SIZE }); const [selected, setSelected] = useState<Set<string>>(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<HTMLInputElement>(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 summary = summaryQ.data;
const rawRows = ordersQ.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<number, string>();
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 rows = useMemo(() => {
const term = (localSearch || searchQuery).toLowerCase(); const term = (localSearch || searchQuery).toLowerCase();
return rawRows.filter((r) => { return rawRows.filter((r) => {
if (locationid && fnum(r.locationid) !== locationid) return false; if (locationid && fnum(r.locationid) !== locationid) return false;
if (branch && fnum(r.applocationid) !== branch) return false;
if (!term) return true; if (!term) return true;
return ( // Broad match across every order field shown or relevant (mirrors the ops
fstr(r.orderid).toLowerCase().includes(term) || // console search): id, both parties + contacts + addresses, branch, rider,
fstr(r.deliverycustomer).toLowerCase().includes(term) || // status, and notes.
fstr(r.pickupcustomer).toLowerCase().includes(term) || return [
fstr(r.deliveryaddress).toLowerCase().includes(term) || r.orderid, r.orderstatus, r.ordernotes, r.tenantname,
fstr(r.deliverysuburb).toLowerCase().includes(term) 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 hasNext = rawRows.length === PAGE_SIZE;
const total = summary?.total ?? 0; const total = summary?.total ?? 0;
const pct = (n: number) => (total > 0 ? Math.round((n / total) * 100) : 0); const pct = (n: number) => (total > 0 ? Math.round((n / total) * 100) : 0);
const countFor = (key: StatusKey): number => (summary ? (summary[key] ?? 0) : 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 = [ const kpis = [
{ label: 'Created Orders', value: (summary?.created ?? 0).toLocaleString('en-IN'), color: '#0ea5e9', icon: <ShoppingBag size={20} />, badge: `${pct(summary?.created ?? 0)}% of total` }, { label: 'Created Orders', value: (summary?.created ?? 0).toLocaleString('en-IN'), color: '#475569', icon: <ShoppingBag size={20} />, badge: `${pct(summary?.created ?? 0)}% of total` },
{ label: 'Pending Orders', value: (summary?.pending ?? 0).toLocaleString('en-IN'), color: '#f59e0b', icon: <Clock size={20} />, badge: `${pct(summary?.pending ?? 0)}% of total` }, { label: 'Pending Orders', value: (summary?.pending ?? 0).toLocaleString('en-IN'), color: '#9a6700', icon: <Clock size={20} />, badge: `${pct(summary?.pending ?? 0)}% of total` },
{ label: 'Delivered Orders', value: (summary?.delivered ?? 0).toLocaleString('en-IN'), color: '#10b981', icon: <CheckCircle2 size={20} />, badge: `${pct(summary?.delivered ?? 0)}% of total` }, { label: 'Delivered Orders', value: (summary?.delivered ?? 0).toLocaleString('en-IN'), color: '#15803d', icon: <CheckCircle2 size={20} />, badge: `${pct(summary?.delivered ?? 0)}% of total` },
{ label: 'Cancelled Orders', value: (summary?.cancelled ?? 0).toLocaleString('en-IN'), color: '#ef4444', icon: <XCircle size={20} />, badge: `${pct(summary?.cancelled ?? 0)}% of total` }, { label: 'Cancelled Orders', value: (summary?.cancelled ?? 0).toLocaleString('en-IN'), color: '#b42318', icon: <XCircle size={20} />, badge: `${pct(summary?.cancelled ?? 0)}% of total` },
]; ];
const setScope = (next: Partial<{ status: StatusKey; from: string; to: string }>) => { const setScope = (next: Partial<{ status: StatusKey; from: string; to: string }>) => {
@@ -96,6 +209,46 @@ export default function OrdersView({ searchQuery = '', locationid }: OrdersViewP
setPageno(1); 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 ( return (
<div className="animate-in fade-in duration-300"> <div className="animate-in fade-in duration-300">
<GradientHeader <GradientHeader
@@ -132,10 +285,10 @@ export default function OrdersView({ searchQuery = '', locationid }: OrdersViewP
</div> </div>
<div className="flex items-center gap-2 text-xs"> <div className="flex items-center gap-2 text-xs">
<input type="date" value={fromdate} max={todate} onChange={(e) => setScope({ from: e.target.value })} <input type="date" value={fromdate} max={todate} onChange={(e) => 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 }} />
<span style={{ color: TEXT_3 }}></span> <span style={{ color: TEXT_3 }}></span>
<input type="date" value={todate} min={fromdate} max={ymd(today)} onChange={(e) => setScope({ to: e.target.value })} <input type="date" value={todate} min={fromdate} onChange={(e) => 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' }} /> className="rounded-full outline-none font-semibold" style={{ padding: '6px 12px', border: `1px solid ${BORDER}`, background: '#fff', color: TEXT_2 }} />
</div> </div>
</div> </div>
</FilterBar> </FilterBar>
@@ -145,7 +298,9 @@ export default function OrdersView({ searchQuery = '', locationid }: OrdersViewP
<div className="flex flex-col lg:flex-row lg:items-center gap-3"> <div className="flex flex-col lg:flex-row lg:items-center gap-3">
<div className="flex items-center gap-2 overflow-x-auto py-0.5 flex-1 min-w-0"> <div className="flex items-center gap-2 overflow-x-auto py-0.5 flex-1 min-w-0">
{STATUS_TABS.map((t) => { {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 ( return (
<React.Fragment key={t.key}> <React.Fragment key={t.key}>
<Pill active={status === t.key} color={color} onClick={() => setScope({ status: t.key })} count={summaryQ.isLoading ? '·' : countFor(t.key).toLocaleString('en-IN')}> <Pill active={status === t.key} color={color} onClick={() => setScope({ status: t.key })} count={summaryQ.isLoading ? '·' : countFor(t.key).toLocaleString('en-IN')}>
@@ -155,41 +310,110 @@ export default function OrdersView({ searchQuery = '', locationid }: OrdersViewP
); );
})} })}
</div> </div>
<div className="w-full lg:w-72 lg:shrink-0"><SearchPill value={localSearch} onChange={setLocalSearch} placeholder="Search orders…" /></div> <div className="flex items-center gap-2 w-full lg:w-auto lg:shrink-0">
{branches.length > 1 && (
<select
value={branch}
onChange={(e) => { setBranch(Number(e.target.value)); setPageno(1); }}
title="Filter by branch / app-location"
className="rounded-full font-bold text-xs outline-none cursor-pointer shrink-0"
style={{ padding: '7px 12px', border: `1.5px solid ${edge(BRAND)}`, background: tint(BRAND), color: BRAND }}
>
<option value={0}>All branches</option>
{branches.map((b) => <option key={b.id} value={b.id}>{b.name}</option>)}
</select>
)}
<div className="w-full lg:w-60"><SearchPill value={localSearch} onChange={setLocalSearch} placeholder="Search orders (Ctrl+K)…" inputRef={searchRef} /></div>
<button
onClick={exportCsv}
disabled={rows.length === 0}
title="Export current view to CSV"
className="inline-flex items-center gap-1.5 rounded-full font-extrabold text-white cursor-pointer disabled:opacity-40 disabled:cursor-not-allowed whitespace-nowrap shrink-0"
style={{ padding: '8px 14px', fontSize: 12, background: `linear-gradient(135deg, ${BRAND}, ${BRAND_LIGHT})`, boxShadow: `0 6px 18px ${ring(BRAND)}` }}
>
<Download size={13} /> CSV
</button>
</div>
</div> </div>
</FilterBar> </FilterBar>
{/* Multi-select assign bar — shown while rows are selected (or to report a result) */}
{(selected.size > 0 || assignMsg) && (
<div className="mb-3 flex flex-wrap items-center gap-2 rounded-2xl px-4 py-3 animate-in fade-in slide-in-from-top-1 duration-200" style={{ background: tint(BRAND), border: `1.5px solid ${edge(BRAND)}` }}>
<span className="inline-flex items-center gap-1.5 font-extrabold text-xs" style={{ color: BRAND }}>
<UserCheck size={15} /> {selected.size} selected
</span>
<select
value={assignRiderId}
onChange={(e) => setAssignRiderId(Number(e.target.value))}
disabled={selected.size === 0}
title="Choose a rider to assign"
className="rounded-full font-bold text-xs outline-none cursor-pointer disabled:opacity-50"
style={{ padding: '7px 12px', border: `1.5px solid ${edge(BRAND)}`, background: '#fff', color: BRAND, maxWidth: 260 }}
>
<option value={0}>{ridersQ.isLoading ? 'Loading riders…' : riderOptions.length ? 'Select rider…' : 'No riders available'}</option>
{riderOptions.map((o) => <option key={o.id} value={o.id}>{o.label}</option>)}
</select>
<button
onClick={handleAssign}
disabled={!assignRiderId || selected.size === 0 || assignMut.isPending}
className="inline-flex items-center gap-1.5 rounded-full font-extrabold text-white cursor-pointer disabled:opacity-40 disabled:cursor-not-allowed"
style={{ padding: '7px 14px', fontSize: 12, background: `linear-gradient(135deg, ${BRAND}, ${BRAND_LIGHT})`, boxShadow: `0 6px 18px ${ring(BRAND)}` }}
>
{assignMut.isPending ? <Loader2 size={13} className="animate-spin" /> : <UserCheck size={13} />} Assign rider
</button>
{selected.size > 0 && (
<button onClick={() => setSelected(new Set())} className="rounded-full font-bold text-xs cursor-pointer" style={{ padding: '7px 12px', border: `1px solid ${BORDER}`, background: '#fff', color: TEXT_2 }}>
Clear
</button>
)}
{assignMsg && <span className="text-[11px] font-semibold ml-auto" style={{ color: TEXT_2 }}>{assignMsg}</span>}
</div>
)}
{/* Table */} {/* Table */}
<div className="bg-white border rounded-2xl overflow-hidden" style={{ borderColor: BORDER }}> <div className="bg-white border rounded-2xl overflow-hidden" style={{ borderColor: BORDER }}>
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<table className="w-full" style={{ minWidth: 960 }}> <table className="w-full" style={{ minWidth: 960 }}>
<thead> <thead>
<tr> <tr>
{['#', 'Order', 'Pickup', 'Drop', 'Qty', 'COD', 'KMs', 'Charges', 'Status', ''].map((h, i) => ( <th className="px-3 py-2.5 text-left" style={TH_STYLE}>
<input type="checkbox" checked={allSelected} onChange={toggleAll} disabled={rows.length === 0} aria-label="Select all orders" style={{ accentColor: BRAND, cursor: 'pointer', width: 15, height: 15 }} />
</th>
{['#', 'Order', 'Branch', 'Pickup', 'Drop', 'Qty', 'COD', 'KMs', 'Charges', 'Status', ''].map((h, i) => (
<th key={i} className="px-3 py-2.5 text-left" style={TH_STYLE}>{h}</th> <th key={i} className="px-3 py-2.5 text-left" style={TH_STYLE}>{h}</th>
))} ))}
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{ordersQ.isLoading ? ( {ordersQ.isLoading ? (
<tr><td colSpan={10} className="px-3 py-12 text-center" style={{ color: TEXT_3 }}> <tr><td colSpan={12} className="px-3 py-12 text-center" style={{ color: TEXT_3 }}>
<span className="inline-flex items-center gap-2 text-xs font-semibold"><Loader2 size={15} className="animate-spin" style={{ color: BRAND }} /> Loading orders</span> <span className="inline-flex items-center gap-2 text-xs font-semibold"><Loader2 size={15} className="animate-spin" style={{ color: BRAND }} /> Loading orders</span>
</td></tr> </td></tr>
) : rows.length === 0 ? ( ) : rows.length === 0 ? (
<tr><td colSpan={10} className="px-3 py-12 text-center text-xs" style={{ color: TEXT_3 }}>No orders found for this status, date range, or search.</td></tr> <tr><td colSpan={12} className="px-3 py-12 text-center text-xs" style={{ color: TEXT_3 }}>No orders found for this status, date range, or search.</td></tr>
) : ( ) : (
rows.map((r, i) => { rows.map((r, i) => {
const st = fstr(r.orderstatus).toLowerCase(); const st = fstr(r.orderstatus).toLowerCase();
const cod = fnum(r.collectionamt); const cod = fnum(r.collectionamt);
const charges = fnum(r.deliverycharge) || fnum(r.deliverycharges); const charges = fnum(r.deliverycharge) || fnum(r.deliverycharges);
return ( return (
<tr key={fstr(r.orderid) || i} className="transition-colors" style={{ borderBottom: `1px solid ${DIVIDER_C}` }} <tr key={fstr(r.orderid) || i} className="transition-colors" style={{ borderBottom: `1px solid ${DIVIDER_C}`, background: selected.has(rowKey(r)) ? tint(BRAND) : 'transparent' }}
onMouseEnter={(e) => (e.currentTarget.style.background = SURFACE_ALT)} onMouseLeave={(e) => (e.currentTarget.style.background = 'transparent')}> onMouseEnter={(e) => { if (!selected.has(rowKey(r))) e.currentTarget.style.background = SURFACE_ALT; }} onMouseLeave={(e) => { e.currentTarget.style.background = selected.has(rowKey(r)) ? tint(BRAND) : 'transparent'; }}>
<td className="px-3 py-2.5">
<input type="checkbox" checked={selected.has(rowKey(r))} onChange={() => toggleRow(rowKey(r))} aria-label="Select order" style={{ accentColor: BRAND, cursor: 'pointer', width: 15, height: 15 }} />
</td>
<td className="px-3 py-2.5 font-mono" style={{ color: TEXT_3 }}>{(pageno - 1) * PAGE_SIZE + i + 1}</td> <td className="px-3 py-2.5 font-mono" style={{ color: TEXT_3 }}>{(pageno - 1) * PAGE_SIZE + i + 1}</td>
<td className="px-3 py-2.5"> <td className="px-3 py-2.5">
<p className="font-extrabold font-mono text-[13px]" style={{ color: TEXT }}>{fstr(r.orderid) || `#${fstr(r.orderheaderid)}`}</p> <p className="font-extrabold font-mono text-[13px]" style={{ color: TEXT }}>{fstr(r.orderid) || `#${fstr(r.orderheaderid)}`}</p>
<p className="text-[10px]" style={{ color: TEXT_2 }}>{shortTime(r.orderdate || r.deliverydate)}</p> <p className="text-[10px]" style={{ color: TEXT_2 }}>{shortTime(r.orderdate || r.deliverydate)}</p>
</td> </td>
<td className="px-3 py-2.5">
<span className="inline-flex items-center gap-1 font-bold text-[12px]" style={{ color: BRAND }}>
<MapPin size={11} /> {fstr(r.applocation) || '—'}
</span>
{fstr(r.locationname) && <p className="text-[10px] truncate max-w-[130px]" style={{ color: TEXT_2 }}>{fstr(r.locationname)}</p>}
</td>
<td className="px-3 py-2.5"> <td className="px-3 py-2.5">
<p className="font-bold text-[12px] truncate max-w-[150px]" style={{ color: TEXT }}>{fstr(r.pickupcustomer) || fstr(r.tenantname) || '—'}</p> <p className="font-bold text-[12px] truncate max-w-[150px]" style={{ color: TEXT }}>{fstr(r.pickupcustomer) || fstr(r.tenantname) || '—'}</p>
<p className="text-[10px] truncate max-w-[150px]" style={{ color: TEXT_2 }}>{fstr(r.pickupsuburb) || fstr(r.pickupaddress)}</p> <p className="text-[10px] truncate max-w-[150px]" style={{ color: TEXT_2 }}>{fstr(r.pickupsuburb) || fstr(r.pickupaddress)}</p>
@@ -199,9 +423,9 @@ export default function OrdersView({ searchQuery = '', locationid }: OrdersViewP
<p className="text-[10px] truncate max-w-[150px]" style={{ color: TEXT_2 }}>{fstr(r.deliverysuburb) || fstr(r.deliveryaddress)}</p> <p className="text-[10px] truncate max-w-[150px]" style={{ color: TEXT_2 }}>{fstr(r.deliverysuburb) || fstr(r.deliveryaddress)}</p>
</td> </td>
<td className="px-3 py-2.5 font-mono text-[12px]" style={{ color: TEXT }}>{fnum(r.quantity) || '—'}</td> <td className="px-3 py-2.5 font-mono text-[12px]" style={{ color: TEXT }}>{fnum(r.quantity) || '—'}</td>
<td className="px-3 py-2.5">{cod > 0 ? <MetricPill color="#ef4444">{cod.toLocaleString('en-IN')}</MetricPill> : <span style={{ color: TEXT_3 }}></span>}</td> <td className="px-3 py-2.5 font-mono text-[12px] font-semibold" style={{ color: cod > 0 ? TEXT : TEXT_3 }}>{cod > 0 ? `${cod.toLocaleString('en-IN')}` : '—'}</td>
<td className="px-3 py-2.5">{fnum(r.kms) ? <MetricPill color="#ef4444">{fnum(r.kms).toFixed(1)}</MetricPill> : <span style={{ color: TEXT_3 }}></span>}</td> <td className="px-3 py-2.5 font-mono text-[12px]" style={{ color: fnum(r.kms) ? TEXT_2 : TEXT_3 }}>{fnum(r.kms) ? fnum(r.kms).toFixed(1) : '—'}</td>
<td className="px-3 py-2.5">{charges > 0 ? <MetricPill color="#10b981">{charges.toLocaleString('en-IN')}</MetricPill> : <span style={{ color: TEXT_3 }}></span>}</td> <td className="px-3 py-2.5 font-mono text-[12px] font-semibold" style={{ color: charges > 0 ? TEXT : TEXT_3 }}>{charges > 0 ? `${charges.toLocaleString('en-IN')}` : '—'}</td>
<td className="px-3 py-2.5"><StatusChip label={st || '—'} color={statusColor(ORDER_STATUS, st)} /></td> <td className="px-3 py-2.5"><StatusChip label={st || '—'} color={statusColor(ORDER_STATUS, st)} /></td>
<td className="px-3 py-2.5 text-right"> <td className="px-3 py-2.5 text-right">
<button onClick={() => setDetailOrder(r)} className="rounded-full font-extrabold cursor-pointer transition-colors" <button onClick={() => setDetailOrder(r)} className="rounded-full font-extrabold cursor-pointer transition-colors"
@@ -214,6 +438,15 @@ export default function OrdersView({ searchQuery = '', locationid }: OrdersViewP
</tbody> </tbody>
</table> </table>
</div> </div>
{/* Totals across the filtered rows */}
{rows.length > 0 && (
<div className="flex flex-wrap items-center justify-end gap-2 px-4 py-2.5 border-t" style={{ borderColor: BORDER, background: SURFACE_ALT }}>
<span className="text-[10px] font-extrabold uppercase tracking-wider mr-auto" style={{ color: TEXT_2 }}>Totals · {rows.length} order{rows.length === 1 ? '' : 's'}</span>
{totals.cod > 0 && <TotalChip label="COD" value={inr(totals.cod)} color={TEXT_2} />}
<TotalChip label="Charges" value={inr(totals.charges)} color={TEXT_2} />
<TotalChip label="Amount" value={inr(totals.amount)} color={BRAND} />
</div>
)}
<div className="flex items-center justify-between px-4 py-3 border-t" style={{ borderColor: BORDER, background: SURFACE_ALT }}> <div className="flex items-center justify-between px-4 py-3 border-t" style={{ borderColor: BORDER, background: SURFACE_ALT }}>
<span className="text-[10px] font-bold uppercase tracking-wider" style={{ color: TEXT_2 }}>Page {pageno} · {rows.length} shown</span> <span className="text-[10px] font-bold uppercase tracking-wider" style={{ color: TEXT_2 }}>Page {pageno} · {rows.length} shown</span>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@@ -224,12 +457,148 @@ export default function OrdersView({ searchQuery = '', locationid }: OrdersViewP
</div> </div>
{detailOrder && <OrderDetailModal order={detailOrder} onClose={() => setDetailOrder(null)} />} {detailOrder && <OrderDetailModal order={detailOrder} onClose={() => 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(
<button
onClick={() => setShowSelected(true)}
title={`Review & assign ${selected.size} selected order${selected.size === 1 ? '' : 's'}`}
className="group fixed right-0 z-[150] flex items-center gap-2 py-3 pl-4 pr-5 text-white font-extrabold text-xs cursor-pointer transition-all duration-200 hover:pr-7 animate-in slide-in-from-right-4"
style={{ top: '70%', background: `linear-gradient(135deg, ${BRAND}, ${BRAND_LIGHT})`, borderTopLeftRadius: 9999, borderBottomLeftRadius: 9999, boxShadow: `0 10px 30px ${ring(BRAND)}` }}
>
<span className="relative inline-flex">
<ClipboardList size={18} />
<span className="absolute -top-2.5 -right-2.5 min-w-[17px] h-[17px] px-1 rounded-full bg-rose-500 text-[9px] font-black flex items-center justify-center ring-2 ring-white">{selected.size}</span>
</span>
<span className="max-w-0 overflow-hidden group-hover:max-w-[80px] transition-all duration-200 whitespace-nowrap">Review</span>
</button>,
document.body,
)}
{showSelected &&
createPortal(
<SelectedOrdersPage
rows={selectedRows}
rowKey={rowKey}
riderOptions={riderOptions}
ridersLoading={ridersQ.isLoading}
assignRiderId={assignRiderId}
setAssignRiderId={setAssignRiderId}
assigning={assignMut.isPending}
assignMsg={assignMsg}
onAssign={handleAssign}
onRemove={(k) => toggleRow(k)}
onClose={() => setShowSelected(false)}
/>,
document.body,
)}
</div> </div>
); );
} }
const DIVIDER_C = '#f1f5f9'; 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 (
<div className="fixed inset-0 z-[200] overflow-y-auto animate-in fade-in duration-200" style={{ background: '#f8fafc' }}>
{/* Sticky page header with the assign controls */}
<div className="sticky top-0 z-10 border-b" style={{ background: '#fff', borderColor: BORDER }}>
<div className="max-w-5xl mx-auto px-4 md:px-8 py-4 flex flex-wrap items-center gap-3">
<button onClick={onClose} className="inline-flex items-center gap-1.5 rounded-full font-bold text-xs cursor-pointer" style={{ padding: '8px 14px', border: `1px solid ${BORDER}`, color: TEXT_2, background: '#fff' }}>
<ArrowLeft size={14} /> Back to orders
</button>
<div className="flex items-center gap-2">
<span className="h-9 w-9 rounded-xl flex items-center justify-center" style={{ background: tint(BRAND), color: BRAND }}><ClipboardList size={18} /></span>
<div>
<h1 className="font-bold text-lg tracking-tight leading-none" style={{ color: TEXT }}>Selected Orders</h1>
<p className="text-[11px] mt-1" style={{ color: TEXT_2 }}>{rows.length} order{rows.length === 1 ? '' : 's'} ready to assign</p>
</div>
</div>
<div className="flex items-center gap-2 ml-auto">
<select value={assignRiderId} onChange={(e) => setAssignRiderId(Number(e.target.value))} className="rounded-full font-bold text-xs outline-none cursor-pointer" style={{ padding: '8px 12px', border: `1.5px solid ${edge(BRAND)}`, background: '#fff', color: BRAND, maxWidth: 260 }}>
<option value={0}>{ridersLoading ? 'Loading riders…' : riderOptions.length ? 'Select rider…' : 'No riders available'}</option>
{riderOptions.map((o) => <option key={o.id} value={o.id}>{o.label}</option>)}
</select>
<button onClick={onAssign} disabled={!assignRiderId || rows.length === 0 || assigning} className="inline-flex items-center gap-1.5 rounded-full font-extrabold text-white cursor-pointer disabled:opacity-40 disabled:cursor-not-allowed" style={{ padding: '8px 16px', fontSize: 12, background: `linear-gradient(135deg, ${BRAND}, ${BRAND_LIGHT})`, boxShadow: `0 6px 18px ${ring(BRAND)}` }}>
{assigning ? <Loader2 size={14} className="animate-spin" /> : <UserCheck size={14} />} Assign rider
</button>
</div>
</div>
</div>
<div className="max-w-5xl mx-auto px-4 md:px-8 py-6">
{assignMsg && <div className="mb-4 rounded-xl px-4 py-2.5 text-xs font-semibold" style={{ background: tint(BRAND), border: `1px solid ${edge(BRAND)}`, color: BRAND }}>{assignMsg}</div>}
{rows.length === 0 ? (
<div className="bg-white border rounded-2xl p-12 text-center text-xs" style={{ borderColor: BORDER, color: TEXT_3 }}>
No orders selected. <button onClick={onClose} className="font-bold underline cursor-pointer" style={{ color: BRAND }}>Go back</button>
</div>
) : (
<div className="bg-white border rounded-2xl overflow-hidden" style={{ borderColor: BORDER }}>
<div className="overflow-x-auto">
<table className="w-full" style={{ minWidth: 720 }}>
<thead><tr>{['#', 'Order', 'Pickup', 'Drop', 'Status', ''].map((h, i) => <th key={i} className="px-3 py-2.5 text-left" style={TH_STYLE}>{h}</th>)}</tr></thead>
<tbody>
{rows.map((r, i) => {
const st = fstr(r.orderstatus).toLowerCase();
return (
<tr key={rowKey(r) || i} style={{ borderBottom: `1px solid ${DIVIDER_C}` }}>
<td className="px-3 py-2.5 font-mono" style={{ color: TEXT_3 }}>{i + 1}</td>
<td className="px-3 py-2.5">
<p className="font-extrabold font-mono text-[13px]" style={{ color: TEXT }}>{fstr(r.orderid) || `#${fstr(r.orderheaderid)}`}</p>
<p className="text-[10px]" style={{ color: TEXT_2 }}>{shortTime(r.orderdate || r.deliverydate)}</p>
</td>
<td className="px-3 py-2.5">
<p className="font-bold text-[12px] truncate max-w-[180px]" style={{ color: TEXT }}>{fstr(r.pickupcustomer) || fstr(r.tenantname) || '—'}</p>
<p className="text-[10px] truncate max-w-[180px]" style={{ color: TEXT_2 }}>{fstr(r.pickupsuburb) || fstr(r.pickupaddress)}</p>
</td>
<td className="px-3 py-2.5">
<p className="font-bold text-[12px] truncate max-w-[180px]" style={{ color: TEXT }}>{fstr(r.deliverycustomer) || '—'}</p>
<p className="text-[10px] truncate max-w-[180px]" style={{ color: TEXT_2 }}>{fstr(r.deliverysuburb) || fstr(r.deliveryaddress)}</p>
</td>
<td className="px-3 py-2.5"><StatusChip label={st || '—'} color={statusColor(ORDER_STATUS, st)} /></td>
<td className="px-3 py-2.5 text-right">
<button onClick={() => onRemove(rowKey(r))} title="Remove from selection" className="p-1 rounded-full cursor-pointer" style={{ color: TEXT_3 }}><X size={15} /></button>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</div>
)}
</div>
</div>
);
}
function TotalChip({ label, value, color }: { label: string; value: string; color: string }) {
return (
<span className="inline-flex items-center gap-1.5 rounded-full font-bold" style={{ padding: '4px 11px', fontSize: 11.5, background: soft(color), color, border: `1px solid ${edge(color)}` }}>
<span className="uppercase tracking-wider text-[9px] font-extrabold opacity-80">{label}</span>
<span className="font-mono">{value}</span>
</span>
);
}
function PagerBtn({ children, disabled, onClick }: { children: React.ReactNode; disabled?: boolean; onClick: () => void }) { function PagerBtn({ children, disabled, onClick }: { children: React.ReactNode; disabled?: boolean; onClick: () => void }) {
return ( return (
<button onClick={onClick} disabled={disabled} <button onClick={onClick} disabled={disabled}
@@ -252,10 +621,15 @@ function OrderDetailModal({ order, onClose }: { order: Row; onClose: () => void
const st = fstr(order.orderstatus).toLowerCase(); const st = fstr(order.orderstatus).toLowerCase();
const total = fnum(order.deliveryamt) || fnum(order.orderamount); const total = fnum(order.deliveryamt) || fnum(order.orderamount);
return ( // Portal to <body> so the overlay escapes any transformed / blurred / overflow
// ancestor in the view tree — otherwise `fixed inset-0` resolves against that
// ancestor (not the viewport) and the panel collapses to a sliver. The explicit
// viewport-relative width is a belt-and-suspenders so sizing never depends on
// percentage resolution against a broken containing block.
return createPortal(
<div className="fixed inset-0 z-[200] flex items-center justify-center p-4" style={{ background: 'rgba(15,23,42,0.4)', backdropFilter: 'blur(4px)' }} <div className="fixed inset-0 z-[200] flex items-center justify-center p-4" style={{ background: 'rgba(15,23,42,0.4)', backdropFilter: 'blur(4px)' }}
onClick={(e) => { if (e.target === e.currentTarget) onClose(); }}> onClick={(e) => { if (e.target === e.currentTarget) onClose(); }}>
<div className="bg-white w-full max-w-lg max-h-[90vh] flex flex-col overflow-hidden rounded-2xl animate-in zoom-in-95 duration-200" style={{ border: `1px solid ${BORDER}`, boxShadow: '0 18px 50px rgba(15,23,42,0.18)' }}> <div className="bg-white max-h-[90vh] flex flex-col overflow-hidden rounded-2xl animate-in zoom-in-95 duration-200" style={{ width: 'min(32rem, 92vw)', border: `1px solid ${BORDER}`, boxShadow: '0 18px 50px rgba(15,23,42,0.18)' }}>
<div style={{ height: 4, background: `linear-gradient(90deg, ${BRAND} 0%, ${soft(BRAND)} 100%)` }} /> <div style={{ height: 4, background: `linear-gradient(90deg, ${BRAND} 0%, ${soft(BRAND)} 100%)` }} />
<div className="p-4 border-b flex justify-between items-center shrink-0" style={{ borderColor: BORDER, background: SURFACE_ALT }}> <div className="p-4 border-b flex justify-between items-center shrink-0" style={{ borderColor: BORDER, background: SURFACE_ALT }}>
<h4 className="font-extrabold flex items-center gap-2" style={{ color: TEXT }}><Package size={16} style={{ color: BRAND }} /> Order {fstr(order.orderid) || `#${fstr(order.orderheaderid)}`}</h4> <h4 className="font-extrabold flex items-center gap-2" style={{ color: TEXT }}><Package size={16} style={{ color: BRAND }} /> Order {fstr(order.orderid) || `#${fstr(order.orderheaderid)}`}</h4>
@@ -294,7 +668,8 @@ function OrderDetailModal({ order, onClose }: { order: Row; onClose: () => void
<button onClick={onClose} className="rounded-full font-bold cursor-pointer text-white" style={{ padding: '8px 16px', background: `linear-gradient(135deg, ${BRAND}, ${BRAND_LIGHT_LOCAL})` }}>Close</button> <button onClick={onClose} className="rounded-full font-bold cursor-pointer text-white" style={{ padding: '8px 16px', background: `linear-gradient(135deg, ${BRAND}, ${BRAND_LIGHT_LOCAL})` }}>Close</button>
</div> </div>
</div> </div>
</div> </div>,
document.body,
); );
} }

View File

@@ -38,12 +38,13 @@ interface ReportsViewProps {
searchQuery: string; searchQuery: string;
isCoimbatoreView: boolean; isCoimbatoreView: boolean;
setIsCoimbatoreView: (val: boolean) => void; setIsCoimbatoreView: (val: boolean) => void;
tenantId?: number;
} }
const MONTH_KEYS = ['jan', 'feb', 'mar', 'apr', 'may', 'jun', 'jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dece']; 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']; 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 [selectedTimeframe, setSelectedTimeframe] = useState('This Year (YTD)');
const [selectedRegion, setSelectedRegion] = useState<'all' | 'coimbatore' | 'chennai' | 'bangalore'>('all'); const [selectedRegion, setSelectedRegion] = useState<'all' | 'coimbatore' | 'chennai' | 'bangalore'>('all');
const [stockFilter, setStockFilter] = useState<'All' | 'Healthy' | 'Low Stock' | 'Critical'>('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 prevEnd = new Date(yearStart.getTime() - 86400000);
const prevStart = new Date(prevEnd.getTime() - periodDays * 86400000); const prevStart = new Date(prevEnd.getTime() - periodDays * 86400000);
const summaryQ = useFiestaOrderSummary(FIESTA_TENANT_ID, ymd(yearStart), todate); const summaryQ = useFiestaOrderSummary(tenantId, ymd(yearStart), todate);
const prevSummaryQ = useFiestaOrderSummary(FIESTA_TENANT_ID, ymd(prevStart), ymd(prevEnd)); const prevSummaryQ = useFiestaOrderSummary(tenantId, ymd(prevStart), ymd(prevEnd));
const locSummaryQ = useFiestaLocationSummary(FIESTA_TENANT_ID); const locSummaryQ = useFiestaLocationSummary(tenantId);
const insightQ = useFiestaOrderInsight(FIESTA_TENANT_ID); const insightQ = useFiestaOrderInsight(tenantId);
const stockQ = useFiestaStockStatement({ const stockQ = useFiestaStockStatement({
tenantid: FIESTA_TENANT_ID, tenantid: tenantId,
locationid: FIESTA_PRIMARY_LOCATION_ID, locationid: FIESTA_PRIMARY_LOCATION_ID,
keyword: '', keyword: '',
pageno: 1, pageno: 1,
@@ -652,7 +653,7 @@ export default function ReportsView({ searchQuery, isCoimbatoreView, setIsCoimba
{chartMetric === 'orders' ? 'Total Orders Velocity Trend' : {chartMetric === 'orders' ? 'Total Orders Velocity Trend' :
chartMetric === 'revenue' ? 'Revenue Expansion Trajectory' : chartMetric === 'revenue' ? 'Revenue Expansion Trajectory' :
chartMetric === 'cancelled' ? 'Order Cancellation Frequency' : chartMetric === 'cancelled' ? 'Order Cancellation Frequency' :
'Catalog Active SKUs Growth'} 'Catalogue Active SKUs Growth'}
</h3> </h3>
</div> </div>

View File

@@ -14,19 +14,17 @@ import {
MapPin, MapPin,
Phone, Phone,
Mail, Mail,
Plus Plus,
Bike
} from 'lucide-react'; } from 'lucide-react';
import { useFiestaAllTenants, useFiestaTenantLocations } from '../services/fiestaQueries'; import { useFiestaAllTenants, useFiestaTenantLocations } from '../services/fiestaQueries';
import { useAppRoles } from '../services/queries'; import { useAppRoles } from '../services/queries';
import { FIESTA_TENANT_ID, str as fstr, num as fnum, roleName } from '../services/fiestaApi'; import { FIESTA_TENANT_ID, str as fstr, num as fnum, roleName } from '../services/fiestaApi';
import UsersPanel from './UsersPanel'; import UsersPanel from './UsersPanel';
import AwaitingApi from './AwaitingApi'; import AwaitingApi from './AwaitingApi';
import AdminConsole from './AdminConsole';
interface SettingsViewProps { type TabKey = 'profile' | 'outlets' | 'users';
tenantId?: number;
}
type TabKey = 'profile' | 'outlets' | 'users' | 'delivery' | 'payment' | 'preferences';
/** Locally-persisted merchant preferences (survive reload via localStorage). */ /** Locally-persisted merchant preferences (survive reload via localStorage). */
interface MerchantSettings { 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 // (see [R6]) so they are not persisted; the operational controls that would
// need persistence show an AwaitingApi notice instead of saving silently. // need persistence show an AwaitingApi notice instead of saving silently.
const [form, setForm] = useState<MerchantSettings>({ ...DEFAULTS }); const [form, setForm] = useState<MerchantSettings>({ ...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 // First-run seeding: fill region/role defaults from the live tenant once it
// arrives (used at runtime by the Add User dialog / region label). // 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: 'profile', label: 'Business Profile', icon: Building2 },
{ key: 'outlets', label: 'Outlets', icon: Store }, { key: 'outlets', label: 'Outlets', icon: Store },
{ key: 'users', label: 'Users & Access', icon: Users }, { 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 // Build role options from the live app-roles API; fall back to the known
@@ -392,14 +394,29 @@ export default function SettingsView({ tenantId = FIESTA_TENANT_ID }: SettingsVi
<div className="bg-white border border-slate-200/60 p-6 rounded-2xl shadow-sm space-y-md animate-in fade-in duration-200"> <div className="bg-white border border-slate-200/60 p-6 rounded-2xl shadow-sm space-y-md animate-in fade-in duration-200">
<div className="flex justify-between items-center pb-4 border-b border-slate-100"> <div className="flex justify-between items-center pb-4 border-b border-slate-100">
<div> <div>
<span className="text-xs font-bold text-slate-400 uppercase tracking-widest block">Our Stores</span> <span className="text-xs font-bold text-slate-450 uppercase tracking-widest block">
<h2 className="text-xl font-bold text-slate-900 mt-1">Store Directory</h2> {showStoreOnboarding ? 'Onboarding' : 'Our Stores'}
</div>
<span className="text-xs text-[#581c87] font-bold bg-purple-50 px-3.5 py-1.5 rounded-full border border-purple-100/50">
{locationsQ.isLoading ? 'Loading…' : `${cleanOutlets.length} outlet${cleanOutlets.length === 1 ? '' : 's'}`}
</span> </span>
<h2 className="text-xl font-bold text-slate-900 mt-1">
{showStoreOnboarding ? 'Add Store Outlet Location' : 'Store Directory'}
</h2>
</div> </div>
<button
type="button"
onClick={() => setShowStoreOnboarding(!showStoreOnboarding)}
className="bg-[#581c87] hover:bg-purple-800 text-white px-4 py-2.5 rounded-xl text-xs font-bold uppercase tracking-wider flex items-center gap-1.5 cursor-pointer shadow-sm active:scale-95 transition-all border-none"
>
{showStoreOnboarding ? 'View Store Directory' : '+ Add Store Branch'}
</button>
</div>
{showStoreOnboarding ? (
<div className="pt-2">
<AdminConsole activeTab="store" showHeader={false} onBack={() => setShowStoreOnboarding(false)} tenantId={tenantId} />
</div>
) : (
<>
{locationsQ.isLoading ? ( {locationsQ.isLoading ? (
<div className="text-center py-lg text-slate-400 font-medium text-sm">Loading live outlets</div> <div className="text-center py-lg text-slate-400 font-medium text-sm">Loading live outlets</div>
) : cleanOutlets.length === 0 ? ( ) : cleanOutlets.length === 0 ? (
@@ -435,7 +452,7 @@ export default function SettingsView({ tenantId = FIESTA_TENANT_ID }: SettingsVi
{/* Outlet Details Grid */} {/* Outlet Details Grid */}
<div className="grid grid-cols-2 gap-3 bg-slate-50/50 p-3.5 rounded-xl border border-slate-100/80"> <div className="grid grid-cols-2 gap-3 bg-slate-50/50 p-3.5 rounded-xl border border-slate-100/80">
<div className="space-y-1"> <div className="space-y-1">
<span className="text-[10px] text-slate-450 uppercase font-bold block">Delivery Range</span> <span className="text-[10px] text-slate-455 uppercase font-bold block">Delivery Range</span>
<p className="font-bold text-slate-700 text-xs"> <p className="font-bold text-slate-700 text-xs">
{loc.deliveryradius ? `Up to ${loc.deliveryradius / 1000} km` : '—'} {loc.deliveryradius ? `Up to ${loc.deliveryradius / 1000} km` : '—'}
</p> </p>
@@ -461,6 +478,8 @@ export default function SettingsView({ tenantId = FIESTA_TENANT_ID }: SettingsVi
))} ))}
</div> </div>
)} )}
</>
)}
</div> </div>
)} )}
@@ -468,100 +487,7 @@ export default function SettingsView({ tenantId = FIESTA_TENANT_ID }: SettingsVi
<UsersPanel tenantId={tenantId} defaultNewUserRole={form.defaultNewUserRole} /> <UsersPanel tenantId={tenantId} defaultNewUserRole={form.defaultNewUserRole} />
)} )}
{activeTab === 'delivery' && (
<div className="bg-white border border-slate-200/60 p-6 rounded-2xl shadow-sm space-y-lg animate-in fade-in duration-200">
<div>
<span className="text-xs font-bold text-slate-450 uppercase tracking-widest block">Delivery</span>
<h2 className="text-xl font-bold text-slate-900 mt-1">Order Prep, Timings & Dispatch</h2>
</div>
{/* No merchant-settings API yet — these operational controls cannot be persisted live. */}
<AwaitingApi label="Merchant settings persistence" api="[R6]" />
</div>
)}
{activeTab === 'payment' && (
<div className="bg-white border border-slate-200/60 p-6 rounded-2xl shadow-sm space-y-lg animate-in fade-in duration-200">
<div>
<span className="text-xs font-bold text-slate-450 uppercase tracking-widest block">Payment & Tax</span>
<h2 className="text-xl font-bold text-slate-900 mt-1">Checkout & Taxation</h2>
</div>
{/* Live (read-only) tenant payment details. */}
<div className="space-y-sm">
<span className="text-xs font-bold text-purple-800 bg-purple-50/70 border border-purple-100/50 px-3 py-1 rounded-full uppercase tracking-wider">
Store Payment Details
</span>
<div className="divide-y divide-slate-100/70 mt-2">
<Row title="Minimum Order Value" desc="Smallest order a customer can place (from store profile).">
<span className="font-bold text-slate-700 text-sm font-mono">
{tenant && fnum(tenant.minorder) ? `${fnum(tenant.minorder).toLocaleString('en-IN')}` : '—'}
</span>
</Row>
<Row title="Payment Gateway ID" desc="Configured payment type for this store.">
<span className="font-mono font-black bg-purple-100 px-3 py-1.5 rounded-xl border border-purple-200/40 text-xs">
{tenant && fnum(tenant.paymenttype) ? fnum(tenant.paymenttype) : '—'}
</span>
</Row>
</div>
</div>
{/* Editable checkout gateways + tax rules have no persistence backend. */}
<div className="space-y-sm">
<span className="text-xs font-bold text-purple-800 bg-purple-50/70 border border-purple-100/50 px-3 py-1 rounded-full uppercase tracking-wider">
Checkout Gateways & Taxation
</span>
<AwaitingApi className="mt-2" label="Merchant settings persistence" api="[R6]" />
</div>
</div>
)}
{activeTab === 'preferences' && (
<div className="bg-white border border-slate-200/60 p-6 rounded-2xl shadow-sm space-y-lg animate-in fade-in duration-200">
{/* Group 1: General Defaults */}
<div className="space-y-sm">
<span className="text-xs font-bold text-purple-800 bg-purple-50/70 border border-purple-100/50 px-3 py-1 rounded-full uppercase tracking-wider">
General Defaults
</span>
<div className="divide-y divide-slate-100/70 mt-2">
<Row title="Default Region" desc="Region applied to new outlets and reports.">
<div className="relative rounded-xl shadow-sm">
<div className="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3">
<MapPin size={14} className="text-slate-400" />
</div>
<input
type="text"
value={form.defaultRegion}
onChange={(e) => 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"
/>
</div>
</Row>
<Row title="Default Role for New Users" desc="Pre-selected role in the Add User dialog.">
<select
value={form.defaultNewUserRole}
onChange={(e) => set('defaultNewUserRole', Number(e.target.value))}
className="border border-slate-200 bg-slate-50/40 hover:bg-slate-50 focus:bg-white rounded-xl py-2 px-3 font-bold text-slate-700 outline-none cursor-pointer focus:border-purple-500 transition-all text-sm shadow-sm"
>
{roleOptions.map((r) => (
<option key={r.id} value={r.id}>{r.name}</option>
))}
</select>
</Row>
</div>
<p className="text-[11px] text-slate-400 font-medium mt-2 px-4">
Region and default-role are in-session workspace preferences applied at runtime; they are not saved to a backend.
</p>
</div>
{/* Group 2: Notifications, sync interval & sandbox — no persistence backend. */}
<div className="space-y-sm">
<span className="text-xs font-bold text-purple-800 bg-purple-50/70 border border-purple-100/50 px-3 py-1 rounded-full uppercase tracking-wider">
Notifications, Sync & Test Mode
</span>
<AwaitingApi className="mt-2" label="Merchant settings persistence" api="[R6]" />
</div>
</div>
)}
</div> </div>
</div> </div>

View File

@@ -9,7 +9,8 @@ import {
Store, Store,
Layers, Layers,
Settings, Settings,
TrendingUp TrendingUp,
ShieldAlert
} from 'lucide-react'; } from 'lucide-react';
import { MainSection } from '../types'; import { MainSection } from '../types';
@@ -19,6 +20,7 @@ interface SidebarProps {
isCoimbatoreView: boolean; isCoimbatoreView: boolean;
setIsCoimbatoreView: (val: boolean) => void; setIsCoimbatoreView: (val: boolean) => void;
isOpen: boolean; isOpen: boolean;
isAdmin?: boolean;
} }
export default function Sidebar({ export default function Sidebar({
@@ -26,20 +28,21 @@ export default function Sidebar({
setCurrentSection, setCurrentSection,
isCoimbatoreView, isCoimbatoreView,
setIsCoimbatoreView, setIsCoimbatoreView,
isOpen isOpen,
isAdmin
}: SidebarProps) { }: SidebarProps) {
// Navigation elements // Navigation elements
const navItems = [ const navItems = [
{ id: 'dashboard' as MainSection, label: 'Dashboard', icon: LayoutDashboard }, { id: 'dashboard' as MainSection, label: 'Dashboard', icon: LayoutDashboard },
{ id: 'stores' as MainSection, label: 'Stores', icon: Store }, { 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: 'reports' as MainSection, label: 'Reports', icon: TrendingUp },
{ id: 'settings' as MainSection, label: 'Settings', icon: Settings } { id: 'settings' as MainSection, label: 'Settings', icon: Settings }
]; ];
return ( return (
<aside <aside
className={`fixed left-0 top-0 h-screen bg-[#581c87] border-r border-[#4c1d95] text-white flex-col py-xl pt-24 z-40 hidden md:flex transition-all duration-300 ${ className={`fixed left-0 top-0 h-screen bg-[#581c87] border-r border-[#4c1d95] text-white flex-col py-xl pt-20 z-40 hidden md:flex transition-all duration-300 ${
isOpen ? 'w-64' : 'w-20' isOpen ? 'w-64' : 'w-20'
}`} }`}
> >
@@ -70,3 +73,4 @@ export default function Sidebar({
</aside> </aside>
); );
} }

View File

@@ -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 * Product-management flow (3 tiers):
* sees ONLY that manager-selected catalog (never the global one) and chooses which * 1. Admin adds products to the GLOBAL catalogue and selects which ones (+ qty)
* products to stock in their own store. Two tabs: * to publish — that's the shared "store catalogue" (services/storeCatalogue).
* • Browse Catalog — the manager-approved products, each addable to the store. * 2. The user sees ONLY that admin-curated catalogue here (never the global one)
* • My Store Inventory — what's currently stocked at this outlet (live stock). * 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 * The catalogue source is the shared store catalogue (localStorage bridge for now;
* (getMasterCatalog) for now — see CATALOG_SOURCE below; swap that one hook for * backend: GET /products/getlocationproducts). The user's picks persist per store
* the approved-products endpoint once it exists. * and `commitSelectionToStore()` is the single backend integration point
* * (POST /products/createproductlocation / a stock-request endpoint).
* 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.
*/ */
import React, { useEffect, useMemo, useState } from 'react'; import React, { useEffect, useMemo, useState } from 'react';
import { import { Search, Boxes, Layers, Plus, Minus, Check, CheckCircle2, X, Store, PackageSearch } from 'lucide-react';
Search, Boxes, Layers, Plus, Check, CheckCircle2, X, Tag, Store, PackageSearch, AlertTriangle, import { useFiestaStockStatement, FIESTA_TENANT_ID } from '../services/fiestaQueries';
} from 'lucide-react';
import {
useFiestaMasterCatalog,
useFiestaStockStatement,
useFiestaProductCategories,
useFiestaProductSubcategories,
FIESTA_TENANT_ID,
} from '../services/fiestaQueries';
import { num as fnum, str as fstr, type Row } from '../services/fiestaApi'; import { num as fnum, str as fstr, type Row } from '../services/fiestaApi';
import { categoryName } from '../services/fiestaMappers'; import { categoryName } from '../services/fiestaMappers';
import { useStoreCatalogue } from '../services/storeCatalogue';
import AwaitingApi from './AwaitingApi'; import AwaitingApi from './AwaitingApi';
const BRAND = '#581c87';
const PLACEHOLDER = 'https://images.unsplash.com/photo-1542838132-92c53300491e?auto=format&fit=crop&q=80&w=200'; const PLACEHOLDER = 'https://images.unsplash.com/photo-1542838132-92c53300491e?auto=format&fit=crop&q=80&w=200';
interface StoreCatalogViewProps { interface StoreCatalogViewProps {
locationid?: number; locationid?: number;
storeName?: string; storeName?: string;
} tenantId?: number;
interface CatalogProduct {
id: string;
name: string;
image: string;
category: string;
categoryid: number;
subcategoryid: number;
subcategoryname: string;
price: number;
unit: string;
} }
function stockStatus(closing: number): { label: string; color: string } { 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' }; return { label: 'Healthy', color: '#10b981' };
} }
export default function StoreCatalogView({ locationid, storeName = 'your store' }: StoreCatalogViewProps) { /** Category → pill badge classes (mirrors the admin Global Catalogue card). */
const tenantid = FIESTA_TENANT_ID; function catBadgeClass(category: string): string {
const [view, setView] = useState<'catalog' | 'inventory'>('catalog'); 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 [search, setSearch] = useState('');
const [categoryid, setCategoryid] = useState(0); const [category, setCategory] = useState('ALL');
const [subcategoryid, setSubcategoryid] = useState(0);
const [notice, setNotice] = useState(false); const [notice, setNotice] = useState(false);
// Selections "to stock at this store" — persisted per outlet so choices survive // The admin-curated catalogue (what the user is allowed to pick from).
// a refresh until the backend write exists. const storeCat = useStoreCatalogue();
const storageKey = `nearledaily.catalog.selected.${locationid ?? 'na'}`; const products = useMemo(
const [selected, setSelected] = useState<Set<string>>(() => { () =>
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<Record<string, number>>(() => {
try { try {
const raw = localStorage.getItem(storageKey); const raw = localStorage.getItem(storageKey);
return new Set(raw ? (JSON.parse(raw) as string[]) : []); return raw ? (JSON.parse(raw) as Record<string, number>) : {};
} catch { } catch {
return new Set(); return {};
} }
}); });
useEffect(() => { useEffect(() => {
try { localStorage.setItem(storageKey, JSON.stringify([...selected])); } catch { /* ignore */ } try { localStorage.setItem(storageKey, JSON.stringify(picks)); } catch { /* ignore */ }
}, [selected, storageKey]); }, [picks, storageKey]);
// ── Data ────────────────────────────────────────────────────────────────────── const togglePick = (id: string) => {
// CATALOG_SOURCE: the manager-selected assortment. Swap this hook for the setNotice(false);
// approved-products endpoint when it's available; the rest of the page is agnostic. setPicks((prev) => {
const catalogQ = useFiestaMasterCatalog({ tenantid, subcategoryid: subcategoryid || undefined, pagesize: 200 }); 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 stockQ = useFiestaStockStatement({ tenantid, locationid: locationid ?? 0, pagesize: 200 });
const categoriesQ = useFiestaProductCategories();
const subcategoriesQ = useFiestaProductSubcategories({ categoryid, tenantid });
const products = useMemo<CatalogProduct[]>(
() =>
(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 inStore = useMemo(() => new Set((stockQ.data ?? []).map((r) => fstr(r.productid))), [stockQ.data]);
const inventory = useMemo( const inventory = useMemo(
() => () =>
(stockQ.data ?? []).map((r: Row) => { (stockQ.data ?? []).map((r: Row) => {
@@ -121,6 +111,8 @@ export default function StoreCatalogView({ locationid, storeName = 'your store'
return { return {
id: fstr(r.productid), id: fstr(r.productid),
name: fstr(r.productname) || 'Unnamed product', name: fstr(r.productname) || 'Unnamed product',
sku: fstr(r.sku) || `SKU-${fstr(r.productid)}`,
image: fstr(r.productimage) || PLACEHOLDER,
category: categoryName(fnum(r.categoryid)), category: categoryName(fnum(r.categoryid)),
closing, closing,
...stockStatus(closing), ...stockStatus(closing),
@@ -128,78 +120,46 @@ export default function StoreCatalogView({ locationid, storeName = 'your store'
}), }),
[stockQ.data], [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 filtered = useMemo(() => {
const term = search.toLowerCase(); const term = search.toLowerCase();
return products.filter((p) => { return products.filter((p) => {
if (categoryid && p.categoryid !== categoryid) return false; if (category !== 'ALL' && p.category !== category) return false;
if (!term) return true; if (!term) return true;
return p.name.toLowerCase().includes(term) || p.category.toLowerCase().includes(term) || p.id.toLowerCase().includes(term); return p.name.toLowerCase().includes(term) || p.category.toLowerCase().includes(term) || p.id.toLowerCase().includes(term);
}); });
}, [products, search, categoryid]); }, [products, search, category]);
// 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<number, string>();
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<number, string>();
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;
});
};
// ── Integration point ────────────────────────────────────────────────────────── // ── Integration point ──────────────────────────────────────────────────────────
// Replace this body with the real mutation: POST the selected product ids to the // Replace with the real request/stock POST (selected productids + quantities),
// store/location assortment (stock-entry) endpoint, then invalidate stockQ. // then invalidate stockQ.
const commitSelectionToStore = () => { const commitSelectionToStore = () => setNotice(true);
setNotice(true);
};
return ( return (
<div className="space-y-lg animate-in fade-in duration-300 font-sans pb-24"> <div className="space-y-lg animate-in fade-in duration-300 font-sans pb-28">
{/* Header */} {/* Header */}
<div> <div>
<h1 className="font-sans font-bold text-2xl tracking-tight text-[#0f172a]">Inventory &amp; Catalog</h1> <h1 className="font-sans font-bold text-2xl tracking-tight text-[#0f172a]">Product Catalogue</h1>
<p className="text-zinc-500 text-xs mt-1"> <p className="text-zinc-500 text-xs mt-1">
Browse the products approved for your store and choose what to stock at <span className="font-semibold text-[#581c87]">{storeName}</span>. Products your admin published for <span className="font-semibold text-[#581c87]">{storeName}</span> choose what you need and set quantities.
</p> </p>
</div> </div>
{/* Tabs */} {/* Tabs */}
<div className="flex items-center gap-1 bg-zinc-100/80 p-1 rounded-xl border border-zinc-200/60 w-full sm:w-auto sm:inline-flex"> <div className="flex items-center gap-1 bg-zinc-100/80 p-1 rounded-xl border border-zinc-200/60 w-full sm:w-auto sm:inline-flex">
<button <button
onClick={() => setView('catalog')} onClick={() => setView('catalogue')}
className={`flex-1 sm:flex-none flex items-center justify-center gap-1.5 px-4 py-2 rounded-lg text-xs font-bold transition-all ${ className={`flex-1 sm:flex-none flex items-center justify-center gap-1.5 px-4 py-2 rounded-lg text-xs font-bold transition-all ${
view === 'catalog' ? 'bg-white text-[#581c87] shadow-sm' : 'text-zinc-500 hover:text-zinc-800' view === 'catalogue' ? 'bg-white text-[#581c87] shadow-sm' : 'text-zinc-500 hover:text-zinc-800'
}`} }`}
> >
<Boxes size={14} /> Browse Catalog ({products.length}) <Boxes size={14} /> Browse Catalogue ({products.length})
</button> </button>
<button <button
onClick={() => setView('inventory')} onClick={() => setView('inventory')}
@@ -217,170 +177,222 @@ export default function StoreCatalogView({ locationid, storeName = 'your store'
<Search size={15} className="absolute left-3 top-1/2 -translate-y-1/2 text-zinc-400" /> <Search size={15} className="absolute left-3 top-1/2 -translate-y-1/2 text-zinc-400" />
<input <input
type="text" type="text"
placeholder={view === 'catalog' ? 'Search catalog products…' : 'Search your stock…'} placeholder={view === 'catalogue' ? 'Search catalogue products…' : 'Search your stock…'}
value={search} value={search}
onChange={(e) => setSearch(e.target.value)} onChange={(e) => setSearch(e.target.value)}
className="w-full pl-9 pr-9 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-9 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"
/> />
{search && ( {search && (
<button onClick={() => setSearch('')} className="absolute right-3 top-1/2 -translate-y-1/2 text-zinc-400 hover:text-zinc-600"> <button onClick={() => setSearch('')} className="absolute right-3 top-1/2 -translate-y-1/2 text-zinc-400 hover:text-zinc-600"><X size={13} /></button>
<X size={13} />
</button>
)} )}
</div> </div>
{view === 'catalogue' && categories.length > 0 && (
{view === 'catalog' && (
<div className="flex items-center gap-sm flex-wrap"> <div className="flex items-center gap-sm flex-wrap">
<span className="flex items-center gap-1.5 text-[10px] font-bold text-zinc-400 uppercase tracking-widest"> <span className="flex items-center gap-1.5 text-[10px] font-bold text-zinc-400 uppercase tracking-widest"><Layers size={13} className="text-[#581c87]" /> Filter</span>
<Layers size={13} className="text-[#581c87]" /> Filter
</span>
<select <select
value={categoryid} value={category}
onChange={(e) => { setCategoryid(Number(e.target.value)); setSubcategoryid(0); }} onChange={(e) => setCategory(e.target.value)}
className="border border-[#e2e8f0] rounded-lg px-3 py-2 text-xs font-semibold text-zinc-700 bg-[#f8fafc] outline-none focus:ring-1 focus:ring-[#581c87] cursor-pointer" className="border border-[#e2e8f0] rounded-lg px-3 py-2 text-xs font-semibold text-zinc-700 bg-[#f8fafc] outline-none focus:ring-1 focus:ring-[#581c87] cursor-pointer"
> >
<option value={0}>All categories</option> <option value="ALL">All categories</option>
{categories.map((c) => <option key={c.id} value={c.id}>{c.name}</option>)} {categories.map((c) => <option key={c} value={c}>{c}</option>)}
</select> </select>
{categoryid > 0 && subcategories.length > 0 && (
<select
value={subcategoryid}
onChange={(e) => setSubcategoryid(Number(e.target.value))}
className="border border-[#e2e8f0] rounded-lg px-3 py-2 text-xs font-semibold text-zinc-700 bg-[#f8fafc] outline-none focus:ring-1 focus:ring-[#581c87] cursor-pointer"
>
<option value={0}>All subcategories</option>
{subcategories.map((s) => <option key={s.id} value={s.id}>{s.name}</option>)}
</select>
)}
</div> </div>
)} )}
<div className="md:ml-auto text-[11px] font-semibold text-zinc-400"> <div className="md:ml-auto text-[11px] font-semibold text-zinc-400">
{view === 'catalog' ? `${filtered.length} products` : `${inventory.length} stocked`} {view === 'catalogue' ? `${filtered.length} products` : `${inventory.length} stocked`}
</div> </div>
</div> </div>
{/* ── Browse Catalog ── */} {/* ── Browse Catalogue ── */}
{view === 'catalog' && ( {view === 'catalogue' && (
catalogQ.isLoading ? ( products.length === 0 ? (
<CenterState icon={<PackageSearch size={26} />} title="Loading catalog…" /> <CenterState
) : catalogQ.isError ? ( icon={<PackageSearch size={34} />}
<CenterState icon={<AlertTriangle size={26} />} title="Couldn't load the catalog" sub="Check your connection and try again." tone="error" /> 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 ? ( ) : filtered.length === 0 ? (
<CenterState icon={<Boxes size={26} />} title="No products found" sub="Your manager hasn't approved products matching this filter yet." /> <CenterState
icon={<Boxes size={34} />}
title="No products match your search"
sub="Try a different keyword or clear the filters to see the full catalogue."
action={
<button
onClick={() => { setSearch(''); setCategory('ALL'); }}
className="inline-flex items-center gap-1.5 px-4 py-2 rounded-xl text-xs font-bold text-white bg-[#581c87] hover:bg-purple-800 transition shadow-sm cursor-pointer"
>
<X size={13} /> Clear filters
</button>
}
/>
) : ( ) : (
<div className="grid grid-cols-2 md:grid-cols-3 xl:grid-cols-4 gap-gutter"> <div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-md">
{filtered.map((p) => { {filtered.map((p) => {
const stocked = inStore.has(p.id); const stocked = inStore.has(p.id);
const isSelected = selected.has(p.id); const picked = picks[p.id] != null;
return ( return (
<div key={p.id} className="group bg-white border border-[#e2e8f0] rounded-2xl overflow-hidden shadow-sm hover:shadow-md transition-all flex flex-col"> <div key={p.id} className="bg-white/80 backdrop-blur-md border border-[#e2e8f0] rounded-2xl p-md flex flex-col justify-between gap-sm shadow-sm hover:shadow-[0_12px_24px_rgba(99,102,241,0.06)] hover:border-purple-200 hover:-translate-y-0.5 transition-all duration-300 relative group">
<div className="relative h-28 w-full overflow-hidden bg-zinc-50"> <div className="flex gap-md">
<img src={p.image} alt={p.name} className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-500" /> {/* Thumbnail with hover zoom */}
<div className="w-16 h-16 rounded-xl border border-zinc-100 shrink-0 overflow-hidden bg-zinc-50 relative">
<img src={p.image} alt={p.name} referrerPolicy="no-referrer" className="w-full h-full object-cover group-hover:scale-110 transition-transform duration-500" />
{stocked && ( {stocked && (
<span className="absolute top-2 right-2 inline-flex items-center gap-1 px-2 py-0.5 rounded-full bg-emerald-500 text-white text-[9px] font-bold uppercase tracking-wide shadow"> <span className="absolute top-1 right-1 inline-flex items-center justify-center w-4 h-4 rounded-full bg-emerald-500 text-white shadow" title="In your store"><CheckCircle2 size={10} /></span>
<CheckCircle2 size={10} /> In Store
</span>
)} )}
</div> </div>
<div className="p-3 flex-1 flex flex-col"> <div className="flex-1 space-y-1 min-w-0">
<span className="inline-flex items-center gap-1 text-[9px] font-bold uppercase tracking-wider text-[#581c87] mb-1"> <div className="flex items-start justify-between gap-2">
<Tag size={9} /> {p.category} <div className="min-w-0">
<h4 className="font-bold text-[#0f172a] leading-tight text-xs truncate group-hover:text-[#581c87] transition-colors">{p.name}</h4>
<span className="text-[10px] text-zinc-400 font-bold font-mono tracking-tight">{p.sku}</span>
</div>
{/* Category pill badge */}
<span className={`px-1.5 py-0.5 rounded text-[8px] font-extrabold uppercase shrink-0 ${catBadgeClass(p.category)}`}>
{p.category.split(' / ')[0]}
</span> </span>
<p className="font-bold text-xs text-[#0f172a] leading-snug line-clamp-2 min-h-[2rem]">{p.name}</p>
<div className="flex items-center justify-between mt-1.5 mb-3">
<span className="font-mono font-extrabold text-sm text-zinc-800">{p.price > 0 ? `${p.price.toLocaleString('en-IN')}` : '—'}</span>
<span className="text-[9px] text-zinc-400 font-semibold">{p.unit}</span>
</div> </div>
{stocked ? ( <div className="flex justify-between items-center pt-2">
<button disabled className="mt-auto w-full py-2 rounded-xl text-[11px] font-bold bg-emerald-50 text-emerald-600 border border-emerald-100 cursor-default flex items-center justify-center gap-1.5"> <div>
<CheckCircle2 size={13} /> Stocked <span className="text-[8px] text-zinc-400 block uppercase tracking-wider font-extrabold">Price</span>
</button> <span className="font-extrabold text-zinc-700 font-mono text-xs">{p.price > 0 ? `${p.price.toLocaleString('en-IN')}` : '—'}</span>
) : isSelected ? ( </div>
<button onClick={() => toggle(p.id)} className="mt-auto w-full py-2 rounded-xl text-[11px] font-bold bg-[#581c87] text-white hover:bg-purple-800 transition flex items-center justify-center gap-1.5 cursor-pointer"> <div className="text-right">
<Check size={13} /> Selected <span className="text-[8px] text-zinc-400 block uppercase tracking-wider font-extrabold">Admin Stock</span>
</button> <span className="font-black text-emerald-600 font-mono text-xs">{p.adminQty}{p.unit ? ` ${p.unit}` : ''}</span>
</div>
</div>
</div>
</div>
{/* Stocked-status row (mirrors the admin card's status line) */}
<div className="flex justify-between items-center pt-2.5 border-t border-[#f1f5f9] mt-1 select-none">
<span className={`inline-flex items-center gap-1.5 text-[10px] font-bold tracking-tight ${stocked ? 'text-emerald-600' : 'text-zinc-400'}`}>
<span className={`w-1.5 h-1.5 rounded-full ${stocked ? 'bg-emerald-500 animate-pulse' : 'bg-zinc-300'}`} />
{stocked ? 'In Your Store' : 'Not stocked yet'}
</span>
{p.unit && <span className="text-[9px] text-zinc-400 font-semibold">{p.unit}</span>}
</div>
{/* Pick action: quantity stepper when selected, else add button */}
{picked ? (
<div className="flex items-center justify-between gap-2 pt-2.5 border-t border-[#f1f5f9] mt-1">
<span className="inline-flex items-center gap-1 text-[10px] font-bold text-[#581c87]"><Check size={12} /> Selected</span>
<div className="flex items-center gap-1">
<button onClick={() => setPickQty(p.id, picks[p.id] - 1)} className="w-6 h-6 rounded-lg border border-[#e2e8f0] text-zinc-500 hover:bg-zinc-50 font-bold cursor-pointer leading-none flex items-center justify-center"><Minus size={12} /></button>
<span className="w-8 text-center font-mono font-bold text-xs text-[#0f172a]">{picks[p.id]}</span>
<button onClick={() => setPickQty(p.id, picks[p.id] + 1)} className="w-6 h-6 rounded-lg border border-[#e2e8f0] text-zinc-500 hover:bg-zinc-50 font-bold cursor-pointer leading-none flex items-center justify-center"><Plus size={12} /></button>
<button onClick={() => togglePick(p.id)} title="Remove" className="ml-1 w-6 h-6 rounded-lg text-rose-500 hover:bg-rose-50 flex items-center justify-center cursor-pointer"><X size={13} /></button>
</div>
</div>
) : ( ) : (
<button onClick={() => toggle(p.id)} className="mt-auto w-full py-2 rounded-xl text-[11px] font-bold bg-white text-[#581c87] border border-purple-200 hover:bg-purple-50 transition flex items-center justify-center gap-1.5 cursor-pointer"> <button
onClick={() => togglePick(p.id)}
className="w-full flex items-center justify-center gap-1.5 pt-2.5 mt-1 border-t border-[#f1f5f9] text-[11px] font-bold text-[#581c87] hover:text-purple-800 cursor-pointer"
>
<Plus size={13} /> Add to Store <Plus size={13} /> Add to Store
</button> </button>
)} )}
</div> </div>
</div>
); );
})} })}
</div> </div>
) )
)} )}
{/* ── My Store Inventory ── */} {/* ── My Store Inventory ── (card grid — same design as Browse Catalogue) */}
{view === 'inventory' && ( {view === 'inventory' && (
<div className="bg-white border border-[#e2e8f0] rounded-2xl shadow-sm overflow-hidden"> stockQ.isLoading ? (
<div className="overflow-x-auto"> <CenterState icon={<Store size={34} />} title="Loading your stock…" sub="Fetching the latest stock levels for your store." />
<table className="w-full text-xs">
<thead>
<tr className="bg-[#f8fafc] border-b border-[#e2e8f0] text-[10px] uppercase tracking-wider text-zinc-400 font-bold">
<th className="px-4 py-3 text-left">#</th>
<th className="px-4 py-3 text-left">Product</th>
<th className="px-4 py-3 text-left">Category</th>
<th className="px-4 py-3 text-right">In Stock</th>
<th className="px-4 py-3 text-center">Status</th>
</tr>
</thead>
<tbody className="divide-y divide-[#f1f5f9]">
{stockQ.isLoading ? (
<tr><td colSpan={5} className="px-4 py-12 text-center text-zinc-400">Loading your stock</td></tr>
) : !locationid ? ( ) : !locationid ? (
<tr><td colSpan={5} className="px-4 py-12 text-center text-zinc-400">No store linked to your account yet.</td></tr> <CenterState icon={<Store size={34} />} 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 ? ( ) : inventory.length === 0 ? (
<tr><td colSpan={5} className="px-4 py-12 text-center text-zinc-400">No products stocked yet add some from the catalog.</td></tr> <CenterState icon={<PackageSearch size={34} />} title="No products stocked yet" sub="Add products from the catalogue and they'll appear here with live stock levels." />
) : filteredInventory.length === 0 ? (
<CenterState
icon={<Boxes size={34} />}
title="No stock matches your search"
sub="Try a different keyword to find an item in your store."
action={
<button onClick={() => setSearch('')} className="inline-flex items-center gap-1.5 px-4 py-2 rounded-xl text-xs font-bold text-white bg-[#581c87] hover:bg-purple-800 transition shadow-sm cursor-pointer">
<X size={13} /> Clear search
</button>
}
/>
) : ( ) : (
inventory.map((it, i) => ( <div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-md">
<tr key={it.id || i} className="hover:bg-zinc-50/70 transition-colors"> {filteredInventory.map((it, i) => (
<td className="px-4 py-3 font-mono text-zinc-400">{i + 1}</td> <div key={it.id || i} className="bg-white/80 backdrop-blur-md border border-[#e2e8f0] rounded-2xl p-md flex flex-col justify-between gap-sm shadow-sm hover:shadow-[0_12px_24px_rgba(99,102,241,0.06)] hover:border-purple-200 hover:-translate-y-0.5 transition-all duration-300 relative group">
<td className="px-4 py-3 font-bold text-[#0f172a]">{it.name}</td> <div className="flex gap-md">
<td className="px-4 py-3 text-zinc-500">{it.category}</td> {/* Thumbnail with status corner dot */}
<td className="px-4 py-3 text-right font-mono font-bold text-zinc-700">{it.closing.toLocaleString('en-IN')}</td> <div className="w-16 h-16 rounded-xl border border-zinc-100 shrink-0 overflow-hidden bg-zinc-50 relative">
<td className="px-4 py-3 text-center"> <img src={it.image} alt={it.name} referrerPolicy="no-referrer" className="w-full h-full object-cover group-hover:scale-110 transition-transform duration-500" />
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-[9px] font-bold uppercase tracking-wider border" style={{ background: `${it.color}14`, color: it.color, borderColor: `${it.color}40` }}> <span className="absolute top-1 right-1 w-3 h-3 rounded-full border-2 border-white shadow" style={{ background: it.color }} title={it.label} />
</div>
<div className="flex-1 space-y-1 min-w-0">
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<h4 className="font-bold text-[#0f172a] leading-tight text-xs truncate group-hover:text-[#581c87] transition-colors">{it.name}</h4>
<span className="text-[10px] text-zinc-400 font-bold font-mono tracking-tight">{it.sku}</span>
</div>
<span className={`px-1.5 py-0.5 rounded text-[8px] font-extrabold uppercase shrink-0 ${catBadgeClass(it.category)}`}>
{it.category.split(' / ')[0]}
</span>
</div>
<div className="flex justify-between items-center pt-2">
<div>
<span className="text-[8px] text-zinc-400 block uppercase tracking-wider font-extrabold">In Stock</span>
<span className="font-black font-mono text-xs" style={{ color: it.color }}>{it.closing.toLocaleString('en-IN')}</span>
</div>
<div className="text-right">
<span className="text-[8px] text-zinc-400 block uppercase tracking-wider font-extrabold">Status</span>
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-[9px] font-bold uppercase tracking-wider border" style={{ background: `${it.color}14`, color: it.color, borderColor: `${it.color}40` }}>{it.label}</span>
</div>
</div>
</div>
</div>
{/* Status line (mirrors the catalogue card's footer) */}
<div className="flex justify-between items-center pt-2.5 border-t border-[#f1f5f9] mt-1 select-none">
<span className="inline-flex items-center gap-1.5 text-[10px] font-bold tracking-tight" style={{ color: it.color }}>
<span className="w-1.5 h-1.5 rounded-full animate-pulse" style={{ background: it.color }} />
{it.label} {it.label}
</span> </span>
</td> <span className="text-[9px] text-zinc-400 font-semibold">{it.category.split(' / ')[0]}</span>
</tr>
))
)}
</tbody>
</table>
</div> </div>
</div> </div>
))}
</div>
)
)} )}
{/* ── Selection action bar (sticky) ── */} {/* ── Selection action bar ── */}
{view === 'catalog' && selected.size > 0 && ( {view === 'catalogue' && pickCount > 0 && (
<div className="fixed bottom-4 left-1/2 -translate-x-1/2 z-[120] w-[min(640px,calc(100vw-2rem))]"> <div className="fixed bottom-4 left-1/2 -translate-x-1/2 z-[120] w-[min(680px,calc(100vw-2rem))]">
<div className="bg-[#0f172a] text-white rounded-2xl shadow-2xl border border-white/10 px-4 py-3"> <div className="bg-[#0f172a] text-white rounded-2xl shadow-2xl border border-white/10 px-4 py-3">
{notice ? ( {notice ? (
<div className="space-y-2"> <div className="space-y-2">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<span className="text-xs font-bold">{selected.size} product{selected.size > 1 ? 's' : ''} marked for {storeName}</span> <span className="text-xs font-bold">{pickCount} product{pickCount > 1 ? 's' : ''} requested for {storeName}</span>
<button onClick={() => { setSelected(new Set()); setNotice(false); }} className="text-[11px] font-semibold text-purple-200 hover:text-white cursor-pointer">Clear</button> <button onClick={() => { setPicks({}); setNotice(false); }} className="text-[11px] font-semibold text-purple-200 hover:text-white cursor-pointer">Clear</button>
</div> </div>
<AwaitingApi label="Adding products to your store" api="stock-entry API" compact className="bg-white/5 border-white/15 text-purple-100" /> <AwaitingApi label="Submitting your store request" api="stock-request API" compact className="bg-white/5 border-white/15 text-purple-100" />
</div> </div>
) : ( ) : (
<div className="flex items-center justify-between gap-3"> <div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-2 min-w-0"> <div className="flex items-center gap-2 min-w-0">
<span className="w-8 h-8 rounded-full bg-white/10 flex items-center justify-center shrink-0"><Boxes size={15} /></span> <span className="w-8 h-8 rounded-full bg-white/10 flex items-center justify-center shrink-0"><Boxes size={15} /></span>
<div className="min-w-0"> <div className="min-w-0">
<p className="text-xs font-bold truncate">{selected.size} product{selected.size > 1 ? 's' : ''} selected</p> <p className="text-xs font-bold truncate">{pickCount} product{pickCount > 1 ? 's' : ''} · {Object.values(picks).reduce((a: number, b: number) => a + b, 0)} units</p>
<p className="text-[10px] text-purple-200">Ready to stock at {storeName}</p> <p className="text-[10px] text-purple-200">Selected for {storeName}</p>
</div> </div>
</div> </div>
<div className="flex items-center gap-2 shrink-0"> <div className="flex items-center gap-2 shrink-0">
<button onClick={() => setSelected(new Set())} className="px-3 py-2 rounded-xl text-[11px] font-bold text-purple-200 hover:text-white hover:bg-white/10 transition cursor-pointer">Clear</button> <button onClick={() => setPicks({})} className="px-3 py-2 rounded-xl text-[11px] font-bold text-purple-200 hover:text-white hover:bg-white/10 transition cursor-pointer">Clear</button>
<button onClick={commitSelectionToStore} className="px-4 py-2 rounded-xl text-[11px] font-bold bg-white text-[#581c87] hover:bg-purple-50 transition cursor-pointer flex items-center gap-1.5"> <button onClick={commitSelectionToStore} className="px-4 py-2 rounded-xl text-[11px] font-bold bg-white text-[#581c87] hover:bg-purple-50 transition cursor-pointer flex items-center gap-1.5">
<Plus size={13} /> Add to Store <Check size={13} /> Request for Store
</button> </button>
</div> </div>
</div> </div>
@@ -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 ( return (
<div className="bg-white border border-dashed border-[#e2e8f0] rounded-2xl p-12 text-center"> <div className="relative overflow-hidden bg-gradient-to-b from-white to-[#faf9ff] border border-[#eceef2] rounded-3xl px-6 py-16 sm:py-20 text-center shadow-sm">
<div className={`mx-auto mb-3 flex items-center justify-center w-14 h-14 rounded-2xl ${tone === 'error' ? 'bg-rose-50 text-rose-500' : 'bg-zinc-100 text-zinc-400'}`}>{icon}</div> {/* Soft decorative glows */}
<p className="font-bold text-sm text-zinc-700">{title}</p> <div className="pointer-events-none absolute -top-20 -right-20 w-60 h-60 rounded-full bg-purple-200/30 blur-3xl" />
{sub && <p className="text-xs text-zinc-400 mt-1">{sub}</p>} <div className="pointer-events-none absolute -bottom-24 -left-20 w-60 h-60 rounded-full bg-indigo-200/30 blur-3xl" />
<div className="relative flex flex-col items-center">
{/* Icon with halo */}
<div className="relative mb-5">
<span className="absolute inset-0 -m-3 rounded-full bg-purple-300/25 blur-xl" />
<span className="relative flex items-center justify-center w-20 h-20 rounded-3xl bg-gradient-to-br from-[#581c87] to-indigo-500 text-white shadow-lg shadow-purple-500/20 ring-8 ring-white">
{icon}
</span>
</div>
<h3 className="font-bold text-lg text-[#0f172a] tracking-tight">{title}</h3>
{sub && <p className="text-sm text-zinc-500 mt-2 max-w-md leading-relaxed">{sub}</p>}
{action && <div className="mt-6">{action}</div>}
{/* Ghost preview cards — hint at what will appear here */}
<div className="mt-9 flex items-end justify-center gap-3 sm:gap-4 opacity-70 select-none" aria-hidden>
{[0, 1, 2].map((i) => (
<div
key={i}
className={`w-24 sm:w-28 rounded-2xl border border-[#eceef2] bg-white/70 p-3 shadow-sm ${i === 1 ? 'scale-110' : 'opacity-80'}`}
>
<div className="w-full h-10 rounded-lg bg-gradient-to-br from-zinc-100 to-zinc-200/70 mb-2" />
<div className="h-2 w-3/4 rounded-full bg-zinc-200 mb-1.5" />
<div className="h-2 w-1/2 rounded-full bg-zinc-100" />
</div>
))}
</div>
<div className="mt-6 inline-flex items-center gap-1.5 text-[10px] font-bold uppercase tracking-widest text-zinc-400">
<span className="w-1.5 h-1.5 rounded-full bg-emerald-500 animate-pulse" /> Syncs automatically
</div>
</div>
</div> </div>
); );
} }

View File

@@ -30,19 +30,24 @@ import {
CreditCard, CreditCard,
History, History,
Building, Building,
Award Award,
ShoppingBag,
QrCode,
ChevronRight,
AtSign
} from 'lucide-react'; } from 'lucide-react';
import { import {
useFiestaStockStatement, useFiestaStockStatement,
useFiestaTenantCustomers, useFiestaTenantCustomers,
useFiestaCustomerOrders, useFiestaCustomerOrders,
useFiestaMasterCatalog,
useFiestaRiders, useFiestaRiders,
FIESTA_TENANT_ID FIESTA_TENANT_ID
} from '../services/fiestaQueries'; } from '../services/fiestaQueries';
import { str as fstr, num as fnum } from '../services/fiestaApi'; import { str as fstr, num as fnum } from '../services/fiestaApi';
import { mapOrderStatus, shortTime } from '../services/fiestaMappers'; import { mapOrderStatus, shortTime } from '../services/fiestaMappers';
import ragulStoreCover from '../assets/images/store_front_view_1780299351800.png'; import ragulStoreCover from '../assets/images/store_front_view_1780299351800.png';
import OrdersDeliveriesView from './OrdersDeliveriesView';
import StoreQRView from './StoreQRView';
import AwaitingApi from './AwaitingApi'; import AwaitingApi from './AwaitingApi';
interface StoreDetailViewProps { interface StoreDetailViewProps {
@@ -68,6 +73,8 @@ interface StoreDetailViewProps {
* Overview, Inventory & Catalogue, and Customers into separate pages. When * Overview, Inventory & Catalogue, and Customers into separate pages. When
* omitted, the full tabbed console renders (admin store detail). */ * omitted, the full tabbed console renders (admin store detail). */
only?: 'overview' | 'inventory' | 'customers'; only?: 'overview' | 'inventory' | 'customers';
/** Merchant tenant to scope to; defaults to the shared constant. */
tenantId?: number;
} }
// Fallback cover images // 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' '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) { export default function StoreDetailView({ store, onBack, canManage = true, only, tenantId = FIESTA_TENANT_ID }: StoreDetailViewProps) {
const [activeTab, setActiveTab] = useState<'overview' | 'inventory' | 'customers'>('overview'); const [activeTab, setActiveTab] = useState<'overview' | 'inventory' | 'customers' | 'orders' | 'qr'>('overview');
// Which section to show: forced by `only` (separate-page mode) or the active tab. // Which section to show: forced by `only` (separate-page mode) or the active tab.
const section = only ?? activeTab; const section = only ?? activeTab;
// The immersive store banner shows on Overview (and the admin tabbed console); // 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<any[]>([]); const [localInventory, setLocalInventory] = useState<any[]>([]);
const [showImportModal, setShowImportModal] = useState(false); const [showImportModal, setShowImportModal] = useState(false);
const [importState, setImportState] = useState<'idle' | 'reading' | 'parsing' | 'saving' | 'done'>('idle'); const [importState, setImportState] = useState<'idle' | 'reading' | 'parsing' | 'saving' | 'done'>('idle');
const [showGlobalModal, setShowGlobalModal] = useState(false);
const [selectedGlobalSkus, setSelectedGlobalSkus] = useState<string[]>([]);
// ── Customer CRM Profile Drawer state ────────────────────────────────────── // ── Customer CRM Profile Drawer state ──────────────────────────────────────
const [selectedCustomer, setSelectedCustomer] = useState<any | null>(null); const [selectedCustomer, setSelectedCustomer] = useState<any | null>(null);
@@ -142,23 +147,17 @@ export default function StoreDetailView({ store, onBack, canManage = true, only
// ── API Queries with live locationid ─────────────────────────────────────── // ── API Queries with live locationid ───────────────────────────────────────
const locationid = store.locationid || 1097; const locationid = store.locationid || 1097;
const stockQ = useFiestaStockStatement({ const stockQ = useFiestaStockStatement({
tenantid: FIESTA_TENANT_ID, tenantid: tenantId,
locationid, locationid,
pagesize: 100 pagesize: 100
}); });
const customersQ = useFiestaTenantCustomers({ const customersQ = useFiestaTenantCustomers({
tenantid: FIESTA_TENANT_ID, tenantid: tenantId,
locationid, locationid,
pagesize: 100 pagesize: 100
}); });
// Live active rider fleet for this tenant (powers KPI fleet count + fleet list) // Live active rider fleet for this tenant (powers KPI fleet count + fleet list)
const ridersQ = useFiestaRiders({ tenantid: FIESTA_TENANT_ID }); const ridersQ = useFiestaRiders({ tenantid: tenantId });
// Master catalogue rows for the Global Catalogue modal
const masterCatalogQ = useFiestaMasterCatalog({
tenantid: FIESTA_TENANT_ID,
locationid,
pagesize: 100
});
// Past orders for the currently-open CRM drawer customer (disabled when no id) // Past orders for the currently-open CRM drawer customer (disabled when no id)
const customerOrdersQ = useFiestaCustomerOrders({ const customerOrdersQ = useFiestaCustomerOrders({
customerid: selectedCustomer?.id ?? null, 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 // Actions simulation handles
const handleReplenishSubmit = (e: React.FormEvent) => { const handleReplenishSubmit = (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
@@ -328,30 +313,6 @@ export default function StoreDetailView({ store, onBack, canManage = true, only
}, 700); }, 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 = () => { const handleExportLedger = () => {
showToast(`Generating secure PDF ledger audit reports for ${store.name}...`, 'info'); showToast(`Generating secure PDF ledger audit reports for ${store.name}...`, 'info');
setTimeout(() => { setTimeout(() => {
@@ -521,7 +482,7 @@ export default function StoreDetailView({ store, onBack, canManage = true, only
}`} }`}
> >
<Layers size={14} /> <Layers size={14} />
<span>Inventory & Catalogue ({inventoryList.length})</span> <span>Inventory ({inventoryList.length})</span>
{inventoryList.some(item => item.status === 'Critical') && ( {inventoryList.some(item => item.status === 'Critical') && (
<span className="px-1.5 py-0.5 rounded-full bg-rose-500 text-white text-[8px] font-black leading-none animate-pulse">!</span> <span className="px-1.5 py-0.5 rounded-full bg-rose-500 text-white text-[8px] font-black leading-none animate-pulse">!</span>
)} )}
@@ -537,6 +498,28 @@ export default function StoreDetailView({ store, onBack, canManage = true, only
<Users size={14} /> <Users size={14} />
<span>Customer CRM Base ({customersList.length})</span> <span>Customer CRM Base ({customersList.length})</span>
</button> </button>
<button
onClick={() => setActiveTab('orders')}
className={`flex items-center gap-xs px-md pb-sm text-xs font-bold uppercase tracking-wider cursor-pointer border-b-2 transition-all ${
activeTab === 'orders'
? 'border-b-[#581c87] text-[#581c87]'
: 'border-b-transparent text-zinc-400 hover:text-zinc-600'
}`}
>
<ShoppingBag size={14} />
<span>Orders & Deliveries</span>
</button>
<button
onClick={() => setActiveTab('qr')}
className={`flex items-center gap-xs px-md pb-sm text-xs font-bold uppercase tracking-wider cursor-pointer border-b-2 transition-all ${
activeTab === 'qr'
? 'border-b-[#581c87] text-[#581c87]'
: 'border-b-transparent text-zinc-400 hover:text-zinc-600'
}`}
>
<QrCode size={14} />
<span>Store QR</span>
</button>
</div> </div>
)} )}
@@ -730,7 +713,7 @@ export default function StoreDetailView({ store, onBack, canManage = true, only
<Search size={15} className="absolute left-3 top-1/2 -translate-y-1/2 text-zinc-450" /> <Search size={15} className="absolute left-3 top-1/2 -translate-y-1/2 text-zinc-450" />
<input <input
type="text" type="text"
placeholder="Search local stocks catalogue..." placeholder="Search inventory by product or SKU..."
value={stockSearch} value={stockSearch}
onChange={(e) => setStockSearch(e.target.value)} onChange={(e) => 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" 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
<span>Import Manual (CSV)</span> <span>Import Manual (CSV)</span>
</button> </button>
<button
onClick={() => { setSelectedGlobalSkus([]); setShowGlobalModal(true); }}
className="px-3 py-2 bg-[#0f172a] text-white hover:bg-zinc-800 rounded-xl font-bold flex items-center gap-xs cursor-pointer transition shadow-sm"
>
<Globe size={14} />
<span>Global Catalogue Master</span>
</button>
<span className="h-6 w-[1px] bg-zinc-200 mx-xs hidden md:block" /> <span className="h-6 w-[1px] bg-zinc-200 mx-xs hidden md:block" />
</> </>
)} )}
@@ -774,7 +749,7 @@ export default function StoreDetailView({ store, onBack, canManage = true, only
<div className="bg-white border border-[#eceef2] rounded-2xl overflow-hidden shadow-sm"> <div className="bg-white border border-[#eceef2] rounded-2xl overflow-hidden shadow-sm">
<div className="p-md border-b border-[#eceef2] bg-[#f8fafc] flex justify-between items-center"> <div className="p-md border-b border-[#eceef2] bg-[#f8fafc] flex justify-between items-center">
<h4 className="font-sans font-bold text-sm text-[#0f172a]"> <h4 className="font-sans font-bold text-sm text-[#0f172a]">
Product Stock Levels & Catalog Product Stock Levels
</h4> </h4>
<span className="text-[10px] font-bold text-[#581c87] bg-purple-50 px-2 py-0.5 rounded border border-purple-100 uppercase tracking-wide">Live list</span> <span className="text-[10px] font-bold text-[#581c87] bg-purple-50 px-2 py-0.5 rounded border border-purple-100 uppercase tracking-wide">Live list</span>
</div> </div>
@@ -885,111 +860,175 @@ export default function StoreDetailView({ store, onBack, canManage = true, only
</div> </div>
)} )}
{section === 'customers' && ( {section === 'customers' && (() => {
<div className="space-y-lg animate-in fade-in duration-300"> const withPhone = customersList.filter((c: any) => c.phone && c.phone !== '—').length;
const withEmail = customersList.filter((c: any) => c.email).length;
{/* Customer directory search and metrics */} // Jewel-tone identity per customer (deterministic by name) — a calm header
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-md bg-white border border-[#eceef2] p-md rounded-2xl shadow-sm"> // band gradient + a soft solid avatar tint drawn from the same hue.
<div className="relative w-full sm:w-80 sm:shrink-0"> const tones = [
<Search size={15} className="absolute left-3 top-1/2 -translate-y-1/2 text-zinc-400" /> { soft: '#f3effb', fg: '#6d28d9', band: 'linear-gradient(135deg,#6d28d9 0%,#9333ea 100%)' },
<input { soft: '#e9f5f1', fg: '#0f766e', band: 'linear-gradient(135deg,#0f766e 0%,#14b8a6 100%)' },
type="text" { soft: '#fdf0eb', fg: '#c2410c', band: 'linear-gradient(135deg,#c2410c 0%,#f97316 100%)' },
placeholder="Search CRM profile roster..." { soft: '#ebeefb', fg: '#3a4fc4', band: 'linear-gradient(135deg,#3949c4 0%,#6366f1 100%)' },
value={customerSearch} { soft: '#fceef4', fg: '#be185d', band: 'linear-gradient(135deg,#be185d 0%,#ec4899 100%)' },
onChange={(e) => setCustomerSearch(e.target.value)} { soft: '#e9f3fb', fg: '#0369a1', band: 'linear-gradient(135deg,#0369a1 0%,#0ea5e9 100%)' },
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"
/>
</div>
<div className="shrink-0 w-full sm:w-auto sm:min-w-[18rem]">
<AwaitingApi label="Customer analytics" api="[R11]" compact />
</div>
</div>
{/* Customer list directory */}
<div className="bg-white border border-[#eceef2] rounded-2xl overflow-hidden shadow-sm">
<div className="p-md border-b border-[#eceef2] bg-[#f8fafc] flex justify-between items-center">
<h4 className="font-sans font-bold text-sm text-[#0f172a]">
Active Customer Directory
</h4>
<span className="text-[10px] font-bold text-[#581c87] bg-purple-50 px-2 py-0.5 rounded border border-purple-100 uppercase tracking-wide">Customer registry</span>
</div>
<div className="overflow-x-auto text-xs font-sans">
<table className="w-full text-left">
<thead className="bg-[#f8fafc] border-b border-[#e2e8f0] text-zinc-450 text-[10px] uppercase font-bold tracking-wider">
<tr>
<th className="px-md py-sm">Customer Profile</th>
<th className="px-md py-sm">Contact Details</th>
<th className="px-md py-sm">Delivery Address</th>
<th className="px-md py-sm">Total Dispatches</th>
<th className="px-md py-sm">Gross Volume Spent</th>
<th className="px-md py-sm text-right">Audit CRM Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-[#f1f5f9] font-medium text-zinc-700">
{customersList.length === 0 ? (
<tr>
<td colSpan={6} className="text-center py-10 text-zinc-400 font-medium">
No customer accounts found matching search keyword.
</td>
</tr>
) : (
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]; 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 ( return (
<tr key={idx} className="hover:bg-[#f8fafc]/60 transition-colors"> <div className="animate-in fade-in duration-300">
<td className="px-md py-md">
<div className="flex items-center gap-xs"> {/* Page heading */}
<div className={`w-8 h-8 rounded-full bg-gradient-to-br ${avatarGrad} flex items-center justify-center font-black text-[10px] shadow-sm shrink-0`}> <div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 mb-6">
{initials} <div>
<h2 className="text-[22px] font-semibold tracking-tight text-[#0f172a]">Customers</h2>
<p className="text-[13px] text-zinc-500 mt-1">
{customersList.length} {customersList.length === 1 ? 'person orders' : 'people order'} from{' '}
<span className="font-medium text-zinc-700">{store.name}</span>
<span className="text-zinc-300"> · </span>
{withPhone} with phone<span className="text-zinc-300"> · </span>{withEmail} with email
</p>
</div>
<div className="relative w-full sm:w-64 shrink-0">
<Search size={15} className="absolute left-3.5 top-1/2 -translate-y-1/2 text-zinc-400" />
<input
type="text"
placeholder="Search customers"
value={customerSearch}
onChange={(e) => setCustomerSearch(e.target.value)}
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"
/>
</div>
</div>
{/* Profile cards */}
{customersList.length === 0 ? (
<div className="bg-white border border-[#e8e9ee] rounded-3xl py-20 flex flex-col items-center gap-3 text-center px-6">
<span className="flex items-center justify-center w-14 h-14 rounded-full bg-[#f4f0fb] text-[#6d28d9]"><Users size={22} /></span>
<div>
<p className="text-[15px] font-semibold text-[#0f172a]">No customers yet</p>
<p className="text-[13px] text-zinc-500 mt-1 max-w-xs leading-relaxed">
{customerSearch ? 'Nothing matches your search.' : 'Customers will appear here once they place their first order.'}
</p>
</div>
{customerSearch && (
<button onClick={() => setCustomerSearch('')} className="mt-1 text-[13px] font-medium text-[#581c87] hover:underline cursor-pointer">Clear search</button>
)}
</div>
) : (
<div className="bg-white border border-[#e8e9ee] rounded-2xl shadow-[0_1px_3px_rgba(16,24,40,0.04)] overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full text-left border-collapse" style={{ minWidth: 860 }}>
<thead>
<tr className="bg-[#fafbfc] border-b border-[#eceef2] text-[11px] font-semibold uppercase tracking-wider text-zinc-400">
<th className="px-5 py-3.5 font-semibold">Customer</th>
<th className="px-5 py-3.5 font-semibold">Phone</th>
<th className="px-5 py-3.5 font-semibold">Email</th>
<th className="px-5 py-3.5 font-semibold">Delivery address</th>
<th className="px-5 py-3.5 font-semibold text-right">Profile</th>
</tr>
</thead>
<tbody className="divide-y divide-[#f1f2f5]">
{customersList.map((c: any, idx: number) => {
const tone = toneFor(c.name || `c${idx}`);
const locality = localityOf(c.address);
return (
<tr key={c.id ?? idx} className="group hover:bg-[#fbfaff] transition-colors">
{/* Customer */}
<td className="px-5 py-3.5">
<div className="flex items-center gap-3">
<span
className="w-9 h-9 rounded-xl flex items-center justify-center font-bold text-[12px] shrink-0 ring-1 ring-black/[0.04]"
style={{ background: tone.soft, color: tone.fg }}
>
{initialsOf(c.name)}
</span>
<div className="min-w-0">
<p className="font-semibold text-[14px] text-[#0f172a] truncate leading-tight" title={c.name}>{c.name}</p>
{locality && (
<p className="text-[12px] text-zinc-400 mt-0.5 inline-flex items-center gap-1 truncate max-w-[180px]">
<MapPin size={11} className="shrink-0" /> {locality}
</p>
)}
</div> </div>
<span className="font-bold text-[#0f172a]">{c.name}</span>
</div> </div>
</td> </td>
<td className="px-md py-md font-mono text-zinc-500 font-semibold">{c.phone}</td> {/* Phone */}
<td className="px-md py-md max-w-xs truncate text-zinc-500 font-medium" title={c.address}> <td className="px-5 py-3.5">
{c.address} <span className="text-[13px] text-zinc-700 tabular-nums">{c.phone}</span>
</td> </td>
<td className="px-md py-md text-zinc-700 font-bold">{c.ordersCount} orders</td> {/* Email */}
<td className="px-md py-md text-[#581c87] font-black">{c.totalSpent}</td> <td className="px-5 py-3.5">
<td className="px-md py-md text-right space-x-sm shrink-0"> {c.email
? <span className="text-[13px] text-zinc-600 truncate inline-block max-w-[220px] align-middle" title={c.email}>{c.email}</span>
: <span className="text-[13px] text-zinc-300"></span>}
</td>
{/* Address */}
<td className="px-5 py-3.5">
<span className="text-[13px] text-zinc-500 truncate inline-block max-w-[280px] align-middle" title={c.address}>{c.address}</span>
</td>
{/* Action */}
<td className="px-5 py-3.5 text-right whitespace-nowrap">
<div className="inline-flex items-center justify-end gap-2">
{canManage && ( {canManage && (
<button <button
onClick={() => showToast(`Voucher promo code successfully dispatched to ${c.phone}.`, 'success')} onClick={() => showToast(`Promo code sent to ${c.phone}.`, 'success')}
className="px-2.5 py-1 border border-zinc-200 hover:border-purple-300 rounded-lg font-bold text-[10px] text-zinc-650 hover:bg-purple-50/50 hover:text-[#581c87] cursor-pointer transition" title="Send promo SMS"
className="w-8 h-8 inline-flex items-center justify-center rounded-lg border border-[#e6e8ee] text-zinc-500 hover:text-[#581c87] hover:border-[#d6bcf0] hover:bg-[#faf5ff] transition-colors cursor-pointer"
> >
Promo SMS <Send size={14} />
</button> </button>
)} )}
<button <button
onClick={() => setSelectedCustomer(c)} onClick={() => setSelectedCustomer(c)}
className="px-2.5 py-1 bg-[#0f172a] hover:bg-zinc-800 text-white rounded-lg font-bold text-[10px] cursor-pointer transition shadow-sm" className="inline-flex items-center gap-1.5 rounded-lg border border-[#e6e8ee] px-3 py-1.5 text-[12.5px] font-semibold text-zinc-700 hover:bg-[#581c87] hover:border-[#581c87] hover:text-white transition-colors cursor-pointer"
> >
View Profile View <ChevronRight size={14} />
</button> </button>
</div>
</td> </td>
</tr> </tr>
); );
}) })}
)}
</tbody> </tbody>
</table> </table>
</div> </div>
<div className="px-5 py-3 border-t border-[#eceef2] bg-[#fafbfc] text-[12px] text-zinc-400">
Showing {customersList.length} {customersList.length === 1 ? 'customer' : 'customers'}
</div> </div>
</div> </div>
)} )}
</div>
);
})()}
{/* Orders & Deliveries moved out of the store console into their own pages. */} {/* Orders & Deliveries — admin full console only (user store pages use the
dedicated Orders / Deliveries nav items instead). */}
{section === 'orders' && (
<OrdersDeliveriesView searchQuery="" isCoimbatoreView={false} locationid={locationid} tenantId={tenantId} />
)}
{/* Store QR — scannable storefront link for this outlet. */}
{section === 'qr' && (
<div className="w-full">
<StoreQRView
tenantId={tenantId}
locationid={locationid}
storeName={store.name}
storeZone={store.zone}
/>
</div>
)}
{/* ── Replenishment Modal Dialog Overlay ── */} {/* ── Replenishment Modal Dialog Overlay ── */}
{replenishModal.show && replenishModal.item && ( {replenishModal.show && replenishModal.item && (
@@ -1112,7 +1151,7 @@ export default function StoreDetailView({ store, onBack, canManage = true, only
<div> <div>
<p className="font-bold text-[#0f172a] uppercase tracking-wide"> <p className="font-bold text-[#0f172a] uppercase tracking-wide">
{importState === 'reading' && 'Reading uploaded CSV sheets...'} {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...'} {importState === 'saving' && 'Syncing manifest entries with local inventory...'}
</p> </p>
<p className="text-[10px] text-zinc-400 font-semibold mt-1">Kindly keep this window open while processing dispatches.</p> <p className="text-[10px] text-zinc-400 font-semibold mt-1">Kindly keep this window open while processing dispatches.</p>
@@ -1145,94 +1184,6 @@ export default function StoreDetailView({ store, onBack, canManage = true, only
</div> </div>
)} )}
{/* ── Choose from Global Catalogue Modal ── */}
{showGlobalModal && (
<div
className="fixed inset-0 bg-[#0f172a]/40 backdrop-blur-sm z-[200] flex items-center justify-center p-md animate-in fade-in duration-200"
onClick={(e) => { if (e.target === e.currentTarget) setShowGlobalModal(false); }}
>
<div className="bg-white border border-[#e2e8f0] rounded-2xl w-full max-w-[28rem] max-h-[90vh] flex flex-col shadow-2xl overflow-hidden animate-in zoom-in-95 duration-200 text-xs font-sans">
<div className="p-md border-b border-[#e2e8f0] bg-[#f8fafc] flex justify-between items-center shrink-0">
<h4 className="font-bold text-[#0f172a] flex items-center gap-xs">
<Globe size={15} className="text-[#581c87]" />
Select Products from Master Catalogue
</h4>
<button
onClick={() => setShowGlobalModal(false)}
className="p-1 hover:bg-zinc-200 rounded-full text-zinc-400 cursor-pointer transition-colors"
>
<X size={16} />
</button>
</div>
<div className="p-md space-y-md overflow-y-auto flex-1">
<p className="text-zinc-500 leading-relaxed font-medium">
Choose master items from the national database to stock and commission locally at <strong>{store.name}</strong>.
</p>
{globalCatalogueItems.length === 0 ? (
<div className="py-8 text-center text-zinc-400 font-medium">
No catalogue products available yet.
</div>
) : (
<div className="space-y-sm divide-y divide-[#f1f5f9]">
{globalCatalogueItems.map((item) => {
const isChecked = selectedGlobalSkus.includes(item.sku);
return (
<div
key={item.sku}
onClick={() => {
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"
>
<div className="flex items-center gap-sm min-w-0">
<input
type="checkbox"
checked={isChecked}
onChange={() => {}} // handled by row click
className="w-4 h-4 rounded text-[#581c87] border-[#e2e8f0] focus:ring-purple-500"
/>
<img
src={item.image}
alt={item.name}
className="w-9 h-9 object-cover rounded-lg border border-zinc-200 shrink-0"
/>
<div className="min-w-0">
<p className="font-bold text-[#0f172a] truncate">{item.name}</p>
<p className="text-[9px] text-zinc-450 font-bold uppercase tracking-wider">{item.category} · SKU: {item.sku}</p>
</div>
</div>
<span className="font-bold text-zinc-800 shrink-0">{item.price != null ? `${item.price.toLocaleString('en-IN')}` : '—'}</span>
</div>
);
})}
</div>
)}
</div>
<div className="p-md border-t border-[#f1f5f9] flex justify-end gap-sm bg-[#f8fafc] shrink-0">
<button
type="button"
onClick={() => setShowGlobalModal(false)}
className="px-4 py-2 border border-[#e2e8f0] rounded-xl font-bold text-zinc-500 hover:bg-zinc-50 cursor-pointer"
>
Cancel
</button>
<button
type="button"
onClick={handleAddGlobalCatalogue}
className="px-4 py-2 bg-[#581c87] text-white rounded-xl font-bold hover:bg-purple-800 cursor-pointer shadow-sm"
>
Add Selected to Store
</button>
</div>
</div>
</div>
)}
{/* ── Customer CRM Profile Side Drawer Overlay ── */} {/* ── Customer CRM Profile Side Drawer Overlay ── */}
{selectedCustomer && ( {selectedCustomer && (
<div <div

View File

@@ -0,0 +1,146 @@
import React, { useMemo, useRef } from 'react';
import { QRCodeSVG, QRCodeCanvas } from 'qrcode.react';
import { MapPin, ScanLine, AlertTriangle, Download } from 'lucide-react';
import { buildStoreQrPayload } from '../services/fiestaApi';
interface StoreQRViewProps {
/** Merchant tenant the QR is scoped to. */
tenantId: number;
/** Resolved outlet id. When absent/0, the QR can't be scoped to a store. */
locationid?: number;
/** Outlet display name (shown on the printable card). */
storeName: string;
/** Zone label, e.g. "R.S. Puram, Coimbatore". */
storeZone?: string;
/** Full street address, when available. */
storeAddress?: string;
}
const QR_FG = '#000000';
const QR_BG = '#ffffff';
const QR_MARGIN = 4;
const QR_LEVEL = 'Q' as const;
export default function StoreQRView({
tenantId,
locationid,
storeName,
storeZone,
storeAddress,
}: StoreQRViewProps) {
const canvasRef = useRef<HTMLCanvasElement>(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 (
<div className="max-w-xl mx-auto animate-in fade-in duration-300">
<div className="bg-white border border-slate-200/70 rounded-3xl p-10 text-center shadow-[0_10px_40px_rgba(0,0,0,0.06)]">
<div className="mx-auto h-16 w-16 rounded-2xl bg-amber-50 text-amber-600 ring-1 ring-amber-100 flex items-center justify-center mb-6">
<AlertTriangle size={30} />
</div>
<h1 className="text-xl font-bold text-slate-900 tracking-tight mb-2">No store to encode</h1>
<p className="text-sm text-slate-500 leading-relaxed">
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.
</p>
</div>
</div>
);
}
return (
<div className="mx-auto flex flex-col items-center" style={{ width: '100%', maxWidth: '380px', minWidth: '280px' }}>
{/* Premium Table Tent Mockup Card */}
<div className="bg-white rounded-3xl border border-slate-200/70 shadow-[0_20px_50px_rgba(88,28,135,0.08)] overflow-hidden relative group transition-all duration-355 hover:shadow-[0_25px_60px_rgba(88,28,135,0.14)]" style={{ width: '100%' }}>
{/* Ambient Background Glow inside card */}
<div className="absolute top-0 right-0 w-32 h-32 bg-purple-500/5 rounded-full blur-xl -mr-6 -mt-6 pointer-events-none group-hover:bg-purple-500/10 transition-colors" />
{/* Gradient Header banner */}
<div className="relative bg-gradient-to-br from-[#581c87] via-[#6b21a8] to-purple-950 px-6 py-8 text-center text-white overflow-hidden">
<div className="absolute -top-12 -right-10 w-36 h-36 bg-purple-400/25 rounded-full blur-2xl pointer-events-none" />
<span className="relative inline-flex items-center gap-1.5 text-[9px] font-extrabold uppercase tracking-[0.16em] text-purple-100 bg-white/10 border border-white/20 rounded-full px-3.5 py-1.5 backdrop-blur-sm shadow-inner-sm">
<ScanLine size={11} className="text-purple-200 animate-pulse" /> Scan &amp; Shop
</span>
<h3 className="relative font-black text-lg mt-4 leading-snug tracking-tight text-white">{storeName}</h3>
{(storeZone || storeAddress) && (
<p className="relative text-[10px] text-purple-200 mt-2 flex items-center justify-center gap-1 font-medium">
<MapPin size={11} className="shrink-0 text-purple-300" />
<span className="truncate max-w-[200px]">{storeZone || storeAddress}</span>
</p>
)}
</div>
{/* QR Code Frame with Overlap Layout */}
<div className="px-6 pb-8 pt-6 flex flex-col items-center relative z-10">
<div className="-mt-12 p-4 bg-white rounded-2xl border border-slate-100 shadow-[0_12px_36px_rgba(15,23,42,0.12)] hover:scale-[1.02] transition-transform duration-300">
<QRCodeSVG
data-qr-svg="true"
value={payload}
size={180}
level={QR_LEVEL}
marginSize={QR_MARGIN}
fgColor={QR_FG}
bgColor={QR_BG}
title={`${storeName} storefront QR`}
/>
</div>
{/* Counter instructions info block */}
<div className="mt-5 text-center space-y-1">
<p className="text-[11px] font-extrabold text-slate-800 uppercase tracking-wider">Storefront QR Code</p>
<p className="text-[10px] text-slate-454 max-w-[220px] leading-relaxed mx-auto font-medium">
Customers scan this using the consumer app to shop this branch.
</p>
</div>
</div>
</div>
{/* Download Action Button */}
<div className="w-full mt-6">
<button
onClick={downloadPng}
className="w-full flex items-center justify-center gap-2 py-3 px-5 bg-gradient-to-r from-[#581c87] to-indigo-650 hover:from-purple-800 hover:to-indigo-700 text-white rounded-xl text-xs font-bold uppercase tracking-wider cursor-pointer active:scale-95 transition-all shadow-md border-none"
>
<Download size={14} />
Download QR Code
</button>
</div>
{/* Hidden high-res canvas — source for the PNG download */}
<div className="sr-only" aria-hidden="true">
<QRCodeCanvas
ref={canvasRef}
value={payload}
size={1024}
level={QR_LEVEL}
marginSize={QR_MARGIN}
fgColor={QR_FG}
bgColor={QR_BG}
/>
</div>
</div>
);
}

View File

@@ -4,10 +4,10 @@
*/ */
import React, { useState } from 'react'; import React, { useState } from 'react';
import { createPortal } from 'react-dom';
import { import {
AlertTriangle, AlertTriangle,
LayoutDashboard, LayoutDashboard,
User,
Mail, Mail,
Phone, Phone,
Store, Store,
@@ -18,6 +18,7 @@ import {
ClipboardList, ClipboardList,
Layers, Layers,
Users, Users,
X,
} from 'lucide-react'; } from 'lucide-react';
import { import {
useFiestaTenantLocations, useFiestaTenantLocations,
@@ -33,6 +34,7 @@ import OrdersView from './OrdersView';
import DeliveriesView from './DeliveriesView'; import DeliveriesView from './DeliveriesView';
import DispatchView from './DispatchView'; import DispatchView from './DispatchView';
import DeliveryReportsView from './DeliveryReportsView'; import DeliveryReportsView from './DeliveryReportsView';
import StoreQRView from './StoreQRView';
import UserStoreSidebar, { type UserNavItem } from './UserStoreSidebar'; import UserStoreSidebar, { type UserNavItem } from './UserStoreSidebar';
interface UserStorePageProps { interface UserStorePageProps {
@@ -46,13 +48,12 @@ interface UserStorePageProps {
// gets a matching branch in `renderSection` below. // gets a matching branch in `renderSection` below.
const NAV_ITEMS: UserNavItem[] = [ const NAV_ITEMS: UserNavItem[] = [
{ id: 'console', label: 'Store Console', icon: LayoutDashboard }, { 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: 'customers', label: 'Customers', icon: Users },
{ id: 'orders', label: 'Orders', icon: ShoppingBag }, { id: 'orders', label: 'Orders', icon: ShoppingBag },
{ id: 'deliveries', label: 'Deliveries', icon: Truck }, { id: 'deliveries', label: 'Deliveries', icon: Truck },
{ id: 'dispatch', label: 'Dispatch', icon: Route }, { id: 'dispatch', label: 'Dispatch', icon: Route },
{ id: 'reports', label: 'Delivery Reports', icon: ClipboardList }, { id: 'reports', label: 'Reports', icon: ClipboardList },
{ id: 'account', label: 'My Account', icon: User },
]; ];
type StoreShape = React.ComponentProps<typeof StoreDetailView>['store']; type StoreShape = React.ComponentProps<typeof StoreDetailView>['store'];
@@ -66,9 +67,14 @@ type StoreShape = React.ComponentProps<typeof StoreDetailView>['store'];
export default function UserStorePage({ onLogout, user }: UserStorePageProps) { export default function UserStorePage({ onLogout, user }: UserStorePageProps) {
const [sidebarOpen, setSidebarOpen] = useState(true); const [sidebarOpen, setSidebarOpen] = useState(true);
const [activeSection, setActiveSection] = useState<string>('console'); const [activeSection, setActiveSection] = useState<string>('console');
const [showQrModal, setShowQrModal] = useState(false);
const locationsQ = useFiestaTenantLocations(FIESTA_TENANT_ID); // Scope every query to the signed-in merchant's tenant; the shared constant is
const locSummaryQ = useFiestaLocationSummary(FIESTA_TENANT_ID); // 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 locations = locationsQ.data ?? [];
const summaries = locSummaryQ.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 // 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. // loading/error states, so they don't need the store-console load gating below.
if (activeSection === 'orders') return <OrdersView locationid={resolvedLocationId || undefined} />; if (activeSection === 'orders') return <OrdersView locationid={resolvedLocationId || undefined} tenantId={tenantId} />;
if (activeSection === 'deliveries') return <DeliveriesView locationid={resolvedLocationId || undefined} />; if (activeSection === 'deliveries') return <DeliveriesView locationid={resolvedLocationId || undefined} tenantId={tenantId} />;
if (activeSection === 'dispatch') return <DispatchView locationid={resolvedLocationId || undefined} />; if (activeSection === 'dispatch') return <DispatchView locationid={resolvedLocationId || undefined} tenantId={tenantId} />;
if (activeSection === 'reports') return <DeliveryReportsView />; if (activeSection === 'reports') return <DeliveryReportsView tenantId={tenantId} />;
// Inventory & Catalog is its own page: the manager-curated catalog the user // 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 // 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). // gating below — only "My Store Inventory" uses the resolved location id).
if (activeSection === 'inventory') return <StoreCatalogView locationid={resolvedLocationId || undefined} storeName={storeName} />; if (activeSection === 'inventory') return <StoreCatalogView locationid={resolvedLocationId || undefined} storeName={storeName} tenantId={tenantId} />;
// The store console needs a resolved store, so gate it on the load state. // The store console needs a resolved store, so gate it on the load state.
if (locationsQ.isLoading || locSummaryQ.isLoading) { 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 // Overview & Performance; Customers is its own page (Inventory & Catalog is the
// dedicated StoreCatalogView, handled above). // dedicated StoreCatalogView, handled above).
const only = activeSection === 'customers' ? 'customers' : 'overview'; const only = activeSection === 'customers' ? 'customers' : 'overview';
return <StoreDetailView store={buildStore()} canManage={false} only={only} />; return <StoreDetailView store={buildStore()} canManage={false} only={only} tenantId={tenantId} />;
}; };
return ( return (
@@ -269,6 +275,8 @@ export default function UserStorePage({ onLogout, user }: UserStorePageProps) {
onToggleSidebar={() => setSidebarOpen((s) => !s)} onToggleSidebar={() => setSidebarOpen((s) => !s)}
onHelpClick={handleHelp} onHelpClick={handleHelp}
onLogoutClick={onLogout} onLogoutClick={onLogout}
onAccountClick={() => setActiveSection('account')}
onQrClick={() => setShowQrModal(true)}
profile={profile} profile={profile}
/> />
@@ -296,6 +304,40 @@ export default function UserStorePage({ onLogout, user }: UserStorePageProps) {
)} )}
</main> </main>
</div> </div>
{/* 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(
<div
className="fixed inset-0 z-[200] flex items-center justify-center p-4"
style={{ background: 'rgba(15,23,42,0.45)', backdropFilter: 'blur(4px)' }}
onClick={(e) => { if (e.target === e.currentTarget) setShowQrModal(false); }}
>
<div className="relative w-full mx-auto" style={{ maxWidth: 600 }}>
<button
onClick={() => setShowQrModal(false)}
title="Close"
aria-label="Close"
className="absolute top-3 right-3 z-10 p-1.5 rounded-full bg-white/15 hover:bg-white/25 text-white ring-1 ring-white/25 backdrop-blur-sm cursor-pointer transition-colors"
>
<X size={16} />
</button>
<StoreQRView
tenantId={tenantId}
locationid={resolvedLocationId || undefined}
storeName={storeName}
storeZone={
matchedLoc
? [fstr(matchedLoc.suburb), fstr(matchedLoc.city)].filter(Boolean).join(', ') || undefined
: undefined
}
storeAddress={matchedLoc ? fstr(matchedLoc.address) || undefined : undefined}
/>
</div>
</div>,
document.body,
)}
</div> </div>
); );
} }

View File

@@ -29,7 +29,7 @@ interface UserStoreSidebarProps {
export default function UserStoreSidebar({ items, activeId, onSelect, isOpen }: UserStoreSidebarProps) { export default function UserStoreSidebar({ items, activeId, onSelect, isOpen }: UserStoreSidebarProps) {
return ( return (
<aside <aside
className={`fixed left-0 top-0 h-screen bg-[#581c87] border-r border-[#4c1d95] text-white flex-col py-xl pt-24 z-40 hidden md:flex transition-all duration-300 ${ className={`fixed left-0 top-0 h-screen bg-[#581c87] border-r border-[#4c1d95] text-white flex-col py-xl pt-20 z-40 hidden md:flex transition-all duration-300 ${
isOpen ? 'w-64' : 'w-20' isOpen ? 'w-64' : 'w-20'
}`} }`}
> >

View File

@@ -9,7 +9,7 @@
* Self-contained: search box, role filter, live query, and Add User modal. * Self-contained: search box, role filter, live query, and Add User modal.
*/ */
import React, { useState } from 'react'; import React, { useState, useEffect } from 'react';
import { import {
Users, Users,
Search, Search,
@@ -21,15 +21,17 @@ import {
Mail, Mail,
Phone, Phone,
MapPin, MapPin,
Lock,
UserCheck, UserCheck,
Check, Check,
SlidersHorizontal, SlidersHorizontal,
Coins Coins,
Store,
Bike
} from 'lucide-react'; } from 'lucide-react';
import { useFiestaUsers, useFiestaCreateUser } from '../services/fiestaQueries'; import { useFiestaUsers, useFiestaCreateUser, useFiestaRiderShifts, useFiestaTenantLocations } from '../services/fiestaQueries';
import { useAppRoles } from '../services/queries'; import { useAppRoles } from '../services/queries';
import { FIESTA_TENANT_ID, str as fstr, num as fnum, roleName } from '../services/fiestaApi'; import { FIESTA_TENANT_ID, str as fstr, num as fnum, roleName } from '../services/fiestaApi';
import AddressAutocomplete, { type AddressResult } from './AddressAutocomplete';
interface UsersPanelProps { interface UsersPanelProps {
tenantId?: number; tenantId?: number;
@@ -45,7 +47,7 @@ const USER_AVATARS = [
const ROLE_THEMES: Record<number, { bg: string; text: string; border: string; label: string }> = { const ROLE_THEMES: Record<number, { bg: string; text: string; border: string; label: string }> = {
1: { bg: 'bg-rose-50/75', text: 'text-rose-700', border: 'border-rose-100', label: 'Owner' }, 1: { bg: 'bg-rose-50/75', text: 'text-rose-700', border: 'border-rose-100', label: 'Owner' },
2: { bg: 'bg-amber-50/75', text: 'text-amber-700', border: 'border-amber-100', label: 'Manager' }, 2: { bg: 'bg-amber-50/75', text: 'text-amber-700', border: 'border-amber-100', label: 'Admin' },
3: { bg: 'bg-blue-50/75', text: 'text-blue-700', border: 'border-blue-100', label: 'Admin' }, 3: { bg: 'bg-blue-50/75', text: 'text-blue-700', border: 'border-blue-100', label: 'Admin' },
4: { bg: 'bg-emerald-50/75', text: 'text-emerald-700', border: 'border-emerald-100', label: 'Staff' }, 4: { bg: 'bg-emerald-50/75', text: 'text-emerald-700', border: 'border-emerald-100', label: 'Staff' },
6: { bg: 'bg-indigo-50/75', text: 'text-indigo-700', border: 'border-indigo-100', label: 'Cashier' }, 6: { bg: 'bg-indigo-50/75', text: 'text-indigo-700', border: 'border-indigo-100', label: 'Cashier' },
@@ -63,7 +65,7 @@ const ROLE_META: Record<number, { icon: typeof ShieldAlert; desc: string }> = {
/** Fallback role choices when the app-roles API returns nothing. */ /** Fallback role choices when the app-roles API returns nothing. */
const FALLBACK_ROLE_CHOICES = [ const FALLBACK_ROLE_CHOICES = [
{ id: 1, label: 'Owner', desc: 'Full business access', icon: ShieldAlert }, { id: 1, label: 'Owner', desc: 'Full business access', icon: ShieldAlert },
{ id: 2, label: 'Manager', desc: 'Operations control', icon: Shield }, { id: 2, label: 'Admin', desc: 'Operations control', icon: Shield },
{ id: 3, label: 'Admin', desc: 'Manage store settings', icon: SlidersHorizontal }, { id: 3, label: 'Admin', desc: 'Manage store settings', icon: SlidersHorizontal },
{ id: 4, label: 'Staff', desc: 'Standard staff duties', icon: User }, { id: 4, label: 'Staff', desc: 'Standard staff duties', icon: User },
{ id: 6, label: 'Cashier', desc: 'Checkout & registers', icon: Coins }, { id: 6, label: 'Cashier', desc: 'Checkout & registers', icon: Coins },
@@ -76,34 +78,81 @@ export default function UsersPanel({ tenantId = FIESTA_TENANT_ID, defaultNewUser
// Selectable roles for the Add User modal — driven by the live app-roles API, // Selectable roles for the Add User modal — driven by the live app-roles API,
// matched to local icon/desc styling by roleid; falls back to the static list. // matched to local icon/desc styling by roleid; falls back to the static list.
// Selectable roles for the Add User modal - limited to Staff and Rider.
const roleChoices = React.useMemo(() => { const roleChoices = React.useMemo(() => {
const rows = rolesQ.data ?? []; return [
const mapped = rows { id: 4, label: 'Staff', desc: 'Standard store staff duties', icon: User, configid: 15 },
.map((r) => { { id: 5, label: 'Rider', desc: 'Delivery fleet rider', icon: Bike, configid: 6 },
const id = fnum((r as Record<string, unknown>).roleid); ];
const label = }, []);
fstr((r as Record<string, unknown>).rolename) ||
fstr((r as Record<string, unknown>).name) ||
roleName(id);
const meta = ROLE_META[id];
return { id, label, desc: meta?.desc ?? '', icon: meta?.icon ?? User };
})
.filter((r) => r.id > 0);
return mapped.length ? mapped : FALLBACK_ROLE_CHOICES;
}, [rolesQ.data]);
const [search, setSearch] = useState(''); const [search, setSearch] = useState('');
const [userRoleFilter, setUserRoleFilter] = useState<number | 'ALL'>('ALL'); const [userRoleFilter, setUserRoleFilter] = useState<string | 'ALL'>('ALL');
const [showAddUserModal, setShowAddUserModal] = useState(false); const [showAddUserModal, setShowAddUserModal] = useState(false);
const [newUser, setNewUser] = useState({ const [newUser, setNewUser] = useState({
firstname: '', firstname: '',
lastname: '',
email: '', email: '',
contactno: '', contactno: '',
password: '',
roleid: defaultNewUserRole, roleid: defaultNewUserRole,
locationid: 0,
applocationid: 0,
address: '',
suburb: '',
city: '',
state: '',
postcode: '',
latitude: '',
longitude: '',
shiftid: 0,
}); });
// Stores/branches for this merchant — the new user is bound to the store the
// admin picks (its locationid + applocationid go into the create payload).
const locationsQ = useFiestaTenantLocations(tenantId);
const allStores = (locationsQ.data ?? [])
.map((l) => ({
locationid: fnum((l as Record<string, unknown>).locationid),
applocationid: fnum((l as Record<string, unknown>).applocationid) || 1,
name: fstr((l as Record<string, unknown>).locationname) || `Store ${fnum((l as Record<string, unknown>).locationid)}`,
address: fstr((l as Record<string, unknown>).address),
status: fstr((l as Record<string, unknown>).status),
}))
.filter((s) => s.locationid > 0);
// Prefer Active stores (per the create-staff flow); fall back to all if a tenant
// doesn't flag status, so the picker is never empty.
const activeStores = allStores.filter((s) => s.status.toLowerCase() === 'active');
const storeOptions = activeStores.length ? activeStores : allStores;
// Auto-bind when the merchant has exactly one store (nothing to choose).
useEffect(() => {
if (!newUser.locationid && storeOptions.length === 1) {
setNewUser((u) => ({ ...u, locationid: storeOptions[0].locationid, applocationid: storeOptions[0].applocationid }));
}
}, [storeOptions.length, newUser.locationid]);
// Rider-shift picker — shown only when a rider role is selected (parity with the
// merchant_web create form). Shifts come from the live partners/getridershifts.
const selectedRole = roleChoices.find((r) => r.id === newUser.roleid);
const isRiderRole = (selectedRole?.label || '').toLowerCase().includes('rider') || newUser.roleid === 5;
const shiftsQ = useFiestaRiderShifts();
const shiftOptions = (shiftsQ.data ?? [])
.map((s) => ({ id: fnum((s as Record<string, unknown>).shiftid), label: fstr((s as Record<string, unknown>).shiftname) || `Shift ${fnum((s as Record<string, unknown>).shiftid)}` }))
.filter((s) => s.id > 0);
// Address autocomplete → discrete fields (or clear them when the field is emptied).
const handleAddressSelect = (r: AddressResult | null) => {
setNewUser((u) => ({
...u,
address: r?.address ?? '',
suburb: r?.suburb ?? '',
city: r?.city ?? '',
state: r?.state ?? '',
postcode: r?.postcode ?? '',
latitude: r?.latitude ?? '',
longitude: r?.longitude ?? '',
}));
};
// Live users mapped to display rows (rendered directly from the query). // Live users mapped to display rows (rendered directly from the query).
const users = (usersQ.data ?? []).map((u, i) => { const users = (usersQ.data ?? []).map((u, i) => {
const shift = fstr(u.shiftname).trim(); const shift = fstr(u.shiftname).trim();
@@ -117,7 +166,7 @@ export default function UsersPanel({ tenantId = FIESTA_TENANT_ID, defaultNewUser
email: fstr(u.email) || fstr(u.authname) || '—', email: fstr(u.email) || fstr(u.authname) || '—',
contact: fstr(u.contactno) || '—', contact: fstr(u.contactno) || '—',
roleid: Number(u.roleid), roleid: Number(u.roleid),
role: roleName(Number(u.roleid)), role: roleName(Number(u.roleid)) === 'Manager' ? 'Admin' : roleName(Number(u.roleid)),
shift: shift && shift !== '-' ? shift : '—', shift: shift && shift !== '-' ? shift : '—',
location: fstr(u.applocation) || fstr(u.city) || 'Coimbatore', location: fstr(u.applocation) || fstr(u.city) || 'Coimbatore',
status: fstr(u.status) || 'Active', status: fstr(u.status) || 'Active',
@@ -132,30 +181,59 @@ export default function UsersPanel({ tenantId = FIESTA_TENANT_ID, defaultNewUser
u.name.toLowerCase().includes(q) || u.name.toLowerCase().includes(q) ||
u.email.toLowerCase().includes(q) || u.email.toLowerCase().includes(q) ||
u.contact.toLowerCase().includes(q); u.contact.toLowerCase().includes(q);
const matchesRole = userRoleFilter === 'ALL' || u.roleid === userRoleFilter; const matchesRole = userRoleFilter === 'ALL' || u.role === userRoleFilter;
return matchesSearch && matchesRole; return matchesSearch && matchesRole;
}); });
const roleOptions = Array.from(new Set(users.map((u) => u.roleid))); const roleOptions = React.useMemo(() => {
return Array.from(new Set(users.map((u) => u.role)));
}, [users]);
const handleCreateUser = async (e: React.FormEvent) => { const handleCreateUser = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
if (!newUser.firstname || !newUser.email || !newUser.contactno || !newUser.password) { if (!newUser.firstname || !newUser.email || !newUser.contactno) {
alert('Please provide first name, email, contact number, and a password.'); alert('Please provide first name, contact number, and email.');
return;
}
if (!/^\d{10}$/.test(newUser.contactno.trim())) {
alert('Contact number must be exactly 10 digits.');
return;
}
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(newUser.email.trim())) {
alert('Please enter a valid email address.');
return;
}
if (!Number(newUser.roleid)) {
alert('Please select a role.');
return;
}
if (!newUser.locationid) {
alert('Please select the store this user belongs to.');
return; return;
} }
try { try {
await createUserMut.mutateAsync({ await createUserMut.mutateAsync({
firstname: newUser.firstname, firstname: newUser.firstname,
lastname: newUser.lastname,
email: newUser.email, email: newUser.email,
contactno: newUser.contactno, contactno: newUser.contactno,
password: newUser.password,
roleid: Number(newUser.roleid), roleid: Number(newUser.roleid),
configid: selectedRole?.configid ?? 15,
// Store binding — sent exactly as the backend expects so the user is tied
// to the chosen branch (tenantid + locationid + applocationid).
tenantid: tenantId, tenantid: tenantId,
locationid: newUser.locationid,
applocationid: newUser.applocationid || 1,
address: newUser.address,
suburb: newUser.suburb,
city: newUser.city,
state: newUser.state,
postcode: newUser.postcode,
latitude: newUser.latitude,
longitude: newUser.longitude,
shiftid: isRiderRole ? Number(newUser.shiftid) || 0 : 0,
}); });
setShowAddUserModal(false); setShowAddUserModal(false);
setNewUser({ firstname: '', lastname: '', email: '', contactno: '', password: '', roleid: defaultNewUserRole }); setNewUser({ firstname: '', email: '', contactno: '', roleid: defaultNewUserRole, locationid: 0, applocationid: 0, address: '', suburb: '', city: '', state: '', postcode: '', latitude: '', longitude: '', shiftid: 0 });
alert(`Team member "${newUser.firstname}" added successfully.`); alert(`Team member "${newUser.firstname}" added successfully.`);
} catch (err) { } catch (err) {
alert(`Could not add team member: ${err instanceof Error ? err.message : 'Unknown error'}`); alert(`Could not add team member: ${err instanceof Error ? err.message : 'Unknown error'}`);
@@ -199,26 +277,28 @@ export default function UsersPanel({ tenantId = FIESTA_TENANT_ID, defaultNewUser
{/* Search & Filter Utility Bar */} {/* Search & Filter Utility Bar */}
<div className="bg-slate-50/50 border border-slate-200/60 p-4 rounded-2xl flex flex-col md:flex-row gap-4 items-stretch md:items-center justify-between select-none"> <div className="bg-slate-50/50 border border-slate-200/60 p-4 rounded-2xl flex flex-col md:flex-row gap-4 items-stretch md:items-center justify-between select-none">
<div className="relative w-full md:max-w-sm shrink-0"> <div className="relative w-full md:max-w-md shrink-0 group">
<span className="absolute inset-y-0 left-0 pl-3.5 flex items-center pointer-events-none text-slate-450"> <div className="relative flex items-center bg-white border border-slate-200 rounded-xl transition-all duration-300 shadow-sm focus-within:ring-4 focus-within:ring-purple-150 focus-within:border-purple-600 hover:border-slate-300">
<span className="pl-4 flex items-center pointer-events-none text-slate-400 group-focus-within:text-purple-600 transition-colors">
<Search className="w-4.5 h-4.5" /> <Search className="w-4.5 h-4.5" />
</span> </span>
<input <input
type="text" type="text"
placeholder="Search team" placeholder="Search team members by name, email, phone..."
value={search} value={search}
onChange={(e) => setSearch(e.target.value)} onChange={(e) => setSearch(e.target.value)}
className="w-full pl-10 pr-9 py-2.5 bg-white border border-slate-200/80 rounded-xl text-sm font-medium text-slate-800 placeholder-slate-405 focus:outline-none focus:ring-4 focus:ring-purple-500/10 focus:border-purple-500 transition-all shadow-sm" className="w-full pl-3 pr-10 py-3 bg-transparent border-none text-xs font-semibold text-slate-800 placeholder-slate-400 focus:outline-none outline-none"
/> />
{search && ( {search && (
<button <button
onClick={() => setSearch('')} onClick={() => setSearch('')}
className="absolute inset-y-0 right-0 pr-3 flex items-center text-slate-450 hover:text-slate-700" className="absolute right-3 top-1/2 -translate-y-1/2 text-slate-400 hover:text-slate-600 active:scale-95 transition-all p-1 hover:bg-slate-100 rounded-lg"
> >
<X className="w-4 h-4" /> <X className="w-3.5 h-3.5" />
</button> </button>
)} )}
</div> </div>
</div>
{/* Role filter capsules */} {/* Role filter capsules */}
<div className="flex items-center gap-2 overflow-x-auto pb-1 md:pb-0 scrollbar-none"> <div className="flex items-center gap-2 overflow-x-auto pb-1 md:pb-0 scrollbar-none">
@@ -232,17 +312,17 @@ export default function UsersPanel({ tenantId = FIESTA_TENANT_ID, defaultNewUser
> >
All Roles All Roles
</button> </button>
{roleOptions.map((rid) => ( {roleOptions.map((roleNameStr) => (
<button <button
key={rid} key={roleNameStr}
onClick={() => setUserRoleFilter(rid)} onClick={() => setUserRoleFilter(roleNameStr)}
className={`px-3.5 py-2 rounded-xl text-xs uppercase tracking-wider font-extrabold transition-all duration-200 cursor-pointer border whitespace-nowrap ${ className={`px-3.5 py-2 rounded-xl text-xs uppercase tracking-wider font-extrabold transition-all duration-200 cursor-pointer border whitespace-nowrap ${
userRoleFilter === rid userRoleFilter === roleNameStr
? 'bg-purple-600 text-white border-purple-600 shadow-sm' ? 'bg-purple-600 text-white border-purple-600 shadow-sm'
: 'bg-white text-slate-600 border-slate-200/80 hover:text-slate-800 hover:bg-slate-100' : 'bg-white text-slate-600 border-slate-200/80 hover:text-slate-800 hover:bg-slate-100'
}`} }`}
> >
{roleName(rid)} {roleNameStr}
</button> </button>
))} ))}
</div> </div>
@@ -352,10 +432,9 @@ export default function UsersPanel({ tenantId = FIESTA_TENANT_ID, defaultNewUser
</p> </p>
<div className="space-y-4"> <div className="space-y-4">
{/* Name Fields */} {/* Firstname */}
<div className="grid grid-cols-2 gap-sm">
<div className="space-y-1.5"> <div className="space-y-1.5">
<label className="font-bold text-slate-500 uppercase tracking-widest text-[10px]">FIRST NAME (*)</label> <label className="font-bold text-slate-500 uppercase tracking-widest text-[10px]">FIRSTNAME (*)</label>
<input <input
type="text" type="text"
placeholder="e.g. Harini" placeholder="e.g. Harini"
@@ -365,21 +444,30 @@ export default function UsersPanel({ tenantId = FIESTA_TENANT_ID, defaultNewUser
required required
/> />
</div> </div>
{/* Contactno */}
<div className="space-y-1.5"> <div className="space-y-1.5">
<label className="font-bold text-slate-500 uppercase tracking-widest text-[10px]">LAST NAME</label> <label className="font-bold text-slate-500 uppercase tracking-widest text-[10px]">CONTACTNO (*)</label>
<div className="relative">
<span className="absolute inset-y-0 left-0 pl-3.5 flex items-center pointer-events-none text-slate-400">
<Phone size={14} />
</span>
<input <input
type="text" type="text"
placeholder="e.g. Rajan" inputMode="numeric"
value={newUser.lastname} maxLength={10}
onChange={(e) => setNewUser({ ...newUser, lastname: e.target.value })} placeholder="e.g. 9988776655"
className="w-full border border-slate-200 rounded-xl p-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" value={newUser.contactno}
onChange={(e) => setNewUser({ ...newUser, contactno: e.target.value.replace(/\D/g, '') })}
className="w-full border border-slate-200 rounded-xl pl-10 pr-4 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"
required
/> />
</div> </div>
</div> </div>
{/* Email & Contact */} {/* Email */}
<div className="space-y-1.5"> <div className="space-y-1.5">
<label className="font-bold text-slate-500 uppercase tracking-widest text-[10px]">EMAIL ADDRESS (*)</label> <label className="font-bold text-slate-500 uppercase tracking-widest text-[10px]">EMAIL (*)</label>
<div className="relative"> <div className="relative">
<span className="absolute inset-y-0 left-0 pl-3.5 flex items-center pointer-events-none text-slate-400"> <span className="absolute inset-y-0 left-0 pl-3.5 flex items-center pointer-events-none text-slate-400">
<Mail size={14} /> <Mail size={14} />
@@ -395,23 +483,6 @@ export default function UsersPanel({ tenantId = FIESTA_TENANT_ID, defaultNewUser
</div> </div>
</div> </div>
<div className="space-y-1.5">
<label className="font-bold text-slate-500 uppercase tracking-widest text-[10px]">CONTACT NUMBER (*)</label>
<div className="relative">
<span className="absolute inset-y-0 left-0 pl-3.5 flex items-center pointer-events-none text-slate-400">
<Phone size={14} />
</span>
<input
type="text"
placeholder="e.g. 9988776655"
value={newUser.contactno}
onChange={(e) => setNewUser({ ...newUser, contactno: e.target.value })}
className="w-full border border-slate-200 rounded-xl pl-10 pr-4 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"
required
/>
</div>
</div>
{/* Interactive Role Buttons instead of standard select */} {/* Interactive Role Buttons instead of standard select */}
<div className="space-y-2"> <div className="space-y-2">
<label className="font-bold text-slate-500 uppercase tracking-widest text-[10px]">SELECT ACCOUNT ROLE (*)</label> <label className="font-bold text-slate-500 uppercase tracking-widest text-[10px]">SELECT ACCOUNT ROLE (*)</label>
@@ -441,23 +512,70 @@ export default function UsersPanel({ tenantId = FIESTA_TENANT_ID, defaultNewUser
</div> </div>
</div> </div>
{/* Temporary Password */} {/* Store / branch — binds the user to a specific outlet (tenantid + locationid + applocationid) */}
<div className="space-y-1.5"> <div className="space-y-1.5">
<label className="font-bold text-slate-500 uppercase tracking-widest text-[10px]">TEMPORARY PASSWORD (*)</label> <label className="font-bold text-slate-500 uppercase tracking-widest text-[10px]">STORE / BRANCH (*)</label>
<div className="relative"> <div className="relative">
<span className="absolute inset-y-0 left-0 pl-3.5 flex items-center pointer-events-none text-slate-400"> <span className="absolute inset-y-0 left-0 pl-3.5 flex items-center pointer-events-none text-slate-400">
<Lock size={14} /> <Store size={14} />
</span> </span>
<select
value={newUser.locationid}
onChange={(e) => {
const lid = Number(e.target.value);
const s = storeOptions.find((o) => o.locationid === lid);
setNewUser({ ...newUser, locationid: lid, applocationid: s?.applocationid ?? 0 });
}}
className="w-full border border-slate-200 rounded-xl pl-10 pr-4 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 cursor-pointer"
>
<option value={0}>{locationsQ.isLoading ? 'Loading stores…' : storeOptions.length ? 'Select a store…' : 'No stores found'}</option>
{storeOptions.map((s) => (
<option key={s.locationid} value={s.locationid}>{s.name}{s.address ? `${s.address}` : ''}</option>
))}
</select>
</div>
</div>
{/* Rider shift — only when a rider role is selected */}
{isRiderRole && (
<div className="space-y-1.5 animate-in slide-in-from-top duration-250">
<label className="font-bold text-slate-500 uppercase tracking-widest text-[10px]">RIDER SHIFT</label>
<div className="relative">
<span className="absolute inset-y-0 left-0 pl-3.5 flex items-center pointer-events-none text-slate-400">
<Clock size={14} />
</span>
<select
value={newUser.shiftid}
onChange={(e) => setNewUser({ ...newUser, shiftid: Number(e.target.value) })}
className="w-full border border-slate-200 rounded-xl pl-10 pr-4 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 cursor-pointer"
>
<option value={0}>{shiftsQ.isLoading ? 'Loading shifts…' : shiftOptions.length ? 'Select rider shift…' : 'No shifts available'}</option>
{shiftOptions.map((s) => <option key={s.id} value={s.id}>{s.label}</option>)}
</select>
</div>
</div>
)}
{/* Address — keyless autocomplete that fills suburb / city / state / postcode */}
<div className="space-y-1.5">
<label className="font-bold text-slate-500 uppercase tracking-widest text-[10px]">ADDRESS</label>
<AddressAutocomplete value={newUser.address} onSelect={handleAddressSelect} placeholder="Search address…" />
</div>
<div className="grid grid-cols-2 gap-sm">
{(['suburb', 'city', 'state', 'postcode'] as const).map((key) => (
<div className="space-y-1.5" key={key}>
<label className="font-bold text-slate-500 uppercase tracking-widest text-[10px]">{key.toUpperCase()}</label>
<input <input
type="text" type="text"
placeholder="Set password credentials" value={newUser[key]}
value={newUser.password} onChange={(e) => setNewUser({ ...newUser, [key]: e.target.value })}
onChange={(e) => setNewUser({ ...newUser, password: e.target.value })} className="w-full border border-slate-200 rounded-xl p-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"
className="w-full border border-slate-200 rounded-xl pl-10 pr-4 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 font-mono text-sm shadow-sm"
required
/> />
</div> </div>
))}
</div> </div>
</div> </div>
</div> </div>

View File

@@ -245,7 +245,7 @@ export function StampCell({ date, time }: { date?: string; time?: string }) {
} }
// ── Search pill ────────────────────────────────────────────────────────────────── // ── Search pill ──────────────────────────────────────────────────────────────────
export function SearchPill({ value, onChange, placeholder, color = BRAND }: { value: string; onChange: (v: string) => void; placeholder?: string; color?: string }) { export function SearchPill({ value, onChange, placeholder, color = BRAND, inputRef }: { value: string; onChange: (v: string) => void; placeholder?: string; color?: string; inputRef?: React.Ref<HTMLInputElement> }) {
return ( return (
<div className="relative w-full"> <div className="relative w-full">
<svg className="absolute left-3 top-1/2 -translate-y-1/2" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke={color} strokeWidth="2.5" strokeLinecap="round"> <svg className="absolute left-3 top-1/2 -translate-y-1/2" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke={color} strokeWidth="2.5" strokeLinecap="round">
@@ -253,6 +253,7 @@ export function SearchPill({ value, onChange, placeholder, color = BRAND }: { va
<path d="m21 21-4.3-4.3" /> <path d="m21 21-4.3-4.3" />
</svg> </svg>
<input <input
ref={inputRef}
type="text" type="text"
value={value} value={value}
onChange={(e) => onChange(e.target.value)} onChange={(e) => onChange(e.target.value)}

View File

@@ -4,14 +4,30 @@ import {QueryClient, QueryClientProvider} from '@tanstack/react-query';
import App from './App.tsx'; import App from './App.tsx';
import './index.css'; import './index.css';
// Single shared query client. Sensible defaults for a dashboard: cache for a // How often every page silently re-syncs with the backend. Orders/deliveries
// minute, one retry, and no refetch storm when the window regains focus. // statuses change out-of-band (riders accept/pick/deliver, customers place
// orders), so the whole console auto-refreshes on this cadence.
const AUTO_REFRESH_MS = 30_000;
// Single shared query client. Auto-refresh is wired here once so EVERY page
// (current and future) inherits it — no per-component polling needed:
// • refetchInterval — poll the backend every AUTO_REFRESH_MS so status/order
// changes appear without a manual reload.
// • refetchIntervalInBackground:false — pause polling while the tab is hidden
// (saves API calls); it resumes + immediately refetches when the tab is shown.
// • refetchOnWindowFocus / refetchOnReconnect — refresh the instant the user
// returns to the tab or the network comes back.
// staleTime is kept below the interval so focus/mount refetches aren't skipped.
// Disabled queries (enabled:false, e.g. closed-modal detail fetches) never poll.
const queryClient = new QueryClient({ const queryClient = new QueryClient({
defaultOptions: { defaultOptions: {
queries: { queries: {
staleTime: 60_000, staleTime: 15_000,
retry: 1, retry: 1,
refetchOnWindowFocus: false, refetchInterval: AUTO_REFRESH_MS,
refetchIntervalInBackground: false,
refetchOnWindowFocus: true,
refetchOnReconnect: true,
}, },
}, },
}); });

View File

@@ -16,6 +16,8 @@
* `./queries`, which add caching, dedup, and loading/error state. * `./queries`, which add caching, dedup, and loading/error state.
*/ */
import { cleanTenantLocations } from './fiestaApi';
const HASURA_BASE = '/hasura'; const HASURA_BASE = '/hasura';
/** Tenant whose live data the dashboard displays. */ /** Tenant whose live data the dashboard displays. */
@@ -185,9 +187,14 @@ export async function getTenantInfo(tenantid: number): Promise<Row | null> {
return firstRow(await hasuraGet('gettenantinfo', { tenantid })); return firstRow(await hasuraGet('gettenantinfo', { tenantid }));
} }
/** /gettenantlocations?tenantid= — physical locations linked to a tenant. */ /**
* /gettenantlocations?tenantid= — physical locations linked to a tenant.
* Piped through the shared cleaner (dedupe + strip test rows) so it matches the
* Fiesta source. The Hasura and Fiesta tenant-location tables share the same DB,
* so both return the same duplicate/junk rows without this.
*/
export async function getTenantLocations(tenantid: number): Promise<Row[]> { export async function getTenantLocations(tenantid: number): Promise<Row[]> {
return toRows(await hasuraGet('gettenantlocations', { tenantid })); return cleanTenantLocations(toRows(await hasuraGet('gettenantlocations', { tenantid })));
} }
/** /getcustomersbytenant?tenantid=&limit=&offset= — customers for a tenant. */ /** /getcustomersbytenant?tenantid=&limit=&offset= — customers for a tenant. */

View File

@@ -22,14 +22,14 @@ import { firstRow, num, str, type Row } from './fiestaApi';
// ── Backend login config ────────────────────────────────────────────────────── // ── Backend login config ──────────────────────────────────────────────────────
/** /**
* Fiesta application login. Routed through the Vite `/fiesta` proxy → * Fiesta application login — called directly at
* https://fiesta.nearle.app/live/api/v1/web/users/applogin. * https://fiesta.nearle.app/live/api/v1/web/users/applogin (CORS-enabled).
* Observed shape: * Observed shape:
* request: { authname: <email>, password: <password>, configid: 1, userfcmtoken: null } * request: { authname: <email>, password: <password>, configid: 1, userfcmtoken: null }
* failure: { code: 409, message: "Invalid Email", status: false } * failure: { code: 409, message: "Invalid Email", status: false }
* success: status !== false (Fiesta envelope, optionally with `details`) * success: status !== false (Fiesta envelope, optionally with `details`)
*/ */
const LOGIN_ENDPOINT = '/fiesta/live/api/v1/web/users/applogin'; const LOGIN_ENDPOINT = 'https://fiesta.nearle.app/live/api/v1/web/users/applogin';
/** Request body field names the endpoint expects for the credentials. */ /** Request body field names the endpoint expects for the credentials. */
const REQUEST_FIELDS = { const REQUEST_FIELDS = {
@@ -54,6 +54,10 @@ const RESPONSE_FIELDS = {
email: 'email', email: 'email',
contactno: 'contactno', contactno: 'contactno',
userid: 'userid', userid: 'userid',
// Tenant binding: the merchant this user belongs to. Drives every Fiesta query
// scope (locations, summaries, orders, stock) so a user only sees their own
// tenant's data — not the shared sandbox tenant the constant defaults to.
tenantid: 'tenantid',
// Store binding: a non-admin user is allocated to an app-location via // Store binding: a non-admin user is allocated to an app-location via
// applocationid; `applocation` is its human-readable name (e.g. "Coimbatore"). // applocationid; `applocation` is its human-readable name (e.g. "Coimbatore").
// locationid/locationname are captured when present (often 0/absent on the // locationid/locationname are captured when present (often 0/absent on the
@@ -83,6 +87,8 @@ export interface AuthUser {
roleid?: number; roleid?: number;
/** Phone number on the user record. */ /** Phone number on the user record. */
contactno?: string; contactno?: string;
/** The merchant/tenant this user belongs to — scopes every Fiesta query. */
tenantid?: number;
/** The app-location this user is allocated to. */ /** The app-location this user is allocated to. */
applocationid?: number; applocationid?: number;
/** App-location / zone name on the user record (e.g. "Coimbatore"). */ /** App-location / zone name on the user record (e.g. "Coimbatore"). */
@@ -224,6 +230,7 @@ export function buildAuthUser(row: Row | null, email: string): AuthUser {
userid: row && row[RESPONSE_FIELDS.userid] != null ? num(row[RESPONSE_FIELDS.userid]) : undefined, userid: row && row[RESPONSE_FIELDS.userid] != null ? num(row[RESPONSE_FIELDS.userid]) : undefined,
roleid, roleid,
contactno: contactno || undefined, contactno: contactno || undefined,
tenantid: row && row[RESPONSE_FIELDS.tenantid] != null ? num(row[RESPONSE_FIELDS.tenantid]) : undefined,
applocationid: applocationid:
row && row[RESPONSE_FIELDS.applocationid] != null ? num(row[RESPONSE_FIELDS.applocationid]) : undefined, row && row[RESPONSE_FIELDS.applocationid] != null ? num(row[RESPONSE_FIELDS.applocationid]) : undefined,
applocation: applocation || undefined, applocation: applocation || undefined,

View File

@@ -1,124 +0,0 @@
/**
* @license
* SPDX-License-Identifier: Apache-2.0
*/
/**
* Sample dispatch data for the Dispatch page.
*
* The live Fiesta deliveries feed (getdeliveries) can come back empty for a given
* day/tenant, which leaves the Dispatch cockpit blank. This module provides a
* realistic sample set — shaped exactly like the rows `DispatchView` consumes —
* so the page demonstrates the rider/zone grouping, waves, and planned-route view.
*
* It is a DEV/DEMO fallback only: DispatchView uses it when the live query returns
* no rows, and clearly labels the header "Sample data" so it never masquerades as
* live. Delete this file (and the fallback in DispatchView) once the live feed is
* reliably populated.
*/
import type { Row } from './fiestaApi';
/** Approx zone centroids in Coimbatore — drop pins are jittered around these. */
const ZONE_COORDS: Record<string, [number, number]> = {
'RS Puram': [11.0069, 76.9498],
'Gandhipuram': [11.0183, 76.9725],
'Peelamedu': [11.0259, 77.001],
'Saibaba Colony': [11.0233, 76.943],
'Singanallur': [10.998, 77.029],
};
const HUB: [number, number] = [11.0045, 76.955]; // Ragul Stores hub (pickup)
/** Build one delivery row in the Fiesta shape DispatchView reads. */
function mk(
deliveryid: number,
userid: number,
ridername: string,
zone: string,
status: string,
assign: string,
kms: number,
profit: number,
step: number,
trip: number,
customer: string,
address: string,
expected: string,
delivered: string,
charge: number,
): Row {
const [zlat, zlon] = ZONE_COORDS[zone] ?? [11.0168, 76.9558];
const droplat = zlat + ((deliveryid % 7) - 3) * 0.0016;
const droplon = zlon + ((deliveryid % 5) - 2) * 0.0018;
return {
orderid: `RS-${deliveryid}`,
deliveryid,
orderheaderid: deliveryid,
tenantname: 'Ragul Stores',
orderstatus: status,
userid,
ridername,
username: ridername,
deliverysuburb: zone,
zone_name: zone,
deliverycustomer: customer,
deliverycontactno: '+91 98430 0' + String(1000 + deliveryid).slice(-4),
deliveryaddress: address,
pickupcustomer: 'Ragul Stores Hub',
kms,
cumulativekms: delivered ? kms : 0,
profit,
step,
trip_number: trip,
assigntime: `2026-06-09 ${assign}:00`,
expecteddeliverytime: expected ? `2026-06-09 ${expected}:00` : '',
deliverytime: delivered ? `2026-06-09 ${delivered}:00` : '',
deliverycharge: charge,
deliveryamt: charge,
droplat,
droplon,
deliverylat: droplat,
deliverylong: droplon,
pickuplat: HUB[0],
pickuplong: HUB[1],
locationid: 1097,
};
}
/** ~16 deliveries across 4 riders + 1 unassigned, 5 zones, and all 3 waves. */
export const MOCK_DELIVERIES: Row[] = [
// ── Suresh Kumar · RS Puram · Morning (trip 1) ──
mk(5001, 101, 'Suresh Kumar', 'RS Puram', 'delivered', '06:40', 2.4, 38, 1, 1, 'Arun Prasad', '12, Lawley Rd, RS Puram', '07:00', '06:58', 35),
mk(5002, 101, 'Suresh Kumar', 'RS Puram', 'delivered', '06:45', 3.1, 42, 2, 1, 'Deepa Lakshmi', '45, DB Rd, RS Puram', '07:20', '07:25', 40),
mk(5003, 101, 'Suresh Kumar', 'RS Puram', 'picked', '06:45', 1.8, 30, 3, 1, 'Mohammed Irfan', '8, Bashyakarlu Rd, RS Puram', '07:40', '', 30),
// ── Vignesh R · Gandhipuram / Peelamedu · Afternoon (trip 1) ──
mk(5004, 102, 'Vignesh R', 'Gandhipuram', 'delivered', '09:30', 2.0, 35, 1, 1, 'Sangeetha R', '23, 100 Feet Rd, Gandhipuram', '10:00', '09:58', 35),
mk(5005, 102, 'Vignesh R', 'Gandhipuram', 'delivered', '09:30', 2.6, 40, 2, 1, 'Bala Subramani', '5, Cross Cut Rd, Gandhipuram', '10:25', '10:30', 38),
mk(5006, 102, 'Vignesh R', 'Peelamedu', 'active', '09:35', 4.2, 55, 3, 1, 'Nithya K', '78, Sathy Rd, Peelamedu', '10:55', '', 50),
mk(5007, 102, 'Vignesh R', 'Peelamedu', 'accepted', '09:35', 3.3, 45, 4, 1, 'Ramesh Babu', '16, Avinashi Rd, Peelamedu', '11:20', '', 42),
// ── Karthik M · Saibaba Colony · Afternoon (trips 1 & 2) ──
mk(5008, 103, 'Karthik M', 'Saibaba Colony', 'delivered', '10:40', 2.9, 41, 1, 1, 'Kavya S', '34, NSR Rd, Saibaba Colony', '11:10', '11:05', 40),
mk(5009, 103, 'Karthik M', 'Saibaba Colony', 'arrived', '10:40', 3.6, 48, 2, 1, 'Vijay Anand', '9, Mettupalayam Rd, Saibaba Colony', '11:35', '', 45),
mk(5010, 103, 'Karthik M', 'Saibaba Colony', 'pending', '10:45', 2.2, 33, 3, 1, 'Meena G', '61, Thadagam Rd, Saibaba Colony', '12:00', '', 32),
mk(5011, 103, 'Karthik M', 'Saibaba Colony', 'cancelled', '11:50', 5.1, 0, 1, 2, 'Hariharan', '2, Ganapathy, Saibaba Colony', '12:20', '', 0),
// ── Priya S · Singanallur / Peelamedu · Evening (trip 1) ──
mk(5012, 104, 'Priya S', 'Singanallur', 'delivered', '16:20', 3.0, 44, 1, 1, 'Divya R', '19, Trichy Rd, Singanallur', '16:50', '16:47', 42),
mk(5013, 104, 'Priya S', 'Singanallur', 'active', '16:20', 4.5, 58, 2, 1, 'Senthil Kumar', '88, Singanallur Main Rd', '17:15', '', 55),
mk(5014, 104, 'Priya S', 'Singanallur', 'picked', '16:25', 2.7, 39, 3, 1, 'Anitha M', '7, Ondipudur, Singanallur', '17:40', '', 38),
mk(5015, 104, 'Priya S', 'Peelamedu', 'accepted', '16:25', 3.9, 50, 4, 1, 'Gokul Raj', '52, Hope College, Peelamedu', '18:05', '', 48),
// ── Unassigned · Peelamedu · Evening ──
mk(5016, 0, '', 'Peelamedu', 'pending', '17:25', 2.3, 34, 1, 1, 'Lakshmi Narayanan', '30, Lakshmi Mills, Peelamedu', '18:30', '', 33),
];
/** Sample active rider fleet (DispatchView only reads the count). */
export const MOCK_RIDERS: Row[] = [
{ userid: 101, firstname: 'Suresh', lastname: 'Kumar', contactno: '+91 98430 01101', starttime: '2026-06-09 06:30:00' },
{ userid: 102, firstname: 'Vignesh', lastname: 'R', contactno: '+91 98430 01102', starttime: '2026-06-09 09:15:00' },
{ userid: 103, firstname: 'Karthik', lastname: 'M', contactno: '+91 98430 01103', starttime: '2026-06-09 10:20:00' },
{ userid: 104, firstname: 'Priya', lastname: 'S', contactno: '+91 98430 01104', starttime: '2026-06-09 16:00:00' },
{ userid: 105, firstname: 'Mahesh', lastname: 'V', contactno: '+91 98430 01105', starttime: '' },
];

View File

@@ -9,16 +9,16 @@
* REST tab). This is the operational backend: order/delivery/location summaries, * REST tab). This is the operational backend: order/delivery/location summaries,
* the deliveries board, riders, stock statements, and customers. * the deliveries board, riders, stock statements, and customers.
* *
* Requests go through the Vite dev proxy at `/fiesta/*`, which forwards to * Requests go directly to `https://fiesta.nearle.app/*` — Fiesta is CORS-enabled
* `https://fiesta.nearle.app/*` (see vite.config.ts). Fiesta is CORS-enabled and * and needs no auth header for these read endpoints, so no dev proxy is required.
* needs no auth header for these read endpoints.
* *
* This sits alongside `./api` (the Hasura/workolik REST surface the dashboard * This sits alongside `./api` (the Hasura/workolik REST surface the dashboard
* uses). Components should call the TanStack hooks in `./fiestaQueries`, not * uses). Components should call the TanStack hooks in `./fiestaQueries`, not
* these functions directly. * these functions directly.
*/ */
const FIESTA_BASE = '/fiesta/live/api/v1/web'; const FIESTA_BASE = 'https://fiesta.nearle.app/live/api/v1/web';
const FIESTA_MOB_BASE = 'https://fiesta.nearle.app/live/api/v1/mob';
/** Tenant / location scope shared by the merchant console (Ragul Stores, Coimbatore). */ /** Tenant / location scope shared by the merchant console (Ragul Stores, Coimbatore). */
export const FIESTA_TENANT_ID = 1087; export const FIESTA_TENANT_ID = 1087;
@@ -29,6 +29,19 @@ export const FIESTA_PRIMARY_LOCATION_ID = 1097;
export type Row = Record<string, unknown>; export type Row = Record<string, unknown>;
type QueryParams = Record<string, string | number | undefined | null>; type QueryParams = Record<string, string | number | undefined | null>;
/**
* The exact payload the nearledaily consumer app expects when its in-app scanner
* reads a store QR: a JSON object `{"tenantid":N,"locationid":N}`. The app parses
* this and resolves the outlet from it.
*
* IMPORTANT: it must be this JSON shape, NOT a URL — the app rejects a URL with
* "invalid QR code content". Keep it to exactly these two keys to match the app's
* schema; extra keys risk strict-schema rejection on the app side.
*/
export function buildStoreQrPayload(opts: { tenantid: number; locationid: number }): string {
return JSON.stringify({ tenantid: opts.tenantid, locationid: opts.locationid });
}
async function fiestaGet<T = unknown>(endpoint: string, params: QueryParams = {}): Promise<T> { async function fiestaGet<T = unknown>(endpoint: string, params: QueryParams = {}): Promise<T> {
const qs = new URLSearchParams(); const qs = new URLSearchParams();
Object.entries(params).forEach(([k, v]) => { Object.entries(params).forEach(([k, v]) => {
@@ -112,13 +125,14 @@ export interface FiestaOrderSummary {
tenantname?: string; tenantname?: string;
} }
/** /orders/getordersummary?tenantid=&fromdate=&todate= — flat order counts. */ /** /orders/getordersummary?tenantid=&locationid=&fromdate=&todate= — flat order counts. */
export async function getOrderSummary( export async function getOrderSummary(
tenantid: number, tenantid: number,
fromdate: string, fromdate: string,
todate: string, todate: string,
locationid?: number,
): Promise<FiestaOrderSummary | null> { ): Promise<FiestaOrderSummary | null> {
const row = firstRow<Row>(await fiestaGet('orders/getordersummary', { tenantid, fromdate, todate })); const row = firstRow<Row>(await fiestaGet('orders/getordersummary', { tenantid, locationid, fromdate, todate }));
if (!row) return null; if (!row) return null;
return { return {
total: num(row.total), total: num(row.total),
@@ -162,21 +176,27 @@ export async function getOrderInsight(tenantid: number): Promise<Row[]> {
return toRows(await fiestaGet('orders/getorderinsight', { tenantid })); return toRows(await fiestaGet('orders/getorderinsight', { tenantid }));
} }
/** /orders/getorders?tenantid=&status=&fromdate=&todate=&pageno=&pagesize= — orders board. */ /** /orders/getorders?tenantid=&locationid=&applocationid=&status=&fromdate=&todate=&keyword=&pageno=&pagesize= — orders board. */
export async function getOrders(opts: { export async function getOrders(opts: {
tenantid: number; tenantid: number;
status: string; status: string;
fromdate: string; fromdate: string;
todate: string; todate: string;
locationid?: number;
applocationid?: number;
keyword?: string;
pageno?: number; pageno?: number;
pagesize?: number; pagesize?: number;
}): Promise<Row[]> { }): Promise<Row[]> {
return toRows( return toRows(
await fiestaGet('orders/getorders', { await fiestaGet('orders/getorders', {
tenantid: opts.tenantid, tenantid: opts.tenantid,
locationid: opts.locationid,
applocationid: opts.applocationid,
status: opts.status, status: opts.status,
fromdate: opts.fromdate, fromdate: opts.fromdate,
todate: opts.todate, todate: opts.todate,
keyword: opts.keyword,
pageno: opts.pageno ?? 1, pageno: opts.pageno ?? 1,
pagesize: opts.pagesize ?? 20, pagesize: opts.pagesize ?? 20,
}), }),
@@ -185,7 +205,15 @@ export async function getOrders(opts: {
/** /orders/getorderdetails?orderheaderid= — line items for a single order. */ /** /orders/getorderdetails?orderheaderid= — line items for a single order. */
export async function getOrderDetails(orderheaderid: number | string): Promise<Row[]> { export async function getOrderDetails(orderheaderid: number | string): Promise<Row[]> {
return toRows(await fiestaGet('orders/getorderdetails', { orderheaderid })); let cleanId = String(orderheaderid).trim();
if (cleanId.toUpperCase().startsWith('DLV-')) {
cleanId = cleanId.substring(4);
}
cleanId = cleanId.split('-')[0];
const numericId = Number(cleanId);
const finalId = Number.isInteger(numericId) && numericId > 0 ? numericId : orderheaderid;
return toRows(await fiestaGet('orders/getorderdetails', { orderheaderid: finalId }));
} }
/** /orders/getorders?customerid=&status=&pageno=&pagesize= — one customer's order history. */ /** /orders/getorders?customerid=&status=&pageno=&pagesize= — one customer's order history. */
@@ -221,17 +249,19 @@ export interface FiestaDeliverySummary {
cancelled: number; cancelled: number;
} }
/** /deliveries/deliverysummary?tenantid=&applocationid=&fromdate=&todate= — dispatch counts. */ /** /deliveries/deliverysummary?tenantid=&applocationid=&locationid=&fromdate=&todate= — dispatch counts. */
export async function getDeliverySummary(opts: { export async function getDeliverySummary(opts: {
tenantid: number; tenantid: number;
applocationid?: number; applocationid?: number;
locationid?: number;
fromdate: string; fromdate: string;
todate: string; todate: string;
}): Promise<FiestaDeliverySummary | null> { }): Promise<FiestaDeliverySummary | null> {
const row = firstRow<Row>( const row = firstRow<Row>(
await fiestaGet('deliveries/deliverysummary', { await fiestaGet('deliveries/deliverysummary', {
tenantid: opts.tenantid, tenantid: opts.tenantid,
applocationid: opts.applocationid ?? FIESTA_APPLOCATION_ID, applocationid: opts.applocationid, // only sent when provided (no forced default)
locationid: opts.locationid,
fromdate: opts.fromdate, fromdate: opts.fromdate,
todate: opts.todate, todate: opts.todate,
}), }),
@@ -250,19 +280,40 @@ export async function getDeliverySummary(opts: {
}; };
} }
/** /deliveries/getdeliveries?tenantid=&fromdate=&todate= — the master deliveries board. */ /** /deliveries/getdeliveries?tenantid=&applocationid=&locationid=&status=&fromdate=&todate=&keyword=&pageno=&pagesize= — the master deliveries board. */
export async function getDeliveries(opts: { export async function getDeliveries(opts: {
tenantid: number; tenantid: number;
fromdate: string; fromdate: string;
todate: string; todate: string;
status?: string;
locationid?: number;
applocationid?: number;
keyword?: string;
pageno?: number;
pagesize?: number;
}): Promise<Row[]> { }): Promise<Row[]> {
return toRows( const rows = toRows(
await fiestaGet('deliveries/getdeliveries', { await fiestaGet('deliveries/getdeliveries', {
tenantid: opts.tenantid, tenantid: opts.tenantid,
// NOTE: do NOT send `locationid` to getdeliveries — the backend's locationid
// filter on THIS endpoint is broken: passing a real outlet id returns []
// (it doesn't match against the row's own `locationid`), even though
// deliverysummary honours the same id and the rows clearly carry it. So we
// fetch tenant-wide here and scope by locationid client-side below; the KPI
// strip (deliverysummary) keeps using the working server-side filter.
applocationid: opts.applocationid,
// The backend treats `status` as a LITERAL orderstatus filter — passing
// 'all' matches nothing (returns []). Send empty to fetch every status and
// let the board filter client-side by its status tabs.
status: !opts.status || opts.status === 'all' ? '' : opts.status,
fromdate: opts.fromdate, fromdate: opts.fromdate,
todate: opts.todate, todate: opts.todate,
keyword: opts.keyword,
pageno: opts.pageno ?? 1,
pagesize: opts.pagesize ?? 200,
}), }),
); );
return opts.locationid ? rows.filter((r) => num(r.locationid) === opts.locationid) : rows;
} }
/** /deliveries/getdeliveryinsight?tenantid= — daily delivery insight. */ /** /deliveries/getdeliveryinsight?tenantid= — daily delivery insight. */
@@ -312,35 +363,214 @@ export async function getFleetSummary(opts: {
); );
} }
/** `YYYY-MM-DD HH:mm:ss` — the timestamp format the delivery endpoints expect. */
function nowStamp(): string {
const d = new Date();
const p = (n: number) => String(n).padStart(2, '0');
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}`;
}
/**
* Build a delivery record from a getorders row for POST /createdeliveries. The
* backend keys the order (orderheaderid) and the rider (userid), copies the
* pickup/drop snapshot, and must carry the SAME tenant/partner/applocation as
* the order so the assignment is valid. Order field names map 1:1 (Go's JSON
* decode is case-insensitive, so `pickupaddress` satisfies `Pickupaddress`).
*/
function deliveryFromOrder(o: Row, userid: number, assigntime: string): Row {
return {
orderheaderid: num(o.orderheaderid),
orderid: str(o.orderid),
applocationid: num(o.applocationid),
configid: num(o.configid) || 1,
partnerid: num(o.partnerid),
tenantid: num(o.tenantid),
moduleid: num(o.moduleid),
locationid: num(o.locationid),
categoryid: num(o.categoryid),
subcategoryid: num(o.subcategoryid),
userid, // the assigned rider
customerid: num(o.customerid),
orderstatus: 'pending',
assigntime,
// The Orders API exposes the scheduled delivery date as `deliverytime` (there
// is no `deliverydate` on an order row). Copy it through so the new delivery
// lands in the Deliveries board's date window — falling back to the order date
// and finally the assign timestamp so the row is never written date-less
// (a date-less delivery is excluded by getdeliveries' from/to filter).
deliverydate: str(o.deliverydate) || str(o.deliverytime) || str(o.orderdate) || assigntime,
itemcount: num(o.itemcount),
orderamount: num(o.orderamount) || num(o.deliveryamt),
deliveryamt: num(o.deliveryamt),
deliverycharges: num(o.deliverycharge) || num(o.deliverycharges),
paymenttype: num(o.paymenttype),
ordernotes: str(o.ordernotes),
pickupcustomer: str(o.pickupcustomer) || str(o.tenantname),
pickupcontactno: str(o.pickupcontactno),
pickupaddress: str(o.pickupaddress),
pickuplocationid: num(o.pickuplocationid),
pickuplat: str(o.pickuplat),
pickuplon: str(o.pickuplong) || str(o.pickuplon),
deliverycustomerid: num(o.deliverycustomerid),
deliverylocationid: num(o.deliverylocationid),
deliverycustomer: str(o.deliverycustomer),
deliverycontactno: str(o.deliverycontactno),
deliveryaddress: str(o.deliveryaddress),
deliverylat: str(o.deliverylat),
deliverylong: str(o.deliverylong),
};
}
/**
* Assign a rider to one or more orders — the CORRECT flow per the backend:
* • orders with no delivery yet (`deliveryid == 0`, i.e. freshly created) →
* POST /deliveries/createdeliveries (one batched call). This creates the
* delivery, enqueues it, AND flips the order to `pending`.
* • orders that already have a delivery → PUT /deliveries/updatedelivery to
* re-point the rider.
* The rider (userid) MUST belong to the same tenant/partner as the orders, or
* the backend rejects the assignment — that scoping is enforced on the rider
* list (see getRiders' partnerid).
*/
export async function assignRiderToOrders(
userid: number,
orders: Row[],
): Promise<{ ok: number; failed: number; total: number }> {
const assigntime = nowStamp();
const toCreate = orders.filter((o) => !num(o.deliveryid));
const toUpdate = orders.filter((o) => num(o.deliveryid));
let ok = 0;
let failed = 0;
if (toCreate.length) {
try {
await fiestaSend('deliveries/createdeliveries', 'POST', toCreate.map((o) => deliveryFromOrder(o, userid, assigntime)));
ok += toCreate.length;
} catch {
failed += toCreate.length;
}
}
if (toUpdate.length) {
const results = await Promise.allSettled(
toUpdate.map((o) =>
fiestaSend('deliveries/updatedelivery', 'PUT', {
userid,
deliveryid: num(o.deliveryid),
orderheaderid: num(o.orderheaderid),
orderstatus: 'pending',
assigntime,
}),
),
);
ok += results.filter((r) => r.status === 'fulfilled').length;
failed += results.filter((r) => r.status === 'rejected').length;
}
return { ok, failed, total: orders.length };
}
// ════════════════════════════════════════════════════════════════════════════ // ════════════════════════════════════════════════════════════════════════════
// PARTNERS / RIDERS // PARTNERS / RIDERS
// ════════════════════════════════════════════════════════════════════════════ // ════════════════════════════════════════════════════════════════════════════
/** /partners/getriders?applocationid=&tenantid= — active rider fleet. */ /**
* /partners/getriders?applocationid=&tenantid=&partnerid= — active rider fleet.
* Scoped by tenant AND partner: a rider belongs to one tenant/partner, so an
* order can only be assigned to a rider sharing its partnerid. Passing the
* order's partnerid keeps the assignable list correct (an out-of-tenant rider
* simply won't appear, which is the intended guard).
*/
export async function getRiders(opts: { export async function getRiders(opts: {
applocationid?: number; applocationid?: number;
tenantid: number; tenantid: number;
partnerid?: number;
}): Promise<Row[]> { }): Promise<Row[]> {
return toRows( return toRows(
await fiestaGet('partners/getriders', { await fiestaGet('partners/getriders', {
applocationid: opts.applocationid ?? FIESTA_APPLOCATION_ID, applocationid: opts.applocationid ?? FIESTA_APPLOCATION_ID,
tenantid: opts.tenantid, tenantid: opts.tenantid,
partnerid: opts.partnerid,
}), }),
); );
} }
/** /partners/getridershifts?applocationid= — rider shift records. */ /** /partners/getridershifts?applocationid= — rider shift records. */
export async function getRiderShifts(applocationid: number = FIESTA_APPLOCATION_ID): Promise<Row[]> { export async function getRiderShifts(applocationid: number = FIESTA_APPLOCATION_ID): Promise<Row[]> {
return toRows(await fiestaGet('partners/getridershifts', { applocationid })); return toRows(await fiestaGet('partners/getridershifts/', { applocationid }));
} }
// ════════════════════════════════════════════════════════════════════════════ // ════════════════════════════════════════════════════════════════════════════
// TENANTS / CUSTOMERS // TENANTS / CUSTOMERS
// ════════════════════════════════════════════════════════════════════════════ // ════════════════════════════════════════════════════════════════════════════
/** /tenants/gettenantlocations?tenantid= — outlet locations for a tenant. */ /**
* Throwaway/test email providers. A location whose contact email is on one of
* these is a sandbox record, never a real outlet — used to drop test data.
*/
const DISPOSABLE_EMAIL_DOMAINS = new Set([
'mailinator.com',
'mailinator.net',
'example.com',
'example.org',
'test.com',
'yopmail.com',
'guerrillamail.com',
'10minutemail.com',
]);
/**
* The tenant-locations endpoint for some tenants returns junk: the primary
* outlet duplicated several times, plus orphan test records geocoded to random
* countries (e.g. "Deborah Lara, Spain", "power, Ireland") with throwaway
* emails. This strips both so the registry/inventory show only real outlets.
*
* The filter is self-calibrating (no hardcoded names/ids): it derives the
* tenant's operating region from the most common state among its outlets, then
* drops rows that either use a disposable email or sit outside that region. If
* the region can't be established (no state data), nothing is region-filtered —
* we'd rather show an extra row than hide a genuine outlet.
*/
export function cleanTenantLocations(rows: Row[]): Row[] {
// 1. Dedupe by locationid — the API repeats the primary outlet.
const seen = new Set<number>();
const deduped = rows.filter((r) => {
const id = num(r.locationid);
if (!id || seen.has(id)) return false;
seen.add(id);
return true;
});
// 2. Find the tenant's home region (plurality of `state`).
const stateCounts = new Map<string, number>();
for (const r of deduped) {
const st = str(r.state).trim().toLowerCase();
if (st) stateCounts.set(st, (stateCounts.get(st) ?? 0) + 1);
}
let homeState = '';
let max = 0;
for (const [st, c] of stateCounts) {
if (c > max) {
max = c;
homeState = st;
}
}
// 3. Drop disposable-email rows and out-of-region rows.
return deduped.filter((r) => {
const emailDomain = (str(r.email).split('@')[1] ?? '').trim().toLowerCase();
if (emailDomain && DISPOSABLE_EMAIL_DOMAINS.has(emailDomain)) return false;
if (homeState) {
const st = str(r.state).trim().toLowerCase();
if (st && st !== homeState) return false;
}
return true;
});
}
/** /tenants/gettenantlocations?tenantid= — outlet locations for a tenant (test rows stripped). */
export async function getTenantLocations(tenantid: number): Promise<Row[]> { export async function getTenantLocations(tenantid: number): Promise<Row[]> {
return toRows(await fiestaGet('tenants/gettenantlocations', { tenantid })); return cleanTenantLocations(toRows(await fiestaGet('tenants/gettenantlocations', { tenantid })));
} }
/** /tenants/getalltenants?applocationid=&status=&pageno=&pagesize= — active tenants. */ /** /tenants/getalltenants?applocationid=&status=&pageno=&pagesize= — active tenants. */
@@ -360,7 +590,27 @@ export async function getAllTenants(opts: {
); );
} }
/** /customers/gettenantcustomers?tenantid=&locationid=&pageno=&pagesize=&keyword= */ /**
* Collapse the gettenantcustomers rows to one per customer. The endpoint returns
* one row per saved DELIVERY ADDRESS (each carries its own deliverylocationid /
* address), so a customer with several addresses repeats many times. Key by
* customerid (fall back to contactno), preferring the row flagged primaryaddress;
* rows with no identity at all are kept as-is so nothing is silently dropped.
*/
export function dedupeCustomers(rows: Row[]): Row[] {
const byCustomer = new Map<string, Row>();
for (const r of rows) {
const cid = num(r.customerid);
const key = cid ? `c${cid}` : (str(r.contactno) ? `p${str(r.contactno)}` : `x${byCustomer.size}`);
const existing = byCustomer.get(key);
if (!existing || (num(r.primaryaddress) && !num(existing.primaryaddress))) {
byCustomer.set(key, r);
}
}
return [...byCustomer.values()];
}
/** /customers/gettenantcustomers?tenantid=&locationid=&pageno=&pagesize=&keyword= (deduped per customer). */
export async function getTenantCustomers(opts: { export async function getTenantCustomers(opts: {
tenantid: number; tenantid: number;
locationid: number; locationid: number;
@@ -368,7 +618,7 @@ export async function getTenantCustomers(opts: {
pageno?: number; pageno?: number;
pagesize?: number; pagesize?: number;
}): Promise<Row[]> { }): Promise<Row[]> {
return toRows( return dedupeCustomers(toRows(
await fiestaGet('customers/gettenantcustomers', { await fiestaGet('customers/gettenantcustomers', {
tenantid: opts.tenantid, tenantid: opts.tenantid,
locationid: opts.locationid, locationid: opts.locationid,
@@ -376,7 +626,7 @@ export async function getTenantCustomers(opts: {
pageno: opts.pageno ?? 1, pageno: opts.pageno ?? 1,
pagesize: opts.pagesize ?? 20, pagesize: opts.pagesize ?? 20,
}), }),
); ));
} }
// ════════════════════════════════════════════════════════════════════════════ // ════════════════════════════════════════════════════════════════════════════
@@ -540,8 +790,13 @@ export interface CreateUserInput {
lastname?: string; lastname?: string;
email: string; email: string;
contactno: string; contactno: string;
password: string; /** Optional — merchant_web's create form doesn't collect one. */
password?: string;
roleid: number; roleid: number;
/** Role config (the selected role's configid) — matches merchant_web's create payload. */
configid?: number;
/** Business module id (merchant_web sends the logged-in user's; 0 when absent). */
moduleid?: number;
dialcode?: string; dialcode?: string;
pin?: number; pin?: number;
address?: string; address?: string;
@@ -549,6 +804,10 @@ export interface CreateUserInput {
city?: string; city?: string;
state?: string; state?: string;
postcode?: string; postcode?: string;
latitude?: string;
longitude?: string;
/** Rider shift (only meaningful for rider-role users). */
shiftid?: number;
tenantid: number; tenantid: number;
locationid?: number; locationid?: number;
applocationid?: number; applocationid?: number;
@@ -561,17 +820,22 @@ export async function createUser(input: CreateUserInput): Promise<Row> {
authname: input.email, authname: input.email,
firstname: input.firstname, firstname: input.firstname,
lastname: input.lastname ?? '', lastname: input.lastname ?? '',
password: input.password, password: input.password ?? '',
email: input.email, email: input.email,
dialcode: input.dialcode ?? '+91', dialcode: input.dialcode ?? '+91',
contactno: input.contactno, contactno: input.contactno,
roleid: input.roleid, roleid: input.roleid,
configid: input.configid ?? 15,
moduleid: input.moduleid ?? 0,
pin: input.pin ?? 0, pin: input.pin ?? 0,
address: input.address ?? '', address: input.address ?? '',
suburb: input.suburb ?? '', suburb: input.suburb ?? '',
city: input.city ?? '', city: input.city ?? '',
state: input.state ?? '', state: input.state ?? '',
postcode: input.postcode ?? '', postcode: input.postcode ?? '',
latitude: input.latitude ?? '',
longitude: input.longitude ?? '',
shiftid: input.shiftid ?? 0,
tenantid: input.tenantid, tenantid: input.tenantid,
locationid: input.locationid ?? 0, locationid: input.locationid ?? 0,
applocationid: input.applocationid ?? FIESTA_APPLOCATION_ID, applocationid: input.applocationid ?? FIESTA_APPLOCATION_ID,
@@ -597,3 +861,56 @@ export interface UpdateUserInput {
export async function updateUser(input: UpdateUserInput): Promise<Row> { export async function updateUser(input: UpdateUserInput): Promise<Row> {
return fiestaSend<Row>('users/update', 'PUT', input); return fiestaSend<Row>('users/update', 'PUT', input);
} }
export interface CreateTenantInput {
tenantname: string;
companyname: string;
primarycontact: string;
primaryemail: string;
address?: string;
suburb?: string;
city?: string;
state?: string;
postcode?: string;
approved?: number;
status?: string;
}
/** POST /tenants/createtenantuser — Onboard a new tenant and create their admin user. */
export async function createTenantUser(input: CreateTenantInput): Promise<Row> {
const res = await fetch(`${FIESTA_MOB_BASE}/tenants/createtenantuser`, {
method: 'POST',
headers: { Accept: 'application/json', 'Content-Type': 'application/json' },
body: JSON.stringify(input),
});
const json = (await res.json().catch(() => null)) as { message?: string; status?: boolean } | null;
if (!res.ok || (json && json.status === false)) {
throw new Error(json?.message || `Tenant onboarding failed: ${res.status}`);
}
return json as Row;
}
export interface CreateTenantLocationInput {
tenantid: number;
locationname: string;
address?: string;
suburb?: string;
city?: string;
state?: string;
postcode?: string;
contactno?: string;
email?: string;
opentime?: string;
closetime?: string;
deliverymins?: number;
deliveryradius?: number;
latitude?: string;
longitude?: string;
status?: string;
}
/** POST /tenants/createtenantlocation — Create a new store location under a tenant. */
export async function createTenantLocation(input: CreateTenantLocationInput): Promise<Row> {
return fiestaSend<Row>('tenants/createtenantlocation', 'POST', input);
}

View File

@@ -122,3 +122,47 @@ export function deliveryRowToOrder(row: Row): CustomerOrder {
locationid: num(row.locationid) locationid: num(row.locationid)
}; };
} }
/**
* orders-board row (from /orders/getorders) -> CustomerOrder card.
* The orders API uses different field names than the deliveries board.
*/
export function orderRowToOrder(row: Row): CustomerOrder {
// Amount: orders API returns orderamount / ordervalue / collectionamt
const amount = num(row.ordervalue) || num(row.orderamount) || num(row.collectionamt);
// Rider: may come from the linked delivery record
const rider = str(row.ridername) || str(row.ridernames) || '';
// Customer: orders use different field names
const customerName =
str(row.deliverycustomer) ||
str(row.customername) ||
str(row.firstname) + (str(row.lastname) ? ` ${str(row.lastname)}` : '') ||
'Customer';
// Address: drop address (where order is delivered)
const address =
str(row.deliveryaddress) ||
str(row.deliverysuburb) ||
str(row.pickupaddress) ||
'Address unavailable';
// Hub: store that fulfilled the order
const hub =
str(row.locationname) ||
str(row.pickupcustomer) ||
str(row.tenantname) ||
`Location ${str(row.locationid)}`;
return {
id: str(row.orderid) || `ORD-${str(row.orderheaderid)}`,
customerName: customerName.trim() || 'Customer',
phone: str(row.contactno) || str(row.deliverycontactno) || '—',
address,
items: [],
amount,
time: shortTime(row.orderdate || row.deliverytime || row.createdat),
status: mapOrderStatus(str(row.orderstatus)),
assignedRider: rider || 'Pending Assignment',
hub,
itemCount: num(row.itemcount) || num(row.quantity),
locationid: num(row.locationid),
};
}

View File

@@ -46,12 +46,17 @@ import {
getUserById, getUserById,
createUser, createUser,
updateUser, updateUser,
assignRiderToOrders,
CreateUserInput, CreateUserInput,
createTenantUser,
createTenantLocation,
CreateTenantInput,
CreateTenantLocationInput,
} from './fiestaApi'; } from './fiestaApi';
export const fiestaKeys = { export const fiestaKeys = {
orderSummary: (tenantid: number, fromdate: string, todate: string) => orderSummary: (tenantid: number, fromdate: string, todate: string, locationid?: number) =>
['fiesta', 'orderSummary', tenantid, fromdate, todate] as const, ['fiesta', 'orderSummary', tenantid, fromdate, todate, locationid ?? 0] as const,
locationSummary: (tenantid: number) => ['fiesta', 'locationSummary', tenantid] as const, locationSummary: (tenantid: number) => ['fiesta', 'locationSummary', tenantid] as const,
orderInsight: (tenantid: number) => ['fiesta', 'orderInsight', tenantid] as const, orderInsight: (tenantid: number) => ['fiesta', 'orderInsight', tenantid] as const,
orders: (params: Record<string, unknown>) => ['fiesta', 'orders', params] as const, orders: (params: Record<string, unknown>) => ['fiesta', 'orders', params] as const,
@@ -60,7 +65,9 @@ export const fiestaKeys = {
deliveryInsight: (tenantid: number) => ['fiesta', 'deliveryInsight', tenantid] as const, deliveryInsight: (tenantid: number) => ['fiesta', 'deliveryInsight', tenantid] as const,
riders: (params: Record<string, unknown>) => ['fiesta', 'riders', params] as const, riders: (params: Record<string, unknown>) => ['fiesta', 'riders', params] as const,
riderShifts: (applocationid: number) => ['fiesta', 'riderShifts', applocationid] as const, riderShifts: (applocationid: number) => ['fiesta', 'riderShifts', applocationid] as const,
tenantLocations: (tenantid: number) => ['fiesta', 'tenantLocations', tenantid] as const, // v2: bumped when test-row filtering was added to getTenantLocations so any
// warm cache holding the old unfiltered (duplicated/junk) rows is bypassed.
tenantLocations: (tenantid: number) => ['fiesta', 'tenantLocations', 'v2', tenantid] as const,
allTenants: (params: Record<string, unknown>) => ['fiesta', 'allTenants', params] as const, allTenants: (params: Record<string, unknown>) => ['fiesta', 'allTenants', params] as const,
tenantCustomers: (params: Record<string, unknown>) => ['fiesta', 'tenantCustomers', params] as const, tenantCustomers: (params: Record<string, unknown>) => ['fiesta', 'tenantCustomers', params] as const,
stockStatement: (params: Record<string, unknown>) => ['fiesta', 'stockStatement', params] as const, stockStatement: (params: Record<string, unknown>) => ['fiesta', 'stockStatement', params] as const,
@@ -79,10 +86,10 @@ export const fiestaKeys = {
}; };
// ── Orders ────────────────────────────────────────────────────────────────── // ── Orders ──────────────────────────────────────────────────────────────────
export function useFiestaOrderSummary(tenantid: number = FIESTA_TENANT_ID, fromdate: string, todate: string) { export function useFiestaOrderSummary(tenantid: number = FIESTA_TENANT_ID, fromdate: string, todate: string, locationid?: number) {
return useQuery({ return useQuery({
queryKey: fiestaKeys.orderSummary(tenantid, fromdate, todate), queryKey: fiestaKeys.orderSummary(tenantid, fromdate, todate, locationid),
queryFn: () => getOrderSummary(tenantid, fromdate, todate), queryFn: () => getOrderSummary(tenantid, fromdate, todate, locationid),
enabled: Boolean(tenantid && fromdate && todate), enabled: Boolean(tenantid && fromdate && todate),
}); });
} }
@@ -108,6 +115,9 @@ export function useFiestaOrders(opts: {
status: string; status: string;
fromdate: string; fromdate: string;
todate: string; todate: string;
locationid?: number;
applocationid?: number;
keyword?: string;
pageno?: number; pageno?: number;
pagesize?: number; pagesize?: number;
}) { }) {
@@ -118,10 +128,61 @@ export function useFiestaOrders(opts: {
}); });
} }
/**
* Fetches orders across all statuses for a given date range by firing one
* request per status in parallel and merging the results. This is needed
* because the /orders/getorders API requires an explicit status param and
* returns an empty array when status is blank or 'all'.
*/
export function useFiestaAllOrders(opts: {
tenantid: number;
fromdate: string;
todate: string;
locationid?: number;
applocationid?: number;
keyword?: string;
}) {
return useQuery({
queryKey: ['fiesta', 'allOrders', opts],
queryFn: async () => {
const statuses = ['created', 'pending', 'processing', 'delivered', 'cancelled'];
const results = await Promise.all(
statuses.map(status =>
getOrders({
tenantid: opts.tenantid,
status,
fromdate: opts.fromdate,
todate: opts.todate,
locationid: opts.locationid,
applocationid: opts.applocationid,
keyword: opts.keyword,
pagesize: 100,
}).catch(() => [] as Row[])
)
);
// Merge and deduplicate by orderid/orderheaderid
const merged: Row[] = [];
const seen = new Set<string>();
for (const list of results) {
for (const row of list) {
const id = String(row.orderid || row.orderheaderid || Math.random());
if (!seen.has(id)) {
seen.add(id);
merged.push(row);
}
}
}
return merged;
},
enabled: Boolean(opts.tenantid && opts.fromdate && opts.todate),
});
}
// ── Deliveries ──────────────────────────────────────────────────────────────── // ── Deliveries ────────────────────────────────────────────────────────────────
export function useFiestaDeliverySummary(opts: { export function useFiestaDeliverySummary(opts: {
tenantid: number; tenantid: number;
applocationid?: number; applocationid?: number;
locationid?: number;
fromdate: string; fromdate: string;
todate: string; todate: string;
}) { }) {
@@ -132,7 +193,17 @@ export function useFiestaDeliverySummary(opts: {
}); });
} }
export function useFiestaDeliveries(opts: { tenantid: number; fromdate: string; todate: string }) { export function useFiestaDeliveries(opts: {
tenantid: number;
fromdate: string;
todate: string;
status?: string;
locationid?: number;
applocationid?: number;
keyword?: string;
pageno?: number;
pagesize?: number;
}) {
return useQuery({ return useQuery({
queryKey: fiestaKeys.deliveries(opts), queryKey: fiestaKeys.deliveries(opts),
queryFn: () => getDeliveries(opts), queryFn: () => getDeliveries(opts),
@@ -148,8 +219,28 @@ export function useFiestaDeliveryInsight(tenantid: number = FIESTA_TENANT_ID) {
}); });
} }
/**
* Bulk-assign one rider to many orders (the Orders board's multi-select assign).
* Fires one updatedelivery per row in parallel, tolerates partial failure, and
* refreshes the orders + deliveries lists on completion.
*/
export function useFiestaAssignRider() {
const qc = useQueryClient();
return useMutation({
mutationFn: (input: { userid: number; orders: Row[] }) => assignRiderToOrders(input.userid, input.orders),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['fiesta', 'orders'] });
qc.invalidateQueries({ queryKey: ['fiesta', 'orderSummary'] });
// Refresh the Deliveries board AND its KPI summary so a freshly-assigned
// order shows up on the deliveries page immediately (table + count cards).
qc.invalidateQueries({ queryKey: ['fiesta', 'deliveries'] });
qc.invalidateQueries({ queryKey: ['fiesta', 'deliverySummary'] });
},
});
}
// ── Partners / Riders ───────────────────────────────────────────────────────── // ── Partners / Riders ─────────────────────────────────────────────────────────
export function useFiestaRiders(opts: { applocationid?: number; tenantid: number }) { export function useFiestaRiders(opts: { applocationid?: number; tenantid: number; partnerid?: number }) {
return useQuery({ return useQuery({
queryKey: fiestaKeys.riders(opts), queryKey: fiestaKeys.riders(opts),
queryFn: () => getRiders(opts), queryFn: () => getRiders(opts),
@@ -409,6 +500,28 @@ export function useFiestaUpdateUser() {
}); });
} }
/** Create a new tenant and admin user, then refresh tenants list on success. */
export function useFiestaCreateTenant() {
const qc = useQueryClient();
return useMutation({
mutationFn: (input: CreateTenantInput) => createTenantUser(input),
onSuccess: () => qc.invalidateQueries({ queryKey: ['fiesta', 'allTenants'] }),
});
}
/** Create a new tenant location, then refresh tenant locations list on success. */
export function useFiestaCreateLocation() {
const qc = useQueryClient();
return useMutation({
mutationFn: (input: CreateTenantLocationInput) => createTenantLocation(input),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['fiesta', 'tenantLocations'] });
qc.invalidateQueries({ queryKey: ['fiesta', 'locationSummary'] });
},
});
}
// ── Auth ────────────────────────────────────────────────────────────────────── // ── Auth ──────────────────────────────────────────────────────────────────────
/** /**
* Verify login credentials against the Fiesta web-login endpoint. A mutation * Verify login credentials against the Fiesta web-login endpoint. A mutation

View File

@@ -0,0 +1,81 @@
/**
* @license
* SPDX-License-Identifier: Apache-2.0
*/
/**
* The shared "store catalogue" — the products the **admin** has selected from the
* global catalogue (each with a quantity). This curated list is what **store
* users** see and pick from. It's the design-stage bridge between the admin
* catalogue page (InventoryView) and the user catalogue page (StoreCatalogView).
*
* Persisted in localStorage so the flow is fully demonstrable on one device; it
* syncs live across tabs/pages via a storage event. The backend equivalents
* (once built) are:
* • admin curates → POST /products/createproductlocation (productid, qty, …)
* • user reads → GET /products/getlocationproducts
* Swap `read`/`write` for those calls when the API is ready; the hook API stays.
*/
import { useEffect, useState } from 'react';
export interface StoreCatalogueItem {
productid: string;
name: string;
image: string;
category: string;
sku?: string;
price: number;
unit: string;
/** Quantity the admin intends to stock for this product. */
qty: number;
}
const KEY = 'nearledaily.storeCatalogue';
const EVENT = 'nearledaily:storeCatalogue';
function read(): StoreCatalogueItem[] {
try {
const raw = localStorage.getItem(KEY);
return raw ? (JSON.parse(raw) as StoreCatalogueItem[]) : [];
} catch {
return [];
}
}
function write(items: StoreCatalogueItem[]): void {
try {
localStorage.setItem(KEY, JSON.stringify(items));
} catch {
/* storage unavailable */
}
// Notify listeners in this tab (storage event only fires in OTHER tabs).
window.dispatchEvent(new Event(EVENT));
}
/**
* Live view of the store catalogue + curation helpers. Re-renders whenever the
* catalogue changes (this tab or another).
*/
export function useStoreCatalogue() {
const [items, setItems] = useState<StoreCatalogueItem[]>(read);
useEffect(() => {
const sync = () => setItems(read());
window.addEventListener(EVENT, sync);
window.addEventListener('storage', sync);
return () => {
window.removeEventListener(EVENT, sync);
window.removeEventListener('storage', sync);
};
}, []);
const has = (id: string) => items.some((i) => i.productid === id);
const getQty = (id: string) => items.find((i) => i.productid === id)?.qty ?? 0;
const add = (item: StoreCatalogueItem) => write([...read().filter((i) => i.productid !== item.productid), item]);
const remove = (id: string) => write(read().filter((i) => i.productid !== id));
const setQty = (id: string, qty: number) =>
write(read().map((i) => (i.productid === id ? { ...i, qty: Math.max(1, Math.round(qty) || 1) } : i)));
return { items, has, getQty, add, remove, setQty };
}

View File

@@ -3,7 +3,7 @@
* SPDX-License-Identifier: Apache-2.0 * SPDX-License-Identifier: Apache-2.0
*/ */
export type MainSection = 'dashboard' | 'stores' | 'inventory' | 'orders' | 'users' | 'settings' | 'reports' | 'operations'; export type MainSection = 'dashboard' | 'stores' | 'inventory' | 'orders' | 'users' | 'settings' | 'reports' | 'operations' | 'admin-console';
export interface KPICardData { export interface KPICardData {
title: string; title: string;