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",
"lucide-react": "^0.546.0",
"motion": "^12.23.24",
"qrcode.react": "^4.2.0",
"react": "^19.0.1",
"react-dom": "^19.0.1",
"react-leaflet": "^5.0.0",
@@ -3307,6 +3308,15 @@
"node": ">= 0.10"
}
},
"node_modules/qrcode.react": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/qrcode.react/-/qrcode.react-4.2.0.tgz",
"integrity": "sha512-QpgqWi8rD9DsS9EP3z7BT+5lY5SFhsqGjpgW5DY/i3mK4M9DTBNz3ErMi8BWYEfI3L0d8GIbGmcdFAS1uIRGjA==",
"license": "ISC",
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/qs": {
"version": "6.15.2",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz",

View File

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

View File

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

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

View File

@@ -14,6 +14,7 @@
*/
import React, { useMemo, useState } from 'react';
import { createPortal } from 'react-dom';
import {
Truck, Clock, CheckCircle2, XCircle, Calendar, Sun, Sunset, Moon, Layers, UserCheck, MapPin, Phone, Package, Loader2, X, Bike,
} from 'lucide-react';
@@ -26,7 +27,7 @@ import {
DELIVERY_STATUS, statusColor, BRAND, BRAND_LIGHT, TEXT, TEXT_2, TEXT_3, BORDER, DIVIDER, SURFACE_ALT, tint, soft, edge,
} from './consoleUi';
interface DeliveriesViewProps { searchQuery?: string; locationid?: number; }
interface DeliveriesViewProps { searchQuery?: string; locationid?: number; tenantId?: number; }
type DeliveryStatus = 'pending' | 'accepted' | 'arrived' | 'picked' | 'active' | 'skipped' | 'delivered' | 'cancelled';
const STATUS_TABS: Array<{ key: DeliveryStatus; label: string }> = [
@@ -55,34 +56,32 @@ function inBatch(r: Row, b: BatchId): boolean {
if (b === 'afternoon') return h >= 9 && h < 12.5;
return h >= 16 && h < 19;
}
function initialBatch(): BatchId {
const h = new Date().getHours();
if (h >= 0 && h < 8) return 'morning';
if (h >= 9 && h < 12.5) return 'afternoon';
if (h >= 16 && h < 19) return 'evening';
return 'all';
}
export default function DeliveriesView({ searchQuery = '', locationid }: DeliveriesViewProps) {
export default function DeliveriesView({ searchQuery = '', locationid, tenantId = FIESTA_TENANT_ID }: DeliveriesViewProps) {
const today = new Date();
const [fromdate, setFromdate] = useState<string>(ymd(today));
const [todate, setTodate] = useState<string>(ymd(today));
const monthStart = new Date(today.getFullYear(), today.getMonth(), 1);
const dayOffset = (n: number) => { const d = new Date(); d.setDate(d.getDate() - n); return ymd(d); };
const dayAhead = (n: number) => { const d = new Date(); d.setDate(d.getDate() + n); return ymd(d); };
const [fromdate, setFromdate] = useState<string>(dayOffset(6));
const [todate, setTodate] = useState<string>(ymd(today));
const presets = [
{ key: 'today', label: 'Today', from: ymd(today), to: ymd(today) },
{ key: 'yesterday', label: 'Yesterday', from: dayOffset(1), to: dayOffset(1) },
{ key: '7d', label: 'Last 7 Days', from: dayOffset(6), to: ymd(today) },
{ key: 'month', label: 'This Month', from: ymd(monthStart), to: dayAhead(7) },
];
const activePreset = presets.find((p) => p.from === fromdate && p.to === todate)?.key ?? 'custom';
const [batch, setBatch] = useState<BatchId>(initialBatch());
const batch: BatchId = 'all';
const [status, setStatus] = useState<DeliveryStatus>('pending');
const [localSearch, setLocalSearch] = useState('');
const [detailRow, setDetailRow] = useState<Row | null>(null);
const summaryQ = useFiestaDeliverySummary({ tenantid: FIESTA_TENANT_ID, fromdate, todate });
const deliveriesQ = useFiestaDeliveries({ tenantid: FIESTA_TENANT_ID, fromdate, todate });
const ridersQ = useFiestaRiders({ tenantid: FIESTA_TENANT_ID });
// Scope to the user's store when a locationid is supplied (server-side per the
// backend's deliverysummary/getdeliveries locationid param). getDeliveries loads
// the whole day (status='all', large pagesize); status/search filter client-side.
const summaryQ = useFiestaDeliverySummary({ tenantid: tenantId, fromdate, todate, locationid });
const deliveriesQ = useFiestaDeliveries({ tenantid: tenantId, fromdate, todate, locationid, status: 'all', pagesize: 200 });
const ridersQ = useFiestaRiders({ tenantid: tenantId });
const allRows = deliveriesQ.data ?? [];
const summary = summaryQ.data;
@@ -143,70 +142,70 @@ export default function DeliveriesView({ searchQuery = '', locationid }: Deliver
<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' }} />
<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 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>
{/* Status tabs + search */}
<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) => {
const color = statusColor(DELIVERY_STATUS, t.key);
return (
{STATUS_TABS.map((t) => (
<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>
);
})}
))}
</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>
</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: 1040 }}>
<table className="w-full" style={{ minWidth: 1240 }}>
<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>
<tbody>
{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 ? (
<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) => {
const st = fstr(r.orderstatus).toLowerCase();
const rider = fstr(r.ridername) || fstr(r.username);
const kms = fnum(r.kms); const actualKms = fnum(r.cumulativekms);
const kms = fnum(r.kms); const actualKms = fnum(r.actualkms) || fnum(r.riderkms);
const charge = fnum(r.deliverycharges); const amt = fnum(r.deliveryamt);
const tenant = fstr(r.tenantname) || fstr(r.pickupcustomer);
// Pickup/Delivery: the backend often leaves customer/contact blank for
// app-created jobs but populates the address — fall back so cells aren't bare.
const pickupName = fstr(r.pickupcustomer) || fstr(r.pickupcontactno);
const pickupAddr = fstr(r.pickupsuburb) || fstr(r.pickuplocation) || fstr(r.Pickupaddress) || fstr(r.pickupaddress);
const dropName = fstr(r.deliverycustomer) || fstr(r.deliverycontactno);
const dropAddr = fstr(r.deliveryaddress) || fstr(r.deliverylocation) || fstr(r.deliverysuburb);
const notes = fstr(r.ordernotes) || fstr(r.notes);
return (
<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" 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">
<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>
</td>
<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="text-[10px] truncate max-w-[160px]" style={{ color: TEXT_2 }}>{fstr(r.deliverysuburb) || fstr(r.deliveryaddress)}</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 }}>{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 className="px-3 py-2.5">
{rider ? (
@@ -216,7 +215,6 @@ export default function DeliveriesView({ searchQuery = '', locationid }: Deliver
</span>
) : <span className="text-[11px] italic" style={{ color: TEXT_3 }}>Unassigned</span>}
</td>
<td className="px-3 py-2.5"><MetricPill color="#06b6d4">{shortTime(r.expecteddeliverytime) || '—'}</MetricPill></td>
<td className="px-3 py-2.5">
<div className="flex flex-col items-start gap-1">
<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>}
</div>
</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">
<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>
@@ -262,13 +264,15 @@ function DeliveryDetailModal({ row, onClose }: { row: Row; onClose: () => void }
const st = fstr(row.orderstatus).toLowerCase();
const rider = fstr(row.ridername) || fstr(row.username);
const steps = [
{ label: 'Assigned', field: 'assigntime' }, { label: 'Accepted', field: 'acceptedtime' }, { label: 'Arrived', field: 'arrivaltime' },
{ label: 'Assigned', field: 'assigntime' }, { label: 'Accepted', field: 'starttime' }, { label: 'Arrived', field: 'arrivaltime' },
{ label: 'Picked', field: 'pickuptime' }, { label: 'Delivered', field: 'deliverytime' },
];
return (
// Portal to <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="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 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>
@@ -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>
</div>
<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>}
<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>
<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>
</div>
</div>
</div>
</div>,
document.body,
);
}

View File

@@ -14,27 +14,23 @@
*/
import React, { useMemo, useState } from 'react';
import { TrendingUp, Clock, CheckCircle2, IndianRupee, Bike, Truck, Calendar, Download, Store, ClipboardList, Route } from 'lucide-react';
import { useFiestaLocationSummary, useFiestaFleetSummary, useFiestaDeliveries } from '../services/fiestaQueries';
import { TrendingUp, Clock, CheckCircle2, IndianRupee, Bike, Truck, Calendar, Store } from 'lucide-react';
import { useFiestaLocationSummary, useFiestaFleetSummary } from '../services/fiestaQueries';
import { FIESTA_TENANT_ID, num as fnum, str as fstr, ymd, type Row } from '../services/fiestaApi';
import { shortTime } from '../services/fiestaMappers';
import AwaitingApi from './AwaitingApi';
import {
GradientHeader, KpiStrip, Pill, StatusChip, MetricPill, SearchPill, FilterBar, TH_STYLE,
GradientHeader, KpiStrip, Pill, StatusChip, MetricPill, FilterBar, TH_STYLE,
DELIVERY_STATUS, statusColor, BRAND, BRAND_LIGHT, TEXT, TEXT_2, TEXT_3, BORDER, DIVIDER, SURFACE_ALT, tint, soft, edge, ring,
} from './consoleUi';
type ReportTab = 'orders-summary' | 'riders-summary' | 'orders-details' | 'maps';
type ReportTab = 'orders-summary' | 'riders-summary';
const TABS: Array<{ key: ReportTab; label: string; icon: typeof TrendingUp }> = [
{ key: 'orders-summary', label: 'Orders Summary', icon: Store },
{ key: 'riders-summary', label: 'Riders Summary', icon: Bike },
{ key: 'orders-details', label: 'Orders Details', icon: ClipboardList },
{ key: 'maps', label: 'Rider Routes', icon: Route },
];
interface DeliveryReportsViewProps { searchQuery?: string; }
interface DeliveryReportsViewProps { searchQuery?: string; tenantId?: number; }
export default function DeliveryReportsView({ searchQuery = '' }: DeliveryReportsViewProps) {
export default function DeliveryReportsView({ searchQuery = '', tenantId = FIESTA_TENANT_ID }: DeliveryReportsViewProps) {
const today = new Date();
const monthStart = new Date(today.getFullYear(), today.getMonth(), 1);
const [fromdate, setFromdate] = useState<string>(ymd(monthStart));
@@ -52,7 +48,7 @@ export default function DeliveryReportsView({ searchQuery = '' }: DeliveryReport
return (
<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 */}
<FilterBar className="mb-4">
@@ -85,15 +81,8 @@ export default function DeliveryReportsView({ searchQuery = '' }: DeliveryReport
</div>
</FilterBar>
{tab === 'orders-summary' && <OrdersSummaryReport />}
{tab === 'riders-summary' && <RidersSummaryReport fromdate={fromdate} todate={todate} />}
{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>
)}
{tab === 'orders-summary' && <OrdersSummaryReport tenantId={tenantId} />}
{tab === 'riders-summary' && <RidersSummaryReport fromdate={fromdate} todate={todate} tenantId={tenantId} />}
</div>
);
}
@@ -115,8 +104,8 @@ function TableShell({ minWidth, head, children, footer }: { minWidth: number; he
}
// ── Orders Summary (per outlet) ──────────────────────────────────────────────────
function OrdersSummaryReport() {
const q = useFiestaLocationSummary(FIESTA_TENANT_ID);
function OrdersSummaryReport({ tenantId }: { tenantId: number }) {
const q = useFiestaLocationSummary(tenantId);
const rows = q.data ?? [];
const totals = rows.reduce((a, r) => ({ total: a.total + r.total, pending: a.pending + r.pending, delivered: a.delivered + r.delivered, cancelled: a.cancelled + r.cancelled }), { total: 0, pending: 0, delivered: 0, cancelled: 0 });
const kpis = [
@@ -150,8 +139,8 @@ function OrdersSummaryReport() {
}
// ── Riders Summary (per rider) ───────────────────────────────────────────────────
function RidersSummaryReport({ fromdate, todate }: { fromdate: string; todate: string }) {
const q = useFiestaFleetSummary({ tenantid: FIESTA_TENANT_ID, fromdate, todate });
function RidersSummaryReport({ fromdate, todate, tenantId }: { fromdate: string; todate: string; tenantId: number }) {
const q = useFiestaFleetSummary({ tenantid: tenantId, fromdate, todate });
const rows = q.data ?? [];
const mapped = rows.map((r) => ({
name: fstr(r.fullname) || `${fstr(r.firstname)} ${fstr(r.lastname)}`.trim() || fstr(r.username) || `Rider ${fstr(r.userid)}`,
@@ -196,97 +185,7 @@ function RidersSummaryReport({ fromdate, todate }: { fromdate: string; todate: s
);
}
// ── Orders Details (line-level + CSV) ────────────────────────────────────────────
const DETAIL_STATUSES = ['all', 'pending', 'accepted', 'arrived', 'picked', 'active', 'delivered', 'skipped', 'cancelled'] as const;
type DetailStatus = (typeof DETAIL_STATUSES)[number];
function OrdersDetailsReport({ fromdate, todate, searchQuery }: { fromdate: string; todate: string; searchQuery: string }) {
const q = useFiestaDeliveries({ tenantid: FIESTA_TENANT_ID, fromdate, todate });
const allRows = q.data ?? [];
const [status, setStatus] = useState<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) ─────────────────────────────────────────────────────────
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
* (nearle_console/dispatch). It reuses that page's actual stylesheet verbatim
* (`./DispatchView.css`, copied from Dispatch.css) and reproduces the same DOM /
* class structure: the `#hdr` bar, `#strat-row` view tabs, `#batch-row` wave
* selector, the 400px `#sidebar` (RIDER DISPATCH header + KPI tiles + rider/zone
* cards + per-trip order cards), and the `#map-wrap` centrepiece.
* class structure: the `#hdr` bar, `#strat-row` view tabs, the 400px `#sidebar`
* (RIDER DISPATCH header + KPI tiles + rider/zone cards + per-trip order cards),
* and the `#map-wrap` centrepiece.
*
* The source map is a Leaflet canvas of planned-vs-actual rider routes (OSRM
* road-snapping, Kalman-smoothed GPS) plus AI rider-assignment posting to
@@ -24,8 +24,8 @@ import {
Map as MapIcon,
MapPin,
Bike,
Globe,
Info,
ShoppingBag,
Truck,
Package,
Ruler,
Wallet,
@@ -40,11 +40,9 @@ import {
ChevronRight,
List,
Play,
PlugZap,
} from 'lucide-react';
import { useFiestaDeliveries, useFiestaRiders } from '../services/fiestaQueries';
import { FIESTA_TENANT_ID, num as fnum, str as fstr, ymd, type Row } from '../services/fiestaApi';
import { MOCK_DELIVERIES, MOCK_RIDERS } from '../services/dispatchMockData';
import DispatchMap, { type MapPoint } from './DispatchMap';
import './DispatchView.css';
@@ -86,45 +84,14 @@ function pickupLatLon(r: Row): [number, number] | null {
return lat && lon ? [lat, lon] : null;
}
// ── Batch / wave model (canonical half-open hour ranges, local time) ─────────────
// Mirrors Dispatch.js BATCH_OPTIONS: gaps (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) ───────────────────────────────────────────
type ViewMode = 'kitchens' | 'zones' | 'riders' | 'all' | 'rider-info';
type ViewMode = 'kitchens' | 'zones' | 'riders' | 'orders' | 'deliveries';
const VIEW_TABS: Array<{ id: ViewMode; label: string; icon: typeof MapIcon }> = [
{ id: 'kitchens', label: 'By Location', icon: MapPin },
{ id: 'zones', label: 'By Zone', icon: MapIcon },
{ id: 'riders', label: 'By Rider', icon: Bike },
{ id: 'all', label: 'All Routes', icon: Globe },
{ id: 'rider-info', label: 'Rider Info', icon: Info },
{ id: 'orders', label: 'By Orders', icon: ShoppingBag },
{ id: 'deliveries', label: 'By Deliveries', icon: Truck },
];
interface Group {
@@ -142,15 +109,15 @@ interface Group {
interface DispatchViewProps {
locationid?: number;
tenantId?: number;
}
const WEEKDAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
export default function DispatchView({ locationid }: DispatchViewProps) {
export default function DispatchView({ locationid, tenantId = FIESTA_TENANT_ID }: DispatchViewProps) {
const today = new Date();
const [date, setDate] = useState<string>(ymd(today));
const [batch, setBatch] = useState<BatchId>(initialBatch());
const [viewMode, setViewMode] = useState<ViewMode>('riders');
const [focusedId, setFocusedId] = useState<string | null>(null);
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
@@ -158,37 +125,26 @@ export default function DispatchView({ locationid }: DispatchViewProps) {
const [animateNonce, setAnimateNonce] = useState(0);
const [animating, setAnimating] = useState(false);
const deliveriesQ = useFiestaDeliveries({ tenantid: FIESTA_TENANT_ID, fromdate: date, todate: date });
const ridersQ = useFiestaRiders({ tenantid: FIESTA_TENANT_ID });
const deliveriesQ = useFiestaDeliveries({ tenantid: tenantId, fromdate: date, todate: date, locationid });
const ridersQ = useFiestaRiders({ tenantid: tenantId });
// Sample-data fallback: when the live feed returns nothing, render the demo set
// so the cockpit isn't blank. The header labels it "Sample data" so it's never
// mistaken for live (see services/dispatchMockData.ts).
const liveRows = deliveriesQ.data ?? [];
const usingMock = !deliveriesQ.isLoading && !deliveriesQ.isError && liveRows.length === 0;
const allRows = usingMock ? MOCK_DELIVERIES : liveRows;
// Sample rows aren't tied to the signed-in store, so skip the outlet filter for them.
const inScope = (r: Row) => usingMock || !locationid || fnum(r.locationid) === locationid;
// Live deliveries only — no sample/demo fallback. When the feed is empty the
// cockpit shows a genuine empty state rather than fabricated riders/stops.
const allRows = deliveriesQ.data ?? [];
const inScope = (r: Row) => !locationid || fnum(r.locationid) === locationid;
const rows = useMemo(
() => allRows.filter((r) => inScope(r) && inBatch(r, batch)),
() => allRows.filter(inScope),
// eslint-disable-next-line react-hooks/exhaustive-deps
[allRows, batch, locationid, usingMock],
[allRows, locationid],
);
const batchCounts = useMemo(() => {
const acc: Record<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 ────────────────────────────────────────────────────────────────
const groups = useMemo<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 } => {
if (viewMode === 'riders' || viewMode === 'rider-info') {
if (viewMode === 'riders') {
const id = fstr(r.userid) || fstr(r.ridername) || 'unassigned';
return { id, name: fstr(r.ridername) || fstr(r.username) || (id === 'unassigned' ? 'Unassigned' : `Rider ${id}`) };
}
@@ -196,7 +152,16 @@ export default function DispatchView({ locationid }: DispatchViewProps) {
const name = fstr(r.pickupcustomer) || fstr(r.pickuplocation) || 'Pickup';
return { id: name.toLowerCase(), name };
}
if (viewMode === 'all') return { id: 'all', name: 'All Routes' };
if (viewMode === 'orders') {
// Bucket by ORDER status (created / pending / processing / delivered / cancelled).
const s = fstr(r.orderstatus).toLowerCase() || 'unknown';
return { id: `o:${s}`, name: titleCase(s) };
}
if (viewMode === 'deliveries') {
// Bucket by DELIVERY/dispatch status (falls back to order status, then unassigned).
const s = (fstr(r.deliverystatus) || fstr(r.orderstatus)).toLowerCase() || 'unassigned';
return { id: `d:${s}`, name: titleCase(s) };
}
const name = fstr(r.deliverysuburb) || fstr(r.zone_name) || 'Unzoned';
return { id: name.toLowerCase(), name };
};
@@ -222,7 +187,7 @@ export default function DispatchView({ locationid }: DispatchViewProps) {
}, [rows, viewMode]);
const focused = groups.find((g) => g.id === focusedId) ?? null;
const groupedByRider = viewMode === 'zones' || viewMode === 'kitchens' || viewMode === 'all';
const groupedByRider = viewMode !== 'riders';
// Trip blocks for the focused group: by trip# (rider view) or by rider (zone/all view).
const tripBlocks = useMemo(() => {
@@ -264,7 +229,7 @@ export default function DispatchView({ locationid }: DispatchViewProps) {
}, [focused, groupedByRider, tripSort]);
// Map points: the focused group's ordered stops (with a route), else every stop
// in the wave (coloured per rider). Rows without coordinates are skipped.
// for the day (coloured per rider). Rows without coordinates are skipped.
const mapPoints = useMemo<MapPoint[]>(() => {
const src = focused ? tripBlocks.flatMap((b) => b.orders) : rows;
const out: MapPoint[] = [];
@@ -293,8 +258,7 @@ export default function DispatchView({ locationid }: DispatchViewProps) {
// KPI scope.
const totalOrders = rows.length;
const activeRiders = new Set(rows.map((r) => fstr(r.userid) || fstr(r.ridername)).filter(Boolean)).size;
const fleetSize = usingMock ? MOCK_RIDERS.length : (ridersQ.data ?? []).length;
const scopeLabel = BATCHES.find((b) => b.id === batch)?.label ?? 'All';
const fleetSize = (ridersQ.data ?? []).length;
// Date chip helpers.
const isToday = date === ymd(today);
@@ -338,9 +302,9 @@ export default function DispatchView({ locationid }: DispatchViewProps) {
<span className="live-status live-status-error">
<span className="live-dot error" /> Offline
</span>
) : usingMock ? (
<span className="live-status" title="No live deliveries for this day — showing sample data">
<span className="live-dot" style={{ background: '#f59e0b' }} /> Sample data · {totalOrders} orders
) : totalOrders === 0 ? (
<span className="live-status" title="No deliveries dispatched for this day">
<span className="live-dot" style={{ background: '#94a3b8' }} /> No deliveries today
</span>
) : (
<span className="live-status live-status-ready">
@@ -383,7 +347,7 @@ export default function DispatchView({ locationid }: DispatchViewProps) {
return (
<button
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); }}
>
<span className="sbt-icon"><Icon size={15} /></span>
@@ -393,24 +357,6 @@ export default function DispatchView({ locationid }: DispatchViewProps) {
})}
</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 ── */}
<div id="body" className={sidebarCollapsed ? 'sidebar-collapsed' : ''}>
<button
@@ -431,7 +377,7 @@ export default function DispatchView({ locationid }: DispatchViewProps) {
</div>
<span className="sb-header-scope">
<span className="sb-scope-dot" />
{scopeLabel}
{totalOrders} stops
</span>
</div>
<div className="sb-header-tiles">
@@ -466,15 +412,15 @@ export default function DispatchView({ locationid }: DispatchViewProps) {
fmtTime={fmtTime}
/>
) : groups.length === 0 ? (
<div className="ph">No deliveries in this wave</div>
<div className="ph">No deliveries for this day</div>
) : (
<>
<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>
{groups.map((g) => (
<React.Fragment key={g.id}>
{viewMode === 'riders' || viewMode === 'rider-info'
{viewMode === 'riders'
? <RiderCard g={g} onClick={() => setFocusedId(g.id)} />
: <ZoneCard g={g} onClick={() => setFocusedId(g.id)} />}
</React.Fragment>
@@ -492,22 +438,18 @@ export default function DispatchView({ locationid }: DispatchViewProps) {
route={Boolean(focused)}
routeColor={focused?.color || '#581c87'}
start={routeStart}
resizeKey={`${sidebarCollapsed}|${viewMode}|${focusedId}|${batch}`}
resizeKey={`${sidebarCollapsed}|${viewMode}|${focusedId}`}
animateNonce={animateNonce}
/>
{/* Contextual note overlaid on the map */}
{viewMode === 'rider-info' ? (
{mapPoints.length === 0 ? (
<div className="dmp-overlay-note">
<PlugZap size={13} /> Live rider telemetry (battery · GPS · speed) awaiting backend map shows planned drops.
</div>
) : mapPoints.length === 0 ? (
<div className="dmp-overlay-note">
<MapIcon size={13} /> No drop coordinates in this {focused ? 'route' : 'wave'} yet.
<MapIcon size={13} /> No drop coordinates in {focused ? 'this route' : 'these deliveries'} yet.
</div>
) : !focused ? (
<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>
) : null}

View File

@@ -4,7 +4,7 @@
*/
import React, { useState, useRef, useEffect } from 'react';
import { Menu, HelpCircle, LogOut, ChevronDown, Mail } from 'lucide-react';
import { Menu, HelpCircle, LogOut, ChevronDown, Mail, QrCode, User } from 'lucide-react';
import { MainSection } from '../types';
interface HeaderProps {
@@ -17,6 +17,10 @@ interface HeaderProps {
isSidebarOpen: boolean;
onHelpClick: () => void;
onLogoutClick: () => void;
/** When provided, shows a "My Account" item in the profile dropdown (user store page). */
onAccountClick?: () => void;
/** When provided, shows a Store QR button on the right of the navbar (user store page). */
onQrClick?: () => void;
/** Signed-in user shown in the profile dropdown. */
profile: { name: string; role: string; email: string };
}
@@ -26,6 +30,8 @@ export default function Header({
isSidebarOpen,
onHelpClick,
onLogoutClick,
onAccountClick,
onQrClick,
profile
}: HeaderProps) {
const [showProfileDropdown, setShowProfileDropdown] = useState(false);
@@ -81,6 +87,18 @@ export default function Header({
{/* Global Actions Bar */}
<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 */}
<div className="relative" ref={profileRef}>
<button
@@ -134,6 +152,17 @@ export default function Header({
{/* Account actions (moved here from the sidebar) */}
<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
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"

View File

@@ -38,6 +38,7 @@ import {
} from '../services/fiestaQueries';
import { FIESTA_TENANT_ID, str as fstr } from '../services/fiestaApi';
import { stockRowToProduct, stockRowToInventory } from '../services/fiestaMappers';
import { useStoreCatalogue } from '../services/storeCatalogue';
import AwaitingApi from './AwaitingApi';
type StockRow = Record<string, unknown>;
@@ -46,18 +47,20 @@ const rowId = (r: StockRow) => String(r.productid ?? '') || String(r.productname
interface InventoryViewProps {
searchQuery: string;
isCoimbatoreView: boolean;
tenantId?: number;
}
export default function InventoryView({
searchQuery,
isCoimbatoreView
isCoimbatoreView,
tenantId = FIESTA_TENANT_ID
}: InventoryViewProps) {
// ── Live stock across every outlet (Fiesta) ───────────────────────────────
// 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
// 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.
const locationsQ = useFiestaTenantLocations(FIESTA_TENANT_ID);
const locationsQ = useFiestaTenantLocations(tenantId);
const locations = useMemo(
() =>
(locationsQ.data ?? []).map((l) => ({
@@ -69,7 +72,7 @@ export default function InventoryView({
);
const storesStock = useFiestaStoresStock(
FIESTA_TENANT_ID,
tenantId,
locations.map(({ locationid, locationname }) => ({ locationid, locationname })),
);
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 [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 [outletFilter, setOutletFilter] = useState<'all' | 'alerts'>('all');
const [outletSearch, setOutletSearch] = useState('');
@@ -197,7 +202,7 @@ export default function InventoryView({
const handleAddNewProduct = (e: React.FormEvent) => {
e.preventDefault();
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;
}
@@ -217,7 +222,7 @@ export default function InventoryView({
setProducts([createdProd, ...products]);
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({
name: '',
@@ -274,9 +279,9 @@ export default function InventoryView({
if (parsedCount > 0) {
setProducts(prev => [...newProds, ...prev]);
alert(`Synchronized ${parsedCount} regional products into Catalog database successfully!`);
alert(`Synchronized ${parsedCount} regional products into Catalogue database successfully!`);
} 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">
<img
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"
/>
<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>
<h1 className="font-sans font-bold text-2xl tracking-tight text-white flex items-center gap-2">
<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">
Global Sync
</span>
</h1>
<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>
</div>
@@ -329,7 +334,7 @@ export default function InventoryView({
}`}
>
<Package size={13} />
<span>Catalog & Stocks</span>
<span>Catalogue & Stocks</span>
</button>
<button
@@ -361,7 +366,7 @@ export default function InventoryView({
<h3 className="text-xl font-extrabold tracking-tight font-mono">
{products.length}
</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>
@@ -405,7 +410,7 @@ export default function InventoryView({
{/* 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="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">
<ShieldCheck className="w-4 h-4" />
</div>
@@ -489,18 +494,26 @@ export default function InventoryView({
{/* 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="flex items-center justify-between mb-sm">
<div>
<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>
<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">
{filteredProducts.length} item{filteredProducts.length === 1 ? '' : 's'} loaded
</span>
</div>
</div>
{storesLoading && products.length === 0 ? (
<div className="text-center py-xl text-zinc-400 text-xs font-bold">Synchronizing regional database...</div>
) : 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">
{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>
</label>
</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>
@@ -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="flex items-center gap-xs text-[#0f172a] font-bold text-sm">
<Sparkles className="text-amber-500 animate-pulse" size={18} />
<h3>Cooperative Catalog Presets</h3>
<h3>Cooperative Catalogue Presets</h3>
</div>
<AwaitingApi label="Catalog presets" api="[R5]" compact />
<AwaitingApi label="Catalogue presets" api="[R5]" compact />
</div>
{/* 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">
<h4 className="font-bold text-[#0f172a] flex items-center gap-xs">
<Package size={15} className="text-[#581c87]" />
Introduce New Grocery Catalog SKU
Introduce New Grocery Catalogue SKU
</h4>
<button
onClick={() => setShowAddProductModal(false)}

View File

@@ -454,7 +454,7 @@ export default function OperationsView({ searchQuery, isCoimbatoreView }: Operat
<button
onClick={() => {
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:');
if (title && sku && category) {
setProductList(prev => [

View File

@@ -3,502 +3,495 @@
* 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 {
ShoppingBag,
Truck,
CheckCircle2,
Clock,
ShoppingBag, Clock, CheckCircle2, XCircle, MapPin, Phone,
Package, Loader2, X, Download, ChevronLeft, ChevronRight, Truck,
UserCheck,
MapPin,
TrendingUp,
ChevronRight,
Package,
ArrowRight,
AlertCircle,
Clock4,
Search,
Check,
Calendar,
X
} from 'lucide-react';
import { CustomerOrder } from '../types';
import {
useFiestaDeliveries,
useFiestaAllOrders,
useFiestaDeliverySummary,
useFiestaRiders,
useFiestaOrderDetails,
} from '../services/fiestaQueries';
import { FIESTA_TENANT_ID, num as fnum, str as fstr, ymd } from '../services/fiestaApi';
import { deliveryRowToOrder } from '../services/fiestaMappers';
import AwaitingApi from './AwaitingApi';
import { FIESTA_TENANT_ID, num as fnum, str as fstr, ymd, type Row } from '../services/fiestaApi';
import { shortTime } from '../services/fiestaMappers';
import {
GradientHeader, LiveStatus, KpiStrip, Pill, StatusChip, SearchPill, FilterBar, TH_STYLE,
ORDER_STATUS, statusColor, BRAND, BRAND_LIGHT, TEXT, TEXT_2, TEXT_3, BORDER, DIVIDER, SURFACE_ALT,
tint, soft, edge, ring,
} from './consoleUi';
interface OrdersDeliveriesViewProps {
searchQuery?: string;
isCoimbatoreView?: boolean;
locationid?: number;
tenantId?: number;
}
interface DeliveryExecutive {
id: string;
name: string;
phone: string;
status: 'Active Duty' | 'Idle' | 'Offline';
completedToday: number;
currentZone: string;
avatar: string;
}
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',
type StatusKey = 'all' | 'created' | 'pending' | 'processing' | 'delivered' | 'cancelled';
const STATUS_TABS: Array<{ key: StatusKey; label: string }> = [
{ key: 'all', label: 'All Orders' },
{ key: 'created', label: 'Created' },
{ key: 'pending', label: 'Pending' },
{ key: 'processing', label: 'Processing' },
{ key: 'delivered', label: 'Delivered' },
{ key: 'cancelled', label: 'Cancelled' },
];
const PAGE_SIZE = 20;
function riderRowToExecutive(row: Record<string, unknown>, idx: number): DeliveryExecutive {
return {
id: `DE-${fstr(row.userid) || idx}`,
name: fstr(row.fullname) || `${fstr(row.firstname)} ${fstr(row.lastname)}`.trim() || 'Rider',
phone: fstr(row.contactno) || '—',
status: fstr(row.starttime) ? 'Active Duty' : 'Idle',
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 = '',
locationid,
tenantId = FIESTA_TENANT_ID,
}: OrdersDeliveriesViewProps) {
const todayStr = ymd(new Date());
export default function OrdersDeliveriesView({ searchQuery = '', isCoimbatoreView = false, locationid }: OrdersDeliveriesViewProps) {
// ── Live deliveries / fleet (Fiesta) ──────────────────────────────────────
// 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 [status, setStatus] = useState<StatusKey>('all');
const [pageno, setPageno] = useState(1);
const [localSearch, setLocalSearch] = useState('');
const [detailOrder, setDetailOrder] = useState<Row | null>(null);
// Seed local state once live data arrives so existing dispatch/create handlers
// continue to mutate in-session.
// Search: Ctrl+K to focus, Escape to blur
const searchRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (deliveriesQ.data) {
const mapped = deliveriesQ.data.map(deliveryRowToOrder);
setOrders(mapped);
// 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.
setSelectedOrder((prev) =>
(prev && mapped.some((o) => o.id === prev.id)) ? prev : mapped[0] ?? null,
);
}
}, [deliveriesQ.data]);
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);
}, []);
useEffect(() => {
if (ridersQ.data) setExecutives(ridersQ.data.map(riderRowToExecutive));
}, [ridersQ.data]);
// ── Queries ──────────────────────────────────────────────────────────────────
const allOrdersQ = useFiestaAllOrders({ tenantid: tenantId, fromdate: todayStr, todate: todayStr, locationid });
const summaryQ = useFiestaDeliverySummary({ tenantid: tenantId, fromdate: todayStr, todate: todayStr, locationid });
const ridersQ = useFiestaRiders({ tenantid: tenantId });
const allRows = allOrdersQ.data ?? [];
const summary = summaryQ.data;
// Local filtered list of orders
const storeOrders = locationid ? orders.filter(o => o.locationid === locationid) : orders;
// Per-status row counts (client-side from merged data)
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 matchesSearch = o.id.toLowerCase().includes(term) ||
o.customerName.toLowerCase().includes(term) ||
o.address.toLowerCase().includes(term);
const matchesFilter = filterStatus === 'ALL' || o.status === filterStatus;
return matchesSearch && matchesFilter;
return allRows.filter((r) => {
if (locationid && fnum(r.locationid) !== locationid) return false;
if (status !== 'all' && fstr(r.orderstatus).toLowerCase() !== status) return false;
if (!term) return true;
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
const totalDeliveriesCount = storeOrders.length;
const pendingFulfillmentCount = storeOrders.filter(o => o.status === 'PROCESSING' || o.status === 'CONFIRMED').length;
const activeDispatchCount = storeOrders.filter(o => o.status === 'OUT_FOR_DELIVERY').length;
const completedDeliveriesCount = storeOrders.filter(o => o.status === 'DELIVERED').length;
// Pagination
const pageRows = useMemo(
() => rows.slice((pageno - 1) * PAGE_SIZE, pageno * PAGE_SIZE),
[rows, pageno],
);
const hasNext = rows.length > pageno * PAGE_SIZE;
// Live line-item details for the currently selected order. The deliveries board
// only carries an itemCount; the actual basket lines come from this endpoint.
const orderDetailsQ = useFiestaOrderDetails(selectedOrder?.id ?? null);
const orderItems = (orderDetailsQ.data ?? []).map((row) => {
const quantity = fnum(row.quantity) || fnum(row.qty);
const price = fnum(row.price) || fnum(row.unitprice);
const lineTotal = fnum(row.amount) || price * quantity;
// Totals
const totals = useMemo(() => {
let cod = 0, amount = 0;
for (const r of rows) {
cod += fnum(r.collectionamt);
amount += fnum(r.ordervalue) || fnum(r.orderamount) || fnum(r.deliveryamt);
}
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 {
name: fstr(row.productname) || fstr(row.itemname) || 'Item',
quantity,
price,
lineTotal,
lineTotal: fnum(row.amount) || fnum(row.productsumprice) || price * quantity,
};
});
return (
<div className="space-y-lg animate-in fade-in duration-500">
const st = fstr(order.orderstatus).toLowerCase();
const total = fnum(order.ordervalue) || fnum(order.orderamount) || fnum(order.deliveryamt);
const rider = fstr(order.ridername) || fstr(order.username);
{/* View Header with Statistics Overview */}
<div className="flex flex-col md:flex-row md:items-center justify-between gap-md border-b border-[#e2e8f0] pb-xl">
<div>
<h1 className="font-sans font-bold text-2xl tracking-tight text-[#0f172a]">
Orders & Delivery Operations
</h1>
<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>
const STEPS = [
{ label: 'Order Placed', field: 'orderdate' },
{ label: 'Confirmed', field: 'starttime' },
{ label: 'Packed & Ready', field: 'packtime' },
{ label: 'Out for Delivery',field: 'pickuptime' },
{ label: 'Delivered', field: 'deliverytime' },
];
{/* Top Level Delivery Performance Indicators */}
<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 => (
return createPortal(
<div
key={order.id}
onClick={() => setSelectedOrder(order)}
className={`p-md flex items-center justify-between hover:bg-zinc-50 border-l-4 transition-all cursor-pointer ${
selectedOrder?.id === order.id ? 'bg-[#faf5ff]/50 border-[#581c87]' : 'border-transparent'
}`}
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="space-y-1">
<div className="flex items-center gap-sm">
<span className="font-bold text-zinc-700">{order.customerName}</span>
<span className="text-[10px] text-zinc-400"> {order.time}</span>
</div>
<p className="text-zinc-500 truncate max-w-[24rem]">{order.address}</p>
<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>
<span className="text-[9px] text-[#581c87] font-bold">{order.itemCount ?? order.items.length} Items</span>
</div>
<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)' }}
>
{/* Brand accent bar */}
<div style={{ height: 4, background: `linear-gradient(90deg, ${BRAND} 0%, ${BRAND_LIGHT} 100%)` }} />
{/* Modal header */}
<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>
<button onClick={onClose} className="p-1 rounded-full cursor-pointer" style={{ color: TEXT_3 }}><X size={16} /></button>
</div>
<div className="text-right space-y-1">
<p className="font-bold font-mono text-sm text-[#0f172a]">{order.amount.toLocaleString()}</p>
<span className={`px-2 py-0.5 rounded text-[9px] font-bold tracking-wider inline-block uppercase ${
order.status === 'DELIVERED'
? 'bg-emerald-50 text-emerald-600 border border-emerald-100'
: order.status === 'OUT_FOR_DELIVERY'
? 'bg-purple-50 text-purple-700 border border-purple-100'
: order.status === 'CONFIRMED'
? 'bg-amber-50 text-amber-600 border border-amber-100 animate-pulse'
: 'bg-zinc-100 text-zinc-650 border border-zinc-200'
}`}>
{order.status.replace(/_/g, ' ')}
{/* Body */}
<div className="p-4 space-y-4 overflow-y-auto flex-1">
{/* Status + rider */}
<div className="flex items-center justify-between flex-wrap gap-2">
<StatusChip label={st || '—'} color={statusColor(ORDER_STATUS, st)} />
<div className="flex items-center gap-3">
{rider && (
<span className="inline-flex items-center gap-1.5 text-[11px] font-medium" style={{ color: TEXT_2 }}>
<UserCheck size={12} style={{ color: BRAND }} /> {rider}
</span>
</div>
</div>
))
)}
</div>
</div>
<span className="text-[11px] font-medium" style={{ color: TEXT_2 }}>{shortTime(order.orderdate || order.deliverydate)}</span>
</div>
</div>
{/* Right column — Order Details, shown parallel to the orders feed */}
<div className="lg:col-span-1 space-y-md">
{selectedOrder ? (
<div className="bg-white border border-[#e2e8f0] rounded-xl p-md shadow-sm space-y-md animate-in zoom-in-95 duration-150">
<span className="text-[10px] font-sans font-bold text-zinc-400 uppercase tracking-widest block pb-xs border-b border-[#f1f5f9]">
Order Details: {selectedOrder.id}
{/* Customer card */}
<div className="p-3 rounded-xl space-y-1.5" style={{ background: SURFACE_ALT, border: `1px solid ${BORDER}` }}>
<div className="flex items-center gap-2 font-bold" style={{ color: TEXT }}>
{fstr(order.deliverycustomer) || 'Customer'}
</div>
{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>
{/* Customer summary */}
<div className="p-sm bg-[#f8fafc] rounded-lg border border-[#e2e8f0]/50 space-y-xs">
<div className="flex justify-between font-semibold">
<span>Customer Name</span>
<span className="text-zinc-700">{selectedOrder.customerName}</span>
</div>
<div className="flex justify-between font-semibold">
<span>Contact info</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 className="space-y-2.5 pl-1">
{STEPS.map((s) => {
const ts = fstr(order[s.field]);
const done = Boolean(ts);
return (
<div key={s.field} className="flex items-center gap-2.5">
<CheckCircle2 size={13} style={{ color: done ? '#10b981' : '#cbd5e1' }} />
<span className="font-semibold text-xs" style={{ color: done ? TEXT : TEXT_3 }}>{s.label}</span>
<span className="ml-auto text-[10px] font-mono" style={{ color: TEXT_3 }}>{done ? shortTime(ts) : '—'}</span>
</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).
*/
import React, { useMemo, useState } from 'react';
import { ShoppingBag, Clock, CheckCircle2, XCircle, Calendar, ChevronLeft, ChevronRight, Package, MapPin, Phone, X, Loader2 } from 'lucide-react';
import { useFiestaOrderSummary, useFiestaOrders, useFiestaOrderDetails } from '../services/fiestaQueries';
import React, { useMemo, useState, useRef, useEffect } from 'react';
import { createPortal } from 'react-dom';
import { ShoppingBag, Clock, CheckCircle2, XCircle, Calendar, ChevronLeft, ChevronRight, Package, MapPin, Phone, X, Loader2, Download, UserCheck, ClipboardList, ArrowLeft } from 'lucide-react';
import { useFiestaOrderSummary, useFiestaOrders, useFiestaOrderDetails, useFiestaRiders, useFiestaAssignRider } from '../services/fiestaQueries';
import { FIESTA_TENANT_ID, num as fnum, str as fstr, ymd, type Row } from '../services/fiestaApi';
import { shortTime } from '../services/fiestaMappers';
import {
GradientHeader, LiveStatus, KpiStrip, Pill, StatusChip, MetricPill, SearchPill, FilterBar, TH_STYLE,
ORDER_STATUS, statusColor, BRAND, TEXT, TEXT_2, TEXT_3, BORDER, SURFACE_ALT, tint, soft, edge,
ORDER_STATUS, statusColor, BRAND, BRAND_LIGHT, TEXT, TEXT_2, TEXT_3, BORDER, SURFACE_ALT, tint, soft, edge, ring,
} from './consoleUi';
interface OrdersViewProps {
searchQuery?: string;
locationid?: number;
/** Merchant tenant to scope to; defaults to the shared constant. */
tenantId?: number;
}
type StatusKey = 'created' | 'pending' | 'processing' | 'delivered' | 'cancelled';
@@ -36,57 +39,167 @@ const STATUS_TABS: Array<{ key: StatusKey; label: string }> = [
];
const PAGE_SIZE = 25;
export default function OrdersView({ searchQuery = '', locationid }: OrdersViewProps) {
export default function OrdersView({ searchQuery = '', locationid, tenantId = FIESTA_TENANT_ID }: OrdersViewProps) {
const today = new Date();
const monthStart = new Date(today.getFullYear(), today.getMonth(), 1);
const [fromdate, setFromdate] = useState<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 dayAhead = (n: number) => { const d = new Date(); d.setDate(d.getDate() + n); return ymd(d); };
// NOTE: the backend lists orders by DELIVERY date (deliverytime), not creation
// date — so an order created today for a future slot only appears once the range
// covers its delivery date. "Next 7 Days" surfaces upcoming-delivery orders.
// "All time" can't pass empty dates (the query is gated on from/to), so it uses
// a wide window — from the platform's earliest plausible data to a year ahead.
const presets = [
{ key: 'today', label: 'Today', from: ymd(today), to: ymd(today) },
{ key: 'yesterday', label: 'Yesterday', from: dayOffset(1), to: dayOffset(1) },
{ key: '7d', label: 'Last 7 Days', from: dayOffset(6), to: ymd(today) },
{ key: '30d', label: 'Last 30 Days', from: dayOffset(29), to: ymd(today) },
{ key: 'month', label: 'This Month', from: ymd(monthStart), to: ymd(today) },
{ key: 'month', label: 'This Month', from: ymd(monthStart), to: dayAhead(7) },
];
const activePreset = presets.find((p) => p.from === fromdate && p.to === todate)?.key ?? 'custom';
const [status, setStatus] = useState<StatusKey>('created');
const [pageno, setPageno] = useState(1);
const [localSearch, setLocalSearch] = useState('');
const [branch, setBranch] = useState(0); // applocationid filter (0 = all branches)
const [detailOrder, setDetailOrder] = useState<Row | null>(null);
const summaryQ = useFiestaOrderSummary(FIESTA_TENANT_ID, fromdate, todate);
const ordersQ = useFiestaOrders({ tenantid: FIESTA_TENANT_ID, status, fromdate, todate, pageno, pagesize: PAGE_SIZE });
// ── Multi-select rider assignment (parity with the ops console) ─────────────
const [selected, setSelected] = useState<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 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 term = (localSearch || searchQuery).toLowerCase();
return rawRows.filter((r) => {
if (locationid && fnum(r.locationid) !== locationid) return false;
if (branch && fnum(r.applocationid) !== branch) return false;
if (!term) return true;
return (
fstr(r.orderid).toLowerCase().includes(term) ||
fstr(r.deliverycustomer).toLowerCase().includes(term) ||
fstr(r.pickupcustomer).toLowerCase().includes(term) ||
fstr(r.deliveryaddress).toLowerCase().includes(term) ||
fstr(r.deliverysuburb).toLowerCase().includes(term)
);
// Broad match across every order field shown or relevant (mirrors the ops
// console search): id, both parties + contacts + addresses, branch, rider,
// status, and notes.
return [
r.orderid, r.orderstatus, r.ordernotes, r.tenantname,
r.pickupcustomer, r.pickupcontactno, r.pickupsuburb, r.pickupaddress, r.pickuplocation,
r.deliverycustomer, r.deliverycontactno, r.deliverysuburb, r.deliveryaddress, r.deliverylocation,
r.applocation, r.locationname, r.ridername,
].some((v) => fstr(v).toLowerCase().includes(term));
});
}, [rawRows, localSearch, searchQuery, locationid]);
}, [rawRows, localSearch, searchQuery, locationid, branch]);
// Footer totals across the filtered rows (parity with the ops console's
// Total Charges / Total Amount summary).
const totals = useMemo(() => {
let cod = 0, charges = 0, amount = 0;
for (const r of rows) {
cod += fnum(r.collectionamt);
charges += fnum(r.deliverycharge) || fnum(r.deliverycharges);
amount += fnum(r.orderamount) || fnum(r.deliveryamt);
}
return { cod, charges, amount };
}, [rows]);
const inr = (n: number) => `${n.toLocaleString('en-IN')}`;
// Export the currently-filtered orders to CSV (RFC-4180 quoting).
const exportCsv = () => {
const headers = ['#', 'Order ID', 'Status', 'Branch', 'Order Date', 'Pickup', 'Pickup Contact', 'Pickup Address', 'Drop', 'Drop Contact', 'Drop Address', 'Qty', 'COD', 'KMs', 'Charges', 'Amount'];
const esc = (v: unknown) => `"${fstr(v).replace(/"/g, '""')}"`;
const lines = rows.map((r, i) => [
i + 1, fstr(r.orderid) || fstr(r.orderheaderid), fstr(r.orderstatus), fstr(r.applocation) || fstr(r.locationname),
shortTime(r.orderdate || r.deliverydate), fstr(r.pickupcustomer) || fstr(r.tenantname), fstr(r.pickupcontactno),
fstr(r.pickupaddress) || fstr(r.pickupsuburb), fstr(r.deliverycustomer), fstr(r.deliverycontactno),
fstr(r.deliveryaddress) || fstr(r.deliverysuburb), fnum(r.quantity), fnum(r.collectionamt),
fnum(r.kms), fnum(r.deliverycharge) || fnum(r.deliverycharges), fnum(r.orderamount) || fnum(r.deliveryamt),
].map(esc).join(','));
const blob = new Blob([[headers.join(','), ...lines].join('\n')], { type: 'text/csv;charset=utf-8;' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `Orders_${status}_${fromdate}_to_${todate}.csv`;
a.click();
URL.revokeObjectURL(url);
};
const hasNext = rawRows.length === PAGE_SIZE;
const total = summary?.total ?? 0;
const pct = (n: number) => (total > 0 ? Math.round((n / total) * 100) : 0);
const countFor = (key: StatusKey): number => (summary ? (summary[key] ?? 0) : 0);
// Restrained, professional palette — deep muted tones (not neon) so the KPI
// strip reads as a serious business dashboard rather than a colourful one.
const kpis = [
{ label: 'Created Orders', value: (summary?.created ?? 0).toLocaleString('en-IN'), color: '#0ea5e9', icon: <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: 'Delivered Orders', value: (summary?.delivered ?? 0).toLocaleString('en-IN'), color: '#10b981', 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: '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: '#9a6700', icon: <Clock size={20} />, badge: `${pct(summary?.pending ?? 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: '#b42318', icon: <XCircle size={20} />, badge: `${pct(summary?.cancelled ?? 0)}% of total` },
];
const setScope = (next: Partial<{ status: StatusKey; from: string; to: string }>) => {
@@ -96,6 +209,46 @@ export default function OrdersView({ searchQuery = '', locationid }: OrdersViewP
setPageno(1);
};
// ── Selection helpers ───────────────────────────────────────────────────────
const rowKey = (r: Row) => fstr(r.orderheaderid) || fstr(r.orderid);
const pageKeys = rows.map(rowKey);
const allSelected = pageKeys.length > 0 && pageKeys.every((k) => selected.has(k));
const toggleRow = (k: string) =>
setSelected((prev) => {
const n = new Set(prev);
if (n.has(k)) n.delete(k);
else n.add(k);
return n;
});
const toggleAll = () =>
setSelected((prev) => {
const n = new Set(prev);
if (allSelected) pageKeys.forEach((k) => n.delete(k));
else pageKeys.forEach((k) => n.add(k));
return n;
});
const handleAssign = async () => {
if (!assignRiderId || selected.size === 0) return;
const toAssign = rows.filter((r) => selected.has(rowKey(r)));
const rider = riderOptions.find((o) => o.id === assignRiderId)?.label ?? 'rider';
try {
const res = await assignMut.mutateAsync({ userid: assignRiderId, orders: toAssign });
setAssignMsg(
res.failed
? `Assigned ${res.ok}/${res.total} to ${rider} · ${res.failed} failed`
: `Assigned ${res.ok} order${res.ok === 1 ? '' : 's'} to ${rider}`,
);
setSelected(new Set());
setShowSelected(false); // return to the board with the result shown in the bar
} catch {
setAssignMsg('Assignment failed — please retry.');
}
};
// Rows currently selected (selection is always within the visible page).
const selectedRows = useMemo(() => rows.filter((r) => selected.has(rowKey(r))), [rows, selected]);
return (
<div className="animate-in fade-in duration-300">
<GradientHeader
@@ -132,10 +285,10 @@ export default function OrdersView({ searchQuery = '', locationid }: OrdersViewP
</div>
<div className="flex items-center gap-2 text-xs">
<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>
<input type="date" value={todate} min={fromdate} max={ymd(today)} 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' }} />
<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: `1px solid ${BORDER}`, background: '#fff', color: TEXT_2 }} />
</div>
</div>
</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 items-center gap-2 overflow-x-auto py-0.5 flex-1 min-w-0">
{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 (
<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')}>
@@ -155,41 +310,110 @@ export default function OrdersView({ searchQuery = '', locationid }: OrdersViewP
);
})}
</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>
</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 */}
<div className="bg-white border rounded-2xl overflow-hidden" style={{ borderColor: BORDER }}>
<div className="overflow-x-auto">
<table className="w-full" style={{ minWidth: 960 }}>
<thead>
<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>
))}
</tr>
</thead>
<tbody>
{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>
</td></tr>
) : 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) => {
const st = fstr(r.orderstatus).toLowerCase();
const cod = fnum(r.collectionamt);
const charges = fnum(r.deliverycharge) || fnum(r.deliverycharges);
return (
<tr key={fstr(r.orderid) || i} className="transition-colors" style={{ borderBottom: `1px solid ${DIVIDER_C}` }}
onMouseEnter={(e) => (e.currentTarget.style.background = SURFACE_ALT)} onMouseLeave={(e) => (e.currentTarget.style.background = 'transparent')}>
<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) => { 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">
<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-[130px]" 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-[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>
@@ -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>
</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">{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">{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: cod > 0 ? TEXT : TEXT_3 }}>{cod > 0 ? `${cod.toLocaleString('en-IN')}` : '—'}</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 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 text-right">
<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>
</table>
</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 }}>
<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">
@@ -224,12 +457,148 @@ export default function OrdersView({ searchQuery = '', locationid }: OrdersViewP
</div>
{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>
);
}
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 }) {
return (
<button onClick={onClick} disabled={disabled}
@@ -252,10 +621,15 @@ function OrderDetailModal({ order, onClose }: { order: Row; onClose: () => void
const st = fstr(order.orderstatus).toLowerCase();
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)' }}
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 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>
@@ -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>
</div>
</div>
</div>
</div>,
document.body,
);
}

View File

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

View File

@@ -14,19 +14,17 @@ import {
MapPin,
Phone,
Mail,
Plus
Plus,
Bike
} from 'lucide-react';
import { useFiestaAllTenants, useFiestaTenantLocations } from '../services/fiestaQueries';
import { useAppRoles } from '../services/queries';
import { FIESTA_TENANT_ID, str as fstr, num as fnum, roleName } from '../services/fiestaApi';
import UsersPanel from './UsersPanel';
import AwaitingApi from './AwaitingApi';
import AdminConsole from './AdminConsole';
interface SettingsViewProps {
tenantId?: number;
}
type TabKey = 'profile' | 'outlets' | 'users' | 'delivery' | 'payment' | 'preferences';
type TabKey = 'profile' | 'outlets' | 'users';
/** Locally-persisted merchant preferences (survive reload via localStorage). */
interface MerchantSettings {
@@ -138,6 +136,13 @@ export default function SettingsView({ tenantId = FIESTA_TENANT_ID }: SettingsVi
// (see [R6]) so they are not persisted; the operational controls that would
// need persistence show an AwaitingApi notice instead of saving silently.
const [form, setForm] = useState<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
// arrives (used at runtime by the Add User dialog / region label).
@@ -177,9 +182,6 @@ export default function SettingsView({ tenantId = FIESTA_TENANT_ID }: SettingsVi
{ key: 'profile', label: 'Business Profile', icon: Building2 },
{ key: 'outlets', label: 'Outlets', icon: Store },
{ key: 'users', label: 'Users & Access', icon: Users },
{ key: 'delivery', label: 'Delivery', icon: Truck },
{ key: 'payment', label: 'Payment & Tax', icon: CreditCard },
{ key: 'preferences', label: 'Preferences', icon: SlidersHorizontal },
];
// Build role options from the live app-roles API; fall back to the known
@@ -392,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="flex justify-between items-center pb-4 border-b border-slate-100">
<div>
<span className="text-xs font-bold text-slate-400 uppercase tracking-widest block">Our Stores</span>
<h2 className="text-xl font-bold text-slate-900 mt-1">Store Directory</h2>
</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 className="text-xs font-bold text-slate-450 uppercase tracking-widest block">
{showStoreOnboarding ? 'Onboarding' : 'Our Stores'}
</span>
<h2 className="text-xl font-bold text-slate-900 mt-1">
{showStoreOnboarding ? 'Add Store Outlet Location' : 'Store Directory'}
</h2>
</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 ? (
<div className="text-center py-lg text-slate-400 font-medium text-sm">Loading live outlets</div>
) : cleanOutlets.length === 0 ? (
@@ -435,7 +452,7 @@ export default function SettingsView({ tenantId = FIESTA_TENANT_ID }: SettingsVi
{/* 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="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">
{loc.deliveryradius ? `Up to ${loc.deliveryradius / 1000} km` : '—'}
</p>
@@ -461,6 +478,8 @@ export default function SettingsView({ tenantId = FIESTA_TENANT_ID }: SettingsVi
))}
</div>
)}
</>
)}
</div>
)}
@@ -468,100 +487,7 @@ export default function SettingsView({ tenantId = FIESTA_TENANT_ID }: SettingsVi
<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>

View File

@@ -9,7 +9,8 @@ import {
Store,
Layers,
Settings,
TrendingUp
TrendingUp,
ShieldAlert
} from 'lucide-react';
import { MainSection } from '../types';
@@ -19,6 +20,7 @@ interface SidebarProps {
isCoimbatoreView: boolean;
setIsCoimbatoreView: (val: boolean) => void;
isOpen: boolean;
isAdmin?: boolean;
}
export default function Sidebar({
@@ -26,20 +28,21 @@ export default function Sidebar({
setCurrentSection,
isCoimbatoreView,
setIsCoimbatoreView,
isOpen
isOpen,
isAdmin
}: SidebarProps) {
// Navigation elements
const navItems = [
{ id: 'dashboard' as MainSection, label: 'Dashboard', icon: LayoutDashboard },
{ id: 'stores' as MainSection, label: 'Stores', icon: Store },
{ id: 'inventory' as MainSection, label: 'Product Catalog', icon: Layers },
{ id: 'inventory' as MainSection, label: 'Product Catalogue', icon: Layers },
{ id: 'reports' as MainSection, label: 'Reports', icon: TrendingUp },
{ id: 'settings' as MainSection, label: 'Settings', icon: Settings }
];
return (
<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'
}`}
>
@@ -70,3 +73,4 @@ export default function Sidebar({
</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
* sees ONLY that manager-selected catalog (never the global one) and chooses which
* products to stock in their own store. Two tabs:
* • Browse Catalog — the manager-approved products, each addable to the store.
* • My Store Inventory — what's currently stocked at this outlet (live stock).
* Product-management flow (3 tiers):
* 1. Admin adds products to the GLOBAL catalogue and selects which ones (+ qty)
* to publish — that's the shared "store catalogue" (services/storeCatalogue).
* 2. The user sees ONLY that admin-curated catalogue here (never the global one)
* and chooses which products they need, each with their own quantity.
* 3. Those picks are the user's request for their store.
*
* The "manager-selected catalog" is sourced from the tenant master catalog
* (getMasterCatalog) for now — see CATALOG_SOURCE below; swap that one hook for
* the approved-products endpoint once it exists.
*
* Stocking a product at a location needs a write endpoint that isn't built yet,
* so selections are kept locally (persisted per store) and marked "pending sync".
* `commitSelectionToStore()` is the single integration point: replace its body
* with the real mutation when the backend is ready.
* The catalogue source is the shared store catalogue (localStorage bridge for now;
* backend: GET /products/getlocationproducts). The user's picks persist per store
* and `commitSelectionToStore()` is the single backend integration point
* (POST /products/createproductlocation / a stock-request endpoint).
*/
import React, { useEffect, useMemo, useState } from 'react';
import {
Search, Boxes, Layers, Plus, Check, CheckCircle2, X, Tag, Store, PackageSearch, AlertTriangle,
} from 'lucide-react';
import {
useFiestaMasterCatalog,
useFiestaStockStatement,
useFiestaProductCategories,
useFiestaProductSubcategories,
FIESTA_TENANT_ID,
} from '../services/fiestaQueries';
import { Search, Boxes, Layers, Plus, Minus, Check, CheckCircle2, X, Store, PackageSearch } from 'lucide-react';
import { useFiestaStockStatement, FIESTA_TENANT_ID } from '../services/fiestaQueries';
import { num as fnum, str as fstr, type Row } from '../services/fiestaApi';
import { categoryName } from '../services/fiestaMappers';
import { useStoreCatalogue } from '../services/storeCatalogue';
import AwaitingApi from './AwaitingApi';
const BRAND = '#581c87';
const PLACEHOLDER = 'https://images.unsplash.com/photo-1542838132-92c53300491e?auto=format&fit=crop&q=80&w=200';
interface StoreCatalogViewProps {
locationid?: number;
storeName?: string;
}
interface CatalogProduct {
id: string;
name: string;
image: string;
category: string;
categoryid: number;
subcategoryid: number;
subcategoryname: string;
price: number;
unit: string;
tenantId?: number;
}
function stockStatus(closing: number): { label: string; color: string } {
@@ -64,56 +42,68 @@ function stockStatus(closing: number): { label: string; color: string } {
return { label: 'Healthy', color: '#10b981' };
}
export default function StoreCatalogView({ locationid, storeName = 'your store' }: StoreCatalogViewProps) {
const tenantid = FIESTA_TENANT_ID;
const [view, setView] = useState<'catalog' | 'inventory'>('catalog');
/** Category → pill badge classes (mirrors the admin Global Catalogue card). */
function catBadgeClass(category: string): string {
const c = category.toLowerCase();
if (c.startsWith('staple')) return 'bg-amber-50 text-amber-600 border border-amber-100';
if (c.includes('grocer')) return 'bg-emerald-50 text-emerald-600 border border-emerald-100';
if (c.includes('beverage')) return 'bg-sky-50 text-sky-600 border border-sky-100';
return 'bg-rose-50 text-rose-600 border border-rose-100';
}
export default function StoreCatalogView({ locationid, storeName = 'your store', tenantId = FIESTA_TENANT_ID }: StoreCatalogViewProps) {
const tenantid = tenantId;
const [view, setView] = useState<'catalogue' | 'inventory'>('catalogue');
const [search, setSearch] = useState('');
const [categoryid, setCategoryid] = useState(0);
const [subcategoryid, setSubcategoryid] = useState(0);
const [category, setCategory] = useState('ALL');
const [notice, setNotice] = useState(false);
// Selections "to stock at this store" — persisted per outlet so choices survive
// a refresh until the backend write exists.
const storageKey = `nearledaily.catalog.selected.${locationid ?? 'na'}`;
const [selected, setSelected] = useState<Set<string>>(() => {
// The admin-curated catalogue (what the user is allowed to pick from).
const storeCat = useStoreCatalogue();
const products = useMemo(
() =>
storeCat.items.map((it) => ({
id: it.productid,
name: it.name,
sku: it.sku || `SKU-${it.productid}`,
image: it.image || PLACEHOLDER,
category: it.category || 'General',
price: it.price,
unit: it.unit,
adminQty: it.qty,
})),
[storeCat.items],
);
// The user's picks: productid → quantity they need. Persisted per store.
const storageKey = `nearledaily.catalogue.request.${locationid ?? 'na'}`;
const [picks, setPicks] = useState<Record<string, number>>(() => {
try {
const raw = localStorage.getItem(storageKey);
return new Set(raw ? (JSON.parse(raw) as string[]) : []);
return raw ? (JSON.parse(raw) as Record<string, number>) : {};
} catch {
return new Set();
return {};
}
});
useEffect(() => {
try { localStorage.setItem(storageKey, JSON.stringify([...selected])); } catch { /* ignore */ }
}, [selected, storageKey]);
try { localStorage.setItem(storageKey, JSON.stringify(picks)); } catch { /* ignore */ }
}, [picks, storageKey]);
// ── Data ──────────────────────────────────────────────────────────────────────
// CATALOG_SOURCE: the manager-selected assortment. Swap this hook for the
// approved-products endpoint when it's available; the rest of the page is agnostic.
const catalogQ = useFiestaMasterCatalog({ tenantid, subcategoryid: subcategoryid || undefined, pagesize: 200 });
const togglePick = (id: string) => {
setNotice(false);
setPicks((prev) => {
const next = { ...prev };
if (next[id] != null) delete next[id];
else next[id] = 1;
return next;
});
};
const setPickQty = (id: string, qty: number) => setPicks((prev) => ({ ...prev, [id]: Math.max(1, Math.round(qty) || 1) }));
const pickCount = Object.keys(picks).length;
// Store inventory (live stock) for the "My Store Inventory" tab + "In Store" tags.
const stockQ = useFiestaStockStatement({ tenantid, locationid: locationid ?? 0, pagesize: 200 });
const categoriesQ = useFiestaProductCategories();
const subcategoriesQ = useFiestaProductSubcategories({ categoryid, tenantid });
const products = useMemo<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 inventory = useMemo(
() =>
(stockQ.data ?? []).map((r: Row) => {
@@ -121,6 +111,8 @@ export default function StoreCatalogView({ locationid, storeName = 'your store'
return {
id: fstr(r.productid),
name: fstr(r.productname) || 'Unnamed product',
sku: fstr(r.sku) || `SKU-${fstr(r.productid)}`,
image: fstr(r.productimage) || PLACEHOLDER,
category: categoryName(fnum(r.categoryid)),
closing,
...stockStatus(closing),
@@ -128,78 +120,46 @@ export default function StoreCatalogView({ locationid, storeName = 'your store'
}),
[stockQ.data],
);
const filteredInventory = useMemo(() => {
const term = search.toLowerCase();
if (!term) return inventory;
return inventory.filter((it) => it.name.toLowerCase().includes(term) || it.category.toLowerCase().includes(term) || it.id.toLowerCase().includes(term));
}, [inventory, search]);
const categories = useMemo(() => [...new Set(products.map((p) => p.category))].sort(), [products]);
const filtered = useMemo(() => {
const term = search.toLowerCase();
return products.filter((p) => {
if (categoryid && p.categoryid !== categoryid) return false;
if (category !== 'ALL' && p.category !== category) return false;
if (!term) return true;
return p.name.toLowerCase().includes(term) || p.category.toLowerCase().includes(term) || p.id.toLowerCase().includes(term);
});
}, [products, search, categoryid]);
// Categories come from the Fiesta product-categories endpoint; if it returns
// nothing, fall back to the categories present in the loaded catalog so the
// filter is never empty.
const categories = useMemo(() => {
const fromApi = (categoriesQ.data ?? [])
.map((c) => ({ id: fnum(c.categoryid), name: fstr(c.categoryname) || categoryName(fnum(c.categoryid)) }))
.filter((c) => c.id);
if (fromApi.length) return fromApi;
const seen = new Map<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;
});
};
}, [products, search, category]);
// ── Integration point ──────────────────────────────────────────────────────────
// Replace this body with the real mutation: POST the selected product ids to the
// store/location assortment (stock-entry) endpoint, then invalidate stockQ.
const commitSelectionToStore = () => {
setNotice(true);
};
// Replace with the real request/stock POST (selected productids + quantities),
// then invalidate stockQ.
const commitSelectionToStore = () => setNotice(true);
return (
<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 */}
<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">
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>
</div>
{/* 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">
<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 ${
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
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" />
<input
type="text"
placeholder={view === 'catalog' ? 'Search catalog products…' : 'Search your stock…'}
placeholder={view === 'catalogue' ? 'Search catalogue products…' : 'Search your stock…'}
value={search}
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"
/>
{search && (
<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>
<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>
)}
</div>
{view === 'catalog' && (
{view === 'catalogue' && categories.length > 0 && (
<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">
<Layers size={13} className="text-[#581c87]" /> Filter
</span>
<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>
<select
value={categoryid}
onChange={(e) => { setCategoryid(Number(e.target.value)); setSubcategoryid(0); }}
value={category}
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"
>
<option value={0}>All categories</option>
{categories.map((c) => <option key={c.id} value={c.id}>{c.name}</option>)}
<option value="ALL">All categories</option>
{categories.map((c) => <option key={c} value={c}>{c}</option>)}
</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 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>
{/* ── Browse Catalog ── */}
{view === 'catalog' && (
catalogQ.isLoading ? (
<CenterState icon={<PackageSearch size={26} />} title="Loading catalog…" />
) : catalogQ.isError ? (
<CenterState icon={<AlertTriangle size={26} />} title="Couldn't load the catalog" sub="Check your connection and try again." tone="error" />
{/* ── Browse Catalogue ── */}
{view === 'catalogue' && (
products.length === 0 ? (
<CenterState
icon={<PackageSearch size={34} />}
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 ? (
<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) => {
const stocked = inStore.has(p.id);
const isSelected = selected.has(p.id);
const picked = picks[p.id] != null;
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 className="relative h-28 w-full overflow-hidden bg-zinc-50">
<img src={p.image} alt={p.name} className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-500" />
<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="flex gap-md">
{/* 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 && (
<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">
<CheckCircle2 size={10} /> In Store
</span>
<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>
)}
</div>
<div className="p-3 flex-1 flex flex-col">
<span className="inline-flex items-center gap-1 text-[9px] font-bold uppercase tracking-wider text-[#581c87] mb-1">
<Tag size={9} /> {p.category}
<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">{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>
<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>
{stocked ? (
<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">
<CheckCircle2 size={13} /> Stocked
</button>
) : isSelected ? (
<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">
<Check size={13} /> Selected
</button>
<div className="flex justify-between items-center pt-2">
<div>
<span className="text-[8px] text-zinc-400 block uppercase tracking-wider font-extrabold">Price</span>
<span className="font-extrabold text-zinc-700 font-mono text-xs">{p.price > 0 ? `${p.price.toLocaleString('en-IN')}` : '—'}</span>
</div>
<div className="text-right">
<span className="text-[8px] text-zinc-400 block uppercase tracking-wider font-extrabold">Admin Stock</span>
<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
</button>
)}
</div>
</div>
);
})}
</div>
)
)}
{/* ── My Store Inventory ── */}
{/* ── My Store Inventory ── (card grid — same design as Browse Catalogue) */}
{view === 'inventory' && (
<div className="bg-white border border-[#e2e8f0] rounded-2xl shadow-sm overflow-hidden">
<div className="overflow-x-auto">
<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>
stockQ.isLoading ? (
<CenterState icon={<Store size={34} />} title="Loading your stock…" sub="Fetching the latest stock levels for your store." />
) : !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 ? (
<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) => (
<tr key={it.id || i} className="hover:bg-zinc-50/70 transition-colors">
<td className="px-4 py-3 font-mono text-zinc-400">{i + 1}</td>
<td className="px-4 py-3 font-bold text-[#0f172a]">{it.name}</td>
<td className="px-4 py-3 text-zinc-500">{it.category}</td>
<td className="px-4 py-3 text-right font-mono font-bold text-zinc-700">{it.closing.toLocaleString('en-IN')}</td>
<td className="px-4 py-3 text-center">
<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` }}>
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-md">
{filteredInventory.map((it, i) => (
<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">
<div className="flex gap-md">
{/* Thumbnail with status corner dot */}
<div className="w-16 h-16 rounded-xl border border-zinc-100 shrink-0 overflow-hidden bg-zinc-50 relative">
<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="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}
</span>
</td>
</tr>
))
)}
</tbody>
</table>
<span className="text-[9px] text-zinc-400 font-semibold">{it.category.split(' / ')[0]}</span>
</div>
</div>
))}
</div>
)
)}
{/* ── Selection action bar (sticky) ── */}
{view === 'catalog' && selected.size > 0 && (
<div className="fixed bottom-4 left-1/2 -translate-x-1/2 z-[120] w-[min(640px,calc(100vw-2rem))]">
{/* ── Selection action bar ── */}
{view === 'catalogue' && pickCount > 0 && (
<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">
{notice ? (
<div className="space-y-2">
<div className="flex items-center justify-between">
<span className="text-xs font-bold">{selected.size} product{selected.size > 1 ? 's' : ''} marked 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>
<span className="text-xs font-bold">{pickCount} product{pickCount > 1 ? 's' : ''} requested for {storeName}</span>
<button onClick={() => { setPicks({}); setNotice(false); }} className="text-[11px] font-semibold text-purple-200 hover:text-white cursor-pointer">Clear</button>
</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 className="flex items-center justify-between gap-3">
<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>
<div className="min-w-0">
<p className="text-xs font-bold truncate">{selected.size} product{selected.size > 1 ? 's' : ''} selected</p>
<p className="text-[10px] text-purple-200">Ready to stock at {storeName}</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">Selected for {storeName}</p>
</div>
</div>
<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">
<Plus size={13} /> Add to Store
<Check size={13} /> Request for Store
</button>
</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 (
<div className="bg-white border border-dashed border-[#e2e8f0] rounded-2xl p-12 text-center">
<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>
<p className="font-bold text-sm text-zinc-700">{title}</p>
{sub && <p className="text-xs text-zinc-400 mt-1">{sub}</p>}
<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">
{/* Soft decorative glows */}
<div className="pointer-events-none absolute -top-20 -right-20 w-60 h-60 rounded-full bg-purple-200/30 blur-3xl" />
<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>
);
}

View File

@@ -30,19 +30,24 @@ import {
CreditCard,
History,
Building,
Award
Award,
ShoppingBag,
QrCode,
ChevronRight,
AtSign
} from 'lucide-react';
import {
useFiestaStockStatement,
useFiestaTenantCustomers,
useFiestaCustomerOrders,
useFiestaMasterCatalog,
useFiestaRiders,
FIESTA_TENANT_ID
} from '../services/fiestaQueries';
import { str as fstr, num as fnum } from '../services/fiestaApi';
import { mapOrderStatus, shortTime } from '../services/fiestaMappers';
import ragulStoreCover from '../assets/images/store_front_view_1780299351800.png';
import OrdersDeliveriesView from './OrdersDeliveriesView';
import StoreQRView from './StoreQRView';
import AwaitingApi from './AwaitingApi';
interface StoreDetailViewProps {
@@ -68,6 +73,8 @@ interface StoreDetailViewProps {
* Overview, Inventory & Catalogue, and Customers into separate pages. When
* omitted, the full tabbed console renders (admin store detail). */
only?: 'overview' | 'inventory' | 'customers';
/** Merchant tenant to scope to; defaults to the shared constant. */
tenantId?: number;
}
// Fallback cover images
@@ -86,8 +93,8 @@ const DETAIL_STORE_COVERS = [
'https://images.unsplash.com/photo-1579621970563-ebec7560ff3e?auto=format&fit=crop&w=800&q=80'
];
export default function StoreDetailView({ store, onBack, canManage = true, only }: StoreDetailViewProps) {
const [activeTab, setActiveTab] = useState<'overview' | 'inventory' | 'customers'>('overview');
export default function StoreDetailView({ store, onBack, canManage = true, only, tenantId = FIESTA_TENANT_ID }: StoreDetailViewProps) {
const [activeTab, setActiveTab] = useState<'overview' | 'inventory' | 'customers' | 'orders' | 'qr'>('overview');
// Which section to show: forced by `only` (separate-page mode) or the active tab.
const section = only ?? activeTab;
// The immersive store banner shows on Overview (and the admin tabbed console);
@@ -133,8 +140,6 @@ export default function StoreDetailView({ store, onBack, canManage = true, only
const [localInventory, setLocalInventory] = useState<any[]>([]);
const [showImportModal, setShowImportModal] = useState(false);
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 ──────────────────────────────────────
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 ───────────────────────────────────────
const locationid = store.locationid || 1097;
const stockQ = useFiestaStockStatement({
tenantid: FIESTA_TENANT_ID,
tenantid: tenantId,
locationid,
pagesize: 100
});
const customersQ = useFiestaTenantCustomers({
tenantid: FIESTA_TENANT_ID,
tenantid: tenantId,
locationid,
pagesize: 100
});
// Live active rider fleet for this tenant (powers KPI fleet count + fleet list)
const ridersQ = useFiestaRiders({ tenantid: FIESTA_TENANT_ID });
// Master catalogue rows for the Global Catalogue modal
const masterCatalogQ = useFiestaMasterCatalog({
tenantid: FIESTA_TENANT_ID,
locationid,
pagesize: 100
});
const ridersQ = useFiestaRiders({ tenantid: tenantId });
// Past orders for the currently-open CRM drawer customer (disabled when no id)
const customerOrdersQ = useFiestaCustomerOrders({
customerid: selectedCustomer?.id ?? null,
@@ -268,20 +267,6 @@ export default function StoreDetailView({ store, onBack, canManage = true, only
};
});
// ── Global Master Catalogue (live) for the "Add from Catalogue" modal ──────
const globalCatalogueItems = (masterCatalogQ.data ?? []).map((row: any) => {
const price = fnum(row.retailprice) || fnum(row.price) || fnum(row.productcost);
return {
sku: fstr(row.sku) || fstr(row.productsku) || `SKU-${fstr(row.productid)}` || 'SKU-UNKNOWN',
name: fstr(row.productname) || fstr(row.name) || 'Product Item',
category: fstr(row.subcategoryname) || fstr(row.categoryname) || 'Catalogue',
price: price > 0 ? price : null,
image:
fstr(row.productimage) ||
'https://images.unsplash.com/photo-1542838132-92c53300491e?auto=format&fit=crop&w=150&q=80'
};
});
// Actions simulation handles
const handleReplenishSubmit = (e: React.FormEvent) => {
e.preventDefault();
@@ -328,30 +313,6 @@ export default function StoreDetailView({ store, onBack, canManage = true, only
}, 700);
};
// Add items from Global Catalog
const handleAddGlobalCatalogue = () => {
if (selectedGlobalSkus.length === 0) {
showToast('Kindly select at least one catalogue item.', 'warning');
return;
}
const itemsToAdd = globalCatalogueItems.filter(item => selectedGlobalSkus.includes(item.sku)).map(item => ({
...item,
stockLevel: 0,
maxCapacity: 200,
status: 'Critical'
}));
setLocalInventory(prev => {
const filtered = prev.filter(item => !itemsToAdd.some(ni => ni.sku === item.sku));
return [...filtered, ...itemsToAdd];
});
showToast(`${itemsToAdd.length} products synced from Master Global Catalogue successfully!`, 'success');
setSelectedGlobalSkus([]);
setShowGlobalModal(false);
};
const handleExportLedger = () => {
showToast(`Generating secure PDF ledger audit reports for ${store.name}...`, 'info');
setTimeout(() => {
@@ -521,7 +482,7 @@ export default function StoreDetailView({ store, onBack, canManage = true, only
}`}
>
<Layers size={14} />
<span>Inventory & Catalogue ({inventoryList.length})</span>
<span>Inventory ({inventoryList.length})</span>
{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>
)}
@@ -537,6 +498,28 @@ export default function StoreDetailView({ store, onBack, canManage = true, only
<Users size={14} />
<span>Customer CRM Base ({customersList.length})</span>
</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>
)}
@@ -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" />
<input
type="text"
placeholder="Search local stocks catalogue..."
placeholder="Search inventory by product or SKU..."
value={stockSearch}
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"
@@ -749,14 +732,6 @@ export default function StoreDetailView({ store, onBack, canManage = true, only
<span>Import Manual (CSV)</span>
</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" />
</>
)}
@@ -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="p-md border-b border-[#eceef2] bg-[#f8fafc] flex justify-between items-center">
<h4 className="font-sans font-bold text-sm text-[#0f172a]">
Product Stock Levels & Catalog
Product Stock Levels
</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>
</div>
@@ -885,111 +860,175 @@ export default function StoreDetailView({ store, onBack, canManage = true, only
</div>
)}
{section === 'customers' && (
<div className="space-y-lg animate-in fade-in duration-300">
{/* Customer directory search and metrics */}
<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">
<div className="relative w-full sm:w-80 sm:shrink-0">
<Search size={15} className="absolute left-3 top-1/2 -translate-y-1/2 text-zinc-400" />
<input
type="text"
placeholder="Search CRM profile roster..."
value={customerSearch}
onChange={(e) => setCustomerSearch(e.target.value)}
className="w-full pl-9 pr-4 py-2.5 border border-[#e2e8f0] rounded-xl text-xs outline-none bg-[#f8fafc] focus:bg-white focus:ring-1 focus:ring-[#581c87] transition-all"
/>
</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'
{section === 'customers' && (() => {
const withPhone = customersList.filter((c: any) => c.phone && c.phone !== '—').length;
const withEmail = customersList.filter((c: any) => c.email).length;
// Jewel-tone identity per customer (deterministic by name) — a calm header
// band gradient + a soft solid avatar tint drawn from the same hue.
const tones = [
{ soft: '#f3effb', fg: '#6d28d9', band: 'linear-gradient(135deg,#6d28d9 0%,#9333ea 100%)' },
{ soft: '#e9f5f1', fg: '#0f766e', band: 'linear-gradient(135deg,#0f766e 0%,#14b8a6 100%)' },
{ soft: '#fdf0eb', fg: '#c2410c', band: 'linear-gradient(135deg,#c2410c 0%,#f97316 100%)' },
{ soft: '#ebeefb', fg: '#3a4fc4', band: 'linear-gradient(135deg,#3949c4 0%,#6366f1 100%)' },
{ soft: '#fceef4', fg: '#be185d', band: 'linear-gradient(135deg,#be185d 0%,#ec4899 100%)' },
{ soft: '#e9f3fb', fg: '#0369a1', band: 'linear-gradient(135deg,#0369a1 0%,#0ea5e9 100%)' },
];
const 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 (
<tr key={idx} className="hover:bg-[#f8fafc]/60 transition-colors">
<td className="px-md py-md">
<div className="flex items-center gap-xs">
<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`}>
{initials}
<div className="animate-in fade-in duration-300">
{/* Page heading */}
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 mb-6">
<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>
<span className="font-bold text-[#0f172a]">{c.name}</span>
</div>
</td>
<td className="px-md py-md font-mono text-zinc-500 font-semibold">{c.phone}</td>
<td className="px-md py-md max-w-xs truncate text-zinc-500 font-medium" title={c.address}>
{c.address}
{/* Phone */}
<td className="px-5 py-3.5">
<span className="text-[13px] text-zinc-700 tabular-nums">{c.phone}</span>
</td>
<td className="px-md py-md text-zinc-700 font-bold">{c.ordersCount} orders</td>
<td className="px-md py-md text-[#581c87] font-black">{c.totalSpent}</td>
<td className="px-md py-md text-right space-x-sm shrink-0">
{/* Email */}
<td className="px-5 py-3.5">
{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 && (
<button
onClick={() => showToast(`Voucher promo code successfully dispatched 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"
onClick={() => showToast(`Promo code sent to ${c.phone}.`, 'success')}
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
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>
</div>
</td>
</tr>
);
})
)}
})}
</tbody>
</table>
</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>
);
})()}
{/* 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 ── */}
{replenishModal.show && replenishModal.item && (
@@ -1112,7 +1151,7 @@ export default function StoreDetailView({ store, onBack, canManage = true, only
<div>
<p className="font-bold text-[#0f172a] uppercase tracking-wide">
{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...'}
</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>
)}
{/* ── 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 ── */}
{selectedCustomer && (
<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 { createPortal } from 'react-dom';
import {
AlertTriangle,
LayoutDashboard,
User,
Mail,
Phone,
Store,
@@ -18,6 +18,7 @@ import {
ClipboardList,
Layers,
Users,
X,
} from 'lucide-react';
import {
useFiestaTenantLocations,
@@ -33,6 +34,7 @@ import OrdersView from './OrdersView';
import DeliveriesView from './DeliveriesView';
import DispatchView from './DispatchView';
import DeliveryReportsView from './DeliveryReportsView';
import StoreQRView from './StoreQRView';
import UserStoreSidebar, { type UserNavItem } from './UserStoreSidebar';
interface UserStorePageProps {
@@ -46,13 +48,12 @@ interface UserStorePageProps {
// gets a matching branch in `renderSection` below.
const NAV_ITEMS: UserNavItem[] = [
{ id: 'console', label: 'Store Console', icon: LayoutDashboard },
{ id: 'inventory', label: 'Inventory & Catalog', icon: Layers },
{ id: 'inventory', label: 'Product Catalogue', icon: Layers },
{ id: 'customers', label: 'Customers', icon: Users },
{ id: 'orders', label: 'Orders', icon: ShoppingBag },
{ id: 'deliveries', label: 'Deliveries', icon: Truck },
{ id: 'dispatch', label: 'Dispatch', icon: Route },
{ id: 'reports', label: 'Delivery Reports', icon: ClipboardList },
{ id: 'account', label: 'My Account', icon: User },
{ id: 'reports', label: 'Reports', icon: ClipboardList },
];
type StoreShape = React.ComponentProps<typeof StoreDetailView>['store'];
@@ -66,9 +67,14 @@ type StoreShape = React.ComponentProps<typeof StoreDetailView>['store'];
export default function UserStorePage({ onLogout, user }: UserStorePageProps) {
const [sidebarOpen, setSidebarOpen] = useState(true);
const [activeSection, setActiveSection] = useState<string>('console');
const [showQrModal, setShowQrModal] = useState(false);
const locationsQ = useFiestaTenantLocations(FIESTA_TENANT_ID);
const locSummaryQ = useFiestaLocationSummary(FIESTA_TENANT_ID);
// Scope every query to the signed-in merchant's tenant; the shared constant is
// only a fallback for legacy sessions whose record predates tenantid capture.
const tenantId = user.tenantid || FIESTA_TENANT_ID;
const locationsQ = useFiestaTenantLocations(tenantId);
const locSummaryQ = useFiestaLocationSummary(tenantId);
const locations = locationsQ.data ?? [];
const summaries = locSummaryQ.data ?? [];
@@ -188,14 +194,14 @@ export default function UserStorePage({ onLogout, user }: UserStorePageProps) {
// Logistics console — scoped to this user's store. These views own their
// loading/error states, so they don't need the store-console load gating below.
if (activeSection === 'orders') return <OrdersView locationid={resolvedLocationId || undefined} />;
if (activeSection === 'deliveries') return <DeliveriesView locationid={resolvedLocationId || undefined} />;
if (activeSection === 'dispatch') return <DispatchView locationid={resolvedLocationId || undefined} />;
if (activeSection === 'reports') return <DeliveryReportsView />;
if (activeSection === 'orders') return <OrdersView locationid={resolvedLocationId || undefined} tenantId={tenantId} />;
if (activeSection === 'deliveries') return <DeliveriesView locationid={resolvedLocationId || undefined} tenantId={tenantId} />;
if (activeSection === 'dispatch') return <DispatchView locationid={resolvedLocationId || undefined} tenantId={tenantId} />;
if (activeSection === 'reports') return <DeliveryReportsView tenantId={tenantId} />;
// Inventory & Catalog is its own page: the manager-curated catalog the user
// stocks from (the catalog query is tenant-level, so it doesn't need the store
// gating below — only "My Store Inventory" uses the resolved location id).
if (activeSection === 'inventory') return <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.
if (locationsQ.isLoading || locSummaryQ.isLoading) {
@@ -259,7 +265,7 @@ export default function UserStorePage({ onLogout, user }: UserStorePageProps) {
// Overview & Performance; Customers is its own page (Inventory & Catalog is the
// dedicated StoreCatalogView, handled above).
const only = activeSection === 'customers' ? 'customers' : 'overview';
return <StoreDetailView store={buildStore()} canManage={false} only={only} />;
return <StoreDetailView store={buildStore()} canManage={false} only={only} tenantId={tenantId} />;
};
return (
@@ -269,6 +275,8 @@ export default function UserStorePage({ onLogout, user }: UserStorePageProps) {
onToggleSidebar={() => setSidebarOpen((s) => !s)}
onHelpClick={handleHelp}
onLogoutClick={onLogout}
onAccountClick={() => setActiveSection('account')}
onQrClick={() => setShowQrModal(true)}
profile={profile}
/>
@@ -296,6 +304,40 @@ export default function UserStorePage({ onLogout, user }: UserStorePageProps) {
)}
</main>
</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>
);
}

View File

@@ -29,7 +29,7 @@ interface UserStoreSidebarProps {
export default function UserStoreSidebar({ items, activeId, onSelect, isOpen }: UserStoreSidebarProps) {
return (
<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'
}`}
>

View File

@@ -9,7 +9,7 @@
* Self-contained: search box, role filter, live query, and Add User modal.
*/
import React, { useState } from 'react';
import React, { useState, useEffect } from 'react';
import {
Users,
Search,
@@ -21,15 +21,17 @@ import {
Mail,
Phone,
MapPin,
Lock,
UserCheck,
Check,
SlidersHorizontal,
Coins
Coins,
Store,
Bike
} from 'lucide-react';
import { useFiestaUsers, useFiestaCreateUser } from '../services/fiestaQueries';
import { useFiestaUsers, useFiestaCreateUser, useFiestaRiderShifts, useFiestaTenantLocations } from '../services/fiestaQueries';
import { useAppRoles } from '../services/queries';
import { FIESTA_TENANT_ID, str as fstr, num as fnum, roleName } from '../services/fiestaApi';
import AddressAutocomplete, { type AddressResult } from './AddressAutocomplete';
interface UsersPanelProps {
tenantId?: number;
@@ -45,7 +47,7 @@ const USER_AVATARS = [
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' },
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' },
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' },
@@ -63,7 +65,7 @@ const ROLE_META: Record<number, { icon: typeof ShieldAlert; desc: string }> = {
/** Fallback role choices when the app-roles API returns nothing. */
const FALLBACK_ROLE_CHOICES = [
{ 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: 4, label: 'Staff', desc: 'Standard staff duties', icon: User },
{ 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,
// 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 rows = rolesQ.data ?? [];
const mapped = rows
.map((r) => {
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]);
return [
{ id: 4, label: 'Staff', desc: 'Standard store staff duties', icon: User, configid: 15 },
{ id: 5, label: 'Rider', desc: 'Delivery fleet rider', icon: Bike, configid: 6 },
];
}, []);
const [search, setSearch] = useState('');
const [userRoleFilter, setUserRoleFilter] = useState<number | 'ALL'>('ALL');
const [userRoleFilter, setUserRoleFilter] = useState<string | 'ALL'>('ALL');
const [showAddUserModal, setShowAddUserModal] = useState(false);
const [newUser, setNewUser] = useState({
firstname: '',
lastname: '',
email: '',
contactno: '',
password: '',
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).
const users = (usersQ.data ?? []).map((u, i) => {
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) || '—',
contact: fstr(u.contactno) || '—',
roleid: Number(u.roleid),
role: roleName(Number(u.roleid)),
role: roleName(Number(u.roleid)) === 'Manager' ? 'Admin' : roleName(Number(u.roleid)),
shift: shift && shift !== '-' ? shift : '—',
location: fstr(u.applocation) || fstr(u.city) || 'Coimbatore',
status: fstr(u.status) || 'Active',
@@ -132,30 +181,59 @@ export default function UsersPanel({ tenantId = FIESTA_TENANT_ID, defaultNewUser
u.name.toLowerCase().includes(q) ||
u.email.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;
});
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) => {
e.preventDefault();
if (!newUser.firstname || !newUser.email || !newUser.contactno || !newUser.password) {
alert('Please provide first name, email, contact number, and a password.');
if (!newUser.firstname || !newUser.email || !newUser.contactno) {
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;
}
try {
await createUserMut.mutateAsync({
firstname: newUser.firstname,
lastname: newUser.lastname,
email: newUser.email,
contactno: newUser.contactno,
password: newUser.password,
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,
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);
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.`);
} catch (err) {
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 */}
<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">
<span className="absolute inset-y-0 left-0 pl-3.5 flex items-center pointer-events-none text-slate-450">
<div className="relative w-full md:max-w-md shrink-0 group">
<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" />
</span>
<input
type="text"
placeholder="Search team"
placeholder="Search team members by name, email, phone..."
value={search}
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 && (
<button
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>
)}
</div>
</div>
{/* Role filter capsules */}
<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
</button>
{roleOptions.map((rid) => (
{roleOptions.map((roleNameStr) => (
<button
key={rid}
onClick={() => setUserRoleFilter(rid)}
key={roleNameStr}
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 ${
userRoleFilter === rid
userRoleFilter === roleNameStr
? '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'
}`}
>
{roleName(rid)}
{roleNameStr}
</button>
))}
</div>
@@ -352,10 +432,9 @@ export default function UsersPanel({ tenantId = FIESTA_TENANT_ID, defaultNewUser
</p>
<div className="space-y-4">
{/* Name Fields */}
<div className="grid grid-cols-2 gap-sm">
{/* Firstname */}
<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
type="text"
placeholder="e.g. Harini"
@@ -365,21 +444,30 @@ export default function UsersPanel({ tenantId = FIESTA_TENANT_ID, defaultNewUser
required
/>
</div>
{/* Contactno */}
<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
type="text"
placeholder="e.g. Rajan"
value={newUser.lastname}
onChange={(e) => setNewUser({ ...newUser, lastname: 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"
inputMode="numeric"
maxLength={10}
placeholder="e.g. 9988776655"
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>
{/* Email & Contact */}
{/* Email */}
<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">
<span className="absolute inset-y-0 left-0 pl-3.5 flex items-center pointer-events-none text-slate-400">
<Mail size={14} />
@@ -395,23 +483,6 @@ export default function UsersPanel({ tenantId = FIESTA_TENANT_ID, defaultNewUser
</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 */}
<div className="space-y-2">
<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>
{/* Temporary Password */}
{/* Store / branch — binds the user to a specific outlet (tenantid + locationid + applocationid) */}
<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">
<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>
<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
type="text"
placeholder="Set password credentials"
value={newUser.password}
onChange={(e) => setNewUser({ ...newUser, password: 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 font-mono text-sm shadow-sm"
required
value={newUser[key]}
onChange={(e) => setNewUser({ ...newUser, [key]: 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"
/>
</div>
))}
</div>
</div>
</div>

View File

@@ -245,7 +245,7 @@ export function StampCell({ date, time }: { date?: string; time?: string }) {
}
// ── 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 (
<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">
@@ -253,6 +253,7 @@ export function SearchPill({ value, onChange, placeholder, color = BRAND }: { va
<path d="m21 21-4.3-4.3" />
</svg>
<input
ref={inputRef}
type="text"
value={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 './index.css';
// Single shared query client. Sensible defaults for a dashboard: cache for a
// minute, one retry, and no refetch storm when the window regains focus.
// How often every page silently re-syncs with the backend. Orders/deliveries
// 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({
defaultOptions: {
queries: {
staleTime: 60_000,
staleTime: 15_000,
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.
*/
import { cleanTenantLocations } from './fiestaApi';
const HASURA_BASE = '/hasura';
/** 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 }));
}
/** /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[]> {
return toRows(await hasuraGet('gettenantlocations', { tenantid }));
return cleanTenantLocations(toRows(await hasuraGet('gettenantlocations', { tenantid })));
}
/** /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 ──────────────────────────────────────────────────────
/**
* Fiesta application login. Routed through the Vite `/fiesta` proxy →
* https://fiesta.nearle.app/live/api/v1/web/users/applogin.
* Fiesta application login — called directly at
* https://fiesta.nearle.app/live/api/v1/web/users/applogin (CORS-enabled).
* Observed shape:
* request: { authname: <email>, password: <password>, configid: 1, userfcmtoken: null }
* failure: { code: 409, message: "Invalid Email", status: false }
* 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. */
const REQUEST_FIELDS = {
@@ -54,6 +54,10 @@ const RESPONSE_FIELDS = {
email: 'email',
contactno: 'contactno',
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
// applocationid; `applocation` is its human-readable name (e.g. "Coimbatore").
// locationid/locationname are captured when present (often 0/absent on the
@@ -83,6 +87,8 @@ export interface AuthUser {
roleid?: number;
/** Phone number on the user record. */
contactno?: string;
/** The merchant/tenant this user belongs to — scopes every Fiesta query. */
tenantid?: number;
/** The app-location this user is allocated to. */
applocationid?: number;
/** 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,
roleid,
contactno: contactno || undefined,
tenantid: row && row[RESPONSE_FIELDS.tenantid] != null ? num(row[RESPONSE_FIELDS.tenantid]) : undefined,
applocationid:
row && row[RESPONSE_FIELDS.applocationid] != null ? num(row[RESPONSE_FIELDS.applocationid]) : 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,
* the deliveries board, riders, stock statements, and customers.
*
* Requests go through the Vite dev proxy at `/fiesta/*`, which forwards to
* `https://fiesta.nearle.app/*` (see vite.config.ts). Fiesta is CORS-enabled and
* needs no auth header for these read endpoints.
* Requests go directly to `https://fiesta.nearle.app/*` — Fiesta is CORS-enabled
* and needs no auth header for these read endpoints, so no dev proxy is required.
*
* This sits alongside `./api` (the Hasura/workolik REST surface the dashboard
* uses). Components should call the TanStack hooks in `./fiestaQueries`, not
* 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). */
export const FIESTA_TENANT_ID = 1087;
@@ -29,6 +29,19 @@ export const FIESTA_PRIMARY_LOCATION_ID = 1097;
export type Row = Record<string, unknown>;
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> {
const qs = new URLSearchParams();
Object.entries(params).forEach(([k, v]) => {
@@ -112,13 +125,14 @@ export interface FiestaOrderSummary {
tenantname?: string;
}
/** /orders/getordersummary?tenantid=&fromdate=&todate= — flat order counts. */
/** /orders/getordersummary?tenantid=&locationid=&fromdate=&todate= — flat order counts. */
export async function getOrderSummary(
tenantid: number,
fromdate: string,
todate: string,
locationid?: number,
): 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;
return {
total: num(row.total),
@@ -162,21 +176,27 @@ export async function getOrderInsight(tenantid: number): Promise<Row[]> {
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: {
tenantid: number;
status: string;
fromdate: string;
todate: string;
locationid?: number;
applocationid?: number;
keyword?: string;
pageno?: number;
pagesize?: number;
}): Promise<Row[]> {
return toRows(
await fiestaGet('orders/getorders', {
tenantid: opts.tenantid,
locationid: opts.locationid,
applocationid: opts.applocationid,
status: opts.status,
fromdate: opts.fromdate,
todate: opts.todate,
keyword: opts.keyword,
pageno: opts.pageno ?? 1,
pagesize: opts.pagesize ?? 20,
}),
@@ -185,7 +205,15 @@ export async function getOrders(opts: {
/** /orders/getorderdetails?orderheaderid= — line items for a single order. */
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. */
@@ -221,17 +249,19 @@ export interface FiestaDeliverySummary {
cancelled: number;
}
/** /deliveries/deliverysummary?tenantid=&applocationid=&fromdate=&todate= — dispatch counts. */
/** /deliveries/deliverysummary?tenantid=&applocationid=&locationid=&fromdate=&todate= — dispatch counts. */
export async function getDeliverySummary(opts: {
tenantid: number;
applocationid?: number;
locationid?: number;
fromdate: string;
todate: string;
}): Promise<FiestaDeliverySummary | null> {
const row = firstRow<Row>(
await fiestaGet('deliveries/deliverysummary', {
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,
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: {
tenantid: number;
fromdate: string;
todate: string;
status?: string;
locationid?: number;
applocationid?: number;
keyword?: string;
pageno?: number;
pagesize?: number;
}): Promise<Row[]> {
return toRows(
const rows = toRows(
await fiestaGet('deliveries/getdeliveries', {
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,
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. */
@@ -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/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: {
applocationid?: number;
tenantid: number;
partnerid?: number;
}): Promise<Row[]> {
return toRows(
await fiestaGet('partners/getriders', {
applocationid: opts.applocationid ?? FIESTA_APPLOCATION_ID,
tenantid: opts.tenantid,
partnerid: opts.partnerid,
}),
);
}
/** /partners/getridershifts?applocationid= — rider shift records. */
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/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[]> {
return toRows(await fiestaGet('tenants/gettenantlocations', { tenantid }));
return cleanTenantLocations(toRows(await fiestaGet('tenants/gettenantlocations', { tenantid })));
}
/** /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: {
tenantid: number;
locationid: number;
@@ -368,7 +618,7 @@ export async function getTenantCustomers(opts: {
pageno?: number;
pagesize?: number;
}): Promise<Row[]> {
return toRows(
return dedupeCustomers(toRows(
await fiestaGet('customers/gettenantcustomers', {
tenantid: opts.tenantid,
locationid: opts.locationid,
@@ -376,7 +626,7 @@ export async function getTenantCustomers(opts: {
pageno: opts.pageno ?? 1,
pagesize: opts.pagesize ?? 20,
}),
);
));
}
// ════════════════════════════════════════════════════════════════════════════
@@ -540,8 +790,13 @@ export interface CreateUserInput {
lastname?: string;
email: string;
contactno: string;
password: string;
/** Optional — merchant_web's create form doesn't collect one. */
password?: string;
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;
pin?: number;
address?: string;
@@ -549,6 +804,10 @@ export interface CreateUserInput {
city?: string;
state?: string;
postcode?: string;
latitude?: string;
longitude?: string;
/** Rider shift (only meaningful for rider-role users). */
shiftid?: number;
tenantid: number;
locationid?: number;
applocationid?: number;
@@ -561,17 +820,22 @@ export async function createUser(input: CreateUserInput): Promise<Row> {
authname: input.email,
firstname: input.firstname,
lastname: input.lastname ?? '',
password: input.password,
password: input.password ?? '',
email: input.email,
dialcode: input.dialcode ?? '+91',
contactno: input.contactno,
roleid: input.roleid,
configid: input.configid ?? 15,
moduleid: input.moduleid ?? 0,
pin: input.pin ?? 0,
address: input.address ?? '',
suburb: input.suburb ?? '',
city: input.city ?? '',
state: input.state ?? '',
postcode: input.postcode ?? '',
latitude: input.latitude ?? '',
longitude: input.longitude ?? '',
shiftid: input.shiftid ?? 0,
tenantid: input.tenantid,
locationid: input.locationid ?? 0,
applocationid: input.applocationid ?? FIESTA_APPLOCATION_ID,
@@ -597,3 +861,56 @@ export interface UpdateUserInput {
export async function updateUser(input: UpdateUserInput): Promise<Row> {
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)
};
}
/**
* 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,
createUser,
updateUser,
assignRiderToOrders,
CreateUserInput,
createTenantUser,
createTenantLocation,
CreateTenantInput,
CreateTenantLocationInput,
} from './fiestaApi';
export const fiestaKeys = {
orderSummary: (tenantid: number, fromdate: string, todate: string) =>
['fiesta', 'orderSummary', tenantid, fromdate, todate] as const,
orderSummary: (tenantid: number, fromdate: string, todate: string, locationid?: number) =>
['fiesta', 'orderSummary', tenantid, fromdate, todate, locationid ?? 0] as const,
locationSummary: (tenantid: number) => ['fiesta', 'locationSummary', tenantid] as const,
orderInsight: (tenantid: number) => ['fiesta', 'orderInsight', tenantid] 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,
riders: (params: Record<string, unknown>) => ['fiesta', 'riders', params] 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,
tenantCustomers: (params: Record<string, unknown>) => ['fiesta', 'tenantCustomers', params] as const,
stockStatement: (params: Record<string, unknown>) => ['fiesta', 'stockStatement', params] as const,
@@ -79,10 +86,10 @@ export const fiestaKeys = {
};
// ── 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({
queryKey: fiestaKeys.orderSummary(tenantid, fromdate, todate),
queryFn: () => getOrderSummary(tenantid, fromdate, todate),
queryKey: fiestaKeys.orderSummary(tenantid, fromdate, todate, locationid),
queryFn: () => getOrderSummary(tenantid, fromdate, todate, locationid),
enabled: Boolean(tenantid && fromdate && todate),
});
}
@@ -108,6 +115,9 @@ export function useFiestaOrders(opts: {
status: string;
fromdate: string;
todate: string;
locationid?: number;
applocationid?: number;
keyword?: string;
pageno?: 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 ────────────────────────────────────────────────────────────────
export function useFiestaDeliverySummary(opts: {
tenantid: number;
applocationid?: number;
locationid?: number;
fromdate: 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({
queryKey: fiestaKeys.deliveries(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 ─────────────────────────────────────────────────────────
export function useFiestaRiders(opts: { applocationid?: number; tenantid: number }) {
export function useFiestaRiders(opts: { applocationid?: number; tenantid: number; partnerid?: number }) {
return useQuery({
queryKey: fiestaKeys.riders(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 ──────────────────────────────────────────────────────────────────────
/**
* 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
*/
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 {
title: string;